Merge pull request #362 from kimatata/develop
This commit is contained in:
@@ -4,6 +4,7 @@ import { DataTypes } from 'sequelize';
|
|||||||
import Papa from 'papaparse';
|
import Papa from 'papaparse';
|
||||||
import defineCase from '../../models/cases.js';
|
import defineCase from '../../models/cases.js';
|
||||||
import defineStep from '../../models/steps.js';
|
import defineStep from '../../models/steps.js';
|
||||||
|
import defineFolder from '../../models/folders.js';
|
||||||
import authMiddleware from '../../middleware/auth.js';
|
import authMiddleware from '../../middleware/auth.js';
|
||||||
import visibilityMiddleware from '../../middleware/verifyVisible.js';
|
import visibilityMiddleware from '../../middleware/verifyVisible.js';
|
||||||
import { testRunStatus, priorities, testTypes, automationStatus, templates } from '../../config/enums.js';
|
import { testRunStatus, priorities, testTypes, automationStatus, templates } from '../../config/enums.js';
|
||||||
@@ -11,6 +12,8 @@ import { testRunStatus, priorities, testTypes, automationStatus, templates } fro
|
|||||||
export default function (sequelize) {
|
export default function (sequelize) {
|
||||||
const Case = defineCase(sequelize, DataTypes);
|
const Case = defineCase(sequelize, DataTypes);
|
||||||
const Step = defineStep(sequelize, DataTypes);
|
const Step = defineStep(sequelize, DataTypes);
|
||||||
|
const Folder = defineFolder(sequelize, DataTypes);
|
||||||
|
Case.belongsTo(Folder);
|
||||||
Case.belongsToMany(Step, { through: 'caseSteps' });
|
Case.belongsToMany(Step, { through: 'caseSteps' });
|
||||||
Step.belongsToMany(Case, { through: 'caseSteps' });
|
Step.belongsToMany(Case, { through: 'caseSteps' });
|
||||||
const { verifySignedIn } = authMiddleware(sequelize);
|
const { verifySignedIn } = authMiddleware(sequelize);
|
||||||
@@ -37,6 +40,10 @@ export default function (sequelize) {
|
|||||||
order: [['stepNo', 'ASC']],
|
order: [['stepNo', 'ASC']],
|
||||||
attributes: { exclude: ['createdAt', 'updatedAt'] },
|
attributes: { exclude: ['createdAt', 'updatedAt'] },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
model: Folder,
|
||||||
|
attributes: ['name'],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
where: { folderId },
|
where: { folderId },
|
||||||
raw: true,
|
raw: true,
|
||||||
@@ -90,6 +97,7 @@ const _formatRawCasesToJson = (cases) => {
|
|||||||
casesObject[c.id] = {
|
casesObject[c.id] = {
|
||||||
id: c.id,
|
id: c.id,
|
||||||
folderId: c.folderId,
|
folderId: c.folderId,
|
||||||
|
folder: c['Folder.name'],
|
||||||
title: c.title,
|
title: c.title,
|
||||||
state: c.state,
|
state: c.state,
|
||||||
priority: c.priority,
|
priority: c.priority,
|
||||||
@@ -119,6 +127,7 @@ const _formatRawCasesToCsv = (cases) => {
|
|||||||
return cases.map((c) => ({
|
return cases.map((c) => ({
|
||||||
id: c.id,
|
id: c.id,
|
||||||
folderId: c.folderId,
|
folderId: c.folderId,
|
||||||
|
folder: c['Folder.name'],
|
||||||
title: c.title,
|
title: c.title,
|
||||||
state: c.state,
|
state: c.state,
|
||||||
priority: c.priority,
|
priority: c.priority,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ vi.mock('../../middleware/verifyVisible.js', () => ({
|
|||||||
const mockCase = {
|
const mockCase = {
|
||||||
findAll: vi.fn(),
|
findAll: vi.fn(),
|
||||||
belongsToMany: vi.fn(),
|
belongsToMany: vi.fn(),
|
||||||
|
belongsTo: vi.fn(),
|
||||||
};
|
};
|
||||||
vi.mock('../../models/cases.js', () => ({
|
vi.mock('../../models/cases.js', () => ({
|
||||||
default: () => mockCase,
|
default: () => mockCase,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import multer from 'multer';
|
|||||||
import XLSX from 'xlsx';
|
import XLSX from 'xlsx';
|
||||||
import { DataTypes } from 'sequelize';
|
import { DataTypes } from 'sequelize';
|
||||||
import defineCase from '../../models/cases.js';
|
import defineCase from '../../models/cases.js';
|
||||||
|
import defineStep from '../../models/steps.js';
|
||||||
|
import defineCaseStep from '../../models/caseSteps.js';
|
||||||
import authMiddleware from '../../middleware/auth.js';
|
import authMiddleware from '../../middleware/auth.js';
|
||||||
import editableMiddleware from '../../middleware/verifyEditable.js';
|
import editableMiddleware from '../../middleware/verifyEditable.js';
|
||||||
import { priorities, testTypes, automationStatus, templates } from '../../config/enums.js';
|
import { priorities, testTypes, automationStatus, templates } from '../../config/enums.js';
|
||||||
@@ -34,6 +36,10 @@ const upload = multer({
|
|||||||
|
|
||||||
export default function (sequelize) {
|
export default function (sequelize) {
|
||||||
const Case = defineCase(sequelize, DataTypes);
|
const Case = defineCase(sequelize, DataTypes);
|
||||||
|
const Step = defineStep(sequelize, DataTypes);
|
||||||
|
const CaseStep = defineCaseStep(sequelize, DataTypes);
|
||||||
|
Case.belongsToMany(Step, { through: CaseStep });
|
||||||
|
Step.belongsToMany(Case, { through: CaseStep });
|
||||||
const { verifySignedIn } = authMiddleware(sequelize);
|
const { verifySignedIn } = authMiddleware(sequelize);
|
||||||
const { verifyProjectDeveloperFromFolderId } = editableMiddleware(sequelize);
|
const { verifyProjectDeveloperFromFolderId } = editableMiddleware(sequelize);
|
||||||
|
|
||||||
@@ -60,86 +66,135 @@ export default function (sequelize) {
|
|||||||
return res.status(400).json({ error: 'folderId is required' });
|
return res.status(400).json({ error: 'folderId is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const t = await sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
const workbook = XLSX.read(req.file.buffer, { type: 'buffer' });
|
const workbook = XLSX.read(req.file.buffer, { type: 'buffer' });
|
||||||
const sheetName = workbook.SheetNames[0];
|
const sheetName = workbook.SheetNames[0];
|
||||||
const worksheet = workbook.Sheets[sheetName];
|
const worksheet = workbook.Sheets[sheetName];
|
||||||
const jsonData = XLSX.utils.sheet_to_json(worksheet);
|
const jsonData = XLSX.utils.sheet_to_json(worksheet);
|
||||||
|
|
||||||
let errorMessage = null;
|
let currentTitle = null;
|
||||||
|
let previousTitle = null;
|
||||||
|
let stepNo = 1;
|
||||||
const casesToCreate = [];
|
const casesToCreate = [];
|
||||||
const requiredFields = ['title', 'priority', 'type', 'template'];
|
const stepsToCreate = [];
|
||||||
for (const [index, row] of jsonData.entries()) {
|
for (const [index, row] of jsonData.entries()) {
|
||||||
const rowNumber = index + 2;
|
const errorMessage = _getRowValidationError(row, index);
|
||||||
for (const field of requiredFields) {
|
|
||||||
if (!row[field]) {
|
|
||||||
errorMessage = `Row ${rowNumber} is missing required field: ${field}`;
|
|
||||||
console.log(`Error found for field: ${errorMessage}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate priority if provided
|
|
||||||
let priorityIndex = priorities.indexOf('medium'); // default to 'medium'
|
|
||||||
if (row['priority']) {
|
|
||||||
priorityIndex = priorities.indexOf(row['priority'].toLowerCase());
|
|
||||||
if (priorityIndex === -1) {
|
|
||||||
errorMessage = `Row ${rowNumber} has invalid priority: ${row['priority']}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate type if provided
|
|
||||||
let typeIndex = testTypes.indexOf('other'); // default to 'other'
|
|
||||||
if (row['type']) {
|
|
||||||
typeIndex = testTypes.indexOf(row['type'].toLowerCase());
|
|
||||||
if (typeIndex === -1) {
|
|
||||||
errorMessage = `Row ${rowNumber} has invalid type: ${row['type']}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate automationStatus if provided
|
|
||||||
let automationStatusIndex = automationStatus.indexOf('automation-not-required'); // default to 'automation-not-required'
|
|
||||||
if (row['automationStatus']) {
|
|
||||||
automationStatusIndex = automationStatus.indexOf(row['automationStatus'].toLowerCase());
|
|
||||||
if (automationStatusIndex === -1) {
|
|
||||||
errorMessage = `Row ${rowNumber} has invalid automationStatus: ${row['automationStatus']}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate template if provided
|
|
||||||
let templateIndex = templates.indexOf('text'); // default to 'text'
|
|
||||||
if (row['template']) {
|
|
||||||
templateIndex = templates.indexOf(row['template'].toLowerCase());
|
|
||||||
if (templateIndex === -1) {
|
|
||||||
errorMessage = `Row ${rowNumber} has invalid template: ${row['template']}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errorMessage) {
|
if (errorMessage) {
|
||||||
return res.status(400).json({ error: errorMessage });
|
return res.status(400).json({ error: errorMessage });
|
||||||
}
|
}
|
||||||
|
|
||||||
casesToCreate.push({
|
// Add step to the same case if the current row title is equal to previous row title
|
||||||
folderId: folderId,
|
// This handle cases with multiple steps :)
|
||||||
title: row['title'],
|
currentTitle = row['title'].trim();
|
||||||
description: row['description'] || '',
|
previousTitle = casesToCreate[casesToCreate.length - 1]?.title.trim();
|
||||||
state: 0, // default state
|
if (casesToCreate.length > 0 && previousTitle === currentTitle) {
|
||||||
priority: priorityIndex,
|
stepNo += 1;
|
||||||
type: typeIndex,
|
stepsToCreate.push({
|
||||||
preConditions: row['preConditions'],
|
caseIndex: casesToCreate.length - 1,
|
||||||
expectedResults: row['expectedResults'],
|
stepNo: stepNo,
|
||||||
automationStatus: automationStatusIndex,
|
step: row['step'] || '',
|
||||||
template: templateIndex,
|
result: row['expectedStepResult'] || '',
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
stepNo = 1;
|
||||||
|
casesToCreate.push({
|
||||||
|
folderId: folderId,
|
||||||
|
title: currentTitle,
|
||||||
|
description: row['description'] || '',
|
||||||
|
state: 0, // default state
|
||||||
|
priority: row['priority'] ? priorities.indexOf(row['priority']) : priorities.indexOf('medium'),
|
||||||
|
type: row['type'] ? testTypes.indexOf(row['type']) : testTypes.indexOf('other'),
|
||||||
|
preConditions: row['preConditions'],
|
||||||
|
expectedResults: row['expectedResults'],
|
||||||
|
automationStatus: row['automationStatus']
|
||||||
|
? automationStatus.indexOf(row['automationStatus'])
|
||||||
|
: automationStatus.indexOf('automation-not-required'),
|
||||||
|
template: row['template'] ? templates.indexOf(row['template']) : templates.indexOf('text'),
|
||||||
|
});
|
||||||
|
stepsToCreate.push({
|
||||||
|
caseIndex: casesToCreate.length - 1,
|
||||||
|
stepNo: stepNo,
|
||||||
|
step: row['step'] || '',
|
||||||
|
result: row['expectedStepResult'] || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdCases = await Case.bulkCreate(casesToCreate);
|
// 'Manually' create cases, steps and caseStep association.
|
||||||
|
const createdCases = await Case.bulkCreate(casesToCreate, { transaction: t });
|
||||||
|
for (const stepData of stepsToCreate) {
|
||||||
|
const createdCase = createdCases[stepData.caseIndex];
|
||||||
|
const createdStep = await Step.create(
|
||||||
|
{
|
||||||
|
step: stepData.step,
|
||||||
|
result: stepData.result,
|
||||||
|
},
|
||||||
|
{ transaction: t }
|
||||||
|
);
|
||||||
|
await CaseStep.create(
|
||||||
|
{
|
||||||
|
caseId: createdCase.id,
|
||||||
|
stepId: createdStep.id,
|
||||||
|
stepNo: stepData.stepNo,
|
||||||
|
},
|
||||||
|
{ transaction: t }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await t.commit();
|
||||||
res.json(createdCases);
|
res.json(createdCases);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
await t.rollback();
|
||||||
console.error(error);
|
console.error(error);
|
||||||
res.status(500).send('Internal Server Error');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _getRowValidationError(row, index) {
|
||||||
|
const requiredFields = ['title', 'priority', 'type', 'template'];
|
||||||
|
const rowNumber = index + 2;
|
||||||
|
|
||||||
|
for (const field of requiredFields) {
|
||||||
|
if (!row[field]) {
|
||||||
|
return `Row ${rowNumber} is missing required field: ${field}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate priority if provided
|
||||||
|
if (row['priority']) {
|
||||||
|
const priorityIndex = priorities.indexOf(row['priority']?.toLowerCase());
|
||||||
|
if (priorityIndex === -1) {
|
||||||
|
return `Row ${rowNumber} has invalid priority: ${row['priority']}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate type if provided
|
||||||
|
if (row['type']) {
|
||||||
|
const typeIndex = testTypes.indexOf(row['type']?.toLowerCase());
|
||||||
|
if (typeIndex === -1) {
|
||||||
|
return `Row ${rowNumber} has invalid type: ${row['type']}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate automationStatus if provided
|
||||||
|
if (row['automationStatus']) {
|
||||||
|
const automationStatusIndex = automationStatus.indexOf(row['automationStatus']?.toLowerCase());
|
||||||
|
if (automationStatusIndex === -1) {
|
||||||
|
return `Row ${rowNumber} has invalid automationStatus: ${row['automationStatus']}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate template if provided
|
||||||
|
if (row['template']) {
|
||||||
|
const templateIndex = templates.indexOf(row['template']?.toLowerCase());
|
||||||
|
if (templateIndex === -1) {
|
||||||
|
return `Row ${rowNumber} has invalid template: ${row['template']}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
import { DataTypes } from 'sequelize';
|
import { DataTypes, Op } from 'sequelize';
|
||||||
import defineProject from '../../models/projects.js';
|
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';
|
||||||
@@ -33,7 +33,7 @@ export default function (sequelize) {
|
|||||||
verifyProjectVisibleFromProjectId,
|
verifyProjectVisibleFromProjectId,
|
||||||
verifyProjectVisibleFromRunId,
|
verifyProjectVisibleFromRunId,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const { projectId, runId } = req.query;
|
const { projectId, runId, status, tag, search } = req.query;
|
||||||
|
|
||||||
if (!projectId) {
|
if (!projectId) {
|
||||||
return res.status(400).json({ error: 'projectId is required' });
|
return res.status(400).json({ error: 'projectId is required' });
|
||||||
@@ -44,7 +44,61 @@ export default function (sequelize) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Build where clause for Case model
|
||||||
|
const caseWhereClause = {};
|
||||||
|
|
||||||
|
// Handle search parameter
|
||||||
|
if (search) {
|
||||||
|
const searchTerm = search.trim();
|
||||||
|
|
||||||
|
if (searchTerm.length > 100) {
|
||||||
|
return res.status(400).json({ error: 'too long search param' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchTerm.length >= 1) {
|
||||||
|
caseWhereClause[Op.or] = [
|
||||||
|
{ title: { [Op.like]: `%${searchTerm}%` } },
|
||||||
|
{ description: { [Op.like]: `%${searchTerm}%` } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle status filter for RunCase
|
||||||
|
let statusFilter = undefined;
|
||||||
|
let runCaseRequired = false;
|
||||||
|
if (status) {
|
||||||
|
const statusValues = status
|
||||||
|
.split(',')
|
||||||
|
.map((t) => parseInt(t.trim(), 10))
|
||||||
|
.filter((t) => !isNaN(t));
|
||||||
|
|
||||||
|
if (statusValues.length > 0) {
|
||||||
|
statusFilter = { status: { [Op.in]: statusValues } };
|
||||||
|
runCaseRequired = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle tag filter
|
||||||
|
const tagInclude = {
|
||||||
|
model: Tags,
|
||||||
|
attributes: ['id', 'name'],
|
||||||
|
through: { attributes: [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tag) {
|
||||||
|
const tagIds = tag
|
||||||
|
.split(',')
|
||||||
|
.map((t) => parseInt(t.trim(), 10))
|
||||||
|
.filter((t) => !isNaN(t));
|
||||||
|
|
||||||
|
if (tagIds.length > 0) {
|
||||||
|
tagInclude.where = { id: { [Op.in]: tagIds } };
|
||||||
|
tagInclude.required = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const cases = await Case.findAll({
|
const cases = await Case.findAll({
|
||||||
|
where: caseWhereClause,
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: Folder,
|
model: Folder,
|
||||||
@@ -56,16 +110,13 @@ export default function (sequelize) {
|
|||||||
{
|
{
|
||||||
model: RunCase,
|
model: RunCase,
|
||||||
attributes: ['id', 'runId', 'status'],
|
attributes: ['id', 'runId', 'status'],
|
||||||
required: false,
|
// Must be 'true' when filtering by status, otherwise all cases are returned.
|
||||||
|
required: runCaseRequired,
|
||||||
where: {
|
where: {
|
||||||
runId: runId,
|
[Op.and]: [{ runId: runId }, statusFilter],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
tagInclude,
|
||||||
model: Tags,
|
|
||||||
attributes: ['id', 'name'],
|
|
||||||
through: { attributes: [] },
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
res.json(cases);
|
res.json(cases);
|
||||||
|
|||||||
42
backend/routes/users/adminResetPassword.js
Normal file
42
backend/routes/users/adminResetPassword.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
import { DataTypes } from 'sequelize';
|
||||||
|
import defineUser from '../../models/users.js';
|
||||||
|
import authMiddleware from '../../middleware/auth.js';
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
export default function (sequelize) {
|
||||||
|
const { verifySignedIn, verifyAdmin } = authMiddleware(sequelize);
|
||||||
|
const User = defineUser(sequelize, DataTypes);
|
||||||
|
|
||||||
|
// Admin resets another user's password
|
||||||
|
router.put('/:id/password', verifySignedIn, verifyAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { newPassword } = req.body;
|
||||||
|
|
||||||
|
if (!newPassword) {
|
||||||
|
return res.status(400).send('New password is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
return res.status(400).send('New password must be at least 8 characters');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findByPk(id);
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).send('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
||||||
|
await user.update({ password: hashedPassword });
|
||||||
|
|
||||||
|
return res.json({ user: { id: user.id } });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return res.status(500).send('Internal Server Error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -52,6 +52,7 @@ import usersFindRoute from './routes/users/find.js';
|
|||||||
import usersSearchRoute from './routes/users/search.js';
|
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 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';
|
||||||
@@ -61,6 +62,7 @@ app.use('/users', usersFindRoute(sequelize));
|
|||||||
app.use('/users', usersSearchRoute(sequelize));
|
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', 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));
|
||||||
|
|||||||
@@ -159,7 +159,11 @@
|
|||||||
"quit_confirm": "If you relinquish your administrator privileges, you will no longer be able to access the administration page.",
|
"quit_confirm": "If you relinquish your administrator privileges, you will no longer be able to access the administration page.",
|
||||||
"role_changed": "Role changed",
|
"role_changed": "Role changed",
|
||||||
"lost_admin_auth": "Lost admin authority",
|
"lost_admin_auth": "Lost admin authority",
|
||||||
"at_least": "At least one administrator is required"
|
"at_least": "At least one administrator is required",
|
||||||
|
"reset_password": "Reset password",
|
||||||
|
"reset": "Reset",
|
||||||
|
"invalid_password": "Password must be at least 8 characters",
|
||||||
|
"password_not_match": "Pasword does not match"
|
||||||
},
|
},
|
||||||
"Projects": {
|
"Projects": {
|
||||||
"project_list": "Project List",
|
"project_list": "Project List",
|
||||||
@@ -217,7 +221,7 @@
|
|||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"are_you_sure": "Are you sure you want to delete test cases?",
|
"are_you_sure": "Are you sure you want to delete test cases?",
|
||||||
"new_test_case": "New Test Case",
|
"new_test_case": "New",
|
||||||
"export": "Export",
|
"export": "Export",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"no_cases_found": "No test cases found",
|
"no_cases_found": "No test cases found",
|
||||||
@@ -248,7 +252,8 @@
|
|||||||
"click_to_upload": "Click to upload",
|
"click_to_upload": "Click to upload",
|
||||||
"or_drag_and_drop": " or drag and drop",
|
"or_drag_and_drop": " or drag and drop",
|
||||||
"max_file_size": "Max. file size",
|
"max_file_size": "Max. file size",
|
||||||
"cases_imported": "Test cases imported"
|
"cases_imported": "Test cases imported",
|
||||||
|
"create_more": "Create more"
|
||||||
},
|
},
|
||||||
"Case": {
|
"Case": {
|
||||||
"back_to_cases": "Back to test cases",
|
"back_to_cases": "Back to test cases",
|
||||||
@@ -339,7 +344,16 @@
|
|||||||
"preconditions": "Preconditions",
|
"preconditions": "Preconditions",
|
||||||
"expected_result": "Expected result",
|
"expected_result": "Expected result",
|
||||||
"details_of_the_step": "Details of the step",
|
"details_of_the_step": "Details of the step",
|
||||||
"close": "Close"
|
"close": "Close",
|
||||||
|
"filter": "Filter",
|
||||||
|
"clear_all": "Clear all",
|
||||||
|
"apply": "Apply",
|
||||||
|
"select_status": "Select status",
|
||||||
|
"please_save": "Please save changes",
|
||||||
|
"case_title_or_description": "Test case title or description",
|
||||||
|
"selected": "Selected",
|
||||||
|
"tags": "Tags",
|
||||||
|
"select_tags": "Select tags"
|
||||||
},
|
},
|
||||||
"Members": {
|
"Members": {
|
||||||
"member_management": "Member Management",
|
"member_management": "Member Management",
|
||||||
|
|||||||
@@ -159,7 +159,11 @@
|
|||||||
"quit_confirm": "管理者権限を放棄すると管理ページにアクセスできなくなります。",
|
"quit_confirm": "管理者権限を放棄すると管理ページにアクセスできなくなります。",
|
||||||
"role_changed": "ロールを更新しました",
|
"role_changed": "ロールを更新しました",
|
||||||
"lost_admin_auth": "管理者権限を失いました",
|
"lost_admin_auth": "管理者権限を失いました",
|
||||||
"at_least": "最低1人以上の管理者が必要です。"
|
"at_least": "最低1人以上の管理者が必要です。",
|
||||||
|
"reset_password": "パスワードをリセット",
|
||||||
|
"reset": "リセット",
|
||||||
|
"invalid_password": "パスワードは8文字以上である必要があります",
|
||||||
|
"password_not_match": "パスワードが一致しません"
|
||||||
},
|
},
|
||||||
"Projects": {
|
"Projects": {
|
||||||
"project_list": "プロジェクト一覧",
|
"project_list": "プロジェクト一覧",
|
||||||
@@ -217,7 +221,7 @@
|
|||||||
"delete": "削除",
|
"delete": "削除",
|
||||||
"close": "閉じる",
|
"close": "閉じる",
|
||||||
"are_you_sure": "テストケースを削除してもよろしいですか?",
|
"are_you_sure": "テストケースを削除してもよろしいですか?",
|
||||||
"new_test_case": "新規テストケース",
|
"new_test_case": "新規",
|
||||||
"export": "エクスポート",
|
"export": "エクスポート",
|
||||||
"status": "ステータス",
|
"status": "ステータス",
|
||||||
"no_cases_found": "テストケースがありません",
|
"no_cases_found": "テストケースがありません",
|
||||||
@@ -248,7 +252,8 @@
|
|||||||
"click_to_upload": "クリックしてアップロード",
|
"click_to_upload": "クリックしてアップロード",
|
||||||
"or_drag_and_drop": "またはドラッグアンドドロップ",
|
"or_drag_and_drop": "またはドラッグアンドドロップ",
|
||||||
"max_file_size": "最大ファイルサイズ",
|
"max_file_size": "最大ファイルサイズ",
|
||||||
"cases_imported": "テストケースがインポートされました"
|
"cases_imported": "テストケースがインポートされました",
|
||||||
|
"create_more": "さらに作成"
|
||||||
},
|
},
|
||||||
"Case": {
|
"Case": {
|
||||||
"back_to_cases": "テストケース一覧に戻る",
|
"back_to_cases": "テストケース一覧に戻る",
|
||||||
@@ -339,7 +344,16 @@
|
|||||||
"preconditions": "前提条件",
|
"preconditions": "前提条件",
|
||||||
"expected_result": "期待結果",
|
"expected_result": "期待結果",
|
||||||
"details_of_the_step": "ステップ詳細",
|
"details_of_the_step": "ステップ詳細",
|
||||||
"close": "閉じる"
|
"close": "閉じる",
|
||||||
|
"filter": "フィルター",
|
||||||
|
"clear_all": "すべてクリア",
|
||||||
|
"apply": "適用",
|
||||||
|
"select_status": "ステータスを選択",
|
||||||
|
"please_save": "変更を保存してください",
|
||||||
|
"case_title_or_description": "テストケースのタイトルまたは説明",
|
||||||
|
"selected": "選択済み",
|
||||||
|
"tags": "タグ",
|
||||||
|
"select_tags": "タグを選択"
|
||||||
},
|
},
|
||||||
"Members": {
|
"Members": {
|
||||||
"member_management": "メンバー管理",
|
"member_management": "メンバー管理",
|
||||||
|
|||||||
@@ -159,7 +159,11 @@
|
|||||||
"quit_confirm": "Se você renunciar aos seus privilégios de administrador, não poderá mais acessar a página de administração.",
|
"quit_confirm": "Se você renunciar aos seus privilégios de administrador, não poderá mais acessar a página de administração.",
|
||||||
"role_changed": "Função alterada",
|
"role_changed": "Função alterada",
|
||||||
"lost_admin_auth": "Perdeu a autoridade de administrador",
|
"lost_admin_auth": "Perdeu a autoridade de administrador",
|
||||||
"at_least": "É necessário pelo menos um administrador"
|
"at_least": "É necessário pelo menos um administrador",
|
||||||
|
"reset_password": "Redefinir senha",
|
||||||
|
"reset": "Redefinir",
|
||||||
|
"invalid_password": "A senha deve ter pelo menos 8 caracteres",
|
||||||
|
"password_not_match": "A senha não corresponde"
|
||||||
},
|
},
|
||||||
"Projects": {
|
"Projects": {
|
||||||
"project_list": "Lista de Projetos",
|
"project_list": "Lista de Projetos",
|
||||||
@@ -217,7 +221,7 @@
|
|||||||
"delete": "Excluir",
|
"delete": "Excluir",
|
||||||
"close": "Fechar",
|
"close": "Fechar",
|
||||||
"are_you_sure": "Tem certeza de que deseja excluir os casos de teste?",
|
"are_you_sure": "Tem certeza de que deseja excluir os casos de teste?",
|
||||||
"new_test_case": "Novo Caso de Teste",
|
"new_test_case": "Novo",
|
||||||
"export": "Exportar",
|
"export": "Exportar",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"no_cases_found": "Nenhum caso de teste encontrado",
|
"no_cases_found": "Nenhum caso de teste encontrado",
|
||||||
@@ -248,7 +252,8 @@
|
|||||||
"click_to_upload": "Clique para enviar",
|
"click_to_upload": "Clique para enviar",
|
||||||
"or_drag_and_drop": " ou arraste e solte",
|
"or_drag_and_drop": " ou arraste e solte",
|
||||||
"max_file_size": "Tamanho máx. do arquivo",
|
"max_file_size": "Tamanho máx. do arquivo",
|
||||||
"cases_imported": "Casos de teste importados"
|
"cases_imported": "Casos de teste importados",
|
||||||
|
"create_more": "Criar mais"
|
||||||
},
|
},
|
||||||
"Case": {
|
"Case": {
|
||||||
"back_to_cases": "Voltar para os casos de teste",
|
"back_to_cases": "Voltar para os casos de teste",
|
||||||
@@ -339,7 +344,16 @@
|
|||||||
"preconditions": "Pré-condições",
|
"preconditions": "Pré-condições",
|
||||||
"expected_result": "Resultado esperado",
|
"expected_result": "Resultado esperado",
|
||||||
"details_of_the_step": "Detalhes do passo",
|
"details_of_the_step": "Detalhes do passo",
|
||||||
"close": "Fechar"
|
"close": "Fechar",
|
||||||
|
"filter": "Filtrar",
|
||||||
|
"clear_all": "Limpar tudo",
|
||||||
|
"apply": "Aplicar",
|
||||||
|
"select_status": "Selecionar status",
|
||||||
|
"please_save": "Por favor, salve as alterações",
|
||||||
|
"case_title_or_description": "Título ou descrição do caso de teste",
|
||||||
|
"selected": "Selecionado",
|
||||||
|
"tags": "Tags",
|
||||||
|
"select_tags": "Selecionar tags"
|
||||||
},
|
},
|
||||||
"Members": {
|
"Members": {
|
||||||
"member_management": "Gerenciamento de Membros",
|
"member_management": "Gerenciamento de Membros",
|
||||||
|
|||||||
BIN
frontend/public/template/unittcms-import-template-v1.1.xlsx
Normal file
BIN
frontend/public/template/unittcms-import-template-v1.1.xlsx
Normal file
Binary file not shown.
@@ -2,14 +2,15 @@
|
|||||||
import { useState, useEffect, useContext } from 'react';
|
import { useState, useEffect, useContext } from 'react';
|
||||||
import { Button, addToast } from '@heroui/react';
|
import { Button, addToast } from '@heroui/react';
|
||||||
import UsersTable from './UsersTable';
|
import UsersTable from './UsersTable';
|
||||||
|
import PasswordResetDialog from './PasswordResetDialog';
|
||||||
|
import DeleteConfirmDialog from '@/components/DeleteConfirmDialog';
|
||||||
import { UserType, AdminMessages } from '@/types/user';
|
import { UserType, AdminMessages } from '@/types/user';
|
||||||
import { TokenContext } from '@/utils/TokenProvider';
|
import { TokenContext } from '@/utils/TokenProvider';
|
||||||
import { useRouter } from '@/src/i18n/routing';
|
import { useRouter } from '@/src/i18n/routing';
|
||||||
import Config from '@/config/config';
|
import Config from '@/config/config';
|
||||||
import { LocaleCodeType } from '@/types/locale';
|
import { LocaleCodeType } from '@/types/locale';
|
||||||
import { updateUserRole } from '@/utils/usersControl';
|
import { updateUserRole, adminResetPassword } from '@/utils/usersControl';
|
||||||
import { roles } from '@/config/selection';
|
import { roles } from '@/config/selection';
|
||||||
import DeleteConfirmDialog from '@/components/DeleteConfirmDialog';
|
|
||||||
import { logError } from '@/utils/errorHandler';
|
import { logError } from '@/utils/errorHandler';
|
||||||
const apiServer = Config.apiServer;
|
const apiServer = Config.apiServer;
|
||||||
|
|
||||||
@@ -123,6 +124,28 @@ export default function AdminPage({ messages, locale }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Reset password dialog state
|
||||||
|
const [isResetDialogOpen, setIsResetDialogOpen] = useState(false);
|
||||||
|
const [resetTarget, setResetTarget] = useState<UserType | null>(null);
|
||||||
|
|
||||||
|
const openResetDialog = (user: UserType) => {
|
||||||
|
setResetTarget(user);
|
||||||
|
setIsResetDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onReset = async (newPassword: string) => {
|
||||||
|
setIsResetDialogOpen(false);
|
||||||
|
if (!resetTarget || !resetTarget.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await adminResetPassword(tokenContext.token.access_token, resetTarget.id, newPassword);
|
||||||
|
addToast({ title: 'Success', color: 'success', description: 'Password updated' });
|
||||||
|
setResetTarget(null);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logError('Failed to reset password', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="container mx-auto max-w-3xl pt-16 px-6 flex-grow">
|
<div className="container mx-auto max-w-3xl pt-16 px-6 flex-grow">
|
||||||
@@ -130,7 +153,13 @@ export default function AdminPage({ messages, locale }: Props) {
|
|||||||
<h3 className="font-bold">{messages.userManagement}</h3>
|
<h3 className="font-bold">{messages.userManagement}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UsersTable users={users} myself={myself} onChangeRole={handleChangeRole} messages={messages} />
|
<UsersTable
|
||||||
|
users={users}
|
||||||
|
myself={myself}
|
||||||
|
onChangeRole={handleChangeRole}
|
||||||
|
openResetDialog={openResetDialog}
|
||||||
|
messages={messages}
|
||||||
|
/>
|
||||||
<Button className="mt-4" color="danger" variant="bordered" onPress={() => setIsConfirmDialogOpen(true)}>
|
<Button className="mt-4" color="danger" variant="bordered" onPress={() => setIsConfirmDialogOpen(true)}>
|
||||||
{messages.quitAdmin}
|
{messages.quitAdmin}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -144,6 +173,13 @@ export default function AdminPage({ messages, locale }: Props) {
|
|||||||
confirmText={messages.quitConfirm}
|
confirmText={messages.quitConfirm}
|
||||||
deleteText={messages.quit}
|
deleteText={messages.quit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<PasswordResetDialog
|
||||||
|
isOpen={isResetDialogOpen}
|
||||||
|
onCancel={() => setIsResetDialogOpen(false)}
|
||||||
|
onReset={onReset}
|
||||||
|
messages={messages}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
76
frontend/src/app/[locale]/admin/PasswordResetDialog.tsx
Normal file
76
frontend/src/app/[locale]/admin/PasswordResetDialog.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { Button, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Input } from '@heroui/react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { isValidPassword } from '../account/validate';
|
||||||
|
import { AdminMessages } from '@/types/user';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onReset: (newPassword: string) => void;
|
||||||
|
messages: AdminMessages;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PasswordResetDialog({ isOpen, onCancel, onReset, messages }: Props) {
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
|
||||||
|
const validate = async () => {
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setErrorMessage(messages.passwordNotMatch);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidPassword(newPassword)) {
|
||||||
|
setErrorMessage(messages.invalidPassword);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onReset(newPassword);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onOpenChange={() => {
|
||||||
|
onCancel();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader className="flex flex-col gap-1">{messages.resetPassword}</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<form>
|
||||||
|
{errorMessage && <div className="my-3 text-danger">{errorMessage}</div>}
|
||||||
|
<Input
|
||||||
|
size="sm"
|
||||||
|
type="password"
|
||||||
|
label="New Password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
size="sm"
|
||||||
|
type="password"
|
||||||
|
label="Confirm New Password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="mt-3"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="light" onPress={onCancel}>
|
||||||
|
{messages.close}
|
||||||
|
</Button>
|
||||||
|
<Button color="danger" onPress={validate}>
|
||||||
|
{messages.reset}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownItem,
|
DropdownItem,
|
||||||
} from '@heroui/react';
|
} from '@heroui/react';
|
||||||
import { ChevronDown } from 'lucide-react';
|
import { ChevronDown, MoreVertical } from 'lucide-react';
|
||||||
import { UserType, AdminMessages } from '@/types/user';
|
import { UserType, AdminMessages } from '@/types/user';
|
||||||
import { roles } from '@/config/selection';
|
import { roles } from '@/config/selection';
|
||||||
import UserAvatar from '@/components/UserAvatar';
|
import UserAvatar from '@/components/UserAvatar';
|
||||||
@@ -22,16 +22,18 @@ type Props = {
|
|||||||
users: UserType[];
|
users: UserType[];
|
||||||
myself: UserType | null;
|
myself: UserType | null;
|
||||||
onChangeRole: (userEdit: UserType, role: number) => void;
|
onChangeRole: (userEdit: UserType, role: number) => void;
|
||||||
|
openResetDialog: (user: UserType) => void;
|
||||||
messages: AdminMessages;
|
messages: AdminMessages;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function UsersTable({ users, myself, onChangeRole, messages }: Props) {
|
export default function UsersTable({ users, myself, onChangeRole, openResetDialog, messages }: Props) {
|
||||||
const headerColumns = [
|
const headerColumns = [
|
||||||
{ name: messages.avatar, uid: 'avatar', sortable: false },
|
{ name: messages.avatar, uid: 'avatar', sortable: false },
|
||||||
{ name: messages.id, uid: 'id', sortable: true },
|
{ name: messages.id, uid: 'id', sortable: true },
|
||||||
{ name: messages.email, uid: 'email', sortable: true },
|
{ name: messages.email, uid: 'email', sortable: true },
|
||||||
{ name: messages.username, uid: 'username', sortable: true },
|
{ name: messages.username, uid: 'username', sortable: true },
|
||||||
{ name: messages.role, uid: 'role', sortable: true },
|
{ name: messages.role, uid: 'role', sortable: true },
|
||||||
|
{ name: '', uid: 'actions', sortable: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({
|
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({
|
||||||
@@ -92,6 +94,24 @@ export default function UsersTable({ users, myself, onChangeRole, messages }: Pr
|
|||||||
</Dropdown>
|
</Dropdown>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case 'actions':
|
||||||
|
return (
|
||||||
|
!isMyself(myself, user) && (
|
||||||
|
<Dropdown>
|
||||||
|
<DropdownTrigger>
|
||||||
|
<Button isIconOnly size="sm" className="bg-transparent rounded-full">
|
||||||
|
<MoreVertical size={16} />
|
||||||
|
</Button>
|
||||||
|
</DropdownTrigger>
|
||||||
|
<DropdownMenu aria-label="Static Actions">
|
||||||
|
<DropdownItem key="edit" onPress={() => openResetDialog(user)}>
|
||||||
|
{messages.resetPassword}
|
||||||
|
</DropdownItem>
|
||||||
|
</DropdownMenu>
|
||||||
|
</Dropdown>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return cellValue;
|
return cellValue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ export default function Page({ params }: PageType) {
|
|||||||
roleChanged: t('role_changed'),
|
roleChanged: t('role_changed'),
|
||||||
lostAdminAuth: t('lost_admin_auth'),
|
lostAdminAuth: t('lost_admin_auth'),
|
||||||
atLeast: t('at_least'),
|
atLeast: t('at_least'),
|
||||||
|
resetPassword: t('reset_password'),
|
||||||
|
reset: t('reset'),
|
||||||
|
invalidPassword: t('invalid_password'),
|
||||||
|
passwordNotMatch: t('password_not_match'),
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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.24</TableCell>
|
<TableCell>1.0.0-beta.25</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
<TableRow key="2">
|
<TableRow key="2">
|
||||||
<TableCell>{messages.api_server}</TableCell>
|
<TableCell>{messages.api_server}</TableCell>
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button, Input, Textarea, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from '@heroui/react';
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Textarea,
|
||||||
|
Modal,
|
||||||
|
ModalContent,
|
||||||
|
ModalHeader,
|
||||||
|
ModalBody,
|
||||||
|
ModalFooter,
|
||||||
|
Switch,
|
||||||
|
} from '@heroui/react';
|
||||||
import { CasesMessages } from '@/types/case';
|
import { CasesMessages } from '@/types/case';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onSubmit: (title: string, description: string) => void;
|
onSubmit: (title: string, description: string, createMore: boolean) => void;
|
||||||
messages: CasesMessages;
|
messages: CasesMessages;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -23,6 +33,8 @@ export default function CaseDialog({ isOpen, onCancel, onSubmit, messages }: Pro
|
|||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [createMore, setCreateMore] = useState(false);
|
||||||
|
|
||||||
const clear = () => {
|
const clear = () => {
|
||||||
setCaseName({
|
setCaseName({
|
||||||
isValid: false,
|
isValid: false,
|
||||||
@@ -47,8 +59,23 @@ export default function CaseDialog({ isOpen, onCancel, onSubmit, messages }: Pro
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onSubmit(caseTitle.text, caseDescription.text);
|
onSubmit(caseTitle.text, caseDescription.text, createMore);
|
||||||
clear();
|
|
||||||
|
if (!createMore) {
|
||||||
|
clear();
|
||||||
|
} else {
|
||||||
|
// Reset form fields but keep dialog open
|
||||||
|
setCaseName({
|
||||||
|
isValid: false,
|
||||||
|
text: 'Untitled Case',
|
||||||
|
errorMessage: '',
|
||||||
|
});
|
||||||
|
setCaseDescription({
|
||||||
|
isValid: false,
|
||||||
|
text: '',
|
||||||
|
errorMessage: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -88,9 +115,9 @@ export default function CaseDialog({ isOpen, onCancel, onSubmit, messages }: Pro
|
|||||||
/>
|
/>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button variant="light" onPress={onCancel}>
|
<Switch size="sm" isSelected={createMore} onValueChange={setCreateMore}>
|
||||||
{messages.close}
|
{messages.createMore}
|
||||||
</Button>
|
</Switch>
|
||||||
<Button color="primary" onPress={validate}>
|
<Button color="primary" onPress={validate}>
|
||||||
{messages.create}
|
{messages.create}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export default function CaseImportDialog({ isOpen, folderId, isDisabled, onImpor
|
|||||||
<div className="flex items-center justify-center w-full">
|
<div className="flex items-center justify-center w-full">
|
||||||
<div className={`mt-1 text-neutral-500 dark:text-neutral-400 text-sm rounded`}>
|
<div className={`mt-1 text-neutral-500 dark:text-neutral-400 text-sm rounded`}>
|
||||||
<div>{messages.importAvailable}</div>
|
<div>{messages.importAvailable}</div>
|
||||||
<a href="/template/unittcms-import-template-v1.xlsx" download className="text-tiny underline">
|
<a href="/template/unittcms-import-template-v1.1.xlsx" download className="text-tiny underline">
|
||||||
{messages.downloadTemplate}
|
{messages.downloadTemplate}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -112,10 +112,12 @@ export default function CasesPane({
|
|||||||
|
|
||||||
const closeDialog = () => setIsCaseDialogOpen(false);
|
const closeDialog = () => setIsCaseDialogOpen(false);
|
||||||
|
|
||||||
const onSubmit = async (title: string, description: string) => {
|
const onSubmit = async (title: string, description: string, createMore: boolean) => {
|
||||||
const newCase = await createCase(context.token.access_token, folderId, title, description);
|
const newCase = await createCase(context.token.access_token, folderId, title, description);
|
||||||
setCases([...cases, newCase]);
|
setCases([...cases, newCase]);
|
||||||
closeDialog();
|
if (!createMore) {
|
||||||
|
closeDialog();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeDeleteConfirmDialog = () => {
|
const closeDeleteConfirmDialog = () => {
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export default function Page({ params }: { params: { projectId: string; folderId
|
|||||||
orDragAndDrop: t('or_drag_and_drop'),
|
orDragAndDrop: t('or_drag_and_drop'),
|
||||||
maxFileSize: t('max_file_size'),
|
maxFileSize: t('max_file_size'),
|
||||||
casesImported: t('cases_imported'),
|
casesImported: t('cases_imported'),
|
||||||
|
createMore: t('create_more'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const priorityTranslation = useTranslations('Priority');
|
const priorityTranslation = useTranslations('Priority');
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Circle, CircleCheck, CircleDashed, CircleX, CircleSlash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
uid: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RunCaseStatus({ uid }: Props) {
|
||||||
|
if (uid === 'untested') {
|
||||||
|
return <Circle size={16} color="#d4d4d8" />;
|
||||||
|
} else if (uid === 'passed') {
|
||||||
|
return <CircleCheck size={16} color="#17c964" />;
|
||||||
|
} else if (uid === 'retest') {
|
||||||
|
return <CircleDashed size={16} color="#f5a524" />;
|
||||||
|
} else if (uid === 'failed') {
|
||||||
|
return <CircleX size={16} color="#f31260" />;
|
||||||
|
} else if (uid === 'skipped') {
|
||||||
|
return <CircleSlash2 size={16} color="#52525b" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,9 @@ import {
|
|||||||
DropdownItem,
|
DropdownItem,
|
||||||
addToast,
|
addToast,
|
||||||
Badge,
|
Badge,
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
} from '@heroui/react';
|
} from '@heroui/react';
|
||||||
import {
|
import {
|
||||||
Save,
|
Save,
|
||||||
@@ -29,6 +32,7 @@ import {
|
|||||||
FileJson,
|
FileJson,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Folder,
|
Folder,
|
||||||
|
Filter,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
import { NodeApi, Tree } from 'react-arborist';
|
import { NodeApi, Tree } from 'react-arborist';
|
||||||
@@ -44,6 +48,7 @@ import {
|
|||||||
import { fetchFolders } from '../../folders/foldersControl';
|
import { fetchFolders } from '../../folders/foldersControl';
|
||||||
import RunProgressChart from './RunPregressDonutChart';
|
import RunProgressChart from './RunPregressDonutChart';
|
||||||
import TestCaseSelector from './TestCaseSelector';
|
import TestCaseSelector from './TestCaseSelector';
|
||||||
|
import TestRunFilter from './TestRunFilter';
|
||||||
import { useRouter } from '@/src/i18n/routing';
|
import { useRouter } from '@/src/i18n/routing';
|
||||||
import { testRunStatus } from '@/config/selection';
|
import { testRunStatus } from '@/config/selection';
|
||||||
import { RunType, RunStatusCountType, RunMessages } from '@/types/run';
|
import { RunType, RunStatusCountType, RunMessages } from '@/types/run';
|
||||||
@@ -102,6 +107,9 @@ export default function RunEditor({
|
|||||||
const [isNameInvalid] = useState<boolean>(false);
|
const [isNameInvalid] = useState<boolean>(false);
|
||||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||||
const [isDirty, setIsDirty] = useState(false);
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
|
const [searchFilter, setSearchFilter] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<number[]>([]);
|
||||||
|
const [tagFilter, setTagFilter] = useState<number[]>([]);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
useFormGuard(isDirty, messages.areYouSureLeave);
|
useFormGuard(isDirty, messages.areYouSureLeave);
|
||||||
|
|
||||||
@@ -111,8 +119,15 @@ export default function RunEditor({
|
|||||||
setRunStatusCounts(statusCounts);
|
setRunStatusCounts(statusCounts);
|
||||||
};
|
};
|
||||||
|
|
||||||
const initTestCases = async () => {
|
const initTestCases = async (search?: string, status?: string[], tag?: string[]) => {
|
||||||
const casesData = await fetchProjectCases(tokenContext.token.access_token, Number(projectId), Number(runId));
|
const casesData = await fetchProjectCases(
|
||||||
|
tokenContext.token.access_token,
|
||||||
|
Number(projectId),
|
||||||
|
Number(runId),
|
||||||
|
search,
|
||||||
|
status,
|
||||||
|
tag
|
||||||
|
);
|
||||||
casesData.forEach((testCase: CaseType) => {
|
casesData.forEach((testCase: CaseType) => {
|
||||||
if (testCase.RunCases && testCase.RunCases.length > 0) {
|
if (testCase.RunCases && testCase.RunCases.length > 0) {
|
||||||
testCase.RunCases[0].editState = 'notChanged';
|
testCase.RunCases[0].editState = 'notChanged';
|
||||||
@@ -200,6 +215,29 @@ export default function RunEditor({
|
|||||||
setIsDirty(false);
|
setIsDirty(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// Filter
|
||||||
|
// **************************************************************************
|
||||||
|
const [showFilter, setShowFilter] = useState(false);
|
||||||
|
const [activeFilterNum, setActiveFilterNum] = useState(0);
|
||||||
|
|
||||||
|
const onFilterChange = async (search: string, status: number[], tag: number[]) => {
|
||||||
|
if (isDirty) {
|
||||||
|
addToast({
|
||||||
|
title: 'Error',
|
||||||
|
color: 'danger',
|
||||||
|
description: messages.pleaseSave,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearchFilter(search);
|
||||||
|
setStatusFilter(status);
|
||||||
|
setTagFilter(tag);
|
||||||
|
setActiveFilterNum((search ? 1 : 0) + (status.length > 0 ? 1 : 0) + (tag.length > 0 ? 1 : 0));
|
||||||
|
await initTestCases(search, status.map(String), tag.map(String));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="border-b-1 dark:border-neutral-700 w-full p-3 flex items-center justify-between">
|
<div className="border-b-1 dark:border-neutral-700 w-full p-3 flex items-center justify-between">
|
||||||
@@ -217,6 +255,41 @@ export default function RunEditor({
|
|||||||
<h3 className="font-bold ms-2">{testRun.name}</h3>
|
<h3 className="font-bold ms-2">{testRun.name}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
|
<Popover placement="bottom" isOpen={showFilter} onOpenChange={(open) => setShowFilter(open)}>
|
||||||
|
<Badge
|
||||||
|
color="danger"
|
||||||
|
content={activeFilterNum}
|
||||||
|
isInvisible={activeFilterNum === 0}
|
||||||
|
shape="circle"
|
||||||
|
placement="top-left"
|
||||||
|
>
|
||||||
|
<PopoverTrigger>
|
||||||
|
<Button
|
||||||
|
startContent={<Filter size={16} />}
|
||||||
|
endContent={<ChevronDown size={16} />}
|
||||||
|
size="sm"
|
||||||
|
variant="bordered"
|
||||||
|
className="me-2"
|
||||||
|
>
|
||||||
|
{messages.filter}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
</Badge>
|
||||||
|
<PopoverContent>
|
||||||
|
<TestRunFilter
|
||||||
|
messages={messages}
|
||||||
|
testRunCaseStatusMessages={testRunCaseStatusMessages}
|
||||||
|
activeSearchFilter={searchFilter}
|
||||||
|
activeStatusFilters={statusFilter}
|
||||||
|
activeTagFilters={tagFilter}
|
||||||
|
projectId={projectId}
|
||||||
|
onFilterChange={(newTitleFilter, newStatusFilters, newTagFilters) => {
|
||||||
|
setShowFilter(false);
|
||||||
|
onFilterChange(newTitleFilter, newStatusFilters, newTagFilters);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
<Dropdown placement="bottom-end">
|
<Dropdown placement="bottom-end">
|
||||||
<DropdownTrigger>
|
<DropdownTrigger>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -14,19 +14,9 @@ import {
|
|||||||
Selection,
|
Selection,
|
||||||
SortDescriptor,
|
SortDescriptor,
|
||||||
} from '@heroui/react';
|
} from '@heroui/react';
|
||||||
import {
|
import { ChevronDown, MoveDiagonal, MoreVertical, CopyPlus, CopyMinus } from 'lucide-react';
|
||||||
ChevronDown,
|
|
||||||
MoveDiagonal,
|
|
||||||
MoreVertical,
|
|
||||||
CopyPlus,
|
|
||||||
CopyMinus,
|
|
||||||
Circle,
|
|
||||||
CircleCheck,
|
|
||||||
CircleDashed,
|
|
||||||
CircleX,
|
|
||||||
CircleSlash2,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import TestCaseDetailDialog from './TestCaseDetailDialog';
|
import TestCaseDetailDialog from './TestCaseDetailDialog';
|
||||||
|
import RunCaseStatus from './RunCaseStatus';
|
||||||
import { testRunCaseStatus } from '@/config/selection';
|
import { testRunCaseStatus } from '@/config/selection';
|
||||||
import { CaseType } from '@/types/case';
|
import { CaseType } from '@/types/case';
|
||||||
import { RunMessages } from '@/types/run';
|
import { RunMessages } from '@/types/run';
|
||||||
@@ -103,20 +93,6 @@ export default function TestCaseSelector({
|
|||||||
|
|
||||||
const notIncludedCaseClass = 'text-neutral-200 dark:text-neutral-600';
|
const notIncludedCaseClass = 'text-neutral-200 dark:text-neutral-600';
|
||||||
|
|
||||||
const renderStatusIcon = (uid: string) => {
|
|
||||||
if (uid === 'untested') {
|
|
||||||
return <Circle size={16} color="#d4d4d8" />;
|
|
||||||
} else if (uid === 'passed') {
|
|
||||||
return <CircleCheck size={16} color="#17c964" />;
|
|
||||||
} else if (uid === 'retest') {
|
|
||||||
return <CircleDashed size={16} color="#f5a524" />;
|
|
||||||
} else if (uid === 'failed') {
|
|
||||||
return <CircleX size={16} color="#f31260" />;
|
|
||||||
} else if (uid === 'skipped') {
|
|
||||||
return <CircleSlash2 size={16} color="#52525b" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isCaseIncluded = (testCase: CaseType) => {
|
const isCaseIncluded = (testCase: CaseType) => {
|
||||||
let isIncluded = false;
|
let isIncluded = false;
|
||||||
if (testCase.RunCases && testCase.RunCases.length > 0) {
|
if (testCase.RunCases && testCase.RunCases.length > 0) {
|
||||||
@@ -161,7 +137,7 @@ export default function TestCaseSelector({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="light"
|
variant="light"
|
||||||
isDisabled={!isIncluded}
|
isDisabled={!isIncluded}
|
||||||
startContent={isIncluded && renderStatusIcon(testRunCaseStatus[runStatus].uid)}
|
startContent={isIncluded && <RunCaseStatus uid={testRunCaseStatus[runStatus].uid} />}
|
||||||
endContent={isIncluded && <ChevronDown size={16} />}
|
endContent={isIncluded && <ChevronDown size={16} />}
|
||||||
>
|
>
|
||||||
<span className="w-12">
|
<span className="w-12">
|
||||||
@@ -173,7 +149,7 @@ export default function TestCaseSelector({
|
|||||||
{testRunCaseStatus.map((runCaseStatus, index) => (
|
{testRunCaseStatus.map((runCaseStatus, index) => (
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
key={runCaseStatus.uid}
|
key={runCaseStatus.uid}
|
||||||
startContent={renderStatusIcon(runCaseStatus.uid)}
|
startContent={<RunCaseStatus uid={runCaseStatus.uid} />}
|
||||||
onPress={() => onChangeStatus(testCase.id, index)}
|
onPress={() => onChangeStatus(testCase.id, index)}
|
||||||
>
|
>
|
||||||
{testRunCaseStatusMessages[runCaseStatus.uid]}
|
{testRunCaseStatusMessages[runCaseStatus.uid]}
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { useState, useEffect, useContext } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dropdown,
|
||||||
|
DropdownTrigger,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownItem,
|
||||||
|
Selection,
|
||||||
|
Input,
|
||||||
|
addToast,
|
||||||
|
} from '@heroui/react';
|
||||||
|
import { SearchIcon, ChevronDown } from 'lucide-react';
|
||||||
|
import RunCaseStatus from './RunCaseStatus';
|
||||||
|
import { RunMessages } from '@/types/run';
|
||||||
|
import { testRunCaseStatus } from '@/config/selection';
|
||||||
|
import { TagType } from '@/types/tag';
|
||||||
|
import { fetchTags } from '@/utils/tagsControls';
|
||||||
|
import { TokenContext } from '@/utils/TokenProvider';
|
||||||
|
import { logError } from '@/utils/errorHandler';
|
||||||
|
import { TestRunCaseStatusMessages } from '@/types/status';
|
||||||
|
|
||||||
|
type TestRunFilterProps = {
|
||||||
|
messages: RunMessages;
|
||||||
|
testRunCaseStatusMessages: TestRunCaseStatusMessages;
|
||||||
|
projectId: string;
|
||||||
|
activeSearchFilter: string;
|
||||||
|
activeStatusFilters: number[];
|
||||||
|
activeTagFilters: number[];
|
||||||
|
onFilterChange: (search: string, statusIndices: number[], tagIds: number[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Tag = Pick<TagType, 'id' | 'name'>;
|
||||||
|
|
||||||
|
export default function TestRunFilter({
|
||||||
|
messages,
|
||||||
|
testRunCaseStatusMessages,
|
||||||
|
onFilterChange,
|
||||||
|
projectId,
|
||||||
|
activeSearchFilter = '',
|
||||||
|
activeStatusFilters = [],
|
||||||
|
activeTagFilters = [],
|
||||||
|
}: TestRunFilterProps) {
|
||||||
|
const tokenContext = useContext(TokenContext);
|
||||||
|
const [search, setSearch] = useState<string>('');
|
||||||
|
const [selectedStatuses, setSelectedStatuses] = useState<Selection>(new Set([]));
|
||||||
|
const [selectedTags, setSelectedTags] = useState<Selection>(new Set([]));
|
||||||
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchDataEffect = async () => {
|
||||||
|
try {
|
||||||
|
const tagsResponse = (await fetchTags(tokenContext.token.access_token, projectId)) || [];
|
||||||
|
setTags(tagsResponse);
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error fetching case tags', error);
|
||||||
|
addToast({ title: 'Error', description: 'Error fetching tags', color: 'danger' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchDataEffect();
|
||||||
|
}, [projectId, tokenContext.token.access_token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSearch(activeSearchFilter || '');
|
||||||
|
}, [activeSearchFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeStatusFilters && activeStatusFilters.length > 0) {
|
||||||
|
const activeKeys = activeStatusFilters.map((index) => testRunCaseStatus[index]?.uid).filter((uid) => !!uid);
|
||||||
|
setSelectedStatuses(new Set(activeKeys as Iterable<string>));
|
||||||
|
} else {
|
||||||
|
setSelectedStatuses(new Set([]));
|
||||||
|
}
|
||||||
|
}, [activeStatusFilters]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTagFilters && activeTagFilters.length > 0) {
|
||||||
|
const activeKeys = activeTagFilters.map((id) => id.toString());
|
||||||
|
setSelectedTags(new Set(activeKeys));
|
||||||
|
} else {
|
||||||
|
setSelectedTags(new Set([]));
|
||||||
|
}
|
||||||
|
}, [activeTagFilters]);
|
||||||
|
|
||||||
|
const handleStatusSelectionChange = (keys: Selection) => {
|
||||||
|
setSelectedStatuses(keys);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApplyFilter = () => {
|
||||||
|
let statusIndices: number[] = [];
|
||||||
|
if (selectedStatuses !== 'all' && selectedStatuses.size > 0) {
|
||||||
|
statusIndices = Array.from(selectedStatuses)
|
||||||
|
.map((key) => testRunCaseStatus.findIndex((status) => status.uid === key))
|
||||||
|
.filter((index) => index !== -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let tagIds: number[] = [];
|
||||||
|
if (selectedTags !== 'all' && selectedTags.size > 0) {
|
||||||
|
tagIds = Array.from(selectedTags)
|
||||||
|
.map((key) => parseInt(key as string))
|
||||||
|
.filter((id) => !isNaN(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
onFilterChange(search, statusIndices, tagIds);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearFilter = () => {
|
||||||
|
setSearch('');
|
||||||
|
setSelectedStatuses(new Set([]));
|
||||||
|
setSelectedTags(new Set([]));
|
||||||
|
onFilterChange('', [], []);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="mb-3 space-y-1">
|
||||||
|
<h3 className="text-default-500 text-small">{messages.caseTitleOrDescription}</h3>
|
||||||
|
<Input
|
||||||
|
variant="bordered"
|
||||||
|
classNames={{
|
||||||
|
base: 'max-w-full h-8',
|
||||||
|
mainWrapper: 'h-full',
|
||||||
|
input: 'text-small',
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
startContent={<SearchIcon size={18} />}
|
||||||
|
type="search"
|
||||||
|
value={search}
|
||||||
|
onValueChange={setSearch}
|
||||||
|
maxLength={100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3 flex justify-between gap-2">
|
||||||
|
<div className="flex-col space-y-1">
|
||||||
|
<h3 className="text-default-500 text-small">{messages.status}</h3>
|
||||||
|
<Dropdown>
|
||||||
|
<DropdownTrigger>
|
||||||
|
<Button size="sm" variant="bordered" className="w-32" endContent={<ChevronDown size={16} />}>
|
||||||
|
{selectedStatuses === 'all' || selectedStatuses.size === 0
|
||||||
|
? messages.selectStatus
|
||||||
|
: `${selectedStatuses.size} ${messages.selected}`}
|
||||||
|
</Button>
|
||||||
|
</DropdownTrigger>
|
||||||
|
<DropdownMenu
|
||||||
|
aria-label="Status filter"
|
||||||
|
selectionMode="multiple"
|
||||||
|
selectedKeys={selectedStatuses}
|
||||||
|
onSelectionChange={handleStatusSelectionChange}
|
||||||
|
>
|
||||||
|
{testRunCaseStatus.map((status) => (
|
||||||
|
<DropdownItem
|
||||||
|
key={status.uid}
|
||||||
|
textValue={testRunCaseStatusMessages[status.uid]}
|
||||||
|
startContent={<RunCaseStatus uid={status.uid} />}
|
||||||
|
className="flex items-center"
|
||||||
|
>
|
||||||
|
{testRunCaseStatusMessages[status.uid]}
|
||||||
|
</DropdownItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenu>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-col space-y-1">
|
||||||
|
<h3 className="text-default-500 text-small">{messages.tags}</h3>
|
||||||
|
<Dropdown>
|
||||||
|
<DropdownTrigger>
|
||||||
|
<Button size="sm" variant="bordered" className="w-32" endContent={<ChevronDown size={16} />}>
|
||||||
|
{selectedTags === 'all' || selectedTags.size === 0
|
||||||
|
? messages.selectTags
|
||||||
|
: `${selectedTags.size} ${messages.selected}`}
|
||||||
|
</Button>
|
||||||
|
</DropdownTrigger>
|
||||||
|
<DropdownMenu
|
||||||
|
className="max-h-[50vh] overflow-y-auto"
|
||||||
|
aria-label="Tag filter"
|
||||||
|
selectionMode="multiple"
|
||||||
|
selectedKeys={selectedTags}
|
||||||
|
onSelectionChange={setSelectedTags}
|
||||||
|
>
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<DropdownItem key={tag.id.toString()} textValue={tag.name}>
|
||||||
|
<span className="text-sm">{tag.name}</span>
|
||||||
|
</DropdownItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenu>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button className="me-2" size="sm" variant="light" onPress={handleClearFilter}>
|
||||||
|
{messages.clearAll}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="solid" color="primary" onPress={handleApplyFilter}>
|
||||||
|
{messages.apply}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,6 +35,15 @@ export default function Page({ params }: { params: { projectId: string; runId: s
|
|||||||
expectedResult: t('expected_result'),
|
expectedResult: t('expected_result'),
|
||||||
detailsOfTheStep: t('details_of_the_step'),
|
detailsOfTheStep: t('details_of_the_step'),
|
||||||
close: t('close'),
|
close: t('close'),
|
||||||
|
filter: t('filter'),
|
||||||
|
clearAll: t('clear_all'),
|
||||||
|
apply: t('apply'),
|
||||||
|
selectStatus: t('select_status'),
|
||||||
|
pleaseSave: t('please_save'),
|
||||||
|
caseTitleOrDescription: t('case_title_or_description'),
|
||||||
|
selected: t('selected'),
|
||||||
|
tags: t('tags'),
|
||||||
|
selectTags: t('select_tags'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const rst = useTranslations('RunStatus');
|
const rst = useTranslations('RunStatus');
|
||||||
|
|||||||
@@ -315,8 +315,31 @@ async function updateRunCases(jwt: string, runId: number, testCases: CaseType[])
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchProjectCases(jwt: string, projectId: number, runId: number) {
|
async function fetchProjectCases(
|
||||||
const url = `${apiServer}/cases/byproject?projectId=${projectId}&runId=${runId}`;
|
jwt: string,
|
||||||
|
projectId: number,
|
||||||
|
runId: number,
|
||||||
|
search?: string,
|
||||||
|
status?: string[],
|
||||||
|
tag?: string[]
|
||||||
|
) {
|
||||||
|
const queryParams = [`projectId=${projectId}&runId=${runId}`];
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
queryParams.push(`search=${search}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status && status.length > 0) {
|
||||||
|
queryParams.push(`status=${status.join(',')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tag && tag.length > 0) {
|
||||||
|
queryParams.push(`tag=${tag.join(',')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `?${queryParams.join('&')}`;
|
||||||
|
|
||||||
|
const url = `${apiServer}/cases/byproject${query}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ type CasesMessages = {
|
|||||||
orDragAndDrop: string;
|
orDragAndDrop: string;
|
||||||
maxFileSize: string;
|
maxFileSize: string;
|
||||||
casesImported: string;
|
casesImported: string;
|
||||||
|
createMore: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CaseMessages = {
|
type CaseMessages = {
|
||||||
|
|||||||
@@ -80,6 +80,15 @@ type RunMessages = {
|
|||||||
expectedResult: string;
|
expectedResult: string;
|
||||||
detailsOfTheStep: string;
|
detailsOfTheStep: string;
|
||||||
close: string;
|
close: string;
|
||||||
|
filter: string;
|
||||||
|
clearAll: string;
|
||||||
|
apply: string;
|
||||||
|
selectStatus: string;
|
||||||
|
pleaseSave: string;
|
||||||
|
caseTitleOrDescription: string;
|
||||||
|
selected: string;
|
||||||
|
tags: string;
|
||||||
|
selectTags: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type { RunType, RunCaseType, RunStatusCountType, ProgressSeriesType, RunsMessages, RunMessages };
|
export type { RunType, RunCaseType, RunStatusCountType, ProgressSeriesType, RunsMessages, RunMessages };
|
||||||
|
|||||||
@@ -84,6 +84,10 @@ export type AdminMessages = {
|
|||||||
roleChanged: string;
|
roleChanged: string;
|
||||||
lostAdminAuth: string;
|
lostAdminAuth: string;
|
||||||
atLeast: string;
|
atLeast: string;
|
||||||
|
resetPassword: string;
|
||||||
|
reset: string;
|
||||||
|
invalidPassword: string;
|
||||||
|
passwordNotMatch: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AccountDropDownMessages = {
|
export type AccountDropDownMessages = {
|
||||||
|
|||||||
@@ -189,4 +189,43 @@ async function deleteAvatar(jwt: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { findUser, searchUsers, updateUserRole, updateUsername, updatePassword, uploadAvatar, deleteAvatar };
|
async function adminResetPassword(jwt: string, userId: number, newPassword: string) {
|
||||||
|
const updateData = {
|
||||||
|
newPassword,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchOptions = {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${jwt}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(updateData),
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = `${apiServer}/users/${userId}/password`;
|
||||||
|
|
||||||
|
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 admin resetting password:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
findUser,
|
||||||
|
searchUsers,
|
||||||
|
updateUserRole,
|
||||||
|
updateUsername,
|
||||||
|
updatePassword,
|
||||||
|
uploadAvatar,
|
||||||
|
deleteAvatar,
|
||||||
|
adminResetPassword,
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user