feat: Add filter on test run page (#358)
This commit is contained in:
@@ -340,7 +340,16 @@
|
||||
"preconditions": "Preconditions",
|
||||
"expected_result": "Expected result",
|
||||
"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": {
|
||||
"member_management": "Member Management",
|
||||
|
||||
@@ -340,7 +340,16 @@
|
||||
"preconditions": "前提条件",
|
||||
"expected_result": "期待結果",
|
||||
"details_of_the_step": "ステップ詳細",
|
||||
"close": "閉じる"
|
||||
"close": "閉じる",
|
||||
"filter": "フィルター",
|
||||
"clear_all": "すべてクリア",
|
||||
"apply": "適用",
|
||||
"select_status": "ステータスを選択",
|
||||
"please_save": "変更を保存してください",
|
||||
"case_title_or_description": "テストケースのタイトルまたは説明",
|
||||
"selected": "選択済み",
|
||||
"tags": "タグ",
|
||||
"select_tags": "タグを選択"
|
||||
},
|
||||
"Members": {
|
||||
"member_management": "メンバー管理",
|
||||
|
||||
@@ -340,7 +340,16 @@
|
||||
"preconditions": "Pré-condições",
|
||||
"expected_result": "Resultado esperado",
|
||||
"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": {
|
||||
"member_management": "Gerenciamento de Membros",
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -80,6 +80,15 @@ type RunMessages = {
|
||||
expectedResult: string;
|
||||
detailsOfTheStep: 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 };
|
||||
|
||||
Reference in New Issue
Block a user