diff --git a/packages/web/src/components/ConditionalIconButton/index.jsx b/packages/web/src/components/ConditionalIconButton/index.jsx index 50125c32..1f859eb6 100644 --- a/packages/web/src/components/ConditionalIconButton/index.jsx +++ b/packages/web/src/components/ConditionalIconButton/index.jsx @@ -9,6 +9,7 @@ function ConditionalIconButton(props) { const { icon, ...buttonProps } = props; const theme = useTheme(); const matchSmallScreens = useMediaQuery(theme.breakpoints.down('md')); + if (matchSmallScreens) { return ( ); } - return + ); +} + +FileUploadInput.propTypes = { + onChange: PropTypes.func.isRequired, + children: PropTypes.node.isRequired, +}; diff --git a/packages/web/src/components/ImportFlowDialog/index.jsx b/packages/web/src/components/ImportFlowDialog/index.jsx new file mode 100644 index 00000000..cc5d8609 --- /dev/null +++ b/packages/web/src/components/ImportFlowDialog/index.jsx @@ -0,0 +1,158 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { useNavigate, Link } from 'react-router-dom'; +import Alert from '@mui/material/Alert'; +import Button from '@mui/material/Button'; +import Stack from '@mui/material/Stack'; +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 Typography from '@mui/material/Typography'; +import UploadIcon from '@mui/icons-material/Upload'; + +import * as URLS from 'config/urls'; +import useFormatMessage from 'hooks/useFormatMessage'; +import FileUploadInput from 'components/FileUploadInput'; +import useImportFlow from 'hooks/useImportFlow'; + +function ImportFlowDialog(props) { + const { + onClose, + open = true, + 'data-test': dataTest = 'import-flow-dialog', + } = props; + + const [hasParsingError, setParsingError] = React.useState(false); + const [selectedFile, setSelectedFile] = React.useState(null); + const navigate = useNavigate(); + const formatMessage = useFormatMessage(); + + const { + mutate: importFlow, + data: importedFlow, + error, + isError, + isSuccess, + reset, + } = useImportFlow(); + + const handleFileSelection = (event) => { + reset(); + setParsingError(false); + + const file = event.target.files[0]; + setSelectedFile(file); + }; + + const parseFlowFile = (fileContents) => { + try { + const flowData = JSON.parse(fileContents); + + return flowData; + } catch { + setParsingError(true); + } + }; + + const handleImportFlow = (event) => { + if (!selectedFile) return; + + const fileReader = new FileReader(); + + fileReader.onload = async function readFileLoaded(e) { + const flowData = parseFlowFile(e.target.result); + + if (flowData) { + importFlow(flowData); + } + }; + + fileReader.readAsText(selectedFile); + }; + + return ( + + {formatMessage('importFlowDialog.title')} + + + + {formatMessage('importFlowDialog.description')} + + + + {formatMessage('importFlowDialog.selectFile')} + + + {selectedFile && ( + + {formatMessage('importFlowDialog.selectedFileInformation', { + fileName: selectedFile.name, + })} + + )} + + + + + + + + + + + {hasParsingError && ( + + {formatMessage('importFlowDialog.parsingError')} + + )} + + {isError && ( + + {error.data || formatMessage('genericError')} + + )} + + {isSuccess && ( + + {formatMessage('importFlowDialog.successfullyImportedFlow', { + link: (str) => ( + {str} + ), + })} + + )} + + ); +} + +ImportFlowDialog.propTypes = { + onClose: PropTypes.func.isRequired, + open: PropTypes.bool, + 'data-test': PropTypes.string, +}; + +export default ImportFlowDialog; diff --git a/packages/web/src/config/urls.js b/packages/web/src/config/urls.js index 820d721c..5c9d4f72 100644 --- a/packages/web/src/config/urls.js +++ b/packages/web/src/config/urls.js @@ -15,37 +15,47 @@ export const APP = (appKey) => `/app/${appKey}`; export const APP_PATTERN = '/app/:appKey'; export const APP_CONNECTIONS = (appKey) => `/app/${appKey}/connections`; export const APP_CONNECTIONS_PATTERN = '/app/:appKey/connections'; + export const APP_ADD_CONNECTION = (appKey, shared = false) => `/app/${appKey}/connections/add?shared=${shared}`; + export const APP_ADD_CONNECTION_WITH_OAUTH_CLIENT_ID = ( appKey, oauthClientId, ) => `/app/${appKey}/connections/add?oauthClientId=${oauthClientId}`; + export const APP_ADD_CONNECTION_PATTERN = '/app/:appKey/connections/add'; + export const APP_RECONNECT_CONNECTION = ( appKey, connectionId, oauthClientId, ) => { const path = `/app/${appKey}/connections/${connectionId}/reconnect`; + if (oauthClientId) { return `${path}?oauthClientId=${oauthClientId}`; } + return path; }; + export const APP_RECONNECT_CONNECTION_PATTERN = '/app/:appKey/connections/:connectionId/reconnect'; -export const APP_FLOWS = (appKey) => `/app/${appKey}/flows`; + export const APP_FLOWS_FOR_CONNECTION = (appKey, connectionId) => `/app/${appKey}/flows?connectionId=${connectionId}`; + +export const APP_FLOWS = (appKey) => `/app/${appKey}/flows`; export const APP_FLOWS_PATTERN = '/app/:appKey/flows'; export const EDITOR = '/editor'; export const CREATE_FLOW = '/editor/create'; +export const IMPORT_FLOW = '/flows/import'; export const FLOW_EDITOR = (flowId) => `/editor/${flowId}`; export const FLOWS = '/flows'; // TODO: revert this back to /flows/:flowId once we have a proper single flow page export const FLOW = (flowId) => `/editor/${flowId}`; -export const FLOW_PATTERN = '/flows/:flowId'; +export const FLOWS_PATTERN = '/flows/:flowId'; export const SETTINGS = '/settings'; export const SETTINGS_DASHBOARD = SETTINGS; export const PROFILE = 'profile'; @@ -73,16 +83,22 @@ 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_APP_CONNECTIONS = (appKey) => `${ADMIN_SETTINGS}/apps/${appKey}/connections`; + export const ADMIN_APP_SETTINGS = (appKey) => `${ADMIN_SETTINGS}/apps/${appKey}/settings`; + export const ADMIN_APP_AUTH_CLIENTS = (appKey) => `${ADMIN_SETTINGS}/apps/${appKey}/oauth-clients`; + export const ADMIN_APP_AUTH_CLIENT = (appKey, id) => `${ADMIN_SETTINGS}/apps/${appKey}/oauth-clients/${id}`; + export const ADMIN_APP_AUTH_CLIENTS_CREATE = (appKey) => `${ADMIN_SETTINGS}/apps/${appKey}/oauth-clients/create`; + export const DASHBOARD = FLOWS; // External links and paths diff --git a/packages/web/src/hooks/useImportFlow.js b/packages/web/src/hooks/useImportFlow.js new file mode 100644 index 00000000..517c2945 --- /dev/null +++ b/packages/web/src/hooks/useImportFlow.js @@ -0,0 +1,15 @@ +import { useMutation } from '@tanstack/react-query'; + +import api from 'helpers/api'; + +export default function useImportFlow() { + const mutation = useMutation({ + mutationFn: async (flowData) => { + const { data } = await api.post('/v1/flows/import', flowData); + + return data; + }, + }); + + return mutation; +} diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json index 200b22b4..f918e9bc 100644 --- a/packages/web/src/locales/en.json +++ b/packages/web/src/locales/en.json @@ -26,6 +26,7 @@ "app.addConnectionWithOAuthClient": "Add connection with OAuth client", "app.reconnectConnection": "Reconnect connection", "app.createFlow": "Create flow", + "app.importFlow": "Import flow", "app.settings": "Settings", "app.connections": "Connections", "app.noConnections": "You don't have any connections yet.", @@ -89,6 +90,7 @@ "flowStep.triggerType": "Trigger", "flowStep.actionType": "Action", "flows.create": "Create flow", + "flows.import": "Import flow", "flows.title": "Flows", "flows.noFlows": "You don't have any flows yet.", "flowEditor.goBack": "Go back to flows", @@ -317,5 +319,13 @@ "oauthClient.inputActive": "Active", "updateOAuthClient.title": "Update OAuth client", "notFoundPage.title": "We can't seem to find a page you're looking for.", - "notFoundPage.button": "Back to home page" + "notFoundPage.button": "Back to home page", + "importFlowDialog.title": "Import flow", + "importFlowDialog.description": "You can import a flow by uploading the exported flow file below.", + "importFlowDialog.parsingError": "Something has gone wrong with parsing the selected file.", + "importFlowDialog.selectFile": "Select file", + "importFlowDialog.close": "Close", + "importFlowDialog.import": "Import", + "importFlowDialog.selectedFileInformation": "Selected file: {fileName}", + "importFlowDialog.successfullyImportedFlow": "The flow has been successfully imported. You can view it here." } diff --git a/packages/web/src/pages/Flow/index.jsx b/packages/web/src/pages/Flow/index.jsx index 922dfe6b..7da82e95 100644 --- a/packages/web/src/pages/Flow/index.jsx +++ b/packages/web/src/pages/Flow/index.jsx @@ -2,9 +2,12 @@ import * as React from 'react'; import { useParams } from 'react-router-dom'; import Box from '@mui/material/Box'; import Grid from '@mui/material/Grid'; + import Container from 'components/Container'; + export default function Flow() { const { flowId } = useParams(); + return ( diff --git a/packages/web/src/pages/Flows/index.jsx b/packages/web/src/pages/Flows/index.jsx index 40c5721f..cb172dee 100644 --- a/packages/web/src/pages/Flows/index.jsx +++ b/packages/web/src/pages/Flows/index.jsx @@ -1,9 +1,16 @@ import * as React from 'react'; -import { Link, useNavigate, useSearchParams } from 'react-router-dom'; +import { + Link, + useNavigate, + useSearchParams, + Routes, + Route, +} from 'react-router-dom'; import debounce from 'lodash/debounce'; 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'; @@ -16,6 +23,7 @@ import ConditionalIconButton from 'components/ConditionalIconButton'; import Container from 'components/Container'; 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 * as URLS from 'config/urls'; @@ -98,85 +106,120 @@ export default function Flows() { ); return ( - - - - - {formatMessage('flows.title')} - - - - - - + <> + + - - {(allowed) => ( - } - to={URLS.CREATE_FLOW} - data-test="create-flow-button" - > - {formatMessage('flows.create')} - - )} - - - + + {formatMessage('flows.title')} + - - {(isLoading || navigateToLastPage) && ( - - )} - {!isLoading && - flows?.map((flow) => ( - - ))} - {!isLoading && !navigateToLastPage && !hasFlows && ( - - )} - {!isLoading && - !navigateToLastPage && - pageInfo && - pageInfo.totalPages > 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')} + + )} + + + + + + + {(isLoading || navigateToLastPage) && ( + + )} + + {!isLoading && + flows?.map((flow) => ( + + ))} + + {!isLoading && !navigateToLastPage && !hasFlows && ( + )} - - + + {!isLoading && + !navigateToLastPage && + pageInfo && + pageInfo.totalPages > 1 && ( + ( + + )} + /> + )} + + + + + } /> + + ); } diff --git a/packages/web/src/routes.jsx b/packages/web/src/routes.jsx index 1f634275..9d3013bd 100644 --- a/packages/web/src/routes.jsx +++ b/packages/web/src/routes.jsx @@ -38,7 +38,9 @@ function Routes() { const { isAuthenticated } = useAuthentication(); const config = configData?.data; - const installed = isSuccess ? automatischInfo.data.installationCompleted : true; + const installed = isSuccess + ? automatischInfo.data.installationCompleted + : true; const navigate = useNavigate(); useEffect(() => { @@ -68,7 +70,7 @@ function Routes() { /> @@ -76,15 +78,6 @@ function Routes() { } /> - - - - } - /> - }> {adminSettingsRoutes} + } /> );