Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Add Initial Support For Creation in Choices Inputs #6215

Merged
merged 17 commits into from
Apr 27, 2021
Merged
504 changes: 504 additions & 0 deletions docs/Inputs.md

Large diffs are not rendered by default.

76 changes: 69 additions & 7 deletions examples/simple/src/comments/CommentEdit.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { Card, Typography } from '@material-ui/core';
import {
Card,
Typography,
Dialog,
DialogContent,
TextField as MuiTextField,
DialogActions,
Button,
} from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles';
import * as React from 'react';
import {
@@ -14,6 +22,8 @@ import {
Title,
minLength,
Record,
useCreateSuggestionContext,
useCreate,
} from 'react-admin'; // eslint-disable-line import/no-unresolved

const LinkToRelatedPost = ({ record }: { record?: Record }) => (
@@ -34,13 +44,64 @@ const useEditStyles = makeStyles({
},
});

const OptionRenderer = ({ record }: { record?: Record }) => (
<span>
{record?.title} - {record?.id}
</span>
);
const OptionRenderer = ({ record }: { record?: Record }) => {
return record.id === '@@ra-create' ? (
<span>{record.name}</span>
) : (
<span>
{record?.title} - {record?.id}
</span>
);
};

const inputText = record =>
record.id === '@@ra-create'
? record.name
: `${record.title} - ${record.id}`;

const inputText = record => `${record.title} - ${record.id}`;
const CreatePost = () => {
const { filter, onCancel, onCreate } = useCreateSuggestionContext();
const [value, setValue] = React.useState(filter || '');
const [create] = useCreate('posts');
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
create(
{
payload: {
data: {
title: value,
},
},
},
{
onSuccess: ({ data }) => {
setValue('');
const choice = data;
onCreate(choice);
},
}
);
return false;
};
return (
<Dialog open onClose={onCancel}>
<form onSubmit={handleSubmit}>
<DialogContent>
<MuiTextField
label="New post title"
value={value}
onChange={event => setValue(event.target.value)}
autoFocus
/>
</DialogContent>
<DialogActions>
<Button type="submit">Save</Button>
<Button onClick={onCancel}>Cancel</Button>
</DialogActions>
</form>
</Dialog>
);
};

const CommentEdit = props => {
const classes = useEditStyles();
@@ -86,6 +147,7 @@ const CommentEdit = props => {
fullWidth
>
<AutocompleteInput
create={<CreatePost />}
matchSuggestion={(
filterValue,
suggestion
323 changes: 193 additions & 130 deletions examples/simple/src/posts/PostEdit.tsx
Original file line number Diff line number Diff line change
@@ -28,13 +28,58 @@ import {
number,
required,
FormDataConsumer,
useCreateSuggestionContext,
EditActionsProps,
} from 'react-admin'; // eslint-disable-line import/no-unresolved
import { Box, BoxProps } from '@material-ui/core';
import {
Box,
BoxProps,
Button,
Dialog,
DialogActions,
DialogContent,
TextField as MuiTextField,
} from '@material-ui/core';

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

const EditActions = ({ basePath, data, hasShow }: any) => (
const CreateCategory = ({
onAddChoice,
}: {
onAddChoice: (record: any) => void;
}) => {
const { filter, onCancel, onCreate } = useCreateSuggestionContext();
const [value, setValue] = React.useState(filter || '');
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
const choice = { name: value, id: value.toLowerCase() };
onAddChoice(choice);
onCreate(choice);
setValue('');
return false;
};
return (
<Dialog open onClose={onCancel}>
<form onSubmit={handleSubmit}>
<DialogContent>
<MuiTextField
label="New Category"
value={value}
onChange={event => setValue(event.target.value)}
autoFocus
/>
</DialogContent>
<DialogActions>
<Button type="submit">Save</Button>
<Button onClick={onCancel}>Cancel</Button>
</DialogActions>
</form>
</Dialog>
);
};

const EditActions = ({ basePath, data, hasShow }: EditActionsProps) => (
<TopToolbar>
<CloneButton
className="button-clone"
@@ -51,140 +96,158 @@ const SanitizedBox = ({
...props
}: BoxProps & { fullWidth?: boolean; basePath?: string }) => <Box {...props} />;

const PostEdit = ({ permissions, ...props }) => (
<Edit title={<PostTitle />} actions={<EditActions />} {...props}>
<TabbedForm initialValues={{ average_note: 0 }} warnWhenUnsavedChanges>
<FormTab label="post.form.summary">
<SanitizedBox
display="flex"
flexDirection="column"
width="100%"
justifyContent="space-between"
fullWidth
>
<TextInput disabled source="id" />
const categories = [
{ name: 'Tech', id: 'tech' },
{ name: 'Lifestyle', id: 'lifestyle' },
];

const PostEdit = ({ permissions, ...props }) => {
return (
<Edit title={<PostTitle />} actions={<EditActions />} {...props}>
<TabbedForm
initialValues={{ average_note: 0 }}
warnWhenUnsavedChanges
>
<FormTab label="post.form.summary">
<SanitizedBox
display="flex"
flexDirection="column"
width="100%"
justifyContent="space-between"
fullWidth
>
<TextInput disabled source="id" />
<TextInput
source="title"
validate={required()}
resettable
/>
</SanitizedBox>
<TextInput
source="title"
multiline={true}
fullWidth={true}
source="teaser"
validate={required()}
resettable
/>
</SanitizedBox>
<TextInput
multiline={true}
fullWidth={true}
source="teaser"
validate={required()}
resettable
/>
<CheckboxGroupInput
source="notifications"
choices={[
{ id: 12, name: 'Ray Hakt' },
{ id: 31, name: 'Ann Gullar' },
{ id: 42, name: 'Sean Phonee' },
]}
/>
<ImageInput multiple source="pictures" accept="image/*">
<ImageField source="src" title="title" />
</ImageInput>
{permissions === 'admin' && (
<ArrayInput source="authors">
<CheckboxGroupInput
source="notifications"
choices={[
{ id: 12, name: 'Ray Hakt' },
{ id: 31, name: 'Ann Gullar' },
{ id: 42, name: 'Sean Phonee' },
]}
/>
<ImageInput multiple source="pictures" accept="image/*">
<ImageField source="src" title="title" />
</ImageInput>
{permissions === 'admin' && (
<ArrayInput source="authors">
<SimpleFormIterator>
<ReferenceInput
label="User"
source="user_id"
reference="users"
>
<AutocompleteInput />
</ReferenceInput>
<FormDataConsumer>
{({
formData,
scopedFormData,
getSource,
...rest
}) =>
scopedFormData &&
scopedFormData.user_id ? (
<SelectInput
label="Role"
source={getSource('role')}
choices={[
{
id: 'headwriter',
name: 'Head Writer',
},
{
id: 'proofreader',
name: 'Proof reader',
},
{
id: 'cowriter',
name: 'Co-Writer',
},
]}
{...rest}
/>
) : null
}
</FormDataConsumer>
</SimpleFormIterator>
</ArrayInput>
)}
</FormTab>
<FormTab label="post.form.body">
<RichTextInput
source="body"
label=""
validate={required()}
addLabel={false}
/>
</FormTab>
<FormTab label="post.form.miscellaneous">
<TagReferenceInput
reference="tags"
source="tags"
label="Tags"
/>
<ArrayInput source="backlinks">
<SimpleFormIterator>
<ReferenceInput
label="User"
source="user_id"
reference="users"
>
<AutocompleteInput />
</ReferenceInput>
<FormDataConsumer>
{({
formData,
scopedFormData,
getSource,
...rest
}) =>
scopedFormData && scopedFormData.user_id ? (
<SelectInput
label="Role"
source={getSource('role')}
choices={[
{
id: 'headwriter',
name: 'Head Writer',
},
{
id: 'proofreader',
name: 'Proof reader',
},
{
id: 'cowriter',
name: 'Co-Writer',
},
]}
{...rest}
/>
) : null
}
</FormDataConsumer>
<DateInput source="date" />
<TextInput source="url" validate={required()} />
</SimpleFormIterator>
</ArrayInput>
)}
</FormTab>
<FormTab label="post.form.body">
<RichTextInput
source="body"
label=""
validate={required()}
addLabel={false}
/>
</FormTab>
<FormTab label="post.form.miscellaneous">
<TagReferenceInput
reference="tags"
source="tags"
label="Tags"
/>
<ArrayInput source="backlinks">
<SimpleFormIterator>
<DateInput source="date" />
<TextInput source="url" validate={required()} />
</SimpleFormIterator>
</ArrayInput>
<DateInput source="published_at" />
<SelectInput
allowEmpty
resettable
source="category"
choices={[
{ name: 'Tech', id: 'tech' },
{ name: 'Lifestyle', id: 'lifestyle' },
]}
/>
<NumberInput
source="average_note"
validate={[required(), number(), minValue(0)]}
/>
<BooleanInput source="commentable" defaultValue />
<TextInput disabled source="views" />
</FormTab>
<FormTab label="post.form.comments">
<ReferenceManyField
reference="comments"
target="post_id"
addLabel={false}
fullWidth
>
<Datagrid>
<DateField source="created_at" />
<TextField source="author.name" />
<TextField source="body" />
<EditButton />
</Datagrid>
</ReferenceManyField>
</FormTab>
</TabbedForm>
</Edit>
);
<DateInput
source="published_at"
options={{ locale: 'pt' }}
/>
<SelectInput
create={
<CreateCategory
// Added on the component because we have to update the choices
// ourselves as we don't use a ReferenceInput
onAddChoice={choice => categories.push(choice)}
/>
}
allowEmpty
resettable
source="category"
choices={categories}
/>
<NumberInput
source="average_note"
validate={[required(), number(), minValue(0)]}
/>
<BooleanInput source="commentable" defaultValue />
<TextInput disabled source="views" />
</FormTab>
<FormTab label="post.form.comments">
<ReferenceManyField
reference="comments"
target="post_id"
addLabel={false}
fullWidth
>
<Datagrid>
<DateField source="created_at" />
<TextField source="author.name" />
<TextField source="body" />
<EditButton />
</Datagrid>
</ReferenceManyField>
</FormTab>
</TabbedForm>
</Edit>
);
};

export default PostEdit;
4 changes: 2 additions & 2 deletions examples/simple/src/posts/PostTitle.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as React from 'react';
import { useTranslate, Record } from 'react-admin';
import { TitleProps, useTranslate } from 'react-admin';

export default ({ record }: { record?: Record }) => {
export default ({ record }: TitleProps) => {
const translate = useTranslate();
return (
<span>
66 changes: 63 additions & 3 deletions examples/simple/src/posts/TagReferenceInput.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
import * as React from 'react';
import { useState } from 'react';
import { useForm } from 'react-final-form';
import { AutocompleteArrayInput, ReferenceArrayInput } from 'react-admin';
import { Button } from '@material-ui/core';
import {
AutocompleteArrayInput,
ReferenceArrayInput,
useCreate,
useCreateSuggestionContext,
} from 'react-admin';
import {
Button,
Dialog,
DialogContent,
DialogActions,
TextField as MuiTextField,
} from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles';

const useStyles = makeStyles({
@@ -37,7 +48,10 @@ const TagReferenceInput = ({
return (
<div className={classes.input}>
<ReferenceArrayInput {...props} filter={{ published: filter }}>
<AutocompleteArrayInput optionText="name.en" />
<AutocompleteArrayInput
create={<CreateTag />}
optionText="name.en"
/>
</ReferenceArrayInput>
<Button
name="change-filter"
@@ -50,4 +64,50 @@ const TagReferenceInput = ({
);
};

const CreateTag = () => {
const { filter, onCancel, onCreate } = useCreateSuggestionContext();
const [value, setValue] = React.useState(filter || '');
const [create] = useCreate('tags');
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
create(
{
payload: {
data: {
name: {
en: value,
},
},
},
},
{
onSuccess: ({ data }) => {
setValue('');
const choice = data;
onCreate(choice);
},
}
);
return false;
};
return (
<Dialog open onClose={onCancel}>
<form onSubmit={handleSubmit}>
<DialogContent>
<MuiTextField
label="New tag"
value={value}
onChange={event => setValue(event.target.value)}
autoFocus
/>
</DialogContent>
<DialogActions>
<Button type="submit">Save</Button>
<Button onClick={onCancel}>Cancel</Button>
</DialogActions>
</form>
</Dialog>
);
};

export default TagReferenceInput;
Original file line number Diff line number Diff line change
@@ -84,6 +84,7 @@ describe('<ReferenceFieldController />', () => {
referenceRecord: { id: 123, title: 'foo' },
resourceLinkPath: '/posts/123',
error: null,
refetch: expect.any(Function),
});
});

@@ -116,6 +117,7 @@ describe('<ReferenceFieldController />', () => {
referenceRecord: { id: 123, title: 'foo' },
resourceLinkPath: '/prefix/posts/123',
error: null,
refetch: expect.any(Function),
});
});

@@ -148,6 +150,7 @@ describe('<ReferenceFieldController />', () => {
referenceRecord: { id: 123, title: 'foo' },
resourceLinkPath: '/edit/123',
error: null,
refetch: expect.any(Function),
});
});

@@ -180,6 +183,7 @@ describe('<ReferenceFieldController />', () => {
referenceRecord: { id: 123, title: 'foo' },
resourceLinkPath: '/show/123',
error: null,
refetch: expect.any(Function),
});
});

@@ -205,6 +209,7 @@ describe('<ReferenceFieldController />', () => {
referenceRecord: { id: 123, title: 'foo' },
resourceLinkPath: '/posts/123/show',
error: null,
refetch: expect.any(Function),
});
});

@@ -238,6 +243,7 @@ describe('<ReferenceFieldController />', () => {
referenceRecord: { id: 123, title: 'foo' },
resourceLinkPath: '/edit/123/show',
error: null,
refetch: expect.any(Function),
});
});

