feat: upload test record attachments per run case

Allow testers to attach evidence files to a single test case within a
specific test run, proving the test was actually executed. Attachments
are scoped to the RunCase (per-execution), not the shared case definition.

Backend:
- runCaseAttachments join table + model (CASCADE on runCase/attachment)
- POST/GET /runcaseattachments routes (reuse /attachments download+delete)

Frontend:
- new "Test record" tab in the run case detail pane with drag-and-drop
  upload, scoped to reporters; i18n for all 6 locales

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
LittleYellow
2026-06-24 07:01:44 +08:00
parent 55e78875ae
commit 02fa631f02
16 changed files with 569 additions and 7 deletions

View File

@@ -362,7 +362,13 @@
"no_case_selected": "Kein Testfall ausgewählt",
"case_detail": "Testfall-Details",
"comments": "Kommentare",
"history": "Verlauf"
"history": "Verlauf",
"test_record": "Testnachweis",
"download": "Herunterladen",
"delete": "Löschen",
"click_to_upload": "Zum Hochladen klicken",
"or_drag_and_drop": " oder per Drag & Drop",
"max_file_size": "Max. Dateigröße"
},
"Comments": {
"comments": "Kommentare",

View File

@@ -362,7 +362,13 @@
"no_case_selected": "No test case selected",
"case_detail": "Test case detail",
"comments": "Comments",
"history": "History"
"history": "History",
"test_record": "Test record",
"download": "Download",
"delete": "Delete",
"click_to_upload": "Click to upload",
"or_drag_and_drop": " or drag and drop",
"max_file_size": "Max. file size"
},
"Comments": {
"comments": "Comments",

View File

@@ -362,7 +362,13 @@
"no_case_selected": "テストケースが選択されていません",
"case_detail": "テストケース詳細",
"comments": "コメント",
"history": "履歴"
"history": "履歴",
"test_record": "テスト記録",
"download": "ダウンロード",
"delete": "削除",
"click_to_upload": "クリックしてアップロード",
"or_drag_and_drop": "またはドラッグアンドドロップ",
"max_file_size": "最大ファイルサイズ"
},
"Comments": {
"comments": "コメント",

View File

@@ -362,7 +362,13 @@
"no_case_selected": "Nenhum caso de teste selecionado",
"case_detail": "Detalhe do caso de teste",
"comments": "Comentários",
"history": "Histórico"
"history": "Histórico",
"test_record": "Registro de teste",
"download": "Baixar",
"delete": "Excluir",
"click_to_upload": "Clique para enviar",
"or_drag_and_drop": " ou arraste e solte",
"max_file_size": "Tamanho máx. do arquivo"
},
"Comments": {
"comments": "Comentários",

View File

@@ -362,7 +362,13 @@
"no_case_selected": "未选择测试用例",
"case_detail": "测试用例详情",
"comments": "评论",
"history": "历史"
"history": "历史",
"test_record": "测试记录",
"download": "下载",
"delete": "删除",
"click_to_upload": "点击上传",
"or_drag_and_drop": " 或拖拽文件到此处",
"max_file_size": "最大文件大小"
},
"Comments": {
"comments": "评论",

View File

@@ -362,7 +362,13 @@
"no_case_selected": "未選擇測試案例",
"case_detail": "測試案例詳情",
"comments": "留言",
"history": "歷史"
"history": "歷史",
"test_record": "測試紀錄",
"download": "下載",
"delete": "刪除",
"click_to_upload": "點擊上傳",
"or_drag_and_drop": " 或將檔案拖曳至此處",
"max_file_size": "檔案大小上限"
},
"Comments": {
"comments": "留言",

View File

@@ -1,8 +1,15 @@
'use client';
import { useEffect, useState, useContext } from 'react';
import { useEffect, useState, useContext, ChangeEvent, DragEvent } from 'react';
import { useSearchParams } from 'next/navigation';
import { Tabs, Tab } from '@heroui/react';
import CaseDetail from './CaseDetail';
import TestRecordEditor, { TestRecordType } from './TestRecordEditor';
import {
fetchRunCaseAttachments,
fetchCreateRunCaseAttachments,
fetchDownloadAttachment,
fetchDeleteAttachment,
} from './attachmentControl';
import Comments from '@/components/Comments';
import History from '@/components/History';
import { TokenContext } from '@/utils/TokenProvider';
@@ -41,6 +48,7 @@ export default function TestCaseDetailPane({
const [isFetching, setIsFetching] = useState(false);
const [testCase, setTestCase] = useState<CaseType | null>(null);
const [runCaseId, setRunCaseId] = useState<number | undefined>(undefined);
const [testRecords, setTestRecords] = useState<TestRecordType[]>([]);
useEffect(() => {
// if the url has ?tab=comments, then select the comments tab
@@ -49,6 +57,8 @@ export default function TestCaseDetailPane({
setSelectedTab('comments');
} else if (tab === 'history') {
setSelectedTab('history');
} else if (tab === 'testRecord') {
setSelectedTab('testRecord');
} else {
setSelectedTab('caseDetail');
}
@@ -84,6 +94,47 @@ export default function TestCaseDetailPane({
fetchDataEffect();
}, [context, caseId, runId]);
useEffect(() => {
async function fetchRecordsEffect() {
if (!context.isSignedIn() || !runCaseId) return;
try {
const records = await fetchRunCaseAttachments(runCaseId);
setTestRecords(records);
} catch (error: unknown) {
logError('Error fetching test records', error);
}
}
fetchRecordsEffect();
}, [context, runCaseId]);
const handleFilesDrop = (event: DragEvent<HTMLElement>) => {
event.preventDefault();
if (event.dataTransfer.files) {
handleCreateRecords(Array.from(event.dataTransfer.files));
}
};
const handleFilesInput = (event: ChangeEvent) => {
const input = event.target as HTMLInputElement;
if (input.files) {
handleCreateRecords(Array.from(input.files));
}
};
const handleCreateRecords = async (files: File[]) => {
if (!runCaseId) return;
const newRecords = await fetchCreateRunCaseAttachments(runCaseId, files);
if (newRecords) {
setTestRecords((prev) => [...prev, ...newRecords]);
}
};
const handleDeleteRecord = async (attachmentId: number) => {
await fetchDeleteAttachment(attachmentId);
setTestRecords((prev) => prev.filter((record) => record.id !== attachmentId));
};
if (isFetching || !testCase) {
return <div>loading...</div>;
} else {
@@ -105,6 +156,17 @@ export default function TestCaseDetailPane({
priorityMessages={priorityMessages}
/>
</Tab>
<Tab key="testRecord" title={messages.testRecord}>
<TestRecordEditor
isDisabled={!runCaseId || !context.isProjectReporter(Number(projectId))}
attachments={testRecords}
onAttachmentDownload={fetchDownloadAttachment}
onAttachmentDelete={handleDeleteRecord}
onFilesDrop={handleFilesDrop}
onFilesInput={handleFilesInput}
messages={messages}
/>
</Tab>
<Tab key="comments" title={messages.comments}>
<Comments
projectId={projectId}

View File

@@ -0,0 +1,163 @@
import { Image, Button, Tooltip, Card, CardBody } from '@heroui/react';
import { Trash, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react';
import { ChangeEvent, DragEvent } from 'react';
import Config from '@/config/config';
import type { RunDetailMessages } from '@/types/run';
const apiServer = Config.apiServer;
export type TestRecordType = {
id: number;
title: string;
detail: string;
filename: string;
createdAt: Date;
updatedAt: Date;
};
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg'];
function isImage(filename: string) {
const extension = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
return imageExtensions.includes(extension);
}
type Props = {
isDisabled: boolean;
attachments: TestRecordType[];
onAttachmentDownload: (attachmentId: number, downloadFileName: string) => void;
onAttachmentDelete: (attachmentId: number) => void;
onFilesDrop: (event: DragEvent<HTMLElement>) => void;
onFilesInput: (event: ChangeEvent) => void;
messages: RunDetailMessages;
};
export default function TestRecordEditor({
isDisabled = false,
attachments = [],
onAttachmentDownload,
onAttachmentDelete,
onFilesDrop,
onFilesInput,
messages,
}: Props) {
const images: TestRecordType[] = [];
const others: TestRecordType[] = [];
attachments.forEach((attachment) => {
if (isImage(attachment.filename)) {
images.push(attachment);
} else {
others.push(attachment);
}
});
return (
<div className="p-4">
<div className="flex flex-wrap mt-3">
{images.map((image, index) => (
<Card key={index} radius="sm" className="mt-2 me-2 max-w-md">
<CardBody>
<Image
alt={image.title}
src={`${apiServer}/uploads/${image.filename}`}
className="object-cover h-40 w-40"
/>
<div className="flex items-center justify-between">
<p>{image.title}</p>
<div>
<Tooltip content={messages.download}>
<Button
isIconOnly
size="sm"
className="bg-transparent rounded-full"
onPress={() => onAttachmentDownload(image.id, image.title)}
>
<ArrowDownToLine size={16} />
</Button>
</Tooltip>
<Tooltip content={messages.delete}>
<Button
isIconOnly
size="sm"
isDisabled={isDisabled}
className="bg-transparent rounded-full"
onPress={() => onAttachmentDelete(image.id)}
>
<Trash size={16} />
</Button>
</Tooltip>
</div>
</div>
</CardBody>
</Card>
))}
</div>
{others.map((file, index) => (
<Card key={index} radius="sm" className="mt-2 max-w-md">
<CardBody>
<div className="flex items-center justify-between">
<p>{file.title}</p>
<div>
<Tooltip content={messages.download}>
<Button
isIconOnly
size="sm"
className="bg-transparent rounded-full"
onPress={() => onAttachmentDownload(file.id, file.title)}
>
<ArrowDownToLine size={16} />
</Button>
</Tooltip>
<Tooltip content={messages.delete}>
<Button
isIconOnly
size="sm"
isDisabled={isDisabled}
className="bg-transparent rounded-full"
onPress={() => onAttachmentDelete(file.id)}
>
<Trash size={16} />
</Button>
</Tooltip>
</div>
</div>
</CardBody>
</Card>
))}
<div
className="flex items-center justify-center w-96 mt-3"
onDrop={(event) => {
if (isDisabled) {
return;
}
onFilesDrop(event);
}}
onDragOver={(event) => event.preventDefault()}
>
<label
htmlFor="testrecord-dropzone-file"
className={`flex flex-col items-center justify-center w-full h-32 border-2 border-neutral-200 border-dashed rounded-lg bg-neutral-50 dark:hover:bg-bray-800 dark:bg-neutral-700 hover:bg-neutral-100 dark:border-neutral-600 dark:hover:border-neutral-500 dark:hover:bg-neutral-600 ${isDisabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
>
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<ArrowUpFromLine />
<p className="mb-2 text-sm text-neutral-500 dark:text-neutral-400">
<span className="font-semibold">{messages.clickToUpload}</span>
<span>{messages.orDragAndDrop}</span>
</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400">{messages.maxFileSize}: 50 MB</p>
</div>
<input
id="testrecord-dropzone-file"
type="file"
className="hidden"
disabled={isDisabled}
onChange={(e) => onFilesInput(e)}
multiple
/>
</label>
</div>
</div>
);
}

View File

@@ -0,0 +1,87 @@
import Config from '@/config/config';
import { logError } from '@/utils/errorHandler';
const apiServer = Config.apiServer;
async function fetchRunCaseAttachments(runCaseId: number) {
const url = `${apiServer}/runcaseattachments?runCaseId=${runCaseId}`;
try {
const response = await fetch(url, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return await response.json();
} catch (error: unknown) {
logError('Error fetching test records', error);
throw error;
}
}
async function fetchCreateRunCaseAttachments(runCaseId: number, files: File[]) {
try {
const formData = new FormData();
for (let i = 0; i < files.length; i++) {
formData.append('files', files[i]);
}
const url = `${apiServer}/runcaseattachments?runCaseId=${runCaseId}`;
const response = await fetch(url, {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('Network response was not ok');
}
return await response.json();
} catch (error: unknown) {
logError('Error uploading test records', error);
}
}
async function fetchDownloadAttachment(attachmentId: number, downloadFileName: string) {
const url = `${apiServer}/attachments/download/${attachmentId}`;
try {
const response = await fetch(url, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = downloadFileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error: unknown) {
logError('Error downloading attachment', error);
throw error;
}
}
async function fetchDeleteAttachment(attachmentId: number) {
const url = `${apiServer}/attachments/${attachmentId}`;
try {
const response = await fetch(url, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
} catch (error: unknown) {
logError('Error deleting file:', error);
throw error;
}
}
export { fetchRunCaseAttachments, fetchCreateRunCaseAttachments, fetchDownloadAttachment, fetchDeleteAttachment };

View File

@@ -24,6 +24,12 @@ export default function Page({
caseDetail: t('case_detail'),
comments: t('comments'),
history: t('history'),
testRecord: t('test_record'),
download: t('download'),
delete: t('delete'),
clickToUpload: t('click_to_upload'),
orDragAndDrop: t('or_drag_and_drop'),
maxFileSize: t('max_file_size'),
};
const pt = useTranslations('Priority');

View File

@@ -106,6 +106,12 @@ type RunDetailMessages = {
caseDetail: string;
comments: string;
history: string;
testRecord: string;
download: string;
delete: string;
clickToUpload: string;
orDragAndDrop: string;
maxFileSize: string;
};
export type {