Merge branch 'main' into AUT-1372

This commit is contained in:
Jakub P.
2025-01-25 20:44:30 +01:00
240 changed files with 7212 additions and 1234 deletions

View File

@@ -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();

View File

@@ -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: {

View File

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

View File

@@ -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 = {

View File

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

View File

@@ -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,
]),
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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`]:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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. */}

View File

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

View File

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

View 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;

View File

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

View File

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

View File

@@ -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"