From 3fd226bdbdc4ae40823da0f384e54f619d6415e0 Mon Sep 17 00:00:00 2001 From: Eliezer Castro Date: Sun, 2 Nov 2025 04:52:15 -0300 Subject: [PATCH] feat(tags): Test case tag editing UI and tag settings UI (#322) --- backend/routes/casetags/delete.js | 41 --- backend/routes/casetags/edit.js | 64 ++++ backend/routes/casetags/new.js | 58 ---- backend/routes/tags/edit.js | 15 +- backend/server.js | 6 +- frontend/messages/en.json | 31 +- frontend/messages/ja.json | 31 +- frontend/messages/pt-BR.json | 31 +- .../[folderId]/cases/TestCaseTable.tsx | 13 + .../[folderId]/cases/[caseId]/CaseEditor.tsx | 83 +++-- .../cases/[caseId]/CaseTagsEditor.tsx | 169 ++++++++++ .../[folderId]/cases/[caseId]/page.tsx | 9 + .../folders/[folderId]/cases/page.tsx | 1 + .../settings/ProjectTagsManager.tsx | 295 ++++++++++++++++++ .../[projectId]/settings/SettingsPage.tsx | 9 + .../projects/[projectId]/settings/page.tsx | 15 + frontend/types/case.ts | 14 + frontend/types/settings.ts | 15 + frontend/types/tag.ts | 7 + frontend/utils/caseTagsControls.ts | 28 ++ frontend/utils/tagsControls.ts | 104 ++++++ 21 files changed, 896 insertions(+), 143 deletions(-) delete mode 100644 backend/routes/casetags/delete.js create mode 100644 backend/routes/casetags/edit.js delete mode 100644 backend/routes/casetags/new.js create mode 100644 frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/[caseId]/CaseTagsEditor.tsx create mode 100644 frontend/src/app/[locale]/projects/[projectId]/settings/ProjectTagsManager.tsx create mode 100644 frontend/types/tag.ts create mode 100644 frontend/utils/caseTagsControls.ts create mode 100644 frontend/utils/tagsControls.ts diff --git a/backend/routes/casetags/delete.js b/backend/routes/casetags/delete.js deleted file mode 100644 index 18caac6..0000000 --- a/backend/routes/casetags/delete.js +++ /dev/null @@ -1,41 +0,0 @@ -import express from 'express'; -const router = express.Router(); -import { DataTypes } from 'sequelize'; -import authMiddleware from '../../middleware/auth.js'; -import editableMiddleware from '../../middleware/verifyEditable.js'; -import definecaseTags from '../../models/caseTags.js'; - -export default function (sequelize) { - const { verifySignedIn } = authMiddleware(sequelize); - const { verifyProjectDeveloperFromCaseId } = editableMiddleware(sequelize); - const CaseTag = definecaseTags(sequelize, DataTypes); - - router.delete('/:id', verifySignedIn, verifyProjectDeveloperFromCaseId, async (req, res) => { - const { id } = req.params; - - if (!id) { - return res.status(400).json({ - error: 'id is required', - }); - } - - try { - const deletedCaseTag = await CaseTag.destroy({ - where: { - id: id, - }, - }); - - if (!deletedCaseTag) { - return res.status(404).json({ error: 'Case-tag association not found' }); - } - - res.status(204).send(); - } catch (error) { - console.error('Error deleting case-tag association:', error); - res.status(500).json({ error: 'Internal server error' }); - } - }); - - return router; -} diff --git a/backend/routes/casetags/edit.js b/backend/routes/casetags/edit.js new file mode 100644 index 0000000..d29e3ef --- /dev/null +++ b/backend/routes/casetags/edit.js @@ -0,0 +1,64 @@ +import express from 'express'; +const router = express.Router(); +import { DataTypes } from 'sequelize'; +import authMiddleware from '../../middleware/auth.js'; +import editableMiddleware from '../../middleware/verifyEditable.js'; +import defineCaseTag from '../../models/caseTags.js'; +import defineCase from '../../models/cases.js'; +import defineTag from '../../models/tags.js'; + +export default function (sequelize) { + const { verifySignedIn } = authMiddleware(sequelize); + const { verifyProjectDeveloperFromCaseId } = editableMiddleware(sequelize); + const CaseTag = defineCaseTag(sequelize, DataTypes); + const Case = defineCase(sequelize, DataTypes); + const Tag = defineTag(sequelize, DataTypes); + + router.post('/update', verifySignedIn, verifyProjectDeveloperFromCaseId, async (req, res) => { + const { tagIds } = req.body; + const caseId = req.query.caseId; + + if (!caseId || !Array.isArray(tagIds)) { + return res.status(400).json({ error: 'caseId and tagIds[] are required' }); + } + + if (tagIds.length > 5) { + return res.status(400).json({ error: 'Maximum of 5 tags allowed' }); + } + + try { + const testCase = await Case.findByPk(caseId); + if (!testCase) { + return res.status(404).json({ error: 'Case not found' }); + } + + const currentAssociations = await CaseTag.findAll({ + where: { caseId }, + }); + + const currentTagIds = currentAssociations.map((ct) => ct.tagId); + + const tagsToAdd = tagIds.filter((id) => !currentTagIds.includes(id)); + const tagsToRemove = currentTagIds.filter((id) => !tagIds.includes(id)); + + if (tagsToAdd.length > 0) { + const validTags = await Tag.findAll({ where: { id: tagsToAdd } }); + const newLinks = validTags.map((tag) => ({ caseId, tagId: tag.id })); + await CaseTag.bulkCreate(newLinks); + } + + if (tagsToRemove.length > 0) { + await CaseTag.destroy({ + where: { caseId, tagId: tagsToRemove }, + }); + } + + res.status(200).json({ message: 'Tags updated successfully' }); + } catch (error) { + console.error('Error updating case tags:', error); + res.status(500).json({ error: 'Internal server error' }); + } + }); + + return router; +} diff --git a/backend/routes/casetags/new.js b/backend/routes/casetags/new.js deleted file mode 100644 index 19b0898..0000000 --- a/backend/routes/casetags/new.js +++ /dev/null @@ -1,58 +0,0 @@ -import express from 'express'; -const router = express.Router(); -import { DataTypes } from 'sequelize'; -import authMiddleware from '../../middleware/auth.js'; -import editableMiddleware from '../../middleware/verifyEditable.js'; -import definecaseTags from '../../models/caseTags.js'; -import defineCase from '../../models/cases.js'; -import defineTag from '../../models/tags.js'; - -export default function (sequelize) { - const { verifySignedIn } = authMiddleware(sequelize); - const { verifyProjectDeveloperFromCaseId } = editableMiddleware(sequelize); - const CaseTag = definecaseTags(sequelize, DataTypes); - const Case = defineCase(sequelize, DataTypes); - const Tags = defineTag(sequelize, DataTypes); - - router.post('/', verifySignedIn, verifyProjectDeveloperFromCaseId, async (req, res) => { - const { caseId, tagId } = req.body; - - if (!caseId || !tagId) { - return res.status(400).json({ - error: 'caseId and tagId are required', - }); - } - - try { - const caseExists = await Case.findByPk(caseId); - if (!caseExists) { - return res.status(404).json({ error: 'Case not found' }); - } - - const tagExists = await Tags.findByPk(tagId); - if (!tagExists) { - return res.status(404).json({ error: 'Tag not found' }); - } - - const existingAssociation = await CaseTag.findOne({ - where: { caseId, tagId }, - }); - - if (existingAssociation) { - return res.status(409).json({ error: 'Tag is already associated with this case' }); - } - - const newCaseTag = await CaseTag.create({ - caseId, - tagId, - }); - - res.status(201).json(newCaseTag); - } catch (error) { - console.error('Error creating case-tag association:', error); - res.status(500).json({ error: 'Internal server error' }); - } - }); - - return router; -} diff --git a/backend/routes/tags/edit.js b/backend/routes/tags/edit.js index cccaeee..b0a51a3 100644 --- a/backend/routes/tags/edit.js +++ b/backend/routes/tags/edit.js @@ -3,7 +3,6 @@ const router = express.Router(); import { DataTypes, Op } from 'sequelize'; import authMiddleware from '../../middleware/auth.js'; import editableMiddleware from '../../middleware/verifyEditable.js'; - import defineTag from '../../models/tags.js'; export default function (sequelize) { @@ -48,9 +47,19 @@ export default function (sequelize) { return res.status(409).json({ error: 'Tag name must be unique' }); } - const [updated, [updatedTag]] = await Tags.update({ name: trimmedName }, { where: { id: tagId, projectId } }); + const [updated] = await Tags.update({ name: trimmedName }, { where: { id: tagId, projectId } }); - if (updated === 0) return res.status(404).json({ error: 'Tag not found' }); + if (updated === 0) { + return res.status(404).json({ error: 'Tag not found' }); + } + + const updatedTag = await Tags.findOne({ + where: { id: tagId, projectId }, + }); + + if (!updatedTag) { + return res.status(404).json({ error: 'Tag not found after update' }); + } res.status(200).json(updatedTag); } catch (error) { diff --git a/backend/server.js b/backend/server.js index a074eb3..ea60146 100644 --- a/backend/server.js +++ b/backend/server.js @@ -168,10 +168,8 @@ app.use('/tags', tagsDeleteRoute(sequelize)); app.use('/tags', tagsEditRoute(sequelize)); // "/casetags" -import caseTagsNewRoute from './routes/casetags/new.js'; -import caseTagsDeleteRoute from './routes/casetags/delete.js'; -app.use('/casetags', caseTagsNewRoute(sequelize)); -app.use('/casetags', caseTagsDeleteRoute(sequelize)); +import caseTagsEditRoute from './routes/casetags/edit.js'; +app.use('/casetags', caseTagsEditRoute(sequelize)); // "/home" import homeIndexRoute from './routes/home/index.js'; diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 1036c3b..b173f92 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -237,7 +237,8 @@ "move": "Move", "clone": "Clone", "cases_moved": "Test cases moved", - "cases_cloned": "Test cases cloned" + "cases_cloned": "Test cases cloned", + "tags": "Tags" }, "Case": { "back_to_cases": "Back to test cases", @@ -269,7 +270,16 @@ "click_to_upload": "Click to upload", "or_drag_and_drop": " or drag and drop", "max_file_size": "Max. file size", - "are_you_sure_leave": "Are you sure you want to leave the page?" + "are_you_sure_leave": "Are you sure you want to leave the page?", + "tags": "Tags", + "create_tag": "Create tag", + "max_tags_limit": "Max. tags limit", + "tag_already_exists": "Tag already exists", + "tag_created_and_added": "Tag created and added", + "error_creating_tag": "Error creating tag", + "error_updating_test_case": "Error updating test case", + "search_or_create_tag": "Search or create tag", + "no_tags_selected": "No tags selected" }, "Runs": { "run_list": "Test Run List", @@ -351,6 +361,21 @@ "delete_project": "Delete Project", "delete": "Delete", "close": "Close", - "are_you_sure": "Are you sure you want to delete the project?" + "are_you_sure": "Are you sure you want to delete the project?", + "tag_management": "Tag Management", + "tag_name": "Tag Name", + "add_tag": "Add Tag", + "no_tags_available": "No tags available", + "delete_tag": "Delete Tag", + "are_you_sure_delete_tag": "Are you sure you want to delete this tag?", + "tag_created": "Tag created successfully.", + "tag_updated": "Tag updated successfully.", + "tag_deleted": "Tag deleted successfully.", + "tag_error_empty": "Tag name cannot be empty.", + "tag_error_min_length": "Tag name must be at least 3 characters long.", + "tag_error_max_length": "Tag name cannot exceed 20 characters.", + "tag_error_create": "Failed to create tag. Please try again.", + "tag_error_update": "Failed to update tag. Please try again.", + "tag_error_delete": "Failed to delete tag. Please try again." } } diff --git a/frontend/messages/ja.json b/frontend/messages/ja.json index 5bf5bb2..b288ed8 100644 --- a/frontend/messages/ja.json +++ b/frontend/messages/ja.json @@ -238,7 +238,8 @@ "move": "移動", "clone": "クローン", "cases_moved": "テストケースを移動しました", - "cases_cloned": "テストケースをクローンしました" + "cases_cloned": "テストケースをクローンしました", + "tags": "タグ" }, "Case": { "back_to_cases": "テストケース一覧に戻る", @@ -270,7 +271,16 @@ "click_to_upload": "クリックしてアップロード", "or_drag_and_drop": "またはドラッグアンドドロップ", "max_file_size": "最大ファイルサイズ", - "are_you_sure_leave": "ページを離れてもよろしいですか?" + "are_you_sure_leave": "ページを離れてもよろしいですか?", + "tags": "タグ", + "create_tag": "タグを作成", + "max_tags_limit": "最大タグ数", + "tag_already_exists": "タグは既に存在します", + "tag_created_and_added": "タグが作成されて追加されました", + "error_creating_tag": "タグの作成中にエラーが発生しました", + "error_updating_test_case": "テストケースの更新中にエラーが発生しました", + "search_or_create_tag": "タグを検索または作成", + "no_tags_selected": "タグが選択されていません" }, "Runs": { "run_list": "テストラン一覧", @@ -352,6 +362,21 @@ "delete_project": "プロジェクトの削除", "delete": "削除", "close": "閉じる", - "are_you_sure": "プロジェクトを削除してもよろしいですか?" + "are_you_sure": "プロジェクトを削除してもよろしいですか?", + "tag_management": "タグ管理", + "tag_name": "タグ名", + "add_tag": "タグを追加", + "no_tags_available": "タグがありません", + "delete_tag": "タグを削除", + "are_you_sure_delete_tag": "このタグを削除してもよろしいですか?", + "tag_created": "タグが正常に作成されました。", + "tag_updated": "タグが正常に更新されました。", + "tag_deleted": "タグが正常に削除されました。", + "tag_error_empty": "タグ名を入力してください。", + "tag_error_min_length": "タグ名は3文字以上である必要があります。", + "tag_error_max_length": "タグ名は20文字を超えることはできません。", + "tag_error_create": "タグの作成に失敗しました。もう一度お試しください。", + "tag_error_update": "タグの更新に失敗しました。もう一度お試しください。", + "tag_error_delete": "タグの削除に失敗しました。もう一度お試しください。" } } diff --git a/frontend/messages/pt-BR.json b/frontend/messages/pt-BR.json index 3621182..f1429f3 100644 --- a/frontend/messages/pt-BR.json +++ b/frontend/messages/pt-BR.json @@ -237,7 +237,8 @@ "move": "Mover", "clone": "Clonar", "cases_moved": "Casos de teste movidos", - "cases_cloned": "Casos de teste clonados" + "cases_cloned": "Casos de teste clonados", + "tags": "Tags" }, "Case": { "back_to_cases": "Voltar para os casos de teste", @@ -269,7 +270,16 @@ "click_to_upload": "Clique para enviar", "or_drag_and_drop": " ou arraste e solte", "max_file_size": "Tamanho máx. do arquivo", - "are_you_sure_leave": "Tem certeza de que deseja sair da página?" + "are_you_sure_leave": "Tem certeza de que deseja sair da página?", + "tags": "Tags", + "create_tag": "Criar tag", + "max_tags_limit": "Limite máximo de tags", + "tag_already_exists": "Tag já existe", + "tag_created_and_added": "Tag criada e adicionada", + "error_creating_tag": "Erro ao criar tag", + "error_updating_test_case": "Erro ao atualizar caso de teste", + "search_or_create_tag": "Buscar ou criar tag", + "no_tags_selected": "Nenhuma tag selecionada" }, "Runs": { "run_list": "Lista de Execuções de Teste", @@ -351,6 +361,21 @@ "delete_project": "Excluir Projeto", "delete": "Excluir", "close": "Fechar", - "are_you_sure": "Tem certeza de que deseja excluir o projeto?" + "are_you_sure": "Tem certeza de que deseja excluir o projeto?", + "tag_management": "Gerenciamento de Tags", + "tag_name": "Nome da Tag", + "add_tag": "Adicionar Tag", + "no_tags_available": "Nenhuma tag disponível", + "delete_tag": "Excluir Tag", + "are_you_sure_delete_tag": "Tem certeza de que deseja excluir esta tag?", + "tag_created": "Tag criada com sucesso.", + "tag_updated": "Tag atualizada com sucesso.", + "tag_deleted": "Tag excluída com sucesso.", + "tag_error_empty": "O nome da tag não pode estar vazio.", + "tag_error_min_length": "O nome da tag deve ter pelo menos 3 caracteres.", + "tag_error_max_length": "O nome da tag não pode exceder 20 caracteres.", + "tag_error_create": "Falha ao criar tag. Por favor, tente novamente.", + "tag_error_update": "Falha ao atualizar tag. Por favor, tente novamente.", + "tag_error_delete": "Falha ao excluir tag. Por favor, tente novamente." } } diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/TestCaseTable.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/TestCaseTable.tsx index 9c778a6..d5e3225 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/TestCaseTable.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/TestCaseTable.tsx @@ -14,6 +14,7 @@ import { Checkbox, Card, CardBody, + Chip, } from '@heroui/react'; import { Plus, @@ -81,6 +82,7 @@ export default function TestCaseTable({ { name: messages.id, uid: 'id', sortable: true }, { name: messages.title, uid: 'title', sortable: true }, { name: messages.priority, uid: 'priority', sortable: true }, + { name: messages.tags, uid: 'tags' }, { name: messages.actions, uid: 'actions' }, ]; @@ -107,6 +109,17 @@ export default function TestCaseTable({ ); case 'priority': return ; + + case 'tags': + return ( +
+ {testCase.Tags?.map((tag) => ( + + {tag.name} + + ))} +
+ ); case 'actions': return ( diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/[caseId]/CaseEditor.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/[caseId]/CaseEditor.tsx index 8a1a2dd..fe3a0aa 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/[caseId]/CaseEditor.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/[caseId]/CaseEditor.tsx @@ -1,11 +1,12 @@ 'use client'; -import { useState, useEffect, useContext, useCallback, ChangeEvent, DragEvent } from 'react'; +import { useState, useEffect, useContext, ChangeEvent, DragEvent } from 'react'; import { Input, Textarea, Select, SelectItem, Button, Divider, Tooltip, addToast, Badge } from '@heroui/react'; import { Save, Plus, ArrowLeft, Circle } from 'lucide-react'; import CaseStepsEditor from './CaseStepsEditor'; import CaseAttachmentsEditor from './CaseAttachmentsEditor'; import { updateSteps } from './stepControl'; import { fetchCreateAttachments, fetchDownloadAttachment, fetchDeleteAttachment } from './attachmentControl'; +import CaseTagsEditor from './CaseTagsEditor'; import { fetchCase, updateCase } from '@/utils/caseControl'; import { priorities, testTypes, templates } from '@/config/selection'; import { useRouter } from '@/src/i18n/routing'; @@ -15,6 +16,7 @@ import { CaseType, AttachmentType, CaseMessages, StepType } from '@/types/case'; import { PriorityMessages } from '@/types/priority'; import { TestTypeMessages } from '@/types/testType'; import { logError } from '@/utils/errorHandler'; +import { updateCaseTags } from '@/utils/caseTagsControls'; const defaultTestCase = { id: 0, @@ -32,6 +34,7 @@ const defaultTestCase = { Attachments: [], isIncluded: false, runStatus: 0, + Tags: [], }; type Props = { @@ -59,6 +62,8 @@ export default function CaseEditor({ const [isUpdating, setIsUpdating] = useState(false); const [plusCount, setPlusCount] = useState(0); const [isDirty, setIsDirty] = useState(false); + const [selectedTags, setSelectedTags] = useState<{ id: number; name: string }[]>([]); + const router = useRouter(); useFormGuard(isDirty, messages.areYouSureLeave); @@ -210,24 +215,24 @@ export default function CaseEditor({ } }; - const fetchAndSetCase = useCallback(async () => { - if (!tokenContext.isSignedIn()) { - return; - } - try { - const data = await fetchCase(tokenContext.token.access_token, Number(caseId)); - data.Steps.forEach((step: StepType) => { - step.editState = 'notChanged'; - }); - setTestCase(data); - } catch (error: unknown) { - logError('Error fetching case data', error); - } - }, [tokenContext, caseId]); - useEffect(() => { + const fetchAndSetCase = async () => { + if (!tokenContext.isSignedIn()) return; + try { + const data = await fetchCase(tokenContext.token.access_token, Number(caseId)); + data.Steps.forEach((step: StepType) => { + step.editState = 'notChanged'; + }); + setTestCase(data); + if (data.Tags) { + setSelectedTags(Array.isArray(data.Tags) ? data.Tags : []); + } + } catch (error: unknown) { + logError('Error fetching case data', error); + } + }; fetchAndSetCase(); - }, [caseId, tokenContext, fetchAndSetCase]); + }, [tokenContext, caseId]); return ( <> @@ -258,18 +263,30 @@ export default function CaseEditor({ isLoading={isUpdating} onPress={async () => { setIsUpdating(true); - await updateCase(tokenContext.token.access_token, testCase); - if (testCase.Steps) { - await updateSteps(tokenContext.token.access_token, Number(caseId), testCase.Steps); - } - await fetchAndSetCase(); + try { + await updateCase(tokenContext.token.access_token, testCase); + if (testCase.Steps) { + await updateSteps(tokenContext.token.access_token, Number(caseId), testCase.Steps); + } - addToast({ - title: 'Info', - description: messages.updatedTestCase, - }); - setIsUpdating(false); - setIsDirty(false); + const tagIds = selectedTags.map((tag) => tag.id); + await updateCaseTags(tokenContext.token.access_token, Number(caseId), tagIds, projectId); + + addToast({ + title: 'Info', + description: messages.updatedTestCase, + }); + setIsDirty(false); + } catch (error) { + logError('Error updating test case', error); + addToast({ + title: 'Error', + description: messages.errorUpdatingTestCase, + color: 'danger', + }); + } finally { + setIsUpdating(false); + } }} > {isUpdating ? messages.updating : messages.update} @@ -305,6 +322,16 @@ export default function CaseEditor({ className="mt-3" /> + { + setSelectedTags(tags); + setIsDirty(true); + }} + messages={messages} + /> +
{ + setTagName(e.target.value); + const { isValid, errorMessage } = validateName(e.target.value, messages); + setIsValidTag(isValid); + setErrorMessage(errorMessage); + }} + /> + +
+ +
+
+
+ {tags.length === 0 &&
{messages.noTagsAvailable}
} + + {tags.map((tag) => ( +
+ {editingTag === tag.id ? ( + <> +
+ { + setEditedTagName(e.target.value); + const { isValid, errorMessage } = validateName(e.target.value, messages); + setIsValidEditTag(isValid); + setEditErrorMessage(errorMessage); + }} + isInvalid={!isValidEditTag} + errorMessage={editErrorMessage} + classNames={{ + inputWrapper: 'h-7 flex', + }} + /> +
+ + +
+
+ + ) : ( + <> +
+ {tag.name} +
+
+ + setOpenPopoverTagId(open ? tag.id : null)} + > + + + + +
+
{messages.deleteTag}
+
{messages.areYouSureDeleteTag}
+
+ + +
+
+
+
+
+ + )} +
+ ))} +
+ + + ); +} diff --git a/frontend/src/app/[locale]/projects/[projectId]/settings/SettingsPage.tsx b/frontend/src/app/[locale]/projects/[projectId]/settings/SettingsPage.tsx index 8881564..0a24be2 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/settings/SettingsPage.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/settings/SettingsPage.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useContext } from 'react'; import { Button, Table, TableHeader, TableColumn, TableBody, TableRow, TableCell } from '@heroui/react'; import { Pencil, Trash } from 'lucide-react'; +import ProjectTagsManager from './ProjectTagsManager'; import { SettingsMessages } from '@/types/settings'; import { TokenContext } from '@/utils/TokenProvider'; import { deleteProject, fetchProject, updateProject } from '@/utils/projectsControl'; @@ -143,6 +144,14 @@ export default function SettingsPage({ projectId, messages, projectDialogMessage +
+

{messages.tagManagement}

+
+ +
+ +
+