pacocoursey / cmdk

Fast, unstyled command menu React component.
https://cmdk.paco.me
MIT License
9.03k stars 258 forks source link

CommandItem not updating on asynchronous changes #267

Open ImamJanjua opened 1 month ago

ImamJanjua commented 1 month ago

Hi, as u can see in the video i am trying to implement a component which on input change gets the addres predictions from server. Its using PopOver from shadcn for the for the popover.

Now the thing is that when the first adresses arrive than it will render it but on chnage of the input value, it gets the new predictions but it will not render them?

https://github.com/pacocoursey/cmdk/assets/137432044/0a94b4de-afd8-4ebb-b234-5316ef7ee68d

"use client";

import * as React from "react";
import { useDebounce } from "@/hooks/useDebounce";

import { Button } from "@/components/ui/Button";
import {
  Command,
  CommandEmpty,
  CommandList,
  CommandGroup,
  CommandInput,
  CommandItem,
} from "@/components/ui/Command";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/Popover";
import { Check, ChevronsUpDown } from "lucide-react";

export function ComboboxDemo() {
  const [open, setOpen] = React.useState(false);
  const [value, setValue] = React.useState("");
  const [selectedValue, setSelectedValue] = React.useState("");

  const [options, setOptions] = React.useState([] as any[]);
  const debouncedValue = useDebounce(value);

  React.useEffect(() => {
    async function handleValueChange() {
      // fetch needed data
      setOptions(results...);
    }
    handleValueChange();
  }, [debouncedValue]);

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger asChild>
        <Button variant="outline" className="w-[200px] justify-between">
          {selectedValue
            ? options.find((option) => option.value === value)?.label
            : "Select framework..."}
          <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
        </Button>
      </PopoverTrigger>
      <PopoverContent className="p-0">
        <Command>
          <CommandInput
            value={value}
            onValueChange={setValue}
            placeholder="Search framework..."
          />
          <CommandList>
            <CommandEmpty>No framework found.</CommandEmpty>

            <CommandGroup>
              {options.map((option) => (
                <CommandItem
                  key={option.value}
                  value={option.value}
                  onSelect={(currentValue) => {
                    setSelectedValue(
                      currentValue === selectedValue ? "" : currentValue,
                    );
                    setOpen(false);
                  }}
                >
                  <Check
                    className={cn(
                      "mr-2 h-4 w-4",
                      selectedValue === option.value
                        ? "opacity-100"
                        : "opacity-0",
                    )}
                  />
                  {option.label}
                </CommandItem>
              ))}
            </CommandGroup>
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  );
}

export default ComboboxDemo;
hisamafahri commented 1 month ago

+1 experiencing the same issue.

In my case, the items are only "updated" only when the popover closes and then reopened or the search box value is deleted (empty)

ImamJanjua commented 1 month ago

@pacocoursey any help with that?

hisamafahri commented 1 month ago

Hi @ImamJanjua, since the options are updated when the Command is closed and reopened, I made a workaround to "abolish" the Command from the dom tree and replace it with another lookalike component while loading. After new data is fetched successfully, the Command will be added back.

It should end up with something like this:

https://github.com/pacocoursey/cmdk/assets/65691613/e29568ff-ece1-4104-ad14-a9077c1eefbf

interface DataTableFacetedFilterProps<TData, TValue> {
  column?: Column<TData, TValue>;
  title?: string;
  options: {
    label: string;
    value: string;
    icon?: React.ComponentType<{ className?: string }>;
  }[];
  onOpenChange?: ((open: boolean) => void) | undefined;
  onValueChange?: ((search: string) => void) | undefined;
  value?: string | undefined;
  defaultValue?: string | undefined;
  isLoading?: boolean;
}

