ui icon indicating copy to clipboard operation
ui copied to clipboard

Load Async Response Into Combobox Options

Open anricoj1 opened this issue 2 years ago • 9 comments

Has anyone successfully built an "Autocomplete" combobox that takes in throttled data from an api endpoint? The options displayed only update when closing and reopening the Combobox but that's not ideal for any user. We have a lot of items and returning all of them at once isn't a viable solution at all.

The idea is to let the user type in their item and a request will be sent to the server every few seconds and return matching records in their options.

https://github.com/shadcn-ui/ui/assets/46979095/73195066-17da-4c00-a853-7406e50de704

// react
import { useEffect, useState } from "react";

// tanstack
import { useQuery } from "@tanstack/react-query";

// acme
import { Combobox, type Option } from "@stew-leonards/core";

// env
import { env } from "~/env";

/** like item */
type LikeItem = {
  store_number: number;
  item_code: string;
  description: string;
  plu: string | null;
};

/** get like items api request */
async function getLikeItems(params: {
  inputValue: string;
  selectedColumn: keyof Omit<LikeItem, "store_number">;
  storeNumber: number;
}): Promise<LikeItem[]> {
  const response = await fetch(`${env.API_PREFIX}/forms/items/likeitem`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(params),
  });
  return response.json();
}

/** search item combobox */
export function SearchItemCombobox() {
  const {
    storeItemsResponse,
    setSelectedColumn,
    setSelectedValue,
    selectedColumn,
    setInputValue,
    selectedValue,
    inputValue,
  } = useSearchItemCombobox();

  return (
    <div>
      <Combobox
        options={storeItemsResponse.data ?? []}
        inputValue={inputValue}
        setInputValue={setInputValue}
        selectedValue={JSON.stringify(selectedValue)}
        setSelectedValue={(value) => {
          setSelectedValue(JSON.parse(value));
        }}
      />
    </div>
  );
}

/** search item combobox hook */
export function useSearchItemCombobox() {
  const [debouncedValue, setDebouncedValue] = useState("");
  const [inputValue, setInputValue] = useState("");
  const [selectedValue, setSelectedValue] = useState<LikeItem>();
  const [selectedColumn, setSelectedColumn] =
    useState<keyof Omit<LikeItem, "store_number">>("description");

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(inputValue);
    }, 1500);

    return () => {
      clearTimeout(handler);
    };
  }, [inputValue]);

  const storeItemsResponse = useQuery({
    queryKey: ["storeItems", debouncedValue],
    queryFn: () =>
      getLikeItems({
        inputValue: debouncedValue,
        storeNumber: 1,
        selectedColumn: selectedColumn,
      }),
    enabled: debouncedValue !== "",
    select: (data) => {
      const options: Option<string>[] = data.map((item) => {
        let label = `${item.item_code} - ${item.description}`;

        if (item.plu) {
          label = `${label} (${item.plu})`;
        }

        return {
          label: label,
          value: JSON.stringify(item),
        };
      });

      return options;
    },
  });

  return {
    storeItemsResponse,
    setSelectedColumn,
    setSelectedValue,
    selectedColumn,
    setInputValue,
    selectedValue,
    inputValue,
  };
}

Here's the combobox component I wrote, it's very similar to the one suggested in the docs just with a controlled input

// react
import { useState } from "react";

// acme
import {
    Button,
    Command,
    CommandEmpty,
    CommandGroup,
    CommandInput,
    CommandItem,
    Popover,
    PopoverContent,
    PopoverTrigger,
} from "@stew-leonards/core/src";
import { cn } from "@stew-leonards/utils/src";

// lucide
import { Check, ChevronsUpDown } from "lucide-react";

/** option type */
export type Option<T extends NonNullable<unknown>> = {
    /** value */
    value: T;
    /** label */
    label: string;
}

/** combo box props */
export type ComboboxProps<T extends NonNullable<unknown>> = {
    /** options */
    options: Option<T>[];
    /** input value */
    inputValue: string;
    /** set input value */
    setInputValue: (value: string) => void;
    /** selected value */
    selectedValue: T;
    /** set selected value */
    setSelectedValue: (value: T) => void;
    /** placeholder */
    placeholder?: string;
}

