diff --git a/backend/routes/cases/index.js b/backend/routes/cases/index.js index 170cf1f..ac8b9a0 100644 --- a/backend/routes/cases/index.js +++ b/backend/routes/cases/index.js @@ -1,6 +1,6 @@ const express = require('express'); const router = express.Router(); -const { DataTypes } = require('sequelize'); +const { DataTypes, Op } = require('sequelize'); const defineCase = require('../../models/cases'); module.exports = function (sequelize) { @@ -9,17 +9,39 @@ module.exports = function (sequelize) { const { verifyProjectVisibleFromFolderId } = require('../../middleware/verifyVisible')(sequelize); router.get('/', verifySignedIn, verifyProjectVisibleFromFolderId, async (req, res) => { - const { folderId } = req.query; + const { folderId, priority, type } = req.query; if (!folderId) { return res.status(400).json({ error: 'folderId is required' }); } try { + const whereClause = { + folderId: folderId, + }; + + if (priority) { + const priorityValues = priority + .split(',') + .map((p) => parseInt(p.trim(), 10)) + .filter((p) => !isNaN(p)); + if (priorityValues.length > 0) { + whereClause.priority = { [Op.in]: priorityValues }; + } + } + + if (type) { + const typeValues = type + .split(',') + .map((t) => parseInt(t.trim(), 10)) + .filter((t) => !isNaN(t)); + if (typeValues.length > 0) { + whereClause.type = { [Op.in]: typeValues }; + } + } + const cases = await Case.findAll({ - where: { - folderId: folderId, - }, + where: whereClause, }); res.json(cases); } catch (error) { diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 709bbb5..b846f46 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -203,7 +203,13 @@ "case_title": "Test Case Title", "case_description": "Test Case Description", "create": "Create", - "please_enter": "Please enter test case title" + "please_enter": "Please enter test case title", + "clear_all": "Clear all", + "apply": "Apply", + "select_priorities": "Select priorities", + "selected": "Selected", + "type": "Type", + "select_types": "Select types" }, "Case": { "back_to_cases": "Back to test cases", diff --git a/frontend/messages/ja.json b/frontend/messages/ja.json index 09f1788..b7cca94 100644 --- a/frontend/messages/ja.json +++ b/frontend/messages/ja.json @@ -204,7 +204,13 @@ "case_title": "テストケースタイトル", "case_description": "テストケース詳細", "create": "作成", - "please_enter": "テストケースタイトルを入力してください" + "please_enter": "テストケースタイトルを入力してください", + "clear_all": "すべてクリア", + "apply": "適用", + "select_priorities": "優先度を選択", + "selected": "選択済み", + "type": "タイプ", + "select_types": "タイプを選択" }, "Case": { "back_to_cases": "テストケース一覧に戻る", diff --git a/frontend/messages/pt-BR.json b/frontend/messages/pt-BR.json index 4d4d2cc..93a04b5 100644 --- a/frontend/messages/pt-BR.json +++ b/frontend/messages/pt-BR.json @@ -203,7 +203,13 @@ "case_title": "Título do Caso de Teste", "case_description": "Descrição do Caso de Teste", "create": "Criar", - "please_enter": "Por favor, insira o título do caso de teste" + "please_enter": "Por favor, insira o título do caso de teste", + "clear_all": "Limpar tudo", + "apply": "Aplicar", + "select_priorities": "Selecionar prioridades", + "selected": "Selecionado", + "type": "Tipo", + "select_types": "Selecionar tipos" }, "Case": { "back_to_cases": "Voltar para os casos de teste", diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/FolderItem.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/FolderItem.tsx index 2a3c995..08a026c 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/folders/FolderItem.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/FolderItem.tsx @@ -2,6 +2,7 @@ import { Button } from '@heroui/react'; import { ChevronDown, ChevronRight, Folder, Plus } from 'lucide-react'; import { NodeApi } from 'react-arborist'; import { useContext } from 'react'; +import { useSearchParams } from 'next/navigation'; import FolderEditMenu from './FolderEditMenu'; import { FolderType, FoldersMessages, TreeNodeData } from '@/types/folder'; @@ -33,6 +34,7 @@ export default function FolderItem({ onDeleteClick, }: FolderItemProps) { const router = useRouter(); + const searchParams = useSearchParams(); const context = useContext(TokenContext); const isSelected = selectedFolder && node.data.folderData.id === selectedFolder.id; @@ -69,11 +71,18 @@ export default function FolderItem({ ); + const handleClick = () => { + const currentParams = new URLSearchParams(searchParams.toString()); + const queryString = currentParams.toString(); + const url = `/projects/${projectId}/folders/${node.data.folderData.id}/cases${queryString ? `?${queryString}` : ''}`; + router.push(url, { locale }); + }; + return ( router.push(`/projects/${projectId}/folders/${node.data.folderData.id}/cases`, { locale })} + onClick={() => handleClick()} toggleButton={toggleButton} icon={} label={node.data.name} 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 dd06ccd..71f48e7 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 @@ -1,5 +1,6 @@ 'use client'; import { useState, useEffect, useContext } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; import TestCaseTable from './TestCaseTable'; import CaseDialog from './CaseDialog'; import { TokenContext } from '@/utils/TokenProvider'; @@ -7,6 +8,7 @@ import { fetchCases, createCase, deleteCases, exportCases } from '@/utils/caseCo import { CaseType, CasesMessages } from '@/types/case'; import DeleteConfirmDialog from '@/components/DeleteConfirmDialog'; import { PriorityMessages } from '@/types/priority'; +import { TestTypeMessages } from '@/types/testType'; import { LocaleCodeType } from '@/types/locale'; import { logError } from '@/utils/errorHandler'; @@ -15,21 +17,63 @@ type Props = { folderId: string; messages: CasesMessages; priorityMessages: PriorityMessages; + testTypeMessages: TestTypeMessages; locale: LocaleCodeType; }; -export default function CasesPane({ projectId, folderId, messages, priorityMessages, locale }: Props) { +export default function CasesPane({ + projectId, + folderId, + messages, + priorityMessages, + testTypeMessages, + locale, +}: Props) { const [cases, setCases] = useState([]); const context = useContext(TokenContext); const [isCaseDialogOpen, setIsCaseDialogOpen] = useState(false); + const [priorityFilter, setPriorityFilter] = useState([]); + const [typeFilter, setTypeFilter] = useState([]); + const router = useRouter(); + const searchParams = useSearchParams(); useEffect(() => { async function fetchDataEffect() { if (!context.isSignedIn()) { return; } + + const priorityParam = searchParams.get('priority'); + let currentPriorityFilter: number[] = []; + if (priorityParam) { + currentPriorityFilter = priorityParam + .split(',') + .map((p) => parseInt(p.trim())) + .filter((p) => !isNaN(p)); + setPriorityFilter(currentPriorityFilter); + } else { + setPriorityFilter([]); + } + + const typeParam = searchParams.get('type'); + let currentTypeFilter: number[] = []; + if (typeParam) { + currentTypeFilter = typeParam + .split(',') + .map((t) => parseInt(t.trim())) + .filter((t) => !isNaN(t)); + setTypeFilter(currentTypeFilter); + } else { + setTypeFilter([]); + } + try { - const data = await fetchCases(context.token.access_token, Number(folderId)); + const data = await fetchCases( + context.token.access_token, + Number(folderId), + currentPriorityFilter.length > 0 ? currentPriorityFilter : undefined, + currentTypeFilter.length > 0 ? currentTypeFilter : undefined + ); setCases(data); } catch (error: unknown) { logError('Error fetching cases:', error); @@ -37,7 +81,7 @@ export default function CasesPane({ projectId, folderId, messages, priorityMessa } fetchDataEffect(); - }, [context, folderId]); + }, [context, folderId, searchParams]); const closeDialog = () => { setIsCaseDialogOpen(false); @@ -49,7 +93,6 @@ export default function CasesPane({ projectId, folderId, messages, priorityMessa closeDialog(); }; - // Delete confirm dialog const [isDeleteConfirmDialogOpen, setIsDeleteConfirmDialogOpen] = useState(false); const [deleteCaseIds, setDeleteCaseIds] = useState([]); const closeDeleteConfirmDialog = () => { @@ -79,6 +122,28 @@ export default function CasesPane({ projectId, folderId, messages, priorityMessa await exportCases(context.token.access_token, Number(folderId), type); }; + const handleFilterChange = (priorities: number[], types: number[]) => { + setPriorityFilter(priorities); + setTypeFilter(types); + + const currentParams = new URLSearchParams(searchParams.toString()); + + if (priorities.length > 0) { + currentParams.set('priority', priorities.join(',')); + } else { + currentParams.delete('priority'); + } + + if (types.length > 0) { + currentParams.set('type', types.join(',')); + } else { + currentParams.delete('type'); + } + + const newUrl = `${window.location.pathname}?${currentParams.toString()}`; + router.push(newUrl, { scroll: false }); + }; + return ( <> 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 new file mode 100644 index 0000000..563d050 --- /dev/null +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/TestCaseFilter.tsx @@ -0,0 +1,153 @@ +import { useState, useEffect } from 'react'; +import { Button, Dropdown, DropdownTrigger, DropdownMenu, DropdownItem, Selection } from '@heroui/react'; +import { 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'; + +type TestCaseFilterProps = { + messages: CasesMessages; + priorityMessages: PriorityMessages; + testTypeMessages: TestTypeMessages; + activePriorityFilters: number[]; + activeTypeFilters: number[]; + onFilterChange: (priorities: number[], types: number[]) => void; + handleFilterChange: () => void; +}; + +export default function TestCaseFilter({ + messages, + priorityMessages, + testTypeMessages, + activePriorityFilters, + activeTypeFilters, + onFilterChange, + handleFilterChange, +}: TestCaseFilterProps) { + const [selectedPriorities, setSelectedPriorities] = useState(new Set([])); + const [selectedTypes, setSelectedTypes] = useState(new Set([])); + + useEffect(() => { + if (activePriorityFilters.length > 0) { + const activeKeys = activePriorityFilters.map((index) => priorities[index]?.uid).filter(Boolean); + setSelectedPriorities(new Set(activeKeys)); + } else { + setSelectedPriorities(new Set([])); + } + }, [activePriorityFilters]); + + useEffect(() => { + if (activeTypeFilters.length > 0) { + const activeKeys = activeTypeFilters.map((index) => testTypes[index]?.uid).filter(Boolean); + setSelectedTypes(new Set(activeKeys)); + } else { + setSelectedTypes(new Set([])); + } + }, [activeTypeFilters]); + + const handlePrioritySelectionChange = (keys: Selection) => { + setSelectedPriorities(keys); + }; + + const handleTypeSelectionChange = (keys: Selection) => { + setSelectedTypes(keys); + }; + + const handleApplyFilter = () => { + let priorityIndices: number[] = []; + if (selectedPriorities !== 'all' && selectedPriorities.size > 0) { + priorityIndices = Array.from(selectedPriorities) + .map((key) => priorities.findIndex((priority) => priority.uid === key)) + .filter((index) => index !== -1); + } + + let typeIndices: number[] = []; + if (selectedTypes !== 'all' && selectedTypes.size > 0) { + typeIndices = Array.from(selectedTypes) + .map((key) => testTypes.findIndex((type) => type.uid === key)) + .filter((index) => index !== -1); + } + + onFilterChange(priorityIndices, typeIndices); + handleFilterChange(); + }; + + const handleClearFilter = () => { + setSelectedPriorities(new Set([])); + setSelectedTypes(new Set([])); + onFilterChange([], []); + handleFilterChange(); + }; + + const isFilterEmpty = + (selectedPriorities === 'all' || (selectedPriorities instanceof Set && selectedPriorities.size === 0)) && + (selectedTypes === 'all' || (selectedTypes instanceof Set && selectedTypes.size === 0)); + + return ( +
+
+

{messages.priority}

+ + + + + + {priorities.map((priority) => ( + +
+ + {priorityMessages[priority.uid]} +
+
+ ))} +
+
+
+ +
+

{messages.type}

+ + + + + + {testTypes.map((type) => ( + + {testTypeMessages[type.uid]} + + ))} + + +
+ +
+ + +
+
+ ); +} 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 791b4f3..0ce1655 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,11 +14,15 @@ import { Selection, SortDescriptor, ButtonGroup, + cn, + Badge, } from '@heroui/react'; -import { Plus, MoreVertical, Trash, FileDown, ChevronDown, FileJson, FileSpreadsheet } from 'lucide-react'; +import { Plus, MoreVertical, Trash, FileDown, ChevronDown, Filter, FileJson, FileSpreadsheet } from 'lucide-react'; +import TestCaseFilter from './TestCaseFilter'; import { Link } from '@/src/i18n/routing'; import { CaseType, CasesMessages } from '@/types/case'; import { PriorityMessages } from '@/types/priority'; +import { TestTypeMessages } from '@/types/testType'; import TestCasePriority from '@/components/TestCasePriority'; import { LocaleCodeType } from '@/types/locale'; @@ -30,8 +34,12 @@ type Props = { onDeleteCase: (caseId: number) => void; onDeleteCases: (caseIds: number[]) => void; onExportCases: (type: string) => void; + onFilterChange: (priorities: number[], types: number[]) => void; + activePriorityFilters: number[]; + activeTypeFilters: number[]; messages: CasesMessages; priorityMessages: PriorityMessages; + testTypeMessages: TestTypeMessages; locale: LocaleCodeType; }; @@ -43,8 +51,12 @@ export default function TestCaseTable({ onDeleteCase, onDeleteCases, onExportCases, + onFilterChange, + activePriorityFilters, + activeTypeFilters, messages, priorityMessages, + testTypeMessages, locale, }: Props) { const headerColumns = [ @@ -60,6 +72,7 @@ export default function TestCaseTable({ direction: 'ascending', }); const [exportType, setExportType] = useState(new Set(['json'])); + const [showFilter, setShowFilter] = useState(false); const sortedItems = useMemo(() => { if (cases.length === 0) { @@ -78,6 +91,10 @@ export default function TestCaseTable({ onDeleteCase(deleteCaseId); }; + const handleFilterChange = () => { + setShowFilter(!showFilter); + }; + const renderCell = useCallback((testCase: CaseType, columnKey: string): ReactNode => { const cellValue = testCase[columnKey as keyof CaseType]; @@ -140,6 +157,8 @@ export default function TestCaseTable({ setExportType(new Set(Array.from(keys as Set))); }; + const hasActiveFilters = activePriorityFilters.length > 0 || activeTypeFilters.length > 0; + const classNames = useMemo( () => ({ wrapper: ['max-w-3xl'], @@ -161,69 +180,98 @@ export default function TestCaseTable({ return ( <> -
-

{messages.testCaseList}

- -
- {((selectedKeys !== 'all' && selectedKeys.size > 0) || selectedKeys === 'all') && ( +
+
+

{messages.testCaseList}

+
+ + + + {((selectedKeys !== 'all' && selectedKeys.size > 0) || selectedKeys === 'all') && ( + + )} + + + + + + + + }> + json + + }> + csv + + + + - )} - - - - - - - - }> - json - - }> - csv - - - - - +
+ {showFilter && ( + + )}
); diff --git a/frontend/types/case.ts b/frontend/types/case.ts index 6f10872..690185d 100644 --- a/frontend/types/case.ts +++ b/frontend/types/case.ts @@ -77,6 +77,12 @@ type CasesMessages = { caseDescription: string; create: string; pleaseEnter: string; + clearAll: string; + apply: string; + selectPriorities: string; + selected: string; + type: string; + selectTypes: string; }; type CaseMessages = { diff --git a/frontend/utils/caseControl.ts b/frontend/utils/caseControl.ts index ba1d5d1..97b7248 100644 --- a/frontend/utils/caseControl.ts +++ b/frontend/utils/caseControl.ts @@ -26,8 +26,20 @@ async function fetchCase(jwt: string, caseId: number) { } } -async function fetchCases(jwt: string, folderId: number) { - const url = `${apiServer}/cases?folderId=${folderId}`; +async function fetchCases(jwt: string, folderId: number, priority?: number[], type?: number[]) { + const queryParams = [`folderId=${folderId}`]; + + if (priority && priority.length > 0) { + queryParams.push(`priority=${priority.join(',')}`); + } + + if (type && type.length > 0) { + queryParams.push(`type=${type.join(',')}`); + } + + const query = queryParams.length > 0 ? `?${queryParams.join('&')}` : ''; + + const url = `${apiServer}/cases${query}`; try { const response = await fetch(url, { @@ -43,9 +55,10 @@ async function fetchCases(jwt: string, folderId: number) { } const data = await response.json(); - return data; + return data || []; } catch (error: unknown) { logError('Error fetching data', error); + return []; } }