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
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);
});
});
61 changes: 61 additions & 0 deletions packages/ra-core/src/form/useChoices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
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;
}

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