feat: Add filter on test run page (#358)

This commit is contained in:
kimatata
2025-12-07 13:26:14 +09:00
committed by GitHub
parent 3badfb1cad
commit 805c1f50b6
11 changed files with 428 additions and 61 deletions

View File

@@ -8,7 +8,6 @@ import defineTag from '../../models/tags.js';
import defineRunCase from '../../models/runCases.js'; import defineRunCase from '../../models/runCases.js';
import authMiddleware from '../../middleware/auth.js'; import authMiddleware from '../../middleware/auth.js';
import visibilityMiddleware from '../../middleware/verifyVisible.js'; import visibilityMiddleware from '../../middleware/verifyVisible.js';
import { testRunCaseStatus } from '../../config/enums.js';
export default function (sequelize) { export default function (sequelize) {
const Project = defineProject(sequelize, DataTypes); const Project = defineProject(sequelize, DataTypes);
@@ -34,7 +33,7 @@ export default function (sequelize) {
verifyProjectVisibleFromProjectId, verifyProjectVisibleFromProjectId,
verifyProjectVisibleFromRunId, verifyProjectVisibleFromRunId,
async (req, res) => { async (req, res) => {
const { projectId, runId, status, tag } = req.query; const { projectId, runId, status, tag, search } = req.query;
if (!projectId) { if (!projectId) {
return res.status(400).json({ error: 'projectId is required' }); return res.status(400).json({ error: 'projectId is required' });
@@ -44,25 +43,62 @@ export default function (sequelize) {
return res.status(400).json({ error: 'runId is required' }); return res.status(400).json({ error: 'runId is required' });
} }
let errorMessage = null;
let statusFilter = undefined;
if (status) {
let statusIndex = testRunCaseStatus.indexOf(status.toLowerCase());
if (statusIndex === -1) {
errorMessage = `Invalid status filter: ${status}`;
} else {
statusFilter = { status: statusIndex };
}
}
let tagFilter = tag ? { name: tag } : undefined;
if (errorMessage) {
return res.status(400).json({ error: errorMessage });
}
try { try {
// Build where clause for Case model
const caseWhereClause = {};
// Handle search parameter
if (search) {
const searchTerm = search.trim();
if (searchTerm.length > 100) {
return res.status(400).json({ error: 'too long search param' });
}
if (searchTerm.length >= 1) {
caseWhereClause[Op.or] = [
{ title: { [Op.like]: `%${searchTerm}%` } },
{ description: { [Op.like]: `%${searchTerm}%` } },
];
}
}
// Handle status filter for RunCase
let statusFilter = undefined;
let runCaseRequired = false;
if (status) {
const statusValues = status
.split(',')
.map((t) => parseInt(t.trim(), 10))
.filter((t) => !isNaN(t));
if (statusValues.length > 0) {
statusFilter = { status: { [Op.in]: statusValues } };
runCaseRequired = true;
}
}
// Handle tag filter
const tagInclude = {
model: Tags,
attributes: ['id', 'name'],
through: { attributes: [] },
};
if (tag) {
const tagIds = tag
.split(',')
.map((t) => parseInt(t.trim(), 10))
.filter((t) => !isNaN(t));
if (tagIds.length > 0) {
tagInclude.where = { id: { [Op.in]: tagIds } };
tagInclude.required = true;
}
}
const cases = await Case.findAll({ const cases = await Case.findAll({
where: caseWhereClause,
include: [ include: [
{ {
model: Folder, model: Folder,
@@ -75,17 +111,12 @@ export default function (sequelize) {
model: RunCase, model: RunCase,
attributes: ['id', 'runId', 'status'], attributes: ['id', 'runId', 'status'],
// Must be 'true' when filtering by status, otherwise all cases are returned. // Must be 'true' when filtering by status, otherwise all cases are returned.
required: statusFilter ? true : false, required: runCaseRequired,
where: { where: {
[Op.and]: [{ runId: runId }, statusFilter], [Op.and]: [{ runId: runId }, statusFilter],
}, },
}, },
{ tagInclude,
model: Tags,
attributes: ['id', 'name'],
through: { attributes: [] },
...(tagFilter ? { where: tagFilter } : {}),
},
], ],
}); });
res.json(cases); res.json(cases);

View File

@@ -340,7 +340,16 @@
"preconditions": "Preconditions", "preconditions": "Preconditions",
"expected_result": "Expected result", "expected_result": "Expected result",
"details_of_the_step": "Details of the step", "details_of_the_step": "Details of the step",
"close": "Close" "close": "Close",
"filter": "Filter",
"clear_all": "Clear all",
"apply": "Apply",
"select_status": "Select status",
"please_save": "Please save changes",
"case_title_or_description": "Test case title or description",
"selected": "Selected",
"tags": "Tags",
"select_tags": "Select tags"
}, },
"Members": { "Members": {
"member_management": "Member Management", "member_management": "Member Management",

View File

@@ -340,7 +340,16 @@
"preconditions": "前提条件", "preconditions": "前提条件",
"expected_result": "期待結果", "expected_result": "期待結果",
"details_of_the_step": "ステップ詳細", "details_of_the_step": "ステップ詳細",
"close": "閉じる" "close": "閉じる",
"filter": "フィルター",
"clear_all": "すべてクリア",
"apply": "適用",
"select_status": "ステータスを選択",
"please_save": "変更を保存してください",
"case_title_or_description": "テストケースのタイトルまたは説明",
"selected": "選択済み",
"tags": "タグ",
"select_tags": "タグを選択"
}, },
"Members": { "Members": {
"member_management": "メンバー管理", "member_management": "メンバー管理",

View File

@@ -340,7 +340,16 @@
"preconditions": "Pré-condições", "preconditions": "Pré-condições",
"expected_result": "Resultado esperado", "expected_result": "Resultado esperado",
"details_of_the_step": "Detalhes do passo", "details_of_the_step": "Detalhes do passo",
"close": "Fechar" "close": "Fechar",
"filter": "Filtrar",
"clear_all": "Limpar tudo",
"apply": "Aplicar",
"select_status": "Selecionar status",
"please_save": "Por favor, salve as alterações",
"case_title_or_description": "Título ou descrição do caso de teste",
"selected": "Selecionado",
"tags": "Tags",
"select_tags": "Selecionar tags"
}, },
"Members": { "Members": {
"member_management": "Gerenciamento de Membros", "member_management": "Gerenciamento de Membros",

View File

@@ -0,0 +1,19 @@
import { Circle, CircleCheck, CircleDashed, CircleX, CircleSlash2 } from 'lucide-react';
type Props = {
uid: string;
};
export default function RunCaseStatus({ uid }: Props) {
if (uid === 'untested') {
return <Circle size={16} color="#d4d4d8" />;
} else if (uid === 'passed') {
return <CircleCheck size={16} color="#17c964" />;
} else if (uid === 'retest') {
return <CircleDashed size={16} color="#f5a524" />;
} else if (uid === 'failed') {
return <CircleX size={16} color="#f31260" />;
} else if (uid === 'skipped') {
return <CircleSlash2 size={16} color="#52525b" />;
}
}

View File

@@ -15,6 +15,9 @@ import {
DropdownItem, DropdownItem,
addToast, addToast,
Badge, Badge,
Popover,
PopoverContent,
PopoverTrigger,
} from '@heroui/react'; } from '@heroui/react';
import { import {
Save, Save,
@@ -29,6 +32,7 @@ import {
FileJson, FileJson,
ChevronRight, ChevronRight,
Folder, Folder,
Filter,
} from 'lucide-react'; } from 'lucide-react';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { NodeApi, Tree } from 'react-arborist'; import { NodeApi, Tree } from 'react-arborist';
@@ -44,6 +48,7 @@ import {
import { fetchFolders } from '../../folders/foldersControl'; import { fetchFolders } from '../../folders/foldersControl';
import RunProgressChart from './RunPregressDonutChart'; import RunProgressChart from './RunPregressDonutChart';
import TestCaseSelector from './TestCaseSelector'; import TestCaseSelector from './TestCaseSelector';
import TestRunFilter from './TestRunFilter';
import { useRouter } from '@/src/i18n/routing'; import { useRouter } from '@/src/i18n/routing';
import { testRunStatus } from '@/config/selection'; import { testRunStatus } from '@/config/selection';
import { RunType, RunStatusCountType, RunMessages } from '@/types/run'; import { RunType, RunStatusCountType, RunMessages } from '@/types/run';
@@ -102,6 +107,9 @@ export default function RunEditor({
const [isNameInvalid] = useState<boolean>(false); const [isNameInvalid] = useState<boolean>(false);
const [isUpdating, setIsUpdating] = useState<boolean>(false); const [isUpdating, setIsUpdating] = useState<boolean>(false);
const [isDirty, setIsDirty] = useState(false); const [isDirty, setIsDirty] = useState(false);
const [searchFilter, setSearchFilter] = useState('');
const [statusFilter, setStatusFilter] = useState<number[]>([]);
const [tagFilter, setTagFilter] = useState<number[]>([]);
const router = useRouter(); const router = useRouter();
useFormGuard(isDirty, messages.areYouSureLeave); useFormGuard(isDirty, messages.areYouSureLeave);
@@ -111,8 +119,15 @@ export default function RunEditor({
setRunStatusCounts(statusCounts); setRunStatusCounts(statusCounts);
}; };
const initTestCases = async () => { const initTestCases = async (search?: string, status?: string[], tag?: string[]) => {
const casesData = await fetchProjectCases(tokenContext.token.access_token, Number(projectId), Number(runId)); const casesData = await fetchProjectCases(
tokenContext.token.access_token,
Number(projectId),
Number(runId),
search,
status,
tag
);
casesData.forEach((testCase: CaseType) => { casesData.forEach((testCase: CaseType) => {
if (testCase.RunCases && testCase.RunCases.length > 0) { if (testCase.RunCases && testCase.RunCases.length > 0) {
testCase.RunCases[0].editState = 'notChanged'; testCase.RunCases[0].editState = 'notChanged';
@@ -200,6 +215,29 @@ export default function RunEditor({
setIsDirty(false); setIsDirty(false);
}; };
// **************************************************************************
// Filter
// **************************************************************************
const [showFilter, setShowFilter] = useState(false);
const [activeFilterNum, setActiveFilterNum] = useState(0);
const onFilterChange = async (search: string, status: number[], tag: number[]) => {
if (isDirty) {
addToast({
title: 'Error',
color: 'danger',
description: messages.pleaseSave,
});
return;
}
setSearchFilter(search);
setStatusFilter(status);
setTagFilter(tag);
setActiveFilterNum((search ? 1 : 0) + (status.length > 0 ? 1 : 0) + (tag.length > 0 ? 1 : 0));
await initTestCases(search, status.map(String), tag.map(String));
};
return ( return (
<> <>
<div className="border-b-1 dark:border-neutral-700 w-full p-3 flex items-center justify-between"> <div className="border-b-1 dark:border-neutral-700 w-full p-3 flex items-center justify-between">
@@ -217,6 +255,41 @@ export default function RunEditor({
<h3 className="font-bold ms-2">{testRun.name}</h3> <h3 className="font-bold ms-2">{testRun.name}</h3>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<Popover placement="bottom" isOpen={showFilter} onOpenChange={(open) => setShowFilter(open)}>
<Badge
color="danger"
content={activeFilterNum}
isInvisible={activeFilterNum === 0}
shape="circle"
placement="top-left"
>
<PopoverTrigger>
<Button
startContent={<Filter size={16} />}
endContent={<ChevronDown size={16} />}
size="sm"
variant="bordered"
className="me-2"
>
{messages.filter}
</Button>
</PopoverTrigger>
</Badge>
<PopoverContent>
<TestRunFilter
messages={messages}
testRunCaseStatusMessages={testRunCaseStatusMessages}
activeSearchFilter={searchFilter}
activeStatusFilters={statusFilter}
activeTagFilters={tagFilter}
projectId={projectId}
onFilterChange={(newTitleFilter, newStatusFilters, newTagFilters) => {
setShowFilter(false);
onFilterChange(newTitleFilter, newStatusFilters, newTagFilters);
}}
/>
</PopoverContent>
</Popover>
<Dropdown placement="bottom-end"> <Dropdown placement="bottom-end">
<DropdownTrigger> <DropdownTrigger>
<Button <Button

View File

@@ -14,19 +14,9 @@ import {
Selection, Selection,
SortDescriptor, SortDescriptor,
} from '@heroui/react'; } from '@heroui/react';
import { import { ChevronDown, MoveDiagonal, MoreVertical, CopyPlus, CopyMinus } from 'lucide-react';
ChevronDown,
MoveDiagonal,
MoreVertical,
CopyPlus,
CopyMinus,
Circle,
CircleCheck,
CircleDashed,
CircleX,
CircleSlash2,
} from 'lucide-react';
import TestCaseDetailDialog from './TestCaseDetailDialog'; import TestCaseDetailDialog from './TestCaseDetailDialog';
import RunCaseStatus from './RunCaseStatus';
import { testRunCaseStatus } from '@/config/selection'; import { testRunCaseStatus } from '@/config/selection';
import { CaseType } from '@/types/case'; import { CaseType } from '@/types/case';
import { RunMessages } from '@/types/run'; import { RunMessages } from '@/types/run';
@@ -103,20 +93,6 @@ export default function TestCaseSelector({
const notIncludedCaseClass = 'text-neutral-200 dark:text-neutral-600'; const notIncludedCaseClass = 'text-neutral-200 dark:text-neutral-600';
const renderStatusIcon = (uid: string) => {
if (uid === 'untested') {
return <Circle size={16} color="#d4d4d8" />;
} else if (uid === 'passed') {
return <CircleCheck size={16} color="#17c964" />;
} else if (uid === 'retest') {
return <CircleDashed size={16} color="#f5a524" />;
} else if (uid === 'failed') {
return <CircleX size={16} color="#f31260" />;
} else if (uid === 'skipped') {
return <CircleSlash2 size={16} color="#52525b" />;
}
};
const isCaseIncluded = (testCase: CaseType) => { const isCaseIncluded = (testCase: CaseType) => {
let isIncluded = false; let isIncluded = false;
if (testCase.RunCases && testCase.RunCases.length > 0) { if (testCase.RunCases && testCase.RunCases.length > 0) {
@@ -161,7 +137,7 @@ export default function TestCaseSelector({
size="sm" size="sm"
variant="light" variant="light"
isDisabled={!isIncluded} isDisabled={!isIncluded}
startContent={isIncluded && renderStatusIcon(testRunCaseStatus[runStatus].uid)} startContent={isIncluded && <RunCaseStatus uid={testRunCaseStatus[runStatus].uid} />}
endContent={isIncluded && <ChevronDown size={16} />} endContent={isIncluded && <ChevronDown size={16} />}
> >
<span className="w-12"> <span className="w-12">
@@ -173,7 +149,7 @@ export default function TestCaseSelector({
{testRunCaseStatus.map((runCaseStatus, index) => ( {testRunCaseStatus.map((runCaseStatus, index) => (
<DropdownItem <DropdownItem
key={runCaseStatus.uid} key={runCaseStatus.uid}
startContent={renderStatusIcon(runCaseStatus.uid)} startContent={<RunCaseStatus uid={runCaseStatus.uid} />}
onPress={() => onChangeStatus(testCase.id, index)} onPress={() => onChangeStatus(testCase.id, index)}
> >
{testRunCaseStatusMessages[runCaseStatus.uid]} {testRunCaseStatusMessages[runCaseStatus.uid]}

View File

@@ -0,0 +1,200 @@
import { useState, useEffect, useContext } from 'react';
import {
Button,
Dropdown,
DropdownTrigger,
DropdownMenu,
DropdownItem,
Selection,
Input,
addToast,
} from '@heroui/react';
import { SearchIcon, ChevronDown } from 'lucide-react';
import RunCaseStatus from './RunCaseStatus';
import { RunMessages } from '@/types/run';
import { testRunCaseStatus } from '@/config/selection';
import { TagType } from '@/types/tag';
import { fetchTags } from '@/utils/tagsControls';
import { TokenContext } from '@/utils/TokenProvider';
import { logError } from '@/utils/errorHandler';
import { TestRunCaseStatusMessages } from '@/types/status';
type TestRunFilterProps = {
messages: RunMessages;
testRunCaseStatusMessages: TestRunCaseStatusMessages;
projectId: string;
activeSearchFilter: string;
activeStatusFilters: number[];
activeTagFilters: number[];
onFilterChange: (search: string, statusIndices: number[], tagIds: number[]) => void;
};
type Tag = Pick<TagType, 'id' | 'name'>;
export default function TestRunFilter({
messages,
testRunCaseStatusMessages,
onFilterChange,
projectId,
activeSearchFilter = '',
activeStatusFilters = [],
activeTagFilters = [],
}: TestRunFilterProps) {
const tokenContext = useContext(TokenContext);
const [search, setSearch] = useState<string>('');
const [selectedStatuses, setSelectedStatuses] = useState<Selection>(new Set([]));
const [selectedTags, setSelectedTags] = useState<Selection>(new Set([]));
const [tags, setTags] = useState<Tag[]>([]);
useEffect(() => {
const fetchDataEffect = async () => {
try {
const tagsResponse = (await fetchTags(tokenContext.token.access_token, projectId)) || [];
setTags(tagsResponse);
} catch (error) {
logError('Error fetching case tags', error);
addToast({ title: 'Error', description: 'Error fetching tags', color: 'danger' });
}
};
fetchDataEffect();
}, [projectId, tokenContext.token.access_token]);
useEffect(() => {
setSearch(activeSearchFilter || '');
}, [activeSearchFilter]);
useEffect(() => {
if (activeStatusFilters && activeStatusFilters.length > 0) {
const activeKeys = activeStatusFilters.map((index) => testRunCaseStatus[index]?.uid).filter((uid) => !!uid);
setSelectedStatuses(new Set(activeKeys as Iterable<string>));
} else {
setSelectedStatuses(new Set([]));
}
}, [activeStatusFilters]);
useEffect(() => {
if (activeTagFilters && activeTagFilters.length > 0) {
const activeKeys = activeTagFilters.map((id) => id.toString());
setSelectedTags(new Set(activeKeys));
} else {
setSelectedTags(new Set([]));
}
}, [activeTagFilters]);
const handleStatusSelectionChange = (keys: Selection) => {
setSelectedStatuses(keys);
};
const handleApplyFilter = () => {
let statusIndices: number[] = [];
if (selectedStatuses !== 'all' && selectedStatuses.size > 0) {
statusIndices = Array.from(selectedStatuses)
.map((key) => testRunCaseStatus.findIndex((status) => status.uid === key))
.filter((index) => index !== -1);
}
let tagIds: number[] = [];
if (selectedTags !== 'all' && selectedTags.size > 0) {
tagIds = Array.from(selectedTags)
.map((key) => parseInt(key as string))
.filter((id) => !isNaN(id));
}
onFilterChange(search, statusIndices, tagIds);
};
const handleClearFilter = () => {
setSearch('');
setSelectedStatuses(new Set([]));
setSelectedTags(new Set([]));
onFilterChange('', [], []);
};
return (
<div className="p-3">
<div className="mb-3 space-y-1">
<h3 className="text-default-500 text-small">{messages.caseTitleOrDescription}</h3>
<Input
variant="bordered"
classNames={{
base: 'max-w-full h-8',
mainWrapper: 'h-full',
input: 'text-small',
}}
size="sm"
startContent={<SearchIcon size={18} />}
type="search"
value={search}
onValueChange={setSearch}
maxLength={100}
/>
</div>
<div className="mb-3 flex justify-between gap-2">
<div className="flex-col space-y-1">
<h3 className="text-default-500 text-small">{messages.status}</h3>
<Dropdown>
<DropdownTrigger>
<Button size="sm" variant="bordered" className="w-32" endContent={<ChevronDown size={16} />}>
{selectedStatuses === 'all' || selectedStatuses.size === 0
? messages.selectStatus
: `${selectedStatuses.size} ${messages.selected}`}
</Button>
</DropdownTrigger>
<DropdownMenu
aria-label="Status filter"
selectionMode="multiple"
selectedKeys={selectedStatuses}
onSelectionChange={handleStatusSelectionChange}
>
{testRunCaseStatus.map((status) => (
<DropdownItem
key={status.uid}
textValue={testRunCaseStatusMessages[status.uid]}
startContent={<RunCaseStatus uid={status.uid} />}
className="flex items-center"
>
{testRunCaseStatusMessages[status.uid]}
</DropdownItem>
))}
</DropdownMenu>
</Dropdown>
</div>
<div className="flex-col space-y-1">
<h3 className="text-default-500 text-small">{messages.tags}</h3>
<Dropdown>
<DropdownTrigger>
<Button size="sm" variant="bordered" className="w-32" endContent={<ChevronDown size={16} />}>
{selectedTags === 'all' || selectedTags.size === 0
? messages.selectTags
: `${selectedTags.size} ${messages.selected}`}
</Button>
</DropdownTrigger>
<DropdownMenu
className="max-h-[50vh] overflow-y-auto"
aria-label="Tag filter"
selectionMode="multiple"
selectedKeys={selectedTags}
onSelectionChange={setSelectedTags}
>
{tags.map((tag) => (
<DropdownItem key={tag.id.toString()} textValue={tag.name}>
<span className="text-sm">{tag.name}</span>
</DropdownItem>
))}
</DropdownMenu>
</Dropdown>
</div>
</div>
<div className="flex justify-end">
<Button className="me-2" size="sm" variant="light" onPress={handleClearFilter}>
{messages.clearAll}
</Button>
<Button size="sm" variant="solid" color="primary" onPress={handleApplyFilter}>
{messages.apply}
</Button>
</div>
</div>
);
}

View File

@@ -35,6 +35,15 @@ export default function Page({ params }: { params: { projectId: string; runId: s
expectedResult: t('expected_result'), expectedResult: t('expected_result'),
detailsOfTheStep: t('details_of_the_step'), detailsOfTheStep: t('details_of_the_step'),
close: t('close'), close: t('close'),
filter: t('filter'),
clearAll: t('clear_all'),
apply: t('apply'),
selectStatus: t('select_status'),
pleaseSave: t('please_save'),
caseTitleOrDescription: t('case_title_or_description'),
selected: t('selected'),
tags: t('tags'),
selectTags: t('select_tags'),
}; };
const rst = useTranslations('RunStatus'); const rst = useTranslations('RunStatus');

View File

@@ -315,8 +315,31 @@ async function updateRunCases(jwt: string, runId: number, testCases: CaseType[])
} }
} }
async function fetchProjectCases(jwt: string, projectId: number, runId: number) { async function fetchProjectCases(
const url = `${apiServer}/cases/byproject?projectId=${projectId}&runId=${runId}`; jwt: string,
projectId: number,
runId: number,
search?: string,
status?: string[],
tag?: string[]
) {
const queryParams = [`projectId=${projectId}&runId=${runId}`];
if (search) {
queryParams.push(`search=${search}`);
}
if (status && status.length > 0) {
queryParams.push(`status=${status.join(',')}`);
}
if (tag && tag.length > 0) {
queryParams.push(`tag=${tag.join(',')}`);
}
const query = `?${queryParams.join('&')}`;
const url = `${apiServer}/cases/byproject${query}`;
try { try {
const response = await fetch(url, { const response = await fetch(url, {

View File

@@ -80,6 +80,15 @@ type RunMessages = {
expectedResult: string; expectedResult: string;
detailsOfTheStep: string; detailsOfTheStep: string;
close: string; close: string;
filter: string;
clearAll: string;
apply: string;
selectStatus: string;
pleaseSave: string;
caseTitleOrDescription: string;
selected: string;
tags: string;
selectTags: string;
}; };
export type { RunType, RunCaseType, RunStatusCountType, ProgressSeriesType, RunsMessages, RunMessages }; export type { RunType, RunCaseType, RunStatusCountType, ProgressSeriesType, RunsMessages, RunMessages };