From 475fa1ced77af1b2318b414129066140fd670000 Mon Sep 17 00:00:00 2001 From: kimatata <117462761+kimatata@users.noreply.github.com> Date: Sat, 10 Jan 2026 18:17:07 +0900 Subject: [PATCH 1/7] feat: display tags in test run page (#378) --- backend/routes/runs/download.js | 18 ++++++++++++- backend/routes/runs/download.test.js | 20 ++++++++++++++ .../runs/[runId]/TestCaseDetailDialog.tsx | 27 ++++++++++++++++++- .../runs/[runId]/TestCaseSelector.tsx | 16 +++++++++++ 4 files changed, 79 insertions(+), 2 deletions(-) diff --git a/backend/routes/runs/download.js b/backend/routes/runs/download.js index 15a6b58..1a30ca5 100644 --- a/backend/routes/runs/download.js +++ b/backend/routes/runs/download.js @@ -7,6 +7,7 @@ import defineRun from '../../models/runs.js'; import defineRunCase from '../../models/runCases.js'; import defineCase from '../../models/cases.js'; import defineFolder from '../../models/folders.js'; +import defineTag from '../../models/tags.js'; import authMiddleware from '../../middleware/auth.js'; import visibilityMiddleware from '../../middleware/verifyVisible.js'; import { testRunCaseStatus, testRunStatus, priorities, testTypes, automationStatus } from '../../config/enums.js'; @@ -19,8 +20,11 @@ export default function (sequelize) { const RunCase = defineRunCase(sequelize, DataTypes); const Case = defineCase(sequelize, DataTypes); const Folder = defineFolder(sequelize, DataTypes); + const Tags = defineTag(sequelize, DataTypes); RunCase.belongsTo(Case, { foreignKey: 'caseId' }); + Case.belongsToMany(Tags, { through: 'caseTags', foreignKey: 'caseId', otherKey: 'tagId' }); + Tags.belongsToMany(Case, { through: 'caseTags', foreignKey: 'tagId', otherKey: 'caseId' }); router.get('/download/:runId', verifySignedIn, verifyProjectVisibleFromRunId, async (req, res) => { const { runId } = req.params; @@ -38,7 +42,18 @@ export default function (sequelize) { const runCases = await RunCase.findAll({ where: { runId }, - include: [{ model: Case }], + include: [ + { + model: Case, + include: [ + { + model: Tags, + attributes: ['id', 'name'], + through: { attributes: [] }, + }, + ], + }, + ], }); if (type === 'xml') { @@ -104,6 +119,7 @@ export default function (sequelize) { priority: priorities[rc.Case.priority] || rc.Case.priority, type: testTypes[rc.Case.type] || rc.Case.type, automationStatus: automationStatus[rc.Case.automationStatus] || rc.Case.automationStatus, + tags: rc.Case.Tags && rc.Case.Tags.length > 0 ? rc.Case.Tags.map((tag) => tag.name).join(', ') : '', status: testRunCaseStatus[rc.status] || rc.status, })); diff --git a/backend/routes/runs/download.test.js b/backend/routes/runs/download.test.js index 714e1c7..1c643a8 100644 --- a/backend/routes/runs/download.test.js +++ b/backend/routes/runs/download.test.js @@ -59,6 +59,7 @@ vi.mock('../../models/runCases.js', () => ({ // mock defineCase const mockCase = { belongsTo: vi.fn(), + belongsToMany: vi.fn(), }; vi.mock('../../models/cases.js', () => ({ default: () => mockCase, @@ -72,6 +73,14 @@ vi.mock('../../models/folders.js', () => ({ default: () => mockFolder, })); +// mock defineTag +const mockTags = { + belongsToMany: vi.fn(), +}; +vi.mock('../../models/tags.js', () => ({ + default: () => mockTags, +})); + describe('GET /download/:runId with type=csv', () => { let app; const sequelize = new Sequelize({ @@ -105,6 +114,10 @@ describe('GET /download/:runId with type=csv', () => { priority: 0, // critical type: 4, // functional automationStatus: 0, // automated + Tags: [ + { id: 1, name: 'tag1' }, + { id: 2, name: 'tag2' }, + ], }, }, { @@ -119,6 +132,7 @@ describe('GET /download/:runId with type=csv', () => { priority: 1, // high type: 1, // security automationStatus: 1, // automation-not-required + Tags: [], }, }, { @@ -133,6 +147,7 @@ describe('GET /download/:runId with type=csv', () => { priority: 2, // medium type: 2, // performance automationStatus: 2, // cannot-be-automated + Tags: [{ id: 3, name: 'tag3' }], }, }, ]); @@ -162,6 +177,11 @@ describe('GET /download/:runId with type=csv', () => { expect(csvContent).toContain('inProgress'); expect(csvContent).toContain('underReview'); + // Check that tags are included in the CSV + expect(csvContent).toContain('tags'); + expect(csvContent).toContain('tag1, tag2'); + expect(csvContent).toContain('tag3'); + // Ensure numeric values are not present (except for id which should be numeric) const lines = csvContent.split('\n'); const dataLines = lines.slice(1).filter((line) => line.trim()); // Skip header diff --git a/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/TestCaseDetailDialog.tsx b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/TestCaseDetailDialog.tsx index 0a7151b..aee94c1 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/TestCaseDetailDialog.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/TestCaseDetailDialog.tsx @@ -1,5 +1,15 @@ import { useState, useEffect, useContext } from 'react'; -import { Button, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Avatar, Textarea } from '@heroui/react'; +import { + Button, + Modal, + ModalContent, + ModalHeader, + ModalBody, + ModalFooter, + Avatar, + Textarea, + Chip, +} from '@heroui/react'; import { testTypes, templates } from '@/config/selection'; import { RunMessages } from '@/types/run'; import { CaseType, StepType } from '@/types/case'; @@ -103,6 +113,21 @@ export default function TestCaseDetailDialog({
{testTypeMessages[testTypes[testCase.type].uid]}
+ +
+

{messages.tags}

+
+ {testCase.Tags && testCase.Tags.length > 0 ? ( + testCase.Tags.map((tag) => ( + + {tag.name} + + )) + ) : ( + - + )} +
+
{templates[testCase.template].uid === 'text' ? ( diff --git a/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/TestCaseSelector.tsx b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/TestCaseSelector.tsx index 42b81b7..9373ac6 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/TestCaseSelector.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/TestCaseSelector.tsx @@ -13,6 +13,7 @@ import { DropdownItem, Selection, SortDescriptor, + Chip, } from '@heroui/react'; import { ChevronDown, MoveDiagonal, MoreVertical, CopyPlus, CopyMinus } from 'lucide-react'; import TestCaseDetailDialog from './TestCaseDetailDialog'; @@ -56,6 +57,7 @@ export default function TestCaseSelector({ { name: messages.id, uid: 'id', sortable: true }, { name: messages.title, uid: 'title', sortable: true }, { name: messages.priority, uid: 'priority', sortable: true }, + { name: messages.tags, uid: 'tags', sortable: false }, { name: messages.status, uid: 'runStatus', sortable: true }, { name: messages.actions, uid: 'actions' }, ]; @@ -129,6 +131,20 @@ export default function TestCaseSelector({ ); + case 'tags': + return ( +
+ {testCase.Tags && testCase.Tags.length > 0 ? ( + testCase.Tags.map((tag) => ( + + {tag.name} + + )) + ) : ( + - + )} +
+ ); case 'runStatus': return ( From ab348fc4e5d0aa5b8019d47c1c50ac8ad24356ec Mon Sep 17 00:00:00 2001 From: kimatata <117462761+kimatata@users.noreply.github.com> Date: Sat, 10 Jan 2026 19:31:47 +0900 Subject: [PATCH 2/7] feat: name export file with meaningful title instead of id (#379) --- backend/config/contentDisposition.js | 16 ++++++++++++++++ backend/routes/cases/download.js | 12 +++++++++++- backend/routes/cases/download.test.js | 12 ++++++++++-- backend/routes/runs/download.js | 8 ++++++-- backend/routes/runs/download.test.js | 2 +- .../projects/[projectId]/runs/runsControl.ts | 6 +++++- frontend/utils/caseControl.ts | 6 +++++- frontend/utils/request.ts | 17 +++++++++++++++++ 8 files changed, 71 insertions(+), 8 deletions(-) create mode 100644 backend/config/contentDisposition.js create mode 100644 frontend/utils/request.ts diff --git a/backend/config/contentDisposition.js b/backend/config/contentDisposition.js new file mode 100644 index 0000000..a1c59f8 --- /dev/null +++ b/backend/config/contentDisposition.js @@ -0,0 +1,16 @@ +export function toSafeFileName(fileName) { + const s = String(fileName ?? '') + // eslint-disable-next-line no-control-regex + .replace(/[\u0000-\u001F\u007F]/g, '') + .replace(/[<>:"/\\|?*]/g, '_') + .replace(/\s+/g, ' ') + .trim(); + + return s; +} + +export function contentDisposition(filename) { + const fallback = filename.replace(/[^\x20-\x7E]/g, '_').replace(/"/g, "'"); + const encoded = encodeURIComponent(filename); + return `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`; +} diff --git a/backend/routes/cases/download.js b/backend/routes/cases/download.js index 3714968..53f6c6a 100644 --- a/backend/routes/cases/download.js +++ b/backend/routes/cases/download.js @@ -7,6 +7,7 @@ import defineStep from '../../models/steps.js'; import defineFolder from '../../models/folders.js'; import authMiddleware from '../../middleware/auth.js'; import visibilityMiddleware from '../../middleware/verifyVisible.js'; +import { contentDisposition, toSafeFileName } from '../../config/contentDisposition.js'; import { testRunStatus, priorities, testTypes, automationStatus, templates } from '../../config/enums.js'; export default function (sequelize) { @@ -31,6 +32,16 @@ export default function (sequelize) { } try { + const folder = await Folder.findByPk(folderId); + if (!folder) { + return res.status(404).send('Folder not found'); + } + + const folderName = toSafeFileName(folder.name); + const filename = `${folderName}.${type}`; + res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition'); + res.setHeader('Content-Disposition', contentDisposition(filename)); + const cases = await Case.findAll({ attributes: { exclude: ['createdAt', 'updatedAt', 'caseSteps'] }, include: [ @@ -74,7 +85,6 @@ export default function (sequelize) { }); res.setHeader('Content-Type', 'text/csv; charset=utf-8'); - res.setHeader('Content-Disposition', `attachment; filename=cases_folder_${folderId}.csv`); return res.send(csv); } diff --git a/backend/routes/cases/download.test.js b/backend/routes/cases/download.test.js index e8a1b56..06f638e 100644 --- a/backend/routes/cases/download.test.js +++ b/backend/routes/cases/download.test.js @@ -34,7 +34,13 @@ vi.mock('../../middleware/verifyVisible.js', () => ({ }), })); -// mock defineCase +const mockFolder = { + findByPk: vi.fn(), +}; +vi.mock('../../models/folders.js', () => ({ + default: () => mockFolder, +})); + const mockCase = { findAll: vi.fn(), belongsToMany: vi.fn(), @@ -66,6 +72,8 @@ describe('GET /download with type=csv', () => { }); it('should return CSV with human-readable labels for state, priority, type, automationStatus, and template', async () => { + mockFolder.findByPk.mockResolvedValue({ id: 1, name: 'Test Folder' }); + mockCase.findAll.mockResolvedValue([ { id: 1, @@ -112,7 +120,7 @@ describe('GET /download with type=csv', () => { expect(response.status).toBe(200); expect(response.headers['content-type']).toBe('text/csv; charset=utf-8'); - expect(response.headers['content-disposition']).toBe('attachment; filename=cases_folder_1.csv'); + expect(response.headers['content-disposition']).toContain('attachment; filename="Test Folder.csv"'); const csvContent = response.text; diff --git a/backend/routes/runs/download.js b/backend/routes/runs/download.js index 1a30ca5..d2d25b0 100644 --- a/backend/routes/runs/download.js +++ b/backend/routes/runs/download.js @@ -10,6 +10,7 @@ import defineFolder from '../../models/folders.js'; import defineTag from '../../models/tags.js'; import authMiddleware from '../../middleware/auth.js'; import visibilityMiddleware from '../../middleware/verifyVisible.js'; +import { contentDisposition, toSafeFileName } from '../../config/contentDisposition.js'; import { testRunCaseStatus, testRunStatus, priorities, testTypes, automationStatus } from '../../config/enums.js'; export default function (sequelize) { @@ -40,6 +41,11 @@ export default function (sequelize) { return res.status(404).send('Run not found'); } + const runName = toSafeFileName(run.name); + const filename = `${runName}.${type}`; + res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition'); + res.setHeader('Content-Disposition', contentDisposition(filename)); + const runCases = await RunCase.findAll({ where: { runId }, include: [ @@ -107,7 +113,6 @@ export default function (sequelize) { const xmlString = xml.end({ prettyPrint: true }); res.setHeader('Content-Type', 'application/xml'); - res.setHeader('Content-Disposition', `attachment; filename=run_${runId}.xml`); return res.send(xmlString); } else if (type === 'json') { return res.json(runCases); @@ -129,7 +134,6 @@ export default function (sequelize) { }); res.setHeader('Content-Type', 'text/csv; charset=utf-8'); - res.setHeader('Content-Disposition', `attachment; filename=run_${runId}.csv`); return res.send(csv); } diff --git a/backend/routes/runs/download.test.js b/backend/routes/runs/download.test.js index 1c643a8..d701737 100644 --- a/backend/routes/runs/download.test.js +++ b/backend/routes/runs/download.test.js @@ -156,7 +156,7 @@ describe('GET /download/:runId with type=csv', () => { expect(response.status).toBe(200); expect(response.headers['content-type']).toBe('text/csv; charset=utf-8'); - expect(response.headers['content-disposition']).toBe('attachment; filename=run_1.csv'); + expect(response.headers['content-disposition']).toContain('attachment; filename="Test Run.csv"'); const csvContent = response.text; diff --git a/frontend/src/app/[locale]/projects/[projectId]/runs/runsControl.ts b/frontend/src/app/[locale]/projects/[projectId]/runs/runsControl.ts index 0a30414..faf1f62 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/runs/runsControl.ts +++ b/frontend/src/app/[locale]/projects/[projectId]/runs/runsControl.ts @@ -1,3 +1,4 @@ +import { getFilenameFromContentDisposition } from '@/utils/request'; import { logError } from '@/utils/errorHandler'; import { CaseType } from '@/types/case'; import { RunType, RunCaseType } from '@/types/run'; @@ -149,11 +150,14 @@ async function exportRun(jwt: string, runId: number, type: string) { throw new Error(`HTTP error! Status: ${response.status}`); } + const disposition = response.headers.get('content-disposition'); + const filename = getFilenameFromContentDisposition(disposition) ?? `cases.${type}`; + const blob = await response.blob(); const objectUrl = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = objectUrl; - a.download = `run_${runId}.${type}`; + a.download = filename; document.body.appendChild(a); a.click(); a.remove(); diff --git a/frontend/utils/caseControl.ts b/frontend/utils/caseControl.ts index 75197bf..b4f01bd 100644 --- a/frontend/utils/caseControl.ts +++ b/frontend/utils/caseControl.ts @@ -1,3 +1,4 @@ +import { getFilenameFromContentDisposition } from '@/utils/request'; import { logError } from '@/utils/errorHandler'; import Config from '@/config/config'; const apiServer = Config.apiServer; @@ -221,11 +222,14 @@ async function exportCases(jwt: string, folderId: number, type: string) { throw new Error(`HTTP error! Status: ${response.status}`); } + const disposition = response.headers.get('content-disposition'); + const filename = getFilenameFromContentDisposition(disposition) ?? `cases.${type}`; + const blob = await response.blob(); const objectUrl = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = objectUrl; - a.download = `folder_${folderId}.${type}`; + a.download = filename; document.body.appendChild(a); a.click(); a.remove(); diff --git a/frontend/utils/request.ts b/frontend/utils/request.ts new file mode 100644 index 0000000..2e2b104 --- /dev/null +++ b/frontend/utils/request.ts @@ -0,0 +1,17 @@ +export function getFilenameFromContentDisposition(disposition: string | null): string | null { + if (!disposition) return null; + + const filenameStar = disposition.match(/filename\*\s*=\s*UTF-8''([^;]+)/i); + if (filenameStar?.[1]) { + try { + return decodeURIComponent(filenameStar[1]); + } catch { + // ignore + } + } + + const filename = disposition.match(/filename\s*=\s*"([^"]+)"/i) || disposition.match(/filename\s*=\s*([^;]+)/i); + if (filename?.[1]) return filename[1].trim(); + + return null; +} From 34135209d91f64b014637ac50aa0df5f027ad369 Mon Sep 17 00:00:00 2001 From: kimatata <117462761+kimatata@users.noreply.github.com> Date: Mon, 12 Jan 2026 23:27:34 +0900 Subject: [PATCH 3/7] feat: Test run detail pane (#381) --- frontend/components/Comments.tsx | 13 ++ frontend/components/History.tsx | 13 ++ frontend/components/ResizablePane.tsx | 71 +++++++ frontend/messages/de.json | 3 +- frontend/messages/en.json | 3 +- frontend/messages/ja.json | 3 +- frontend/messages/pt-BR.json | 3 +- frontend/messages/zh-CN.json | 3 +- .../[projectId]/runs/[runId]/RunEditor.tsx | 12 +- .../runs/[runId]/TestCaseDetailDialog.tsx | 197 ------------------ .../runs/[runId]/TestCaseSelector.tsx | 51 ++--- .../[runId]/cases/[caseId]/CaseDetail.tsx | 112 ++++++++++ .../[runId]/cases/[caseId]/DetailPane.tsx | 95 +++++++++ .../runs/[runId]/cases/[caseId]/page.tsx | 61 ++++++ .../[projectId]/runs/[runId]/layout.tsx | 117 +++++++++++ .../[projectId]/runs/[runId]/page.tsx | 104 +-------- frontend/types/run.ts | 23 +- frontend/utils/formGuard.ts | 19 +- 18 files changed, 564 insertions(+), 339 deletions(-) create mode 100644 frontend/components/Comments.tsx create mode 100644 frontend/components/History.tsx create mode 100644 frontend/components/ResizablePane.tsx delete mode 100644 frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/TestCaseDetailDialog.tsx create mode 100644 frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/cases/[caseId]/CaseDetail.tsx create mode 100644 frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/cases/[caseId]/DetailPane.tsx create mode 100644 frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/cases/[caseId]/page.tsx create mode 100644 frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/layout.tsx diff --git a/frontend/components/Comments.tsx b/frontend/components/Comments.tsx new file mode 100644 index 0000000..a6abe03 --- /dev/null +++ b/frontend/components/Comments.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { Alert } from '@heroui/react'; + +export default function Comments() { + return ( +
+
+ +
+
+ ); +} diff --git a/frontend/components/History.tsx b/frontend/components/History.tsx new file mode 100644 index 0000000..3d30f0b --- /dev/null +++ b/frontend/components/History.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { Alert } from '@heroui/react'; + +export default function History() { + return ( +
+
+ +
+
+ ); +} diff --git a/frontend/components/ResizablePane.tsx b/frontend/components/ResizablePane.tsx new file mode 100644 index 0000000..5330539 --- /dev/null +++ b/frontend/components/ResizablePane.tsx @@ -0,0 +1,71 @@ +'use client'; +import { useState, useRef, useEffect, ReactNode } from 'react'; + +type Props = { + leftPane: ReactNode; + rightPane: ReactNode; +}; + +export default function ResizablePanes({ leftPane, rightPane }: Props) { + const [leftWidth, setLeftWidth] = useState(70); // default 70% + const [isDragging, setIsDragging] = useState(false); + const containerRef = useRef(null); + + const minLeftWidth = 40; // left panel min width 40% + const minRightWidth = 15; // right panel min width 15% + + const handleMouseDown = () => { + setIsDragging(true); + }; + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!isDragging || !containerRef.current) return; + + const containerRect = containerRef.current.getBoundingClientRect(); + const newLeftWidth = ((e.clientX - containerRect.left) / containerRect.width) * 100; + + // Clamp the width between min and max + const maxLeftWidth = 100 - minRightWidth; + const clampedWidth = Math.max(minLeftWidth, Math.min(maxLeftWidth, newLeftWidth)); + + setLeftWidth(clampedWidth); + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + if (isDragging) { + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + } + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [isDragging]); + + return ( +
+
+ {leftPane} +
+ +
+ +
+ {rightPane} +
+
+ ); +} diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 65eb4e3..2d81a38 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -353,7 +353,8 @@ "case_title_or_description": "Testfall-Titel oder Beschreibung", "selected": "Ausgewählt", "tags": "Tags", - "select_tags": "Tags auswählen" + "select_tags": "Tags auswählen", + "no_case_selected": "Kein Testfall ausgewählt" }, "Members": { "member_management": "Mitgliederverwaltung", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 83c5ee4..b63160a 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -353,7 +353,8 @@ "case_title_or_description": "Test case title or description", "selected": "Selected", "tags": "Tags", - "select_tags": "Select tags" + "select_tags": "Select tags", + "no_case_selected": "No test case selected" }, "Members": { "member_management": "Member Management", diff --git a/frontend/messages/ja.json b/frontend/messages/ja.json index 9827ae1..6c42c16 100644 --- a/frontend/messages/ja.json +++ b/frontend/messages/ja.json @@ -353,7 +353,8 @@ "case_title_or_description": "テストケースのタイトルまたは説明", "selected": "選択済み", "tags": "タグ", - "select_tags": "タグを選択" + "select_tags": "タグを選択", + "no_case_selected": "テストケースが選択されていません" }, "Members": { "member_management": "メンバー管理", diff --git a/frontend/messages/pt-BR.json b/frontend/messages/pt-BR.json index f372406..7f7c8d6 100644 --- a/frontend/messages/pt-BR.json +++ b/frontend/messages/pt-BR.json @@ -353,7 +353,8 @@ "case_title_or_description": "Título ou descrição do caso de teste", "selected": "Selecionado", "tags": "Tags", - "select_tags": "Selecionar tags" + "select_tags": "Selecionar tags", + "no_case_selected": "Nenhum caso de teste selecionado" }, "Members": { "member_management": "Gerenciamento de Membros", diff --git a/frontend/messages/zh-CN.json b/frontend/messages/zh-CN.json index af2f603..2e77188 100644 --- a/frontend/messages/zh-CN.json +++ b/frontend/messages/zh-CN.json @@ -353,7 +353,8 @@ "case_title_or_description": "测试用例标题或描述", "selected": "已选择", "tags": "标签", - "select_tags": "选择标签" + "select_tags": "选择标签", + "no_case_selected": "未选择测试用例" }, "Members": { "member_management": "成员管理", 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 09da940..08d1af2 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/RunEditor.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/RunEditor.tsx @@ -111,7 +111,9 @@ export default function RunEditor({ const [statusFilter, setStatusFilter] = useState([]); const [tagFilter, setTagFilter] = useState([]); const router = useRouter(); - useFormGuard(isDirty, messages.areYouSureLeave); + + // not show warning when navigating to test case detail page + useFormGuard(isDirty, messages.areYouSureLeave, [`/projects/${projectId}/runs/${runId}/cases/\\d+`]); const fetchRunAndStatusCount = async () => { const { run, statusCounts } = await fetchRun(tokenContext.token.access_token, Number(runId)); @@ -136,7 +138,10 @@ export default function RunEditor({ setTestCases(casesData); }; + const isSignedIn = tokenContext.isSignedIn(); useEffect(() => { + if (!isSignedIn) return; + async function fetchDataEffect() { if (!tokenContext.isSignedIn()) { return; @@ -156,7 +161,7 @@ export default function RunEditor({ fetchDataEffect(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tokenContext]); + }, [isSignedIn]); useEffect(() => { function onFilter() { @@ -502,6 +507,9 @@ export default function RunEditor({
void; - messages: RunMessages; - testTypeMessages: TestTypeMessages; - priorityMessages: PriorityMessages; -}; - -const defaultTestCase = { - id: 0, - title: '', - state: 0, - priority: 0, - type: 0, - automationStatus: 0, - description: '', - template: 0, - preConditions: '', - expectedResults: '', - folderId: 0, -}; - -export default function TestCaseDetailDialog({ - isOpen, - caseId, - onCancel, - messages, - testTypeMessages, - priorityMessages, -}: Props) { - const context = useContext(TokenContext); - const [testCase, setTestCase] = useState(defaultTestCase); - - useEffect(() => { - async function fetchDataEffect() { - if (!context.isSignedIn()) { - return; - } - - if (!caseId || caseId <= 0) { - return; - } - - try { - const data = await fetchCase(context.token.access_token, Number(caseId)); - if (data.Steps && data.Steps.length > 0) { - data.Steps.sort((a: StepType, b: StepType) => { - const stepNoA = a.caseSteps.stepNo; - const stepNoB = b.caseSteps.stepNo; - return stepNoA - stepNoB; - }); - } - - setTestCase(data); - } catch (error: unknown) { - logError('Error fetching case data', error); - } - } - - fetchDataEffect(); - }, [context, caseId]); - - return ( - { - onCancel(); - }} - classNames={{ - header: 'border-b-[1px] border-[#e5e5e5]', - body: 'border-b-[1px] border-[#e5e5e5]', - }} - > - - {testCase.title} - -

{messages.description}

-
{testCase.description}
- -
-
-

{messages.priority}

- -
- -
-

{messages.type}

-
{testTypeMessages[testTypes[testCase.type].uid]}
-
-
- -
-

{messages.tags}

-
- {testCase.Tags && testCase.Tags.length > 0 ? ( - testCase.Tags.map((tag) => ( - - {tag.name} - - )) - ) : ( - - - )} -
-
-
- - {templates[testCase.template].uid === 'text' ? ( - <> -

{messages.testDetail}

-
-
-