feat: comment to test run's case (#390)
This commit is contained in:
95
frontend/components/CommentItem.tsx
Normal file
95
frontend/components/CommentItem.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Button, Textarea, Card, CardBody } from '@heroui/react';
|
||||
import { Trash2, Edit2 } from 'lucide-react';
|
||||
import UserAvatar from './UserAvatar';
|
||||
import { CommentMessages, CommentType } from '@/types/comment';
|
||||
|
||||
type Props = {
|
||||
comment: CommentType;
|
||||
isEditing: boolean;
|
||||
canEdit: boolean;
|
||||
editContent: string;
|
||||
isSubmitting: boolean;
|
||||
messages: CommentMessages;
|
||||
onEditContentChange: (value: string) => void;
|
||||
onStartEdit: () => void;
|
||||
onCancelEdit: () => void;
|
||||
onSave: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
export default function CommentItem({
|
||||
comment,
|
||||
isEditing,
|
||||
canEdit,
|
||||
editContent,
|
||||
isSubmitting,
|
||||
messages,
|
||||
onEditContentChange,
|
||||
onStartEdit,
|
||||
onCancelEdit,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: Props) {
|
||||
return (
|
||||
<Card shadow="sm">
|
||||
<CardBody>
|
||||
<div className="flex items-start gap-3">
|
||||
<UserAvatar username={comment.User.username} size={24} />
|
||||
<div className="flex-grow min-w-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<span className="font-semibold text-sm">{comment.User.username}</span>
|
||||
<span className="text-xs text-default-400 ml-2">{new Date(comment.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
aria-label="Edit Comment"
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="light"
|
||||
onPress={onStartEdit}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Delete Comment"
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="danger"
|
||||
onPress={onDelete}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<div>
|
||||
<Textarea
|
||||
value={editContent}
|
||||
onValueChange={onEditContentChange}
|
||||
minRows={3}
|
||||
isDisabled={isSubmitting}
|
||||
/>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Button size="sm" color="primary" onPress={onSave} isLoading={isSubmitting}>
|
||||
{messages.save}
|
||||
</Button>
|
||||
<Button size="sm" variant="bordered" onPress={onCancelEdit} isDisabled={isSubmitting}>
|
||||
{messages.cancel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm whitespace-pre-wrap">{comment.content}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,201 @@
|
||||
'use client';
|
||||
import { useEffect, useState, useContext } from 'react';
|
||||
import { Button, Textarea, Spinner, addToast } from '@heroui/react';
|
||||
import CommentItem from './CommentItem';
|
||||
import { TokenContext } from '@/utils/TokenProvider';
|
||||
import { fetchComments, createComment, updateComment, deleteComment } from '@/utils/commentControl';
|
||||
import { logError } from '@/utils/errorHandler';
|
||||
import type { CommentMessages, CommentType } from '@/types/comment';
|
||||
|
||||
import { Alert } from '@heroui/react';
|
||||
type Props = {
|
||||
projectId: string;
|
||||
commentableType: 'RunCase' | 'Run' | 'Case';
|
||||
commentableId?: number;
|
||||
messages: CommentMessages;
|
||||
};
|
||||
|
||||
export default function Comments({ projectId, commentableType, commentableId, messages }: Props) {
|
||||
const context = useContext(TokenContext);
|
||||
const [comments, setComments] = useState<CommentType[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [newComment, setNewComment] = useState('');
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editContent, setEditContent] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadComments() {
|
||||
if (!commentableType || !commentableId || !context.isSignedIn()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await fetchComments(context.token.access_token, commentableType, commentableId);
|
||||
setComments(data);
|
||||
} catch (error: unknown) {
|
||||
logError('Error fetching comments', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadComments();
|
||||
}, [commentableType, commentableId, context]);
|
||||
|
||||
const handleAddComment = async () => {
|
||||
if (!newComment.trim() || !commentableType || !commentableId) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const comment = await createComment(context.token.access_token, commentableType, commentableId, newComment);
|
||||
if (!comment) {
|
||||
throw new Error('Failed to create comment');
|
||||
}
|
||||
const updatedComments = [...comments, comment];
|
||||
setComments(updatedComments);
|
||||
setNewComment('');
|
||||
addToast({
|
||||
title: 'Success',
|
||||
color: 'success',
|
||||
description: messages.commentAdded,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
logError('Error adding comment', error);
|
||||
addToast({
|
||||
title: 'Error',
|
||||
color: 'danger',
|
||||
description: messages.failedToAddComment,
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartEdit = (id: number, content: string) => {
|
||||
setEditingId(id);
|
||||
setEditContent(content);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingId(null);
|
||||
setEditContent('');
|
||||
};
|
||||
|
||||
const handleSaveEdit = async (id: number) => {
|
||||
if (!editContent.trim()) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const updated = await updateComment(context.token.access_token, id, editContent);
|
||||
if (!updated) {
|
||||
throw new Error('Failed to update comment');
|
||||
}
|
||||
setComments(comments.map((c) => (c.id === id ? { ...c, content: editContent } : c)));
|
||||
setEditingId(null);
|
||||
setEditContent('');
|
||||
addToast({
|
||||
title: 'Success',
|
||||
color: 'success',
|
||||
description: messages.commentUpdated,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
logError('Error updating comment', error);
|
||||
addToast({
|
||||
title: 'Error',
|
||||
color: 'danger',
|
||||
description: messages.failedToUpdateComment,
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteComment = async (id: number) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await deleteComment(context.token.access_token, id);
|
||||
const updatedComments = comments.filter((c) => c.id !== id);
|
||||
setComments(updatedComments);
|
||||
addToast({
|
||||
title: 'Success',
|
||||
color: 'success',
|
||||
description: messages.commentDeleted,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
logError('Error deleting comment', error);
|
||||
addToast({
|
||||
title: 'Error',
|
||||
color: 'danger',
|
||||
description: messages.failedToDeleteComment,
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!commentableType || !commentableId) {
|
||||
return (
|
||||
<div className="text-default-500 text-sm">
|
||||
{commentableType === 'RunCase' && !commentableId ? <p>{messages.notIncludedInRun}</p> : <p>Unknown state</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const canComment = projectId && context.isProjectReporter(Number(projectId));
|
||||
|
||||
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 className="h-full flex flex-col justify-between">
|
||||
{comments.length === 0 ? (
|
||||
<div className="text-center text-default-400 py-8">
|
||||
<p>{messages.noComments}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{comments.map((comment) => (
|
||||
<CommentItem
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
isEditing={editingId === comment.id}
|
||||
canEdit={comment.userId === context.token.user?.id}
|
||||
editContent={editContent}
|
||||
isSubmitting={isSubmitting}
|
||||
messages={messages}
|
||||
onEditContentChange={setEditContent}
|
||||
onStartEdit={() => handleStartEdit(comment.id, comment.content)}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onSave={() => handleSaveEdit(comment.id)}
|
||||
onDelete={() => handleDeleteComment(comment.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-12">
|
||||
<Textarea
|
||||
placeholder={messages.placeholder}
|
||||
value={newComment}
|
||||
onValueChange={setNewComment}
|
||||
minRows={3}
|
||||
variant="bordered"
|
||||
isDisabled={!canComment || isSubmitting}
|
||||
/>
|
||||
<Button
|
||||
color="primary"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onPress={handleAddComment}
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={!newComment.trim() || !canComment}
|
||||
>
|
||||
{messages.addComment}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "メンバー管理",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "成员管理",
|
||||
|
||||
@@ -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({
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
);
|
||||
case 'comments':
|
||||
return (
|
||||
<div className={isIncluded ? '' : notIncludedCaseClass}>
|
||||
{isIncluded && commentCount > 0 ? (
|
||||
<Link
|
||||
href={`/projects/${projectId}/runs/${runId}/cases/${testCase.id}?tab=comments`}
|
||||
locale={locale}
|
||||
className="flex items-center gap-1"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MessageCircle size={16} />
|
||||
<span>{commentCount}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-default-400">-</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case 'actions':
|
||||
return (
|
||||
<Dropdown>
|
||||
|
||||
@@ -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<CaseType | null>(null);
|
||||
const [runCaseId, setRunCaseId] = useState<number | undefined>(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 <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">
|
||||
<div className="flex h-full w-full flex-col p-3">
|
||||
<Tabs
|
||||
aria-label="Options"
|
||||
size="sm"
|
||||
selectedKey={selectedTab}
|
||||
onSelectionChange={(key) => setSelectedTab(String(key))}
|
||||
>
|
||||
<Tab key="caseDetail" title={messages.caseDetail}>
|
||||
<CaseDetail
|
||||
projectId={projectId}
|
||||
testCase={testCase}
|
||||
@@ -72,20 +105,15 @@ export default function TestCaseDetailPane({
|
||||
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 key="comments" title={messages.comments}>
|
||||
<Comments
|
||||
projectId={projectId}
|
||||
commentableType="RunCase"
|
||||
commentableId={runCaseId}
|
||||
messages={commentMessages}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab key="history" title="History">
|
||||
<Tab key="history" title={messages.history}>
|
||||
<History />
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
@@ -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 (
|
||||
<DetailPane
|
||||
projectId={params.projectId}
|
||||
runId={params.runId}
|
||||
caseId={params.caseId}
|
||||
locale={params.locale}
|
||||
messages={messages}
|
||||
priorityMessages={priorityMessages}
|
||||
testTypeMessages={testTypeMessages}
|
||||
commentMessages={commentMessages}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -44,6 +44,7 @@ type RunCaseType = {
|
||||
caseId: number;
|
||||
status: number;
|
||||
editState: 'notChanged' | 'changed' | 'new' | 'deleted';
|
||||
commentCount?: number;
|
||||
};
|
||||
|
||||
type CaseAttachmentType = {
|
||||
|
||||
32
frontend/types/comment.ts
Normal file
32
frontend/types/comment.ts
Normal file
@@ -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 };
|
||||
@@ -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 {
|
||||
|
||||
110
frontend/utils/commentControl.ts
Normal file
110
frontend/utils/commentControl.ts
Normal file
@@ -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<CommentType[]> {
|
||||
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<CommentType | null> {
|
||||
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<CommentType | null> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user