diff --git a/docs/CreateEdit.md b/docs/CreateEdit.md index fc5c28ad429..0a424a78442 100644 --- a/docs/CreateEdit.md +++ b/docs/CreateEdit.md @@ -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 [``](#the-tabbedform-component), an [``](#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 ``, 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) => ( + + + + + + } + aria-controls="options-content" + id="options-header" + > + Options + + + + + + + + +); + +const AccordionSectionTitle = ({ children, name }) => { + const formGroupState = useFormGroup(name); + + return ( + + {children} + + ); +} +``` diff --git a/examples/simple/src/posts/PostEdit.js b/examples/simple/src/posts/PostEdit.js index a0697c25d66..d5742055e75 100644 --- a/examples/simple/src/posts/PostEdit.js +++ b/examples/simple/src/posts/PostEdit.js @@ -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'; @@ -43,12 +45,25 @@ const EditActions = ({ basePath, data, hasShow }) => ( ); +const SanitizedBox = ({ fullWidth, basePath, ...props }) => ; + const PostEdit = ({ permissions, ...props }) => ( } actions={} {...props}> - - + + + + {} }; - -const FormContext = createContext(defaultFormFunctions); - -FormContext.displayName = 'FormContext'; - -export default FormContext; +export const FormContext = createContext(undefined); diff --git a/packages/ra-core/src/form/FormContextProvider.tsx b/packages/ra-core/src/form/FormContextProvider.tsx new file mode 100644 index 00000000000..17c23638971 --- /dev/null +++ b/packages/ra-core/src/form/FormContextProvider.tsx @@ -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; +}) => {children}; diff --git a/packages/ra-core/src/form/FormGroupContext.ts b/packages/ra-core/src/form/FormGroupContext.ts new file mode 100644 index 00000000000..104b08ecfa5 --- /dev/null +++ b/packages/ra-core/src/form/FormGroupContext.ts @@ -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(undefined); + +export type FormGroupContextValue = string; diff --git a/packages/ra-core/src/form/FormGroupContextProvider.tsx b/packages/ra-core/src/form/FormGroupContextProvider.tsx new file mode 100644 index 00000000000..e58d83667ba --- /dev/null +++ b/packages/ra-core/src/form/FormGroupContextProvider.tsx @@ -0,0 +1,82 @@ +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) => ( + * + * + * + * + * + * } + * aria-controls="options-content" + * id="options-header" + * > + * Options + * + * + * + * + * + * + * + * + * ); + * + * const AccordionSectionTitle = ({ children, name }) => { + * const formGroupState = useFormGroup(name); + * return ( + * + * {children} + * + * ); + * } + * + * @param props The component props + * @param {ReactNode} props.children The form group content + * @param {String} props.name The form group name + */ +export const FormGroupContextProvider = ({ + children, + name, +}: { + children: ReactNode; + name: string; +}) => { + const formContext = useFormContext(); + + useEffect(() => { + if ( + !formContext || + !formContext.registerGroup || + !formContext.unregisterGroup + ) { + 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 ( + + {children} + + ); +}; diff --git a/packages/ra-core/src/form/FormWithRedirect.tsx b/packages/ra-core/src/form/FormWithRedirect.tsx index 65109c64743..629eed2d8b4 100644 --- a/packages/ra-core/src/form/FormWithRedirect.tsx +++ b/packages/ra-core/src/form/FormWithRedirect.tsx @@ -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, @@ -64,8 +64,9 @@ const FormWithRedirect: FC = ({ 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 @@ -92,7 +93,37 @@ const FormWithRedirect: FC = ({ [save] ); - const formContextValue = useMemo(() => ({ setOnSave }), [setOnSave]); + const formContextValue = useMemo( + () => ({ + setOnSave, + getGroupFields: name => formGroups.current[name] || [], + 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); + } + }, + 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); + } + } + }, + }), + [setOnSave] + ); const finalInitialValues = getFormInitialValues( initialValues, @@ -118,7 +149,7 @@ const FormWithRedirect: FC = ({ }; return ( - +
= ({ /> )} /> - + ); }; diff --git a/packages/ra-core/src/form/index.ts b/packages/ra-core/src/form/index.ts index 79b293646ca..e694695ccfc 100644 --- a/packages/ra-core/src/form/index.ts +++ b/packages/ra-core/src/form/index.ts @@ -3,7 +3,6 @@ import FormDataConsumer, { FormDataConsumerRender, FormDataConsumerRenderParams, } from './FormDataConsumer'; -import FormContext from './FormContext'; import FormField from './FormField'; import FormWithRedirect, { FormWithRedirectProps, @@ -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'; diff --git a/packages/ra-core/src/form/useFormContext.ts b/packages/ra-core/src/form/useFormContext.ts new file mode 100644 index 00000000000..de6ceeedeb2 --- /dev/null +++ b/packages/ra-core/src/form/useFormContext.ts @@ -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); diff --git a/packages/ra-core/src/form/useFormGroup.spec.tsx b/packages/ra-core/src/form/useFormGroup.spec.tsx new file mode 100644 index 00000000000..1a61cfc8ccf --- /dev/null +++ b/packages/ra-core/src/form/useFormGroup.spec.tsx @@ -0,0 +1,110 @@ +import { getFormGroupState } from './useFormGroup'; + +describe('useFormGroup', () => { + test.each([ + [ + 'some fields are dirty and invalid', + [ + { + valid: true, + invalid: false, + dirty: false, + pristine: true, + blur: jest.fn(), + change: jest.fn(), + focus: jest.fn(), + name: 'title', + }, + { + valid: false, + invalid: true, + dirty: true, + pristine: false, + error: 'Invalid', + blur: jest.fn(), + change: jest.fn(), + focus: jest.fn(), + name: 'description', + }, + ], + { + valid: false, + invalid: true, + dirty: true, + pristine: false, + errors: { + description: 'Invalid', + }, + }, + ], + [ + 'none of the fields is invalid nor dirty', + [ + { + valid: true, + invalid: false, + dirty: false, + pristine: true, + blur: jest.fn(), + change: jest.fn(), + focus: jest.fn(), + name: 'title', + }, + { + valid: true, + invalid: false, + dirty: false, + pristine: true, + blur: jest.fn(), + change: jest.fn(), + focus: jest.fn(), + name: 'description', + }, + ], + { + valid: true, + invalid: false, + dirty: false, + pristine: true, + errors: {}, + }, + ], + [ + 'none of the fields is invalid but some are dirty', + [ + { + valid: true, + invalid: false, + dirty: false, + pristine: true, + blur: jest.fn(), + change: jest.fn(), + focus: jest.fn(), + name: 'title', + }, + { + valid: true, + invalid: false, + dirty: true, + pristine: false, + blur: jest.fn(), + change: jest.fn(), + focus: jest.fn(), + name: 'description', + }, + ], + { + valid: true, + invalid: false, + dirty: true, + pristine: false, + errors: {}, + }, + ], + ])( + 'should return a correct form group state when %s', + (_, fieldStates, expectedGroupState) => { + expect(getFormGroupState(fieldStates)).toEqual(expectedGroupState); + } + ); +}); diff --git a/packages/ra-core/src/form/useFormGroup.ts b/packages/ra-core/src/form/useFormGroup.ts new file mode 100644 index 00000000000..3936912f772 --- /dev/null +++ b/packages/ra-core/src/form/useFormGroup.ts @@ -0,0 +1,131 @@ +import { useState, useEffect } from 'react'; +import { useForm } from 'react-final-form'; +import isEqual from 'lodash/isEqual'; +import { useFormContext } from './useFormContext'; +import { FieldState } from 'final-form'; + +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) + * + * @example + * import { Edit, SimpleForm, TextInput, FormGroupContextProvider, useFormGroup } from 'react-admin'; + * import { Accordion, AccordionDetails, AccordionSummary, Typography } from '@material-ui/core'; + * + * const PostEdit = (props) => ( + * + * + * + * + * + * } + * aria-controls="options-content" + * id="options-header" + * > + * Options + * + * + * + * + * + * + * + * + * ); + * + * const AccordionSectionTitle = ({ children, name }) => { + * const formGroupState = useFormGroup(name); + * return ( + * + * {children} + * + * ); + * } + * + * @param {string] name The form group name + * @returns {FormGroupState} The form group state + */ +export const useFormGroup = (name: string): FormGroupState => { + const form = useForm(); + const formContext = useFormContext(); + const [state, setState] = useState({ + errors: undefined, + valid: true, + invalid: false, + pristine: true, + dirty: false, + }); + + useEffect(() => { + const unsubscribe = form.subscribe( + () => { + const fields = formContext.getGroupFields(name); + const fieldStates = fields.map(form.getFieldState); + const newState = getFormGroupState(fieldStates); + + setState(oldState => { + if (!isEqual(oldState, newState)) { + return newState; + } + + return oldState; + }); + }, + { + errors: true, + invalid: true, + dirty: true, + pristine: true, + valid: true, + } + ); + return unsubscribe; + }, [form, formContext, name]); + + return state; +}; + +/** + * Get the state of a form group + * + * @param {FieldStates} fieldStates A map of field states from final-form where the key is the field name. + * @returns {FormGroupState} The state of the group. + */ +export const getFormGroupState = ( + fieldStates: FieldState[] +): FormGroupState => + fieldStates.reduce( + (acc, fieldState) => { + let errors = acc.errors || {}; + + if (fieldState.error) { + errors[fieldState.name] = fieldState.error; + } + + const newState = { + 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, + } + ); diff --git a/packages/ra-core/src/form/useFormGroupContext.ts b/packages/ra-core/src/form/useFormGroupContext.ts new file mode 100644 index 00000000000..728d15fee1c --- /dev/null +++ b/packages/ra-core/src/form/useFormGroupContext.ts @@ -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; +}; diff --git a/packages/ra-core/src/form/useInput.ts b/packages/ra-core/src/form/useInput.ts index 5282e3346b5..1c20aa5f8e5 100644 --- a/packages/ra-core/src/form/useInput.ts +++ b/packages/ra-core/src/form/useInput.ts @@ -6,7 +6,9 @@ import { } from 'react-final-form'; import { Validator, composeValidators } from './validate'; import isRequired from './isRequired'; -import { useCallback, ChangeEvent, FocusEvent } from 'react'; +import { useCallback, ChangeEvent, FocusEvent, useEffect } from 'react'; +import { useFormGroupContext } from './useFormGroupContext'; +import { useFormContext } from './useFormContext'; export interface InputProps extends Omit< @@ -44,6 +46,19 @@ const useInput = ({ ...options }: InputProps): UseInputValue => { const finalName = name || source; + const formGroupName = useFormGroupContext(); + const formContext = useFormContext(); + + useEffect(() => { + if (!formContext || !formGroupName) { + return; + } + formContext.registerField(source, formGroupName); + + return () => { + formContext.unregisterField(source, formGroupName); + }; + }, [formContext, formGroupName, source]); const sanitizedValidate = Array.isArray(validate) ? composeValidators(validate) diff --git a/packages/ra-core/src/types.ts b/packages/ra-core/src/types.ts index c484cb33674..dd11fa30963 100644 --- a/packages/ra-core/src/types.ts +++ b/packages/ra-core/src/types.ts @@ -498,6 +498,15 @@ export type SetOnSave = ( onSave?: (values: object, redirect: any) => void ) => void; +export type FormContextValue = { + setOnSave?: SetOnSave; + registerGroup: (name: string) => void; + unregisterGroup: (name: string) => void; + registerField: (source: string, group?: string) => void; + unregisterField: (source: string, group?: string) => void; + getGroupFields: (name: string) => string[]; +}; + export type FormFunctions = { setOnSave?: SetOnSave; }; diff --git a/packages/ra-ui-materialui/src/button/SaveButton.spec.tsx b/packages/ra-ui-materialui/src/button/SaveButton.spec.tsx index 213c56bb021..2fb28fff112 100644 --- a/packages/ra-ui-materialui/src/button/SaveButton.spec.tsx +++ b/packages/ra-ui-materialui/src/button/SaveButton.spec.tsx @@ -7,6 +7,7 @@ import { DataProviderContext, DataProvider, SaveContextProvider, + FormContextProvider, } from 'ra-core'; import { ThemeProvider } from '@material-ui/core'; import { createMuiTheme } from '@material-ui/core/styles'; @@ -36,6 +37,14 @@ const invalidButtonDomProps = { describe('', () => { const saveContextValue = { save: jest.fn(), saving: false }; + const formContextValue = { + setOnSave: jest.fn(), + registerGroup: jest.fn(), + unregisterField: jest.fn(), + unregisterGroup: jest.fn(), + registerField: jest.fn(), + getGroupFields: jest.fn(), + }; it('should render as submit type with no DOM errors', () => { const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); @@ -44,7 +53,9 @@ describe('', () => { - + + + @@ -63,7 +74,9 @@ describe('', () => { - + + + @@ -75,7 +88,9 @@ describe('', () => { const { getByLabelText } = render( - + + + ); @@ -88,7 +103,9 @@ describe('', () => { const { getByLabelText } = render( - + + + ); @@ -103,10 +120,12 @@ describe('', () => { const { getByLabelText } = render( - + + + ); @@ -121,7 +140,12 @@ describe('', () => { const { getByLabelText } = render( - + + + ); @@ -140,10 +164,12 @@ describe('', () => { dispatchSpy = jest.spyOn(store, 'dispatch'); return ( - + + + ); }} diff --git a/packages/ra-ui-materialui/src/button/SaveButton.tsx b/packages/ra-ui-materialui/src/button/SaveButton.tsx index 1dc78c1a8f0..ad9e43245a0 100644 --- a/packages/ra-ui-materialui/src/button/SaveButton.tsx +++ b/packages/ra-ui-materialui/src/button/SaveButton.tsx @@ -1,10 +1,4 @@ -import React, { - useContext, - cloneElement, - FC, - ReactElement, - SyntheticEvent, -} from 'react'; +import React, { cloneElement, FC, ReactElement, SyntheticEvent } from 'react'; import PropTypes from 'prop-types'; import Button, { ButtonProps } from '@material-ui/core/Button'; import CircularProgress from '@material-ui/core/CircularProgress'; @@ -19,9 +13,9 @@ import { OnFailure, TransformData, Record, - FormContext, HandleSubmitWithRedirect, useSaveContext, + useFormContext, } from 'ra-core'; import { sanitizeButtonRestProps } from './Button'; @@ -87,7 +81,7 @@ const SaveButton: FC = props => { const classes = useStyles(props); const notify = useNotify(); const translate = useTranslate(); - const { setOnSave } = useContext(FormContext); + const { setOnSave } = useFormContext(); const { setOnSuccess, setOnFailure, setTransform } = useSaveContext(props); const handleClick = event => { diff --git a/packages/ra-ui-materialui/src/form/FormTab.tsx b/packages/ra-ui-materialui/src/form/FormTab.tsx index 3706bd76afd..cf369b97945 100644 --- a/packages/ra-ui-materialui/src/form/FormTab.tsx +++ b/packages/ra-ui-materialui/src/form/FormTab.tsx @@ -1,10 +1,15 @@ import * as React from 'react'; -import { FC, ReactElement } from 'react'; +import { FC, ReactElement, ReactNode } from 'react'; import PropTypes from 'prop-types'; import { Link, useLocation } from 'react-router-dom'; import MuiTab from '@material-ui/core/Tab'; import classnames from 'classnames'; -import { useTranslate, Record } from 'ra-core'; +import { + FormGroupContextProvider, + useTranslate, + Record, + useFormGroup, +} from 'ra-core'; import FormInput from './FormInput'; @@ -13,6 +18,7 @@ const hiddenStyle = { display: 'none' }; const FormTab: FC = ({ basePath, className, + classes, contentClassName, children, hidden, @@ -26,43 +32,77 @@ const FormTab: FC = ({ variant, value, ...rest +}) => { + const renderHeader = () => ( + + ); + + const renderContent = () => ( + + + {React.Children.map( + children, + (input: ReactElement) => + input && ( + + ) + )} + + + ); + + return intent === 'header' ? renderHeader() : renderContent(); +}; + +export const FormTabHeader = ({ + classes, + label, + value, + icon, + className, + ...rest }) => { const translate = useTranslate(); const location = useLocation(); + const formGroup = useFormGroup(value); - const renderHeader = () => ( + return ( ); - - const renderContent = () => ( - - {React.Children.map( - children, - (input: ReactElement) => - input && ( - - ) - )} - - ); - - return intent === 'header' ? renderHeader() : renderContent(); }; FormTab.propTypes = { @@ -86,6 +126,8 @@ FormTab.propTypes = { export interface FormTabProps { basePath?: string; className?: string; + classes?: object; + children?: ReactNode; contentClassName?: string; hidden?: boolean; icon?: ReactElement; diff --git a/packages/ra-ui-materialui/src/form/TabbedForm.spec.tsx b/packages/ra-ui-materialui/src/form/TabbedForm.spec.tsx index 2c202f70c0b..0b3d9c832da 100644 --- a/packages/ra-ui-materialui/src/form/TabbedForm.spec.tsx +++ b/packages/ra-ui-materialui/src/form/TabbedForm.spec.tsx @@ -1,14 +1,17 @@ import * as React from 'react'; -import { createElement } from 'react'; import { MemoryRouter } from 'react-router-dom'; import { + minLength, renderWithRedux, + required, SaveContextProvider, SideEffectContextProvider, } from 'ra-core'; import TabbedForm, { findTabsWithErrors } from './TabbedForm'; import FormTab from './FormTab'; +import TextInput from '../input/TextInput'; +import { fireEvent, waitFor } from '@testing-library/react'; describe('', () => { const saveContextValue = { save: jest.fn(), saving: false }; @@ -68,44 +71,62 @@ describe('', () => { expect(queryByText('submitOnEnter: true')).not.toBeNull(); }); - describe('findTabsWithErrors', () => { - it('should find the tabs containing errors', () => { - const errors = { - field1: 'required', - field5: 'required', - field7: { - test: 'required', - }, - }; - const children = [ - createElement( - FormTab, - { label: 'tab1' }, - createElement('input', { source: 'field1' }), - createElement('input', { source: 'field2' }) - ), - createElement( - FormTab, - { label: 'tab2' }, - createElement('input', { source: 'field3' }), - createElement('input', { source: 'field4' }) - ), - createElement( - FormTab, - { label: 'tab3' }, - createElement('input', { source: 'field5' }), - createElement('input', { source: 'field6' }) - ), - createElement( - FormTab, - { label: 'tab4' }, - createElement('input', { source: 'field7.test' }), - createElement('input', { source: 'field8' }) - ), - ]; + it('should set the style of an inactive Tab button with errors', async () => { + const { getAllByRole, getByLabelText, debug } = renderWithRedux( + + + + + + + + + + + ); + + const tabs = getAllByRole('tab'); + fireEvent.click(tabs[1]); + const input = getByLabelText('resources.posts.fields.description'); + fireEvent.change(input, { target: { value: 'foo' } }); + fireEvent.blur(input); + fireEvent.click(tabs[0]); + expect(tabs[0].classList.contains('error')).toEqual(false); + expect(tabs[1].classList.contains('error')).toEqual(true); + }); + + it('should not set the style of an active Tab button with errors', () => { + const { getAllByRole, getByLabelText } = renderWithRedux( + + + + + + + + + + + ); - const tabs = findTabsWithErrors(children, errors); - expect(tabs).toEqual(['tab1', 'tab3', 'tab4']); - }); + const tabs = getAllByRole('tab'); + fireEvent.click(tabs[1]); + const input = getByLabelText('resources.posts.fields.description'); + fireEvent.change(input, { target: { value: 'foo' } }); + fireEvent.blur(input); + expect(tabs[0].classList.contains('error')).toEqual(false); + expect(tabs[1].classList.contains('error')).toEqual(false); }); }); diff --git a/packages/ra-ui-materialui/src/form/TabbedForm.tsx b/packages/ra-ui-materialui/src/form/TabbedForm.tsx index 1559ac73288..4834b37d640 100644 --- a/packages/ra-ui-materialui/src/form/TabbedForm.tsx +++ b/packages/ra-ui-materialui/src/form/TabbedForm.tsx @@ -127,6 +127,7 @@ export interface TabbedFormProps basePath?: string; children: ReactNode; className?: string; + classes?: ClassesOverride; initialValues?: any; margin?: 'none' | 'normal' | 'dense'; record?: Record; @@ -184,7 +185,6 @@ export const TabbedFormView: FC = props => { margin, ...rest } = props; - const tabsWithErrors = findTabsWithErrors(children, form.getState().errors); const classes = useStyles(props); const match = useRouteMatch(); const location = useLocation(); @@ -200,7 +200,6 @@ export const TabbedFormView: FC = props => { { classes, url, - tabsWithErrors, }, children )} @@ -210,34 +209,31 @@ export const TabbedFormView: FC = props => { on tabs not in focus. The tabs receive a `hidden` property, which they'll use to hide the tab using CSS if it's not the one in focus. See https://github.com/marmelab/react-admin/issues/1866 */} - {Children.map( - children, - (tab: ReactElement, index) => - tab && ( - - {routeProps => - isValidElement(tab) - ? React.cloneElement(tab, { - intent: 'content', - resource, - record, - basePath, - hidden: !routeProps.match, - variant: - tab.props.variant || variant, - margin: - tab.props.margin || margin, - }) - : null - } - - ) - )} + {Children.map(children, (tab: ReactElement, index) => { + if (!tab) { + return; + } + const tabPath = getTabFullPath(tab, index, url); + return ( + + {routeProps => + isValidElement(tab) + ? React.cloneElement(tab, { + intent: 'content', + classes, + resource, + record, + basePath, + hidden: !routeProps.match, + variant: tab.props.variant || variant, + margin: tab.props.margin || margin, + value: tabPath, + }) + : null + } + + ); + })} {toolbar && React.cloneElement(toolbar, { @@ -282,7 +278,6 @@ TabbedFormView.propTypes = { saving: PropTypes.bool, submitOnEnter: PropTypes.bool, tabs: PropTypes.element.isRequired, - tabsWithErrors: PropTypes.arrayOf(PropTypes.string), toolbar: PropTypes.element, translate: PropTypes.func, undoable: PropTypes.bool, @@ -344,7 +339,13 @@ const sanitizeRestProps = ({ ...props }) => props; +export default TabbedForm; + export const findTabsWithErrors = (children, errors) => { + console.warn( + 'Deprecated. FormTab now wrap their content inside a FormGroupContextProvider. If you implemented custom forms with tabs, please use the FormGroupContextProvider. See https://marmelab.com/react-admin/CreateEdit.html#grouping-inputs' + ); + return Children.toArray(children).reduce((acc: any[], child) => { if (!isValidElement(child)) { return acc; @@ -364,5 +365,3 @@ export const findTabsWithErrors = (children, errors) => { return acc; }, []); }; - -export default TabbedForm; diff --git a/packages/ra-ui-materialui/src/form/TabbedFormTabs.spec.tsx b/packages/ra-ui-materialui/src/form/TabbedFormTabs.spec.tsx deleted file mode 100644 index 1594ddab72e..00000000000 --- a/packages/ra-ui-materialui/src/form/TabbedFormTabs.spec.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import * as React from 'react'; -import { render } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; - -import TabbedFormTabs from './TabbedFormTabs'; -import FormTab from './FormTab'; - -describe('', () => { - it('should set the style of an inactive Tab button with errors', () => { - const { getAllByRole } = render( - - - - - - - ); - - const tabs = getAllByRole('tab'); - expect(tabs[0].classList.contains('error')).toEqual(false); - expect(tabs[1].classList.contains('error')).toEqual(true); - }); - - it('should not set the style of an active Tab button with errors', () => { - const { getAllByRole } = render( - - - - - - - ); - - const tabs = getAllByRole('tab'); - expect(tabs[0].classList.contains('error')).toEqual(false); - expect(tabs[1].classList.contains('error')).toEqual(false); - }); -}); diff --git a/packages/ra-ui-materialui/src/form/TabbedFormTabs.tsx b/packages/ra-ui-materialui/src/form/TabbedFormTabs.tsx index 297227f93f6..afcc9d4f385 100644 --- a/packages/ra-ui-materialui/src/form/TabbedFormTabs.tsx +++ b/packages/ra-ui-materialui/src/form/TabbedFormTabs.tsx @@ -14,7 +14,6 @@ const TabbedFormTabs: FC = ({ children, classes, url, - tabsWithErrors, ...rest }) => { const location = useLocation(); @@ -49,11 +48,7 @@ const TabbedFormTabs: FC = ({ return cloneElement(tab, { intent: 'header', value: tabPath, - className: - tabsWithErrors.includes(tab.props.label) && - location.pathname !== tabPath - ? classes.errorTabButton - : null, + classes, }); })}