const TableFacetedFilter = <TData, TValue>({
  column,
  title,
  options,
  onOpenChange,
  onValueChange,
  value,
  defaultValue,
  isLoading,
}: DataTableFacetedFilterProps<TData, TValue>) => {
  const facets = column?.getFacetedUniqueValues();
  const selectedValues = new Set(column?.getFilterValue() as string[]);
  // If it's loading, replace it with lookalike component
  if (isLoading) {
    return (
      <Popover onOpenChange={onOpenChange}>
        <PopoverTrigger asChild className="w-full">
          <Button variant="outline" className="border-dashed">
            <PlusCircledIcon className="mr-2 h-4 w-4" />
            {title}
            {selectedValues?.size > 0 && (
              <>
                <Separator orientation="vertical" className="mx-2 h-4" />
                <Badge
                  variant="secondary"
                  className="rounded-sm px-1 font-normal lg:hidden"
                >
                  {selectedValues.size}
                </Badge>
                <div className="hidden space-x-1 lg:flex">
                  {selectedValues.size > 0 ? (
                    <Badge
                      variant="secondary"
                      className="rounded-sm px-1 font-normal"
                    >
                      {selectedValues.size} selected
                    </Badge>
                  ) : (
                    options
                      .filter((option) => selectedValues.has(option.value))
                      .map((option) => (
                        <Badge
                          variant="secondary"
                          key={option.value}
                          className="rounded-sm px-1 font-normal"
                        >
                          {option.label}
                        </Badge>
                      ))
                  )}
                </div>
              </>
            )}
          </Button>
        </PopoverTrigger>
        <PopoverContent
          className="w-full p-0 popover-content-width-same-as-its-trigger"
          align="start"
        >
          <div className="flex items-center border-b px-3">
            <Icons.Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
            <Input // emulate the Command.Input
              className="flex h-10 w-full rounded-md bg-transparent py-3 px-0 text-sm !outline-none placeholder:text-muted-foreground !ring-0 !border-0"
              placeholder={title}
              defaultValue={value}
              autoFocus // keep the input focused
            />
          </div>
          <div className="flex flex-col items-center justify-center py-6">
            <Icons.Spinner className="h-4 w-4 animate-spin" />
            <p className="text-center text-sm">Fetching data…</p>
          </div>
        </PopoverContent>
      </Popover>
    );
  }

  // After the loading finished, re-render the options again
  return (
    <Popover onOpenChange={onOpenChange}>
      <PopoverTrigger asChild className="w-full">
        <Button variant="outline" className="border-dashed">
          <PlusCircledIcon className="mr-2 h-4 w-4" />
          {title}
          {selectedValues?.size > 0 && (
            <>
              <Separator orientation="vertical" className="mx-2 h-4" />
              <Badge
                variant="secondary"
                className="rounded-sm px-1 font-normal lg:hidden"
              >
                {selectedValues.size}
              </Badge>
              <div className="hidden space-x-1 lg:flex">
                {selectedValues.size > 0 ? (
                  <Badge
                    variant="secondary"
                    className="rounded-sm px-1 font-normal"
                  >
                    {selectedValues.size} selected
                  </Badge>
                ) : (
                  options
                    .filter((option) => selectedValues.has(option.value))
                    .map((option) => (
                      <Badge
                        variant="secondary"
                        key={option.value}
                        className="rounded-sm px-1 font-normal"
                      >
                        {option.label}
                      </Badge>
                    ))
                )}
              </div>
            </>
          )}
        </Button>
      </PopoverTrigger>
      <PopoverContent
        className="w-full p-0 popover-content-width-same-as-its-trigger"
        align="start"
      >
        <Command>
          <CommandInput
            placeholder={title}
            onValueChange={onValueChange}
            value={value}
            defaultValue={defaultValue}
            autoFocus
          />
          <CommandList>
            <CommandEmpty>No results found.</CommandEmpty>
            <CommandGroup>
              {options?.map((option) => {
                const isSelected = selectedValues.has(option.value);
                return (
                  <CommandItem
                    key={option.value}
                    onSelect={() => {
                      if (isSelected) {
                        selectedValues.delete(option.value);
                      } else {
                        selectedValues.add(option.value);
                      }
                      const filterValues = Array.from(selectedValues);
                      column?.setFilterValue(
                        filterValues.length ? filterValues : undefined
                      );
                    }}
                  >
                    <div
                      className={cn(
                        "mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
                        isSelected
                          ? "bg-primary text-primary-foreground"
                          : "opacity-50 [&_svg]:invisible"
                      )}
                    >
                      <CheckIcon className={cn("h-4 w-4")} />
                    </div>
                    {option.icon && (
                      <option.icon className="mr-2 h-4 w-4 text-muted-foreground" />
                    )}
                    <span>{option.label}</span>
                    {facets?.get(option.value) && (
                      <span className="ml-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
                        {facets.get(option.value)}
                      </span>
                    )}
                  </CommandItem>
                );
              })}
            </CommandGroup>
            {selectedValues.size > 0 && (
              <>
                <CommandSeparator />
                <CommandGroup>
                  <CommandItem
                    onSelect={() => column?.setFilterValue(undefined)}
                    className="justify-center text-center"
                  >
                    Clear filters
                  </CommandItem>
                </CommandGroup>
              </>
            )}
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  );
};
TalalBadreddine commented 1 month ago

