feat(web): introduce templates

This commit is contained in:
Ali BARIN
2025-02-26 10:40:47 +00:00
parent 69e91fea18
commit 839fda8880
33 changed files with 904 additions and 86 deletions

View File

@@ -22,6 +22,7 @@ import useCurrentUserAbility from 'hooks/useCurrentUserAbility';
import Footer from './Footer';
function createDrawerLinks({
canCreateFlows,
canReadRole,
canReadUser,
canUpdateConfig,
@@ -69,7 +70,16 @@ function createDrawerLinks({
dataTest: 'apps-drawer-link',
}
: null,
canUpdateConfig
? {
Icon: AppsIcon,
primary: 'adminSettingsDrawer.templates',
to: URLS.ADMIN_TEMPLATES,
dataTest: 'templates-drawer-link',
}
: null,
].filter(Boolean);
return items;
}
@@ -81,7 +91,9 @@ function SettingsLayout() {
const [isDrawerOpen, setDrawerOpen] = React.useState(!matchSmallScreens);
const openDrawer = () => setDrawerOpen(true);
const closeDrawer = () => setDrawerOpen(false);
const drawerLinks = createDrawerLinks({
canCreateFlows: currentUserAbility.can('create', 'Flow'),
canReadUser: currentUserAbility.can('read', 'User'),
canReadRole: currentUserAbility.can('read', 'Role'),
canUpdateConfig: currentUserAbility.can('update', 'Config'),
@@ -91,6 +103,7 @@ function SettingsLayout() {
currentUserAbility.can('create', 'SamlAuthProvider'),
canUpdateApp: currentUserAbility.can('update', 'App'),
});
const drawerBottomLinks = [
{
Icon: ArrowBackIosNewIcon,
@@ -99,6 +112,7 @@ function SettingsLayout() {
dataTest: 'go-back-drawer-link',
},
];
return (
<Can I="read" a="User">
<AppBar
@@ -106,6 +120,7 @@ function SettingsLayout() {
onDrawerOpen={openDrawer}
onDrawerClose={closeDrawer}
/>
<Box sx={{ display: 'flex', flex: 1 }}>
<Drawer
links={drawerLinks}
@@ -114,6 +129,7 @@ function SettingsLayout() {
onOpen={openDrawer}
onClose={closeDrawer}
/>
<Stack sx={{ flex: 1 }}>
<Toolbar />
<Outlet />

View File

@@ -0,0 +1,71 @@
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { useQueryClient } from '@tanstack/react-query';
import PropTypes from 'prop-types';
import * as React from 'react';
import { Link, useNavigate } from 'react-router-dom';
import Can from 'components/Can';
import FlowFolderChangeDialog from 'components/FlowFolderChangeDialog';
import * as URLS from 'config/urls';
import useAdminDeleteTemplate from 'hooks/useAdminDeleteTemplate.ee';
import useDownloadJsonAsFile from 'hooks/useDownloadJsonAsFile';
import useDuplicateFlow from 'hooks/useDuplicateFlow';
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
import useExportFlow from 'hooks/useExportFlow';
import useFormatMessage from 'hooks/useFormatMessage';
import useIsCurrentUserAdmin from 'hooks/useIsCurrentUserAdmin';
function AdminTemplateContextMenu(props) {
const { templateId, onClose, anchorEl } = props;
const [showFlowFolderChangeDialog, setShowFlowFolderChangeDialog] =
React.useState(false);
const navigate = useNavigate();
const enqueueSnackbar = useEnqueueSnackbar();
const formatMessage = useFormatMessage();
const isCurrentUserAdmin = useIsCurrentUserAdmin();
const { mutateAsync: deleteTemplate } = useAdminDeleteTemplate(templateId);
const onTemplateDelete = React.useCallback(async () => {
await deleteTemplate();
enqueueSnackbar(
formatMessage('adminTemplateContextMenu.successfullyDeleted'),
{
variant: 'success',
},
);
onClose();
}, [deleteTemplate, enqueueSnackbar, formatMessage, onClose]);
return (
<Menu
open={true}
onClose={onClose}
hideBackdrop={false}
anchorEl={anchorEl}
>
<Can I="delete" a="Flow" passThrough>
{(allowed) => (
<MenuItem disabled={!allowed} onClick={onTemplateDelete}>
{formatMessage('adminTemplateContextMenu.delete')}
</MenuItem>
)}
</Can>
</Menu>
);
}
AdminTemplateContextMenu.propTypes = {
templateId: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
anchorEl: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
]).isRequired,
};
export default AdminTemplateContextMenu;

View File

@@ -2,10 +2,8 @@ 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 { Link, useNavigate } from 'react-router-dom';
import Can from 'components/Can';
import FlowFolderChangeDialog from 'components/FlowFolderChangeDialog';
@@ -13,16 +11,22 @@ import * as URLS from 'config/urls';
import useDeleteFlow from 'hooks/useDeleteFlow';
import useDownloadJsonAsFile from 'hooks/useDownloadJsonAsFile';
import useDuplicateFlow from 'hooks/useDuplicateFlow';
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
import useExportFlow from 'hooks/useExportFlow';
import useFormatMessage from 'hooks/useFormatMessage';
import useIsCurrentUserAdmin from 'hooks/useIsCurrentUserAdmin';
function ContextMenu(props) {
const { flowId, onClose, anchorEl, onDuplicateFlow, appKey } = props;
const [showFlowFolderChangeDialog, setShowFlowFolderChangeDialog] =
React.useState(false);
const navigate = useNavigate();
const enqueueSnackbar = useEnqueueSnackbar();
const formatMessage = useFormatMessage();
const queryClient = useQueryClient();
const isCurrentUserAdmin = useIsCurrentUserAdmin();
const { mutateAsync: duplicateFlow } = useDuplicateFlow(flowId);
const { mutateAsync: deleteFlow } = useDeleteFlow(flowId);
const { mutateAsync: exportFlow } = useExportFlow(flowId);
@@ -56,6 +60,10 @@ function ContextMenu(props) {
formatMessage,
]);
const onCreateTemplate = React.useCallback(async () => {
navigate(URLS.ADMIN_CREATE_TEMPLATE(flowId));
}, [flowId]);
const onFlowDelete = React.useCallback(async () => {
await deleteFlow();
@@ -126,6 +134,16 @@ function ContextMenu(props) {
)}
</Can>
{isCurrentUserAdmin && (
<Can I="create" a="Flow" passThrough>
{(allowed) => (
<MenuItem disabled={!allowed} onClick={onCreateTemplate}>
{formatMessage('flow.createTemplateFromFlow')}
</MenuItem>
)}
</Can>
)}
<Can I="update" a="Flow" passThrough>
{(allowed) => (
<MenuItem disabled={!allowed} onClick={onFlowFolderUpdate}>

View File

@@ -0,0 +1,110 @@
import AddIcon from '@mui/icons-material/Add';
import UploadIcon from '@mui/icons-material/Upload';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import Divider from '@mui/material/Divider';
import Grid from '@mui/material/Grid';
import Button from '@mui/material/Button';
import Pagination from '@mui/material/Pagination';
import { useTheme } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery';
import PaginationItem from '@mui/material/PaginationItem';
import * as React from 'react';
import {
Link,
Route,
Routes,
useNavigate,
useSearchParams,
} from 'react-router-dom';
import Can from 'components/Can';
import ConditionalIconButton from 'components/ConditionalIconButton';
import Container from 'components/Container';
import FlowRow from 'components/FlowRow';
import Folders from 'components/Folders';
import ImportFlowDialog from 'components/ImportFlowDialog';
import SplitButton from 'components/SplitButton';
import NoResultFound from 'components/NoResultFound';
import PageTitle from 'components/PageTitle';
import SearchInput from 'components/SearchInput';
import TemplatesDialog from 'components/TemplatesDialog/index.ee';
import * as URLS from 'config/urls';
import useCurrentUserAbility from 'hooks/useCurrentUserAbility';
import useFlows from 'hooks/useFlows';
import useFormatMessage from 'hooks/useFormatMessage';
import useAutomatischConfig from 'hooks/useAutomatischConfig';
export default function FlowsButtons() {
const formatMessage = useFormatMessage();
const navigate = useNavigate();
const currentUserAbility = useCurrentUserAbility();
const theme = useTheme();
const { data: config } = useAutomatischConfig();
const matchSmallScreens = useMediaQuery(theme.breakpoints.down('md'));
const canCreateFlow = currentUserAbility.can('create', 'Flow');
const enableTemplates = config?.data.enableTemplates === true;
const createFlowButtonData = {
label: formatMessage('flows.createFlow'),
key: 'createFlow',
'data-test': 'create-flow-button',
to: URLS.CREATE_FLOW,
startIcon: <AddIcon />,
};
const createFlowFromTemplateButtonData = {
label: formatMessage('flows.createFlowFromTemplate'),
key: 'createFlowFromTemplate',
'data-test': 'create-flow-from-template-button',
hide: !enableTemplates,
to: URLS.VIEW_TEMPLATES,
};
const importFlowButtonData = {
label: formatMessage('flows.importFlow'),
key: 'importFlow',
'data-test': 'import-flow-button',
to: URLS.IMPORT_FLOW,
};
if (matchSmallScreens) {
const connectionOptions = [
createFlowButtonData,
createFlowFromTemplateButtonData,
importFlowButtonData,
].filter((option) => !option.hide);
return (
<>
<SplitButton disabled={!canCreateFlow} options={connectionOptions} />
</>
);
}
return (
<>
<Button
type="submit"
variant="outlined"
color="info"
size="large"
component={Link}
disabled={!canCreateFlow}
startIcon={<UploadIcon />}
to={URLS.IMPORT_FLOW}
data-test="import-flow-button"
>
{formatMessage('flows.importFlow')}
</Button>
<SplitButton
disabled={!canCreateFlow}
options={[
createFlowButtonData,
createFlowFromTemplateButtonData,
].filter((option) => !option.hide)}
/>
</>
);
}

View File

@@ -1,5 +1,7 @@
import { styled } from '@mui/material/styles';
import MuiCardContent from '@mui/material/CardContent';
export const CardContent = styled(MuiCardContent)`
display: flex;
justify-content: center;

View File

@@ -24,9 +24,6 @@ export default function SplitButton(props) {
};
const handleClose = (event) => {
if (anchorRef.current && anchorRef.current.contains(event.target)) {
return;
}
setOpen(false);
};
@@ -43,6 +40,7 @@ export default function SplitButton(props) {
data-test={selectedOption['data-test']}
component={Link}
to={selectedOption.to}
startIcon={selectedOption.startIcon}
sx={{
// Link component causes style loss in ButtonGroup
borderRadius: 0,
@@ -105,7 +103,8 @@ SplitButton.propTypes = {
key: PropTypes.string.isRequired,
'data-test': PropTypes.string.isRequired,
to: PropTypes.string.isRequired,
disabled: PropTypes.bool.isRequired,
disabled: PropTypes.bool,
startIcon: PropTypes.node,
}).isRequired,
).isRequired,
disabled: PropTypes.bool,

View File

@@ -0,0 +1,89 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import Card from '@mui/material/Card';
import CardActionArea from '@mui/material/CardActionArea';
import { DateTime } from 'luxon';
import IconButton from '@mui/material/IconButton';
import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
import FlowAppIcons from 'components/FlowAppIcons';
import AdminTemplateContextMenu from 'components/AdminTemplateContextMenu';
import useFormatMessage from 'hooks/useFormatMessage';
import * as URLS from 'config/urls';
import { Apps, CardContent, ContextMenu, Title, Typography } from './style';
import { FlowPropType } from 'propTypes/propTypes';
import useIsCurrentUserAdmin from 'hooks/useIsCurrentUserAdmin';
function TemplateItem(props) {
const formatMessage = useFormatMessage();
const contextButtonRef = React.useRef(null);
const [anchorEl, setAnchorEl] = React.useState(null);
const isCurrentUserAdmin = useIsCurrentUserAdmin();
const { template, to } = props;
const handleClose = () => {
setAnchorEl(null);
};
const onContextMenuClick = (event) => {
event.preventDefault();
event.stopPropagation();
event.nativeEvent.stopImmediatePropagation();
setAnchorEl(contextButtonRef.current);
};
return (
<>
<Card sx={{ mb: 1 }} data-test="template-row">
<CardActionArea component={Link} to={to} data-test="card-action-area">
<CardContent>
<Apps direction="row" gap={1} sx={{ gridArea: 'apps' }}>
<FlowAppIcons steps={template.flowData.steps} />
</Apps>
<Title
justifyContent="center"
alignItems="flex-start"
spacing={1}
sx={{ gridArea: 'title' }}
>
<Typography variant="h6" noWrap>
{template?.name}
</Typography>
</Title>
{isCurrentUserAdmin && (
<ContextMenu>
<IconButton
size="large"
color="inherit"
aria-label="open context menu"
ref={contextButtonRef}
onClick={onContextMenuClick}
>
<MoreHorizIcon />
</IconButton>
</ContextMenu>
)}
</CardContent>
</CardActionArea>
</Card>
{anchorEl && (
<AdminTemplateContextMenu
templateId={template.id}
onClose={handleClose}
anchorEl={anchorEl}
/>
)}
</>
);
}
TemplateItem.propTypes = {
template: FlowPropType.isRequired,
to: PropTypes.string.isRequired,
};
export default TemplateItem;

View File

@@ -0,0 +1,46 @@
import { styled } from '@mui/material/styles';
import MuiStack from '@mui/material/Stack';
import MuiBox from '@mui/material/Box';
import MuiCardContent from '@mui/material/CardContent';
import MuiTypography from '@mui/material/Typography';
export const CardContent = styled(MuiCardContent)(({ theme }) => ({
display: 'grid',
gridTemplateRows: 'auto',
gridTemplateColumns: 'calc(30px * 3 + 8px * 2) minmax(0, auto) min-content',
gridGap: theme.spacing(2),
gridTemplateAreas: `
"apps title menu"
`,
alignItems: 'center',
[theme.breakpoints.down('sm')]: {
gridTemplateAreas: `
"apps menu"
"title menu"
`,
gridTemplateColumns: 'minmax(0, auto) min-content',
gridTemplateRows: 'auto auto',
},
}));
export const Apps = styled(MuiStack)(() => ({
gridArea: 'apps',
}));
export const Title = styled(MuiStack)(() => ({
gridArea: 'title',
}));
export const ContextMenu = styled(MuiBox)(({ theme }) => ({
flexDirection: 'row',
display: 'flex',
alignItems: 'center',
gap: theme.spacing(0.625),
gridArea: 'menu',
}));
export const Typography = styled(MuiTypography)(() => ({
display: 'inline-block',
width: '100%',
maxWidth: '85%',
}));
export const DesktopOnlyBreakline = styled('br')(({ theme }) => ({
[theme.breakpoints.down('sm')]: {
display: 'none',
},
}));

View File

@@ -0,0 +1,70 @@
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 { useNavigate } from 'react-router-dom';
import * as URLS from 'config/urls';
import { getUnifiedErrorMessage } from 'helpers/errors';
import useTemplates from 'hooks/useTemplates.ee';
import useFormatMessage from 'hooks/useFormatMessage';
import TemplateItem from './TemplateItem/TemplateItem.ee';
import NoResultFound from 'components/NoResultFound';
export default function TemplatesDialog(props) {
const { open = true } = props;
const formatMessage = useFormatMessage();
const navigate = useNavigate();
const { data: templates } = useTemplates();
const handleClose = () => {
navigate('..');
};
return (
<Dialog open={open} onClose={handleClose} data-test="templates-dialog">
<DialogTitle>{formatMessage('templatesDialog.title')}</DialogTitle>
<IconButton
aria-label="close"
onClick={handleClose}
sx={{
position: 'absolute',
right: 8,
top: 8,
color: (theme) => theme.palette.grey[500],
}}
>
<CloseIcon />
</IconButton>
<DialogContent>
<DialogContentText mb={2}>
{templates?.meta.count !== 0 &&
formatMessage('templatesDialog.description')}
{templates?.meta.count === 0 &&
formatMessage('adminTemplatesPage.noResult')}
</DialogContentText>
{templates?.data.map((template) => (
<TemplateItem
key={template.id}
template={template}
to={URLS.CREATE_FLOW_FROM_TEMPLATE(template.id)}
/>
))}
</DialogContent>
</Dialog>
);
}