Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce Form Groups #5752

Merged
merged 17 commits into from
Jan 18, 2021
43 changes: 43 additions & 0 deletions docs/CreateEdit.md
Original file line number Diff line number Diff line change
Expand Up @@ -2019,3 +2019,46 @@ const SaveWithNoteButton = props => {
```

The `onSave` value should be a function expecting 2 arguments: the form values to save, and the redirection to perform.

## Grouping Inputs

Sometimes, you may want to group inputs in order to make a form more approachable. You may use a [`<TabbedForm>`](#the-tabbedform-component), an [`<AccordionForm>`](#the-accordionform-component) or you may want to roll your own layout. In this case, you might need to know the state of a group of inputs: whether it's valid or if the user has changed them (dirty/pristine state).

For this, you can use the `<FormGroupContextProvider>`, which accepts a group name. All inputs rendered inside this context will register to it (thanks to the `useInput` hook). You may then call the `useFormGroup` hook to retrieve the status of the group. For example:

```jsx
import { Edit, SimpleForm, TextInput, FormGroupContextProvider, useFormGroup } from 'react-admin';
import { Accordion, AccordionDetails, AccordionSummary, Typography } from '@material-ui/core';

const PostEdit = (props) => (
<Edit {...props}>
<SimpleForm>
<TextInput source="title" />
<FormGroupContextProvider name="options">
<Accordion>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
aria-controls="options-content"
id="options-header"
>
<AccordionSectionTitle name="options">Options</AccordionSectionTitle>
</AccordionSummary>
<AccordionDetails id="options-content" aria-labelledby="options-header">
<TextInput source="teaser" validate={minLength(20)} />
</AccordionDetails>
</Accordion>
</FormGroupContextProvider>
</SimpleForm>
</Edit>
);

const AccordionSectionTitle = ({ children, name }) => {
const formGroupState = useFormGroup(name);

return (
<Typography color={formGroupState.invalid && formGroupState.dirty ? 'error' : 'inherit'}>
{children}
</Typography>
);
}
```
19 changes: 17 additions & 2 deletions examples/simple/src/posts/PostEdit.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import {
required,
FormDataConsumer,
} from 'react-admin'; // eslint-disable-line import/no-unresolved
import { Box } from '@material-ui/core';

import PostTitle from './PostTitle';
import TagReferenceInput from './TagReferenceInput';

Expand All @@ -43,12 +45,25 @@ const EditActions = ({ basePath, data, hasShow }) => (
</TopToolbar>
);

const SanitizedBox = ({ fullWidth, basePath, ...props }) => <Box {...props} />;

const PostEdit = ({ permissions, ...props }) => (
<Edit title={<PostTitle />} actions={<EditActions />} {...props}>
<TabbedForm initialValues={{ average_note: 0 }} warnWhenUnsavedChanges>
<FormTab label="post.form.summary">
<TextInput disabled source="id" />
<TextInput source="title" validate={required()} resettable />
<SanitizedBox
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved
display="flex"
width="100%"
justifyContent="space-between"
fullWidth
>
<TextInput disabled source="id" />
<TextInput
source="title"
validate={required()}
resettable
/>
</SanitizedBox>
<TextInput
multiline={true}
fullWidth={true}
Expand Down
10 changes: 2 additions & 8 deletions packages/ra-core/src/form/FormContext.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
import { createContext } from 'react';
import { FormFunctions } from '../types';
import { FormContextValue } from '../types';

const defaultFormFunctions: FormFunctions = { setOnSave: () => {} };

const FormContext = createContext<FormFunctions>(defaultFormFunctions);

FormContext.displayName = 'FormContext';

export default FormContext;
export const FormContext = createContext<FormContextValue>(undefined);
18 changes: 18 additions & 0 deletions packages/ra-core/src/form/FormContextProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import * as React from 'react';
import { ReactNode } from 'react';
import { FormContextValue } from '../types';
import { FormContext } from './FormContext';

/**
* Provides utilities to Form children, allowing them to change the default save function or register inputs to a group.
* @param props The component props
* @param {ReactNode} props.children The form content
* @param {FormContextValue} props.value The form context
*/
export const FormContextProvider = ({
children,
value,
}: {
children: ReactNode;
value: FormContextValue;
}) => <FormContext.Provider value={value}>{children}</FormContext.Provider>;
12 changes: 12 additions & 0 deletions packages/ra-core/src/form/FormGroupContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { createContext } from 'react';

/**
* Context allowing inputs to register to a specific group.
* This enables other components in the group to access group properties such as its
* validation (valid/invalid) or whether its inputs have been updated (dirty/pristine).
*
* This should only be used through a FormGroupContextProvider.
*/
export const FormGroupContext = createContext<FormGroupContextValue>(undefined);
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved

export type FormGroupContextValue = string;
78 changes: 78 additions & 0 deletions packages/ra-core/src/form/FormGroupContextProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import * as React from 'react';
import { ReactNode, useEffect } from 'react';
import { FormGroupContext } from './FormGroupContext';
import { useFormContext } from './useFormContext';

/**
* This provider allows its input children to register to a specific group.
* This enables other components in the group to access group properties such as its
* validation (valid/invalid) or whether its inputs have been updated (dirty/pristine).
*
* @example
* import { Edit, SimpleForm, TextInput, FormGroupContextProvider, useFormGroup } from 'react-admin';
* import { Accordion, AccordionDetails, AccordionSummary, Typography } from '@material-ui/core';
*
* const PostEdit = (props) => (
* <Edit {...props}>
* <SimpleForm>
* <TextInput source="title" />
* <FormGroupContextProvider name="options">
* <Accordion>
* <AccordionSummary
* expandIcon={<ExpandMoreIcon />}
* aria-controls="options-content"
* id="options-header"
* >
* <AccordionSectionTitle name="options">Options</AccordionSectionTitle>
* </AccordionSummary>
* <AccordionDetails id="options-content" aria-labelledby="options-header">
* <TextInput source="teaser" validate={minLength(20)} />
* </AccordionDetails>
* </Accordion>
* </FormGroupContextProvider>
* </SimpleForm>
* </Edit>
* );
*
* const AccordionSectionTitle = ({ children, name }) => {
* const formGroupState = useFormGroup(name);
* return (
* <Typography color={formGroupState.invalid && formGroupState.dirty ? 'error' : 'inherit'}>
* {children}
* </Typography>
* );
* }
*
* @param props The component props
* @param {ReactNode} props.children The form group content
* @param {String} props.name The form group name
*/
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved
export const FormGroupContextProvider = ({
children,
name,
}: {
children: ReactNode;
name: string;
}) => {
const formContext = useFormContext();

fzaninotto marked this conversation as resolved.
Show resolved Hide resolved
useEffect(() => {
if (!formContext) {
djhi marked this conversation as resolved.
Show resolved Hide resolved
console.warn(
`The FormGroupContextProvider can only be used inside a FormContext such as provided by the SimpleForm and TabbedForm components`
);
return;
}
formContext.registerGroup(name);

return () => {
formContext.unregisterGroup(name);
};
}, [formContext, name]);

return (
<FormGroupContext.Provider value={name}>
{children}
</FormGroupContext.Provider>
);
};
45 changes: 38 additions & 7 deletions packages/ra-core/src/form/FormWithRedirect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ import useInitializeFormWithRecord from './useInitializeFormWithRecord';
import useWarnWhenUnsavedChanges from './useWarnWhenUnsavedChanges';
import sanitizeEmptyValues from './sanitizeEmptyValues';
import getFormInitialValues from './getFormInitialValues';
import FormContext from './FormContext';
import { Record } from '../types';
import { FormContextValue, Record } from '../types';
import { RedirectionSideEffect } from '../sideEffect';
import { useDispatch } from 'react-redux';
import { setAutomaticRefresh } from '../actions/uiActions';
import { FormContextProvider } from './FormContextProvider';

/**
* Wrapper around react-final-form's Form to handle redirection on submit,
Expand Down Expand Up @@ -64,8 +64,9 @@ const FormWithRedirect: FC<FormWithRedirectProps> = ({
sanitizeEmptyValues: shouldSanitizeEmptyValues = true,
...props
}) => {
let redirect = useRef(props.redirect);
let onSave = useRef(save);
const redirect = useRef(props.redirect);
const onSave = useRef(save);
const formGroups = useRef<{ [key: string]: string[] }>({});

// We don't use state here for two reasons:
// 1. There no way to execute code only after the state has been updated
Expand All @@ -92,7 +93,37 @@ const FormWithRedirect: FC<FormWithRedirectProps> = ({
[save]
);

const formContextValue = useMemo(() => ({ setOnSave }), [setOnSave]);
const formContextValue = useMemo<FormContextValue>(
() => ({
setOnSave,
getGroupFields: name => formGroups.current[name] || [],
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved
registerGroup: name => {
formGroups.current[name] = formGroups.current[name] || [];
},
unregisterGroup: name => {
delete formGroups[name];
},
registerField: (source, group) => {
if (group) {
const fields = new Set(formGroups.current[group] || []);
fields.add(source);
formGroups.current[group] = Array.from(fields);
}
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved
},
unregisterField: (source, group) => {
if (group) {
if (!formGroups.current[group]) {
console.warn(`Invalid form group ${group}`);
} else {
const fields = new Set(formGroups.current[group]);
fields.delete(source);
formGroups.current[group] = Array.from(fields);
}
}
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved
},
}),
[setOnSave]
);

const finalInitialValues = getFormInitialValues(
initialValues,
Expand All @@ -118,7 +149,7 @@ const FormWithRedirect: FC<FormWithRedirectProps> = ({
};

return (
<FormContext.Provider value={formContextValue}>
<FormContextProvider value={formContextValue}>
<Form
key={version} // support for refresh button
debug={debug}
Expand Down Expand Up @@ -146,7 +177,7 @@ const FormWithRedirect: FC<FormWithRedirectProps> = ({
/>
)}
/>
</FormContext.Provider>
</FormContextProvider>
);
};

Expand Down
9 changes: 7 additions & 2 deletions packages/ra-core/src/form/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import FormDataConsumer, {
FormDataConsumerRender,
FormDataConsumerRenderParams,
} from './FormDataConsumer';
import FormContext from './FormContext';
import FormField from './FormField';
import FormWithRedirect, {
FormWithRedirectProps,
Expand Down Expand Up @@ -48,9 +47,15 @@ export {
useInitializeFormWithRecord,
useSuggestions,
ValidationError,
FormContext,
useWarnWhenUnsavedChanges,
};
export { isRequired } from './FormField';
export * from './validate';
export * from './constants';
export * from './FormContextProvider';
export * from './FormContext';
export * from './useFormContext';
export * from './FormGroupContext';
export * from './FormGroupContextProvider';
export * from './useFormGroup';
export * from './useFormGroupContext';
8 changes: 8 additions & 0 deletions packages/ra-core/src/form/useFormContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { useContext } from 'react';
import { FormContext } from './FormContext';

/**
* Retrieve the form context enabling consumers to alter its save function or to register inputs inside a form group.
* @returns {FormContext} The form context.
*/
export const useFormContext = () => useContext(FormContext);
Loading