+1, same issue here

guilhermeseckert commented 1 month ago

+1, same issue here.

TheRafaelFarias commented 4 weeks ago

I have two forms of solving this problem.

First it's just not use the Input provided by cmdk, use a native input handling changes and displayed data manually. Something like this:

Solution 1 - Best solution for my use case

const [currentData, setCurrentData] = useState(initialData);

const searchRoleByTerm = async (event: ChangeEvent<HTMLInputElement>) => {
    const value = event.target!.value
    setIsLoading(true);

    try {
      const response = await getServerRolesCategoriesAndChannels(
        guildId,
        "roles",
        {
          query: value,
        }
      );
      setCurrentData(response.data.roles);
      setIsLoading(false);
    } catch (error) {
      errorToast(
        "Unable to search by this term. Please try again or contact the management team"
      );

      console.log(error);

      setIsLoading(false);
    }
  };

<Command>
    <div className="flex items-center border-b border-primary/10 px-3">
      <HiSearch className="mr-2 h-4 w-4 shrink-0 opacity-50" />
      <input
         onChange={handleDebouncedRoleSearch}
         placeholder="Search a role..."
         className="flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-defaultText font-medium disabled:cursor-not-allowed disabled:opacity-50"
      />
    </div>

    <CommandList>...</CommandList>
</Command>

This way you will be able to control 100% of the results and flow of the search. Using uncontrolled input

Solution 2

This one you have some problems and I don't recommend using it.

This one consists of a key on the Command element, that changes when a new result comes, making the Command re-render

const [currentData, setCurrentData] = useState(initialData);
const [currentCommandId, setCurrentCommandId] = useState(
    Math.random() * 1000
  );

const searchRoleByTerm = async (value: string) => {
    setIsLoading(true);

    try {
      const response = await getServerRolesCategoriesAndChannels(
        guildId,
        "roles",
        {
          query: value,
        }
      );
      setCurrentData(response.data.roles);
      setIsLoading(false);

      setCurrentCommandId(Math.random() * 1000); // This updates Command element
    } catch (error) {
      errorToast(
        "Unable to search by this term. Please try again or contact the management team"
      );

      console.log(error);

      setIsLoading(false);
    }
  };

<Command key={currentCommandId}>
    <CommandInput
        onValueChange={onValueChange}
     />

    <CommandList>...</CommandList>
</Command>

But you will see that the input lose the value typed before

For that you can set it's value directly after Command has been updated. With something like this:

// ...rest of the states
const inputRef = useRef<HTMLInputElement | null>(null);

const searchRoleByTerm = async (value: string) => {
    setIsLoading(true);

    try {
      // ... fetch result
      setCurrentCommandId(Math.random() * 1000);

      setTimeout(() => {
        inputRef.current!.value = value;
        inputRef.current!.defaultValue = value;
      }, 10);
      // This updates the input value after 10ms
      // PS: State changes are asynchronous, so if the input updates before Command element re-render, it won't update the input after
    } catch (error) {
      errorToast(
        "Unable to search by this term. Please try again or contact the management team"
      );

      console.log(error);

      setIsLoading(false);
    }
  };

<Command key={currentCommandId}>
    <CommandInput
        ref={inputRef}
        onValueChange={onValueChange}
     />

    <CommandList>...</CommandList>
</Command>

If it doesn't work for someone I would happy to help, I'm also available on Discord as rafaelfarias

softmarshmallow commented 2 weeks ago

Well this is annoying

My case was for hiding / showing the CommandList content instead of having a empty state view, (using this as a select input with auto complete, withoyt popover)

Ended up having a key={String(open)} and ref for input, focusing back to the input

