feat: Test run detail pane (#381)
This commit is contained in:
13
frontend/components/Comments.tsx
Normal file
13
frontend/components/Comments.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
frontend/components/History.tsx
Normal file
13
frontend/components/History.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
frontend/components/ResizablePane.tsx
Normal file
71
frontend/components/ResizablePane.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -353,7 +353,8 @@
|
||||
"case_title_or_description": "テストケースのタイトルまたは説明",
|
||||
"selected": "選択済み",
|
||||
"tags": "タグ",
|
||||
"select_tags": "タグを選択"
|
||||
"select_tags": "タグを選択",
|
||||
"no_case_selected": "テストケースが選択されていません"
|
||||
},
|
||||
"Members": {
|
||||
"member_management": "メンバー管理",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -353,7 +353,8 @@
|
||||
"case_title_or_description": "测试用例标题或描述",
|
||||
"selected": "已选择",
|
||||
"tags": "标签",
|
||||
"select_tags": "选择标签"
|
||||
"select_tags": "选择标签",
|
||||
"no_case_selected": "未选择测试用例"
|
||||
},
|
||||
"Members": {
|
||||
"member_management": "成员管理",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user