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,