diff --git a/backend/routes/cases/indexByProjectId.js b/backend/routes/cases/indexByProjectId.js index d9badc7..216c027 100644 --- a/backend/routes/cases/indexByProjectId.js +++ b/backend/routes/cases/indexByProjectId.js @@ -8,7 +8,6 @@ import defineTag from '../../models/tags.js'; import defineRunCase from '../../models/runCases.js'; import authMiddleware from '../../middleware/auth.js'; import visibilityMiddleware from '../../middleware/verifyVisible.js'; -import { testRunCaseStatus } from '../../config/enums.js'; export default function (sequelize) { const Project = defineProject(sequelize, DataTypes); @@ -34,7 +33,7 @@ export default function (sequelize) { verifyProjectVisibleFromProjectId, verifyProjectVisibleFromRunId, async (req, res) => { - const { projectId, runId, status, tag } = req.query; + const { projectId, runId, status, tag, search } = req.query; if (!projectId) { 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' }); } - 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 { + // 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({ + where: caseWhereClause, include: [ { model: Folder, @@ -75,17 +111,12 @@ export default function (sequelize) { model: RunCase, attributes: ['id', 'runId', 'status'], // Must be 'true' when filtering by status, otherwise all cases are returned. - required: statusFilter ? true : false, + required: runCaseRequired, where: { [Op.and]: [{ runId: runId }, statusFilter], }, }, - { - model: Tags, - attributes: ['id', 'name'], - through: { attributes: [] }, - ...(tagFilter ? { where: tagFilter } : {}), - }, + tagInclude, ], }); res.json(cases); diff --git a/frontend/messages/en.json b/frontend/messages/en.json index cb599d3..0419ca1 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -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", diff --git a/frontend/messages/ja.json b/frontend/messages/ja.json index 8285ad3..d194d95 100644 --- a/frontend/messages/ja.json +++ b/frontend/messages/ja.json @@ -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": "メンバー管理", diff --git a/frontend/messages/pt-BR.json b/frontend/messages/pt-BR.json index da1dcf8..1d9133f 100644 --- a/frontend/messages/pt-BR.json +++ b/frontend/messages/pt-BR.json @@ -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", diff --git a/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/RunCaseStatus.tsx b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/RunCaseStatus.tsx new file mode 100644 index 0000000..c49c003 --- /dev/null +++ b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/RunCaseStatus.tsx @@ -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 ; + } else if (uid === 'passed') { + return ; + } else if (uid === 'retest') { + return ; + } else if (uid === 'failed') { + return ; + } else if (uid === 'skipped') { + return ; + } +} diff --git a/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/RunEditor.tsx b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/RunEditor.tsx index 1a291a8..09da940 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/RunEditor.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/RunEditor.tsx @@ -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(false); const [isUpdating, setIsUpdating] = useState(false); const [isDirty, setIsDirty] = useState(false); + const [searchFilter, setSearchFilter] = useState(''); + const [statusFilter, setStatusFilter] = useState([]); + const [tagFilter, setTagFilter] = useState([]); 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 ( <>
@@ -217,6 +255,41 @@ export default function RunEditor({

{testRun.name}

+ setShowFilter(open)}> + + + + + + + { + setShowFilter(false); + onFilterChange(newTitleFilter, newStatusFilters, newTagFilters); + }} + /> + + + + + {testRunCaseStatus.map((status) => ( + } + className="flex items-center" + > + {testRunCaseStatusMessages[status.uid]} + + ))} + + +
+ +
+

{messages.tags}

+ + + + + + {tags.map((tag) => ( + + {tag.name} + + ))} + + +
+ + +
+ + +
+ + ); +} diff --git a/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/page.tsx b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/page.tsx index 9bbeee9..f9626a9 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/page.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/page.tsx @@ -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'); diff --git a/frontend/src/app/[locale]/projects/[projectId]/runs/runsControl.ts b/frontend/src/app/[locale]/projects/[projectId]/runs/runsControl.ts index dd1c2ee..0a30414 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/runs/runsControl.ts +++ b/frontend/src/app/[locale]/projects/[projectId]/runs/runsControl.ts @@ -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, { diff --git a/frontend/types/run.ts b/frontend/types/run.ts index c696fe3..2680c75 100644 --- a/frontend/types/run.ts +++ b/frontend/types/run.ts @@ -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 };