feat(web): add import flow functionality

This commit is contained in:
Ali BARIN
2025-01-20 12:27:09 +00:00
committed by Faruk AYDIN
parent 48e9541af5
commit 5695609180
9 changed files with 369 additions and 90 deletions

View File

@@ -9,6 +9,7 @@ function ConditionalIconButton(props) {
const { icon, ...buttonProps } = props; const { icon, ...buttonProps } = props;
const theme = useTheme(); const theme = useTheme();
const matchSmallScreens = useMediaQuery(theme.breakpoints.down('md')); const matchSmallScreens = useMediaQuery(theme.breakpoints.down('md'));
if (matchSmallScreens) { if (matchSmallScreens) {
return ( return (
<IconButton <IconButton
@@ -24,7 +25,8 @@ function ConditionalIconButton(props) {
</IconButton> </IconButton>
); );
} }
return <Button {...buttonProps} />;
return <Button {...buttonProps} startIcon={icon} />;
} }
ConditionalIconButton.propTypes = { ConditionalIconButton.propTypes = {

View File

@@ -0,0 +1,38 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import { styled } from '@mui/material/styles';
import Button from '@mui/material/Button';
import AttachFileIcon from '@mui/icons-material/AttachFile';
const VisuallyHiddenInput = styled('input')({
clip: 'rect(0 0 0 0)',
clipPath: 'inset(50%)',
height: 1,
overflow: 'hidden',
position: 'absolute',
bottom: 0,
left: 0,
whiteSpace: 'nowrap',
width: 1,
});
export default function FileUploadInput(props) {
return (
<Button
component="label"
role={undefined}
variant="contained"
tabIndex={-1}
startIcon={<AttachFileIcon />}
>
{props.children}
<VisuallyHiddenInput type="file" onChange={props.onChange} />
</Button>
);
}
FileUploadInput.propTypes = {
onChange: PropTypes.func.isRequired,
children: PropTypes.node.isRequired,
};

View File

@@ -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 (
<Dialog open={open} onClose={onClose} data-test={dataTest}>
<DialogTitle>{formatMessage('importFlowDialog.title')}</DialogTitle>
<DialogContent>
<DialogContentText>
{formatMessage('importFlowDialog.description')}
<Stack direction="row" alignItems="center" spacing={2} mt={4}>
<FileUploadInput
onChange={handleFileSelection}
data-test="import-flow-dialog-button"
>
{formatMessage('importFlowDialog.selectFile')}
</FileUploadInput>
{selectedFile && (
<Typography>
{formatMessage('importFlowDialog.selectedFileInformation', {
fileName: selectedFile.name,
})}
</Typography>
)}
</Stack>
</DialogContentText>
</DialogContent>
<DialogActions>
<Button
variant="outlined"
onClick={() => navigate('..')}
data-test="import-flow-dialog-close-button"
>
{formatMessage('importFlowDialog.close')}
</Button>
<Button
variant="contained"
onClick={handleImportFlow}
data-test="import-flow-dialog-import-button"
startIcon={<UploadIcon />}
>
{formatMessage('importFlowDialog.import')}
</Button>
</DialogActions>
{hasParsingError && (
<Alert
data-test="import-flow-dialog-parsing-error-alert"
severity="error"
>
{formatMessage('importFlowDialog.parsingError')}
</Alert>
)}
{isError && (
<Alert
data-test="import-flow-dialog-generic-error-alert"
severity="error"
>
{error.data || formatMessage('genericError')}
</Alert>
)}
{isSuccess && (
<Alert data-test="import-flow-dialog-success-alert" severity="success">
{formatMessage('importFlowDialog.successfullyImportedFlow', {
link: (str) => (
<Link to={URLS.FLOW(importedFlow.data.id)}>{str}</Link>
),
})}
</Alert>
)}
</Dialog>
);
}
ImportFlowDialog.propTypes = {
onClose: PropTypes.func.isRequired,
open: PropTypes.bool,
'data-test': PropTypes.string,
};
export default ImportFlowDialog;

View File

@@ -15,37 +15,47 @@ export const APP = (appKey) => `/app/${appKey}`;
export const APP_PATTERN = '/app/:appKey'; export const APP_PATTERN = '/app/:appKey';
export const APP_CONNECTIONS = (appKey) => `/app/${appKey}/connections`; export const APP_CONNECTIONS = (appKey) => `/app/${appKey}/connections`;
export const APP_CONNECTIONS_PATTERN = '/app/:appKey/connections'; export const APP_CONNECTIONS_PATTERN = '/app/:appKey/connections';
export const APP_ADD_CONNECTION = (appKey, shared = false) => export const APP_ADD_CONNECTION = (appKey, shared = false) =>
`/app/${appKey}/connections/add?shared=${shared}`; `/app/${appKey}/connections/add?shared=${shared}`;
export const APP_ADD_CONNECTION_WITH_OAUTH_CLIENT_ID = ( export const APP_ADD_CONNECTION_WITH_OAUTH_CLIENT_ID = (
appKey, appKey,
oauthClientId, oauthClientId,
) => `/app/${appKey}/connections/add?oauthClientId=${oauthClientId}`; ) => `/app/${appKey}/connections/add?oauthClientId=${oauthClientId}`;
export const APP_ADD_CONNECTION_PATTERN = '/app/:appKey/connections/add'; export const APP_ADD_CONNECTION_PATTERN = '/app/:appKey/connections/add';
export const APP_RECONNECT_CONNECTION = ( export const APP_RECONNECT_CONNECTION = (
appKey, appKey,
connectionId, connectionId,
oauthClientId, oauthClientId,
) => { ) => {
const path = `/app/${appKey}/connections/${connectionId}/reconnect`; const path = `/app/${appKey}/connections/${connectionId}/reconnect`;
if (oauthClientId) { if (oauthClientId) {
return `${path}?oauthClientId=${oauthClientId}`; return `${path}?oauthClientId=${oauthClientId}`;
} }
return path; return path;
}; };
export const APP_RECONNECT_CONNECTION_PATTERN = export const APP_RECONNECT_CONNECTION_PATTERN =
'/app/:appKey/connections/:connectionId/reconnect'; '/app/:appKey/connections/:connectionId/reconnect';
export const APP_FLOWS = (appKey) => `/app/${appKey}/flows`;
export const APP_FLOWS_FOR_CONNECTION = (appKey, connectionId) => export const APP_FLOWS_FOR_CONNECTION = (appKey, connectionId) =>
`/app/${appKey}/flows?connectionId=${connectionId}`; `/app/${appKey}/flows?connectionId=${connectionId}`;
export const APP_FLOWS = (appKey) => `/app/${appKey}/flows`;
export const APP_FLOWS_PATTERN = '/app/:appKey/flows'; export const APP_FLOWS_PATTERN = '/app/:appKey/flows';
export const EDITOR = '/editor'; export const EDITOR = '/editor';
export const CREATE_FLOW = '/editor/create'; export const CREATE_FLOW = '/editor/create';
export const IMPORT_FLOW = '/flows/import';
export const FLOW_EDITOR = (flowId) => `/editor/${flowId}`; export const FLOW_EDITOR = (flowId) => `/editor/${flowId}`;
export const FLOWS = '/flows'; export const FLOWS = '/flows';
// TODO: revert this back to /flows/:flowId once we have a proper single flow page // TODO: revert this back to /flows/:flowId once we have a proper single flow page
export const FLOW = (flowId) => `/editor/${flowId}`; 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 = '/settings';
export const SETTINGS_DASHBOARD = SETTINGS; export const SETTINGS_DASHBOARD = SETTINGS;
export const PROFILE = 'profile'; 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_SETTINGS_PATTERN = `${ADMIN_SETTINGS}/apps/:appKey/settings`;
export const ADMIN_APP_AUTH_CLIENTS_PATTERN = `${ADMIN_SETTINGS}/apps/:appKey/oauth-clients`; 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_PATTERN = `${ADMIN_SETTINGS}/apps/:appKey/connections`;
export const ADMIN_APP_CONNECTIONS = (appKey) => export const ADMIN_APP_CONNECTIONS = (appKey) =>
`${ADMIN_SETTINGS}/apps/${appKey}/connections`; `${ADMIN_SETTINGS}/apps/${appKey}/connections`;
export const ADMIN_APP_SETTINGS = (appKey) => export const ADMIN_APP_SETTINGS = (appKey) =>
`${ADMIN_SETTINGS}/apps/${appKey}/settings`; `${ADMIN_SETTINGS}/apps/${appKey}/settings`;
export const ADMIN_APP_AUTH_CLIENTS = (appKey) => export const ADMIN_APP_AUTH_CLIENTS = (appKey) =>
`${ADMIN_SETTINGS}/apps/${appKey}/oauth-clients`; `${ADMIN_SETTINGS}/apps/${appKey}/oauth-clients`;
export const ADMIN_APP_AUTH_CLIENT = (appKey, id) => export const ADMIN_APP_AUTH_CLIENT = (appKey, id) =>
`${ADMIN_SETTINGS}/apps/${appKey}/oauth-clients/${id}`; `${ADMIN_SETTINGS}/apps/${appKey}/oauth-clients/${id}`;
export const ADMIN_APP_AUTH_CLIENTS_CREATE = (appKey) => export const ADMIN_APP_AUTH_CLIENTS_CREATE = (appKey) =>
`${ADMIN_SETTINGS}/apps/${appKey}/oauth-clients/create`; `${ADMIN_SETTINGS}/apps/${appKey}/oauth-clients/create`;
export const DASHBOARD = FLOWS; export const DASHBOARD = FLOWS;
// External links and paths // External links and paths

View File

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

View File

@@ -26,6 +26,7 @@
"app.addConnectionWithOAuthClient": "Add connection with OAuth client", "app.addConnectionWithOAuthClient": "Add connection with OAuth client",
"app.reconnectConnection": "Reconnect connection", "app.reconnectConnection": "Reconnect connection",
"app.createFlow": "Create flow", "app.createFlow": "Create flow",
"app.importFlow": "Import flow",
"app.settings": "Settings", "app.settings": "Settings",
"app.connections": "Connections", "app.connections": "Connections",
"app.noConnections": "You don't have any connections yet.", "app.noConnections": "You don't have any connections yet.",
@@ -89,6 +90,7 @@
"flowStep.triggerType": "Trigger", "flowStep.triggerType": "Trigger",
"flowStep.actionType": "Action", "flowStep.actionType": "Action",
"flows.create": "Create flow", "flows.create": "Create flow",
"flows.import": "Import flow",
"flows.title": "Flows", "flows.title": "Flows",
"flows.noFlows": "You don't have any flows yet.", "flows.noFlows": "You don't have any flows yet.",
"flowEditor.goBack": "Go back to flows", "flowEditor.goBack": "Go back to flows",
@@ -317,5 +319,13 @@
"oauthClient.inputActive": "Active", "oauthClient.inputActive": "Active",
"updateOAuthClient.title": "Update OAuth client", "updateOAuthClient.title": "Update OAuth client",
"notFoundPage.title": "We can't seem to find a page you're looking for.", "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 <link>here</link>."
} }

