}
size="sm"
diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/layout.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/layout.tsx
index a0f674f..6929281 100644
--- a/frontend/src/app/[locale]/projects/[projectId]/folders/layout.tsx
+++ b/frontend/src/app/[locale]/projects/[projectId]/folders/layout.tsx
@@ -1,5 +1,6 @@
import { useTranslations } from 'next-intl';
import FoldersPane from './FoldersPane';
+import ResizablePanes from '@/components/ResizablePane';
export default function FoldersLayout({
children,
@@ -25,9 +26,12 @@ export default function FoldersLayout({
};
return (
-
+
}
+ rightPane={children}
+ />
);
}
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..ce6656f 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() {
@@ -500,8 +505,11 @@ 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]}
-
-
-
-
- {templates[testCase.template].uid === 'text' ? (
- <>
- {messages.testDetail}
-
- >
- ) : (
- <>
- {messages.steps}
- {testCase.Steps &&
- testCase.Steps.map((step) => (
-
- ))}
- >
- )}
-
-
-
-
-
-
- );
-}
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..bba5771 100644
--- a/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/TestCaseSelector.tsx
+++ b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/TestCaseSelector.tsx
@@ -13,10 +13,11 @@ import {
DropdownItem,
Selection,
SortDescriptor,
+ Chip,
} from '@heroui/react';
-import { ChevronDown, MoveDiagonal, MoreVertical, CopyPlus, CopyMinus } from 'lucide-react';
-import TestCaseDetailDialog from './TestCaseDetailDialog';
+import { ChevronDown, MoreVertical, CopyPlus, CopyMinus, MessageCircle } 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';
@@ -26,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;
@@ -40,6 +44,9 @@ type Props = {
};
export default function TestCaseSelector({
+ projectId,
+ runId,
+ locale,
cases,
isDisabled,
selectedKeys,
@@ -49,14 +56,15 @@ export default function TestCaseSelector({
onExcludeCase,
messages,
testRunCaseStatusMessages,
- testTypeMessages,
priorityMessages,
}: Props) {
const headerColumns = [
{ 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.comments, uid: 'comments', sortable: false },
{ name: messages.actions, uid: 'actions' },
];
@@ -104,24 +112,25 @@ export default function TestCaseSelector({
return isIncluded;
};
-
const renderCell = (testCase: CaseType, columnKey: string): ReactNode => {
const cellValue = testCase[columnKey as keyof CaseType];
const isIncluded = isCaseIncluded(testCase);
const runStatus = testCase.RunCases && testCase.RunCases.length > 0 ? testCase.RunCases[0].status : 0;
+ const commentCount = testCase.RunCases && testCase.RunCases.length > 0 ? testCase.RunCases[0].commentCount || 0 : 0;
switch (columnKey) {
case 'title':
return (
- }
- onPress={() => showTestCaseDetailDialog(testCase.id)}
- >
- {cellValue as string}
-
+
+ e.stopPropagation()}
+ >
+ {cellValue as string}
+
+
);
case 'priority':
return (
@@ -129,6 +138,20 @@ export default function TestCaseSelector({
);
+ case 'tags':
+ return (
+
+ {testCase.Tags && testCase.Tags.length > 0 ? (
+ testCase.Tags.map((tag) => (
+
+ {tag.name}
+
+ ))
+ ) : (
+ -
+ )}
+
+ );
case 'runStatus':
return (
@@ -158,6 +181,24 @@ export default function TestCaseSelector({
);
+ case 'comments':
+ return (
+
+ {isIncluded && commentCount > 0 ? (
+ e.stopPropagation()}
+ >
+
+ {commentCount}
+
+ ) : (
+ -
+ )}
+
+ );
case 'actions':
return (
@@ -222,17 +263,6 @@ export default function TestCaseSelector({
onSelectionChange(keys);
};
- // Test Case Detail
- const [isTestCaseDetailDialogOpen, setIsTestCaseDetailDialogOpen] = useState(false);
- const [showingTestCaseId, setShowingTestCaseId] = useState(0);
- const showTestCaseDetailDialog = (showTestCaseId: number) => {
- setIsTestCaseDetailDialogOpen(true);
- setShowingTestCaseId(showTestCaseId);
- };
- const hideTestCaseDetailDialog = () => {
- setIsTestCaseDetailDialogOpen(false);
- };
-
return (
<>
-
-
>
);
}
diff --git a/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/cases/[caseId]/CaseDetail.tsx b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/cases/[caseId]/CaseDetail.tsx
new file mode 100644
index 0000000..11cb196
--- /dev/null
+++ b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/cases/[caseId]/CaseDetail.tsx
@@ -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 (
+
+
+
+ #{testCase.id} {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}
+
+ >
+ ) : (
+ <>
+
{messages.steps}
+ {testCase.Steps &&
+ testCase.Steps.length > 0 &&
+ testCase.Steps.map((step) => (
+
+ ))}
+ >
+ )}
+
+ );
+}
diff --git a/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/cases/[caseId]/DetailPane.tsx b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/cases/[caseId]/DetailPane.tsx
new file mode 100644
index 0000000..e1524bb
--- /dev/null
+++ b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/cases/[caseId]/DetailPane.tsx
@@ -0,0 +1,123 @@
+'use client';
+import { useEffect, useState, useContext } from 'react';
+import { useSearchParams } from 'next/navigation';
+import { Tabs, Tab } 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 { RunCaseType, RunDetailMessages } from '@/types/run';
+import type { PriorityMessages } from '@/types/priority';
+import type { TestTypeMessages } from '@/types/testType';
+import type { CommentMessages } from '@/types/comment';
+
+type Props = {
+ projectId: string;
+ runId: string;
+ locale: string;
+ caseId: string;
+ messages: RunDetailMessages;
+ testTypeMessages: TestTypeMessages;
+ priorityMessages: PriorityMessages;
+ commentMessages: CommentMessages;
+};
+
+export default function TestCaseDetailPane({
+ projectId,
+ runId,
+ locale,
+ caseId,
+ messages,
+ testTypeMessages,
+ priorityMessages,
+ commentMessages,
+}: Props) {
+ const context = useContext(TokenContext);
+ const searchParams = useSearchParams();
+ const [selectedTab, setSelectedTab] = useState('caseDetail');
+ const [isFetching, setIsFetching] = useState(false);
+ const [testCase, setTestCase] = useState(null);
+ const [runCaseId, setRunCaseId] = useState(undefined);
+
+ useEffect(() => {
+ // if the url has ?tab=comments, then select the comments tab
+ const tab = searchParams.get('tab');
+ if (tab === 'comments') {
+ setSelectedTab('comments');
+ } else if (tab === 'history') {
+ setSelectedTab('history');
+ } else {
+ setSelectedTab('caseDetail');
+ }
+ }, [searchParams]);
+
+ 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);
+
+ // Find the runCase for this case in this run
+ if (data.RunCases && data.RunCases.length > 0) {
+ const runCase = data.RunCases.find((rc: RunCaseType) => rc.runId === Number(runId));
+ if (runCase) {
+ setRunCaseId(runCase.id);
+ }
+ }
+ } catch (error: unknown) {
+ logError('Error fetching case data', error);
+ } finally {
+ setIsFetching(false);
+ }
+ }
+
+ fetchDataEffect();
+ }, [context, caseId, runId]);
+
+ if (isFetching || !testCase) {
+ return loading...
;
+ } else {
+ return (
+
+ setSelectedTab(String(key))}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
diff --git a/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/cases/[caseId]/page.tsx b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/cases/[caseId]/page.tsx
new file mode 100644
index 0000000..d36ddbd
--- /dev/null
+++ b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/cases/[caseId]/page.tsx
@@ -0,0 +1,83 @@
+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'),
+ caseDetail: t('case_detail'),
+ comments: t('comments'),
+ history: t('history'),
+ };
+
+ 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'),
+ };
+
+ const ct = useTranslations('Comments');
+ const commentMessages = {
+ comments: ct('comments'),
+ noComments: ct('no_comments'),
+ addComment: ct('add_comment'),
+ save: ct('save'),
+ cancel: ct('cancel'),
+ placeholder: ct('placeholder'),
+ notIncludedInRun: ct('not_included_in_run'),
+ commentAdded: ct('comment_added'),
+ failedToAddComment: ct('failed_to_add_comment'),
+ commentUpdated: ct('comment_updated'),
+ failedToUpdateComment: ct('failed_to_update_comment'),
+ commentDeleted: ct('comment_deleted'),
+ failedToDeleteComment: ct('failed_to_delete_comment'),
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/layout.tsx b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/layout.tsx
new file mode 100644
index 0000000..b70c1de
--- /dev/null
+++ b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/layout.tsx
@@ -0,0 +1,118 @@
+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'),
+ comments: t('comments'),
+ };
+
+ 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 (
+
+ }
+ rightPane={children}
+ />
+ );
+}
diff --git a/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/page.tsx b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/page.tsx
index f9626a9..1914fdd 100644
--- a/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/page.tsx
+++ b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/page.tsx
@@ -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 (
-
+
+
+
{t('no_case_selected')}
+
+
);
}
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/types/case.ts b/frontend/types/case.ts
index 49e1ec4..58f7f83 100644
--- a/frontend/types/case.ts
+++ b/frontend/types/case.ts
@@ -44,6 +44,7 @@ type RunCaseType = {
caseId: number;
status: number;
editState: 'notChanged' | 'changed' | 'new' | 'deleted';
+ commentCount?: number;
};
type CaseAttachmentType = {
diff --git a/frontend/types/comment.ts b/frontend/types/comment.ts
new file mode 100644
index 0000000..76ca835
--- /dev/null
+++ b/frontend/types/comment.ts
@@ -0,0 +1,32 @@
+type CommentType = {
+ id: number;
+ commentableType: 'RunCase' | 'Run' | 'Case';
+ commentableId: number;
+ userId: number;
+ content: string;
+ createdAt: string;
+ updatedAt: string;
+ User: {
+ id: number;
+ username: string;
+ email: string;
+ };
+};
+
+type CommentMessages = {
+ comments: string;
+ noComments: string;
+ addComment: string;
+ save: string;
+ cancel: string;
+ placeholder: string;
+ notIncludedInRun: string;
+ commentAdded: string;
+ failedToAddComment: string;
+ commentUpdated: string;
+ failedToUpdateComment: string;
+ commentDeleted: string;
+ failedToDeleteComment: string;
+};
+
+export type { CommentType, CommentMessages };
diff --git a/frontend/types/run.ts b/frontend/types/run.ts
index 2680c75..8061eb9 100644
--- a/frontend/types/run.ts
+++ b/frontend/types/run.ts
@@ -89,6 +89,31 @@ type RunMessages = {
selected: string;
tags: string;
selectTags: string;
+ comments: 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;
+ caseDetail: string;
+ comments: string;
+ history: string;
+};
+
+export type {
+ RunType,
+ RunCaseType,
+ RunStatusCountType,
+ ProgressSeriesType,
+ RunsMessages,
+ RunMessages,
+ RunDetailMessages,
+};
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/commentControl.ts b/frontend/utils/commentControl.ts
new file mode 100644
index 0000000..073fcf1
--- /dev/null
+++ b/frontend/utils/commentControl.ts
@@ -0,0 +1,110 @@
+import { logError } from './errorHandler';
+import { CommentType } from '@/types/comment';
+import Config from '@/config/config';
+const apiServer = Config.apiServer;
+
+export async function fetchComments(
+ jwt: string,
+ commentableType: 'RunCase' | 'Run' | 'Case',
+ commentableId: number
+): Promise {
+ const fetchOptions = {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${jwt}`,
+ },
+ };
+
+ const url = `${apiServer}/comments?commentableType=${commentableType}&commentableId=${commentableId}`;
+ try {
+ const response = await fetch(url, fetchOptions);
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! Status: ${response.status}`);
+ }
+ const data = await response.json();
+ return data || [];
+ } catch (error: unknown) {
+ logError('Error fetching comments:', error);
+ return [];
+ }
+}
+
+export async function createComment(
+ jwt: string,
+ commentableType: 'RunCase' | 'Run' | 'Case',
+ commentableId: number,
+ content: string
+): Promise {
+ const fetchOptions = {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${jwt}`,
+ },
+ body: JSON.stringify({ content }),
+ };
+
+ const url = `${apiServer}/comments/?commentableType=${commentableType}&commentableId=${commentableId}`;
+ try {
+ const response = await fetch(url, fetchOptions);
+ if (!response.ok) {
+ throw new Error(`HTTP error! Status: ${response.status}`);
+ }
+ const data = await response.json();
+ return data || null;
+ } catch (error: unknown) {
+ logError('Error creating comments:', error);
+ return null;
+ }
+}
+
+export async function updateComment(jwt: string, commentId: number, content: string): Promise {
+ const fetchOptions = {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${jwt}`,
+ },
+ body: JSON.stringify({ content }),
+ };
+
+ const url = `${apiServer}/comments/${commentId}`;
+ try {
+ const response = await fetch(url, fetchOptions);
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! Status: ${response.status}`);
+ }
+ const data = await response.json();
+ return data || null;
+ } catch (error: unknown) {
+ logError('Error updating comments:', error);
+ return null;
+ }
+}
+
+export async function deleteComment(jwt: string, commentId: number): Promise {
+ const fetchOptions = {
+ method: 'DELETE',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${jwt}`,
+ },
+ };
+
+ const url = `${apiServer}/comments/${commentId}`;
+ try {
+ const response = await fetch(url, fetchOptions);
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! Status: ${response.status}`);
+ }
+ await response.json();
+ return;
+ } catch (error: unknown) {
+ logError('Error deleting comments:', error);
+ return;
+ }
+}
diff --git a/frontend/utils/formGuard.ts b/frontend/utils/formGuard.ts
index 79d5d16..cc5a468 100644
--- a/frontend/utils/formGuard.ts
+++ b/frontend/utils/formGuard.ts
@@ -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]);
};
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;
+}