11 Commits

Author SHA1 Message Date
LittleYellow
55e78875ae feat: support JSON import for test cases and add Traditional Chinese locale
Some checks failed
UnitTCMS CI / unittcms-test (push) Has been cancelled
JSON import lets users round-trip cases exported via the JSON download
endpoint back into a folder, alongside the existing Excel import path.
2026-06-16 21:35:05 +08:00
kimatata
e6f3bc799e Merge pull request #411 from kimatata/develop
Some checks failed
UnitTCMS CI / unittcms-test (push) Has been cancelled
2026-03-08 14:24:44 +09:00
kimatata
4dcff48792 chore: release (#410) 2026-03-08 14:19:11 +09:00
Copilot
4cafcaeedc fix: spurious unsaved-changes warning when navigating between test cases in a test run (#406)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: kimatata <117462761+kimatata@users.noreply.github.com>
2026-03-08 14:13:18 +09:00
Han Sen
93e66d0122 fix: Step case overwriting prevention by hypothetical ID (#396) 2026-03-03 14:31:59 +09:00
CY
a065c1800f feat: Move language option to profile setting (#387) 2026-02-26 23:51:12 +09:00
kimatata
3b059f1897 Merge pull request #394 from kimatata/develop 2026-02-23 12:41:26 +09:00
kimatata
9a5f0a602a chore: release (#393) 2026-02-23 12:29:29 +09:00
kimatata
a9674c81ab feat: comment to test run's case (#390) 2026-02-23 12:26:07 +09:00
CY
1f4ac0ae7b fix: Test case selector overflow (#385) 2026-01-31 12:12:12 +09:00
CY
fe8983e212 feat: Folder column size adjustment (#384) 2026-01-29 08:53:24 +09:00
64 changed files with 2554 additions and 242 deletions

View File

@@ -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
backend/config/locale.js Normal file
View File

@@ -0,0 +1 @@
export const SUPPORTED_LOCALES = ['de', 'en', 'pt-BR', 'zh-CN', 'zh-TW', 'ja'];

View File

@@ -5,6 +5,7 @@ import defineProject from '../models/projects.js';
import defineFolder from '../models/folders.js'; import defineFolder from '../models/folders.js';
import defineCase from '../models/cases.js'; import defineCase from '../models/cases.js';
import defineRun from '../models/runs.js'; import defineRun from '../models/runs.js';
import defineRunCase from '../models/runCases.js';
export default function verifyEditableMiddleware(sequelize) { export default function verifyEditableMiddleware(sequelize) {
/** /**
@@ -243,6 +244,55 @@ export default function verifyEditableMiddleware(sequelize) {
return res.status(403).json({ error: 'Forbidden' }); return res.status(403).json({ error: 'Forbidden' });
} }
/**
* Verify user is reporter of the project by CommentableId
* (have to be called after verifySignedIn() middleware)
*/
async function verifyProjectReporterFromCommentableId(req, res, next) {
const commentableType = req.params.commentableType || req.query.commentableType;
const commentableId = req.params.commentableId || req.query.commentableId;
if (!commentableType || !commentableId) {
return res.status(400).json({ error: 'commentableType and commentableId are required' });
}
if (commentableType === 'Run') {
// not implemented yet
next();
return;
} else if (commentableType === 'Case') {
// not implemented yet
next();
return;
} else if (commentableType === 'RunCase') {
const RunCase = defineRunCase(sequelize, DataTypes);
const runCaseId = req.params.commentableId || req.query.commentableId;
if (!runCaseId) {
return res.status(400).json({ error: 'runCaseId is required' });
}
const runCase = await RunCase.findByPk(runCaseId);
const runId = runCase && runCase.runId;
if (!runId) {
return res.status(404).send('failed to find runId');
}
const Run = defineRun(sequelize, DataTypes);
const run = await Run.findByPk(runId);
const projectId = run && run.projectId;
if (!projectId) {
return res.status(404).send('failed to find projectId');
}
const isReporterRet = await isReporter(projectId, req.userId);
if (isReporterRet) {
next();
return;
}
} else {
return res.status(400).json({ error: 'unsupported commentableType' });
}
}
async function isReporter(projectId, userId) { async function isReporter(projectId, userId) {
const Project = defineProject(sequelize, DataTypes); const Project = defineProject(sequelize, DataTypes);
const Member = defineMember(sequelize, DataTypes); const Member = defineMember(sequelize, DataTypes);
@@ -289,5 +339,6 @@ export default function verifyEditableMiddleware(sequelize) {
verifyProjectDeveloperFromCaseId, verifyProjectDeveloperFromCaseId,
verifyProjectReporterFromProjectId, verifyProjectReporterFromProjectId,
verifyProjectReporterFromRunId, verifyProjectReporterFromRunId,
verifyProjectReporterFromCommentableId,
}; };
} }

View File

@@ -4,6 +4,7 @@ import defineProject from '../models/projects.js';
import defineFolder from '../models/folders.js'; import defineFolder from '../models/folders.js';
import defineCase from '../models/cases.js'; import defineCase from '../models/cases.js';
import defineRun from '../models/runs.js'; import defineRun from '../models/runs.js';
import defineRunCase from '../models/runCases.js';
export default function verifyVisibleMiddleware(sequelize) { export default function verifyVisibleMiddleware(sequelize) {
/** /**
@@ -16,8 +17,8 @@ export default function verifyVisibleMiddleware(sequelize) {
return res.status(400).json({ error: 'projectId is required' }); return res.status(400).json({ error: 'projectId is required' });
} }
const isVisble = await isVisible(projectId, req.userId); const visible = await isVisible(projectId, req.userId);
if (isVisble) { if (visible) {
next(); next();
return; return;
} }
@@ -44,8 +45,8 @@ export default function verifyVisibleMiddleware(sequelize) {
return res.status(404).send('failed to find projectId'); return res.status(404).send('failed to find projectId');
} }
const isVisble = await isVisible(projectId, req.userId); const visible = await isVisible(projectId, req.userId);
if (isVisble) { if (visible) {
next(); next();
return; return;
} }
@@ -80,8 +81,8 @@ export default function verifyVisibleMiddleware(sequelize) {
return res.status(404).send('failed to find projectId'); return res.status(404).send('failed to find projectId');
} }
const isVisble = await isVisible(projectId, req.userId); const visible = await isVisible(projectId, req.userId);
if (isVisble) { if (visible) {
next(); next();
return; return;
} }
@@ -108,8 +109,8 @@ export default function verifyVisibleMiddleware(sequelize) {
return res.status(404).send('failed to find projectId'); return res.status(404).send('failed to find projectId');
} }
const isVisble = await isVisible(projectId, req.userId); const visible = await isVisible(projectId, req.userId);
if (isVisble) { if (visible) {
next(); next();
return; return;
} }
@@ -117,6 +118,51 @@ export default function verifyVisibleMiddleware(sequelize) {
return res.status(403).json({ error: 'Forbidden' }); return res.status(403).json({ error: 'Forbidden' });
} }
async function verifyProjectVisibleFromCommentableId(req, res, next) {
const commentableType = req.params.commentableType || req.query.commentableType;
const commentableId = req.params.commentableId || req.query.commentableId;
if (!commentableType || !commentableId) {
return res.status(400).json({ error: 'commentableType and commentableId are required' });
}
if (commentableType === 'Run') {
// not implemented yet
next();
return;
} else if (commentableType === 'Case') {
// not implemented yet
next();
return;
} else if (commentableType === 'RunCase') {
const RunCase = defineRunCase(sequelize, DataTypes);
const runCaseId = req.params.commentableId || req.query.commentableId;
if (!runCaseId) {
return res.status(400).json({ error: 'runCaseId is required' });
}
const runCase = await RunCase.findByPk(runCaseId);
const runId = runCase && runCase.runId;
if (!runId) {
return res.status(404).send('failed to find runId');
}
const Run = defineRun(sequelize, DataTypes);
const run = await Run.findByPk(runId);
const projectId = run && run.projectId;
if (!projectId) {
return res.status(404).send('failed to find projectId');
}
const visible = await isVisible(projectId, req.userId);
if (visible) {
next();
return;
}
} else {
return res.status(400).json({ error: 'unsupported commentableType' });
}
}
async function isVisible(projectId, userId) { async function isVisible(projectId, userId) {
const Project = defineProject(sequelize, DataTypes); const Project = defineProject(sequelize, DataTypes);
const Member = defineMember(sequelize, DataTypes); const Member = defineMember(sequelize, DataTypes);
@@ -158,5 +204,6 @@ export default function verifyVisibleMiddleware(sequelize) {
verifyProjectVisibleFromFolderId, verifyProjectVisibleFromFolderId,
verifyProjectVisibleFromCaseId, verifyProjectVisibleFromCaseId,
verifyProjectVisibleFromRunId, verifyProjectVisibleFromRunId,
verifyProjectVisibleFromCommentableId,
}; };
} }

View File

@@ -0,0 +1,47 @@
export async function up(queryInterface, Sequelize) {
await queryInterface.createTable('comments', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
},
commentableType: {
type: Sequelize.STRING,
allowNull: false,
},
commentableId: {
type: Sequelize.INTEGER,
allowNull: false,
},
userId: {
type: Sequelize.INTEGER,
references: {
model: 'users',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
},
content: {
type: Sequelize.TEXT,
allowNull: false,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
});
// Add composite index for efficient polymorphic queries
await queryInterface.addIndex('comments', ['commentableType', 'commentableId'], {
name: 'comments_commentable_index',
});
}
export async function down(queryInterface) {
await queryInterface.dropTable('comments');
}

View File

@@ -0,0 +1,10 @@
export async function up(queryInterface, Sequelize) {
await queryInterface.addColumn('users', 'locale', {
type: Sequelize.STRING(20),
allowNull: true,
});
}
export async function down(queryInterface) {
await queryInterface.removeColumn('users', 'locale');
}

View File

@@ -0,0 +1,47 @@
function defineComment(sequelize, DataTypes) {
const Comment = sequelize.define('Comment', {
commentableType: {
type: DataTypes.STRING,
allowNull: false,
},
commentableId: {
type: DataTypes.INTEGER,
allowNull: false,
},
userId: {
type: DataTypes.INTEGER,
allowNull: true,
},
content: {
type: DataTypes.TEXT,
allowNull: false,
},
});
Comment.associate = (models) => {
// Polymorphic associations
Comment.belongsTo(models.RunCase, {
foreignKey: 'commentableId',
constraints: false,
as: 'runCase',
});
Comment.belongsTo(models.Run, {
foreignKey: 'commentableId',
constraints: false,
as: 'run',
});
Comment.belongsTo(models.Case, {
foreignKey: 'commentableId',
constraints: false,
as: 'case',
});
Comment.belongsTo(models.User, {
foreignKey: 'userId',
onDelete: 'SET NULL',
});
};
return Comment;
}
export default defineComment;

View File

@@ -23,6 +23,10 @@ function defineRunCase(sequelize, DataTypes) {
foreignKey: 'caseId', foreignKey: 'caseId',
onDelete: 'CASCADE', onDelete: 'CASCADE',
}); });
RunCase.hasMany(models.Comment, {
foreignKey: 'commentableId',
onDelete: 'CASCADE',
});
}; };
return RunCase; return RunCase;

View File

@@ -22,6 +22,10 @@ function defineUser(sequelize, DataTypes) {
avatarPath: { avatarPath: {
type: DataTypes.STRING, type: DataTypes.STRING,
}, },
locale: {
type: DataTypes.STRING(20),
allowNull: true,
},
}, },
{ underscored: true } { underscored: true }
); );

View File

@@ -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,61 +63,23 @@ 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' });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const jsonData = XLSX.utils.sheet_to_json(worksheet);
let currentTitle = null;
let previousTitle = null;
let stepNo = 1;
const casesToCreate = [];
const stepsToCreate = [];
for (const [index, row] of jsonData.entries()) {
const errorMessage = _getRowValidationError(row, index);
if (errorMessage) {
return res.status(400).json({ error: errorMessage });
}
// Add step to the same case if the current row title is equal to previous row title
// This handle cases with multiple steps :)
currentTitle = row['title'].trim();
previousTitle = casesToCreate[casesToCreate.length - 1]?.title.trim();
if (casesToCreate.length > 0 && previousTitle === currentTitle) {
stepNo += 1;
stepsToCreate.push({
caseIndex: casesToCreate.length - 1,
stepNo: stepNo,
step: row['step'] || '',
result: row['expectedStepResult'] || '',
});
} else {
stepNo = 1;
casesToCreate.push({
folderId: folderId,
title: currentTitle,
description: row['description'] || '',
state: 0, // default state
priority: row['priority'] ? priorities.indexOf(row['priority']) : priorities.indexOf('medium'),
type: row['type'] ? testTypes.indexOf(row['type']) : testTypes.indexOf('other'),
preConditions: row['preConditions'],
expectedResults: row['expectedResults'],
automationStatus: row['automationStatus']
? automationStatus.indexOf(row['automationStatus'])
: automationStatus.indexOf('automation-not-required'),
template: row['template'] ? templates.indexOf(row['template']) : templates.indexOf('text'),
});
stepsToCreate.push({
caseIndex: casesToCreate.length - 1,
stepNo: stepNo,
step: row['step'] || '',
result: row['expectedStepResult'] || '',
});
}
}
// 'Manually' create cases, steps and caseStep association. // 'Manually' create cases, steps and caseStep association.
const createdCases = await Case.bulkCreate(casesToCreate, { transaction: t }); const createdCases = await Case.bulkCreate(casesToCreate, { transaction: t });
for (const stepData of stepsToCreate) { for (const stepData of stepsToCreate) {
@@ -147,6 +106,9 @@ export default function (sequelize) {
} catch (error) { } catch (error) {
await t.rollback(); await t.rollback();
console.error(error); console.error(error);
if (!res.headersSent) {
res.status(500).json({ error: 'Internal Server Error' });
}
} }
} }
); );
@@ -154,6 +116,120 @@ export default function (sequelize) {
return router; 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 worksheet = workbook.Sheets[sheetName];
const jsonData = XLSX.utils.sheet_to_json(worksheet);
let currentTitle = null;
let previousTitle = null;
let stepNo = 1;
const casesToCreate = [];
const stepsToCreate = [];
for (const [index, row] of jsonData.entries()) {
const errorMessage = _getRowValidationError(row, index);
if (errorMessage) {
throw new Error(errorMessage);
}
// Add step to the same case if the current row title is equal to previous row title
// This handle cases with multiple steps :)
currentTitle = row['title'].trim();
previousTitle = casesToCreate[casesToCreate.length - 1]?.title.trim();
if (casesToCreate.length > 0 && previousTitle === currentTitle) {
stepNo += 1;
stepsToCreate.push({
caseIndex: casesToCreate.length - 1,
stepNo: stepNo,
step: row['step'] || '',
result: row['expectedStepResult'] || '',
});
} else {
stepNo = 1;
casesToCreate.push({
folderId: folderId,
title: currentTitle,
description: row['description'] || '',
state: 0, // default state
priority: row['priority'] ? priorities.indexOf(row['priority']) : priorities.indexOf('medium'),
type: row['type'] ? testTypes.indexOf(row['type']) : testTypes.indexOf('other'),
preConditions: row['preConditions'],
expectedResults: row['expectedResults'],
automationStatus: row['automationStatus']
? automationStatus.indexOf(row['automationStatus'])
: automationStatus.indexOf('automation-not-required'),
template: row['template'] ? templates.indexOf(row['template']) : templates.indexOf('text'),
});
stepsToCreate.push({
caseIndex: casesToCreate.length - 1,
stepNo: stepNo,
step: row['step'] || '',
result: row['expectedStepResult'] || '',
});
}
}
return { casesToCreate, stepsToCreate };
}
// Parse a JSON buffer into { casesToCreate, stepsToCreate }.
// Accepts the same shape produced by the JSON export (`/cases/download?type=json`):
// either an array of case objects, or an object map keyed by id.
// `id`, `folderId`, `folder` and `state` are ignored; cases are created in the
// 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');
}
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) {
const requiredFields = ['title', 'priority', 'type', 'template']; const requiredFields = ['title', 'priority', 'type', 'template'];
const rowNumber = index + 2; const rowNumber = index + 2;
@@ -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;
}

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

View File

@@ -109,7 +109,19 @@ export default function (sequelize) {
}, },
{ {
model: RunCase, model: RunCase,
attributes: ['id', 'runId', 'status'], attributes: [
'id',
'runId',
'status',
[
sequelize.literal(
'(SELECT COUNT(*) FROM `comments` WHERE `comments`.`commentableType` = ' +
sequelize.escape('RunCase') +
' AND `comments`.`commentableId` = `RunCases`.`id`)'
),
'commentCount',
],
],
// Must be 'true' when filtering by status, otherwise all cases are returned. // Must be 'true' when filtering by status, otherwise all cases are returned.
required: runCaseRequired, required: runCaseRequired,
where: { where: {

View File

@@ -7,6 +7,7 @@ import defineTag from '../../models/tags.js';
import defineAttachment from '../../models/attachments.js'; import defineAttachment from '../../models/attachments.js';
import authMiddleware from '../../middleware/auth.js'; import authMiddleware from '../../middleware/auth.js';
import visibilityMiddleware from '../../middleware/verifyVisible.js'; import visibilityMiddleware from '../../middleware/verifyVisible.js';
import defineRunCase from '../../models/runCases.js';
export default function (sequelize) { export default function (sequelize) {
const Case = defineCase(sequelize, DataTypes); const Case = defineCase(sequelize, DataTypes);
@@ -19,6 +20,10 @@ export default function (sequelize) {
Attachment.belongsToMany(Case, { through: 'caseAttachments' }); Attachment.belongsToMany(Case, { through: 'caseAttachments' });
Case.belongsToMany(Tags, { through: 'caseTags', foreignKey: 'caseId', otherKey: 'tagId' }); Case.belongsToMany(Tags, { through: 'caseTags', foreignKey: 'caseId', otherKey: 'tagId' });
Tags.belongsToMany(Case, { through: 'caseTags', foreignKey: 'tagId', otherKey: 'caseId' }); Tags.belongsToMany(Case, { through: 'caseTags', foreignKey: 'tagId', otherKey: 'caseId' });
const RunCase = defineRunCase(sequelize, DataTypes);
RunCase.belongsTo(Case, { foreignKey: 'caseId' });
Case.hasMany(RunCase, { foreignKey: 'caseId' });
const { verifySignedIn } = authMiddleware(sequelize); const { verifySignedIn } = authMiddleware(sequelize);
const { verifyProjectVisibleFromCaseId } = visibilityMiddleware(sequelize); const { verifyProjectVisibleFromCaseId } = visibilityMiddleware(sequelize);
@@ -44,6 +49,9 @@ export default function (sequelize) {
attributes: ['id', 'name'], attributes: ['id', 'name'],
through: { attributes: [] }, through: { attributes: [] },
}, },
{
model: RunCase,
},
], ],
}); });
return res.json(testcase); return res.json(testcase);

View File

@@ -0,0 +1,38 @@
import express from 'express';
const router = express.Router();
import { DataTypes } from 'sequelize';
import defineComment from '../../models/comments.js';
import authMiddleware from '../../middleware/auth.js';
export default function (sequelize) {
const { verifySignedIn } = authMiddleware(sequelize);
const Comment = defineComment(sequelize, DataTypes);
router.delete('/:commentId', verifySignedIn, async (req, res) => {
const commentId = req.params.commentId;
if (!commentId) {
return res.status(400).json({ error: 'commentId is required' });
}
try {
const comment = await Comment.findByPk(commentId);
if (!comment) {
return res.status(404).json({ error: 'Comment not found' });
}
// Verify the user owns the comment
if (comment.userId !== req.userId) {
return res.status(403).json({ error: 'Unauthorized' });
}
await comment.destroy();
res.json({ success: true });
} catch (error) {
console.error(error);
res.status(500).send('Internal Server Error');
}
});
return router;
}

View File

@@ -0,0 +1,53 @@
import express from 'express';
const router = express.Router();
import { DataTypes } from 'sequelize';
import defineComment from '../../models/comments.js';
import defineUser from '../../models/users.js';
import authMiddleware from '../../middleware/auth.js';
export default function (sequelize) {
const { verifySignedIn } = authMiddleware(sequelize);
const Comment = defineComment(sequelize, DataTypes);
const User = defineUser(sequelize, DataTypes);
Comment.belongsTo(User, { foreignKey: 'userId' });
router.put('/:commentId', verifySignedIn, async (req, res) => {
const commentId = req.params.commentId;
const { content } = req.body;
if (!commentId || !content) {
return res.status(400).json({ error: 'id and content are required' });
}
try {
const comment = await Comment.findByPk(commentId);
if (!comment) {
return res.status(404).json({ error: 'Comment not found' });
}
// Verify the user owns the comment
if (comment.userId !== req.userId) {
return res.status(403).json({ error: 'Unauthorized' });
}
await comment.update({ content });
// Fetch the comment with user data
const commentWithUser = await Comment.findByPk(commentId, {
include: [
{
model: sequelize.models.User,
attributes: ['id', 'username', 'email'],
},
],
});
res.json(commentWithUser);
} catch (error) {
console.error(error);
res.status(500).send('Internal Server Error');
}
});
return router;
}

View File

@@ -0,0 +1,46 @@
import express from 'express';
const router = express.Router();
import { DataTypes } from 'sequelize';
import defineComment from '../../models/comments.js';
import defineUser from '../../models/users.js';
import authMiddleware from '../../middleware/auth.js';
import visibilityMiddleware from '../../middleware/verifyVisible.js';
export default function (sequelize) {
const { verifySignedIn } = authMiddleware(sequelize);
const { verifyProjectVisibleFromCommentableId } = visibilityMiddleware(sequelize);
const Comment = defineComment(sequelize, DataTypes);
const User = defineUser(sequelize, DataTypes);
Comment.belongsTo(User, { foreignKey: 'userId', onDelete: 'CASCADE' });
User.hasMany(Comment, { foreignKey: 'userId', onDelete: 'CASCADE' });
router.get('/', verifySignedIn, verifyProjectVisibleFromCommentableId, async (req, res) => {
const { commentableType, commentableId } = req.query;
if (!commentableType || !commentableId) {
return res.status(400).json({ error: 'commentableType and commentableId are required' });
}
try {
const comments = await Comment.findAll({
where: {
commentableType: commentableType,
commentableId: commentableId,
},
include: [
{
model: User,
attributes: ['id', 'username', 'email'],
},
],
order: [['createdAt', 'ASC']],
});
res.json(comments);
} catch (error) {
console.error(error);
res.status(500).send('Internal Server Error');
}
});
return router;
}

View File

@@ -0,0 +1,50 @@
import express from 'express';
const router = express.Router();
import { DataTypes } from 'sequelize';
import defineComment from '../../models/comments.js';
import defineUser from '../../models/users.js';
import authMiddleware from '../../middleware/auth.js';
import editableMiddleware from '../../middleware/verifyEditable.js';
export default function (sequelize) {
const { verifySignedIn } = authMiddleware(sequelize);
const { verifyProjectReporterFromCommentableId } = editableMiddleware(sequelize);
const Comment = defineComment(sequelize, DataTypes);
const User = defineUser(sequelize, DataTypes);
Comment.belongsTo(User, { foreignKey: 'userId' });
router.post('/', verifySignedIn, verifyProjectReporterFromCommentableId, async (req, res) => {
const { commentableType, commentableId } = req.query;
const { content } = req.body;
if (!commentableType || !commentableId || !content) {
return res.status(400).json({ error: 'commentableType, commentableId, and content are required' });
}
try {
const newComment = await Comment.create({
commentableType: commentableType,
commentableId: commentableId,
userId: req.userId,
content: content,
});
// Fetch the comment with user data
const commentWithUser = await Comment.findByPk(newComment.id, {
include: [
{
model: sequelize.models.User,
attributes: ['id', 'username', 'email'],
},
],
});
res.json(commentWithUser);
} catch (error) {
console.error(error);
res.status(500).send('Internal Server Error');
}
});
return router;
}

View File

@@ -126,7 +126,7 @@ export default function (sequelize) {
// Return updated user without password // Return updated user without password
const updatedUser = await User.findByPk(userId, { const updatedUser = await User.findByPk(userId, {
attributes: ['id', 'email', 'username', 'role', 'avatarPath'], attributes: ['id', 'email', 'username', 'role', 'avatarPath', 'locale'],
}); });
res.json({ user: updatedUser }); res.json({ user: updatedUser });

View File

@@ -0,0 +1,46 @@
import express from 'express';
import { DataTypes } from 'sequelize';
import defineUser from '../../models/users.js';
import authMiddleware from '../../middleware/auth.js';
import { SUPPORTED_LOCALES } from '../../config/locale.js';
const router = express.Router();
export default function (sequelize) {
const { verifySignedIn } = authMiddleware(sequelize);
const User = defineUser(sequelize, DataTypes);
router.put('/locale', verifySignedIn, async (req, res) => {
try {
const userId = req.userId;
const user = await User.findByPk(userId);
if (!user) {
return res.status(404).send('User not found');
}
const { locale } = req.body;
const normalizedLocale = typeof locale === 'string' ? locale.trim() : '';
if (!normalizedLocale || normalizedLocale.length === 0) {
return res.status(400).send('Locale is required');
}
if (!SUPPORTED_LOCALES.includes(normalizedLocale)) {
return res.status(400).send('Invalid locale');
}
await user.update({ locale: normalizedLocale });
const updatedUser = await User.findByPk(userId, {
attributes: ['id', 'email', 'username', 'role', 'avatarPath', 'locale'],
});
res.json({ user: updatedUser });
} catch (error) {
console.error(error);
res.status(500).send('Internal Server Error');
}
});
return router;
}

View File

@@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import request from 'supertest';
import express from 'express';
import { Sequelize } from 'sequelize';
import { SUPPORTED_LOCALES } from '../../config/locale.js';
import updateLocaleRoute from './updateLocale.js';
// mock of authentication middleware
let mockUserId = 1;
vi.mock('../../middleware/auth.js', () => ({
default: () => ({
verifySignedIn: vi.fn((req, res, next) => {
req.userId = mockUserId; // Mock user ID
next();
}),
}),
}));
// mock defineUser
const mockUsers = new Map();
const mockUser = {
findByPk: vi.fn((id) => {
const user = mockUsers.get(id);
if (!user) return null;
return {
...user,
update: vi.fn(async (data) => {
Object.assign(user, data);
return user;
}),
};
}),
};
vi.mock('../../models/users.js', () => ({
default: () => mockUser,
}));
describe('User Locale Routes', () => {
let app;
const sequelize = new Sequelize({
dialect: 'sqlite',
logging: false,
});
beforeEach(() => {
app = express();
app.use(express.json());
app.use('/users', updateLocaleRoute(sequelize));
// Reset mock users
mockUsers.clear();
mockUserId = 1;
// Create a test user
mockUsers.set(1, {
id: 1,
email: 'test@example.com',
username: 'testuser',
password: '',
role: 1,
avatarPath: null,
locale: null,
});
vi.clearAllMocks();
});
it('should update locale', async () => {
const newLocale = SUPPORTED_LOCALES[0];
const response = await request(app).put('/users/locale').send({ locale: newLocale });
expect(response.status).toBe(200);
expect(response.body.user.locale).toBe(newLocale);
});
it('should replace existing locale', async () => {
mockUsers.set(1, {
locale: SUPPORTED_LOCALES[0],
});
const response = await request(app).put('/users/locale').send({ locale: SUPPORTED_LOCALES[1] });
expect(response.status).toBe(200);
expect(response.body.user.locale).toBe(SUPPORTED_LOCALES[1]);
});
it.each([' ', 'chinese', 'english'])('should reject not supported locale: %s', async (locale) => {
const response = await request(app).put('/users/locale').send({ locale });
expect(response.status).toBe(400);
});
});

View File

@@ -26,7 +26,7 @@ export default function (sequelize) {
// Return updated user without password // Return updated user without password
const updatedUser = await User.findByPk(userId, { const updatedUser = await User.findByPk(userId, {
attributes: ['id', 'email', 'username', 'role', 'avatarPath'], attributes: ['id', 'email', 'username', 'role', 'avatarPath', 'locale'],
}); });
res.json({ user: updatedUser }); res.json({ user: updatedUser });

View File

@@ -53,6 +53,7 @@ import usersSearchRoute from './routes/users/search.js';
import usersUpdateUsernameRoute from './routes/users/updateUsername.js'; import usersUpdateUsernameRoute from './routes/users/updateUsername.js';
import usersUpdatePasswordRoute from './routes/users/updatePassword.js'; import usersUpdatePasswordRoute from './routes/users/updatePassword.js';
import usersAdminResetPasswordRoute from './routes/users/adminResetPassword.js'; import usersAdminResetPasswordRoute from './routes/users/adminResetPassword.js';
import usersUpdateLocaleRoute from './routes/users/updateLocale.js';
import usersUpdateAvatarRoute from './routes/users/updateAvatar.js'; import usersUpdateAvatarRoute from './routes/users/updateAvatar.js';
import usersUpdateRoleRoute from './routes/users/updateRole.js'; import usersUpdateRoleRoute from './routes/users/updateRole.js';
import signUpRoute from './routes/users/signup.js'; import signUpRoute from './routes/users/signup.js';
@@ -63,6 +64,7 @@ app.use('/users', usersSearchRoute(sequelize));
app.use('/users', usersUpdateUsernameRoute(sequelize)); app.use('/users', usersUpdateUsernameRoute(sequelize));
app.use('/users', usersUpdatePasswordRoute(sequelize)); app.use('/users', usersUpdatePasswordRoute(sequelize));
app.use('/users', usersAdminResetPasswordRoute(sequelize)); app.use('/users', usersAdminResetPasswordRoute(sequelize));
app.use('/users', usersUpdateLocaleRoute(sequelize));
app.use('/users', usersUpdateAvatarRoute(sequelize)); app.use('/users', usersUpdateAvatarRoute(sequelize));
app.use('/users', usersUpdateRoleRoute(sequelize)); app.use('/users', usersUpdateRoleRoute(sequelize));
app.use('/users', signUpRoute(sequelize)); app.use('/users', signUpRoute(sequelize));
@@ -175,6 +177,16 @@ app.use('/tags', tagsEditRoute(sequelize));
import caseTagsEditRoute from './routes/casetags/edit.js'; import caseTagsEditRoute from './routes/casetags/edit.js';
app.use('/casetags', caseTagsEditRoute(sequelize)); app.use('/casetags', caseTagsEditRoute(sequelize));
// "/comments"
import commentsIndexRoute from './routes/comments/index.js';
import commentsNewRoute from './routes/comments/new.js';
import commentsEditRoute from './routes/comments/edit.js';
import commentsDeleteRoute from './routes/comments/delete.js';
app.use('/comments', commentsIndexRoute(sequelize));
app.use('/comments', commentsNewRoute(sequelize));
app.use('/comments', commentsEditRoute(sequelize));
app.use('/comments', commentsDeleteRoute(sequelize));
// "/home" // "/home"
import homeIndexRoute from './routes/home/index.js'; import homeIndexRoute from './routes/home/index.js';
app.use('/home', homeIndexRoute(sequelize)); app.use('/home', homeIndexRoute(sequelize));

View File

@@ -0,0 +1,95 @@
import { Button, Textarea, Card, CardBody } from '@heroui/react';
import { Trash2, Edit2 } from 'lucide-react';
import UserAvatar from './UserAvatar';
import { CommentMessages, CommentType } from '@/types/comment';
type Props = {
comment: CommentType;
isEditing: boolean;
canEdit: boolean;
editContent: string;
isSubmitting: boolean;
messages: CommentMessages;
onEditContentChange: (value: string) => void;
onStartEdit: () => void;
onCancelEdit: () => void;
onSave: () => void;
onDelete: () => void;
};
export default function CommentItem({
comment,
isEditing,
canEdit,
editContent,
isSubmitting,
messages,
onEditContentChange,
onStartEdit,
onCancelEdit,
onSave,
onDelete,
}: Props) {
return (
<Card shadow="sm">
<CardBody>
<div className="flex items-start gap-3">
<UserAvatar username={comment.User.username} size={24} />
<div className="flex-grow min-w-0">
<div className="flex items-center justify-between mb-2">
<div>
<span className="font-semibold text-sm">{comment.User.username}</span>
<span className="text-xs text-default-400 ml-2">{new Date(comment.createdAt).toLocaleString()}</span>
</div>
{canEdit && (
<div className="flex gap-2">
<Button
aria-label="Edit Comment"
isIconOnly
size="sm"
variant="light"
onPress={onStartEdit}
isDisabled={isSubmitting}
>
<Edit2 size={16} />
</Button>
<Button
aria-label="Delete Comment"
isIconOnly
size="sm"
variant="light"
color="danger"
onPress={onDelete}
isDisabled={isSubmitting}
>
<Trash2 size={16} />
</Button>
</div>
)}
</div>
{isEditing ? (
<div>
<Textarea
value={editContent}
onValueChange={onEditContentChange}
minRows={3}
isDisabled={isSubmitting}
/>
<div className="flex gap-2 mt-2">
<Button size="sm" color="primary" onPress={onSave} isLoading={isSubmitting}>
{messages.save}
</Button>
<Button size="sm" variant="bordered" onPress={onCancelEdit} isDisabled={isSubmitting}>
{messages.cancel}
</Button>
</div>
</div>
) : (
<p className="text-sm whitespace-pre-wrap">{comment.content}</p>
)}
</div>
</div>
</CardBody>
</Card>
);
}

View File

@@ -1,12 +1,201 @@
'use client'; 'use client';
import { useEffect, useState, useContext } from 'react';
import { Button, Textarea, Spinner, addToast } from '@heroui/react';
import CommentItem from './CommentItem';
import { TokenContext } from '@/utils/TokenProvider';
import { fetchComments, createComment, updateComment, deleteComment } from '@/utils/commentControl';
import { logError } from '@/utils/errorHandler';
import type { CommentMessages, CommentType } from '@/types/comment';
import { Alert } from '@heroui/react'; type Props = {
projectId: string;
commentableType: 'RunCase' | 'Run' | 'Case';
commentableId?: number;
messages: CommentMessages;
};
export default function Comments({ projectId, commentableType, commentableId, messages }: Props) {
const context = useContext(TokenContext);
const [comments, setComments] = useState<CommentType[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [newComment, setNewComment] = useState('');
const [editingId, setEditingId] = useState<number | null>(null);
const [editContent, setEditContent] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
async function loadComments() {
if (!commentableType || !commentableId || !context.isSignedIn()) return;
setIsLoading(true);
try {
const data = await fetchComments(context.token.access_token, commentableType, commentableId);
setComments(data);
} catch (error: unknown) {
logError('Error fetching comments', error);
} finally {
setIsLoading(false);
}
}
loadComments();
}, [commentableType, commentableId, context]);
const handleAddComment = async () => {
if (!newComment.trim() || !commentableType || !commentableId) return;
setIsSubmitting(true);
try {
const comment = await createComment(context.token.access_token, commentableType, commentableId, newComment);
if (!comment) {
throw new Error('Failed to create comment');
}
const updatedComments = [...comments, comment];
setComments(updatedComments);
setNewComment('');
addToast({
title: 'Success',
color: 'success',
description: messages.commentAdded,
});
} catch (error: unknown) {
logError('Error adding comment', error);
addToast({
title: 'Error',
color: 'danger',
description: messages.failedToAddComment,
});
} finally {
setIsSubmitting(false);
}
};
const handleStartEdit = (id: number, content: string) => {
setEditingId(id);
setEditContent(content);
};
const handleCancelEdit = () => {
setEditingId(null);
setEditContent('');
};
const handleSaveEdit = async (id: number) => {
if (!editContent.trim()) return;
setIsSubmitting(true);
try {
const updated = await updateComment(context.token.access_token, id, editContent);
if (!updated) {
throw new Error('Failed to update comment');
}
setComments(comments.map((c) => (c.id === id ? { ...c, content: editContent } : c)));
setEditingId(null);
setEditContent('');
addToast({
title: 'Success',
color: 'success',
description: messages.commentUpdated,
});
} catch (error: unknown) {
logError('Error updating comment', error);
addToast({
title: 'Error',
color: 'danger',
description: messages.failedToUpdateComment,
});
} finally {
setIsSubmitting(false);
}
};
const handleDeleteComment = async (id: number) => {
setIsSubmitting(true);
try {
await deleteComment(context.token.access_token, id);
const updatedComments = comments.filter((c) => c.id !== id);
setComments(updatedComments);
addToast({
title: 'Success',
color: 'success',
description: messages.commentDeleted,
});
} catch (error: unknown) {
logError('Error deleting comment', error);
addToast({
title: 'Error',
color: 'danger',
description: messages.failedToDeleteComment,
});
} finally {
setIsSubmitting(false);
}
};
if (!commentableType || !commentableId) {
return (
<div className="text-default-500 text-sm">
{commentableType === 'RunCase' && !commentableId ? <p>{messages.notIncludedInRun}</p> : <p>Unknown state</p>}
</div>
);
}
if (isLoading) {
return (
<div className="h-full flex items-center justify-center">
<Spinner />
</div>
);
}
const canComment = projectId && context.isProjectReporter(Number(projectId));
export default function Comments() {
return ( return (
<div className="h-full text-default-500"> <div className="h-full flex flex-col justify-between">
<div className="mb-4"> {comments.length === 0 ? (
<Alert color="secondary" title="Sorry" description={'Comments function will be implemented'} /> <div className="text-center text-default-400 py-8">
<p>{messages.noComments}</p>
</div>
) : (
<div className="space-y-4">
{comments.map((comment) => (
<CommentItem
key={comment.id}
comment={comment}
isEditing={editingId === comment.id}
canEdit={comment.userId === context.token.user?.id}
editContent={editContent}
isSubmitting={isSubmitting}
messages={messages}
onEditContentChange={setEditContent}
onStartEdit={() => handleStartEdit(comment.id, comment.content)}
onCancelEdit={handleCancelEdit}
onSave={() => handleSaveEdit(comment.id)}
onDelete={() => handleDeleteComment(comment.id)}
/>
))}
</div>
)}
<div className="mt-12">
<Textarea
placeholder={messages.placeholder}
value={newComment}
onValueChange={setNewComment}
minRows={3}
variant="bordered"
isDisabled={!canComment || isSubmitting}
/>
<Button
color="primary"
size="sm"
className="mt-2"
onPress={handleAddComment}
isLoading={isSubmitting}
isDisabled={!newComment.trim() || !canComment}
>
{messages.addComment}
</Button>
</div> </div>
</div> </div>
); );

View File

@@ -4,16 +4,22 @@ import { useState, useRef, useEffect, ReactNode } from 'react';
type Props = { type Props = {
leftPane: ReactNode; leftPane: ReactNode;
rightPane: ReactNode; rightPane: ReactNode;
minLeftWidth?: number;
minRightWidth?: number;
defaultLeftWidth?: number;
}; };
export default function ResizablePanes({ leftPane, rightPane }: Props) { export default function ResizablePanes({
const [leftWidth, setLeftWidth] = useState(70); // default 70% leftPane,
rightPane,
minLeftWidth = 40,
minRightWidth = 15,
defaultLeftWidth = 70,
}: Props) {
const [leftWidth, setLeftWidth] = useState(defaultLeftWidth); // default 70%
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const minLeftWidth = 40; // left panel min width 40%
const minRightWidth = 15; // right panel min width 15%
const handleMouseDown = () => { const handleMouseDown = () => {
setIsDragging(true); setIsDragging(true);
}; };
@@ -45,7 +51,7 @@ export default function ResizablePanes({ leftPane, rightPane }: Props) {
document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener('mouseup', handleMouseUp);
}; };
}, [isDragging]); }, [isDragging, minLeftWidth, minRightWidth]);
return ( return (
<div ref={containerRef} className="flex h-full" style={{ userSelect: isDragging ? 'none' : 'auto' }}> <div ref={containerRef} className="flex h-full" style={{ userSelect: isDragging ? 'none' : 'auto' }}>

View File

@@ -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: '日本語' },
]; ];

View File

@@ -101,6 +101,7 @@
"confirm_password": "Passwort (Bestätigung)", "confirm_password": "Passwort (Bestätigung)",
"invalid_email": "Ungültige E-Mail", "invalid_email": "Ungültige E-Mail",
"invalid_password": "Passwort muss mindestens 8 Zeichen haben", "invalid_password": "Passwort muss mindestens 8 Zeichen haben",
"invalid_locale": "Ungültige Sprache",
"username_empty": "Benutzername ist leer", "username_empty": "Benutzername ist leer",
"password_empty": "Passwort ist leer", "password_empty": "Passwort ist leer",
"password_not_match": "Passwörter stimmen nicht überein", "password_not_match": "Passwörter stimmen nicht überein",
@@ -125,6 +126,9 @@
"confirm_new_password": "Neues Passwort bestätigen", "confirm_new_password": "Neues Passwort bestätigen",
"update_password": "Passwort aktualisieren", "update_password": "Passwort aktualisieren",
"password_updated": "Passwort erfolgreich geändert", "password_updated": "Passwort erfolgreich geändert",
"change_locale": "Sprache ändern",
"update_locale": "Sprache aktualisieren",
"locale_updated": "Sprache erfolgreich geändert",
"change_avatar": "Avatar ändern", "change_avatar": "Avatar ändern",
"upload_avatar": "Avatar hochladen", "upload_avatar": "Avatar hochladen",
"remove_avatar": "Avatar entfernen", "remove_avatar": "Avatar entfernen",
@@ -247,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",
@@ -354,7 +359,25 @@
"selected": "Ausgewählt", "selected": "Ausgewählt",
"tags": "Tags", "tags": "Tags",
"select_tags": "Tags auswählen", "select_tags": "Tags auswählen",
"no_case_selected": "Kein Testfall ausgewählt" "no_case_selected": "Kein Testfall ausgewählt",
"case_detail": "Testfall-Details",
"comments": "Kommentare",
"history": "Verlauf"
},
"Comments": {
"comments": "Kommentare",
"no_comments": "Keine Kommentare",
"add_comment": "Kommentar hinzufügen",
"save": "Speichern",
"cancel": "Abbrechen",
"placeholder": "Kommentar eingeben...",
"not_included_in_run": "Kann keine Kommentare zu Testfällen abgeben, die nicht im Testlauf enthalten sind",
"comment_added": "Kommentar hinzugefügt",
"failed_to_add_comment": "Fehler beim Hinzufügen des Kommentars",
"comment_updated": "Kommentar aktualisiert",
"failed_to_update_comment": "Fehler beim Aktualisieren des Kommentars",
"comment_deleted": "Kommentar gelöscht",
"failed_to_delete_comment": "Fehler beim Löschen des Kommentars"
}, },
"Members": { "Members": {
"member_management": "Mitgliederverwaltung", "member_management": "Mitgliederverwaltung",

View File

@@ -101,6 +101,7 @@
"confirm_password": "Password (confirm)", "confirm_password": "Password (confirm)",
"invalid_email": "Invalid email", "invalid_email": "Invalid email",
"invalid_password": "Password must be at least 8 characters", "invalid_password": "Password must be at least 8 characters",
"invalid_locale": "Invalid language",
"username_empty": "username is empty", "username_empty": "username is empty",
"password_empty": "password is empty", "password_empty": "password is empty",
"password_not_match": "Pasword does not match", "password_not_match": "Pasword does not match",
@@ -125,6 +126,9 @@
"confirm_new_password": "Confirm New Password", "confirm_new_password": "Confirm New Password",
"update_password": "Update Password", "update_password": "Update Password",
"password_updated": "Password updated successfully", "password_updated": "Password updated successfully",
"change_locale": "Change Language",
"update_locale": "Update Language",
"locale_updated": "Language updated successfully",
"change_avatar": "Change Avatar", "change_avatar": "Change Avatar",
"upload_avatar": "Upload Avatar", "upload_avatar": "Upload Avatar",
"remove_avatar": "Remove Avatar", "remove_avatar": "Remove Avatar",
@@ -247,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",
@@ -354,7 +359,25 @@
"selected": "Selected", "selected": "Selected",
"tags": "Tags", "tags": "Tags",
"select_tags": "Select tags", "select_tags": "Select tags",
"no_case_selected": "No test case selected" "no_case_selected": "No test case selected",
"case_detail": "Test case detail",
"comments": "Comments",
"history": "History"
},
"Comments": {
"comments": "Comments",
"no_comments": "No comments",
"add_comment": "Add comment",
"save": "Save",
"cancel": "Cancel",
"placeholder": "Enter a comment...",
"not_included_in_run": "Cannot comment on test cases not included in the test run",
"comment_added": "Comment added",
"failed_to_add_comment": "Failed to add comment",
"comment_updated": "Comment updated",
"failed_to_update_comment": "Failed to update comment",
"comment_deleted": "Comment deleted",
"failed_to_delete_comment": "Failed to delete comment"
}, },
"Members": { "Members": {
"member_management": "Member Management", "member_management": "Member Management",

View File

@@ -101,6 +101,7 @@
"confirm_password": "パスワード(確認)", "confirm_password": "パスワード(確認)",
"invalid_email": "無効なメールアドレスです", "invalid_email": "無効なメールアドレスです",
"invalid_password": "パスワードは8文字以上である必要があります", "invalid_password": "パスワードは8文字以上である必要があります",
"invalid_locale": "無効な言語です",
"username_empty": "ユーザー名が入力されていません", "username_empty": "ユーザー名が入力されていません",
"password_empty": "パスワードが入力されていません", "password_empty": "パスワードが入力されていません",
"password_not_match": "パスワードが一致しません", "password_not_match": "パスワードが一致しません",
@@ -125,6 +126,9 @@
"confirm_new_password": "新しいパスワード(確認)", "confirm_new_password": "新しいパスワード(確認)",
"update_password": "パスワードを更新", "update_password": "パスワードを更新",
"password_updated": "パスワードが正常に更新されました", "password_updated": "パスワードが正常に更新されました",
"change_locale": "言語の変更",
"update_locale": "言語を更新",
"locale_updated": "言語が正常に更新されました",
"change_avatar": "アバターの変更", "change_avatar": "アバターの変更",
"upload_avatar": "アバターをアップロード", "upload_avatar": "アバターをアップロード",
"remove_avatar": "アバターを削除", "remove_avatar": "アバターを削除",
@@ -247,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": "最大ファイルサイズ",
@@ -354,7 +359,25 @@
"selected": "選択済み", "selected": "選択済み",
"tags": "タグ", "tags": "タグ",
"select_tags": "タグを選択", "select_tags": "タグを選択",
"no_case_selected": "テストケースが選択されていません" "no_case_selected": "テストケースが選択されていません",
"case_detail": "テストケース詳細",
"comments": "コメント",
"history": "履歴"
},
"Comments": {
"comments": "コメント",
"no_comments": "コメントがありません",
"add_comment": "コメントを追加",
"save": "保存",
"cancel": "キャンセル",
"placeholder": "コメントを入力...",
"not_included_in_run": "テストランに含まれていないテストケースにはコメントできません",
"comment_added": "コメントが追加されました",
"failed_to_add_comment": "コメントの追加に失敗しました",
"comment_updated": "コメントが更新されました",
"failed_to_update_comment": "コメントの更新に失敗しました",
"comment_deleted": "コメントが削除されました",
"failed_to_delete_comment": "コメントの削除に失敗しました"
}, },
"Members": { "Members": {
"member_management": "メンバー管理", "member_management": "メンバー管理",

View File

@@ -101,6 +101,7 @@
"confirm_password": "Senha (confirmação)", "confirm_password": "Senha (confirmação)",
"invalid_email": "E-mail inválido", "invalid_email": "E-mail inválido",
"invalid_password": "A senha deve ter pelo menos 8 caracteres", "invalid_password": "A senha deve ter pelo menos 8 caracteres",
"invalid_locale": "Idioma inválido",
"username_empty": "O nome de usuário está vazio", "username_empty": "O nome de usuário está vazio",
"password_empty": "A senha está vazia", "password_empty": "A senha está vazia",
"password_not_match": "A senha não corresponde", "password_not_match": "A senha não corresponde",
@@ -125,6 +126,9 @@
"confirm_new_password": "Confirmar Nova Senha", "confirm_new_password": "Confirmar Nova Senha",
"update_password": "Atualizar Senha", "update_password": "Atualizar Senha",
"password_updated": "Senha atualizada com sucesso", "password_updated": "Senha atualizada com sucesso",
"change_locale": "Alterar Idioma",
"update_locale": "Atualizar Idioma",
"locale_updated": "Idioma atualizado com sucesso",
"change_avatar": "Alterar Avatar", "change_avatar": "Alterar Avatar",
"upload_avatar": "Enviar Avatar", "upload_avatar": "Enviar Avatar",
"remove_avatar": "Remover Avatar", "remove_avatar": "Remover Avatar",
@@ -247,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",
@@ -354,7 +359,25 @@
"selected": "Selecionado", "selected": "Selecionado",
"tags": "Tags", "tags": "Tags",
"select_tags": "Selecionar tags", "select_tags": "Selecionar tags",
"no_case_selected": "Nenhum caso de teste selecionado" "no_case_selected": "Nenhum caso de teste selecionado",
"case_detail": "Detalhe do caso de teste",
"comments": "Comentários",
"history": "Histórico"
},
"Comments": {
"comments": "Comentários",
"no_comments": "Nenhum comentário",
"add_comment": "Adicionar comentário",
"save": "Salvar",
"cancel": "Cancelar",
"placeholder": "Digite um comentário...",
"not_included_in_run": "Não é possível comentar em casos de teste que não estão incluídos na execução de teste",
"comment_added": "Comentário adicionado",
"failed_to_add_comment": "Falha ao adicionar comentário",
"comment_updated": "Comentário atualizado",
"failed_to_update_comment": "Falha ao atualizar comentário",
"comment_deleted": "Comentário excluído",
"failed_to_delete_comment": "Falha ao excluir comentário"
}, },
"Members": { "Members": {
"member_management": "Gerenciamento de Membros", "member_management": "Gerenciamento de Membros",

View File

@@ -101,6 +101,7 @@
"confirm_password": "确认密码", "confirm_password": "确认密码",
"invalid_email": "无效的邮箱地址", "invalid_email": "无效的邮箱地址",
"invalid_password": "密码必须至少包含 8 个字符", "invalid_password": "密码必须至少包含 8 个字符",
"invalid_locale": "无效的语言",
"username_empty": "用户名不能为空", "username_empty": "用户名不能为空",
"password_empty": "密码不能为空", "password_empty": "密码不能为空",
"password_not_match": "密码不匹配", "password_not_match": "密码不匹配",
@@ -125,6 +126,9 @@
"confirm_new_password": "确认新密码", "confirm_new_password": "确认新密码",
"update_password": "更新密码", "update_password": "更新密码",
"password_updated": "密码更新成功", "password_updated": "密码更新成功",
"change_locale": "修改语言",
"update_locale": "更新语言",
"locale_updated": "语言更新成功",
"change_avatar": "修改头像", "change_avatar": "修改头像",
"upload_avatar": "上传头像", "upload_avatar": "上传头像",
"remove_avatar": "移除头像", "remove_avatar": "移除头像",
@@ -247,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": "最大文件大小",
@@ -354,7 +359,25 @@
"selected": "已选择", "selected": "已选择",
"tags": "标签", "tags": "标签",
"select_tags": "选择标签", "select_tags": "选择标签",
"no_case_selected": "未选择测试用例" "no_case_selected": "未选择测试用例",
"case_detail": "测试用例详情",
"comments": "评论",
"history": "历史"
},
"Comments": {
"comments": "评论",
"no_comments": "暂无评论",
"add_comment": "添加评论",
"save": "保存",
"cancel": "取消",
"placeholder": "输入评论...",
"not_included_in_run": "无法对未包含在测试运行中的测试用例进行评论",
"comment_added": "评论已添加",
"failed_to_add_comment": "添加评论失败",
"comment_updated": "评论已更新",
"failed_to_update_comment": "更新评论失败",
"comment_deleted": "评论已删除",
"failed_to_delete_comment": "删除评论失败"
}, },
"Members": { "Members": {
"member_management": "成员管理", "member_management": "成员管理",

View File

@@ -0,0 +1,432 @@
{
"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": "歷史"
},
"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": "標籤刪除失敗,請重試。"
}
}

View 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."
}
]
}
]

View File

@@ -16,7 +16,7 @@ export default function DropdownLanguage({ locale, onChangeLocale }: Props) {
{locales.find((entry) => entry.code === locale)?.name || locale} {locales.find((entry) => entry.code === locale)?.name || locale}
</Button> </Button>
</DropdownTrigger> </DropdownTrigger>
<DropdownMenu aria-label="lacales"> <DropdownMenu aria-label="locales">
{locales.map((entry) => ( {locales.map((entry) => (
<DropdownItem key={entry.code} onPress={() => onChangeLocale(entry.code)}> <DropdownItem key={entry.code} onPress={() => onChangeLocale(entry.code)}>
{entry.name} {entry.name}

View File

@@ -136,7 +136,7 @@ export default function HeaderNavbarMenu({ messages, locale }: Props) {
<ThemeSwitch /> <ThemeSwitch />
<div className="hidden md:block"> <div className="hidden md:block">
<DropdownAccount messages={messages} locale={locale} onItemPress={() => {}} /> <DropdownAccount messages={messages} locale={locale} onItemPress={() => {}} />
<DropdownLanguage locale={locale} onChangeLocale={changeLocale} /> {!context.isSignedIn() && <DropdownLanguage locale={locale} onChangeLocale={changeLocale} />}
</div> </div>
<NavbarMenuToggle className="md:hidden" onChange={() => setIsMenuOpen(!isMenuOpen)} /> <NavbarMenuToggle className="md:hidden" onChange={() => setIsMenuOpen(!isMenuOpen)} />
</NavbarContent> </NavbarContent>
@@ -224,51 +224,53 @@ export default function HeaderNavbarMenu({ messages, locale }: Props) {
/> />
</Listbox> </Listbox>
) : ( ) : (
<Listbox <>
aria-label="Account links" <Listbox
itemClasses={{ aria-label="Account links"
base: 'h-10 text-large', itemClasses={{
}} base: 'h-10 text-large',
>
<ListboxItem
key="signin"
startContent={<ArrowRightToLine size={16} />}
title={messages.signIn}
onPress={() => {
router.push('/account/signin', { locale: locale });
setIsMenuOpen(false);
}} }}
/> >
<ListboxItem <ListboxItem
key="signup" key="signin"
title={messages.signUp} startContent={<ArrowRightToLine size={16} />}
startContent={<PenTool size={16} />} title={messages.signIn}
onPress={() => { onPress={() => {
router.push('/account/signup', { locale: locale }); router.push('/account/signin', { locale: locale });
setIsMenuOpen(false); setIsMenuOpen(false);
}}
/>
<ListboxItem
key="signup"
title={messages.signUp}
startContent={<PenTool size={16} />}
onPress={() => {
router.push('/account/signup', { locale: locale });
setIsMenuOpen(false);
}}
/>
</Listbox>
<p className="font-bold">{messages.languages}</p>
<Listbox
aria-label="Language links"
itemClasses={{
base: 'h-10 text-large',
}} }}
/> >
</Listbox> {locales.map((entry) => (
<ListboxItem
key={entry.code}
startContent={<Globe size={16} />}
title={entry.name}
onPress={() => {
changeLocale(entry.code);
setIsMenuOpen(false);
}}
/>
))}
</Listbox>
</>
)} )}
<p className="font-bold">{messages.languages}</p>
<Listbox
aria-label="Language links"
itemClasses={{
base: 'h-10 text-large',
}}
>
{locales.map((entry) => (
<ListboxItem
key={entry.code}
startContent={<Globe size={16} />}
title={entry.name}
onPress={() => {
changeLocale(entry.code);
setIsMenuOpen(false);
}}
/>
))}
</Listbox>
</div> </div>
</NavbarMenu> </NavbarMenu>
</Navbar> </Navbar>

View File

@@ -60,6 +60,7 @@ async function signInAsGuest() {
username: 'Guest', username: 'Guest',
role: roles.findIndex((entry) => entry.uid === 'user'), role: roles.findIndex((entry) => entry.uid === 'user'),
avatarPath: '', avatarPath: '',
locale: null,
}; };
const token = await signUp(guestUser); const token = await signUp(guestUser);
return token; return token;

View File

@@ -30,6 +30,7 @@ export default function AuthPage({ isSignup, messages, locale }: Props) {
username: '', username: '',
role: roles.findIndex((entry) => entry.uid === 'user'), role: roles.findIndex((entry) => entry.uid === 'user'),
avatarPath: '', avatarPath: '',
locale: null,
}); });
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [errorMessage, setErrorMessage] = useState(''); const [errorMessage, setErrorMessage] = useState('');
@@ -83,14 +84,14 @@ export default function AuthPage({ isSignup, messages, locale }: Props) {
context.setToken(token); context.setToken(token);
context.storeTokenToLocalStorage(token); context.storeTokenToLocalStorage(token);
router.push('/account', { locale: locale }); router.push('/account', { locale: token.user?.locale ?? locale });
}; };
const handleSignInAsGuest = async () => { const handleSignInAsGuest = async () => {
const token = await signInAsGuest(); const token = await signInAsGuest();
context.setToken(token); context.setToken(token);
context.storeTokenToLocalStorage(token); context.storeTokenToLocalStorage(token);
router.push('/account', { locale: locale }); router.push('/account', { locale: token.user?.locale ?? locale });
}; };
return ( return (

View File

@@ -1,11 +1,15 @@
'use client'; 'use client';
import { useState, useContext, useRef } from 'react'; import { useState, useContext, useRef } from 'react';
import { Button, Input, Card, CardHeader, CardBody, addToast, CardFooter } from '@heroui/react'; import { Button, Input, Card, CardHeader, CardBody, addToast, CardFooter, Select, SelectItem } from '@heroui/react';
import { Globe } from 'lucide-react';
import { TokenContext } from '@/utils/TokenProvider'; import { TokenContext } from '@/utils/TokenProvider';
import { updateUsername, updatePassword, uploadAvatar, deleteAvatar } from '@/utils/usersControl'; import { updateUsername, updatePassword, uploadAvatar, deleteAvatar, updateLocale } from '@/utils/usersControl';
import { LocaleCodeType } from '@/types/locale'; import { LocaleCodeType } from '@/types/locale';
import { logError } from '@/utils/errorHandler'; import { logError } from '@/utils/errorHandler';
import UserAvatar from '@/components/UserAvatar'; import UserAvatar from '@/components/UserAvatar';
import { useRouter, usePathname } from '@/src/i18n/routing';
import { locales } from '@/config/selection';
import { LocaleType } from '@/types/locale';
type ProfileSettingsPageMessages = { type ProfileSettingsPageMessages = {
profileSettings: string; profileSettings: string;
@@ -19,6 +23,9 @@ type ProfileSettingsPageMessages = {
confirmNewPassword: string; confirmNewPassword: string;
updatePassword: string; updatePassword: string;
passwordUpdated: string; passwordUpdated: string;
changeLocale: string;
updateLocale: string;
localeUpdated: string;
changeAvatar: string; changeAvatar: string;
uploadAvatar: string; uploadAvatar: string;
removeAvatar: string; removeAvatar: string;
@@ -31,6 +38,7 @@ type ProfileSettingsPageMessages = {
invalidPassword: string; invalidPassword: string;
passwordNotMatch: string; passwordNotMatch: string;
usernameEmpty: string; usernameEmpty: string;
invalidLocale: string;
}; };
type Props = { type Props = {
@@ -38,15 +46,21 @@ type Props = {
locale: LocaleCodeType; locale: LocaleCodeType;
}; };
export default function ProfileSettingsPage({ messages }: Props) { export default function ProfileSettingsPage({ messages, locale: defaultLocale }: Props) {
const context = useContext(TokenContext); const context = useContext(TokenContext);
const router = useRouter();
const pathname = usePathname();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [currentPassword, setCurrentPassword] = useState(''); const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [locale, setLocale] = useState<LocaleCodeType>(context.token?.user?.locale ?? defaultLocale);
const [isUpdatingUsername, setIsUpdatingUsername] = useState(false); const [isUpdatingUsername, setIsUpdatingUsername] = useState(false);
const [isUpdatingPassword, setIsUpdatingPassword] = useState(false); const [isUpdatingPassword, setIsUpdatingPassword] = useState(false);
const [isUpdatingLocale, setIsUpdatingLocale] = useState(false);
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false); const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
const handleUsernameUpdate = async () => { const handleUsernameUpdate = async () => {
@@ -150,6 +164,53 @@ export default function ProfileSettingsPage({ messages }: Props) {
} }
}; };
const handleLocaleUpdate = async () => {
if (!locales.some((l) => l.code === locale)) {
addToast({
title: 'Warning',
color: 'warning',
description: messages.invalidLocale,
});
return;
}
setIsUpdatingLocale(true);
try {
const result = await updateLocale(context.token.access_token, locale);
if (result && result.user) {
// refresh locale
const newToken = { ...context.token };
if (newToken.user) {
newToken.user.locale = result.user.locale;
}
context.setToken(newToken);
context.storeTokenToLocalStorage(newToken);
addToast({
title: 'Success',
color: 'success',
description: messages.localeUpdated,
});
const nextLocale = result.user.locale ?? locale;
setLocale(nextLocale);
changeLocale(nextLocale);
}
} catch (error) {
logError('Error updating locale:', error);
addToast({
title: 'Error',
color: 'danger',
description: messages.updateError,
});
} finally {
setIsUpdatingLocale(false);
}
};
async function changeLocale(nextLocale: LocaleCodeType) {
router.push(pathname, { locale: nextLocale });
}
const handleAvatarUpload = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleAvatarUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (!file) return; if (!file) return;
@@ -333,6 +394,46 @@ export default function ProfileSettingsPage({ messages }: Props) {
</CardFooter> </CardFooter>
</Card> </Card>
{/* Change Locale */}
<Card className="mb-6">
<CardHeader>
<Globe size={16} />
<h2 className="text-large font-semibold ml-2">{messages.changeLocale}</h2>
</CardHeader>
<CardBody>
<form>
<div className="space-y-4">
<Select<LocaleType>
fullWidth
aria-label="change locale"
selectedKeys={[locale]}
disabledKeys={[locale]}
onSelectionChange={(value) => {
const selectedLocale = locales.find((locale) => locale.code === value.currentKey);
if (!selectedLocale) return;
setLocale(selectedLocale.code);
}}
>
{locales.map((locale) => (
<SelectItem key={locale.code}>{locale.name}</SelectItem>
))}
</Select>
</div>
</form>
</CardBody>
<CardFooter className="flex justify-end">
<Button
color="primary"
onPress={handleLocaleUpdate}
isLoading={isUpdatingLocale}
isDisabled={locale === context.token?.user?.locale}
size="sm"
>
{messages.updateLocale}
</Button>
</CardFooter>
</Card>
{/* Change Avatar */} {/* Change Avatar */}
<Card className="mb-6"> <Card className="mb-6">
<CardHeader> <CardHeader>

View File

@@ -17,6 +17,9 @@ export default function Page({ params }: PageType) {
confirmNewPassword: t('confirm_new_password'), confirmNewPassword: t('confirm_new_password'),
updatePassword: t('update_password'), updatePassword: t('update_password'),
passwordUpdated: t('password_updated'), passwordUpdated: t('password_updated'),
changeLocale: t('change_locale'),
updateLocale: t('update_locale'),
localeUpdated: t('locale_updated'),
changeAvatar: t('change_avatar'), changeAvatar: t('change_avatar'),
uploadAvatar: t('upload_avatar'), uploadAvatar: t('upload_avatar'),
removeAvatar: t('remove_avatar'), removeAvatar: t('remove_avatar'),
@@ -29,6 +32,7 @@ export default function Page({ params }: PageType) {
invalidPassword: t('invalid_password'), invalidPassword: t('invalid_password'),
passwordNotMatch: t('password_not_match'), passwordNotMatch: t('password_not_match'),
usernameEmpty: t('username_empty'), usernameEmpty: t('username_empty'),
invalidLocale: t('invalid_locale'),
}; };
return <ProfileSettingsPage messages={messages} locale={params.locale as LocaleCodeType} />; return <ProfileSettingsPage messages={messages} locale={params.locale as LocaleCodeType} />;

View File

@@ -62,7 +62,7 @@ export default function HealthPage({ messages, locale }: Props) {
<TableBody> <TableBody>
<TableRow key="1"> <TableRow key="1">
<TableCell>{messages.unittcms_version}</TableCell> <TableCell>{messages.unittcms_version}</TableCell>
<TableCell>1.0.0-beta.26</TableCell> <TableCell>1.0.0-beta.28</TableCell>
</TableRow> </TableRow>
<TableRow key="2"> <TableRow key="2">
<TableCell>{messages.api_server}</TableCell> <TableCell>{messages.api_server}</TableCell>

View File

@@ -132,7 +132,7 @@ export default function FoldersPane({ projectId, messages, locale }: Props) {
return ( return (
<> <>
<div className="w-80 min-h-[calc(100vh-64px)] border-r-1 dark:border-neutral-700"> <div className="min-h-[calc(100vh-64px)] border-r-1 dark:border-neutral-700">
<Button <Button
startContent={<Plus size={16} />} startContent={<Plus size={16} />}
size="sm" size="sm"

View File

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

View File

@@ -60,7 +60,7 @@ export default function CaseEditor({
const [testCase, setTestCase] = useState<CaseType>(defaultTestCase); const [testCase, setTestCase] = useState<CaseType>(defaultTestCase);
const [isTitleInvalid] = useState<boolean>(false); const [isTitleInvalid] = useState<boolean>(false);
const [isUpdating, setIsUpdating] = useState<boolean>(false); const [isUpdating, setIsUpdating] = useState<boolean>(false);
const [plusCount, setPlusCount] = useState<number>(0); const [idCounter, setIdCounter] = useState<number>(0);
const [isDirty, setIsDirty] = useState(false); const [isDirty, setIsDirty] = useState(false);
const [selectedTags, setSelectedTags] = useState<{ id: number; name: string }[]>([]); const [selectedTags, setSelectedTags] = useState<{ id: number; name: string }[]>([]);
@@ -68,9 +68,14 @@ export default function CaseEditor({
useFormGuard(isDirty, messages.areYouSureLeave); useFormGuard(isDirty, messages.areYouSureLeave);
const onPlusClick = async (newStepNo: number) => { const onPlusClick = async (newStepNo: number) => {
if (!testCase.Steps) {
return;
}
setIsDirty(true); setIsDirty(true);
const nextId = idCounter + 1;
const newStep: StepType = { const newStep: StepType = {
id: plusCount, // hypothetical ID
id: nextId,
step: '', step: '',
result: '', result: '',
createdAt: new Date(), createdAt: new Date(),
@@ -78,66 +83,65 @@ export default function CaseEditor({
caseSteps: { caseSteps: {
stepNo: newStepNo, stepNo: newStepNo,
}, },
uid: `uid${plusCount}`, uid: `uid${nextId}`,
editState: 'new', editState: 'new',
}; };
setPlusCount(plusCount + 1);
if (testCase.Steps) { const updatedSteps = testCase.Steps.map((step) => {
const updatedSteps = testCase.Steps.map((step) => { if (step.caseSteps.stepNo >= newStepNo) {
if (step.caseSteps.stepNo >= newStepNo) { return {
return { ...step,
...step, editState: step.editState === 'notChanged' ? 'changed' : step.editState,
editState: step.editState === 'notChanged' ? 'changed' : step.editState, caseSteps: {
caseSteps: { ...step.caseSteps,
...step.caseSteps, stepNo: step.caseSteps.stepNo + 1,
stepNo: step.caseSteps.stepNo + 1, },
}, };
}; }
} return step;
return step; });
});
updatedSteps.push(newStep); updatedSteps.push(newStep);
setTestCase({ setTestCase({
...testCase, ...testCase,
Steps: updatedSteps, Steps: updatedSteps,
}); });
} setIdCounter(nextId);
}; };
const onDeleteClick = async (stepId: number) => { const onDeleteClick = async (stepId: number) => {
setIsDirty(true); setIsDirty(true);
if (!testCase.Steps) {
// find deletedStep's stepNo return;
if (testCase.Steps) {
const deletedStep = testCase.Steps.find((step) => step.id === stepId);
if (!deletedStep) {
return;
}
const deletedStepNo = deletedStep.caseSteps.stepNo;
deletedStep.editState = 'deleted';
const updatedSteps = testCase.Steps.map((step) => {
if (step.caseSteps.stepNo > deletedStepNo) {
return {
...step,
editState: step.editState === 'notChanged' ? 'changed' : step.editState,
caseSteps: {
...step.caseSteps,
stepNo: step.caseSteps.stepNo - 1,
},
};
}
return step;
});
setTestCase({
...testCase,
Steps: updatedSteps,
});
} }
// find deletedStep's stepNo
const deletedStep = testCase.Steps.find((step) => step.id === stepId);
if (!deletedStep) {
return;
}
const deletedStepNo = deletedStep.caseSteps.stepNo;
deletedStep.editState = 'deleted';
const updatedSteps = testCase.Steps.map((step) => {
if (step.caseSteps.stepNo > deletedStepNo) {
return {
...step,
editState: step.editState === 'notChanged' ? 'changed' : step.editState,
caseSteps: {
...step.caseSteps,
stepNo: step.caseSteps.stepNo - 1,
},
};
}
return step;
});
setTestCase({
...testCase,
Steps: updatedSteps,
});
}; };
const handleDrop = (event: DragEvent<HTMLElement>) => { const handleDrop = (event: DragEvent<HTMLElement>) => {
@@ -201,18 +205,20 @@ export default function CaseEditor({
changeStep.editState = 'changed'; changeStep.editState = 'changed';
} }
if (testCase.Steps) { if (!testCase.Steps) {
setTestCase({ return;
...testCase,
Steps: testCase.Steps.map((step) => {
if (step.id === stepId) {
return changeStep;
} else {
return step;
}
}),
});
} }
setTestCase({
...testCase,
Steps: testCase.Steps.map((step) => {
if (step.id === stepId) {
return changeStep;
} else {
return step;
}
}),
});
}; };
useEffect(() => { useEffect(() => {
@@ -223,6 +229,11 @@ export default function CaseEditor({
data.Steps.forEach((step: StepType) => { data.Steps.forEach((step: StepType) => {
step.editState = 'notChanged'; step.editState = 'notChanged';
}); });
// set idCounter to the max step id to avoid id conflict for new steps
// id is not reflected on database
const maxStepId = data.Steps.reduce((maxId: number, step: StepType) => Math.max(maxId, step.id), 0);
setIdCounter(maxStepId);
setTestCase(data); setTestCase(data);
if (data.Tags) { if (data.Tags) {
setSelectedTags(Array.isArray(data.Tags) ? data.Tags : []); setSelectedTags(Array.isArray(data.Tags) ? data.Tags : []);

View File

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

View File

@@ -1,5 +1,6 @@
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import FoldersPane from './FoldersPane'; import FoldersPane from './FoldersPane';
import ResizablePanes from '@/components/ResizablePane';
export default function FoldersLayout({ export default function FoldersLayout({
children, children,
@@ -25,9 +26,12 @@ export default function FoldersLayout({
}; };
return ( return (
<div className="flex w-full"> <ResizablePanes
<FoldersPane projectId={params.projectId} messages={messages} locale={params.locale} /> minLeftWidth={15}
<div className="flex-grow w-full">{children}</div> minRightWidth={40}
</div> defaultLeftWidth={20}
leftPane={<FoldersPane projectId={params.projectId} messages={messages} locale={params.locale} />}
rightPane={children}
/>
); );
} }

View File

@@ -505,7 +505,7 @@ export default function RunEditor({
)} )}
</Tree> </Tree>
</div> </div>
<div className="w-9/12"> <div className="w-9/12 overflow-x-auto">
<TestCaseSelector <TestCaseSelector
projectId={projectId} projectId={projectId}
runId={runId} runId={runId}

View File

@@ -15,7 +15,7 @@ import {
SortDescriptor, SortDescriptor,
Chip, Chip,
} from '@heroui/react'; } from '@heroui/react';
import { ChevronDown, MoreVertical, CopyPlus, CopyMinus } from 'lucide-react'; import { ChevronDown, MoreVertical, CopyPlus, CopyMinus, MessageCircle } from 'lucide-react';
import RunCaseStatus from './RunCaseStatus'; import RunCaseStatus from './RunCaseStatus';
import { Link, NextUiLinkClasses } from '@/src/i18n/routing'; import { Link, NextUiLinkClasses } from '@/src/i18n/routing';
import { testRunCaseStatus } from '@/config/selection'; import { testRunCaseStatus } from '@/config/selection';
@@ -64,6 +64,7 @@ export default function TestCaseSelector({
{ name: messages.priority, uid: 'priority', sortable: true }, { name: messages.priority, uid: 'priority', sortable: true },
{ name: messages.tags, uid: 'tags', sortable: false }, { name: messages.tags, uid: 'tags', sortable: false },
{ name: messages.status, uid: 'runStatus', sortable: true }, { name: messages.status, uid: 'runStatus', sortable: true },
{ name: messages.comments, uid: 'comments', sortable: false },
{ name: messages.actions, uid: 'actions' }, { name: messages.actions, uid: 'actions' },
]; ];
@@ -115,6 +116,7 @@ export default function TestCaseSelector({
const cellValue = testCase[columnKey as keyof CaseType]; const cellValue = testCase[columnKey as keyof CaseType];
const isIncluded = isCaseIncluded(testCase); const isIncluded = isCaseIncluded(testCase);
const runStatus = testCase.RunCases && testCase.RunCases.length > 0 ? testCase.RunCases[0].status : 0; const runStatus = testCase.RunCases && testCase.RunCases.length > 0 ? testCase.RunCases[0].status : 0;
const commentCount = testCase.RunCases && testCase.RunCases.length > 0 ? testCase.RunCases[0].commentCount || 0 : 0;
switch (columnKey) { switch (columnKey) {
case 'title': case 'title':
@@ -179,6 +181,24 @@ export default function TestCaseSelector({
</DropdownMenu> </DropdownMenu>
</Dropdown> </Dropdown>
); );
case 'comments':
return (
<div className={isIncluded ? '' : notIncludedCaseClass}>
{isIncluded && commentCount > 0 ? (
<Link
href={`/projects/${projectId}/runs/${runId}/cases/${testCase.id}?tab=comments`}
locale={locale}
className="flex items-center gap-1"
onPointerDown={(e) => e.stopPropagation()}
>
<MessageCircle size={16} />
<span>{commentCount}</span>
</Link>
) : (
<span className="text-default-400">-</span>
)}
</div>
);
case 'actions': case 'actions':
return ( return (
<Dropdown> <Dropdown>

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useEffect, useState, useContext } from 'react'; import { useEffect, useState, useContext } from 'react';
import { Tabs, Tab, Chip } from '@heroui/react'; import { useSearchParams } from 'next/navigation';
import { Tabs, Tab } from '@heroui/react';
import CaseDetail from './CaseDetail'; import CaseDetail from './CaseDetail';
import Comments from '@/components/Comments'; import Comments from '@/components/Comments';
import History from '@/components/History'; import History from '@/components/History';
@@ -9,30 +9,50 @@ import { TokenContext } from '@/utils/TokenProvider';
import { fetchCase } from '@/utils/caseControl'; import { fetchCase } from '@/utils/caseControl';
import { logError } from '@/utils/errorHandler'; import { logError } from '@/utils/errorHandler';
import type { CaseType, StepType } from '@/types/case'; import type { CaseType, StepType } from '@/types/case';
import type { RunDetailMessages } from '@/types/run'; import type { RunCaseType, RunDetailMessages } from '@/types/run';
import type { PriorityMessages } from '@/types/priority'; import type { PriorityMessages } from '@/types/priority';
import type { TestTypeMessages } from '@/types/testType'; import type { TestTypeMessages } from '@/types/testType';
import type { CommentMessages } from '@/types/comment';
type Props = { type Props = {
projectId: string; projectId: string;
runId: string;
locale: string; locale: string;
caseId: string; caseId: string;
messages: RunDetailMessages; messages: RunDetailMessages;
testTypeMessages: TestTypeMessages; testTypeMessages: TestTypeMessages;
priorityMessages: PriorityMessages; priorityMessages: PriorityMessages;
commentMessages: CommentMessages;
}; };
export default function TestCaseDetailPane({ export default function TestCaseDetailPane({
projectId, projectId,
runId,
locale, locale,
caseId, caseId,
messages, messages,
testTypeMessages, testTypeMessages,
priorityMessages, priorityMessages,
commentMessages,
}: Props) { }: Props) {
const context = useContext(TokenContext); const context = useContext(TokenContext);
const searchParams = useSearchParams();
const [selectedTab, setSelectedTab] = useState('caseDetail');
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);
useEffect(() => {
// if the url has ?tab=comments, then select the comments tab
const tab = searchParams.get('tab');
if (tab === 'comments') {
setSelectedTab('comments');
} else if (tab === 'history') {
setSelectedTab('history');
} else {
setSelectedTab('caseDetail');
}
}, [searchParams]);
useEffect(() => { useEffect(() => {
async function fetchDataEffect() { async function fetchDataEffect() {
@@ -46,6 +66,14 @@ export default function TestCaseDetailPane({
data.Steps.sort((a: StepType, b: StepType) => a.caseSteps.stepNo - b.caseSteps.stepNo); data.Steps.sort((a: StepType, b: StepType) => a.caseSteps.stepNo - b.caseSteps.stepNo);
} }
setTestCase(data); setTestCase(data);
// Find the runCase for this case in this run
if (data.RunCases && data.RunCases.length > 0) {
const runCase = data.RunCases.find((rc: RunCaseType) => rc.runId === Number(runId));
if (runCase) {
setRunCaseId(runCase.id);
}
}
} catch (error: unknown) { } catch (error: unknown) {
logError('Error fetching case data', error); logError('Error fetching case data', error);
} finally { } finally {
@@ -54,15 +82,20 @@ export default function TestCaseDetailPane({
} }
fetchDataEffect(); fetchDataEffect();
}, [context, caseId]); }, [context, caseId, runId]);
if (isFetching || !testCase) { if (isFetching || !testCase) {
return <div>loading...</div>; return <div>loading...</div>;
} else { } else {
return ( return (
<div className="flex w-full flex-col p-3"> <div className="flex h-full w-full flex-col p-3">
<Tabs aria-label="Options" size="sm"> <Tabs
<Tab key="caseDetail" title="Case Detail"> aria-label="Options"
size="sm"
selectedKey={selectedTab}
onSelectionChange={(key) => setSelectedTab(String(key))}
>
<Tab key="caseDetail" title={messages.caseDetail}>
<CaseDetail <CaseDetail
projectId={projectId} projectId={projectId}
testCase={testCase} testCase={testCase}
@@ -72,20 +105,15 @@ export default function TestCaseDetailPane({
priorityMessages={priorityMessages} priorityMessages={priorityMessages}
/> />
</Tab> </Tab>
<Tab <Tab key="comments" title={messages.comments}>
key="comments" <Comments
title={ projectId={projectId}
<div className="flex items-center space-x-2"> commentableType="RunCase"
<span>Comments</span> commentableId={runCaseId}
<Chip size="sm" variant="faded"> messages={commentMessages}
3 />
</Chip>
</div>
}
>
<Comments />
</Tab> </Tab>
<Tab key="history" title="History"> <Tab key="history" title={messages.history}>
<History /> <History />
</Tab> </Tab>
</Tabs> </Tabs>

View File

@@ -21,6 +21,9 @@ export default function Page({
preconditions: t('preconditions'), preconditions: t('preconditions'),
expectedResult: t('expected_result'), expectedResult: t('expected_result'),
detailsOfTheStep: t('details_of_the_step'), detailsOfTheStep: t('details_of_the_step'),
caseDetail: t('case_detail'),
comments: t('comments'),
history: t('history'),
}; };
const pt = useTranslations('Priority'); const pt = useTranslations('Priority');
@@ -48,14 +51,33 @@ export default function Page({
manual: tt('manual'), manual: tt('manual'),
}; };
const ct = useTranslations('Comments');
const commentMessages = {
comments: ct('comments'),
noComments: ct('no_comments'),
addComment: ct('add_comment'),
save: ct('save'),
cancel: ct('cancel'),
placeholder: ct('placeholder'),
notIncludedInRun: ct('not_included_in_run'),
commentAdded: ct('comment_added'),
failedToAddComment: ct('failed_to_add_comment'),
commentUpdated: ct('comment_updated'),
failedToUpdateComment: ct('failed_to_update_comment'),
commentDeleted: ct('comment_deleted'),
failedToDeleteComment: ct('failed_to_delete_comment'),
};
return ( return (
<DetailPane <DetailPane
projectId={params.projectId} projectId={params.projectId}
runId={params.runId}
caseId={params.caseId} caseId={params.caseId}
locale={params.locale} locale={params.locale}
messages={messages} messages={messages}
priorityMessages={priorityMessages} priorityMessages={priorityMessages}
testTypeMessages={testTypeMessages} testTypeMessages={testTypeMessages}
commentMessages={commentMessages}
/> />
); );
} }

View File

@@ -51,6 +51,7 @@ export default function RunLayout({
selected: t('selected'), selected: t('selected'),
tags: t('tags'), tags: t('tags'),
selectTags: t('select_tags'), selectTags: t('select_tags'),
comments: t('comments'),
}; };
const rst = useTranslations('RunStatus'); const rst = useTranslations('RunStatus');

View File

@@ -43,6 +43,7 @@ export default function SettingsPage({ projectId, messages, projectDialogMessage
avatarPath: '', avatarPath: '',
role: -1, role: -1,
username: '', username: '',
locale: null,
}); });
useEffect(() => { useEffect(() => {

View File

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

View File

@@ -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*'],
}; };

View File

@@ -44,6 +44,7 @@ type RunCaseType = {
caseId: number; caseId: number;
status: number; status: number;
editState: 'notChanged' | 'changed' | 'new' | 'deleted'; editState: 'notChanged' | 'changed' | 'new' | 'deleted';
commentCount?: number;
}; };
type CaseAttachmentType = { type CaseAttachmentType = {
@@ -101,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;

32
frontend/types/comment.ts Normal file
View File

@@ -0,0 +1,32 @@
type CommentType = {
id: number;
commentableType: 'RunCase' | 'Run' | 'Case';
commentableId: number;
userId: number;
content: string;
createdAt: string;
updatedAt: string;
User: {
id: number;
username: string;
email: string;
};
};
type CommentMessages = {
comments: string;
noComments: string;
addComment: string;
save: string;
cancel: string;
placeholder: string;
notIncludedInRun: string;
commentAdded: string;
failedToAddComment: string;
commentUpdated: string;
failedToUpdateComment: string;
commentDeleted: string;
failedToDeleteComment: string;
};
export type { CommentType, CommentMessages };

View File

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

View File

@@ -89,6 +89,7 @@ type RunMessages = {
selected: string; selected: string;
tags: string; tags: string;
selectTags: string; selectTags: string;
comments: string;
}; };
type RunDetailMessages = { type RunDetailMessages = {
@@ -102,6 +103,9 @@ type RunDetailMessages = {
preconditions: string; preconditions: string;
expectedResult: string; expectedResult: string;
detailsOfTheStep: string; detailsOfTheStep: string;
caseDetail: string;
comments: string;
history: string;
}; };
export type { export type {

View File

@@ -8,6 +8,7 @@ export type UserType = {
username: string; username: string;
role: number; role: number;
avatarPath: string | null; avatarPath: string | null;
locale: LocaleCodeType | null;
}; };
export type TokenProps = { export type TokenProps = {

View File

@@ -0,0 +1,110 @@
import { logError } from './errorHandler';
import { CommentType } from '@/types/comment';
import Config from '@/config/config';
const apiServer = Config.apiServer;
export async function fetchComments(
jwt: string,
commentableType: 'RunCase' | 'Run' | 'Case',
commentableId: number
): Promise<CommentType[]> {
const fetchOptions = {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwt}`,
},
};
const url = `${apiServer}/comments?commentableType=${commentableType}&commentableId=${commentableId}`;
try {
const response = await fetch(url, fetchOptions);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
return data || [];
} catch (error: unknown) {
logError('Error fetching comments:', error);
return [];
}
}
export async function createComment(
jwt: string,
commentableType: 'RunCase' | 'Run' | 'Case',
commentableId: number,
content: string
): Promise<CommentType | null> {
const fetchOptions = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwt}`,
},
body: JSON.stringify({ content }),
};
const url = `${apiServer}/comments/?commentableType=${commentableType}&commentableId=${commentableId}`;
try {
const response = await fetch(url, fetchOptions);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
return data || null;
} catch (error: unknown) {
logError('Error creating comments:', error);
return null;
}
}
export async function updateComment(jwt: string, commentId: number, content: string): Promise<CommentType | null> {
const fetchOptions = {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwt}`,
},
body: JSON.stringify({ content }),
};
const url = `${apiServer}/comments/${commentId}`;
try {
const response = await fetch(url, fetchOptions);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
return data || null;
} catch (error: unknown) {
logError('Error updating comments:', error);
return null;
}
}
export async function deleteComment(jwt: string, commentId: number): Promise<void> {
const fetchOptions = {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwt}`,
},
};
const url = `${apiServer}/comments/${commentId}`;
try {
const response = await fetch(url, fetchOptions);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
await response.json();
return;
} catch (error: unknown) {
logError('Error deleting comments:', error);
return;
}
}

View File

@@ -0,0 +1,103 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// mock cleanup
let cleanupFn: (() => void) | undefined;
vi.mock('react', () => ({
useEffect: (fn: () => void) => {
cleanupFn = fn() as (() => void) | undefined;
},
}));
import { useFormGuard } from './formGuard';
describe('useFormGuard', () => {
let confirmSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
// @ts-expect-error - we will mock confirm
confirmSpy = vi.spyOn(window, 'confirm');
cleanupFn = undefined;
});
afterEach(() => {
cleanupFn?.();
cleanupFn = undefined;
vi.restoreAllMocks();
document.body.innerHTML = '';
});
// helper: simulate clicking an anchor
const clickAnchor = (href: string, target?: string): MouseEvent => {
const anchor = document.createElement('a');
anchor.setAttribute('href', href);
if (target) anchor.setAttribute('target', target);
document.body.appendChild(anchor);
const event = new MouseEvent('click', { bubbles: true, cancelable: true });
anchor.dispatchEvent(event);
return event;
};
describe('Basic functionality', () => {
it('does not show confirm when form is clean', () => {
useFormGuard(false, 'Leave?');
clickAnchor('/some-path');
expect(confirmSpy).not.toHaveBeenCalled();
});
it('shows confirm with the given text when dirty', () => {
confirmSpy.mockReturnValue(true);
useFormGuard(true, 'Are you sure?');
clickAnchor('/other-page');
expect(confirmSpy).toHaveBeenCalledWith('Are you sure?');
});
it('prevents navigation when dirty and user cancels confirm', () => {
confirmSpy.mockReturnValue(false);
useFormGuard(true, 'Leave?');
const event = clickAnchor('/other-page');
expect(event.defaultPrevented).toBe(true);
});
it('allows navigation when dirty and user confirms', () => {
confirmSpy.mockReturnValue(true);
useFormGuard(true, 'Leave?');
const event = clickAnchor('/other-page');
expect(event.defaultPrevented).toBe(false);
});
});
describe('external links', () => {
it('does not show confirm for anchor with target="_blank"', () => {
useFormGuard(true, 'Leave?');
clickAnchor('https://example.com', '_blank');
expect(confirmSpy).not.toHaveBeenCalled();
});
});
describe('UnitTCMS use case', () => {
it('does not show confirm when navigating to case detail page of test run', () => {
const projectId = '1';
const runId = '2';
useFormGuard(true, 'Leave?', [`/projects/${projectId}/runs/${runId}/cases/\\d+`]);
clickAnchor(`/projects/${projectId}/runs/${runId}/cases/123`);
expect(confirmSpy).not.toHaveBeenCalled();
});
it('shows confirm when navigating to test cases page', () => {
const projectId = '1';
const runId = '2';
useFormGuard(true, 'Leave?', [`/projects/${projectId}/runs/${runId}/cases/\\d+`]);
clickAnchor(`/projects/${projectId}/runs/${runId}/cases`);
expect(confirmSpy).toHaveBeenCalled();
});
});
});

View File

@@ -1,7 +1,19 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
export const useFormGuard = (isDirty: boolean, confirmText: string, ignorePaths?: string[]) => { const isIgnoredPath = (href: string, compiledPatterns: RegExp[]): boolean => {
return compiledPatterns.some((regex) => regex.test(href));
};
export const useFormGuard = (isDirty: boolean, confirmText: string, ignorePathPatterns?: string[]) => {
useEffect(() => { useEffect(() => {
const compiledPatterns: RegExp[] = (ignorePathPatterns ?? []).flatMap((pattern) => {
try {
return [new RegExp(pattern)];
} catch {
return [];
}
});
const handleClick = (event: MouseEvent) => { const handleClick = (event: MouseEvent) => {
if (!isDirty) return; if (!isDirty) return;
@@ -13,7 +25,7 @@ export const useFormGuard = (isDirty: boolean, confirmText: string, ignorePaths?
if (!href) return; if (!href) return;
// do not show confirm for ignored paths // do not show confirm for ignored paths
if (ignorePaths && ignorePaths.some((path) => href.includes(path))) { if (isIgnoredPath(href, compiledPatterns)) {
return; return;
} }
@@ -38,5 +50,5 @@ export const useFormGuard = (isDirty: boolean, confirmText: string, ignorePaths?
window.removeEventListener('beforeunload', handleBeforeUnload); window.removeEventListener('beforeunload', handleBeforeUnload);
window.removeEventListener('click', handleClick, true); window.removeEventListener('click', handleClick, true);
}; };
}, [confirmText, isDirty, ignorePaths]); }, [confirmText, isDirty, ignorePathPatterns]);
}; };

View File

@@ -219,6 +219,36 @@ async function adminResetPassword(jwt: string, userId: number, newPassword: stri
} }
} }
async function updateLocale(jwt: string, locale: string) {
const updateData = {
locale,
};
const fetchOptions = {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwt}`,
},
body: JSON.stringify(updateData),
};
const url = `${apiServer}/users/locale`;
try {
const response = await fetch(url, fetchOptions);
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || `HTTP error! Status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error: unknown) {
logError('Error updating locale:', error);
throw error;
}
}
export { export {
findUser, findUser,
searchUsers, searchUsers,
@@ -228,4 +258,5 @@ export {
uploadAvatar, uploadAvatar,
deleteAvatar, deleteAvatar,
adminResetPassword, adminResetPassword,
updateLocale,
}; };

106
package-lock.json generated
View File

@@ -19,7 +19,9 @@
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"express": "^4.21.0", "express": "^4.21.0",
"globals": "^16.0.0", "globals": "^16.0.0",
"happy-dom": "^20.8.3",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"react": "^19.2.4",
"sequelize": "^6.37.4", "sequelize": "^6.37.4",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",
"supertest": "^7.1.4", "supertest": "^7.1.4",
@@ -50,10 +52,11 @@
} }
}, },
"node_modules/@babel/helper-validator-identifier": { "node_modules/@babel/helper-validator-identifier": {
"version": "7.25.9", "version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
@@ -1255,6 +1258,23 @@
"integrity": "sha512-2ipwZ2NydGQJImne+FhNdhgRM37e9lCev99KnqkbFHd94Xn/mErARWI1RSLem1QA19ch5kOhzIZd7e8CA2FI8g==", "integrity": "sha512-2ipwZ2NydGQJImne+FhNdhgRM37e9lCev99KnqkbFHd94Xn/mErARWI1RSLem1QA19ch5kOhzIZd7e8CA2FI8g==",
"dev": true "dev": true
}, },
"node_modules/@types/whatwg-mimetype": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.29.1", "version": "8.29.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.29.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.29.1.tgz",
@@ -1289,6 +1309,7 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.29.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.29.1.tgz",
"integrity": "sha512-zczrHVEqEaTwh12gWBIJWj8nx+ayDcCJs06yoNMY0kwjMWDM6+kppljY+BxWI06d2Ja+h4+WdufDcwMnnMEWmg==", "integrity": "sha512-zczrHVEqEaTwh12gWBIJWj8nx+ayDcCJs06yoNMY0kwjMWDM6+kppljY+BxWI06d2Ja+h4+WdufDcwMnnMEWmg==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.29.1", "@typescript-eslint/scope-manager": "8.29.1",
"@typescript-eslint/types": "8.29.1", "@typescript-eslint/types": "8.29.1",
@@ -1838,6 +1859,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
"dev": true, "dev": true,
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -2938,6 +2960,19 @@
"once": "^1.4.0" "once": "^1.4.0"
} }
}, },
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/env-paths": { "node_modules/env-paths": {
"version": "2.2.1", "version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
@@ -3182,6 +3217,7 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.24.0.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.24.0.tgz",
"integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==", "integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -3361,6 +3397,7 @@
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz",
"integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"@rtsao/scc": "^1.1.0", "@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.8", "array-includes": "^3.1.8",
@@ -4220,6 +4257,25 @@
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
"dev": true "dev": true
}, },
"node_modules/happy-dom": {
"version": "20.8.3",
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.3.tgz",
"integrity": "sha512-lMHQRRwIPyJ70HV0kkFT7jH/gXzSI7yDkQFe07E2flwmNDFoWUTRMKpW2sglsnpeA7b6S2TJPp98EbQxai8eaQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/node": ">=20.0.0",
"@types/whatwg-mimetype": "^3.0.2",
"@types/ws": "^8.18.1",
"entities": "^7.0.1",
"whatwg-mimetype": "^3.0.0",
"ws": "^8.18.3"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/has-bigints": { "node_modules/has-bigints": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
@@ -6349,6 +6405,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -7498,6 +7564,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true, "dev": true,
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -7974,6 +8041,7 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz",
"integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"@vitest/expect": "1.6.1", "@vitest/expect": "1.6.1",
"@vitest/runner": "1.6.1", "@vitest/runner": "1.6.1",
@@ -8034,6 +8102,16 @@
} }
} }
}, },
"node_modules/whatwg-mimetype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -8184,6 +8262,28 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true "dev": true
}, },
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",

View File

@@ -25,7 +25,9 @@
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"express": "^4.21.0", "express": "^4.21.0",
"globals": "^16.0.0", "globals": "^16.0.0",
"happy-dom": "^20.8.3",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"react": "^19.2.4",
"sequelize": "^6.37.4", "sequelize": "^6.37.4",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",
"supertest": "^7.1.4", "supertest": "^7.1.4",