feat: search test case (#277)
This commit is contained in:
@@ -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} />
|
||||
|
||||
@@ -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=""
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user