From 424b1bdbc74068501dc950bf9accd68e33d0001c Mon Sep 17 00:00:00 2001 From: Eliezer Castro Date: Wed, 6 Aug 2025 10:26:38 -0300 Subject: [PATCH 1/5] feat: nested folders (#266) --- ...175803-add-cascade-delete-parent-folder.js | 22 +++ backend/models/folders.js | 6 + frontend/components/TreeItem.tsx | 46 ++++++ frontend/package-lock.json | 139 ++++++++++++++++++ frontend/package.json | 1 + .../[projectId]/folders/FolderTree.tsx | 83 +++++++++++ .../[projectId]/folders/FoldersPane.tsx | 115 ++++++++------- .../[projectId]/runs/[runId]/RunEditor.tsx | 78 ++++++---- frontend/types/folder.ts | 10 ++ frontend/utils/buildFolderTree.ts | 36 +++++ 10 files changed, 457 insertions(+), 79 deletions(-) create mode 100644 backend/migrations/20250804175803-add-cascade-delete-parent-folder.js create mode 100644 frontend/components/TreeItem.tsx create mode 100644 frontend/src/app/[locale]/projects/[projectId]/folders/FolderTree.tsx create mode 100644 frontend/utils/buildFolderTree.ts 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; +} From 517d480eef9361d0b2ae4485a19b07851112189a Mon Sep 17 00:00:00 2001 From: kimatata <117462761+kimatata@users.noreply.github.com> Date: Sat, 9 Aug 2025 19:59:52 +0900 Subject: [PATCH 2/5] chore: add test for build folder tree (#270) --- frontend/utils/buildFolderTree.test.ts | 63 ++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 frontend/utils/buildFolderTree.test.ts diff --git a/frontend/utils/buildFolderTree.test.ts b/frontend/utils/buildFolderTree.test.ts new file mode 100644 index 0000000..6c277bb --- /dev/null +++ b/frontend/utils/buildFolderTree.test.ts @@ -0,0 +1,63 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { describe, it, expect } from 'vitest'; +import { buildFolderTree } from './buildFolderTree'; +import type { FolderType } from '@/types/folder'; + +describe('buildFolderTree', () => { + it('should return an empty array when input is empty', () => { + expect(buildFolderTree([])).toEqual([]); + }); + + it('should build a single root node', () => { + const folders: FolderType[] = [ + { id: 1, name: 'root', detail: '', projectId: 1, parentFolderId: null, createdAt: '', updatedAt: '', Cases: [] }, + ]; + const tree = buildFolderTree(folders); + expect(tree.length).toBe(1); + expect(tree[0].id).toBe('1'); + expect(tree[0].children).toEqual([]); + }); + + it('should build a tree with parent and child', () => { + const folders: FolderType[] = [ + { id: 1, name: 'root', detail: '', projectId: 1, parentFolderId: null, createdAt: '', updatedAt: '', Cases: [] }, + { id: 2, name: 'child', detail: '', projectId: 1, parentFolderId: 1, createdAt: '', updatedAt: '', Cases: [] }, + ]; + const tree = buildFolderTree(folders); + expect(tree.length).toBe(1); + expect(tree[0].id).toBe('1'); + expect(tree[0]!.children!.length).toBe(1); + expect(tree[0]!.children![0].id).toBe('2'); + }); + + it('should build a tree with multiple levels', () => { + const folders: FolderType[] = [ + { id: 1, name: 'root', detail: '', projectId: 1, parentFolderId: null, createdAt: '', updatedAt: '', Cases: [] }, + { id: 2, name: 'child', detail: '', projectId: 1, parentFolderId: 1, createdAt: '', updatedAt: '', Cases: [] }, + { + id: 3, + name: 'grandchild', + detail: '', + projectId: 1, + parentFolderId: 2, + createdAt: '', + updatedAt: '', + Cases: [], + }, + ]; + const tree = buildFolderTree(folders); + expect(tree.length).toBe(1); + expect(tree![0].children![0].children![0].id).toBe('3'); + }); + + it('should build a forest if multiple roots', () => { + const folders: FolderType[] = [ + { id: 1, name: 'root1', detail: '', projectId: 1, parentFolderId: null, createdAt: '', updatedAt: '', Cases: [] }, + { id: 2, name: 'root2', detail: '', projectId: 1, parentFolderId: null, createdAt: '', updatedAt: '', Cases: [] }, + ]; + const tree = buildFolderTree(folders); + expect(tree.length).toBe(2); + expect(tree.map((n) => n.id)).toContain('1'); + expect(tree.map((n) => n.id)).toContain('2'); + }); +}); From 763a3ab8329871a28f1985097b309fecd19837ac Mon Sep 17 00:00:00 2001 From: kimatata <117462761+kimatata@users.noreply.github.com> Date: Sat, 9 Aug 2025 20:11:52 +0900 Subject: [PATCH 3/5] fix: tsx file name (#271) --- .../[projectId]/folders/{FolderTree.tsx => FolderItem.tsx} | 0 .../app/[locale]/projects/[projectId]/folders/FoldersPane.tsx | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename frontend/src/app/[locale]/projects/[projectId]/folders/{FolderTree.tsx => FolderItem.tsx} (100%) diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/FolderTree.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/FolderItem.tsx similarity index 100% rename from frontend/src/app/[locale]/projects/[projectId]/folders/FolderTree.tsx rename to frontend/src/app/[locale]/projects/[projectId]/folders/FolderItem.tsx diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/FoldersPane.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/FoldersPane.tsx index de9e3a3..d78409a 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/folders/FoldersPane.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/FoldersPane.tsx @@ -4,8 +4,8 @@ import { Button } from '@heroui/react'; import { Plus } from 'lucide-react'; import { Tree } from 'react-arborist'; import FolderDialog from './FolderDialog'; +import FolderItem from './FolderItem'; 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'; From 905eda068fc1e2c331e309f56fc9dcb63ca3963b Mon Sep 17 00:00:00 2001 From: kimatata <117462761+kimatata@users.noreply.github.com> Date: Sun, 10 Aug 2025 12:58:41 +0900 Subject: [PATCH 4/5] chore: sidebar design update on project page (#272) --- frontend/messages/en.json | 1 + frontend/messages/ja.json | 1 + frontend/messages/pt-BR.json | 1 + frontend/package-lock.json | 9 +- frontend/package.json | 2 +- .../[locale]/projects/[projectId]/Sidebar.tsx | 85 +++++++++++++------ .../[locale]/projects/[projectId]/layout.tsx | 1 + frontend/types/project.ts | 1 + 8 files changed, 69 insertions(+), 32 deletions(-) diff --git a/frontend/messages/en.json b/frontend/messages/en.json index aebb48e..709bbb5 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -154,6 +154,7 @@ }, "Project": { "project": "Project", + "toggle_sidebar": "Toggle Sidebar", "home": "Home", "test_cases": "Test Cases", "test_runs": "Test Runs", diff --git a/frontend/messages/ja.json b/frontend/messages/ja.json index 621bede..09f1788 100644 --- a/frontend/messages/ja.json +++ b/frontend/messages/ja.json @@ -155,6 +155,7 @@ }, "Project": { "project": "プロジェクト", + "toggle_sidebar": "サイドバーの切り替え", "home": "ホーム", "test_cases": "テストケース", "test_runs": "テストラン", diff --git a/frontend/messages/pt-BR.json b/frontend/messages/pt-BR.json index ff7afec..4d4d2cc 100644 --- a/frontend/messages/pt-BR.json +++ b/frontend/messages/pt-BR.json @@ -154,6 +154,7 @@ }, "Project": { "project": "Projeto", + "toggle_sidebar": "Alternar barra lateral", "home": "Início", "test_cases": "Casos de Teste", "test_runs": "Execuções de Teste", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1f64ca3..10af470 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,7 +23,7 @@ "dayjs": "^1.11.13", "framer-motion": "^11.18.2", "intl-messageformat": "^10.7.15", - "lucide-react": "^0.475.0", + "lucide-react": "^0.539.0", "next": "^14.2.24", "next-intl": "^3.26.5", "next-themes": "^0.4.4", @@ -10502,9 +10502,10 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" }, "node_modules/lucide-react": { - "version": "0.475.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.475.0.tgz", - "integrity": "sha512-NJzvVu1HwFVeZ+Gwq2q00KygM1aBhy/ZrhY9FsAgJtpB+E4R7uxRk9M2iKvHa6/vNxZydIB59htha4c2vvwvVg==", + "version": "0.539.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.539.0.tgz", + "integrity": "sha512-VVISr+VF2krO91FeuCrm1rSOLACQUYVy7NQkzrOty52Y8TlTPcXcMdQFj9bYzBgXbWCiywlwSZ3Z8u6a+6bMlg==", + "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } diff --git a/frontend/package.json b/frontend/package.json index f87a34e..e957082 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,7 +24,7 @@ "dayjs": "^1.11.13", "framer-motion": "^11.18.2", "intl-messageformat": "^10.7.15", - "lucide-react": "^0.475.0", + "lucide-react": "^0.539.0", "next": "^14.2.24", "next-intl": "^3.26.5", "next-themes": "^0.4.4", diff --git a/frontend/src/app/[locale]/projects/[projectId]/Sidebar.tsx b/frontend/src/app/[locale]/projects/[projectId]/Sidebar.tsx index 82fedd5..dc3b317 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/Sidebar.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/Sidebar.tsx @@ -1,7 +1,15 @@ 'use client'; import { useState, useEffect } from 'react'; -import { Listbox, ListboxItem } from '@heroui/react'; -import { Home, Files, FlaskConical, Users, Settings } from 'lucide-react'; +import { Button, Tooltip } from '@heroui/react'; +import { + PanelLeftClose, + PanelLeftOpen, + ChartColumnStacked, + ClipboardList, + FlaskConical, + UserRound, + Settings, +} from 'lucide-react'; import { usePathname, useRouter } from '@/src/i18n/routing'; import useGetCurrentIds from '@/utils/useGetCurrentIds'; import { ProjectMessages } from '@/types/project'; @@ -16,11 +24,15 @@ export default function Sidebar({ messages, locale }: Props) { const router = useRouter(); const pathname = usePathname(); - const [currentKey, setCurrentTab] = useState('home'); - const baseClass = 'p-3'; - const selectedClass = `${baseClass} bg-neutral-200 dark:bg-neutral-700`; + const [currentKey, setCurrentKey] = useState('home'); + const [isSideBarOpen, setIsSideBarOpen] = useState(true); - const handleTabClick = (key: string) => { + const TOGGLE_ICON_STROKE_WIDTH = 1; + const TOGGLE_ICON_SIZE = 18; + const ICON_STROKE_WIDTH = 1; + const ICON_SIZE = 26; + + const handleClick = (key: string) => { if (key === 'home') { router.push(`/projects/${projectId}/home`, { locale: locale }); } else if (key === 'cases') { @@ -37,15 +49,15 @@ export default function Sidebar({ messages, locale }: Props) { useEffect(() => { const handleRouteChange = (currentPath: string) => { if (currentPath.includes('home')) { - setCurrentTab('home'); + setCurrentKey('home'); } else if (currentPath.includes('folders')) { - setCurrentTab('cases'); + setCurrentKey('cases'); } else if (currentPath.includes('runs')) { - setCurrentTab('runs'); + setCurrentKey('runs'); } else if (currentPath.includes('members')) { - setCurrentTab('members'); + setCurrentKey('members'); } else if (currentPath.includes('settings')) { - setCurrentTab('settings'); + setCurrentKey('settings'); } }; @@ -56,44 +68,63 @@ export default function Sidebar({ messages, locale }: Props) { { key: 'home', text: messages.home, - startContent: , + startContent: , }, { key: 'cases', text: messages.testCases, - startContent: , + startContent: , }, { key: 'runs', text: messages.testRuns, - startContent: , + startContent: , }, { key: 'members', text: messages.members, - startContent: , + startContent: , }, { key: 'settings', text: messages.settings, - startContent: , + startContent: , }, ]; return ( -
- +
+
+ + + +
+ +
{tabItems.map((itr) => ( - handleTabClick(itr.key)} - className={currentKey === itr.key ? selectedClass : baseClass} - > - {itr.text} - +
+ +
))} - +
); } diff --git a/frontend/src/app/[locale]/projects/[projectId]/layout.tsx b/frontend/src/app/[locale]/projects/[projectId]/layout.tsx index 0fb1744..b5dd1b5 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/layout.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/layout.tsx @@ -11,6 +11,7 @@ export default function SidebarLayout({ }) { const t = useTranslations('Project'); const messages: ProjectMessages = { + toggleSidebar: t('toggle_sidebar'), home: t('home'), testCases: t('test_cases'), testRuns: t('test_runs'), diff --git a/frontend/types/project.ts b/frontend/types/project.ts index da7045d..a880e24 100644 --- a/frontend/types/project.ts +++ b/frontend/types/project.ts @@ -40,6 +40,7 @@ export type ProjectsMessages = { }; export type ProjectMessages = { + toggleSidebar: string; home: string; testCases: string; testRuns: string; From da550c3024c33288758676b825e4355c400a747a Mon Sep 17 00:00:00 2001 From: kimatata <117462761+kimatata@users.noreply.github.com> Date: Sun, 10 Aug 2025 13:04:30 +0900 Subject: [PATCH 5/5] chore: update version (#273) Co-authored-by: kimatata <> --- frontend/src/app/[locale]/health/HealthPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/[locale]/health/HealthPage.tsx b/frontend/src/app/[locale]/health/HealthPage.tsx index 4b04333..441e7df 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.17 + 1.0.0-beta.18 {messages.api_server}