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

feat: Allow lazy-loaded SelectFields to change their options. #962

Merged
merged 5 commits into from
Oct 10, 2023

Conversation

stephenh
Copy link
Contributor

@stephenh stephenh commented Oct 10, 2023

I.e. if a variable in the GQL query changed, after we've done the initial load.

Previously our API for lazy SelectField was:

<SelectField options={{
  initial: ...some GQL value of current id+name...
  load: ...a promise that returned the options when interacted...
}}
/>

However this API only worked with the assumption that load() would only need to be called once, when the SelectField was 1st interacted with.

Turns out some lazy-selects will some options based on other data (like the value of a sibling select field, i.e. changing bid item needs to change the products), and so need to re-load/change their options even after the initial interaction, i.e. after a GQL query to load the option has its variable change.

So we've changed the options API for lazy selects to be:

  • current (not just initial) value from either the model or apollo cache
  • load a function that just triggers a load, but doesn't return the options
  • options the latest options, initially undefined pre-load-being-called, but then can change repeatedly after load has been called, i.e. as GQL queries fire:
export function ProductSelectField(props: ProductSelectFieldProps) {
  const { value, filterFn, onSelect, disabled, label = "Product", disableOptions, filter, ...others } = props;
  const [load, { data }] = useProductSelectLazyQuery({ variables: { filter: { ...filter }, first: 500 } });
  return (
    <SelectField<ProductSelect_ProductFragment, string>
      label={label}
      getOptionValue={(o) => o.id}
      getOptionLabel={optionDisplayName}
      disabledOptions={disableOptions}
      onSelect={(id, value) => onSelect(value)}
      value={value?.id}
      options={{
        current: value ?? undefined,
        load,
        options: data?.products.filter((p) => !filterFn || filterFn(p)),
      }}
      disabled={disabled}
      {...others}
    />
  );
}

@@ -46,7 +45,7 @@ function Template() {
{o.label}
</div>
)}
onSelect={action("onSelect")}
onSelect={(o) => setValue(o.label)}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Er, I was playing with Autocomplete a few days ago and this snuck in...

I.e. if a variable in the GQL query changed, after we've done the
initial load.
@stephenh stephenh force-pushed the allow-loaded-options-to-change branch from cdfd827 to 9919970 Compare October 10, 2023 18:40
@@ -37,7 +37,7 @@ class SingleFilter<O, V extends Key> extends BaseFilter<V, SingleFilterProps<O,

const options = Array.isArray(maybeOptions)
? [allOption as O, ...maybeOptions]
: { ...maybeOptions, initial: [allOption as O, ...maybeOptions.initial] };
: { ...maybeOptions, initial: maybeOptions.initial };
Copy link
Contributor

Choose a reason for hiding this comment

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

thought: I think this could cause bugs if anyone is using the SingleFilter with lazily loaded options. The All option will no longer appear. 🤔 Though, I think this is already a bug. The allOption probably isn't being returned in the load callback, so 🤷. I don't know if we can really get around this anyways... nevermind.

