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

@@ -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)}
>
{cellValue as string}
</Button>
<div className={isIncluded ? '' : notIncludedCaseClass}>
<Link
href={`/projects/${projectId}/runs/${runId}/cases/${testCase.id}`}
locale={locale}
className={NextUiLinkClasses}
onPointerDown={(e) => e.stopPropagation()}
>
{cellValue as string}
</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>
);
}