@@ -271,6 +277,7 @@ describe('<ReferenceFieldController />', () => {
referenceRecord: { id: 123, title: 'foo' },
resourceLinkPath: '/show/123/show',
error: null,
refetch: expect.any(Function),
});
});

@@ -296,6 +303,7 @@ describe('<ReferenceFieldController />', () => {
referenceRecord: { id: 123, title: 'foo' },
resourceLinkPath: false,
error: null,
refetch: expect.any(Function),
});
});
});
Original file line number Diff line number Diff line change
@@ -170,6 +170,7 @@ describe('<ReferenceInputController />', () => {
'possibleValues.showFilter',
])
).toEqual({
refetch: expect.any(Function),
possibleValues: {
basePath: '/comments',
currentSort: {
Original file line number Diff line number Diff line change
@@ -17,6 +17,7 @@ import { useSortState } from '..';
import useFilterState from '../useFilterState';
import useSelectionState from '../useSelectionState';
import { useResourceContext } from '../../core';
import { Refetch } from '../../dataProvider';

const defaultReferenceSource = (resource: string, source: string) =>
`${resource}@${source}`;
@@ -126,6 +127,7 @@ export const useReferenceInputController = (
// fetch current value
const {
referenceRecord,
refetch,
error: referenceError,
loading: referenceLoading,
loaded: referenceLoaded,
@@ -202,6 +204,7 @@ export const useReferenceInputController = (
loading: possibleValuesLoading || referenceLoading,
loaded: possibleValuesLoaded && referenceLoaded,
filter: filterValues,
refetch,
setFilter,
pagination,
setPagination,
@@ -238,6 +241,7 @@ export interface ReferenceInputValue {
setSort: (sort: SortPayload) => void;
sort: SortPayload;
warning?: string;
refetch: Refetch;
}

interface Option {
2 changes: 2 additions & 0 deletions packages/ra-core/src/controller/useReference.spec.tsx
Original file line number Diff line number Diff line change
@@ -143,6 +143,7 @@ describe('useReference', () => {
loading: true,
loaded: true,
error: null,
refetch: expect.any(Function),
});
});

@@ -171,6 +172,7 @@ describe('useReference', () => {
loading: true,
loaded: false,
error: null,
refetch: expect.any(Function),
});
});
});
8 changes: 6 additions & 2 deletions packages/ra-core/src/controller/useReference.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Record } from '../types';
import { useGetMany } from '../dataProvider';
import { Refetch, useGetMany } from '../dataProvider';

interface Option {
id: string;
@@ -11,6 +11,7 @@ export interface UseReferenceProps {
loaded: boolean;
referenceRecord?: Record;
error?: any;
refetch: Refetch;
}

/**
@@ -41,9 +42,12 @@ export interface UseReferenceProps {
* @returns {ReferenceProps} The reference record
*/
export const useReference = ({ reference, id }: Option): UseReferenceProps => {
const { data, error, loading, loaded } = useGetMany(reference, [id]);
const { data, error, loading, loaded, refetch } = useGetMany(reference, [
id,
]);
return {
referenceRecord: error ? undefined : data[0],
refetch,
error,
loading,
loaded,
3 changes: 3 additions & 0 deletions packages/ra-core/src/dataProvider/useGetMany.spec.tsx
Original file line number Diff line number Diff line change
@@ -217,6 +217,7 @@ describe('useGetMany', () => {
loading: true,
loaded: true,
error: null,
refetch: expect.any(Function),
});
});

@@ -257,6 +258,7 @@ describe('useGetMany', () => {
loading: false,
loaded: true,
error: null,
refetch: expect.any(Function),
});
});
});
@@ -310,6 +312,7 @@ describe('useGetMany', () => {
loading: true,
loaded: false,
error: null,
refetch: expect.any(Function),
});
});

87 changes: 57 additions & 30 deletions packages/ra-core/src/dataProvider/useGetMany.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import ReactDOM from 'react-dom';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
@@ -12,6 +12,8 @@ import { Identifier, Record, ReduxState, DataProviderProxy } from '../types';
import { useSafeSetState } from '../util/hooks';
import useDataProvider from './useDataProvider';
import { useEffect } from 'react';
import { useVersion } from '../controller';
import { Refetch } from './useQueryWithStore';

