/** @module GenericForm */
import React, { useState } from 'react';
import PropTypes from 'prop-types';
// Third party components
import { Card, CardContent, Container, Grid, Stack, Typography } from '@mui/material';
// Shared components
import LineLoader from './LineLoader';
import SubHeader from './SubHeader';
import Button from './Button';
import LoadingSpinner from './LoadingSpinner';
import ContainerWithCard from './ContainerWithCard';
import AnyField from './AnyField';
// Custom hooks
import { useDynamicForm } from '../hooks';
import { useNavigate } from 'react-router-dom';
import { useSnackbar } from 'notistack';
import axios from 'axios';
/**
* The generic form component
* @function GenericForm
* @param {object} props
* @param {string} props.formTitle - The title of the form
* @param {string} props.headerTitle - The title of the header
* @param {string} props.cancelUrl - The url to navigate to when the user cancels
* @param {string} props.successUrl - The url to navigate to when the user successfully submits the form
* @param {boolean} props.isEdit - Whether or not this is an edit form
* @param {object} props.defaultValues - The default values for the form
* @param {object} props.layoutOptions - The layout options for the form
* @param {boolean} props.twoColumn - Whether or not to render the form in two columns
* @param {string} props.domainUrl - The url for the domain
* @param {string} props.unitLabel - The label for the unit
* @param {string} props.helpText - The help text for the form
* @param {string} props.submitUrl - The url to submit the form to
* @param {object} props.alternatingCols - Whether or not to alternate the columns
* @param {object} props.iconOptions - The options for the icons
* @param {function} props.formatPayload - A function to format the payload before submitting
* @param {function} props.onSuccess - A function to call when the form is successfully submitted
* @param {boolean} props.suppressSuccessToast - Whether or not to suppress the success toast
* @param {boolean} props.suppressErrorToast - Whether or not to suppress the success toast
* @param {function} props.formatSubmitMessage - A function to format the success toaster message
* @param {function} props.formatSubmitError - A function to format the error message (sends the error as a parameter and true if it came from the server)
* @param {object} props.asyncOptions - The options for the async select fields
* @param {boolean} props.hideEmptySections - Whether or not to render sections that have no visible fields
* @param {string} props.cancelColor - The color of the cancel button
* @param {string} props.submitColor - The color of the submit button
* @param {string} props.editColor - The color of the edit button
* @returns {React.ReactElement} - The component
*
* @example
* //example of choiceFormatter function
const choiceFormatter = (fieldId, res, otherOptions) => {
const { mappedId } = otherOptions || {};
return res?.data?.map((opt) => {
const id = mappedId && opt[mappedId] ? opt[mappedId] : opt.id || opt.streamID;
return { id, label: opt.name || opt.label }
})
}
*/
const GenericForm = ({
formTitle, headerTitle, cancelUrl, successUrl, isEdit, defaultValues, layoutOptions = {}, twoColumn = false,
domainUrl, unitLabel, helpText, submitUrl, formatPayload, onSuccess, alternatingCols = false, iconOptions = {},
suppressSuccessToast, suppressErrorToast, formatSubmitMessage, formatSubmitError, asyncOptions, cancelColor = 'tertiary',
submitColor = 'primary', editColor = 'primary', hideEmptySections = true
}) => {
const [modifying, setModifying] = useState(false);
const { sections, layoutLoading, control, reset, processing, handleSubmit } = useDynamicForm(layoutOptions, defaultValues, domainUrl, setModifying, asyncOptions);
const nav = useNavigate();
const { enqueueSnackbar } = useSnackbar();
const onSubmit = async (data) => {
const payload = formatPayload(data);
addOrUpdate(payload, isEdit, successUrl, cancelUrl);
};
const addOrUpdate = async (orgData, edit, successUrl, cancelUrl) => {
if (!submitUrl) {
console.log('No submit url provided. Data to submit:', orgData);
return;
}
setModifying(true);
try {
const result = await axios.post(submitUrl, orgData);
if (result?.data?.streamID) {
if (!suppressSuccessToast) {
const successMsg = formatSubmitMessage ? formatSubmitMessage(result) : `${unitLabel} successfully ${edit ? 'updated' : 'created'}`;
enqueueSnackbar(successMsg, { variant: 'success' });
}
// If we have an onSuccess callback, call it otherwise navigate to the successUrl
if (onSuccess) {
onSuccess(result);
} else {
nav(successUrl || cancelUrl);
setModifying(false);
nav(successUrl || cancelUrl);
}
} else {
setModifying(false);
if (!suppressErrorToast) {
const errorMsg = formatSubmitError ? formatSubmitError(result) : `Error ${edit ? 'updating' : 'creating'} ${unitLabel}`;
enqueueSnackbar(errorMsg, { variant: 'error' });
}
}
} catch (error) {
if (!suppressErrorToast) {
// If we have a nice server error use it.
const serverError = error?.response?.data?.error;
// Sending a true flag to formatSubmitError indicates that the error came from the server
const errorMsg = formatSubmitError ? formatSubmitError(error, true) : `Error ${edit ? 'updating' : 'creating'} ${unitLabel}`;
enqueueSnackbar(serverError || errorMsg, { variant: 'error' });
}
setModifying(false);
}
};
// onSubmit is not called if the form is invalid
// so we need to manually check for this
const preSubmit = (evt) => {
handleSubmit(onSubmit)(evt);
};
const rendered = () => {
if (layoutLoading) {
return (
<ContainerWithCard>
<LineLoader message="Loading..." />
</ContainerWithCard>
);
}
const theSection = twoColumn ? renderTwoColumnSection : renderFormSection;
const sectOpts = twoColumn ? { alternatingCols } : {};
sectOpts.iconOptions = iconOptions;
sectOpts.hideEmptySections = hideEmptySections;
return (
<>
<SubHeader data-src-form-subheader="genericForm"
title={headerTitle}
rightRender={
() =>
<Stack spacing={2} direction="row" justifyContent="flex-end">
<Button data-src-form-button="cancel" color={cancelColor} href={cancelUrl} label="Cancel" />
{isEdit && <Button color={editColor} onClick={() => reset()} label={'Reset'} />}
<Button data-src-form-button="submit" color={submitColor} onClick={preSubmit}>{isEdit ? 'Edit' : 'Save'}</Button>
</Stack>
}
/>
<LoadingSpinner isActive={modifying || processing} />
<Container sx={{ position: 'relative', marginTop: '16px' }} maxWidth={false}>
<form data-src-form="genericForm">
{sections.map((section, index) => {
const sx = { position: 'relative' };
if (index) {
sx.marginTop = '16px';
}
const hasTopText = formTitle || helpText;
return (
<Card key={index} sx={sx}>
{index === 0 && hasTopText &&
<>
<CardContent sx={{ paddingBottom: '0px' }}>
{formTitle && <Typography variant="sectionHeader">{formTitle}</Typography>}
{helpText && helpText()}
</CardContent>
<hr />
</>
}
{theSection(section, control, index, sectOpts)}
</Card>
);
})}
</form>
</Container>
</>
);
};
return rendered();
};
GenericForm.propTypes = {
formTitle: PropTypes.string,
cancelUrl: PropTypes.string,
successUrl: PropTypes.string,
isEdit: PropTypes.bool,
defaultValues: PropTypes.object,
layoutOptions: PropTypes.shape({
type: PropTypes.string,
key: PropTypes.string,
url: PropTypes.string,
layout: PropTypes.object,
}),
twoColumn: PropTypes.bool,
alternatingCols: PropTypes.bool,
onSuccess: PropTypes.func,
suppressSuccessToast: PropTypes.bool,
submitUrl: PropTypes.string,
formatPayload: PropTypes.func.isRequired,
domainUrl: PropTypes.string,
unitLabel: PropTypes.string,
helpText: PropTypes.func,
headerTitle: PropTypes.string,
iconOptions: PropTypes.object,
submitColor: PropTypes.string,
editColor: PropTypes.string,
cancelColor: PropTypes.string,
hideEmptySections: PropTypes.bool,
formatSubmitMessage: PropTypes.func,
formatSubmitError: PropTypes.func,
suppressErrorToast: PropTypes.bool
};
const renderFormSection = (section, control, index, options) => {
if (section.visible === false && options?.hideEmptySections) {
return null;
}
return (
<CardContent key={index}>
{section.name && <Typography variant="sectionHeader">{section.name}</Typography>}
{section.fields.map((field, fIndex) => (
<AnyField
sx={{ marginTop: fIndex ? '16px' : null }}
layout={field.render}
control={control}
key={field?.render?.name}
options={{ icon: options?.iconOptions }}
/>
))}
</CardContent>
);
};
const renderTwoColumnSection = (section, control, index, options) => {
// create two columns of fields
if (section.visible === false && options?.hideEmptySections) {
return null;
}
let leftCol = [];
let rightCol = [];
if (options?.alternatingCols) {
section.fields.forEach((field, fIndex) => {
if (fIndex % 2 === 0) {
leftCol.push(field);
} else {
rightCol.push(field);
}
});
} else {
const nextCol = Math.ceil(section.fields.length / 2);
leftCol = section.fields.slice(0, nextCol);
rightCol = section.fields.slice(nextCol);
}
return (
<CardContent key={index}>
{section.name && <Typography variant="sectionHeader">{section.name}</Typography>}
<Grid
container
spacing={{ xs: 1, sm: 2, md: 4 }}
>
<Grid item xs={6} key={`${index}-left`}>
{leftCol.map((field, fIndex) => (
<AnyField
sx={{ marginTop: fIndex ? '16px' : null }}
layout={field.render}
control={control}
key={`${index}-left-${field?.render?.name}`}
options={{ icon: options?.iconOptions }}
/>
))}
</Grid>
<Grid item xs={6} key={`${index}-right`}>
{rightCol.map((field, fIndex) => (
<AnyField
sx={{ marginTop: fIndex ? '16px' : null }}
layout={field.render}
control={control}
options={{ icon: options?.iconOptions }}
key={`${index}-right-${field?.render?.name}`}
/>
))}
</Grid>
</Grid>
</CardContent>
);
};
// example of choiceFormatter function
// const choiceFormatter = (fieldId, res, otherOptions) => {
// const { mappedId } = otherOptions || {};
// return res?.data?.map((opt) => {
// const id = mappedId && opt[mappedId] ? opt[mappedId] : opt.id || opt.streamID;
// return { id, label: opt.name || opt.label }
// })
// }
export default GenericForm;
Source