feat: comment to test run's case (#390)
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,201 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
import { useEffect, useState, useContext } from 'react';
|
||||||
|
import { Button, Textarea, Spinner, addToast } from '@heroui/react';
|
||||||
|
import CommentItem from './CommentItem';
|
||||||
|
import { TokenContext } from '@/utils/TokenProvider';
|
||||||
|
import { fetchComments, createComment, updateComment, deleteComment } from '@/utils/commentControl';
|
||||||
|
import { logError } from '@/utils/errorHandler';
|
||||||
|
import type { CommentMessages, CommentType } from '@/types/comment';
|
||||||
|
|
||||||
import { Alert } from '@heroui/react';
|
type Props = {
|
||||||
|
projectId: string;
|
||||||
|
commentableType: 'RunCase' | 'Run' | 'Case';
|
||||||
|
commentableId?: number;
|
||||||
|
messages: CommentMessages;
|
||||||
|
};
|
||||||
|
|
||||||
export default function Comments() {
|
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 (
|
return (
|
||||||
<div className="h-full text-default-500">
|
<div className="text-default-500 text-sm">
|
||||||
<div className="mb-4">
|
{commentableType === 'RunCase' && !commentableId ? <p>{messages.notIncludedInRun}</p> : <p>Unknown state</p>}
|
||||||
<Alert color="secondary" title="Sorry" description={'Comments function will be implemented'} />
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -354,7 +354,25 @@
|
|||||||
"selected": "Ausgewählt",
|
"selected": "Ausgewählt",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"select_tags": "Tags auswählen",
|
"select_tags": "Tags auswählen",
|
||||||
"no_case_selected": "Kein Testfall ausgewählt"
|
"no_case_selected": "Kein Testfall ausgewählt",
|
||||||
|
"case_detail": "Testfall-Details",
|
||||||
|
"comments": "Kommentare",
|
||||||
|
"history": "Verlauf"
|
||||||
|
},
|
||||||
|
"Comments": {
|
||||||
|
"comments": "Kommentare",
|
||||||
|
"no_comments": "Keine Kommentare",
|
||||||
|
"add_comment": "Kommentar hinzufügen",
|
||||||
|
"save": "Speichern",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"placeholder": "Kommentar eingeben...",
|
||||||
|
"not_included_in_run": "Kann keine Kommentare zu Testfällen abgeben, die nicht im Testlauf enthalten sind",
|
||||||
|
"comment_added": "Kommentar hinzugefügt",
|
||||||
|
"failed_to_add_comment": "Fehler beim Hinzufügen des Kommentars",
|
||||||
|
"comment_updated": "Kommentar aktualisiert",
|
||||||
|
"failed_to_update_comment": "Fehler beim Aktualisieren des Kommentars",
|
||||||
|
"comment_deleted": "Kommentar gelöscht",
|
||||||
|
"failed_to_delete_comment": "Fehler beim Löschen des Kommentars"
|
||||||
},
|
},
|
||||||
"Members": {
|
"Members": {
|
||||||
"member_management": "Mitgliederverwaltung",
|
"member_management": "Mitgliederverwaltung",
|
||||||
|
|||||||
@@ -354,7 +354,25 @@
|
|||||||
"selected": "Selected",
|
"selected": "Selected",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"select_tags": "Select tags",
|
"select_tags": "Select tags",
|
||||||
"no_case_selected": "No test case selected"
|
"no_case_selected": "No test case selected",
|
||||||
|
"case_detail": "Test case detail",
|
||||||
|
"comments": "Comments",
|
||||||
|
"history": "History"
|
||||||
|
},
|
||||||
|
"Comments": {
|
||||||
|
"comments": "Comments",
|
||||||
|
"no_comments": "No comments",
|
||||||
|
"add_comment": "Add comment",
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"placeholder": "Enter a comment...",
|
||||||
|
"not_included_in_run": "Cannot comment on test cases not included in the test run",
|
||||||
|
"comment_added": "Comment added",
|
||||||
|
"failed_to_add_comment": "Failed to add comment",
|
||||||
|
"comment_updated": "Comment updated",
|
||||||
|
"failed_to_update_comment": "Failed to update comment",
|
||||||
|
"comment_deleted": "Comment deleted",
|
||||||
|
"failed_to_delete_comment": "Failed to delete comment"
|
||||||
},
|
},
|
||||||
"Members": {
|
"Members": {
|
||||||
"member_management": "Member Management",
|
"member_management": "Member Management",
|
||||||
|
|||||||
@@ -354,7 +354,25 @@
|
|||||||
"selected": "選択済み",
|
"selected": "選択済み",
|
||||||
"tags": "タグ",
|
"tags": "タグ",
|
||||||
"select_tags": "タグを選択",
|
"select_tags": "タグを選択",
|
||||||
"no_case_selected": "テストケースが選択されていません"
|
"no_case_selected": "テストケースが選択されていません",
|
||||||
|
"case_detail": "テストケース詳細",
|
||||||
|
"comments": "コメント",
|
||||||
|
"history": "履歴"
|
||||||
|
},
|
||||||
|
"Comments": {
|
||||||
|
"comments": "コメント",
|
||||||
|
"no_comments": "コメントがありません",
|
||||||
|
"add_comment": "コメントを追加",
|
||||||
|
"save": "保存",
|
||||||
|
"cancel": "キャンセル",
|
||||||
|
"placeholder": "コメントを入力...",
|
||||||
|
"not_included_in_run": "テストランに含まれていないテストケースにはコメントできません",
|
||||||
|
"comment_added": "コメントが追加されました",
|
||||||
|
"failed_to_add_comment": "コメントの追加に失敗しました",
|
||||||
|
"comment_updated": "コメントが更新されました",
|
||||||
|
"failed_to_update_comment": "コメントの更新に失敗しました",
|
||||||
|
"comment_deleted": "コメントが削除されました",
|
||||||
|
"failed_to_delete_comment": "コメントの削除に失敗しました"
|
||||||
},
|
},
|
||||||
"Members": {
|
"Members": {
|
||||||
"member_management": "メンバー管理",
|
"member_management": "メンバー管理",
|
||||||
|
|||||||
@@ -354,7 +354,25 @@
|
|||||||
"selected": "Selecionado",
|
"selected": "Selecionado",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"select_tags": "Selecionar tags",
|
"select_tags": "Selecionar tags",
|
||||||
"no_case_selected": "Nenhum caso de teste selecionado"
|
"no_case_selected": "Nenhum caso de teste selecionado",
|
||||||
|
"case_detail": "Detalhe do caso de teste",
|
||||||
|
"comments": "Comentários",
|
||||||
|
"history": "Histórico"
|
||||||
|
},
|
||||||
|
"Comments": {
|
||||||
|
"comments": "Comentários",
|
||||||
|
"no_comments": "Nenhum comentário",
|
||||||
|
"add_comment": "Adicionar comentário",
|
||||||
|
"save": "Salvar",
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"placeholder": "Digite um comentário...",
|
||||||
|
"not_included_in_run": "Não é possível comentar em casos de teste que não estão incluídos na execução de teste",
|
||||||
|
"comment_added": "Comentário adicionado",
|
||||||
|
"failed_to_add_comment": "Falha ao adicionar comentário",
|
||||||
|
"comment_updated": "Comentário atualizado",
|
||||||
|
"failed_to_update_comment": "Falha ao atualizar comentário",
|
||||||
|
"comment_deleted": "Comentário excluído",
|
||||||
|
"failed_to_delete_comment": "Falha ao excluir comentário"
|
||||||
},
|
},
|
||||||
"Members": {
|
"Members": {
|
||||||
"member_management": "Gerenciamento de Membros",
|
"member_management": "Gerenciamento de Membros",
|
||||||
|
|||||||
@@ -354,7 +354,25 @@
|
|||||||
"selected": "已选择",
|
"selected": "已选择",
|
||||||
"tags": "标签",
|
"tags": "标签",
|
||||||
"select_tags": "选择标签",
|
"select_tags": "选择标签",
|
||||||
"no_case_selected": "未选择测试用例"
|
"no_case_selected": "未选择测试用例",
|
||||||
|
"case_detail": "测试用例详情",
|
||||||
|
"comments": "评论",
|
||||||
|
"history": "历史"
|
||||||
|
},
|
||||||
|
"Comments": {
|
||||||
|
"comments": "评论",
|
||||||
|
"no_comments": "暂无评论",
|
||||||
|
"add_comment": "添加评论",
|
||||||
|
"save": "保存",
|
||||||
|
"cancel": "取消",
|
||||||
|
"placeholder": "输入评论...",
|
||||||
|
"not_included_in_run": "无法对未包含在测试运行中的测试用例进行评论",
|
||||||
|
"comment_added": "评论已添加",
|
||||||
|
"failed_to_add_comment": "添加评论失败",
|
||||||
|
"comment_updated": "评论已更新",
|
||||||
|
"failed_to_update_comment": "更新评论失败",
|
||||||
|
"comment_deleted": "评论已删除",
|
||||||
|
"failed_to_delete_comment": "删除评论失败"
|
||||||
},
|
},
|
||||||
"Members": {
|
"Members": {
|
||||||
"member_management": "成员管理",
|
"member_management": "成员管理",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
SortDescriptor,
|
SortDescriptor,
|
||||||
Chip,
|
Chip,
|
||||||
} from '@heroui/react';
|
} from '@heroui/react';
|
||||||
import { ChevronDown, MoreVertical, CopyPlus, CopyMinus } from 'lucide-react';
|
import { ChevronDown, MoreVertical, CopyPlus, CopyMinus, MessageCircle } from 'lucide-react';
|
||||||
import RunCaseStatus from './RunCaseStatus';
|
import RunCaseStatus from './RunCaseStatus';
|
||||||
import { Link, NextUiLinkClasses } from '@/src/i18n/routing';
|
import { Link, NextUiLinkClasses } from '@/src/i18n/routing';
|
||||||
import { testRunCaseStatus } from '@/config/selection';
|
import { testRunCaseStatus } from '@/config/selection';
|
||||||
@@ -64,6 +64,7 @@ export default function TestCaseSelector({
|
|||||||
{ name: messages.priority, uid: 'priority', sortable: true },
|
{ name: messages.priority, uid: 'priority', sortable: true },
|
||||||
{ name: messages.tags, uid: 'tags', sortable: false },
|
{ name: messages.tags, uid: 'tags', sortable: false },
|
||||||
{ name: messages.status, uid: 'runStatus', sortable: true },
|
{ name: messages.status, uid: 'runStatus', sortable: true },
|
||||||
|
{ name: messages.comments, uid: 'comments', sortable: false },
|
||||||
{ name: messages.actions, uid: 'actions' },
|
{ name: messages.actions, uid: 'actions' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -115,6 +116,7 @@ export default function TestCaseSelector({
|
|||||||
const cellValue = testCase[columnKey as keyof CaseType];
|
const cellValue = testCase[columnKey as keyof CaseType];
|
||||||
const isIncluded = isCaseIncluded(testCase);
|
const isIncluded = isCaseIncluded(testCase);
|
||||||
const runStatus = testCase.RunCases && testCase.RunCases.length > 0 ? testCase.RunCases[0].status : 0;
|
const runStatus = testCase.RunCases && testCase.RunCases.length > 0 ? testCase.RunCases[0].status : 0;
|
||||||
|
const commentCount = testCase.RunCases && testCase.RunCases.length > 0 ? testCase.RunCases[0].commentCount || 0 : 0;
|
||||||
|
|
||||||
switch (columnKey) {
|
switch (columnKey) {
|
||||||
case 'title':
|
case 'title':
|
||||||
@@ -179,6 +181,24 @@ export default function TestCaseSelector({
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
);
|
);
|
||||||
|
case 'comments':
|
||||||
|
return (
|
||||||
|
<div className={isIncluded ? '' : notIncludedCaseClass}>
|
||||||
|
{isIncluded && commentCount > 0 ? (
|
||||||
|
<Link
|
||||||
|
href={`/projects/${projectId}/runs/${runId}/cases/${testCase.id}?tab=comments`}
|
||||||
|
locale={locale}
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<MessageCircle size={16} />
|
||||||
|
<span>{commentCount}</span>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-default-400">-</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
case 'actions':
|
case 'actions':
|
||||||
return (
|
return (
|
||||||
<Dropdown>
|
<Dropdown>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, useContext } from 'react';
|
import { useEffect, useState, useContext } from 'react';
|
||||||
import { Tabs, Tab, Chip } from '@heroui/react';
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { Tabs, Tab } from '@heroui/react';
|
||||||
import CaseDetail from './CaseDetail';
|
import CaseDetail from './CaseDetail';
|
||||||
import Comments from '@/components/Comments';
|
import Comments from '@/components/Comments';
|
||||||
import History from '@/components/History';
|
import History from '@/components/History';
|
||||||
@@ -9,30 +9,50 @@ import { TokenContext } from '@/utils/TokenProvider';
|
|||||||
import { fetchCase } from '@/utils/caseControl';
|
import { fetchCase } from '@/utils/caseControl';
|
||||||
import { logError } from '@/utils/errorHandler';
|
import { logError } from '@/utils/errorHandler';
|
||||||
import type { CaseType, StepType } from '@/types/case';
|
import type { CaseType, StepType } from '@/types/case';
|
||||||
import type { RunDetailMessages } from '@/types/run';
|
import type { RunCaseType, RunDetailMessages } from '@/types/run';
|
||||||
import type { PriorityMessages } from '@/types/priority';
|
import type { PriorityMessages } from '@/types/priority';
|
||||||
import type { TestTypeMessages } from '@/types/testType';
|
import type { TestTypeMessages } from '@/types/testType';
|
||||||
|
import type { CommentMessages } from '@/types/comment';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
runId: string;
|
||||||
locale: string;
|
locale: string;
|
||||||
caseId: string;
|
caseId: string;
|
||||||
messages: RunDetailMessages;
|
messages: RunDetailMessages;
|
||||||
testTypeMessages: TestTypeMessages;
|
testTypeMessages: TestTypeMessages;
|
||||||
priorityMessages: PriorityMessages;
|
priorityMessages: PriorityMessages;
|
||||||
|
commentMessages: CommentMessages;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TestCaseDetailPane({
|
export default function TestCaseDetailPane({
|
||||||
projectId,
|
projectId,
|
||||||
|
runId,
|
||||||
locale,
|
locale,
|
||||||
caseId,
|
caseId,
|
||||||
messages,
|
messages,
|
||||||
testTypeMessages,
|
testTypeMessages,
|
||||||
priorityMessages,
|
priorityMessages,
|
||||||
|
commentMessages,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const context = useContext(TokenContext);
|
const context = useContext(TokenContext);
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [selectedTab, setSelectedTab] = useState('caseDetail');
|
||||||
const [isFetching, setIsFetching] = useState(false);
|
const [isFetching, setIsFetching] = useState(false);
|
||||||
const [testCase, setTestCase] = useState<CaseType | null>(null);
|
const [testCase, setTestCase] = useState<CaseType | null>(null);
|
||||||
|
const [runCaseId, setRunCaseId] = useState<number | undefined>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// if the url has ?tab=comments, then select the comments tab
|
||||||
|
const tab = searchParams.get('tab');
|
||||||
|
if (tab === 'comments') {
|
||||||
|
setSelectedTab('comments');
|
||||||
|
} else if (tab === 'history') {
|
||||||
|
setSelectedTab('history');
|
||||||
|
} else {
|
||||||
|
setSelectedTab('caseDetail');
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchDataEffect() {
|
async function fetchDataEffect() {
|
||||||
@@ -46,6 +66,14 @@ export default function TestCaseDetailPane({
|
|||||||
data.Steps.sort((a: StepType, b: StepType) => a.caseSteps.stepNo - b.caseSteps.stepNo);
|
data.Steps.sort((a: StepType, b: StepType) => a.caseSteps.stepNo - b.caseSteps.stepNo);
|
||||||
}
|
}
|
||||||
setTestCase(data);
|
setTestCase(data);
|
||||||
|
|
||||||
|
// Find the runCase for this case in this run
|
||||||
|
if (data.RunCases && data.RunCases.length > 0) {
|
||||||
|
const runCase = data.RunCases.find((rc: RunCaseType) => rc.runId === Number(runId));
|
||||||
|
if (runCase) {
|
||||||
|
setRunCaseId(runCase.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logError('Error fetching case data', error);
|
logError('Error fetching case data', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -54,15 +82,20 @@ export default function TestCaseDetailPane({
|
|||||||
}
|
}
|
||||||
|
|
||||||
fetchDataEffect();
|
fetchDataEffect();
|
||||||
}, [context, caseId]);
|
}, [context, caseId, runId]);
|
||||||
|
|
||||||
if (isFetching || !testCase) {
|
if (isFetching || !testCase) {
|
||||||
return <div>loading...</div>;
|
return <div>loading...</div>;
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col p-3">
|
<div className="flex h-full w-full flex-col p-3">
|
||||||
<Tabs aria-label="Options" size="sm">
|
<Tabs
|
||||||
<Tab key="caseDetail" title="Case Detail">
|
aria-label="Options"
|
||||||
|
size="sm"
|
||||||
|
selectedKey={selectedTab}
|
||||||
|
onSelectionChange={(key) => setSelectedTab(String(key))}
|
||||||
|
>
|
||||||
|
<Tab key="caseDetail" title={messages.caseDetail}>
|
||||||
<CaseDetail
|
<CaseDetail
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
testCase={testCase}
|
testCase={testCase}
|
||||||
@@ -72,20 +105,15 @@ export default function TestCaseDetailPane({
|
|||||||
priorityMessages={priorityMessages}
|
priorityMessages={priorityMessages}
|
||||||
/>
|
/>
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab
|
<Tab key="comments" title={messages.comments}>
|
||||||
key="comments"
|
<Comments
|
||||||
title={
|
projectId={projectId}
|
||||||
<div className="flex items-center space-x-2">
|
commentableType="RunCase"
|
||||||
<span>Comments</span>
|
commentableId={runCaseId}
|
||||||
<Chip size="sm" variant="faded">
|
messages={commentMessages}
|
||||||
3
|
/>
|
||||||
</Chip>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Comments />
|
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab key="history" title="History">
|
<Tab key="history" title={messages.history}>
|
||||||
<History />
|
<History />
|
||||||
</Tab>
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ export default function Page({
|
|||||||
preconditions: t('preconditions'),
|
preconditions: t('preconditions'),
|
||||||
expectedResult: t('expected_result'),
|
expectedResult: t('expected_result'),
|
||||||
detailsOfTheStep: t('details_of_the_step'),
|
detailsOfTheStep: t('details_of_the_step'),
|
||||||
|
caseDetail: t('case_detail'),
|
||||||
|
comments: t('comments'),
|
||||||
|
history: t('history'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const pt = useTranslations('Priority');
|
const pt = useTranslations('Priority');
|
||||||
@@ -48,14 +51,33 @@ export default function Page({
|
|||||||
manual: tt('manual'),
|
manual: tt('manual'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ct = useTranslations('Comments');
|
||||||
|
const commentMessages = {
|
||||||
|
comments: ct('comments'),
|
||||||
|
noComments: ct('no_comments'),
|
||||||
|
addComment: ct('add_comment'),
|
||||||
|
save: ct('save'),
|
||||||
|
cancel: ct('cancel'),
|
||||||
|
placeholder: ct('placeholder'),
|
||||||
|
notIncludedInRun: ct('not_included_in_run'),
|
||||||
|
commentAdded: ct('comment_added'),
|
||||||
|
failedToAddComment: ct('failed_to_add_comment'),
|
||||||
|
commentUpdated: ct('comment_updated'),
|
||||||
|
failedToUpdateComment: ct('failed_to_update_comment'),
|
||||||
|
commentDeleted: ct('comment_deleted'),
|
||||||
|
failedToDeleteComment: ct('failed_to_delete_comment'),
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DetailPane
|
<DetailPane
|
||||||
projectId={params.projectId}
|
projectId={params.projectId}
|
||||||
|
runId={params.runId}
|
||||||
caseId={params.caseId}
|
caseId={params.caseId}
|
||||||
locale={params.locale}
|
locale={params.locale}
|
||||||
messages={messages}
|
messages={messages}
|
||||||
priorityMessages={priorityMessages}
|
priorityMessages={priorityMessages}
|
||||||
testTypeMessages={testTypeMessages}
|
testTypeMessages={testTypeMessages}
|
||||||
|
commentMessages={commentMessages}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export default function RunLayout({
|
|||||||
selected: t('selected'),
|
selected: t('selected'),
|
||||||
tags: t('tags'),
|
tags: t('tags'),
|
||||||
selectTags: t('select_tags'),
|
selectTags: t('select_tags'),
|
||||||
|
comments: t('comments'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const rst = useTranslations('RunStatus');
|
const rst = useTranslations('RunStatus');
|
||||||
|
|||||||
@@ -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,7 @@ type RunMessages = {
|
|||||||
selected: string;
|
selected: string;
|
||||||
tags: string;
|
tags: string;
|
||||||
selectTags: string;
|
selectTags: string;
|
||||||
|
comments: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RunDetailMessages = {
|
type RunDetailMessages = {
|
||||||
@@ -102,6 +103,9 @@ type RunDetailMessages = {
|
|||||||
preconditions: string;
|
preconditions: string;
|
||||||
expectedResult: string;
|
expectedResult: string;
|
||||||
detailsOfTheStep: string;
|
detailsOfTheStep: string;
|
||||||
|
caseDetail: string;
|
||||||
|
comments: string;
|
||||||
|
history: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user