Compare commits
10 Commits
34135209d9
...
e6f3bc799e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6f3bc799e | ||
|
|
4dcff48792 | ||
|
|
4cafcaeedc | ||
|
|
93e66d0122 | ||
|
|
a065c1800f | ||
|
|
3b059f1897 | ||
|
|
9a5f0a602a | ||
|
|
a9674c81ab | ||
|
|
1f4ac0ae7b | ||
|
|
fe8983e212 |
1
backend/config/locale.js
Normal file
1
backend/config/locale.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const SUPPORTED_LOCALES = ['de', 'en', 'pt-BR', 'zh-CN', 'ja'];
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export async function up(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.addColumn('users', 'locale', {
|
||||||
|
type: Sequelize.STRING(20),
|
||||||
|
allowNull: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(queryInterface) {
|
||||||
|
await queryInterface.removeColumn('users', 'locale');
|
||||||
|
}
|
||||||
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;
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ function defineUser(sequelize, DataTypes) {
|
|||||||
avatarPath: {
|
avatarPath: {
|
||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
},
|
},
|
||||||
|
locale: {
|
||||||
|
type: DataTypes.STRING(20),
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{ underscored: true }
|
{ underscored: true }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -126,7 +126,7 @@ export default function (sequelize) {
|
|||||||
|
|
||||||
// Return updated user without password
|
// Return updated user without password
|
||||||
const updatedUser = await User.findByPk(userId, {
|
const updatedUser = await User.findByPk(userId, {
|
||||||
attributes: ['id', 'email', 'username', 'role', 'avatarPath'],
|
attributes: ['id', 'email', 'username', 'role', 'avatarPath', 'locale'],
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ user: updatedUser });
|
res.json({ user: updatedUser });
|
||||||
|
|||||||
46
backend/routes/users/updateLocale.js
Normal file
46
backend/routes/users/updateLocale.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { DataTypes } from 'sequelize';
|
||||||
|
import defineUser from '../../models/users.js';
|
||||||
|
import authMiddleware from '../../middleware/auth.js';
|
||||||
|
import { SUPPORTED_LOCALES } from '../../config/locale.js';
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
export default function (sequelize) {
|
||||||
|
const { verifySignedIn } = authMiddleware(sequelize);
|
||||||
|
const User = defineUser(sequelize, DataTypes);
|
||||||
|
|
||||||
|
router.put('/locale', verifySignedIn, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId;
|
||||||
|
|
||||||
|
const user = await User.findByPk(userId);
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).send('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { locale } = req.body;
|
||||||
|
|
||||||
|
const normalizedLocale = typeof locale === 'string' ? locale.trim() : '';
|
||||||
|
if (!normalizedLocale || normalizedLocale.length === 0) {
|
||||||
|
return res.status(400).send('Locale is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SUPPORTED_LOCALES.includes(normalizedLocale)) {
|
||||||
|
return res.status(400).send('Invalid locale');
|
||||||
|
}
|
||||||
|
|
||||||
|
await user.update({ locale: normalizedLocale });
|
||||||
|
|
||||||
|
const updatedUser = await User.findByPk(userId, {
|
||||||
|
attributes: ['id', 'email', 'username', 'role', 'avatarPath', 'locale'],
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ user: updatedUser });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).send('Internal Server Error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
92
backend/routes/users/updateLocale.test.js
Normal file
92
backend/routes/users/updateLocale.test.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import express from 'express';
|
||||||
|
import { Sequelize } from 'sequelize';
|
||||||
|
import { SUPPORTED_LOCALES } from '../../config/locale.js';
|
||||||
|
import updateLocaleRoute from './updateLocale.js';
|
||||||
|
|
||||||
|
// mock of authentication middleware
|
||||||
|
let mockUserId = 1;
|
||||||
|
vi.mock('../../middleware/auth.js', () => ({
|
||||||
|
default: () => ({
|
||||||
|
verifySignedIn: vi.fn((req, res, next) => {
|
||||||
|
req.userId = mockUserId; // Mock user ID
|
||||||
|
next();
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// mock defineUser
|
||||||
|
const mockUsers = new Map();
|
||||||
|
const mockUser = {
|
||||||
|
findByPk: vi.fn((id) => {
|
||||||
|
const user = mockUsers.get(id);
|
||||||
|
if (!user) return null;
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
update: vi.fn(async (data) => {
|
||||||
|
Object.assign(user, data);
|
||||||
|
return user;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('../../models/users.js', () => ({
|
||||||
|
default: () => mockUser,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('User Locale Routes', () => {
|
||||||
|
let app;
|
||||||
|
const sequelize = new Sequelize({
|
||||||
|
dialect: 'sqlite',
|
||||||
|
logging: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/users', updateLocaleRoute(sequelize));
|
||||||
|
|
||||||
|
// Reset mock users
|
||||||
|
mockUsers.clear();
|
||||||
|
mockUserId = 1;
|
||||||
|
|
||||||
|
// Create a test user
|
||||||
|
mockUsers.set(1, {
|
||||||
|
id: 1,
|
||||||
|
email: 'test@example.com',
|
||||||
|
username: 'testuser',
|
||||||
|
password: '',
|
||||||
|
role: 1,
|
||||||
|
avatarPath: null,
|
||||||
|
locale: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update locale', async () => {
|
||||||
|
const newLocale = SUPPORTED_LOCALES[0];
|
||||||
|
const response = await request(app).put('/users/locale').send({ locale: newLocale });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.user.locale).toBe(newLocale);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should replace existing locale', async () => {
|
||||||
|
mockUsers.set(1, {
|
||||||
|
locale: SUPPORTED_LOCALES[0],
|
||||||
|
});
|
||||||
|
const response = await request(app).put('/users/locale').send({ locale: SUPPORTED_LOCALES[1] });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.user.locale).toBe(SUPPORTED_LOCALES[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([' ', 'chinese', 'english'])('should reject not supported locale: %s', async (locale) => {
|
||||||
|
const response = await request(app).put('/users/locale').send({ locale });
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,7 +26,7 @@ export default function (sequelize) {
|
|||||||
|
|
||||||
// Return updated user without password
|
// Return updated user without password
|
||||||
const updatedUser = await User.findByPk(userId, {
|
const updatedUser = await User.findByPk(userId, {
|
||||||
attributes: ['id', 'email', 'username', 'role', 'avatarPath'],
|
attributes: ['id', 'email', 'username', 'role', 'avatarPath', 'locale'],
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ user: updatedUser });
|
res.json({ user: updatedUser });
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import usersSearchRoute from './routes/users/search.js';
|
|||||||
import usersUpdateUsernameRoute from './routes/users/updateUsername.js';
|
import usersUpdateUsernameRoute from './routes/users/updateUsername.js';
|
||||||
import usersUpdatePasswordRoute from './routes/users/updatePassword.js';
|
import usersUpdatePasswordRoute from './routes/users/updatePassword.js';
|
||||||
import usersAdminResetPasswordRoute from './routes/users/adminResetPassword.js';
|
import usersAdminResetPasswordRoute from './routes/users/adminResetPassword.js';
|
||||||
|
import usersUpdateLocaleRoute from './routes/users/updateLocale.js';
|
||||||
import usersUpdateAvatarRoute from './routes/users/updateAvatar.js';
|
import usersUpdateAvatarRoute from './routes/users/updateAvatar.js';
|
||||||
import usersUpdateRoleRoute from './routes/users/updateRole.js';
|
import usersUpdateRoleRoute from './routes/users/updateRole.js';
|
||||||
import signUpRoute from './routes/users/signup.js';
|
import signUpRoute from './routes/users/signup.js';
|
||||||
@@ -63,6 +64,7 @@ app.use('/users', usersSearchRoute(sequelize));
|
|||||||
app.use('/users', usersUpdateUsernameRoute(sequelize));
|
app.use('/users', usersUpdateUsernameRoute(sequelize));
|
||||||
app.use('/users', usersUpdatePasswordRoute(sequelize));
|
app.use('/users', usersUpdatePasswordRoute(sequelize));
|
||||||
app.use('/users', usersAdminResetPasswordRoute(sequelize));
|
app.use('/users', usersAdminResetPasswordRoute(sequelize));
|
||||||
|
app.use('/users', usersUpdateLocaleRoute(sequelize));
|
||||||
app.use('/users', usersUpdateAvatarRoute(sequelize));
|
app.use('/users', usersUpdateAvatarRoute(sequelize));
|
||||||
app.use('/users', usersUpdateRoleRoute(sequelize));
|
app.use('/users', usersUpdateRoleRoute(sequelize));
|
||||||
app.use('/users', signUpRoute(sequelize));
|
app.use('/users', signUpRoute(sequelize));
|
||||||
@@ -175,6 +177,16 @@ app.use('/tags', tagsEditRoute(sequelize));
|
|||||||
import caseTagsEditRoute from './routes/casetags/edit.js';
|
import caseTagsEditRoute from './routes/casetags/edit.js';
|
||||||
app.use('/casetags', caseTagsEditRoute(sequelize));
|
app.use('/casetags', caseTagsEditRoute(sequelize));
|
||||||
|
|
||||||
|
// "/comments"
|
||||||
|
import commentsIndexRoute from './routes/comments/index.js';
|
||||||
|
import commentsNewRoute from './routes/comments/new.js';
|
||||||
|
import commentsEditRoute from './routes/comments/edit.js';
|
||||||
|
import commentsDeleteRoute from './routes/comments/delete.js';
|
||||||
|
app.use('/comments', commentsIndexRoute(sequelize));
|
||||||
|
app.use('/comments', commentsNewRoute(sequelize));
|
||||||
|
app.use('/comments', commentsEditRoute(sequelize));
|
||||||
|
app.use('/comments', commentsDeleteRoute(sequelize));
|
||||||
|
|
||||||
// "/home"
|
// "/home"
|
||||||
import homeIndexRoute from './routes/home/index.js';
|
import homeIndexRoute from './routes/home/index.js';
|
||||||
app.use('/home', homeIndexRoute(sequelize));
|
app.use('/home', homeIndexRoute(sequelize));
|
||||||
|
|||||||
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({ projectId, commentableType, commentableId, messages }: Props) {
|
||||||
|
const context = useContext(TokenContext);
|
||||||
|
const [comments, setComments] = useState<CommentType[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [newComment, setNewComment] = useState('');
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
const [editContent, setEditContent] = useState('');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadComments() {
|
||||||
|
if (!commentableType || !commentableId || !context.isSignedIn()) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await fetchComments(context.token.access_token, commentableType, commentableId);
|
||||||
|
setComments(data);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logError('Error fetching comments', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadComments();
|
||||||
|
}, [commentableType, commentableId, context]);
|
||||||
|
|
||||||
|
const handleAddComment = async () => {
|
||||||
|
if (!newComment.trim() || !commentableType || !commentableId) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const comment = await createComment(context.token.access_token, commentableType, commentableId, newComment);
|
||||||
|
if (!comment) {
|
||||||
|
throw new Error('Failed to create comment');
|
||||||
|
}
|
||||||
|
const updatedComments = [...comments, comment];
|
||||||
|
setComments(updatedComments);
|
||||||
|
setNewComment('');
|
||||||
|
addToast({
|
||||||
|
title: 'Success',
|
||||||
|
color: 'success',
|
||||||
|
description: messages.commentAdded,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logError('Error adding comment', error);
|
||||||
|
addToast({
|
||||||
|
title: 'Error',
|
||||||
|
color: 'danger',
|
||||||
|
description: messages.failedToAddComment,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartEdit = (id: number, content: string) => {
|
||||||
|
setEditingId(id);
|
||||||
|
setEditContent(content);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
setEditContent('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = async (id: number) => {
|
||||||
|
if (!editContent.trim()) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const updated = await updateComment(context.token.access_token, id, editContent);
|
||||||
|
if (!updated) {
|
||||||
|
throw new Error('Failed to update comment');
|
||||||
|
}
|
||||||
|
setComments(comments.map((c) => (c.id === id ? { ...c, content: editContent } : c)));
|
||||||
|
setEditingId(null);
|
||||||
|
setEditContent('');
|
||||||
|
addToast({
|
||||||
|
title: 'Success',
|
||||||
|
color: 'success',
|
||||||
|
description: messages.commentUpdated,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logError('Error updating comment', error);
|
||||||
|
addToast({
|
||||||
|
title: 'Error',
|
||||||
|
color: 'danger',
|
||||||
|
description: messages.failedToUpdateComment,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteComment = async (id: number) => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await deleteComment(context.token.access_token, id);
|
||||||
|
const updatedComments = comments.filter((c) => c.id !== id);
|
||||||
|
setComments(updatedComments);
|
||||||
|
addToast({
|
||||||
|
title: 'Success',
|
||||||
|
color: 'success',
|
||||||
|
description: messages.commentDeleted,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logError('Error deleting comment', error);
|
||||||
|
addToast({
|
||||||
|
title: 'Error',
|
||||||
|
color: 'danger',
|
||||||
|
description: messages.failedToDeleteComment,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!commentableType || !commentableId) {
|
||||||
|
return (
|
||||||
|
<div className="text-default-500 text-sm">
|
||||||
|
{commentableType === 'RunCase' && !commentableId ? <p>{messages.notIncludedInRun}</p> : <p>Unknown state</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex items-center justify-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const canComment = projectId && context.isProjectReporter(Number(projectId));
|
||||||
|
|
||||||
export default function Comments() {
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full text-default-500">
|
<div className="h-full flex flex-col justify-between">
|
||||||
<div className="mb-4">
|
{comments.length === 0 ? (
|
||||||
<Alert color="secondary" title="Sorry" description={'Comments function will be implemented'} />
|
<div className="text-center text-default-400 py-8">
|
||||||
|
<p>{messages.noComments}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{comments.map((comment) => (
|
||||||
|
<CommentItem
|
||||||
|
key={comment.id}
|
||||||
|
comment={comment}
|
||||||
|
isEditing={editingId === comment.id}
|
||||||
|
canEdit={comment.userId === context.token.user?.id}
|
||||||
|
editContent={editContent}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
messages={messages}
|
||||||
|
onEditContentChange={setEditContent}
|
||||||
|
onStartEdit={() => handleStartEdit(comment.id, comment.content)}
|
||||||
|
onCancelEdit={handleCancelEdit}
|
||||||
|
onSave={() => handleSaveEdit(comment.id)}
|
||||||
|
onDelete={() => handleDeleteComment(comment.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-12">
|
||||||
|
<Textarea
|
||||||
|
placeholder={messages.placeholder}
|
||||||
|
value={newComment}
|
||||||
|
onValueChange={setNewComment}
|
||||||
|
minRows={3}
|
||||||
|
variant="bordered"
|
||||||
|
isDisabled={!canComment || isSubmitting}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
size="sm"
|
||||||
|
className="mt-2"
|
||||||
|
onPress={handleAddComment}
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
isDisabled={!newComment.trim() || !canComment}
|
||||||
|
>
|
||||||
|
{messages.addComment}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,16 +4,22 @@ import { useState, useRef, useEffect, ReactNode } from 'react';
|
|||||||
type Props = {
|
type Props = {
|
||||||
leftPane: ReactNode;
|
leftPane: ReactNode;
|
||||||
rightPane: ReactNode;
|
rightPane: ReactNode;
|
||||||
|
minLeftWidth?: number;
|
||||||
|
minRightWidth?: number;
|
||||||
|
defaultLeftWidth?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ResizablePanes({ leftPane, rightPane }: Props) {
|
export default function ResizablePanes({
|
||||||
const [leftWidth, setLeftWidth] = useState(70); // default 70%
|
leftPane,
|
||||||
|
rightPane,
|
||||||
|
minLeftWidth = 40,
|
||||||
|
minRightWidth = 15,
|
||||||
|
defaultLeftWidth = 70,
|
||||||
|
}: Props) {
|
||||||
|
const [leftWidth, setLeftWidth] = useState(defaultLeftWidth); // default 70%
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const minLeftWidth = 40; // left panel min width 40%
|
|
||||||
const minRightWidth = 15; // right panel min width 15%
|
|
||||||
|
|
||||||
const handleMouseDown = () => {
|
const handleMouseDown = () => {
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
};
|
};
|
||||||
@@ -45,7 +51,7 @@ export default function ResizablePanes({ leftPane, rightPane }: Props) {
|
|||||||
document.removeEventListener('mousemove', handleMouseMove);
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
document.removeEventListener('mouseup', handleMouseUp);
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
};
|
};
|
||||||
}, [isDragging]);
|
}, [isDragging, minLeftWidth, minRightWidth]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="flex h-full" style={{ userSelect: isDragging ? 'none' : 'auto' }}>
|
<div ref={containerRef} className="flex h-full" style={{ userSelect: isDragging ? 'none' : 'auto' }}>
|
||||||
|
|||||||
@@ -101,6 +101,7 @@
|
|||||||
"confirm_password": "Passwort (Bestätigung)",
|
"confirm_password": "Passwort (Bestätigung)",
|
||||||
"invalid_email": "Ungültige E-Mail",
|
"invalid_email": "Ungültige E-Mail",
|
||||||
"invalid_password": "Passwort muss mindestens 8 Zeichen haben",
|
"invalid_password": "Passwort muss mindestens 8 Zeichen haben",
|
||||||
|
"invalid_locale": "Ungültige Sprache",
|
||||||
"username_empty": "Benutzername ist leer",
|
"username_empty": "Benutzername ist leer",
|
||||||
"password_empty": "Passwort ist leer",
|
"password_empty": "Passwort ist leer",
|
||||||
"password_not_match": "Passwörter stimmen nicht überein",
|
"password_not_match": "Passwörter stimmen nicht überein",
|
||||||
@@ -125,6 +126,9 @@
|
|||||||
"confirm_new_password": "Neues Passwort bestätigen",
|
"confirm_new_password": "Neues Passwort bestätigen",
|
||||||
"update_password": "Passwort aktualisieren",
|
"update_password": "Passwort aktualisieren",
|
||||||
"password_updated": "Passwort erfolgreich geändert",
|
"password_updated": "Passwort erfolgreich geändert",
|
||||||
|
"change_locale": "Sprache ändern",
|
||||||
|
"update_locale": "Sprache aktualisieren",
|
||||||
|
"locale_updated": "Sprache erfolgreich geändert",
|
||||||
"change_avatar": "Avatar ändern",
|
"change_avatar": "Avatar ändern",
|
||||||
"upload_avatar": "Avatar hochladen",
|
"upload_avatar": "Avatar hochladen",
|
||||||
"remove_avatar": "Avatar entfernen",
|
"remove_avatar": "Avatar entfernen",
|
||||||
@@ -354,7 +358,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",
|
||||||
|
|||||||
@@ -101,6 +101,7 @@
|
|||||||
"confirm_password": "Password (confirm)",
|
"confirm_password": "Password (confirm)",
|
||||||
"invalid_email": "Invalid email",
|
"invalid_email": "Invalid email",
|
||||||
"invalid_password": "Password must be at least 8 characters",
|
"invalid_password": "Password must be at least 8 characters",
|
||||||
|
"invalid_locale": "Invalid language",
|
||||||
"username_empty": "username is empty",
|
"username_empty": "username is empty",
|
||||||
"password_empty": "password is empty",
|
"password_empty": "password is empty",
|
||||||
"password_not_match": "Pasword does not match",
|
"password_not_match": "Pasword does not match",
|
||||||
@@ -125,6 +126,9 @@
|
|||||||
"confirm_new_password": "Confirm New Password",
|
"confirm_new_password": "Confirm New Password",
|
||||||
"update_password": "Update Password",
|
"update_password": "Update Password",
|
||||||
"password_updated": "Password updated successfully",
|
"password_updated": "Password updated successfully",
|
||||||
|
"change_locale": "Change Language",
|
||||||
|
"update_locale": "Update Language",
|
||||||
|
"locale_updated": "Language updated successfully",
|
||||||
"change_avatar": "Change Avatar",
|
"change_avatar": "Change Avatar",
|
||||||
"upload_avatar": "Upload Avatar",
|
"upload_avatar": "Upload Avatar",
|
||||||
"remove_avatar": "Remove Avatar",
|
"remove_avatar": "Remove Avatar",
|
||||||
@@ -354,7 +358,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",
|
||||||
|
|||||||
@@ -101,6 +101,7 @@
|
|||||||
"confirm_password": "パスワード(確認)",
|
"confirm_password": "パスワード(確認)",
|
||||||
"invalid_email": "無効なメールアドレスです",
|
"invalid_email": "無効なメールアドレスです",
|
||||||
"invalid_password": "パスワードは8文字以上である必要があります",
|
"invalid_password": "パスワードは8文字以上である必要があります",
|
||||||
|
"invalid_locale": "無効な言語です",
|
||||||
"username_empty": "ユーザー名が入力されていません",
|
"username_empty": "ユーザー名が入力されていません",
|
||||||
"password_empty": "パスワードが入力されていません",
|
"password_empty": "パスワードが入力されていません",
|
||||||
"password_not_match": "パスワードが一致しません",
|
"password_not_match": "パスワードが一致しません",
|
||||||
@@ -125,6 +126,9 @@
|
|||||||
"confirm_new_password": "新しいパスワード(確認)",
|
"confirm_new_password": "新しいパスワード(確認)",
|
||||||
"update_password": "パスワードを更新",
|
"update_password": "パスワードを更新",
|
||||||
"password_updated": "パスワードが正常に更新されました",
|
"password_updated": "パスワードが正常に更新されました",
|
||||||
|
"change_locale": "言語の変更",
|
||||||
|
"update_locale": "言語を更新",
|
||||||
|
"locale_updated": "言語が正常に更新されました",
|
||||||
"change_avatar": "アバターの変更",
|
"change_avatar": "アバターの変更",
|
||||||
"upload_avatar": "アバターをアップロード",
|
"upload_avatar": "アバターをアップロード",
|
||||||
"remove_avatar": "アバターを削除",
|
"remove_avatar": "アバターを削除",
|
||||||
@@ -354,7 +358,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": "メンバー管理",
|
||||||
|
|||||||
@@ -101,6 +101,7 @@
|
|||||||
"confirm_password": "Senha (confirmação)",
|
"confirm_password": "Senha (confirmação)",
|
||||||
"invalid_email": "E-mail inválido",
|
"invalid_email": "E-mail inválido",
|
||||||
"invalid_password": "A senha deve ter pelo menos 8 caracteres",
|
"invalid_password": "A senha deve ter pelo menos 8 caracteres",
|
||||||
|
"invalid_locale": "Idioma inválido",
|
||||||
"username_empty": "O nome de usuário está vazio",
|
"username_empty": "O nome de usuário está vazio",
|
||||||
"password_empty": "A senha está vazia",
|
"password_empty": "A senha está vazia",
|
||||||
"password_not_match": "A senha não corresponde",
|
"password_not_match": "A senha não corresponde",
|
||||||
@@ -125,6 +126,9 @@
|
|||||||
"confirm_new_password": "Confirmar Nova Senha",
|
"confirm_new_password": "Confirmar Nova Senha",
|
||||||
"update_password": "Atualizar Senha",
|
"update_password": "Atualizar Senha",
|
||||||
"password_updated": "Senha atualizada com sucesso",
|
"password_updated": "Senha atualizada com sucesso",
|
||||||
|
"change_locale": "Alterar Idioma",
|
||||||
|
"update_locale": "Atualizar Idioma",
|
||||||
|
"locale_updated": "Idioma atualizado com sucesso",
|
||||||
"change_avatar": "Alterar Avatar",
|
"change_avatar": "Alterar Avatar",
|
||||||
"upload_avatar": "Enviar Avatar",
|
"upload_avatar": "Enviar Avatar",
|
||||||
"remove_avatar": "Remover Avatar",
|
"remove_avatar": "Remover Avatar",
|
||||||
@@ -354,7 +358,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",
|
||||||
|
|||||||
@@ -101,6 +101,7 @@
|
|||||||
"confirm_password": "确认密码",
|
"confirm_password": "确认密码",
|
||||||
"invalid_email": "无效的邮箱地址",
|
"invalid_email": "无效的邮箱地址",
|
||||||
"invalid_password": "密码必须至少包含 8 个字符",
|
"invalid_password": "密码必须至少包含 8 个字符",
|
||||||
|
"invalid_locale": "无效的语言",
|
||||||
"username_empty": "用户名不能为空",
|
"username_empty": "用户名不能为空",
|
||||||
"password_empty": "密码不能为空",
|
"password_empty": "密码不能为空",
|
||||||
"password_not_match": "密码不匹配",
|
"password_not_match": "密码不匹配",
|
||||||
@@ -125,6 +126,9 @@
|
|||||||
"confirm_new_password": "确认新密码",
|
"confirm_new_password": "确认新密码",
|
||||||
"update_password": "更新密码",
|
"update_password": "更新密码",
|
||||||
"password_updated": "密码更新成功",
|
"password_updated": "密码更新成功",
|
||||||
|
"change_locale": "修改语言",
|
||||||
|
"update_locale": "更新语言",
|
||||||
|
"locale_updated": "语言更新成功",
|
||||||
"change_avatar": "修改头像",
|
"change_avatar": "修改头像",
|
||||||
"upload_avatar": "上传头像",
|
"upload_avatar": "上传头像",
|
||||||
"remove_avatar": "移除头像",
|
"remove_avatar": "移除头像",
|
||||||
@@ -354,7 +358,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": "成员管理",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default function DropdownLanguage({ locale, onChangeLocale }: Props) {
|
|||||||
{locales.find((entry) => entry.code === locale)?.name || locale}
|
{locales.find((entry) => entry.code === locale)?.name || locale}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownTrigger>
|
</DropdownTrigger>
|
||||||
<DropdownMenu aria-label="lacales">
|
<DropdownMenu aria-label="locales">
|
||||||
{locales.map((entry) => (
|
{locales.map((entry) => (
|
||||||
<DropdownItem key={entry.code} onPress={() => onChangeLocale(entry.code)}>
|
<DropdownItem key={entry.code} onPress={() => onChangeLocale(entry.code)}>
|
||||||
{entry.name}
|
{entry.name}
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export default function HeaderNavbarMenu({ messages, locale }: Props) {
|
|||||||
<ThemeSwitch />
|
<ThemeSwitch />
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
<DropdownAccount messages={messages} locale={locale} onItemPress={() => {}} />
|
<DropdownAccount messages={messages} locale={locale} onItemPress={() => {}} />
|
||||||
<DropdownLanguage locale={locale} onChangeLocale={changeLocale} />
|
{!context.isSignedIn() && <DropdownLanguage locale={locale} onChangeLocale={changeLocale} />}
|
||||||
</div>
|
</div>
|
||||||
<NavbarMenuToggle className="md:hidden" onChange={() => setIsMenuOpen(!isMenuOpen)} />
|
<NavbarMenuToggle className="md:hidden" onChange={() => setIsMenuOpen(!isMenuOpen)} />
|
||||||
</NavbarContent>
|
</NavbarContent>
|
||||||
@@ -224,51 +224,53 @@ export default function HeaderNavbarMenu({ messages, locale }: Props) {
|
|||||||
/>
|
/>
|
||||||
</Listbox>
|
</Listbox>
|
||||||
) : (
|
) : (
|
||||||
<Listbox
|
<>
|
||||||
aria-label="Account links"
|
<Listbox
|
||||||
itemClasses={{
|
aria-label="Account links"
|
||||||
base: 'h-10 text-large',
|
itemClasses={{
|
||||||
}}
|
base: 'h-10 text-large',
|
||||||
>
|
|
||||||
<ListboxItem
|
|
||||||
key="signin"
|
|
||||||
startContent={<ArrowRightToLine size={16} />}
|
|
||||||
title={messages.signIn}
|
|
||||||
onPress={() => {
|
|
||||||
router.push('/account/signin', { locale: locale });
|
|
||||||
setIsMenuOpen(false);
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
<ListboxItem
|
<ListboxItem
|
||||||
key="signup"
|
key="signin"
|
||||||
title={messages.signUp}
|
startContent={<ArrowRightToLine size={16} />}
|
||||||
startContent={<PenTool size={16} />}
|
title={messages.signIn}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push('/account/signup', { locale: locale });
|
router.push('/account/signin', { locale: locale });
|
||||||
setIsMenuOpen(false);
|
setIsMenuOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ListboxItem
|
||||||
|
key="signup"
|
||||||
|
title={messages.signUp}
|
||||||
|
startContent={<PenTool size={16} />}
|
||||||
|
onPress={() => {
|
||||||
|
router.push('/account/signup', { locale: locale });
|
||||||
|
setIsMenuOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Listbox>
|
||||||
|
<p className="font-bold">{messages.languages}</p>
|
||||||
|
<Listbox
|
||||||
|
aria-label="Language links"
|
||||||
|
itemClasses={{
|
||||||
|
base: 'h-10 text-large',
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
</Listbox>
|
{locales.map((entry) => (
|
||||||
|
<ListboxItem
|
||||||
|
key={entry.code}
|
||||||
|
startContent={<Globe size={16} />}
|
||||||
|
title={entry.name}
|
||||||
|
onPress={() => {
|
||||||
|
changeLocale(entry.code);
|
||||||
|
setIsMenuOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Listbox>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<p className="font-bold">{messages.languages}</p>
|
|
||||||
<Listbox
|
|
||||||
aria-label="Language links"
|
|
||||||
itemClasses={{
|
|
||||||
base: 'h-10 text-large',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{locales.map((entry) => (
|
|
||||||
<ListboxItem
|
|
||||||
key={entry.code}
|
|
||||||
startContent={<Globe size={16} />}
|
|
||||||
title={entry.name}
|
|
||||||
onPress={() => {
|
|
||||||
changeLocale(entry.code);
|
|
||||||
setIsMenuOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Listbox>
|
|
||||||
</div>
|
</div>
|
||||||
</NavbarMenu>
|
</NavbarMenu>
|
||||||
</Navbar>
|
</Navbar>
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ async function signInAsGuest() {
|
|||||||
username: 'Guest',
|
username: 'Guest',
|
||||||
role: roles.findIndex((entry) => entry.uid === 'user'),
|
role: roles.findIndex((entry) => entry.uid === 'user'),
|
||||||
avatarPath: '',
|
avatarPath: '',
|
||||||
|
locale: null,
|
||||||
};
|
};
|
||||||
const token = await signUp(guestUser);
|
const token = await signUp(guestUser);
|
||||||
return token;
|
return token;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export default function AuthPage({ isSignup, messages, locale }: Props) {
|
|||||||
username: '',
|
username: '',
|
||||||
role: roles.findIndex((entry) => entry.uid === 'user'),
|
role: roles.findIndex((entry) => entry.uid === 'user'),
|
||||||
avatarPath: '',
|
avatarPath: '',
|
||||||
|
locale: null,
|
||||||
});
|
});
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
@@ -83,14 +84,14 @@ export default function AuthPage({ isSignup, messages, locale }: Props) {
|
|||||||
|
|
||||||
context.setToken(token);
|
context.setToken(token);
|
||||||
context.storeTokenToLocalStorage(token);
|
context.storeTokenToLocalStorage(token);
|
||||||
router.push('/account', { locale: locale });
|
router.push('/account', { locale: token.user?.locale ?? locale });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSignInAsGuest = async () => {
|
const handleSignInAsGuest = async () => {
|
||||||
const token = await signInAsGuest();
|
const token = await signInAsGuest();
|
||||||
context.setToken(token);
|
context.setToken(token);
|
||||||
context.storeTokenToLocalStorage(token);
|
context.storeTokenToLocalStorage(token);
|
||||||
router.push('/account', { locale: locale });
|
router.push('/account', { locale: token.user?.locale ?? locale });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useState, useContext, useRef } from 'react';
|
import { useState, useContext, useRef } from 'react';
|
||||||
import { Button, Input, Card, CardHeader, CardBody, addToast, CardFooter } from '@heroui/react';
|
import { Button, Input, Card, CardHeader, CardBody, addToast, CardFooter, Select, SelectItem } from '@heroui/react';
|
||||||
|
import { Globe } from 'lucide-react';
|
||||||
import { TokenContext } from '@/utils/TokenProvider';
|
import { TokenContext } from '@/utils/TokenProvider';
|
||||||
import { updateUsername, updatePassword, uploadAvatar, deleteAvatar } from '@/utils/usersControl';
|
import { updateUsername, updatePassword, uploadAvatar, deleteAvatar, updateLocale } from '@/utils/usersControl';
|
||||||
import { LocaleCodeType } from '@/types/locale';
|
import { LocaleCodeType } from '@/types/locale';
|
||||||
import { logError } from '@/utils/errorHandler';
|
import { logError } from '@/utils/errorHandler';
|
||||||
import UserAvatar from '@/components/UserAvatar';
|
import UserAvatar from '@/components/UserAvatar';
|
||||||
|
import { useRouter, usePathname } from '@/src/i18n/routing';
|
||||||
|
import { locales } from '@/config/selection';
|
||||||
|
import { LocaleType } from '@/types/locale';
|
||||||
|
|
||||||
type ProfileSettingsPageMessages = {
|
type ProfileSettingsPageMessages = {
|
||||||
profileSettings: string;
|
profileSettings: string;
|
||||||
@@ -19,6 +23,9 @@ type ProfileSettingsPageMessages = {
|
|||||||
confirmNewPassword: string;
|
confirmNewPassword: string;
|
||||||
updatePassword: string;
|
updatePassword: string;
|
||||||
passwordUpdated: string;
|
passwordUpdated: string;
|
||||||
|
changeLocale: string;
|
||||||
|
updateLocale: string;
|
||||||
|
localeUpdated: string;
|
||||||
changeAvatar: string;
|
changeAvatar: string;
|
||||||
uploadAvatar: string;
|
uploadAvatar: string;
|
||||||
removeAvatar: string;
|
removeAvatar: string;
|
||||||
@@ -31,6 +38,7 @@ type ProfileSettingsPageMessages = {
|
|||||||
invalidPassword: string;
|
invalidPassword: string;
|
||||||
passwordNotMatch: string;
|
passwordNotMatch: string;
|
||||||
usernameEmpty: string;
|
usernameEmpty: string;
|
||||||
|
invalidLocale: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -38,15 +46,21 @@ type Props = {
|
|||||||
locale: LocaleCodeType;
|
locale: LocaleCodeType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ProfileSettingsPage({ messages }: Props) {
|
export default function ProfileSettingsPage({ messages, locale: defaultLocale }: Props) {
|
||||||
const context = useContext(TokenContext);
|
const context = useContext(TokenContext);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [currentPassword, setCurrentPassword] = useState('');
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [locale, setLocale] = useState<LocaleCodeType>(context.token?.user?.locale ?? defaultLocale);
|
||||||
const [isUpdatingUsername, setIsUpdatingUsername] = useState(false);
|
const [isUpdatingUsername, setIsUpdatingUsername] = useState(false);
|
||||||
const [isUpdatingPassword, setIsUpdatingPassword] = useState(false);
|
const [isUpdatingPassword, setIsUpdatingPassword] = useState(false);
|
||||||
|
const [isUpdatingLocale, setIsUpdatingLocale] = useState(false);
|
||||||
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
|
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
|
||||||
|
|
||||||
const handleUsernameUpdate = async () => {
|
const handleUsernameUpdate = async () => {
|
||||||
@@ -150,6 +164,53 @@ export default function ProfileSettingsPage({ messages }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLocaleUpdate = async () => {
|
||||||
|
if (!locales.some((l) => l.code === locale)) {
|
||||||
|
addToast({
|
||||||
|
title: 'Warning',
|
||||||
|
color: 'warning',
|
||||||
|
description: messages.invalidLocale,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUpdatingLocale(true);
|
||||||
|
try {
|
||||||
|
const result = await updateLocale(context.token.access_token, locale);
|
||||||
|
if (result && result.user) {
|
||||||
|
// refresh locale
|
||||||
|
const newToken = { ...context.token };
|
||||||
|
if (newToken.user) {
|
||||||
|
newToken.user.locale = result.user.locale;
|
||||||
|
}
|
||||||
|
context.setToken(newToken);
|
||||||
|
context.storeTokenToLocalStorage(newToken);
|
||||||
|
|
||||||
|
addToast({
|
||||||
|
title: 'Success',
|
||||||
|
color: 'success',
|
||||||
|
description: messages.localeUpdated,
|
||||||
|
});
|
||||||
|
const nextLocale = result.user.locale ?? locale;
|
||||||
|
setLocale(nextLocale);
|
||||||
|
changeLocale(nextLocale);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error updating locale:', error);
|
||||||
|
addToast({
|
||||||
|
title: 'Error',
|
||||||
|
color: 'danger',
|
||||||
|
description: messages.updateError,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsUpdatingLocale(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function changeLocale(nextLocale: LocaleCodeType) {
|
||||||
|
router.push(pathname, { locale: nextLocale });
|
||||||
|
}
|
||||||
|
|
||||||
const handleAvatarUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleAvatarUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -333,6 +394,46 @@ export default function ProfileSettingsPage({ messages }: Props) {
|
|||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Change Locale */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardHeader>
|
||||||
|
<Globe size={16} />
|
||||||
|
<h2 className="text-large font-semibold ml-2">{messages.changeLocale}</h2>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<form>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Select<LocaleType>
|
||||||
|
fullWidth
|
||||||
|
aria-label="change locale"
|
||||||
|
selectedKeys={[locale]}
|
||||||
|
disabledKeys={[locale]}
|
||||||
|
onSelectionChange={(value) => {
|
||||||
|
const selectedLocale = locales.find((locale) => locale.code === value.currentKey);
|
||||||
|
if (!selectedLocale) return;
|
||||||
|
setLocale(selectedLocale.code);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{locales.map((locale) => (
|
||||||
|
<SelectItem key={locale.code}>{locale.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardBody>
|
||||||
|
<CardFooter className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
onPress={handleLocaleUpdate}
|
||||||
|
isLoading={isUpdatingLocale}
|
||||||
|
isDisabled={locale === context.token?.user?.locale}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{messages.updateLocale}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Change Avatar */}
|
{/* Change Avatar */}
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export default function Page({ params }: PageType) {
|
|||||||
confirmNewPassword: t('confirm_new_password'),
|
confirmNewPassword: t('confirm_new_password'),
|
||||||
updatePassword: t('update_password'),
|
updatePassword: t('update_password'),
|
||||||
passwordUpdated: t('password_updated'),
|
passwordUpdated: t('password_updated'),
|
||||||
|
changeLocale: t('change_locale'),
|
||||||
|
updateLocale: t('update_locale'),
|
||||||
|
localeUpdated: t('locale_updated'),
|
||||||
changeAvatar: t('change_avatar'),
|
changeAvatar: t('change_avatar'),
|
||||||
uploadAvatar: t('upload_avatar'),
|
uploadAvatar: t('upload_avatar'),
|
||||||
removeAvatar: t('remove_avatar'),
|
removeAvatar: t('remove_avatar'),
|
||||||
@@ -29,6 +32,7 @@ export default function Page({ params }: PageType) {
|
|||||||
invalidPassword: t('invalid_password'),
|
invalidPassword: t('invalid_password'),
|
||||||
passwordNotMatch: t('password_not_match'),
|
passwordNotMatch: t('password_not_match'),
|
||||||
usernameEmpty: t('username_empty'),
|
usernameEmpty: t('username_empty'),
|
||||||
|
invalidLocale: t('invalid_locale'),
|
||||||
};
|
};
|
||||||
|
|
||||||
return <ProfileSettingsPage messages={messages} locale={params.locale as LocaleCodeType} />;
|
return <ProfileSettingsPage messages={messages} locale={params.locale as LocaleCodeType} />;
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export default function HealthPage({ messages, locale }: Props) {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
<TableRow key="1">
|
<TableRow key="1">
|
||||||
<TableCell>{messages.unittcms_version}</TableCell>
|
<TableCell>{messages.unittcms_version}</TableCell>
|
||||||
<TableCell>1.0.0-beta.26</TableCell>
|
<TableCell>1.0.0-beta.28</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
<TableRow key="2">
|
<TableRow key="2">
|
||||||
<TableCell>{messages.api_server}</TableCell>
|
<TableCell>{messages.api_server}</TableCell>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default function CaseEditor({
|
|||||||
const [testCase, setTestCase] = useState<CaseType>(defaultTestCase);
|
const [testCase, setTestCase] = useState<CaseType>(defaultTestCase);
|
||||||
const [isTitleInvalid] = useState<boolean>(false);
|
const [isTitleInvalid] = useState<boolean>(false);
|
||||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||||
const [plusCount, setPlusCount] = useState<number>(0);
|
const [idCounter, setIdCounter] = useState<number>(0);
|
||||||
const [isDirty, setIsDirty] = useState(false);
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
const [selectedTags, setSelectedTags] = useState<{ id: number; name: string }[]>([]);
|
const [selectedTags, setSelectedTags] = useState<{ id: number; name: string }[]>([]);
|
||||||
|
|
||||||
@@ -68,9 +68,14 @@ export default function CaseEditor({
|
|||||||
useFormGuard(isDirty, messages.areYouSureLeave);
|
useFormGuard(isDirty, messages.areYouSureLeave);
|
||||||
|
|
||||||
const onPlusClick = async (newStepNo: number) => {
|
const onPlusClick = async (newStepNo: number) => {
|
||||||
|
if (!testCase.Steps) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setIsDirty(true);
|
setIsDirty(true);
|
||||||
|
const nextId = idCounter + 1;
|
||||||
const newStep: StepType = {
|
const newStep: StepType = {
|
||||||
id: plusCount,
|
// hypothetical ID
|
||||||
|
id: nextId,
|
||||||
step: '',
|
step: '',
|
||||||
result: '',
|
result: '',
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@@ -78,66 +83,65 @@ export default function CaseEditor({
|
|||||||
caseSteps: {
|
caseSteps: {
|
||||||
stepNo: newStepNo,
|
stepNo: newStepNo,
|
||||||
},
|
},
|
||||||
uid: `uid${plusCount}`,
|
uid: `uid${nextId}`,
|
||||||
editState: 'new',
|
editState: 'new',
|
||||||
};
|
};
|
||||||
setPlusCount(plusCount + 1);
|
|
||||||
|
|
||||||
if (testCase.Steps) {
|
const updatedSteps = testCase.Steps.map((step) => {
|
||||||
const updatedSteps = testCase.Steps.map((step) => {
|
if (step.caseSteps.stepNo >= newStepNo) {
|
||||||
if (step.caseSteps.stepNo >= newStepNo) {
|
return {
|
||||||
return {
|
...step,
|
||||||
...step,
|
editState: step.editState === 'notChanged' ? 'changed' : step.editState,
|
||||||
editState: step.editState === 'notChanged' ? 'changed' : step.editState,
|
caseSteps: {
|
||||||
caseSteps: {
|
...step.caseSteps,
|
||||||
...step.caseSteps,
|
stepNo: step.caseSteps.stepNo + 1,
|
||||||
stepNo: step.caseSteps.stepNo + 1,
|
},
|
||||||
},
|
};
|
||||||
};
|
}
|
||||||
}
|
return step;
|
||||||
return step;
|
});
|
||||||
});
|
|
||||||
|
|
||||||
updatedSteps.push(newStep);
|
updatedSteps.push(newStep);
|
||||||
|
|
||||||
setTestCase({
|
setTestCase({
|
||||||
...testCase,
|
...testCase,
|
||||||
Steps: updatedSteps,
|
Steps: updatedSteps,
|
||||||
});
|
});
|
||||||
}
|
setIdCounter(nextId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDeleteClick = async (stepId: number) => {
|
const onDeleteClick = async (stepId: number) => {
|
||||||
setIsDirty(true);
|
setIsDirty(true);
|
||||||
|
if (!testCase.Steps) {
|
||||||
// find deletedStep's stepNo
|
return;
|
||||||
if (testCase.Steps) {
|
|
||||||
const deletedStep = testCase.Steps.find((step) => step.id === stepId);
|
|
||||||
if (!deletedStep) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const deletedStepNo = deletedStep.caseSteps.stepNo;
|
|
||||||
deletedStep.editState = 'deleted';
|
|
||||||
|
|
||||||
const updatedSteps = testCase.Steps.map((step) => {
|
|
||||||
if (step.caseSteps.stepNo > deletedStepNo) {
|
|
||||||
return {
|
|
||||||
...step,
|
|
||||||
editState: step.editState === 'notChanged' ? 'changed' : step.editState,
|
|
||||||
caseSteps: {
|
|
||||||
...step.caseSteps,
|
|
||||||
stepNo: step.caseSteps.stepNo - 1,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return step;
|
|
||||||
});
|
|
||||||
|
|
||||||
setTestCase({
|
|
||||||
...testCase,
|
|
||||||
Steps: updatedSteps,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
// find deletedStep's stepNo
|
||||||
|
|
||||||
|
const deletedStep = testCase.Steps.find((step) => step.id === stepId);
|
||||||
|
if (!deletedStep) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const deletedStepNo = deletedStep.caseSteps.stepNo;
|
||||||
|
deletedStep.editState = 'deleted';
|
||||||
|
|
||||||
|
const updatedSteps = testCase.Steps.map((step) => {
|
||||||
|
if (step.caseSteps.stepNo > deletedStepNo) {
|
||||||
|
return {
|
||||||
|
...step,
|
||||||
|
editState: step.editState === 'notChanged' ? 'changed' : step.editState,
|
||||||
|
caseSteps: {
|
||||||
|
...step.caseSteps,
|
||||||
|
stepNo: step.caseSteps.stepNo - 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return step;
|
||||||
|
});
|
||||||
|
|
||||||
|
setTestCase({
|
||||||
|
...testCase,
|
||||||
|
Steps: updatedSteps,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDrop = (event: DragEvent<HTMLElement>) => {
|
const handleDrop = (event: DragEvent<HTMLElement>) => {
|
||||||
@@ -201,18 +205,20 @@ export default function CaseEditor({
|
|||||||
changeStep.editState = 'changed';
|
changeStep.editState = 'changed';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (testCase.Steps) {
|
if (!testCase.Steps) {
|
||||||
setTestCase({
|
return;
|
||||||
...testCase,
|
|
||||||
Steps: testCase.Steps.map((step) => {
|
|
||||||
if (step.id === stepId) {
|
|
||||||
return changeStep;
|
|
||||||
} else {
|
|
||||||
return step;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setTestCase({
|
||||||
|
...testCase,
|
||||||
|
Steps: testCase.Steps.map((step) => {
|
||||||
|
if (step.id === stepId) {
|
||||||
|
return changeStep;
|
||||||
|
} else {
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -223,6 +229,11 @@ export default function CaseEditor({
|
|||||||
data.Steps.forEach((step: StepType) => {
|
data.Steps.forEach((step: StepType) => {
|
||||||
step.editState = 'notChanged';
|
step.editState = 'notChanged';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// set idCounter to the max step id to avoid id conflict for new steps
|
||||||
|
// id is not reflected on database
|
||||||
|
const maxStepId = data.Steps.reduce((maxId: number, step: StepType) => Math.max(maxId, step.id), 0);
|
||||||
|
setIdCounter(maxStepId);
|
||||||
setTestCase(data);
|
setTestCase(data);
|
||||||
if (data.Tags) {
|
if (data.Tags) {
|
||||||
setSelectedTags(Array.isArray(data.Tags) ? data.Tags : []);
|
setSelectedTags(Array.isArray(data.Tags) ? data.Tags : []);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -505,7 +505,7 @@ export default function RunEditor({
|
|||||||
)}
|
)}
|
||||||
</Tree>
|
</Tree>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-9/12">
|
<div className="w-9/12 overflow-x-auto">
|
||||||
<TestCaseSelector
|
<TestCaseSelector
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
runId={runId}
|
runId={runId}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export default function SettingsPage({ projectId, messages, projectDialogMessage
|
|||||||
avatarPath: '',
|
avatarPath: '',
|
||||||
role: -1,
|
role: -1,
|
||||||
username: '',
|
username: '',
|
||||||
|
locale: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type UserType = {
|
|||||||
username: string;
|
username: string;
|
||||||
role: number;
|
role: number;
|
||||||
avatarPath: string | null;
|
avatarPath: string | null;
|
||||||
|
locale: LocaleCodeType | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TokenProps = {
|
export type TokenProps = {
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
103
frontend/utils/formGuard.test.ts
Normal file
103
frontend/utils/formGuard.test.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// mock cleanup
|
||||||
|
let cleanupFn: (() => void) | undefined;
|
||||||
|
vi.mock('react', () => ({
|
||||||
|
useEffect: (fn: () => void) => {
|
||||||
|
cleanupFn = fn() as (() => void) | undefined;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useFormGuard } from './formGuard';
|
||||||
|
|
||||||
|
describe('useFormGuard', () => {
|
||||||
|
let confirmSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// @ts-expect-error - we will mock confirm
|
||||||
|
confirmSpy = vi.spyOn(window, 'confirm');
|
||||||
|
cleanupFn = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanupFn?.();
|
||||||
|
cleanupFn = undefined;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// helper: simulate clicking an anchor
|
||||||
|
const clickAnchor = (href: string, target?: string): MouseEvent => {
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.setAttribute('href', href);
|
||||||
|
if (target) anchor.setAttribute('target', target);
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
|
||||||
|
const event = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
anchor.dispatchEvent(event);
|
||||||
|
return event;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Basic functionality', () => {
|
||||||
|
it('does not show confirm when form is clean', () => {
|
||||||
|
useFormGuard(false, 'Leave?');
|
||||||
|
clickAnchor('/some-path');
|
||||||
|
|
||||||
|
expect(confirmSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows confirm with the given text when dirty', () => {
|
||||||
|
confirmSpy.mockReturnValue(true);
|
||||||
|
useFormGuard(true, 'Are you sure?');
|
||||||
|
clickAnchor('/other-page');
|
||||||
|
|
||||||
|
expect(confirmSpy).toHaveBeenCalledWith('Are you sure?');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents navigation when dirty and user cancels confirm', () => {
|
||||||
|
confirmSpy.mockReturnValue(false);
|
||||||
|
useFormGuard(true, 'Leave?');
|
||||||
|
|
||||||
|
const event = clickAnchor('/other-page');
|
||||||
|
expect(event.defaultPrevented).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows navigation when dirty and user confirms', () => {
|
||||||
|
confirmSpy.mockReturnValue(true);
|
||||||
|
useFormGuard(true, 'Leave?');
|
||||||
|
|
||||||
|
const event = clickAnchor('/other-page');
|
||||||
|
expect(event.defaultPrevented).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('external links', () => {
|
||||||
|
it('does not show confirm for anchor with target="_blank"', () => {
|
||||||
|
useFormGuard(true, 'Leave?');
|
||||||
|
clickAnchor('https://example.com', '_blank');
|
||||||
|
|
||||||
|
expect(confirmSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('UnitTCMS use case', () => {
|
||||||
|
it('does not show confirm when navigating to case detail page of test run', () => {
|
||||||
|
const projectId = '1';
|
||||||
|
const runId = '2';
|
||||||
|
useFormGuard(true, 'Leave?', [`/projects/${projectId}/runs/${runId}/cases/\\d+`]);
|
||||||
|
clickAnchor(`/projects/${projectId}/runs/${runId}/cases/123`);
|
||||||
|
|
||||||
|
expect(confirmSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows confirm when navigating to test cases page', () => {
|
||||||
|
const projectId = '1';
|
||||||
|
const runId = '2';
|
||||||
|
useFormGuard(true, 'Leave?', [`/projects/${projectId}/runs/${runId}/cases/\\d+`]);
|
||||||
|
clickAnchor(`/projects/${projectId}/runs/${runId}/cases`);
|
||||||
|
|
||||||
|
expect(confirmSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,19 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
export const useFormGuard = (isDirty: boolean, confirmText: string, ignorePaths?: string[]) => {
|
const isIgnoredPath = (href: string, compiledPatterns: RegExp[]): boolean => {
|
||||||
|
return compiledPatterns.some((regex) => regex.test(href));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useFormGuard = (isDirty: boolean, confirmText: string, ignorePathPatterns?: string[]) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const compiledPatterns: RegExp[] = (ignorePathPatterns ?? []).flatMap((pattern) => {
|
||||||
|
try {
|
||||||
|
return [new RegExp(pattern)];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const handleClick = (event: MouseEvent) => {
|
const handleClick = (event: MouseEvent) => {
|
||||||
if (!isDirty) return;
|
if (!isDirty) return;
|
||||||
|
|
||||||
@@ -13,7 +25,7 @@ export const useFormGuard = (isDirty: boolean, confirmText: string, ignorePaths?
|
|||||||
if (!href) return;
|
if (!href) return;
|
||||||
|
|
||||||
// do not show confirm for ignored paths
|
// do not show confirm for ignored paths
|
||||||
if (ignorePaths && ignorePaths.some((path) => href.includes(path))) {
|
if (isIgnoredPath(href, compiledPatterns)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,5 +50,5 @@ export const useFormGuard = (isDirty: boolean, confirmText: string, ignorePaths?
|
|||||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
window.removeEventListener('click', handleClick, true);
|
window.removeEventListener('click', handleClick, true);
|
||||||
};
|
};
|
||||||
}, [confirmText, isDirty, ignorePaths]);
|
}, [confirmText, isDirty, ignorePathPatterns]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -219,6 +219,36 @@ async function adminResetPassword(jwt: string, userId: number, newPassword: stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateLocale(jwt: string, locale: string) {
|
||||||
|
const updateData = {
|
||||||
|
locale,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchOptions = {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${jwt}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(updateData),
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = `${apiServer}/users/locale`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, fetchOptions);
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(errorText || `HTTP error! Status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
return data;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logError('Error updating locale:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
findUser,
|
findUser,
|
||||||
searchUsers,
|
searchUsers,
|
||||||
@@ -228,4 +258,5 @@ export {
|
|||||||
uploadAvatar,
|
uploadAvatar,
|
||||||
deleteAvatar,
|
deleteAvatar,
|
||||||
adminResetPassword,
|
adminResetPassword,
|
||||||
|
updateLocale,
|
||||||
};
|
};
|
||||||
|
|||||||
106
package-lock.json
generated
106
package-lock.json
generated
@@ -19,7 +19,9 @@
|
|||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
|
"happy-dom": "^20.8.3",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
|
"react": "^19.2.4",
|
||||||
"sequelize": "^6.37.4",
|
"sequelize": "^6.37.4",
|
||||||
"sqlite3": "^5.1.7",
|
"sqlite3": "^5.1.7",
|
||||||
"supertest": "^7.1.4",
|
"supertest": "^7.1.4",
|
||||||
@@ -50,10 +52,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-validator-identifier": {
|
"node_modules/@babel/helper-validator-identifier": {
|
||||||
"version": "7.25.9",
|
"version": "7.28.5",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||||
"integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
|
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
@@ -1255,6 +1258,23 @@
|
|||||||
"integrity": "sha512-2ipwZ2NydGQJImne+FhNdhgRM37e9lCev99KnqkbFHd94Xn/mErARWI1RSLem1QA19ch5kOhzIZd7e8CA2FI8g==",
|
"integrity": "sha512-2ipwZ2NydGQJImne+FhNdhgRM37e9lCev99KnqkbFHd94Xn/mErARWI1RSLem1QA19ch5kOhzIZd7e8CA2FI8g==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/whatwg-mimetype": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.29.1",
|
"version": "8.29.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.29.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.29.1.tgz",
|
||||||
@@ -1289,6 +1309,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.29.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.29.1.tgz",
|
||||||
"integrity": "sha512-zczrHVEqEaTwh12gWBIJWj8nx+ayDcCJs06yoNMY0kwjMWDM6+kppljY+BxWI06d2Ja+h4+WdufDcwMnnMEWmg==",
|
"integrity": "sha512-zczrHVEqEaTwh12gWBIJWj8nx+ayDcCJs06yoNMY0kwjMWDM6+kppljY+BxWI06d2Ja+h4+WdufDcwMnnMEWmg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.29.1",
|
"@typescript-eslint/scope-manager": "8.29.1",
|
||||||
"@typescript-eslint/types": "8.29.1",
|
"@typescript-eslint/types": "8.29.1",
|
||||||
@@ -1838,6 +1859,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
|
||||||
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
|
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -2938,6 +2960,19 @@
|
|||||||
"once": "^1.4.0"
|
"once": "^1.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "7.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||||
|
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/env-paths": {
|
"node_modules/env-paths": {
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||||
@@ -3182,6 +3217,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.24.0.tgz",
|
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.24.0.tgz",
|
||||||
"integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==",
|
"integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -3361,6 +3397,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz",
|
||||||
"integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==",
|
"integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.8",
|
"array-includes": "^3.1.8",
|
||||||
@@ -4220,6 +4257,25 @@
|
|||||||
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
|
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/happy-dom": {
|
||||||
|
"version": "20.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.3.tgz",
|
||||||
|
"integrity": "sha512-lMHQRRwIPyJ70HV0kkFT7jH/gXzSI7yDkQFe07E2flwmNDFoWUTRMKpW2sglsnpeA7b6S2TJPp98EbQxai8eaQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": ">=20.0.0",
|
||||||
|
"@types/whatwg-mimetype": "^3.0.2",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
|
"entities": "^7.0.1",
|
||||||
|
"whatwg-mimetype": "^3.0.0",
|
||||||
|
"ws": "^8.18.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/has-bigints": {
|
"node_modules/has-bigints": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
||||||
@@ -6349,6 +6405,16 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react": {
|
||||||
|
"version": "19.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||||
|
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
@@ -7498,6 +7564,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
|
||||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -7974,6 +8041,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz",
|
||||||
"integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==",
|
"integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/expect": "1.6.1",
|
"@vitest/expect": "1.6.1",
|
||||||
"@vitest/runner": "1.6.1",
|
"@vitest/runner": "1.6.1",
|
||||||
@@ -8034,6 +8102,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/whatwg-mimetype": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -8184,6 +8262,28 @@
|
|||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||||
|
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||||
|
|||||||
@@ -25,7 +25,9 @@
|
|||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
|
"happy-dom": "^20.8.3",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
|
"react": "^19.2.4",
|
||||||
"sequelize": "^6.37.4",
|
"sequelize": "^6.37.4",
|
||||||
"sqlite3": "^5.1.7",
|
"sqlite3": "^5.1.7",
|
||||||
"supertest": "^7.1.4",
|
"supertest": "^7.1.4",
|
||||||
|
|||||||
Reference in New Issue
Block a user