diff --git a/backend/routes/cases/download.js b/backend/routes/cases/download.js index ab2071d..3714968 100644 --- a/backend/routes/cases/download.js +++ b/backend/routes/cases/download.js @@ -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, diff --git a/backend/routes/cases/download.test.js b/backend/routes/cases/download.test.js index 06e60c2..e8a1b56 100644 --- a/backend/routes/cases/download.test.js +++ b/backend/routes/cases/download.test.js @@ -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, diff --git a/backend/routes/cases/import.js b/backend/routes/cases/import.js index 9b07cfd..894b859 100644 --- a/backend/routes/cases/import.js +++ b/backend/routes/cases/import.js @@ -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; +} diff --git a/backend/routes/cases/indexByProjectId.js b/backend/routes/cases/indexByProjectId.js index c630441..216c027 100644 --- a/backend/routes/cases/indexByProjectId.js +++ b/backend/routes/cases/indexByProjectId.js @@ -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); diff --git a/backend/routes/users/adminResetPassword.js b/backend/routes/users/adminResetPassword.js new file mode 100644 index 0000000..e2d9370 --- /dev/null +++ b/backend/routes/users/adminResetPassword.js @@ -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; +} diff --git a/backend/server.js b/backend/server.js index db432b8..52bc492 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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)); diff --git a/frontend/messages/en.json b/frontend/messages/en.json index eee362c..83c5ee4 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -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", diff --git a/frontend/messages/ja.json b/frontend/messages/ja.json index 53229ec..9827ae1 100644 --- a/frontend/messages/ja.json +++ b/frontend/messages/ja.json @@ -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": "メンバー管理", diff --git a/frontend/messages/pt-BR.json b/frontend/messages/pt-BR.json index 3f3b767..f372406 100644 --- a/frontend/messages/pt-BR.json +++ b/frontend/messages/pt-BR.json @@ -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", diff --git a/frontend/public/template/unittcms-import-template-v1.1.xlsx b/frontend/public/template/unittcms-import-template-v1.1.xlsx new file mode 100644 index 0000000..ff6c70e Binary files /dev/null and b/frontend/public/template/unittcms-import-template-v1.1.xlsx differ diff --git a/frontend/src/app/[locale]/admin/AdminPage.tsx b/frontend/src/app/[locale]/admin/AdminPage.tsx index 37f8303..1946e84 100644 --- a/frontend/src/app/[locale]/admin/AdminPage.tsx +++ b/frontend/src/app/[locale]/admin/AdminPage.tsx @@ -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(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 ( <>
@@ -130,7 +153,13 @@ export default function AdminPage({ messages, locale }: Props) {

{messages.userManagement}

- + @@ -144,6 +173,13 @@ export default function AdminPage({ messages, locale }: Props) { confirmText={messages.quitConfirm} deleteText={messages.quit} /> + + setIsResetDialogOpen(false)} + onReset={onReset} + messages={messages} + /> ); } diff --git a/frontend/src/app/[locale]/admin/PasswordResetDialog.tsx b/frontend/src/app/[locale]/admin/PasswordResetDialog.tsx new file mode 100644 index 0000000..c317e2c --- /dev/null +++ b/frontend/src/app/[locale]/admin/PasswordResetDialog.tsx @@ -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 ( + { + onCancel(); + }} + > + + {messages.resetPassword} + +
+
+ {errorMessage &&
{errorMessage}
} + setNewPassword(e.target.value)} + autoComplete="new-password" + /> + setConfirmPassword(e.target.value)} + autoComplete="new-password" + className="mt-3" + /> +
+
+
+ + + + +
+
+ ); +} diff --git a/frontend/src/app/[locale]/admin/UsersTable.tsx b/frontend/src/app/[locale]/admin/UsersTable.tsx index ea90486..11f2f88 100644 --- a/frontend/src/app/[locale]/admin/UsersTable.tsx +++ b/frontend/src/app/[locale]/admin/UsersTable.tsx @@ -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({ @@ -92,6 +94,24 @@ export default function UsersTable({ users, myself, onChangeRole, messages }: Pr ); + case 'actions': + return ( + !isMyself(myself, user) && ( + + + + + + openResetDialog(user)}> + {messages.resetPassword} + + + + ) + ); + default: return cellValue; } diff --git a/frontend/src/app/[locale]/admin/page.tsx b/frontend/src/app/[locale]/admin/page.tsx index b47bf46..f71ac06 100644 --- a/frontend/src/app/[locale]/admin/page.tsx +++ b/frontend/src/app/[locale]/admin/page.tsx @@ -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 ( diff --git a/frontend/src/app/[locale]/health/HealthPage.tsx b/frontend/src/app/[locale]/health/HealthPage.tsx index cecebd5..2f8aa63 100644 --- a/frontend/src/app/[locale]/health/HealthPage.tsx +++ b/frontend/src/app/[locale]/health/HealthPage.tsx @@ -62,7 +62,7 @@ export default function HealthPage({ messages, locale }: Props) { {messages.unittcms_version} - 1.0.0-beta.24 + 1.0.0-beta.25 {messages.api_server} diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CaseDialog.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CaseDialog.tsx index e718d96..9d261fe 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CaseDialog.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CaseDialog.tsx @@ -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 /> - + + {messages.createMore} + diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CaseImportDialog.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CaseImportDialog.tsx index 3a1167e..d771130 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CaseImportDialog.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CaseImportDialog.tsx @@ -70,7 +70,7 @@ export default function CaseImportDialog({ isOpen, folderId, isDisabled, onImpor
{messages.importAvailable}
- + {messages.downloadTemplate}
diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CasesPane.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CasesPane.tsx index 5be48ed..e109829 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CasesPane.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CasesPane.tsx @@ -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 = () => { diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/page.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/page.tsx index 11bf0ac..1e275d7 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/page.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/page.tsx @@ -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'); diff --git a/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/RunCaseStatus.tsx b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/RunCaseStatus.tsx new file mode 100644 index 0000000..c49c003 --- /dev/null +++ b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/RunCaseStatus.tsx @@ -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 ; + } else if (uid === 'passed') { + return ; + } else if (uid === 'retest') { + return ; + } else if (uid === 'failed') { + return ; + } else if (uid === 'skipped') { + return ; + } +} diff --git a/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/RunEditor.tsx b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/RunEditor.tsx index 1a291a8..09da940 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/RunEditor.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/RunEditor.tsx @@ -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(false); const [isUpdating, setIsUpdating] = useState(false); const [isDirty, setIsDirty] = useState(false); + const [searchFilter, setSearchFilter] = useState(''); + const [statusFilter, setStatusFilter] = useState([]); + const [tagFilter, setTagFilter] = useState([]); 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 ( <>
@@ -217,6 +255,41 @@ export default function RunEditor({

{testRun.name}

+ setShowFilter(open)}> + + + + + + + { + setShowFilter(false); + onFilterChange(newTitleFilter, newStatusFilters, newTagFilters); + }} + /> + + + + + {testRunCaseStatus.map((status) => ( + } + className="flex items-center" + > + {testRunCaseStatusMessages[status.uid]} + + ))} + + +
+ +
+

{messages.tags}

+ + + + + + {tags.map((tag) => ( + + {tag.name} + + ))} + + +
+
+ +
+ + +
+ + ); +} diff --git a/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/page.tsx b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/page.tsx index 9bbeee9..f9626a9 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/page.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/runs/[runId]/page.tsx @@ -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'); diff --git a/frontend/src/app/[locale]/projects/[projectId]/runs/runsControl.ts b/frontend/src/app/[locale]/projects/[projectId]/runs/runsControl.ts index dd1c2ee..0a30414 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/runs/runsControl.ts +++ b/frontend/src/app/[locale]/projects/[projectId]/runs/runsControl.ts @@ -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, { diff --git a/frontend/types/case.ts b/frontend/types/case.ts index 664439f..49e1ec4 100644 --- a/frontend/types/case.ts +++ b/frontend/types/case.ts @@ -105,6 +105,7 @@ type CasesMessages = { orDragAndDrop: string; maxFileSize: string; casesImported: string; + createMore: string; }; type CaseMessages = { diff --git a/frontend/types/run.ts b/frontend/types/run.ts index c696fe3..2680c75 100644 --- a/frontend/types/run.ts +++ b/frontend/types/run.ts @@ -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 }; diff --git a/frontend/types/user.ts b/frontend/types/user.ts index 993b4b3..cc3e204 100644 --- a/frontend/types/user.ts +++ b/frontend/types/user.ts @@ -84,6 +84,10 @@ export type AdminMessages = { roleChanged: string; lostAdminAuth: string; atLeast: string; + resetPassword: string; + reset: string; + invalidPassword: string; + passwordNotMatch: string; }; export type AccountDropDownMessages = { diff --git a/frontend/utils/usersControl.ts b/frontend/utils/usersControl.ts index cc5836c..211e69f 100644 --- a/frontend/utils/usersControl.ts +++ b/frontend/utils/usersControl.ts @@ -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, +};