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

[RFR] Introduce useChoices & useSuggestions hook #3683

Merged
merged 16 commits into from
Sep 15, 2019
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