feat: comment to test run's case (#390)

This commit is contained in:
kimatata
2026-02-23 12:26:07 +09:00
committed by GitHub
parent 1f4ac0ae7b
commit a9674c81ab
27 changed files with 1045 additions and 40 deletions

View 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>
);
}

View File

@@ -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>
);

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "メンバー管理",

View File

@@ -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",

View File

@@ -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": "成员管理",

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}
/>
);
}

View File

@@ -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');

View File

@@ -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
View 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 };

View File

@@ -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 {

View 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;
}
}