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

Updating combobox input value when selected item loads or updates #1683

Merged
merged 29 commits into from
Apr 21, 2021

Conversation

LFDanLu
Copy link
Member

@LFDanLu LFDanLu commented Mar 10, 2021

Closes #1645

✅ Pull Request Checklist:

  • Included link to corresponding React Spectrum GitHub Issue.
  • Added/updated unit tests and storybook for this change (for new code or code which already has tests).
  • Filled out test instructions.
  • Updated documentation (if it already exists for this component).
  • Looked at the Accessibility Practices for this feature - Aria Practices

📝 Test Instructions:

🧢 Your Project:

I think this is probably easier. Also ran into difficulties this was affecting inputValue/defaultSelected key cases and how the combobox actually renders twice due to setMenuWidth
@adobe-bot
Copy link

Build successful! 🎉

@adobe-bot
Copy link

Build successful! 🎉

@adobe-bot
Copy link

Build successful! 🎉

@LFDanLu LFDanLu changed the title (WIP) Updating combobox input value when selected item loads or updates Updating combobox input value when selected item loads or updates Mar 11, 2021
Comment on lines -166 to -207
// In this case, it's the user's responsibility to update inputValue in onSelectionChange. In addition, we preserve the defaultInputValue
// on initial render, even if it doesn't match the selected item's text.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

last bit of this comment was outdated since the reseting is done in the if statement immediately above now

Comment on lines 185 to 186
(props.selectedKey !== undefined && props.inputValue !== undefined) ||
(props.inputValue !== undefined && props.isOpen !== undefined)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the commitSelection logic, but w/o the selectedKey + isOpen controlled condition here since resetInputValue should work just fine on its own and we don't need to explicitly call onSelectionChange to close the menu IMO. Still mulling this over

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, should we handle this case at all or should we only handle when inputValue is uncontrolled? I feel like maybe it's the user's responsibility to update inputValue when they update items if it is controlled.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about when someone uses useAsyncList with the ComboBox? Would this be the expectation:

  • they calculate the new filterValue from the fetched list of items inside the load func that they provide to the useAsyncList hook
  • they call list.setFilterText or provide the adjusted filterText value through load's return object?

Just trying to feel out the mechanics of how the user would go about doing this. IMO I felt this was quite similar to our commitSelection logic in that we reset the inputValue (or call onSelectionChange instead, depending on what is controlled) for them even if inputValue was controlled

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, feels like if you're controlling inputValue you wouldn't necessarily want it to change, even when items changes, and therefore it's your responsibility to update it in that case. Basically, whenever you control inputValue you need to update it whenever anything changes that would affect it, and I think that's true in other cases (e.g. in onSelectionChange). So yeah, they'd need to do it in the places you said.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So essentially the logic would become just:

 if (props.inputValue === undefined) {
     resetInputValue();
 }

?

I guess this is fine as long as we call this out in the docs (aka if users are controlling items or providing a item map then they should be aware that inputValue and selectedKey text value could drift).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're mentioning updating the docs, but I don't see that change? Separate PR based on how docs deploy?

…ue reset

this covers the scenario where items/defaultItems are both undef and the user provides items to the combobox via map
@adobe-bot
Copy link

Build successful! 🎉

only resetting input value when collection changes for uncontrolled inputValue comboboxes now
@adobe-bot
Copy link

Build successful! 🎉

This example uses the [useAsyncList](../react-stately/useAsyncList.html) hook to handle loading the data.
See the docs for more information.
This example uses the [useAsyncList](../react-stately/useAsyncList.html) hook to handle loading the data. See the docs for more information.
**Note:** If you provide a `selectedKey` to the Combobox example below, it is your responsibility to update the input text when the `items` change or load.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe call out specifically that it's because inputValue is controlled as well. Also you need a newline if you want this in a separate paragraph.

Suggested change
**Note:** If you provide a `selectedKey` to the Combobox example below, it is your responsibility to update the input text when the `items` change or load.
**Note:** When both `inputValue` and `selectedKey` are controlled, and the `selectedKey` is set to an initial value before the `items` load, you must update the `inputValue` when the `items` load. This can be done by returning `filterText` from the `useAsyncList` load function.

Also I guess we should implement the ability to return filterText from the load function.

Should we show this in the example as well or would that make things too complex?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also hmm... we could probably do this automatically in useAsyncList? Like, we have the selectedKey, filterText, and items already. 🤔

Copy link
Member Author

@LFDanLu LFDanLu Mar 16, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've put a first stab at doing the above in 67b8a93, is this kinda what you were envisioning?
As for adding it to the example, I can go ahead and add it a separate one, feels like a use case some people will have

@@ -182,6 +187,15 @@ function reducer<T, C>(data: AsyncListState<T, C>, action: Action<T, C>): AsyncL
items: action.type === 'loading' ? [] : data.items,
abortController: action.abortController
};
case 'update':
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added update case handling now that selectedKeys/setSelectedKeys from useAsyncList is being used instead of a user defined selectedKey tracker

@LFDanLu LFDanLu changed the title Updating combobox input value when selected item loads or updates (WIP) Updating combobox input value when selected item loads or updates Mar 16, 2021
@devongovett devongovett added the waiting Waiting on Issue Author label Mar 18, 2021
@adobe-bot
Copy link

Build successful! 🎉

@devongovett devongovett self-assigned this Apr 15, 2021
case 'loadingMore':
// If already loading more and another loading more is triggered, abort the new load more.
// Do not overwrite the data.abortController
action.abortController.abort();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How would this case occur? Don't we prevent it here?

