diff --git a/backend/routes/cases/index.js b/backend/routes/cases/index.js index ac8b9a0..c525ba4 100644 --- a/backend/routes/cases/index.js +++ b/backend/routes/cases/index.js @@ -9,7 +9,7 @@ module.exports = function (sequelize) { const { verifyProjectVisibleFromFolderId } = require('../../middleware/verifyVisible')(sequelize); router.get('/', verifySignedIn, verifyProjectVisibleFromFolderId, async (req, res) => { - const { folderId, priority, type } = req.query; + const { folderId, priority, type, q } = req.query; if (!folderId) { return res.status(400).json({ error: 'folderId is required' }); @@ -20,6 +20,18 @@ module.exports = function (sequelize) { folderId: folderId, }; + if (q) { + const searchTerm = q.trim(); + + if (searchTerm.length > 100) { + return res.status(400).json({ error: 'Search term too long' }); + } + + if (searchTerm.length >= 2) { + whereClause[Op.or] = [{ title: { [Op.like]: `%${q}%` } }]; + } + } + if (priority) { const priorityValues = priority .split(',') diff --git a/frontend/messages/en.json b/frontend/messages/en.json index b846f46..e546a20 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -209,7 +209,8 @@ "select_priorities": "Select priorities", "selected": "Selected", "type": "Type", - "select_types": "Select types" + "select_types": "Select types", + "search_placeholder": "Type to search..." }, "Case": { "back_to_cases": "Back to test cases", diff --git a/frontend/messages/ja.json b/frontend/messages/ja.json index b7cca94..7bd7cf1 100644 --- a/frontend/messages/ja.json +++ b/frontend/messages/ja.json @@ -210,7 +210,8 @@ "select_priorities": "優先度を選択", "selected": "選択済み", "type": "タイプ", - "select_types": "タイプを選択" + "select_types": "タイプを選択", + "search_placeholder": "入力して検索..." }, "Case": { "back_to_cases": "テストケース一覧に戻る", diff --git a/frontend/messages/pt-BR.json b/frontend/messages/pt-BR.json index 93a04b5..e575d7c 100644 --- a/frontend/messages/pt-BR.json +++ b/frontend/messages/pt-BR.json @@ -209,7 +209,8 @@ "select_priorities": "Selecionar prioridades", "selected": "Selecionado", "type": "Tipo", - "select_types": "Selecionar tipos" + "select_types": "Selecionar tipos", + "search_placeholder": "Digite para buscar..." }, "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 71f48e7..6bfbbf9 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 @@ -11,6 +11,7 @@ import { PriorityMessages } from '@/types/priority'; import { TestTypeMessages } from '@/types/testType'; import { LocaleCodeType } from '@/types/locale'; import { logError } from '@/utils/errorHandler'; +import { parseQueryParam } from '@/utils/parseQueryParam'; type Props = { projectId: string; @@ -30,62 +31,75 @@ export default function CasesPane({ locale, }: Props) { const [cases, setCases] = useState([]); - const context = useContext(TokenContext); const [isCaseDialogOpen, setIsCaseDialogOpen] = useState(false); const [priorityFilter, setPriorityFilter] = useState([]); const [typeFilter, setTypeFilter] = useState([]); + const [queryTerm, setQueryTerm] = useState(''); + const [isDeleteConfirmDialogOpen, setIsDeleteConfirmDialogOpen] = useState(false); + const [deleteCaseIds, setDeleteCaseIds] = useState([]); + const [isSearching, setIsSearching] = useState(false); + + const context = useContext(TokenContext); const router = useRouter(); const searchParams = useSearchParams(); + const updateUrlParams = (updates: { priority?: number[]; type?: number[]; q?: string }) => { + const currentParams = new URLSearchParams(searchParams.toString()); + + if (updates.priority && updates.priority.length > 0) { + currentParams.set('priority', updates.priority.join(',')); + } else { + currentParams.delete('priority'); + } + + if (updates.type && updates.type.length > 0) { + currentParams.set('type', updates.type.join(',')); + } else { + currentParams.delete('type'); + } + + if (updates.q) { + currentParams.set('q', updates.q); + } else { + currentParams.delete('q'); + } + + const newUrl = `${window.location.pathname}?${currentParams.toString()}`; + router.push(newUrl, { scroll: false }); + }; + useEffect(() => { async function fetchDataEffect() { - if (!context.isSignedIn()) { - return; - } + 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 priorityParam = parseQueryParam(searchParams.get('priority')); + const typeParam = parseQueryParam(searchParams.get('type')); + const queryParam = searchParams.get('q') || ''; - 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([]); - } + setPriorityFilter(priorityParam); + setTypeFilter(typeParam); + setQueryTerm(queryParam); try { const data = await fetchCases( context.token.access_token, Number(folderId), - currentPriorityFilter.length > 0 ? currentPriorityFilter : undefined, - currentTypeFilter.length > 0 ? currentTypeFilter : undefined + priorityParam.length > 0 ? priorityParam : undefined, + typeParam.length > 0 ? typeParam : undefined, + queryParam || undefined ); setCases(data); } catch (error: unknown) { logError('Error fetching cases:', error); + } finally { + setIsSearching(false); } } fetchDataEffect(); }, [context, folderId, searchParams]); - const closeDialog = () => { - setIsCaseDialogOpen(false); - }; + const closeDialog = () => setIsCaseDialogOpen(false); const onSubmit = async (title: string, description: string) => { const newCase = await createCase(context.token.access_token, folderId, title, description); @@ -93,14 +107,12 @@ export default function CasesPane({ closeDialog(); }; - const [isDeleteConfirmDialogOpen, setIsDeleteConfirmDialogOpen] = useState(false); - const [deleteCaseIds, setDeleteCaseIds] = useState([]); const closeDeleteConfirmDialog = () => { setIsDeleteConfirmDialogOpen(false); setDeleteCaseIds([]); }; - const onDeleteCase = async (deleteCaseId: number) => { + const onDeleteCase = (deleteCaseId: number) => { setDeleteCaseIds([deleteCaseId]); setIsDeleteConfirmDialogOpen(true); }; @@ -125,23 +137,15 @@ export default function CasesPane({ const handleFilterChange = (priorities: number[], types: number[]) => { setPriorityFilter(priorities); setTypeFilter(types); + updateUrlParams({ priority: priorities, type: types, q: queryTerm }); + }; - const currentParams = new URLSearchParams(searchParams.toString()); - - if (priorities.length > 0) { - currentParams.set('priority', priorities.join(',')); - } else { - currentParams.delete('priority'); + const handleQueryChange = (q: string) => { + setQueryTerm(q); + if (q.trim()) { + setIsSearching(true); } - - 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 }); + updateUrlParams({ priority: priorityFilter, type: typeFilter, q }); }; return ( @@ -155,12 +159,15 @@ export default function CasesPane({ onDeleteCases={onDeleteCases} onExportCases={onExportCases} onFilterChange={handleFilterChange} + onQueryChange={handleQueryChange} activePriorityFilters={priorityFilter} activeTypeFilters={typeFilter} messages={messages} priorityMessages={priorityMessages} testTypeMessages={testTypeMessages} locale={locale} + queryTerm={queryTerm} + isSearching={isSearching} /> 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 0ce1655..83f48b3 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 @@ -1,4 +1,4 @@ -import { useState, useMemo, useCallback, ReactNode } from 'react'; +import { useState, useMemo, useCallback, ReactNode, useEffect } from 'react'; import { Table, TableHeader, @@ -16,8 +16,20 @@ import { ButtonGroup, cn, Badge, + Input, + Spinner, } from '@heroui/react'; -import { Plus, MoreVertical, Trash, FileDown, ChevronDown, Filter, FileJson, FileSpreadsheet } from 'lucide-react'; +import { + Plus, + MoreVertical, + Trash, + FileDown, + ChevronDown, + Filter, + FileJson, + FileSpreadsheet, + SearchIcon, +} from 'lucide-react'; import TestCaseFilter from './TestCaseFilter'; import { Link } from '@/src/i18n/routing'; import { CaseType, CasesMessages } from '@/types/case'; @@ -25,6 +37,8 @@ import { PriorityMessages } from '@/types/priority'; import { TestTypeMessages } from '@/types/testType'; import TestCasePriority from '@/components/TestCasePriority'; import { LocaleCodeType } from '@/types/locale'; +import useDebounce from '@/utils/useDebounce'; +import { highlightSearchTerm } from '@/utils/highlightSearchTerm'; type Props = { projectId: string; @@ -35,12 +49,15 @@ type Props = { onDeleteCases: (caseIds: number[]) => void; onExportCases: (type: string) => void; onFilterChange: (priorities: number[], types: number[]) => void; + onQueryChange: (q: string) => void; + queryTerm: string; activePriorityFilters: number[]; activeTypeFilters: number[]; messages: CasesMessages; priorityMessages: PriorityMessages; testTypeMessages: TestTypeMessages; locale: LocaleCodeType; + isSearching: boolean; }; export default function TestCaseTable({ @@ -52,12 +69,15 @@ export default function TestCaseTable({ onDeleteCases, onExportCases, onFilterChange, + onQueryChange, activePriorityFilters, activeTypeFilters, messages, priorityMessages, testTypeMessages, locale, + queryTerm, + isSearching, }: Props) { const headerColumns = [ { name: messages.id, uid: 'id', sortable: true }, @@ -73,6 +93,15 @@ export default function TestCaseTable({ }); const [exportType, setExportType] = useState(new Set(['json'])); const [showFilter, setShowFilter] = useState(false); + const [localQueryTerm, setLocalQueryTerm] = useState(queryTerm); + + const debouncedQuery = useDebounce((value: string) => { + onQueryChange(value); + }, 500); + + useEffect(() => { + setLocalQueryTerm(queryTerm); + }, [queryTerm]); const sortedItems = useMemo(() => { if (cases.length === 0) { @@ -95,52 +124,65 @@ export default function TestCaseTable({ setShowFilter(!showFilter); }; - const renderCell = useCallback((testCase: CaseType, columnKey: string): ReactNode => { - const cellValue = testCase[columnKey as keyof CaseType]; - - switch (columnKey) { - case 'id': - return {cellValue as number}; - case 'title': - return ( - - ); - case 'priority': - return ; - case 'actions': - return ( - - - - - - handleDeleteCase(testCase.id)} - > - {messages.deleteCase} - - - - ); - default: - return cellValue as string; + const handleQueryChange = (value: string) => { + setLocalQueryTerm(value); + if (value.length >= 2 || value.length === 0) { + debouncedQuery(value); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }; + + const renderCell = useCallback( + (testCase: CaseType, columnKey: string): ReactNode => { + const cellValue = testCase[columnKey as keyof CaseType]; + + switch (columnKey) { + case 'id': + return {cellValue as number}; + case 'title': + return ( + + ); + case 'priority': + return ; + case 'actions': + return ( + + + + + + handleDeleteCase(testCase.id)} + > + {messages.deleteCase} + + + + ); + default: + return cellValue as string; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, + [localQueryTerm] + ); const handleDeleteCases = () => { let deleteCaseIds: number[]; @@ -183,7 +225,25 @@ export default function TestCaseTable({

{messages.testCaseList}

-
+
+ } + endContent={isSearching && } + type="search" + value={localQueryTerm} + onValueChange={handleQueryChange} + aria-label={messages.searchPlaceholder} + maxLength={100} + /> 0) { @@ -37,6 +37,10 @@ async function fetchCases(jwt: string, folderId: number, priority?: number[], ty queryParams.push(`type=${type.join(',')}`); } + if (q) { + queryParams.push(`q=${q}`); + } + const query = queryParams.length > 0 ? `?${queryParams.join('&')}` : ''; const url = `${apiServer}/cases${query}`; diff --git a/frontend/utils/highlightSearchTerm.tsx b/frontend/utils/highlightSearchTerm.tsx new file mode 100644 index 0000000..eb1216c --- /dev/null +++ b/frontend/utils/highlightSearchTerm.tsx @@ -0,0 +1,38 @@ +import { cn } from '@heroui/theme'; +import { ReactNode } from 'react'; + +interface HighlightSearchTermProps { + text: string; + searchTerm: string; + className?: string; + minSearchLength?: number; +} + +export function highlightSearchTerm({ + text, + searchTerm, + className, + minSearchLength = 2, +}: HighlightSearchTermProps): ReactNode { + if (!text || !searchTerm || searchTerm.length < minSearchLength) { + return text || null; + } + + const regex = new RegExp(`(${searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'); + const parts = text.split(regex); + + return ( + <> + {parts.map((part, index) => { + if (regex.test(part)) { + return ( + + {part} + + ); + } + return part; + })} + + ); +} diff --git a/frontend/utils/parseQueryParam.ts b/frontend/utils/parseQueryParam.ts new file mode 100644 index 0000000..b4d8f4a --- /dev/null +++ b/frontend/utils/parseQueryParam.ts @@ -0,0 +1,8 @@ +export const parseQueryParam = (param: string | null): number[] => { + return param + ? param + .split(',') + .map((p) => parseInt(p.trim())) + .filter((p) => !isNaN(p)) + : []; +}; diff --git a/frontend/utils/useDebounce.ts b/frontend/utils/useDebounce.ts new file mode 100644 index 0000000..7c3a82f --- /dev/null +++ b/frontend/utils/useDebounce.ts @@ -0,0 +1,30 @@ +import { useRef, useCallback, useEffect } from 'react'; + +export default function useDebounce void>( + fn: T, + delay: number +): (...args: Parameters) => void { + const timeoutRef = useRef(null); + + const debounceFn = useCallback( + (...args: Parameters): void => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + fn(...args); + }, delay); + }, + [fn, delay] + ); + + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + return debounceFn; +}