Merge pull request #362 from kimatata/develop

This commit is contained in:
kimatata
2025-12-07 20:44:44 +09:00
committed by GitHub
29 changed files with 851 additions and 130 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 });
} }
// 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({ casesToCreate.push({
folderId: folderId, folderId: folderId,
title: row['title'], title: currentTitle,
description: row['description'] || '', description: row['description'] || '',
state: 0, // default state state: 0, // default state
priority: priorityIndex, priority: row['priority'] ? priorities.indexOf(row['priority']) : priorities.indexOf('medium'),
type: typeIndex, type: row['type'] ? testTypes.indexOf(row['type']) : testTypes.indexOf('other'),
preConditions: row['preConditions'], preConditions: row['preConditions'],
expectedResults: row['expectedResults'], expectedResults: row['expectedResults'],
automationStatus: automationStatusIndex, automationStatus: row['automationStatus']
template: templateIndex, ? 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;
}

View File

@@ -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);

View 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;
}

View File

@@ -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));

View File

@@ -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",

View File

@@ -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": "メンバー管理",

View File

@@ -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",

View File

@@ -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}
/>
</> </>
); );
} }

View 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>
);
}

View File

@@ -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;
} }

View File

@@ -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 (

View File

@@ -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>

View File

@@ -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);
if (!createMore) {
clear(); 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>

View File

@@ -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>

View File

@@ -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]);
if (!createMore) {
closeDialog(); closeDialog();
}
}; };
const closeDeleteConfirmDialog = () => { const closeDeleteConfirmDialog = () => {

View File

@@ -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');

View File

@@ -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" />;
}
}

View File

@@ -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

View File

@@ -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]}

View File

@@ -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>
);
}

View File

@@ -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');

View File

@@ -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, {

View File

@@ -105,6 +105,7 @@ type CasesMessages = {
orDragAndDrop: string; orDragAndDrop: string;
maxFileSize: string; maxFileSize: string;
casesImported: string; casesImported: string;
createMore: string;
}; };
type CaseMessages = { type CaseMessages = {

View File

@@ -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 };

View File

@@ -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 = {

View File

@@ -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,
};