Skip to content

Commit

Permalink
Merge pull request #3683 from marmelab/use-choices
Browse files Browse the repository at this point in the history
[RFR] Introduce useChoices & useSuggestions hook
  • Loading branch information
fzaninotto authored Sep 15, 2019
2 parents 0360aaa + c9637dd commit 5972005
Show file tree
Hide file tree
Showing 24 changed files with 1,236 additions and 1,020 deletions.
25 changes: 24 additions & 1 deletion UPGRADE.md
Original file line number Diff line number Diff line change
Expand Up @@ -893,7 +893,7 @@ const PostFilter = props =>

We rewrote the `<AutocompleteInput>` and `<AutocompleteArrayInput>` components from scratch using [`downshift`](https://github.com/downshift-js/downshift), while the previous version was based on [react-autosuggest](http://react-autosuggest.js.org/). The new components are more robust and more future-proof, and their API didn't change.

There are two breaking changes in the new `<AutocompleteInput>` and `<AutocompleteArrayInput>` components:
There are three breaking changes in the new `<AutocompleteInput>` and `<AutocompleteArrayInput>` components:

- The `inputValueMatcher` prop is gone. We removed a feature many found confusing: the auto-selection of an item when it was matched exactly. So react-admin no longer selects anything automatically, therefore the `inputValueMatcher` prop is obsolete

Expand Down Expand Up @@ -921,6 +921,29 @@ There are two breaking changes in the new `<AutocompleteInput>` and `<Autocomple
/>
```

- The `suggestionComponent` prop is gone.

Instead, the new `<AutocompleteInput>` and `<AutocompleteArrayInput>` components use the `optionText` like all other inputs accepting choices.
However, if you pass a React element as the `optionText`, you must now also specify the new `matchSuggestion` prop.
This is required because the inputs use the `optionText` by default to filter suggestions.
This function receives the current filter and a choice, and should return a boolean indicating whether this choice matches the filter.

```diff
<AutocompleteInput
source="role"
- suggestionComponent={MyComponent}
+ optionText={<MyComponent />}
+ matchSuggestion={matchSuggestion}
/>

<AutocompleteArrayInput
source="role"
- suggestionComponent={MyComponent}
+ optionText={<MyComponent />}
+ matchSuggestion={matchSuggestion}
/>
```

Besides, some props which were applicable to both components did not make sense for the `<AutocompleteArrayInput>` component:

- `allowEmpty`: As the `<AutocompleteArrayInput>` deals with arrays, it does not make sense to add an empty choice. This prop is no longer accepted and will be ignored.
Expand Down
18 changes: 10 additions & 8 deletions docs/Inputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,14 +207,16 @@ Lastly, would you need to override the props of the suggestions container (a `Po
| Prop | Required | Type | Default | Description |
| ---|---|---|---|--- |
| `choices` | Required | `Object[]` | - | List of items to autosuggest |
| `resource` | Required | `string` | - | The resource working on. This field is passed down by wrapped components like `Create` and `Edit`. |
| `source` | Required | `string` | - | Name of field to edit, its type should match the type retrieved from `optionValue` |
| `allowEmpty` | Optional | `boolean` | `false` | If `false` and the searchText typed did not match any suggestion, the searchText will revert to the current value when the field is blurred. If `true` and the `searchText` is set to `''` then the field will set the input value to `null`. |
| `choices` | Required | `Object[]` | - | List of items to autosuggest |
| `emptyValue` | Optional | anything | `null` | The value to use for the empty element |
| `emptyText` | Optional | `string` | '' | The text to use for the empty element |
| `matchSuggestion` | Optional | `Function` | - | Required if `optionText` is a React element. Function returning a boolean indicating whether a choice matches the filter. `(filter, choice) => boolean`
| `optionValue` | Optional | `string` | `id` | Fieldname of record containing the value to use as input value |
| `optionText` | Optional | <code>string &#124; Function</code> | `name` | Fieldname of record to display in the suggestion item or function which accepts the correct record as argument (`(record)=> {string}`) |
| `resource` | Required | `string` | - | The resource working on. This field is passed down by wrapped components like `Create` and `Edit`. |
| `setFilter` | Optional | `Function` | null | A callback to inform the `searchText` has changed and new `choices` can be retrieved based on this `searchText`. Signature `searchText => void`. This function is automatically setup when using `ReferenceInput`. |
| `suggestionComponent` | Optional | Function | `({ suggestion, query, isHighlighted, props }) => <div {...props} />` | Allows to override how the item is rendered. |
| `source` | Required | `string` | - | Name of field to edit, its type should match the type retrieved from `optionValue` |
| `shouldRenderSuggestions` | Optional | Function | `() => true` | A function that returns a `boolean` to determine whether or not suggestions are rendered. Use this when working with large collections of data to improve performance and user experience. This function is passed into the underlying react-autosuggest component. Ex.`(value) => value.trim() > 2` |
## `<AutocompleteArrayInput>`
Expand Down Expand Up @@ -313,15 +315,15 @@ If you need to override the props of the suggestions container (a `Popper` eleme
| Prop | Required | Type | Default | Description |
| ---|---|---|---|--- |
| `choices` | Required | `Object[]` | - | List of items to autosuggest |
| `resource` | Required | `string` | - | The resource working on. This field is passed down by wrapped components like `Create` and `Edit`. |
| `source` | Required | `string` | - | Name of field to edit, its type should match the type retrieved from `optionValue` |
| `fullWith` | Optional | Boolean | If `true`, the input will take all the form width
| `matchSuggestion` | Optional | `Function` | - | Required if `optionText` is a React element. Function returning a boolean indicating whether a choice matches the filter. `(filter, choice) => boolean`
| `optionValue` | Optional | `string` | `id` | Fieldname of record containing the value to use as input value |
| `optionText` | Optional | <code>string &#124; Function</code> | `name` | Fieldname of record to display in the suggestion item or function which accepts the current record as argument (`(record)=> {string}`) |
| `resource` | Required | `string` | - | The resource working on. This field is passed down by wrapped components like `Create` and `Edit`. |
| `setFilter` | Optional | `Function` | null | A callback to inform the `searchText` has changed and new `choices` can be retrieved based on this `searchText`. Signature `searchText => void`. This function is automatically setup when using `ReferenceInput`. |
| `suggestionComponent` | Optional | Function | `({ suggestion, query, isHighlighted, props }) => <div {...props} />` | Allows to override how the item is rendered. |
| `source` | Required | `string` | - | Name of field to edit, its type should match the type retrieved from `optionValue` |
| `suggestionLimit` | Optional | Number | null | Limits the numbers of suggestions that are shown in the dropdown list |
| `shouldRenderSuggestions` | Optional | Function | `() => true` | A function that returns a `boolean` to determine whether or not suggestions are rendered. Use this when working with large collections of data to improve performance and user experience. This function is passed into the underlying react-autosuggest component. Ex.`(value) => value.trim() > 2` |
| `fullWith` | Optional | Boolean | If `true`, the input will take all the form width
## `<BooleanInput>` and `<NullableBooleanInput>`
Expand Down
11 changes: 11 additions & 0 deletions packages/ra-core/src/form/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,25 @@ import FormField from './FormField';
import useInput, { InputProps } from './useInput';
import ValidationError from './ValidationError';
import useInitializeFormWithRecord from './useInitializeFormWithRecord';
import useChoices, {
ChoicesProps,
OptionTextElement,
OptionText,
} from './useChoices';
import useSuggestions from './useSuggestions';

export {
addField,
ChoicesProps,
FormDataConsumer,
FormField,
InputProps,
OptionTextElement,
OptionText,
useChoices,
useInput,
useInitializeFormWithRecord,
useSuggestions,
ValidationError,
};
export { isRequired } from './FormField';
Expand Down
90 changes: 90 additions & 0 deletions packages/ra-core/src/form/useChoices.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import React from 'react';
import expect from 'expect';
import { render, cleanup } from '@testing-library/react';
import useChoices from './useChoices';
import { renderWithRedux } from '../util';
import { TestTranslationProvider } from '../i18n';

describe('useChoices hook', () => {
afterEach(cleanup);
const defaultProps = {
choice: { id: 42, name: 'test' },
optionValue: 'id',
optionText: 'name',
translateChoice: true,
};

const Component = ({
choice,
optionText,
optionValue,
translateChoice,
}) => {
const { getChoiceText, getChoiceValue } = useChoices({
optionText,
optionValue,
translateChoice,
});

return (
<div data-value={getChoiceValue(choice)}>
{getChoiceText(choice)}
</div>
);
};

it('should use optionValue as value identifier', () => {
const { getByText } = render(<Component {...defaultProps} />);
expect(getByText('test').getAttribute('data-value')).toEqual('42');
});

it('should use optionText with a string value as text identifier', () => {
const { queryAllByText } = render(<Component {...defaultProps} />);
expect(queryAllByText('test')).toHaveLength(1);
});

it('should use optionText with a function value as text identifier', () => {
const { queryAllByText } = render(
<Component
{...defaultProps}
optionText={choice => choice.foobar}
choice={{ id: 42, foobar: 'test' }}
/>
);
expect(queryAllByText('test')).toHaveLength(1);
});

it('should use optionText with an element value as text identifier', () => {
const Foobar = ({ record }: { record?: any }) => (
<span>{record.foobar}</span>
);
const { queryAllByText } = render(
<Component
{...defaultProps}
optionText={<Foobar />}
choice={{ id: 42, foobar: 'test' }}
/>
);
expect(queryAllByText('test')).toHaveLength(1);
});

it('should translate the choice by default', () => {
const { queryAllByText } = renderWithRedux(
<TestTranslationProvider translate={x => `**${x}**`}>
<Component {...defaultProps} />
</TestTranslationProvider>
);
expect(queryAllByText('test')).toHaveLength(0);
expect(queryAllByText('**test**')).toHaveLength(1);
});

it('should not translate the choice if translateChoice is false', () => {
const { queryAllByText } = renderWithRedux(
<TestTranslationProvider translate={x => `**${x}**`}>
<Component {...defaultProps} translateChoice={false} />
</TestTranslationProvider>
);
expect(queryAllByText('test')).toHaveLength(1);
expect(queryAllByText('**test**')).toHaveLength(0);
});
});
72 changes: 72 additions & 0 deletions packages/ra-core/src/form/useChoices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { ReactElement, isValidElement, cloneElement, useCallback } from 'react';
import get from 'lodash/get';

import { useTranslate } from '../i18n';
import { Record } from '../types';

export type OptionTextElement = ReactElement<{
record: Record;
}>;
export type OptionText = (choice: object) => string | OptionTextElement;

export interface ChoicesProps {
choices: object[];
optionValue?: string;
optionText?: OptionTextElement | OptionText | string;
translateChoice?: boolean;
}

export interface UseChoicesOptions {
optionValue?: string;
optionText?: OptionTextElement | OptionText | string;
translateChoice?: boolean;
}

/*
* Returns helper functions for choices handling.
*
* @param optionText Either a string defining the property to use to get the choice text, a function or a React element
* @param optionValue The property to use to get the choice value
* @param translateChoice A boolean indicating whether to option text should be translated
*
* @returns An object with helper functions:
* - getChoiceText: Returns the choice text or a React element
* - getChoiceValue: Returns the choice value
*/
const useChoices = ({
optionText = 'name',
optionValue = 'id',
translateChoice = true,
}: UseChoicesOptions) => {
const translate = useTranslate();

const getChoiceText = useCallback(
choice => {
if (isValidElement<{ record: any }>(optionText)) {
return cloneElement<{ record: any }>(optionText, {
record: choice,
});
}
const choiceName =
typeof optionText === 'function'
? optionText(choice)
: get(choice, optionText);

return translateChoice
? translate(choiceName, { _: choiceName })
: choiceName;
},
[optionText, translate, translateChoice]
);

const getChoiceValue = useCallback(choice => get(choice, optionValue), [
optionValue,
]);

return {
getChoiceText,
getChoiceValue,
};
};

export default useChoices;
111 changes: 111 additions & 0 deletions packages/ra-core/src/form/useSuggestions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import expect from 'expect';
import { getSuggestionsFactory as getSuggestions } from './useSuggestions';

describe('getSuggestions', () => {
const choices = [
{ id: 1, value: 'one' },
{ id: 2, value: 'two' },
{ id: 3, value: 'three' },
];

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

it('should return all suggestions when filtered by empty string', () => {
expect(getSuggestions(defaultOptions)('')).toEqual(choices);
});

it('should filter choices according to the filter argument', () => {
expect(getSuggestions(defaultOptions)('o')).toEqual([
{ id: 1, value: 'one' },
{ id: 2, value: 'two' },
]);
});

it('should filter choices according to the filter argument when it contains RegExp reserved characters', () => {
expect(
getSuggestions({
...defaultOptions,
choices: [
{ id: 1, value: '**one' },
{ id: 2, value: 'two' },
{ id: 3, value: 'three' },
],
})('**o')
).toEqual([{ id: 1, value: '**one' }]);
});

it('should filter choices according to the currently selected values if selectedItem is an array', () => {
expect(
getSuggestions({
...defaultOptions,
selectedItem: [choices[0]],
})('')
).toEqual(choices.slice(1));
});

it('should not filter choices according to the currently selected value if selectedItem is not an array and limitChoicesToValue is false', () => {
expect(
getSuggestions({
...defaultOptions,
limitChoicesToValue: false,
selectedItem: choices[0],
})('one')
).toEqual(choices);
});

it('should filter choices according to the currently selected value if selectedItem is not an array and limitChoicesToValue is true', () => {
expect(
getSuggestions({
...defaultOptions,
limitChoicesToValue: true,
selectedItem: choices[0],
})('one')
).toEqual([choices[0]]);
});
it('should add emptySuggestion if allowEmpty is true', () => {
expect(
getSuggestions({
...defaultOptions,
allowEmpty: true,
})('')
).toEqual([
{ id: 1, value: 'one' },
{ id: 2, value: 'two' },
{ id: 3, value: 'three' },
{ id: null, value: '' },
]);
});

it('should limit the number of choices', () => {
expect(
getSuggestions({
...defaultOptions,
suggestionLimit: 2,
})('')
).toEqual([{ id: 1, value: 'one' }, { id: 2, value: 'two' }]);

expect(
getSuggestions({
...defaultOptions,
suggestionLimit: 2,
allowEmpty: true,
})('')
).toEqual([
{ id: 1, value: 'one' },
{ id: 2, value: 'two' },
{ id: null, value: '' },
]);
});
});
Loading

0 comments on commit 5972005

Please sign in to comment.