From d52e08f80ca80de88fbe201974f41dc46ecb99cc Mon Sep 17 00:00:00 2001 From: Mantvydas Deltuva Date: Sun, 1 Sep 2024 02:19:09 +0300 Subject: [PATCH 1/7] MDE/PKFE-19 updated workspace provider for hierarchy --- .../stores/workspaceContextProvider.tsx | 64 +++++++++++++++---- 1 file changed, 53 insertions(+), 11 deletions(-) diff --git a/app/front-end/src/features/editor/stores/workspaceContextProvider.tsx b/app/front-end/src/features/editor/stores/workspaceContextProvider.tsx index e34d965..cda592c 100644 --- a/app/front-end/src/features/editor/stores/workspaceContextProvider.tsx +++ b/app/front-end/src/features/editor/stores/workspaceContextProvider.tsx @@ -1,6 +1,10 @@ import { FilebarGroupItemProps } from '@/features/editor/components/filebarView'; -import { FileTypes } from '@/types'; -import React, { createContext, useState } from 'react'; +import { FileTreeViewItemProps } from '@/features/editor/types'; +import { useSessionContext } from '@/hooks'; +import { axios, socket } from '@/lib'; +import { Endpoints, Events, FileTypes } from '@/types'; +import { TreeViewBaseItem } from '@mui/x-tree-view'; +import React, { createContext, useCallback, useEffect, useState } from 'react'; export interface WorkspaceContextProps { fileId: string; @@ -9,6 +13,8 @@ export interface WorkspaceContextProps { update: (newId: string, newLabel: string, newType: FileTypes) => void; fileHistory: FilebarGroupItemProps[]; remove: (fileId: string) => void; + fileTreeViewItems: TreeViewBaseItem[] | undefined; + fileTreeViewIsLoading: boolean; } export const WorkspaceContext = createContext({ @@ -18,6 +24,8 @@ export const WorkspaceContext = createContext({ update: () => {}, fileHistory: [], remove: () => {}, + fileTreeViewItems: undefined, + fileTreeViewIsLoading: true, }); interface Props { @@ -25,19 +33,21 @@ interface Props { } /** - * `WorkspaceContextProvider` is a component that provides context for managing the current workspace file's state. + * `WorkspaceContextProvider` is a React component that provides context for managing workspace file state across the application. * - * @description This component sets up a React context for managing and sharing workspace file information across the - * application. It tracks the current file's ID, label, and type, and provides an API to update these values. The context - * also maintains a history of recently opened files (excluding folders) and supports removing files from this history. + * @description This component sets up a context that holds and manages the state related to the current file in the workspace, + * including its ID, label, type, and history. It provides functions to update the current file, add or remove files from the + * history, and manage the file tree view data. The context is also updated with real-time changes via WebSocket. * * The context includes: * - `fileId`: The unique identifier for the current file. * - `fileLabel`: The label or name of the current file. - * - `fileType`: The type of the file (e.g., folder, document). - * - `update`: A function to update the current file's ID, label, and type, and add the file to the history. - * - `fileHistory`: An array of recently opened files, excluding folders. - * - `remove`: A function to remove a file from the history and update the current file if the removed file was active. + * - `fileType`: The type of the file (e.g., `FileTypes.FILE`, `FileTypes.FOLDER`). + * - `update`: A function to update the current file's ID, label, and type, and add the file to the history if it's not a folder. + * - `fileHistory`: An array of recently accessed files, excluding folders, with their IDs, labels, and types. + * - `remove`: A function to remove a file from the history and update the current file if it was active. + * - `fileTreeViewItems`: The hierarchical data for the file tree view. + * - `fileTreeViewIsLoading`: A boolean indicating if the file tree view data is still loading. * * @component * @@ -52,13 +62,43 @@ interface Props { * @param {Object} props - The props for the WorkspaceContextProvider component. * @param {React.ReactNode} [props.children] - Optional child components that will have access to the workspace context. * - * @returns {JSX.Element} The `WorkspaceContext.Provider` with the current workspace context value. + * @returns {JSX.Element} The `WorkspaceContext.Provider` component with the current workspace context value. */ export const WorkspaceContextProvider: React.FC = ({ children }) => { const [fileId, setFileId] = useState(''); const [fileLabel, setFileLabel] = useState(''); const [fileType, setFileType] = useState(FileTypes.FOLDER); const [fileHistory, setFileHistory] = useState([]); + const [fileTreeViewItems, setFileTreeViewItems] = useState[] | undefined>( + undefined + ); + const [fileTreeViewIsLoading, setFileTreeViewIsLoading] = useState(true); + + const { connected } = useSessionContext(); + + const getWorkspace = useCallback(async () => { + setFileTreeViewIsLoading(true); + try { + const response = await axios.get(Endpoints.WORKSPACE); + setFileTreeViewItems(response.data); + } catch (error) { + console.error('Failed to fetch workspace data:', error); + } finally { + setFileTreeViewIsLoading(false); + } + }, []); + + useEffect(() => { + if (connected) { + getWorkspace(); + } + + socket.on(Events.WORKSPACE_UPDATE_FEEDBACK_EVENT, getWorkspace); + + return () => { + socket.off(Events.WORKSPACE_UPDATE_FEEDBACK_EVENT); + }; + }, [connected, getWorkspace]); const update = (newId: string, newLabel: string, newType: FileTypes) => { setFileId(newId); @@ -112,6 +152,8 @@ export const WorkspaceContextProvider: React.FC = ({ children }) => { update, fileHistory, remove, + fileTreeViewItems, + fileTreeViewIsLoading, }; return {children}; From ec753568278cd7132fbe9357f50452a0a8fb89b2 Mon Sep 17 00:00:00 2001 From: Mantvydas Deltuva Date: Sun, 1 Sep 2024 02:20:11 +0300 Subject: [PATCH 2/7] MDE/PKFE-19 implemented function for checking if specific file exists --- .../src/features/editor/utils/helpers.ts | 17 +++++++++++++++++ .../src/features/editor/utils/index.ts | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/app/front-end/src/features/editor/utils/helpers.ts b/app/front-end/src/features/editor/utils/helpers.ts index ed53cf5..790c150 100644 --- a/app/front-end/src/features/editor/utils/helpers.ts +++ b/app/front-end/src/features/editor/utils/helpers.ts @@ -1,5 +1,7 @@ import { FileTypes } from '@/types/enums'; import { Article as ArticleIcon, FolderRounded, InsertDriveFile as InsertDriveFileIcon } from '@mui/icons-material'; +import { TreeViewBaseItem } from '@mui/x-tree-view'; +import { FileTreeViewItemProps } from '../types'; export const isExpandable = (reactChildren: React.ReactNode) => { if (Array.isArray(reactChildren)) { @@ -22,3 +24,18 @@ export const getIconFromFileType = (fileType: FileTypes) => { return InsertDriveFileIcon; } }; + +export const doesFileExist = (fileTreeView: TreeViewBaseItem[], path: string): boolean => { + for (const item of fileTreeView) { + if (item.id === path) { + return true; + } + + if (path.startsWith(item.id) && item.children) { + if (doesFileExist(item.children, path)) { + return true; + } + } + } + return false; +} \ No newline at end of file diff --git a/app/front-end/src/features/editor/utils/index.ts b/app/front-end/src/features/editor/utils/index.ts index c2774e5..18e5ab4 100644 --- a/app/front-end/src/features/editor/utils/index.ts +++ b/app/front-end/src/features/editor/utils/index.ts @@ -1 +1 @@ -export { getIconFromFileType, isExpandable } from './helpers'; +export { doesFileExist, getIconFromFileType, isExpandable } from './helpers'; From 7038a06932de984101d788ae4bdcc9370824738a Mon Sep 17 00:00:00 2001 From: Mantvydas Deltuva Date: Sun, 1 Sep 2024 02:25:08 +0300 Subject: [PATCH 3/7] MDE/PKFE-19 updated context menu textfield dialog for additional checks and removed ability to specify custom file extensions --- .../fileTreeItem/fileTreeItemContextMenu.tsx | 45 ++-- ...fileTreeItemContextMenuTextfieldDialog.tsx | 220 +++++++++++++++--- .../types/fileTreeItemContextMenuActions.tsx | 8 + .../src/features/editor/types/index.ts | 1 + 4 files changed, 212 insertions(+), 62 deletions(-) create mode 100644 app/front-end/src/features/editor/types/fileTreeItemContextMenuActions.tsx diff --git a/app/front-end/src/features/editor/components/fileTreeView/fileTreeItem/fileTreeItemContextMenu.tsx b/app/front-end/src/features/editor/components/fileTreeView/fileTreeItem/fileTreeItemContextMenu.tsx index 918a29e..47eedea 100644 --- a/app/front-end/src/features/editor/components/fileTreeView/fileTreeItem/fileTreeItemContextMenu.tsx +++ b/app/front-end/src/features/editor/components/fileTreeView/fileTreeItem/fileTreeItemContextMenu.tsx @@ -3,7 +3,7 @@ import { FileTreeItemContextMenuTextfieldDialog, } from '@/features/editor/components/fileTreeView/fileTreeItem'; import { useWorkspaceContext } from '@/features/editor/hooks'; -import { FileTreeViewItemProps } from '@/features/editor/types'; +import { FileTreeItemContextMenuActions, FileTreeViewItemProps } from '@/features/editor/types'; import { axios } from '@/lib'; import { Endpoints, FileTypes } from '@/types'; import { Divider, Menu, MenuItem } from '@mui/material'; @@ -60,7 +60,7 @@ export const FileTreeItemContextMenu: React.FC = ( const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const menuItems = []; - if (item.fileType === undefined) { + if (item.fileType === undefined || item.fileType === FileTypes.FOLDER) { menuItems.push( handleActionContextMenu('newFile')}> New file... @@ -74,29 +74,19 @@ export const FileTreeItemContextMenu: React.FC = ( ); } else { - if (item.fileType === FileTypes.FOLDER || item.fileType === undefined) { - menuItems.push( - handleActionContextMenu('newFile')}> - New file... - , - handleActionContextMenu('newFolder')}> - New folder... - , - , - handleActionContextMenu('import')} disabled> - Import... - , - - ); - } else { - menuItems.push( - handleActionContextMenu('export')} disabled> - Export... - , - - ); - } + menuItems.push( + handleActionContextMenu('export')} disabled> + Export... + , + + ); + } + if (item.fileType === FileTypes.FOLDER) { + menuItems.push(); + } + + if (item.fileType !== undefined) { menuItems.push( handleActionContextMenu('rename')}> Rename... @@ -176,20 +166,25 @@ export const FileTreeItemContextMenu: React.FC = ( setNewFileDialogOpen(false)} onSave={handleNewFileSave} /> setNewFolderDialogOpen(false)} onSave={handleNewFolderSave} /> = ( /> setDeleteDialogOpen(false)} onConfirm={handleDeleteConfirm} diff --git a/app/front-end/src/features/editor/components/fileTreeView/fileTreeItem/fileTreeItemContextMenuTextfieldDialog.tsx b/app/front-end/src/features/editor/components/fileTreeView/fileTreeItem/fileTreeItemContextMenuTextfieldDialog.tsx index d01e2a7..0d200e8 100644 --- a/app/front-end/src/features/editor/components/fileTreeView/fileTreeItem/fileTreeItemContextMenuTextfieldDialog.tsx +++ b/app/front-end/src/features/editor/components/fileTreeView/fileTreeItem/fileTreeItemContextMenuTextfieldDialog.tsx @@ -1,14 +1,21 @@ import { FileTreeItemContextMenuStyledDialog } from '@/features/editor/components/fileTreeView/fileTreeItem'; -import { FileTreeViewItemProps } from '@/features/editor/types'; +import { useWorkspaceContext } from '@/features/editor/hooks'; +import { FileTreeItemContextMenuActions, FileTreeViewItemProps } from '@/features/editor/types'; +import { doesFileExist } from '@/features/editor/utils'; import { FileTypes } from '@/types'; import { Close as CloseIcon } from '@mui/icons-material'; import { + Box, Button, DialogActions, DialogContent, DialogTitle, + FormControl, Grid, IconButton, + MenuItem, + Select, + SelectChangeEvent, TextField, Typography, useTheme, @@ -17,37 +24,40 @@ import { useEffect, useState } from 'react'; export interface FileTreeItemContextMenuTextfieldDialogProps { open: boolean; + action: FileTreeItemContextMenuActions; title: string; label: string; - item?: FileTreeViewItemProps; + item: FileTreeViewItemProps; onClose: () => void; onSave: (label: string) => void; } /** - * `FileTreeItemContextMenuTextfieldDialog` component provides a dialog for editing the label of a file tree item. + * `FileTreeItemContextMenuTextfieldDialog` is a dialog component that allows users to input or edit the label of a file tree item. * - * @description This component displays a modal dialog for users to input or edit a label for a file or folder in the file tree. - * It includes validation to ensure the input is not empty and does not exceed 50 characters. The dialog features a title - * and a text field for input, with a save and cancel button. The dialog is styled with the application's theme for a consistent - * look and feel. + * @description This component provides a modal dialog for editing or creating a file or folder label within a file tree view. + * It supports different actions, such as renaming an existing file or folder, or creating a new file or folder. The dialog includes + * input validation to ensure the new label is valid, such as not being empty, not exceeding 50 characters, and not containing + * forbidden characters. For file creation, it allows selecting a file extension. * * @component * * @param {FileTreeItemContextMenuTextfieldDialogProps} props - The props for the component. - * @param {boolean} props.open - A boolean indicating whether the dialog is open or closed. - * @param {string} props.title - The title of the dialog. - * @param {string} props.label - The label for the text field. - * @param {FileTreeViewItemProps} [props.item] - Optional file tree item object that includes the current label and file type. + * @param {boolean} props.open - A boolean that determines whether the dialog is visible or hidden. + * @param {FileTreeItemContextMenuActions} props.action - The action to be performed, such as renaming or creating a new file. + * @param {string} props.title - The title displayed at the top of the dialog. + * @param {string} props.label - The label for the input field. + * @param {FileTreeViewItemProps} props.item - The file tree item object being edited, containing information such as the current label and file type. * @param {() => void} props.onClose - Callback function to be called when the dialog is closed. - * @param {(label: string) => void} props.onSave - Callback function to be called when the user saves the input. + * @param {(label: string) => void} props.onSave - Callback function to be called when the user saves the new label. * * @example * // Example usage of the FileTreeItemContextMenuTextfieldDialog component * setDialogOpen(false)} * onSave={(newLabel) => console.log('New label:', newLabel)} @@ -57,6 +67,7 @@ export interface FileTreeItemContextMenuTextfieldDialogProps { */ export const FileTreeItemContextMenuTextfieldDialog: React.FC = ({ open, + action, title, label, item, @@ -64,45 +75,161 @@ export const FileTreeItemContextMenuTextfieldDialog: React.FC { const Theme = useTheme(); - const [value, setValue] = useState(item?.label || ''); + const { fileTreeViewItems } = useWorkspaceContext(); + + const [value, setValue] = useState(() => { + switch (action) { + case FileTreeItemContextMenuActions.RENAME: + if (item.fileType !== FileTypes.FOLDER) return item.label.match(/^(.*)(?=\.[^.]+$)/)?.[0] || ''; + return item.label; + default: + return ''; + } + }); + + const [fileExtension, setFileExtension] = useState(() => { + switch (action) { + case FileTreeItemContextMenuActions.NEW_FILE: + return FileTypes.CSV; + case FileTreeItemContextMenuActions.NEW_FOLDER: + return ''; + case FileTreeItemContextMenuActions.RENAME: + if (item.fileType !== FileTypes.FOLDER) return item.label.match(/[^.]+$/)?.[0] || FileTypes.CSV; + return ''; + default: + return ''; + } + }); const [error, setError] = useState(null); useEffect(() => { if (open) { - setValue(item?.label || ''); + setValue(() => { + switch (action) { + case FileTreeItemContextMenuActions.RENAME: + if (item.fileType !== FileTypes.FOLDER) return item.label.match(/^(.*)(?=\.[^.]+$)/)?.[0] || ''; + return item.label; + default: + return ''; + } + }); + setFileExtension(() => { + switch (action) { + case FileTreeItemContextMenuActions.NEW_FILE: + return FileTypes.CSV; + case FileTreeItemContextMenuActions.NEW_FOLDER: + return ''; + case FileTreeItemContextMenuActions.RENAME: + if (item.fileType !== FileTypes.FOLDER) return item.label.match(/[^.]+$/)?.[0] || FileTypes.CSV; + return ''; + default: + return ''; + } + }); setError(null); } - }, [open, item?.label]); + }, [open, item, action]); + + const validateInput = (input: string, fileExtension: string) => { + const parentPath = item.id.match(/^(.*)(?=\/[^/]*$)/)?.[0] || ''; + const path = () => { + if (action === FileTreeItemContextMenuActions.RENAME) { + if (parentPath === '') return input; + return parentPath + '/' + input; + } else { + if (item.id === '') return input; + return item.id + '/' + input; + } + }; - const validateInput = (input: string) => { - if (!input.trim()) { + // Check if file already exists + switch (action) { + case FileTreeItemContextMenuActions.NEW_FILE: + if (doesFileExist(fileTreeViewItems || [], path() + '.' + fileExtension)) return 'This name already exists'; + break; + case FileTreeItemContextMenuActions.NEW_FOLDER: + if (doesFileExist(fileTreeViewItems || [], path())) return 'This name already exists'; + break; + case FileTreeItemContextMenuActions.RENAME: + if (fileExtension === '') { + if (doesFileExist(fileTreeViewItems || [], path())) return 'This name already exists'; + } else { + if (doesFileExist(fileTreeViewItems || [], path() + '.' + fileExtension)) return 'This name already exists'; + } + break; + default: + break; + } + + // Check if the input is empty + if (!input) { return 'Input cannot be empty'; } + // Check if the input exceeds the length limit if (input.length > 50) { return 'Input must be less than 50 characters'; } + // Check for forbidden characters + if (input.includes('\0')) { + return 'Input contains a forbidden null character'; + } + + if (input.includes('/')) { + return 'Input cannot contain a forward slash (/)'; + } + + if (/[*?[\]]/.test(input)) { + return 'Input contains forbidden glob characters (*, ?, [, ])'; + } + + // Check for special filename cases ('.' and '..') + if (input === '.' || input === '..') { + return 'Input cannot be "." or ".."'; + } + + // Check for leading or trailing dots + if (input.startsWith('.') || input.endsWith('.')) { + return 'Input cannot start or end with a dot'; + } + return null; // No error }; const handleSave = () => { - const validationError = validateInput(value); + const trimmedValue = value.trim(); + const validationError = validateInput(trimmedValue, fileExtension); if (validationError) { setError(validationError); } else { - onSave(value); + switch (action) { + case FileTreeItemContextMenuActions.NEW_FILE: + onSave(trimmedValue + '.' + fileExtension); + break; + case FileTreeItemContextMenuActions.NEW_FOLDER: + onSave(trimmedValue); + break; + case FileTreeItemContextMenuActions.RENAME: + if (fileExtension !== '') onSave(trimmedValue + '.' + fileExtension); + else onSave(trimmedValue); + break; + default: + break; + } } }; + const handleFileExtension = (event: SelectChangeEvent) => { + setFileExtension(event.target.value as FileTypes); + }; + return ( - - {title} {item ? (item.fileType === FileTypes.FOLDER ? 'Folder' : 'File') : ''} - + {title} - setValue(event.target.value)} - error={Boolean(error)} - helperText={error} - sx={{ - ':hover': { borderColor: Theme.palette.primary.main }, - backgroundColor: Theme.palette.background.paper, - justifyItems: 'center', - }} - /> + + setValue(event.target.value)} + error={Boolean(error)} + helperText={error} + sx={{ + ':hover': { borderColor: Theme.palette.primary.main }, + backgroundColor: Theme.palette.background.paper, + justifyItems: 'center', + }} + /> + {(action === FileTreeItemContextMenuActions.NEW_FILE || + (action === FileTreeItemContextMenuActions.RENAME && item.fileType !== FileTypes.FOLDER)) && ( + <> + + + + + )} +