From 25fa7fdebe1b08ec3a11b701ade9d3a383934883 Mon Sep 17 00:00:00 2001 From: Stefanos Hadjipetrou Date: Sun, 10 May 2026 23:14:51 +0300 Subject: [PATCH 1/3] feat: initial folder system implementation --- src/app/assets/vectors/EmptyFolder.svg | 14 + src/app/assets/vectors/FolderBig.svg | 14 + src/app/hooks/queries/report-builder.ts | 127 +++++++- .../components/all-reports-view/cards.tsx | 301 ++++++++++++++++++ .../components/all-reports-view/index.tsx | 247 +++++++------- .../components/move-to-folder-modal/data.ts | 7 + .../components/move-to-folder-modal/index.tsx | 78 +++++ .../main/components/new-folder-modal/data.ts | 7 + .../components/new-folder-modal/index.tsx | 43 ++- .../main/components/new-report-modal/data.ts | 8 + .../components/new-report-modal/index.tsx | 38 +-- src/app/pages/report-builder/main/index.tsx | 142 ++++++++- .../action-reducers/report-builder/sync.ts | 21 ++ 13 files changed, 861 insertions(+), 186 deletions(-) create mode 100644 src/app/assets/vectors/EmptyFolder.svg create mode 100644 src/app/assets/vectors/FolderBig.svg create mode 100644 src/app/pages/report-builder/main/components/all-reports-view/cards.tsx create mode 100644 src/app/pages/report-builder/main/components/move-to-folder-modal/data.ts create mode 100644 src/app/pages/report-builder/main/components/move-to-folder-modal/index.tsx create mode 100644 src/app/pages/report-builder/main/components/new-folder-modal/data.ts create mode 100644 src/app/pages/report-builder/main/components/new-report-modal/data.ts diff --git a/src/app/assets/vectors/EmptyFolder.svg b/src/app/assets/vectors/EmptyFolder.svg new file mode 100644 index 000000000..ddf732e54 --- /dev/null +++ b/src/app/assets/vectors/EmptyFolder.svg @@ -0,0 +1,14 @@ + + + diff --git a/src/app/assets/vectors/FolderBig.svg b/src/app/assets/vectors/FolderBig.svg new file mode 100644 index 000000000..a16ffc1b5 --- /dev/null +++ b/src/app/assets/vectors/FolderBig.svg @@ -0,0 +1,14 @@ + + + diff --git a/src/app/hooks/queries/report-builder.ts b/src/app/hooks/queries/report-builder.ts index 263f664b1..56a1c0156 100644 --- a/src/app/hooks/queries/report-builder.ts +++ b/src/app/hooks/queries/report-builder.ts @@ -15,6 +15,8 @@ import { RBReportPatchModel, RBAssetModel, RBAssetModelResponse, + RBFolderModel, + RBFolderModelResponse, } from "app/state/api/action-reducers/report-builder/sync"; import { AssetViewType } from "app/pages/report-builder/main/components/all-assets-view/toolbar"; @@ -34,6 +36,14 @@ export const useCreateAsset = () => { }); }; +export const useCreateFolder = () => { + return useMutation({ + mutationKey: ["ReportBuilderCreateFolder"], + mutationFn: (data: RBFolderModel) => + axiosInstance.post(`/folder`, data), + }); +}; + export const useGetReport = (reportId?: string) => { return useQuery({ queryKey: ["ReportBuilderGetReport", reportId], @@ -52,7 +62,21 @@ export const useGetAsset = (assetId?: string) => { }); }; -export const useGetReports = (params: { sort: string; search: string }) => { +export const useGetFolder = (folderId?: string) => { + return useQuery({ + queryKey: ["ReportBuilderGetFolder", folderId], + queryFn: () => + axiosInstance.get(`/folder/${folderId}`), + enabled: !!folderId, + staleTime: 1000 * 60 * 5, // 5 minutes + }); +}; + +export const useGetReports = (params: { + sort: string; + search: string; + includeFolders?: boolean; +}) => { // TODO: cache and manage invalidation let filter = ""; if (params.search) { @@ -61,10 +85,19 @@ export const useGetReports = (params: { sort: string; search: string }) => { filter = `{"order":["${params.sort}"]}`; } return useQuery({ - queryKey: ["ReportBuilderGetReports", params.search, params.sort], + queryKey: [ + "ReportBuilderGetReports", + params.search, + params.sort, + params.includeFolders, + ], queryFn: () => axiosInstance.get(`/reports`, { - params: { filter }, + params: { + filter, + folderFilter: filter, + includeFolders: params.includeFolders, + }, }), staleTime: 1000 * 60 * 5, }); @@ -75,6 +108,16 @@ export const useGetAssets = (params: { search: string; type: AssetViewType; }) => { + let filter = ""; + if (params.search && params.type !== "all") { + filter = `{"where":{"name":{"like":".*${params.search}.*","options":"i"},"type":"${params.type}"}},"order":["${params.sort}"]}`; + } else if (params.search && params.type === "all") { + filter = `{"where":{"name":{"like":".*${params.search}.*","options":"i"}},"order":["${params.sort}"]}`; + } else if (!params.search && params.type !== "all") { + filter = `{"where":{"type":"${params.type}"},"order":["${params.sort}"]}`; + } else { + filter = `{"order":["${params.sort}"]}`; + } return useQuery({ queryKey: [ "ReportBuilderGetAssets", @@ -84,8 +127,35 @@ export const useGetAssets = (params: { ], queryFn: () => axiosInstance.get(`/assets`, { + params: { filter }, + }), + staleTime: 1000 * 60 * 5, + }); +}; + +export const useGetFolders = (params: { + sort: string; + search: string; + includeSubFolders?: boolean; +}) => { + let filter = ""; + if (params.search) { + filter = `{"where":{"name":{"like":".*${params.search}.*","options":"i"}},"order":["${params.sort}"]}`; + } else { + filter = `{"order":["${params.sort}"]}`; + } + return useQuery({ + queryKey: [ + "ReportBuilderGetFolders", + params.search, + params.sort, + params.includeSubFolders, + ], + queryFn: () => + axiosInstance.get(`/folders`, { params: { - filter: `{"where":{"name":{"like":".*${params.search}.*","options":"i"}${params.type !== "all" ? `,"type":"${params.type}"` : ""}},"order":["${params.sort}"]}`, + filter, + includeSubFolders: Boolean(params.includeSubFolders), }, }), staleTime: 1000 * 60 * 5, @@ -138,6 +208,33 @@ export const usePatchAsset = (assetId?: string) => { }); }; +export const usePatchFolder = (folderId?: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ["ReportBuilderPatchFolder", folderId], + mutationFn: (data: Partial) => + axiosInstance.patch(`/folder/${folderId}`, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["ReportBuilderGetFolders"] }); + queryClient.invalidateQueries({ + queryKey: ["ReportBuilderGetFolder", folderId], + }); + }, + }); +}; + +export const usePatchFolder2 = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ["ReportBuilderPatchFolder"], + mutationFn: (data: Partial) => + axiosInstance.patch(`/folder/${data.id}`, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["ReportBuilderGetFolders"] }); + }, + }); +}; + export const useDeleteReport = () => { return useMutation({ mutationKey: ["ReportBuilderDeleteReport"], @@ -156,6 +253,17 @@ export const useDeleteAsset = () => { }); }; +export const useDeleteFolder = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ["ReportBuilderDeleteFolder"], + mutationFn: (id: string) => axiosInstance.delete(`/folder/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["ReportBuilderGetFolders"] }); + }, + }); +}; + export const useDuplicateReport = () => { return useMutation({ mutationKey: ["ReportBuilderDuplicateReport"], @@ -174,6 +282,17 @@ export const useDuplicateAsset = () => { }); }; +export const useDuplicateFolder = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ["ReportBuilderDuplicateFolder"], + mutationFn: (id: string) => axiosInstance.get(`/folder/duplicate/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["ReportBuilderGetFolders"] }); + }, + }); +}; + export const useRenderChartData = () => { return useMutation({ mutationKey: ["ReportBuilderRenderChartData"], diff --git a/src/app/pages/report-builder/main/components/all-reports-view/cards.tsx b/src/app/pages/report-builder/main/components/all-reports-view/cards.tsx new file mode 100644 index 000000000..9a23ab795 --- /dev/null +++ b/src/app/pages/report-builder/main/components/all-reports-view/cards.tsx @@ -0,0 +1,301 @@ +import React from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import IconButton from "@mui/material/IconButton"; +import MoreVert from "@mui/icons-material/MoreVert"; +import FolderIcon from "app/assets/vectors/FolderBig.svg?react"; +import EmptyFolderIcon from "app/assets/vectors/EmptyFolder.svg?react"; + +export const ReportCard: React.FC<{ + id: string; + name: string; + description: string; + createdDate: string; + updatedDate: string; + selectedItemForRenaming: string | null; + setSelectedItemForRenaming: (id: string | null) => void; + handleRenameEnter: (id: string, type: "report" | "folder") => void; + handleItemMenuClick: (event: React.MouseEvent) => void; + handleItemClick: (id: string, type: "report" | "folder") => () => void; + handleEditClick: (id: string) => () => void; +}> = ({ + id, + name, + description, + selectedItemForRenaming, + setSelectedItemForRenaming, + handleRenameEnter, + handleItemMenuClick, + handleItemClick, + handleEditClick, +}) => { + return ( + + + +
+ + + {selectedItemForRenaming === id ? ( + { + if (e.relatedTarget?.id === "rb-item-menu-paper") { + return; + } + handleRenameEnter(id, "report"); + }} + onKeyDown={(e) => { + if (e.key === "Escape") { + setSelectedItemForRenaming(null); + } + if (e.key === "Enter") { + handleRenameEnter(id, "report"); + } + }} + sx={{ + input: { + fontWeight: "700", + pl: "0 !important", + }, + ".MuiInputBase-root:before, .MuiInputBase-root:after": { + borderBottom: "2px solid #3154F4 !important", + }, + }} + /> + ) : ( + + {name} + + )} + + + {description} + + + button:not(:last-child)": { + flex: "1", + fontSize: "14px", + bgcolor: "#fff", + fontWeight: "400", + height: "36px", + borderRadius: "4px", + lineHeight: "normal", + textTransform: "none", + border: "1px solid #98a1aa", + ":hover": { + borderColor: "#3154f4", + }, + }, + }} + > + + + + + + + + ); +}; + +export const FolderCard: React.FC<{ + id: string; + name: string; + assetCount: number; + reportCount: number; + createdDate: string; + updatedDate: string; + selectedItemForRenaming: string | null; + setSelectedItemForRenaming: (id: string | null) => void; + handleRenameEnter: (id: string, type: "report" | "folder") => void; + handleItemMenuClick: (event: React.MouseEvent) => void; + handleItemClick: (id: string, type: "report" | "folder") => () => void; +}> = ({ + id, + name, + assetCount, + reportCount, + selectedItemForRenaming, + setSelectedItemForRenaming, + handleRenameEnter, + handleItemMenuClick, + handleItemClick, +}) => { + const text = React.useMemo(() => { + if (assetCount === 0 && reportCount === 0) { + return "Empty Folder"; + } + if (assetCount > 0 && reportCount === 0) { + return `${assetCount} ${assetCount === 1 ? "Asset" : "Assets"} inside`; + } + if (assetCount === 0 && reportCount > 0) { + return `${reportCount} ${reportCount === 1 ? "Report" : "Reports"} inside`; + } + return `${reportCount} ${reportCount === 1 ? "Report" : "Reports"} and ${assetCount} ${assetCount === 1 ? "Asset" : "Assets"} inside`; + }, [assetCount, reportCount]); + + return ( + + + + {assetCount === 0 && reportCount === 0 ? ( + + + {text} + + ) : ( + + + {text} + + )} + + + {selectedItemForRenaming === id ? ( + { + if (e.relatedTarget?.id === "rb-item-menu-paper") { + return; + } + handleRenameEnter(id, "folder"); + }} + onKeyDown={(e) => { + if (e.key === "Escape") { + setSelectedItemForRenaming(null); + } + if (e.key === "Enter") { + handleRenameEnter(id, "folder"); + } + }} + sx={{ + input: { + fontWeight: "700", + pl: "0 !important", + }, + ".MuiInputBase-root:before, .MuiInputBase-root:after": { + borderBottom: "2px solid #3154F4 !important", + }, + }} + /> + ) : ( + + {name} + + )} + + + button:not(:last-child)": { + flex: "1", + fontSize: "14px", + bgcolor: "#fff", + fontWeight: "400", + height: "36px", + borderRadius: "4px", + lineHeight: "normal", + textTransform: "none", + border: "1px solid #98a1aa", + ":hover": { + borderColor: "#3154f4", + }, + }, + }} + > + + + + + + + ); +}; diff --git a/src/app/pages/report-builder/main/components/all-reports-view/index.tsx b/src/app/pages/report-builder/main/components/all-reports-view/index.tsx index 5a1e801ae..c55d6dbc0 100644 --- a/src/app/pages/report-builder/main/components/all-reports-view/index.tsx +++ b/src/app/pages/report-builder/main/components/all-reports-view/index.tsx @@ -1,19 +1,21 @@ import React from "react"; import Box from "@mui/material/Box"; import Grid from "@mui/material/Grid"; -import Button from "@mui/material/Button"; import { Table } from "app/components/table"; import { useNavigate } from "react-router-dom"; -import TextField from "@mui/material/TextField"; import { CellComponent } from "tabulator-tables"; -import Typography from "@mui/material/Typography"; -import IconButton from "@mui/material/IconButton"; -import MoreVert from "@mui/icons-material/MoreVert"; import CircularProgress from "@mui/material/CircularProgress"; import { ReportBuilderItemMenu } from "app/pages/report-builder/main/components/item-menu"; import { + ReportCard, + FolderCard, +} from "app/pages/report-builder/main/components/all-reports-view/cards"; +import { + useDeleteFolder, useDeleteReport, + useDuplicateFolder, useDuplicateReport, + usePatchFolder2, usePatchReport2, } from "app/hooks/queries/report-builder"; import { @@ -28,6 +30,7 @@ import { export const AllReportsView: React.FC<{ refetch: () => void; selectedView: "cards" | "list"; + handleFolderOpen: (id: string) => void; reports: { isLoading: boolean; data: { @@ -36,13 +39,19 @@ export const AllReportsView: React.FC<{ description: string; createdDate: string; updatedDate: string; + isFolder?: boolean; + assetCount?: number; + reportCount?: number; }[]; }; -}> = ({ selectedView, reports, refetch }) => { +}> = ({ selectedView, reports, refetch, handleFolderOpen }) => { const navigate = useNavigate(); const deleteReport = useDeleteReport(); + const deleteFolder = useDeleteFolder(); const updateReport = usePatchReport2(); + const updateFolder = usePatchFolder2(); const duplicateReport = useDuplicateReport(); + const duplicateFolder = useDuplicateFolder(); const [anchorEl, setAnchorEl] = React.useState(null); const [selectedItemForRenaming, setSelectedItemForRenaming] = React.useState< @@ -56,6 +65,8 @@ export const AllReportsView: React.FC<{ const getAnchorElId = () => anchorEl?.getAttribute("id"); + const getAnchorElName = () => anchorEl?.getAttribute("name"); + const handleRename = () => { const id = getAnchorElId(); if (!id) return; @@ -69,7 +80,7 @@ export const AllReportsView: React.FC<{ }, 100); }; - const handleRenameEnter = (id: string) => { + const handleRenameEnter = (id: string, type: "report" | "folder") => { const name = ( document.getElementById(`rename-field-${id}`) as HTMLInputElement )?.value; @@ -77,36 +88,65 @@ export const AllReportsView: React.FC<{ setSelectedItemForRenaming(null); return; } - updateReport.mutate( - { id, name }, - { - onSuccess: () => { - setSelectedItemForRenaming(null); + if (type === "folder") { + updateFolder.mutate( + { id, name }, + { + onSuccess: () => { + setSelectedItemForRenaming(null); + }, }, - }, - ); + ); + } else { + updateReport.mutate( + { id, name }, + { + onSuccess: () => { + setSelectedItemForRenaming(null); + }, + }, + ); + } }; const handleDuplicate = () => { const id = getAnchorElId(); + const isFolder = getAnchorElName() === "folder"; if (!id) return; setAnchorEl(null); - duplicateReport.mutate(id, { - onSuccess: () => refetch(), - }); + if (isFolder) { + duplicateFolder.mutate(id, { + onSuccess: () => refetch(), + }); + } else { + duplicateReport.mutate(id, { + onSuccess: () => refetch(), + }); + } }; const handleDelete = () => { const id = getAnchorElId(); + const isFolder = getAnchorElName() === "folder"; if (!id) return; setAnchorEl(null); - deleteReport.mutate(id, { - onSuccess: () => refetch(), - }); + if (isFolder) { + deleteFolder.mutate(id, { + onSuccess: () => refetch(), + }); + } else { + deleteReport.mutate(id, { + onSuccess: () => refetch(), + }); + } }; - const handleItemClick = (id: string) => () => { - navigate(`/report-builder/reports/${id}`); + const handleItemClick = (id: string, type: "report" | "folder") => () => { + if (type === "folder") { + handleFolderOpen(id); + } else { + navigate(`/report-builder/reports/${id}`); + } }; const handleEditClick = (id: string) => () => { @@ -115,7 +155,8 @@ export const AllReportsView: React.FC<{ const handleTableCellClick = (_e: UIEvent, cell: CellComponent) => { const id = cell.getRow().getData()?.id; - if (id) handleItemClick(id)(); + const type = cell.getRow().getData()?.type; + if (id) handleItemClick(id, type === "Folder" ? "folder" : "report")(); }; const view = React.useMemo(() => { @@ -138,126 +179,52 @@ export const AllReportsView: React.FC<{ return ( {reports.data.map((item) => ( - - - -
- - - {selectedItemForRenaming === item.id ? ( - { - if (e.relatedTarget?.id === "rb-item-menu-paper") { - return; - } - handleRenameEnter(item.id); - }} - onKeyDown={(e) => { - if (e.key === "Escape") { - setSelectedItemForRenaming(null); - } - if (e.key === "Enter") { - handleRenameEnter(item.id); - } - }} - sx={{ - input: { - fontWeight: "700", - pl: "0 !important", - }, - ".MuiInputBase-root:before, .MuiInputBase-root:after": { - borderBottom: "2px solid #3154F4 !important", - }, - }} - /> - ) : ( - - {item.name} - - )} - - - {item.description} - - + button:not(:last-child)": { - flex: "1", - fontSize: "14px", - bgcolor: "#fff", - fontWeight: "400", - height: "36px", - borderRadius: "4px", - lineHeight: "normal", - textTransform: "none", - border: "1px solid #98a1aa", + padding: "16px", + borderRadius: "4px", + flexDirection: "column", + justifyContent: "space-between", + border: `1px solid ${getAnchorElId() === item.id ? "#3154f4" : "#cfd4da"}`, + ":hover": { + borderColor: "#3154f4", }, }} > - - - - - + {!item.isFolder && ( + + )} + {item.isFolder && ( + + )} ))} @@ -313,6 +280,7 @@ export const AllReportsView: React.FC<{ id: item.id, name: item.name, description: item.description, + type: item.isFolder ? "Folder" : "Report", dateCreated: `${cdate.getDate()}-${cdate.getMonth() + 1}-${cdate.getFullYear()}`, dateEdited: `${edate.getDate()}-${edate.getMonth() + 1}-${edate.getFullYear()}`, }; @@ -320,7 +288,7 @@ export const AllReportsView: React.FC<{ columns={[ { title: "", field: "id", visible: false }, { - title: "Report name", + title: "Name", field: "name", width: "30%", cellClick: handleTableCellClick, @@ -328,8 +296,9 @@ export const AllReportsView: React.FC<{ `${cell.getValue()}`, }, { title: "Description", field: "description", width: "40%" }, - { title: "Date Created", field: "dateCreated", width: "15%" }, - { title: "Last Edited", field: "dateEdited", width: "15%" }, + { title: "Type", field: "type", width: "10%" }, + { title: "Date Created", field: "dateCreated", width: "10%" }, + { title: "Last Edited", field: "dateEdited", width: "10%" }, ]} /> ); diff --git a/src/app/pages/report-builder/main/components/move-to-folder-modal/data.ts b/src/app/pages/report-builder/main/components/move-to-folder-modal/data.ts new file mode 100644 index 000000000..214040232 --- /dev/null +++ b/src/app/pages/report-builder/main/components/move-to-folder-modal/data.ts @@ -0,0 +1,7 @@ +import { RBFolderModelResponse } from "app/state/api/action-reducers/report-builder/sync"; + +export interface ReportBuilderMoveToFolderModalProps { + open: boolean; + onClose: () => void; + folderStructure: RBFolderModelResponse[]; +} diff --git a/src/app/pages/report-builder/main/components/move-to-folder-modal/index.tsx b/src/app/pages/report-builder/main/components/move-to-folder-modal/index.tsx new file mode 100644 index 000000000..8ed60d20a --- /dev/null +++ b/src/app/pages/report-builder/main/components/move-to-folder-modal/index.tsx @@ -0,0 +1,78 @@ +import React from "react"; +import Box from "@mui/material/Box"; +import Modal from "@mui/material/Modal"; +import Button from "@mui/material/Button"; +import Typography from "@mui/material/Typography"; +import CloseIcon from "@mui/icons-material/Close"; +import IconButton from "@mui/material/IconButton"; +import { ReportBuilderMoveToFolderModalProps } from "app/pages/report-builder/main/components/move-to-folder-modal/data"; + +export const ReportBuilderMoveToFolderModal: React.FC< + ReportBuilderMoveToFolderModalProps +> = ({ open, onClose }) => { + return ( + + + + + Move to Folder + + + + + + + + + + + + + + ); +}; diff --git a/src/app/pages/report-builder/main/components/new-folder-modal/data.ts b/src/app/pages/report-builder/main/components/new-folder-modal/data.ts new file mode 100644 index 000000000..6e3b2ed91 --- /dev/null +++ b/src/app/pages/report-builder/main/components/new-folder-modal/data.ts @@ -0,0 +1,7 @@ +export interface ReportBuilderNewFolderModalProps { + open: boolean; + nameValue: string; + reload: () => void; + onClose: () => void; + setNameValue: (value: string) => void; +} diff --git a/src/app/pages/report-builder/main/components/new-folder-modal/index.tsx b/src/app/pages/report-builder/main/components/new-folder-modal/index.tsx index 6d511e7a4..d79d3002c 100644 --- a/src/app/pages/report-builder/main/components/new-folder-modal/index.tsx +++ b/src/app/pages/report-builder/main/components/new-folder-modal/index.tsx @@ -5,13 +5,28 @@ import Button from "@mui/material/Button"; import Typography from "@mui/material/Typography"; import CloseIcon from "@mui/icons-material/Close"; import IconButton from "@mui/material/IconButton"; +import { useCreateFolder } from "app/hooks/queries/report-builder"; +import { ReportBuilderNewFolderModalProps } from "app/pages/report-builder/main/components/new-folder-modal/data"; + +export const ReportBuilderNewFolderModal: React.FC< + ReportBuilderNewFolderModalProps +> = ({ open, onClose, nameValue, setNameValue, reload }) => { + const createFolder = useCreateFolder(); + + const onSubmit = () => { + if (nameValue) { + const newFolder = { name: nameValue }; + + createFolder.mutate(newFolder, { + onSuccess: () => { + reload(); + onClose(); + setNameValue(""); + }, + }); + } + }; -export const ReportBuilderNewFolderModal: React.FC<{ - open: boolean; - nameValue: string; - onClose: () => void; - setNameValue: (value: string) => void; -}> = ({ open, onClose, nameValue, setNameValue }) => { return ( Create Folder - - + + - + Folder Name Create Folder diff --git a/src/app/pages/report-builder/main/components/new-report-modal/data.ts b/src/app/pages/report-builder/main/components/new-report-modal/data.ts new file mode 100644 index 000000000..4819fc393 --- /dev/null +++ b/src/app/pages/report-builder/main/components/new-report-modal/data.ts @@ -0,0 +1,8 @@ +export interface ReportBuilderNewReportModalProps { + open: boolean; + nameValue: string; + onClose: () => void; + descriptionValue: string; + setNameValue: (value: string) => void; + setDescriptionValue: (value: string) => void; +} diff --git a/src/app/pages/report-builder/main/components/new-report-modal/index.tsx b/src/app/pages/report-builder/main/components/new-report-modal/index.tsx index 85345bbed..0c8ba4933 100644 --- a/src/app/pages/report-builder/main/components/new-report-modal/index.tsx +++ b/src/app/pages/report-builder/main/components/new-report-modal/index.tsx @@ -8,15 +8,11 @@ import CloseIcon from "@mui/icons-material/Close"; import IconButton from "@mui/material/IconButton"; import { useCreateReport } from "app/hooks/queries/report-builder"; import { PageLoader } from "app/components/page-loader"; +import { ReportBuilderNewReportModalProps } from "app/pages/report-builder/main/components/new-report-modal/data"; -export const ReportBuilderNewReportModal: React.FC<{ - open: boolean; - nameValue: string; - onClose: () => void; - descriptionValue: string; - setNameValue: (value: string) => void; - setDescriptionValue: (value: string) => void; -}> = ({ +export const ReportBuilderNewReportModal: React.FC< + ReportBuilderNewReportModalProps +> = ({ open, onClose, nameValue, @@ -84,7 +80,7 @@ export const ReportBuilderNewReportModal: React.FC<{ sx={{ width: "100%", display: "flex", - padding: "10px", + padding: "4px 10px", flexDirection: "row", alignItems: "center", justifyContent: "space-between", @@ -94,8 +90,8 @@ export const ReportBuilderNewReportModal: React.FC<{ Create a New Report - - + + - + Report Name - + {nameValue.length}/100 @@ -124,10 +120,10 @@ export const ReportBuilderNewReportModal: React.FC<{ marginBottom: "20px", input: { width: "100%", + borderRadius: "4px", padding: "11px 16px", - background: "#f1f3f5", - border: "2px solid #f1f3f5", - borderBottomColor: "#868e96", + background: "#fff", + border: "1px solid #98a1aa", "&:focus, &:active": { borderColor: "#3154f4", }, @@ -150,10 +146,10 @@ export const ReportBuilderNewReportModal: React.FC<{ justifyContent: "space-between", }} > - + Report Description - + {descriptionValue.length}/250 @@ -164,9 +160,9 @@ export const ReportBuilderNewReportModal: React.FC<{ width: "100%", height: "129px", padding: "16px", - background: "#f1f3f5", - border: "2px solid #f1f3f5", - borderBottomColor: "#868e96", + borderRadius: "4px", + background: "#fff", + border: "1px solid #98a1aa", "&:focus, &:active": { borderColor: "#3154f4", }, diff --git a/src/app/pages/report-builder/main/index.tsx b/src/app/pages/report-builder/main/index.tsx index 438650dcc..dd0c33bb4 100644 --- a/src/app/pages/report-builder/main/index.tsx +++ b/src/app/pages/report-builder/main/index.tsx @@ -2,7 +2,10 @@ import React from "react"; import get from "lodash/get"; import Box from "@mui/material/Box"; import Grid from "@mui/material/Grid"; -import { useGetAssets, useGetReports } from "app/hooks/queries/report-builder"; +import { useSessionStorage } from "react-use"; +import Typography from "@mui/material/Typography"; +import Breadcrumbs from "@mui/material/Breadcrumbs"; +import NavigateNext from "@mui/icons-material/NavigateNext"; import { ReportBuilderSidebar } from "app/pages/report-builder/main/components/sidebar"; import { ReportBuilderToolbar } from "app/pages/report-builder/main/components/toolbar"; import { AllAssetsView } from "app/pages/report-builder/main/components/all-assets-view"; @@ -14,12 +17,19 @@ import { AssetViewType, ReportBuilderAssetsToolbar, } from "app/pages/report-builder/main/components/all-assets-view/toolbar"; +import { + useGetAssets, + useGetFolder, + useGetFolders, + useGetReports, +} from "app/hooks/queries/report-builder"; +import { ReportBuilderMoveToFolderModal } from "./components/move-to-folder-modal"; export const ReportBuilder: React.FC = () => { const [sidebarSelectedItem, setSidebarSelectedItem] = React.useState("All Reports"); const [search, setSearch] = React.useState(""); - const [selectedView, setSelectedView] = React.useState<"cards" | "list">( + const [selectedView, setSelectedView] = useSessionStorage<"cards" | "list">( "cards", ); @@ -34,8 +44,39 @@ export const ReportBuilder: React.FC = () => { React.useState(""); const [newReportModalDescriptionValue, setNewReportModalDescriptionValue] = React.useState(""); + const [moveToFolderModalOpen, setMoveToFolderModalOpen] = + React.useState(false); + + const getReports = useGetReports({ + search: search, + sort: selectedSort, + includeFolders: true, + }); + + const getFoldersStructure = useGetFolders({ + search: "", + sort: selectedSort, + includeSubFolders: true, + }); + + const [openedFolders, setOpenedFolders] = React.useState< + { id: string; name: string }[] + >([]); - const getReports = useGetReports({ search: search, sort: selectedSort }); + const [allReportsViewItems, setAllReportsViewItems] = React.useState< + { + id: string; + name: string; + description: string; + createdDate: string; + updatedDate: string; + isFolder?: boolean; + assetCount?: number; + reportCount?: number; + }[] + >(get(getReports, "data.data", [])); + + const getFolder = useGetFolder(openedFolders[0]?.id); const getAssets = useGetAssets({ search: search, @@ -59,17 +100,49 @@ export const ReportBuilder: React.FC = () => { setNewReportModalOpen(false); }; + // const handleMoveToFolderModalOpen = () => { + // setMoveToFolderModalOpen(true); + // }; + + const handleMoveToFolderModalClose = () => { + setMoveToFolderModalOpen(false); + }; + + const handleFolderOpen = (id: string) => { + const folder = allReportsViewItems.find((item) => item.id === id); + if (!folder) return; + setOpenedFolders((prev) => [{ id, name: folder.name }, ...prev]); + }; + + const handleRootBreadcrumbClick = () => { + setOpenedFolders([]); + getReports.refetch().then((res) => { + const reportsData = get(res, "data.data", []); + setAllReportsViewItems(reportsData); + }); + }; + + const handleFolderBreadcrumbClick = (index: number) => () => { + if (index === openedFolders.length - 1) return; + setOpenedFolders((prev) => prev.slice(0, index + 1)); + }; + const view = React.useMemo(() => { switch (sidebarSelectedItem) { case "All Reports": return ( ); case "Templates and Layouts": @@ -104,23 +177,36 @@ export const ReportBuilder: React.FC = () => { }, [ selectedView, selectedAssetView, + allReportsViewItems, sidebarSelectedItem, getReports.isLoading, - getReports.data?.data, + getReports.isFetching, + getFolder.isLoading, + getFolder.isFetching, getAssets.isLoading, getAssets.data?.data, ]); React.useEffect(() => { if (sidebarSelectedItem === "All Reports") { - getReports.refetch(); + getReports.refetch().then((res) => { + const reportsData = get(res, "data.data", []); + setAllReportsViewItems(reportsData); + }); } }, [sidebarSelectedItem, search]); + React.useEffect(() => { + if (openedFolders.length > 0 && getFolder.data) { + const folderData = get(getFolder, "data.data.reports", []); + setAllReportsViewItems(folderData); + } + }, [getFolder.data, openedFolders]); + return ( - + { onNewReportClick={handleNewReportModalOpen} /> + {openedFolders.length > 0 && ( + + } + sx={{ fontSize: "14px", position: "absolute" }} + > + + Workspace + + {openedFolders.map((folder, i) => ( + + {folder.name} + + ))} + + )} + {openedFolders.length > 0 && } {view} { descriptionValue={newReportModalDescriptionValue} setDescriptionValue={setNewReportModalDescriptionValue} /> + ); }; diff --git a/src/app/state/api/action-reducers/report-builder/sync.ts b/src/app/state/api/action-reducers/report-builder/sync.ts index 494b2c7ae..ec5cf8961 100644 --- a/src/app/state/api/action-reducers/report-builder/sync.ts +++ b/src/app/state/api/action-reducers/report-builder/sync.ts @@ -293,6 +293,17 @@ export interface RBAssetModel { data: any; } +export interface RBFolderModel { + id?: string; + name: string; + public?: boolean; + owner?: string; + createdDate?: string; + updatedDate?: string; + assets?: RBAssetModel[]; + reports?: RBReportModel[]; +} + export interface RBAssetModelResponse { id: string; name: string; @@ -329,6 +340,16 @@ export interface RBReportModelResponse { updatedDate: string; createdDate: string; } +export interface RBFolderModelResponse { + id: string; + name: string; + public: boolean; + owner: string; + createdDate: string; + updatedDate: string; + assets: RBAssetModelResponse[]; + reports: RBReportModelResponse[]; +} export interface RBRenderedChartData { renderedContent: string; appliedFilters: any; From 04872407393bedf71d31b4971a91ed67b56f3ee3 Mon Sep 17 00:00:00 2001 From: Stefanos Hadjipetrou Date: Thu, 21 May 2026 11:00:47 +0300 Subject: [PATCH 2/3] feat: move to folder feature implementation --- .abacusai/config.json | 39 ++++ src/app/hooks/queries/report-builder.ts | 41 ++++ .../components/dropdown/index.tsx | 5 +- .../components/all-reports-view/cards.tsx | 81 ++++--- .../main/components/all-reports-view/data.ts | 64 ++++++ .../components/all-reports-view/index.tsx | 30 ++- .../components/delete-folder-modal/data.ts | 7 + .../components/delete-folder-modal/index.tsx | 108 +++++++++ .../components/delete-report-modal/data.ts | 7 + .../components/delete-report-modal/index.tsx | 11 +- .../components/move-to-folder-modal/data.ts | 14 ++ .../components/move-to-folder-modal/index.tsx | 213 ++++++++++++++++-- .../move-to-folder-modal/tree-view.tsx | 153 +++++++++++++ .../main/components/toolbar/index.tsx | 6 +- src/app/pages/report-builder/main/index.tsx | 92 ++++++-- .../action-reducers/report-builder/sync.ts | 9 +- 16 files changed, 774 insertions(+), 106 deletions(-) create mode 100644 .abacusai/config.json create mode 100644 src/app/pages/report-builder/main/components/all-reports-view/data.ts create mode 100644 src/app/pages/report-builder/main/components/delete-folder-modal/data.ts create mode 100644 src/app/pages/report-builder/main/components/delete-folder-modal/index.tsx create mode 100644 src/app/pages/report-builder/main/components/delete-report-modal/data.ts create mode 100644 src/app/pages/report-builder/main/components/move-to-folder-modal/tree-view.tsx diff --git a/.abacusai/config.json b/.abacusai/config.json new file mode 100644 index 000000000..0927481c4 --- /dev/null +++ b/.abacusai/config.json @@ -0,0 +1,39 @@ +{ + "permissions": { + "allow": [ + "Bash(cat *)", + "Bash(ls *)", + "Bash(ls /Users/stefanos/Documents/GitHub/rb-core-middleware/src/services/ 2>&1 || true)", + "Bash(cat /Users/stefanos/Documents/GitHub/rb-core-middleware/package.json 2>&1 | head -30 || true)", + "Bash(Bash(yarn build 2>& *))", + "Bash(grep *)", + "Bash(Bash(yarn build 2>& *))", + "Bash(ls /Users/stefanos/Documents/GitHub/rb-core-middleware/src/models/ /Users/stefanos/Documents/GitHub/rb-core-middleware/s…)", + "Bash(Bash(yarn build 2>& *))", + "Bash(cd /Users/stefanos/Documents/GitHub/data-explorer-server)", + "Bash(find *)", + "Bash(curl *)", + "Bash(redis-cli *)", + "Bash(mongosh *)", + "KillShell", + "Bash(pkill *)", + "Bash(Bash(yarn build 2>& *))", + "Bash(Bash(yarn build 2>& *))", + "Bash(grep -A 30 \"async find\" /Users/stefanos/Documents/GitHub/rb-core-middleware/dist/services/folder.service.js | head -40)", + "Bash(sleep 5; curl -s \"http://localhost:5555/folders\" > /dev/null; sleep 1; grep \"DEBUG\" /tmp/srv.log)", + "Bash(Bash(yarn build 2>&1 *))", + "Bash(pkill -9 -f \"node -r source-map\" 2>&1 || true; sleep 1; echo done)", + "Bash(cat /tmp/srv.log 2>&1 | grep -A 2 DEBUG | head -50 || true)", + "Bash(Bash(yarn build 2>& *))", + "Bash(pkill -9 -f \"node -r source-map\" 2>&1 || true; redis-cli FLUSHALL 2>&1; sleep 1; cd /Users/stefanos/Documents/GitHub/dat…)", + "Bash(mongosh --quiet data-explorer-rb-db --eval 'db.AssetModel.find({}, {name:1, folderId:1}).limit(10).toArray()' 2>&1 | hea…)" + ] + }, + "additionalDirectories": [ + "/Users/stefanos/Documents/GitHub/rb-core-middleware/src/services", + "/Users/stefanos/Documents/GitHub/rb-core-middleware/src/models", + "/Users/stefanos/Documents/GitHub/rb-core-middleware/src/repositories", + "/Users/stefanos/Documents/GitHub/rb-core-middleware/src", + "/Users/stefanos/Documents/GitHub/data-explorer-server/src/controllers/report-builder" + ] +} \ No newline at end of file diff --git a/src/app/hooks/queries/report-builder.ts b/src/app/hooks/queries/report-builder.ts index 56a1c0156..30b74c6b6 100644 --- a/src/app/hooks/queries/report-builder.ts +++ b/src/app/hooks/queries/report-builder.ts @@ -75,6 +75,7 @@ export const useGetFolder = (folderId?: string) => { export const useGetReports = (params: { sort: string; search: string; + onlyRootLevel?: boolean; includeFolders?: boolean; }) => { // TODO: cache and manage invalidation @@ -89,6 +90,7 @@ export const useGetReports = (params: { "ReportBuilderGetReports", params.search, params.sort, + params.onlyRootLevel, params.includeFolders, ], queryFn: () => @@ -96,6 +98,7 @@ export const useGetReports = (params: { params: { filter, folderFilter: filter, + onlyRootLevel: params.onlyRootLevel, includeFolders: params.includeFolders, }, }), @@ -162,6 +165,44 @@ export const useGetFolders = (params: { }); }; +export const useAddReportToFolder = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ["ReportBuilderAddReportToFolder"], + mutationFn: (data: { folderId: string; reportId: string }) => + axiosInstance.get(`/folder/add-report/${data.folderId}`, { + params: { reportId: data.reportId }, + }), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: ["ReportBuilderGetFolders"], + }); + await queryClient.invalidateQueries({ + queryKey: ["ReportBuilderGetReports"], + }); + }, + }); +}; + +export const useAddFolderToFolder = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ["ReportBuilderAddFolderToFolder"], + mutationFn: (data: { folderId: string; folderIdToAdd: string }) => + axiosInstance.get(`/folder/add-folder/${data.folderId}`, { + params: { folderId: data.folderIdToAdd }, + }), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: ["ReportBuilderGetFolders"], + }); + await queryClient.invalidateQueries({ + queryKey: ["ReportBuilderGetReports"], + }); + }, + }); +}; + export const usePatchReport = (reportId: string | undefined) => { const queryClient = useQueryClient(); return useMutation({ diff --git a/src/app/pages/report-builder/components/dropdown/index.tsx b/src/app/pages/report-builder/components/dropdown/index.tsx index c994acfa0..eb29eb99c 100644 --- a/src/app/pages/report-builder/components/dropdown/index.tsx +++ b/src/app/pages/report-builder/components/dropdown/index.tsx @@ -51,7 +51,6 @@ const StyledMenuItem = styled(MenuItem)(() => ({ fontSize: "16px", padding: "12px 16px", justifyContent: "space-between", - color: appColors.SEARCH.DROPDOWN_ITEM_BACKGROUND_COLOR, "&:hover": { background: "#eff1fe", }, @@ -100,7 +99,7 @@ export const RBDropdown: React.FC = (props) => { justifyContent: "space-between", maxHeight: props.height ?? "35px", color: appColors.SEARCH.DROPDOWN_BUTTON_TEXT_COLOR, - border: `1px solid ${anchorEl ? "#3154f4" : appColors.SEARCH.DROPDOWN_BUTTON_BORDER_COLOR}`, + border: `1px solid ${anchorEl ? "#3154f4" : "#dfe3e5"}`, background: anchorEl ? "#eff1fe" : appColors.SEARCH.DROPDOWN_BUTTON_BACKGROUND_COLOR, @@ -121,7 +120,7 @@ export const RBDropdown: React.FC = (props) => { }, }} > - {selectedItem?.icon ?? props.fixedIcon} + {props.fixedIcon} diff --git a/src/app/pages/report-builder/main/components/all-reports-view/cards.tsx b/src/app/pages/report-builder/main/components/all-reports-view/cards.tsx index 9a23ab795..1db7525e6 100644 --- a/src/app/pages/report-builder/main/components/all-reports-view/cards.tsx +++ b/src/app/pages/report-builder/main/components/all-reports-view/cards.tsx @@ -7,29 +7,21 @@ import IconButton from "@mui/material/IconButton"; import MoreVert from "@mui/icons-material/MoreVert"; import FolderIcon from "app/assets/vectors/FolderBig.svg?react"; import EmptyFolderIcon from "app/assets/vectors/EmptyFolder.svg?react"; +import { + ReportCardProps, + FolderCardProps, +} from "app/pages/report-builder/main/components/all-reports-view/data"; -export const ReportCard: React.FC<{ - id: string; - name: string; - description: string; - createdDate: string; - updatedDate: string; - selectedItemForRenaming: string | null; - setSelectedItemForRenaming: (id: string | null) => void; - handleRenameEnter: (id: string, type: "report" | "folder") => void; - handleItemMenuClick: (event: React.MouseEvent) => void; - handleItemClick: (id: string, type: "report" | "folder") => () => void; - handleEditClick: (id: string) => () => void; -}> = ({ +export const ReportCard: React.FC = ({ id, name, description, - selectedItemForRenaming, - setSelectedItemForRenaming, - handleRenameEnter, - handleItemMenuClick, handleItemClick, handleEditClick, + handleRenameEnter, + handleItemMenuClick, + selectedItemForRenaming, + setSelectedItemForRenaming, }) => { return ( @@ -138,49 +130,47 @@ export const ReportCard: React.FC<{ > - - + + ); }; -export const FolderCard: React.FC<{ - id: string; - name: string; - assetCount: number; - reportCount: number; - createdDate: string; - updatedDate: string; - selectedItemForRenaming: string | null; - setSelectedItemForRenaming: (id: string | null) => void; - handleRenameEnter: (id: string, type: "report" | "folder") => void; - handleItemMenuClick: (event: React.MouseEvent) => void; - handleItemClick: (id: string, type: "report" | "folder") => () => void; -}> = ({ +export const FolderCard: React.FC = ({ id, name, assetCount, reportCount, - selectedItemForRenaming, - setSelectedItemForRenaming, + folderCount, + handleItemClick, handleRenameEnter, handleItemMenuClick, - handleItemClick, + selectedItemForRenaming, + setSelectedItemForRenaming, }) => { const text = React.useMemo(() => { - if (assetCount === 0 && reportCount === 0) { + if (assetCount === 0 && reportCount === 0 && folderCount === 0) { return "Empty Folder"; } - if (assetCount > 0 && reportCount === 0) { + if (assetCount > 0 && reportCount === 0 && folderCount === 0) { return `${assetCount} ${assetCount === 1 ? "Asset" : "Assets"} inside`; } - if (assetCount === 0 && reportCount > 0) { + if (assetCount > 0 && reportCount > 0 && folderCount === 0) { + return `${reportCount} ${reportCount === 1 ? "Report" : "Reports"} and ${assetCount} ${assetCount === 1 ? "Asset" : "Assets"} inside`; + } + if (assetCount > 0 && reportCount === 0 && folderCount > 0) { + return `${folderCount} ${folderCount === 1 ? "Folder" : "Folders"} and ${assetCount} ${assetCount === 1 ? "Asset" : "Assets"} inside`; + } + if (assetCount === 0 && reportCount > 0 && folderCount === 0) { return `${reportCount} ${reportCount === 1 ? "Report" : "Reports"} inside`; } - return `${reportCount} ${reportCount === 1 ? "Report" : "Reports"} and ${assetCount} ${assetCount === 1 ? "Asset" : "Assets"} inside`; - }, [assetCount, reportCount]); + if (assetCount === 0 && reportCount > 0 && folderCount > 0) { + return `${reportCount} ${reportCount === 1 ? "Report" : "Reports"} and ${folderCount} ${folderCount === 1 ? "Folder" : "Folders"} inside`; + } + return `${reportCount} ${reportCount === 1 ? "Report" : "Reports"}, ${assetCount} ${assetCount === 1 ? "Asset" : "Assets"} and ${folderCount} ${folderCount === 1 ? "Folder" : "Folders"} inside`; + }, [assetCount, reportCount, folderCount]); return ( @@ -200,7 +190,7 @@ export const FolderCard: React.FC<{ }} onClick={handleItemClick(id, "folder")} > - {assetCount === 0 && reportCount === 0 ? ( + {assetCount === 0 && reportCount === 0 && folderCount === 0 ? ( {text} @@ -292,8 +282,13 @@ export const FolderCard: React.FC<{ > Open Folder - - + + diff --git a/src/app/pages/report-builder/main/components/all-reports-view/data.ts b/src/app/pages/report-builder/main/components/all-reports-view/data.ts new file mode 100644 index 000000000..55093408d --- /dev/null +++ b/src/app/pages/report-builder/main/components/all-reports-view/data.ts @@ -0,0 +1,64 @@ +export interface AllReportsViewProps { + refetch: () => void; + selectedView: "cards" | "list"; + handleFolderOpen: (id: string) => void; + onDeleteReport: (id: string, name: string) => void; + onDeleteFolder: (id: string, name: string) => void; + onMoveItemToFolder: ( + id: string, + name: string, + type: "report" | "folder", + ) => void; + onDetailsClick: (details: { + id: string; + name: string; + description: string; + createdDate: string; + updatedDate: string; + }) => void; + reports: { + isLoading: boolean; + data: { + id: string; + name: string; + public?: boolean; + shared?: boolean; + isFolder?: boolean; + description: string; + createdDate: string; + updatedDate: string; + assetCount?: number; + reportCount?: number; + folderCount?: number; + }[]; + }; +} + +export interface ReportCardProps { + id: string; + name: string; + description: string; + createdDate: string; + updatedDate: string; + selectedItemForRenaming: string | null; + setSelectedItemForRenaming: (id: string | null) => void; + handleRenameEnter: (id: string, type: "report" | "folder") => void; + handleItemMenuClick: (event: React.MouseEvent) => void; + handleItemClick: (id: string, type: "report" | "folder") => () => void; + handleEditClick: (id: string) => () => void; +} + +export interface FolderCardProps { + id: string; + name: string; + assetCount: number; + reportCount: number; + folderCount: number; + createdDate: string; + updatedDate: string; + selectedItemForRenaming: string | null; + setSelectedItemForRenaming: (id: string | null) => void; + handleRenameEnter: (id: string, type: "report" | "folder") => void; + handleItemMenuClick: (event: React.MouseEvent) => void; + handleItemClick: (id: string, type: "report" | "folder") => () => void; +} diff --git a/src/app/pages/report-builder/main/components/all-reports-view/index.tsx b/src/app/pages/report-builder/main/components/all-reports-view/index.tsx index 12407a95c..44c0d9934 100644 --- a/src/app/pages/report-builder/main/components/all-reports-view/index.tsx +++ b/src/app/pages/report-builder/main/components/all-reports-view/index.tsx @@ -35,6 +35,7 @@ export const AllReportsView: React.FC = ({ onDeleteReport, onDeleteFolder, handleFolderOpen, + onMoveItemToFolder, }) => { const navigate = useNavigate(); const updateReport = usePatchReport2(); @@ -216,6 +217,16 @@ export const AllReportsView: React.FC = ({ } }; + const handleMoveToFolder = () => { + const id = getAnchorElId(); + const isFolder = getAnchorElName() === "folder"; + const name = reports.data.find((r) => r.id === id)?.name; + if (!id || !name) return; + setAnchorEl(null); + setAnchorElTableId(null); + onMoveItemToFolder(id, name, isFolder ? "folder" : "report"); + }; + const view = React.useMemo(() => { if (reports.isLoading) { return ( @@ -234,7 +245,7 @@ export const AllReportsView: React.FC = ({ } if (selectedView === "cards") { return ( - + {reports.data.map((item) => ( = ({ )} @@ -303,8 +315,7 @@ export const AllReportsView: React.FC = ({ { label: "Move to Folder", icon: , - onClick: handleClose, - disabled: true, + onClick: handleMoveToFolder, }, { label: "Duplicate", @@ -438,8 +449,7 @@ export const AllReportsView: React.FC = ({ { label: "Move to Folder", icon: , - onClick: handleCloseTable, - disabled: true, + onClick: handleMoveToFolder, }, { label: "Duplicate", diff --git a/src/app/pages/report-builder/main/components/delete-folder-modal/data.ts b/src/app/pages/report-builder/main/components/delete-folder-modal/data.ts new file mode 100644 index 000000000..de355fce0 --- /dev/null +++ b/src/app/pages/report-builder/main/components/delete-folder-modal/data.ts @@ -0,0 +1,7 @@ +export interface ReportBuilderDeleteFolderModalProps { + open: boolean; + folderId: string; + folderName: string; + onClose: () => void; + refetch: () => void; +} diff --git a/src/app/pages/report-builder/main/components/delete-folder-modal/index.tsx b/src/app/pages/report-builder/main/components/delete-folder-modal/index.tsx new file mode 100644 index 000000000..85c190d24 --- /dev/null +++ b/src/app/pages/report-builder/main/components/delete-folder-modal/index.tsx @@ -0,0 +1,108 @@ +import React from "react"; +import Box from "@mui/material/Box"; +import Modal from "@mui/material/Modal"; +import Button from "@mui/material/Button"; +import Typography from "@mui/material/Typography"; +import CloseIcon from "@mui/icons-material/Close"; +import IconButton from "@mui/material/IconButton"; +import { useDeleteFolder } from "app/hooks/queries/report-builder"; +import WarningIcon from "app/assets/vectors/WarningIconBig.svg?react"; +import { ReportBuilderDeleteFolderModalProps } from "app/pages/report-builder/main/components/delete-folder-modal/data"; + +export const ReportBuilderDeleteFolderModal: React.FC< + ReportBuilderDeleteFolderModalProps +> = ({ open, onClose, folderName, refetch, folderId }) => { + const deleteFolder = useDeleteFolder(); + + const handleDelete = () => { + deleteFolder.mutate(folderId, { + onSuccess: () => { + refetch(); + onClose(); + }, + }); + }; + + return ( + + + + + + + + Delete folder? + + + This action cannot be undone. + + + + + + + + + + Are you sure you want to delete {folderName}? + + + + + + + + + ); +}; diff --git a/src/app/pages/report-builder/main/components/delete-report-modal/data.ts b/src/app/pages/report-builder/main/components/delete-report-modal/data.ts new file mode 100644 index 000000000..c5b23b697 --- /dev/null +++ b/src/app/pages/report-builder/main/components/delete-report-modal/data.ts @@ -0,0 +1,7 @@ +export interface ReportBuilderDeleteReportModalProps { + open: boolean; + reportId: string; + reportName: string; + onClose: () => void; + refetch: () => void; +} diff --git a/src/app/pages/report-builder/main/components/delete-report-modal/index.tsx b/src/app/pages/report-builder/main/components/delete-report-modal/index.tsx index 855d610ec..e20745aca 100644 --- a/src/app/pages/report-builder/main/components/delete-report-modal/index.tsx +++ b/src/app/pages/report-builder/main/components/delete-report-modal/index.tsx @@ -7,14 +7,11 @@ import CloseIcon from "@mui/icons-material/Close"; import IconButton from "@mui/material/IconButton"; import { useDeleteReport } from "app/hooks/queries/report-builder"; import WarningIcon from "app/assets/vectors/WarningIconBig.svg?react"; +import { ReportBuilderDeleteReportModalProps } from "app/pages/report-builder/main/components/delete-report-modal/data"; -export const ReportBuilderDeleteReportModal: React.FC<{ - open: boolean; - reportId: string; - reportName: string; - onClose: () => void; - refetch: () => void; -}> = ({ open, onClose, reportName, refetch, reportId }) => { +export const ReportBuilderDeleteReportModal: React.FC< + ReportBuilderDeleteReportModalProps +> = ({ open, onClose, reportName, refetch, reportId }) => { const deleteReport = useDeleteReport(); const handleDelete = () => { diff --git a/src/app/pages/report-builder/main/components/move-to-folder-modal/data.ts b/src/app/pages/report-builder/main/components/move-to-folder-modal/data.ts index 214040232..268f3a2ea 100644 --- a/src/app/pages/report-builder/main/components/move-to-folder-modal/data.ts +++ b/src/app/pages/report-builder/main/components/move-to-folder-modal/data.ts @@ -2,6 +2,20 @@ import { RBFolderModelResponse } from "app/state/api/action-reducers/report-buil export interface ReportBuilderMoveToFolderModalProps { open: boolean; + itemId: string; + itemName: string; + refetch: () => void; onClose: () => void; + itemLocation: string; + itemType: "report" | "folder"; + refetchOpenedFolder: () => void; folderStructure: RBFolderModelResponse[]; + setOpenedFolders: React.Dispatch< + React.SetStateAction< + { + id: string; + name: string; + }[] + > + >; } diff --git a/src/app/pages/report-builder/main/components/move-to-folder-modal/index.tsx b/src/app/pages/report-builder/main/components/move-to-folder-modal/index.tsx index 8ed60d20a..0ef7c7a5a 100644 --- a/src/app/pages/report-builder/main/components/move-to-folder-modal/index.tsx +++ b/src/app/pages/report-builder/main/components/move-to-folder-modal/index.tsx @@ -1,15 +1,130 @@ import React from "react"; +import get from "lodash/get"; import Box from "@mui/material/Box"; import Modal from "@mui/material/Modal"; import Button from "@mui/material/Button"; +import Input from "@mui/material/Input/Input"; +import Search from "@mui/icons-material/Search"; import Typography from "@mui/material/Typography"; import CloseIcon from "@mui/icons-material/Close"; import IconButton from "@mui/material/IconButton"; +import InputAdornment from "@mui/material/InputAdornment"; import { ReportBuilderMoveToFolderModalProps } from "app/pages/report-builder/main/components/move-to-folder-modal/data"; +import { + buildTree, + filterTree, + FolderTreeItem, +} from "app/pages/report-builder/main/components/move-to-folder-modal/tree-view"; +import { + useAddFolderToFolder, + useAddReportToFolder, + useGetFolders, +} from "app/hooks/queries/report-builder"; export const ReportBuilderMoveToFolderModal: React.FC< ReportBuilderMoveToFolderModalProps -> = ({ open, onClose }) => { +> = ({ + open, + refetch, + onClose, + itemId, + itemType, + itemLocation, + folderStructure, + setOpenedFolders, + refetchOpenedFolder, +}) => { + const [search, setSearch] = React.useState(""); + const [selectedId, setSelectedId] = React.useState(null); + const [expanded, setExpanded] = React.useState>({ + __root__: true, + }); + + const addReportToFolder = useAddReportToFolder(); + const addFolderToFolder = useAddFolderToFolder(); + const allFolders = useGetFolders({ + search: "", + includeSubFolders: true, + sort: "createdDate DESC", + }); + + const handleSearchChange = (e: React.ChangeEvent) => { + setSearch(e.target.value); + }; + + const handleToggle = (id: string) => { + setExpanded((prev) => ({ ...prev, [id]: !(prev[id] ?? true) })); + }; + + const onSuccess = () => { + refetch(); + onClose(); + const folderIdToOpen = selectedId === "__root__" ? undefined : selectedId; + if (folderIdToOpen) { + const items: { id: string; name: string }[] = []; + const fFolder = get(allFolders, "data.data", []).find( + (f) => f.id === folderIdToOpen, + ); + if (fFolder && fFolder.locationPath) { + const parts = fFolder.locationPath.split(" > "); + const pathItems = parts.map((name) => { + if (name === "__root__") { + return null; + } + const folder = get(allFolders, "data.data", []).find( + (f) => f.name === name, + ); + return folder ? { id: folder.id, name: folder.name } : null; + }); + items.push(...(pathItems.filter(Boolean) as any[])); + } else { + items.push({ + id: folderIdToOpen, + name: fFolder ? fFolder.name : "Unknown", + }); + } + setOpenedFolders(items.filter(Boolean) as { id: string; name: string }[]); + setTimeout(() => { + refetchOpenedFolder(); + }, 100); + } else { + setOpenedFolders([]); + } + }; + + const handleSubmit = () => { + if (!selectedId) return; + if (itemType === "report") { + addReportToFolder.mutate( + { reportId: itemId, folderId: selectedId }, + { onSuccess }, + ); + } else { + addFolderToFolder.mutate( + { folderIdToAdd: itemId, folderId: selectedId }, + { onSuccess }, + ); + } + }; + + const tree = React.useMemo( + () => buildTree("__root__", "My Workspace", folderStructure ?? []), + [folderStructure], + ); + + const filtered = React.useMemo( + () => filterTree(tree, search), + [tree, search], + ); + + React.useEffect(() => { + if (!open) { + setSearch(""); + setSelectedId(null); + setExpanded({ __root__: true }); + } + }, [open]); + return ( + + + + } + sx={{ + flexGrow: 1, + height: "43px", + fontSize: "14px", + padding: "0px 8px", + borderRadius: "4px", + background: "#f1f3f5", + border: "1px solid #98a1aa", + }} + /> - - + {filtered ? ( + + ) : ( + + No folders found + + )} + + Current location: {itemLocation} + + + + + diff --git a/src/app/pages/report-builder/main/components/move-to-folder-modal/tree-view.tsx b/src/app/pages/report-builder/main/components/move-to-folder-modal/tree-view.tsx new file mode 100644 index 000000000..6db49918d --- /dev/null +++ b/src/app/pages/report-builder/main/components/move-to-folder-modal/tree-view.tsx @@ -0,0 +1,153 @@ +import React from "react"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import FolderOutlined from "@mui/icons-material/FolderOutlined"; +import KeyboardArrowDown from "@mui/icons-material/KeyboardArrowDown"; +import KeyboardArrowRight from "@mui/icons-material/KeyboardArrowRight"; +import { RBFolderModelResponse } from "app/state/api/action-reducers/report-builder/sync"; + +interface TreeNode { + id: string; + name: string; + children?: TreeNode[]; +} + +interface FolderTreeItemProps { + level: number; + node: TreeNode; + itemId: string; + selectedId: string | null; + itemType: "report" | "folder"; + onToggle: (id: string) => void; + onSelect: (id: string) => void; + expanded: Record; +} + +export const FolderTreeItem: React.FC = ({ + node, + level, + itemId, + itemType, + expanded, + onToggle, + onSelect, + selectedId, +}) => { + const hasChildren = React.useMemo( + () => !!node.children && node.children.length > 0, + [node.children], + ); + + const isExpanded = React.useMemo( + () => expanded[node.id] ?? true, + [expanded, node.id], + ); + + const isSelected = React.useMemo( + () => selectedId === node.id, + [selectedId, node.id], + ); + + const isDisabled = React.useMemo( + () => node.id === itemId && itemType === "folder", + [node.id, itemId, itemType], + ); + + return ( + <> + !isDisabled && onSelect(node.id)} + sx={{ + gap: "6px", + display: "flex", + padding: "4px 6px", + alignItems: "center", + borderRadius: "4px", + paddingLeft: `${6 + level * 24}px`, + cursor: isDisabled ? "not-allowed" : "pointer", + background: isSelected ? "#e8ecff" : "transparent", + "&:hover": { + background: isSelected ? "#e8ecff" : "#f1f3f5", + }, + }} + > + { + e.stopPropagation(); + if (hasChildren) onToggle(node.id); + }} + sx={{ + width: "16px", + height: "16px", + display: "flex", + alignItems: "center", + justifyContent: "center", + cursor: hasChildren ? "pointer" : "default", + }} + > + {hasChildren ? ( + isExpanded ? ( + + ) : ( + + ) + ) : null} + + + + {node.name} + + + {hasChildren && isExpanded && ( + <> + {node.children!.map((child) => ( + + ))} + + )} + + ); +}; + +export const buildTree = ( + id: string, + name: string, + folders: RBFolderModelResponse[], +): TreeNode => ({ + id, + name, + children: folders.map((f) => buildTree(f.id, f.name, f.children ?? [])), +}); + +export const filterTree = (node: TreeNode, query: string): TreeNode | null => { + const q = query.trim().toLowerCase(); + if (!q) return node; + const filteredChildren = + node.children + ?.map((c) => filterTree(c, query)) + .filter((c): c is TreeNode => c !== null) ?? []; + if ( + node.name.toLowerCase().includes(q) || + filteredChildren.length > 0 || + node.id === "__root__" + ) { + return { ...node, children: filteredChildren }; + } + return null; +}; diff --git a/src/app/pages/report-builder/main/components/toolbar/index.tsx b/src/app/pages/report-builder/main/components/toolbar/index.tsx index 549442919..2733f3e81 100644 --- a/src/app/pages/report-builder/main/components/toolbar/index.tsx +++ b/src/app/pages/report-builder/main/components/toolbar/index.tsx @@ -41,9 +41,11 @@ export const ReportBuilderToolbar: React.FC<{ return ( { + useTitle("The Data Explorer - Report Builder"); + const [sidebarSelectedItem, setSidebarSelectedItem] = React.useState("All Reports"); const [search, setSearch] = React.useState(""); @@ -49,6 +52,11 @@ export const ReportBuilder: React.FC = () => { React.useState(""); const [moveToFolderModalOpen, setMoveToFolderModalOpen] = React.useState(false); + const [itemToMove, setItemToMove] = React.useState<{ + id: string; + name: string; + type: "report" | "folder"; + } | null>(null); const [deleteReportModalOpen, setDeleteReportModalOpen] = React.useState(false); const [reportToDelete, setReportToDelete] = React.useState<{ @@ -81,6 +89,7 @@ export const ReportBuilder: React.FC = () => { search: search, sort: selectedSort, includeFolders: true, + onlyRootLevel: true, }); const getFoldersStructure = useGetFolders({ @@ -103,10 +112,12 @@ export const ReportBuilder: React.FC = () => { isFolder?: boolean; assetCount?: number; reportCount?: number; + folderCount?: number; + locationPath?: string; }[] >(get(getReports, "data.data", [])); - const getFolder = useGetFolder(openedFolders[0]?.id); + const getFolder = useGetFolder(openedFolders[openedFolders.length - 1]?.id); const getAssets = useGetAssets({ search: search, @@ -130,18 +141,27 @@ export const ReportBuilder: React.FC = () => { setNewReportModalOpen(false); }; - // const handleMoveToFolderModalOpen = () => { - // setMoveToFolderModalOpen(true); - // }; + const handleMoveToFolderModalOpen = () => { + setMoveToFolderModalOpen(true); + }; const handleMoveToFolderModalClose = () => { setMoveToFolderModalOpen(false); }; + const handleItemMoveToFolder = ( + id: string, + name: string, + type: "report" | "folder", + ) => { + setItemToMove({ id, name, type }); + handleMoveToFolderModalOpen(); + }; + const handleFolderOpen = (id: string) => { const folder = allReportsViewItems.find((item) => item.id === id); if (!folder) return; - setOpenedFolders((prev) => [{ id, name: folder.name }, ...prev]); + setOpenedFolders((prev) => [...prev, { id, name: folder.name }]); }; const handleRootBreadcrumbClick = () => { @@ -198,6 +218,13 @@ export const ReportBuilder: React.FC = () => { handleDeleteFolderModalOpen(); }; + const refetch = () => { + getReports.refetch().then((res) => { + const reportsData = get(res, "data.data", []); + setAllReportsViewItems(reportsData); + }); + }; + const view = React.useMemo(() => { switch (sidebarSelectedItem) { case "All Reports": @@ -211,11 +238,12 @@ export const ReportBuilder: React.FC = () => { getFolder.isFetching || getFolder.isLoading, }} - refetch={getReports.refetch} + refetch={refetch} onDeleteReport={handleDeleteReport} onDeleteFolder={handleDeleteFolder} handleFolderOpen={handleFolderOpen} selectedView={selectedView ?? "cards"} + onMoveItemToFolder={handleItemMoveToFolder} onDetailsClick={handleReportDetailsPanelOpen} /> ); @@ -261,6 +289,13 @@ export const ReportBuilder: React.FC = () => { getAssets.data?.data, ]); + const selectedItemToMove = React.useMemo(() => { + if (!itemToMove) return null; + return ( + allReportsViewItems.find((item) => item.id === itemToMove.id) ?? null + ); + }, [itemToMove, allReportsViewItems]); + React.useEffect(() => { if (sidebarSelectedItem === "All Reports") { getReports.refetch().then((res) => { @@ -272,7 +307,22 @@ export const ReportBuilder: React.FC = () => { React.useEffect(() => { if (openedFolders.length > 0 && getFolder.data) { - const folderData = get(getFolder, "data.data.reports", []); + const subReports = get(getFolder, "data.data.reports", []); + const subFolders = get(getFolder, "data.data.children", []).map( + (folder: RBFolderModelResponse) => ({ + id: folder.id, + name: folder.name, + description: "", + createdDate: folder.createdDate, + updatedDate: folder.updatedDate, + isFolder: true, + assetCount: folder.assets ? folder.assets.length : 0, + reportCount: folder.reports ? folder.reports.length : 0, + folderCount: folder.children ? folder.children.length : 0, + locationPath: folder.locationPath, + }), + ); + const folderData = [...subReports, ...subFolders]; setAllReportsViewItems(folderData); } }, [getFolder.data, openedFolders]); @@ -304,7 +354,7 @@ export const ReportBuilder: React.FC = () => { separator={ } - sx={{ fontSize: "14px", position: "absolute" }} + sx={{ zIndex: 1, fontSize: "14px", position: "absolute" }} > { )} {openedFolders.length > 0 && } - {view} {view} { { setDescriptionValue={setNewReportModalDescriptionValue} /> ); diff --git a/src/app/state/api/action-reducers/report-builder/sync.ts b/src/app/state/api/action-reducers/report-builder/sync.ts index c9d7cef44..c2a3c5f74 100644 --- a/src/app/state/api/action-reducers/report-builder/sync.ts +++ b/src/app/state/api/action-reducers/report-builder/sync.ts @@ -297,8 +297,10 @@ export interface RBAssetModel { export interface RBFolderModel { id?: string; name: string; - public?: boolean; owner?: string; + public?: boolean; + parentId?: string; + locationPath?: string; createdDate?: string; updatedDate?: string; assets?: RBAssetModel[]; @@ -344,12 +346,15 @@ export interface RBReportModelResponse { export interface RBFolderModelResponse { id: string; name: string; - public: boolean; owner: string; + public: boolean; + parentId?: string; createdDate: string; updatedDate: string; + locationPath?: string; assets: RBAssetModelResponse[]; reports: RBReportModelResponse[]; + children?: RBFolderModelResponse[]; } export interface RBRenderedChartData { renderedContent: string; From 1c547c3d0fe1e125f29d20f246033c2eb3480987 Mon Sep 17 00:00:00 2001 From: Stefanos Hadjipetrou Date: Fri, 22 May 2026 14:55:18 +0300 Subject: [PATCH 3/3] chore: incremental --- .../report-builder/main/components/all-reports-view/cards.tsx | 3 ++- .../report-builder/main/components/all-reports-view/data.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/pages/report-builder/main/components/all-reports-view/cards.tsx b/src/app/pages/report-builder/main/components/all-reports-view/cards.tsx index 1db7525e6..4ca4ec827 100644 --- a/src/app/pages/report-builder/main/components/all-reports-view/cards.tsx +++ b/src/app/pages/report-builder/main/components/all-reports-view/cards.tsx @@ -16,6 +16,7 @@ export const ReportCard: React.FC = ({ id, name, description, + imageVersion, handleItemClick, handleEditClick, handleRenameEnter, @@ -36,7 +37,7 @@ export const ReportCard: React.FC = ({ justifyContent: "center", div: { width: "calc(100% - 10px)", - backgroundImage: `url(${import.meta.env.VITE_API}/report-thumbnail/${id}.png)`, + backgroundImage: `url(${import.meta.env.VITE_API}/report-thumbnail/${id}.png?v=${imageVersion})`, backgroundRepeat: "no-repeat", backgroundPosition: "center", backgroundSize: "contain", diff --git a/src/app/pages/report-builder/main/components/all-reports-view/data.ts b/src/app/pages/report-builder/main/components/all-reports-view/data.ts index 55093408d..a8e45deb8 100644 --- a/src/app/pages/report-builder/main/components/all-reports-view/data.ts +++ b/src/app/pages/report-builder/main/components/all-reports-view/data.ts @@ -40,6 +40,7 @@ export interface ReportCardProps { description: string; createdDate: string; updatedDate: string; + imageVersion: number; selectedItemForRenaming: string | null; setSelectedItemForRenaming: (id: string | null) => void; handleRenameEnter: (id: string, type: "report" | "folder") => void;