if (data.state === 'loadingMore' || data.state === 'filtering' || data.cursor == null) {
return;
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is what I thought at first, but if loadMore is called multiple times in quick succession it is apparently possible that data.state doesn't update in time. I only noticed this in the async combobox story where Virtualizer occasionally called loadMore multiple times when the menu and break cuz loadingMore wasn't a valid action when state was loadingMore

I added a test specifically for this here:

it('should handle multiple loadMore called in quick succession', async () => {
let load = jest.fn()
.mockImplementationOnce(getItems)
.mockImplementationOnce(getItems2)
.mockImplementationOnce(getItems);
let {result} = renderHook(
() => useAsyncList({load})
);
expect(load).toHaveBeenCalledTimes(1);
expect(result.current.isLoading).toBe(true);
expect(result.current.items).toEqual([]);
await act(async () => {
jest.runAllTimers();
});
expect(load).toHaveBeenCalledTimes(1);
expect(result.current.isLoading).toBe(false);
expect(result.current.items).toEqual(ITEMS);
await act(async () => {
result.current.loadMore();
result.current.loadMore();
jest.runAllTimers();
});
// Only original load more is handled, the other is canceled
expect(load).toHaveBeenCalledTimes(3);
expect(result.current.isLoading).toBe(false);
expect(result.current.items).toEqual(ITEMS.concat(ITEMS2));
});

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you reset this branch to 121f4bc (aka before I made these changes) and reuse the above test with a try catch around the double .loadMore calls you can see the error Invalid action "loadingMore" in state "loadingMore"

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh that makes sense. Ideally we wouldn't start a request only to immediately abort it though. One option would be to track the loading state in a ref so we don't have to wait for render to get the updated value. Another option would be to only trigger the request from within reduce, but this may delay the start for longer than necessary.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kk, implemented this kinda. Added a separate loadingState ref that gets updated in dispatchFetch but preserved data.state. Was the intention here to completely remove data.state and replace it with the loadingState ref?

@@ -199,6 +209,10 @@ function reducer<T, C>(data: AsyncListState<T, C>, action: Action<T, C>): AsyncL
cursor: action.cursor
};
case 'error':
if (action.abortController !== data.abortController) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What was this case for?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Paired with the added case below handling loadingMore if loadingMore is already happening. The change below will abort the newer (and extraneous) loadingMore actions which as a result will dispatch an error for the newer loadingMore actions. This case will then be triggered and preserve the current data state (i.e. we don't want to change the state to 'error' just cuz we canceled some extraneous loadMore calls)

@devongovett devongovett removed their assignment Apr 15, 2021
@adobe-bot
Copy link

Build successful! 🎉


```tsx example
function AsyncLoadingExample() {
let [isFocused, setFocused] = React.useState(false);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't affect rendering so could be a ref to avoid extra re-renders

<ComboBox
label="Star Wars Character Lookup"
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onFocusChange might be a bit simpler


// If selectedKey exists and combobox isn't focused, update the input value with the selected key text
// This allows the input value to be up to date when items load for the first time or the selected key text is updated server side.
if (!isFocused && selectedKey) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A little unfortunate that the user needs to track isFocused themselves for this. Would it update on blur though? I guess not because it's controlled? Should it perhaps be based on the loadingState (so loadingState === 'loading') instead? If the user typed in the input before the initial load completed, the initial load would get aborted anyway.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, it does update on blur in this case since onSelectionChange is called on blur in useComboBoxState since this is a multiple controlled prop combobox. Since onSelectionChange in this example is:

  let onSelectionChange = (key) => {
    let itemText = list.getItem(key)?.name;
    list.setSelectedKeys(new Set([key]));
    list.setFilterText(itemText);
  };

the combobox value will be updated to "Luke Skywalker" on blur. I can try changing it to be based on loadingState, I guess I'll add it to one of the params load accepts in useAsyncList

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think that makes sense? I guess the only case where it wouldn't work would be if the user focused the input before the items loaded but didn't type anything? But maybe that's ok? Just trying to think which is easier to explain/document.

@adobe-bot
Copy link

Build successful! 🎉

@adobe-bot
Copy link

Build successful! 🎉

name: string,
url: string
}
let isFocused = useRef(false);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will use this story or revert to using the loadingState in load depending on what we feel is the most appropriate

if (action.type !== 'success' && action.type !== 'update') {
loadingState.current = action.type;
} else {
loadingState.current = 'idle';
Copy link
Member Author

@LFDanLu LFDanLu Apr 20, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

opinions on this? Not sure a case exists where dispatchFetch is called w/ action=success/update but I needed a state to be set if there is hence the idle but that doesn't feel quite right... I could also modify dispatch in such a way that loadingState ref is modified in the reducer.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm I don't think a case exists where action.type would be success or update calling dispatchFetch.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, figured as much. Maybe I'll just delete the else part

@adobe-bot
Copy link

Build successful! 🎉

@adobe-bot
Copy link

Build successful! 🎉

// Fetch a new filtered list if filterText is updated via `load` response func rather than list.setFilterText
// Only do this if not aborted (e.g. user triggers another filter action before load completes)
if (filterText && (filterText !== previousFilterText) && !abortController.signal.aborted) {
loadingState.current = 'filtering';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be set inside dispatchFetch already right?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bleh, was too broad with my search on "dispatch", good catch

@adobe-bot
Copy link

Build successful! 🎉

@adobe-bot
Copy link

Build successful! 🎉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[combobox] when async loading items, defaultSelectedKey does not set input value
4 participants