Compare commits
1 Commits
main
...
feat/runca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02fa631f02 |
@@ -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');
|
||||||
|
}
|
||||||
27
backend/models/runCaseAttachments.js
Normal file
27
backend/models/runCaseAttachments.js
Normal file
@@ -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;
|
||||||
35
backend/routes/runcaseattachments/index.js
Normal file
35
backend/routes/runcaseattachments/index.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
91
backend/routes/runcaseattachments/new.js
Normal file
91
backend/routes/runcaseattachments/new.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -149,6 +149,12 @@ import runCaseEditRoute from './routes/runcases/edit.js';
|
|||||||
app.use('/runcases', runCaseIndexRoute(sequelize));
|
app.use('/runcases', runCaseIndexRoute(sequelize));
|
||||||
app.use('/runcases', runCaseEditRoute(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"
|
// "/members"
|
||||||
import membersIndexRoute from './routes/members/index.js';
|
import membersIndexRoute from './routes/members/index.js';
|
||||||
import membersNewRoute from './routes/members/new.js';
|
import membersNewRoute from './routes/members/new.js';
|
||||||
|
|||||||
@@ -362,7 +362,13 @@
|
|||||||
"no_case_selected": "Kein Testfall ausgewählt",
|
"no_case_selected": "Kein Testfall ausgewählt",
|
||||||
"case_detail": "Testfall-Details",
|
"case_detail": "Testfall-Details",
|
||||||
"comments": "Kommentare",
|
"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": {
|
||||||
"comments": "Kommentare",
|
"comments": "Kommentare",
|
||||||
|
|||||||
@@ -362,7 +362,13 @@
|
|||||||
"no_case_selected": "No test case selected",
|
"no_case_selected": "No test case selected",
|
||||||
"case_detail": "Test case detail",
|
"case_detail": "Test case detail",
|
||||||
"comments": "Comments",
|
"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": "Comments",
|
"comments": "Comments",
|
||||||
|
|||||||
@@ -362,7 +362,13 @@
|
|||||||
"no_case_selected": "テストケースが選択されていません",
|
"no_case_selected": "テストケースが選択されていません",
|
||||||
"case_detail": "テストケース詳細",
|
"case_detail": "テストケース詳細",
|
||||||
"comments": "コメント",
|
"comments": "コメント",
|
||||||
"history": "履歴"
|
"history": "履歴",
|
||||||
|
"test_record": "テスト記録",
|
||||||
|
"download": "ダウンロード",
|
||||||
|
"delete": "削除",
|
||||||
|
"click_to_upload": "クリックしてアップロード",
|
||||||
|
"or_drag_and_drop": "またはドラッグアンドドロップ",
|
||||||
|
"max_file_size": "最大ファイルサイズ"
|
||||||
},
|
},
|
||||||
"Comments": {
|
"Comments": {
|
||||||
"comments": "コメント",
|
"comments": "コメント",
|
||||||
|
|||||||
@@ -362,7 +362,13 @@
|
|||||||
"no_case_selected": "Nenhum caso de teste selecionado",
|
"no_case_selected": "Nenhum caso de teste selecionado",
|
||||||
"case_detail": "Detalhe do caso de teste",
|
"case_detail": "Detalhe do caso de teste",
|
||||||
"comments": "Comentários",
|
"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": {
|
||||||
"comments": "Comentários",
|
"comments": "Comentários",
|
||||||
|
|||||||
@@ -362,7 +362,13 @@
|
|||||||
"no_case_selected": "未选择测试用例",
|
"no_case_selected": "未选择测试用例",
|
||||||
"case_detail": "测试用例详情",
|
"case_detail": "测试用例详情",
|
||||||
"comments": "评论",
|
"comments": "评论",
|
||||||
"history": "历史"
|
"history": "历史",
|
||||||
|
"test_record": "测试记录",
|
||||||
|
"download": "下载",
|
||||||
|
"delete": "删除",
|
||||||
|
"click_to_upload": "点击上传",
|
||||||
|
"or_drag_and_drop": " 或拖拽文件到此处",
|
||||||
|
"max_file_size": "最大文件大小"
|
||||||
},
|
},
|
||||||
"Comments": {
|
"Comments": {
|
||||||
"comments": "评论",
|
"comments": "评论",
|
||||||
|
|||||||
@@ -362,7 +362,13 @@
|
|||||||
"no_case_selected": "未選擇測試案例",
|
"no_case_selected": "未選擇測試案例",
|
||||||
"case_detail": "測試案例詳情",
|
"case_detail": "測試案例詳情",
|
||||||
"comments": "留言",
|
"comments": "留言",
|
||||||
"history": "歷史"
|
"history": "歷史",
|
||||||
|
"test_record": "測試紀錄",
|
||||||
|
"download": "下載",
|
||||||
|
"delete": "刪除",
|
||||||
|
"click_to_upload": "點擊上傳",
|
||||||
|
"or_drag_and_drop": " 或將檔案拖曳至此處",
|
||||||
|
"max_file_size": "檔案大小上限"
|
||||||
},
|
},
|
||||||
"Comments": {
|
"Comments": {
|
||||||
"comments": "留言",
|
"comments": "留言",
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useEffect, useState, useContext } from 'react';
|
import { useEffect, useState, useContext, ChangeEvent, DragEvent } from 'react';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { Tabs, Tab } from '@heroui/react';
|
import { Tabs, Tab } from '@heroui/react';
|
||||||
import CaseDetail from './CaseDetail';
|
import CaseDetail from './CaseDetail';
|
||||||
|
import TestRecordEditor, { TestRecordType } from './TestRecordEditor';
|
||||||
|
import {
|
||||||
|
fetchRunCaseAttachments,
|
||||||
|
fetchCreateRunCaseAttachments,
|
||||||
|
fetchDownloadAttachment,
|
||||||
|
fetchDeleteAttachment,
|
||||||
|
} from './attachmentControl';
|
||||||
import Comments from '@/components/Comments';
|
import Comments from '@/components/Comments';
|
||||||
import History from '@/components/History';
|
import History from '@/components/History';
|
||||||
import { TokenContext } from '@/utils/TokenProvider';
|
import { TokenContext } from '@/utils/TokenProvider';
|
||||||
@@ -41,6 +48,7 @@ export default function TestCaseDetailPane({
|
|||||||
const [isFetching, setIsFetching] = useState(false);
|
const [isFetching, setIsFetching] = useState(false);
|
||||||
const [testCase, setTestCase] = useState<CaseType | null>(null);
|
const [testCase, setTestCase] = useState<CaseType | null>(null);
|
||||||
const [runCaseId, setRunCaseId] = useState<number | undefined>(undefined);
|
const [runCaseId, setRunCaseId] = useState<number | undefined>(undefined);
|
||||||
|
const [testRecords, setTestRecords] = useState<TestRecordType[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// if the url has ?tab=comments, then select the comments tab
|
// if the url has ?tab=comments, then select the comments tab
|
||||||
@@ -49,6 +57,8 @@ export default function TestCaseDetailPane({
|
|||||||
setSelectedTab('comments');
|
setSelectedTab('comments');
|
||||||
} else if (tab === 'history') {
|
} else if (tab === 'history') {
|
||||||
setSelectedTab('history');
|
setSelectedTab('history');
|
||||||
|
} else if (tab === 'testRecord') {
|
||||||
|
setSelectedTab('testRecord');
|
||||||
} else {
|
} else {
|
||||||
setSelectedTab('caseDetail');
|
setSelectedTab('caseDetail');
|
||||||
}
|
}
|
||||||
@@ -84,6 +94,47 @@ export default function TestCaseDetailPane({
|
|||||||
fetchDataEffect();
|
fetchDataEffect();
|
||||||
}, [context, caseId, runId]);
|
}, [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) {
|
if (isFetching || !testCase) {
|
||||||
return <div>loading...</div>;
|
return <div>loading...</div>;
|
||||||
} else {
|
} else {
|
||||||
@@ -105,6 +156,17 @@ export default function TestCaseDetailPane({
|
|||||||
priorityMessages={priorityMessages}
|
priorityMessages={priorityMessages}
|
||||||
/>
|
/>
|
||||||
</Tab>
|
</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}>
|
<Tab key="comments" title={messages.comments}>
|
||||||
<Comments
|
<Comments
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -24,6 +24,12 @@ export default function Page({
|
|||||||
caseDetail: t('case_detail'),
|
caseDetail: t('case_detail'),
|
||||||
comments: t('comments'),
|
comments: t('comments'),
|
||||||
history: t('history'),
|
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');
|
const pt = useTranslations('Priority');
|
||||||
|
|||||||
@@ -106,6 +106,12 @@ type RunDetailMessages = {
|
|||||||
caseDetail: string;
|
caseDetail: string;
|
||||||
comments: string;
|
comments: string;
|
||||||
history: string;
|
history: string;
|
||||||
|
testRecord: string;
|
||||||
|
download: string;
|
||||||
|
delete: string;
|
||||||
|
clickToUpload: string;
|
||||||
|
orDragAndDrop: string;
|
||||||
|
maxFileSize: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
Reference in New Issue
Block a user