Skip to content

Commit a583281

Browse files
authored
feat(hooks): itemToKey prop (#1573)
* change for useCombobox reducer * useCombo docs * finish useCombo tests * types * docs * finish changes * correct docs
1 parent 59366d9 commit a583281

File tree

11 files changed

+336
-23
lines changed

11 files changed

+336
-23
lines changed

src/hooks/useCombobox/README.md

+48
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ and update if necessary.
8181
- [defaultIsOpen](#defaultisopen)
8282
- [defaultHighlightedIndex](#defaulthighlightedindex)
8383
- [defaultInputValue](#defaultinputvalue)
84+
- [itemToKey](#itemtokey)
8485
- [selectedItemChanged](#selecteditemchanged)
8586
- [getA11yStatusMessage](#geta11ystatusmessage)
8687
- [getA11ySelectionMessage](#geta11yselectionmessage)
@@ -393,8 +394,55 @@ reset or when an item is selected.
393394
Pass a string that sets the content of the input when downshift is reset or when
394395
an item is selected.
395396

397+
### itemToKey
398+
399+
> `function(item: any)` | defaults to: `item => item`
400+
401+
Used to determine the uniqueness of an item when searching for the item or
402+
comparing the item with another. Returns the item itself, by default, so the
403+
comparing/searching is done internally via referential equality.
404+
405+
If using items as objects and their reference will change during use, you can
406+
use the function to generate a unique key for each item, such as an `id` prop.
407+
408+
```js
409+
function itemToKey(item) {
410+
return item.id
411+
}
412+
```
413+
414+
> This deprecates the "selectedItemChanged" prop. If you are using the prop
415+
> already, make sure you change to "itemToKey" as the former will be removed in
416+
> the next Breaking Change update. A migration example:
417+
418+
```js
419+
// initial items.
420+
const items = [
421+
{id: 1, value: 'Apples'},
422+
{id: 2, value: 'Oranges'},
423+
]
424+
// the same items but with different references, for any reason.
425+
const newItems = [
426+
{id: 1, value: 'Apples'},
427+
{id: 2, value: 'Oranges'},
428+
]
429+
430+
// previously, if you probably had something like this.
431+
function selectedItemChanged(item1, item2) {
432+
return item1.id === item2.id
433+
}
434+
435+
// moving forward, switch to this one.
436+
function itemToKey(item) {
437+
return item.id
438+
// and we will do the comparison like: const isChanged = itemToKey(prevSelectedItem) !== itemToKey(nextSelectedItem)
439+
}
440+
```
441+
396442
### selectedItemChanged
397443

444+
> DEPRECATED. Please use "itemToKey".
445+
398446
> `function(prevItem: any, item: any)` | defaults to:
399447
> `(prevItem, item) => (prevItem !== item)`
400448

src/hooks/useCombobox/__tests__/props.test.js

+174
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,9 @@ describe('props', () => {
165165
})
166166

167167
test('props update of selectedItem will not update inputValue state if selectedItemChanged returns false', () => {
168+
const consoleWarnSpy = jest
169+
.spyOn(console, 'warn')
170+
.mockImplementation(() => {})
168171
const initialSelectedItem = {id: 1, value: 'hmm'}
169172
const selectedItem = {id: 1, value: 'wow'}
170173
function itemToString(item) {
@@ -197,6 +200,116 @@ describe('props', () => {
197200
initialSelectedItem,
198201
selectedItem,
199202
)
203+
expect(consoleWarnSpy).toHaveBeenCalledTimes(1)
204+
expect(consoleWarnSpy).toHaveBeenCalledWith(
205+
`The "selectedItemChanged" is deprecated. Please use "itemToKey instead". https://github.com/downshift-js/downshift/blob/master/src/hooks/useCombobox/README.md#selecteditemchanged`,
206+
)
207+
consoleWarnSpy.mockRestore()
208+
})
209+
})
210+
211+
describe('itemToKey', () => {
212+
test('props update of selectedItem will update inputValue state with default itemToKey referential equality check', () => {
213+
const initialSelectedItem = {id: 3, value: 'init'}
214+
const selectedItem = {id: 1, value: 'wow'}
215+
const newSelectedItem = {id: 1, value: 'not wow'}
216+
function itemToString(item) {
217+
return item.value
218+
}
219+
const stateReducer = jest
220+
.fn()
221+
.mockImplementation((_state, {changes}) => changes)
222+
223+
const {rerender} = renderCombobox({
224+
stateReducer,
225+
itemToString,
226+
selectedItem: initialSelectedItem,
227+
})
228+
229+
expect(stateReducer).not.toHaveBeenCalled() // won't get called on first render
230+
231+
rerender({
232+
stateReducer,
233+
itemToString,
234+
selectedItem,
235+
})
236+
237+
expect(stateReducer).toHaveBeenCalledTimes(1)
238+
expect(stateReducer).toHaveBeenCalledWith(
239+
{
240+
inputValue: itemToString(initialSelectedItem),
241+
selectedItem,
242+
highlightedIndex: -1,
243+
isOpen: false,
244+
},
245+
expect.objectContaining({
246+
type: useCombobox.stateChangeTypes.ControlledPropUpdatedSelectedItem,
247+
changes: {
248+
inputValue: itemToString(selectedItem),
249+
selectedItem,
250+
highlightedIndex: -1,
251+
isOpen: false,
252+
},
253+
}),
254+
)
255+
256+
stateReducer.mockClear()
257+
rerender({
258+
stateReducer,
259+
selectedItem: newSelectedItem,
260+
itemToString,
261+
})
262+
263+
expect(stateReducer).toHaveBeenCalledTimes(1)
264+
expect(stateReducer).toHaveBeenCalledWith(
265+
{
266+
inputValue: itemToString(selectedItem),
267+
selectedItem: newSelectedItem,
268+
highlightedIndex: -1,
269+
isOpen: false,
270+
},
271+
expect.objectContaining({
272+
changes: {
273+
inputValue: itemToString(newSelectedItem),
274+
selectedItem: newSelectedItem,
275+
highlightedIndex: -1,
276+
isOpen: false,
277+
},
278+
type: useCombobox.stateChangeTypes.ControlledPropUpdatedSelectedItem,
279+
}),
280+
)
281+
expect(getInput()).toHaveValue(itemToString(newSelectedItem))
282+
})
283+
284+
test('props update of selectedItem will not update inputValue state if itemToKey returns equal values', () => {
285+
const initialSelectedItem = {id: 1, value: 'hmm'}
286+
const selectedItem = {id: 1, value: 'wow'}
287+
function itemToString(item) {
288+
return item.value
289+
}
290+
const itemToKey = jest.fn().mockImplementation(item => item.id)
291+
const stateReducer = jest
292+
.fn()
293+
.mockImplementation((_state, {changes}) => changes)
294+
295+
const {rerender} = renderCombobox({
296+
itemToKey,
297+
stateReducer,
298+
selectedItem: initialSelectedItem,
299+
itemToString,
300+
})
301+
302+
rerender({
303+
itemToKey,
304+
stateReducer,
305+
selectedItem,
306+
itemToString,
307+
})
308+
309+
expect(getInput()).toHaveValue(itemToString(initialSelectedItem))
310+
expect(itemToKey).toHaveBeenCalledTimes(2)
311+
expect(itemToKey).toHaveBeenNthCalledWith(1, selectedItem)
312+
expect(itemToKey).toHaveBeenNthCalledWith(2, initialSelectedItem)
200313
})
201314
})
202315

@@ -601,6 +714,67 @@ describe('props', () => {
601714
expect(input).toHaveValue(selectedItem)
602715
})
603716

717+
test('selectedItem change updates the input value', async () => {
718+
const selectedItem = items[2]
719+
const newSelectedItem = items[4]
720+
const nullSelectedItem = null
721+
const lastSelectedItem = items[1]
722+
const stateReducer = jest.fn().mockImplementation((s, a) => a.changes)
723+
724+
const {rerender} = renderCombobox({
725+
selectedItem,
726+
stateReducer,
727+
})
728+
const input = getInput()
729+
730+
expect(input).toHaveValue(selectedItem)
731+
expect(stateReducer).not.toHaveBeenCalled() // don't call on first render.
732+
733+
rerender({
734+
selectedItem: newSelectedItem,
735+
stateReducer,
736+
})
737+
738+
expect(stateReducer).toHaveBeenCalledTimes(1)
739+
expect(stateReducer).toHaveBeenCalledWith(
740+
expect.anything(),
741+
expect.objectContaining({
742+
type: useCombobox.stateChangeTypes.ControlledPropUpdatedSelectedItem,
743+
}),
744+
)
745+
expect(input).toHaveValue(newSelectedItem)
746+
747+
stateReducer.mockClear()
748+
rerender({
749+
selectedItem: nullSelectedItem,
750+
stateReducer,
751+
})
752+
753+
expect(stateReducer).toHaveBeenCalledTimes(1)
754+
expect(stateReducer).toHaveBeenCalledWith(
755+
expect.anything(),
756+
expect.objectContaining({
757+
type: useCombobox.stateChangeTypes.ControlledPropUpdatedSelectedItem,
758+
}),
759+
)
760+
expect(input).toHaveValue('')
761+
762+
stateReducer.mockClear()
763+
rerender({
764+
selectedItem: lastSelectedItem,
765+
stateReducer,
766+
})
767+
768+
expect(stateReducer).toHaveBeenCalledTimes(1)
769+
expect(stateReducer).toHaveBeenCalledWith(
770+
expect.anything(),
771+
expect.objectContaining({
772+
type: useCombobox.stateChangeTypes.ControlledPropUpdatedSelectedItem,
773+
}),
774+
)
775+
expect(input).toHaveValue(lastSelectedItem)
776+
})
777+
604778
describe('stateReducer', () => {
605779
beforeEach(() => jest.useFakeTimers())
606780
afterEach(() => {

src/hooks/useCombobox/utils.js

+24-10
Original file line numberDiff line numberDiff line change
@@ -83,16 +83,31 @@ export function useControlledReducer(
8383
}
8484

8585
if (
86-
!isInitialMount && // on first mount we already have the proper inputValue for a initial selected item.
87-
props.selectedItemChanged(
88-
previousSelectedItemRef.current,
89-
props.selectedItem,
90-
)
86+
!isInitialMount // on first mount we already have the proper inputValue for a initial selected item.
9187
) {
92-
dispatch({
93-
type: ControlledPropUpdatedSelectedItem,
94-
inputValue: props.itemToString(props.selectedItem),
95-
})
88+
let shouldCallDispatch
89+
90+
if (props.selectedItemChanged === undefined) {
91+
shouldCallDispatch =
92+
props.itemToKey(props.selectedItem) !==
93+
props.itemToKey(previousSelectedItemRef.current)
94+
} else {
95+
console.warn(
96+
`The "selectedItemChanged" is deprecated. Please use "itemToKey instead". https://github.com/downshift-js/downshift/blob/master/src/hooks/useCombobox/README.md#selecteditemchanged`,
97+
)
98+
99+
shouldCallDispatch = props.selectedItemChanged(
100+
previousSelectedItemRef.current,
101+
props.selectedItem,
102+
)
103+
}
104+
105+
if (shouldCallDispatch) {
106+
dispatch({
107+
type: ControlledPropUpdatedSelectedItem,
108+
inputValue: props.itemToString(props.selectedItem),
109+
})
110+
}
96111
}
97112

98113
previousSelectedItemRef.current =
@@ -116,7 +131,6 @@ if (process.env.NODE_ENV !== 'production') {
116131

117132
export const defaultProps = {
118133
...defaultPropsCommon,
119-
selectedItemChanged: (prevItem, item) => prevItem !== item,
120134
getA11yStatusMessage,
121135
isItemDisabled() {
122136
return false

src/hooks/useMultipleSelection/README.md

+30
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ such as when an item has been removed from selection.
4444
- [initialActiveIndex](#initialactiveindex)
4545
- [defaultSelectedItems](#defaultselecteditems)
4646
- [defaultActiveIndex](#defaultactiveindex)
47+
- [itemToKey](#itemtokey)
4748
- [getA11yRemovalMessage](#geta11yremovalmessage)
4849
- [onActiveIndexChange](#onactiveindexchange)
4950
- [onStateChange](#onstatechange)
@@ -423,6 +424,35 @@ Pass an array of items that are going to be used when downshift is reset.
423424
Pass a number that sets the index of the focused / active selected item when
424425
downshift is reset.
425426

427+
### itemToKey
428+
429+
> `function(item: any)` | defaults to: `item => item`
430+
431+
Used to determine the uniqueness of an item when searching for the item or
432+
comparing the item with another. Returns the item itself, by default, so the
433+
comparing/searching is done internally via referential equality.
434+
435+
If using items as objects and their reference will change during use, you can
436+
use the function to generate a unique key for each item, such as an `id` prop.
437+
438+
```js
439+
// initial items.
440+
const selectedItems = [
441+
{id: 1, value: 'Apples'},
442+
{id: 2, value: 'Oranges'},
443+
]
444+
// the same items but with different references, for any reason.
445+
const newSelectedItems = [
446+
{id: 1, value: 'Apples'},
447+
{id: 2, value: 'Oranges'},
448+
]
449+
450+
function itemToKey(item) {
451+
return item.id
452+
// and we will do the comparison like: const isChanged = itemToKey(prevSelectedItem) !== itemToKey(nextSelectedItem)
453+
}
454+
```
455+
426456
### getA11yRemovalMessage
427457

428458
> `function({/* see below */})` | default messages provided in English

src/hooks/useMultipleSelection/index.js

+4-1
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,10 @@ function useMultipleSelection(userProps = {}) {
6363

6464
if (selectedItems.length < previousSelectedItemsRef.current.length) {
6565
const removedSelectedItem = previousSelectedItemsRef.current.find(
66-
item => selectedItems.indexOf(item) < 0,
66+
selectedItem =>
67+
selectedItems.findIndex(
68+
item => props.itemToKey(item) === props.itemToKey(selectedItem),
69+
) < 0,
6770
)
6871

6972
setStatus(

src/hooks/useMultipleSelection/reducer.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,9 @@ export default function downshiftMultipleSelectionReducer(state, action) {
7474
break
7575
case stateChangeTypes.FunctionRemoveSelectedItem: {
7676
let newActiveIndex = activeIndex
77-
const selectedItemIndex = selectedItems.indexOf(selectedItem)
77+
const selectedItemIndex = selectedItems.findIndex(
78+
item => props.itemToKey(item) === props.itemToKey(selectedItem),
79+
)
7880

7981
if (selectedItemIndex < 0) {
8082
break

src/hooks/useMultipleSelection/utils.js

+1
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ const propTypes = {
127127

128128
export const defaultProps = {
129129
itemToString: defaultPropsCommon.itemToString,
130+
itemToKey: defaultPropsCommon.itemToKey,
130131
stateReducer: defaultPropsCommon.stateReducer,
131132
environment: defaultPropsCommon.environment,
132133
getA11yRemovalMessage,

0 commit comments

Comments
 (0)