-
-
Notifications
You must be signed in to change notification settings - Fork 5.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
4 changed files
with
330 additions
and
20 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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: '' }, | ||
]); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}; |