Skip to content

Commit

Permalink
Introduce useSuggestions hook
Browse files Browse the repository at this point in the history
  • Loading branch information
djhi committed Sep 13, 2019
1 parent 78246fe commit 8ddf9cb
Show file tree
Hide file tree
Showing 4 changed files with 330 additions and 20 deletions.
2 changes: 2 additions & 0 deletions packages/ra-core/src/form/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import useChoices, {
OptionTextElement,
OptionText,
} from './useChoices';
import useSuggestions from './useSuggestions';

export {
addField,
Expand All @@ -21,6 +22,7 @@ export {
useChoices,
useInput,
useInitializeFormWithRecord,
useSuggestions,
ValidationError,
};
export { isRequired } from './FormField';
Expand Down
45 changes: 25 additions & 20 deletions packages/ra-core/src/form/useChoices.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ReactElement, isValidElement, cloneElement } from 'react';
import { ReactElement, isValidElement, cloneElement, useCallback } from 'react';
import get from 'lodash/get';

import { useTranslate } from '../i18n';
Expand All @@ -16,7 +16,7 @@ export interface ChoicesProps {
translateChoice?: boolean;
}

interface Options {
export interface UseChoicesOptions {
optionValue?: string;
optionText?: OptionTextElement | OptionText | string;
translateChoice?: boolean;
Expand All @@ -26,26 +26,31 @@ const useChoices = ({
optionText = 'name',
optionValue = 'id',
translateChoice = true,
}: Options) => {
}: UseChoicesOptions) => {
const translate = useTranslate();

const getChoiceText = 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;
};

const getChoiceValue = choice => get(choice, optionValue);
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,
Expand Down
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: '' },
]);
});
});
192 changes: 192 additions & 0 deletions packages/ra-core/src/form/useSuggestions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { useCallback, isValidElement } from 'react';
import set from 'lodash/set';
import useChoices, { UseChoicesOptions } from './useChoices';
import { useTranslate } from '../i18n';

const defaultMatchSuggestion = getChoiceText => (filter, suggestion) => {
const suggestionText = getChoiceText(suggestion);

const isReactElement = isValidElement(suggestionText);

return isReactElement
? false
: 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')
);
};

const useSuggestions = ({
allowEmpty,
choices,
emptyText = '',
emptyValue = null,
limitChoicesToValue,
matchSuggestion,
optionText,
optionValue,
selectedItem,
suggestionLimit = 0,
translateChoice,
}: Options) => {
const translate = useTranslate();
const { getChoiceText, getChoiceValue } = useChoices({
optionText,
optionValue,
translateChoice,
});

const getSuggestions = useCallback(
filter =>
getSuggestionsFactory({
allowEmpty,
choices,
emptyText: translate(emptyText, { _: emptyText }),
emptyValue,
getChoiceText,
getChoiceValue,
limitChoicesToValue,
matchSuggestion,
optionText,
optionValue,
selectedItem,
suggestionLimit,
})(filter),
[
allowEmpty,
choices,
emptyText,
emptyValue,
getChoiceText,
getChoiceValue,
limitChoicesToValue,
matchSuggestion,
optionText,
optionValue,
selectedItem,
suggestionLimit,
translate,
]
);

return {
getChoiceText,
getChoiceValue,
getSuggestions,
};
};

export default useSuggestions;

const escapeRegExp = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string

interface Options extends UseChoicesOptions {
choices: any[];
allowEmpty?: boolean;
emptyText?: string;
emptyValue?: any;
limitChoicesToValue?: boolean;
matchSuggestion?: (filter: string) => (suggestion: any) => boolean;
suggestionLimit?: number;
selectedItem?: any | any[];
}

/**
* Get the suggestions to display after applying a fuzzy search on the available choices
*
* @example
* getSuggestions({
* choices: [{ id: 1, name: 'admin' }, { id: 2, name: 'publisher' }],
* optionText: 'name',
* optionValue: 'id',
* getSuggestionText: choice => choice[optionText],
* })('pub')
*
* Will return [{ id: 2, name: 'publisher' }]
*/
export const getSuggestionsFactory = ({
choices = [],
allowEmpty,
emptyText,
emptyValue,
optionText,
optionValue,
getChoiceText,
getChoiceValue,
limitChoicesToValue,
matchSuggestion = defaultMatchSuggestion(getChoiceText),
selectedItem,
suggestionLimit = 0,
}) => filter => {
// When we display the suggestions for the first time and the input
// already has a value, we want to display more choices than just the
// currently selected one, unless limitChoicesToValue was set to true
if (
selectedItem &&
!Array.isArray(selectedItem) &&
matchSuggestion(filter, selectedItem)
) {
if (limitChoicesToValue) {
return limitSuggestions(
choices.filter(
choice =>
getChoiceValue(choice) === getChoiceValue(selectedItem)
),
suggestionLimit
);
}

return limitSuggestions(
removeAlreadySelectedSuggestions(selectedItem, getChoiceValue)(
choices
),
suggestionLimit
);
}

const filteredChoices = choices.filter(choice =>
matchSuggestion(filter, choice)
);

const finalChoices = limitSuggestions(
removeAlreadySelectedSuggestions(selectedItem, getChoiceValue)(
filteredChoices
),
suggestionLimit
);

if (allowEmpty) {
const emptySuggestion = {};
set(emptySuggestion, optionValue, emptyValue);

if (typeof optionText !== 'function') {
set(emptySuggestion, optionText, emptyText);
}
return finalChoices.concat(emptySuggestion);
}

return finalChoices;
};

const removeAlreadySelectedSuggestions = (
selectedItem,
getChoiceValue
) => suggestions => {
if (!Array.isArray(selectedItem)) {
return suggestions;
}

const selectedValues = selectedItem.map(getChoiceValue);

return suggestions.filter(
suggestion => !selectedValues.includes(getChoiceValue(suggestion))
);
};

const limitSuggestions = (suggestions, limit = 0) => {
if (Number.isInteger(limit) && limit > 0) {
return suggestions.slice(0, limit);
}
return suggestions;
};

0 comments on commit 8ddf9cb

Please sign in to comment.