From 839fda888061dba9853946cdd3fa917cdb103f27 Mon Sep 17 00:00:00 2001 From: Ali BARIN Date: Wed, 26 Feb 2025 10:40:47 +0000 Subject: [PATCH] feat(web): introduce templates --- .../api/v1/admin/config/update.ee.js | 2 + .../templates/update-template.ee.test.js | 1 + .../src/serializers/admin/template.ee.js | 2 +- .../src/serializers/admin/template.ee.test.js | 2 +- .../v1/admin/templates/create-template.ee.js | 2 +- .../api/v1/admin/templates/get-template.ee.js | 2 +- .../v1/admin/templates/get-templates.ee.js | 2 +- packages/web/src/adminSettingsRoutes.jsx | 31 +++++ .../components/AdminSettingsLayout/index.jsx | 16 +++ .../AdminTemplateContextMenu/index.jsx | 71 +++++++++++ .../src/components/FlowContextMenu/index.jsx | 24 +++- .../web/src/components/FlowsButtons/index.jsx | 110 ++++++++++++++++++ .../web/src/components/NoResultFound/style.js | 2 + .../web/src/components/SplitButton/index.jsx | 7 +- .../TemplateItem/TemplateItem.ee.jsx | 89 ++++++++++++++ .../TemplatesDialog/TemplateItem/style.js | 46 ++++++++ .../components/TemplatesDialog/index.ee.jsx | 70 +++++++++++ packages/web/src/config/urls.js | 13 +++ .../src/hooks/useAdminCreateTemplate.ee.js | 15 +++ .../src/hooks/useAdminDeleteTemplate.ee.js | 23 ++++ packages/web/src/hooks/useAdminTemplate.ee.js | 17 +++ .../web/src/hooks/useAdminTemplates.ee.js | 17 +++ .../web/src/hooks/useAdminUpdateConfig.js | 10 +- .../src/hooks/useAdminUpdateTemplate.ee.js | 17 +++ packages/web/src/hooks/useCreateFlow.js | 14 ++- .../web/src/hooks/useIsCurrentUserAdmin.js | 8 ++ packages/web/src/hooks/useTemplates.ee.js | 17 +++ packages/web/src/locales/en.json | 21 +++- .../src/pages/AdminCreateTemplate/index.jsx | 72 ++++++++++++ .../web/src/pages/AdminTemplates/index.jsx | 103 ++++++++++++++++ .../src/pages/AdminUpdateTemplate/index.jsx | 67 +++++++++++ packages/web/src/pages/Editor/create.jsx | 18 ++- packages/web/src/pages/Flows/index.jsx | 79 ++++--------- 33 files changed, 904 insertions(+), 86 deletions(-) create mode 100644 packages/web/src/components/AdminTemplateContextMenu/index.jsx create mode 100644 packages/web/src/components/FlowsButtons/index.jsx create mode 100644 packages/web/src/components/TemplatesDialog/TemplateItem/TemplateItem.ee.jsx create mode 100644 packages/web/src/components/TemplatesDialog/TemplateItem/style.js create mode 100644 packages/web/src/components/TemplatesDialog/index.ee.jsx create mode 100644 packages/web/src/hooks/useAdminCreateTemplate.ee.js create mode 100644 packages/web/src/hooks/useAdminDeleteTemplate.ee.js create mode 100644 packages/web/src/hooks/useAdminTemplate.ee.js create mode 100644 packages/web/src/hooks/useAdminTemplates.ee.js create mode 100644 packages/web/src/hooks/useAdminUpdateTemplate.ee.js create mode 100644 packages/web/src/hooks/useIsCurrentUserAdmin.js create mode 100644 packages/web/src/hooks/useTemplates.ee.js create mode 100644 packages/web/src/pages/AdminCreateTemplate/index.jsx create mode 100644 packages/web/src/pages/AdminTemplates/index.jsx create mode 100644 packages/web/src/pages/AdminUpdateTemplate/index.jsx diff --git a/packages/backend/src/controllers/api/v1/admin/config/update.ee.js b/packages/backend/src/controllers/api/v1/admin/config/update.ee.js index 263d9f6b..750cd5d4 100644 --- a/packages/backend/src/controllers/api/v1/admin/config/update.ee.js +++ b/packages/backend/src/controllers/api/v1/admin/config/update.ee.js @@ -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, diff --git a/packages/backend/src/controllers/api/v1/admin/templates/update-template.ee.test.js b/packages/backend/src/controllers/api/v1/admin/templates/update-template.ee.test.js index ebac1ed7..f6052818 100644 --- a/packages/backend/src/controllers/api/v1/admin/templates/update-template.ee.test.js +++ b/packages/backend/src/controllers/api/v1/admin/templates/update-template.ee.test.js @@ -35,6 +35,7 @@ describe('PATCH /api/v1/admin/templates/:templateId', () => { const expectedPayload = await updateTemplateMock({ ...refetchedTemplate, + flowData: refetchedTemplate.getFlowDataWithIconUrls(), name: updatedName, }); diff --git a/packages/backend/src/serializers/admin/template.ee.js b/packages/backend/src/serializers/admin/template.ee.js index 60b02a9c..1f720991 100644 --- a/packages/backend/src/serializers/admin/template.ee.js +++ b/packages/backend/src/serializers/admin/template.ee.js @@ -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(), }; diff --git a/packages/backend/src/serializers/admin/template.ee.test.js b/packages/backend/src/serializers/admin/template.ee.test.js index 545bcf8c..aa78e56b 100644 --- a/packages/backend/src/serializers/admin/template.ee.test.js +++ b/packages/backend/src/serializers/admin/template.ee.test.js @@ -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(), }; diff --git a/packages/backend/test/mocks/rest/api/v1/admin/templates/create-template.ee.js b/packages/backend/test/mocks/rest/api/v1/admin/templates/create-template.ee.js index 2081a5b9..b9e8ae76 100644 --- a/packages/backend/test/mocks/rest/api/v1/admin/templates/create-template.ee.js +++ b/packages/backend/test/mocks/rest/api/v1/admin/templates/create-template.ee.js @@ -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 { diff --git a/packages/backend/test/mocks/rest/api/v1/admin/templates/get-template.ee.js b/packages/backend/test/mocks/rest/api/v1/admin/templates/get-template.ee.js index 71ff8428..256819fa 100644 --- a/packages/backend/test/mocks/rest/api/v1/admin/templates/get-template.ee.js +++ b/packages/backend/test/mocks/rest/api/v1/admin/templates/get-template.ee.js @@ -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 { diff --git a/packages/backend/test/mocks/rest/api/v1/admin/templates/get-templates.ee.js b/packages/backend/test/mocks/rest/api/v1/admin/templates/get-templates.ee.js index 673729fa..f14c2ea2 100644 --- a/packages/backend/test/mocks/rest/api/v1/admin/templates/get-templates.ee.js +++ b/packages/backend/test/mocks/rest/api/v1/admin/templates/get-templates.ee.js @@ -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 { diff --git a/packages/web/src/adminSettingsRoutes.jsx b/packages/web/src/adminSettingsRoutes.jsx index 3e842d81..560d4d23 100644 --- a/packages/web/src/adminSettingsRoutes.jsx +++ b/packages/web/src/adminSettingsRoutes.jsx @@ -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 ( } /> + + + + } + /> + + + + + } + /> + + + + + } + /> + } diff --git a/packages/web/src/components/AdminSettingsLayout/index.jsx b/packages/web/src/components/AdminSettingsLayout/index.jsx index 4570edc1..29351314 100644 --- a/packages/web/src/components/AdminSettingsLayout/index.jsx +++ b/packages/web/src/components/AdminSettingsLayout/index.jsx @@ -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 ( + + diff --git a/packages/web/src/components/AdminTemplateContextMenu/index.jsx b/packages/web/src/components/AdminTemplateContextMenu/index.jsx new file mode 100644 index 00000000..d8c963c1 --- /dev/null +++ b/packages/web/src/components/AdminTemplateContextMenu/index.jsx @@ -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 ( + + + {(allowed) => ( + + {formatMessage('adminTemplateContextMenu.delete')} + + )} + + + ); +} + +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; diff --git a/packages/web/src/components/FlowContextMenu/index.jsx b/packages/web/src/components/FlowContextMenu/index.jsx index 5b7ce68e..e24ad7fe 100644 --- a/packages/web/src/components/FlowContextMenu/index.jsx +++ b/packages/web/src/components/FlowContextMenu/index.jsx @@ -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) { )} + {isCurrentUserAdmin && ( + + {(allowed) => ( + + {formatMessage('flow.createTemplateFromFlow')} + + )} + + )} + {(allowed) => ( diff --git a/packages/web/src/components/FlowsButtons/index.jsx b/packages/web/src/components/FlowsButtons/index.jsx new file mode 100644 index 00000000..ebd091e3 --- /dev/null +++ b/packages/web/src/components/FlowsButtons/index.jsx @@ -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: , + }; + + 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 ( + <> + + + ); + } + + return ( + <> + + + !option.hide)} + /> + + ); +} diff --git a/packages/web/src/components/NoResultFound/style.js b/packages/web/src/components/NoResultFound/style.js index 828f1878..7d7cde32 100644 --- a/packages/web/src/components/NoResultFound/style.js +++ b/packages/web/src/components/NoResultFound/style.js @@ -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; diff --git a/packages/web/src/components/SplitButton/index.jsx b/packages/web/src/components/SplitButton/index.jsx index 82e8c733..4a26d020 100644 --- a/packages/web/src/components/SplitButton/index.jsx +++ b/packages/web/src/components/SplitButton/index.jsx @@ -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, diff --git a/packages/web/src/components/TemplatesDialog/TemplateItem/TemplateItem.ee.jsx b/packages/web/src/components/TemplatesDialog/TemplateItem/TemplateItem.ee.jsx new file mode 100644 index 00000000..0233958f --- /dev/null +++ b/packages/web/src/components/TemplatesDialog/TemplateItem/TemplateItem.ee.jsx @@ -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 ( + <> + + + + + + + + + <Typography variant="h6" noWrap> + {template?.name} + </Typography> + + + {isCurrentUserAdmin && ( + + + + + + )} + + + + + {anchorEl && ( + + )} + + ); +} + +TemplateItem.propTypes = { + template: FlowPropType.isRequired, + to: PropTypes.string.isRequired, +}; + +export default TemplateItem; diff --git a/packages/web/src/components/TemplatesDialog/TemplateItem/style.js b/packages/web/src/components/TemplatesDialog/TemplateItem/style.js new file mode 100644 index 00000000..f8d7aa28 --- /dev/null +++ b/packages/web/src/components/TemplatesDialog/TemplateItem/style.js @@ -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', + }, +})); diff --git a/packages/web/src/components/TemplatesDialog/index.ee.jsx b/packages/web/src/components/TemplatesDialog/index.ee.jsx new file mode 100644 index 00000000..6cf44338 --- /dev/null +++ b/packages/web/src/components/TemplatesDialog/index.ee.jsx @@ -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 ( + + {formatMessage('templatesDialog.title')} + + theme.palette.grey[500], + }} + > + + + + + + {templates?.meta.count !== 0 && + formatMessage('templatesDialog.description')} + + {templates?.meta.count === 0 && + formatMessage('adminTemplatesPage.noResult')} + + + {templates?.data.map((template) => ( + + ))} + + + ); +} diff --git a/packages/web/src/config/urls.js b/packages/web/src/config/urls.js index ceba4489..0f77ece8 100644 --- a/packages/web/src/config/urls.js +++ b/packages/web/src/config/urls.js @@ -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 diff --git a/packages/web/src/hooks/useAdminCreateTemplate.ee.js b/packages/web/src/hooks/useAdminCreateTemplate.ee.js new file mode 100644 index 00000000..ab9a1df5 --- /dev/null +++ b/packages/web/src/hooks/useAdminCreateTemplate.ee.js @@ -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; +} diff --git a/packages/web/src/hooks/useAdminDeleteTemplate.ee.js b/packages/web/src/hooks/useAdminDeleteTemplate.ee.js new file mode 100644 index 00000000..38e9f05b --- /dev/null +++ b/packages/web/src/hooks/useAdminDeleteTemplate.ee.js @@ -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; +} diff --git a/packages/web/src/hooks/useAdminTemplate.ee.js b/packages/web/src/hooks/useAdminTemplate.ee.js new file mode 100644 index 00000000..d2db9130 --- /dev/null +++ b/packages/web/src/hooks/useAdminTemplate.ee.js @@ -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; +} diff --git a/packages/web/src/hooks/useAdminTemplates.ee.js b/packages/web/src/hooks/useAdminTemplates.ee.js new file mode 100644 index 00000000..9bd517f6 --- /dev/null +++ b/packages/web/src/hooks/useAdminTemplates.ee.js @@ -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; +} diff --git a/packages/web/src/hooks/useAdminUpdateConfig.js b/packages/web/src/hooks/useAdminUpdateConfig.js index 4b5f513b..7bef684f 100644 --- a/packages/web/src/hooks/useAdminUpdateConfig.js +++ b/packages/web/src/hooks/useAdminUpdateConfig.js @@ -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; } diff --git a/packages/web/src/hooks/useAdminUpdateTemplate.ee.js b/packages/web/src/hooks/useAdminUpdateTemplate.ee.js new file mode 100644 index 00000000..da4e18c1 --- /dev/null +++ b/packages/web/src/hooks/useAdminUpdateTemplate.ee.js @@ -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; +} diff --git a/packages/web/src/hooks/useCreateFlow.js b/packages/web/src/hooks/useCreateFlow.js index fd3993bb..5acf343a 100644 --- a/packages/web/src/hooks/useCreateFlow.js +++ b/packages/web/src/hooks/useCreateFlow.js @@ -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; } diff --git a/packages/web/src/hooks/useIsCurrentUserAdmin.js b/packages/web/src/hooks/useIsCurrentUserAdmin.js new file mode 100644 index 00000000..e1e84272 --- /dev/null +++ b/packages/web/src/hooks/useIsCurrentUserAdmin.js @@ -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; +} diff --git a/packages/web/src/hooks/useTemplates.ee.js b/packages/web/src/hooks/useTemplates.ee.js new file mode 100644 index 00000000..7232c50e --- /dev/null +++ b/packages/web/src/hooks/useTemplates.ee.js @@ -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; +} diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json index 88de935d..37b4fe47 100644 --- a/packages/web/src/locales/en.json +++ b/packages/web/src/locales/en.json @@ -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." } diff --git a/packages/web/src/pages/AdminCreateTemplate/index.jsx b/packages/web/src/pages/AdminCreateTemplate/index.jsx new file mode 100644 index 00000000..b3f56cfe --- /dev/null +++ b/packages/web/src/pages/AdminCreateTemplate/index.jsx @@ -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 ( + + + + {formatMessage('adminTemplatePage.title')} + + + + + {!isTemplateLoading && ( +
+ + + + + {formatMessage('adminCreateTemplate.submit')} + + +
+ )} +
+
+
+ ); +} +export default AdminCreateTemplatePage; diff --git a/packages/web/src/pages/AdminTemplates/index.jsx b/packages/web/src/pages/AdminTemplates/index.jsx new file mode 100644 index 00000000..83d94215 --- /dev/null +++ b/packages/web/src/pages/AdminTemplates/index.jsx @@ -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 ( + + + + + {formatMessage('adminTemplatesPage.title')} + + + + + + + + + + + + +
( + + )} + /> + + + {isTemplatesLoading && ( + + )} + + + {!isTemplatesLoading && + templates?.data?.map((template) => ( + + ))} + + {!isTemplatesLoading && templates?.meta.count === 0 && ( + + )} + + + + ); +} + +export default AdminTemplates; diff --git a/packages/web/src/pages/AdminUpdateTemplate/index.jsx b/packages/web/src/pages/AdminUpdateTemplate/index.jsx new file mode 100644 index 00000000..e1f8db91 --- /dev/null +++ b/packages/web/src/pages/AdminUpdateTemplate/index.jsx @@ -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 ( + + + + {formatMessage('adminTemplatePage.title')} + + + + + {!isTemplateLoading && ( + + + + + + {formatMessage('adminUpdateTemplate.submit')} + + + + )} + + + + ); +} +export default AdminUpdateTemplatePage; diff --git a/packages/web/src/pages/Editor/create.jsx b/packages/web/src/pages/Editor/create.jsx index 9e9a10f4..043b3793 100644 --- a/packages/web/src/pages/Editor/create.jsx +++ b/packages/web/src/pages/Editor/create.jsx @@ -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() { }} > + {formatMessage('createFlow.creating')} diff --git a/packages/web/src/pages/Flows/index.jsx b/packages/web/src/pages/Flows/index.jsx index 90aa5a94..f7a06bcd 100644 --- a/packages/web/src/pages/Flows/index.jsx +++ b/packages/web/src/pages/Flows/index.jsx @@ -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() { {formatMessage('flows.title')}
- + @@ -109,51 +111,19 @@ export default function Flows() { sm="auto" gap={1} alignItems="center" - order={{ xs: 1, sm: 2 }} + order={{ xs: 1 }} > - - {(allowed) => ( - } - to={URLS.IMPORT_FLOW} - data-test="import-flow-button" - > - {formatMessage('flows.import')} - - )} - - - - {(allowed) => ( - } - to={URLS.CREATE_FLOW} - data-test="create-flow-button" - > - {formatMessage('flows.create')} - - )} - +
- + + + @@ -205,6 +175,7 @@ export default function Flows() { } /> + } /> );