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