Compare commits
2 Commits
e6f3bc799e
...
feat/runca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02fa631f02 | ||
|
|
55e78875ae |
@@ -87,6 +87,7 @@ UnitTCMS currently supports the following languages:
|
|||||||
- English (en)
|
- English (en)
|
||||||
- Portuguese (pt-BR)
|
- Portuguese (pt-BR)
|
||||||
- Chinese (zh-CN)
|
- Chinese (zh-CN)
|
||||||
|
- Traditional Chinese (zh-TW)
|
||||||
- Japanese (ja)
|
- Japanese (ja)
|
||||||
|
|
||||||
If you would like to add support for another language, feel free to submit a pull request. For reference, you can see how Portuguese was added in [PR #260](https://github.com/kimatata/unittcms/pull/260).
|
If you would like to add support for another language, feel free to submit a pull request. For reference, you can see how Portuguese was added in [PR #260](https://github.com/kimatata/unittcms/pull/260).
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const SUPPORTED_LOCALES = ['de', 'en', 'pt-BR', 'zh-CN', 'ja'];
|
export const SUPPORTED_LOCALES = ['de', 'en', 'pt-BR', 'zh-CN', 'zh-TW', 'ja'];
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -12,18 +12,15 @@ import editableMiddleware from '../../middleware/verifyEditable.js';
|
|||||||
import { priorities, testTypes, automationStatus, templates } from '../../config/enums.js';
|
import { priorities, testTypes, automationStatus, templates } from '../../config/enums.js';
|
||||||
|
|
||||||
const fileFilter = (req, file, cb) => {
|
const fileFilter = (req, file, cb) => {
|
||||||
const allowedFileTypes = ['.xlsx', '.xls'];
|
// Accept Excel and JSON by extension. JSON mime types vary across browsers
|
||||||
const allowedMimeTypes = [
|
// (application/json, text/plain, application/octet-stream), so rely on the extension.
|
||||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
const allowedFileTypes = ['.xlsx', '.xls', '.json'];
|
||||||
'application/vnd.ms-excel',
|
|
||||||
];
|
|
||||||
const extname = allowedFileTypes.includes(path.extname(file.originalname).toLowerCase());
|
const extname = allowedFileTypes.includes(path.extname(file.originalname).toLowerCase());
|
||||||
const mimetype = allowedMimeTypes.includes(file.mimetype);
|
|
||||||
|
|
||||||
if (extname && mimetype) {
|
if (extname) {
|
||||||
return cb(null, true);
|
return cb(null, true);
|
||||||
} else {
|
} else {
|
||||||
cb(new Error('Only Excel files (.xlsx, .xls) are allowed!'));
|
cb(new Error('Only Excel (.xlsx, .xls) or JSON (.json) files are allowed!'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -66,9 +63,63 @@ export default function (sequelize) {
|
|||||||
return res.status(400).json({ error: 'folderId is required' });
|
return res.status(400).json({ error: 'folderId is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse and validate the file before opening a transaction.
|
||||||
|
// Both parsers return the same { casesToCreate, stepsToCreate } shape,
|
||||||
|
// or throw an Error with a user-facing validation message.
|
||||||
|
let casesToCreate;
|
||||||
|
let stepsToCreate;
|
||||||
|
try {
|
||||||
|
const ext = path.extname(req.file.originalname).toLowerCase();
|
||||||
|
const parsed =
|
||||||
|
ext === '.json' ? _parseJsonBuffer(req.file.buffer, folderId) : _parseExcelBuffer(req.file.buffer, folderId);
|
||||||
|
casesToCreate = parsed.casesToCreate;
|
||||||
|
stepsToCreate = parsed.stepsToCreate;
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(400).json({ error: error.message });
|
||||||
|
}
|
||||||
|
|
||||||
const t = await sequelize.transaction();
|
const t = await sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
const workbook = XLSX.read(req.file.buffer, { type: 'buffer' });
|
// 'Manually' create cases, steps and caseStep association.
|
||||||
|
const createdCases = await Case.bulkCreate(casesToCreate, { transaction: t });
|
||||||
|
for (const stepData of stepsToCreate) {
|
||||||
|
const createdCase = createdCases[stepData.caseIndex];
|
||||||
|
const createdStep = await Step.create(
|
||||||
|
{
|
||||||
|
step: stepData.step,
|
||||||
|
result: stepData.result,
|
||||||
|
},
|
||||||
|
{ transaction: t }
|
||||||
|
);
|
||||||
|
await CaseStep.create(
|
||||||
|
{
|
||||||
|
caseId: createdCase.id,
|
||||||
|
stepId: createdStep.id,
|
||||||
|
stepNo: stepData.stepNo,
|
||||||
|
},
|
||||||
|
{ transaction: t }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await t.commit();
|
||||||
|
res.json(createdCases);
|
||||||
|
} catch (error) {
|
||||||
|
await t.rollback();
|
||||||
|
console.error(error);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ error: 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse an Excel (.xlsx, .xls) buffer into { casesToCreate, stepsToCreate }.
|
||||||
|
// Rows sharing the same title are grouped into a single case with multiple steps.
|
||||||
|
function _parseExcelBuffer(buffer, folderId) {
|
||||||
|
const workbook = XLSX.read(buffer, { type: 'buffer' });
|
||||||
const sheetName = workbook.SheetNames[0];
|
const sheetName = workbook.SheetNames[0];
|
||||||
const worksheet = workbook.Sheets[sheetName];
|
const worksheet = workbook.Sheets[sheetName];
|
||||||
const jsonData = XLSX.utils.sheet_to_json(worksheet);
|
const jsonData = XLSX.utils.sheet_to_json(worksheet);
|
||||||
@@ -81,7 +132,7 @@ export default function (sequelize) {
|
|||||||
for (const [index, row] of jsonData.entries()) {
|
for (const [index, row] of jsonData.entries()) {
|
||||||
const errorMessage = _getRowValidationError(row, index);
|
const errorMessage = _getRowValidationError(row, index);
|
||||||
if (errorMessage) {
|
if (errorMessage) {
|
||||||
return res.status(400).json({ error: errorMessage });
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add step to the same case if the current row title is equal to previous row title
|
// Add step to the same case if the current row title is equal to previous row title
|
||||||
@@ -121,37 +172,62 @@ export default function (sequelize) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 'Manually' create cases, steps and caseStep association.
|
return { casesToCreate, stepsToCreate };
|
||||||
const createdCases = await Case.bulkCreate(casesToCreate, { transaction: t });
|
|
||||||
for (const stepData of stepsToCreate) {
|
|
||||||
const createdCase = createdCases[stepData.caseIndex];
|
|
||||||
const createdStep = await Step.create(
|
|
||||||
{
|
|
||||||
step: stepData.step,
|
|
||||||
result: stepData.result,
|
|
||||||
},
|
|
||||||
{ transaction: t }
|
|
||||||
);
|
|
||||||
await CaseStep.create(
|
|
||||||
{
|
|
||||||
caseId: createdCase.id,
|
|
||||||
stepId: createdStep.id,
|
|
||||||
stepNo: stepData.stepNo,
|
|
||||||
},
|
|
||||||
{ transaction: t }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await t.commit();
|
// Parse a JSON buffer into { casesToCreate, stepsToCreate }.
|
||||||
res.json(createdCases);
|
// Accepts the same shape produced by the JSON export (`/cases/download?type=json`):
|
||||||
} catch (error) {
|
// either an array of case objects, or an object map keyed by id.
|
||||||
await t.rollback();
|
// `id`, `folderId`, `folder` and `state` are ignored; cases are created in the
|
||||||
console.error(error);
|
// target folder with the default `new` state.
|
||||||
|
function _parseJsonBuffer(buffer, folderId) {
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(buffer.toString('utf-8'));
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid JSON file');
|
||||||
}
|
}
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return router;
|
const rawCases = Array.isArray(parsed) ? parsed : parsed && typeof parsed === 'object' ? Object.values(parsed) : null;
|
||||||
|
if (!rawCases || rawCases.length === 0) {
|
||||||
|
throw new Error('No cases found in JSON file');
|
||||||
|
}
|
||||||
|
|
||||||
|
const casesToCreate = [];
|
||||||
|
const stepsToCreate = [];
|
||||||
|
for (const [index, c] of rawCases.entries()) {
|
||||||
|
const errorMessage = _getJsonCaseValidationError(c, index);
|
||||||
|
if (errorMessage) {
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
casesToCreate.push({
|
||||||
|
folderId: folderId,
|
||||||
|
title: String(c.title).trim(),
|
||||||
|
description: c.description || '',
|
||||||
|
state: 0, // default state
|
||||||
|
priority: priorities.indexOf(c.priority),
|
||||||
|
type: testTypes.indexOf(c.type),
|
||||||
|
preConditions: c.preConditions || '',
|
||||||
|
expectedResults: c.expectedResults || '',
|
||||||
|
automationStatus: c.automationStatus
|
||||||
|
? automationStatus.indexOf(c.automationStatus)
|
||||||
|
: automationStatus.indexOf('automation-not-required'),
|
||||||
|
template: templates.indexOf(c.template),
|
||||||
|
});
|
||||||
|
|
||||||
|
const steps = Array.isArray(c.Steps) ? c.Steps : [];
|
||||||
|
steps.forEach((s, stepIndex) => {
|
||||||
|
stepsToCreate.push({
|
||||||
|
caseIndex: casesToCreate.length - 1,
|
||||||
|
stepNo: stepIndex + 1,
|
||||||
|
step: s?.step || '',
|
||||||
|
result: s?.expectedStepResult || s?.result || '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { casesToCreate, stepsToCreate };
|
||||||
}
|
}
|
||||||
|
|
||||||
function _getRowValidationError(row, index) {
|
function _getRowValidationError(row, index) {
|
||||||
@@ -198,3 +274,37 @@ function _getRowValidationError(row, index) {
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _getJsonCaseValidationError(c, index) {
|
||||||
|
const caseNumber = index + 1;
|
||||||
|
|
||||||
|
if (!c || typeof c !== 'object') {
|
||||||
|
return `Case ${caseNumber} is not a valid object`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredFields = ['title', 'priority', 'type', 'template'];
|
||||||
|
for (const field of requiredFields) {
|
||||||
|
if (!c[field]) {
|
||||||
|
return `Case ${caseNumber} is missing required field: ${field}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Values are human-readable labels, matching the JSON export format.
|
||||||
|
if (priorities.indexOf(c.priority) === -1) {
|
||||||
|
return `Case ${caseNumber} has invalid priority: ${c.priority}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (testTypes.indexOf(c.type) === -1) {
|
||||||
|
return `Case ${caseNumber} has invalid type: ${c.type}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (templates.indexOf(c.template) === -1) {
|
||||||
|
return `Case ${caseNumber} has invalid template: ${c.template}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c.automationStatus && automationStatus.indexOf(c.automationStatus) === -1) {
|
||||||
|
return `Case ${caseNumber} has invalid automationStatus: ${c.automationStatus}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
163
backend/routes/cases/import.test.js
Normal file
163
backend/routes/cases/import.test.js
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import express from 'express';
|
||||||
|
import { Sequelize } from 'sequelize';
|
||||||
|
import casesImportRoute from './import.js';
|
||||||
|
|
||||||
|
// mock of authentication / permission middleware
|
||||||
|
vi.mock('../../middleware/auth.js', () => ({
|
||||||
|
default: () => ({
|
||||||
|
verifySignedIn: vi.fn((req, res, next) => {
|
||||||
|
req.userId = 1;
|
||||||
|
next();
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock('../../middleware/verifyEditable.js', () => ({
|
||||||
|
default: () => ({
|
||||||
|
verifyProjectDeveloperFromFolderId: vi.fn((req, res, next) => {
|
||||||
|
next();
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockCase = {
|
||||||
|
bulkCreate: vi.fn(),
|
||||||
|
belongsToMany: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.mock('../../models/cases.js', () => ({
|
||||||
|
default: () => mockCase,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockStep = {
|
||||||
|
create: vi.fn(),
|
||||||
|
belongsToMany: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.mock('../../models/steps.js', () => ({
|
||||||
|
default: () => mockStep,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockCaseStep = {
|
||||||
|
create: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.mock('../../models/caseSteps.js', () => ({
|
||||||
|
default: () => mockCaseStep,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('POST /import with JSON file', () => {
|
||||||
|
let app;
|
||||||
|
const sequelize = new Sequelize({ dialect: 'sqlite', logging: false });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/', casesImportRoute(sequelize));
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.spyOn(sequelize, 'transaction').mockResolvedValue({
|
||||||
|
commit: vi.fn(),
|
||||||
|
rollback: vi.fn(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const attachJson = (data) =>
|
||||||
|
request(app)
|
||||||
|
.post('/import?folderId=1')
|
||||||
|
.attach('file', Buffer.from(JSON.stringify(data)), 'cases.json');
|
||||||
|
|
||||||
|
it('creates cases from a JSON array, mapping labels to enum indexes', async () => {
|
||||||
|
mockCase.bulkCreate.mockResolvedValue([{ id: 101 }, { id: 102 }]);
|
||||||
|
mockStep.create.mockResolvedValue({ id: 201 });
|
||||||
|
mockCaseStep.create.mockResolvedValue({});
|
||||||
|
|
||||||
|
const response = await attachJson([
|
||||||
|
{
|
||||||
|
title: 'Text case',
|
||||||
|
priority: 'high',
|
||||||
|
type: 'functional',
|
||||||
|
template: 'text',
|
||||||
|
description: 'desc',
|
||||||
|
Steps: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Step case',
|
||||||
|
priority: 'medium',
|
||||||
|
type: 'acceptance',
|
||||||
|
template: 'step',
|
||||||
|
automationStatus: 'cannot-be-automated',
|
||||||
|
Steps: [
|
||||||
|
{ step: 'do A', expectedStepResult: 'A done' },
|
||||||
|
{ step: 'do B', expectedStepResult: 'B done' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
const created = mockCase.bulkCreate.mock.calls[0][0];
|
||||||
|
expect(created).toHaveLength(2);
|
||||||
|
expect(created[0]).toMatchObject({
|
||||||
|
folderId: '1',
|
||||||
|
title: 'Text case',
|
||||||
|
state: 0,
|
||||||
|
priority: 1, // high
|
||||||
|
type: 4, // functional
|
||||||
|
template: 0, // text
|
||||||
|
automationStatus: 1, // default automation-not-required
|
||||||
|
});
|
||||||
|
expect(created[1]).toMatchObject({
|
||||||
|
title: 'Step case',
|
||||||
|
priority: 2, // medium
|
||||||
|
type: 5, // acceptance
|
||||||
|
template: 1, // step
|
||||||
|
automationStatus: 2, // cannot-be-automated
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only the second case has steps -> 2 steps created
|
||||||
|
expect(mockStep.create).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockCaseStep.create).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the object-map shape produced by the JSON export', async () => {
|
||||||
|
mockCase.bulkCreate.mockResolvedValue([{ id: 101 }]);
|
||||||
|
|
||||||
|
const response = await attachJson({
|
||||||
|
1: { title: 'Mapped case', priority: 'low', type: 'other', template: 'text', Steps: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(mockCase.bulkCreate.mock.calls[0][0]).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 when a required field is missing', async () => {
|
||||||
|
const response = await attachJson([{ priority: 'high', type: 'functional', template: 'text' }]);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error).toContain('missing required field: title');
|
||||||
|
expect(mockCase.bulkCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 for an invalid priority label', async () => {
|
||||||
|
const response = await attachJson([
|
||||||
|
{ title: 'Bad priority', priority: 'urgent', type: 'functional', template: 'text' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error).toContain('invalid priority');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 for malformed JSON', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/import?folderId=1')
|
||||||
|
.attach('file', Buffer.from('{ not json'), 'cases.json');
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error).toBe('Invalid JSON file');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unsupported file extensions', async () => {
|
||||||
|
const response = await request(app).post('/import?folderId=1').attach('file', Buffer.from('hello'), 'cases.txt');
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error).toContain('Only Excel');
|
||||||
|
});
|
||||||
|
});
|
||||||
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';
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const locales: LocaleType[] = [
|
|||||||
{ code: 'en', name: 'English' },
|
{ code: 'en', name: 'English' },
|
||||||
{ code: 'pt-BR', name: 'Português' },
|
{ code: 'pt-BR', name: 'Português' },
|
||||||
{ code: 'zh-CN', name: '简体中文' },
|
{ code: 'zh-CN', name: '简体中文' },
|
||||||
|
{ code: 'zh-TW', name: '繁體中文' },
|
||||||
{ code: 'ja', name: '日本語' },
|
{ code: 'ja', name: '日本語' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -251,8 +251,9 @@
|
|||||||
"select_tags": "Tags auswählen",
|
"select_tags": "Tags auswählen",
|
||||||
"import": "Importieren",
|
"import": "Importieren",
|
||||||
"import_cases": "Testfälle importieren",
|
"import_cases": "Testfälle importieren",
|
||||||
"import_available": "Du kannst Testfälle aus einer Excel-Datei (xlsx, xls) importieren. Die Datei muss dem vorgegebenen Format entsprechen.",
|
"import_available": "Du kannst Testfälle aus einer Excel-Datei (xlsx, xls) oder einer JSON-Datei (json) importieren. Die Datei muss dem vorgegebenen Format entsprechen.",
|
||||||
"download_template": "Vorlage herunterladen",
|
"download_template": "Vorlage herunterladen",
|
||||||
|
"download_json_sample": "JSON-Beispiel herunterladen",
|
||||||
"click_to_upload": "Zum Hochladen klicken",
|
"click_to_upload": "Zum Hochladen klicken",
|
||||||
"or_drag_and_drop": " oder per Drag & Drop",
|
"or_drag_and_drop": " oder per Drag & Drop",
|
||||||
"max_file_size": "Max. Dateigröße",
|
"max_file_size": "Max. Dateigröße",
|
||||||
@@ -361,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",
|
||||||
|
|||||||
@@ -251,8 +251,9 @@
|
|||||||
"select_tags": "Select tags",
|
"select_tags": "Select tags",
|
||||||
"import": "Import",
|
"import": "Import",
|
||||||
"import_cases": "Import Test Cases",
|
"import_cases": "Import Test Cases",
|
||||||
"import_available": "You can import test cases from Excel file (xlsx, xls). The Excel file to be imported must follow the specified format.",
|
"import_available": "You can import test cases from an Excel file (xlsx, xls) or a JSON file (json). The file to be imported must follow the specified format.",
|
||||||
"download_template": "Download Template",
|
"download_template": "Download Template",
|
||||||
|
"download_json_sample": "Download JSON Sample",
|
||||||
"click_to_upload": "Click to upload",
|
"click_to_upload": "Click to upload",
|
||||||
"or_drag_and_drop": " or drag and drop",
|
"or_drag_and_drop": " or drag and drop",
|
||||||
"max_file_size": "Max. file size",
|
"max_file_size": "Max. file size",
|
||||||
@@ -361,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",
|
||||||
|
|||||||
@@ -251,8 +251,9 @@
|
|||||||
"select_tags": "タグを選択",
|
"select_tags": "タグを選択",
|
||||||
"import": "インポート",
|
"import": "インポート",
|
||||||
"import_cases": "テストケースのインポート",
|
"import_cases": "テストケースのインポート",
|
||||||
"import_available": "Excelファイル(xlsx, xls)からテストケースをインポートできます。インポートするExcelファイルは既定のフォーマットに従う必要があります。",
|
"import_available": "Excelファイル(xlsx, xls)またはJSONファイル(json)からテストケースをインポートできます。インポートするファイルは既定のフォーマットに従う必要があります。",
|
||||||
"download_template": "テンプレートをダウンロード",
|
"download_template": "テンプレートをダウンロード",
|
||||||
|
"download_json_sample": "JSONサンプルをダウンロード",
|
||||||
"click_to_upload": "クリックしてアップロード",
|
"click_to_upload": "クリックしてアップロード",
|
||||||
"or_drag_and_drop": "またはドラッグアンドドロップ",
|
"or_drag_and_drop": "またはドラッグアンドドロップ",
|
||||||
"max_file_size": "最大ファイルサイズ",
|
"max_file_size": "最大ファイルサイズ",
|
||||||
@@ -361,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": "コメント",
|
||||||
|
|||||||
@@ -251,8 +251,9 @@
|
|||||||
"select_tags": "Selecionar tags",
|
"select_tags": "Selecionar tags",
|
||||||
"import": "Importar",
|
"import": "Importar",
|
||||||
"import_cases": "Importar Casos de Teste",
|
"import_cases": "Importar Casos de Teste",
|
||||||
"import_available": "Você pode importar casos de teste a partir de um arquivo Excel (xlsx, xls). O arquivo Excel a ser importado deve seguir o formato especificado.",
|
"import_available": "Você pode importar casos de teste a partir de um arquivo Excel (xlsx, xls) ou um arquivo JSON (json). O arquivo a ser importado deve seguir o formato especificado.",
|
||||||
"download_template": "Baixar Modelo",
|
"download_template": "Baixar Modelo",
|
||||||
|
"download_json_sample": "Baixar Exemplo JSON",
|
||||||
"click_to_upload": "Clique para enviar",
|
"click_to_upload": "Clique para enviar",
|
||||||
"or_drag_and_drop": " ou arraste e solte",
|
"or_drag_and_drop": " ou arraste e solte",
|
||||||
"max_file_size": "Tamanho máx. do arquivo",
|
"max_file_size": "Tamanho máx. do arquivo",
|
||||||
@@ -361,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",
|
||||||
|
|||||||
@@ -251,8 +251,9 @@
|
|||||||
"select_tags": "选择标签",
|
"select_tags": "选择标签",
|
||||||
"import": "导入",
|
"import": "导入",
|
||||||
"import_cases": "导入测试用例",
|
"import_cases": "导入测试用例",
|
||||||
"import_available": "您可以从 Excel 文件 (xlsx, xls) 导入测试用例。导入的 Excel 文件必须遵循指定格式。",
|
"import_available": "您可以从 Excel 文件 (xlsx, xls) 或 JSON 文件 (json) 导入测试用例。导入的文件必须遵循指定格式。",
|
||||||
"download_template": "下载模板",
|
"download_template": "下载模板",
|
||||||
|
"download_json_sample": "下载 JSON 示例",
|
||||||
"click_to_upload": "点击上传",
|
"click_to_upload": "点击上传",
|
||||||
"or_drag_and_drop": " 或拖拽文件到此处",
|
"or_drag_and_drop": " 或拖拽文件到此处",
|
||||||
"max_file_size": "最大文件大小",
|
"max_file_size": "最大文件大小",
|
||||||
@@ -361,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": "评论",
|
||||||
|
|||||||
438
frontend/messages/zh-TW.json
Normal file
438
frontend/messages/zh-TW.json
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
{
|
||||||
|
"RunStatus": {
|
||||||
|
"new": "新建",
|
||||||
|
"inProgress": "進行中",
|
||||||
|
"underReview": "審核中",
|
||||||
|
"rejected": "已拒絕",
|
||||||
|
"done": "完成",
|
||||||
|
"closed": "已關閉"
|
||||||
|
},
|
||||||
|
"RunCaseStatus": {
|
||||||
|
"untested": "未測試",
|
||||||
|
"passed": "通過",
|
||||||
|
"failed": "失敗",
|
||||||
|
"retest": "重測",
|
||||||
|
"skipped": "略過"
|
||||||
|
},
|
||||||
|
"Priority": {
|
||||||
|
"critical": "嚴重",
|
||||||
|
"high": "高",
|
||||||
|
"medium": "中",
|
||||||
|
"low": "低"
|
||||||
|
},
|
||||||
|
"Type": {
|
||||||
|
"other": "其他",
|
||||||
|
"security": "安全",
|
||||||
|
"performance": "效能",
|
||||||
|
"accessibility": "無障礙",
|
||||||
|
"functional": "功能",
|
||||||
|
"acceptance": "驗收",
|
||||||
|
"usability": "易用性",
|
||||||
|
"smoke_sanity": "冒煙與健全性",
|
||||||
|
"compatibility": "相容性",
|
||||||
|
"destructive": "破壞性",
|
||||||
|
"regression": "迴歸",
|
||||||
|
"automated": "自動化",
|
||||||
|
"manual": "手動"
|
||||||
|
},
|
||||||
|
"ProjectDialog": {
|
||||||
|
"project": "專案",
|
||||||
|
"project_name": "專案名稱",
|
||||||
|
"project_detail": "專案詳情",
|
||||||
|
"public": "公開",
|
||||||
|
"private": "私有",
|
||||||
|
"if_you_make_public": "將專案設為公開將使其對非專案成員的使用者可見。",
|
||||||
|
"close": "關閉",
|
||||||
|
"create": "建立",
|
||||||
|
"update": "更新",
|
||||||
|
"please_enter": "請輸入專案名稱"
|
||||||
|
},
|
||||||
|
"Index": {
|
||||||
|
"get_started": "開始使用",
|
||||||
|
"demo": "示範",
|
||||||
|
"oss_tcms": "開源測試案例管理系統",
|
||||||
|
"integrate_and_manage": "整合並管理您所有的軟體測試。",
|
||||||
|
"oss_title": "開源",
|
||||||
|
"oss_detail": "UnitTCMS 是免費且開源的。應用程式支援自我託管,可部署於具有嚴格安全要求的環境中。",
|
||||||
|
"organize_title": "組織測試案例",
|
||||||
|
"organize_detail": "可以透過專案與資料夾來組織測試案例。現代化的 UI 框架使其運行迅速。",
|
||||||
|
"usability_title": "易用性",
|
||||||
|
"usability_detail": "定義好的測試案例可以在測試執行中反覆使用。測試執行與專案的狀態可以透過圖表直覺地檢視。",
|
||||||
|
"universal_title": "通用性",
|
||||||
|
"universal_detail": "多語言支援與深色模式讓任何人都能無障礙地使用此系統。",
|
||||||
|
"project_title": "以專案為基礎",
|
||||||
|
"project_subtitle": "以專案為單位管理測試案例與測試執行。我們的儀表板提供了每個專案中測試案例類型及其進度的概覽。這使您能夠即時監控專案狀態並進行高效率的管理。",
|
||||||
|
"case_management_title": "測試案例管理",
|
||||||
|
"case_management_subtitle": "在專案中建立資料夾,利用我們現代化且直覺的 UI 輕鬆定義測試案例。支援附件上傳,可對測試案例進行詳細說明,便於在整個團隊中共享資訊。",
|
||||||
|
"run_management_title": "測試執行管理",
|
||||||
|
"run_management_subtitle": "定義好的測試案例可以在測試執行中多次重複使用,實現高效率的測試循環。此外,您還可以視覺化地監控測試執行與專案的狀態。",
|
||||||
|
"member_management_title": "成員管理",
|
||||||
|
"member_management_subtitle": "透過在專案中新增或移除成員來支援團隊開發。您可以為每個成員指派角色並詳細設定權限。我們提供三種主要角色:「管理者」管理整個專案,「開發者」設計測試,「報告者」執行測試。"
|
||||||
|
},
|
||||||
|
"Header": {
|
||||||
|
"title": "開源測試案例管理系統",
|
||||||
|
"description": "整合並管理您所有的軟體測試",
|
||||||
|
"docs": "文件",
|
||||||
|
"roadmap": "路線圖",
|
||||||
|
"projects": "專案",
|
||||||
|
"admin": "管理後台",
|
||||||
|
"account": "帳戶",
|
||||||
|
"profile_settings": "個人設定",
|
||||||
|
"signup": "註冊",
|
||||||
|
"signin": "登入",
|
||||||
|
"signout": "登出",
|
||||||
|
"links": "連結",
|
||||||
|
"languages": "語言"
|
||||||
|
},
|
||||||
|
"Toast": {
|
||||||
|
"need_signed_in": "您需要先登入",
|
||||||
|
"session_expired": "工作階段已過期"
|
||||||
|
},
|
||||||
|
"Auth": {
|
||||||
|
"account": "帳戶",
|
||||||
|
"signup": "註冊",
|
||||||
|
"signin": "登入",
|
||||||
|
"or_signup": "還沒有帳號?前往註冊",
|
||||||
|
"or_signin": "已有帳號?前往登入",
|
||||||
|
"signin_as_guest": "或以訪客身分登入",
|
||||||
|
"email": "電子郵件",
|
||||||
|
"username": "使用者名稱",
|
||||||
|
"password": "密碼",
|
||||||
|
"confirm_password": "確認密碼",
|
||||||
|
"invalid_email": "無效的電子郵件地址",
|
||||||
|
"invalid_password": "密碼必須至少包含 8 個字元",
|
||||||
|
"invalid_locale": "無效的語言",
|
||||||
|
"username_empty": "使用者名稱不能為空",
|
||||||
|
"password_empty": "密碼不能為空",
|
||||||
|
"password_not_match": "密碼不一致",
|
||||||
|
"email_already_exist": "電子郵件已被註冊",
|
||||||
|
"email_not_exist": "找不到該電子郵件地址",
|
||||||
|
"signup_error": "註冊失敗",
|
||||||
|
"signin_error": "登入失敗",
|
||||||
|
"demo_page_warning": "這是一個示範環境,您輸入的任何資料都將被定期重置。",
|
||||||
|
"your_projects": "您的專案",
|
||||||
|
"public": "公開",
|
||||||
|
"private": "私有",
|
||||||
|
"not_own_any_projects": "您沒有任何專案。",
|
||||||
|
"find_projects": "探索專案",
|
||||||
|
"profile_settings": "個人設定",
|
||||||
|
"change_username": "變更使用者名稱",
|
||||||
|
"new_username": "新使用者名稱",
|
||||||
|
"update_username": "更新使用者名稱",
|
||||||
|
"username_updated": "使用者名稱更新成功",
|
||||||
|
"change_password": "變更密碼",
|
||||||
|
"current_password": "目前密碼",
|
||||||
|
"new_password": "新密碼",
|
||||||
|
"confirm_new_password": "確認新密碼",
|
||||||
|
"update_password": "更新密碼",
|
||||||
|
"password_updated": "密碼更新成功",
|
||||||
|
"change_locale": "變更語言",
|
||||||
|
"update_locale": "更新語言",
|
||||||
|
"locale_updated": "語言更新成功",
|
||||||
|
"change_avatar": "變更頭像",
|
||||||
|
"upload_avatar": "上傳頭像",
|
||||||
|
"remove_avatar": "移除頭像",
|
||||||
|
"avatar_updated": "頭像更新成功",
|
||||||
|
"avatar_removed": "頭像移除成功",
|
||||||
|
"max_file_size_5mb": "檔案大小上限:5MB",
|
||||||
|
"only_images_allowed": "僅允許上傳圖片檔案",
|
||||||
|
"current_password_incorrect": "目前密碼不正確",
|
||||||
|
"update_error": "更新失敗"
|
||||||
|
},
|
||||||
|
"Health": {
|
||||||
|
"health_check": "健康檢查",
|
||||||
|
"status": "狀態",
|
||||||
|
"ok": "正常",
|
||||||
|
"error": "錯誤",
|
||||||
|
"api_server": "API 伺服器",
|
||||||
|
"unittcms_version": "UnitTCMS 版本"
|
||||||
|
},
|
||||||
|
"Admin": {
|
||||||
|
"user_management": "使用者管理",
|
||||||
|
"avatar": "頭像",
|
||||||
|
"id": "ID",
|
||||||
|
"email": "電子郵件",
|
||||||
|
"username": "使用者名稱",
|
||||||
|
"role": "角色",
|
||||||
|
"administrator": "管理員",
|
||||||
|
"user": "使用者",
|
||||||
|
"no_users_found": "找不到使用者",
|
||||||
|
"quit_admin": "放棄管理員權限",
|
||||||
|
"close": "關閉",
|
||||||
|
"quit": "放棄",
|
||||||
|
"quit_confirm": "如果您放棄管理員權限,將無法再存取管理頁面。",
|
||||||
|
"role_changed": "角色已變更",
|
||||||
|
"lost_admin_auth": "已失去管理員權限",
|
||||||
|
"at_least": "至少需要保留一名管理員",
|
||||||
|
"reset_password": "重設密碼",
|
||||||
|
"reset": "重設",
|
||||||
|
"invalid_password": "密碼必須至少包含 8 個字元",
|
||||||
|
"password_not_match": "密碼不一致"
|
||||||
|
},
|
||||||
|
"Projects": {
|
||||||
|
"project_list": "專案列表",
|
||||||
|
"new_project": "新增專案",
|
||||||
|
"id": "ID",
|
||||||
|
"publicity": "公開性",
|
||||||
|
"public": "公開",
|
||||||
|
"private": "私有",
|
||||||
|
"name": "名稱",
|
||||||
|
"detail": "詳情",
|
||||||
|
"last_update": "最後更新",
|
||||||
|
"no_projects_found": "找不到專案"
|
||||||
|
},
|
||||||
|
"Project": {
|
||||||
|
"project": "專案",
|
||||||
|
"toggle_sidebar": "切換側邊欄",
|
||||||
|
"home": "首頁",
|
||||||
|
"test_cases": "測試案例",
|
||||||
|
"test_runs": "測試執行",
|
||||||
|
"members": "成員",
|
||||||
|
"settings": "設定"
|
||||||
|
},
|
||||||
|
"Home": {
|
||||||
|
"home": "首頁",
|
||||||
|
"Folders": "資料夾",
|
||||||
|
"test_cases": "測試案例",
|
||||||
|
"test_runs": "測試執行",
|
||||||
|
"progress": "進度",
|
||||||
|
"test_classification": "測試分類",
|
||||||
|
"by_type": "依測試類型",
|
||||||
|
"by_priority": "依測試優先級"
|
||||||
|
},
|
||||||
|
"Folders": {
|
||||||
|
"folder": "資料夾",
|
||||||
|
"new_folder": "新增資料夾",
|
||||||
|
"edit_folder": "編輯資料夾",
|
||||||
|
"delete_folder": "刪除資料夾",
|
||||||
|
"folder_name": "資料夾名稱",
|
||||||
|
"folder_detail": "資料夾詳情",
|
||||||
|
"close": "關閉",
|
||||||
|
"create": "建立",
|
||||||
|
"update": "更新",
|
||||||
|
"please_enter": "請輸入資料夾名稱",
|
||||||
|
"delete": "刪除",
|
||||||
|
"are_you_sure": "您確定要刪除此資料夾嗎?",
|
||||||
|
"no_folders_found": "找不到資料夾"
|
||||||
|
},
|
||||||
|
"Cases": {
|
||||||
|
"test_case_list": "測試案例列表",
|
||||||
|
"id": "ID",
|
||||||
|
"title": "標題",
|
||||||
|
"priority": "優先級",
|
||||||
|
"actions": "操作",
|
||||||
|
"delete_case": "刪除測試案例",
|
||||||
|
"delete": "刪除",
|
||||||
|
"close": "關閉",
|
||||||
|
"are_you_sure": "您確定要刪除測試案例嗎?",
|
||||||
|
"new_test_case": "新增",
|
||||||
|
"export": "匯出",
|
||||||
|
"status": "狀態",
|
||||||
|
"no_cases_found": "找不到測試案例",
|
||||||
|
"case_title": "測試案例標題",
|
||||||
|
"case_description": "測試案例描述",
|
||||||
|
"case_title_or_description": "測試案例標題或描述",
|
||||||
|
"create": "建立",
|
||||||
|
"please_enter": "請輸入測試案例標題",
|
||||||
|
"filter": "篩選",
|
||||||
|
"clear_all": "清除全部",
|
||||||
|
"apply": "套用",
|
||||||
|
"select_priorities": "選擇優先級",
|
||||||
|
"selected": "已選擇",
|
||||||
|
"type": "類型",
|
||||||
|
"select_types": "選擇類型",
|
||||||
|
"cases_selected": "個案例已選擇",
|
||||||
|
"select_action": "選擇操作",
|
||||||
|
"move": "移動",
|
||||||
|
"clone": "複製",
|
||||||
|
"cases_moved": "測試案例已移動",
|
||||||
|
"cases_cloned": "測試案例已複製",
|
||||||
|
"tags": "標籤",
|
||||||
|
"select_tags": "選擇標籤",
|
||||||
|
"import": "匯入",
|
||||||
|
"import_cases": "匯入測試案例",
|
||||||
|
"import_available": "您可以從 Excel 檔案 (xlsx, xls) 或 JSON 檔案 (json) 匯入測試案例。匯入的檔案必須遵循指定格式。",
|
||||||
|
"download_template": "下載範本",
|
||||||
|
"download_json_sample": "下載 JSON 範例",
|
||||||
|
"click_to_upload": "點擊上傳",
|
||||||
|
"or_drag_and_drop": " 或將檔案拖曳至此處",
|
||||||
|
"max_file_size": "檔案大小上限",
|
||||||
|
"cases_imported": "測試案例已匯入",
|
||||||
|
"create_more": "繼續建立"
|
||||||
|
},
|
||||||
|
"Case": {
|
||||||
|
"back_to_cases": "返回測試案例列表",
|
||||||
|
"updating": "更新中...",
|
||||||
|
"update": "更新",
|
||||||
|
"updated_test_case": "測試案例已更新",
|
||||||
|
"basic": "基本資訊",
|
||||||
|
"title": "標題",
|
||||||
|
"please_enter_title": "請輸入標題",
|
||||||
|
"description": "描述",
|
||||||
|
"test_case_description": "測試案例描述",
|
||||||
|
"priority": "優先級",
|
||||||
|
"type": "類型",
|
||||||
|
"template": "範本",
|
||||||
|
"test_detail": "測試詳情",
|
||||||
|
"preconditions": "前置條件",
|
||||||
|
"expected_result": "預期結果",
|
||||||
|
"step": "步驟",
|
||||||
|
"text": "文字",
|
||||||
|
"steps": "步驟",
|
||||||
|
"new_step": "新增步驟",
|
||||||
|
"details_of_the_step": "步驟詳情",
|
||||||
|
"delete_this_step": "刪除此步驟",
|
||||||
|
"insert_step": "插入步驟",
|
||||||
|
"attachments": "附件",
|
||||||
|
"delete": "刪除",
|
||||||
|
"download": "下載",
|
||||||
|
"delete_file": "刪除檔案",
|
||||||
|
"click_to_upload": "點擊上傳",
|
||||||
|
"or_drag_and_drop": " 或將檔案拖曳至此處",
|
||||||
|
"max_file_size": "檔案大小上限",
|
||||||
|
"are_you_sure_leave": "您確定要離開此頁面嗎?",
|
||||||
|
"tags": "標籤",
|
||||||
|
"create_tag": "建立標籤",
|
||||||
|
"max_tags_limit": "標籤數量上限",
|
||||||
|
"tag_already_exists": "標籤已存在",
|
||||||
|
"tag_created_and_added": "標籤已建立並新增",
|
||||||
|
"error_creating_tag": "建立標籤失敗",
|
||||||
|
"error_updating_test_case": "更新測試案例失敗",
|
||||||
|
"search_or_create_tag": "搜尋或建立標籤",
|
||||||
|
"no_tags_selected": "未選擇標籤"
|
||||||
|
},
|
||||||
|
"Runs": {
|
||||||
|
"run_list": "測試執行列表",
|
||||||
|
"run": "執行",
|
||||||
|
"new_run": "新增執行",
|
||||||
|
"edit_run": "編輯執行",
|
||||||
|
"delete_run": "刪除執行",
|
||||||
|
"id": "ID",
|
||||||
|
"name": "名稱",
|
||||||
|
"description": "描述",
|
||||||
|
"last_update": "最後更新",
|
||||||
|
"actions": "操作",
|
||||||
|
"run_name": "執行名稱",
|
||||||
|
"run_description": "執行描述",
|
||||||
|
"no_runs_found": "找不到測試執行",
|
||||||
|
"close": "關閉",
|
||||||
|
"create": "建立",
|
||||||
|
"update": "更新",
|
||||||
|
"please_enter": "請輸入執行名稱",
|
||||||
|
"are_you_sure": "您確定要刪除此測試執行嗎?",
|
||||||
|
"delete": "刪除"
|
||||||
|
},
|
||||||
|
"Run": {
|
||||||
|
"back_to_runs": "返回測試執行列表",
|
||||||
|
"updating": "更新中...",
|
||||||
|
"update": "更新",
|
||||||
|
"updated_test_run": "測試執行已更新",
|
||||||
|
"export": "匯出",
|
||||||
|
"progress": "進度",
|
||||||
|
"refresh": "重新整理",
|
||||||
|
"id": "ID",
|
||||||
|
"title": "標題",
|
||||||
|
"please_enter": "請輸入執行名稱",
|
||||||
|
"description": "描述",
|
||||||
|
"priority": "優先級",
|
||||||
|
"status": "狀態",
|
||||||
|
"actions": "操作",
|
||||||
|
"select_test_case": "選擇測試案例",
|
||||||
|
"test_case_selection": "測試案例選擇",
|
||||||
|
"include_in_run": "納入執行",
|
||||||
|
"exclude_from_run": "從執行中排除",
|
||||||
|
"no_cases_found": "找不到案例",
|
||||||
|
"are_you_sure_leave": "您確定要離開此頁面嗎?",
|
||||||
|
"type": "類型",
|
||||||
|
"test_detail": "測試詳情",
|
||||||
|
"steps": "步驟",
|
||||||
|
"preconditions": "前置條件",
|
||||||
|
"expected_result": "預期結果",
|
||||||
|
"details_of_the_step": "步驟詳情",
|
||||||
|
"close": "關閉",
|
||||||
|
"filter": "篩選",
|
||||||
|
"clear_all": "清除全部",
|
||||||
|
"apply": "套用",
|
||||||
|
"select_status": "選擇狀態",
|
||||||
|
"please_save": "請儲存變更",
|
||||||
|
"case_title_or_description": "測試案例標題或描述",
|
||||||
|
"selected": "已選擇",
|
||||||
|
"tags": "標籤",
|
||||||
|
"select_tags": "選擇標籤",
|
||||||
|
"no_case_selected": "未選擇測試案例",
|
||||||
|
"case_detail": "測試案例詳情",
|
||||||
|
"comments": "留言",
|
||||||
|
"history": "歷史",
|
||||||
|
"test_record": "測試紀錄",
|
||||||
|
"download": "下載",
|
||||||
|
"delete": "刪除",
|
||||||
|
"click_to_upload": "點擊上傳",
|
||||||
|
"or_drag_and_drop": " 或將檔案拖曳至此處",
|
||||||
|
"max_file_size": "檔案大小上限"
|
||||||
|
},
|
||||||
|
"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": "成員管理",
|
||||||
|
"avatar": "頭像",
|
||||||
|
"email": "電子郵件",
|
||||||
|
"username": "使用者名稱",
|
||||||
|
"role": "角色",
|
||||||
|
"manager": "管理者",
|
||||||
|
"developer": "開發者",
|
||||||
|
"reporter": "報告者",
|
||||||
|
"delete": "刪除",
|
||||||
|
"deleteMember": "刪除成員",
|
||||||
|
"no_members_found": "找不到成員",
|
||||||
|
"add_member": "新增成員",
|
||||||
|
"user_name_or_email": "使用者名稱或電子郵件",
|
||||||
|
"close": "關閉",
|
||||||
|
"add": "新增",
|
||||||
|
"are_you_sure": "您確定要刪除此成員嗎?",
|
||||||
|
"member_added": "成員已新增",
|
||||||
|
"role_changed": "成員角色已變更",
|
||||||
|
"member_deleted": "成員已刪除"
|
||||||
|
},
|
||||||
|
"Settings": {
|
||||||
|
"project_management": "專案管理",
|
||||||
|
"project_name": "專案名稱",
|
||||||
|
"project_detail": "專案詳情",
|
||||||
|
"project_owner": "專案擁有者",
|
||||||
|
"edit_project": "編輯專案",
|
||||||
|
"publicity": "公開性",
|
||||||
|
"public": "公開",
|
||||||
|
"private": "私有",
|
||||||
|
"delete_project": "刪除專案",
|
||||||
|
"delete": "刪除",
|
||||||
|
"close": "關閉",
|
||||||
|
"are_you_sure": "您確定要刪除此專案嗎?",
|
||||||
|
"tag_management": "標籤管理",
|
||||||
|
"tag_name": "標籤名稱",
|
||||||
|
"add_tag": "新增標籤",
|
||||||
|
"no_tags_available": "尚無標籤",
|
||||||
|
"delete_tag": "刪除標籤",
|
||||||
|
"are_you_sure_delete_tag": "您確定要刪除此標籤嗎?",
|
||||||
|
"tag_created": "標籤建立成功。",
|
||||||
|
"tag_updated": "標籤更新成功。",
|
||||||
|
"tag_deleted": "標籤刪除成功。",
|
||||||
|
"tag_error_empty": "標籤名稱不能為空。",
|
||||||
|
"tag_error_min_length": "標籤名稱長度至少為 3 個字元。",
|
||||||
|
"tag_error_max_length": "標籤名稱不能超過 20 個字元。",
|
||||||
|
"tag_error_create": "標籤建立失敗,請重試。",
|
||||||
|
"tag_error_update": "標籤更新失敗,請重試。",
|
||||||
|
"tag_error_delete": "標籤刪除失敗,請重試。"
|
||||||
|
}
|
||||||
|
}
|
||||||
33
frontend/public/template/unittcms-import-sample.json
Normal file
33
frontend/public/template/unittcms-import-sample.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"title": "User can sign in with valid credentials",
|
||||||
|
"priority": "high",
|
||||||
|
"type": "functional",
|
||||||
|
"template": "text",
|
||||||
|
"automationStatus": "automation-not-required",
|
||||||
|
"description": "Verify that a registered user can sign in.",
|
||||||
|
"preConditions": "A user account already exists.",
|
||||||
|
"expectedResults": "The user is redirected to the projects page after signing in.",
|
||||||
|
"Steps": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Create a new project",
|
||||||
|
"priority": "medium",
|
||||||
|
"type": "acceptance",
|
||||||
|
"template": "step",
|
||||||
|
"automationStatus": "cannot-be-automated",
|
||||||
|
"description": "Verify that a signed-in user can create a project.",
|
||||||
|
"preConditions": "The user is signed in.",
|
||||||
|
"expectedResults": "The new project appears in the project list.",
|
||||||
|
"Steps": [
|
||||||
|
{
|
||||||
|
"step": "Click the \"New project\" button.",
|
||||||
|
"expectedStepResult": "The project creation dialog opens."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": "Enter a project name and submit.",
|
||||||
|
"expectedStepResult": "The dialog closes and the project is created."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -73,6 +73,10 @@ export default function CaseImportDialog({ isOpen, folderId, isDisabled, onImpor
|
|||||||
<a href="/template/unittcms-import-template-v1.1.xlsx" download className="text-tiny underline">
|
<a href="/template/unittcms-import-template-v1.1.xlsx" download className="text-tiny underline">
|
||||||
{messages.downloadTemplate}
|
{messages.downloadTemplate}
|
||||||
</a>
|
</a>
|
||||||
|
<span className="mx-2 text-tiny">|</span>
|
||||||
|
<a href="/template/unittcms-import-sample.json" download className="text-tiny underline">
|
||||||
|
{messages.downloadJsonSample}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{importError && <Alert color="danger" className="mt-1" title="error" description={importError} />}
|
{importError && <Alert color="danger" className="mt-1" title="error" description={importError} />}
|
||||||
@@ -101,6 +105,7 @@ export default function CaseImportDialog({ isOpen, folderId, isDisabled, onImpor
|
|||||||
<input
|
<input
|
||||||
id="dropzone-file"
|
id="dropzone-file"
|
||||||
type="file"
|
type="file"
|
||||||
|
accept=".xlsx,.xls,.json"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
onChange={(e) => handleInput(e)}
|
onChange={(e) => handleInput(e)}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export default function Page({ params }: { params: { projectId: string; folderId
|
|||||||
importCases: t('import_cases'),
|
importCases: t('import_cases'),
|
||||||
importAvailable: t('import_available'),
|
importAvailable: t('import_available'),
|
||||||
downloadTemplate: t('download_template'),
|
downloadTemplate: t('download_template'),
|
||||||
|
downloadJsonSample: t('download_json_sample'),
|
||||||
clickToUpload: t('click_to_upload'),
|
clickToUpload: t('click_to_upload'),
|
||||||
orDragAndDrop: t('or_drag_and_drop'),
|
orDragAndDrop: t('or_drag_and_drop'),
|
||||||
maxFileSize: t('max_file_size'),
|
maxFileSize: t('max_file_size'),
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createNavigation } from 'next-intl/navigation';
|
|||||||
import { defineRouting } from 'next-intl/routing';
|
import { defineRouting } from 'next-intl/routing';
|
||||||
|
|
||||||
export const routing = defineRouting({
|
export const routing = defineRouting({
|
||||||
locales: ['de', 'en', 'pt-BR', 'zh-CN', 'ja'],
|
locales: ['de', 'en', 'pt-BR', 'zh-CN', 'zh-TW', 'ja'],
|
||||||
defaultLocale: 'en',
|
defaultLocale: 'en',
|
||||||
localePrefix: {
|
localePrefix: {
|
||||||
mode: 'always',
|
mode: 'always',
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ import { routing } from './i18n/routing';
|
|||||||
export default createMiddleware(routing);
|
export default createMiddleware(routing);
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ['/', '/(de|en|pt-BR|zh-CN|ja)/:path*'],
|
matcher: ['/', '/(de|en|pt-BR|zh-CN|zh-TW|ja)/:path*'],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ type CasesMessages = {
|
|||||||
importCases: string;
|
importCases: string;
|
||||||
importAvailable: string;
|
importAvailable: string;
|
||||||
downloadTemplate: string;
|
downloadTemplate: string;
|
||||||
|
downloadJsonSample: string;
|
||||||
clickToUpload: string;
|
clickToUpload: string;
|
||||||
orDragAndDrop: string;
|
orDragAndDrop: string;
|
||||||
maxFileSize: string;
|
maxFileSize: string;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type LocaleCodeType = 'de' | 'en' | 'pt-BR' | 'zh-CN' | 'ja';
|
export type LocaleCodeType = 'de' | 'en' | 'pt-BR' | 'zh-CN' | 'zh-TW' | 'ja';
|
||||||
|
|
||||||
export type LocaleType = {
|
export type LocaleType = {
|
||||||
code: LocaleCodeType;
|
code: LocaleCodeType;
|
||||||
|
|||||||
@@ -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