diff --git a/packages/ra-ui-materialui/src/detail/editFieldTypes.tsx b/packages/ra-ui-materialui/src/detail/editFieldTypes.tsx index 5fb4fc2a034..9d31cabccd1 100644 --- a/packages/ra-ui-materialui/src/detail/editFieldTypes.tsx +++ b/packages/ra-ui-materialui/src/detail/editFieldTypes.tsx @@ -1,17 +1,18 @@ import * as React from 'react'; import { ReactNode, ReactElement } from 'react'; import SimpleForm from '../form/SimpleForm'; -import { SimpleFormIterator } from '../form/SimpleFormIterator'; -import ArrayInput from '../input/ArrayInput'; -import BooleanInput from '../input/BooleanInput'; -import DateInput from '../input/DateInput'; -import NumberInput from '../input/NumberInput'; -import ReferenceInput from '../input/ReferenceInput'; -import ReferenceArrayInput, { +import { + ArrayInput, + BooleanInput, + DateInput, + NumberInput, + ReferenceInput, + ReferenceArrayInput, ReferenceArrayInputProps, -} from '../input/ReferenceArrayInput'; -import { SelectInput } from '../input/SelectInput'; -import TextInput from '../input/TextInput'; + SelectInput, + SimpleFormIterator, + TextInput, +} from '../input'; import { InferredElement, InferredTypeMap, InputProps } from 'ra-core'; const editFieldTypes: InferredTypeMap = { diff --git a/packages/ra-ui-materialui/src/form/SimpleFormIterator.tsx b/packages/ra-ui-materialui/src/form/SimpleFormIterator.tsx deleted file mode 100644 index f699719306e..00000000000 --- a/packages/ra-ui-materialui/src/form/SimpleFormIterator.tsx +++ /dev/null @@ -1,416 +0,0 @@ -import * as React from 'react'; -import { - Children, - cloneElement, - MouseEvent, - MouseEventHandler, - isValidElement, - ReactElement, - ReactNode, - useRef, -} from 'react'; -import { Button, FormHelperText, Typography } from '@material-ui/core'; -import { makeStyles } from '@material-ui/core/styles'; -import AddIcon from '@material-ui/icons/AddCircleOutline'; -import CloseIcon from '@material-ui/icons/RemoveCircleOutline'; -import ArrowUpwardIcon from '@material-ui/icons/ArrowUpward'; -import ArrowDownwardIcon from '@material-ui/icons/ArrowDownward'; -import classNames from 'classnames'; -import get from 'lodash/get'; -import PropTypes from 'prop-types'; -import { Record, useTranslate, ValidationError } from 'ra-core'; -import { FieldArrayRenderProps } from 'react-final-form-arrays'; -import { CSSTransition, TransitionGroup } from 'react-transition-group'; - -import { ClassesOverride } from '../types'; -import { IconButtonWithTooltip } from '../button'; -import FormInput from './FormInput'; - -export const SimpleFormIterator = (props: SimpleFormIteratorProps) => { - const { - addButton = , - removeButton = , - reOrderButtons = , - basePath, - children, - className, - fields, - meta: { error, submitFailed }, - record, - resource, - source, - disabled, - disableAdd, - disableRemove, - disableReordering, - variant, - margin, - TransitionProps, - defaultValue, - getItemLabel = DefaultLabelFn, - } = props; - const classes = useStyles(props); - const nodeRef = useRef(null); - - // We need a unique id for each field for a proper enter/exit animation - // so we keep an internal map between the field position and an auto-increment id - const nextId = useRef( - fields && fields.length - ? fields.length - : defaultValue - ? defaultValue.length - : 0 - ); - - // We check whether we have a defaultValue (which must be an array) before checking - // the fields prop which will always be empty for a new record. - // Without it, our ids wouldn't match the default value and we would get key warnings - // on the CssTransition element inside our render method - const ids = useRef( - nextId.current > 0 ? Array.from(Array(nextId.current).keys()) : [] - ); - - const removeField = (index: number) => () => { - ids.current.splice(index, 1); - fields?.remove(index); - }; - - // Returns a boolean to indicate whether to disable the remove button for certain fields. - // If disableRemove is a function, then call the function with the current record to - // determining if the button should be disabled. Otherwise, use a boolean property that - // enables or disables the button for all of the fields. - const disableRemoveField = ( - record: Record, - disableRemove: boolean | DisableRemoveFunction - ) => { - if (typeof disableRemove === 'boolean') { - return disableRemove; - } - return disableRemove && disableRemove(record); - }; - - const addField = () => { - ids.current.push(nextId.current++); - fields?.push(undefined); - }; - - // add field and call the onClick event of the button passed as addButton prop - const handleAddButtonClick = ( - originalOnClickHandler: MouseEventHandler - ) => (event: MouseEvent) => { - addField(); - if (originalOnClickHandler) { - originalOnClickHandler(event); - } - }; - - // remove field and call the onClick event of the button passed as removeButton prop - const handleRemoveButtonClick = ( - originalOnClickHandler: MouseEventHandler, - index: number - ) => (event: MouseEvent) => { - removeField(index)(); - if (originalOnClickHandler) { - originalOnClickHandler(event); - } - }; - - const handleReorder = (origin: number, destination: number) => { - const item = ids.current[origin]; - ids.current[origin] = ids.current[destination]; - ids.current[destination] = item; - fields?.move(origin, destination); - }; - - const records = get(record, source); - return fields ? ( -
    - {submitFailed && typeof error !== 'object' && error && ( - - - - )} - - {fields.map((member, index) => ( - -
  • -
    -
    - - {getItemLabel(index)} - - {!disabled && - !disableReordering && - cloneElement(reOrderButtons, { - index, - max: fields.length, - onReorder: handleReorder, - className: classNames( - 'button-reorder', - `button-reorder-${source}-${index}` - ), - })} -
    -
    -
    - {Children.map( - children, - (input: ReactElement, index2) => { - if (!isValidElement(input)) { - return null; - } - const { - source, - ...inputProps - } = input.props; - return ( - - ); - } - )} -
    - {!disabled && - !disableRemoveField( - (records && records[index]) || {}, - disableRemove - ) && ( - - {cloneElement(removeButton, { - onClick: handleRemoveButtonClick( - removeButton.props.onClick, - index - ), - className: classNames( - 'button-remove', - `button-remove-${source}-${index}` - ), - })} - - )} -
  • -
    - ))} -
    - {!disabled && !disableAdd && ( -
  • - - {cloneElement(addButton, { - onClick: handleAddButtonClick( - addButton.props.onClick - ), - className: classNames( - 'button-add', - `button-add-${source}` - ), - })} - -
  • - )} -
- ) : null; -}; - -SimpleFormIterator.defaultProps = { - disableAdd: false, - disableRemove: false, -}; - -SimpleFormIterator.propTypes = { - defaultValue: PropTypes.any, - addButton: PropTypes.element, - removeButton: PropTypes.element, - basePath: PropTypes.string, - children: PropTypes.node, - classes: PropTypes.object, - className: PropTypes.string, - // @ts-ignore - fields: PropTypes.object, - meta: PropTypes.object, - // @ts-ignore - record: PropTypes.object, - source: PropTypes.string, - resource: PropTypes.string, - translate: PropTypes.func, - disableAdd: PropTypes.bool, - disableRemove: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]), - TransitionProps: PropTypes.shape({}), -}; - -type DisableRemoveFunction = (record: Record) => boolean; - -export interface SimpleFormIteratorProps - extends Partial, 'meta'>> { - addButton?: ReactElement; - basePath?: string; - children?: ReactNode; - classes?: ClassesOverride; - className?: string; - defaultValue?: any; - disabled?: boolean; - disableAdd?: boolean; - disableRemove?: boolean | DisableRemoveFunction; - disableReordering?: boolean; - getItemLabel?: (index: number) => string; - margin?: 'none' | 'normal' | 'dense'; - meta?: { - // the type defined in FieldArrayRenderProps says error is boolean, which is wrong. - error?: any; - submitFailed?: boolean; - }; - record?: Record; - removeButton?: ReactElement; - reOrderButtons?: ReactElement; - resource?: string; - source?: string; - TransitionProps?: any; - variant?: 'standard' | 'outlined' | 'filled'; -} - -const useStyles = makeStyles( - theme => ({ - root: { - padding: 0, - marginBottom: 0, - '& > li:last-child': { - borderBottom: 'none', - }, - }, - line: { - display: 'flex', - listStyleType: 'none', - borderBottom: `solid 1px ${theme.palette.divider}`, - [theme.breakpoints.down('xs')]: { display: 'block' }, - '&.fade-enter': { - opacity: 0.01, - transform: 'translateX(100vw)', - }, - '&.fade-enter-active': { - opacity: 1, - transform: 'translateX(0)', - transition: 'all 500ms ease-in', - }, - '&.fade-exit': { - opacity: 1, - transform: 'translateX(0)', - }, - '&.fade-exit-active': { - opacity: 0.01, - transform: 'translateX(100vw)', - transition: 'all 500ms ease-in', - }, - }, - index: { - [theme.breakpoints.down('sm')]: { display: 'none' }, - marginRight: theme.spacing(1), - }, - indexContainer: { - display: 'flex', - paddingTop: '1em', - marginRight: theme.spacing(1), - alignItems: 'center', - }, - form: { flex: 2 }, - action: { - paddingTop: '0.5em', - }, - leftIcon: { - marginRight: theme.spacing(1), - }, - }), - { name: 'RaSimpleFormIterator' } -); - -const DefaultAddButton = props => { - const classes = useStyles(props); - const translate = useTranslate(); - return ( - - ); -}; - -const DefaultLabelFn = index => index + 1; - -const DefaultRemoveButton = props => { - const classes = useStyles(props); - const translate = useTranslate(); - return ( - - ); -}; - -const DefaultReOrderButtons = ({ - className, - index, - max, - onReorder, -}: { - className?: string; - index?: number; - max?: number; - onReorder?: (origin: number, destination: number) => void; -}) => ( -
- onReorder(index, index - 1)} - disabled={index <= 0} - > - - - onReorder(index, index + 1)} - disabled={max == null || index >= max - 1} - > - - -
-); diff --git a/packages/ra-ui-materialui/src/form/index.tsx b/packages/ra-ui-materialui/src/form/index.tsx index e8cee28545a..11ea2a96a48 100644 --- a/packages/ra-ui-materialui/src/form/index.tsx +++ b/packages/ra-ui-materialui/src/form/index.tsx @@ -6,7 +6,6 @@ import getFormInitialValues from './getFormInitialValues'; import { SimpleFormView, SimpleFormViewProps } from './SimpleFormView'; import { TabbedFormView, TabbedFormViewProps } from './TabbedFormView'; -export * from './SimpleFormIterator'; export * from './TabbedForm'; export * from './FormTab'; export * from './FormTabHeader'; diff --git a/packages/ra-ui-materialui/src/input/ArrayInput/AddItemButton.tsx b/packages/ra-ui-materialui/src/input/ArrayInput/AddItemButton.tsx new file mode 100644 index 00000000000..58541a465e9 --- /dev/null +++ b/packages/ra-ui-materialui/src/input/ArrayInput/AddItemButton.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import AddIcon from '@material-ui/icons/AddCircleOutline'; +import { useSimpleFormIterator } from './useSimpleFormIterator'; + +import { Button, ButtonProps } from '../../button'; + +export const AddItemButton = (props: Omit) => { + const { add } = useSimpleFormIterator(); + return ( + + ); +}; diff --git a/packages/ra-ui-materialui/src/input/ArrayInput.spec.tsx b/packages/ra-ui-materialui/src/input/ArrayInput/ArrayInput.spec.tsx similarity index 58% rename from packages/ra-ui-materialui/src/input/ArrayInput.spec.tsx rename to packages/ra-ui-materialui/src/input/ArrayInput/ArrayInput.spec.tsx index 2949002322f..e6f9ea9354c 100644 --- a/packages/ra-ui-materialui/src/input/ArrayInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/ArrayInput/ArrayInput.spec.tsx @@ -2,13 +2,17 @@ import * as React from 'react'; import { fireEvent, render, waitFor } from '@testing-library/react'; import { Form } from 'react-final-form'; import arrayMutators from 'final-form-arrays'; +import { ThemeProvider } from '@material-ui/core'; +import { createTheme } from '@material-ui/core/styles'; -import ArrayInput from './ArrayInput'; -import NumberInput from './NumberInput'; -import TextInput from './TextInput'; -import { SimpleFormIterator } from '../form/SimpleFormIterator'; +import { ArrayInput } from './ArrayInput'; +import NumberInput from '../NumberInput'; +import TextInput from '../TextInput'; +import { SimpleFormIterator } from './SimpleFormIterator'; import { minLength, required } from 'ra-core'; +const theme = createTheme(); + describe('', () => { const onSubmit = jest.fn(); const mutators = { ...arrayMutators }; @@ -61,33 +65,37 @@ describe('', () => { it('should not create any section subform when the value is undefined', () => { const { baseElement } = render( - ( -
- - - -
- )} - /> + + ( +
+ + + +
+ )} + /> +
); expect(baseElement.querySelectorAll('section')).toHaveLength(0); }); it('should create one section subform per value in the array', () => { const { baseElement } = render( - ( -
- - - -
- )} - /> + + ( +
+ + + +
+ )} + /> +
); expect(baseElement.querySelectorAll('section')).toHaveLength(3); }); @@ -100,19 +108,21 @@ describe('', () => { ], }; const { queryAllByLabelText } = render( - ( -
- - - - - - -
- )} - /> + + ( +
+ + + + + + +
+ )} + /> +
); expect(queryAllByLabelText('resources.bar.fields.id')).toHaveLength(2); expect( @@ -130,28 +140,30 @@ describe('', () => { it('should apply validation to both itself and its inner inputs', async () => { const { getByText, getAllByLabelText, queryByText } = render( - ( -
- - - - - - -
- )} - /> + + ( +
+ + + + + + +
+ )} + /> +
); fireEvent.click(getByText('ra.action.add')); diff --git a/packages/ra-ui-materialui/src/input/ArrayInput.tsx b/packages/ra-ui-materialui/src/input/ArrayInput/ArrayInput.tsx similarity index 87% rename from packages/ra-ui-materialui/src/input/ArrayInput.tsx rename to packages/ra-ui-materialui/src/input/ArrayInput/ArrayInput.tsx index 5fd16771270..0c976aa86b6 100644 --- a/packages/ra-ui-materialui/src/input/ArrayInput.tsx +++ b/packages/ra-ui-materialui/src/input/ArrayInput/ArrayInput.tsx @@ -9,11 +9,12 @@ import { } from 'ra-core'; import { useFieldArray } from 'react-final-form-arrays'; import { InputLabel, FormControl, FormHelperText } from '@material-ui/core'; -import InputHelperText from './InputHelperText'; -import sanitizeInputRestProps from './sanitizeInputRestProps'; -import Labeled from './Labeled'; -import { LinearProgress } from '../layout'; +import { LinearProgress } from '../../layout'; +import InputHelperText from '../InputHelperText'; +import sanitizeInputRestProps from '../sanitizeInputRestProps'; +import Labeled from '../Labeled'; +import { ArrayInputContext } from './ArrayInputContext'; /** * To edit arrays of data embedded inside a record, creates a list of sub-forms. @@ -56,7 +57,7 @@ import { LinearProgress } from '../layout'; * * @see https://github.com/final-form/react-final-form-arrays */ -const ArrayInput: FC = ({ +export const ArrayInput: FC = ({ className, defaultValue, label, @@ -120,15 +121,17 @@ const ArrayInput: FC = ({ isRequired={isRequired(validate)} /> - {cloneElement(Children.only(children), { - ...fieldProps, - record, - resource, - source, - variant, - margin, - disabled, - })} + + {cloneElement(Children.only(children), { + ...fieldProps, + record, + resource, + source, + variant, + margin, + disabled, + })} + {!!((touched || dirty) && arrayInputError) || helperText ? ( ( + undefined +); + +export type ArrayInputContextValue = FieldArrayRenderProps; diff --git a/packages/ra-ui-materialui/src/input/ArrayInput/ReOrderButtons.tsx b/packages/ra-ui-materialui/src/input/ArrayInput/ReOrderButtons.tsx new file mode 100644 index 00000000000..83bd8418ebe --- /dev/null +++ b/packages/ra-ui-materialui/src/input/ArrayInput/ReOrderButtons.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; + +import { IconButtonWithTooltip } from '../../button'; +import ArrowUpwardIcon from '@material-ui/icons/ArrowUpward'; +import ArrowDownwardIcon from '@material-ui/icons/ArrowDownward'; +import { useSimpleFormIteratorItem } from './useSimpleFormIteratorItem'; + +export const ReOrderButtons = ({ className }: { className?: string }) => { + const { index, total, reOrder } = useSimpleFormIteratorItem(); + + return ( +
+ reOrder(index - 1)} + disabled={index <= 0} + > + + + reOrder(index + 1)} + disabled={total == null || index >= total - 1} + > + + +
+ ); +}; diff --git a/packages/ra-ui-materialui/src/input/ArrayInput/RemoveItemButton.tsx b/packages/ra-ui-materialui/src/input/ArrayInput/RemoveItemButton.tsx new file mode 100644 index 00000000000..ab221ea46d4 --- /dev/null +++ b/packages/ra-ui-materialui/src/input/ArrayInput/RemoveItemButton.tsx @@ -0,0 +1,15 @@ +import * as React from 'react'; +import CloseIcon from '@material-ui/icons/RemoveCircleOutline'; + +import { Button, ButtonProps } from '../../button'; +import { useSimpleFormIteratorItem } from './useSimpleFormIteratorItem'; + +export const RemoveItemButton = (props: Omit) => { + const { remove } = useSimpleFormIteratorItem(); + + return ( + + ); +}; diff --git a/packages/ra-ui-materialui/src/form/SimpleFormIterator.spec.tsx b/packages/ra-ui-materialui/src/input/ArrayInput/SimpleFormIterator.spec.tsx similarity index 83% rename from packages/ra-ui-materialui/src/form/SimpleFormIterator.spec.tsx rename to packages/ra-ui-materialui/src/input/ArrayInput/SimpleFormIterator.spec.tsx index 7527006de5a..c4bad1da2d9 100644 --- a/packages/ra-ui-materialui/src/form/SimpleFormIterator.spec.tsx +++ b/packages/ra-ui-materialui/src/input/ArrayInput/SimpleFormIterator.spec.tsx @@ -1,13 +1,13 @@ +import * as React from 'react'; import { ThemeProvider } from '@material-ui/core'; import { createTheme } from '@material-ui/core/styles'; import { fireEvent, getByText, waitFor } from '@testing-library/react'; import expect from 'expect'; import { SaveContextProvider, SideEffectContextProvider } from 'ra-core'; import { renderWithRedux } from 'ra-test'; -import * as React from 'react'; -import { ArrayInput } from '../input'; -import TextInput from '../input/TextInput'; -import SimpleForm from './SimpleForm'; +import SimpleForm from '../../form/SimpleForm'; +import { ArrayInput } from './ArrayInput'; +import TextInput from '../TextInput'; import { SimpleFormIterator } from './SimpleFormIterator'; const theme = createTheme(); @@ -123,17 +123,19 @@ describe('', () => { it('should display an add item button at least', () => { const { getByText } = renderWithRedux( - - - - - - - - - - - + + + + + + + + + + + + + ); expect(getByText('ra.action.add')).not.toBeNull(); @@ -231,17 +233,19 @@ describe('', () => { queryAllByLabelText, queryAllByText, } = renderWithRedux( - - - - - - - - - - - + + + + + + + + + + + + + ); const addItemElement = getByText('ra.action.add').closest('button'); @@ -283,17 +287,22 @@ describe('', () => { queryAllByLabelText, queryAllByText, } = renderWithRedux( - - - - - - - - - - - + + + + + + + + + + + + + ); const addItemElement = getByText('ra.action.add').closest('button'); @@ -322,21 +331,23 @@ describe('', () => { queryAllByLabelText, queryAllByText, } = renderWithRedux( - - - - - - - - - - - + + + + + + + + + + + + + ); const addItemElement = getByText('ra.action.add').closest('button'); @@ -469,19 +480,23 @@ describe('', () => { it('should not display the default add button if a custom add button is passed', () => { const { getByText, queryAllByText } = renderWithRedux( - - - - - Custom Add Button} - > - - - - - - + + + + + + Custom Add Button + } + > + + + + + + + ); expect(queryAllByText('ra.action.add').length).toBe(0); @@ -516,6 +531,7 @@ describe('', () => { }); it('should not display the default reorder element if a custom reorder element is passed', () => { + const ReOrderButton = () => ; const { getByText, queryAllByLabelText } = renderWithRedux( @@ -525,9 +541,7 @@ describe('', () => { > Custom reorder Button - } + reOrderButtons={} > @@ -629,19 +643,23 @@ describe('', () => { it('should display the custom add button', () => { const { getByText } = renderWithRedux( - - - - - Custom Add Button} - > - - - - - - + + + + + + Custom Add Button + } + > + + + + + + + ); expect(getByText('Custom Add Button')).not.toBeNull(); diff --git a/packages/ra-ui-materialui/src/input/ArrayInput/SimpleFormIterator.tsx b/packages/ra-ui-materialui/src/input/ArrayInput/SimpleFormIterator.tsx new file mode 100644 index 00000000000..fa8b1815e64 --- /dev/null +++ b/packages/ra-ui-materialui/src/input/ArrayInput/SimpleFormIterator.tsx @@ -0,0 +1,241 @@ +import * as React from 'react'; +import { + cloneElement, + MouseEvent, + MouseEventHandler, + ReactElement, + ReactNode, + useCallback, + useMemo, + useRef, +} from 'react'; +import { FormHelperText } from '@material-ui/core'; +import classNames from 'classnames'; +import get from 'lodash/get'; +import PropTypes from 'prop-types'; +import { Record, ValidationError } from 'ra-core'; +import { FieldArrayRenderProps } from 'react-final-form-arrays'; +import { CSSTransition, TransitionGroup } from 'react-transition-group'; +import { CSSTransitionProps } from 'react-transition-group/CSSTransition'; + +import { ClassesOverride } from '../../types'; +import { useArrayInput } from './useArrayInput'; +import { useSimpleFormIteratorStyles } from './useSimpleFormIteratorStyles'; +import { SimpleFormIteratorContext } from './SimpleFormIteratorContext'; +import { + DisableRemoveFunction, + SimpleFormIteratorItem, +} from './SimpleFormIteratorItem'; +import { AddItemButton as DefaultAddItemButton } from './AddItemButton'; +import { RemoveItemButton as DefaultRemoveItemButton } from './RemoveItemButton'; +import { ReOrderButtons as DefaultReOrderButtons } from './ReOrderButtons'; + +export const SimpleFormIterator = (props: SimpleFormIteratorProps) => { + const { + addButton = , + removeButton = , + reOrderButtons = , + basePath, + children, + className, + record, + resource, + source, + disabled, + disableAdd, + disableRemove, + disableReordering, + variant, + margin, + TransitionProps, + defaultValue, + getItemLabel = DefaultLabelFn, + } = props; + const classes = useSimpleFormIteratorStyles(props); + const { fields, meta } = useArrayInput(props); + const { error, submitFailed } = meta; + const nodeRef = useRef(null); + + // We need a unique id for each field for a proper enter/exit animation + // so we keep an internal map between the field position and an auto-increment id + const nextId = useRef( + fields && fields.length + ? fields.length + : defaultValue + ? defaultValue.length + : 0 + ); + + // We check whether we have a defaultValue (which must be an array) before checking + // the fields prop which will always be empty for a new record. + // Without it, our ids wouldn't match the default value and we would get key warnings + // on the CssTransition element inside our render method + const ids = useRef( + nextId.current > 0 ? Array.from(Array(nextId.current).keys()) : [] + ); + + const removeField = useCallback( + (index: number) => { + ids.current.splice(index, 1); + fields.remove(index); + }, + [fields] + ); + + const addField = useCallback( + (item: any = undefined) => { + ids.current.push(nextId.current++); + fields.push(item); + }, + [fields] + ); + + // add field and call the onClick event of the button passed as addButton prop + const handleAddButtonClick = ( + originalOnClickHandler: MouseEventHandler + ) => (event: MouseEvent) => { + addField(); + if (originalOnClickHandler) { + originalOnClickHandler(event); + } + }; + + const handleReorder = useCallback( + (origin: number, destination: number) => { + const item = ids.current[origin]; + ids.current[origin] = ids.current[destination]; + ids.current[destination] = item; + fields.move(origin, destination); + }, + [fields] + ); + + const records = get(record, source); + + const context = useMemo( + () => ({ + total: fields.length, + add: addField, + remove: removeField, + reOrder: handleReorder, + }), + [fields.length, addField, removeField, handleReorder] + ); + return fields ? ( + +
    + {submitFailed && typeof error !== 'object' && error && ( + + + + )} + + {fields.map((member, index) => ( + + + {children} + + + ))} + + {!disabled && !disableAdd && ( +
  • + + {cloneElement(addButton, { + onClick: handleAddButtonClick( + addButton.props.onClick + ), + className: classNames( + 'button-add', + `button-add-${source}` + ), + })} + +
  • + )} +
+
+ ) : null; +}; + +SimpleFormIterator.defaultProps = { + disableAdd: false, + disableRemove: false, +}; + +SimpleFormIterator.propTypes = { + defaultValue: PropTypes.any, + addButton: PropTypes.element, + removeButton: PropTypes.element, + basePath: PropTypes.string, + children: PropTypes.node, + classes: PropTypes.object, + className: PropTypes.string, + // @ts-ignore + fields: PropTypes.object, + meta: PropTypes.object, + // @ts-ignore + record: PropTypes.object, + source: PropTypes.string, + resource: PropTypes.string, + translate: PropTypes.func, + disableAdd: PropTypes.bool, + disableRemove: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]), + TransitionProps: PropTypes.shape({}), +}; + +export interface SimpleFormIteratorProps + extends Partial, 'meta'>> { + addButton?: ReactElement; + basePath?: string; + children?: ReactNode; + classes?: ClassesOverride; + className?: string; + defaultValue?: any; + disabled?: boolean; + disableAdd?: boolean; + disableRemove?: boolean | DisableRemoveFunction; + disableReordering?: boolean; + getItemLabel?: (index: number) => string; + margin?: 'none' | 'normal' | 'dense'; + meta?: { + // the type defined in FieldArrayRenderProps says error is boolean, which is wrong. + error?: any; + submitFailed?: boolean; + }; + record?: Record; + removeButton?: ReactElement; + reOrderButtons?: ReactElement; + resource?: string; + source?: string; + TransitionProps?: CSSTransitionProps; + variant?: 'standard' | 'outlined' | 'filled'; +} + +const DefaultLabelFn = index => index + 1; diff --git a/packages/ra-ui-materialui/src/input/ArrayInput/SimpleFormIteratorContext.ts b/packages/ra-ui-materialui/src/input/ArrayInput/SimpleFormIteratorContext.ts new file mode 100644 index 00000000000..758f07cbb0b --- /dev/null +++ b/packages/ra-ui-materialui/src/input/ArrayInput/SimpleFormIteratorContext.ts @@ -0,0 +1,18 @@ +import { createContext } from 'react'; + +/** + * A React context that provides access to a SimpleFormIterator data (the total number of items) and mutators (add, reorder and remove). + * Useful to create custom array input iterators. + * @see {SimpleFormIterator} + * @see {ArrayInput} + */ +export const SimpleFormIteratorContext = createContext< + SimpleFormIteratorContextValue +>(undefined); + +export type SimpleFormIteratorContextValue = { + total: number; + add: () => void; + remove: (index: number) => void; + reOrder: (index: number, newIndex: number) => void; +}; diff --git a/packages/ra-ui-materialui/src/input/ArrayInput/SimpleFormIteratorItem.tsx b/packages/ra-ui-materialui/src/input/ArrayInput/SimpleFormIteratorItem.tsx new file mode 100644 index 00000000000..1112fb9ee3c --- /dev/null +++ b/packages/ra-ui-materialui/src/input/ArrayInput/SimpleFormIteratorItem.tsx @@ -0,0 +1,171 @@ +import * as React from 'react'; +import { + Children, + cloneElement, + MouseEvent, + MouseEventHandler, + isValidElement, + ReactElement, + ReactNode, + useMemo, +} from 'react'; +import { Typography } from '@material-ui/core'; +import classNames from 'classnames'; +import { Record } from 'ra-core'; + +import { ClassesOverride } from '../../types'; +import FormInput from '../../form/FormInput'; +import { useSimpleFormIteratorStyles } from './useSimpleFormIteratorStyles'; +import { useSimpleFormIterator } from './useSimpleFormIterator'; +import { ArrayInputContextValue } from './ArrayInputContext'; +import { + SimpleFormIteratorItemContext, + SimpleFormIteratorItemContextValue, +} from './SimpleFormIteratorItemContext'; + +export const SimpleFormIteratorItem = (props: SimpleFormIteratorItemProps) => { + const { + basePath, + children, + classes, + disabled, + disableReordering, + disableRemove, + getItemLabel, + index, + margin, + member, + record, + removeButton, + reOrderButtons, + resource, + source, + variant, + } = props; + + const { total, reOrder, remove } = useSimpleFormIterator(); + // Returns a boolean to indicate whether to disable the remove button for certain fields. + // If disableRemove is a function, then call the function with the current record to + // determining if the button should be disabled. Otherwise, use a boolean property that + // enables or disables the button for all of the fields. + const disableRemoveField = (record: Record) => { + if (typeof disableRemove === 'boolean') { + return disableRemove; + } + return disableRemove && disableRemove(record); + }; + + // remove field and call the onClick event of the button passed as removeButton prop + const handleRemoveButtonClick = ( + originalOnClickHandler: MouseEventHandler, + index: number + ) => (event: MouseEvent) => { + remove(index); + if (originalOnClickHandler) { + originalOnClickHandler(event); + } + }; + + const context = useMemo( + () => ({ + index, + total, + reOrder: newIndex => reOrder(index, newIndex), + remove: () => remove(index), + }), + [index, total, reOrder, remove] + ); + + return ( + +
  • +
    +
    + + {getItemLabel(index)} + + {!disabled && + !disableReordering && + cloneElement(reOrderButtons, { + index, + max: total, + reOrder, + className: classNames( + 'button-reorder', + `button-reorder-${source}-${index}` + ), + })} +
    +
    +
    + {Children.map(children, (input: ReactElement, index2) => { + if (!isValidElement(input)) { + return null; + } + const { source, ...inputProps } = input.props; + return ( + + ); + })} +
    + {!disabled && !disableRemoveField(record) && ( + + {cloneElement(removeButton, { + onClick: handleRemoveButtonClick( + removeButton.props.onClick, + index + ), + className: classNames( + 'button-remove', + `button-remove-${source}-${index}` + ), + })} + + )} +
  • +
    + ); +}; + +export type DisableRemoveFunction = (record: Record) => boolean; + +export type SimpleFormIteratorItemProps = ArrayInputContextValue & { + basePath: string; + children?: ReactNode; + classes?: ClassesOverride; + disabled?: boolean; + disableRemove?: boolean | DisableRemoveFunction; + disableReordering?: boolean; + getItemLabel?: (index: number) => string; + index: number; + margin?: 'none' | 'normal' | 'dense'; + member: string; + onRemoveField: (index: number) => void; + onReorder: (origin: number, destination: number) => void; + record: Record; + removeButton?: ReactElement; + reOrderButtons?: ReactElement; + resource: string; + source: string; + variant?: 'standard' | 'outlined' | 'filled'; +}; diff --git a/packages/ra-ui-materialui/src/input/ArrayInput/SimpleFormIteratorItemContext.ts b/packages/ra-ui-materialui/src/input/ArrayInput/SimpleFormIteratorItemContext.ts new file mode 100644 index 00000000000..faf993802b8 --- /dev/null +++ b/packages/ra-ui-materialui/src/input/ArrayInput/SimpleFormIteratorItemContext.ts @@ -0,0 +1,18 @@ +import { createContext } from 'react'; + +/** + * A React context that provides access to a SimpleFormIterator item meta (its index and the total number of items) and mutators (reorder and remove this remove). + * Useful to create custom array input iterators. + * @see {SimpleFormIterator} + * @see {ArrayInput} + */ +export const SimpleFormIteratorItemContext = createContext< + SimpleFormIteratorItemContextValue +>(undefined); + +export type SimpleFormIteratorItemContextValue = { + index: number; + total: number; + remove: () => void; + reOrder: (newIndex: number) => void; +}; diff --git a/packages/ra-ui-materialui/src/input/ArrayInput/index.ts b/packages/ra-ui-materialui/src/input/ArrayInput/index.ts new file mode 100644 index 00000000000..776db37c9db --- /dev/null +++ b/packages/ra-ui-materialui/src/input/ArrayInput/index.ts @@ -0,0 +1,2 @@ +export * from './ArrayInput'; +export * from './SimpleFormIterator'; diff --git a/packages/ra-ui-materialui/src/input/ArrayInput/useArrayInput.ts b/packages/ra-ui-materialui/src/input/ArrayInput/useArrayInput.ts new file mode 100644 index 00000000000..bcf7f384ac8 --- /dev/null +++ b/packages/ra-ui-materialui/src/input/ArrayInput/useArrayInput.ts @@ -0,0 +1,27 @@ +import { useContext, useMemo } from 'react'; +import { ArrayInputContext, ArrayInputContextValue } from './ArrayInputContext'; + +/** + * A hook to access an array input mutators and meta as provided by react-final-form-array. + * Useful to create custom array input iterators. + * @see {ArrayInput} + * @see {@link https://github.com/final-form/react-final-form-arrays|react-final-form-array} + */ +export const useArrayInput = ( + props?: Partial +): ArrayInputContextValue => { + const context = useContext(ArrayInputContext); + const memo = useMemo( + () => ({ + fields: props?.fields, + meta: props?.meta, + }), + [props] + ); + + if (props?.fields && props?.meta) { + return memo; + } + + return context; +}; diff --git a/packages/ra-ui-materialui/src/input/ArrayInput/useSimpleFormIterator.ts b/packages/ra-ui-materialui/src/input/ArrayInput/useSimpleFormIterator.ts new file mode 100644 index 00000000000..d93ee632ccb --- /dev/null +++ b/packages/ra-ui-materialui/src/input/ArrayInput/useSimpleFormIterator.ts @@ -0,0 +1,11 @@ +import { useContext } from 'react'; +import { SimpleFormIteratorContext } from './SimpleFormIteratorContext'; + +/** + * A hook that provides access to a SimpleFormIterator data (the total number of items) and mutators (add, reorder and remove). + * Useful to create custom array input iterators. + * @see {SimpleFormIterator} + * @see {ArrayInput} + */ +export const useSimpleFormIterator = () => + useContext(SimpleFormIteratorContext); diff --git a/packages/ra-ui-materialui/src/input/ArrayInput/useSimpleFormIteratorItem.ts b/packages/ra-ui-materialui/src/input/ArrayInput/useSimpleFormIteratorItem.ts new file mode 100644 index 00000000000..9c476976139 --- /dev/null +++ b/packages/ra-ui-materialui/src/input/ArrayInput/useSimpleFormIteratorItem.ts @@ -0,0 +1,11 @@ +import { useContext } from 'react'; +import { SimpleFormIteratorItemContext } from './SimpleFormIteratorItemContext'; + +/** + * A hook that provides access to a SimpleFormIterator item meta (its index and the total number of items) and mutators (reorder and remove this remove). + * Useful to create custom array input iterators. + * @see {SimpleFormIterator} + * @see {ArrayInput} + */ +export const useSimpleFormIteratorItem = () => + useContext(SimpleFormIteratorItemContext); diff --git a/packages/ra-ui-materialui/src/input/ArrayInput/useSimpleFormIteratorStyles.ts b/packages/ra-ui-materialui/src/input/ArrayInput/useSimpleFormIteratorStyles.ts new file mode 100644 index 00000000000..dd8578a28e5 --- /dev/null +++ b/packages/ra-ui-materialui/src/input/ArrayInput/useSimpleFormIteratorStyles.ts @@ -0,0 +1,55 @@ +import { makeStyles } from '@material-ui/core/styles'; + +export const useSimpleFormIteratorStyles = makeStyles( + theme => ({ + root: { + padding: 0, + marginBottom: 0, + '& > li:last-child': { + borderBottom: 'none', + }, + }, + line: { + display: 'flex', + listStyleType: 'none', + borderBottom: `solid 1px ${theme.palette.divider}`, + [theme.breakpoints.down('xs')]: { display: 'block' }, + '&.fade-enter': { + opacity: 0.01, + transform: 'translateX(100vw)', + }, + '&.fade-enter-active': { + opacity: 1, + transform: 'translateX(0)', + transition: 'all 500ms ease-in', + }, + '&.fade-exit': { + opacity: 1, + transform: 'translateX(0)', + }, + '&.fade-exit-active': { + opacity: 0.01, + transform: 'translateX(100vw)', + transition: 'all 500ms ease-in', + }, + }, + index: { + [theme.breakpoints.down('sm')]: { display: 'none' }, + marginRight: theme.spacing(1), + }, + indexContainer: { + display: 'flex', + paddingTop: '1em', + marginRight: theme.spacing(1), + alignItems: 'center', + }, + form: { flex: 2 }, + action: { + paddingTop: '0.5em', + }, + leftIcon: { + marginRight: theme.spacing(1), + }, + }), + { name: 'RaSimpleFormIterator' } +); diff --git a/packages/ra-ui-materialui/src/input/DateTimeInput.spec.tsx b/packages/ra-ui-materialui/src/input/DateTimeInput.spec.tsx index f7538bb2faf..e51b964950b 100644 --- a/packages/ra-ui-materialui/src/input/DateTimeInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/DateTimeInput.spec.tsx @@ -5,12 +5,15 @@ import { Form } from 'react-final-form'; import { required, FormWithRedirect } from 'ra-core'; import { renderWithRedux } from 'ra-test'; import format from 'date-fns/format'; +import { ThemeProvider } from '@material-ui/core'; +import { createTheme } from '@material-ui/core/styles'; import DateTimeInput from './DateTimeInput'; -import ArrayInput from './ArrayInput'; -import { SimpleFormIterator } from '../form/SimpleFormIterator'; +import { ArrayInput, SimpleFormIterator } from './ArrayInput'; import { FormApi } from 'final-form'; +const theme = createTheme(); + describe('', () => { const defaultProps = { resource: 'posts', @@ -56,22 +59,24 @@ describe('', () => { ]; let formApi: FormApi; const { getByDisplayValue } = renderWithRedux( - { - formApi = form; - return ( - - - - - - ); - }} - /> + + { + formApi = form; + return ( + + + + + + ); + }} + /> + ); expect(getByDisplayValue(format(date, 'YYYY-MM-DDTHH:mm'))); diff --git a/packages/ra-ui-materialui/src/input/index.ts b/packages/ra-ui-materialui/src/input/index.ts index d035541db21..f2c0987c286 100644 --- a/packages/ra-ui-materialui/src/input/index.ts +++ b/packages/ra-ui-materialui/src/input/index.ts @@ -1,4 +1,3 @@ -import ArrayInput, { ArrayInputProps } from './ArrayInput'; import AutocompleteArrayInput, { AutocompleteArrayInputProps, } from './AutocompleteArrayInput'; @@ -32,6 +31,7 @@ import SearchInput, { SearchInputProps } from './SearchInput'; import SelectArrayInput, { SelectArrayInputProps } from './SelectArrayInput'; import TextInput, { TextInputProps } from './TextInput'; import sanitizeInputRestProps from './sanitizeInputRestProps'; +export * from './ArrayInput'; export * from './AutocompleteInput'; export * from './SelectInput'; export * from './useSupportCreateSuggestion'; @@ -41,7 +41,6 @@ export * from './TranslatableInputsTabs'; export * from './TranslatableInputsTab'; export { - ArrayInput, AutocompleteArrayInput, BooleanInput, CheckboxGroupInput, @@ -66,7 +65,6 @@ export { }; export type { - ArrayInputProps, AutocompleteArrayInputProps, CheckboxGroupInputProps, DateInputProps,