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 defineCase from '../../models/cases.js';
|
||||
import defineStep from '../../models/steps.js';
|
||||
import defineFolder from '../../models/folders.js';
|
||||
import authMiddleware from '../../middleware/auth.js';
|
||||
import visibilityMiddleware from '../../middleware/verifyVisible.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) {
|
||||
const Case = defineCase(sequelize, DataTypes);
|
||||
const Step = defineStep(sequelize, DataTypes);
|
||||
const Folder = defineFolder(sequelize, DataTypes);
|
||||
Case.belongsTo(Folder);
|
||||
Case.belongsToMany(Step, { through: 'caseSteps' });
|
||||
Step.belongsToMany(Case, { through: 'caseSteps' });
|
||||
const { verifySignedIn } = authMiddleware(sequelize);
|
||||
@@ -37,6 +40,10 @@ export default function (sequelize) {
|
||||
order: [['stepNo', 'ASC']],
|
||||
attributes: { exclude: ['createdAt', 'updatedAt'] },
|
||||
},
|
||||
{
|
||||
model: Folder,
|
||||
attributes: ['name'],
|
||||
},
|
||||
],
|
||||
where: { folderId },
|
||||
raw: true,
|
||||
@@ -90,6 +97,7 @@ const _formatRawCasesToJson = (cases) => {
|
||||
casesObject[c.id] = {
|
||||
id: c.id,
|
||||
folderId: c.folderId,
|
||||
folder: c['Folder.name'],
|
||||
title: c.title,
|
||||
state: c.state,
|
||||
priority: c.priority,
|
||||
@@ -119,6 +127,7 @@ const _formatRawCasesToCsv = (cases) => {
|
||||
return cases.map((c) => ({
|
||||
id: c.id,
|
||||
folderId: c.folderId,
|
||||
folder: c['Folder.name'],
|
||||
title: c.title,
|
||||
state: c.state,
|
||||
priority: c.priority,
|
||||
|
||||
@@ -38,6 +38,7 @@ vi.mock('../../middleware/verifyVisible.js', () => ({
|
||||
const mockCase = {
|
||||
findAll: vi.fn(),
|
||||
belongsToMany: vi.fn(),
|
||||
belongsTo: vi.fn(),
|
||||
};
|
||||
vi.mock('../../models/cases.js', () => ({
|
||||
default: () => mockCase,
|
||||
|
||||
@@ -5,6 +5,8 @@ import multer from 'multer';
|
||||
import XLSX from 'xlsx';
|
||||
import { DataTypes } from 'sequelize';
|
||||
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 editableMiddleware from '../../middleware/verifyEditable.js';
|
||||
import { priorities, testTypes, automationStatus, templates } from '../../config/enums.js';
|
||||
@@ -34,6 +36,10 @@ const upload = multer({
|
||||
|
||||
export default function (sequelize) {
|
||||
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 { verifyProjectDeveloperFromFolderId } = editableMiddleware(sequelize);
|
||||
|
||||
@@ -60,86 +66,135 @@ export default function (sequelize) {
|
||||
return res.status(400).json({ error: 'folderId is required' });
|
||||
}
|
||||
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const workbook = XLSX.read(req.file.buffer, { type: 'buffer' });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
const jsonData = XLSX.utils.sheet_to_json(worksheet);
|
||||
|
||||
let errorMessage = null;
|
||||
let currentTitle = null;
|
||||
let previousTitle = null;
|
||||
let stepNo = 1;
|
||||
const casesToCreate = [];
|
||||
const requiredFields = ['title', 'priority', 'type', 'template'];
|
||||
const stepsToCreate = [];
|
||||
for (const [index, row] of jsonData.entries()) {
|
||||
const rowNumber = index + 2;
|
||||
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']}`;
|
||||
}
|
||||
}
|
||||
|
||||
const errorMessage = _getRowValidationError(row, index);
|
||||
if (errorMessage) {
|
||||
return res.status(400).json({ error: errorMessage });
|
||||
}
|
||||
|
||||
casesToCreate.push({
|
||||
folderId: folderId,
|
||||
title: row['title'],
|
||||
description: row['description'] || '',
|
||||
state: 0, // default state
|
||||
priority: priorityIndex,
|
||||
type: typeIndex,
|
||||
preConditions: row['preConditions'],
|
||||
expectedResults: row['expectedResults'],
|
||||
automationStatus: automationStatusIndex,
|
||||
template: templateIndex,
|
||||
});
|
||||
// Add step to the same case if the current row title is equal to previous row title
|
||||
// This handle cases with multiple steps :)
|
||||
currentTitle = row['title'].trim();
|
||||
previousTitle = casesToCreate[casesToCreate.length - 1]?.title.trim();
|
||||
if (casesToCreate.length > 0 && previousTitle === currentTitle) {
|
||||
stepNo += 1;
|
||||
stepsToCreate.push({
|
||||
caseIndex: casesToCreate.length - 1,
|
||||
stepNo: stepNo,
|
||||
step: row['step'] || '',
|
||||
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);
|
||||
} catch (error) {
|
||||
await t.rollback();
|
||||
console.error(error);
|
||||
res.status(500).send('Internal Server Error');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
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';
|
||||
const router = express.Router();
|
||||
import { DataTypes } from 'sequelize';
|
||||
import { DataTypes, Op } from 'sequelize';
|
||||
import defineProject from '../../models/projects.js';
|
||||
import defineFolder from '../../models/folders.js';
|
||||
import defineCase from '../../models/cases.js';
|
||||
@@ -33,7 +33,7 @@ export default function (sequelize) {
|
||||
verifyProjectVisibleFromProjectId,
|
||||
verifyProjectVisibleFromRunId,
|
||||
async (req, res) => {
|
||||
const { projectId, runId } = req.query;
|
||||
const { projectId, runId, status, tag, search } = req.query;
|
||||
|
||||
if (!projectId) {
|
||||
return res.status(400).json({ error: 'projectId is required' });
|
||||
@@ -44,7 +44,61 @@ export default function (sequelize) {
|
||||
}
|
||||
|
||||
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({
|
||||
where: caseWhereClause,
|
||||
include: [
|
||||
{
|
||||
model: Folder,
|
||||
@@ -56,16 +110,13 @@ export default function (sequelize) {
|
||||
{
|
||||
model: RunCase,
|
||||
attributes: ['id', 'runId', 'status'],
|
||||
required: false,
|
||||
// Must be 'true' when filtering by status, otherwise all cases are returned.
|
||||
required: runCaseRequired,
|
||||
where: {
|
||||
runId: runId,
|
||||
[Op.and]: [{ runId: runId }, statusFilter],
|
||||
},
|
||||
},
|
||||
{
|
||||
model: Tags,
|
||||
attributes: ['id', 'name'],
|
||||
through: { attributes: [] },
|
||||
},
|
||||
tagInclude,
|
||||
],
|
||||
});
|
||||
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 usersUpdateUsernameRoute from './routes/users/updateUsername.js';
|
||||
import usersUpdatePasswordRoute from './routes/users/updatePassword.js';
|
||||
import usersAdminResetPasswordRoute from './routes/users/adminResetPassword.js';
|
||||
import usersUpdateAvatarRoute from './routes/users/updateAvatar.js';
|
||||
import usersUpdateRoleRoute from './routes/users/updateRole.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', usersUpdateUsernameRoute(sequelize));
|
||||
app.use('/users', usersUpdatePasswordRoute(sequelize));
|
||||
app.use('/users', usersAdminResetPasswordRoute(sequelize));
|
||||
app.use('/users', usersUpdateAvatarRoute(sequelize));
|
||||
app.use('/users', usersUpdateRoleRoute(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.",
|
||||
"role_changed": "Role changed",
|
||||
"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": {
|
||||
"project_list": "Project List",
|
||||
@@ -217,7 +221,7 @@
|
||||
"delete": "Delete",
|
||||
"close": "Close",
|
||||
"are_you_sure": "Are you sure you want to delete test cases?",
|
||||
"new_test_case": "New Test Case",
|
||||
"new_test_case": "New",
|
||||
"export": "Export",
|
||||
"status": "Status",
|
||||
"no_cases_found": "No test 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": "Test cases imported"
|
||||
"cases_imported": "Test cases imported",
|
||||
"create_more": "Create more"
|
||||
},
|
||||
"Case": {
|
||||
"back_to_cases": "Back to test cases",
|
||||
@@ -339,7 +344,16 @@
|
||||
"preconditions": "Preconditions",
|
||||
"expected_result": "Expected result",
|
||||
"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": {
|
||||
"member_management": "Member Management",
|
||||
|
||||
@@ -159,7 +159,11 @@
|
||||
"quit_confirm": "管理者権限を放棄すると管理ページにアクセスできなくなります。",
|
||||
"role_changed": "ロールを更新しました",
|
||||
"lost_admin_auth": "管理者権限を失いました",
|
||||
"at_least": "最低1人以上の管理者が必要です。"
|
||||
"at_least": "最低1人以上の管理者が必要です。",
|
||||
"reset_password": "パスワードをリセット",
|
||||
"reset": "リセット",
|
||||
"invalid_password": "パスワードは8文字以上である必要があります",
|
||||
"password_not_match": "パスワードが一致しません"
|
||||
},
|
||||
"Projects": {
|
||||
"project_list": "プロジェクト一覧",
|
||||
@@ -217,7 +221,7 @@
|
||||
"delete": "削除",
|
||||
"close": "閉じる",
|
||||
"are_you_sure": "テストケースを削除してもよろしいですか?",
|
||||
"new_test_case": "新規テストケース",
|
||||
"new_test_case": "新規",
|
||||
"export": "エクスポート",
|
||||
"status": "ステータス",
|
||||
"no_cases_found": "テストケースがありません",
|
||||
@@ -248,7 +252,8 @@
|
||||
"click_to_upload": "クリックしてアップロード",
|
||||
"or_drag_and_drop": "またはドラッグアンドドロップ",
|
||||
"max_file_size": "最大ファイルサイズ",
|
||||
"cases_imported": "テストケースがインポートされました"
|
||||
"cases_imported": "テストケースがインポートされました",
|
||||
"create_more": "さらに作成"
|
||||
},
|
||||
"Case": {
|
||||
"back_to_cases": "テストケース一覧に戻る",
|
||||
@@ -339,7 +344,16 @@
|
||||
"preconditions": "前提条件",
|
||||
"expected_result": "期待結果",
|
||||
"details_of_the_step": "ステップ詳細",
|
||||
"close": "閉じる"
|
||||
"close": "閉じる",
|
||||
"filter": "フィルター",
|
||||
"clear_all": "すべてクリア",
|
||||
"apply": "適用",
|
||||
"select_status": "ステータスを選択",
|
||||
"please_save": "変更を保存してください",
|
||||
"case_title_or_description": "テストケースのタイトルまたは説明",
|
||||
"selected": "選択済み",
|
||||
"tags": "タグ",
|
||||
"select_tags": "タグを選択"
|
||||
},
|
||||
"Members": {
|
||||
"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.",
|
||||
"role_changed": "Função alterada",
|
||||
"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": {
|
||||
"project_list": "Lista de Projetos",
|
||||
@@ -217,7 +221,7 @@
|
||||
"delete": "Excluir",
|
||||
"close": "Fechar",
|
||||
"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",
|
||||
"status": "Status",
|
||||
"no_cases_found": "Nenhum caso de teste encontrado",
|
||||
@@ -248,7 +252,8 @@
|
||||
"click_to_upload": "Clique para enviar",
|
||||
"or_drag_and_drop": " ou arraste e solte",
|
||||
"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": {
|
||||
"back_to_cases": "Voltar para os casos de teste",
|
||||
@@ -339,7 +344,16 @@
|
||||
"preconditions": "Pré-condições",
|
||||
"expected_result": "Resultado esperado",
|
||||
"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": {
|
||||
"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 { Button, addToast } from '@heroui/react';
|
||||
import UsersTable from './UsersTable';
|
||||
import PasswordResetDialog from './PasswordResetDialog';
|
||||
import DeleteConfirmDialog from '@/components/DeleteConfirmDialog';
|
||||
import { UserType, AdminMessages } from '@/types/user';
|
||||
import { TokenContext } from '@/utils/TokenProvider';
|
||||
import { useRouter } from '@/src/i18n/routing';
|
||||
import Config from '@/config/config';
|
||||
import { LocaleCodeType } from '@/types/locale';
|
||||
import { updateUserRole } from '@/utils/usersControl';
|
||||
import { updateUserRole, adminResetPassword } from '@/utils/usersControl';
|
||||
import { roles } from '@/config/selection';
|
||||
import DeleteConfirmDialog from '@/components/DeleteConfirmDialog';
|
||||
import { logError } from '@/utils/errorHandler';
|
||||
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 (
|
||||
<>
|
||||
<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>
|
||||
</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)}>
|
||||
{messages.quitAdmin}
|
||||
</Button>
|
||||
@@ -144,6 +173,13 @@ export default function AdminPage({ messages, locale }: Props) {
|
||||
confirmText={messages.quitConfirm}
|
||||
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,
|
||||
DropdownItem,
|
||||
} from '@heroui/react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { ChevronDown, MoreVertical } from 'lucide-react';
|
||||
import { UserType, AdminMessages } from '@/types/user';
|
||||
import { roles } from '@/config/selection';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
@@ -22,16 +22,18 @@ type Props = {
|
||||
users: UserType[];
|
||||
myself: UserType | null;
|
||||
onChangeRole: (userEdit: UserType, role: number) => void;
|
||||
openResetDialog: (user: UserType) => void;
|
||||
messages: AdminMessages;
|
||||
};
|
||||
|
||||
export default function UsersTable({ users, myself, onChangeRole, messages }: Props) {
|
||||
export default function UsersTable({ users, myself, onChangeRole, openResetDialog, messages }: Props) {
|
||||
const headerColumns = [
|
||||
{ name: messages.avatar, uid: 'avatar', sortable: false },
|
||||
{ name: messages.id, uid: 'id', sortable: true },
|
||||
{ name: messages.email, uid: 'email', sortable: true },
|
||||
{ name: messages.username, uid: 'username', sortable: true },
|
||||
{ name: messages.role, uid: 'role', sortable: true },
|
||||
{ name: '', uid: 'actions', sortable: false },
|
||||
];
|
||||
|
||||
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({
|
||||
@@ -92,6 +94,24 @@ export default function UsersTable({ users, myself, onChangeRole, messages }: Pr
|
||||
</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:
|
||||
return cellValue;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,10 @@ export default function Page({ params }: PageType) {
|
||||
roleChanged: t('role_changed'),
|
||||
lostAdminAuth: t('lost_admin_auth'),
|
||||
atLeast: t('at_least'),
|
||||
resetPassword: t('reset_password'),
|
||||
reset: t('reset'),
|
||||
invalidPassword: t('invalid_password'),
|
||||
passwordNotMatch: t('password_not_match'),
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function HealthPage({ messages, locale }: Props) {
|
||||
<TableBody>
|
||||
<TableRow key="1">
|
||||
<TableCell>{messages.unittcms_version}</TableCell>
|
||||
<TableCell>1.0.0-beta.24</TableCell>
|
||||
<TableCell>1.0.0-beta.25</TableCell>
|
||||
</TableRow>
|
||||
<TableRow key="2">
|
||||
<TableCell>{messages.api_server}</TableCell>
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
'use client';
|
||||
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';
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: (title: string, description: string) => void;
|
||||
onSubmit: (title: string, description: string, createMore: boolean) => void;
|
||||
messages: CasesMessages;
|
||||
};
|
||||
|
||||
@@ -23,6 +33,8 @@ export default function CaseDialog({ isOpen, onCancel, onSubmit, messages }: Pro
|
||||
errorMessage: '',
|
||||
});
|
||||
|
||||
const [createMore, setCreateMore] = useState(false);
|
||||
|
||||
const clear = () => {
|
||||
setCaseName({
|
||||
isValid: false,
|
||||
@@ -47,8 +59,23 @@ export default function CaseDialog({ isOpen, onCancel, onSubmit, messages }: Pro
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit(caseTitle.text, caseDescription.text);
|
||||
clear();
|
||||
onSubmit(caseTitle.text, caseDescription.text, createMore);
|
||||
|
||||
if (!createMore) {
|
||||
clear();
|
||||
} else {
|
||||
// Reset form fields but keep dialog open
|
||||
setCaseName({
|
||||
isValid: false,
|
||||
text: 'Untitled Case',
|
||||
errorMessage: '',
|
||||
});
|
||||
setCaseDescription({
|
||||
isValid: false,
|
||||
text: '',
|
||||
errorMessage: '',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -88,9 +115,9 @@ export default function CaseDialog({ isOpen, onCancel, onSubmit, messages }: Pro
|
||||
/>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onCancel}>
|
||||
{messages.close}
|
||||
</Button>
|
||||
<Switch size="sm" isSelected={createMore} onValueChange={setCreateMore}>
|
||||
{messages.createMore}
|
||||
</Switch>
|
||||
<Button color="primary" onPress={validate}>
|
||||
{messages.create}
|
||||
</Button>
|
||||
|
||||
@@ -70,7 +70,7 @@ export default function CaseImportDialog({ isOpen, folderId, isDisabled, onImpor
|
||||
<div className="flex items-center justify-center w-full">
|
||||
<div className={`mt-1 text-neutral-500 dark:text-neutral-400 text-sm rounded`}>
|
||||
<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}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -112,10 +112,12 @@ export default function CasesPane({
|
||||
|
||||
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);
|
||||
setCases([...cases, newCase]);
|
||||
closeDialog();
|
||||
if (!createMore) {
|
||||
closeDialog();
|
||||
}
|
||||
};
|
||||
|
||||
const closeDeleteConfirmDialog = () => {
|
||||
|
||||
@@ -57,6 +57,7 @@ export default function Page({ params }: { params: { projectId: string; folderId
|
||||
orDragAndDrop: t('or_drag_and_drop'),
|
||||
maxFileSize: t('max_file_size'),
|
||||
casesImported: t('cases_imported'),
|
||||
createMore: t('create_more'),
|
||||
};
|
||||
|
||||
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,
|
||||
addToast,
|
||||
Badge,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@heroui/react';
|
||||
import {
|
||||
Save,
|
||||
@@ -29,6 +32,7 @@ import {
|
||||
FileJson,
|
||||
ChevronRight,
|
||||
Folder,
|
||||
Filter,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { NodeApi, Tree } from 'react-arborist';
|
||||
@@ -44,6 +48,7 @@ import {
|
||||
import { fetchFolders } from '../../folders/foldersControl';
|
||||
import RunProgressChart from './RunPregressDonutChart';
|
||||
import TestCaseSelector from './TestCaseSelector';
|
||||
import TestRunFilter from './TestRunFilter';
|
||||
import { useRouter } from '@/src/i18n/routing';
|
||||
import { testRunStatus } from '@/config/selection';
|
||||
import { RunType, RunStatusCountType, RunMessages } from '@/types/run';
|
||||
@@ -102,6 +107,9 @@ export default function RunEditor({
|
||||
const [isNameInvalid] = useState<boolean>(false);
|
||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [searchFilter, setSearchFilter] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<number[]>([]);
|
||||
const [tagFilter, setTagFilter] = useState<number[]>([]);
|
||||
const router = useRouter();
|
||||
useFormGuard(isDirty, messages.areYouSureLeave);
|
||||
|
||||
@@ -111,8 +119,15 @@ export default function RunEditor({
|
||||
setRunStatusCounts(statusCounts);
|
||||
};
|
||||
|
||||
const initTestCases = async () => {
|
||||
const casesData = await fetchProjectCases(tokenContext.token.access_token, Number(projectId), Number(runId));
|
||||
const initTestCases = async (search?: string, status?: string[], tag?: string[]) => {
|
||||
const casesData = await fetchProjectCases(
|
||||
tokenContext.token.access_token,
|
||||
Number(projectId),
|
||||
Number(runId),
|
||||
search,
|
||||
status,
|
||||
tag
|
||||
);
|
||||
casesData.forEach((testCase: CaseType) => {
|
||||
if (testCase.RunCases && testCase.RunCases.length > 0) {
|
||||
testCase.RunCases[0].editState = 'notChanged';
|
||||
@@ -200,6 +215,29 @@ export default function RunEditor({
|
||||
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 (
|
||||
<>
|
||||
<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>
|
||||
</div>
|
||||
<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">
|
||||
<DropdownTrigger>
|
||||
<Button
|
||||
|
||||
@@ -14,19 +14,9 @@ import {
|
||||
Selection,
|
||||
SortDescriptor,
|
||||
} from '@heroui/react';
|
||||
import {
|
||||
ChevronDown,
|
||||
MoveDiagonal,
|
||||
MoreVertical,
|
||||
CopyPlus,
|
||||
CopyMinus,
|
||||
Circle,
|
||||
CircleCheck,
|
||||
CircleDashed,
|
||||
CircleX,
|
||||
CircleSlash2,
|
||||
} from 'lucide-react';
|
||||
import { ChevronDown, MoveDiagonal, MoreVertical, CopyPlus, CopyMinus } from 'lucide-react';
|
||||
import TestCaseDetailDialog from './TestCaseDetailDialog';
|
||||
import RunCaseStatus from './RunCaseStatus';
|
||||
import { testRunCaseStatus } from '@/config/selection';
|
||||
import { CaseType } from '@/types/case';
|
||||
import { RunMessages } from '@/types/run';
|
||||
@@ -103,20 +93,6 @@ export default function TestCaseSelector({
|
||||
|
||||
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) => {
|
||||
let isIncluded = false;
|
||||
if (testCase.RunCases && testCase.RunCases.length > 0) {
|
||||
@@ -161,7 +137,7 @@ export default function TestCaseSelector({
|
||||
size="sm"
|
||||
variant="light"
|
||||
isDisabled={!isIncluded}
|
||||
startContent={isIncluded && renderStatusIcon(testRunCaseStatus[runStatus].uid)}
|
||||
startContent={isIncluded && <RunCaseStatus uid={testRunCaseStatus[runStatus].uid} />}
|
||||
endContent={isIncluded && <ChevronDown size={16} />}
|
||||
>
|
||||
<span className="w-12">
|
||||
@@ -173,7 +149,7 @@ export default function TestCaseSelector({
|
||||
{testRunCaseStatus.map((runCaseStatus, index) => (
|
||||
<DropdownItem
|
||||
key={runCaseStatus.uid}
|
||||
startContent={renderStatusIcon(runCaseStatus.uid)}
|
||||
startContent={<RunCaseStatus uid={runCaseStatus.uid} />}
|
||||
onPress={() => onChangeStatus(testCase.id, index)}
|
||||
>
|
||||
{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'),
|
||||
detailsOfTheStep: t('details_of_the_step'),
|
||||
close: t('close'),
|
||||
filter: t('filter'),
|
||||
clearAll: t('clear_all'),
|
||||
apply: t('apply'),
|
||||
selectStatus: t('select_status'),
|
||||
pleaseSave: t('please_save'),
|
||||
caseTitleOrDescription: t('case_title_or_description'),
|
||||
selected: t('selected'),
|
||||
tags: t('tags'),
|
||||
selectTags: t('select_tags'),
|
||||
};
|
||||
|
||||
const rst = useTranslations('RunStatus');
|
||||
|
||||
@@ -315,8 +315,31 @@ async function updateRunCases(jwt: string, runId: number, testCases: CaseType[])
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProjectCases(jwt: string, projectId: number, runId: number) {
|
||||
const url = `${apiServer}/cases/byproject?projectId=${projectId}&runId=${runId}`;
|
||||
async function fetchProjectCases(
|
||||
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 {
|
||||
const response = await fetch(url, {
|
||||
|
||||
@@ -105,6 +105,7 @@ type CasesMessages = {
|
||||
orDragAndDrop: string;
|
||||
maxFileSize: string;
|
||||
casesImported: string;
|
||||
createMore: string;
|
||||
};
|
||||
|
||||
type CaseMessages = {
|
||||
|
||||
@@ -80,6 +80,15 @@ type RunMessages = {
|
||||
expectedResult: string;
|
||||
detailsOfTheStep: 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 };
|
||||
|
||||
@@ -84,6 +84,10 @@ export type AdminMessages = {
|
||||
roleChanged: string;
|
||||
lostAdminAuth: string;
|
||||
atLeast: string;
|
||||
resetPassword: string;
|
||||
reset: string;
|
||||
invalidPassword: string;
|
||||
passwordNotMatch: string;
|
||||
};
|
||||
|
||||
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