feat: test case filtering (#276)

This commit is contained in:
Eliezer Castro
2025-08-24 12:15:39 -03:00
committed by GitHub
parent 720ec7c508
commit afe569a79c
11 changed files with 432 additions and 69 deletions

View File

@@ -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) {

View File

@@ -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",

View File

@@ -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": "テストケース一覧に戻る",

View File

@@ -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",

View File

@@ -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 (
<TreeItem
style={style}
isSelected={isSelected}
onClick={() => router.push(`/projects/${projectId}/folders/${node.data.folderData.id}/cases`, { locale })}
onClick={() => handleClick()}
toggleButton={toggleButton}
icon={<Folder size={20} color="#F7C24E" fill="#F7C24E" className="flex-shrink-0" />}
label={node.data.name}

View File

@@ -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<CaseType[]>([]);
const context = useContext(TokenContext);
const [isCaseDialogOpen, setIsCaseDialogOpen] = useState(false);
const [priorityFilter, setPriorityFilter] = useState<number[]>([]);
const [typeFilter, setTypeFilter] = useState<number[]>([]);
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<number[]>([]);
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 (
<>
<TestCaseTable
@@ -89,8 +154,12 @@ export default function CasesPane({ projectId, folderId, messages, priorityMessa
onDeleteCase={onDeleteCase}
onDeleteCases={onDeleteCases}
onExportCases={onExportCases}
onFilterChange={handleFilterChange}
activePriorityFilters={priorityFilter}
activeTypeFilters={typeFilter}
messages={messages}
priorityMessages={priorityMessages}
testTypeMessages={testTypeMessages}
locale={locale}
/>

View File

@@ -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<Selection>(new Set([]));
const [selectedTypes, setSelectedTypes] = useState<Selection>(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 (
<div className="flex items-end border-t border-default-200 p-3">
<div className="flex-col space-y-2 mr-2">
<h3 className="text-default-500 text-small">{messages.priority}</h3>
<Dropdown>
<DropdownTrigger>
<Button variant="bordered" endContent={<ChevronDown size={16} />}>
{selectedPriorities === 'all' || selectedPriorities.size === 0
? messages.selectPriorities
: `${selectedPriorities.size} ${messages.selected || 'selected'}`}
</Button>
</DropdownTrigger>
<DropdownMenu
aria-label="Priority filter"
selectionMode="multiple"
selectedKeys={selectedPriorities}
onSelectionChange={handlePrioritySelectionChange}
>
{priorities.map((priority) => (
<DropdownItem key={priority.uid} textValue={priorityMessages[priority.uid]} className="flex items-center">
<div className="flex items-center gap-2">
<Circle size={8} color={priority.color} fill={priority.color} />
<span className="text-sm">{priorityMessages[priority.uid]}</span>
</div>
</DropdownItem>
))}
</DropdownMenu>
</Dropdown>
</div>
<div className="flex-col space-y-2 mr-2">
<h3 className="text-default-500 text-small">{messages.type}</h3>
<Dropdown>
<DropdownTrigger>
<Button variant="bordered" endContent={<ChevronDown size={16} />}>
{selectedTypes === 'all' || selectedTypes.size === 0
? messages.selectTypes || 'Select Types'
: `${selectedTypes.size} ${messages.selected || 'selected'}`}
</Button>
</DropdownTrigger>
<DropdownMenu
className="max-h-[50vh] overflow-y-auto"
aria-label="Type filter"
selectionMode="multiple"
selectedKeys={selectedTypes}
onSelectionChange={handleTypeSelectionChange}
>
{testTypes.map((type) => (
<DropdownItem key={type.uid} textValue={testTypeMessages[type.uid]} className="flex items-center">
<span className="text-sm">{testTypeMessages[type.uid]}</span>
</DropdownItem>
))}
</DropdownMenu>
</Dropdown>
</div>
<div className="ml-auto">
<Button className="me-2" size="sm" variant="light" onPress={handleClearFilter} isDisabled={isFilterEmpty}>
{messages.clearAll}
</Button>
<Button size="sm" variant="solid" color="primary" onPress={handleApplyFilter} isDisabled={isFilterEmpty}>
{messages.apply}
</Button>
</div>
</div>
);
}

View File

@@ -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<string>)));
};
const hasActiveFilters = activePriorityFilters.length > 0 || activeTypeFilters.length > 0;
const classNames = useMemo(
() => ({
wrapper: ['max-w-3xl'],
@@ -161,69 +180,98 @@ export default function TestCaseTable({
return (
<>
<div className="border-b-1 dark:border-neutral-700 w-full p-3 flex items-center justify-between">
<h3 className="font-bold">{messages.testCaseList}</h3>
<div>
{((selectedKeys !== 'all' && selectedKeys.size > 0) || selectedKeys === 'all') && (
<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>
<Badge
color="warning"
content=""
isInvisible={!hasActiveFilters}
shape="circle"
size="sm"
placement="bottom-left"
>
<Button
size="sm"
isIconOnly
onPress={handleFilterChange}
className={cn('me-2', showFilter && 'bg-primary')}
>
<Filter size={16} className={cn('text-default-500', showFilter && 'text-white')} />
</Button>
</Badge>
{((selectedKeys !== 'all' && selectedKeys.size > 0) || selectedKeys === 'all') && (
<Button
startContent={<Trash size={16} />}
size="sm"
isDisabled={isDisabled}
color="danger"
className="me-2"
onPress={handleDeleteCases}
>
{messages.delete}
</Button>
)}
<ButtonGroup className="me-2">
<Button
startContent={<FileDown size={16} />}
size="sm"
onPress={() => onExportCases(Array.from(exportType)[0])}
>
{messages.export} {exportType}
</Button>
<Dropdown placement="bottom-end">
<DropdownTrigger>
<Button isIconOnly size="sm">
<ChevronDown size={16} />
</Button>
</DropdownTrigger>
<DropdownMenu
disallowEmptySelection
aria-label="Export options"
className="max-w-[300px]"
selectedKeys={exportType}
selectionMode="single"
onSelectionChange={handleExportTypeChange}
>
<DropdownItem key="json" startContent={<FileJson size={16} />}>
json
</DropdownItem>
<DropdownItem key="csv" startContent={<FileSpreadsheet size={16} />}>
csv
</DropdownItem>
</DropdownMenu>
</Dropdown>
</ButtonGroup>
<Button
startContent={<Trash size={16} />}
startContent={<Plus size={16} />}
size="sm"
isDisabled={isDisabled}
color="danger"
className="me-2"
onPress={handleDeleteCases}
color="primary"
onPress={onCreateCase}
>
{messages.delete}
{messages.newTestCase}
</Button>
)}
<ButtonGroup className="me-2">
<Button
startContent={<FileDown size={16} />}
size="sm"
onPress={() => onExportCases(Array.from(exportType)[0])}
>
{messages.export} {exportType}
</Button>
<Dropdown placement="bottom-end">
<DropdownTrigger>
<Button isIconOnly size="sm">
<ChevronDown size={16} />
</Button>
</DropdownTrigger>
<DropdownMenu
disallowEmptySelection
aria-label="Export options"
className="max-w-[300px]"
selectedKeys={exportType}
selectionMode="single"
onSelectionChange={handleExportTypeChange}
>
<DropdownItem key="json" startContent={<FileJson size={16} />}>
json
</DropdownItem>
<DropdownItem key="csv" startContent={<FileSpreadsheet size={16} />}>
csv
</DropdownItem>
</DropdownMenu>
</Dropdown>
</ButtonGroup>
<Button
startContent={<Plus size={16} />}
size="sm"
isDisabled={isDisabled}
color="primary"
onPress={onCreateCase}
>
{messages.newTestCase}
</Button>
</div>
</div>
{showFilter && (
<TestCaseFilter
messages={messages}
priorityMessages={priorityMessages}
testTypeMessages={testTypeMessages}
activePriorityFilters={activePriorityFilters}
activeTypeFilters={activeTypeFilters}
onFilterChange={onFilterChange}
handleFilterChange={handleFilterChange}
/>
)}
</div>
<Table
isCompact
removeWrapper
aria-label="Tese cases table"
aria-label="Test cases table"
classNames={classNames}
selectedKeys={selectedKeys}
selectionMode="multiple"

View File

@@ -2,6 +2,7 @@ import { getTranslations } from 'next-intl/server';
import { useTranslations } from 'next-intl';
import CasesPane from './CasesPane';
import { PriorityMessages } from '@/types/priority';
import { TestTypeMessages } from '@/types/testType';
import { LocaleCodeType } from '@/types/locale';
export async function generateMetadata({ params: { locale } }: { params: { locale: LocaleCodeType } }) {
@@ -32,6 +33,12 @@ export default function Page({ params }: { params: { projectId: string; folderId
caseDescription: t('case_description'),
create: t('create'),
pleaseEnter: t('please_enter'),
apply: t('apply'),
clearAll: t('clear_all'),
selectPriorities: t('select_priorities'),
selected: t('selected'),
type: t('type'),
selectTypes: t('select_types'),
};
const priorityTranslation = useTranslations('Priority');
@@ -42,6 +49,23 @@ export default function Page({ params }: { params: { projectId: string; folderId
low: priorityTranslation('low'),
};
const testTypeTranslation = useTranslations('Type');
const testTypeMessages: TestTypeMessages = {
other: testTypeTranslation('other'),
security: testTypeTranslation('security'),
performance: testTypeTranslation('performance'),
accessibility: testTypeTranslation('accessibility'),
functional: testTypeTranslation('functional'),
acceptance: testTypeTranslation('acceptance'),
usability: testTypeTranslation('usability'),
smokeSanity: testTypeTranslation('smoke_sanity'),
compatibility: testTypeTranslation('compatibility'),
destructive: testTypeTranslation('destructive'),
regression: testTypeTranslation('regression'),
automated: testTypeTranslation('automated'),
manual: testTypeTranslation('manual'),
};
return (
<>
<CasesPane
@@ -50,6 +74,7 @@ export default function Page({ params }: { params: { projectId: string; folderId
locale={params.locale as LocaleCodeType}
messages={messages}
priorityMessages={priorityMessages}
testTypeMessages={testTypeMessages}
/>
</>
);

View File

@@ -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 = {

View File

@@ -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 [];
}
}