feat(web): introduce folders to organize flows

This commit is contained in:
Ali BARIN
2025-02-04 15:24:07 +00:00
parent e496e20c17
commit 8e7d06b5dc
18 changed files with 842 additions and 80 deletions

View File

@@ -19,7 +19,9 @@ function ConfirmationDialog(props) {
open = true,
errorMessage,
} = props;
const dataTest = props['data-test'];
return (
<Dialog open={open} onClose={onClose} data-test={dataTest}>
{title && <DialogTitle>{title}</DialogTitle>}
@@ -46,6 +48,7 @@ function ConfirmationDialog(props) {
</Button>
)}
</DialogActions>
{errorMessage && (
<Alert data-test="confirmation-dialog-error-alert" severity="error">
{errorMessage}

View File

@@ -0,0 +1,106 @@
import AddIcon from '@mui/icons-material/Add';
import CloseIcon from '@mui/icons-material/Close';
import Alert from '@mui/material/Alert';
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';
import IconButton from '@mui/material/IconButton';
import TextField from '@mui/material/TextField';
import * as React from 'react';
import { getUnifiedErrorMessage } from 'helpers/errors';
import useCreateFolder from 'hooks/useCreateFolder';
import useFormatMessage from 'hooks/useFormatMessage';
export default function CreateFolderDialog(props) {
const { open = true, onClose } = props;
const [folderName, setFolderName] = React.useState('');
const formatMessage = useFormatMessage();
const { mutate: createFolder, error, isError, isSuccess } = useCreateFolder();
const handleCreateFolder = () => {
createFolder({ name: folderName });
setFolderName('');
};
const handleTextFieldChange = (event) => {
setFolderName(event.target.value);
};
const handleTextFieldKeyDown = (event) => {
if (event.key === 'Enter') {
handleCreateFolder();
}
};
return (
<Dialog open={open} onClose={onClose} data-test="create-folder-dialog">
<DialogTitle>{formatMessage('createFolderDialog.title')}</DialogTitle>
<IconButton
aria-label="close"
onClick={onClose}
sx={{
position: 'absolute',
right: 8,
top: 8,
color: (theme) => theme.palette.grey[500],
}}
>
<CloseIcon />
</IconButton>
<DialogContent>
<DialogContentText>
{formatMessage('createFolderDialog.description')}
<TextField
sx={{ mt: 2 }}
value={folderName}
onKeyDown={handleTextFieldKeyDown}
onChange={handleTextFieldChange}
label={formatMessage('createFolderDialog.folderNameInputLabel')}
fullWidth
/>
</DialogContentText>
</DialogContent>
<DialogActions sx={{ mb: 1 }}>
<Button
variant="contained"
onClick={handleCreateFolder}
data-test="create-folder-dialog-create-button"
startIcon={<AddIcon />}
>
{formatMessage('createFolderDialog.create')}
</Button>
</DialogActions>
{isError && (
<Alert
data-test="create-folder-dialog-generic-error-alert"
severity="error"
sx={{ whiteSpace: 'pre-line' }}
>
{getUnifiedErrorMessage(error?.response?.data?.errors) ||
formatMessage('genericError')}
</Alert>
)}
{isSuccess && (
<Alert
data-test="create-folder-dialog-success-alert"
severity="success"
>
{formatMessage('createFolderDialog.successfullyCreatedFolder')}
</Alert>
)}
</Dialog>
);
}

View File

@@ -0,0 +1,107 @@
import CloseIcon from '@mui/icons-material/Close';
import SaveIcon from '@mui/icons-material/Save';
import Alert from '@mui/material/Alert';
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';
import IconButton from '@mui/material/IconButton';
import TextField from '@mui/material/TextField';
import * as React from 'react';
import { getUnifiedErrorMessage } from 'helpers/errors';
import useFormatMessage from 'hooks/useFormatMessage';
import useUpdateFolder from 'hooks/useUpdateFolder';
export default function EditFolderDialog(props) {
const { open = true, onClose, folder = {} } = props;
const [folderName, setFolderName] = React.useState(folder.name);
const formatMessage = useFormatMessage();
const {
mutate: updateFolder,
error,
isError,
isSuccess,
} = useUpdateFolder(folder.id);
const handleUpdateFolder = () => {
updateFolder({ name: folderName });
};
const handleTextFieldChange = (event) => {
setFolderName(event.target.value);
};
const handleTextFieldKeyDown = (event) => {
if (event.key === 'Enter') {
handleUpdateFolder();
}
};
return (
<Dialog open={open} onClose={onClose} data-test="edit-folder-dialog">
<DialogTitle>{formatMessage('editFolderDialog.title')}</DialogTitle>
<IconButton
aria-label="close"
onClick={onClose}
sx={{
position: 'absolute',
right: 8,
top: 8,
color: (theme) => theme.palette.grey[500],
}}
>
<CloseIcon />
</IconButton>
<DialogContent>
<DialogContentText>
{formatMessage('editFolderDialog.description')}
<TextField
sx={{ mt: 2 }}
value={folderName}
onKeyDown={handleTextFieldKeyDown}
onChange={handleTextFieldChange}
label={formatMessage('editFolderDialog.folderNameInputLabel')}
fullWidth
/>
</DialogContentText>
</DialogContent>
<DialogActions sx={{ mb: 1 }}>
<Button
variant="contained"
onClick={handleUpdateFolder}
data-test="edit-folder-dialog-update-button"
startIcon={<SaveIcon />}
Save
>
{formatMessage('editFolderDialog.update')}
</Button>
</DialogActions>
{isError && (
<Alert
data-test="edit-folder-dialog-generic-error-alert"
severity="error"
sx={{ whiteSpace: 'pre-line' }}
>
{getUnifiedErrorMessage(error?.response?.data?.errors) ||
formatMessage('genericError')}
</Alert>
)}
{isSuccess && (
<Alert data-test="edit-folder-dialog-success-alert" severity="success">
{formatMessage('editFolderDialog.successfullyUpdatedFolder')}
</Alert>
)}
</Dialog>
);
}

View File

@@ -1,22 +1,25 @@
import PropTypes from 'prop-types';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { useQueryClient } from '@tanstack/react-query';
import PropTypes from 'prop-types';
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
import * as React from 'react';
import { Link } from 'react-router-dom';
import Can from 'components/Can';
import FlowFolderChangeDialog from 'components/FlowFolderChangeDialog';
import * as URLS from 'config/urls';
import useFormatMessage from 'hooks/useFormatMessage';
import useDuplicateFlow from 'hooks/useDuplicateFlow';
import useDeleteFlow from 'hooks/useDeleteFlow';
import useExportFlow from 'hooks/useExportFlow';
import useDownloadJsonAsFile from 'hooks/useDownloadJsonAsFile';
import useDuplicateFlow from 'hooks/useDuplicateFlow';
import useExportFlow from 'hooks/useExportFlow';
import useFormatMessage from 'hooks/useFormatMessage';
function ContextMenu(props) {
const { flowId, onClose, anchorEl, onDuplicateFlow, appKey } = props;
const [showFlowFolderChangeDialog, setShowFlowFolderChangeDialog] =
React.useState(false);
const enqueueSnackbar = useEnqueueSnackbar();
const formatMessage = useFormatMessage();
const queryClient = useQueryClient();
@@ -91,45 +94,67 @@ function ContextMenu(props) {
onClose();
}, [exportFlow, downloadJsonAsFile, enqueueSnackbar, formatMessage, onClose]);
const onFlowFolderUpdate = React.useCallback(() => {
setShowFlowFolderChangeDialog(true);
}, []);
return (
<Menu
open={true}
onClose={onClose}
hideBackdrop={false}
anchorEl={anchorEl}
>
<Can I="read" a="Flow" passThrough>
{(allowed) => (
<MenuItem disabled={!allowed} component={Link} to={URLS.FLOW(flowId)}>
{formatMessage('flow.view')}
</MenuItem>
)}
</Can>
<>
<Menu
open={true}
onClose={onClose}
hideBackdrop={false}
anchorEl={anchorEl}
>
<Can I="read" a="Flow" passThrough>
{(allowed) => (
<MenuItem
disabled={!allowed}
component={Link}
to={URLS.FLOW(flowId)}
>
{formatMessage('flow.view')}
</MenuItem>
)}
</Can>
<Can I="create" a="Flow" passThrough>
{(allowed) => (
<MenuItem disabled={!allowed} onClick={onFlowDuplicate}>
{formatMessage('flow.duplicate')}
</MenuItem>
)}
</Can>
<Can I="create" a="Flow" passThrough>
{(allowed) => (
<MenuItem disabled={!allowed} onClick={onFlowDuplicate}>
{formatMessage('flow.duplicate')}
</MenuItem>
)}
</Can>
<Can I="read" a="Flow" passThrough>
{(allowed) => (
<MenuItem disabled={!allowed} onClick={onFlowExport}>
{formatMessage('flow.export')}
</MenuItem>
)}
</Can>
<Can I="update" a="Flow" passThrough>
{(allowed) => (
<MenuItem disabled={!allowed} onClick={onFlowFolderUpdate}>
{formatMessage('flow.moveTo')}
</MenuItem>
)}
</Can>
<Can I="delete" a="Flow" passThrough>
{(allowed) => (
<MenuItem disabled={!allowed} onClick={onFlowDelete}>
{formatMessage('flow.delete')}
</MenuItem>
)}
</Can>
</Menu>
<Can I="read" a="Flow" passThrough>
{(allowed) => (
<MenuItem disabled={!allowed} onClick={onFlowExport}>
{formatMessage('flow.export')}
</MenuItem>
)}
</Can>
<Can I="delete" a="Flow" passThrough>
{(allowed) => (
<MenuItem disabled={!allowed} onClick={onFlowDelete}>
{formatMessage('flow.delete')}
</MenuItem>
)}
</Can>
</Menu>
{showFlowFolderChangeDialog && (
<FlowFolderChangeDialog flowId={flowId} onClose={onClose} />
)}
</>
);
}

View File

@@ -0,0 +1,145 @@
import Alert from '@mui/material/Alert';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import LoadingButton from '@mui/lab/LoadingButton';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';
import FormControl from '@mui/material/FormControl';
import CircularProgress from '@mui/material/CircularProgress';
import Autocomplete from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField';
import IconButton from '@mui/material/IconButton';
import CloseIcon from '@mui/icons-material/Close';
import * as React from 'react';
import useFolders from 'hooks/useFolders';
import useFormatMessage from 'hooks/useFormatMessage';
import useFlowFolder from 'hooks/useFlowFolder';
import useUpdateFlowFolder from 'hooks/useUpdateFlowFolder';
function FlowFolderChangeDialog(props) {
const { flowId, onClose, open = true } = props;
const formatMessage = useFormatMessage();
const { data: folders, isLoading: isFoldersLoading } = useFolders();
const { data: flowFolder, isLoading: isFlowFolderLoading } =
useFlowFolder(flowId);
const [selectedFolder, setSelectedFolder] = React.useState(null);
const uncategorizedFolder = { id: null, name: 'Uncategorized' };
const {
mutate: updateFlowFolder,
isSuccess,
isPending: isUpdateFlowFolderPending,
error: createUpdateFlowFolderError,
} = useUpdateFlowFolder(flowId);
const handleChange = (event, newValue) => {
setSelectedFolder(newValue ? newValue.id : null);
};
const handleConfirm = () => {
updateFlowFolder(selectedFolder);
};
React.useEffect(
function updateInitialSelectedFolder() {
if (!flowFolder) return;
setSelectedFolder(flowFolder.data?.id || null);
},
[flowFolder],
);
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle>{formatMessage('flowFolderChangeDialog.title')}</DialogTitle>
<IconButton
aria-label="close"
onClick={onClose}
sx={{
position: 'absolute',
right: 8,
top: 8,
color: (theme) => theme.palette.grey[500],
}}
>
<CloseIcon />
</IconButton>
<DialogContent>
<DialogContentText sx={{ mb: 2 }}>
{formatMessage('flowFolderChangeDialog.description')}
</DialogContentText>
<FormControl fullWidth>
<Autocomplete
value={
folders?.data.find((folder) => folder.id === selectedFolder) ||
uncategorizedFolder
}
disableClearable={true}
onChange={handleChange}
options={[uncategorizedFolder, ...(folders?.data || [])]}
isOptionEqualToValue={(option, value) => option.id === value.id}
getOptionLabel={(option) => option.name}
loading={isFoldersLoading || isFlowFolderLoading}
disabled={isFoldersLoading || isFlowFolderLoading}
renderInput={(params) => (
<TextField
{...params}
label={formatMessage('flowFolderChangeDialog.folderInputLabel')}
InputProps={{
...params.InputProps,
endAdornment: (
<>
{(isFoldersLoading || isFlowFolderLoading) && (
<CircularProgress color="inherit" size={20} />
)}
{params.InputProps.endAdornment}
</>
),
}}
/>
)}
/>
</FormControl>
</DialogContent>
<DialogActions>
<LoadingButton
onClick={handleConfirm}
data-test="flow-folder-change-dialog-confirm-button"
loading={isUpdateFlowFolderPending}
>
{formatMessage('flowFolderChangeDialog.confirm')}
</LoadingButton>
</DialogActions>
{createUpdateFlowFolderError && (
<Alert
data-test="flow-folder-change-dialog-error-alert"
severity="error"
>
{createUpdateFlowFolderError.message}
</Alert>
)}
{isSuccess && (
<Alert
data-test="flow-folder-change-dialog-success-alert"
severity="success"
>
{formatMessage('flowFolderChangeDialog.successfullyUpdatedFolder')}
</Alert>
)}
</Dialog>
);
}
export default FlowFolderChangeDialog;

