Merge branch 'main' into AUT-1372
This commit is contained in:
@@ -37,6 +37,7 @@
|
||||
"slate": "^0.94.1",
|
||||
"slate-history": "^0.93.0",
|
||||
"slate-react": "^0.94.2",
|
||||
"slugify": "^1.6.6",
|
||||
"uuid": "^9.0.0",
|
||||
"web-vitals": "^1.0.1",
|
||||
"yup": "^0.32.11"
|
||||
@@ -83,7 +84,6 @@
|
||||
"access": "public"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@simbathesailor/use-what-changed": "^2.0.0",
|
||||
"@tanstack/eslint-plugin-query": "^5.20.1",
|
||||
"@tanstack/react-query-devtools": "^5.24.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
|
||||
@@ -14,6 +14,7 @@ import InputCreator from 'components/InputCreator';
|
||||
import * as URLS from 'config/urls';
|
||||
import useAuthenticateApp from 'hooks/useAuthenticateApp.ee';
|
||||
import useFormatMessage from 'hooks/useFormatMessage';
|
||||
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
|
||||
import { generateExternalLink } from 'helpers/translationValues';
|
||||
import { Form } from './style';
|
||||
import useAppAuth from 'hooks/useAppAuth';
|
||||
@@ -39,6 +40,7 @@ function AddAppConnection(props) {
|
||||
useShared: !!oauthClientId,
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
const enqueueSnackbar = useEnqueueSnackbar();
|
||||
|
||||
React.useEffect(function relayProviderData() {
|
||||
if (window.opener) {
|
||||
@@ -58,8 +60,14 @@ function AddAppConnection(props) {
|
||||
if (!authenticate) return;
|
||||
|
||||
const asyncAuthenticate = async () => {
|
||||
await authenticate();
|
||||
navigate(URLS.APP_CONNECTIONS(key));
|
||||
try {
|
||||
await authenticate();
|
||||
navigate(URLS.APP_CONNECTIONS(key));
|
||||
} catch (error) {
|
||||
enqueueSnackbar(error?.message || formatMessage('genericError'), {
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
asyncAuthenticate();
|
||||
|
||||
@@ -91,15 +91,15 @@ function ChooseAppAndEventSubstep(props) {
|
||||
const onEventChange = React.useCallback(
|
||||
(event, selectedOption) => {
|
||||
if (typeof selectedOption === 'object') {
|
||||
// TODO: try to simplify type casting below.
|
||||
const typedSelectedOption = selectedOption;
|
||||
const option = typedSelectedOption;
|
||||
const eventKey = option?.value;
|
||||
const eventKey = selectedOption?.value;
|
||||
const eventLabel = selectedOption?.label;
|
||||
|
||||
if (step.key !== eventKey) {
|
||||
onChange({
|
||||
step: {
|
||||
...step,
|
||||
key: eventKey,
|
||||
keyLabel: eventLabel,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -111,10 +111,8 @@ function ChooseAppAndEventSubstep(props) {
|
||||
const onAppChange = React.useCallback(
|
||||
(event, selectedOption) => {
|
||||
if (typeof selectedOption === 'object') {
|
||||
// TODO: try to simplify type casting below.
|
||||
const typedSelectedOption = selectedOption;
|
||||
const option = typedSelectedOption;
|
||||
const appKey = option?.value;
|
||||
const appKey = selectedOption?.value;
|
||||
|
||||
if (step.appKey !== appKey) {
|
||||
onChange({
|
||||
step: {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import useAppConnections from 'hooks/useAppConnections';
|
||||
import useTestConnection from 'hooks/useTestConnection';
|
||||
import useOAuthClients from 'hooks/useOAuthClients';
|
||||
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
|
||||
|
||||
const ADD_CONNECTION_VALUE = 'ADD_CONNECTION';
|
||||
const ADD_SHARED_CONNECTION_VALUE = 'ADD_SHARED_CONNECTION';
|
||||
@@ -55,6 +56,7 @@ function ChooseConnectionSubstep(props) {
|
||||
React.useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const { data: appOAuthClients } = useOAuthClients(application.key);
|
||||
const enqueueSnackbar = useEnqueueSnackbar();
|
||||
|
||||
const { authenticate } = useAuthenticateApp({
|
||||
appKey: application.key,
|
||||
@@ -156,8 +158,10 @@ function ChooseConnectionSubstep(props) {
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// void
|
||||
} catch (error) {
|
||||
enqueueSnackbar(error?.message || formatMessage('genericError'), {
|
||||
variant: 'error',
|
||||
});
|
||||
} finally {
|
||||
setShowAddSharedConnectionDialog(false);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ function ConditionalIconButton(props) {
|
||||
const { icon, ...buttonProps } = props;
|
||||
const theme = useTheme();
|
||||
const matchSmallScreens = useMediaQuery(theme.breakpoints.down('md'));
|
||||
|
||||
if (matchSmallScreens) {
|
||||
return (
|
||||
<IconButton
|
||||
@@ -24,7 +25,8 @@ function ConditionalIconButton(props) {
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
return <Button {...buttonProps} />;
|
||||
|
||||
return <Button {...buttonProps} startIcon={icon} />;
|
||||
}
|
||||
|
||||
ConditionalIconButton.propTypes = {
|
||||
|
||||
@@ -6,6 +6,7 @@ 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 Alert from '@mui/material/Alert';
|
||||
|
||||
function ConfirmationDialog(props) {
|
||||
const {
|
||||
@@ -16,6 +17,7 @@ function ConfirmationDialog(props) {
|
||||
cancelButtonChildren,
|
||||
confirmButtonChildren,
|
||||
open = true,
|
||||
errorMessage,
|
||||
} = props;
|
||||
const dataTest = props['data-test'];
|
||||
return (
|
||||
@@ -44,6 +46,11 @@ function ConfirmationDialog(props) {
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
{errorMessage && (
|
||||
<Alert data-test="confirmation-dialog-error-alert" severity="error">
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -57,6 +64,7 @@ ConfirmationDialog.propTypes = {
|
||||
confirmButtonChildren: PropTypes.node.isRequired,
|
||||
open: PropTypes.bool,
|
||||
'data-test': PropTypes.string,
|
||||
errorMessage: PropTypes.string,
|
||||
};
|
||||
|
||||
export default ConfirmationDialog;
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import * as React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import MuiContainer from '@mui/material/Container';
|
||||
|
||||
export default function Container(props) {
|
||||
return <MuiContainer {...props} />;
|
||||
export default function Container({ maxWidth = 'lg', ...props }) {
|
||||
return <MuiContainer maxWidth={maxWidth} {...props} />;
|
||||
}
|
||||
|
||||
Container.defaultProps = {
|
||||
maxWidth: 'lg',
|
||||
Container.propTypes = {
|
||||
maxWidth: PropTypes.oneOf([
|
||||
'xs',
|
||||
'sm',
|
||||
'md',
|
||||
'lg',
|
||||
'xl',
|
||||
false,
|
||||
PropTypes.string,
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import IconButton from '@mui/material/IconButton';
|
||||
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
|
||||
import * as React from 'react';
|
||||
|
||||
import { getGeneralErrorMessage, getFieldErrorMessage } from 'helpers/errors';
|
||||
import Can from 'components/Can';
|
||||
import ConfirmationDialog from 'components/ConfirmationDialog';
|
||||
import useFormatMessage from 'hooks/useFormatMessage';
|
||||
@@ -15,7 +16,21 @@ function DeleteRoleButton(props) {
|
||||
const formatMessage = useFormatMessage();
|
||||
const enqueueSnackbar = useEnqueueSnackbar();
|
||||
|
||||
const { mutateAsync: deleteRole } = useAdminDeleteRole(roleId);
|
||||
const {
|
||||
mutateAsync: deleteRole,
|
||||
error: deleteRoleError,
|
||||
reset: resetDeleteRole,
|
||||
} = useAdminDeleteRole(roleId);
|
||||
|
||||
const roleErrorMessage = getFieldErrorMessage({
|
||||
fieldName: 'role',
|
||||
error: deleteRoleError,
|
||||
});
|
||||
|
||||
const generalErrorMessage = getGeneralErrorMessage({
|
||||
error: deleteRoleError,
|
||||
fallbackMessage: formatMessage('deleteRoleButton.generalError'),
|
||||
});
|
||||
|
||||
const handleConfirm = React.useCallback(async () => {
|
||||
try {
|
||||
@@ -28,24 +43,14 @@ function DeleteRoleButton(props) {
|
||||
'data-test': 'snackbar-delete-role-success',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const errors = Object.values(
|
||||
error.response.data.errors || [['Failed while deleting!']],
|
||||
);
|
||||
|
||||
for (const [error] of errors) {
|
||||
enqueueSnackbar(error, {
|
||||
variant: 'error',
|
||||
SnackbarProps: {
|
||||
'data-test': 'snackbar-delete-role-error',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error('Failed while deleting!');
|
||||
}
|
||||
} catch {}
|
||||
}, [deleteRole, enqueueSnackbar, formatMessage]);
|
||||
|
||||
const handleClose = () => {
|
||||
setShowConfirmation(false);
|
||||
resetDeleteRole();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Can I="delete" a="Role" passThrough>
|
||||
@@ -65,11 +70,12 @@ function DeleteRoleButton(props) {
|
||||
open={showConfirmation}
|
||||
title={formatMessage('deleteRoleButton.title')}
|
||||
description={formatMessage('deleteRoleButton.description')}
|
||||
onClose={() => setShowConfirmation(false)}
|
||||
onClose={handleClose}
|
||||
onConfirm={handleConfirm}
|
||||
cancelButtonChildren={formatMessage('deleteRoleButton.cancel')}
|
||||
confirmButtonChildren={formatMessage('deleteRoleButton.confirm')}
|
||||
data-test="delete-role-modal"
|
||||
errorMessage={roleErrorMessage || generalErrorMessage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { getGeneralErrorMessage } from 'helpers/errors';
|
||||
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
|
||||
import * as React from 'react';
|
||||
import ConfirmationDialog from 'components/ConfirmationDialog';
|
||||
@@ -12,12 +13,21 @@ import useAdminUserDelete from 'hooks/useAdminUserDelete';
|
||||
function DeleteUserButton(props) {
|
||||
const { userId } = props;
|
||||
const [showConfirmation, setShowConfirmation] = React.useState(false);
|
||||
const { mutateAsync: deleteUser } = useAdminUserDelete(userId);
|
||||
const {
|
||||
mutateAsync: deleteUser,
|
||||
error: deleteUserError,
|
||||
reset: resetDeleteUser,
|
||||
} = useAdminUserDelete(userId);
|
||||
|
||||
const formatMessage = useFormatMessage();
|
||||
const enqueueSnackbar = useEnqueueSnackbar();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const generalErrorMessage = getGeneralErrorMessage({
|
||||
error: deleteUserError,
|
||||
fallbackMessage: formatMessage('deleteUserButton.deleteError'),
|
||||
});
|
||||
|
||||
const handleConfirm = React.useCallback(async () => {
|
||||
try {
|
||||
await deleteUser();
|
||||
@@ -29,16 +39,14 @@ function DeleteUserButton(props) {
|
||||
'data-test': 'snackbar-delete-user-success',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
enqueueSnackbar(
|
||||
error?.message || formatMessage('deleteUserButton.deleteError'),
|
||||
{
|
||||
variant: 'error',
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch {}
|
||||
}, [deleteUser]);
|
||||
|
||||
const handleClose = () => {
|
||||
setShowConfirmation(false);
|
||||
resetDeleteUser();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
@@ -53,11 +61,12 @@ function DeleteUserButton(props) {
|
||||
open={showConfirmation}
|
||||
title={formatMessage('deleteUserButton.title')}
|
||||
description={formatMessage('deleteUserButton.description')}
|
||||
onClose={() => setShowConfirmation(false)}
|
||||
onClose={handleClose}
|
||||
onConfirm={handleConfirm}
|
||||
cancelButtonChildren={formatMessage('deleteUserButton.cancel')}
|
||||
confirmButtonChildren={formatMessage('deleteUserButton.confirm')}
|
||||
data-test="delete-user-modal"
|
||||
errorMessage={generalErrorMessage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import * as React from 'react';
|
||||
import Stack from '@mui/material/Stack';
|
||||
|
||||
import InputCreator from 'components/InputCreator';
|
||||
import { EditorContext } from 'contexts/Editor';
|
||||
import { FieldsPropType } from 'propTypes/propTypes';
|
||||
import { FieldEntryProvider } from 'contexts/FieldEntry';
|
||||
import useFieldEntryContext from 'hooks/useFieldEntryContext';
|
||||
|
||||
function DynamicFieldEntry(props) {
|
||||
const { fields, stepId, namePrefix } = props;
|
||||
const editorContext = React.useContext(EditorContext);
|
||||
const fieldEntryContext = useFieldEntryContext();
|
||||
|
||||
const newFieldEntryPaths = [
|
||||
...(fieldEntryContext?.fieldEntryPaths || []),
|
||||
namePrefix,
|
||||
];
|
||||
|
||||
return (
|
||||
<FieldEntryProvider value={{ fieldEntryPaths: newFieldEntryPaths }}>
|
||||
{fields.map((fieldSchema, fieldSchemaIndex) => (
|
||||
<Stack
|
||||
minWidth={0}
|
||||
flex="1 0 0px"
|
||||
spacing={2}
|
||||
key={`field-${namePrefix}-${fieldSchemaIndex}`}
|
||||
>
|
||||
<InputCreator
|
||||
schema={fieldSchema}
|
||||
namePrefix={namePrefix}
|
||||
disabled={editorContext.readOnly}
|
||||
shouldUnregister={false}
|
||||
stepId={stepId}
|
||||
/>
|
||||
</Stack>
|
||||
))}
|
||||
</FieldEntryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
DynamicFieldEntry.propTypes = {
|
||||
stepId: PropTypes.string,
|
||||
namePrefix: PropTypes.string,
|
||||
index: PropTypes.number,
|
||||
fields: FieldsPropType.isRequired,
|
||||
};
|
||||
|
||||
export default DynamicFieldEntry;
|
||||
@@ -4,19 +4,21 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Box from '@mui/material/Box';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import RemoveIcon from '@mui/icons-material/Remove';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import InputCreator from 'components/InputCreator';
|
||||
import { EditorContext } from 'contexts/Editor';
|
||||
|
||||
import { FieldsPropType } from 'propTypes/propTypes';
|
||||
import DynamicFieldEntry from './DynamicFieldEntry';
|
||||
import { FieldEntryProvider } from 'contexts/FieldEntry';
|
||||
import useFieldEntryContext from 'hooks/useFieldEntryContext';
|
||||
|
||||
function DynamicField(props) {
|
||||
const { label, description, fields, name, defaultValue, stepId } = props;
|
||||
const { control, setValue, getValues } = useFormContext();
|
||||
const fieldsValue = useWatch({ control, name });
|
||||
const editorContext = React.useContext(EditorContext);
|
||||
const fieldEntryContext = useFieldEntryContext();
|
||||
|
||||
const createEmptyItem = React.useCallback(() => {
|
||||
return fields.reduce((previousValue, field) => {
|
||||
return {
|
||||
@@ -26,6 +28,7 @@ function DynamicField(props) {
|
||||
};
|
||||
}, {});
|
||||
}, [fields]);
|
||||
|
||||
const addItem = React.useCallback(() => {
|
||||
const values = getValues(name);
|
||||
if (!values) {
|
||||
@@ -34,6 +37,7 @@ function DynamicField(props) {
|
||||
setValue(name, values.concat(createEmptyItem()));
|
||||
}
|
||||
}, [getValues, createEmptyItem]);
|
||||
|
||||
const removeItem = React.useCallback(
|
||||
(index) => {
|
||||
if (fieldsValue.length === 1) return;
|
||||
@@ -44,6 +48,7 @@ function DynamicField(props) {
|
||||
},
|
||||
[fieldsValue],
|
||||
);
|
||||
|
||||
React.useEffect(
|
||||
function addInitialGroupWhenEmpty() {
|
||||
const fieldValues = getValues(name);
|
||||
@@ -55,14 +60,17 @@ function DynamicField(props) {
|
||||
},
|
||||
[createEmptyItem, defaultValue],
|
||||
);
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Typography variant="subtitle2">{label}</Typography>
|
||||
|
||||
{fieldsValue?.map((field, index) => (
|
||||
return (
|
||||
<FieldEntryProvider value={fieldEntryContext}>
|
||||
<Typography variant="subtitle2">{label}</Typography>
|
||||
{fieldsValue?.map?.((field, index) => (
|
||||
<Stack direction="row" spacing={2} key={`fieldGroup-${field.__id}`}>
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row' }}
|
||||
direction={{
|
||||
xs: 'column',
|
||||
sm: fields.length > 2 ? 'column' : 'row',
|
||||
}}
|
||||
spacing={{ xs: 2 }}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
@@ -70,26 +78,12 @@ function DynamicField(props) {
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{fields.map((fieldSchema, fieldSchemaIndex) => (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flex: '1 0 0px',
|
||||
minWidth: 0,
|
||||
}}
|
||||
key={`field-${field.__id}-${fieldSchemaIndex}`}
|
||||
>
|
||||
<InputCreator
|
||||
schema={fieldSchema}
|
||||
namePrefix={`${name}.${index}`}
|
||||
disabled={editorContext.readOnly}
|
||||
shouldUnregister={false}
|
||||
stepId={stepId}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
<DynamicFieldEntry
|
||||
fields={fields}
|
||||
namePrefix={`${name}.${index}`}
|
||||
stepId={stepId}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
edge="start"
|
||||
@@ -100,10 +94,8 @@ function DynamicField(props) {
|
||||
</IconButton>
|
||||
</Stack>
|
||||
))}
|
||||
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Stack spacing={{ xs: 2 }} sx={{ display: 'flex', flex: 1 }} />
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
edge="start"
|
||||
@@ -113,9 +105,8 @@ function DynamicField(props) {
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
|
||||
<Typography variant="caption">{description}</Typography>
|
||||
</React.Fragment>
|
||||
</FieldEntryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,37 +7,68 @@ import { Box, TextField } from './style';
|
||||
const noop = () => null;
|
||||
|
||||
function EditableTypography(props) {
|
||||
const { children, onConfirm = noop, sx, ...typographyProps } = props;
|
||||
const {
|
||||
children,
|
||||
onConfirm = noop,
|
||||
sx,
|
||||
iconColor = 'inherit',
|
||||
disabled = false,
|
||||
prefixValue = '',
|
||||
...typographyProps
|
||||
} = props;
|
||||
|
||||
const [editing, setEditing] = React.useState(false);
|
||||
|
||||
const handleClick = React.useCallback(() => {
|
||||
if (disabled) return;
|
||||
|
||||
setEditing((editing) => !editing);
|
||||
}, []);
|
||||
}, [disabled]);
|
||||
|
||||
const handleTextFieldClick = React.useCallback((event) => {
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleTextFieldKeyDown = React.useCallback(
|
||||
async (event) => {
|
||||
const target = event.target;
|
||||
if (event.key === 'Enter') {
|
||||
const eventKey = event.key;
|
||||
|
||||
if (eventKey === 'Enter') {
|
||||
if (target.value !== children) {
|
||||
await onConfirm(target.value);
|
||||
}
|
||||
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
if (eventKey === 'Escape') {
|
||||
setEditing(false);
|
||||
}
|
||||
},
|
||||
[children],
|
||||
[children, onConfirm],
|
||||
);
|
||||
|
||||
const handleTextFieldBlur = React.useCallback(
|
||||
async (event) => {
|
||||
const value = event.target.value;
|
||||
|
||||
if (value !== children) {
|
||||
await onConfirm(value);
|
||||
}
|
||||
|
||||
setEditing(false);
|
||||
},
|
||||
[onConfirm, children],
|
||||
);
|
||||
let component = <Typography {...typographyProps}>{children}</Typography>;
|
||||
|
||||
let component = (
|
||||
<Typography {...typographyProps}>
|
||||
{prefixValue}
|
||||
{children}
|
||||
</Typography>
|
||||
);
|
||||
|
||||
if (editing) {
|
||||
component = (
|
||||
<TextField
|
||||
@@ -51,18 +82,24 @@ function EditableTypography(props) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box sx={sx} onClick={handleClick} editing={editing}>
|
||||
<EditIcon sx={{ mr: 1 }} />
|
||||
|
||||
return (
|
||||
<Box sx={sx} onClick={handleClick} editing={editing} disabled={disabled}>
|
||||
{component}
|
||||
|
||||
{!disabled && editing === false && (
|
||||
<EditIcon fontSize="small" color={iconColor} sx={{ ml: 1 }} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
EditableTypography.propTypes = {
|
||||
children: PropTypes.string.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
iconColor: PropTypes.oneOf(['action', 'inherit']),
|
||||
onConfirm: PropTypes.func,
|
||||
prefixValue: PropTypes.string,
|
||||
sx: PropTypes.object,
|
||||
};
|
||||
|
||||
|
||||
@@ -2,17 +2,22 @@ import { styled } from '@mui/material/styles';
|
||||
import MuiBox from '@mui/material/Box';
|
||||
import MuiTextField from '@mui/material/TextField';
|
||||
import { inputClasses } from '@mui/material/Input';
|
||||
const boxShouldForwardProp = (prop) => !['editing'].includes(prop);
|
||||
|
||||
const boxShouldForwardProp = (prop) => !['editing', 'disabled'].includes(prop);
|
||||
|
||||
export const Box = styled(MuiBox, {
|
||||
shouldForwardProp: boxShouldForwardProp,
|
||||
})`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
width: 300px;
|
||||
min-width: 300px;
|
||||
max-width: 90%;
|
||||
height: 33px;
|
||||
align-items: center;
|
||||
${({ disabled }) => !disabled && 'cursor: pointer;'}
|
||||
${({ editing }) => editing && 'border-bottom: 1px dashed #000;'}
|
||||
`;
|
||||
|
||||
export const TextField = styled(MuiTextField)({
|
||||
width: '100%',
|
||||
[`.${inputClasses.root}:before, .${inputClasses.root}:after, .${inputClasses.root}:hover`]:
|
||||
|
||||
@@ -27,6 +27,10 @@ function Editor(props) {
|
||||
connectionId: step.connection?.id,
|
||||
};
|
||||
|
||||
if (step.name || step.keyLabel) {
|
||||
payload.name = step.name || step.keyLabel;
|
||||
}
|
||||
|
||||
if (step.appKey) {
|
||||
payload.appKey = step.appKey;
|
||||
}
|
||||
|
||||
@@ -6,29 +6,36 @@ import Button from '@mui/material/Button';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import Snackbar from '@mui/material/Snackbar';
|
||||
import { ReactFlowProvider } from 'reactflow';
|
||||
|
||||
import { EditorProvider } from 'contexts/Editor';
|
||||
import EditableTypography from 'components/EditableTypography';
|
||||
import Container from 'components/Container';
|
||||
import Editor from 'components/Editor';
|
||||
import Can from 'components/Can';
|
||||
import useFormatMessage from 'hooks/useFormatMessage';
|
||||
import * as URLS from 'config/urls';
|
||||
import { TopBar } from './style';
|
||||
import * as URLS from 'config/urls';
|
||||
import Can from 'components/Can';
|
||||
import Container from 'components/Container';
|
||||
import EditableTypography from 'components/EditableTypography';
|
||||
import Editor from 'components/Editor';
|
||||
import EditorNew from 'components/EditorNew/EditorNew';
|
||||
import useFlow from 'hooks/useFlow';
|
||||
import useFormatMessage from 'hooks/useFormatMessage';
|
||||
import useUpdateFlow from 'hooks/useUpdateFlow';
|
||||
import useUpdateFlowStatus from 'hooks/useUpdateFlowStatus';
|
||||
import EditorNew from 'components/EditorNew/EditorNew';
|
||||
import useExportFlow from 'hooks/useExportFlow';
|
||||
import useDownloadJsonAsFile from 'hooks/useDownloadJsonAsFile';
|
||||
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
|
||||
|
||||
const useNewFlowEditor = process.env.REACT_APP_USE_NEW_FLOW_EDITOR === 'true';
|
||||
|
||||
export default function EditorLayout() {
|
||||
const { flowId } = useParams();
|
||||
const formatMessage = useFormatMessage();
|
||||
const enqueueSnackbar = useEnqueueSnackbar();
|
||||
const { mutateAsync: updateFlow } = useUpdateFlow(flowId);
|
||||
const { mutateAsync: updateFlowStatus } = useUpdateFlowStatus(flowId);
|
||||
const { mutateAsync: exportFlow } = useExportFlow(flowId);
|
||||
const downloadJsonAsFile = useDownloadJsonAsFile();
|
||||
const { data, isLoading: isFlowLoading } = useFlow(flowId);
|
||||
const flow = data?.data;
|
||||
|
||||
@@ -38,6 +45,19 @@ export default function EditorLayout() {
|
||||
});
|
||||
};
|
||||
|
||||
const onExportFlow = async (name) => {
|
||||
const flowExport = await exportFlow();
|
||||
|
||||
downloadJsonAsFile({
|
||||
contents: flowExport.data,
|
||||
name: flowExport.data.name,
|
||||
});
|
||||
|
||||
enqueueSnackbar(formatMessage('flowEditor.flowSuccessfullyExported'), {
|
||||
variant: 'success',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBar
|
||||
@@ -72,6 +92,7 @@ export default function EditorLayout() {
|
||||
variant="body1"
|
||||
onConfirm={onFlowNameUpdate}
|
||||
noWrap
|
||||
iconColor="action"
|
||||
sx={{ display: 'flex', flex: 1, maxWidth: '50vw', ml: 2 }}
|
||||
>
|
||||
{flow?.name}
|
||||
@@ -79,7 +100,23 @@ export default function EditorLayout() {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box pr={1}>
|
||||
<Box pr={1} display="flex" gap={1}>
|
||||
<Can I="read" a="Flow" passThrough>
|
||||
{(allowed) => (
|
||||
<Button
|
||||
disabled={!allowed || !flow}
|
||||
variant="outlined"
|
||||
color="info"
|
||||
size="small"
|
||||
onClick={onExportFlow}
|
||||
data-test="export-flow-button"
|
||||
startIcon={<DownloadIcon />}
|
||||
>
|
||||
{formatMessage('flowEditor.export')}
|
||||
</Button>
|
||||
)}
|
||||
</Can>
|
||||
|
||||
<Can I="publish" a="Flow" passThrough>
|
||||
{(allowed) => (
|
||||
<Button
|
||||
|
||||
@@ -90,6 +90,10 @@ const EditorNew = ({ flow }) => {
|
||||
connectionId: step.connection?.id,
|
||||
};
|
||||
|
||||
if (step.name || step.keyLabel) {
|
||||
payload.name = step.name || step.keyLabel;
|
||||
}
|
||||
|
||||
if (step.appKey) {
|
||||
payload.appKey = step.appKey;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import Tab from '@mui/material/Tab';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Box from '@mui/material/Box';
|
||||
import Chip from '@mui/material/Chip';
|
||||
|
||||
import TabPanel from 'components/TabPanel';
|
||||
import SearchableJSONViewer from 'components/SearchableJSONViewer';
|
||||
@@ -100,6 +101,10 @@ function ExecutionStep(props) {
|
||||
|
||||
const hasError = !!executionStep.errorDetails;
|
||||
|
||||
const stepTypeName = isTrigger
|
||||
? formatMessage('flowStep.triggerType')
|
||||
: formatMessage('flowStep.actionType');
|
||||
|
||||
return (
|
||||
<Wrapper elevation={1} data-test="execution-step">
|
||||
<Header>
|
||||
@@ -119,13 +124,20 @@ function ExecutionStep(props) {
|
||||
<ExecutionStepId id={executionStep.step.id} />
|
||||
|
||||
<Box flex="1" gridArea="step">
|
||||
<Typography variant="caption">
|
||||
{isTrigger && formatMessage('flowStep.triggerType')}
|
||||
{isAction && formatMessage('flowStep.actionType')}
|
||||
<Typography
|
||||
component={Stack}
|
||||
direction="row"
|
||||
variant="stepApp"
|
||||
alignItems="center"
|
||||
gap={0.5}
|
||||
>
|
||||
<Chip label={stepTypeName} variant="stepType" size="small" />
|
||||
|
||||
{app?.name}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2">
|
||||
{step.position}. {app?.name}
|
||||
{step.position}. {step.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
|
||||
38
packages/web/src/components/FileUploadInput/index.js
Normal file
38
packages/web/src/components/FileUploadInput/index.js
Normal 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,
|
||||
};
|
||||
@@ -12,15 +12,18 @@ import * as URLS from 'config/urls';
|
||||
import useFormatMessage from 'hooks/useFormatMessage';
|
||||
import useDuplicateFlow from 'hooks/useDuplicateFlow';
|
||||
import useDeleteFlow from 'hooks/useDeleteFlow';
|
||||
import useExportFlow from 'hooks/useExportFlow';
|
||||
import useDownloadJsonAsFile from 'hooks/useDownloadJsonAsFile';
|
||||
|
||||
function ContextMenu(props) {
|
||||
const { flowId, onClose, anchorEl, onDuplicateFlow, onDeleteFlow, appKey } =
|
||||
props;
|
||||
const { flowId, onClose, anchorEl, onDuplicateFlow, appKey } = props;
|
||||
const enqueueSnackbar = useEnqueueSnackbar();
|
||||
const formatMessage = useFormatMessage();
|
||||
const queryClient = useQueryClient();
|
||||
const { mutateAsync: duplicateFlow } = useDuplicateFlow(flowId);
|
||||
const { mutateAsync: deleteFlow } = useDeleteFlow();
|
||||
const { mutateAsync: deleteFlow } = useDeleteFlow(flowId);
|
||||
const { mutateAsync: exportFlow } = useExportFlow(flowId);
|
||||
const downloadJsonAsFile = useDownloadJsonAsFile();
|
||||
|
||||
const onFlowDuplicate = React.useCallback(async () => {
|
||||
await duplicateFlow();
|
||||
@@ -51,7 +54,7 @@ function ContextMenu(props) {
|
||||
]);
|
||||
|
||||
const onFlowDelete = React.useCallback(async () => {
|
||||
await deleteFlow(flowId);
|
||||
await deleteFlow();
|
||||
|
||||
if (appKey) {
|
||||
await queryClient.invalidateQueries({
|
||||
@@ -63,9 +66,30 @@ function ContextMenu(props) {
|
||||
variant: 'success',
|
||||
});
|
||||
|
||||
onDeleteFlow?.();
|
||||
onClose();
|
||||
}, [flowId, onClose, deleteFlow, queryClient, onDeleteFlow]);
|
||||
}, [
|
||||
deleteFlow,
|
||||
appKey,
|
||||
enqueueSnackbar,
|
||||
formatMessage,
|
||||
onClose,
|
||||
queryClient,
|
||||
]);
|
||||
|
||||
const onFlowExport = React.useCallback(async () => {
|
||||
const flowExport = await exportFlow();
|
||||
|
||||
downloadJsonAsFile({
|
||||
contents: flowExport.data,
|
||||
name: flowExport.data.name,
|
||||
});
|
||||
|
||||
enqueueSnackbar(formatMessage('flow.successfullyExported'), {
|
||||
variant: 'success',
|
||||
});
|
||||
|
||||
onClose();
|
||||
}, [exportFlow, downloadJsonAsFile, enqueueSnackbar, formatMessage, onClose]);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
@@ -90,6 +114,14 @@ function ContextMenu(props) {
|
||||
)}
|
||||
</Can>
|
||||
|
||||
<Can I="read" a="Flow" passThrough>
|
||||
{(allowed) => (
|
||||
<MenuItem disabled={!allowed} onClick={onFlowExport}>
|
||||
{formatMessage('flow.export')}
|
||||
</MenuItem>
|
||||
)}
|
||||
</Can>
|
||||
|
||||
<Can I="delete" a="Flow" passThrough>
|
||||
{(allowed) => (
|
||||
<MenuItem disabled={!allowed} onClick={onFlowDelete}>
|
||||
@@ -108,7 +140,6 @@ ContextMenu.propTypes = {
|
||||
PropTypes.func,
|
||||
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
|
||||
]).isRequired,
|
||||
onDeleteFlow: PropTypes.func,
|
||||
onDuplicateFlow: PropTypes.func,
|
||||
appKey: PropTypes.string,
|
||||
};
|
||||
|
||||
@@ -37,7 +37,7 @@ function FlowRow(props) {
|
||||
const formatMessage = useFormatMessage();
|
||||
const contextButtonRef = React.useRef(null);
|
||||
const [anchorEl, setAnchorEl] = React.useState(null);
|
||||
const { flow, onDuplicateFlow, onDeleteFlow, appKey } = props;
|
||||
const { flow, onDuplicateFlow, appKey } = props;
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
@@ -118,7 +118,6 @@ function FlowRow(props) {
|
||||
flowId={flow.id}
|
||||
onClose={handleClose}
|
||||
anchorEl={anchorEl}
|
||||
onDeleteFlow={onDeleteFlow}
|
||||
onDuplicateFlow={onDuplicateFlow}
|
||||
appKey={appKey}
|
||||
/>
|
||||
@@ -129,7 +128,6 @@ function FlowRow(props) {
|
||||
|
||||
FlowRow.propTypes = {
|
||||
flow: FlowPropType.isRequired,
|
||||
onDeleteFlow: PropTypes.func,
|
||||
onDuplicateFlow: PropTypes.func,
|
||||
appKey: PropTypes.string,
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as React from 'react';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Button from '@mui/material/Button';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import List from '@mui/material/List';
|
||||
@@ -18,6 +19,7 @@ import { isEqual } from 'lodash';
|
||||
import { EditorContext } from 'contexts/Editor';
|
||||
import { StepExecutionsProvider } from 'contexts/StepExecutions';
|
||||
import TestSubstep from 'components/TestSubstep';
|
||||
import EditableTypography from 'components/EditableTypography';
|
||||
import FlowSubstep from 'components/FlowSubstep';
|
||||
import ChooseAppAndEventSubstep from 'components/ChooseAppAndEventSubstep';
|
||||
import ChooseConnectionSubstep from 'components/ChooseConnectionSubstep';
|
||||
@@ -106,10 +108,9 @@ function generateValidationSchema(substeps) {
|
||||
}
|
||||
|
||||
function FlowStep(props) {
|
||||
const { collapsed, onChange, onContinue, flowId } = props;
|
||||
const { collapsed, onChange, onContinue, flowId, step } = props;
|
||||
const editorContext = React.useContext(EditorContext);
|
||||
const contextButtonRef = React.useRef(null);
|
||||
const step = props.step;
|
||||
const [anchorEl, setAnchorEl] = React.useState(null);
|
||||
const isTrigger = step.type === 'trigger';
|
||||
const isAction = step.type === 'action';
|
||||
@@ -117,6 +118,10 @@ function FlowStep(props) {
|
||||
const [currentSubstep, setCurrentSubstep] = React.useState(0);
|
||||
const useAppsOptions = {};
|
||||
|
||||
const stepTypeName = isTrigger
|
||||
? formatMessage('flowStep.triggerType')
|
||||
: formatMessage('flowStep.actionType');
|
||||
|
||||
if (isTrigger) {
|
||||
useAppsOptions.onlyWithTriggers = true;
|
||||
}
|
||||
@@ -183,6 +188,13 @@ function FlowStep(props) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleStepNameChange = async (name) => {
|
||||
await onChange({
|
||||
...step,
|
||||
name,
|
||||
});
|
||||
};
|
||||
|
||||
const stepValidationSchema = React.useMemo(
|
||||
() => generateValidationSchema(substeps),
|
||||
[substeps],
|
||||
@@ -226,7 +238,7 @@ function FlowStep(props) {
|
||||
data-test="flow-step"
|
||||
>
|
||||
<Header collapsed={collapsed}>
|
||||
<Stack direction="row" alignItems="center" gap={2}>
|
||||
<Stack direction="row" alignItems="center" gap={3}>
|
||||
<AppIconWrapper>
|
||||
<AppIcon
|
||||
url={app?.iconUrl}
|
||||
@@ -239,17 +251,30 @@ function FlowStep(props) {
|
||||
</AppIconStatusIconWrapper>
|
||||
</AppIconWrapper>
|
||||
|
||||
<div>
|
||||
<Typography variant="caption">
|
||||
{isTrigger
|
||||
? formatMessage('flowStep.triggerType')
|
||||
: formatMessage('flowStep.actionType')}
|
||||
<Stack direction="column" gap={0.5} sx={{ width: '100%' }}>
|
||||
<Typography
|
||||
component={Stack}
|
||||
direction="row"
|
||||
variant="stepApp"
|
||||
alignItems="center"
|
||||
gap={0.5}
|
||||
>
|
||||
<Chip label={stepTypeName} variant="stepType" size="small" />
|
||||
|
||||
{app?.name}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2">
|
||||
{step.position}. {app?.name}
|
||||
</Typography>
|
||||
</div>
|
||||
<EditableTypography
|
||||
iconPosition="end"
|
||||
iconSize="small"
|
||||
variant="body2"
|
||||
onConfirm={handleStepNameChange}
|
||||
prefixValue={`${step.position}. `}
|
||||
disabled={editorContext.readOnly || collapsed}
|
||||
>
|
||||
{step.name}
|
||||
</EditableTypography>
|
||||
</Stack>
|
||||
|
||||
<Box display="flex" flex={1} justifyContent="end">
|
||||
{/* as there are no other actions besides "delete step", we hide the context menu. */}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import MuiListItemButton from '@mui/material/ListItemButton';
|
||||
import MuiTypography from '@mui/material/Typography';
|
||||
|
||||
export const ListItemButton = styled(MuiListItemButton)`
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
export const Typography = styled(MuiTypography)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as React from 'react';
|
||||
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
import PropTypes from 'prop-types';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import useFormatMessage from 'hooks/useFormatMessage';
|
||||
|
||||
const noop = () => null;
|
||||
|
||||
@@ -18,6 +19,8 @@ function Form(props) {
|
||||
...formProps
|
||||
} = props;
|
||||
|
||||
const formatMessage = useFormatMessage();
|
||||
|
||||
const methods = useForm({
|
||||
defaultValues,
|
||||
reValidateMode,
|
||||
@@ -25,6 +28,8 @@ function Form(props) {
|
||||
mode,
|
||||
});
|
||||
|
||||
const { setError } = methods;
|
||||
|
||||
const form = useWatch({ control: methods.control });
|
||||
const prevDefaultValues = React.useRef(defaultValues);
|
||||
|
||||
@@ -44,9 +49,53 @@ function Form(props) {
|
||||
}
|
||||
}, [defaultValues]);
|
||||
|
||||
const handleErrors = React.useCallback(
|
||||
function (errors) {
|
||||
if (!errors) return;
|
||||
|
||||
let shouldSetGenericGeneralError = true;
|
||||
const fieldNames = Object.keys(defaultValues);
|
||||
|
||||
Object.entries(errors).forEach(([fieldName, fieldErrors]) => {
|
||||
if (fieldNames.includes(fieldName) && Array.isArray(fieldErrors)) {
|
||||
shouldSetGenericGeneralError = false;
|
||||
setError(fieldName, {
|
||||
type: 'fieldRequestError',
|
||||
message: fieldErrors.join(', '),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// in case of general errors
|
||||
if (Array.isArray(errors.general)) {
|
||||
for (const error of errors.general) {
|
||||
shouldSetGenericGeneralError = false;
|
||||
setError('root.general', { type: 'requestError', message: error });
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldSetGenericGeneralError) {
|
||||
setError('root.general', {
|
||||
type: 'requestError',
|
||||
message: formatMessage('form.genericError'),
|
||||
});
|
||||
}
|
||||
},
|
||||
[defaultValues, formatMessage, setError],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form onSubmit={methods.handleSubmit(onSubmit)} {...formProps}>
|
||||
<form
|
||||
onSubmit={methods.handleSubmit(async (data, event) => {
|
||||
try {
|
||||
return await onSubmit?.(data);
|
||||
} catch (errors) {
|
||||
handleErrors(errors);
|
||||
}
|
||||
})}
|
||||
{...formProps}
|
||||
>
|
||||
{render ? render(methods) : children}
|
||||
</form>
|
||||
</FormProvider>
|
||||
|
||||
160
packages/web/src/components/ImportFlowDialog/index.jsx
Normal file
160
packages/web/src/components/ImportFlowDialog/index.jsx
Normal file
@@ -0,0 +1,160 @@
|
||||
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';
|
||||
import { getUnifiedErrorMessage } from 'helpers/errors';
|
||||
|
||||
function ImportFlowDialog(props) {
|
||||
const { 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);
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
navigate('..');
|
||||
};
|
||||
|
||||
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 sx={{ mb: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={onClose}
|
||||
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"
|
||||
sx={{ whiteSpace: 'pre-line' }}
|
||||
>
|
||||
{getUnifiedErrorMessage(error.response.data.errors) ||
|
||||
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 = {
|
||||
open: PropTypes.bool,
|
||||
'data-test': PropTypes.string,
|
||||
};
|
||||
|
||||
export default ImportFlowDialog;
|
||||
@@ -26,6 +26,7 @@ function InputCreator(props) {
|
||||
showOptionValue,
|
||||
shouldUnregister,
|
||||
} = props;
|
||||
|
||||
const {
|
||||
key: name,
|
||||
label,
|
||||
@@ -35,9 +36,11 @@ function InputCreator(props) {
|
||||
description,
|
||||
type,
|
||||
} = schema;
|
||||
|
||||
const { data, loading } = useDynamicData(stepId, schema);
|
||||
const { data: additionalFieldsData, isLoading: isDynamicFieldsLoading } =
|
||||
useDynamicFields(stepId, schema);
|
||||
|
||||
const additionalFields = additionalFieldsData?.data;
|
||||
|
||||
const computedName = namePrefix ? `${namePrefix}.${name}` : name;
|
||||
@@ -224,6 +227,7 @@ function InputCreator(props) {
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return <React.Fragment />;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,10 @@ import * as React from 'react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Alert } from '@mui/material';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import LoadingButton from '@mui/lab/LoadingButton';
|
||||
import * as yup from 'yup';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import Link from '@mui/material/Link';
|
||||
|
||||
@@ -16,21 +15,41 @@ import * as URLS from 'config/urls';
|
||||
import Form from 'components/Form';
|
||||
import TextField from 'components/TextField';
|
||||
|
||||
const validationSchema = yup.object().shape({
|
||||
fullName: yup.string().trim().required('installationForm.mandatoryInput'),
|
||||
email: yup
|
||||
.string()
|
||||
.trim()
|
||||
.email('installationForm.validateEmail')
|
||||
.required('installationForm.mandatoryInput'),
|
||||
password: yup.string().required('installationForm.mandatoryInput'),
|
||||
confirmPassword: yup
|
||||
.string()
|
||||
.required('installationForm.mandatoryInput')
|
||||
.oneOf([yup.ref('password')], 'installationForm.passwordsMustMatch'),
|
||||
});
|
||||
const getValidationSchema = (formatMessage) => {
|
||||
const getMandatoryInputMessage = (inputNameId) =>
|
||||
formatMessage('installationForm.mandatoryInput', {
|
||||
inputName: formatMessage(inputNameId),
|
||||
});
|
||||
|
||||
const initialValues = {
|
||||
return yup.object().shape({
|
||||
fullName: yup
|
||||
.string()
|
||||
.trim()
|
||||
.required(
|
||||
getMandatoryInputMessage('installationForm.fullNameFieldLabel'),
|
||||
),
|
||||
email: yup
|
||||
.string()
|
||||
.trim()
|
||||
.required(getMandatoryInputMessage('installationForm.emailFieldLabel'))
|
||||
.email(formatMessage('installationForm.validateEmail')),
|
||||
password: yup
|
||||
.string()
|
||||
.required(getMandatoryInputMessage('installationForm.passwordFieldLabel'))
|
||||
.min(6, formatMessage('installationForm.passwordMinLength')),
|
||||
confirmPassword: yup
|
||||
.string()
|
||||
.required(
|
||||
getMandatoryInputMessage('installationForm.confirmPasswordFieldLabel'),
|
||||
)
|
||||
.oneOf(
|
||||
[yup.ref('password')],
|
||||
formatMessage('installationForm.passwordsMustMatch'),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const defaultValues = {
|
||||
fullName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
@@ -39,7 +58,7 @@ const initialValues = {
|
||||
|
||||
function InstallationForm() {
|
||||
const formatMessage = useFormatMessage();
|
||||
const install = useInstallation();
|
||||
const { mutateAsync: install, isSuccess, isPending } = useInstallation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleOnRedirect = () => {
|
||||
@@ -48,21 +67,16 @@ function InstallationForm() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (values) => {
|
||||
const { fullName, email, password } = values;
|
||||
const handleSubmit = async ({ fullName, email, password }) => {
|
||||
try {
|
||||
await install.mutateAsync({
|
||||
await install({
|
||||
fullName,
|
||||
email,
|
||||
password,
|
||||
});
|
||||
} catch (error) {
|
||||
enqueueSnackbar(
|
||||
error?.message || formatMessage('installationForm.error'),
|
||||
{
|
||||
variant: 'error',
|
||||
},
|
||||
);
|
||||
const errors = error?.response?.data?.errors;
|
||||
throw errors || error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -82,11 +96,13 @@ function InstallationForm() {
|
||||
{formatMessage('installationForm.title')}
|
||||
</Typography>
|
||||
<Form
|
||||
defaultValues={initialValues}
|
||||
automaticValidation={false}
|
||||
noValidate
|
||||
defaultValues={defaultValues}
|
||||
onSubmit={handleSubmit}
|
||||
resolver={yupResolver(validationSchema)}
|
||||
resolver={yupResolver(getValidationSchema(formatMessage))}
|
||||
mode="onChange"
|
||||
render={({ formState: { errors, touchedFields } }) => (
|
||||
render={({ formState: { errors } }) => (
|
||||
<>
|
||||
<TextField
|
||||
label={formatMessage('installationForm.fullNameFieldLabel')}
|
||||
@@ -95,19 +111,12 @@ function InstallationForm() {
|
||||
margin="dense"
|
||||
autoComplete="fullName"
|
||||
data-test="fullName-text-field"
|
||||
error={touchedFields.fullName && !!errors?.fullName}
|
||||
helperText={
|
||||
touchedFields.fullName && errors?.fullName?.message
|
||||
? formatMessage(errors?.fullName?.message, {
|
||||
inputName: formatMessage(
|
||||
'installationForm.fullNameFieldLabel',
|
||||
),
|
||||
})
|
||||
: ''
|
||||
}
|
||||
error={!!errors?.fullName}
|
||||
helperText={errors?.fullName?.message}
|
||||
required
|
||||
readOnly={install.isSuccess}
|
||||
readOnly={isSuccess}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label={formatMessage('installationForm.emailFieldLabel')}
|
||||
name="email"
|
||||
@@ -115,19 +124,12 @@ function InstallationForm() {
|
||||
margin="dense"
|
||||
autoComplete="email"
|
||||
data-test="email-text-field"
|
||||
error={touchedFields.email && !!errors?.email}
|
||||
helperText={
|
||||
touchedFields.email && errors?.email?.message
|
||||
? formatMessage(errors?.email?.message, {
|
||||
inputName: formatMessage(
|
||||
'installationForm.emailFieldLabel',
|
||||
),
|
||||
})
|
||||
: ''
|
||||
}
|
||||
error={!!errors?.email}
|
||||
helperText={errors?.email?.message}
|
||||
required
|
||||
readOnly={install.isSuccess}
|
||||
readOnly={isSuccess}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label={formatMessage('installationForm.passwordFieldLabel')}
|
||||
name="password"
|
||||
@@ -135,19 +137,12 @@ function InstallationForm() {
|
||||
margin="dense"
|
||||
type="password"
|
||||
data-test="password-text-field"
|
||||
error={touchedFields.password && !!errors?.password}
|
||||
helperText={
|
||||
touchedFields.password && errors?.password?.message
|
||||
? formatMessage(errors?.password?.message, {
|
||||
inputName: formatMessage(
|
||||
'installationForm.passwordFieldLabel',
|
||||
),
|
||||
})
|
||||
: ''
|
||||
}
|
||||
error={!!errors?.password}
|
||||
helperText={errors?.password?.message}
|
||||
required
|
||||
readOnly={install.isSuccess}
|
||||
readOnly={isSuccess}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label={formatMessage(
|
||||
'installationForm.confirmPasswordFieldLabel',
|
||||
@@ -157,52 +152,53 @@ function InstallationForm() {
|
||||
margin="dense"
|
||||
type="password"
|
||||
data-test="repeat-password-text-field"
|
||||
error={touchedFields.confirmPassword && !!errors?.confirmPassword}
|
||||
helperText={
|
||||
touchedFields.confirmPassword &&
|
||||
errors?.confirmPassword?.message
|
||||
? formatMessage(errors?.confirmPassword?.message, {
|
||||
inputName: formatMessage(
|
||||
'installationForm.confirmPasswordFieldLabel',
|
||||
),
|
||||
})
|
||||
: ''
|
||||
}
|
||||
error={!!errors?.confirmPassword}
|
||||
helperText={errors?.confirmPassword?.message}
|
||||
required
|
||||
readOnly={install.isSuccess}
|
||||
readOnly={isSuccess}
|
||||
/>
|
||||
|
||||
{errors?.root?.general && (
|
||||
<Alert data-test="error-alert" severity="error" sx={{ mt: 2 }}>
|
||||
{errors.root.general.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isSuccess && (
|
||||
<Alert
|
||||
data-test="success-alert"
|
||||
severity="success"
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
{formatMessage('installationForm.success', {
|
||||
link: (str) => (
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={URLS.LOGIN}
|
||||
onClick={handleOnRedirect}
|
||||
replace
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
})}
|
||||
</Alert>
|
||||
)}
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ boxShadow: 2, mt: 3 }}
|
||||
loading={install.isPending}
|
||||
disabled={install.isSuccess}
|
||||
sx={{ boxShadow: 2, mt: 2 }}
|
||||
loading={isPending}
|
||||
disabled={isSuccess}
|
||||
fullWidth
|
||||
data-test="signUp-button"
|
||||
data-test="installation-button"
|
||||
>
|
||||
{formatMessage('installationForm.submit')}
|
||||
</LoadingButton>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
{install.isSuccess && (
|
||||
<Alert data-test="success-alert" severity="success" sx={{ mt: 3 }}>
|
||||
{formatMessage('installationForm.success', {
|
||||
link: (str) => (
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={URLS.LOGIN}
|
||||
onClick={handleOnRedirect}
|
||||
replace
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
})}
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import LoadingButton from '@mui/lab/LoadingButton';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import * as yup from 'yup';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
|
||||
@@ -12,24 +13,41 @@ import Form from 'components/Form';
|
||||
import TextField from 'components/TextField';
|
||||
import useFormatMessage from 'hooks/useFormatMessage';
|
||||
import useCreateAccessToken from 'hooks/useCreateAccessToken';
|
||||
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
|
||||
import useRegisterUser from 'hooks/useRegisterUser';
|
||||
|
||||
const validationSchema = yup.object().shape({
|
||||
fullName: yup.string().trim().required('signupForm.mandatoryInput'),
|
||||
email: yup
|
||||
.string()
|
||||
.trim()
|
||||
.email('signupForm.validateEmail')
|
||||
.required('signupForm.mandatoryInput'),
|
||||
password: yup.string().required('signupForm.mandatoryInput'),
|
||||
confirmPassword: yup
|
||||
.string()
|
||||
.required('signupForm.mandatoryInput')
|
||||
.oneOf([yup.ref('password')], 'signupForm.passwordsMustMatch'),
|
||||
});
|
||||
const getValidationSchema = (formatMessage) => {
|
||||
const getMandatoryInputMessage = (inputNameId) =>
|
||||
formatMessage('signupForm.mandatoryInput', {
|
||||
inputName: formatMessage(inputNameId),
|
||||
});
|
||||
|
||||
const initialValues = {
|
||||
return yup.object().shape({
|
||||
fullName: yup
|
||||
.string()
|
||||
.trim()
|
||||
.required(getMandatoryInputMessage('signupForm.fullNameFieldLabel')),
|
||||
email: yup
|
||||
.string()
|
||||
.trim()
|
||||
.required(getMandatoryInputMessage('signupForm.emailFieldLabel'))
|
||||
.email(formatMessage('signupForm.validateEmail')),
|
||||
password: yup
|
||||
.string()
|
||||
.required(getMandatoryInputMessage('signupForm.passwordFieldLabel'))
|
||||
.min(6, formatMessage('signupForm.passwordMinLength')),
|
||||
confirmPassword: yup
|
||||
.string()
|
||||
.required(
|
||||
getMandatoryInputMessage('signupForm.confirmPasswordFieldLabel'),
|
||||
)
|
||||
.oneOf(
|
||||
[yup.ref('password')],
|
||||
formatMessage('signupForm.passwordsMustMatch'),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const defaultValues = {
|
||||
fullName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
@@ -40,7 +58,6 @@ function SignUpForm() {
|
||||
const navigate = useNavigate();
|
||||
const authentication = useAuthentication();
|
||||
const formatMessage = useFormatMessage();
|
||||
const enqueueSnackbar = useEnqueueSnackbar();
|
||||
const { mutateAsync: registerUser, isPending: isRegisterUserPending } =
|
||||
useRegisterUser();
|
||||
const { mutateAsync: createAccessToken, isPending: loginLoading } =
|
||||
@@ -67,27 +84,8 @@ function SignUpForm() {
|
||||
const { token } = data;
|
||||
authentication.updateToken(token);
|
||||
} catch (error) {
|
||||
const errors = error?.response?.data?.errors
|
||||
? Object.values(error.response.data.errors)
|
||||
: [];
|
||||
|
||||
if (errors.length) {
|
||||
for (const [error] of errors) {
|
||||
enqueueSnackbar(error, {
|
||||
variant: 'error',
|
||||
SnackbarProps: {
|
||||
'data-test': 'snackbar-sign-up-error',
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
enqueueSnackbar(error?.message || formatMessage('signupForm.error'), {
|
||||
variant: 'error',
|
||||
SnackbarProps: {
|
||||
'data-test': 'snackbar-sign-up-error',
|
||||
},
|
||||
});
|
||||
}
|
||||
const errors = error?.response?.data?.errors;
|
||||
throw errors || error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -108,11 +106,13 @@ function SignUpForm() {
|
||||
</Typography>
|
||||
|
||||
<Form
|
||||
defaultValues={initialValues}
|
||||
automaticValidation={false}
|
||||
noValidate
|
||||
defaultValues={defaultValues}
|
||||
onSubmit={handleSubmit}
|
||||
resolver={yupResolver(validationSchema)}
|
||||
resolver={yupResolver(getValidationSchema(formatMessage))}
|
||||
mode="onChange"
|
||||
render={({ formState: { errors, touchedFields } }) => (
|
||||
render={({ formState: { errors } }) => (
|
||||
<>
|
||||
<TextField
|
||||
label={formatMessage('signupForm.fullNameFieldLabel')}
|
||||
@@ -121,14 +121,9 @@ function SignUpForm() {
|
||||
margin="dense"
|
||||
autoComplete="fullName"
|
||||
data-test="fullName-text-field"
|
||||
error={touchedFields.fullName && !!errors?.fullName}
|
||||
helperText={
|
||||
touchedFields.fullName && errors?.fullName?.message
|
||||
? formatMessage(errors?.fullName?.message, {
|
||||
inputName: formatMessage('signupForm.fullNameFieldLabel'),
|
||||
})
|
||||
: ''
|
||||
}
|
||||
error={!!errors?.fullName}
|
||||
helperText={errors?.fullName?.message}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextField
|
||||
@@ -138,14 +133,9 @@ function SignUpForm() {
|
||||
margin="dense"
|
||||
autoComplete="email"
|
||||
data-test="email-text-field"
|
||||
error={touchedFields.email && !!errors?.email}
|
||||
helperText={
|
||||
touchedFields.email && errors?.email?.message
|
||||
? formatMessage(errors?.email?.message, {
|
||||
inputName: formatMessage('signupForm.emailFieldLabel'),
|
||||
})
|
||||
: ''
|
||||
}
|
||||
error={!!errors?.email}
|
||||
helperText={errors?.email?.message}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextField
|
||||
@@ -154,14 +144,9 @@ function SignUpForm() {
|
||||
fullWidth
|
||||
margin="dense"
|
||||
type="password"
|
||||
error={touchedFields.password && !!errors?.password}
|
||||
helperText={
|
||||
touchedFields.password && errors?.password?.message
|
||||
? formatMessage(errors?.password?.message, {
|
||||
inputName: formatMessage('signupForm.passwordFieldLabel'),
|
||||
})
|
||||
: ''
|
||||
}
|
||||
error={!!errors?.password}
|
||||
helperText={errors?.password?.message}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextField
|
||||
@@ -170,19 +155,21 @@ function SignUpForm() {
|
||||
fullWidth
|
||||
margin="dense"
|
||||
type="password"
|
||||
error={touchedFields.confirmPassword && !!errors?.confirmPassword}
|
||||
helperText={
|
||||
touchedFields.confirmPassword &&
|
||||
errors?.confirmPassword?.message
|
||||
? formatMessage(errors?.confirmPassword?.message, {
|
||||
inputName: formatMessage(
|
||||
'signupForm.confirmPasswordFieldLabel',
|
||||
),
|
||||
})
|
||||
: ''
|
||||
}
|
||||
error={!!errors?.confirmPassword}
|
||||
helperText={errors?.confirmPassword?.message}
|
||||
required
|
||||
/>
|
||||
|
||||
{errors?.root?.general && (
|
||||
<Alert
|
||||
data-test="alert-sign-up-error"
|
||||
severity="error"
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
{errors.root.general.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
variant="contained"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,6 +7,7 @@ export const EditorContext = React.createContext({
|
||||
|
||||
export const EditorProvider = (props) => {
|
||||
const { children, value } = props;
|
||||
|
||||
return (
|
||||
<EditorContext.Provider value={value}>{children}</EditorContext.Provider>
|
||||
);
|
||||
@@ -14,5 +15,7 @@ export const EditorProvider = (props) => {
|
||||
|
||||
EditorProvider.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
value: PropTypes.shape({ readOnly: PropTypes.bool.isRequired }).isRequired,
|
||||
value: PropTypes.shape({
|
||||
readOnly: PropTypes.bool.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
19
packages/web/src/contexts/FieldEntry.jsx
Normal file
19
packages/web/src/contexts/FieldEntry.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export const FieldEntryContext = React.createContext({});
|
||||
|
||||
export const FieldEntryProvider = (props) => {
|
||||
const { children, value } = props;
|
||||
|
||||
return (
|
||||
<FieldEntryContext.Provider value={value}>
|
||||
{children}
|
||||
</FieldEntryContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
FieldEntryProvider.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
value: PropTypes.object,
|
||||
};
|
||||
@@ -6,6 +6,7 @@ export const StepExecutionsContext = React.createContext([]);
|
||||
|
||||
export const StepExecutionsProvider = (props) => {
|
||||
const { children, value } = props;
|
||||
|
||||
return (
|
||||
<StepExecutionsContext.Provider value={value}>
|
||||
{children}
|
||||
|
||||
35
packages/web/src/helpers/errors.js
Normal file
35
packages/web/src/helpers/errors.js
Normal file
@@ -0,0 +1,35 @@
|
||||
// Helpers to extract errors received from the API
|
||||
|
||||
export const getGeneralErrorMessage = ({ error, fallbackMessage }) => {
|
||||
if (!error) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errors = error?.response?.data?.errors;
|
||||
const generalError = errors?.general;
|
||||
|
||||
if (generalError && Array.isArray(generalError)) {
|
||||
return generalError.join(' ');
|
||||
}
|
||||
|
||||
if (!errors) {
|
||||
return error?.message || fallbackMessage;
|
||||
}
|
||||
};
|
||||
|
||||
export const getFieldErrorMessage = ({ fieldName, error }) => {
|
||||
const errors = error?.response?.data?.errors;
|
||||
const fieldErrors = errors?.[fieldName];
|
||||
|
||||
if (fieldErrors && Array.isArray(fieldErrors)) {
|
||||
return fieldErrors.join(', ');
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
export const getUnifiedErrorMessage = (errors) => {
|
||||
return Object.values(errors)
|
||||
.flatMap((error) => error)
|
||||
.join('\n\r');
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from 'helpers/api';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
|
||||
export default function useAdminCreateSamlAuthProvider() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -16,20 +15,6 @@ export default function useAdminCreateSamlAuthProvider() {
|
||||
queryKey: ['admin', 'samlAuthProviders'],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const errors = Object.entries(
|
||||
error.response.data.errors || [['', 'Failed while saving!']],
|
||||
);
|
||||
|
||||
for (const error of errors) {
|
||||
enqueueSnackbar(`${error[0] ? error[0] + ': ' : ''} ${error[1]}`, {
|
||||
variant: 'error',
|
||||
SnackbarProps: {
|
||||
'data-test': 'snackbar-create-saml-auth-provider-error',
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return query;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from 'helpers/api';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
|
||||
export default function useAdminUpdateSamlAuthProvider(samlAuthProviderId) {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -19,20 +18,6 @@ export default function useAdminUpdateSamlAuthProvider(samlAuthProviderId) {
|
||||
queryKey: ['admin', 'samlAuthProviders'],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const errors = Object.entries(
|
||||
error.response.data.errors || [['', 'Failed while saving!']],
|
||||
);
|
||||
|
||||
for (const error of errors) {
|
||||
enqueueSnackbar(`${error[0] ? error[0] + ': ' : ''} ${error[1]}`, {
|
||||
variant: 'error',
|
||||
SnackbarProps: {
|
||||
'data-test': 'snackbar-update-saml-auth-provider-error',
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return query;
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from 'helpers/api';
|
||||
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
|
||||
import useFormatMessage from 'hooks/useFormatMessage';
|
||||
|
||||
export default function useAdminUpdateUser(userId) {
|
||||
const queryClient = useQueryClient();
|
||||
const enqueueSnackbar = useEnqueueSnackbar();
|
||||
const formatMessage = useFormatMessage();
|
||||
|
||||
const query = useMutation({
|
||||
mutationFn: async (payload) => {
|
||||
@@ -19,15 +15,6 @@ export default function useAdminUpdateUser(userId) {
|
||||
queryKey: ['admin', 'users'],
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
enqueueSnackbar(formatMessage('editUser.error'), {
|
||||
variant: 'error',
|
||||
persist: true,
|
||||
SnackbarProps: {
|
||||
'data-test': 'snackbar-error',
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return query;
|
||||
|
||||
@@ -13,7 +13,6 @@ import useCreateConnectionAuthUrl from './useCreateConnectionAuthUrl';
|
||||
import useUpdateConnection from './useUpdateConnection';
|
||||
import useResetConnection from './useResetConnection';
|
||||
import useVerifyConnection from './useVerifyConnection';
|
||||
import { useWhatChanged } from '@simbathesailor/use-what-changed';
|
||||
|
||||
function getSteps(auth, hasConnection, useShared) {
|
||||
if (hasConnection) {
|
||||
@@ -143,24 +142,6 @@ export default function useAuthenticateApp(payload) {
|
||||
verifyConnection,
|
||||
]);
|
||||
|
||||
useWhatChanged(
|
||||
[
|
||||
steps,
|
||||
appKey,
|
||||
oauthClientId,
|
||||
connectionId,
|
||||
queryClient,
|
||||
createConnection,
|
||||
createConnectionAuthUrl,
|
||||
updateConnection,
|
||||
resetConnection,
|
||||
verifyConnection,
|
||||
],
|
||||
'steps, appKey, oauthClientId, connectionId, queryClient, createConnection, createConnectionAuthUrl, updateConnection, resetConnection, verifyConnection',
|
||||
'',
|
||||
'useAuthenticate',
|
||||
);
|
||||
|
||||
return {
|
||||
authenticate,
|
||||
inProgress: authenticationInProgress,
|
||||
|
||||
@@ -2,18 +2,18 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import api from 'helpers/api';
|
||||
|
||||
export default function useDeleteFlow() {
|
||||
export default function useDeleteFlow(flowId) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useMutation({
|
||||
mutationFn: async (flowId) => {
|
||||
mutationFn: async () => {
|
||||
const { data } = await api.delete(`/v1/flows/${flowId}`);
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['flows'],
|
||||
});
|
||||
},
|
||||
|
||||
31
packages/web/src/hooks/useDownloadJsonAsFile.js
Normal file
31
packages/web/src/hooks/useDownloadJsonAsFile.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as React from 'react';
|
||||
import slugify from 'slugify';
|
||||
|
||||
export default function useDownloadJsonAsFile() {
|
||||
const handleDownloadJsonAsFile = React.useCallback(
|
||||
function handleDownloadJsonAsFile({ contents, name }) {
|
||||
const stringifiedContents = JSON.stringify(contents, null, 2);
|
||||
|
||||
const slugifiedName = slugify(name, {
|
||||
lower: true,
|
||||
strict: true,
|
||||
replacement: '-',
|
||||
});
|
||||
|
||||
const fileBlob = new Blob([stringifiedContents], {
|
||||
type: 'application/json',
|
||||
});
|
||||
|
||||
const fileObjectUrl = URL.createObjectURL(fileBlob);
|
||||
|
||||
const temporaryDownloadLink = document.createElement('a');
|
||||
temporaryDownloadLink.href = fileObjectUrl;
|
||||
temporaryDownloadLink.download = slugifiedName;
|
||||
|
||||
temporaryDownloadLink.click();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return handleDownloadJsonAsFile;
|
||||
}
|
||||
@@ -12,8 +12,8 @@ export default function useDuplicateFlow(flowId) {
|
||||
return data;
|
||||
},
|
||||
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['flows'],
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,24 +1,38 @@
|
||||
import * as React from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import set from 'lodash/set';
|
||||
import first from 'lodash/first';
|
||||
import last from 'lodash/last';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
|
||||
import api from 'helpers/api';
|
||||
import useFieldEntryContext from './useFieldEntryContext';
|
||||
|
||||
const variableRegExp = /({.*?})/;
|
||||
|
||||
function computeArguments(args, getValues) {
|
||||
function computeArguments(args, getValues, fieldEntryPaths) {
|
||||
const initialValue = {};
|
||||
|
||||
return args.reduce((result, { name, value }) => {
|
||||
const isVariable = variableRegExp.test(value);
|
||||
|
||||
if (isVariable) {
|
||||
const sanitizedFieldPath = value.replace(/{|}/g, '');
|
||||
const fieldsEntryPath = last(fieldEntryPaths);
|
||||
const outerFieldsEntryPath = first(fieldEntryPaths);
|
||||
|
||||
const sanitizedFieldPath = value
|
||||
.replace(/{|}/g, '')
|
||||
.replace('fieldsScope.', `${fieldsEntryPath}.`)
|
||||
.replace('outerScope.', `${outerFieldsEntryPath}.`);
|
||||
|
||||
const computedValue = getValues(sanitizedFieldPath);
|
||||
|
||||
if (computedValue === undefined)
|
||||
throw new Error(`The ${sanitizedFieldPath} field is required.`);
|
||||
|
||||
set(result, name, computedValue);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -52,7 +66,9 @@ function useDynamicData(stepId, schema) {
|
||||
});
|
||||
|
||||
const { getValues } = useFormContext();
|
||||
const { fieldEntryPaths } = useFieldEntryContext();
|
||||
const formValues = getValues();
|
||||
|
||||
/**
|
||||
* Return `null` when even a field is missing value.
|
||||
*
|
||||
@@ -62,23 +78,31 @@ function useDynamicData(stepId, schema) {
|
||||
const computedVariables = React.useMemo(() => {
|
||||
if (schema.type === 'dropdown' && schema.source) {
|
||||
try {
|
||||
const variables = computeArguments(schema.source.arguments, getValues);
|
||||
const variables = computeArguments(
|
||||
schema.source.arguments,
|
||||
getValues,
|
||||
fieldEntryPaths,
|
||||
);
|
||||
|
||||
// if computed variables are the same, return the last computed variables.
|
||||
if (isEqual(variables, lastComputedVariables.current)) {
|
||||
return lastComputedVariables.current;
|
||||
}
|
||||
|
||||
lastComputedVariables.current = variables;
|
||||
|
||||
return variables;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
/**
|
||||
* `formValues` is to trigger recomputation when form is updated.
|
||||
* `getValues` is for convenience as it supports paths for fields like `getValues('foo.bar.baz')`.
|
||||
*/
|
||||
}, [schema, formValues, getValues]);
|
||||
}, [schema, formValues, getValues, fieldEntryPaths]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
|
||||
15
packages/web/src/hooks/useExportFlow.js
Normal file
15
packages/web/src/hooks/useExportFlow.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import api from 'helpers/api';
|
||||
|
||||
export default function useExportFlow(flowId) {
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await api.post(`/v1/flows/${flowId}/export`);
|
||||
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
return mutation;
|
||||
}
|
||||
8
packages/web/src/hooks/useFieldEntryContext.jsx
Normal file
8
packages/web/src/hooks/useFieldEntryContext.jsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import * as React from 'react';
|
||||
import { FieldEntryContext } from 'contexts/FieldEntry';
|
||||
|
||||
export default function useFieldEntryContext() {
|
||||
const fieldEntryContext = React.useContext(FieldEntryContext);
|
||||
|
||||
return fieldEntryContext;
|
||||
}
|
||||
18
packages/web/src/hooks/useFlows.js
Normal file
18
packages/web/src/hooks/useFlows.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import api from 'helpers/api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
export default function useFlows({ flowName, page }) {
|
||||
const query = useQuery({
|
||||
queryKey: ['flows', flowName, { page }],
|
||||
queryFn: async ({ signal }) => {
|
||||
const { data } = await api.get('/v1/flows', {
|
||||
params: { name: flowName, page },
|
||||
signal,
|
||||
});
|
||||
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
return query;
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
export default function useFormatMessage() {
|
||||
const { formatMessage } = useIntl();
|
||||
return (id, values = {}) => formatMessage({ id }, values);
|
||||
|
||||
const customFormatMessage = React.useCallback(
|
||||
(id, values = {}) => formatMessage({ id }, values),
|
||||
[formatMessage],
|
||||
);
|
||||
|
||||
return customFormatMessage;
|
||||
}
|
||||
|
||||
21
packages/web/src/hooks/useImportFlow.js
Normal file
21
packages/web/src/hooks/useImportFlow.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import api from 'helpers/api';
|
||||
|
||||
export default function useImportFlow() {
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (flowData) => {
|
||||
const { data } = await api.post('/v1/flows/import', flowData);
|
||||
|
||||
return data;
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['flows'],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return mutation;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import api from 'helpers/api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
export default function useLazyFlows({ flowName, page }, { onSettled }) {
|
||||
const abortControllerRef = React.useRef(new AbortController());
|
||||
|
||||
React.useEffect(() => {
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
return () => {
|
||||
abortControllerRef.current?.abort();
|
||||
};
|
||||
}, [flowName]);
|
||||
|
||||
const query = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await api.get('/v1/flows', {
|
||||
params: { name: flowName, page },
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
|
||||
return data;
|
||||
},
|
||||
onSettled,
|
||||
});
|
||||
|
||||
return query;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import api from 'helpers/api';
|
||||
|
||||
export default function useSamlAuthProvider({ samlAuthProviderId } = {}) {
|
||||
const query = useQuery({
|
||||
queryKey: ['samlAuthProviders', samlAuthProviderId],
|
||||
queryKey: ['admin', 'samlAuthProviders', samlAuthProviderId],
|
||||
queryFn: async ({ signal }) => {
|
||||
const { data } = await api.get(
|
||||
`/v1/admin/saml-auth-providers/${samlAuthProviderId}`,
|
||||
|
||||
@@ -6,19 +6,20 @@ export default function useUpdateStep() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useMutation({
|
||||
mutationFn: async ({ id, appKey, key, connectionId, parameters }) => {
|
||||
mutationFn: async ({ id, appKey, key, connectionId, name, parameters }) => {
|
||||
const { data } = await api.patch(`/v1/steps/${id}`, {
|
||||
appKey,
|
||||
key,
|
||||
connectionId,
|
||||
name,
|
||||
parameters,
|
||||
});
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['flows'],
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"brandText": "Automatisch",
|
||||
"searchPlaceholder": "Search",
|
||||
"genericError": "Something went wrong. Please try again.",
|
||||
"accountDropdownMenu.settings": "Settings",
|
||||
"accountDropdownMenu.adminSettings": "Admin",
|
||||
"accountDropdownMenu.logout": "Logout",
|
||||
@@ -25,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.",
|
||||
@@ -56,9 +58,11 @@
|
||||
"flow.draft": "Draft",
|
||||
"flow.successfullyDeleted": "The flow and associated executions have been deleted.",
|
||||
"flow.successfullyDuplicated": "The flow has been successfully duplicated.",
|
||||
"flow.successfullyExported": "The flow export has been successfully generated.",
|
||||
"flowEditor.publish": "PUBLISH",
|
||||
"flowEditor.unpublish": "UNPUBLISH",
|
||||
"flowEditor.publishedFlowCannotBeUpdated": "To edit this flow, you must first unpublish it.",
|
||||
"flowEditor.export": "EXPORT",
|
||||
"flowEditor.noTestDataTitle": "We couldn't find matching data",
|
||||
"flowEditor.noTestDataMessage": "Create a sample in the associated service and test the step again.",
|
||||
"flowEditor.testAndContinue": "Test & Continue",
|
||||
@@ -70,6 +74,7 @@
|
||||
"flowEditor.triggerEvent": "Trigger event",
|
||||
"flowEditor.actionEvent": "Action event",
|
||||
"flowEditor.instantTriggerType": "Instant",
|
||||
"flowEditor.flowSuccessfullyExported": "The flow export has been successfully generated.",
|
||||
"filterConditions.onlyContinueIf": "Only continue if…",
|
||||
"filterConditions.orContinueIf": "OR continue if…",
|
||||
"chooseConnectionSubstep.continue": "Continue",
|
||||
@@ -81,9 +86,11 @@
|
||||
"flow.view": "View",
|
||||
"flow.duplicate": "Duplicate",
|
||||
"flow.delete": "Delete",
|
||||
"flow.export": "Export",
|
||||
"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",
|
||||
@@ -130,6 +137,7 @@
|
||||
"webhookUrlInfo.description": "You'll need to configure your application with this webhook URL.",
|
||||
"webhookUrlInfo.helperText": "We've generated a custom webhook URL for you to send requests to. <link>Learn more about webhooks</link>.",
|
||||
"webhookUrlInfo.copy": "Copy",
|
||||
"form.genericError": "Something went wrong. Please try again.",
|
||||
"installationForm.title": "Installation",
|
||||
"installationForm.fullNameFieldLabel": "Full name",
|
||||
"installationForm.emailFieldLabel": "Email",
|
||||
@@ -138,9 +146,9 @@
|
||||
"installationForm.submit": "Create admin",
|
||||
"installationForm.validateEmail": "Email must be valid.",
|
||||
"installationForm.passwordsMustMatch": "Passwords must match.",
|
||||
"installationForm.passwordMinLength": "Password must be at least 6 characters long.",
|
||||
"installationForm.mandatoryInput": "{inputName} is required.",
|
||||
"installationForm.success": "The admin account has been created, and thus, the installation has been completed. You can now log in <link>here</link>.",
|
||||
"installationForm.error": "Something went wrong. Please try again.",
|
||||
"signupForm.title": "Sign up",
|
||||
"signupForm.fullNameFieldLabel": "Full name",
|
||||
"signupForm.emailFieldLabel": "Email",
|
||||
@@ -149,8 +157,8 @@
|
||||
"signupForm.submit": "Sign up",
|
||||
"signupForm.validateEmail": "Email must be valid.",
|
||||
"signupForm.passwordsMustMatch": "Passwords must match.",
|
||||
"signupForm.passwordMinLength": "Password must be at least 6 characters long.",
|
||||
"signupForm.mandatoryInput": "{inputName} is required.",
|
||||
"signupForm.error": "Something went wrong. Please try again.",
|
||||
"loginForm.title": "Login",
|
||||
"loginForm.emailFieldLabel": "Email",
|
||||
"loginForm.passwordFieldLabel": "Password",
|
||||
@@ -225,15 +233,15 @@
|
||||
"userForm.email": "Email",
|
||||
"userForm.role": "Role",
|
||||
"userForm.password": "Password",
|
||||
"userForm.mandatoryInput": "{inputName} is required.",
|
||||
"userForm.validateEmail": "Email must be valid.",
|
||||
"createUser.submit": "Create",
|
||||
"createUser.successfullyCreated": "The user has been created.",
|
||||
"createUser.invitationEmailInfo": "Invitation email will be sent if SMTP credentials are valid. Otherwise, you can share the invitation link manually: <link></link>",
|
||||
"createUser.error": "Error while creating the user.",
|
||||
"editUserPage.title": "Edit user",
|
||||
"editUser.status": "Status",
|
||||
"editUser.submit": "Update",
|
||||
"editUser.successfullyUpdated": "The user has been updated.",
|
||||
"editUser.error": "Error while updating the user.",
|
||||
"userList.fullName": "Full name",
|
||||
"userList.email": "Email",
|
||||
"userList.role": "Role",
|
||||
@@ -245,12 +253,15 @@
|
||||
"deleteRoleButton.cancel": "Cancel",
|
||||
"deleteRoleButton.confirm": "Delete",
|
||||
"deleteRoleButton.successfullyDeleted": "The role has been deleted.",
|
||||
"deleteRoleButton.generalError": "Failed while deleting!",
|
||||
"editRolePage.title": "Edit role",
|
||||
"createRolePage.title": "Create role",
|
||||
"roleForm.name": "Name",
|
||||
"roleForm.description": "Description",
|
||||
"roleForm.mandatoryInput": "{inputName} is required.",
|
||||
"createRole.submit": "Create",
|
||||
"createRole.successfullyCreated": "The role has been created.",
|
||||
"createRole.permissionsError": "Permissions are invalid.",
|
||||
"editRole.submit": "Update",
|
||||
"editRole.successfullyUpdated": "The role has been updated.",
|
||||
"roleList.name": "Name",
|
||||
@@ -281,12 +292,13 @@
|
||||
"authenticationForm.defaultRole": "Default role",
|
||||
"authenticationForm.successfullySaved": "The provider has been saved.",
|
||||
"authenticationForm.save": "Save",
|
||||
"authenticationForm.mandatoryInput": "{inputName} is required.",
|
||||
"roleMappingsForm.title": "Role mappings",
|
||||
"roleMappingsForm.remoteRoleName": "Remote role name",
|
||||
"roleMappingsForm.role": "Role",
|
||||
"roleMappingsForm.appendRoleMapping": "Append",
|
||||
"roleMappingsForm.save": "Save",
|
||||
"roleMappingsForm.notFound": "No role mappings have found.",
|
||||
"roleMappingsForm.notFound": "No role mappings have been found.",
|
||||
"roleMappingsForm.successfullySaved": "Role mappings have been saved.",
|
||||
"adminApps.title": "Apps",
|
||||
"adminApps.connections": "Connections",
|
||||
@@ -307,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 <link>here</link>."
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import LoadingButton from '@mui/lab/LoadingButton';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
|
||||
import { useMemo } from 'react';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as yup from 'yup';
|
||||
|
||||
@@ -63,11 +63,11 @@ const getValidationSchema = (formatMessage) =>
|
||||
|
||||
function RoleMappings({ provider, providerLoading }) {
|
||||
const formatMessage = useFormatMessage();
|
||||
const enqueueSnackbar = useEnqueueSnackbar();
|
||||
|
||||
const {
|
||||
mutateAsync: updateRoleMappings,
|
||||
isPending: isUpdateRoleMappingsPending,
|
||||
isSuccess: isUpdateRoleMappingsSuccess,
|
||||
} = useAdminUpdateSamlAuthProviderRoleMappings(provider?.id);
|
||||
|
||||
const { data, isLoading: isAdminSamlAuthProviderRoleMappingsLoading } =
|
||||
@@ -75,9 +75,12 @@ function RoleMappings({ provider, providerLoading }) {
|
||||
adminSamlAuthProviderId: provider?.id,
|
||||
});
|
||||
const roleMappings = data?.data;
|
||||
const fieldNames = ['remoteRoleName', 'roleId'];
|
||||
const [fieldErrors, setFieldErrors] = useState(null);
|
||||
|
||||
const handleRoleMappingsUpdate = async (values) => {
|
||||
try {
|
||||
setFieldErrors(null);
|
||||
if (provider?.id) {
|
||||
await updateRoleMappings(
|
||||
values.roleMappings.map(({ roleId, remoteRoleName }) => ({
|
||||
@@ -85,29 +88,20 @@ function RoleMappings({ provider, providerLoading }) {
|
||||
remoteRoleName,
|
||||
})),
|
||||
);
|
||||
|
||||
enqueueSnackbar(formatMessage('roleMappingsForm.successfullySaved'), {
|
||||
variant: 'success',
|
||||
SnackbarProps: {
|
||||
'data-test': 'snackbar-update-role-mappings-success',
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errors = Object.values(
|
||||
error.response.data.errors || [['Failed while saving!']],
|
||||
);
|
||||
|
||||
for (const [error] of errors) {
|
||||
enqueueSnackbar(error, {
|
||||
variant: 'error',
|
||||
SnackbarProps: {
|
||||
'data-test': 'snackbar-update-role-mappings-error',
|
||||
},
|
||||
const errors = error?.response?.data?.errors;
|
||||
if (errors) {
|
||||
Object.entries(errors).forEach(([fieldName, fieldErrors]) => {
|
||||
if (fieldNames.includes(fieldName) && Array.isArray(fieldErrors)) {
|
||||
setFieldErrors((prevErrors) => [
|
||||
...(prevErrors || []),
|
||||
`${fieldName}: ${fieldErrors.join(', ')}`,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error('Failed while saving!');
|
||||
throw errors || error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -118,6 +112,25 @@ function RoleMappings({ provider, providerLoading }) {
|
||||
[roleMappings],
|
||||
);
|
||||
|
||||
const renderErrors = (errors) => {
|
||||
const generalError = errors?.root?.general?.message;
|
||||
if (fieldErrors) {
|
||||
return fieldErrors.map((error, index) => (
|
||||
<Alert key={index} data-test="error-alert" severity="error">
|
||||
{error}
|
||||
</Alert>
|
||||
));
|
||||
}
|
||||
|
||||
if (generalError) {
|
||||
return (
|
||||
<Alert data-test="error-alert" severity="error">
|
||||
{generalError}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (
|
||||
providerLoading ||
|
||||
!provider?.id ||
|
||||
@@ -140,27 +153,35 @@ function RoleMappings({ provider, providerLoading }) {
|
||||
reValidateMode="onChange"
|
||||
noValidate
|
||||
automaticValidation={false}
|
||||
>
|
||||
<Stack direction="column" spacing={2}>
|
||||
<RoleMappingsFieldArray />
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ boxShadow: 2 }}
|
||||
loading={isUpdateRoleMappingsPending}
|
||||
>
|
||||
{formatMessage('roleMappingsForm.save')}
|
||||
</LoadingButton>
|
||||
</Stack>
|
||||
</Form>
|
||||
render={({ formState: { errors, isDirty } }) => (
|
||||
<Stack direction="column" spacing={2}>
|
||||
<RoleMappingsFieldArray />
|
||||
{renderErrors(errors)}
|
||||
{isUpdateRoleMappingsSuccess && !isDirty && (
|
||||
<Alert data-test="success-alert" severity="success">
|
||||
{formatMessage('roleMappingsForm.successfullySaved')}
|
||||
</Alert>
|
||||
)}
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ boxShadow: 2 }}
|
||||
loading={isUpdateRoleMappingsPending}
|
||||
disabled={!isDirty}
|
||||
>
|
||||
{formatMessage('roleMappingsForm.save')}
|
||||
</LoadingButton>
|
||||
</Stack>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
RoleMappings.propTypes = {
|
||||
provider: PropTypes.shape({
|
||||
id: PropTypes.oneOf([PropTypes.number, PropTypes.string]).isRequired,
|
||||
id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
|
||||
}),
|
||||
providerLoading: PropTypes.bool,
|
||||
};
|
||||
|
||||
@@ -2,9 +2,11 @@ import PropTypes from 'prop-types';
|
||||
import LoadingButton from '@mui/lab/LoadingButton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import MuiTextField from '@mui/material/TextField';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import * as React from 'react';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as yup from 'yup';
|
||||
|
||||
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
|
||||
import ControlledAutocomplete from 'components/ControlledAutocomplete';
|
||||
import Form from 'components/Form';
|
||||
import Switch from 'components/Switch';
|
||||
@@ -28,29 +30,94 @@ const defaultValues = {
|
||||
defaultRoleId: '',
|
||||
};
|
||||
|
||||
const getValidationSchema = (formatMessage) => {
|
||||
const getMandatoryFieldMessage = (fieldTranslationId) =>
|
||||
formatMessage('authenticationForm.mandatoryInput', {
|
||||
inputName: formatMessage(fieldTranslationId),
|
||||
});
|
||||
|
||||
return yup.object().shape({
|
||||
active: yup.boolean(),
|
||||
name: yup
|
||||
.string()
|
||||
.trim()
|
||||
.required(getMandatoryFieldMessage('authenticationForm.name')),
|
||||
certificate: yup
|
||||
.string()
|
||||
.trim()
|
||||
.required(getMandatoryFieldMessage('authenticationForm.certificate')),
|
||||
signatureAlgorithm: yup
|
||||
.string()
|
||||
.trim()
|
||||
.required(
|
||||
getMandatoryFieldMessage('authenticationForm.signatureAlgorithm'),
|
||||
),
|
||||
issuer: yup
|
||||
.string()
|
||||
.trim()
|
||||
.required(getMandatoryFieldMessage('authenticationForm.issuer')),
|
||||
entryPoint: yup
|
||||
.string()
|
||||
.trim()
|
||||
.required(getMandatoryFieldMessage('authenticationForm.entryPoint')),
|
||||
firstnameAttributeName: yup
|
||||
.string()
|
||||
.trim()
|
||||
.required(
|
||||
getMandatoryFieldMessage('authenticationForm.firstnameAttributeName'),
|
||||
),
|
||||
surnameAttributeName: yup
|
||||
.string()
|
||||
.trim()
|
||||
.required(
|
||||
getMandatoryFieldMessage('authenticationForm.surnameAttributeName'),
|
||||
),
|
||||
emailAttributeName: yup
|
||||
.string()
|
||||
.trim()
|
||||
.required(
|
||||
getMandatoryFieldMessage('authenticationForm.emailAttributeName'),
|
||||
),
|
||||
roleAttributeName: yup
|
||||
.string()
|
||||
.trim()
|
||||
.required(
|
||||
getMandatoryFieldMessage('authenticationForm.roleAttributeName'),
|
||||
),
|
||||
defaultRoleId: yup
|
||||
.string()
|
||||
.trim()
|
||||
.required(getMandatoryFieldMessage('authenticationForm.defaultRole')),
|
||||
});
|
||||
};
|
||||
|
||||
function generateRoleOptions(roles) {
|
||||
return roles?.map(({ name: label, id: value }) => ({ label, value }));
|
||||
}
|
||||
|
||||
function SamlConfiguration({ provider, providerLoading }) {
|
||||
const formatMessage = useFormatMessage();
|
||||
const { data, loading: isRolesLoading } = useRoles();
|
||||
const { data, isLoading: isRolesLoading } = useRoles();
|
||||
const roles = data?.data;
|
||||
const enqueueSnackbar = useEnqueueSnackbar();
|
||||
|
||||
const {
|
||||
mutateAsync: createSamlAuthProvider,
|
||||
isPending: isCreateSamlAuthProviderPending,
|
||||
isSuccess: isCreateSamlAuthProviderSuccess,
|
||||
} = useAdminCreateSamlAuthProvider();
|
||||
|
||||
const {
|
||||
mutateAsync: updateSamlAuthProvider,
|
||||
isPending: isUpdateSamlAuthProviderPending,
|
||||
isSuccess: isUpdateSamlAuthProviderSuccess,
|
||||
} = useAdminUpdateSamlAuthProvider(provider?.id);
|
||||
|
||||
const isPending =
|
||||
isCreateSamlAuthProviderPending || isUpdateSamlAuthProviderPending;
|
||||
|
||||
const isSuccess =
|
||||
isCreateSamlAuthProviderSuccess || isUpdateSamlAuthProviderSuccess;
|
||||
|
||||
const handleSubmit = async (providerData) => {
|
||||
try {
|
||||
if (provider?.id) {
|
||||
@@ -58,15 +125,9 @@ function SamlConfiguration({ provider, providerLoading }) {
|
||||
} else {
|
||||
await createSamlAuthProvider(providerData);
|
||||
}
|
||||
|
||||
enqueueSnackbar(formatMessage('authenticationForm.successfullySaved'), {
|
||||
variant: 'success',
|
||||
SnackbarProps: {
|
||||
'data-test': 'snackbar-save-saml-provider-success',
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
throw new Error('Failed while saving!');
|
||||
} catch (error) {
|
||||
const errors = error?.response?.data?.errors;
|
||||
throw errors || error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -75,103 +136,145 @@ function SamlConfiguration({ provider, providerLoading }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Form defaultValues={provider || defaultValues} onSubmit={handleSubmit}>
|
||||
<Stack direction="column" gap={2}>
|
||||
<Switch
|
||||
name="active"
|
||||
label={formatMessage('authenticationForm.active')}
|
||||
/>
|
||||
<TextField
|
||||
required={true}
|
||||
name="name"
|
||||
label={formatMessage('authenticationForm.name')}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
required={true}
|
||||
name="certificate"
|
||||
label={formatMessage('authenticationForm.certificate')}
|
||||
fullWidth
|
||||
multiline
|
||||
/>
|
||||
<ControlledAutocomplete
|
||||
name="signatureAlgorithm"
|
||||
fullWidth
|
||||
disablePortal
|
||||
disableClearable={true}
|
||||
options={[
|
||||
{ label: 'SHA1', value: 'sha1' },
|
||||
{ label: 'SHA256', value: 'sha256' },
|
||||
{ label: 'SHA512', value: 'sha512' },
|
||||
]}
|
||||
renderInput={(params) => (
|
||||
<MuiTextField
|
||||
{...params}
|
||||
label={formatMessage('authenticationForm.signatureAlgorithm')}
|
||||
/>
|
||||
<Form
|
||||
defaultValues={provider || defaultValues}
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
resolver={yupResolver(getValidationSchema(formatMessage))}
|
||||
automaticValidation={false}
|
||||
render={({ formState: { errors, isDirty } }) => (
|
||||
<Stack direction="column" gap={2}>
|
||||
<Switch
|
||||
name="active"
|
||||
label={formatMessage('authenticationForm.active')}
|
||||
/>
|
||||
<TextField
|
||||
required={true}
|
||||
name="name"
|
||||
label={formatMessage('authenticationForm.name')}
|
||||
fullWidth
|
||||
error={!!errors?.name}
|
||||
helperText={errors?.name?.message}
|
||||
/>
|
||||
<TextField
|
||||
required={true}
|
||||
name="certificate"
|
||||
label={formatMessage('authenticationForm.certificate')}
|
||||
fullWidth
|
||||
multiline
|
||||
error={!!errors?.certificate}
|
||||
helperText={errors?.certificate?.message}
|
||||
/>
|
||||
<ControlledAutocomplete
|
||||
name="signatureAlgorithm"
|
||||
fullWidth
|
||||
disablePortal
|
||||
disableClearable={true}
|
||||
options={[
|
||||
{ label: 'SHA1', value: 'sha1' },
|
||||
{ label: 'SHA256', value: 'sha256' },
|
||||
{ label: 'SHA512', value: 'sha512' },
|
||||
]}
|
||||
showHelperText={false}
|
||||
renderInput={(params) => (
|
||||
<MuiTextField
|
||||
{...params}
|
||||
label={formatMessage('authenticationForm.signatureAlgorithm')}
|
||||
required
|
||||
error={!!errors?.signatureAlgorithm}
|
||||
helperText={errors?.signatureAlgorithm?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<TextField
|
||||
required={true}
|
||||
name="issuer"
|
||||
label={formatMessage('authenticationForm.issuer')}
|
||||
fullWidth
|
||||
error={!!errors?.issuer}
|
||||
helperText={errors?.issuer?.message}
|
||||
/>
|
||||
<TextField
|
||||
required={true}
|
||||
name="entryPoint"
|
||||
label={formatMessage('authenticationForm.entryPoint')}
|
||||
fullWidth
|
||||
error={!!errors?.entryPoint}
|
||||
helperText={errors?.entryPoint?.message}
|
||||
/>
|
||||
<TextField
|
||||
required={true}
|
||||
name="firstnameAttributeName"
|
||||
label={formatMessage('authenticationForm.firstnameAttributeName')}
|
||||
fullWidth
|
||||
error={!!errors?.firstnameAttributeName}
|
||||
helperText={errors?.firstnameAttributeName?.message}
|
||||
/>
|
||||
<TextField
|
||||
required={true}
|
||||
name="surnameAttributeName"
|
||||
label={formatMessage('authenticationForm.surnameAttributeName')}
|
||||
fullWidth
|
||||
error={!!errors?.surnameAttributeName}
|
||||
helperText={errors?.surnameAttributeName?.message}
|
||||
/>
|
||||
<TextField
|
||||
required={true}
|
||||
name="emailAttributeName"
|
||||
label={formatMessage('authenticationForm.emailAttributeName')}
|
||||
fullWidth
|
||||
error={!!errors?.emailAttributeName}
|
||||
helperText={errors?.emailAttributeName?.message}
|
||||
/>
|
||||
<TextField
|
||||
required={true}
|
||||
name="roleAttributeName"
|
||||
label={formatMessage('authenticationForm.roleAttributeName')}
|
||||
fullWidth
|
||||
error={!!errors?.roleAttributeName}
|
||||
helperText={errors?.roleAttributeName?.message}
|
||||
/>
|
||||
<ControlledAutocomplete
|
||||
name="defaultRoleId"
|
||||
fullWidth
|
||||
disablePortal
|
||||
disableClearable={true}
|
||||
options={generateRoleOptions(roles)}
|
||||
showHelperText={false}
|
||||
renderInput={(params) => (
|
||||
<MuiTextField
|
||||
{...params}
|
||||
label={formatMessage('authenticationForm.defaultRole')}
|
||||
required
|
||||
error={!!errors?.defaultRoleId}
|
||||
helperText={errors?.defaultRoleId?.message}
|
||||
/>
|
||||
)}
|
||||
loading={isRolesLoading}
|
||||
/>
|
||||
{errors?.root?.general && (
|
||||
<Alert data-test="error-alert" severity="error">
|
||||
{errors.root.general.message}
|
||||
</Alert>
|
||||
)}
|
||||
/>
|
||||
<TextField
|
||||
required={true}
|
||||
name="issuer"
|
||||
label={formatMessage('authenticationForm.issuer')}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
required={true}
|
||||
name="entryPoint"
|
||||
label={formatMessage('authenticationForm.entryPoint')}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
required={true}
|
||||
name="firstnameAttributeName"
|
||||
label={formatMessage('authenticationForm.firstnameAttributeName')}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
required={true}
|
||||
name="surnameAttributeName"
|
||||
label={formatMessage('authenticationForm.surnameAttributeName')}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
required={true}
|
||||
name="emailAttributeName"
|
||||
label={formatMessage('authenticationForm.emailAttributeName')}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
required={true}
|
||||
name="roleAttributeName"
|
||||
label={formatMessage('authenticationForm.roleAttributeName')}
|
||||
fullWidth
|
||||
/>
|
||||
<ControlledAutocomplete
|
||||
name="defaultRoleId"
|
||||
fullWidth
|
||||
disablePortal
|
||||
disableClearable={true}
|
||||
options={generateRoleOptions(roles)}
|
||||
renderInput={(params) => (
|
||||
<MuiTextField
|
||||
{...params}
|
||||
label={formatMessage('authenticationForm.defaultRole')}
|
||||
/>
|
||||
{isSuccess && !isDirty && (
|
||||
<Alert data-test="success-alert" severity="success">
|
||||
{formatMessage('authenticationForm.successfullySaved')}
|
||||
</Alert>
|
||||
)}
|
||||
loading={isRolesLoading}
|
||||
/>
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ boxShadow: 2 }}
|
||||
loading={isPending}
|
||||
>
|
||||
{formatMessage('authenticationForm.save')}
|
||||
</LoadingButton>
|
||||
</Stack>
|
||||
</Form>
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ boxShadow: 2 }}
|
||||
loading={isPending}
|
||||
disabled={!isDirty}
|
||||
>
|
||||
{formatMessage('authenticationForm.save')}
|
||||
</LoadingButton>
|
||||
</Stack>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import LoadingButton from '@mui/lab/LoadingButton';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import AlertTitle from '@mui/material/AlertTitle';
|
||||
import PermissionCatalogField from 'components/PermissionCatalogField/index.ee';
|
||||
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
|
||||
import * as React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as yup from 'yup';
|
||||
|
||||
import Container from 'components/Container';
|
||||
import Form from 'components/Form';
|
||||
@@ -19,6 +23,40 @@ import useFormatMessage from 'hooks/useFormatMessage';
|
||||
import useAdminCreateRole from 'hooks/useAdminCreateRole';
|
||||
import usePermissionCatalog from 'hooks/usePermissionCatalog.ee';
|
||||
|
||||
const getValidationSchema = (formatMessage) => {
|
||||
const getMandatoryFieldMessage = (fieldTranslationId) =>
|
||||
formatMessage('roleForm.mandatoryInput', {
|
||||
inputName: formatMessage(fieldTranslationId),
|
||||
});
|
||||
|
||||
return yup.object().shape({
|
||||
name: yup
|
||||
.string()
|
||||
.trim()
|
||||
.required(getMandatoryFieldMessage('roleForm.name')),
|
||||
description: yup.string().trim(),
|
||||
});
|
||||
};
|
||||
|
||||
const getPermissionsErrorMessage = (error) => {
|
||||
const errors = error?.response?.data?.errors;
|
||||
|
||||
if (errors) {
|
||||
const permissionsErrors = Object.keys(errors)
|
||||
.filter((key) => key.startsWith('permissions'))
|
||||
.reduce((obj, key) => {
|
||||
obj[key] = errors[key];
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
if (Object.keys(permissionsErrors).length > 0) {
|
||||
return JSON.stringify(permissionsErrors, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default function CreateRole() {
|
||||
const navigate = useNavigate();
|
||||
const formatMessage = useFormatMessage();
|
||||
@@ -27,6 +65,7 @@ export default function CreateRole() {
|
||||
useAdminCreateRole();
|
||||
const { data: permissionCatalogData, isLoading: isPermissionCatalogLoading } =
|
||||
usePermissionCatalog();
|
||||
const [permissionError, setPermissionError] = React.useState(null);
|
||||
|
||||
const defaultValues = React.useMemo(
|
||||
() => ({
|
||||
@@ -44,6 +83,7 @@ export default function CreateRole() {
|
||||
|
||||
const handleRoleCreation = async (roleData) => {
|
||||
try {
|
||||
setPermissionError(null);
|
||||
const permissions = getPermissions(roleData.computedPermissions);
|
||||
|
||||
await createRole({
|
||||
@@ -61,16 +101,13 @@ export default function CreateRole() {
|
||||
|
||||
navigate(URLS.ROLES);
|
||||
} catch (error) {
|
||||
const errors = Object.values(error.response.data.errors);
|
||||
|
||||
for (const [errorMessage] of errors) {
|
||||
enqueueSnackbar(errorMessage, {
|
||||
variant: 'error',
|
||||
SnackbarProps: {
|
||||
'data-test': 'snackbar-error',
|
||||
},
|
||||
});
|
||||
const permissionError = getPermissionsErrorMessage(error);
|
||||
if (permissionError) {
|
||||
setPermissionError(permissionError);
|
||||
}
|
||||
|
||||
const errors = error?.response?.data?.errors;
|
||||
throw errors || error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -84,39 +121,67 @@ export default function CreateRole() {
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} justifyContent="flex-end" sx={{ pt: 5 }}>
|
||||
<Form onSubmit={handleRoleCreation} defaultValues={defaultValues}>
|
||||
<Stack direction="column" gap={2}>
|
||||
<TextField
|
||||
required={true}
|
||||
name="name"
|
||||
label={formatMessage('roleForm.name')}
|
||||
fullWidth
|
||||
data-test="name-input"
|
||||
disabled={isPermissionCatalogLoading}
|
||||
/>
|
||||
<Form
|
||||
onSubmit={handleRoleCreation}
|
||||
defaultValues={defaultValues}
|
||||
noValidate
|
||||
resolver={yupResolver(getValidationSchema(formatMessage))}
|
||||
automaticValidation={false}
|
||||
render={({ formState: { errors } }) => (
|
||||
<Stack direction="column" gap={2}>
|
||||
<TextField
|
||||
required={true}
|
||||
name="name"
|
||||
label={formatMessage('roleForm.name')}
|
||||
fullWidth
|
||||
data-test="name-input"
|
||||
error={!!errors?.name}
|
||||
helperText={errors?.name?.message}
|
||||
disabled={isPermissionCatalogLoading}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
name="description"
|
||||
label={formatMessage('roleForm.description')}
|
||||
fullWidth
|
||||
data-test="description-input"
|
||||
disabled={isPermissionCatalogLoading}
|
||||
/>
|
||||
<TextField
|
||||
name="description"
|
||||
label={formatMessage('roleForm.description')}
|
||||
fullWidth
|
||||
data-test="description-input"
|
||||
error={!!errors?.description}
|
||||
helperText={errors?.description?.message}
|
||||
disabled={isPermissionCatalogLoading}
|
||||
/>
|
||||
|
||||
<PermissionCatalogField name="computedPermissions" />
|
||||
<PermissionCatalogField name="computedPermissions" />
|
||||
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ boxShadow: 2 }}
|
||||
loading={isCreateRolePending}
|
||||
data-test="create-button"
|
||||
>
|
||||
{formatMessage('createRole.submit')}
|
||||
</LoadingButton>
|
||||
</Stack>
|
||||
</Form>
|
||||
{permissionError && (
|
||||
<Alert severity="error" data-test="create-role-error-alert">
|
||||
<AlertTitle>
|
||||
{formatMessage('createRole.permissionsError')}
|
||||
</AlertTitle>
|
||||
<pre>
|
||||
<code>{permissionError}</code>
|
||||
</pre>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{errors?.root?.general && !permissionError && (
|
||||
<Alert severity="error" data-test="create-role-error-alert">
|
||||
{errors?.root?.general?.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ boxShadow: 2 }}
|
||||
loading={isCreateRolePending}
|
||||
data-test="create-button"
|
||||
>
|
||||
{formatMessage('createRole.submit')}
|
||||
</LoadingButton>
|
||||
</Stack>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
|
||||
@@ -3,9 +3,10 @@ import Grid from '@mui/material/Grid';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import MuiTextField from '@mui/material/TextField';
|
||||
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
|
||||
import * as React from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import * as yup from 'yup';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
|
||||
import Can from 'components/Can';
|
||||
import Container from 'components/Container';
|
||||
@@ -16,50 +17,70 @@ import TextField from 'components/TextField';
|
||||
import useFormatMessage from 'hooks/useFormatMessage';
|
||||
import useRoles from 'hooks/useRoles.ee';
|
||||
import useAdminCreateUser from 'hooks/useAdminCreateUser';
|
||||
import useCurrentUserAbility from 'hooks/useCurrentUserAbility';
|
||||
|
||||
function generateRoleOptions(roles) {
|
||||
return roles?.map(({ name: label, id: value }) => ({ label, value }));
|
||||
}
|
||||
|
||||
const getValidationSchema = (formatMessage, canUpdateRole) => {
|
||||
const getMandatoryFieldMessage = (fieldTranslationId) =>
|
||||
formatMessage('userForm.mandatoryInput', {
|
||||
inputName: formatMessage(fieldTranslationId),
|
||||
});
|
||||
|
||||
return yup.object().shape({
|
||||
fullName: yup
|
||||
.string()
|
||||
.trim()
|
||||
.required(getMandatoryFieldMessage('userForm.fullName')),
|
||||
email: yup
|
||||
.string()
|
||||
.trim()
|
||||
.email(formatMessage('userForm.validateEmail'))
|
||||
.required(getMandatoryFieldMessage('userForm.email')),
|
||||
...(canUpdateRole
|
||||
? {
|
||||
roleId: yup
|
||||
.string()
|
||||
.required(getMandatoryFieldMessage('userForm.role')),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
};
|
||||
|
||||
const defaultValues = {
|
||||
fullName: '',
|
||||
email: '',
|
||||
roleId: '',
|
||||
};
|
||||
|
||||
export default function CreateUser() {
|
||||
const formatMessage = useFormatMessage();
|
||||
const {
|
||||
mutateAsync: createUser,
|
||||
isPending: isCreateUserPending,
|
||||
data: createdUser,
|
||||
isSuccess: createUserSuccess,
|
||||
} = useAdminCreateUser();
|
||||
const { data: rolesData, loading: isRolesLoading } = useRoles();
|
||||
const roles = rolesData?.data;
|
||||
const enqueueSnackbar = useEnqueueSnackbar();
|
||||
const queryClient = useQueryClient();
|
||||
const currentUserAbility = useCurrentUserAbility();
|
||||
const canUpdateRole = currentUserAbility.can('update', 'Role');
|
||||
|
||||
const handleUserCreation = async (userData) => {
|
||||
try {
|
||||
await createUser({
|
||||
fullName: userData.fullName,
|
||||
email: userData.email,
|
||||
roleId: userData.role?.id,
|
||||
roleId: userData.roleId,
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] });
|
||||
|
||||
enqueueSnackbar(formatMessage('createUser.successfullyCreated'), {
|
||||
variant: 'success',
|
||||
persist: true,
|
||||
SnackbarProps: {
|
||||
'data-test': 'snackbar-create-user-success',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
enqueueSnackbar(formatMessage('createUser.error'), {
|
||||
variant: 'error',
|
||||
persist: true,
|
||||
SnackbarProps: {
|
||||
'data-test': 'snackbar-error',
|
||||
},
|
||||
});
|
||||
|
||||
throw new Error('Failed while creating!');
|
||||
const errors = error?.response?.data?.errors;
|
||||
throw errors || error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -73,74 +94,111 @@ export default function CreateUser() {
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} justifyContent="flex-end" sx={{ pt: 5 }}>
|
||||
<Form onSubmit={handleUserCreation}>
|
||||
<Stack direction="column" gap={2}>
|
||||
<TextField
|
||||
required={true}
|
||||
name="fullName"
|
||||
label={formatMessage('userForm.fullName')}
|
||||
data-test="full-name-input"
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<TextField
|
||||
required={true}
|
||||
name="email"
|
||||
label={formatMessage('userForm.email')}
|
||||
data-test="email-input"
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<Can I="update" a="Role">
|
||||
<ControlledAutocomplete
|
||||
name="role.id"
|
||||
<Form
|
||||
noValidate
|
||||
onSubmit={handleUserCreation}
|
||||
mode="onSubmit"
|
||||
defaultValues={defaultValues}
|
||||
resolver={yupResolver(
|
||||
getValidationSchema(formatMessage, canUpdateRole),
|
||||
)}
|
||||
automaticValidation={false}
|
||||
render={({ formState: { errors } }) => (
|
||||
<Stack direction="column" gap={2}>
|
||||
<TextField
|
||||
required={true}
|
||||
name="fullName"
|
||||
label={formatMessage('userForm.fullName')}
|
||||
data-test="full-name-input"
|
||||
fullWidth
|
||||
disablePortal
|
||||
disableClearable={true}
|
||||
options={generateRoleOptions(roles)}
|
||||
renderInput={(params) => (
|
||||
<MuiTextField
|
||||
{...params}
|
||||
required
|
||||
label={formatMessage('userForm.role')}
|
||||
/>
|
||||
)}
|
||||
loading={isRolesLoading}
|
||||
error={!!errors?.fullName}
|
||||
helperText={errors?.fullName?.message}
|
||||
/>
|
||||
</Can>
|
||||
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ boxShadow: 2 }}
|
||||
loading={isCreateUserPending}
|
||||
data-test="create-button"
|
||||
>
|
||||
{formatMessage('createUser.submit')}
|
||||
</LoadingButton>
|
||||
<TextField
|
||||
required={true}
|
||||
name="email"
|
||||
label={formatMessage('userForm.email')}
|
||||
data-test="email-input"
|
||||
fullWidth
|
||||
error={!!errors?.email}
|
||||
helperText={errors?.email?.message}
|
||||
/>
|
||||
|
||||
{createdUser && (
|
||||
<Alert
|
||||
severity="info"
|
||||
<Can I="update" a="Role">
|
||||
<ControlledAutocomplete
|
||||
name="roleId"
|
||||
fullWidth
|
||||
disablePortal
|
||||
disableClearable={true}
|
||||
options={generateRoleOptions(roles)}
|
||||
renderInput={(params) => (
|
||||
<MuiTextField
|
||||
{...params}
|
||||
required
|
||||
label={formatMessage('userForm.role')}
|
||||
error={!!errors?.roleId}
|
||||
helperText={errors?.roleId?.message}
|
||||
/>
|
||||
)}
|
||||
loading={isRolesLoading}
|
||||
showHelperText={false}
|
||||
/>
|
||||
</Can>
|
||||
|
||||
{errors?.root?.general && (
|
||||
<Alert data-test="create-user-error-alert" severity="error">
|
||||
{errors?.root?.general?.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{createUserSuccess && (
|
||||
<Alert
|
||||
severity="success"
|
||||
data-test="create-user-success-alert"
|
||||
>
|
||||
{formatMessage('createUser.successfullyCreated')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{createdUser && (
|
||||
<Alert
|
||||
severity="info"
|
||||
color="primary"
|
||||
data-test="invitation-email-info-alert"
|
||||
sx={{
|
||||
a: {
|
||||
wordBreak: 'break-all',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{formatMessage('createUser.invitationEmailInfo', {
|
||||
link: () => (
|
||||
<a
|
||||
href={createdUser.data.acceptInvitationUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{createdUser.data.acceptInvitationUrl}
|
||||
</a>
|
||||
),
|
||||
})}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
data-test="invitation-email-info-alert"
|
||||
sx={{ boxShadow: 2 }}
|
||||
loading={isCreateUserPending}
|
||||
data-test="create-button"
|
||||
>
|
||||
{formatMessage('createUser.invitationEmailInfo', {
|
||||
link: () => (
|
||||
<a
|
||||
href={createdUser.data.acceptInvitationUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{createdUser.data.acceptInvitationUrl}
|
||||
</a>
|
||||
),
|
||||
})}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Form>
|
||||
{formatMessage('createUser.submit')}
|
||||
</LoadingButton>
|
||||
</Stack>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
|
||||
@@ -5,9 +5,12 @@ import Stack from '@mui/material/Stack';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import MuiTextField from '@mui/material/TextField';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
|
||||
import * as React from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as yup from 'yup';
|
||||
|
||||
import Can from 'components/Can';
|
||||
import Container from 'components/Container';
|
||||
@@ -20,11 +23,44 @@ import useFormatMessage from 'hooks/useFormatMessage';
|
||||
import useRoles from 'hooks/useRoles.ee';
|
||||
import useAdminUpdateUser from 'hooks/useAdminUpdateUser';
|
||||
import useAdminUser from 'hooks/useAdminUser';
|
||||
import useCurrentUserAbility from 'hooks/useCurrentUserAbility';
|
||||
|
||||
function generateRoleOptions(roles) {
|
||||
return roles?.map(({ name: label, id: value }) => ({ label, value }));
|
||||
}
|
||||
|
||||
const getValidationSchema = (formatMessage, canUpdateRole) => {
|
||||
const getMandatoryFieldMessage = (fieldTranslationId) =>
|
||||
formatMessage('userForm.mandatoryInput', {
|
||||
inputName: formatMessage(fieldTranslationId),
|
||||
});
|
||||
|
||||
return yup.object().shape({
|
||||
fullName: yup
|
||||
.string()
|
||||
.trim()
|
||||
.required(getMandatoryFieldMessage('userForm.fullName')),
|
||||
email: yup
|
||||
.string()
|
||||
.trim()
|
||||
.email(formatMessage('userForm.validateEmail'))
|
||||
.required(getMandatoryFieldMessage('userForm.email')),
|
||||
...(canUpdateRole
|
||||
? {
|
||||
roleId: yup
|
||||
.string()
|
||||
.required(getMandatoryFieldMessage('userForm.role')),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
};
|
||||
|
||||
const defaultValues = {
|
||||
fullName: '',
|
||||
email: '',
|
||||
roleId: '',
|
||||
};
|
||||
|
||||
export default function EditUser() {
|
||||
const formatMessage = useFormatMessage();
|
||||
const { userId } = useParams();
|
||||
@@ -36,13 +72,15 @@ export default function EditUser() {
|
||||
const roles = data?.data;
|
||||
const enqueueSnackbar = useEnqueueSnackbar();
|
||||
const navigate = useNavigate();
|
||||
const currentUserAbility = useCurrentUserAbility();
|
||||
const canUpdateRole = currentUserAbility.can('update', 'Role');
|
||||
|
||||
const handleUserUpdate = async (userDataToUpdate) => {
|
||||
try {
|
||||
await updateUser({
|
||||
fullName: userDataToUpdate.fullName,
|
||||
email: userDataToUpdate.email,
|
||||
roleId: userDataToUpdate.role?.id,
|
||||
roleId: userDataToUpdate.roleId,
|
||||
});
|
||||
|
||||
enqueueSnackbar(formatMessage('editUser.successfullyUpdated'), {
|
||||
@@ -55,7 +93,9 @@ export default function EditUser() {
|
||||
|
||||
navigate(URLS.USERS);
|
||||
} catch (error) {
|
||||
throw new Error('Failed while updating!');
|
||||
const errors = error?.response?.data?.errors;
|
||||
|
||||
throw errors || error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -80,65 +120,94 @@ export default function EditUser() {
|
||||
)}
|
||||
|
||||
{!isUserLoading && (
|
||||
<Form defaultValues={user} onSubmit={handleUserUpdate}>
|
||||
<Stack direction="column" gap={2}>
|
||||
<Stack direction="row" gap={2} mb={2} alignItems="center">
|
||||
<Typography variant="h6" noWrap>
|
||||
{formatMessage('editUser.status')}
|
||||
</Typography>
|
||||
<Form
|
||||
defaultValues={
|
||||
user
|
||||
? {
|
||||
fullName: user.fullName,
|
||||
email: user.email,
|
||||
roleId: user.role.id,
|
||||
}
|
||||
: defaultValues
|
||||
}
|
||||
onSubmit={handleUserUpdate}
|
||||
resolver={yupResolver(
|
||||
getValidationSchema(formatMessage, canUpdateRole),
|
||||
)}
|
||||
noValidate
|
||||
render={({ formState: { errors } }) => (
|
||||
<Stack direction="column" gap={2}>
|
||||
<Stack direction="row" gap={2} mb={2} alignItems="center">
|
||||
<Typography variant="h6" noWrap>
|
||||
{formatMessage('editUser.status')}
|
||||
</Typography>
|
||||
|
||||
<Chip
|
||||
label={user.status}
|
||||
variant="outlined"
|
||||
color={user.status === 'active' ? 'success' : 'warning'}
|
||||
/>
|
||||
</Stack>
|
||||
<Chip
|
||||
label={user.status}
|
||||
variant="outlined"
|
||||
color={user.status === 'active' ? 'success' : 'warning'}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<TextField
|
||||
required={true}
|
||||
name="fullName"
|
||||
label={formatMessage('userForm.fullName')}
|
||||
data-test="full-name-input"
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<TextField
|
||||
required={true}
|
||||
name="email"
|
||||
label={formatMessage('userForm.email')}
|
||||
data-test="email-input"
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<Can I="update" a="Role">
|
||||
<ControlledAutocomplete
|
||||
name="role.id"
|
||||
<TextField
|
||||
required={true}
|
||||
name="fullName"
|
||||
label={formatMessage('userForm.fullName')}
|
||||
data-test="full-name-input"
|
||||
fullWidth
|
||||
disablePortal
|
||||
disableClearable={true}
|
||||
options={generateRoleOptions(roles)}
|
||||
renderInput={(params) => (
|
||||
<MuiTextField
|
||||
{...params}
|
||||
label={formatMessage('userForm.role')}
|
||||
/>
|
||||
)}
|
||||
loading={isRolesLoading}
|
||||
error={!!errors?.fullName}
|
||||
helperText={errors?.fullName?.message}
|
||||
/>
|
||||
</Can>
|
||||
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ boxShadow: 2 }}
|
||||
loading={isAdminUpdateUserPending}
|
||||
data-test="update-button"
|
||||
>
|
||||
{formatMessage('editUser.submit')}
|
||||
</LoadingButton>
|
||||
</Stack>
|
||||
</Form>
|
||||
<TextField
|
||||
required={true}
|
||||
name="email"
|
||||
label={formatMessage('userForm.email')}
|
||||
data-test="email-input"
|
||||
fullWidth
|
||||
error={!!errors?.email}
|
||||
helperText={errors?.email?.message}
|
||||
/>
|
||||
|
||||
<Can I="update" a="Role">
|
||||
<ControlledAutocomplete
|
||||
name="roleId"
|
||||
fullWidth
|
||||
disablePortal
|
||||
disableClearable={true}
|
||||
options={generateRoleOptions(roles)}
|
||||
renderInput={(params) => (
|
||||
<MuiTextField
|
||||
{...params}
|
||||
label={formatMessage('userForm.role')}
|
||||
error={!!errors?.roleId}
|
||||
helperText={errors?.roleId?.message}
|
||||
/>
|
||||
)}
|
||||
loading={isRolesLoading}
|
||||
showHelperText={false}
|
||||
/>
|
||||
</Can>
|
||||
|
||||
{errors?.root?.general && (
|
||||
<Alert data-test="update-user-error-alert" severity="error">
|
||||
{errors?.root?.general?.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ boxShadow: 2 }}
|
||||
loading={isAdminUpdateUserPending}
|
||||
data-test="update-button"
|
||||
>
|
||||
{formatMessage('editUser.submit')}
|
||||
</LoadingButton>
|
||||
</Stack>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
@@ -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 (
|
||||
<Box sx={{ py: 3 }}>
|
||||
<Container>
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import * as React from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import debounce from 'lodash/debounce';
|
||||
import {
|
||||
Link,
|
||||
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';
|
||||
@@ -16,10 +22,11 @@ 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';
|
||||
import useLazyFlows from 'hooks/useLazyFlows';
|
||||
import useFlows from 'hooks/useFlows';
|
||||
|
||||
export default function Flows() {
|
||||
const formatMessage = useFormatMessage();
|
||||
@@ -27,21 +34,9 @@ export default function Flows() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const page = parseInt(searchParams.get('page') || '', 10) || 1;
|
||||
const flowName = searchParams.get('flowName') || '';
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
const currentUserAbility = useCurrentUserAbility();
|
||||
|
||||
const {
|
||||
data,
|
||||
mutate: fetchFlows,
|
||||
isSuccess,
|
||||
} = useLazyFlows(
|
||||
{ flowName, page },
|
||||
{
|
||||
onSettled: () => {
|
||||
setIsLoading(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
const { data, isSuccess, isLoading } = useFlows({ flowName, page });
|
||||
|
||||
const flows = data?.data || [];
|
||||
const pageInfo = data?.meta;
|
||||
@@ -68,26 +63,9 @@ export default function Flows() {
|
||||
const onDuplicateFlow = () => {
|
||||
if (pageInfo?.currentPage > 1) {
|
||||
navigate(getPathWithSearchParams(1, flowName));
|
||||
} else {
|
||||
fetchFlows();
|
||||
}
|
||||
};
|
||||
|
||||
const fetchData = React.useMemo(
|
||||
() => debounce(fetchFlows, 300),
|
||||
[fetchFlows],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsLoading(true);
|
||||
|
||||
fetchData({ flowName, page });
|
||||
|
||||
return () => {
|
||||
fetchData.cancel();
|
||||
};
|
||||
}, [fetchData, flowName, page]);
|
||||
|
||||
React.useEffect(
|
||||
function redirectToLastPage() {
|
||||
if (navigateToLastPage) {
|
||||
@@ -98,85 +76,119 @@ export default function Flows() {
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ py: 3 }}>
|
||||
<Container>
|
||||
<Grid container sx={{ mb: [0, 3] }} columnSpacing={1.5} rowSpacing={3}>
|
||||
<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>
|
||||
|
||||
<>
|
||||
<Box sx={{ py: 3 }}>
|
||||
<Container>
|
||||
<Grid
|
||||
container
|
||||
item
|
||||
xs="auto"
|
||||
sm="auto"
|
||||
alignItems="center"
|
||||
order={{ xs: 1, sm: 2 }}
|
||||
sx={{ mb: [0, 3] }}
|
||||
columnSpacing={1.5}
|
||||
rowSpacing={3}
|
||||
>
|
||||
<Can I="create" a="Flow" passThrough>
|
||||
{(allowed) => (
|
||||
<ConditionalIconButton
|
||||
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>
|
||||
<Grid container item xs sm alignItems="center" order={{ xs: 0 }}>
|
||||
<PageTitle>{formatMessage('flows.title')}</PageTitle>
|
||||
</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,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{!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}
|
||||
/>
|
||||
)}
|
||||
<Grid item xs={12} sm="auto" order={{ xs: 2, sm: 1 }}>
|
||||
<SearchInput onChange={onSearchChange} defaultValue={flowName} />
|
||||
</Grid>
|
||||
|
||||
<Grid
|
||||
container
|
||||
item
|
||||
display="flex"
|
||||
direction="row"
|
||||
xs="auto"
|
||||
sm="auto"
|
||||
gap={1}
|
||||
alignItems="center"
|
||||
order={{ xs: 1, sm: 2 }}
|
||||
>
|
||||
<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>
|
||||
</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}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,8 +123,6 @@ export const RawTriggerPropType = PropTypes.shape({
|
||||
showWebhookUrl: PropTypes.bool,
|
||||
pollInterval: PropTypes.number,
|
||||
description: PropTypes.string,
|
||||
useSingletonWebhook: PropTypes.bool,
|
||||
singletonWebhookRefValueParameter: PropTypes.string,
|
||||
getInterval: PropTypes.func,
|
||||
run: PropTypes.func,
|
||||
testRun: PropTypes.func,
|
||||
@@ -140,8 +138,6 @@ export const TriggerPropType = PropTypes.shape({
|
||||
showWebhookUrl: PropTypes.bool,
|
||||
pollInterval: PropTypes.number,
|
||||
description: PropTypes.string,
|
||||
useSingletonWebhook: PropTypes.bool,
|
||||
singletonWebhookRefValueParameter: PropTypes.string,
|
||||
getInterval: PropTypes.func,
|
||||
run: PropTypes.func,
|
||||
testRun: PropTypes.func,
|
||||
|
||||
@@ -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() {
|
||||
/>
|
||||
|
||||
<Route
|
||||
path={URLS.FLOWS}
|
||||
path={`${URLS.FLOWS}/*`}
|
||||
element={
|
||||
<Layout>
|
||||
<Flows />
|
||||
@@ -76,15 +78,6 @@ function Routes() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path={URLS.FLOW_PATTERN}
|
||||
element={
|
||||
<Layout>
|
||||
<Flow />
|
||||
</Layout>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path={`${URLS.APPS}/*`}
|
||||
element={
|
||||
@@ -186,6 +179,7 @@ function Routes() {
|
||||
<Route path={URLS.ADMIN_SETTINGS} element={<AdminSettingsLayout />}>
|
||||
{adminSettingsRoutes}
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<NoResultFound />} />
|
||||
</ReactRouterRoutes>
|
||||
);
|
||||
|
||||
@@ -158,6 +158,10 @@ export const defaultTheme = createTheme({
|
||||
fontSize: referenceTheme.typography.pxToRem(16),
|
||||
},
|
||||
},
|
||||
stepApp: {
|
||||
fontSize: referenceTheme.typography.pxToRem(12),
|
||||
color: '#5C5C5C',
|
||||
},
|
||||
},
|
||||
components: {
|
||||
MuiAppBar: {
|
||||
@@ -211,6 +215,23 @@ export const defaultTheme = createTheme({
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiChip: {
|
||||
variants: [
|
||||
{
|
||||
props: { variant: 'stepType' },
|
||||
style: ({ theme }) => ({
|
||||
color: '#001F52',
|
||||
fontSize: theme.typography.pxToRem(12),
|
||||
border: '1px solid',
|
||||
borderColor: alpha(theme.palette.primary.main, 0.3),
|
||||
bgcolor: alpha(
|
||||
theme.palette.primary.main,
|
||||
theme.palette.action.selectedOpacity,
|
||||
),
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
MuiContainer: {
|
||||
defaultProps: {
|
||||
maxWidth: 'xl',
|
||||
@@ -294,6 +315,7 @@ export const defaultTheme = createTheme({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const mationTheme = createTheme(
|
||||
deepmerge(defaultTheme, {
|
||||
palette: {
|
||||
@@ -315,4 +337,5 @@ export const mationTheme = createTheme(
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export default defaultTheme;
|
||||
|
||||
@@ -2126,11 +2126,6 @@
|
||||
resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.10.4.tgz#427d5549943a9c6fce808e39ea64dbe60d4047f1"
|
||||
integrity sha512-WJgX9nzTqknM393q1QJDJmoW28kUfEnybeTfVNcNAPnIx210RXm2DiXiHzfNPJNIUUb1tJnz/l4QGtJ30PgWmA==
|
||||
|
||||
"@simbathesailor/use-what-changed@^2.0.0":
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@simbathesailor/use-what-changed/-/use-what-changed-2.0.0.tgz#7f82d78f92c8588b5fadd702065dde93bd781403"
|
||||
integrity sha512-ulBNrPSvfho9UN6zS2fii3AsdEcp2fMaKeqUZZeCNPaZbB6aXyTUhpEN9atjMAbu/eyK3AY8L4SYJUG62Ekocw==
|
||||
|
||||
"@sinclair/typebox@^0.24.1":
|
||||
version "0.24.51"
|
||||
resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.24.51.tgz#645f33fe4e02defe26f2f5c0410e1c094eac7f5f"
|
||||
@@ -9638,6 +9633,11 @@ slate@^0.94.1:
|
||||
is-plain-object "^5.0.0"
|
||||
tiny-warning "^1.0.3"
|
||||
|
||||
slugify@^1.6.6:
|
||||
version "1.6.6"
|
||||
resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.6.6.tgz#2d4ac0eacb47add6af9e04d3be79319cbcc7924b"
|
||||
integrity sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==
|
||||
|
||||
sockjs@^0.3.24:
|
||||
version "0.3.24"
|
||||
resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.24.tgz#c9bc8995f33a111bea0395ec30aa3206bdb5ccce"
|
||||
|
||||
Reference in New Issue
Block a user