diff --git a/backend/migrations/20260624000000-create-runcase-attachments.js b/backend/migrations/20260624000000-create-runcase-attachments.js new file mode 100644 index 0000000..5e48c6a --- /dev/null +++ b/backend/migrations/20260624000000-create-runcase-attachments.js @@ -0,0 +1,43 @@ +export async function up(queryInterface, Sequelize) { + await queryInterface.createTable('runCaseAttachments', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + runCaseId: { + type: Sequelize.INTEGER, + references: { + model: 'runCases', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + attachmentId: { + type: Sequelize.INTEGER, + references: { + model: 'attachments', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + }, + }); + + await queryInterface.addIndex('runCaseAttachments', ['runCaseId', 'attachmentId'], { + unique: true, + }); +} + +export async function down(queryInterface) { + await queryInterface.dropTable('runCaseAttachments'); +} diff --git a/backend/models/runCaseAttachments.js b/backend/models/runCaseAttachments.js new file mode 100644 index 0000000..08ff4db --- /dev/null +++ b/backend/models/runCaseAttachments.js @@ -0,0 +1,27 @@ +function defineRunCaseAttachment(sequelize, DataTypes) { + const RunCaseAttachment = sequelize.define('RunCaseAttachment', { + runCaseId: { + type: DataTypes.INTEGER, + allowNull: false, + }, + attachmentId: { + type: DataTypes.INTEGER, + allowNull: false, + }, + }); + + RunCaseAttachment.associate = (models) => { + RunCaseAttachment.belongsTo(models.RunCase, { + foreignKey: 'runCaseId', + onDelete: 'CASCADE', + }); + RunCaseAttachment.belongsTo(models.Attachment, { + foreignKey: 'attachmentId', + onDelete: 'CASCADE', + }); + }; + + return RunCaseAttachment; +} + +export default defineRunCaseAttachment; diff --git a/backend/routes/runcaseattachments/index.js b/backend/routes/runcaseattachments/index.js new file mode 100644 index 0000000..0b5a46e --- /dev/null +++ b/backend/routes/runcaseattachments/index.js @@ -0,0 +1,35 @@ +import express from 'express'; +const router = express.Router(); +import { DataTypes } from 'sequelize'; +import defineAttachment from '../../models/attachments.js'; +import defineRunCase from '../../models/runCases.js'; + +export default function (sequelize) { + const Attachment = defineAttachment(sequelize, DataTypes); + const RunCase = defineRunCase(sequelize, DataTypes); + RunCase.belongsToMany(Attachment, { through: 'runCaseAttachments', foreignKey: 'runCaseId', otherKey: 'attachmentId' }); + Attachment.belongsToMany(RunCase, { through: 'runCaseAttachments', foreignKey: 'attachmentId', otherKey: 'runCaseId' }); + + // ponytail: no auth middleware, mirroring the sibling /attachments routes (TODO there). + router.get('/', async (req, res) => { + const { runCaseId } = req.query; + if (!runCaseId) { + return res.status(400).json({ error: 'runCaseId is required' }); + } + + try { + const runCase = await RunCase.findByPk(runCaseId, { + include: [{ model: Attachment }], + }); + if (!runCase) { + return res.status(404).send('RunCase not found'); + } + res.json(runCase.Attachments); + } catch (error) { + console.error(error); + res.status(500).send('Internal Server Error'); + } + }); + + return router; +} diff --git a/backend/routes/runcaseattachments/new.js b/backend/routes/runcaseattachments/new.js new file mode 100644 index 0000000..539a6b5 --- /dev/null +++ b/backend/routes/runcaseattachments/new.js @@ -0,0 +1,91 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import multer from 'multer'; +import express from 'express'; +const router = express.Router(); +import { DataTypes } from 'sequelize'; +import defineAttachment from '../../models/attachments.js'; +import defineRunCaseAttachment from '../../models/runCaseAttachments.js'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export default function (sequelize) { + const Attachment = defineAttachment(sequelize, DataTypes); + const RunCaseAttachment = defineRunCaseAttachment(sequelize, DataTypes); + + // Create uploads folder if it does not exist + const uploadDir = path.join(__dirname, '../../public/uploads'); + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); + } + + const storage = multer.diskStorage({ + destination: (req, file, cb) => { + cb(null, uploadDir); + }, + filename: async (req, file, cb) => { + const ext = path.extname(file.originalname); + const baseName = path.basename(file.originalname, ext); + let fileName = `${baseName}${ext}`; + + // Check if a file with the same name already exists + let fileExists = true; + let fileIndex = 1; + while (fileExists) { + const filePath = path.join(uploadDir, fileName); + if (fs.existsSync(filePath)) { + // If a file with the same name exists, add an index and rename the file + fileName = `${baseName}_${fileIndex}${ext}`; + fileIndex++; + } else { + fileExists = false; + } + } + + cb(null, fileName); + }, + }); + + const upload = multer({ storage }); + + // ponytail: no auth middleware, mirroring the sibling /attachments upload route + // (see routes/attachments/new.js TODO). Upgrade path: add verifyProjectReporterFromRunCaseId. + router.post('/', upload.array('files', 10), async (req, res) => { + const t = await sequelize.transaction(); + try { + const runCaseId = req.query.runCaseId; + if (!runCaseId) { + return res.status(400).json({ error: 'runCaseId is required' }); + } + + const files = req.files; + if (files.length === 0) { + return res.status(400).json({ error: 'No files uploaded' }); + } + + const attachmentsData = files.map((file) => ({ + title: file.originalname, + filename: file.filename, + })); + + const newAttachments = await Attachment.bulkCreate(attachmentsData, { + transaction: t, + }); + + const runCaseAttachmentsData = newAttachments.map((attachment) => ({ + runCaseId: runCaseId, + attachmentId: attachment.id, + })); + await RunCaseAttachment.bulkCreate(runCaseAttachmentsData, { transaction: t }); + await t.commit(); + res.json(newAttachments); + } catch (error) { + console.error(error); + await t.rollback(); + res.status(500).json({ error: 'Internal server error' }); + } + }); + + return router; +} diff --git a/backend/server.js b/backend/server.js index 83b790f..4f0a894 100644 --- a/backend/server.js +++ b/backend/server.js @@ -149,6 +149,12 @@ import runCaseEditRoute from './routes/runcases/edit.js'; app.use('/runcases', runCaseIndexRoute(sequelize)); app.use('/runcases', runCaseEditRoute(sequelize)); +// "/runcaseattachments" +import runCaseAttachmentsNewRoute from './routes/runcaseattachments/new.js'; +import runCaseAttachmentsIndexRoute from './routes/runcaseattachments/index.js'; +app.use('/runcaseattachments', runCaseAttachmentsNewRoute(sequelize)); +app.use('/runcaseattachments', runCaseAttachmentsIndexRoute(sequelize)); + // "/members" import membersIndexRoute from './routes/members/index.js'; import membersNewRoute from './routes/members/new.js'; diff --git a/frontend/messages/de.json b/frontend/messages/de.json index c4fe4a7..97680f4 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -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", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index b50cd96..c2cebf4 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -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", diff --git a/frontend/messages/ja.json b/frontend/messages/ja.json index 117ba17..6ea13a8 100644 --- a/frontend/messages/ja.json +++ b/frontend/messages/ja.json @@ -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": "コメント", diff --git a/frontend/messages/pt-BR.json b/frontend/messages/pt-BR.json index 1a0c1fe..ce0ca77 100644 --- a/frontend/messages/pt-BR.json +++ b/frontend/messages/pt-BR.json @@ -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", diff --git a/frontend/messages/zh-CN.json b/frontend/messages/zh-CN.json index b49e329..911a55b 100644 --- a/frontend/messages/zh-CN.json +++ b/frontend/messages/zh-CN.json @@ -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": "评论", diff --git a/frontend/messages/zh-TW.json b/frontend/messages/zh-TW.json index 7cc841a..a997497 100644 --- a/frontend/messages/zh-TW.json +++ b/frontend/messages/zh-TW.json @@ -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": "留言", 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 e1524bb..b1417e4 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,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(null); const [runCaseId, setRunCaseId] = useState(undefined); + const [testRecords, setTestRecords] = useState([]); 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) => { + 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
loading...
; } else { @@ -105,6 +156,17 @@ export default function TestCaseDetailPane({ priorityMessages={priorityMessages} /> + + + void; + onAttachmentDelete: (attachmentId: number) => void; + onFilesDrop: (event: DragEvent) => 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 ( +
+
+ {images.map((image, index) => ( + + + {image.title} +
+

{image.title}

+
+ + + + + + +
+
+
+
+ ))} +
+ + {others.map((file, index) => ( + + +
+

{file.title}

+
+ + + + + + +
+
+
+
+ ))} + +
{ + if (isDisabled) { + return; + } + onFilesDrop(event); + }} + onDragOver={(event) => event.preventDefault()} + > + +
+
+ ); +} diff --git a/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/cases/[caseId]/attachmentControl.ts b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/cases/[caseId]/attachmentControl.ts new file mode 100644 index 0000000..8408b05 --- /dev/null +++ b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/cases/[caseId]/attachmentControl.ts @@ -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 }; 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 d36ddbd..0d64ae3 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 @@ -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'); diff --git a/frontend/types/run.ts b/frontend/types/run.ts index 8061eb9..67bbdd2 100644 --- a/frontend/types/run.ts +++ b/frontend/types/run.ts @@ -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 {