View File

@@ -2,9 +2,12 @@ import * as React from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Grid from '@mui/material/Grid'; import Grid from '@mui/material/Grid';
import Container from 'components/Container'; import Container from 'components/Container';
export default function Flow() { export default function Flow() {
const { flowId } = useParams(); const { flowId } = useParams();
return ( return (
<Box sx={{ py: 3 }}> <Box sx={{ py: 3 }}>
<Container> <Container>

View File

@@ -1,9 +1,16 @@
import * as React from 'react'; 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 debounce from 'lodash/debounce';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Grid from '@mui/material/Grid'; import Grid from '@mui/material/Grid';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import UploadIcon from '@mui/icons-material/Upload';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import Divider from '@mui/material/Divider'; import Divider from '@mui/material/Divider';
import Pagination from '@mui/material/Pagination'; import Pagination from '@mui/material/Pagination';
@@ -16,6 +23,7 @@ import ConditionalIconButton from 'components/ConditionalIconButton';
import Container from 'components/Container'; import Container from 'components/Container';
import PageTitle from 'components/PageTitle'; import PageTitle from 'components/PageTitle';
import SearchInput from 'components/SearchInput'; import SearchInput from 'components/SearchInput';
import ImportFlowDialog from 'components/ImportFlowDialog';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
import useCurrentUserAbility from 'hooks/useCurrentUserAbility'; import useCurrentUserAbility from 'hooks/useCurrentUserAbility';
import * as URLS from 'config/urls'; import * as URLS from 'config/urls';
@@ -98,85 +106,120 @@ export default function Flows() {
); );
return ( return (
<Box sx={{ py: 3 }}> <>
<Container> <Box sx={{ py: 3 }}>
<Grid container sx={{ mb: [0, 3] }} columnSpacing={1.5} rowSpacing={3}> <Container>
<Grid container item xs sm alignItems="center" order={{ xs: 0 }}>
<PageTitle>{formatMessage('flows.title')}</PageTitle>
</Grid>
<Grid item xs={12} sm="auto" order={{ xs: 2, sm: 1 }}>
<SearchInput onChange={onSearchChange} defaultValue={flowName} />
</Grid>
<Grid <Grid
container container
item sx={{ mb: [0, 3] }}
xs="auto" columnSpacing={1.5}
sm="auto" rowSpacing={3}
alignItems="center"
order={{ xs: 1, sm: 2 }}
> >
<Can I="create" a="Flow" passThrough> <Grid container item xs sm alignItems="center" order={{ xs: 0 }}>
{(allowed) => ( <PageTitle>{formatMessage('flows.title')}</PageTitle>
<ConditionalIconButton </Grid>
type="submit"
variant="contained"
color="primary"
size="large"
component={Link}
fullWidth
disabled={!allowed}
icon={<AddIcon />}
to={URLS.CREATE_FLOW}
data-test="create-flow-button"
>
{formatMessage('flows.create')}
</ConditionalIconButton>
)}
</Can>
</Grid>
</Grid>
<Divider sx={{ mt: [2, 0], mb: 2 }} /> <Grid item xs={12} sm="auto" order={{ xs: 2, sm: 1 }}>
{(isLoading || navigateToLastPage) && ( <SearchInput onChange={onSearchChange} defaultValue={flowName} />
<CircularProgress sx={{ display: 'block', margin: '20px auto' }} /> </Grid>
)}
{!isLoading && <Grid
flows?.map((flow) => ( container
<FlowRow item
key={flow.id} display="flex"
flow={flow} direction="row"
onDuplicateFlow={onDuplicateFlow} xs="auto"
onDeleteFlow={fetchFlows} sm="auto"
/> gap={1}
))} alignItems="center"
{!isLoading && !navigateToLastPage && !hasFlows && ( order={{ xs: 1, sm: 2 }}
<NoResultFound >
text={formatMessage('flows.noFlows')} <Can I="create" a="Flow" passThrough>
{...(currentUserAbility.can('create', 'Flow') && { {(allowed) => (
to: URLS.CREATE_FLOW, <ConditionalIconButton
})} type="submit"
/> variant="outlined"
)} color="info"
{!isLoading && size="large"
!navigateToLastPage && component={Link}
pageInfo && disabled={!allowed}
pageInfo.totalPages > 1 && ( icon={<UploadIcon />}
<Pagination to={URLS.IMPORT_FLOW}
sx={{ display: 'flex', justifyContent: 'center', mt: 3 }} data-test="import-flow-button"
page={pageInfo?.currentPage} >
count={pageInfo?.totalPages} {formatMessage('flows.import')}
renderItem={(item) => ( </ConditionalIconButton>
<PaginationItem )}
component={Link} </Can>
to={getPathWithSearchParams(item.page, flowName)}
{...item} <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>
</Grid>
</Grid>
<Divider sx={{ mt: [2, 0], mb: 2 }} />
{(isLoading || navigateToLastPage) && (
<CircularProgress sx={{ display: 'block', margin: '20px auto' }} />
)}
{!isLoading &&
flows?.map((flow) => (
<FlowRow
key={flow.id}
flow={flow}
onDuplicateFlow={onDuplicateFlow}
onDeleteFlow={fetchFlows}
/>
))}
{!isLoading && !navigateToLastPage && !hasFlows && (
<NoResultFound
text={formatMessage('flows.noFlows')}
{...(currentUserAbility.can('create', 'Flow') && {
to: URLS.CREATE_FLOW,
})}
/> />
)} )}
</Container>
</Box> {!isLoading &&
!navigateToLastPage &&
pageInfo &&
pageInfo.totalPages > 1 && (
<Pagination
sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}
page={pageInfo?.currentPage}
count={pageInfo?.totalPages}
renderItem={(item) => (
<PaginationItem
component={Link}
to={getPathWithSearchParams(item.page, flowName)}
{...item}
/>
)}
/>
)}
</Container>
</Box>
<Routes>
<Route path="/import" element={<ImportFlowDialog />} />
</Routes>
</>
); );
} }

View File

@@ -38,7 +38,9 @@ function Routes() {
const { isAuthenticated } = useAuthentication(); const { isAuthenticated } = useAuthentication();
const config = configData?.data; const config = configData?.data;
const installed = isSuccess ? automatischInfo.data.installationCompleted : true; const installed = isSuccess
? automatischInfo.data.installationCompleted
: true;
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
@@ -68,7 +70,7 @@ function Routes() {
/> />
<Route <Route
path={URLS.FLOWS} path={`${URLS.FLOWS}/*`}
element={ element={
<Layout> <Layout>
<Flows /> <Flows />
@@ -76,15 +78,6 @@ function Routes() {
} }
/> />
<Route
path={URLS.FLOW_PATTERN}
element={
<Layout>
<Flow />
</Layout>
}
/>
<Route <Route
path={`${URLS.APPS}/*`} path={`${URLS.APPS}/*`}
element={ element={
@@ -186,6 +179,7 @@ function Routes() {
<Route path={URLS.ADMIN_SETTINGS} element={<AdminSettingsLayout />}> <Route path={URLS.ADMIN_SETTINGS} element={<AdminSettingsLayout />}>
{adminSettingsRoutes} {adminSettingsRoutes}
</Route> </Route>
<Route path="*" element={<NoResultFound />} /> <Route path="*" element={<NoResultFound />} />
</ReactRouterRoutes> </ReactRouterRoutes>
); );