diff --git a/backend/migrations/20250804175803-add-cascade-delete-parent-folder.js b/backend/migrations/20250804175803-add-cascade-delete-parent-folder.js new file mode 100644 index 0000000..608372d --- /dev/null +++ b/backend/migrations/20250804175803-add-cascade-delete-parent-folder.js @@ -0,0 +1,22 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + await queryInterface.addConstraint('folders', { + fields: ['parentFolderId'], + type: 'foreign key', + name: 'fk_folders_parentFolderId', + references: { + table: 'folders', + field: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }); + }, + + async down(queryInterface) { + await queryInterface.removeConstraint('folders', 'fk_folders_parentFolderId'); + }, +}; diff --git a/backend/models/folders.js b/backend/models/folders.js index a0b3880..9cdd238 100644 --- a/backend/models/folders.js +++ b/backend/models/folders.js @@ -11,6 +11,11 @@ function defineFolder(sequelize, DataTypes) { parentFolderId: { type: DataTypes.INTEGER, allowNull: true, + references: { + model: 'folder', + key: 'id', + }, + onDelete: 'CASCADE', }, projectId: { type: DataTypes.INTEGER, @@ -25,6 +30,7 @@ function defineFolder(sequelize, DataTypes) { Folder.associate = (models) => { Folder.belongsTo(models.Project, { foreignKey: 'projectId', onDelete: 'CASCADE' }); + Folder.belongsTo(models.folder, { foreignKey: 'parentFolderId', onDelete: 'CASCADE' }); Folder.hasMany(models.Case, { foreignKey: 'folderId' }); }; diff --git a/frontend/components/TreeItem.tsx b/frontend/components/TreeItem.tsx new file mode 100644 index 0000000..2adc46c --- /dev/null +++ b/frontend/components/TreeItem.tsx @@ -0,0 +1,46 @@ +import { cn } from '@heroui/react'; +import { ReactNode } from 'react'; + +interface TreeItemProps { + style: React.CSSProperties; + isSelected: boolean | null; + onClick: () => void; + toggleButton?: ReactNode; + icon: ReactNode; + label: string; + actions?: ReactNode; + className?: string; +} + +export default function TreeItem({ + style, + isSelected, + onClick, + toggleButton, + icon, + label, + actions, + className, +}: TreeItemProps) { + const baseClass = ''; + const selectedClass = `${baseClass} bg-neutral-200 dark:bg-neutral-700 dark:hover:bg-neutral-600 hover:bg-neutral-300`; + + return ( +
+
+ {toggleButton ||
} + {icon} + {label} + {actions &&
{actions}
} +
+
+ ); +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 384eb58..1f64ca3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -30,6 +30,7 @@ "postcss": "8.5.3", "react": "^18.3.1", "react-apexcharts": "^1.7.0", + "react-arborist": "^3.4.3", "react-dom": "18.3.1", "sharp": "^0.33.5", "tailwind-variants": "^0.3.1", @@ -9310,6 +9311,24 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/@react-dnd/asap": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.1.tgz", + "integrity": "sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg==", + "license": "MIT" + }, + "node_modules/@react-dnd/invariant": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-2.0.0.tgz", + "integrity": "sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw==", + "license": "MIT" + }, + "node_modules/@react-dnd/shallowequal": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz", + "integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==", + "license": "MIT" + }, "node_modules/@react-stately/flags": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.0.5.tgz", @@ -10135,6 +10154,26 @@ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" }, + "node_modules/dnd-core": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-14.0.1.tgz", + "integrity": "sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==", + "license": "MIT", + "dependencies": { + "@react-dnd/asap": "^4.0.0", + "@react-dnd/invariant": "^2.0.0", + "redux": "^4.1.1" + } + }, + "node_modules/dnd-core/node_modules/redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -10158,6 +10197,12 @@ "node": ">=6" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -10312,6 +10357,15 @@ "node": ">= 0.4" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, "node_modules/input-otp": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.1.tgz", @@ -10455,6 +10509,12 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -10934,6 +10994,62 @@ "react": ">=0.13" } }, + "node_modules/react-arborist": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/react-arborist/-/react-arborist-3.4.3.tgz", + "integrity": "sha512-yFnq1nIQhT2uJY4TZVz2tgAiBb9lxSyvF4vC3S8POCK8xLzjGIxVv3/4dmYquQJ7AHxaZZArRGHiHKsEewKdTQ==", + "license": "MIT", + "dependencies": { + "react-dnd": "^14.0.3", + "react-dnd-html5-backend": "^14.0.3", + "react-window": "^1.8.11", + "redux": "^5.0.0", + "use-sync-external-store": "^1.2.0" + }, + "peerDependencies": { + "react": ">= 16.14", + "react-dom": ">= 16.14" + } + }, + "node_modules/react-dnd": { + "version": "14.0.5", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.5.tgz", + "integrity": "sha512-9i1jSgbyVw0ELlEVt/NkCUkxy1hmhJOkePoCH713u75vzHGyXhPDm28oLfc2NMSBjZRM1Y+wRjHXJT3sPrTy+A==", + "license": "MIT", + "dependencies": { + "@react-dnd/invariant": "^2.0.0", + "@react-dnd/shallowequal": "^2.0.0", + "dnd-core": "14.0.1", + "fast-deep-equal": "^3.1.3", + "hoist-non-react-statics": "^3.3.2" + }, + "peerDependencies": { + "@types/hoist-non-react-statics": ">= 3.3.1", + "@types/node": ">= 12", + "@types/react": ">= 16", + "react": ">= 16.14" + }, + "peerDependenciesMeta": { + "@types/hoist-non-react-statics": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-dnd-html5-backend": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-14.1.0.tgz", + "integrity": "sha512-6ONeqEC3XKVf4eVmMTe0oPds+c5B9Foyj8p/ZKLb7kL2qh9COYxiBHv3szd6gztqi/efkmriywLUVlPotqoJyw==", + "license": "MIT", + "dependencies": { + "dnd-core": "14.0.1" + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -10967,6 +11083,23 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/react-window": { + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz", + "integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.0.0", + "memoize-one": ">=3.1.1 <6" + }, + "engines": { + "node": ">8.0.0" + }, + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -10986,6 +11119,12 @@ "node": ">=8.10.0" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, "node_modules/regenerator-runtime": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9fa9d8c..f87a34e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,6 +31,7 @@ "postcss": "8.5.3", "react": "^18.3.1", "react-apexcharts": "^1.7.0", + "react-arborist": "^3.4.3", "react-dom": "18.3.1", "sharp": "^0.33.5", "tailwind-variants": "^0.3.1", diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/FolderTree.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/FolderTree.tsx new file mode 100644 index 0000000..2a3c995 --- /dev/null +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/FolderTree.tsx @@ -0,0 +1,83 @@ +import { Button } from '@heroui/react'; +import { ChevronDown, ChevronRight, Folder, Plus } from 'lucide-react'; +import { NodeApi } from 'react-arborist'; +import { useContext } from 'react'; +import FolderEditMenu from './FolderEditMenu'; + +import { FolderType, FoldersMessages, TreeNodeData } from '@/types/folder'; +import { useRouter } from '@/src/i18n/routing'; +import { TokenContext } from '@/utils/TokenProvider'; +import TreeItem from '@/components/TreeItem'; + +interface FolderItemProps { + node: NodeApi; + style: React.CSSProperties; + projectId: string; + selectedFolder: FolderType | null; + locale: string; + messages: FoldersMessages; + openDialogForCreate: (folderId: number | null) => void; + onEditClick: (folder: FolderType) => void; + onDeleteClick: (folderId: number) => void; +} + +export default function FolderItem({ + node, + style, + projectId, + selectedFolder, + locale, + messages, + openDialogForCreate, + onEditClick, + onDeleteClick, +}: FolderItemProps) { + const router = useRouter(); + const context = useContext(TokenContext); + const isSelected = selectedFolder && node.data.folderData.id === selectedFolder.id; + + const toggleButton = + node.data.children && node.data.children.length > 0 ? ( + + ) : null; + + const actions = ( + <> + + + + ); + + return ( + router.push(`/projects/${projectId}/folders/${node.data.folderData.id}/cases`, { locale })} + toggleButton={toggleButton} + icon={} + label={node.data.name} + actions={actions} + /> + ); +} diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/FoldersPane.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/FoldersPane.tsx index c0ba0b9..de9e3a3 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/folders/FoldersPane.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/FoldersPane.tsx @@ -1,16 +1,18 @@ 'use client'; import { useState, useEffect, useContext } from 'react'; -import { Button, Listbox, ListboxItem } from '@heroui/react'; -import { Folder, Plus } from 'lucide-react'; +import { Button } from '@heroui/react'; +import { Plus } from 'lucide-react'; +import { Tree } from 'react-arborist'; import FolderDialog from './FolderDialog'; -import FolderEditMenu from './FolderEditMenu'; import { fetchFolders, createFolder, updateFolder, deleteFolder } from './foldersControl'; +import FolderItem from './FolderTree'; import { usePathname, useRouter } from '@/src/i18n/routing'; import { TokenContext } from '@/utils/TokenProvider'; import useGetCurrentIds from '@/utils/useGetCurrentIds'; import DeleteConfirmDialog from '@/components/DeleteConfirmDialog'; -import { FolderType, FoldersMessages } from '@/types/folder'; +import { FolderType, FoldersMessages, TreeNodeData } from '@/types/folder'; import { logError } from '@/utils/errorHandler'; +import { buildFolderTree } from '@/utils/buildFolderTree'; type Props = { projectId: string; @@ -22,11 +24,12 @@ export default function FoldersPane({ projectId, messages, locale }: Props) { const router = useRouter(); const pathname = usePathname(); const context = useContext(TokenContext); - const [folders, setFolders] = useState([]); + const [treeData, setTreeData] = useState([]); const [selectedFolder, setSelectedFolder] = useState(null); const { folderId } = useGetCurrentIds(); const [isFolderDialogOpen, setIsFolderDialogOpen] = useState(false); const [editingFolder, setEditingFolder] = useState(null); + const [parentFolderId, setParentFolderId] = useState(null); useEffect(() => { async function fetchDataEffect() { @@ -34,21 +37,20 @@ export default function FoldersPane({ projectId, messages, locale }: Props) { return; } try { - const folders: FolderType[] = await fetchFolders(context.token.access_token, Number(projectId)); - setFolders(folders); + const fetchedFolders: FolderType[] = await fetchFolders(context.token.access_token, Number(projectId)); + const tree = buildFolderTree(fetchedFolders); + setTreeData(tree); - // no folder on project - if (folders.length === 0) { + if (tree.length === 0) { return; } - const selectedFolderFromUrl = folders.find((folder) => folder.id === folderId); + const selectedFolderFromUrl = fetchedFolders.find((folder) => folder.id === folderId); setSelectedFolder(selectedFolderFromUrl ? selectedFolderFromUrl : null); - // Redirect to the smallest folder ID page if the path is "projects/[projectId]/folders if (pathname === `/projects/${projectId}/folders`) { - const smallestFolderId = Math.min(...folders.map((folder) => folder.id)); - router.push(`/projects/${projectId}/folders/${smallestFolderId}/cases`, { locale: locale }); + const smallestFolderId = Math.min(...fetchedFolders.map((folder) => folder.id)); + router.push(`/projects/${projectId}/folders/${smallestFolderId}/cases`, { locale }); } } catch (error: unknown) { logError('Error fetching folders:', error); @@ -58,7 +60,8 @@ export default function FoldersPane({ projectId, messages, locale }: Props) { fetchDataEffect(); }, [context, folderId, locale, pathname, projectId, router]); - const openDialogForCreate = () => { + const openDialogForCreate = (folderId: number | null = null) => { + setParentFolderId(folderId); setIsFolderDialogOpen(true); setEditingFolder(null); }; @@ -66,35 +69,30 @@ export default function FoldersPane({ projectId, messages, locale }: Props) { const closeDialog = () => { setIsFolderDialogOpen(false); setEditingFolder(null); + setParentFolderId(null); }; const onSubmit = async (name: string, detail: string) => { if (editingFolder) { - const updatedProject = await updateFolder( - context.token.access_token, - editingFolder.id, - name, - detail, - projectId, - null - ); - const updatedProjects = folders.map((project) => (project.id === updatedProject.id ? updatedProject : project)); - setFolders(updatedProjects); + await updateFolder(context.token.access_token, editingFolder.id, name, detail, projectId, parentFolderId); } else { - const newProject = await createFolder(context.token.access_token, name, detail, projectId, null); - setFolders([...folders, newProject]); + await createFolder(context.token.access_token, name, detail, projectId, parentFolderId); } + const fetchedFolders: FolderType[] = await fetchFolders(context.token.access_token, Number(projectId)); + const tree = buildFolderTree(fetchedFolders); + setTreeData(tree); closeDialog(); }; const onEditClick = (folder: FolderType) => { setEditingFolder(folder); + setParentFolderId(folder.parentFolderId); setIsFolderDialogOpen(true); }; - // Delete confirm dialog const [isDeleteConfirmDialogOpen, setIsDeleteConfirmDialogOpen] = useState(false); const [deleteFolderId, setDeleteFolderId] = useState(null); + const closeDeleteConfirmDialog = () => { setIsDeleteConfirmDialogOpen(false); setDeleteFolderId(null); @@ -108,48 +106,57 @@ export default function FoldersPane({ projectId, messages, locale }: Props) { const onConfirm = async () => { if (deleteFolderId) { await deleteFolder(context.token.access_token, deleteFolderId); - router.push(`/projects/${projectId}/folders`, { locale: locale }); + const fetchedFolders: FolderType[] = await fetchFolders(context.token.access_token, Number(projectId)); + const tree = buildFolderTree(fetchedFolders); + setTreeData(tree); + router.push(`/projects/${projectId}/folders`, { locale }); closeDeleteConfirmDialog(); } }; - const baseClass = ''; - const selectedClass = `${baseClass} bg-neutral-200 dark:bg-neutral-700`; - return ( <> -
+
- - {folders.map((folder, index) => ( - router.push(`/projects/${projectId}/folders/${folder.id}/cases`, { locale: locale })} - startContent={} - className={selectedFolder && folder.id === selectedFolder.id ? selectedClass : baseClass} - endContent={ - - } - > - {folder.name} - - ))} - + + {treeData.length > 0 && ( + + {(props) => ( + + )} + + )}
(defaultTestRun); - const [folders, setFolders] = useState([]); + const [treeData, setTreeData] = useState([]); const [runStatusCounts, setRunStatusCounts] = useState([]); const [selectedKeys, setSelectedKeys] = useState(new Set([])); - const [selectedFolder, setSelectedFolder] = useState(null); + const [selectedFolder, setSelectedFolder] = useState(null); const [testCases, setTestCases] = useState([]); const [filteredTestCases, setFilteredTestCases] = useState([]); const [isNameInvalid] = useState(false); @@ -130,7 +132,8 @@ export default function RunEditor({ try { await fetchRunAndStatusCount(); const foldersData = await fetchFolders(tokenContext.token.access_token, Number(projectId)); - setFolders(foldersData); + const tree = buildFolderTree(foldersData); + setTreeData(tree); setSelectedFolder(foldersData[0]); initTestCases(); } catch (error: unknown) { @@ -146,7 +149,7 @@ export default function RunEditor({ function onFilter() { if (selectedFolder && selectedFolder.id) { try { - const filteredData = testCases.filter((testCase) => testCase.folderId === selectedFolder.id); + const filteredData = testCases.filter((testCase) => testCase.folderId.toString() === selectedFolder.id); setFilteredTestCases(filteredData); } catch (error: unknown) { logError('Error filtering test cases', error); @@ -198,9 +201,6 @@ export default function RunEditor({ setIsDirty(false); }; - const baseClass = ''; - const selectedClass = `${baseClass} bg-neutral-200 dark:bg-neutral-700`; - const handleExportTypeChange = (keys: Selection) => { setExportType(new Set(Array.from(keys as Set))); }; @@ -386,21 +386,49 @@ export default function RunEditor({
- - {folders.map((folder, index) => ( - { - setSelectedKeys(new Set([])); // reset selection - setSelectedFolder(folder); + + {({ node, style }: { node: NodeApi; style: React.CSSProperties }) => ( + { + setSelectedKeys(new Set([])); + setSelectedFolder(node.data); }} - startContent={} - className={selectedFolder && folder.id === selectedFolder.id ? selectedClass : baseClass} - > - {folder.name} - - ))} - + toggleButton={ + node.data.children && node.data.children.length > 0 ? ( + + ) : null + } + icon={} + label={node.data.name} + /> + )} +
(); + + folders.forEach((folder) => { + folderMap.set(folder.id, { + id: folder.id.toString(), + name: folder.name, + detail: folder.detail, + parentFolderId: folder.parentFolderId, + projectId: folder.projectId, + folderData: folder, + children: [], + }); + }); + + const tree: TreeNodeData[] = []; + + folders.forEach((folder) => { + const currentNode = folderMap.get(folder.id); + + if (!currentNode) return; + + if (folder.parentFolderId === null) { + tree.push(currentNode); + } else { + const parent = folderMap.get(folder.parentFolderId); + if (parent && parent.children) { + parent.children.push(currentNode); + } + } + }); + + return tree; +}