Merge pull request #394 from kimatata/develop
This commit is contained in:
16
backend/config/contentDisposition.js
Normal file
16
backend/config/contentDisposition.js
Normal 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}`;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
47
backend/migrations/20260131000000-create-comments.js
Normal file
47
backend/migrations/20260131000000-create-comments.js
Normal 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');
|
||||||
|
}
|
||||||
47
backend/models/comments.js
Normal file
47
backend/models/comments.js
Normal 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;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
38
backend/routes/comments/delete.js
Normal file
38
backend/routes/comments/delete.js
Normal 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;
|
||||||
|
}
|
||||||
53
backend/routes/comments/edit.js
Normal file
53
backend/routes/comments/edit.js
Normal 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;
|
||||||
|
}
|
||||||
46
backend/routes/comments/index.js
Normal file
46
backend/routes/comments/index.js
Normal 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;
|
||||||
|
}
|
||||||
50
backend/routes/comments/new.js
Normal file
50
backend/routes/comments/new.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
95
frontend/components/CommentItem.tsx
Normal file
95
frontend/components/CommentItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
202
frontend/components/Comments.tsx
Normal file
202
frontend/components/Comments.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
frontend/components/History.tsx
Normal file
13
frontend/components/History.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
frontend/components/ResizablePane.tsx
Normal file
77
frontend/components/ResizablePane.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "メンバー管理",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "成员管理",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
32
frontend/types/comment.ts
Normal 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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
110
frontend/utils/commentControl.ts
Normal file
110
frontend/utils/commentControl.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
17
frontend/utils/request.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user