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 accept a group name and will links any inputs inside it to the group. You may then call the `useFormGroup` hook to retrieve the group status. For example:
djhi marked this conversation as resolved.
Show resolved Hide resolved

```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 Forms children, allowing them to change the default save function or register inputs inside a group.
djhi marked this conversation as resolved.
Show resolved Hide resolved
* @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>;
5 changes: 5 additions & 0 deletions packages/ra-core/src/form/FormGroupContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createContext } from 'react';

export const FormGroupContext = createContext<FormGroupContextValue>(undefined);
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved

export type FormGroupContextValue = string;
36 changes: 36 additions & 0 deletions packages/ra-core/src/form/FormGroupContextProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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 know they belong to a specific group.
djhi marked this conversation as resolved.
Show resolved Hide resolved
* This enables other components to access groups properties such as its
djhi marked this conversation as resolved.
Show resolved Hide resolved
* validation (valid/invalid) or whether its inputs have been updated (dirty/pristine).
* @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(() => {
formContext.registerGroup(name);

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

return (
<FormGroupContext.Provider value={name}>
{children}
</FormGroupContext.Provider>
);
};
46 changes: 39 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,38 @@ 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) {
formGroups.current[group] = formGroups.current[group] || [];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do you allow an input to register to a group that hasn't been registered here, while you forbid it in the following function?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found out that inputs were registering themselves before the group

const fields = new Set(formGroups.current[group]);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you could merge the previous line with this one using

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 +150,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 +178,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';
12 changes: 12 additions & 0 deletions packages/ra-core/src/form/useFormContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
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 = () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could be a one liner

const context = useContext(FormContext);

return context;
};
79 changes: 79 additions & 0 deletions packages/ra-core/src/form/useFormGroup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { useState } from 'react';
import { useForm } from 'react-final-form';
import isEqual from 'lodash/isEqual';
import { useFormContext } from './useFormContext';

type FormGroupState = {
errors: object;
valid: boolean;
invalid: boolean;
pristine: boolean;
dirty: boolean;
};

/**
* Retrieve a specific form group data such as its validation status (valid/invalid) or
* or whether its inputs have been updated (dirty/pristine)
* @param {string] name The form group name
* @returns {FormGroupState} The form group state
*/
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved
export const useFormGroup = (name: string): FormGroupState => {
const form = useForm();
const formContext = useFormContext();
const [state, setState] = useState<FormGroupState>({
errors: undefined,
valid: true,
invalid: false,
pristine: true,
dirty: false,
});

form.subscribe(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you never unsubscribe... I think there's a leak here

() => {
const fields = formContext.getGroupFields(name);
const newState = Array.from(fields).reduce<FormGroupState>(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why the Array.from()?

(acc, field) => {
const fieldState = form.getFieldState(field);
let errors = acc.errors;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let errors = acc.errors || []; and remove line 40 to 42


if (fieldState.error) {
if (!errors) {
errors = {};
}
errors[field] = fieldState.error;
}

const newState = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a unit test for that logic

errors,
valid: acc.valid && fieldState.valid,
invalid: acc.invalid || fieldState.invalid,
pristine: acc.pristine && fieldState.pristine,
dirty: acc.dirty || fieldState.dirty,
};

return newState;
},
{
errors: undefined,
valid: true,
invalid: false,
pristine: true,
dirty: false,
}
);

if (!isEqual(state, newState)) {
setState(newState);
}
},
{
errors: true,
invalid: true,
dirty: true,
pristine: true,
valid: true,
}
);

return state;
};
10 changes: 10 additions & 0 deletions packages/ra-core/src/form/useFormGroupContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { useContext } from 'react';
import { FormGroupContext } from './FormGroupContext';

/**
* Retrieve the name of the form group the consumer belongs to. May be undefined if the consumer is not inside a form group.
*/
export const useFormGroupContext = () => {
const context = useContext(FormGroupContext);
return context;
};
Loading