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

@@ -11,6 +11,7 @@ export default async (request, response) => {
const configParams = (request) => {
const {
enableTemplates,
enableFooter,
footerBackgroundColor,
footerCopyrightText,
@@ -28,6 +29,7 @@ const configParams = (request) => {
} = request.body;
return {
enableTemplates,
enableFooter,
footerBackgroundColor,
footerCopyrightText,

View File

@@ -35,6 +35,7 @@ describe('PATCH /api/v1/admin/templates/:templateId', () => {
const expectedPayload = await updateTemplateMock({
...refetchedTemplate,
flowData: refetchedTemplate.getFlowDataWithIconUrls(),
name: updatedName,
});

View File

@@ -2,7 +2,7 @@ const adminTemplateSerializer = (template) => {
return {
id: template.id,
name: template.name,
flowData: template.flowData,
flowData: template.getFlowDataWithIconUrls(),
createdAt: template.createdAt.getTime(),
updatedAt: template.updatedAt.getTime(),
};

View File

@@ -13,7 +13,7 @@ describe('adminTemplateSerializer', () => {
const expectedPayload = {
id: template.id,
name: template.name,
flowData: template.flowData,
flowData: template.getFlowDataWithIconUrls(),
createdAt: template.createdAt.getTime(),
updatedAt: template.updatedAt.getTime(),
};

View File

@@ -4,7 +4,7 @@ const createTemplateMock = async (template) => {
name: template.name,
createdAt: template.createdAt.getTime(),
updatedAt: template.updatedAt.getTime(),
flowData: template.flowData,
flowData: template.getFlowDataWithIconUrls(),
};
return {

View File

@@ -4,7 +4,7 @@ const getTemplateMock = async (template) => {
name: template.name,
createdAt: template.createdAt.getTime(),
updatedAt: template.updatedAt.getTime(),
flowData: template.flowData,
flowData: template.getFlowDataWithIconUrls(),
};
return {

View File

@@ -4,7 +4,7 @@ const getTemplatesMock = async (templates) => {
name: template.name,
createdAt: template.createdAt.getTime(),
updatedAt: template.updatedAt.getTime(),
flowData: template.flowData,
flowData: template.getFlowDataWithIconUrls(),
}));
return {

View File

@@ -12,6 +12,10 @@ import * as URLS from 'config/urls';
import Can from 'components/Can';
import AdminApplications from 'pages/AdminApplications';
import AdminApplication from 'pages/AdminApplication';
import AdminTemplates from 'pages/AdminTemplates';
import AdminCreateTemplate from 'pages/AdminCreateTemplate';
import AdminUpdateTemplate from 'pages/AdminUpdateTemplate';
// TODO: consider introducing redirections to `/` as fallback
export default (
<>
@@ -109,6 +113,33 @@ export default (
}
/>
<Route
path={`${URLS.ADMIN_TEMPLATES}/*`}
element={
<Can I="update" a="Config">
<AdminTemplates />
</Can>
}
/>
<Route
path={`${URLS.ADMIN_CREATE_TEMPLATE_PATTERN}/*`}
element={
<Can I="update" a="Config">
<AdminCreateTemplate />
</Can>
}
/>
<Route
path={`${URLS.ADMIN_UPDATE_TEMPLATE_PATTERN}/*`}
element={
<Can I="update" a="Config">
<AdminUpdateTemplate />
</Can>
}
/>
<Route
path={URLS.ADMIN_SETTINGS}
element={<Navigate to={URLS.USERS} replace />}

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>
);
}

View File

@@ -51,6 +51,7 @@ export const FOLDER_FLOWS = (folderId) => `/flows?folderId=${folderId}`;
export const APP_FLOWS_PATTERN = '/app/:appKey/flows';
export const EDITOR = '/editor';
export const CREATE_FLOW = '/editor/create';
export const VIEW_TEMPLATES = '/flows/templates';
export const IMPORT_FLOW = '/flows/import';
export const FLOW_EDITOR = (flowId) => `/editor/${flowId}`;
export const FLOWS = '/flows';
@@ -84,6 +85,12 @@ export const ADMIN_APP_PATTERN = `${ADMIN_SETTINGS}/apps/:appKey`;
export const ADMIN_APP_SETTINGS_PATTERN = `${ADMIN_SETTINGS}/apps/:appKey/settings`;
export const ADMIN_APP_AUTH_CLIENTS_PATTERN = `${ADMIN_SETTINGS}/apps/:appKey/oauth-clients`;
export const ADMIN_APP_CONNECTIONS_PATTERN = `${ADMIN_SETTINGS}/apps/:appKey/connections`;
export const ADMIN_TEMPLATES = `${ADMIN_SETTINGS}/templates`;
export const ADMIN_CREATE_TEMPLATE_PATTERN = `${ADMIN_SETTINGS}/templates/create/:flowId`;
export const ADMIN_UPDATE_TEMPLATE_PATTERN = `${ADMIN_SETTINGS}/templates/update/:templateId`;
export const CREATE_FLOW_FROM_TEMPLATE = (templateId) =>
`/editor/create?templateId=${templateId}`;
export const ADMIN_APP_CONNECTIONS = (appKey) =>
`${ADMIN_SETTINGS}/apps/${appKey}/connections`;
@@ -100,6 +107,12 @@ export const ADMIN_APP_AUTH_CLIENT = (appKey, id) =>
export const ADMIN_APP_AUTH_CLIENTS_CREATE = (appKey) =>
`${ADMIN_SETTINGS}/apps/${appKey}/oauth-clients/create`;
export const ADMIN_CREATE_TEMPLATE = (flowId) =>
`${ADMIN_SETTINGS}/templates/create/${flowId}`;
export const ADMIN_UPDATE_TEMPLATE = (templateId) =>
`${ADMIN_SETTINGS}/templates/update/${templateId}`;
export const DASHBOARD = FLOWS;
// External links and paths

View File

@@ -0,0 +1,15 @@
import { useMutation } from '@tanstack/react-query';
import api from 'helpers/api';
export default function useAdminCreateTemplate() {
const mutation = useMutation({
mutationFn: async ({ flowId, name }) => {
const { data } = await api.post(`/v1/admin/templates`, { flowId, name });
return data;
},
});
return mutation;
}

View File

@@ -0,0 +1,23 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import api from 'helpers/api';
export default function useAdminDeleteTemplate(templateId) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: async () => {
const { data } = await api.delete(`/v1/admin/templates/${templateId}`);
return data;
},
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ['admin', 'templates'],
});
},
});
return mutation;
}

View File

@@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/react-query';
import api from 'helpers/api';
export default function useAdminTemplate(templateId) {
const query = useQuery({
queryKey: ['admin', 'templates', templateId],
queryFn: async ({ signal }) => {
const { data } = await api.get(`/v1/admin/templates/${templateId}`, {
signal,
});
return data;
},
});
return query;
}

View File

@@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/react-query';
import api from 'helpers/api';
export default function useAdminTemplates() {
const query = useQuery({
queryKey: ['admin', 'templates'],
queryFn: async ({ signal }) => {
const { data } = await api.get('/v1/admin/templates', {
signal,
});
return data;
},
});
return query;
}

View File

@@ -1,21 +1,21 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import api from 'helpers/api';
export default function useAdminUpdateConfig(appKey) {
export default function useAdminUpdateConfig() {
const queryClient = useQueryClient();
const query = useMutation({
const mutation = useMutation({
mutationFn: async (payload) => {
const { data } = await api.patch('/v1/admin/config', payload);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ['automatisch', 'config'],
});
},
});
return query;
return mutation;
}

View File

@@ -0,0 +1,17 @@
import { useMutation } from '@tanstack/react-query';
import api from 'helpers/api';
export default function useAdminUpdateTemplate(templateId) {
const mutation = useMutation({
mutationFn: async ({ name }) => {
const { data } = await api.patch(`/v1/admin/templates/${templateId}`, {
name,
});
return data;
},
});
return mutation;
}

View File

@@ -5,19 +5,21 @@ import api from 'helpers/api';
export default function useCreateFlow() {
const queryClient = useQueryClient();
const query = useMutation({
mutationFn: async () => {
const { data } = await api.post('/v1/flows');
const mutation = useMutation({
mutationFn: async ({ templateId }) => {
const { data } = await api.post('/v1/flows', null, {
params: { templateId },
});
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ['flows'],
});
},
});
return query;
return mutation;
}

View File

@@ -0,0 +1,8 @@
import userAbility from 'helpers/userAbility';
import useCurrentUser from 'hooks/useCurrentUser';
export default function useIsCurrentUserAdmin() {
const { data: currentUser } = useCurrentUser();
return currentUser?.data.role.isAdmin === true;
}

View File

@@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/react-query';
import api from 'helpers/api';
export default function useTemplates() {
const query = useQuery({
queryKey: ['templates'],
queryFn: async ({ signal }) => {
const { data } = await api.get('/v1/templates', {
signal,
});
return data;
},
});
return query;
}

View File

@@ -20,6 +20,7 @@
"adminSettingsDrawer.userInterface": "User Interface",
"adminSettingsDrawer.goBack": "Go to the dashboard",
"adminSettingsDrawer.apps": "Applications",
"adminSettingsDrawer.templates": "Templates",
"adminSettingsFooter.version": "Version {version}",
"app.connectionCount": "{count} connections",
"app.flowCount": "{count} flows",
@@ -89,10 +90,12 @@
"flow.moveTo": "Move to",
"flow.delete": "Delete",
"flow.export": "Export",
"flow.createTemplateFromFlow": "Use as template",
"flowStep.triggerType": "Trigger",
"flowStep.actionType": "Action",
"flows.create": "Create flow",
"flows.import": "Import flow",
"flows.createFlow": "Create flow",
"flows.createFlowFromTemplate": "Create from template",
"flows.importFlow": "Import flow",
"flows.title": "Flows",
"flows.noFlows": "You don't have any flows yet.",
"flowEditor.goBack": "Go back to flows",
@@ -370,5 +373,17 @@
"footer.docsLinkText": "Documentation",
"footer.tosLinkText": "Terms of Service",
"footer.privacyPolicyLinkText": "Privacy",
"footer.imprintLinkText": "Imprint"
"footer.imprintLinkText": "Imprint",
"templatesDialog.title": "Templates",
"templatesDialog.description": "You may choose one of the templates below to create a new flow.",
"templatesDialog.close": "Close",
"adminTemplatesPage.title": "Templates",
"adminTemplatesPage.noResult": "There are currently no templates.",
"adminTemplatePage.title": "Template",
"adminUpdateTemplate.titleFieldLabel": "Template name",
"adminUpdateTemplate.submit": "Update",
"adminCreateTemplate.titleFieldLabel": "Template name",
"adminCreateTemplate.submit": "Create",
"adminTemplateContextMenu.delete": "Delete",
"adminTemplateContextMenu.successfullyDeleted": "The template has been deleted."
}

View File

@@ -0,0 +1,72 @@
import LoadingButton from '@mui/lab/LoadingButton';
import Grid from '@mui/material/Grid';
import Skeleton from '@mui/material/Skeleton';
import Stack from '@mui/material/Stack';
import { useNavigate, useParams } from 'react-router-dom';
import * as URLS from 'config/urls';
import Container from 'components/Container';
import Form from 'components/Form';
import PageTitle from 'components/PageTitle';
import TextField from 'components/TextField';
import useFlow from 'hooks/useFlow';
import useAutomatischConfig from 'hooks/useAutomatischConfig';
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
import useFormatMessage from 'hooks/useFormatMessage';
import useAdminCreateTemplate from 'hooks/useAdminCreateTemplate.ee';
function AdminCreateTemplatePage() {
const formatMessage = useFormatMessage();
const { flowId } = useParams();
const navigate = useNavigate();
const { data: flow, isLoading: isTemplateLoading } = useFlow(flowId);
const { mutateAsync: createTemplate, isPending } = useAdminCreateTemplate();
const handleFormSubmit = async (data) => {
await createTemplate({
name: data.name,
flowId: flowId,
});
navigate(URLS.ADMIN_TEMPLATES);
};
return (
<Container sx={{ py: 3, display: 'flex', justifyContent: 'center' }}>
<Grid container item xs={12} sm={10} md={9}>
<Grid container item xs={12} sx={{ mb: [2, 5] }}>
<PageTitle>{formatMessage('adminTemplatePage.title')}</PageTitle>
</Grid>
<Grid item xs={12} sx={{ pt: 5, pb: 5 }}>
<Stack spacing={5}></Stack>
{!isTemplateLoading && (
<Form onSubmit={handleFormSubmit} defaultValues={flow.data}>
<Stack direction="column" gap={2}>
<TextField
name="name"
label={formatMessage('adminCreateTemplate.titleFieldLabel')}
fullWidth
/>
<LoadingButton
type="submit"
variant="contained"
color="primary"
sx={{ boxShadow: 2 }}
loading={isPending}
data-test="update-button"
>
{formatMessage('adminCreateTemplate.submit')}
</LoadingButton>
</Stack>
</Form>
)}
</Grid>
</Grid>
</Container>
);
}
export default AdminCreateTemplatePage;

View File

@@ -0,0 +1,103 @@
import * as React from 'react';
import Grid from '@mui/material/Grid';
import CircularProgress from '@mui/material/CircularProgress';
import Divider from '@mui/material/Divider';
import Switch from 'components/Switch';
import Form from 'components/Form';
import PageTitle from 'components/PageTitle';
import Container from 'components/Container';
import SearchInput from 'components/SearchInput';
import TemplateItem from 'components/TemplatesDialog/TemplateItem/TemplateItem.ee.jsx';
import * as URLS from 'config/urls';
import useFormatMessage from 'hooks/useFormatMessage';
import useAdminTemplates from 'hooks/useAdminTemplates.ee';
import useAdminUpdateConfig from 'hooks/useAdminUpdateConfig';
import useAutomatischConfig from 'hooks/useAutomatischConfig';
import NoResultFound from 'components/NoResultFound';
function AdminTemplates() {
const formatMessage = useFormatMessage();
const [templateName, setTemplateName] = React.useState('');
const { data: config } = useAutomatischConfig();
const { data: templates, isLoading: isTemplatesLoading } = useAdminTemplates({
name: templateName,
});
const { mutateAsync: updateConfig, isPending: isUpdateConfigPending } =
useAdminUpdateConfig();
const onSearchChange = React.useCallback((event) => {
setTemplateName(event.target.value);
}, []);
const handleChangeOnFeatureToggle = async (event) => {
const value = event.target.checked;
await updateConfig({ enableTemplates: value });
};
return (
<Container sx={{ py: 3, display: 'flex', justifyContent: 'center' }}>
<Grid container item xs={12} sm={10} md={9}>
<Grid container sx={{ mb: [0, 3] }} columnSpacing={1.5} rowSpacing={3}>
<Grid container item xs sm alignItems="center" order={{ xs: 0 }}>
<PageTitle>{formatMessage('adminTemplatesPage.title')}</PageTitle>
</Grid>
<Grid item xs={12} sm="auto" order={{ xs: 2, sm: 1 }}>
<SearchInput onChange={onSearchChange} />
</Grid>
</Grid>
<Grid item xs={12}>
<Divider sx={{ mt: [2, 0], mb: 2 }} />
</Grid>
<Grid item xs={12} sx={{ mb: 3 }}>
<Form
defaultValues={{ enableTemplates: config?.data.enableTemplates }}
noValidate
automaticValidation={false}
render={({ formState: { errors, isDirty } }) => (
<Switch
name="enableTemplates"
disabled={isUpdateConfigPending}
onChange={handleChangeOnFeatureToggle}
label={formatMessage('authenticationForm.active')}
/>
)}
/>
</Grid>
{isTemplatesLoading && (
<CircularProgress
data-test="templates-loader"
sx={{ display: 'block', margin: '20px auto' }}
/>
)}
<Grid item xs={12}>
{!isTemplatesLoading &&
templates?.data?.map((template) => (
<TemplateItem
key={template.name}
template={template}
to={URLS.ADMIN_UPDATE_TEMPLATE(template.id)}
/>
))}
{!isTemplatesLoading && templates?.meta.count === 0 && (
<NoResultFound
text={formatMessage('adminTemplatesPage.noResult')}
/>
)}
</Grid>
</Grid>
</Container>
);
}
export default AdminTemplates;

View File

@@ -0,0 +1,67 @@
import LoadingButton from '@mui/lab/LoadingButton';
import Grid from '@mui/material/Grid';
import Skeleton from '@mui/material/Skeleton';
import Stack from '@mui/material/Stack';
import { useParams } from 'react-router-dom';
import Container from 'components/Container';
import Form from 'components/Form';
import PageTitle from 'components/PageTitle';
import TextField from 'components/TextField';
import useAdminTemplate from 'hooks/useAdminTemplate.ee';
import useAutomatischConfig from 'hooks/useAutomatischConfig';
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
import useFormatMessage from 'hooks/useFormatMessage';
import useAdminUpdateTemplate from 'hooks/useAdminUpdateTemplate.ee';
function AdminUpdateTemplatePage() {
const formatMessage = useFormatMessage();
const { templateId } = useParams();
const { data: template, isLoading: isTemplateLoading } =
useAdminTemplate(templateId);
const { mutateAsync: updateTemplate, isPending } =
useAdminUpdateTemplate(templateId);
const handleFormSubmit = async (data) => {
updateTemplate(data);
};
return (
<Container sx={{ py: 3, display: 'flex', justifyContent: 'center' }}>
<Grid container item xs={12} sm={10} md={9}>
<Grid container item xs={12} sx={{ mb: [2, 5] }}>
<PageTitle>{formatMessage('adminTemplatePage.title')}</PageTitle>
</Grid>
<Grid item xs={12} sx={{ pt: 5, pb: 5 }}>
<Stack spacing={5}></Stack>
{!isTemplateLoading && (
<Form onSubmit={handleFormSubmit} defaultValues={template.data}>
<Stack direction="column" gap={2}>
<TextField
name="name"
label={formatMessage('adminUpdateTemplate.titleFieldLabel')}
fullWidth
/>
<LoadingButton
type="submit"
variant="contained"
color="primary"
sx={{ boxShadow: 2 }}
loading={isPending}
data-test="update-button"
>
{formatMessage('adminUpdateTemplate.submit')}
</LoadingButton>
</Stack>
</Form>
)}
</Grid>
</Grid>
</Container>
);
}
export default AdminUpdateTemplatePage;

View File

@@ -1,5 +1,5 @@
import * as React from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import CircularProgress from '@mui/material/CircularProgress';
import Typography from '@mui/material/Typography';
import * as URLS from 'config/urls';
@@ -10,21 +10,26 @@ import Box from '@mui/material/Box';
export default function CreateFlow() {
const navigate = useNavigate();
const formatMessage = useFormatMessage();
const { mutateAsync: createFlow, isError } = useCreateFlow();
const [searchParams] = useSearchParams();
const { mutateAsync: createFlow, isCreateFlowError } = useCreateFlow();
const navigateToEditor = (flowId) =>
navigate(URLS.FLOW_EDITOR(flowId), { replace: true });
React.useEffect(() => {
async function initiate() {
const response = await createFlow();
const templateId = searchParams.get('templateId');
const response = await createFlow({ templateId });
const flowId = response.data?.id;
navigate(URLS.FLOW_EDITOR(flowId), { replace: true });
navigateToEditor(flowId);
}
initiate();
}, [createFlow, navigate]);
}, [createFlow, navigate, searchParams]);
if (isError) {
if (isCreateFlowError) {
return null;
}
@@ -40,6 +45,7 @@ export default function CreateFlow() {
}}
>
<CircularProgress size={16} thickness={7.5} />
<Typography variant="body2">
{formatMessage('createFlow.creating')}
</Typography>

View File

@@ -1,33 +1,35 @@
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 Pagination from '@mui/material/Pagination';
import PaginationItem from '@mui/material/PaginationItem';
import * as React from 'react';
import {
Link,
Route,
Routes,
useNavigate,
useSearchParams,
Routes,
Route,
} from 'react-router-dom';
import Box from '@mui/material/Box';
import Grid from '@mui/material/Grid';
import AddIcon from '@mui/icons-material/Add';
import UploadIcon from '@mui/icons-material/Upload';
import CircularProgress from '@mui/material/CircularProgress';
import Divider from '@mui/material/Divider';
import Pagination from '@mui/material/Pagination';
import PaginationItem from '@mui/material/PaginationItem';
import Can from 'components/Can';
import Folders from 'components/Folders';
import FlowRow from 'components/FlowRow';
import NoResultFound from 'components/NoResultFound';
import FlowsButtons from 'components/FlowsButtons';
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 NoResultFound from 'components/NoResultFound';
import PageTitle from 'components/PageTitle';
import SearchInput from 'components/SearchInput';
import ImportFlowDialog from 'components/ImportFlowDialog';
import useFormatMessage from 'hooks/useFormatMessage';
import useCurrentUserAbility from 'hooks/useCurrentUserAbility';
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';
export default function Flows() {
const formatMessage = useFormatMessage();
@@ -96,7 +98,7 @@ export default function Flows() {
<PageTitle>{formatMessage('flows.title')}</PageTitle>
</Grid>
<Grid item xs={12} sm="auto" order={{ xs: 2, sm: 1 }}>
<Grid item xs={12} md="auto" order={{ xs: 2, md: 1 }}>
<SearchInput onChange={onSearchChange} defaultValue={flowName} />
</Grid>
@@ -109,51 +111,19 @@ export default function Flows() {
sm="auto"
gap={1}
alignItems="center"
order={{ xs: 1, sm: 2 }}
order={{ xs: 1 }}
>
<Can I="create" a="Flow" passThrough>
{(allowed) => (
<ConditionalIconButton
type="submit"
variant="outlined"
color="info"
size="large"
component={Link}
disabled={!allowed}
icon={<UploadIcon />}
to={URLS.IMPORT_FLOW}
data-test="import-flow-button"
>
{formatMessage('flows.import')}
</ConditionalIconButton>
)}
</Can>
<Can I="create" a="Flow" passThrough>
{(allowed) => (
<ConditionalIconButton
type="submit"
variant="contained"
color="primary"
size="large"
component={Link}
disabled={!allowed}
icon={<AddIcon />}
to={URLS.CREATE_FLOW}
data-test="create-flow-button"
>
{formatMessage('flows.create')}
</ConditionalIconButton>
)}
</Can>
<FlowsButtons />
</Grid>
</Grid>
<Divider sx={{ mt: [2, 0], mb: 2 }} />
<Grid container columnSpacing={2}>
<Grid container columnSpacing={2} rowSpacing={2}>
<Grid item xs={12} sm={3}>
<Folders />
<Divider sx={{ mt: { xs: 2 }, display: { sm: 'none' } }} />
</Grid>
<Grid item xs={12} sm={9}>
@@ -205,6 +175,7 @@ export default function Flows() {
<Routes>
<Route path="/import" element={<ImportFlowDialog />} />
<Route path="/templates" element={<TemplatesDialog />} />
</Routes>
</>
);