From 3fd226bdbdc4ae40823da0f384e54f619d6415e0 Mon Sep 17 00:00:00 2001 From: Eliezer Castro Date: Sun, 2 Nov 2025 04:52:15 -0300 Subject: [PATCH 1/9] 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}

+
+ +
+ +
+ Date: Sun, 2 Nov 2025 10:57:32 -0300 Subject: [PATCH 2/9] fix: test runs can be managed separately (#321) --- backend/routes/cases/indexByProjectId.js | 75 +++++++++++-------- backend/routes/runs/new.js | 13 ++++ .../[projectId]/runs/[runId]/RunEditor.tsx | 2 +- .../projects/[projectId]/runs/runsControl.ts | 4 +- 4 files changed, 61 insertions(+), 33 deletions(-) diff --git a/backend/routes/cases/indexByProjectId.js b/backend/routes/cases/indexByProjectId.js index 4bc883c..c630441 100644 --- a/backend/routes/cases/indexByProjectId.js +++ b/backend/routes/cases/indexByProjectId.js @@ -25,41 +25,56 @@ export default function (sequelize) { RunCase.belongsTo(Case, { foreignKey: 'caseId' }); const { verifySignedIn } = authMiddleware(sequelize); const { verifyProjectVisibleFromProjectId } = visibilityMiddleware(sequelize); + const { verifyProjectVisibleFromRunId } = visibilityMiddleware(sequelize); - router.get('/byproject', verifySignedIn, verifyProjectVisibleFromProjectId, async (req, res) => { - const { projectId } = req.query; + router.get( + '/byproject', + verifySignedIn, + verifyProjectVisibleFromProjectId, + verifyProjectVisibleFromRunId, + async (req, res) => { + const { projectId, runId } = req.query; - if (!projectId) { - return res.status(400).json({ error: 'projectId is required' }); - } + if (!projectId) { + return res.status(400).json({ error: 'projectId is required' }); + } - try { - const cases = await Case.findAll({ - include: [ - { - model: Folder, - where: { - projectId: projectId, + if (!runId) { + return res.status(400).json({ error: 'runId is required' }); + } + + try { + const cases = await Case.findAll({ + include: [ + { + model: Folder, + where: { + projectId: projectId, + }, + attributes: [], }, - attributes: [], - }, - { - model: RunCase, - attributes: ['id', 'runId', 'status'], - }, - { - model: Tags, - attributes: ['id', 'name'], - through: { attributes: [] }, - }, - ], - }); - res.json(cases); - } catch (error) { - console.error(error); - res.status(500).send('Internal Server Error'); + { + model: RunCase, + attributes: ['id', 'runId', 'status'], + required: false, + where: { + runId: runId, + }, + }, + { + model: Tags, + attributes: ['id', 'name'], + through: { attributes: [] }, + }, + ], + }); + res.json(cases); + } catch (error) { + console.error(error); + res.status(500).send('Internal Server Error'); + } } - }); + ); return router; } diff --git a/backend/routes/runs/new.js b/backend/routes/runs/new.js index 2ed61ed..6127245 100644 --- a/backend/routes/runs/new.js +++ b/backend/routes/runs/new.js @@ -1,7 +1,11 @@ import express from 'express'; const router = express.Router(); import { DataTypes } from 'sequelize'; +import defineProject from '../../models/projects.js'; +import defineFolder from '../../models/folders.js'; import defineRun from '../../models/runs.js'; +import defineRunCase from '../../models/runCases.js'; +import defineCase from '../../models/cases.js'; import authMiddleware from '../../middleware/auth.js'; import editableMiddleware from '../../middleware/verifyEditable.js'; @@ -9,6 +13,15 @@ export default function (sequelize) { const { verifySignedIn } = authMiddleware(sequelize); const { verifyProjectReporterFromProjectId } = editableMiddleware(sequelize); const Run = defineRun(sequelize, DataTypes); + const RunCase = defineRunCase(sequelize, DataTypes); + const Case = defineCase(sequelize, DataTypes); + const Project = defineProject(sequelize, DataTypes); + const Folder = defineFolder(sequelize, DataTypes); + Project.hasMany(Folder, { foreignKey: 'projectId' }); + Folder.hasMany(Case, { foreignKey: 'folderId' }); + Folder.belongsTo(Project, { foreignKey: 'projectId' }); + Case.belongsTo(Folder, { foreignKey: 'folderId' }); + Case.hasMany(RunCase, { foreignKey: 'caseId' }); router.post('/', verifySignedIn, verifyProjectReporterFromProjectId, async (req, res) => { try { diff --git a/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/RunEditor.tsx b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/RunEditor.tsx index cc311e0..113af19 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/RunEditor.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/RunEditor.tsx @@ -112,7 +112,7 @@ export default function RunEditor({ }; const initTestCases = async () => { - const casesData = await fetchProjectCases(tokenContext.token.access_token, Number(projectId)); + const casesData = await fetchProjectCases(tokenContext.token.access_token, Number(projectId), Number(runId)); casesData.forEach((testCase: CaseType) => { if (testCase.RunCases && testCase.RunCases.length > 0) { testCase.RunCases[0].editState = 'notChanged'; diff --git a/frontend/src/app/[locale]/projects/[projectId]/runs/runsControl.ts b/frontend/src/app/[locale]/projects/[projectId]/runs/runsControl.ts index ca78ba0..dd1c2ee 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/runs/runsControl.ts +++ b/frontend/src/app/[locale]/projects/[projectId]/runs/runsControl.ts @@ -315,8 +315,8 @@ async function updateRunCases(jwt: string, runId: number, testCases: CaseType[]) } } -async function fetchProjectCases(jwt: string, projectId: number) { - const url = `${apiServer}/cases/byproject?projectId=${projectId}`; +async function fetchProjectCases(jwt: string, projectId: number, runId: number) { + const url = `${apiServer}/cases/byproject?projectId=${projectId}&runId=${runId}`; try { const response = await fetch(url, { From 12b5a1babefdd4300fc1bffc7e4ec458f2cf9225 Mon Sep 17 00:00:00 2001 From: Eliezer Castro Date: Sat, 8 Nov 2025 10:30:50 -0300 Subject: [PATCH 3/9] feat: tag filtering functionality for test cases (#324) --- backend/routes/cases/index.js | 28 ++++-- frontend/messages/en.json | 3 +- frontend/messages/ja.json | 3 +- frontend/messages/pt-BR.json | 3 +- .../folders/[folderId]/cases/CasesPane.tsx | 20 ++++- .../[folderId]/cases/TestCaseFilter.tsx | 90 +++++++++++++++++-- .../[folderId]/cases/TestCaseTable.tsx | 13 ++- .../folders/[folderId]/cases/page.tsx | 1 + frontend/types/case.ts | 1 + frontend/utils/caseControl.ts | 13 ++- 10 files changed, 150 insertions(+), 25 deletions(-) diff --git a/backend/routes/cases/index.js b/backend/routes/cases/index.js index b64e51a..6139440 100644 --- a/backend/routes/cases/index.js +++ b/backend/routes/cases/index.js @@ -17,7 +17,7 @@ export default function (sequelize) { Tags.belongsToMany(Case, { through: 'caseTags', foreignKey: 'tagId', otherKey: 'caseId' }); router.get('/', verifySignedIn, verifyProjectVisibleFromFolderId, async (req, res) => { - const { folderId, title, priority, type } = req.query; + const { folderId, title, priority, type, tag } = req.query; if (!folderId) { return res.status(400).json({ error: 'folderId is required' }); @@ -60,15 +60,27 @@ export default function (sequelize) { } } + const tagInclude = { + model: Tags, + attributes: ['id', 'name'], + through: { attributes: [] }, + }; + + if (tag) { + const tagIds = tag + .split(',') + .map((t) => parseInt(t.trim(), 10)) + .filter((t) => !isNaN(t)); + + if (tagIds.length > 0) { + tagInclude.where = { id: { [Op.in]: tagIds } }; + tagInclude.required = true; + } + } + const cases = await Case.findAll({ where: whereClause, - include: [ - { - model: Tags, - attributes: ['id', 'name'], - through: { attributes: [] }, - }, - ], + include: [tagInclude], }); res.json(cases); } catch (error) { diff --git a/frontend/messages/en.json b/frontend/messages/en.json index b173f92..787a77c 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -238,7 +238,8 @@ "clone": "Clone", "cases_moved": "Test cases moved", "cases_cloned": "Test cases cloned", - "tags": "Tags" + "tags": "Tags", + "select_tags": "Select tags" }, "Case": { "back_to_cases": "Back to test cases", diff --git a/frontend/messages/ja.json b/frontend/messages/ja.json index b288ed8..49e5009 100644 --- a/frontend/messages/ja.json +++ b/frontend/messages/ja.json @@ -239,7 +239,8 @@ "clone": "クローン", "cases_moved": "テストケースを移動しました", "cases_cloned": "テストケースをクローンしました", - "tags": "タグ" + "tags": "タグ", + "select_tags": "タグを選択" }, "Case": { "back_to_cases": "テストケース一覧に戻る", diff --git a/frontend/messages/pt-BR.json b/frontend/messages/pt-BR.json index f1429f3..1a0be80 100644 --- a/frontend/messages/pt-BR.json +++ b/frontend/messages/pt-BR.json @@ -238,7 +238,8 @@ "clone": "Clonar", "cases_moved": "Casos de teste movidos", "cases_cloned": "Casos de teste clonados", - "tags": "Tags" + "tags": "Tags", + "select_tags": "Selecionar tags" }, "Case": { "back_to_cases": "Voltar para os casos de teste", diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CasesPane.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CasesPane.tsx index b851eb0..3a28d36 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CasesPane.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CasesPane.tsx @@ -37,6 +37,7 @@ export default function CasesPane({ const [titleFilter, setTitleFilter] = useState(''); const [priorityFilter, setPriorityFilter] = useState([]); const [typeFilter, setTypeFilter] = useState([]); + const [tagFilter, setTagFilter] = useState([]); const [isDeleteConfirmDialogOpen, setIsDeleteConfirmDialogOpen] = useState(false); const [deleteCaseIds, setDeleteCaseIds] = useState([]); @@ -44,7 +45,7 @@ export default function CasesPane({ const router = useRouter(); const searchParams = useSearchParams(); - const updateUrlParams = (updates: { title?: string; priority?: number[]; type?: number[] }) => { + const updateUrlParams = (updates: { title?: string; priority?: number[]; type?: number[]; tag?: number[] }) => { const currentParams = new URLSearchParams(searchParams.toString()); if (updates.title) { @@ -65,6 +66,12 @@ export default function CasesPane({ currentParams.delete('type'); } + if (updates.tag && updates.tag.length > 0) { + currentParams.set('tag', updates.tag.join(',')); + } else { + currentParams.delete('tag'); + } + const newUrl = `${window.location.pathname}?${currentParams.toString()}`; router.push(newUrl, { scroll: false }); }; @@ -76,10 +83,12 @@ export default function CasesPane({ const titleParam = searchParams.get('title') || ''; const priorityParam = parseQueryParam(searchParams.get('priority')); const typeParam = parseQueryParam(searchParams.get('type')); + const tagParam = parseQueryParam(searchParams.get('tag')); setTitleFilter(titleParam); setPriorityFilter(priorityParam); setTypeFilter(typeParam); + setTagFilter(tagParam); try { const data = await fetchCases( @@ -87,7 +96,8 @@ export default function CasesPane({ Number(folderId), titleParam || undefined, priorityParam.length > 0 ? priorityParam : undefined, - typeParam.length > 0 ? typeParam : undefined + typeParam.length > 0 ? typeParam : undefined, + tagParam.length > 0 ? tagParam : undefined ); setCases(data); } catch (error: unknown) { @@ -133,11 +143,12 @@ export default function CasesPane({ await exportCases(context.token.access_token, Number(folderId), type); }; - const handleFilterChange = (title: string, priorities: number[], types: number[]) => { + const handleFilterChange = (title: string, priorities: number[], types: number[], tag: number[]) => { setTitleFilter(title); setPriorityFilter(priorities); setTypeFilter(types); - updateUrlParams({ title: title, priority: priorities, type: types }); + setTagFilter(tag); + updateUrlParams({ title: title, priority: priorities, type: types, tag: tag }); }; // ************************************************************************** @@ -178,6 +189,7 @@ export default function CasesPane({ activeTitleFilter={titleFilter} activePriorityFilters={priorityFilter} activeTypeFilters={typeFilter} + activeTagFilters={tagFilter} messages={messages} priorityMessages={priorityMessages} testTypeMessages={testTypeMessages} diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/TestCaseFilter.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/TestCaseFilter.tsx index de6a17a..d7c04e3 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/TestCaseFilter.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/TestCaseFilter.tsx @@ -1,10 +1,23 @@ -import { useState, useEffect } from 'react'; -import { Button, Dropdown, DropdownTrigger, DropdownMenu, DropdownItem, Selection, Input } from '@heroui/react'; +import { useState, useEffect, useContext } from 'react'; +import { + Button, + Dropdown, + DropdownTrigger, + DropdownMenu, + DropdownItem, + Selection, + Input, + addToast, +} from '@heroui/react'; import { SearchIcon, ChevronDown, Circle } from 'lucide-react'; import { PriorityMessages } from '@/types/priority'; import { TestTypeMessages } from '@/types/testType'; import { CasesMessages } from '@/types/case'; import { priorities, testTypes } from '@/config/selection'; +import { TagType } from '@/types/tag'; +import { fetchTags } from '@/utils/tagsControls'; +import { TokenContext } from '@/utils/TokenProvider'; +import { logError } from '@/utils/errorHandler'; type TestCaseFilterProps = { messages: CasesMessages; @@ -13,9 +26,13 @@ type TestCaseFilterProps = { activeTitleFilter: string; activePriorityFilters: number[]; activeTypeFilters: number[]; - onFilterChange: (title: string, priorities: number[], types: number[]) => void; + activeTagFilters: number[]; + projectId: string; + onFilterChange: (title: string, priorities: number[], types: number[], tags: number[]) => void; }; +type Tag = Pick; + export default function TestCaseFilter({ messages, priorityMessages, @@ -23,11 +40,38 @@ export default function TestCaseFilter({ activeTitleFilter, activePriorityFilters, activeTypeFilters, + activeTagFilters, onFilterChange, + projectId, }: TestCaseFilterProps) { + const tokenContext = useContext(TokenContext); const [title, setTitle] = useState(''); const [selectedPriorities, setSelectedPriorities] = useState(new Set([])); const [selectedTypes, setSelectedTypes] = useState(new Set([])); + const [selectedTags, setSelectedTags] = useState(new Set([])); + const [tags, setTags] = useState([]); + + useEffect(() => { + const fetchDataEffect = async () => { + try { + const tagsResponse = (await fetchTags(tokenContext.token.access_token, projectId)) || []; + setTags(tagsResponse); + } catch (error) { + logError('Error fetching case tags', error); + addToast({ title: 'Error', description: 'Error fetching tags', color: 'danger' }); + } + }; + fetchDataEffect(); + }, [projectId, tokenContext.token.access_token]); + + useEffect(() => { + if (activeTagFilters.length > 0) { + const activeKeys = activeTagFilters.map((id) => id.toString()); + setSelectedTags(new Set(activeKeys)); + } else { + setSelectedTags(new Set([])); + } + }, [activeTagFilters]); useEffect(() => { setTitle(activeTitleFilter); @@ -74,13 +118,20 @@ export default function TestCaseFilter({ .filter((index) => index !== -1); } - onFilterChange(title, priorityIndices, typeIndices); + let tagIds: number[] = []; + if (selectedTags !== 'all' && selectedTags.size > 0) { + tagIds = Array.from(selectedTags) + .map((key) => parseInt(key as string)) + .filter((id) => !isNaN(id)); + } + + onFilterChange(title, priorityIndices, typeIndices, tagIds); }; const handleClearFilter = () => { setSelectedPriorities(new Set([])); setSelectedTypes(new Set([])); - onFilterChange('', [], []); + onFilterChange('', [], [], []); }; return ( @@ -159,6 +210,35 @@ export default function TestCaseFilter({
+
+

+
+ + +
+

{messages.tags}

+ + + + + + {tags.map((tag) => ( + + {tag.name} + + ))} + +