type Callback = (args?: any) => void;
type SetState = (args: any) => void;
@@ -34,6 +36,7 @@ interface UseGetManyResult {
error?: any;
loading: boolean;
loaded: boolean;
refetch: Refetch;
}
let queriesToCall: QueriesToCall = {};
let dataProvider: DataProviderProxy;
@@ -99,13 +102,23 @@ const useGetMany = (
const data = useSelector((state: ReduxState) =>
selectMany(state, resource, ids)
);
const version = useVersion(); // used to allow force reload
// used to force a refetch without relying on version
// which might trigger other queries as well
const [innerVersion, setInnerVersion] = useSafeSetState(0);

const refetch = useCallback(() => {
setInnerVersion(prevInnerVersion => prevInnerVersion + 1);
}, [setInnerVersion]);

const [state, setState] = useSafeSetState({
data,
error: null,
loading: ids.length !== 0,
loaded:
ids.length === 0 ||
(data.length !== 0 && !data.includes(undefined)),
refetch,
});
if (!isEqual(state.data, data)) {
setState({
@@ -115,36 +128,50 @@ const useGetMany = (
});
}
dataProvider = useDataProvider(); // not the best way to pass the dataProvider to a function outside the hook, but I couldn't find a better one
useEffect(() => {
if (options.enabled === false) {
return;
}
useEffect(
() => {
if (options.enabled === false) {
return;
}

if (!queriesToCall[resource]) {
queriesToCall[resource] = [];
}
/**
* queriesToCall stores the queries to call under the following shape:
*
* {
* 'posts': [
* { ids: [1, 2], setState }
* { ids: [2, 3], setState, onSuccess }
* { ids: [4, 5], setState }
* ],
* 'comments': [
* { ids: [345], setState, onFailure }
* ]
* }
*/
queriesToCall[resource] = queriesToCall[resource].concat({
ids,
setState,
onSuccess: options && options.onSuccess,
onFailure: options && options.onFailure,
});
callQueries(); // debounced by lodash
}, [JSON.stringify({ resource, ids, options }), dataProvider]); // eslint-disable-line react-hooks/exhaustive-deps
if (!queriesToCall[resource]) {
queriesToCall[resource] = [];
}
/**
* queriesToCall stores the queries to call under the following shape:
*
* {
* 'posts': [
* { ids: [1, 2], setState }
* { ids: [2, 3], setState, onSuccess }
* { ids: [4, 5], setState }
* ],
* 'comments': [
* { ids: [345], setState, onFailure }
* ]
* }
*/
queriesToCall[resource] = queriesToCall[resource].concat({
ids,
setState,
onSuccess: options && options.onSuccess,
onFailure: options && options.onFailure,
});
callQueries(); // debounced by lodash
},
/* eslint-disable react-hooks/exhaustive-deps */
[
JSON.stringify({
resource,
ids,
options,
version,
innerVersion,
}),
dataProvider,
]
/* eslint-enable react-hooks/exhaustive-deps */
);

return state;
};
4 changes: 2 additions & 2 deletions packages/ra-core/src/dataProvider/useQuery.ts
Original file line number Diff line number Diff line change
@@ -6,7 +6,7 @@ import useDataProvider from './useDataProvider';
import useDataProviderWithDeclarativeSideEffects from './useDataProviderWithDeclarativeSideEffects';
import { DeclarativeSideEffect } from './useDeclarativeSideEffects';
import useVersion from '../controller/useVersion';
import { DataProviderQuery } from './useQueryWithStore';
import { DataProviderQuery, Refetch } from './useQueryWithStore';

/**
* Call the data provider on mount
@@ -165,5 +165,5 @@ export type UseQueryValue = {
error?: any;
loading: boolean;
loaded: boolean;
refetch: () => void;
refetch: Refetch;
};
4 changes: 3 additions & 1 deletion packages/ra-core/src/dataProvider/useQueryWithStore.ts
Original file line number Diff line number Diff line change
@@ -14,13 +14,15 @@ export interface DataProviderQuery {
payload: object;
}

export type Refetch = () => void;

export interface UseQueryWithStoreValue {
data?: any;
total?: number;
error?: any;
loading: boolean;
loaded: boolean;
refetch: () => void;
refetch: Refetch;
}

export interface QueryOptions {
7 changes: 4 additions & 3 deletions packages/ra-core/src/form/useChoices.ts
Original file line number Diff line number Diff line change
@@ -8,7 +8,8 @@ import { InputProps } from '.';
export type OptionTextElement = ReactElement<{
record: Record;
}>;
export type OptionText = (choice: object) => string | OptionTextElement;
export type OptionTextFunc = (choice: object) => string | OptionTextElement;
export type OptionText = OptionTextElement | OptionTextFunc | string;

export interface ChoicesInputProps<T = any>
extends Omit<InputProps<T>, 'source'> {
@@ -22,13 +23,13 @@ export interface ChoicesInputProps<T = any>
export interface ChoicesProps {
choices: object[];
optionValue?: string;
optionText?: OptionTextElement | OptionText | string;
optionText?: OptionText;
translateChoice?: boolean;
}

export interface UseChoicesOptions {
optionValue?: string;
optionText?: OptionTextElement | OptionText | string;
optionText?: OptionText;
disableValue?: string;
translateChoice?: boolean;
}
20 changes: 15 additions & 5 deletions packages/ra-core/src/form/useSuggestions.spec.ts
Original file line number Diff line number Diff line change
@@ -10,15 +10,10 @@ describe('getSuggestions', () => {

const defaultOptions = {
choices,
allowEmpty: false,
emptyText: '',
emptyValue: null,
getChoiceText: ({ value }) => value,
getChoiceValue: ({ id }) => id,
limitChoicesToValue: false,
matchSuggestion: undefined,
optionText: 'value',
optionValue: 'id',
selectedItem: undefined,
};

@@ -86,6 +81,7 @@ describe('getSuggestions', () => {
})('one')
).toEqual([choices[0]]);
});

it('should add emptySuggestion if allowEmpty is true', () => {
expect(
getSuggestions({
@@ -100,6 +96,20 @@ describe('getSuggestions', () => {
]);
});

it('should add createSuggestion if allowCreate is true', () => {
expect(
getSuggestions({
...defaultOptions,
allowCreate: true,
})('')
).toEqual([
{ id: 1, value: 'one' },
{ id: 2, value: 'two' },
{ id: 3, value: 'three' },
{ id: '@@create', value: 'ra.action.create' },
]);
});

it('should limit the number of choices', () => {
expect(
getSuggestions({
122 changes: 86 additions & 36 deletions packages/ra-core/src/form/useSuggestions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useCallback, isValidElement } from 'react';
import set from 'lodash/set';
import useChoices, { UseChoicesOptions } from './useChoices';
import useChoices, { OptionText, UseChoicesOptions } from './useChoices';
import { useTranslate } from '../i18n';

/*
@@ -25,9 +25,12 @@ import { useTranslate } from '../i18n';
* - getSuggestions: A function taking a filter value (string) and returning the matching suggestions
*/
const useSuggestions = ({
allowCreate,
allowDuplicates,
allowEmpty,
choices,
createText = 'ra.action.create',
createValue = '@@create',
emptyText = '',
emptyValue = null,
limitChoicesToValue,
@@ -37,7 +40,7 @@ const useSuggestions = ({
selectedItem,
suggestionLimit = 0,
translateChoice,
}: Options) => {
}: UseSuggestionsOptions) => {
const translate = useTranslate();
const { getChoiceText, getChoiceValue } = useChoices({
optionText,
@@ -48,9 +51,12 @@ const useSuggestions = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
const getSuggestions = useCallback(
getSuggestionsFactory({
allowCreate,
allowDuplicates,
allowEmpty,
choices,
createText,
createValue,
emptyText: translate(emptyText, { _: emptyText }),
emptyValue,
getChoiceText,
@@ -63,9 +69,12 @@ const useSuggestions = ({
suggestionLimit,
}),
[
allowCreate,
allowDuplicates,
allowEmpty,
choices,
createText,
createValue,
emptyText,
emptyValue,
getChoiceText,
@@ -92,33 +101,45 @@ export default useSuggestions;
const escapeRegExp = value =>
value ? value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') : ''; // $& means the whole matched string

interface Options extends UseChoicesOptions {
choices: any[];
export interface UseSuggestionsOptions extends UseChoicesOptions {
allowCreate?: boolean;
allowDuplicates?: boolean;
allowEmpty?: boolean;
choices: any[];
createText?: string;
createValue?: any;
emptyText?: string;
emptyValue?: any;
limitChoicesToValue?: boolean;
matchSuggestion?: (filter: string, suggestion: any) => boolean;
matchSuggestion?: (
filter: string,
suggestion: any,
exact?: boolean
) => boolean;
suggestionLimit?: number;
selectedItem?: any | any[];
}

/**
* Default matcher implementation which check whether the suggestion text matches the filter.
*/
const defaultMatchSuggestion = getChoiceText => (filter, suggestion) => {
const defaultMatchSuggestion = getChoiceText => (
filter,
suggestion,
exact = false
) => {
const suggestionText = getChoiceText(suggestion);

const isReactElement = isValidElement(suggestionText);
const regex = escapeRegExp(filter);

return isReactElement
? false
: suggestionText &&
suggestionText.match(
// We must escape any RegExp reserved characters to avoid errors
// For example, the filter might contains * which must be escaped as \*
new RegExp(escapeRegExp(filter), 'i')
new RegExp(exact ? `^${regex}$` : regex, 'i')
);
};

@@ -145,19 +166,25 @@ const defaultMatchSuggestion = getChoiceText => (filter, suggestion) => {
* // Will return [{ id: 2, name: 'publisher' }]
*/
export const getSuggestionsFactory = ({
allowCreate = false,
allowDuplicates = false,
allowEmpty = false,
choices = [],
allowDuplicates,
allowEmpty,
emptyText,
emptyValue,
optionText,
optionValue,
createText = 'ra.action.create',
createValue = '@@create',
emptyText = '',
emptyValue = null,
optionText = 'name',
optionValue = 'id',
getChoiceText,
getChoiceValue,
limitChoicesToValue = false,
matchSuggestion = defaultMatchSuggestion(getChoiceText),
selectedItem,
suggestionLimit = 0,
}: UseSuggestionsOptions & {
getChoiceText: (choice: any) => string;
getChoiceValue: (choice: any) => string;
}) => filter => {
let suggestions = [];
// if an item is selected and matches the filter
@@ -195,15 +222,37 @@ export const getSuggestionsFactory = ({

suggestions = limitSuggestions(suggestions, suggestionLimit);

if (allowEmpty) {
suggestions = addEmptySuggestion(suggestions, {
optionText,
optionValue,
emptyText,
emptyValue,
});
const hasExactMatch = suggestions.some(suggestion =>
matchSuggestion(filter, suggestion, true)
);

const filterIsSelectedItem = !!selectedItem
? matchSuggestion(filter, selectedItem, true)
: false;

if (allowCreate) {
if (!hasExactMatch && !filterIsSelectedItem) {
suggestions.push(
getSuggestion({
optionText,
optionValue,
text: createText,
value: createValue,
})
);
}
}

if (allowEmpty) {
suggestions.unshift(
getSuggestion({
optionText,
optionValue,
text: emptyText,
value: emptyValue,
})
);
}
return suggestions;
};

@@ -257,31 +306,32 @@ const limitSuggestions = (suggestions: any[], limit: any = 0) =>
: suggestions;

/**
* addEmptySuggestion(
* addSuggestion(
* [{ id: 1, name: 'foo'}, { id: 2, name: 'bar' }],
* );
*
* // Will return [{ id: null, name: '' }, { id: 1, name: 'foo' }, , { id: 2, name: 'bar' }]
*
* @param suggestions List of suggestions
* @param options
* @param options.optionText
*/
const addEmptySuggestion = (
suggestions: any[],
{
optionText = 'name',
optionValue = 'id',
emptyText = '',
emptyValue = null,
}
) => {
let newSuggestions = suggestions;

const emptySuggestion = {};
set(emptySuggestion, optionValue, emptyValue);
const getSuggestion = ({
optionText = 'name',
optionValue = 'id',
text = '',
value = null,
}: {
optionText: OptionText;
optionValue: string;
text: string;
value: any;
}) => {
const suggestion = {};
set(suggestion, optionValue, value);
if (typeof optionText === 'string') {
set(emptySuggestion, optionText, emptyText);
set(suggestion, optionText, text);
}

return [].concat(emptySuggestion, newSuggestions);
return suggestion;
};
1 change: 1 addition & 0 deletions packages/ra-language-english/src/index.ts
Original file line number Diff line number Diff line change
@@ -12,6 +12,7 @@ const englishMessages: TranslationMessages = {
clone: 'Clone',
confirm: 'Confirm',
create: 'Create',
create_item: 'Create %{item}',
delete: 'Delete',
edit: 'Edit',
export: 'Export',
1 change: 1 addition & 0 deletions packages/ra-language-french/src/index.ts
Original file line number Diff line number Diff line change
@@ -13,6 +13,7 @@ const frenchMessages: TranslationMessages = {
clone: 'Dupliquer',
confirm: 'Confirmer',
create: 'Créer',
create_item: 'Créer %{item}',
delete: 'Supprimer',
edit: 'Éditer',
export: 'Exporter',
2 changes: 1 addition & 1 deletion packages/ra-ui-materialui/src/detail/editFieldTypes.tsx
Original file line number Diff line number Diff line change
@@ -10,7 +10,7 @@ import ReferenceInput from '../input/ReferenceInput';
import ReferenceArrayInput, {
ReferenceArrayInputProps,
} from '../input/ReferenceArrayInput';
import SelectInput from '../input/SelectInput';
import { SelectInput } from '../input/SelectInput';
import TextInput from '../input/TextInput';
import { InferredElement, InferredTypeMap, InputProps } from 'ra-core';

Original file line number Diff line number Diff line change
@@ -13,6 +13,7 @@ const sanitizeFieldRestProps: (props: any) => any = ({
link,
locale,
record,
refetch,
resource,
sortable,
sortBy,
174 changes: 174 additions & 0 deletions packages/ra-ui-materialui/src/input/AutocompleteArrayInput.spec.tsx
Original file line number Diff line number Diff line change
@@ -5,6 +5,7 @@ import expect from 'expect';

import AutocompleteArrayInput from './AutocompleteArrayInput';
import { TestTranslationProvider } from 'ra-core';
import { useCreateSuggestionContext } from './useSupportCreateSuggestion';

describe('<AutocompleteArrayInput />', () => {
const defaultProps = {
@@ -750,4 +751,177 @@ describe('<AutocompleteArrayInput />', () => {

expect(queryByRole('progressbar')).toBeNull();
});

it('should support creation of a new choice through the onCreate event', async () => {
const choices = [
{ id: 'ang', name: 'Angular' },
{ id: 'rea', name: 'React' },
];
const handleCreate = filter => {
const newChoice = {
id: 'js_fatigue',
name: filter,
};
choices.push(newChoice);
return newChoice;
};

const { getByLabelText, getByText, queryByText, rerender } = render(
<Form
validateOnBlur
onSubmit={jest.fn()}
render={() => (
<AutocompleteArrayInput
source="language"
resource="posts"
choices={choices}
onCreate={handleCreate}
/>
)}
/>
);

const input = getByLabelText('resources.posts.fields.language', {
selector: 'input',
}) as HTMLInputElement;
input.focus();
fireEvent.change(input, { target: { value: 'New Kid On The Block' } });
fireEvent.click(getByText('ra.action.create_item'));
await new Promise(resolve => setImmediate(resolve));
rerender(
<Form
validateOnBlur
onSubmit={jest.fn()}
render={() => (
<AutocompleteArrayInput
source="language"
resource="posts"
resettable
choices={choices}
onCreate={handleCreate}
/>
)}
/>
);

expect(queryByText('New Kid On The Block')).not.toBeNull();
});

it('should support creation of a new choice through the onCreate event with a promise', async () => {
const choices = [
{ id: 'ang', name: 'Angular' },
{ id: 'rea', name: 'React' },
];
const handleCreate = filter => {
return new Promise(resolve => {
const newChoice = {
id: 'js_fatigue',
name: filter,
};
choices.push(newChoice);
setImmediate(() => resolve(newChoice));
});
};

const { getByLabelText, getByText, queryByText, rerender } = render(
<Form
validateOnBlur
onSubmit={jest.fn()}
render={() => (
<AutocompleteArrayInput
source="language"
resource="posts"
choices={choices}
onCreate={handleCreate}
resettable
/>
)}
/>
);

const input = getByLabelText('resources.posts.fields.language', {
selector: 'input',
}) as HTMLInputElement;
input.focus();
fireEvent.change(input, { target: { value: 'New Kid On The Block' } });
fireEvent.click(getByText('ra.action.create_item'));
await new Promise(resolve => setImmediate(resolve));
rerender(
<Form
validateOnBlur
onSubmit={jest.fn()}
render={() => (
<AutocompleteArrayInput
source="language"
resource="posts"
resettable
choices={choices}
onCreate={handleCreate}
/>
)}
/>
);

expect(queryByText('New Kid On The Block')).not.toBeNull();
});

it('should support creation of a new choice through the create element', async () => {
const choices = [
{ id: 'ang', name: 'Angular' },
{ id: 'rea', name: 'React' },
];
const newChoice = { id: 'js_fatigue', name: 'New Kid On The Block' };

const Create = () => {
const context = useCreateSuggestionContext();
const handleClick = () => {
choices.push(newChoice);
context.onCreate(newChoice);
};

return <button onClick={handleClick}>Get the kid</button>;
};

const { getByLabelText, rerender, getByText, queryByText } = render(
<Form
validateOnBlur
onSubmit={jest.fn()}
render={() => (
<AutocompleteArrayInput
source="language"
resource="posts"
choices={choices}
create={<Create />}
resettable
/>
)}
/>
);

const input = getByLabelText('resources.posts.fields.language', {
selector: 'input',
}) as HTMLInputElement;
input.focus();
fireEvent.change(input, { target: { value: 'New Kid On The Block' } });
fireEvent.click(getByText('ra.action.create_item'));
fireEvent.click(getByText('Get the kid'));
await new Promise(resolve => setImmediate(resolve));
rerender(
<Form
validateOnBlur
onSubmit={jest.fn()}
render={() => (
<AutocompleteArrayInput
source="language"
resource="posts"
resettable
choices={choices}
create={<Create />}
/>
)}
/>
);

expect(queryByText('New Kid On The Block')).not.toBeNull();
});
});
317 changes: 178 additions & 139 deletions packages/ra-ui-materialui/src/input/AutocompleteArrayInput.tsx

Large diffs are not rendered by default.

181 changes: 176 additions & 5 deletions packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import * as React from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react';

import AutocompleteInput from './AutocompleteInput';
import { AutocompleteInput } from './AutocompleteInput';
import { Form } from 'react-final-form';
import { TestTranslationProvider } from 'ra-core';
import { useCreateSuggestionContext } from './useSupportCreateSuggestion';

describe('<AutocompleteInput />', () => {
// Fix document.createRange is not a function error on fireEvent usage (Fixed in jsdom v16.0.0)
@@ -684,19 +685,189 @@ describe('<AutocompleteInput />', () => {

it('should not render a LinearProgress if loading is false', () => {
const { queryByRole } = render(
<Form
validateOnBlur
onSubmit={jest.fn()}
render={() => <AutocompleteInput {...defaultProps} />}
/>
);

expect(queryByRole('progressbar')).toBeNull();
});

it('should support creation of a new choice through the onCreate event', async () => {
const choices = [
{ id: 'ang', name: 'Angular' },
{ id: 'rea', name: 'React' },
];
const handleCreate = filter => {
const newChoice = {
id: 'js_fatigue',
name: filter,
};
choices.push(newChoice);
return newChoice;
};

const { getByLabelText, getByText, queryByText, rerender } = render(
<Form
validateOnBlur
onSubmit={jest.fn()}
render={() => (
<AutocompleteInput
{...{
...defaultProps,
}}
source="language"
resource="posts"
choices={choices}
onCreate={handleCreate}
/>
)}
/>
);

expect(queryByRole('progressbar')).toBeNull();
const input = getByLabelText('resources.posts.fields.language', {
selector: 'input',
}) as HTMLInputElement;
input.focus();
fireEvent.change(input, { target: { value: 'New Kid On The Block' } });
fireEvent.click(getByText('ra.action.create_item'));
await new Promise(resolve => setImmediate(resolve));
rerender(
<Form
validateOnBlur
onSubmit={jest.fn()}
render={() => (
<AutocompleteInput
source="language"
resource="posts"
resettable
choices={choices}
onCreate={handleCreate}
/>
)}
/>
);
fireEvent.click(getByLabelText('ra.action.clear_input_value'));

expect(queryByText('New Kid On The Block')).not.toBeNull();
});

it('should support creation of a new choice through the onCreate event with a promise', async () => {
const choices = [
{ id: 'ang', name: 'Angular' },
{ id: 'rea', name: 'React' },
];
const handleCreate = filter => {
return new Promise(resolve => {
const newChoice = {
id: 'js_fatigue',
name: filter,
};
choices.push(newChoice);
setTimeout(() => resolve(newChoice), 100);
});
};

const { getByLabelText, getByText, queryByText, rerender } = render(
<Form
validateOnBlur
onSubmit={jest.fn()}
render={() => (
<AutocompleteInput
source="language"
resource="posts"
choices={choices}
onCreate={handleCreate}
resettable
/>
)}
/>
);

const input = getByLabelText('resources.posts.fields.language', {
selector: 'input',
}) as HTMLInputElement;
input.focus();
fireEvent.change(input, { target: { value: 'New Kid On The Block' } });
fireEvent.click(getByText('ra.action.create_item'));
await new Promise(resolve => setImmediate(resolve));
rerender(
<Form
validateOnBlur
onSubmit={jest.fn()}
render={() => (
<AutocompleteInput
source="language"
resource="posts"
resettable
choices={choices}
onCreate={handleCreate}
/>
)}
/>
);
fireEvent.click(getByLabelText('ra.action.clear_input_value'));

expect(queryByText('New Kid On The Block')).not.toBeNull();
});

it('should support creation of a new choice through the create element', async () => {
const choices = [
{ id: 'ang', name: 'Angular' },
{ id: 'rea', name: 'React' },
];
const newChoice = { id: 'js_fatigue', name: 'New Kid On The Block' };

const Create = () => {
const context = useCreateSuggestionContext();
const handleClick = () => {
choices.push(newChoice);
context.onCreate(newChoice);
};

return <button onClick={handleClick}>Get the kid</button>;
};

const { getByLabelText, rerender, getByText, queryByText } = render(
<Form
validateOnBlur
onSubmit={jest.fn()}
render={() => (
<AutocompleteInput
source="language"
resource="posts"
choices={choices}
create={<Create />}
resettable
/>
)}
/>
);

const input = getByLabelText('resources.posts.fields.language', {
selector: 'input',
}) as HTMLInputElement;
input.focus();
fireEvent.change(input, { target: { value: 'New Kid On The Block' } });
fireEvent.click(getByText('ra.action.create_item'));
fireEvent.click(getByText('Get the kid'));
await new Promise(resolve => setImmediate(resolve));
rerender(
<Form
validateOnBlur
onSubmit={jest.fn()}
render={() => (
<AutocompleteInput
source="language"
resource="posts"
resettable
choices={choices}
create={<Create />}
/>
)}
/>
);
fireEvent.click(getByLabelText('ra.action.clear_input_value'));

expect(queryByText('New Kid On The Block')).not.toBeNull();
});
});
325 changes: 176 additions & 149 deletions packages/ra-ui-materialui/src/input/AutocompleteInput.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -26,7 +26,8 @@ const useStyles = makeStyles(
{ name: 'RaAutocompleteSuggestionItem' }
);

interface Props {
export interface AutocompleteSuggestionItemProps {
createValue?: any;
suggestion: any;
index: number;
highlightedIndex: number;
@@ -37,9 +38,10 @@ interface Props {
}

const AutocompleteSuggestionItem: FunctionComponent<
Props & MenuItemProps<'li', { button?: true }>
AutocompleteSuggestionItemProps & MenuItemProps<'li', { button?: true }>
> = props => {
const {
createValue,
suggestion,
index,
highlightedIndex,
@@ -51,7 +53,10 @@ const AutocompleteSuggestionItem: FunctionComponent<
} = props;
const classes = useStyles(props);
const isHighlighted = highlightedIndex === index;
const suggestionText = getSuggestionText(suggestion);
const suggestionText =
suggestion?.id === createValue
? suggestion.name
: getSuggestionText(suggestion);
let matches;
let parts;

132 changes: 124 additions & 8 deletions packages/ra-ui-materialui/src/input/SelectArrayInput.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import * as React from 'react';
import expect from 'expect';
import { render, fireEvent } from '@testing-library/react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import { Form } from 'react-final-form';
import { TestTranslationProvider } from 'ra-core';

import SelectArrayInput from './SelectArrayInput';
import { useCreateSuggestionContext } from './useSupportCreateSuggestion';

describe('<SelectArrayInput />', () => {
const defaultProps = {
@@ -194,6 +195,7 @@ describe('<SelectArrayInput />', () => {
const option2 = getByText('React');
expect(option2.getAttribute('aria-disabled')).toEqual('true');
});

it('should translate the choices', () => {
const { getByRole, queryByText } = render(
<TestTranslationProvider translate={x => `**${x}**`}>
@@ -303,17 +305,131 @@ describe('<SelectArrayInput />', () => {
<Form
validateOnBlur
onSubmit={jest.fn()}
render={() => (
<SelectArrayInput
{...{
...defaultProps,
}}
/>
)}
render={() => <SelectArrayInput {...defaultProps} />}
/>
);

expect(queryByRole('progressbar')).toBeNull();
});
});

it('should support creation of a new choice through the onCreate event', async () => {
const choices = [...defaultProps.choices];
const newChoice = { id: 'js_fatigue', name: 'New Kid On The Block' };

const { getByLabelText, getByRole, getByText, queryAllByText } = render(
<Form
validateOnBlur
onSubmit={jest.fn()}
render={() => (
<SelectArrayInput
{...defaultProps}
choices={choices}
onCreate={() => {
choices.push(newChoice);
return newChoice;
}}
/>
)}
/>
);

const input = getByLabelText(
'resources.posts.fields.categories'
) as HTMLInputElement;
input.focus();
const select = getByRole('button');
fireEvent.mouseDown(select);

fireEvent.click(getByText('ra.action.create'));
await new Promise(resolve => setImmediate(resolve));
input.blur();
// 2 because there is both the chip for the new selected item and the option (event if hidden)
expect(queryAllByText(newChoice.name).length).toEqual(2);
});

it('should support creation of a new choice through the onCreate event with a promise', async () => {
const choices = [...defaultProps.choices];
const newChoice = { id: 'js_fatigue', name: 'New Kid On The Block' };

const { getByLabelText, getByRole, getByText, queryAllByText } = render(
<Form
validateOnBlur
onSubmit={jest.fn()}
render={() => (
<SelectArrayInput
{...defaultProps}
choices={choices}
onCreate={() => {
return new Promise(resolve => {
setTimeout(() => {
choices.push(newChoice);
resolve(newChoice);
}, 200);
});
}}
/>
)}
/>
);

const input = getByLabelText(
'resources.posts.fields.categories'
) as HTMLInputElement;
input.focus();
const select = getByRole('button');
fireEvent.mouseDown(select);

fireEvent.click(getByText('ra.action.create'));
await new Promise(resolve => setImmediate(resolve));
input.blur();

await waitFor(() => {
// 2 because there is both the chip for the new selected item and the option (event if hidden)
expect(queryAllByText(newChoice.name).length).toEqual(2);
});
});

it('should support creation of a new choice through the create element', async () => {
const choices = [...defaultProps.choices];
const newChoice = { id: 'js_fatigue', name: 'New Kid On The Block' };

const Create = () => {
const context = useCreateSuggestionContext();
const handleClick = () => {
choices.push(newChoice);
context.onCreate(newChoice);
};

return <button onClick={handleClick}>Get the kid</button>;
};

const { getByLabelText, getByRole, getByText, queryAllByText } = render(
<Form
validateOnBlur
onSubmit={jest.fn()}
render={() => (
<SelectArrayInput
{...defaultProps}
choices={choices}
create={<Create />}
/>
)}
/>
);

const input = getByLabelText(
'resources.posts.fields.categories'
) as HTMLInputElement;
input.focus();
const select = getByRole('button');
fireEvent.mouseDown(select);

fireEvent.click(getByText('ra.action.create'));
fireEvent.click(getByText('Get the kid'));
input.blur();

// 2 because there is both the chip for the new selected item and the option (event if hidden)
expect(queryAllByText(newChoice.name).length).toEqual(2);
});
});
282 changes: 161 additions & 121 deletions packages/ra-ui-materialui/src/input/SelectArrayInput.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
import * as React from 'react';
import {
FunctionComponent,
useCallback,
useRef,
useState,
useEffect,
} from 'react';
import { useCallback, useRef, useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import {
Select,
@@ -29,63 +23,10 @@ import { SelectProps } from '@material-ui/core/Select';
import { FormControlProps } from '@material-ui/core/FormControl';
import Labeled from './Labeled';
import { LinearProgress } from '../layout';

const sanitizeRestProps = ({
addLabel,
allowEmpty,
alwaysOn,
basePath,
choices,
classNamInputWithOptionsPropse,
componenInputWithOptionsPropst,
crudGetMInputWithOptionsPropsatching,
crudGetOInputWithOptionsPropsne,
defaultValue,
disableValue,
filter,
filterToQuery,
formClassName,
initializeForm,
input,
isRequired,
label,
limitChoicesToValue,
loaded,
locale,
meta,
onChange,
options,
optionValue,
optionText,
perPage,
record,
reference,
resource,
setFilter,
setPagination,
setSort,
sort,
source,
textAlign,
translate,
translateChoice,
validation,
...rest
}: any) => rest;

const useStyles = makeStyles(
theme => ({
root: {},
chips: {
display: 'flex',
flexWrap: 'wrap',
},
chip: {
margin: theme.spacing(1 / 4),
},
}),
{ name: 'RaSelectArrayInput' }
);
import {
SupportCreateSuggestionOptions,
useSupportCreateSuggestion,
} from './useSupportCreateSuggestion';

/**
* An Input component for a select box allowing multiple selections, using an array of objects for the options
@@ -139,11 +80,14 @@ const useStyles = makeStyles(
* { id: 'photography', name: 'myroot.tags.photography' },
* ];
*/
const SelectArrayInput: FunctionComponent<SelectArrayInputProps> = props => {
const SelectArrayInput = (props: SelectArrayInputProps) => {
const {
choices = [],
classes: classesOverride,
className,
create,
createLabel,
createValue,
disableValue,
format,
helperText,
@@ -153,6 +97,7 @@ const SelectArrayInput: FunctionComponent<SelectArrayInputProps> = props => {
margin = 'dense',
onBlur,
onChange,
onCreate,
onFocus,
options,
optionText,
@@ -165,9 +110,11 @@ const SelectArrayInput: FunctionComponent<SelectArrayInputProps> = props => {
variant = 'filled',
...rest
} = props;

const classes = useStyles(props);
const inputLabel = useRef(null);
const [labelWidth, setLabelWidth] = useState(0);

useEffect(() => {
// Will be null while loading and we don't need this fix in that case
if (inputLabel.current) {
@@ -197,6 +144,33 @@ const SelectArrayInput: FunctionComponent<SelectArrayInputProps> = props => {
...rest,
});

const handleChange = useCallback(
(event, newItem) => {
if (newItem) {
input.onChange([...input.value, getChoiceValue(newItem)]);
return;
}
input.onChange(event);
},
[input, getChoiceValue]
);

const {
getCreateItem,
handleChange: handleChangeWithCreateSupport,
createElement,
} = useSupportCreateSuggestion({
create,
createLabel,
createValue,
handleChange,
onCreate,
});

const createItem = getCreateItem();
const finalChoices =
create || onCreate ? [...choices, createItem] : choices;

const renderMenuItemOption = useCallback(choice => getChoiceText(choice), [
getChoiceText,
]);
@@ -209,11 +183,13 @@ const SelectArrayInput: FunctionComponent<SelectArrayInputProps> = props => {
value={getChoiceValue(choice)}
disabled={getDisableValue(choice)}
>
{renderMenuItemOption(choice)}
{choice?.id === createItem.id
? createItem.name
: renderMenuItemOption(choice)}
</MenuItem>
) : null;
},
[getChoiceValue, getDisableValue, renderMenuItemOption]
[getChoiceValue, getDisableValue, renderMenuItemOption, createItem]
);

if (loading) {
@@ -231,68 +207,75 @@ const SelectArrayInput: FunctionComponent<SelectArrayInputProps> = props => {
}

return (
<FormControl
margin={margin}
className={classnames(classes.root, className)}
error={touched && !!(error || submitError)}
variant={variant}
{...sanitizeRestProps(rest)}
>
<InputLabel
ref={inputLabel}
id={`${label}-outlined-label`}
<>
<FormControl
margin={margin}
className={classnames(classes.root, className)}
error={touched && !!(error || submitError)}
variant={variant}
{...sanitizeRestProps(rest)}
>
<FieldTitle
label={label}
source={source}
resource={resource}
isRequired={isRequired}
/>
</InputLabel>
<Select
autoWidth
labelId={`${label}-outlined-label`}
multiple
error={!!(touched && (error || submitError))}
renderValue={(selected: any[]) => (
<div className={classes.chips}>
{selected
.map(item =>
choices.find(
choice => getChoiceValue(choice) === item
<InputLabel
ref={inputLabel}
id={`${label}-outlined-label`}
error={touched && !!(error || submitError)}
>
<FieldTitle
label={label}
source={source}
resource={resource}
isRequired={isRequired}
/>
</InputLabel>
<Select
autoWidth
labelId={`${label}-outlined-label`}
multiple
error={!!(touched && (error || submitError))}
renderValue={(selected: any[]) => (
<div className={classes.chips}>
{selected
.map(item =>
choices.find(
choice =>
getChoiceValue(choice) === item
)
)
)
.map(item => (
<Chip
key={getChoiceValue(item)}
label={renderMenuItemOption(item)}
className={classes.chip}
/>
))}
</div>
)}
data-testid="selectArray"
{...input}
value={input.value || []}
{...options}
labelWidth={labelWidth}
>
{choices.map(renderMenuItem)}
</Select>
<FormHelperText error={touched && !!(error || submitError)}>
<InputHelperText
touched={touched}
error={error || submitError}
helperText={helperText}
/>
</FormHelperText>
</FormControl>
.filter(item => !!item)
.map(item => (
<Chip
key={getChoiceValue(item)}
label={renderMenuItemOption(item)}
className={classes.chip}
/>
))}
</div>
)}
data-testid="selectArray"
{...input}
onChange={handleChangeWithCreateSupport}
value={input.value || []}
{...options}
labelWidth={labelWidth}
>
{finalChoices.map(renderMenuItem)}
</Select>
<FormHelperText error={touched && !!(error || submitError)}>
<InputHelperText
touched={touched}
error={error || submitError}
helperText={helperText}
/>
</FormHelperText>
</FormControl>
{createElement}
</>
);
};

export interface SelectArrayInputProps
extends Omit<ChoicesProps, 'choices'>,
Omit<SupportCreateSuggestionOptions, 'handleChange'>,
Omit<InputProps<SelectProps>, 'source'>,
Omit<
FormControlProps,
@@ -329,4 +312,61 @@ SelectArrayInput.defaultProps = {
translateChoice: true,
};

const sanitizeRestProps = ({
addLabel,
allowEmpty,
alwaysOn,
basePath,
choices,
classNamInputWithOptionsPropse,
componenInputWithOptionsPropst,
crudGetMInputWithOptionsPropsatching,
crudGetOInputWithOptionsPropsne,
defaultValue,
disableValue,
filter,
filterToQuery,
formClassName,
initializeForm,
input,
isRequired,
label,
limitChoicesToValue,
loaded,
locale,
meta,
onChange,
options,
optionValue,
optionText,
perPage,
record,
reference,
resource,
setFilter,
setPagination,
setSort,
sort,
source,
textAlign,
translate,
translateChoice,
validation,
...rest
}: any) => rest;

const useStyles = makeStyles(
theme => ({
root: {},
chips: {
display: 'flex',
flexWrap: 'wrap',
},
chip: {
margin: theme.spacing(1 / 4),
},
}),
{ name: 'RaSelectArrayInput' }
);

export default SelectArrayInput;
132 changes: 127 additions & 5 deletions packages/ra-ui-materialui/src/input/SelectInput.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import * as React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import { Form } from 'react-final-form';
import { TestTranslationProvider } from 'ra-core';

import SelectInput from './SelectInput';
import { SelectInput } from './SelectInput';
import { required } from 'ra-core';
import { useCreateSuggestionContext } from './useSupportCreateSuggestion';

describe('<SelectInput />', () => {
const defaultProps = {
@@ -492,19 +493,140 @@ describe('<SelectInput />', () => {

it('should not render a LinearProgress if loading is false', () => {
const { queryByRole } = render(
<Form
validateOnBlur
onSubmit={jest.fn()}
render={() => <SelectInput {...defaultProps} />}
/>
);

expect(queryByRole('progressbar')).toBeNull();
});

it('should support creation of a new choice through the onCreate event', async () => {
const choices = [...defaultProps.choices];
const newChoice = { id: 'js_fatigue', name: 'New Kid On The Block' };

const { getByLabelText, getByRole, getByText, queryByText } = render(
<Form
validateOnBlur
onSubmit={jest.fn()}
render={() => (
<SelectInput
{...{
...defaultProps,
{...defaultProps}
choices={choices}
onCreate={() => {
choices.push(newChoice);
return newChoice;
}}
/>
)}
/>
);

expect(queryByRole('progressbar')).toBeNull();
const input = getByLabelText(
'resources.posts.fields.language'
) as HTMLInputElement;
input.focus();
const select = getByRole('button');
fireEvent.mouseDown(select);

fireEvent.click(getByText('ra.action.create'));
await new Promise(resolve => setImmediate(resolve));
input.blur();

expect(
// The selector ensure we don't get the options from the menu but the select value
queryByText(newChoice.name, { selector: '[role=button]' })
).not.toBeNull();
});

it('should support creation of a new choice through the onCreate event with a promise', async () => {
const choices = [...defaultProps.choices];
const newChoice = { id: 'js_fatigue', name: 'New Kid On The Block' };

const { getByLabelText, getByRole, getByText, queryByText } = render(
<Form
validateOnBlur
onSubmit={jest.fn()}
render={() => (
<SelectInput
{...defaultProps}
choices={choices}
onCreate={() => {
return new Promise(resolve => {
setTimeout(() => {
choices.push(newChoice);
resolve(newChoice);
}, 200);
});
}}
/>
)}
/>
);

const input = getByLabelText(
'resources.posts.fields.language'
) as HTMLInputElement;
input.focus();
const select = getByRole('button');
fireEvent.mouseDown(select);

fireEvent.click(getByText('ra.action.create'));
await new Promise(resolve => setImmediate(resolve));
input.blur();

await waitFor(() => {
expect(
// The selector ensure we don't get the options from the menu but the select value
queryByText(newChoice.name, { selector: '[role=button]' })
).not.toBeNull();
});
});

it('should support creation of a new choice through the create element', async () => {
const choices = [...defaultProps.choices];
const newChoice = { id: 'js_fatigue', name: 'New Kid On The Block' };

const Create = () => {
const context = useCreateSuggestionContext();
const handleClick = () => {
choices.push(newChoice);
context.onCreate(newChoice);
};

return <button onClick={handleClick}>Get the kid</button>;
};

const { getByLabelText, getByRole, getByText, queryByText } = render(
<Form
validateOnBlur
onSubmit={jest.fn()}
render={() => (
<SelectInput
{...defaultProps}
choices={choices}
create={<Create />}
/>
)}
/>
);

const input = getByLabelText(
'resources.posts.fields.language'
) as HTMLInputElement;
input.focus();
const select = getByRole('button');
fireEvent.mouseDown(select);

fireEvent.click(getByText('ra.action.create'));
fireEvent.click(getByText('Get the kid'));
input.blur();

expect(
// The selector ensure we don't get the options from the menu but the select value
queryByText(newChoice.name, { selector: '[role=button]' })
).not.toBeNull();
});
});
233 changes: 138 additions & 95 deletions packages/ra-ui-materialui/src/input/SelectInput.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from 'react';
import { useCallback, FunctionComponent } from 'react';
import { useCallback } from 'react';
import PropTypes from 'prop-types';
import get from 'lodash/get';
import MenuItem from '@material-ui/core/MenuItem';
@@ -19,49 +19,10 @@ import InputHelperText from './InputHelperText';
import sanitizeInputRestProps from './sanitizeInputRestProps';
import Labeled from './Labeled';
import { LinearProgress } from '../layout';

const sanitizeRestProps = ({
addLabel,
afterSubmit,
allowNull,
beforeSubmit,
choices,
className,
crudGetMatching,
crudGetOne,
data,
filter,
filterToQuery,
formatOnBlur,
isEqual,
limitChoicesToValue,
multiple,
name,
pagination,
perPage,
ref,
reference,
render,
setFilter,
setPagination,
setSort,
sort,
subscription,
type,
validateFields,
validation,
value,
...rest
}: any) => sanitizeInputRestProps(rest);

const useStyles = makeStyles(
theme => ({
input: {
minWidth: theme.spacing(20),
},
}),
{ name: 'RaSelectInput' }
);
import {
useSupportCreateSuggestion,
SupportCreateSuggestionOptions,
} from './useSupportCreateSuggestion';

/**
* An Input component for a select box, using an array of objects for the options
@@ -137,12 +98,15 @@ const useStyles = makeStyles(
* <SelectInput source="gender" choices={choices} disableValue="not_available" />
*
*/
const SelectInput: FunctionComponent<SelectInputProps> = props => {
export const SelectInput = (props: SelectInputProps) => {
const {
allowEmpty,
choices = [],
classes: classesOverride,
className,
create,
createLabel,
createValue,
disableValue,
emptyText,
emptyValue,
@@ -153,6 +117,7 @@ const SelectInput: FunctionComponent<SelectInputProps> = props => {
loading,
onBlur,
onChange,
onCreate,
onFocus,
options,
optionText,
@@ -209,6 +174,30 @@ const SelectInput: FunctionComponent<SelectInputProps> = props => {
getChoiceText,
]);

const handleChange = useCallback(
async (event: React.ChangeEvent<HTMLSelectElement>, newItem) => {
if (newItem) {
const value = getChoiceValue(newItem);
input.onChange(value);
return;
}

input.onChange(event);
},
[input, getChoiceValue]
);

const {
getCreateItem,
handleChange: handleChangeWithCreateSupport,
createElement,
} = useSupportCreateSuggestion({
create,
createLabel,
createValue,
handleChange,
onCreate,
});
if (loading) {
return (
<Labeled
@@ -226,55 +215,66 @@ const SelectInput: FunctionComponent<SelectInputProps> = props => {
);
}

const createItem = getCreateItem();

return (
<ResettableTextField
id={id}
{...input}
select
label={
label !== '' &&
label !== false && (
<FieldTitle
label={label}
source={source}
resource={resource}
isRequired={isRequired}
<>
<ResettableTextField
id={id}
{...input}
onChange={handleChangeWithCreateSupport}
select
label={
label !== '' &&
label !== false && (
<FieldTitle
label={label}
source={source}
resource={resource}
isRequired={isRequired}
/>
)
}
className={`${classes.input} ${className}`}
clearAlwaysVisible
error={!!(touched && (error || submitError))}
helperText={
<InputHelperText
touched={touched}
error={error || submitError}
helperText={helperText}
/>
)
}
className={`${classes.input} ${className}`}
clearAlwaysVisible
error={!!(touched && (error || submitError))}
helperText={
<InputHelperText
touched={touched}
error={error || submitError}
helperText={helperText}
/>
}
{...options}
{...sanitizeRestProps(rest)}
>
{allowEmpty ? (
<MenuItem
value={emptyValue}
key="null"
aria-label={translate('ra.action.clear_input_value')}
title={translate('ra.action.clear_input_value')}
>
{renderEmptyItemOption()}
</MenuItem>
) : null}
{choices.map(choice => (
<MenuItem
key={getChoiceValue(choice)}
value={getChoiceValue(choice)}
disabled={get(choice, disableValue)}
>
{renderMenuItemOption(choice)}
</MenuItem>
))}
</ResettableTextField>
}
{...options}
{...sanitizeRestProps(rest)}
>
{allowEmpty ? (
<MenuItem
value={emptyValue}
key="null"
aria-label={translate('ra.action.clear_input_value')}
title={translate('ra.action.clear_input_value')}
>
{renderEmptyItemOption()}
</MenuItem>
) : null}
{choices.map(choice => (
<MenuItem
key={getChoiceValue(choice)}
value={getChoiceValue(choice)}
disabled={get(choice, disableValue)}
>
{renderMenuItemOption(choice)}
</MenuItem>
))}
{onCreate || create ? (
<MenuItem value={createItem.id} key={createItem.id}>
{createItem.name}
</MenuItem>
) : null}
</ResettableTextField>
{createElement}
</>
);
};

@@ -310,7 +310,50 @@ SelectInput.defaultProps = {
disableValue: 'disabled',
};

export type SelectInputProps = ChoicesInputProps<TextFieldProps> &
Omit<TextFieldProps, 'label' | 'helperText'>;
const sanitizeRestProps = ({
addLabel,
afterSubmit,
allowNull,
beforeSubmit,
choices,
className,
crudGetMatching,
crudGetOne,
data,
filter,
filterToQuery,
formatOnBlur,
isEqual,
limitChoicesToValue,
multiple,
name,
pagination,
perPage,
ref,
reference,
render,
setFilter,
setPagination,
setSort,
sort,
subscription,
type,
validateFields,
validation,
value,
...rest
}: any) => sanitizeInputRestProps(rest);

const useStyles = makeStyles(
theme => ({
input: {
minWidth: theme.spacing(20),
},
}),
{ name: 'RaSelectInput' }
);

export default SelectInput;
export interface SelectInputProps
extends ChoicesInputProps<TextFieldProps>,
Omit<SupportCreateSuggestionOptions, 'handleChange'>,
Omit<TextFieldProps, 'label' | 'helperText'> {}
9 changes: 3 additions & 6 deletions packages/ra-ui-materialui/src/input/index.ts
Original file line number Diff line number Diff line change
@@ -2,7 +2,6 @@ import ArrayInput, { ArrayInputProps } from './ArrayInput';
import AutocompleteArrayInput, {
AutocompleteArrayInputProps,
} from './AutocompleteArrayInput';
import AutocompleteInput, { AutocompleteInputProps } from './AutocompleteInput';
import BooleanInput from './BooleanInput';
import CheckboxGroupInput, {
CheckboxGroupInputProps,
@@ -31,9 +30,11 @@ import ResettableTextField, {
} from './ResettableTextField';
import SearchInput, { SearchInputProps } from './SearchInput';
import SelectArrayInput, { SelectArrayInputProps } from './SelectArrayInput';
import SelectInput, { SelectInputProps } from './SelectInput';
import TextInput, { TextInputProps } from './TextInput';
import sanitizeInputRestProps from './sanitizeInputRestProps';
export * from './AutocompleteInput';
export * from './SelectInput';
export * from './useSupportCreateSuggestion';
export * from './TranslatableInputs';
export * from './TranslatableInputsTabContent';
export * from './TranslatableInputsTabs';
@@ -42,7 +43,6 @@ export * from './TranslatableInputsTab';
export {
ArrayInput,
AutocompleteArrayInput,
AutocompleteInput,
BooleanInput,
CheckboxGroupInput,
DateInput,
@@ -61,14 +61,12 @@ export {
ResettableTextField,
SearchInput,
SelectArrayInput,
SelectInput,
TextInput,
sanitizeInputRestProps,
};

export type {
ArrayInputProps,
AutocompleteInputProps,
AutocompleteArrayInputProps,
CheckboxGroupInputProps,
DateInputProps,
@@ -86,6 +84,5 @@ export type {
ResettableTextFieldProps,
SearchInputProps,
SelectArrayInputProps,
SelectInputProps,
TextInputProps,
};
124 changes: 124 additions & 0 deletions packages/ra-ui-materialui/src/input/useSupportCreateSuggestion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import * as React from 'react';
import {
ChangeEvent,
createContext,
isValidElement,
ReactElement,
useContext,
useState,
} from 'react';
import { Identifier, useTranslate } from 'ra-core';

/**
* This hook provides support for suggestion creation in inputs which have choices.
*
* @param options The hook option
* @param {ReactElement} options.create A react element which will be rendered when users choose to create a new choice. This component must call the `useCreateSuggestionContext` hook which provides `onCancel`, `onCreate` and `filter`. See the examples.
* @param {String} options.createLabel Optional. The label for the choice item allowing users to create a new choice. Can be a translation key. Defaults to `ra.action.create`.
* @param {String} options.createItemLabel Optional. The label for the choice item allowing users to create a new choice when they already entered a filter. Can be a translation key. The translation will receive an `item` parameter. Defaults to `ra.action.create_item`.
* @param {any} options.createValue Optional. The value for the choice item allowing users to create a new choice. Defaults to `@@ra-create`.
* @param {String} options.filter Optional. The filter users may have already entered. Useful for autocomplete inputs for example.
* @param {OnCreateHandler} options.onCreate Optional. A function which will be called when users choose to create a new choice, if the `create` option wasn't provided.
* @param handleChange: a function to pass to the input. Receives the same parameter as the original event handler and an additional newItem parameter if a new item was create.
* @returns {UseSupportCreateValue} An object with the following properties:
* - getCreateItem: a function which will return the label of the choice for create a new choice.
* - createElement: a React element to render after the input. It will be rendered when users choose to create a new choice. It renders null otherwise.
*/
export const useSupportCreateSuggestion = (
options: SupportCreateSuggestionOptions
): UseSupportCreateValue => {
const {
create,
createLabel = 'ra.action.create',
createItemLabel = 'ra.action.create_item',
createValue = '@@ra-create',
filter,
handleChange,
onCreate,
} = options;
const translate = useTranslate();
const [renderOnCreate, setRenderOnCreate] = useState(false);

const context = {
filter,
onCancel: () => setRenderOnCreate(false),
onCreate: item => {
setRenderOnCreate(false);
handleChange(undefined, item);
},
};

return {
getCreateItem: () => {
return {
id: createValue,
name:
filter && createItemLabel
? translate(createItemLabel, {
item: filter,
_: createItemLabel,
})
: translate(createLabel, { _: createLabel }),
};
},
handleChange: async eventOrValue => {
const value = eventOrValue.target?.value || eventOrValue;
const finalValue = Array.isArray(value) ? [...value].pop() : value;

if (eventOrValue?.preventDefault) {
eventOrValue.preventDefault();
eventOrValue.stopPropagation();
}
if (finalValue?.id === createValue || finalValue === createValue) {
if (!isValidElement(create)) {
const newSuggestion = await onCreate(filter);

if (newSuggestion) {
handleChange(eventOrValue, newSuggestion);
return;
}
} else {
setRenderOnCreate(true);
return;
}
}
handleChange(eventOrValue, undefined);
},
createElement:
renderOnCreate && isValidElement(create) ? (
<CreateSuggestionContext.Provider value={context}>
{create}
</CreateSuggestionContext.Provider>
) : null,
};
};

export interface SupportCreateSuggestionOptions {
create?: ReactElement;
createValue?: string;
createLabel?: string;
createItemLabel?: string;
filter?: string;
handleChange: (value: any, newChoice: any) => void;
onCreate?: OnCreateHandler;
}

export interface UseSupportCreateValue {
getCreateItem: () => { id: Identifier; name: string };
handleChange: (eventOrValue: ChangeEvent | any) => Promise<void>;
createElement: ReactElement | null;
}

const CreateSuggestionContext = createContext<CreateSuggestionContextValue>(
undefined
);

interface CreateSuggestionContextValue {
filter?: string;
onCreate: (choice: any) => void;
onCancel: () => void;
}
export const useCreateSuggestionContext = () =>
useContext(CreateSuggestionContext);

export type OnCreateHandler = (filter?: string) => any | Promise<any>;
Original file line number Diff line number Diff line change
@@ -5,7 +5,7 @@ import { renderWithRedux } from 'ra-test';

import FilterForm, { mergeInitialValuesWithDefaultValues } from './FilterForm';
import TextInput from '../../input/TextInput';
import SelectInput from '../../input/SelectInput';
import { SelectInput } from '../../input/SelectInput';

describe('<FilterForm />', () => {
const defaultProps = {