@@ -218,15 +214,8 @@ export function ComboBoxBase<O, V extends Value>(props: ComboBoxBaseProps<O, V>)
async function maybeInitLoad() {
Copy link
Contributor

Choose a reason for hiding this comment

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

question: maybeInitLoad is only being called once due to the firstOpen.current check in onOpenChange. Did you want to allow await maybeOptions.load() to be called multiple times?

Copy link
Contributor Author

@stephenh stephenh Oct 10, 2023

Choose a reason for hiding this comment

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

No, after the 1st call, I'm anticipating the page/caller will just pipe options directly into the component, without it having to be interacted with/triggered by the onOpenChange:

export function BidItemSelectField(props: BidItemSelectFieldProps) {
  const { filter, value, onSelect, readOnly, label, ...others } = props;

  const { refetch, data } = useBidItemSelectQuery({
    skip: true,
    variables: { filter },
    nextFetchPolicy: "cache-first",
  });

  return (
    <SelectField
      {...others}
      label={label ?? "Bid Item"}
      readOnly={readOnly}
      getOptionLabel={(o) => o.displayName}
      getOptionValue={(o) => o.id}
      unsetLabel={emptyCellDash}
      onSelect={onSelect}
      options={{
        initial: value ? { ...value, products: [] } : undefined,
        load: refetch,
        options: data?.potentialBidItems ?? [],
      }}
      value={value?.id}
    />
  );
}

Copy link
Contributor

Choose a reason for hiding this comment

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

If I understand this correctly, then we are skip-ing the useQuery call so that we only call it when we need to via refetch. But we're only going to be calling refetch once due to options.load only being triggered once. Does skip no longer apply if refetch was triggered once or something?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Right, kinda surprising, but once enabled by refetch, changes to variables will update the query. Kinda makes sense...but had to confirm it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fwiw I've also been moving callers over to the "useLazyQuery", where it's more intuitive that, after load is called, the query is kept up to date

Copy link
Contributor

Choose a reason for hiding this comment

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

Something about this felt a little odd to me, and I think finally see it. If we have a options.options property, maybe we use that for both the "initial" and "loaded" options and get rid of options.initial?

options={{
  load: refetch,
  options: data?.potentialBidItems ?? (value ? [{ ...value, products: [] }] : []),
}}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Here is a current impl:

export function ProductSelectField(props: ProductSelectFieldProps) {
  const { value, onSelect, disabled, label = "Product", disableOptions, filter, ...others } = props;
  const [load, { data }] = useProductSelectLazyQuery({ variables: { filter: { ...filter }, first: 500 } });
  return (
    <SelectField<ProductSelect_ProductFragment, string>
      label={label}
      getOptionValue={(o) => o.id}
      getOptionLabel={optionDisplayName}
      disabledOptions={disableOptions}
      onSelect={(id, value) => onSelect(value)}
      value={value?.id}
      options={{ initial: value ?? undefined, load, options: data?.products }}
      disabled={disabled}
      {...others}
    />
  );
}

Given your prompt, I had two thoughts:

a) Why keep options as a hash at all, i.e. we could do:

const [load, { data } = useLazyQuery();
<SelectFields options={data?.products ?? [value]} loadOptions={load} />

Which might be nice b/c it'd move options back to just an O[]

b) I'm wondering if we really want data?.products ?? [value], i.e. in the current select fields, I'm using a value prop to set both initial (pre-interaction) and "currently selected value" (post-interaction):

    <SelectField<ProductSelect_ProductFragment, string>
      label={label}
      getOptionValue={(o) => o.id}
      getOptionLabel={optionDisplayName}
      disabledOptions={disableOptions}
      onSelect={(id, value) => onSelect(value)}
      value={value?.id}
      options={{ initial: value ?? undefined, load, options: data?.products }}
      disabled={disabled}
      {...others}
    />

So "initial" is no longer "initial", it's really the current value, which makes me wonder (I believe we'd discussed this before), what happens if the current value is something that's not in the list of options the GQL query returned?

Maybe we should be looking for value / initial inside of options, and then appending it, if it's not there.

Which would be easier to do if initial + options were separate props, b/c SelectField could do the stitching internally...

Granted, I've not observed this "initial is not in options" happen in production yet, but seems like its just a matter of time...

So, dunno, wydt of renaming initial -> current and using the "ensure current is really within options" as a rationale for keeping options overloaded as { current, load, options }?

B/c if we don't want to do that (detect current is not in options), then maybe per "a" we should go back to options: O[], with the implicit contract (which I guess I don't like the implicit-ness of it) that "you should add current here", and an extra loadOptions prop.

...dunno, currently liking current + load + options, but lmk...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I also need to ship the "just add a product" field I'm working on, so I'll also probably offer to do the "merge current into options if missing" as a follow up.

Copy link
Contributor

Choose a reason for hiding this comment

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

Okay. Yea, I like the idea of current. I 100% agree with:

Granted, I've not observed this "initial is not in options" happen in production yet, but seems like its just a matter of time...
Feels like we are on our way to this, especially with your use case of a separate component changing the set of options for this SelectField.
Regarding option a, we could have the caller do the merging of the "current into options" with some helper.

<SelectFields options={ensureHasOption(data?.products, value) ?? [value]} loadOptions={load} />

But then, maybe its just easier for Beam to handle that so that nuance isn't overlooked by the developer.

I'm on board with initial -> current and ensuring current is always in the list of options.

@stephenh stephenh merged commit e919851 into main Oct 10, 2023
1 check passed
@stephenh stephenh deleted the allow-loaded-options-to-change branch October 10, 2023 20:42
homebound-team-bot pushed a commit that referenced this pull request Oct 10, 2023
## [2.320.0](v2.319.1...v2.320.0) (2023-10-10)

### Features

* Allow lazy-loaded SelectFields to change their options. ([#962](#962)) ([e919851](e919851))
@homebound-team-bot
Copy link

🎉 This PR is included in version 2.320.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

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

Successfully merging this pull request may close these issues.

3 participants