View File

@@ -0,0 +1,6 @@
import { styled } from '@mui/material/styles';
import MuiListItemIcon from '@mui/material/ListItemIcon';
export const ListItemIcon = styled(MuiListItemIcon)(({ theme }) => ({
minWidth: theme.spacing(4),
}));

View File

@@ -116,6 +116,7 @@ function FlowRow(props) {
{anchorEl && (
<FlowContextMenu
flowId={flow.id}
folderId={flow.folder?.id}
onClose={handleClose}
anchorEl={anchorEl}
onDuplicateFlow={onDuplicateFlow}

View File

@@ -0,0 +1,200 @@
import * as React from 'react';
import { Link, useSearchParams, useNavigate } from 'react-router-dom';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import Card from '@mui/material/Card';
import IconButton from '@mui/material/IconButton';
import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import Divider from '@mui/material/Divider';
import AddIcon from '@mui/icons-material/Add';
import ViewListIcon from '@mui/icons-material/ViewList';
import Inventory2Icon from '@mui/icons-material/Inventory2';
import { getGeneralErrorMessage } from 'helpers/errors';
import ConfirmationDialog from 'components/ConfirmationDialog';
import CreateFolderDialog from 'components/CreateFolderDialog';
import EditFolderDialog from 'components/EditFolderDialog';
import useFormatMessage from 'hooks/useFormatMessage';
import useFolders from 'hooks/useFolders';
import useDeleteFolder from 'hooks/useDeleteFolder';
import { ListItemIcon } from './style';
export default function Folders() {
const [showEditFolderDialog, setShowEditFolderDialog] = React.useState(false);
const formatMessage = useFormatMessage();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { data: folders } = useFolders();
const { mutateAsync: deleteFolder, error: deleteFolderError } =
useDeleteFolder();
const [showCreateFolderDialog, setShowCreateFolderDialog] =
React.useState(false);
const [showDeleteFolderDialog, setShowDeleteFolderDialog] =
React.useState(false);
const selectedFolderId = searchParams.get('folderId');
const selectedFolder = folders?.data?.find(
(folder) => folder.id === selectedFolderId,
);
const allFlowsFolder = new URLSearchParams().toString();
const unassignedFlowsFolder = new URLSearchParams('folderId=null').toString();
const allFlowsFolderSelected = selectedFolderId === null;
const unassignedFlowsFolderSelected = selectedFolderId === 'null'; // intendedly stringified
const generalErrorMessage = getGeneralErrorMessage({
error: deleteFolderError,
fallbackMessage: formatMessage('genericError'),
});
const handleDeleteFolderConfirmation = async () => {
await deleteFolder(selectedFolderId);
navigate({ search: allFlowsFolder });
};
const getFolderSearchParams = (folderId) => {
const searchParams = new URLSearchParams(`folderId=${folderId}`);
return searchParams.toString();
};
const generateFolderItem = (folder) => {
if (folder.id === selectedFolderId) {
return generateListItem(folder);
}
return generateListItemButton(folder);
};
const generateListItem = (folder) => {
return (
<ListItem
key={folder.id}
disablePadding
secondaryAction={
<Stack direction="row" gap={1}>
<IconButton
edge="end"
aria-label="edit"
onClick={() => setShowEditFolderDialog(true)}
>
<EditIcon />
</IconButton>
<IconButton
edge="end"
aria-label="delete"
onClick={() => setShowDeleteFolderDialog(true)}
>
<DeleteIcon />
</IconButton>
</Stack>
}
>
<ListItemButton
selected={true}
disableRipple
sx={{ pointerEvents: 'none' }}
>
<ListItemText primary={folder.name} />
</ListItemButton>
</ListItem>
);
};
const generateListItemButton = (folder) => {
return (
<ListItemButton
key={folder.id}
component={Link}
to={{ search: getFolderSearchParams(folder.id) }}
>
<ListItemText primary={folder.name} />
</ListItemButton>
);
};
return (
<>
<Box
component={Card}
// sx={{ width: '100%', maxWidth: 360, bgcolor: 'background.paper' }}
>
<List component="nav" aria-label="static folders">
<ListItemButton
component={Link}
to={{ search: allFlowsFolder }}
selected={allFlowsFolderSelected}
>
<ListItemIcon>
<ViewListIcon />
</ListItemIcon>
<ListItemText primary={formatMessage('folders.allFlows')} />
</ListItemButton>
<ListItemButton
component={Link}
to={{ search: unassignedFlowsFolder }}
selected={unassignedFlowsFolderSelected}
>
<ListItemIcon>
<Inventory2Icon />
</ListItemIcon>
<ListItemText primary={formatMessage('folders.unassignedFlows')} />
</ListItemButton>
</List>
<Divider />
<List component="nav" aria-label="user folders">
{folders?.data?.map((folder) => generateFolderItem(folder))}
<ListItemButton onClick={() => setShowCreateFolderDialog(true)}>
<ListItemIcon>
<AddIcon />
</ListItemIcon>
<ListItemText primary={formatMessage('folders.createNew')} />
</ListItemButton>
</List>
</Box>
{showCreateFolderDialog && (
<CreateFolderDialog onClose={() => setShowCreateFolderDialog(false)} />
)}
{selectedFolder && showEditFolderDialog && (
<EditFolderDialog
folder={selectedFolder}
onClose={() => setShowEditFolderDialog(false)}
/>
)}
{selectedFolder && showDeleteFolderDialog && (
<ConfirmationDialog
title={formatMessage('deleteFolderDialog.title')}
description={formatMessage('deleteFolderDialog.description')}
onClose={() => setShowDeleteFolderDialog(false)}
onConfirm={handleDeleteFolderConfirmation}
cancelButtonChildren={formatMessage('deleteFolderDialog.cancel')}
confirmButtonChildren={formatMessage('deleteFolderDialog.confirm')}
errorMessage={generalErrorMessage}
/>
)}
</>
);
}

View File

@@ -0,0 +1,6 @@
import { styled } from '@mui/material/styles';
import MuiListItemIcon from '@mui/material/ListItemIcon';
export const ListItemIcon = styled(MuiListItemIcon)(({ theme }) => ({
minWidth: theme.spacing(4),
}));