-
+
+ {comments.length === 0 ? (
+
+
{messages.noComments}
+
+ ) : (
+
+ {comments.map((comment) => (
+ handleStartEdit(comment.id, comment.content)}
+ onCancelEdit={handleCancelEdit}
+ onSave={() => handleSaveEdit(comment.id)}
+ onDelete={() => handleDeleteComment(comment.id)}
+ />
+ ))}
+
+ )}
+
+
+
+
);
diff --git a/frontend/messages/de.json b/frontend/messages/de.json
index 2d81a38..4002646 100644
--- a/frontend/messages/de.json
+++ b/frontend/messages/de.json
@@ -354,7 +354,25 @@
"selected": "Ausgewählt",
"tags": "Tags",
"select_tags": "Tags auswählen",
- "no_case_selected": "Kein Testfall ausgewählt"
+ "no_case_selected": "Kein Testfall ausgewählt",
+ "case_detail": "Testfall-Details",
+ "comments": "Kommentare",
+ "history": "Verlauf"
+ },
+ "Comments": {
+ "comments": "Kommentare",
+ "no_comments": "Keine Kommentare",
+ "add_comment": "Kommentar hinzufügen",
+ "save": "Speichern",
+ "cancel": "Abbrechen",
+ "placeholder": "Kommentar eingeben...",
+ "not_included_in_run": "Kann keine Kommentare zu Testfällen abgeben, die nicht im Testlauf enthalten sind",
+ "comment_added": "Kommentar hinzugefügt",
+ "failed_to_add_comment": "Fehler beim Hinzufügen des Kommentars",
+ "comment_updated": "Kommentar aktualisiert",
+ "failed_to_update_comment": "Fehler beim Aktualisieren des Kommentars",
+ "comment_deleted": "Kommentar gelöscht",
+ "failed_to_delete_comment": "Fehler beim Löschen des Kommentars"
},
"Members": {
"member_management": "Mitgliederverwaltung",
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index b63160a..d046ef1 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -354,7 +354,25 @@
"selected": "Selected",
"tags": "Tags",
"select_tags": "Select tags",
- "no_case_selected": "No test case selected"
+ "no_case_selected": "No test case selected",
+ "case_detail": "Test case detail",
+ "comments": "Comments",
+ "history": "History"
+ },
+ "Comments": {
+ "comments": "Comments",
+ "no_comments": "No comments",
+ "add_comment": "Add comment",
+ "save": "Save",
+ "cancel": "Cancel",
+ "placeholder": "Enter a comment...",
+ "not_included_in_run": "Cannot comment on test cases not included in the test run",
+ "comment_added": "Comment added",
+ "failed_to_add_comment": "Failed to add comment",
+ "comment_updated": "Comment updated",
+ "failed_to_update_comment": "Failed to update comment",
+ "comment_deleted": "Comment deleted",
+ "failed_to_delete_comment": "Failed to delete comment"
},
"Members": {
"member_management": "Member Management",
diff --git a/frontend/messages/ja.json b/frontend/messages/ja.json
index 6c42c16..1d05d02 100644
--- a/frontend/messages/ja.json
+++ b/frontend/messages/ja.json
@@ -354,7 +354,25 @@
"selected": "選択済み",
"tags": "タグ",
"select_tags": "タグを選択",
- "no_case_selected": "テストケースが選択されていません"
+ "no_case_selected": "テストケースが選択されていません",
+ "case_detail": "テストケース詳細",
+ "comments": "コメント",
+ "history": "履歴"
+ },
+ "Comments": {
+ "comments": "コメント",
+ "no_comments": "コメントがありません",
+ "add_comment": "コメントを追加",
+ "save": "保存",
+ "cancel": "キャンセル",
+ "placeholder": "コメントを入力...",
+ "not_included_in_run": "テストランに含まれていないテストケースにはコメントできません",
+ "comment_added": "コメントが追加されました",
+ "failed_to_add_comment": "コメントの追加に失敗しました",
+ "comment_updated": "コメントが更新されました",
+ "failed_to_update_comment": "コメントの更新に失敗しました",
+ "comment_deleted": "コメントが削除されました",
+ "failed_to_delete_comment": "コメントの削除に失敗しました"
},
"Members": {
"member_management": "メンバー管理",
diff --git a/frontend/messages/pt-BR.json b/frontend/messages/pt-BR.json
index 7f7c8d6..2dce4f6 100644
--- a/frontend/messages/pt-BR.json
+++ b/frontend/messages/pt-BR.json
@@ -354,7 +354,25 @@
"selected": "Selecionado",
"tags": "Tags",
"select_tags": "Selecionar tags",
- "no_case_selected": "Nenhum caso de teste selecionado"
+ "no_case_selected": "Nenhum caso de teste selecionado",
+ "case_detail": "Detalhe do caso de teste",
+ "comments": "Comentários",
+ "history": "Histórico"
+ },
+ "Comments": {
+ "comments": "Comentários",
+ "no_comments": "Nenhum comentário",
+ "add_comment": "Adicionar comentário",
+ "save": "Salvar",
+ "cancel": "Cancelar",
+ "placeholder": "Digite um comentário...",
+ "not_included_in_run": "Não é possível comentar em casos de teste que não estão incluídos na execução de teste",
+ "comment_added": "Comentário adicionado",
+ "failed_to_add_comment": "Falha ao adicionar comentário",
+ "comment_updated": "Comentário atualizado",
+ "failed_to_update_comment": "Falha ao atualizar comentário",
+ "comment_deleted": "Comentário excluído",
+ "failed_to_delete_comment": "Falha ao excluir comentário"
},
"Members": {
"member_management": "Gerenciamento de Membros",
diff --git a/frontend/messages/zh-CN.json b/frontend/messages/zh-CN.json
index 2e77188..01344b5 100644
--- a/frontend/messages/zh-CN.json
+++ b/frontend/messages/zh-CN.json
@@ -354,7 +354,25 @@
"selected": "已选择",
"tags": "标签",
"select_tags": "选择标签",
- "no_case_selected": "未选择测试用例"
+ "no_case_selected": "未选择测试用例",
+ "case_detail": "测试用例详情",
+ "comments": "评论",
+ "history": "历史"
+ },
+ "Comments": {
+ "comments": "评论",
+ "no_comments": "暂无评论",
+ "add_comment": "添加评论",
+ "save": "保存",
+ "cancel": "取消",
+ "placeholder": "输入评论...",
+ "not_included_in_run": "无法对未包含在测试运行中的测试用例进行评论",
+ "comment_added": "评论已添加",
+ "failed_to_add_comment": "添加评论失败",
+ "comment_updated": "评论已更新",
+ "failed_to_update_comment": "更新评论失败",
+ "comment_deleted": "评论已删除",
+ "failed_to_delete_comment": "删除评论失败"
},
"Members": {
"member_management": "成员管理",
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 ed21d37..bba5771 100644
--- a/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/TestCaseSelector.tsx
+++ b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/TestCaseSelector.tsx
@@ -15,7 +15,7 @@ import {
SortDescriptor,
Chip,
} from '@heroui/react';
-import { ChevronDown, MoreVertical, CopyPlus, CopyMinus } from 'lucide-react';
+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';
@@ -64,6 +64,7 @@ export default function TestCaseSelector({
{ 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' },
];
@@ -115,6 +116,7 @@ export default function TestCaseSelector({
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':
@@ -179,6 +181,24 @@ export default function TestCaseSelector({
);
+ case 'comments':
+ return (
+
+ {isIncluded && commentCount > 0 ? (
+ e.stopPropagation()}
+ >
+
+ {commentCount}
+
+ ) : (
+ -
+ )}
+
+ );
case 'actions':
return (
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
index a270371..e1524bb 100644
--- 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
@@ -1,7 +1,7 @@
'use client';
-
import { useEffect, useState, useContext } from 'react';
-import { Tabs, Tab, Chip } from '@heroui/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';
@@ -9,30 +9,50 @@ 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 { 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() {
@@ -46,6 +66,14 @@ export default function TestCaseDetailPane({
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 {
@@ -54,15 +82,20 @@ export default function TestCaseDetailPane({
}
fetchDataEffect();
- }, [context, caseId]);
+ }, [context, caseId, runId]);
if (isFetching || !testCase) {
return loading...
;
} else {
return (
-
-
-
+
+ setSelectedTab(String(key))}
+ >
+
-
- Comments
-
- 3
-
-
- }
- >
-
+
+
-
+
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
index 0838f80..d36ddbd 100644
--- 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
@@ -21,6 +21,9 @@ export default function Page({
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');
@@ -48,14 +51,33 @@ export default function Page({
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
index a02f390..b70c1de 100644
--- a/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/layout.tsx
+++ b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/layout.tsx
@@ -51,6 +51,7 @@ export default function RunLayout({
selected: t('selected'),
tags: t('tags'),
selectTags: t('select_tags'),
+ comments: t('comments'),
};
const rst = useTranslations('RunStatus');
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 60e2325..8061eb9 100644
--- a/frontend/types/run.ts
+++ b/frontend/types/run.ts
@@ -89,6 +89,7 @@ type RunMessages = {
selected: string;
tags: string;
selectTags: string;
+ comments: string;
};
type RunDetailMessages = {
@@ -102,6 +103,9 @@ type RunDetailMessages = {
preconditions: string;
expectedResult: string;
detailsOfTheStep: string;
+ caseDetail: string;
+ comments: string;
+ history: string;
};
export type {
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;
+ }
+}