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} + + ))} + +