diff --git a/apps/web/src/app/onboarding/component/onboarding-form.tsx b/apps/web/src/app/onboarding/component/onboarding-form.tsx index 33df742b..84e49496 100644 --- a/apps/web/src/app/onboarding/component/onboarding-form.tsx +++ b/apps/web/src/app/onboarding/component/onboarding-form.tsx @@ -14,7 +14,6 @@ import { useRouter } from "next/navigation"; import React, { Activity } from "react"; import { toast } from "sonner"; import z from "zod"; -import MultipleSelector from "@/app/onboarding/component/multiselect"; import { SchoolCombobox } from "@/app/onboarding/component/school-combobox"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -33,6 +32,7 @@ import { FieldLabel, Field as UIField, } from "@/components/ui/field"; +import MultipleSelector from "@/components/ui/multiselect"; import { Select, SelectContent, @@ -45,6 +45,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { useDebounce } from "@/hooks/use-debounce"; import DegreeProgreeUpload from "@/modules/report-parsing/components/degree-progress-upload"; import type { UserCourse } from "@/modules/report-parsing/types"; import type { StartingTerm } from "@/modules/report-parsing/utils/parse-starting-term"; @@ -108,16 +109,15 @@ export function OnboardingForm() { ); // Programs - const [programsQuery, setProgramsQuery] = React.useState( - undefined, - ); - const { - results: programs, - status: programsStatus, - loadMore: programsLoadMore, - } = usePaginatedQuery( + const [programSearchInput, setProgramSearchInput] = + React.useState(""); + const debouncedProgramSearch = useDebounce(programSearchInput, 300); + + const { results: programs } = usePaginatedQuery( api.programs.getPrograms, - isAuthenticated ? { query: programsQuery } : ("skip" as const), + isAuthenticated + ? { query: debouncedProgramSearch.trim() || undefined } + : ("skip" as const), { initialNumItems: 20 }, ); @@ -125,25 +125,25 @@ export function OnboardingForm() { () => (programs ?? []).map((program) => ({ value: program._id, - label: program.name, + label: `${program.name} - ${program.school}`, })), [programs], ); - const handleSearchPrograms = React.useCallback( - async (value: string) => { - const trimmed = value.trim(); - setProgramsQuery(trimmed.length === 0 ? undefined : trimmed); - return programOptions; - }, - [programOptions], + // Cache to store program labels so they don't disappear when search results change + const programLabelCache = React.useRef, string>>( + new Map(), ); - const handleLoadMorePrograms = React.useCallback(() => { - if (programsStatus === "CanLoadMore") { - void programsLoadMore(10); - } - }, [programsStatus, programsLoadMore]); + // Update cache whenever new programs are loaded + React.useEffect(() => { + programOptions.forEach((option) => { + programLabelCache.current.set( + option.value as Id<"programs">, + option.label, + ); + }); + }, [programOptions]); const currentYear = React.useMemo(() => new Date().getFullYear(), []); const defaultTerm = React.useMemo(() => { @@ -372,8 +372,10 @@ export function OnboardingForm() { {(field) => { const selected = (field.state.value ?? []).map((p) => ({ value: p, - label: programOptions.find((val) => val.value === p) - ?.label as string, + label: + programOptions.find((val) => val.value === p)?.label || + programLabelCache.current.get(p) || + "", })); return ( @@ -390,12 +392,16 @@ export function OnboardingForm() { } defaultOptions={programOptions} options={programOptions} - delay={300} - onSearch={handleSearchPrograms} - triggerSearchOnFocus placeholder="Select your programs" - commandProps={{ label: "Select programs" }} - onListReachEnd={handleLoadMorePrograms} + commandProps={{ + label: "Select programs", + shouldFilter: false, + }} + inputProps={{ + onValueChange: (value) => { + setProgramSearchInput(value); + }, + }} emptyIndicator={

No programs found diff --git a/apps/web/src/app/onboarding/component/multiselect.tsx b/apps/web/src/components/ui/multiselect.tsx similarity index 58% rename from apps/web/src/app/onboarding/component/multiselect.tsx rename to apps/web/src/components/ui/multiselect.tsx index 03c8f168..a4967039 100644 --- a/apps/web/src/app/onboarding/component/multiselect.tsx +++ b/apps/web/src/components/ui/multiselect.tsx @@ -1,138 +1,139 @@ -"use client"; +"use client" -import { Command as CommandPrimitive, useCommandState } from "cmdk"; -import { XIcon } from "lucide-react"; -import * as React from "react"; -import { useEffect } from "react"; +import * as React from "react" +import { useEffect } from "react" +import { Command as CommandPrimitive, useCommandState } from "cmdk" +import { XIcon } from "lucide-react" -import { cn } from "@/lib/utils"; -import { Command, CommandGroup, CommandItem, CommandList } from "./command"; +import { cn } from "@/lib/utils" +import { + Command, + CommandGroup, + CommandItem, + CommandList, +} from "@/components/ui/command" export interface Option { - value: string; - label: string; - disable?: boolean; + value: string + label: string + disable?: boolean /** fixed option that can't be removed. */ - fixed?: boolean; + fixed?: boolean /** Group the options by providing key. */ - [key: string]: string | boolean | undefined; + [key: string]: string | boolean | undefined } interface GroupOption { - [key: string]: Option[]; + [key: string]: Option[] } interface MultipleSelectorProps { - value?: Option[]; - defaultOptions?: Option[]; + value?: Option[] + defaultOptions?: Option[] /** manually controlled options */ - options?: Option[]; - placeholder?: string; + options?: Option[] + placeholder?: string /** Loading component. */ - loadingIndicator?: React.ReactNode; + loadingIndicator?: React.ReactNode /** Empty component. */ - emptyIndicator?: React.ReactNode; + emptyIndicator?: React.ReactNode /** Debounce time for async search. Only work with `onSearch`. */ - delay?: number; + delay?: number /** * Only work with `onSearch` prop. Trigger search when `onFocus`. * For example, when user click on the input, it will trigger the search to get initial options. **/ - triggerSearchOnFocus?: boolean; + triggerSearchOnFocus?: boolean /** async search */ - onSearch?: (value: string) => Promise; + onSearch?: (value: string) => Promise /** * sync search. This search will not showing loadingIndicator. * The rest props are the same as async search. * i.e.: creatable, groupBy, delay. **/ - onSearchSync?: (value: string) => Option[]; - onChange?: (options: Option[]) => void; + onSearchSync?: (value: string) => Option[] + onChange?: (options: Option[]) => void /** Limit the maximum number of selected options. */ - maxSelected?: number; + maxSelected?: number /** When the number of selected options exceeds the limit, the onMaxSelected will be called. */ - onMaxSelected?: (maxLimit: number) => void; + onMaxSelected?: (maxLimit: number) => void /** Hide the placeholder when there are options selected. */ - hidePlaceholderWhenSelected?: boolean; - disabled?: boolean; + hidePlaceholderWhenSelected?: boolean + disabled?: boolean /** Group the options base on provided key. */ - groupBy?: string; - className?: string; - badgeClassName?: string; + groupBy?: string + className?: string + badgeClassName?: string /** * First item selected is a default behavior by cmdk. That is why the default is true. * This is a workaround solution by add a dummy item. * * @reference: https://github.com/pacocoursey/cmdk/issues/171 */ - selectFirstItem?: boolean; + selectFirstItem?: boolean /** Allow user to create option when there is no option matched. */ - creatable?: boolean; + creatable?: boolean /** Props of `Command` */ - commandProps?: React.ComponentPropsWithoutRef; + commandProps?: React.ComponentPropsWithoutRef /** Props of `CommandInput` */ inputProps?: Omit< React.ComponentPropsWithoutRef, "value" | "placeholder" | "disabled" - >; - /** Called when the options list is scrolled near the end. */ - onListReachEnd?: () => void; - /** Distance in pixels from the bottom to trigger `onListReachEnd`. */ - listEndOffset?: number; + > /** hide the clear all button. */ - hideClearAllButton?: boolean; + hideClearAllButton?: boolean } export interface MultipleSelectorRef { - selectedValue: Option[]; - input: HTMLInputElement; - focus: () => void; - reset: () => void; + selectedValue: Option[] + input: HTMLInputElement + focus: () => void + reset: () => void } export function useDebounce(value: T, delay?: number): T { - const [debouncedValue, setDebouncedValue] = React.useState(value); + const [debouncedValue, setDebouncedValue] = React.useState(value) useEffect(() => { - const timer = setTimeout(() => setDebouncedValue(value), delay || 500); + const timer = setTimeout(() => setDebouncedValue(value), delay || 500) return () => { - clearTimeout(timer); - }; - }, [value, delay]); + clearTimeout(timer) + } + }, [value, delay]) - return debouncedValue; + return debouncedValue } function transToGroupOption(options: Option[], groupBy?: string) { if (options.length === 0) { - return {}; + return {} } if (!groupBy) { return { "": options, - }; + } } - const groupOption: GroupOption = {}; + const groupOption: GroupOption = {} options.forEach((option) => { - const key = (option[groupBy] as string) || ""; + const key = (option[groupBy] as string) || "" if (!groupOption[key]) { - groupOption[key] = []; + groupOption[key] = [] } - groupOption[key].push(option); - }); - return groupOption; + groupOption[key].push(option) + }) + return groupOption } function removePickedOption(groupOption: GroupOption, picked: Option[]) { - const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption; + const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption for (const [key, value] of Object.entries(cloneOption)) { cloneOption[key] = value.filter( - (val) => !picked.find((p) => p.value === val.value), - ); + (val) => !picked.find((p) => p.value === val.value) + ) } - return cloneOption; + return cloneOption } function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) { @@ -140,19 +141,19 @@ function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) { if ( value.some((option) => targetOption.find((p) => p.value === option.value)) ) { - return true; + return true } } - return false; + return false } const CommandEmpty = ({ className, ...props }: React.HTMLAttributes) => { - const render = useCommandState((state) => state.filtered.count === 0); + const render = useCommandState((state) => state.filtered.count === 0) - if (!render) return null; + if (!render) return null return (

- ); -}; + ) +} -CommandEmpty.displayName = "CommandEmpty"; +CommandEmpty.displayName = "CommandEmpty" const MultipleSelector = ({ value, @@ -188,204 +189,153 @@ const MultipleSelector = ({ triggerSearchOnFocus = false, commandProps, inputProps, - onListReachEnd, - listEndOffset = 80, hideClearAllButton = false, }: MultipleSelectorProps) => { - const inputRef = React.useRef(null); - const [open, setOpen] = React.useState(false); - const [onScrollbar, setOnScrollbar] = React.useState(false); - const [isLoading, setIsLoading] = React.useState(false); - const dropdownRef = React.useRef(null); - const commandListRef = React.useRef(null); - const [hasReachedListEnd, setHasReachedListEnd] = React.useState(false); - - const [selected, setSelected] = React.useState(value || []); + const inputRef = React.useRef(null) + const [open, setOpen] = React.useState(false) + const [onScrollbar, setOnScrollbar] = React.useState(false) + const [isLoading, setIsLoading] = React.useState(false) + const dropdownRef = React.useRef(null) // Added this + + const [selected, setSelected] = React.useState(value || []) const [options, setOptions] = React.useState( - transToGroupOption(arrayDefaultOptions, groupBy), - ); - const [inputValue, setInputValue] = React.useState(""); - const debouncedSearchTerm = useDebounce(inputValue, delay || 500); - - const handleClickOutside = React.useCallback( - (event: MouseEvent | TouchEvent) => { - if ( - dropdownRef.current && - !dropdownRef.current.contains(event.target as Node) && - inputRef.current && - !inputRef.current.contains(event.target as Node) - ) { - setOpen(false); - inputRef.current.blur(); - } - }, - [], - ); + transToGroupOption(arrayDefaultOptions, groupBy) + ) + const [inputValue, setInputValue] = React.useState("") + const debouncedSearchTerm = useDebounce(inputValue, delay || 500) + + const handleClickOutside = (event: MouseEvent | TouchEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) && + inputRef.current && + !inputRef.current.contains(event.target as Node) + ) { + setOpen(false) + inputRef.current.blur() + } + } const handleUnselect = React.useCallback( (option: Option) => { - const newOptions = selected.filter((s) => s.value !== option.value); - setSelected(newOptions); - onChange?.(newOptions); + const newOptions = selected.filter((s) => s.value !== option.value) + setSelected(newOptions) + onChange?.(newOptions) }, - [onChange, selected], - ); + [onChange, selected] + ) const handleKeyDown = React.useCallback( (e: React.KeyboardEvent) => { - const input = inputRef.current; + const input = inputRef.current if (input) { if (e.key === "Delete" || e.key === "Backspace") { if (input.value === "" && selected.length > 0) { - const lastSelectOption = selected[selected.length - 1]; + const lastSelectOption = selected[selected.length - 1] // If last item is fixed, we should not remove it. if (!lastSelectOption.fixed) { - handleUnselect(selected[selected.length - 1]); + handleUnselect(selected[selected.length - 1]) } } } // This is not a default behavior of the field if (e.key === "Escape") { - input.blur(); + input.blur() } } }, - [handleUnselect, selected], - ); - - const handleListScroll = React.useCallback( - (event: React.UIEvent) => { - if (!onListReachEnd) { - return; - } - - const target = event.currentTarget; - const distanceToBottom = - target.scrollHeight - target.scrollTop - target.clientHeight; - - if (distanceToBottom <= listEndOffset) { - setHasReachedListEnd((alreadyReached) => { - if (!alreadyReached) { - onListReachEnd(); - } - return true; - }); - } else { - setHasReachedListEnd(false); - } - }, - [listEndOffset, onListReachEnd], - ); + [handleUnselect, selected] + ) useEffect(() => { if (open) { - document.addEventListener("mousedown", handleClickOutside); - document.addEventListener("touchend", handleClickOutside); + document.addEventListener("mousedown", handleClickOutside) + document.addEventListener("touchend", handleClickOutside) } else { - document.removeEventListener("mousedown", handleClickOutside); - document.removeEventListener("touchend", handleClickOutside); - setHasReachedListEnd(false); + document.removeEventListener("mousedown", handleClickOutside) + document.removeEventListener("touchend", handleClickOutside) } return () => { - document.removeEventListener("mousedown", handleClickOutside); - document.removeEventListener("touchend", handleClickOutside); - }; - }, [open, handleClickOutside]); - - useEffect(() => { - if (!open || !onListReachEnd || hasReachedListEnd) { - return; - } - - const listElement = commandListRef.current; - if (!listElement) { - return; - } - - const distanceToBottom = - listElement.scrollHeight - listElement.clientHeight; - if (distanceToBottom <= listEndOffset) { - onListReachEnd(); - setHasReachedListEnd(true); + document.removeEventListener("mousedown", handleClickOutside) + document.removeEventListener("touchend", handleClickOutside) } - }, [hasReachedListEnd, listEndOffset, onListReachEnd, open]); + }, [open]) useEffect(() => { if (value) { - setSelected(value); + setSelected(value) } - }, [value]); - - useEffect(() => { - setHasReachedListEnd(false); - }, []); + }, [value]) useEffect(() => { - if (!arrayOptions) { - return; + /** If `onSearch` is provided, do not trigger options updated. */ + if (!arrayOptions || onSearch) { + return } - const newOption = transToGroupOption(arrayOptions, groupBy); + const newOption = transToGroupOption(arrayOptions || [], groupBy) if (JSON.stringify(newOption) !== JSON.stringify(options)) { - setOptions(newOption); + setOptions(newOption) } - }, [arrayOptions, groupBy, options]); + }, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options]) useEffect(() => { /** sync search */ const doSearchSync = () => { - const res = onSearchSync?.(debouncedSearchTerm); - setOptions(transToGroupOption(res || [], groupBy)); - }; + const res = onSearchSync?.(debouncedSearchTerm) + setOptions(transToGroupOption(res || [], groupBy)) + } const exec = async () => { - if (!onSearchSync || !open) return; + if (!onSearchSync || !open) return if (triggerSearchOnFocus) { - doSearchSync(); + doSearchSync() } if (debouncedSearchTerm) { - doSearchSync(); + doSearchSync() } - }; + } - void exec(); - }, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus, onSearchSync]); + void exec() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]) useEffect(() => { /** async search */ const doSearch = async () => { - setIsLoading(true); - const res = await onSearch?.(debouncedSearchTerm); - setOptions(transToGroupOption(res || [], groupBy)); - setIsLoading(false); - }; + setIsLoading(true) + const res = await onSearch?.(debouncedSearchTerm) + setOptions(transToGroupOption(res || [], groupBy)) + setIsLoading(false) + } const exec = async () => { - if (!onSearch || !open) return; + if (!onSearch || !open) return if (triggerSearchOnFocus) { - await doSearch(); + await doSearch() } if (debouncedSearchTerm) { - await doSearch(); + await doSearch() } - }; + } - void exec(); - }, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus, onSearch]); + void exec() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]) const CreatableItem = () => { - if (!creatable) return undefined; + if (!creatable) return undefined if ( isOptionsExist(options, [{ value: inputValue, label: inputValue }]) || selected.find((s) => s.value === inputValue) ) { - return undefined; + return undefined } const Item = ( @@ -393,39 +343,39 @@ const MultipleSelector = ({ value={inputValue} className="cursor-pointer" onMouseDown={(e) => { - e.preventDefault(); - e.stopPropagation(); + e.preventDefault() + e.stopPropagation() }} onSelect={(value: string) => { if (selected.length >= maxSelected) { - onMaxSelected?.(selected.length); - return; + onMaxSelected?.(selected.length) + return } - setInputValue(""); - const newOptions = [...selected, { value, label: value }]; - setSelected(newOptions); - onChange?.(newOptions); + setInputValue("") + const newOptions = [...selected, { value, label: value }] + setSelected(newOptions) + onChange?.(newOptions) }} > {`Create "${inputValue}"`} - ); + ) // For normal creatable if (!onSearch && inputValue.length > 0) { - return Item; + return Item } // For async search creatable. avoid showing creatable item before loading at first. if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) { - return Item; + return Item } - return undefined; - }; + return undefined + } const EmptyItem = React.useCallback(() => { - if (!emptyIndicator) return undefined; + if (!emptyIndicator) return undefined // For async search that showing emptyIndicator if (onSearch && !creatable && Object.keys(options).length === 0) { @@ -433,43 +383,43 @@ const MultipleSelector = ({ {emptyIndicator} - ); + ) } - return {emptyIndicator}; - }, [creatable, emptyIndicator, onSearch, options]); + return {emptyIndicator} + }, [creatable, emptyIndicator, onSearch, options]) const selectables = React.useMemo( () => removePickedOption(options, selected), - [options, selected], - ); + [options, selected] + ) /** Avoid Creatable Selector freezing or lagging when paste a long string. */ const commandFilter = React.useCallback(() => { if (commandProps?.filter) { - return commandProps.filter; + return commandProps.filter } if (creatable) { return (value: string, search: string) => { - return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1; - }; + return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1 + } } // Using default filter in `cmdk`. We don‘t have to provide it. - return undefined; - }, [creatable, commandProps?.filter]); + return undefined + }, [creatable, commandProps?.filter]) return ( { - handleKeyDown(e); - commandProps?.onKeyDown?.(e); + handleKeyDown(e) + commandProps?.onKeyDown?.(e) }} className={cn( "h-auto overflow-visible bg-transparent", - commandProps?.className, + commandProps?.className )} shouldFilter={ commandProps?.shouldFilter !== undefined @@ -478,8 +428,6 @@ const MultipleSelector = ({ } // When onSearch is provided, we don‘t want to filter the options. You can still override it. filter={commandFilter()} > - {/* biome-ignore lint/a11y/noStaticElementInteractions: Custom input component */} - {/* biome-ignore lint/a11y/useKeyWithClickEvents: Custom input component */}
{ - if (disabled) return; - inputRef?.current?.focus(); + if (disabled) return + inputRef?.current?.focus() }} >
@@ -502,23 +450,22 @@ const MultipleSelector = ({ key={option.value} className={cn( "animate-fadeIn relative inline-flex h-7 cursor-default items-center rounded-md border bg-background ps-2 pe-7 pl-2 text-xs font-medium text-secondary-foreground transition-all hover:bg-background disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 data-fixed:pe-2", - badgeClassName, + badgeClassName )} data-fixed={option.fixed} data-disabled={disabled || undefined} > {option.label}
- ); + ) })} {/* Avoid having the "Search" Icon */} { - setInputValue(value); - inputProps?.onValueChange?.(value); + setInputValue(value) + inputProps?.onValueChange?.(value) }} onBlur={(event) => { if (!onScrollbar) { - setOpen(false); + setOpen(false) } - inputProps?.onBlur?.(event); + inputProps?.onBlur?.(event) }} onFocus={(event) => { - setOpen(true); + setOpen(true) if (triggerSearchOnFocus) { - onSearch?.(debouncedSearchTerm); + onSearch?.(debouncedSearchTerm) } - inputProps?.onFocus?.(event); + inputProps?.onFocus?.(event) }} placeholder={ hidePlaceholderWhenSelected && selected.length !== 0 @@ -563,14 +510,14 @@ const MultipleSelector = ({ "px-3 py-2": selected.length === 0, "ml-1": selected.length !== 0, }, - inputProps?.className, + inputProps?.className )} />
- ); -}; + ) +} -MultipleSelector.displayName = "MultipleSelector"; -export default MultipleSelector; +MultipleSelector.displayName = "MultipleSelector" +export default MultipleSelector diff --git a/bun.lock b/bun.lock index c4fa72d0..7655ed30 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "albert-plus",