/** combo box */
export function Combobox<T extends NonNullable<unknown>>({
    setSelectedValue,
    selectedValue,
    setInputValue,
    inputValue,
    options,
    placeholder = "Select an option",
}: ComboboxProps<T>) {
    // state
    const [open, setOpen] = useState(false);

    const compareValues = (value1: T, value2: T) => {
        if (typeof value1 === 'object' && typeof value2 === 'object') {
            // Compare object values (deep comparison)
            return JSON.stringify(value1) === JSON.stringify(value2);
        }
        // Compare primitive values
        return value1 === value2;
    };

    return (
        <Popover open={open} onOpenChange={setOpen}>
            <PopoverTrigger asChild>
                <Button
                    variant="outline"
                    role="combobox"
                    aria-expanded={open}
                    className="w-[200px] justify-between"
                >
                    {options.find(option => {
                        return compareValues(option.value, selectedValue);
                    })?.label || placeholder}
                    <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
                </Button>
            </PopoverTrigger>
            <PopoverContent className="w-[200px] p-0">
                <Command>
                    <CommandInput
                        placeholder={placeholder}
                        value={inputValue}
                        onInput={(event) => {
                            setInputValue(event.currentTarget.value);
                        }}
                    />
                    <CommandEmpty>No option found.</CommandEmpty>
                    <CommandGroup>
                        {options.map(option => (
                            <CommandItem
                                key={option.label}
                                onSelect={() => {
                                    setSelectedValue(option.value);
                                    setOpen(false);
                                }}
                            >
                                <Check
                                    className={cn("mr-2 h-4 w-4", selectedValue === option.value ? "opacity-100" : "opacity-0")}
                                />
                                {option.label}
                            </CommandItem>
                        ))}
                    </CommandGroup>
                </Command>
            </PopoverContent>
        </Popover>
    )
}

anricoj1 avatar Aug 30 '23 15:08 anricoj1

I also have similar problem. Any workaround to fix this bug?

nestordabon avatar Sep 01 '23 14:09 nestordabon

@nestordabon I had to scrap this in favor of React Select due to time constraints (really didn't want to do this).

I plan to keep this issue open, hopefully it gains some traction. I really like the functionality of this "Combobox", async requests like this are very common (especially for us at the item level).

I have a feeling it has something to do with the open state. I'll have to set some personal time outside of work to give this another go.

anricoj1 avatar Sep 02 '23 16:09 anricoj1

I also needed this. In my case, I'm hitting a search endpoint which itself acts as the filter. Managed to get something working in my app, and created a small public demo for it with some notes: https://github.com/melanieseltzer/example-async-combobox-options

Hope it helps!

melanieseltzer avatar Sep 05 '23 05:09 melanieseltzer

@melanieseltzer I checked your code and the solution seems to be adding shouldFilter={false} to <Command

Thank you very much, saved so much time.

kalideir avatar Sep 10 '23 11:09 kalideir

@melanieseltzer I checked your code and the solution seems to be adding shouldFilter={false} to <Command

Thank you very much, saved so much time.

Finally

csulit avatar Sep 23 '23 09:09 csulit

I also needed this. In my case, I'm hitting a search endpoint which itself acts as the filter. Managed to get something working in my app, and created a small public demo for it with some notes: https://github.com/melanieseltzer/example-async-combobox-options

Hope it helps!

Amazing @melanieseltzer !

diegobonagurio avatar Dec 31 '23 21:12 diegobonagurio

@shadcn can close this one.

SweydManaf avatar Mar 12 '24 09:03 SweydManaf

I also needed this. In my case, I'm hitting a search endpoint which itself acts as the filter. Managed to get something working in my app, and created a small public demo for it with some notes: https://github.com/melanieseltzer/example-async-combobox-options

Hope it helps!

Anybody having issues using this code, I found that you need to remove the return null in the search.tsx

// Remove the line below 
  if (!enabled) return null;

Lukem121 avatar May 07 '24 14:05 Lukem121

Can we have an official example in the shadcn docs for this?

hsavit1 avatar Jun 24 '24 14:06 hsavit1

This issue has been automatically closed because it received no activity for a while. If you think it was closed by accident, please leave a comment. Thank you.

shadcn avatar Jul 19 '24 01:07 shadcn