diff --git a/backend/testjam/routers/cases.py b/backend/testjam/routers/cases.py index ca3e992..f09b226 100644 --- a/backend/testjam/routers/cases.py +++ b/backend/testjam/routers/cases.py @@ -218,6 +218,130 @@ def unarchive_case(id: int, db: Session = Depends(get_db), current: User = Depen return case +class CaseCopy(BaseModel): + target_suite_id: int + name: str | None = None + + +class BulkCaseCopy(BaseModel): + case_ids: list[int] + target_suite_id: int | None = None + new_suite_name: str | None = None + parent_suite_id: int | None = None + + +def _unique_case_name(db: Session, suite_id: int, base_name: str) -> str: + existing = { + row[0] + for row in db.query(TestCase.name).filter(TestCase.suite_id == suite_id).all() + } + if base_name not in existing: + return base_name + for i in range(1, 100): + candidate = f"{base_name} (copy {i})" if i > 1 else f"{base_name} (copy)" + if candidate not in existing: + return candidate + return f"{base_name} (copy)" + + +def _clone_case(db: Session, source: TestCase, target_suite_id: int, name: str, current: User) -> TestCase: + max_order = db.query(TestCase).filter(TestCase.suite_id == target_suite_id).count() + copy = TestCase( + suite_id=target_suite_id, + name=name, + description=source.description, + preconditions=source.preconditions, + setup=source.setup, + teardown=source.teardown, + tags=list(source.tags) if source.tags else [], + custom_fields=dict(source.custom_fields) if source.custom_fields else {}, + order=max_order + 1, + created_by_id=current.id, + updated_by_id=current.id, + ) + db.add(copy) + db.flush() + for step in source.steps: + db.add(TestStep( + test_case_id=copy.id, + action=step.action, + expected_result=step.expected_result, + step_type=step.step_type, + order=step.order, + )) + write_revision(db, copy, current, "created") + return copy + + +@cases_router.post("/{id}/copy", response_model=TestCaseOut, status_code=status.HTTP_201_CREATED) +def copy_case(id: int, body: CaseCopy, db: Session = Depends(get_db), current: User = Depends(get_current_user)): + source = db.get(TestCase, id) + if not source: + raise HTTPException(status_code=404, detail="Not found") + target_suite = db.get(TestSuite, body.target_suite_id) + if not target_suite: + raise HTTPException(status_code=404, detail="Target suite not found") + _require_case_writer(db, current, target_suite.project_id) + if body.name: + name = body.name + elif body.target_suite_id == source.suite_id: + name = _unique_case_name(db, body.target_suite_id, source.name) + else: + name = source.name + copy = _clone_case(db, source, body.target_suite_id, name, current) + db.commit() + db.refresh(copy) + return copy + + +@cases_router.post("/bulk-copy", response_model=list[TestCaseOut], status_code=status.HTTP_201_CREATED) +def bulk_copy_cases(body: BulkCaseCopy, db: Session = Depends(get_db), current: User = Depends(get_current_user)): + if not body.case_ids: + return [] + if not body.target_suite_id and not body.new_suite_name: + raise HTTPException(status_code=422, detail="Provide target_suite_id or new_suite_name") + sources = db.query(TestCase).filter(TestCase.id.in_(body.case_ids)).all() + if not sources: + raise HTTPException(status_code=404, detail="No cases found") + source_project_id = _project_id_for_case(sources[0], db) + if body.new_suite_name: + _require_case_writer(db, current, source_project_id) + trimmed_name = body.new_suite_name.strip() + existing_suite = ( + db.query(TestSuite) + .filter( + TestSuite.project_id == source_project_id, + TestSuite.parent_suite_id == body.parent_suite_id, + TestSuite.name == trimmed_name, + ) + .first() + ) + if existing_suite: + raise HTTPException(status_code=409, detail=f"Suite '{trimmed_name}' already exists") + suite = TestSuite( + project_id=source_project_id, + name=trimmed_name, + parent_suite_id=body.parent_suite_id, + ) + db.add(suite) + db.flush() + target_suite_id = suite.id + else: + target_suite = db.get(TestSuite, body.target_suite_id) + if not target_suite: + raise HTTPException(status_code=404, detail="Target suite not found") + _require_case_writer(db, current, target_suite.project_id) + target_suite_id = body.target_suite_id + copies = [] + for source in sources: + name = source.name if target_suite_id != source.suite_id else _unique_case_name(db, target_suite_id, source.name) + copies.append(_clone_case(db, source, target_suite_id, name, current)) + db.commit() + for copy in copies: + db.refresh(copy) + return copies + + class BulkIds(BaseModel): ids: list[int] diff --git a/backend/testjam/schemas/testcase.py b/backend/testjam/schemas/testcase.py index 3a7242c..f1b4e2d 100644 --- a/backend/testjam/schemas/testcase.py +++ b/backend/testjam/schemas/testcase.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import Any -from pydantic import BaseModel, Field, computed_field +from pydantic import BaseModel, Field, computed_field, field_validator from testjam.core.config import settings from testjam.schemas.user import UserOut @@ -63,6 +63,11 @@ class TestCaseBase(BaseModel): teardown: str | None = None external_id: str | None = None + @field_validator("tags", mode="before") + @classmethod + def coerce_null_tags(cls, value): + return value if value is not None else [] + class TestCaseCreate(TestCaseBase): suite_id: int @@ -131,6 +136,11 @@ class TestSuiteBase(BaseModel): tags: list[str] = [] parent_suite_id: int | None = None + @field_validator("tags", mode="before") + @classmethod + def coerce_null_tags(cls, value): + return value if value is not None else [] + class TestSuiteCreate(TestSuiteBase): pass diff --git a/frontend/src/api/testcases.js b/frontend/src/api/testcases.js index 68fc8d3..5181149 100644 --- a/frontend/src/api/testcases.js +++ b/frontend/src/api/testcases.js @@ -41,6 +41,15 @@ export const casesApi = { update: (id, data) => api.put(`/cases/${id}`, data).then(r => r.data), delete: (id) => api.delete(`/cases/${id}`), bulkDelete: (ids) => api.post('/cases/bulk-delete', { ids }).then(r => r.data), + copy: (id, targetSuiteId, name) => + api.post(`/cases/${id}/copy`, { target_suite_id: targetSuiteId, name: name || undefined }).then(r => r.data), + bulkCopy: (caseIds, { targetSuiteId, newSuiteName, parentSuiteId } = {}) => + api.post('/cases/bulk-copy', { + case_ids: caseIds, + target_suite_id: targetSuiteId || undefined, + new_suite_name: newSuiteName || undefined, + parent_suite_id: parentSuiteId || undefined, + }).then(r => r.data), listSteps: (caseId) => api.get(`/cases/${caseId}/steps`).then(r => r.data), createStep: (caseId, data) => api.post(`/cases/${caseId}/steps`, data).then(r => r.data), diff --git a/frontend/src/components/project/SuiteRow.jsx b/frontend/src/components/project/SuiteRow.jsx index 4d461f3..54cd460 100644 --- a/frontend/src/components/project/SuiteRow.jsx +++ b/frontend/src/components/project/SuiteRow.jsx @@ -1,7 +1,7 @@ import { useState, useMemo, useEffect, useContext, createContext } from "react" import { Link, useParams } from "react-router-dom" import { useTranslation } from "react-i18next" -import { Plus, Trash2, Pencil, ChevronRight, FolderOpen, ListPlus, X, GripVertical } from "lucide-react" +import { Copy, Plus, Trash2, Pencil, ChevronRight, FolderOpen, ListPlus, X, GripVertical } from "lucide-react" import { useTreeItemNav } from "../../hooks/useTreeItemNav" import { useProjectRole } from "../../hooks/useProjectRole" @@ -10,7 +10,8 @@ import { TestCaseItem } from "../ui/test-case-item" import { useQueryClient, useQuery } from "@tanstack/react-query" import { useChildSuites, useCreateSuite, useUpdateSuite, useDeleteSuite, useArchiveSuite, useReorderProjectSuites, - useCases, useCreateCase, useDeleteCase, useBulkDeleteCases, useReorderSuiteSteps, useReorderSuiteCases, + useCases, useCreateCase, useDeleteCase, useBulkDeleteCases, useBulkCopyCases, useSuitesAll, useReorderSuiteSteps, useReorderSuiteCases, + sortSuitesHierarchically, } from "../../hooks/useSuites" import { suitesApi } from "../../api/testcases" import { plansApi } from "../../api/testplans" @@ -73,7 +74,7 @@ function AddSubSuiteInline({ parentSuiteId, projectId, onDone }) { ) } -function SortableCaseRow({ tc, suiteId, deleteCase, selected, toggle, canWrite }) { +function SortableCaseRow({ tc, suiteId, onDelete, selected, toggle, canWrite }) { const { t } = useTranslation("suites") const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tc.id, activationConstraint: { distance: 5 } }) @@ -116,7 +117,7 @@ function SortableCaseRow({ tc, suiteId, deleteCase, selected, toggle, canWrite } {canWrite && ( @@ -132,10 +133,15 @@ function CaseList({ suiteId }) { const { data: cases = [] } = useCases(suiteId) const deleteCase = useDeleteCase(suiteId) const bulkDelete = useBulkDeleteCases(suiteId) + const bulkCopy = useBulkCopyCases() const reorderCases = useReorderSuiteCases(suiteId) const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })) const [selected, setSelected] = useState(new Set()) const [planId, setPlanId] = useState("") + const [copySuiteId, setCopySuiteId] = useState("") + const [newSuiteName, setNewSuiteName] = useState("") + const [bulkCopyOpen, setBulkCopyOpen] = useState(false) + const { data: allSuites = [] } = useSuitesAll(selected.size > 0 ? projectId : undefined) const { data: plans = [] } = useQuery({ queryKey: ["plans", projectId], @@ -154,6 +160,19 @@ function CaseList({ suiteId }) { const toggleAll = () => setSelected(allSelected ? new Set() : new Set(allIds)) const clear = () => setSelected(new Set()) + const [casePendingDelete, setCasePendingDelete] = useState(null) + const confirmCaseDelete = async () => { + if (!casePendingDelete) return + try { + await deleteCase.mutateAsync(casePendingDelete.id) + toast.success(t("row.caseDeleted")) + } catch (error) { + toast.error(error?.response?.data?.detail ?? t("row.caseDeleteFailed")) + } finally { + setCasePendingDelete(null) + } + } + const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false) const confirmBulkDelete = async () => { const ids = [...selected] @@ -210,7 +229,7 @@ function CaseList({ suiteId }) { c.id)} strategy={verticalListSortingStrategy}> @@ -223,10 +242,15 @@ function CaseList({ suiteId }) { {t("row.selectedCount", { count: selected.size })} {canWrite && ( - + <> + + + )} {plans.length > 0 && (
@@ -261,6 +285,27 @@ function CaseList({ suiteId }) { busy={bulkDelete.isPending} onConfirm={confirmBulkDelete} /> + + { if (!open) setCasePendingDelete(null) }} + title={t("row.caseDeleteTitle")} + description={t("row.caseDeleteDescription", { name: casePendingDelete?.name })} + confirmLabel={t("common:actions.delete")} + destructive + busy={deleteCase.isPending} + onConfirm={confirmCaseDelete} + /> + + { clear(); setBulkCopyOpen(false) }} + />
) } @@ -474,15 +519,11 @@ export function SuiteRow({ suite, projectId, dragHandleProps }) { const handleDeleteClick = async () => { try { const impact = await suitesApi.deleteImpact(suite.id) - if (impact.result_count > 0) { - setDeleteImpact(impact) - setConfirmOpen(true) - return - } + setDeleteImpact(impact) } catch { - // fall through to plain delete on impact failure + setDeleteImpact(null) } - deleteSuite.mutate(suite.id, { onSuccess: () => toast.success(t("deleted")) }) + setConfirmOpen(true) } const handleArchiveConfirmed = () => { archiveSuite.mutate(suite.id, { @@ -637,3 +678,135 @@ function DeleteSuiteDialog({ open, impact, onCancel, onArchive, onDelete, archiv ) } + + +function BulkCopyDialog({ open, onOpenChange, caseIds, currentSuiteId, suites, bulkCopy, onSuccess }) { + const { t } = useTranslation("suites") + const [mode, setMode] = useState("existing") + const [targetSuiteId, setTargetSuiteId] = useState(null) + const [newSuiteName, setNewSuiteName] = useState("") + const [parentSuiteId, setParentSuiteId] = useState(null) + const [search, setSearch] = useState("") + + const depthMap = useMemo(() => { + const map = {} + const parentMap = {} + for (const s of suites) parentMap[s.id] = s.parent_suite_id ?? null + const depth = (id) => { + if (map[id] !== undefined) return map[id] + const parent = parentMap[id] + map[id] = parent === null ? 0 : depth(parent) + 1 + return map[id] + } + for (const s of suites) depth(s.id) + return map + }, [suites]) + + const sorted = useMemo(() => sortSuitesHierarchically(suites), [suites]) + const filtered = sorted.filter(s => + s.name.toLowerCase().includes(search.toLowerCase()), + ) + + const canSubmit = mode === "new" ? newSuiteName.trim().length > 0 : targetSuiteId !== null + + const handleCopy = async () => { + try { + if (mode === "new") { + await bulkCopy.mutateAsync({ caseIds, newSuiteName: newSuiteName.trim(), parentSuiteId }) + } else { + await bulkCopy.mutateAsync({ caseIds, targetSuiteId }) + } + toast.success(t("row.bulkCopySuccess", { count: caseIds.length })) + onSuccess() + } catch (error) { + toast.error(error?.response?.data?.detail ?? t("row.bulkCopyFailed")) + } + } + + return ( + + + + {t("row.bulkCopyTitle", { count: caseIds.length })} + +
+ + +
+ {mode === "existing" ? ( +
+ setSearch(event.target.value)} + autoFocus + /> +
+ {filtered.map(s => ( + + ))} + {filtered.length === 0 && ( +

{t("row.noSuitesFound")}

+ )} +
+
+ ) : ( +
+ setNewSuiteName(event.target.value)} + autoFocus + /> +

{t("row.bulkCopyParent")}

+
+ + {sorted.map(s => ( + + ))} +
+
+ )} + + + + +
+
+ ) +} diff --git a/frontend/src/hooks/useSuites.js b/frontend/src/hooks/useSuites.js index aadf5d8..95dbe4a 100644 --- a/frontend/src/hooks/useSuites.js +++ b/frontend/src/hooks/useSuites.js @@ -165,6 +165,29 @@ export function useBulkDeleteCases(suiteId) { }) } +export function useCopyCase() { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ caseId, targetSuiteId, name }) => casesApi.copy(caseId, targetSuiteId, name), + onSuccess: (result) => { + qc.invalidateQueries({ queryKey: ["cases-list", result.suite_id] }) + }, + }) +} + +export function useBulkCopyCases() { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ caseIds, targetSuiteId, newSuiteName, parentSuiteId }) => + casesApi.bulkCopy(caseIds, { targetSuiteId, newSuiteName, parentSuiteId }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["suites-list"] }) + qc.invalidateQueries({ queryKey: ["suites-list-all"] }) + qc.invalidateQueries({ queryKey: ["cases-list"] }) + }, + }) +} + export function useCaseRevisions(caseId) { return useQuery({ queryKey: ["case-revisions", caseId], diff --git a/frontend/src/i18n/locales/ca/cases.json b/frontend/src/i18n/locales/ca/cases.json index 28d747b..9ad4eb4 100644 --- a/frontend/src/i18n/locales/ca/cases.json +++ b/frontend/src/i18n/locales/ca/cases.json @@ -79,5 +79,16 @@ "cancel": "Cancel·la", "deleteTitle": "Eliminar aquest comentari?", "deleteDescription": "El comentari s'eliminarà de manera permanent." + }, + "copy": { + "button": "Copy", + "title": "Copy test case", + "description": "Copy \"{{name}}\" with all steps to another suite.", + "currentSuite": "current", + "sameSuiteHint": "A copy suffix will be added to the name.", + "success": "Test case copied", + "failed": "Could not copy test case", + "searchSuites": "Search suites…", + "noSuites": "No suites found" } } diff --git a/frontend/src/i18n/locales/ca/suites.json b/frontend/src/i18n/locales/ca/suites.json index de49b58..44ac363 100644 --- a/frontend/src/i18n/locales/ca/suites.json +++ b/frontend/src/i18n/locales/ca/suites.json @@ -100,7 +100,22 @@ "cancel": "Cancel·la", "addCaseTitle": "Títol del cas…", "addCaseButton": "Afegeix", - "caseCreated": "Cas creat" + "caseCreated": "Cas creat", + "copyButton": "Copy", + "bulkCopyTitle": "Copy {{count}} test cases", + "bulkCopyExisting": "Existing suite", + "bulkCopyNew": "New suite", + "bulkCopySearchSuites": "Search suites…", + "bulkCopyNewName": "New suite name", + "bulkCopySuccess": "{{count}} cases copied", + "bulkCopyFailed": "Could not copy cases", + "currentSuite": "current", + "noSuitesFound": "No suites found", + "bulkCopyParent": "Parent suite (optional)", + "bulkCopyRoot": "(root level)", + "caseDeleteTitle": "Delete test case?", + "caseDeleteDescription": "This will permanently delete \"{{name}}\" and all its steps.", + "caseDeleteFailed": "Could not delete test case" }, "picker": { "allExcluded": "Tots els casos ja estan afegits" diff --git a/frontend/src/i18n/locales/en/cases.json b/frontend/src/i18n/locales/en/cases.json index 59dd3ac..bc07e97 100644 --- a/frontend/src/i18n/locales/en/cases.json +++ b/frontend/src/i18n/locales/en/cases.json @@ -79,5 +79,16 @@ "cancel": "Cancel", "deleteTitle": "Delete this comment?", "deleteDescription": "This will permanently remove the comment." + }, + "copy": { + "button": "Copy", + "title": "Copy test case", + "description": "Copy \"{{name}}\" with all steps to another suite.", + "currentSuite": "current", + "sameSuiteHint": "A copy suffix will be added to the name.", + "success": "Test case copied", + "failed": "Could not copy test case", + "searchSuites": "Search suites…", + "noSuites": "No suites found" } } diff --git a/frontend/src/i18n/locales/en/suites.json b/frontend/src/i18n/locales/en/suites.json index 932c96a..01968d8 100644 --- a/frontend/src/i18n/locales/en/suites.json +++ b/frontend/src/i18n/locales/en/suites.json @@ -100,7 +100,22 @@ "cancel": "Cancel", "addCaseTitle": "Test case title…", "addCaseButton": "Add", - "caseCreated": "Test case created" + "caseCreated": "Test case created", + "copyButton": "Copy", + "bulkCopyTitle": "Copy {{count}} test cases", + "bulkCopyExisting": "Existing suite", + "bulkCopyNew": "New suite", + "bulkCopySearchSuites": "Search suites…", + "bulkCopyNewName": "New suite name", + "bulkCopySuccess": "{{count}} cases copied", + "bulkCopyFailed": "Could not copy cases", + "currentSuite": "current", + "noSuitesFound": "No suites found", + "bulkCopyParent": "Parent suite (optional)", + "bulkCopyRoot": "(root level)", + "caseDeleteTitle": "Delete test case?", + "caseDeleteDescription": "This will permanently delete \"{{name}}\" and all its steps.", + "caseDeleteFailed": "Could not delete test case" }, "picker": { "allExcluded": "All cases already added", diff --git a/frontend/src/i18n/locales/es/cases.json b/frontend/src/i18n/locales/es/cases.json index 2cd1227..1bafa2d 100644 --- a/frontend/src/i18n/locales/es/cases.json +++ b/frontend/src/i18n/locales/es/cases.json @@ -79,5 +79,16 @@ "cancel": "Cancelar", "deleteTitle": "¿Eliminar este comentario?", "deleteDescription": "El comentario se eliminará de forma permanente." + }, + "copy": { + "button": "Copiar", + "title": "Copiar caso de prueba", + "description": "Copiar \"{{name}}\" con todos los pasos a otra suite.", + "currentSuite": "actual", + "sameSuiteHint": "Se añadirá un sufijo al nombre.", + "success": "Caso copiado", + "failed": "No se pudo copiar el caso", + "searchSuites": "Buscar suites…", + "noSuites": "No se encontraron suites" } } diff --git a/frontend/src/i18n/locales/es/suites.json b/frontend/src/i18n/locales/es/suites.json index bc08c55..40fb73f 100644 --- a/frontend/src/i18n/locales/es/suites.json +++ b/frontend/src/i18n/locales/es/suites.json @@ -100,7 +100,22 @@ "cancel": "Cancelar", "addCaseTitle": "Título del caso…", "addCaseButton": "Añadir", - "caseCreated": "Caso creado" + "caseCreated": "Caso creado", + "copyButton": "Copiar", + "bulkCopyTitle": "Copiar {{count}} casos", + "bulkCopyExisting": "Suite existente", + "bulkCopyNew": "Nueva suite", + "bulkCopySearchSuites": "Buscar suites…", + "bulkCopyNewName": "Nombre de la nueva suite", + "bulkCopySuccess": "{{count}} casos copiados", + "bulkCopyFailed": "No se pudieron copiar los casos", + "currentSuite": "actual", + "noSuitesFound": "No se encontraron suites", + "bulkCopyParent": "Suite padre (opcional)", + "bulkCopyRoot": "(nivel raíz)", + "caseDeleteTitle": "¿Eliminar caso de prueba?", + "caseDeleteDescription": "Se eliminará permanentemente \"{{name}}\" y todos sus pasos.", + "caseDeleteFailed": "No se pudo eliminar el caso" }, "picker": { "allExcluded": "Todos los casos ya están añadidos", diff --git a/frontend/src/i18n/locales/eu/cases.json b/frontend/src/i18n/locales/eu/cases.json index 760cc28..c1d2b1c 100644 --- a/frontend/src/i18n/locales/eu/cases.json +++ b/frontend/src/i18n/locales/eu/cases.json @@ -79,5 +79,16 @@ "cancel": "Utzi", "deleteTitle": "Iruzkin hau ezabatu?", "deleteDescription": "Iruzkina behin betiko ezabatuko da." + }, + "copy": { + "button": "Copy", + "title": "Copy test case", + "description": "Copy \"{{name}}\" with all steps to another suite.", + "currentSuite": "current", + "sameSuiteHint": "A copy suffix will be added to the name.", + "success": "Test case copied", + "failed": "Could not copy test case", + "searchSuites": "Search suites…", + "noSuites": "No suites found" } } diff --git a/frontend/src/i18n/locales/eu/suites.json b/frontend/src/i18n/locales/eu/suites.json index 8f02b72..45d7822 100644 --- a/frontend/src/i18n/locales/eu/suites.json +++ b/frontend/src/i18n/locales/eu/suites.json @@ -100,7 +100,22 @@ "cancel": "Utzi", "addCaseTitle": "Kasuaren izenburua…", "addCaseButton": "Gehitu", - "caseCreated": "Kasua sortuta" + "caseCreated": "Kasua sortuta", + "copyButton": "Copy", + "bulkCopyTitle": "Copy {{count}} test cases", + "bulkCopyExisting": "Existing suite", + "bulkCopyNew": "New suite", + "bulkCopySearchSuites": "Search suites…", + "bulkCopyNewName": "New suite name", + "bulkCopySuccess": "{{count}} cases copied", + "bulkCopyFailed": "Could not copy cases", + "currentSuite": "current", + "noSuitesFound": "No suites found", + "bulkCopyParent": "Parent suite (optional)", + "bulkCopyRoot": "(root level)", + "caseDeleteTitle": "Delete test case?", + "caseDeleteDescription": "This will permanently delete \"{{name}}\" and all its steps.", + "caseDeleteFailed": "Could not delete test case" }, "picker": { "allExcluded": "Kasu guztiak gehituta daude jada" diff --git a/frontend/src/i18n/locales/gl/cases.json b/frontend/src/i18n/locales/gl/cases.json index fc05d73..610af77 100644 --- a/frontend/src/i18n/locales/gl/cases.json +++ b/frontend/src/i18n/locales/gl/cases.json @@ -79,5 +79,16 @@ "cancel": "Cancelar", "deleteTitle": "Eliminar este comentario?", "deleteDescription": "O comentario eliminarase de xeito permanente." + }, + "copy": { + "button": "Copy", + "title": "Copy test case", + "description": "Copy \"{{name}}\" with all steps to another suite.", + "currentSuite": "current", + "sameSuiteHint": "A copy suffix will be added to the name.", + "success": "Test case copied", + "failed": "Could not copy test case", + "searchSuites": "Search suites…", + "noSuites": "No suites found" } } diff --git a/frontend/src/i18n/locales/gl/suites.json b/frontend/src/i18n/locales/gl/suites.json index 0180474..0ac86d5 100644 --- a/frontend/src/i18n/locales/gl/suites.json +++ b/frontend/src/i18n/locales/gl/suites.json @@ -100,7 +100,22 @@ "cancel": "Cancelar", "addCaseTitle": "Título do caso…", "addCaseButton": "Engadir", - "caseCreated": "Caso creado" + "caseCreated": "Caso creado", + "copyButton": "Copy", + "bulkCopyTitle": "Copy {{count}} test cases", + "bulkCopyExisting": "Existing suite", + "bulkCopyNew": "New suite", + "bulkCopySearchSuites": "Search suites…", + "bulkCopyNewName": "New suite name", + "bulkCopySuccess": "{{count}} cases copied", + "bulkCopyFailed": "Could not copy cases", + "currentSuite": "current", + "noSuitesFound": "No suites found", + "bulkCopyParent": "Parent suite (optional)", + "bulkCopyRoot": "(root level)", + "caseDeleteTitle": "Delete test case?", + "caseDeleteDescription": "This will permanently delete \"{{name}}\" and all its steps.", + "caseDeleteFailed": "Could not delete test case" }, "picker": { "allExcluded": "Todos os casos xa están engadidos" diff --git a/frontend/src/pages/TestCasePage.jsx b/frontend/src/pages/TestCasePage.jsx index 9652094..4646e6f 100644 --- a/frontend/src/pages/TestCasePage.jsx +++ b/frontend/src/pages/TestCasePage.jsx @@ -1,8 +1,8 @@ -import { useState } from "react" +import { useMemo, useState } from "react" import { useParams, Link } from "react-router-dom" import { useTranslation } from "react-i18next" -import { Plus, History, User as UserIcon, Clock } from "lucide-react" -import { useCase, useUpdateCase, useReorderSteps, useSuite } from "../hooks/useSuites" +import { Copy, Plus, History, User as UserIcon, Clock } from "lucide-react" +import { useCase, useUpdateCase, useReorderSteps, useSuite, useSuitesAll, useCopyCase, sortSuitesHierarchically } from "../hooks/useSuites" import { useProject } from "../hooks/useProjects" import { casesApi } from "../api/testcases" import { useQueryClient } from "@tanstack/react-query" @@ -11,6 +11,7 @@ import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable" import { MdEditor, MdViewer } from "../components/MdEditor" import { Button } from "../components/ui/button" import { Input } from "../components/ui/input" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "../components/ui/dialog" import { Tabs, TabsList, TabsTrigger, TabsContent } from "../components/ui/tabs" import { PageHeader, PageBody } from "../components/ui/page-header" import { CaseRevisions } from "../components/case/CaseRevisions" @@ -40,6 +41,9 @@ export function TestCasePage() { const [description, setDescription] = useState("") const [preconditions, setPreconditions] = useState("") const [newStepContent, setNewStepContent] = useState("") + const [copyDialogOpen, setCopyDialogOpen] = useState(false) + const { data: allSuites = [] } = useSuitesAll(suite?.project_id) + const copyCase = useCopyCase() if (isLoading) return

{t("loading")}

if (!tc) return null @@ -148,6 +152,7 @@ export function TestCasePage() {
@@ -168,7 +173,8 @@ export function TestCasePage() {
) : ( -
+
+

{tc.name}

{tc.created_by && ( @@ -200,6 +206,10 @@ export function TestCasePage() {

{t("clickToAdd")}

)}
+ +
)}
@@ -250,6 +260,101 @@ export function TestCasePage() {
+ + ) } + +function CopyCaseDialog({ open, onOpenChange, caseId, caseName, currentSuiteId, suites, copyCase }) { + const { t } = useTranslation("cases") + const [targetSuiteId, setTargetSuiteId] = useState(currentSuiteId) + const [search, setSearch] = useState("") + const isSameSuite = targetSuiteId === currentSuiteId + + const depthMap = useMemo(() => { + const map = {} + const parentMap = {} + for (const s of suites) parentMap[s.id] = s.parent_suite_id ?? null + const depth = (id) => { + if (map[id] !== undefined) return map[id] + const parent = parentMap[id] + map[id] = parent === null ? 0 : depth(parent) + 1 + return map[id] + } + for (const s of suites) depth(s.id) + return map + }, [suites]) + + const sorted = useMemo(() => sortSuitesHierarchically(suites), [suites]) + const filtered = sorted.filter(s => + s.name.toLowerCase().includes(search.toLowerCase()), + ) + + const handleCopy = async () => { + try { + await copyCase.mutateAsync({ caseId, targetSuiteId }) + toast.success(t("copy.success")) + onOpenChange(false) + } catch (error) { + toast.error(error?.response?.data?.detail ?? t("copy.failed")) + } + } + + return ( + + + + {t("copy.title")} + +

+ {t("copy.description", { name: caseName })} +

+
+ setSearch(event.target.value)} + autoFocus + /> +
+ {filtered.map(s => ( + + ))} + {filtered.length === 0 && ( +

{t("copy.noSuites")}

+ )} +
+ {isSameSuite && ( +

{t("copy.sameSuiteHint")}

+ )} +
+ + + + +
+
+ ) +} diff --git a/tests/e2e/testjam_e2e/keywords/ui/cases.py b/tests/e2e/testjam_e2e/keywords/ui/cases.py index 3e0d544..6bd0ea4 100644 --- a/tests/e2e/testjam_e2e/keywords/ui/cases.py +++ b/tests/e2e/testjam_e2e/keywords/ui/cases.py @@ -42,6 +42,9 @@ def add_case_ui(self, case_name: str, suite_name: str) -> None: @keyword("I delete the test case ${name} via the UI") def delete_case_ui(self, name: str) -> None: BuiltIn().run_keyword("Click", _case_delete(name)) + BuiltIn().run_keyword( + "Click", 'div[role="dialog"] button:has-text("Delete")', + ) BuiltIn().run_keyword( "Wait For Elements State", _case_link(name), "hidden", "timeout=5s", ) diff --git a/tests/e2e/testjam_e2e/keywords/ui/suites.py b/tests/e2e/testjam_e2e/keywords/ui/suites.py index b6b5155..ed415d9 100644 --- a/tests/e2e/testjam_e2e/keywords/ui/suites.py +++ b/tests/e2e/testjam_e2e/keywords/ui/suites.py @@ -72,6 +72,9 @@ def create_suite_ui(self, name: str) -> None: @keyword("I delete the suite ${name} via the UI") def delete_suite_ui(self, name: str) -> None: BuiltIn().run_keyword("Click", _suite_delete(name)) + BuiltIn().run_keyword( + "Click", 'div[role="dialog"] button:has-text("Delete")', + ) BuiltIn().run_keyword( "Wait For Elements State", _suite_header(name), "hidden", "timeout=5s", )