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

@@ -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,
addToast,
Badge,
Popover,
PopoverContent,
PopoverTrigger,
} from '@heroui/react';
import {
Save,
@@ -29,6 +32,7 @@ import {
FileJson,
ChevronRight,
Folder,
Filter,
} from 'lucide-react';
import { useTheme } from 'next-themes';
import { NodeApi, Tree } from 'react-arborist';
@@ -44,6 +48,7 @@ import {
import { fetchFolders } from '../../folders/foldersControl';
import RunProgressChart from './RunPregressDonutChart';
import TestCaseSelector from './TestCaseSelector';
import TestRunFilter from './TestRunFilter';
import { useRouter } from '@/src/i18n/routing';
import { testRunStatus } from '@/config/selection';
import { RunType, RunStatusCountType, RunMessages } from '@/types/run';
@@ -102,6 +107,9 @@ export default function RunEditor({
const [isNameInvalid] = useState<boolean>(false);
const [isUpdating, setIsUpdating] = useState<boolean>(false);
const [isDirty, setIsDirty] = useState(false);
const [searchFilter, setSearchFilter] = useState('');
const [statusFilter, setStatusFilter] = useState<number[]>([]);
const [tagFilter, setTagFilter] = useState<number[]>([]);
const router = useRouter();
useFormGuard(isDirty, messages.areYouSureLeave);
@@ -111,8 +119,15 @@ export default function RunEditor({
setRunStatusCounts(statusCounts);
};
const initTestCases = async () => {
const casesData = await fetchProjectCases(tokenContext.token.access_token, Number(projectId), Number(runId));
const initTestCases = async (search?: string, status?: string[], tag?: string[]) => {
const casesData = await fetchProjectCases(
tokenContext.token.access_token,
Number(projectId),
Number(runId),
search,
status,
tag
);
casesData.forEach((testCase: CaseType) => {
if (testCase.RunCases && testCase.RunCases.length > 0) {
testCase.RunCases[0].editState = 'notChanged';
@@ -200,6 +215,29 @@ export default function RunEditor({
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 (
<>
<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>
</div>
<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">
<DropdownTrigger>
<Button

View File

@@ -14,19 +14,9 @@ import {
Selection,
SortDescriptor,
} from '@heroui/react';
import {
ChevronDown,
MoveDiagonal,
MoreVertical,
CopyPlus,
CopyMinus,
Circle,
CircleCheck,
CircleDashed,
CircleX,
CircleSlash2,
} from 'lucide-react';
import { ChevronDown, MoveDiagonal, MoreVertical, CopyPlus, CopyMinus } from 'lucide-react';
import TestCaseDetailDialog from './TestCaseDetailDialog';
import RunCaseStatus from './RunCaseStatus';
import { testRunCaseStatus } from '@/config/selection';
import { CaseType } from '@/types/case';
import { RunMessages } from '@/types/run';
@@ -103,20 +93,6 @@ export default function TestCaseSelector({
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) => {
let isIncluded = false;
if (testCase.RunCases && testCase.RunCases.length > 0) {
@@ -161,7 +137,7 @@ export default function TestCaseSelector({
size="sm"
variant="light"
isDisabled={!isIncluded}
startContent={isIncluded && renderStatusIcon(testRunCaseStatus[runStatus].uid)}
startContent={isIncluded && <RunCaseStatus uid={testRunCaseStatus[runStatus].uid} />}
endContent={isIncluded && <ChevronDown size={16} />}
>
<span className="w-12">
@@ -173,7 +149,7 @@ export default function TestCaseSelector({
{testRunCaseStatus.map((runCaseStatus, index) => (
<DropdownItem
key={runCaseStatus.uid}
startContent={renderStatusIcon(runCaseStatus.uid)}
startContent={<RunCaseStatus uid={runCaseStatus.uid} />}
onPress={() => onChangeStatus(testCase.id, index)}
>
{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'),
detailsOfTheStep: t('details_of_the_step'),
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');

View File

@@ -315,8 +315,31 @@ async function updateRunCases(jwt: string, runId: number, testCases: CaseType[])
}
}
async function fetchProjectCases(jwt: string, projectId: number, runId: number) {
const url = `${apiServer}/cases/byproject?projectId=${projectId}&runId=${runId}`;
async function fetchProjectCases(
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 {
const response = await fetch(url, {