feat: Add filter on test run page (#358)
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "メンバー管理",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
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
|
||||||
|
|||||||
@@ -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]}
|
||||||
|
|||||||
@@ -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'),
|
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');
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user