"use client";
import React, { useEffect } from "react";
import { Link1Icon, PersonIcon, PlusIcon } from "@radix-ui/react-icons";
import { Command as CommandPrimitive } from "cmdk";
import {
  Command,
  CommandGroup,
  CommandItem,
  CommandList,
  CommandSeparator,
} from "@/components/ui/command";
import { useState } from "react";
import { useEditorState } from "../editor";
import { SupabaseLogo } from "@/components/logos";
import { SYSTEM_GF_CUSTOMER_UUID_KEY } from "@/k/system";
import { cn } from "@/utils";
import { PrivateEditorApi } from "@/lib/private";
import { GridaSupabase } from "@/types";

const Input = React.forwardRef<
  React.ElementRef<typeof CommandPrimitive.Input>,
  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
  <div className="flex items-center border-b px-3" cmdk-input-wrapper="">
    <CommandPrimitive.Input
      ref={ref}
      className={cn(
        "flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
        className
      )}
      {...props}
    />
  </div>
));

Input.displayName = "CommandInput";

export function NameInput({
  autoFocus,
  value,
  onValueChange,
}: {
  autoFocus?: boolean;
  value?: string;
  onValueChange?: (value: string) => void;
}) {
  const ref = React.useRef<React.ElementRef<
    typeof CommandPrimitive.Input
  > | null>(null);
  const [state] = useEditorState();
  const [open, setOpen] = useState<boolean>(false);
  const [focus, setFocus] = useState<boolean>(false);

  const [tableSchema, setTableSchema] = useState<
    GridaSupabase.SupabaseTable["sb_table_schema"] | undefined
  >();

  useEffect(() => {
    if (state.connections.supabase) {
      PrivateEditorApi.SupabaseConnection.getConnectionTable(
        state.form_id
      ).then(({ data }) => {
        setTableSchema(data.data.sb_table_schema);
      });
    }
  }, [state.form_id, state.connections.supabase]);

  useEffect(() => {
    setOpen(focus && !!value);
  }, [value, focus]);

  useEffect(() => {
    // https://github.com/pacocoursey/cmdk/issues/267
    if (open || (!open && !value)) {
      ref.current?.focus();
    }
  }, [open, ref, value]);

  const onSelect = (val: string) => {
    onValueChange?.(val);
    setOpen(false);
    setFocus(false);
  };

  return (
    <Command key={String(open)} className="rounded-lg border">
      <Input
        required
        autoFocus={autoFocus}
        ref={ref}
        placeholder="field_name"
        value={value}
        onValueChange={onValueChange}
        onFocus={() => setFocus(true)}
        onBlur={() => setFocus(false)}
      />
      <CommandList>
        {open && (
          <>
            {value && (
              <>
                <CommandGroup>
                  <CommandItem key={"current"} onSelect={onSelect}>
                    <PlusIcon className="mr-2 h-4 w-4" />
                    <span>{value}</span>
                  </CommandItem>
                </CommandGroup>
              </>
            )}
            <CommandSeparator />
            <CommandGroup heading="System">
              <CommandItem
                key={SYSTEM_GF_CUSTOMER_UUID_KEY}
                onSelect={onSelect}
              >
                <PersonIcon className="mr-2 h-4 w-4" />
                <span>{SYSTEM_GF_CUSTOMER_UUID_KEY}</span>
              </CommandItem>
            </CommandGroup>
            {state.connections.supabase && (
              <>
                <CommandSeparator />
                <CommandGroup
                  heading={
                    <>
                      <SupabaseLogo className="inline w-4 h-4 me-1 align-middle" />{" "}
                      Supabase
                    </>
                  }
                >
                  {Object.keys(tableSchema?.properties ?? {}).map((key) => {
                    // const property = tableSchema?.properties[key];
                    return (
                      <CommandItem key={key} onSelect={onSelect}>
                        <Link1Icon className="mr-2 h-4 w-4" />
                        <span>{key}</span>
                      </CommandItem>
                    );
                  })}
                </CommandGroup>
              </>
            )}
          </>
        )}
      </CommandList>
    </Command>
  );
}

Final behaviour:

https://github.com/pacocoursey/cmdk/assets/16307013/a3eae3ff-e222-4121-a336-1220d06f0fd8