feat: Test run detail pane (#381)

This commit is contained in:
kimatata
2026-01-12 23:27:34 +09:00
committed by GitHub
parent ab348fc4e5
commit 34135209d9
18 changed files with 564 additions and 339 deletions

View File

@@ -0,0 +1,13 @@
'use client';
import { Alert } from '@heroui/react';
export default function Comments() {
return (
<div className="h-full text-default-500">
<div className="mb-4">
<Alert color="secondary" title="Sorry" description={'Comments function will be implemented'} />
</div>
</div>
);
}

View File

@@ -0,0 +1,13 @@
'use client';
import { Alert } from '@heroui/react';
export default function History() {
return (
<div className="h-full text-default-500">
<div className="mb-4">
<Alert color="secondary" title="Sorry" description={'History function will be implemented'} />
</div>
</div>
);
}

View File

@@ -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<HTMLDivElement>(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 (
<div ref={containerRef} className="flex h-full" style={{ userSelect: isDragging ? 'none' : 'auto' }}>
<div
className="border-r-1 dark:border-neutral-700 overflow-auto"
style={{ width: `${leftWidth}%`, minWidth: `${minLeftWidth}%` }}
>
{leftPane}
</div>
<div
className="w-1 cursor-col-resize hover:bg-primary/50 active:bg-primary transition-colors"
role="separator"
onMouseDown={handleMouseDown}
style={{ flexShrink: 0 }}
/>
<div className="flex-1 overflow-auto" style={{ minWidth: `${minRightWidth}%` }}>
{rightPane}
</div>
</div>
);
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -353,7 +353,8 @@
"case_title_or_description": "テストケースのタイトルまたは説明",
"selected": "選択済み",
"tags": "タグ",
"select_tags": "タグを選択"
"select_tags": "タグを選択",
"no_case_selected": "テストケースが選択されていません"
},
"Members": {
"member_management": "メンバー管理",

View File

@@ -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",

View File

@@ -353,7 +353,8 @@
"case_title_or_description": "测试用例标题或描述",
"selected": "已选择",
"tags": "标签",
"select_tags": "选择标签"
"select_tags": "选择标签",
"no_case_selected": "未选择测试用例"
},
"Members": {
"member_management": "成员管理",

View File

@@ -111,7 +111,9 @@ export default function RunEditor({
const [statusFilter, setStatusFilter] = useState<number[]>([]);
const [tagFilter, setTagFilter] = useState<number[]>([]);
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({
</div>
<div className="w-9/12">
<TestCaseSelector
projectId={projectId}
runId={runId}
locale={locale}
cases={filteredTestCases}
isDisabled={!tokenContext.isProjectReporter(Number(projectId))}
selectedKeys={selectedKeys}

View File

@@ -1,197 +0,0 @@
import { useState, useEffect, useContext } from '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';
import { PriorityMessages } from '@/types/priority';
import TestCasePriority from '@/components/TestCasePriority';
import { TokenContext } from '@/utils/TokenProvider';
import { fetchCase } from '@/utils/caseControl';
import { TestTypeMessages } from '@/types/testType';
import { logError } from '@/utils/errorHandler';
type Props = {
isOpen: boolean;
caseId: number;
onCancel: () => 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<CaseType>(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 (
<Modal
isOpen={isOpen}
size="5xl"
scrollBehavior="outside"
onOpenChange={() => {
onCancel();
}}
classNames={{
header: 'border-b-[1px] border-[#e5e5e5]',
body: 'border-b-[1px] border-[#e5e5e5]',
}}
>
<ModalContent>
<ModalHeader className="flex flex-col gap-1">{testCase.title}</ModalHeader>
<ModalBody>
<p className={'font-bold mt-2'}>{messages.description}</p>
<div>{testCase.description}</div>
<div className="flex my-2">
<div className="w-1/2">
<p className={'font-bold'}>{messages.priority}</p>
<TestCasePriority priorityValue={testCase.priority} priorityMessages={priorityMessages} />
</div>
<div className="w-1/2">
<p className={'font-bold'}>{messages.type}</p>
<div>{testTypeMessages[testTypes[testCase.type].uid]}</div>
</div>
</div>
<div className="my-2">
<p className={'font-bold'}>{messages.tags}</p>
<div className="flex gap-1 flex-wrap mt-1">
{testCase.Tags && testCase.Tags.length > 0 ? (
testCase.Tags.map((tag) => (
<Chip key={tag.id} size="sm" variant="flat">
{tag.name}
</Chip>
))
) : (
<span>-</span>
)}
</div>
</div>
</ModalBody>
<ModalBody>
{templates[testCase.template].uid === 'text' ? (
<>
<p className={'font-bold mt-2'}>{messages.testDetail}</p>
<div className="flex gap-2 my-2">
<div className="w-1/2">
<Textarea
isReadOnly
size="sm"
variant="flat"
label={messages.preconditions}
value={testCase.preConditions}
/>
</div>
<div className="w-1/2">
<Textarea
isReadOnly
size="sm"
variant="flat"
label={messages.expectedResult}
value={testCase.expectedResults}
/>
</div>
</div>
</>
) : (
<>
<p className={'font-bold mt-2'}>{messages.steps}</p>
{testCase.Steps &&
testCase.Steps.map((step) => (
<div key={step.id} className="flex items-center my-1">
<Avatar className="me-2" size="sm" name={step.caseSteps.stepNo.toString()} />
<div key={step.id} className="grow flex gap-2">
<div className="w-1/2">
<Textarea
isReadOnly
size="sm"
variant="flat"
label={messages.detailsOfTheStep}
value={step.step}
/>
</div>
<div className="w-1/2">
<Textarea
isReadOnly
size="sm"
variant="flat"
label={messages.expectedResult}
value={step.result}
/>
</div>
</div>
</div>
))}
</>
)}
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onCancel}>
{messages.close}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}

View File

@@ -15,9 +15,9 @@ import {
SortDescriptor,
Chip,
} from '@heroui/react';
import { ChevronDown, MoveDiagonal, MoreVertical, CopyPlus, CopyMinus } from 'lucide-react';
import TestCaseDetailDialog from './TestCaseDetailDialog';
import { ChevronDown, MoreVertical, CopyPlus, CopyMinus } from 'lucide-react';
import RunCaseStatus from './RunCaseStatus';
import { Link, NextUiLinkClasses } from '@/src/i18n/routing';
import { testRunCaseStatus } from '@/config/selection';
import { CaseType } from '@/types/case';
import { RunMessages } from '@/types/run';
@@ -27,6 +27,9 @@ import { TestTypeMessages } from '@/types/testType';
import { TestRunCaseStatusMessages } from '@/types/status';
type Props = {
projectId: string;
runId: string;
locale: string;
cases: CaseType[];
isDisabled: boolean;
selectedKeys: Selection;
@@ -41,6 +44,9 @@ type Props = {
};
export default function TestCaseSelector({
projectId,
runId,
locale,
cases,
isDisabled,
selectedKeys,
@@ -50,7 +56,6 @@ export default function TestCaseSelector({
onExcludeCase,
messages,
testRunCaseStatusMessages,
testTypeMessages,
priorityMessages,
}: Props) {
const headerColumns = [
@@ -106,7 +111,6 @@ export default function TestCaseSelector({
return isIncluded;
};
const renderCell = (testCase: CaseType, columnKey: string): ReactNode => {
const cellValue = testCase[columnKey as keyof CaseType];
const isIncluded = isCaseIncluded(testCase);
@@ -115,15 +119,16 @@ export default function TestCaseSelector({
switch (columnKey) {
case 'title':
return (
<Button
size="sm"
variant="light"
className="group"
endContent={<MoveDiagonal size={12} className="text-transparent group-hover:text-inherit" />}
onPress={() => showTestCaseDetailDialog(testCase.id)}
<div className={isIncluded ? '' : notIncludedCaseClass}>
<Link
href={`/projects/${projectId}/runs/${runId}/cases/${testCase.id}`}
locale={locale}
className={NextUiLinkClasses}
onPointerDown={(e) => e.stopPropagation()}
>
{cellValue as string}
</Button>
</Link>
</div>
);
case 'priority':
return (
@@ -238,17 +243,6 @@ export default function TestCaseSelector({
onSelectionChange(keys);
};
// Test Case Detail
const [isTestCaseDetailDialogOpen, setIsTestCaseDetailDialogOpen] = useState(false);
const [showingTestCaseId, setShowingTestCaseId] = useState<number>(0);
const showTestCaseDetailDialog = (showTestCaseId: number) => {
setIsTestCaseDetailDialogOpen(true);
setShowingTestCaseId(showTestCaseId);
};
const hideTestCaseDetailDialog = () => {
setIsTestCaseDetailDialogOpen(false);
};
return (
<>
<Table
@@ -283,15 +277,6 @@ export default function TestCaseSelector({
))}
</TableBody>
</Table>
<TestCaseDetailDialog
isOpen={isTestCaseDetailDialogOpen}
caseId={showingTestCaseId}
onCancel={hideTestCaseDetailDialog}
messages={messages}
priorityMessages={priorityMessages}
testTypeMessages={testTypeMessages}
/>
</>
);
}

View File

@@ -0,0 +1,112 @@
'use client';
import { Textarea, Chip } from '@heroui/react';
import { templates, testTypes } from '@/config/selection';
import type { CaseType } from '@/types/case';
import type { RunDetailMessages } from '@/types/run';
import type { PriorityMessages } from '@/types/priority';
import type { TestTypeMessages } from '@/types/testType';
import TestCasePriority from '@/components/TestCasePriority';
import { Link, NextUiLinkClasses } from '@/src/i18n/routing';
type Props = {
projectId: string;
testCase: CaseType;
locale: string;
messages: RunDetailMessages;
testTypeMessages: TestTypeMessages;
priorityMessages: PriorityMessages;
};
export default function CaseDetail({
projectId,
testCase,
locale,
messages,
testTypeMessages,
priorityMessages,
}: Props) {
return (
<div className="h-full p-4 text-default-500">
<div className="mb-4">
<Link
href={`/projects/${projectId}/folders/${testCase.folderId}/cases/${testCase.id}`}
locale={locale}
className={`${NextUiLinkClasses}`}
>
#{testCase.id} {testCase.title}
</Link>
</div>
<div className="mb-4">
<p className="font-bold">{messages.description}</p>
<div>{testCase.description}</div>
</div>
<div className="mb-4">
<p className="font-bold">{messages.priority}</p>
<TestCasePriority priorityValue={testCase.priority} priorityMessages={priorityMessages} />
</div>
<div className="mb-4">
<p className="font-bold">{messages.type}</p>
<div>{testTypeMessages[testTypes[testCase.type].uid]}</div>
</div>
<div className="mb-4">
<p className="font-bold">{messages.tags}</p>
<div className="flex gap-1 flex-wrap mt-1">
{testCase.Tags &&
testCase.Tags.length > 0 &&
testCase.Tags.map((tag) => (
<Chip key={tag.id} size="sm" variant="flat">
{tag.name}
</Chip>
))}
</div>
</div>
{templates[testCase.template].uid === 'text' ? (
<>
<p className="font-bold mt-2">{messages.testDetail}</p>
<div className="flex gap-2 my-2">
<div className="w-1/2">
<Textarea
isReadOnly
size="sm"
variant="flat"
label={messages.preconditions}
value={testCase.preConditions}
/>
</div>
<div className="w-1/2">
<Textarea
isReadOnly
size="sm"
variant="flat"
label={messages.expectedResult}
value={testCase.expectedResults}
/>
</div>
</div>
</>
) : (
<>
<p className="font-bold mt-2">{messages.steps}</p>
{testCase.Steps &&
testCase.Steps.length > 0 &&
testCase.Steps.map((step) => (
<div key={step.id} className="flex gap-2 my-2">
<div className="w-1/2">
<Textarea isReadOnly size="sm" variant="flat" label={messages.detailsOfTheStep} value={step.step} />
</div>
<div className="w-1/2">
<Textarea isReadOnly size="sm" variant="flat" label={messages.expectedResult} value={step.result} />
</div>
</div>
))}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,95 @@
'use client';
import { useEffect, useState, useContext } from 'react';
import { Tabs, Tab, Chip } from '@heroui/react';
import CaseDetail from './CaseDetail';
import Comments from '@/components/Comments';
import History from '@/components/History';
import { TokenContext } from '@/utils/TokenProvider';
import { fetchCase } from '@/utils/caseControl';
import { logError } from '@/utils/errorHandler';
import type { CaseType, StepType } from '@/types/case';
import type { RunDetailMessages } from '@/types/run';
import type { PriorityMessages } from '@/types/priority';
import type { TestTypeMessages } from '@/types/testType';
type Props = {
projectId: string;
locale: string;
caseId: string;
messages: RunDetailMessages;
testTypeMessages: TestTypeMessages;
priorityMessages: PriorityMessages;
};
export default function TestCaseDetailPane({
projectId,
locale,
caseId,
messages,
testTypeMessages,
priorityMessages,
}: Props) {
const context = useContext(TokenContext);
const [isFetching, setIsFetching] = useState(false);
const [testCase, setTestCase] = useState<CaseType | null>(null);
useEffect(() => {
async function fetchDataEffect() {
if (!context.isSignedIn()) return;
if (!caseId || Number(caseId) <= 0) return;
setIsFetching(true);
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) => a.caseSteps.stepNo - b.caseSteps.stepNo);
}
setTestCase(data);
} catch (error: unknown) {
logError('Error fetching case data', error);
} finally {
setIsFetching(false);
}
}
fetchDataEffect();
}, [context, caseId]);
if (isFetching || !testCase) {
return <div>loading...</div>;
} else {
return (
<div className="flex w-full flex-col p-3">
<Tabs aria-label="Options" size="sm">
<Tab key="caseDetail" title="Case Detail">
<CaseDetail
projectId={projectId}
testCase={testCase}
locale={locale}
messages={messages}
testTypeMessages={testTypeMessages}
priorityMessages={priorityMessages}
/>
</Tab>
<Tab
key="comments"
title={
<div className="flex items-center space-x-2">
<span>Comments</span>
<Chip size="sm" variant="faded">
3
</Chip>
</div>
}
>
<Comments />
</Tab>
<Tab key="history" title="History">
<History />
</Tab>
</Tabs>
</div>
);
}
}

View File

@@ -0,0 +1,61 @@
import { useTranslations } from 'next-intl';
import DetailPane from './DetailPane';
import type { RunDetailMessages } from '@/types/run';
import type { PriorityMessages } from '@/types/priority';
import type { TestTypeMessages } from '@/types/testType';
export default function Page({
params,
}: {
params: { projectId: string; runId: string; caseId: string; locale: string };
}) {
const t = useTranslations('Run');
const messages: RunDetailMessages = {
title: t('title'),
description: t('description'),
priority: t('priority'),
type: t('type'),
tags: t('tags'),
testDetail: t('test_detail'),
steps: t('steps'),
preconditions: t('preconditions'),
expectedResult: t('expected_result'),
detailsOfTheStep: t('details_of_the_step'),
};
const pt = useTranslations('Priority');
const priorityMessages: PriorityMessages = {
critical: pt('critical'),
high: pt('high'),
medium: pt('medium'),
low: pt('low'),
};
const tt = useTranslations('Type');
const testTypeMessages: TestTypeMessages = {
other: tt('other'),
security: tt('security'),
performance: tt('performance'),
accessibility: tt('accessibility'),
functional: tt('functional'),
acceptance: tt('acceptance'),
usability: tt('usability'),
smokeSanity: tt('smoke_sanity'),
compatibility: tt('compatibility'),
destructive: tt('destructive'),
regression: tt('regression'),
automated: tt('automated'),
manual: tt('manual'),
};
return (
<DetailPane
projectId={params.projectId}
caseId={params.caseId}
locale={params.locale}
messages={messages}
priorityMessages={priorityMessages}
testTypeMessages={testTypeMessages}
/>
);
}

View File

@@ -0,0 +1,117 @@
import { useTranslations } from 'next-intl';
import RunEditor from './RunEditor';
import ResizablePanes from '@/components/ResizablePane';
import { RunMessages } from '@/types/run';
import { PriorityMessages } from '@/types/priority';
import { RunStatusMessages, TestRunCaseStatusMessages } from '@/types/status';
import { TestTypeMessages } from '@/types/testType';
export default function RunLayout({
children,
params: { projectId, runId, locale },
}: {
children: React.ReactNode;
params: { projectId: string; runId: string; locale: string };
}) {
const t = useTranslations('Run');
const messages: RunMessages = {
backToRuns: t('back_to_runs'),
updating: t('updating'),
update: t('update'),
updatedTestRun: t('updated_test_run'),
export: t('export'),
progress: t('progress'),
refresh: t('refresh'),
id: t('id'),
title: t('title'),
pleaseEnter: t('please_enter'),
description: t('description'),
priority: t('priority'),
actions: t('actions'),
status: t('status'),
selectTestCase: t('select_test_case'),
testCaseSelection: t('test_case_selection'),
includeInRun: t('include_in_run'),
excludeFromRun: t('exclude_from_run'),
noCasesFound: t('no_cases_found'),
areYouSureLeave: t('are_you_sure_leave'),
type: t('type'),
testDetail: t('test_detail'),
steps: t('steps'),
preconditions: t('preconditions'),
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');
const runStatusMessages: RunStatusMessages = {
new: rst('new'),
inProgress: rst('inProgress'),
underReview: rst('underReview'),
rejected: rst('rejected'),
done: rst('done'),
closed: rst('closed'),
};
const rcst = useTranslations('RunCaseStatus');
const testRunCaseStatusMessages: TestRunCaseStatusMessages = {
untested: rcst('untested'),
passed: rcst('passed'),
failed: rcst('failed'),
retest: rcst('retest'),
skipped: rcst('skipped'),
};
const pt = useTranslations('Priority');
const priorityMessages: PriorityMessages = {
critical: pt('critical'),
high: pt('high'),
medium: pt('medium'),
low: pt('low'),
};
const tt = useTranslations('Type');
const testTypeMessages: TestTypeMessages = {
other: tt('other'),
security: tt('security'),
performance: tt('performance'),
accessibility: tt('accessibility'),
functional: tt('functional'),
acceptance: tt('acceptance'),
usability: tt('usability'),
smokeSanity: tt('smoke_sanity'),
compatibility: tt('compatibility'),
destructive: tt('destructive'),
regression: tt('regression'),
automated: tt('automated'),
manual: tt('manual'),
};
return (
<ResizablePanes
leftPane={
<RunEditor
projectId={projectId}
runId={runId}
messages={messages}
runStatusMessages={runStatusMessages}
testRunCaseStatusMessages={testRunCaseStatusMessages}
priorityMessages={priorityMessages}
testTypeMessages={testTypeMessages}
locale={locale}
/>
}
rightPane={children}
/>
);
}

View File

@@ -1,105 +1,13 @@
import { useTranslations } from 'next-intl';
import RunEditor from './RunEditor';
import { RunMessages } from '@/types/run';
import { PriorityMessages } from '@/types/priority';
import { RunStatusMessages, TestRunCaseStatusMessages } from '@/types/status';
import { TestTypeMessages } from '@/types/testType';
export default function Page({ params }: { params: { projectId: string; runId: string; locale: string } }) {
export default function Page() {
const t = useTranslations('Run');
const messages: RunMessages = {
backToRuns: t('back_to_runs'),
updating: t('updating'),
update: t('update'),
updatedTestRun: t('updated_test_run'),
export: t('export'),
progress: t('progress'),
refresh: t('refresh'),
id: t('id'),
title: t('title'),
pleaseEnter: t('please_enter'),
description: t('description'),
priority: t('priority'),
actions: t('actions'),
status: t('status'),
selectTestCase: t('select_test_case'),
testCaseSelection: t('test_case_selection'),
includeInRun: t('include_in_run'),
excludeFromRun: t('exclude_from_run'),
noCasesFound: t('no_cases_found'),
areYouSureLeave: t('are_you_sure_leave'),
type: t('type'),
testDetail: t('test_detail'),
steps: t('steps'),
preconditions: t('preconditions'),
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');
const runStatusMessages: RunStatusMessages = {
new: rst('new'),
inProgress: rst('inProgress'),
underReview: rst('underReview'),
rejected: rst('rejected'),
done: rst('done'),
closed: rst('closed'),
};
const rcst = useTranslations('RunCaseStatus');
const testRunCaseStatusMessages: TestRunCaseStatusMessages = {
untested: rcst('untested'),
passed: rcst('passed'),
failed: rcst('failed'),
retest: rcst('retest'),
skipped: rcst('skipped'),
};
const pt = useTranslations('Priority');
const priorityMessages: PriorityMessages = {
critical: pt('critical'),
high: pt('high'),
medium: pt('medium'),
low: pt('low'),
};
const tt = useTranslations('Type');
const testTypeMessages: TestTypeMessages = {
other: tt('other'),
security: tt('security'),
performance: tt('performance'),
accessibility: tt('accessibility'),
functional: tt('functional'),
acceptance: tt('acceptance'),
usability: tt('usability'),
smokeSanity: tt('smoke_sanity'),
compatibility: tt('compatibility'),
destructive: tt('destructive'),
regression: tt('regression'),
automated: tt('automated'),
manual: tt('manual'),
};
return (
<RunEditor
projectId={params.projectId}
runId={params.runId}
messages={messages}
runStatusMessages={runStatusMessages}
testRunCaseStatusMessages={testRunCaseStatusMessages}
priorityMessages={priorityMessages}
testTypeMessages={testTypeMessages}
locale={params.locale}
/>
<div className="container mx-auto max-w-3xl pt-6 px-6 flex-grow">
<div className="w-full p-3 flex items-center justify-between">
<h3 className="font-bold">{t('no_case_selected')}</h3>
</div>
</div>
);
}

View File

@@ -91,4 +91,25 @@ type RunMessages = {
selectTags: string;
};
export type { RunType, RunCaseType, RunStatusCountType, ProgressSeriesType, RunsMessages, RunMessages };
type RunDetailMessages = {
title: string;
description: string;
priority: string;
type: string;
tags: string;
testDetail: string;
steps: string;
preconditions: string;
expectedResult: string;
detailsOfTheStep: string;
};
export type {
RunType,
RunCaseType,
RunStatusCountType,
ProgressSeriesType,
RunsMessages,
RunMessages,
RunDetailMessages,
};

View File

@@ -1,9 +1,22 @@
import { useEffect } from 'react';
export const useFormGuard = (isDirty: boolean, confirmText: string) => {
export const useFormGuard = (isDirty: boolean, confirmText: string, ignorePaths?: string[]) => {
useEffect(() => {
const handleClick = (event: MouseEvent) => {
if (isDirty && event.target instanceof Element && event.target.closest('a:not([target="_blank"]')) {
if (!isDirty) return;
if (event.target instanceof Element) {
const anchor = event.target.closest('a:not([target="_blank"])');
if (!anchor) return;
const href = anchor.getAttribute('href');
if (!href) return;
// do not show confirm for ignored paths
if (ignorePaths && ignorePaths.some((path) => href.includes(path))) {
return;
}
if (!window.confirm(confirmText)) {
event.preventDefault();
event.stopPropagation();
@@ -25,5 +38,5 @@ export const useFormGuard = (isDirty: boolean, confirmText: string) => {
window.removeEventListener('beforeunload', handleBeforeUnload);
window.removeEventListener('click', handleClick, true);
};
}, [confirmText, isDirty]);
}, [confirmText, isDirty, ignorePaths]);
};