feat(web): introduce templates
This commit is contained in:
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
@@ -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}>
|
||||
|
||||
110
packages/web/src/components/FlowsButtons/index.jsx
Normal file
110
packages/web/src/components/FlowsButtons/index.jsx
Normal 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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
},
|
||||
}));
|
||||
70
packages/web/src/components/TemplatesDialog/index.ee.jsx
Normal file
70
packages/web/src/components/TemplatesDialog/index.ee.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user