feat: search test case (#277)
This commit is contained in:
@@ -9,7 +9,7 @@ module.exports = function (sequelize) {
|
|||||||
const { verifyProjectVisibleFromFolderId } = require('../../middleware/verifyVisible')(sequelize);
|
const { verifyProjectVisibleFromFolderId } = require('../../middleware/verifyVisible')(sequelize);
|
||||||
|
|
||||||
router.get('/', verifySignedIn, verifyProjectVisibleFromFolderId, async (req, res) => {
|
router.get('/', verifySignedIn, verifyProjectVisibleFromFolderId, async (req, res) => {
|
||||||
const { folderId, priority, type } = req.query;
|
const { folderId, priority, type, q } = req.query;
|
||||||
|
|
||||||
if (!folderId) {
|
if (!folderId) {
|
||||||
return res.status(400).json({ error: 'folderId is required' });
|
return res.status(400).json({ error: 'folderId is required' });
|
||||||
@@ -20,6 +20,18 @@ module.exports = function (sequelize) {
|
|||||||
folderId: folderId,
|
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) {
|
if (priority) {
|
||||||
const priorityValues = priority
|
const priorityValues = priority
|
||||||
.split(',')
|
.split(',')
|
||||||
|
|||||||
@@ -209,7 +209,8 @@
|
|||||||
"select_priorities": "Select priorities",
|
"select_priorities": "Select priorities",
|
||||||
"selected": "Selected",
|
"selected": "Selected",
|
||||||
"type": "Type",
|
"type": "Type",
|
||||||
"select_types": "Select types"
|
"select_types": "Select types",
|
||||||
|
"search_placeholder": "Type to search..."
|
||||||
},
|
},
|
||||||
"Case": {
|
"Case": {
|
||||||
"back_to_cases": "Back to test cases",
|
"back_to_cases": "Back to test cases",
|
||||||
|
|||||||
@@ -210,7 +210,8 @@
|
|||||||
"select_priorities": "優先度を選択",
|
"select_priorities": "優先度を選択",
|
||||||
"selected": "選択済み",
|
"selected": "選択済み",
|
||||||
"type": "タイプ",
|
"type": "タイプ",
|
||||||
"select_types": "タイプを選択"
|
"select_types": "タイプを選択",
|
||||||
|
"search_placeholder": "入力して検索..."
|
||||||
},
|
},
|
||||||
"Case": {
|
"Case": {
|
||||||
"back_to_cases": "テストケース一覧に戻る",
|
"back_to_cases": "テストケース一覧に戻る",
|
||||||
|
|||||||
@@ -209,7 +209,8 @@
|
|||||||
"select_priorities": "Selecionar prioridades",
|
"select_priorities": "Selecionar prioridades",
|
||||||
"selected": "Selecionado",
|
"selected": "Selecionado",
|
||||||
"type": "Tipo",
|
"type": "Tipo",
|
||||||
"select_types": "Selecionar tipos"
|
"select_types": "Selecionar tipos",
|
||||||
|
"search_placeholder": "Digite para buscar..."
|
||||||
},
|
},
|
||||||
"Case": {
|
"Case": {
|
||||||
"back_to_cases": "Voltar para os casos de teste",
|
"back_to_cases": "Voltar para os casos de teste",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { PriorityMessages } from '@/types/priority';
|
|||||||
import { TestTypeMessages } from '@/types/testType';
|
import { TestTypeMessages } from '@/types/testType';
|
||||||
import { LocaleCodeType } from '@/types/locale';
|
import { LocaleCodeType } from '@/types/locale';
|
||||||
import { logError } from '@/utils/errorHandler';
|
import { logError } from '@/utils/errorHandler';
|
||||||
|
import { parseQueryParam } from '@/utils/parseQueryParam';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -30,62 +31,75 @@ export default function CasesPane({
|
|||||||
locale,
|
locale,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [cases, setCases] = useState<CaseType[]>([]);
|
const [cases, setCases] = useState<CaseType[]>([]);
|
||||||
const context = useContext(TokenContext);
|
|
||||||
const [isCaseDialogOpen, setIsCaseDialogOpen] = useState(false);
|
const [isCaseDialogOpen, setIsCaseDialogOpen] = useState(false);
|
||||||
const [priorityFilter, setPriorityFilter] = useState<number[]>([]);
|
const [priorityFilter, setPriorityFilter] = useState<number[]>([]);
|
||||||
const [typeFilter, setTypeFilter] = 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 router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
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(() => {
|
useEffect(() => {
|
||||||
async function fetchDataEffect() {
|
async function fetchDataEffect() {
|
||||||
if (!context.isSignedIn()) {
|
if (!context.isSignedIn()) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const priorityParam = searchParams.get('priority');
|
const priorityParam = parseQueryParam(searchParams.get('priority'));
|
||||||
let currentPriorityFilter: number[] = [];
|
const typeParam = parseQueryParam(searchParams.get('type'));
|
||||||
if (priorityParam) {
|
const queryParam = searchParams.get('q') || '';
|
||||||
currentPriorityFilter = priorityParam
|
|
||||||
.split(',')
|
|
||||||
.map((p) => parseInt(p.trim()))
|
|
||||||
.filter((p) => !isNaN(p));
|
|
||||||
setPriorityFilter(currentPriorityFilter);
|
|
||||||
} else {
|
|
||||||
setPriorityFilter([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const typeParam = searchParams.get('type');
|
setPriorityFilter(priorityParam);
|
||||||
let currentTypeFilter: number[] = [];
|
setTypeFilter(typeParam);
|
||||||
if (typeParam) {
|
setQueryTerm(queryParam);
|
||||||
currentTypeFilter = typeParam
|
|
||||||
.split(',')
|
|
||||||
.map((t) => parseInt(t.trim()))
|
|
||||||
.filter((t) => !isNaN(t));
|
|
||||||
setTypeFilter(currentTypeFilter);
|
|
||||||
} else {
|
|
||||||
setTypeFilter([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await fetchCases(
|
const data = await fetchCases(
|
||||||
context.token.access_token,
|
context.token.access_token,
|
||||||
Number(folderId),
|
Number(folderId),
|
||||||
currentPriorityFilter.length > 0 ? currentPriorityFilter : undefined,
|
priorityParam.length > 0 ? priorityParam : undefined,
|
||||||
currentTypeFilter.length > 0 ? currentTypeFilter : undefined
|
typeParam.length > 0 ? typeParam : undefined,
|
||||||
|
queryParam || undefined
|
||||||
);
|
);
|
||||||
setCases(data);
|
setCases(data);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logError('Error fetching cases:', error);
|
logError('Error fetching cases:', error);
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchDataEffect();
|
fetchDataEffect();
|
||||||
}, [context, folderId, searchParams]);
|
}, [context, folderId, searchParams]);
|
||||||
|
|
||||||
const closeDialog = () => {
|
const closeDialog = () => setIsCaseDialogOpen(false);
|
||||||
setIsCaseDialogOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmit = async (title: string, description: string) => {
|
const onSubmit = async (title: string, description: string) => {
|
||||||
const newCase = await createCase(context.token.access_token, folderId, title, description);
|
const newCase = await createCase(context.token.access_token, folderId, title, description);
|
||||||
@@ -93,14 +107,12 @@ export default function CasesPane({
|
|||||||
closeDialog();
|
closeDialog();
|
||||||
};
|
};
|
||||||
|
|
||||||
const [isDeleteConfirmDialogOpen, setIsDeleteConfirmDialogOpen] = useState(false);
|
|
||||||
const [deleteCaseIds, setDeleteCaseIds] = useState<number[]>([]);
|
|
||||||
const closeDeleteConfirmDialog = () => {
|
const closeDeleteConfirmDialog = () => {
|
||||||
setIsDeleteConfirmDialogOpen(false);
|
setIsDeleteConfirmDialogOpen(false);
|
||||||
setDeleteCaseIds([]);
|
setDeleteCaseIds([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDeleteCase = async (deleteCaseId: number) => {
|
const onDeleteCase = (deleteCaseId: number) => {
|
||||||
setDeleteCaseIds([deleteCaseId]);
|
setDeleteCaseIds([deleteCaseId]);
|
||||||
setIsDeleteConfirmDialogOpen(true);
|
setIsDeleteConfirmDialogOpen(true);
|
||||||
};
|
};
|
||||||
@@ -125,23 +137,15 @@ export default function CasesPane({
|
|||||||
const handleFilterChange = (priorities: number[], types: number[]) => {
|
const handleFilterChange = (priorities: number[], types: number[]) => {
|
||||||
setPriorityFilter(priorities);
|
setPriorityFilter(priorities);
|
||||||
setTypeFilter(types);
|
setTypeFilter(types);
|
||||||
|
updateUrlParams({ priority: priorities, type: types, q: queryTerm });
|
||||||
|
};
|
||||||
|
|
||||||
const currentParams = new URLSearchParams(searchParams.toString());
|
const handleQueryChange = (q: string) => {
|
||||||
|
setQueryTerm(q);
|
||||||
if (priorities.length > 0) {
|
if (q.trim()) {
|
||||||
currentParams.set('priority', priorities.join(','));
|
setIsSearching(true);
|
||||||
} else {
|
|
||||||
currentParams.delete('priority');
|
|
||||||
}
|
}
|
||||||
|
updateUrlParams({ priority: priorityFilter, type: typeFilter, q });
|
||||||
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 (
|
return (
|
||||||
@@ -155,12 +159,15 @@ export default function CasesPane({
|
|||||||
onDeleteCases={onDeleteCases}
|
onDeleteCases={onDeleteCases}
|
||||||
onExportCases={onExportCases}
|
onExportCases={onExportCases}
|
||||||
onFilterChange={handleFilterChange}
|
onFilterChange={handleFilterChange}
|
||||||
|
onQueryChange={handleQueryChange}
|
||||||
activePriorityFilters={priorityFilter}
|
activePriorityFilters={priorityFilter}
|
||||||
activeTypeFilters={typeFilter}
|
activeTypeFilters={typeFilter}
|
||||||
messages={messages}
|
messages={messages}
|
||||||
priorityMessages={priorityMessages}
|
priorityMessages={priorityMessages}
|
||||||
testTypeMessages={testTypeMessages}
|
testTypeMessages={testTypeMessages}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
|
queryTerm={queryTerm}
|
||||||
|
isSearching={isSearching}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CaseDialog isOpen={isCaseDialogOpen} onCancel={closeDialog} onSubmit={onSubmit} messages={messages} />
|
<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 {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
@@ -16,8 +16,20 @@ import {
|
|||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
cn,
|
cn,
|
||||||
Badge,
|
Badge,
|
||||||
|
Input,
|
||||||
|
Spinner,
|
||||||
} from '@heroui/react';
|
} 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 TestCaseFilter from './TestCaseFilter';
|
||||||
import { Link } from '@/src/i18n/routing';
|
import { Link } from '@/src/i18n/routing';
|
||||||
import { CaseType, CasesMessages } from '@/types/case';
|
import { CaseType, CasesMessages } from '@/types/case';
|
||||||
@@ -25,6 +37,8 @@ import { PriorityMessages } from '@/types/priority';
|
|||||||
import { TestTypeMessages } from '@/types/testType';
|
import { TestTypeMessages } from '@/types/testType';
|
||||||
import TestCasePriority from '@/components/TestCasePriority';
|
import TestCasePriority from '@/components/TestCasePriority';
|
||||||
import { LocaleCodeType } from '@/types/locale';
|
import { LocaleCodeType } from '@/types/locale';
|
||||||
|
import useDebounce from '@/utils/useDebounce';
|
||||||
|
import { highlightSearchTerm } from '@/utils/highlightSearchTerm';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -35,12 +49,15 @@ type Props = {
|
|||||||
onDeleteCases: (caseIds: number[]) => void;
|
onDeleteCases: (caseIds: number[]) => void;
|
||||||
onExportCases: (type: string) => void;
|
onExportCases: (type: string) => void;
|
||||||
onFilterChange: (priorities: number[], types: number[]) => void;
|
onFilterChange: (priorities: number[], types: number[]) => void;
|
||||||
|
onQueryChange: (q: string) => void;
|
||||||
|
queryTerm: string;
|
||||||
activePriorityFilters: number[];
|
activePriorityFilters: number[];
|
||||||
activeTypeFilters: number[];
|
activeTypeFilters: number[];
|
||||||
messages: CasesMessages;
|
messages: CasesMessages;
|
||||||
priorityMessages: PriorityMessages;
|
priorityMessages: PriorityMessages;
|
||||||
testTypeMessages: TestTypeMessages;
|
testTypeMessages: TestTypeMessages;
|
||||||
locale: LocaleCodeType;
|
locale: LocaleCodeType;
|
||||||
|
isSearching: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TestCaseTable({
|
export default function TestCaseTable({
|
||||||
@@ -52,12 +69,15 @@ export default function TestCaseTable({
|
|||||||
onDeleteCases,
|
onDeleteCases,
|
||||||
onExportCases,
|
onExportCases,
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
|
onQueryChange,
|
||||||
activePriorityFilters,
|
activePriorityFilters,
|
||||||
activeTypeFilters,
|
activeTypeFilters,
|
||||||
messages,
|
messages,
|
||||||
priorityMessages,
|
priorityMessages,
|
||||||
testTypeMessages,
|
testTypeMessages,
|
||||||
locale,
|
locale,
|
||||||
|
queryTerm,
|
||||||
|
isSearching,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const headerColumns = [
|
const headerColumns = [
|
||||||
{ name: messages.id, uid: 'id', sortable: true },
|
{ name: messages.id, uid: 'id', sortable: true },
|
||||||
@@ -73,6 +93,15 @@ export default function TestCaseTable({
|
|||||||
});
|
});
|
||||||
const [exportType, setExportType] = useState(new Set(['json']));
|
const [exportType, setExportType] = useState(new Set(['json']));
|
||||||
const [showFilter, setShowFilter] = useState(false);
|
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(() => {
|
const sortedItems = useMemo(() => {
|
||||||
if (cases.length === 0) {
|
if (cases.length === 0) {
|
||||||
@@ -95,52 +124,65 @@ export default function TestCaseTable({
|
|||||||
setShowFilter(!showFilter);
|
setShowFilter(!showFilter);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderCell = useCallback((testCase: CaseType, columnKey: string): ReactNode => {
|
const handleQueryChange = (value: string) => {
|
||||||
const cellValue = testCase[columnKey as keyof CaseType];
|
setLocalQueryTerm(value);
|
||||||
|
if (value.length >= 2 || value.length === 0) {
|
||||||
switch (columnKey) {
|
debouncedQuery(value);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
// 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 = () => {
|
const handleDeleteCases = () => {
|
||||||
let deleteCaseIds: number[];
|
let deleteCaseIds: number[];
|
||||||
@@ -183,7 +225,25 @@ export default function TestCaseTable({
|
|||||||
<div className="border-b-1 dark:border-neutral-700 w-full ">
|
<div className="border-b-1 dark:border-neutral-700 w-full ">
|
||||||
<div className="flex items-center justify-between p-3 ">
|
<div className="flex items-center justify-between p-3 ">
|
||||||
<h3 className="font-bold">{messages.testCaseList}</h3>
|
<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
|
<Badge
|
||||||
color="warning"
|
color="warning"
|
||||||
content=""
|
content=""
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export default function Page({ params }: { params: { projectId: string; folderId
|
|||||||
selected: t('selected'),
|
selected: t('selected'),
|
||||||
type: t('type'),
|
type: t('type'),
|
||||||
selectTypes: t('select_types'),
|
selectTypes: t('select_types'),
|
||||||
|
searchPlaceholder: t('search_placeholder'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const priorityTranslation = useTranslations('Priority');
|
const priorityTranslation = useTranslations('Priority');
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ type CasesMessages = {
|
|||||||
selected: string;
|
selected: string;
|
||||||
type: string;
|
type: string;
|
||||||
selectTypes: string;
|
selectTypes: string;
|
||||||
|
searchPlaceholder: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CaseMessages = {
|
type CaseMessages = {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ async function fetchCase(jwt: string, caseId: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchCases(jwt: string, folderId: number, priority?: number[], type?: number[]) {
|
async function fetchCases(jwt: string, folderId: number, priority?: number[], type?: number[], q?: string) {
|
||||||
const queryParams = [`folderId=${folderId}`];
|
const queryParams = [`folderId=${folderId}`];
|
||||||
|
|
||||||
if (priority && priority.length > 0) {
|
if (priority && priority.length > 0) {
|
||||||
@@ -37,6 +37,10 @@ async function fetchCases(jwt: string, folderId: number, priority?: number[], ty
|
|||||||
queryParams.push(`type=${type.join(',')}`);
|
queryParams.push(`type=${type.join(',')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (q) {
|
||||||
|
queryParams.push(`q=${q}`);
|
||||||
|
}
|
||||||
|
|
||||||
const query = queryParams.length > 0 ? `?${queryParams.join('&')}` : '';
|
const query = queryParams.length > 0 ? `?${queryParams.join('&')}` : '';
|
||||||
|
|
||||||
const url = `${apiServer}/cases${query}`;
|
const url = `${apiServer}/cases${query}`;
|
||||||
|
|||||||
38
frontend/utils/highlightSearchTerm.tsx
Normal file
38
frontend/utils/highlightSearchTerm.tsx
Normal file
@@ -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 (
|
||||||
|
<mark key={`${part}-${index}`} className={cn(className, 'bg-yellow-200 dark:bg-yellow-60 py-0.5 rounded')}>
|
||||||
|
{part}
|
||||||
|
</mark>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return part;
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
frontend/utils/parseQueryParam.ts
Normal file
8
frontend/utils/parseQueryParam.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export const parseQueryParam = (param: string | null): number[] => {
|
||||||
|
return param
|
||||||
|
? param
|
||||||
|
.split(',')
|
||||||
|
.map((p) => parseInt(p.trim()))
|
||||||
|
.filter((p) => !isNaN(p))
|
||||||
|
: [];
|
||||||
|
};
|
||||||
30
frontend/utils/useDebounce.ts
Normal file
30
frontend/utils/useDebounce.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { useRef, useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function useDebounce<T extends (...args: any[]) => void>(
|
||||||
|
fn: T,
|
||||||
|
delay: number
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const debounceFn = useCallback(
|
||||||
|
(...args: Parameters<T>): 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user