Merge pull request #394 from kimatata/develop

This commit is contained in:
kimatata
2026-02-23 12:41:26 +09:00
committed by GitHub
45 changed files with 1719 additions and 339 deletions

View File

@@ -0,0 +1,16 @@
export function toSafeFileName(fileName) {
const s = String(fileName ?? '')
// eslint-disable-next-line no-control-regex
.replace(/[\u0000-\u001F\u007F]/g, '')
.replace(/[<>:"/\\|?*]/g, '_')
.replace(/\s+/g, ' ')
.trim();
return s;
}
export function contentDisposition(filename) {
const fallback = filename.replace(/[^\x20-\x7E]/g, '_').replace(/"/g, "'");
const encoded = encodeURIComponent(filename);
return `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`;
}

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

@@ -7,6 +7,7 @@ import defineStep from '../../models/steps.js';
import defineFolder from '../../models/folders.js'; import defineFolder from '../../models/folders.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 { contentDisposition, toSafeFileName } from '../../config/contentDisposition.js';
import { testRunStatus, priorities, testTypes, automationStatus, templates } from '../../config/enums.js'; import { testRunStatus, priorities, testTypes, automationStatus, templates } from '../../config/enums.js';
export default function (sequelize) { export default function (sequelize) {
@@ -31,6 +32,16 @@ export default function (sequelize) {
} }
try { try {
const folder = await Folder.findByPk(folderId);
if (!folder) {
return res.status(404).send('Folder not found');
}
const folderName = toSafeFileName(folder.name);
const filename = `${folderName}.${type}`;
res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition');
res.setHeader('Content-Disposition', contentDisposition(filename));
const cases = await Case.findAll({ const cases = await Case.findAll({
attributes: { exclude: ['createdAt', 'updatedAt', 'caseSteps'] }, attributes: { exclude: ['createdAt', 'updatedAt', 'caseSteps'] },
include: [ include: [
@@ -74,7 +85,6 @@ export default function (sequelize) {
}); });
res.setHeader('Content-Type', 'text/csv; charset=utf-8'); res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename=cases_folder_${folderId}.csv`);
return res.send(csv); return res.send(csv);
} }

View File

@@ -34,7 +34,13 @@ vi.mock('../../middleware/verifyVisible.js', () => ({
}), }),
})); }));
// mock defineCase const mockFolder = {
findByPk: vi.fn(),
};
vi.mock('../../models/folders.js', () => ({
default: () => mockFolder,
}));
const mockCase = { const mockCase = {
findAll: vi.fn(), findAll: vi.fn(),
belongsToMany: vi.fn(), belongsToMany: vi.fn(),
@@ -66,6 +72,8 @@ describe('GET /download with type=csv', () => {
}); });
it('should return CSV with human-readable labels for state, priority, type, automationStatus, and template', async () => { it('should return CSV with human-readable labels for state, priority, type, automationStatus, and template', async () => {
mockFolder.findByPk.mockResolvedValue({ id: 1, name: 'Test Folder' });
mockCase.findAll.mockResolvedValue([ mockCase.findAll.mockResolvedValue([
{ {
id: 1, id: 1,
@@ -112,7 +120,7 @@ describe('GET /download with type=csv', () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.headers['content-type']).toBe('text/csv; charset=utf-8'); expect(response.headers['content-type']).toBe('text/csv; charset=utf-8');
expect(response.headers['content-disposition']).toBe('attachment; filename=cases_folder_1.csv'); expect(response.headers['content-disposition']).toContain('attachment; filename="Test Folder.csv"');
const csvContent = response.text; const csvContent = response.text;

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

@@ -7,8 +7,10 @@ import defineRun from '../../models/runs.js';
import defineRunCase from '../../models/runCases.js'; import defineRunCase from '../../models/runCases.js';
import defineCase from '../../models/cases.js'; import defineCase from '../../models/cases.js';
import defineFolder from '../../models/folders.js'; import defineFolder from '../../models/folders.js';
import defineTag from '../../models/tags.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 { contentDisposition, toSafeFileName } from '../../config/contentDisposition.js';
import { testRunCaseStatus, testRunStatus, priorities, testTypes, automationStatus } from '../../config/enums.js'; import { testRunCaseStatus, testRunStatus, priorities, testTypes, automationStatus } from '../../config/enums.js';
export default function (sequelize) { export default function (sequelize) {
@@ -19,8 +21,11 @@ export default function (sequelize) {
const RunCase = defineRunCase(sequelize, DataTypes); const RunCase = defineRunCase(sequelize, DataTypes);
const Case = defineCase(sequelize, DataTypes); const Case = defineCase(sequelize, DataTypes);
const Folder = defineFolder(sequelize, DataTypes); const Folder = defineFolder(sequelize, DataTypes);
const Tags = defineTag(sequelize, DataTypes);
RunCase.belongsTo(Case, { foreignKey: 'caseId' }); RunCase.belongsTo(Case, { foreignKey: 'caseId' });
Case.belongsToMany(Tags, { through: 'caseTags', foreignKey: 'caseId', otherKey: 'tagId' });
Tags.belongsToMany(Case, { through: 'caseTags', foreignKey: 'tagId', otherKey: 'caseId' });
router.get('/download/:runId', verifySignedIn, verifyProjectVisibleFromRunId, async (req, res) => { router.get('/download/:runId', verifySignedIn, verifyProjectVisibleFromRunId, async (req, res) => {
const { runId } = req.params; const { runId } = req.params;
@@ -36,9 +41,25 @@ export default function (sequelize) {
return res.status(404).send('Run not found'); return res.status(404).send('Run not found');
} }
const runName = toSafeFileName(run.name);
const filename = `${runName}.${type}`;
res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition');
res.setHeader('Content-Disposition', contentDisposition(filename));
const runCases = await RunCase.findAll({ const runCases = await RunCase.findAll({
where: { runId }, where: { runId },
include: [{ model: Case }], include: [
{
model: Case,
include: [
{
model: Tags,
attributes: ['id', 'name'],
through: { attributes: [] },
},
],
},
],
}); });
if (type === 'xml') { if (type === 'xml') {
@@ -92,7 +113,6 @@ export default function (sequelize) {
const xmlString = xml.end({ prettyPrint: true }); const xmlString = xml.end({ prettyPrint: true });
res.setHeader('Content-Type', 'application/xml'); res.setHeader('Content-Type', 'application/xml');
res.setHeader('Content-Disposition', `attachment; filename=run_${runId}.xml`);
return res.send(xmlString); return res.send(xmlString);
} else if (type === 'json') { } else if (type === 'json') {
return res.json(runCases); return res.json(runCases);
@@ -104,6 +124,7 @@ export default function (sequelize) {
priority: priorities[rc.Case.priority] || rc.Case.priority, priority: priorities[rc.Case.priority] || rc.Case.priority,
type: testTypes[rc.Case.type] || rc.Case.type, type: testTypes[rc.Case.type] || rc.Case.type,
automationStatus: automationStatus[rc.Case.automationStatus] || rc.Case.automationStatus, automationStatus: automationStatus[rc.Case.automationStatus] || rc.Case.automationStatus,
tags: rc.Case.Tags && rc.Case.Tags.length > 0 ? rc.Case.Tags.map((tag) => tag.name).join(', ') : '',
status: testRunCaseStatus[rc.status] || rc.status, status: testRunCaseStatus[rc.status] || rc.status,
})); }));
@@ -113,7 +134,6 @@ export default function (sequelize) {
}); });
res.setHeader('Content-Type', 'text/csv; charset=utf-8'); res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename=run_${runId}.csv`);
return res.send(csv); return res.send(csv);
} }

View File

@@ -59,6 +59,7 @@ vi.mock('../../models/runCases.js', () => ({
// mock defineCase // mock defineCase
const mockCase = { const mockCase = {
belongsTo: vi.fn(), belongsTo: vi.fn(),
belongsToMany: vi.fn(),
}; };
vi.mock('../../models/cases.js', () => ({ vi.mock('../../models/cases.js', () => ({
default: () => mockCase, default: () => mockCase,
@@ -72,6 +73,14 @@ vi.mock('../../models/folders.js', () => ({
default: () => mockFolder, default: () => mockFolder,
})); }));
// mock defineTag
const mockTags = {
belongsToMany: vi.fn(),
};
vi.mock('../../models/tags.js', () => ({
default: () => mockTags,
}));
describe('GET /download/:runId with type=csv', () => { describe('GET /download/:runId with type=csv', () => {
let app; let app;
const sequelize = new Sequelize({ const sequelize = new Sequelize({
@@ -105,6 +114,10 @@ describe('GET /download/:runId with type=csv', () => {
priority: 0, // critical priority: 0, // critical
type: 4, // functional type: 4, // functional
automationStatus: 0, // automated automationStatus: 0, // automated
Tags: [
{ id: 1, name: 'tag1' },
{ id: 2, name: 'tag2' },
],
}, },
}, },
{ {
@@ -119,6 +132,7 @@ describe('GET /download/:runId with type=csv', () => {
priority: 1, // high priority: 1, // high
type: 1, // security type: 1, // security
automationStatus: 1, // automation-not-required automationStatus: 1, // automation-not-required
Tags: [],
}, },
}, },
{ {
@@ -133,6 +147,7 @@ describe('GET /download/:runId with type=csv', () => {
priority: 2, // medium priority: 2, // medium
type: 2, // performance type: 2, // performance
automationStatus: 2, // cannot-be-automated automationStatus: 2, // cannot-be-automated
Tags: [{ id: 3, name: 'tag3' }],
}, },
}, },
]); ]);
@@ -141,7 +156,7 @@ describe('GET /download/:runId with type=csv', () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.headers['content-type']).toBe('text/csv; charset=utf-8'); expect(response.headers['content-type']).toBe('text/csv; charset=utf-8');
expect(response.headers['content-disposition']).toBe('attachment; filename=run_1.csv'); expect(response.headers['content-disposition']).toContain('attachment; filename="Test Run.csv"');
const csvContent = response.text; const csvContent = response.text;
@@ -162,6 +177,11 @@ describe('GET /download/:runId with type=csv', () => {
expect(csvContent).toContain('inProgress'); expect(csvContent).toContain('inProgress');
expect(csvContent).toContain('underReview'); expect(csvContent).toContain('underReview');
// Check that tags are included in the CSV
expect(csvContent).toContain('tags');
expect(csvContent).toContain('tag1, tag2');
expect(csvContent).toContain('tag3');
// Ensure numeric values are not present (except for id which should be numeric) // Ensure numeric values are not present (except for id which should be numeric)
const lines = csvContent.split('\n'); const lines = csvContent.split('\n');
const dataLines = lines.slice(1).filter((line) => line.trim()); // Skip header const dataLines = lines.slice(1).filter((line) => line.trim()); // Skip header

View File

@@ -175,6 +175,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

@@ -0,0 +1,202 @@
'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';
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));
return (
<div className="h-full flex flex-col justify-between">
{comments.length === 0 ? (
<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>
);
}

View File

@@ -0,0 +1,13 @@
'use client';
import { Alert } from '@heroui/react';
export default function History() {
return (
<div className="h-full text-default-500">
<div className="mb-4">
<Alert color="secondary" title="Sorry" description={'History function will be implemented'} />
</div>
</div>
);
}

View File

@@ -0,0 +1,77 @@
'use client';
import { useState, useRef, useEffect, ReactNode } from 'react';
type Props = {
leftPane: ReactNode;
rightPane: ReactNode;
minLeftWidth?: number;
minRightWidth?: number;
defaultLeftWidth?: number;
};
export default function ResizablePanes({
leftPane,
rightPane,
minLeftWidth = 40,
minRightWidth = 15,
defaultLeftWidth = 70,
}: Props) {
const [leftWidth, setLeftWidth] = useState(defaultLeftWidth); // default 70%
const [isDragging, setIsDragging] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const handleMouseDown = () => {
setIsDragging(true);
};
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging || !containerRef.current) return;
const containerRect = containerRef.current.getBoundingClientRect();
const newLeftWidth = ((e.clientX - containerRect.left) / containerRect.width) * 100;
// Clamp the width between min and max
const maxLeftWidth = 100 - minRightWidth;
const clampedWidth = Math.max(minLeftWidth, Math.min(maxLeftWidth, newLeftWidth));
setLeftWidth(clampedWidth);
};
const handleMouseUp = () => {
setIsDragging(false);
};
if (isDragging) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, minLeftWidth, minRightWidth]);
return (
<div ref={containerRef} className="flex h-full" style={{ userSelect: isDragging ? 'none' : 'auto' }}>
<div
className="border-r-1 dark:border-neutral-700 overflow-auto"
style={{ width: `${leftWidth}%`, minWidth: `${minLeftWidth}%` }}
>
{leftPane}
</div>
<div
className="w-1 cursor-col-resize hover:bg-primary/50 active:bg-primary transition-colors"
role="separator"
onMouseDown={handleMouseDown}
style={{ flexShrink: 0 }}
/>
<div className="flex-1 overflow-auto" style={{ minWidth: `${minRightWidth}%` }}>
{rightPane}
</div>
</div>
);
}

View File

@@ -353,7 +353,26 @@
"case_title_or_description": "Testfall-Titel oder Beschreibung", "case_title_or_description": "Testfall-Titel oder Beschreibung",
"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",
"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

@@ -353,7 +353,26 @@
"case_title_or_description": "Test case title or description", "case_title_or_description": "Test case title or description",
"selected": "Selected", "selected": "Selected",
"tags": "Tags", "tags": "Tags",
"select_tags": "Select tags" "select_tags": "Select tags",
"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

@@ -353,7 +353,26 @@
"case_title_or_description": "テストケースのタイトルまたは説明", "case_title_or_description": "テストケースのタイトルまたは説明",
"selected": "選択済み", "selected": "選択済み",
"tags": "タグ", "tags": "タグ",
"select_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": { "Members": {
"member_management": "メンバー管理", "member_management": "メンバー管理",

View File

@@ -353,7 +353,26 @@
"case_title_or_description": "Título ou descrição do caso de teste", "case_title_or_description": "Título ou descrição do caso de teste",
"selected": "Selecionado", "selected": "Selecionado",
"tags": "Tags", "tags": "Tags",
"select_tags": "Selecionar tags" "select_tags": "Selecionar tags",
"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

@@ -353,7 +353,26 @@
"case_title_or_description": "测试用例标题或描述", "case_title_or_description": "测试用例标题或描述",
"selected": "已选择", "selected": "已选择",
"tags": "标签", "tags": "标签",
"select_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": { "Members": {
"member_management": "成员管理", "member_management": "成员管理",

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.27</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

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

@@ -111,7 +111,9 @@ export default function RunEditor({
const [statusFilter, setStatusFilter] = useState<number[]>([]); const [statusFilter, setStatusFilter] = useState<number[]>([]);
const [tagFilter, setTagFilter] = useState<number[]>([]); const [tagFilter, setTagFilter] = useState<number[]>([]);
const router = useRouter(); const router = useRouter();
useFormGuard(isDirty, messages.areYouSureLeave);
// not show warning when navigating to test case detail page
useFormGuard(isDirty, messages.areYouSureLeave, [`/projects/${projectId}/runs/${runId}/cases/\\d+`]);
const fetchRunAndStatusCount = async () => { const fetchRunAndStatusCount = async () => {
const { run, statusCounts } = await fetchRun(tokenContext.token.access_token, Number(runId)); const { run, statusCounts } = await fetchRun(tokenContext.token.access_token, Number(runId));
@@ -136,7 +138,10 @@ export default function RunEditor({
setTestCases(casesData); setTestCases(casesData);
}; };
const isSignedIn = tokenContext.isSignedIn();
useEffect(() => { useEffect(() => {
if (!isSignedIn) return;
async function fetchDataEffect() { async function fetchDataEffect() {
if (!tokenContext.isSignedIn()) { if (!tokenContext.isSignedIn()) {
return; return;
@@ -156,7 +161,7 @@ export default function RunEditor({
fetchDataEffect(); fetchDataEffect();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [tokenContext]); }, [isSignedIn]);
useEffect(() => { useEffect(() => {
function onFilter() { function onFilter() {
@@ -500,8 +505,11 @@ 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}
runId={runId}
locale={locale}
cases={filteredTestCases} cases={filteredTestCases}
isDisabled={!tokenContext.isProjectReporter(Number(projectId))} isDisabled={!tokenContext.isProjectReporter(Number(projectId))}
selectedKeys={selectedKeys} selectedKeys={selectedKeys}

View File

@@ -1,172 +0,0 @@
import { useState, useEffect, useContext } from 'react';
import { Button, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Avatar, Textarea } from '@heroui/react';
import { testTypes, templates } from '@/config/selection';
import { RunMessages } from '@/types/run';
import { CaseType, StepType } from '@/types/case';
import { PriorityMessages } from '@/types/priority';
import TestCasePriority from '@/components/TestCasePriority';
import { TokenContext } from '@/utils/TokenProvider';
import { fetchCase } from '@/utils/caseControl';
import { TestTypeMessages } from '@/types/testType';
import { logError } from '@/utils/errorHandler';
type Props = {
isOpen: boolean;
caseId: number;
onCancel: () => void;
messages: RunMessages;
testTypeMessages: TestTypeMessages;
priorityMessages: PriorityMessages;
};
const defaultTestCase = {
id: 0,
title: '',
state: 0,
priority: 0,
type: 0,
automationStatus: 0,
description: '',
template: 0,
preConditions: '',
expectedResults: '',
folderId: 0,
};
export default function TestCaseDetailDialog({
isOpen,
caseId,
onCancel,
messages,
testTypeMessages,
priorityMessages,
}: Props) {
const context = useContext(TokenContext);
const [testCase, setTestCase] = useState<CaseType>(defaultTestCase);
useEffect(() => {
async function fetchDataEffect() {
if (!context.isSignedIn()) {
return;
}
if (!caseId || caseId <= 0) {
return;
}
try {
const data = await fetchCase(context.token.access_token, Number(caseId));
if (data.Steps && data.Steps.length > 0) {
data.Steps.sort((a: StepType, b: StepType) => {
const stepNoA = a.caseSteps.stepNo;
const stepNoB = b.caseSteps.stepNo;
return stepNoA - stepNoB;
});
}
setTestCase(data);
} catch (error: unknown) {
logError('Error fetching case data', error);
}
}
fetchDataEffect();
}, [context, caseId]);
return (
<Modal
isOpen={isOpen}
size="5xl"
scrollBehavior="outside"
onOpenChange={() => {
onCancel();
}}
classNames={{
header: 'border-b-[1px] border-[#e5e5e5]',
body: 'border-b-[1px] border-[#e5e5e5]',
}}
>
<ModalContent>
<ModalHeader className="flex flex-col gap-1">{testCase.title}</ModalHeader>
<ModalBody>
<p className={'font-bold mt-2'}>{messages.description}</p>
<div>{testCase.description}</div>
<div className="flex my-2">
<div className="w-1/2">
<p className={'font-bold'}>{messages.priority}</p>
<TestCasePriority priorityValue={testCase.priority} priorityMessages={priorityMessages} />
</div>
<div className="w-1/2">
<p className={'font-bold'}>{messages.type}</p>
<div>{testTypeMessages[testTypes[testCase.type].uid]}</div>
</div>
</div>
</ModalBody>
<ModalBody>
{templates[testCase.template].uid === 'text' ? (
<>
<p className={'font-bold mt-2'}>{messages.testDetail}</p>
<div className="flex gap-2 my-2">
<div className="w-1/2">
<Textarea
isReadOnly
size="sm"
variant="flat"
label={messages.preconditions}
value={testCase.preConditions}
/>
</div>
<div className="w-1/2">
<Textarea
isReadOnly
size="sm"
variant="flat"
label={messages.expectedResult}
value={testCase.expectedResults}
/>
</div>
</div>
</>
) : (
<>
<p className={'font-bold mt-2'}>{messages.steps}</p>
{testCase.Steps &&
testCase.Steps.map((step) => (
<div key={step.id} className="flex items-center my-1">
<Avatar className="me-2" size="sm" name={step.caseSteps.stepNo.toString()} />
<div key={step.id} className="grow flex gap-2">
<div className="w-1/2">
<Textarea
isReadOnly
size="sm"
variant="flat"
label={messages.detailsOfTheStep}
value={step.step}
/>
</div>
<div className="w-1/2">
<Textarea
isReadOnly
size="sm"
variant="flat"
label={messages.expectedResult}
value={step.result}
/>
</div>
</div>
</div>
))}
</>
)}
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onCancel}>
{messages.close}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}

View File

@@ -13,10 +13,11 @@ import {
DropdownItem, DropdownItem,
Selection, Selection,
SortDescriptor, SortDescriptor,
Chip,
} from '@heroui/react'; } from '@heroui/react';
import { ChevronDown, MoveDiagonal, MoreVertical, CopyPlus, CopyMinus } from 'lucide-react'; import { ChevronDown, MoreVertical, CopyPlus, CopyMinus, MessageCircle } from 'lucide-react';
import TestCaseDetailDialog from './TestCaseDetailDialog';
import RunCaseStatus from './RunCaseStatus'; import RunCaseStatus from './RunCaseStatus';
import { Link, NextUiLinkClasses } from '@/src/i18n/routing';
import { testRunCaseStatus } from '@/config/selection'; import { testRunCaseStatus } from '@/config/selection';
import { CaseType } from '@/types/case'; import { CaseType } from '@/types/case';
import { RunMessages } from '@/types/run'; import { RunMessages } from '@/types/run';
@@ -26,6 +27,9 @@ import { TestTypeMessages } from '@/types/testType';
import { TestRunCaseStatusMessages } from '@/types/status'; import { TestRunCaseStatusMessages } from '@/types/status';
type Props = { type Props = {
projectId: string;
runId: string;
locale: string;
cases: CaseType[]; cases: CaseType[];
isDisabled: boolean; isDisabled: boolean;
selectedKeys: Selection; selectedKeys: Selection;
@@ -40,6 +44,9 @@ type Props = {
}; };
export default function TestCaseSelector({ export default function TestCaseSelector({
projectId,
runId,
locale,
cases, cases,
isDisabled, isDisabled,
selectedKeys, selectedKeys,
@@ -49,14 +56,15 @@ export default function TestCaseSelector({
onExcludeCase, onExcludeCase,
messages, messages,
testRunCaseStatusMessages, testRunCaseStatusMessages,
testTypeMessages,
priorityMessages, priorityMessages,
}: Props) { }: Props) {
const headerColumns = [ const headerColumns = [
{ name: messages.id, uid: 'id', sortable: true }, { name: messages.id, uid: 'id', sortable: true },
{ name: messages.title, uid: 'title', sortable: true }, { name: messages.title, uid: 'title', sortable: true },
{ name: messages.priority, uid: 'priority', sortable: true }, { name: messages.priority, uid: 'priority', sortable: true },
{ 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' },
]; ];
@@ -104,24 +112,25 @@ export default function TestCaseSelector({
return isIncluded; return isIncluded;
}; };
const renderCell = (testCase: CaseType, columnKey: string): ReactNode => { const renderCell = (testCase: CaseType, columnKey: string): ReactNode => {
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':
return ( return (
<Button <div className={isIncluded ? '' : notIncludedCaseClass}>
size="sm" <Link
variant="light" href={`/projects/${projectId}/runs/${runId}/cases/${testCase.id}`}
className="group" locale={locale}
endContent={<MoveDiagonal size={12} className="text-transparent group-hover:text-inherit" />} className={NextUiLinkClasses}
onPress={() => showTestCaseDetailDialog(testCase.id)} onPointerDown={(e) => e.stopPropagation()}
> >
{cellValue as string} {cellValue as string}
</Button> </Link>
</div>
); );
case 'priority': case 'priority':
return ( return (
@@ -129,6 +138,20 @@ export default function TestCaseSelector({
<TestCasePriority priorityValue={cellValue as number} priorityMessages={priorityMessages} /> <TestCasePriority priorityValue={cellValue as number} priorityMessages={priorityMessages} />
</div> </div>
); );
case 'tags':
return (
<div className={`flex gap-1 flex-wrap ${isIncluded ? '' : notIncludedCaseClass}`}>
{testCase.Tags && testCase.Tags.length > 0 ? (
testCase.Tags.map((tag) => (
<Chip key={tag.id} size="sm" variant="flat">
{tag.name}
</Chip>
))
) : (
<span>-</span>
)}
</div>
);
case 'runStatus': case 'runStatus':
return ( return (
<Dropdown> <Dropdown>
@@ -158,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>
@@ -222,17 +263,6 @@ export default function TestCaseSelector({
onSelectionChange(keys); onSelectionChange(keys);
}; };
// Test Case Detail
const [isTestCaseDetailDialogOpen, setIsTestCaseDetailDialogOpen] = useState(false);
const [showingTestCaseId, setShowingTestCaseId] = useState<number>(0);
const showTestCaseDetailDialog = (showTestCaseId: number) => {
setIsTestCaseDetailDialogOpen(true);
setShowingTestCaseId(showTestCaseId);
};
const hideTestCaseDetailDialog = () => {
setIsTestCaseDetailDialogOpen(false);
};
return ( return (
<> <>
<Table <Table
@@ -267,15 +297,6 @@ export default function TestCaseSelector({
))} ))}
</TableBody> </TableBody>
</Table> </Table>
<TestCaseDetailDialog
isOpen={isTestCaseDetailDialogOpen}
caseId={showingTestCaseId}
onCancel={hideTestCaseDetailDialog}
messages={messages}
priorityMessages={priorityMessages}
testTypeMessages={testTypeMessages}
/>
</> </>
); );
} }

View File

@@ -0,0 +1,112 @@
'use client';
import { Textarea, Chip } from '@heroui/react';
import { templates, testTypes } from '@/config/selection';
import type { CaseType } from '@/types/case';
import type { RunDetailMessages } from '@/types/run';
import type { PriorityMessages } from '@/types/priority';
import type { TestTypeMessages } from '@/types/testType';
import TestCasePriority from '@/components/TestCasePriority';
import { Link, NextUiLinkClasses } from '@/src/i18n/routing';
type Props = {
projectId: string;
testCase: CaseType;
locale: string;
messages: RunDetailMessages;
testTypeMessages: TestTypeMessages;
priorityMessages: PriorityMessages;
};
export default function CaseDetail({
projectId,
testCase,
locale,
messages,
testTypeMessages,
priorityMessages,
}: Props) {
return (
<div className="h-full p-4 text-default-500">
<div className="mb-4">
<Link
href={`/projects/${projectId}/folders/${testCase.folderId}/cases/${testCase.id}`}
locale={locale}
className={`${NextUiLinkClasses}`}
>
#{testCase.id} {testCase.title}
</Link>
</div>
<div className="mb-4">
<p className="font-bold">{messages.description}</p>
<div>{testCase.description}</div>
</div>
<div className="mb-4">
<p className="font-bold">{messages.priority}</p>
<TestCasePriority priorityValue={testCase.priority} priorityMessages={priorityMessages} />
</div>
<div className="mb-4">
<p className="font-bold">{messages.type}</p>
<div>{testTypeMessages[testTypes[testCase.type].uid]}</div>
</div>
<div className="mb-4">
<p className="font-bold">{messages.tags}</p>
<div className="flex gap-1 flex-wrap mt-1">
{testCase.Tags &&
testCase.Tags.length > 0 &&
testCase.Tags.map((tag) => (
<Chip key={tag.id} size="sm" variant="flat">
{tag.name}
</Chip>
))}
</div>
</div>
{templates[testCase.template].uid === 'text' ? (
<>
<p className="font-bold mt-2">{messages.testDetail}</p>
<div className="flex gap-2 my-2">
<div className="w-1/2">
<Textarea
isReadOnly
size="sm"
variant="flat"
label={messages.preconditions}
value={testCase.preConditions}
/>
</div>
<div className="w-1/2">
<Textarea
isReadOnly
size="sm"
variant="flat"
label={messages.expectedResult}
value={testCase.expectedResults}
/>
</div>
</div>
</>
) : (
<>
<p className="font-bold mt-2">{messages.steps}</p>
{testCase.Steps &&
testCase.Steps.length > 0 &&
testCase.Steps.map((step) => (
<div key={step.id} className="flex gap-2 my-2">
<div className="w-1/2">
<Textarea isReadOnly size="sm" variant="flat" label={messages.detailsOfTheStep} value={step.step} />
</div>
<div className="w-1/2">
<Textarea isReadOnly size="sm" variant="flat" label={messages.expectedResult} value={step.result} />
</div>
</div>
))}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,123 @@
'use client';
import { useEffect, useState, useContext } from 'react';
import { useSearchParams } from 'next/navigation';
import { Tabs, Tab } from '@heroui/react';
import CaseDetail from './CaseDetail';
import Comments from '@/components/Comments';
import History from '@/components/History';
import { TokenContext } from '@/utils/TokenProvider';
import { fetchCase } from '@/utils/caseControl';
import { logError } from '@/utils/errorHandler';
import type { CaseType, StepType } from '@/types/case';
import type { RunCaseType, RunDetailMessages } from '@/types/run';
import type { PriorityMessages } from '@/types/priority';
import type { TestTypeMessages } from '@/types/testType';
import type { CommentMessages } from '@/types/comment';
type Props = {
projectId: string;
runId: string;
locale: string;
caseId: string;
messages: RunDetailMessages;
testTypeMessages: TestTypeMessages;
priorityMessages: PriorityMessages;
commentMessages: CommentMessages;
};
export default function TestCaseDetailPane({
projectId,
runId,
locale,
caseId,
messages,
testTypeMessages,
priorityMessages,
commentMessages,
}: Props) {
const context = useContext(TokenContext);
const searchParams = useSearchParams();
const [selectedTab, setSelectedTab] = useState('caseDetail');
const [isFetching, setIsFetching] = useState(false);
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(() => {
async function fetchDataEffect() {
if (!context.isSignedIn()) return;
if (!caseId || Number(caseId) <= 0) return;
setIsFetching(true);
try {
const data = await fetchCase(context.token.access_token, Number(caseId));
if (data.Steps && data.Steps.length > 0) {
data.Steps.sort((a: StepType, b: StepType) => a.caseSteps.stepNo - b.caseSteps.stepNo);
}
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) {
logError('Error fetching case data', error);
} finally {
setIsFetching(false);
}
}
fetchDataEffect();
}, [context, caseId, runId]);
if (isFetching || !testCase) {
return <div>loading...</div>;
} else {
return (
<div className="flex h-full w-full flex-col p-3">
<Tabs
aria-label="Options"
size="sm"
selectedKey={selectedTab}
onSelectionChange={(key) => setSelectedTab(String(key))}
>
<Tab key="caseDetail" title={messages.caseDetail}>
<CaseDetail
projectId={projectId}
testCase={testCase}
locale={locale}
messages={messages}
testTypeMessages={testTypeMessages}
priorityMessages={priorityMessages}
/>
</Tab>
<Tab key="comments" title={messages.comments}>
<Comments
projectId={projectId}
commentableType="RunCase"
commentableId={runCaseId}
messages={commentMessages}
/>
</Tab>
<Tab key="history" title={messages.history}>
<History />
</Tab>
</Tabs>
</div>
);
}
}

View File

@@ -0,0 +1,83 @@
import { useTranslations } from 'next-intl';
import DetailPane from './DetailPane';
import type { RunDetailMessages } from '@/types/run';
import type { PriorityMessages } from '@/types/priority';
import type { TestTypeMessages } from '@/types/testType';
export default function Page({
params,
}: {
params: { projectId: string; runId: string; caseId: string; locale: string };
}) {
const t = useTranslations('Run');
const messages: RunDetailMessages = {
title: t('title'),
description: t('description'),
priority: t('priority'),
type: t('type'),
tags: t('tags'),
testDetail: t('test_detail'),
steps: t('steps'),
preconditions: t('preconditions'),
expectedResult: t('expected_result'),
detailsOfTheStep: t('details_of_the_step'),
caseDetail: t('case_detail'),
comments: t('comments'),
history: t('history'),
};
const pt = useTranslations('Priority');
const priorityMessages: PriorityMessages = {
critical: pt('critical'),
high: pt('high'),
medium: pt('medium'),
low: pt('low'),
};
const tt = useTranslations('Type');
const testTypeMessages: TestTypeMessages = {
other: tt('other'),
security: tt('security'),
performance: tt('performance'),
accessibility: tt('accessibility'),
functional: tt('functional'),
acceptance: tt('acceptance'),
usability: tt('usability'),
smokeSanity: tt('smoke_sanity'),
compatibility: tt('compatibility'),
destructive: tt('destructive'),
regression: tt('regression'),
automated: tt('automated'),
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 (
<DetailPane
projectId={params.projectId}
runId={params.runId}
caseId={params.caseId}
locale={params.locale}
messages={messages}
priorityMessages={priorityMessages}
testTypeMessages={testTypeMessages}
commentMessages={commentMessages}
/>
);
}

View File

@@ -0,0 +1,118 @@
import { useTranslations } from 'next-intl';
import RunEditor from './RunEditor';
import ResizablePanes from '@/components/ResizablePane';
import { RunMessages } from '@/types/run';
import { PriorityMessages } from '@/types/priority';
import { RunStatusMessages, TestRunCaseStatusMessages } from '@/types/status';
import { TestTypeMessages } from '@/types/testType';
export default function RunLayout({
children,
params: { projectId, runId, locale },
}: {
children: React.ReactNode;
params: { projectId: string; runId: string; locale: string };
}) {
const t = useTranslations('Run');
const messages: RunMessages = {
backToRuns: t('back_to_runs'),
updating: t('updating'),
update: t('update'),
updatedTestRun: t('updated_test_run'),
export: t('export'),
progress: t('progress'),
refresh: t('refresh'),
id: t('id'),
title: t('title'),
pleaseEnter: t('please_enter'),
description: t('description'),
priority: t('priority'),
actions: t('actions'),
status: t('status'),
selectTestCase: t('select_test_case'),
testCaseSelection: t('test_case_selection'),
includeInRun: t('include_in_run'),
excludeFromRun: t('exclude_from_run'),
noCasesFound: t('no_cases_found'),
areYouSureLeave: t('are_you_sure_leave'),
type: t('type'),
testDetail: t('test_detail'),
steps: t('steps'),
preconditions: t('preconditions'),
expectedResult: t('expected_result'),
detailsOfTheStep: t('details_of_the_step'),
close: t('close'),
filter: t('filter'),
clearAll: t('clear_all'),
apply: t('apply'),
selectStatus: t('select_status'),
pleaseSave: t('please_save'),
caseTitleOrDescription: t('case_title_or_description'),
selected: t('selected'),
tags: t('tags'),
selectTags: t('select_tags'),
comments: t('comments'),
};
const rst = useTranslations('RunStatus');
const runStatusMessages: RunStatusMessages = {
new: rst('new'),
inProgress: rst('inProgress'),
underReview: rst('underReview'),
rejected: rst('rejected'),
done: rst('done'),
closed: rst('closed'),
};
const rcst = useTranslations('RunCaseStatus');
const testRunCaseStatusMessages: TestRunCaseStatusMessages = {
untested: rcst('untested'),
passed: rcst('passed'),
failed: rcst('failed'),
retest: rcst('retest'),
skipped: rcst('skipped'),
};
const pt = useTranslations('Priority');
const priorityMessages: PriorityMessages = {
critical: pt('critical'),
high: pt('high'),
medium: pt('medium'),
low: pt('low'),
};
const tt = useTranslations('Type');
const testTypeMessages: TestTypeMessages = {
other: tt('other'),
security: tt('security'),
performance: tt('performance'),
accessibility: tt('accessibility'),
functional: tt('functional'),
acceptance: tt('acceptance'),
usability: tt('usability'),
smokeSanity: tt('smoke_sanity'),
compatibility: tt('compatibility'),
destructive: tt('destructive'),
regression: tt('regression'),
automated: tt('automated'),
manual: tt('manual'),
};
return (
<ResizablePanes
leftPane={
<RunEditor
projectId={projectId}
runId={runId}
messages={messages}
runStatusMessages={runStatusMessages}
testRunCaseStatusMessages={testRunCaseStatusMessages}
priorityMessages={priorityMessages}
testTypeMessages={testTypeMessages}
locale={locale}
/>
}
rightPane={children}
/>
);
}

View File

@@ -1,105 +1,13 @@
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import RunEditor from './RunEditor';
import { RunMessages } from '@/types/run';
import { PriorityMessages } from '@/types/priority';
import { RunStatusMessages, TestRunCaseStatusMessages } from '@/types/status';
import { TestTypeMessages } from '@/types/testType';
export default function Page({ params }: { params: { projectId: string; runId: string; locale: string } }) { export default function Page() {
const t = useTranslations('Run'); const t = useTranslations('Run');
const messages: RunMessages = {
backToRuns: t('back_to_runs'),
updating: t('updating'),
update: t('update'),
updatedTestRun: t('updated_test_run'),
export: t('export'),
progress: t('progress'),
refresh: t('refresh'),
id: t('id'),
title: t('title'),
pleaseEnter: t('please_enter'),
description: t('description'),
priority: t('priority'),
actions: t('actions'),
status: t('status'),
selectTestCase: t('select_test_case'),
testCaseSelection: t('test_case_selection'),
includeInRun: t('include_in_run'),
excludeFromRun: t('exclude_from_run'),
noCasesFound: t('no_cases_found'),
areYouSureLeave: t('are_you_sure_leave'),
type: t('type'),
testDetail: t('test_detail'),
steps: t('steps'),
preconditions: t('preconditions'),
expectedResult: t('expected_result'),
detailsOfTheStep: t('details_of_the_step'),
close: t('close'),
filter: t('filter'),
clearAll: t('clear_all'),
apply: t('apply'),
selectStatus: t('select_status'),
pleaseSave: t('please_save'),
caseTitleOrDescription: t('case_title_or_description'),
selected: t('selected'),
tags: t('tags'),
selectTags: t('select_tags'),
};
const rst = useTranslations('RunStatus');
const runStatusMessages: RunStatusMessages = {
new: rst('new'),
inProgress: rst('inProgress'),
underReview: rst('underReview'),
rejected: rst('rejected'),
done: rst('done'),
closed: rst('closed'),
};
const rcst = useTranslations('RunCaseStatus');
const testRunCaseStatusMessages: TestRunCaseStatusMessages = {
untested: rcst('untested'),
passed: rcst('passed'),
failed: rcst('failed'),
retest: rcst('retest'),
skipped: rcst('skipped'),
};
const pt = useTranslations('Priority');
const priorityMessages: PriorityMessages = {
critical: pt('critical'),
high: pt('high'),
medium: pt('medium'),
low: pt('low'),
};
const tt = useTranslations('Type');
const testTypeMessages: TestTypeMessages = {
other: tt('other'),
security: tt('security'),
performance: tt('performance'),
accessibility: tt('accessibility'),
functional: tt('functional'),
acceptance: tt('acceptance'),
usability: tt('usability'),
smokeSanity: tt('smoke_sanity'),
compatibility: tt('compatibility'),
destructive: tt('destructive'),
regression: tt('regression'),
automated: tt('automated'),
manual: tt('manual'),
};
return ( return (
<RunEditor <div className="container mx-auto max-w-3xl pt-6 px-6 flex-grow">
projectId={params.projectId} <div className="w-full p-3 flex items-center justify-between">
runId={params.runId} <h3 className="font-bold">{t('no_case_selected')}</h3>
messages={messages} </div>
runStatusMessages={runStatusMessages} </div>
testRunCaseStatusMessages={testRunCaseStatusMessages}
priorityMessages={priorityMessages}
testTypeMessages={testTypeMessages}
locale={params.locale}
/>
); );
} }

View File

@@ -1,3 +1,4 @@
import { getFilenameFromContentDisposition } from '@/utils/request';
import { logError } from '@/utils/errorHandler'; import { logError } from '@/utils/errorHandler';
import { CaseType } from '@/types/case'; import { CaseType } from '@/types/case';
import { RunType, RunCaseType } from '@/types/run'; import { RunType, RunCaseType } from '@/types/run';
@@ -149,11 +150,14 @@ async function exportRun(jwt: string, runId: number, type: string) {
throw new Error(`HTTP error! Status: ${response.status}`); throw new Error(`HTTP error! Status: ${response.status}`);
} }
const disposition = response.headers.get('content-disposition');
const filename = getFilenameFromContentDisposition(disposition) ?? `cases.${type}`;
const blob = await response.blob(); const blob = await response.blob();
const objectUrl = window.URL.createObjectURL(blob); const objectUrl = window.URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = objectUrl; a.href = objectUrl;
a.download = `run_${runId}.${type}`; a.download = filename;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
a.remove(); a.remove();

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 = {

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

@@ -89,6 +89,31 @@ type RunMessages = {
selected: string; selected: string;
tags: string; tags: string;
selectTags: string; selectTags: string;
comments: string;
}; };
export type { RunType, RunCaseType, RunStatusCountType, ProgressSeriesType, RunsMessages, RunMessages }; type RunDetailMessages = {
title: string;
description: string;
priority: string;
type: string;
tags: string;
testDetail: string;
steps: string;
preconditions: string;
expectedResult: string;
detailsOfTheStep: string;
caseDetail: string;
comments: string;
history: string;
};
export type {
RunType,
RunCaseType,
RunStatusCountType,
ProgressSeriesType,
RunsMessages,
RunMessages,
RunDetailMessages,
};

View File

@@ -1,3 +1,4 @@
import { getFilenameFromContentDisposition } from '@/utils/request';
import { logError } from '@/utils/errorHandler'; import { logError } from '@/utils/errorHandler';
import Config from '@/config/config'; import Config from '@/config/config';
const apiServer = Config.apiServer; const apiServer = Config.apiServer;
@@ -221,11 +222,14 @@ async function exportCases(jwt: string, folderId: number, type: string) {
throw new Error(`HTTP error! Status: ${response.status}`); throw new Error(`HTTP error! Status: ${response.status}`);
} }
const disposition = response.headers.get('content-disposition');
const filename = getFilenameFromContentDisposition(disposition) ?? `cases.${type}`;
const blob = await response.blob(); const blob = await response.blob();
const objectUrl = window.URL.createObjectURL(blob); const objectUrl = window.URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = objectUrl; a.href = objectUrl;
a.download = `folder_${folderId}.${type}`; a.download = filename;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
a.remove(); a.remove();

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

@@ -1,9 +1,22 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
export const useFormGuard = (isDirty: boolean, confirmText: string) => { export const useFormGuard = (isDirty: boolean, confirmText: string, ignorePaths?: string[]) => {
useEffect(() => { useEffect(() => {
const handleClick = (event: MouseEvent) => { const handleClick = (event: MouseEvent) => {
if (isDirty && event.target instanceof Element && event.target.closest('a:not([target="_blank"]')) { if (!isDirty) return;
if (event.target instanceof Element) {
const anchor = event.target.closest('a:not([target="_blank"])');
if (!anchor) return;
const href = anchor.getAttribute('href');
if (!href) return;
// do not show confirm for ignored paths
if (ignorePaths && ignorePaths.some((path) => href.includes(path))) {
return;
}
if (!window.confirm(confirmText)) { if (!window.confirm(confirmText)) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
@@ -25,5 +38,5 @@ export const useFormGuard = (isDirty: boolean, confirmText: string) => {
window.removeEventListener('beforeunload', handleBeforeUnload); window.removeEventListener('beforeunload', handleBeforeUnload);
window.removeEventListener('click', handleClick, true); window.removeEventListener('click', handleClick, true);
}; };
}, [confirmText, isDirty]); }, [confirmText, isDirty, ignorePaths]);
}; };

17
frontend/utils/request.ts Normal file
View File

@@ -0,0 +1,17 @@
export function getFilenameFromContentDisposition(disposition: string | null): string | null {
if (!disposition) return null;
const filenameStar = disposition.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
if (filenameStar?.[1]) {
try {
return decodeURIComponent(filenameStar[1]);
} catch {
// ignore
}
}
const filename = disposition.match(/filename\s*=\s*"([^"]+)"/i) || disposition.match(/filename\s*=\s*([^;]+)/i);
if (filename?.[1]) return filename[1].trim();
return null;
}