feat: search test case (#277)

This commit is contained in:
Eliezer Castro
2025-08-30 11:26:46 -03:00
committed by GitHub
parent afe569a79c
commit f76a3cdaf5
12 changed files with 266 additions and 102 deletions

View File

@@ -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<CaseType[]>([]);
const context = useContext(TokenContext);
const [isCaseDialogOpen, setIsCaseDialogOpen] = useState(false);
const [priorityFilter, setPriorityFilter] = useState<number[]>([]);
const [typeFilter, setTypeFilter] = useState<number[]>([]);
const [queryTerm, setQueryTerm] = useState('');
const [isDeleteConfirmDialogOpen, setIsDeleteConfirmDialogOpen] = useState(false);
const [deleteCaseIds, setDeleteCaseIds] = useState<number[]>([]);
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<number[]>([]);
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}
/>
<CaseDialog isOpen={isCaseDialogOpen} onCancel={closeDialog} onSubmit={onSubmit} messages={messages} />

View File

@@ -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 <span>{cellValue as number}</span>;
case 'title':
return (
<Button
size="sm"
as={Link}
href={`/projects/${projectId}/folders/${testCase.folderId}/cases/${testCase.id}`}
locale={locale}
variant="light"
className="data-[hover=true]:bg-transparent"
>
{cellValue as string}
</Button>
);
case 'priority':
return <TestCasePriority priorityValue={cellValue as number} priorityMessages={priorityMessages} />;
case 'actions':
return (
<Dropdown>
<DropdownTrigger>
<Button isIconOnly radius="full" size="sm" variant="light">
<MoreVertical size={16} />
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="test case actions">
<DropdownItem
key="delete-case"
className="text-danger"
isDisabled={isDisabled}
onPress={() => handleDeleteCase(testCase.id)}
>
{messages.deleteCase}
</DropdownItem>
</DropdownMenu>
</Dropdown>
);
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 <span>{cellValue as number}</span>;
case 'title':
return (
<Button
size="sm"
as={Link}
href={`/projects/${projectId}/folders/${testCase.folderId}/cases/${testCase.id}`}
locale={locale}
variant="light"
className="data-[hover=true]:bg-transparent gap-0"
>
{highlightSearchTerm({
text: cellValue as string,
searchTerm: localQueryTerm,
})}
</Button>
);
case 'priority':
return <TestCasePriority priorityValue={cellValue as number} priorityMessages={priorityMessages} />;
case 'actions':
return (
<Dropdown>
<DropdownTrigger>
<Button isIconOnly radius="full" size="sm" variant="light">
<MoreVertical size={16} />
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="test case actions">
<DropdownItem
key="delete-case"
className="text-danger"
isDisabled={isDisabled}
onPress={() => handleDeleteCase(testCase.id)}
>
{messages.deleteCase}
</DropdownItem>
</DropdownMenu>
</Dropdown>
);
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({
<div className="border-b-1 dark:border-neutral-700 w-full ">
<div className="flex items-center justify-between p-3 ">
<h3 className="font-bold">{messages.testCaseList}</h3>
<div>
<div className="flex items-center">
<Input
className="me-2"
variant="bordered"
classNames={{
base: 'max-w-full sm:max-w-[12rem] h-8',
mainWrapper: 'h-full',
input: 'text-small',
}}
placeholder={messages.searchPlaceholder}
size="sm"
startContent={<SearchIcon size={18} />}
endContent={isSearching && <Spinner size="sm" />}
type="search"
value={localQueryTerm}
onValueChange={handleQueryChange}
aria-label={messages.searchPlaceholder}
maxLength={100}
/>
<Badge
color="warning"
content=""

View File

@@ -39,6 +39,7 @@ export default function Page({ params }: { params: { projectId: string; folderId
selected: t('selected'),
type: t('type'),
selectTypes: t('select_types'),
searchPlaceholder: t('search_placeholder'),
};
const priorityTranslation = useTranslations('Priority');