diff --git a/backend/routes/cases/move.js b/backend/routes/cases/move.js new file mode 100644 index 0000000..13ecfd4 --- /dev/null +++ b/backend/routes/cases/move.js @@ -0,0 +1,36 @@ +import express from 'express'; +const router = express.Router(); +import { DataTypes } from 'sequelize'; +import defineCase from '../../models/cases.js'; +import authMiddleware from '../../middleware/auth.js'; +import editableMiddleware from '../../middleware/verifyEditable.js'; + +export default function (sequelize) { + const { verifySignedIn } = authMiddleware(sequelize); + const { verifyProjectDeveloperFromProjectId } = editableMiddleware(sequelize); + const Case = defineCase(sequelize, DataTypes); + + router.put('/move', verifySignedIn, verifyProjectDeveloperFromProjectId, async (req, res) => { + const { caseIds, targetFolderId } = req.body; + + if (!Array.isArray(caseIds) || caseIds.length === 0 || !targetFolderId) { + return res.status(400).json({ error: 'caseIds(array) and targetFolderId are required' }); + } + + try { + const cases = await Case.findAll({ where: { id: caseIds } }); + if (cases.length !== caseIds.length) { + return res.status(404).json({ error: 'Some cases not found' }); + } + + await Case.update({ folderId: targetFolderId }, { where: { id: caseIds } }); + + res.status(200).json({ message: 'Cases moved successfully', movedCaseIds: caseIds, targetFolderId }); + } catch (error) { + console.error('Error moving cases:', error); + res.status(500).send('Internal Server Error'); + } + }); + + return router; +} diff --git a/backend/server.js b/backend/server.js index 66ded8e..5250b32 100644 --- a/backend/server.js +++ b/backend/server.js @@ -82,6 +82,7 @@ app.use('/folders', foldersDeleteRoute(sequelize)); // "/cases" import casesDownloadRoute from './routes/cases/download.js'; +import casesMoveRoute from './routes/cases/move.js'; import casesIndexRoute from './routes/cases/index.js'; import casesIndexByProjectIdRoute from './routes/cases/indexByProjectId.js'; import casesShowRoute from './routes/cases/show.js'; @@ -89,6 +90,7 @@ import casesNewRoute from './routes/cases/new.js'; import casesEditRoute from './routes/cases/edit.js'; import casesDeleteRoute from './routes/cases/delete.js'; app.use('/cases', casesDownloadRoute(sequelize)); +app.use('/cases', casesMoveRoute(sequelize)); app.use('/cases', casesIndexRoute(sequelize)); app.use('/cases', casesIndexByProjectIdRoute(sequelize)); app.use('/cases', casesShowRoute(sequelize)); diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 9a43321..337d4fe 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -210,7 +210,8 @@ "select_priorities": "Select priorities", "selected": "Selected", "type": "Type", - "select_types": "Select types" + "select_types": "Select types", + "cases_moved": "Test cases moved" }, "Case": { "back_to_cases": "Back to test cases", diff --git a/frontend/messages/ja.json b/frontend/messages/ja.json index 6084d3f..1ff3c2b 100644 --- a/frontend/messages/ja.json +++ b/frontend/messages/ja.json @@ -211,7 +211,8 @@ "select_priorities": "優先度を選択", "selected": "選択済み", "type": "タイプ", - "select_types": "タイプを選択" + "select_types": "タイプを選択", + "cases_moved": "テストケースを移動しました" }, "Case": { "back_to_cases": "テストケース一覧に戻る", diff --git a/frontend/messages/pt-BR.json b/frontend/messages/pt-BR.json index 99105af..8f4cbab 100644 --- a/frontend/messages/pt-BR.json +++ b/frontend/messages/pt-BR.json @@ -210,7 +210,8 @@ "select_priorities": "Selecionar prioridades", "selected": "Selecionado", "type": "Tipo", - "select_types": "Selecionar tipos" + "select_types": "Selecionar tipos", + "moved": "Casos de teste movidos" }, "Case": { "back_to_cases": "Voltar para os casos de teste", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 10af470..d93f099 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -69,6 +69,25 @@ "tslib": "^2.4.0" } }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.2.tgz", + "integrity": "sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emotion/memoize": "^0.8.1" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", + "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@formatjs/ecma402-abstract": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.3.tgz", @@ -10114,9 +10133,10 @@ } }, "node_modules/csstype": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", - "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" }, "node_modules/dayjs": { "version": "1.11.13", diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/FoldersPane.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/FoldersPane.tsx index d78409a..f88cd94 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/folders/FoldersPane.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/FoldersPane.tsx @@ -13,6 +13,7 @@ import DeleteConfirmDialog from '@/components/DeleteConfirmDialog'; import { FolderType, FoldersMessages, TreeNodeData } from '@/types/folder'; import { logError } from '@/utils/errorHandler'; import { buildFolderTree } from '@/utils/buildFolderTree'; +import { emitMoveEvent } from '@/utils/testCaseMoveEvent'; type Props = { projectId: string; @@ -114,6 +115,21 @@ export default function FoldersPane({ projectId, messages, locale }: Props) { } }; + // ************************************************************************** + // move test case + // ************************************************************************** + const handleDragOver = (e: React.DragEvent) => { + e.stopPropagation(); + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + }; + + const handleDrop = (e: React.DragEvent, dropFolderId: string) => { + e.stopPropagation(); + const ids = JSON.parse(e.dataTransfer.getData('application/json')); + emitMoveEvent(ids, Number(dropFolderId)); + }; + return ( <>
@@ -144,16 +160,18 @@ export default function FoldersPane({ projectId, messages, locale }: Props) { disableDrag={true} > {(props) => ( - +
handleDrop(e, props.node.id)}> + +
)} )} 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 b9e99c5..a20bccc 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 @@ -1,10 +1,11 @@ 'use client'; import { useState, useEffect, useContext } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; +import { addToast } from '@heroui/react'; import TestCaseTable from './TestCaseTable'; import CaseDialog from './CaseDialog'; import { TokenContext } from '@/utils/TokenProvider'; -import { fetchCases, createCase, deleteCases, exportCases } from '@/utils/caseControl'; +import { fetchCases, createCase, deleteCases, exportCases, moveCases } from '@/utils/caseControl'; import { CaseType, CasesMessages } from '@/types/case'; import DeleteConfirmDialog from '@/components/DeleteConfirmDialog'; import { PriorityMessages } from '@/types/priority'; @@ -12,6 +13,7 @@ import { TestTypeMessages } from '@/types/testType'; import { LocaleCodeType } from '@/types/locale'; import { logError } from '@/utils/errorHandler'; import { parseQueryParam } from '@/utils/parseQueryParam'; +import { onMoveEvent } from '@/utils/testCaseMoveEvent'; type Props = { projectId: string; @@ -96,6 +98,25 @@ export default function CasesPane({ fetchDataEffect(); }, [context, folderId, searchParams]); + useEffect(() => { + const unsubscribe = onMoveEvent(async (e) => { + const { testCaseIds, targetFolderId } = e.detail; + const moveRet = await moveCases(context.token.access_token, testCaseIds, targetFolderId, Number(projectId)); + if (!moveRet) { + console.error('Error moving cases'); + return; + } + + setCases(cases.filter((entry) => !testCaseIds.includes(entry.id))); + addToast({ + title: 'Success', + color: 'success', + description: messages.casesMoved, + }); + }); + return unsubscribe; + }, [cases, context.token.access_token, messages.casesMoved, projectId]); + const closeDialog = () => setIsCaseDialogOpen(false); const onSubmit = async (title: string, description: string) => { diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/TestCaseTable.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/TestCaseTable.tsx index bf62b65..49ecd56 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/TestCaseTable.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/TestCaseTable.tsx @@ -1,11 +1,5 @@ -import { useState, useMemo, useCallback, ReactNode } from 'react'; +import { useState, useMemo, useCallback, ReactNode, useEffect } from 'react'; import { - Table, - TableHeader, - TableColumn, - TableBody, - TableRow, - TableCell, Button, DropdownTrigger, Dropdown, @@ -17,16 +11,31 @@ import { Popover, PopoverContent, PopoverTrigger, + Checkbox, + Card, + CardBody, } from '@heroui/react'; -import { Plus, MoreVertical, Trash, FileDown, ChevronDown, Filter, FileJson, FileSpreadsheet } from 'lucide-react'; +import { + Plus, + MoreVertical, + Trash, + FileDown, + ChevronUp, + ChevronDown, + Filter, + FileJson, + FileSpreadsheet, +} from 'lucide-react'; +import { table } from '@heroui/theme'; import TestCaseFilter from './TestCaseFilter'; -import { Link } from '@/src/i18n/routing'; +import { Link, NextUiLinkClasses } from '@/src/i18n/routing'; import { CaseType, CasesMessages } from '@/types/case'; import { PriorityMessages } from '@/types/priority'; import { TestTypeMessages } from '@/types/testType'; import TestCasePriority from '@/components/TestCasePriority'; import { LocaleCodeType } from '@/types/locale'; import { highlightSearchTerm } from '@/utils/highlightSearchTerm'; +import { onMoveEvent } from '@/utils/testCaseMoveEvent'; type Props = { projectId: string; @@ -63,6 +72,11 @@ export default function TestCaseTable({ testTypeMessages, locale, }: Props) { + const heroUITableClasses = table(); + const thClassNames = 'bg-transparent text-default-500 border-b border-divider'; + const tdClassNames = + '!py-1 group-data-[first=true]:first:before:rounded-none group-data-[first=true]:last:before:rounded-none group-data-[middle=true]:before:rounded-none group-data-[last=true]:first:before:rounded-none group-data-[last=true]:last:before:rounded-none'; + const headerColumns = [ { name: messages.id, uid: 'id', sortable: true }, { name: messages.title, uid: 'title', sortable: true }, @@ -70,30 +84,6 @@ export default function TestCaseTable({ { name: messages.actions, uid: 'actions' }, ]; - const [selectedKeys, setSelectedKeys] = useState(new Set([])); - const [sortDescriptor, setSortDescriptor] = useState({ - column: 'id', - direction: 'ascending', - }); - const [showFilter, setShowFilter] = useState(false); - - const sortedItems = useMemo(() => { - if (cases.length === 0) { - return []; - } - return [...cases].sort((a: CaseType, b: CaseType) => { - const first = a[sortDescriptor.column as keyof CaseType] as number; - const second = b[sortDescriptor.column as keyof CaseType] as number; - const cmp = first < second ? -1 : first > second ? 1 : 0; - - return sortDescriptor.direction === 'descending' ? -cmp : cmp; - }); - }, [sortDescriptor, cases]); - - const handleDeleteCase = (deleteCaseId: number) => { - onDeleteCase(deleteCaseId); - }; - const renderCell = useCallback( (testCase: CaseType, columnKey: string): ReactNode => { const cellValue = testCase[columnKey as keyof CaseType]; @@ -103,19 +93,17 @@ export default function TestCaseTable({ return {cellValue as number}; case 'title': return ( - + ); case 'priority': return ; @@ -147,6 +135,82 @@ export default function TestCaseTable({ [activeTitleFilter] ); + // ************************************************************************** + // filter test case + // ************************************************************************** + const [showFilter, setShowFilter] = useState(false); + const activeFilterNum = (activeTitleFilter ? 1 : 0) + activePriorityFilters.length + activeTypeFilters.length; + + // ************************************************************************** + // select test case + // ************************************************************************** + const [selectedKeys, setSelectedKeys] = useState(new Set([])); + const handleSelectRow = (id: number) => { + setSelectedKeys((prev) => { + const newSet = new Set(prev); + if (newSet.has(id)) { + newSet.delete(id); + } else { + newSet.add(id); + } + return newSet; + }); + }; + + const handleSelectAll = () => { + if (selectedKeys !== 'all' && selectedKeys instanceof Set && selectedKeys.size === sortedItems.length) { + setSelectedKeys(new Set()); + } else { + setSelectedKeys(new Set(sortedItems.map((item) => item.id))); + } + }; + + const isSelected = (id: number) => { + return selectedKeys === 'all' || (selectedKeys instanceof Set && selectedKeys.has(id)); + }; + + const isSelectedAll = () => { + return ( + (selectedKeys === 'all' && sortedItems.length > 0) || + (selectedKeys instanceof Set && selectedKeys.size === sortedItems.length && sortedItems.length > 0) + ); + }; + + // ************************************************************************** + // sort test case + // ************************************************************************** + const [sortDescriptor, setSortDescriptor] = useState({ + column: 'id', + direction: 'ascending', + }); + const sortedItems = useMemo(() => { + if (cases.length === 0) { + return []; + } + return [...cases].sort((a: CaseType, b: CaseType) => { + const first = a[sortDescriptor.column as keyof CaseType] as number; + const second = b[sortDescriptor.column as keyof CaseType] as number; + const cmp = first < second ? -1 : first > second ? 1 : 0; + + return sortDescriptor.direction === 'descending' ? -cmp : cmp; + }); + }, [sortDescriptor, cases]); + const handleSort = (columnUid: string) => { + setSortDescriptor((prev) => { + if (prev.column === columnUid) { + return { + column: columnUid, + direction: prev.direction === 'ascending' ? 'descending' : 'ascending', + }; + } + return { column: columnUid, direction: 'ascending' }; + }); + }; + + const handleDeleteCase = (deleteCaseId: number) => { + onDeleteCase(deleteCaseId); + }; + const handleDeleteCases = () => { let deleteCaseIds: number[]; if (selectedKeys === 'all') { @@ -158,26 +222,41 @@ export default function TestCaseTable({ setSelectedKeys(new Set([])); }; - const activeFilterNum = (activeTitleFilter ? 1 : 0) + activePriorityFilters.length + activeTypeFilters.length; + // ************************************************************************** + // move test case + // ************************************************************************** + const [dragCount, setDragCount] = useState(null); + const [mousePos, setMousePos] = useState({ x: 0, y: 0 }); + const handleDragStart = (e: React.DragEvent, id: number) => { + e.stopPropagation(); - const classNames = useMemo( - () => ({ - wrapper: ['max-w-3xl'], - th: ['bg-transparent', 'text-default-500', 'border-b', 'border-divider'], - td: [ - // changing the rows border radius - // first - 'group-data-[first=true]:first:before:rounded-none', - 'group-data-[first=true]:last:before:rounded-none', - // middle - 'group-data-[middle=true]:before:rounded-none', - // last - 'group-data-[last=true]:first:before:rounded-none', - 'group-data-[last=true]:last:before:rounded-none', - ], - }), - [] - ); + let selectedIds: number[]; + if (selectedKeys === 'all' || (selectedKeys instanceof Set && selectedKeys.has(id) && selectedKeys.size > 1)) { + // when multiple row selected + selectedIds = + selectedKeys === 'all' ? sortedItems.map((item) => item.id) : (Array.from(selectedKeys) as number[]); + } else { + // when no row selected or only one row selected + selectedIds = [id]; + } + setDragCount(selectedIds.length); + e.dataTransfer.setData('application/json', JSON.stringify(selectedIds)); + const img = new window.Image(); + img.src = 'data:image/svg+xml;base64,'; + e.dataTransfer.setDragImage(img, 0, 0); + }; + const handleDrag = (e: React.DragEvent) => { + setMousePos({ x: e.clientX, y: e.clientY }); + }; + const handleDragEnd = () => { + setDragCount(null); + }; + useEffect(() => { + const unsubscribe = onMoveEvent(() => { + handleDragEnd(); + }); + return unsubscribe; + }, []); return ( <> @@ -265,36 +344,77 @@ export default function TestCaseTable({
- - - {(column) => ( - - {column.name} - - )} - - - {(item) => ( - - {(columnKey) => {renderCell(item, columnKey as string)}} - - )} - -
+
+ + + + + {headerColumns.map((column) => ( + + ))} + + + + {sortedItems.map((item) => ( + handleDragStart(e, item.id)} + onDrag={handleDrag} + onDragEnd={handleDragEnd} + style={{ opacity: dragCount ? 0.5 : 1 }} + > + + {headerColumns.map((column) => ( + + ))} + + ))} + +
+ + column.sortable && handleSort(column.uid)} + style={{ cursor: column.sortable ? 'pointer' : 'default' }} + > +
+ {column.name} + {column.sortable && sortDescriptor.column === column.uid && ( + <> + {sortDescriptor.direction === 'ascending' ? : } + + )} +
+
+ handleSelectRow(item.id)} /> + + {renderCell(item, column.uid)} +
+
+ + {sortedItems.length === 0 && ( +
+
No test case
+
+ )} + + {dragCount !== null && ( + + +

{dragCount} cases selected

+
+
+ )} ); } 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 e7778bf..7279361 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 @@ -40,6 +40,7 @@ export default function Page({ params }: { params: { projectId: string; folderId selected: t('selected'), type: t('type'), selectTypes: t('select_types'), + casesMoved: t('cases_moved'), }; const priorityTranslation = useTranslations('Priority'); diff --git a/frontend/types/case.ts b/frontend/types/case.ts index a90e828..00cd5b5 100644 --- a/frontend/types/case.ts +++ b/frontend/types/case.ts @@ -84,6 +84,7 @@ type CasesMessages = { selected: string; type: string; selectTypes: string; + casesMoved: string; }; type CaseMessages = { diff --git a/frontend/utils/caseControl.ts b/frontend/utils/caseControl.ts index 0b8dfb7..3af3989 100644 --- a/frontend/utils/caseControl.ts +++ b/frontend/utils/caseControl.ts @@ -125,6 +125,28 @@ async function updateCase(jwt: string, updateCaseData: CaseType) { } } +export async function moveCases(jwt: string, moveCaseIds: number[], targetFolderId: number, projectId: number) { + const fetchOptions = { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify({ caseIds: moveCaseIds, targetFolderId }), + }; + const url = `${apiServer}/cases/move?projectId=${projectId}`; + try { + const response = await fetch(url, fetchOptions); + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + const data = await response.json(); + return data; + } catch (error: unknown) { + logError('Error updating project', error); + } +} + async function deleteCases(jwt: string, deleteCaseIds: number[], projectId: number) { const fetchOptions = { method: 'POST', diff --git a/frontend/utils/testCaseMoveEvent.ts b/frontend/utils/testCaseMoveEvent.ts new file mode 100644 index 0000000..7490fad --- /dev/null +++ b/frontend/utils/testCaseMoveEvent.ts @@ -0,0 +1,19 @@ +const eventBus = new EventTarget(); +const TEST_CASE_MOVE = 'testCasesMove'; + +type MoveEventDetail = { + testCaseIds: number[]; + targetFolderId: number; +}; + +export const emitMoveEvent = (ids: number[], targetFolderId: number) => { + eventBus.dispatchEvent( + new CustomEvent(TEST_CASE_MOVE, { detail: { testCaseIds: ids, targetFolderId: targetFolderId } }) + ); +}; + +export const onMoveEvent = (listener: (event: CustomEvent) => void) => { + const handler = (e: Event) => listener(e as CustomEvent); + eventBus.addEventListener(TEST_CASE_MOVE, handler); + return () => eventBus.removeEventListener(TEST_CASE_MOVE, handler); +};