From 457d922b9cfb7beb55ff84d51c513244bd74f6d6 Mon Sep 17 00:00:00 2001 From: daproclaima Date: Tue, 12 Mar 2024 19:56:52 +0100 Subject: [PATCH 01/27] =?UTF-8?q?=E2=9C=A8(react)=20introduce=20mono=20asy?= =?UTF-8?q?nc=20searchable=20select?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add *.tool-versions to .gitignore Add a test asserting that a mono uncontrolled searchable select can work with an async callback given as an options prop. Add types for the callback provided as an options prop for the Form/Select component Add Searchable Uncontrolled With Async Options Fetching and Searchable Uncontrolled With Async Options Fetching And Default Value stories in storybook. Change SelectMono so that only an array of options is passed to SelectMonoSimple since options prop of SelectMono may also be a callback to pass to SelectMonoSearchable. This new feature allow to pass an async callback as options prop for a Searchable Mono Select component. Give it a function to fetch dynamically data from a third service and format them into an array of options. A context param is automatically passed to the callback so that the function is able to filter tha data according to the search string. If the props.defaultValue is provided, then the Select will pick a default option matching the default value. --- .gitignore | 1 + .../src/components/Forms/Select/index.tsx | 10 +- .../Forms/Select/mono-searchable.tsx | 97 +++++++++++++--- .../components/Forms/Select/mono-simple.tsx | 25 +++- .../src/components/Forms/Select/mono.spec.tsx | 107 +++++++++++++++++- .../components/Forms/Select/mono.stories.tsx | 28 ++++- .../src/components/Forms/Select/mono.tsx | 19 +++- .../Forms/Select/multi-searchable.tsx | 8 +- .../components/Forms/Select/multi-simple.tsx | 8 +- .../src/components/Forms/Select/multi.tsx | 8 +- .../components/Forms/Select/stories-utils.tsx | 29 ++++- 11 files changed, 307 insertions(+), 33 deletions(-) diff --git a/.gitignore b/.gitignore index 67e585e7d..4c01b2b48 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ dist *.njsproj *.sln *.sw? +*.tool-versions vite.config.ts.timestamp-* env.d diff --git a/packages/react/src/components/Forms/Select/index.tsx b/packages/react/src/components/Forms/Select/index.tsx index 931e9e2a4..64d86e543 100644 --- a/packages/react/src/components/Forms/Select/index.tsx +++ b/packages/react/src/components/Forms/Select/index.tsx @@ -23,6 +23,14 @@ export type OptionWithoutRender = Omit & { export type Option = OptionWithoutRender | OptionWithRender; +export type ContextCallbackFetchOptions = { + search?: string; +}; + +export type CallbackFetchOptions = ( + context: ContextCallbackFetchOptions, +) => Promise; + export interface SelectHandle { blur: () => void; } @@ -31,7 +39,7 @@ export type SelectProps = PropsWithChildren & FieldProps & { label: string; hideLabel?: boolean; - options: Option[]; + options: Option[] | CallbackFetchOptions; searchable?: boolean; name?: string; defaultValue?: string | number | string[]; diff --git a/packages/react/src/components/Forms/Select/mono-searchable.tsx b/packages/react/src/components/Forms/Select/mono-searchable.tsx index c92a3eed9..0adef3eba 100644 --- a/packages/react/src/components/Forms/Select/mono-searchable.tsx +++ b/packages/react/src/components/Forms/Select/mono-searchable.tsx @@ -15,13 +15,21 @@ import { SelectMonoAux, SubProps, } from ":/components/Forms/Select/mono-common"; -import { SelectHandle } from ":/components/Forms/Select"; +import { + ContextCallbackFetchOptions, + Option, + CallbackFetchOptions, + SelectHandle, +} from ":/components/Forms/Select"; import { isOptionWithRender } from ":/components/Forms/Select/utils"; export const SelectMonoSearchable = forwardRef( ({ showLabelWhenSelected = true, ...props }, ref) => { const { t } = useCunningham(); - const [optionsToDisplay, setOptionsToDisplay] = useState(props.options); + + const [optionsToDisplay, setOptionsToDisplay] = useState( + Array.isArray(props.options) ? props.options : [], + ); const [hasInputFocused, setHasInputFocused] = useState(false); const [inputFilter, setInputFilter] = useState(); const inputRef = useRef(null); @@ -39,6 +47,33 @@ export const SelectMonoSearchable = forwardRef( const [labelAsPlaceholder, setLabelAsPlaceholder] = useState( !downshiftReturn.selectedItem, ); + + const computeOptionsToDisplay = async ( + context: ContextCallbackFetchOptions, + ): Promise => { + const arrayOptions = await fetchOptions(context); + + setOptionsToDisplay(arrayOptions); + }; + + const fetchOptions = async ( + context: ContextCallbackFetchOptions, + ): Promise => (props.options as CallbackFetchOptions)(context); + + const computeDefaultOptionToSelect = async (defaultValue: string) => { + const arrayOptions = await fetchOptions({ + search: defaultValue, + }); + + const defaultOption = arrayOptions.find( + (option) => String(option.value) === props.defaultValue, + ); + + if (defaultOption) { + downshiftReturn.selectItem(defaultOption); + } + }; + useEffect(() => { if (hasInputFocused || downshiftReturn.inputValue) { setLabelAsPlaceholder(false); @@ -51,6 +86,27 @@ export const SelectMonoSearchable = forwardRef( downshiftReturn.inputValue, ]); + useEffect(() => { + if ( + typeof props.defaultValue === "string" && + typeof props.options === "function" + ) { + (async () => { + await computeDefaultOptionToSelect(String(props.defaultValue)); + })(); + } + }, []); + + useEffect(() => { + props.onSearchInputChange?.({ target: { value: inputFilter } }); + + if (typeof props.options === "function") { + (async () => { + await computeOptionsToDisplay({ search: inputFilter }); + })(); + } + }, [inputFilter]); + // Similar to: useKeepSelectedItemInSyncWithOptions ( see docs ) // The only difference is that it does not apply when there is an inputFilter. ( See below why ) useEffect(() => { @@ -59,20 +115,35 @@ export const SelectMonoSearchable = forwardRef( if (inputFilter) { return; } - const optionToSelect = props.options.find( - (option) => optionToValue(option) === props.value, - ); - downshiftReturn.selectItem(optionToSelect ?? null); + + if (Array.isArray(props.options)) { + const selectedItem = downshiftReturn.selectedItem + ? optionToValue(downshiftReturn.selectedItem) + : undefined; + + const optionToSelect = props.options.find( + (option) => optionToValue(option) === props.value, + ); + + // Already selected + if (optionToSelect && selectedItem === props.value) { + return; + } + + downshiftReturn.selectItem(optionToSelect ?? null); + } }, [props.value, props.options, inputFilter]); // Even there is already a value selected, when opening the combobox menu we want to display all available choices. useEffect(() => { if (downshiftReturn.isOpen) { - setOptionsToDisplay( - inputFilter - ? props.options.filter(getOptionsFilter(inputFilter)) - : props.options, - ); + if (Array.isArray(props.options)) { + setOptionsToDisplay( + inputFilter + ? props.options.filter(getOptionsFilter(inputFilter)) + : props.options, + ); + } } else { setInputFilter(undefined); } @@ -85,10 +156,6 @@ export const SelectMonoSearchable = forwardRef( }, })); - useEffect(() => { - props.onSearchInputChange?.({ target: { value: inputFilter } }); - }, [inputFilter]); - const onInputBlur = () => { setHasInputFocused(false); if (downshiftReturn.selectedItem) { diff --git a/packages/react/src/components/Forms/Select/mono-simple.tsx b/packages/react/src/components/Forms/Select/mono-simple.tsx index 8e37d50ec..284a88c50 100644 --- a/packages/react/src/components/Forms/Select/mono-simple.tsx +++ b/packages/react/src/components/Forms/Select/mono-simple.tsx @@ -34,14 +34,36 @@ const useKeepSelectedItemInSyncWithOptions = ( export const SelectMonoSimple = forwardRef( (props, ref) => { + const arrayOptions: Option[] = Array.isArray(props.options) + ? props.options + : []; + const downshiftReturn = useSelect({ ...props.downshiftProps, - items: props.options, + items: arrayOptions, itemToString: optionToString, }); useKeepSelectedItemInSyncWithOptions(downshiftReturn, props); + // When component is controlled, this useEffect will update the local selected item. + useEffect(() => { + const selectedItem = downshiftReturn.selectedItem + ? optionToValue(downshiftReturn.selectedItem) + : undefined; + + const optionToSelect = arrayOptions.find( + (option) => optionToValue(option) === props.value, + ); + + // Already selected + if (optionToSelect && selectedItem === props.value) { + return; + } + + downshiftReturn.selectItem(optionToSelect ?? null); + }, [props.value, arrayOptions]); + const wrapperRef = useRef(null); useImperativeHandle(ref, () => ({ @@ -54,6 +76,7 @@ export const SelectMonoSimple = forwardRef( return ( ", () => { expectMenuToBeOpen(menu); expectOptions(["Paris", "Panama"]); + myOptions.shift(); // Rerender the select with the options mutated @@ -1087,6 +1089,109 @@ describe(" + , + ); + + expect(spiedAsyncOptions).toHaveBeenCalledTimes(1); + + const input = screen.getByRole("combobox", { + name: "City", + }); + + // It returns the input. + expect(input.tagName).toEqual("INPUT"); + + const menu: HTMLDivElement = screen.getByRole("listbox", { + name: "City", + }); + + expectMenuToBeClosed(menu); + + // Click on the input. + await user.click(input); + expectMenuToBeOpen(menu); + + expectOptions(["Paris", "Panama", "London", "New York", "Tokyo"]); + + // Verify that filtering works. + await user.type(input, "Pa"); + + expect(spiedAsyncOptions).toHaveBeenCalledTimes(3); + expectMenuToBeOpen(menu); + expectOptions(["Paris", "Panama"]); + + await user.type(input, "r", { skipClick: true }); + expect(spiedAsyncOptions).toHaveBeenCalledTimes(4); + expectOptions(["Paris"]); + + // Select option. + const option: HTMLLIElement = screen.getByRole("option", { + name: "Paris", + }); + await user.click(option); + + expect(input).toHaveValue("Paris"); + expect(spiedAsyncOptions).toHaveBeenCalledTimes(5); + }); }); describe("Simple", () => { diff --git a/packages/react/src/components/Forms/Select/mono.stories.tsx b/packages/react/src/components/Forms/Select/mono.stories.tsx index c6865f7f5..22d184a9f 100644 --- a/packages/react/src/components/Forms/Select/mono.stories.tsx +++ b/packages/react/src/components/Forms/Select/mono.stories.tsx @@ -4,11 +4,16 @@ import { FormProvider, useForm } from "react-hook-form"; import * as Yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; import { onSubmit } from ":/components/Forms/Examples/ReactHookForm/reactHookFormUtils"; -import { Select, SelectHandle } from ":/components/Forms/Select"; +import { + ContextCallbackFetchOptions, + Select, + SelectHandle, +} from ":/components/Forms/Select"; import { Button } from ":/components/Button"; import { getCountryOption, RhfSelect, + fetchOptions, } from ":/components/Forms/Select/stories-utils"; import { Modal, ModalSize, useModal } from ":/components/Modal"; import { Input } from ":/components/Forms/Input"; @@ -175,6 +180,27 @@ export const SearchableUncontrolled = { }, }; +export const SearchableUncontrolledWithAsyncOptionsFetching = { + render: Template, + args: { + label: "Select a city", + options: async (context: ContextCallbackFetchOptions) => + fetchOptions(context, OPTIONS), + searchable: true, + }, +}; + +export const SearchableUncontrolledWithAsyncOptionsFetchingAndDefaultValue = { + render: Template, + args: { + label: "Select a city", + options: async (context: ContextCallbackFetchOptions) => + fetchOptions(context, OPTIONS), + defaultValue: OPTIONS[4].value, + searchable: true, + }, +}; + export const SearchableDisabled = { render: Template, diff --git a/packages/react/src/components/Forms/Select/mono.tsx b/packages/react/src/components/Forms/Select/mono.tsx index f364b8198..833169046 100644 --- a/packages/react/src/components/Forms/Select/mono.tsx +++ b/packages/react/src/components/Forms/Select/mono.tsx @@ -7,11 +7,19 @@ import { Option, SelectHandle, SelectProps } from ":/components/Forms/Select"; export const SelectMono = forwardRef( (props, ref) => { - const defaultSelectedItem = props.defaultValue - ? props.options.find( - (option) => optionToValue(option) === props.defaultValue, - ) - : undefined; + const { options } = props; + + const isPropOptionsAnArray = Array.isArray(options); + + const arrayOptions: Option[] = isPropOptionsAnArray ? options : []; + + const defaultSelectedItem = + props.defaultValue && arrayOptions?.length + ? arrayOptions.find( + (option) => optionToValue(option) === props.defaultValue, + ) + : undefined; + const [value, setValue] = useState( defaultSelectedItem ? optionToValue(defaultSelectedItem) : props.value, ); @@ -58,6 +66,7 @@ export const SelectMono = forwardRef( ) : ( ( const inputRef = useRef(null); const options = React.useMemo( () => - props.options.filter( - getMultiOptionsFilter(props.selectedItems, inputValue), - ), + Array.isArray(props.options) + ? props.options.filter( + getMultiOptionsFilter(props.selectedItems, inputValue), + ) + : [], [props.selectedItems, inputValue], ); const [hasInputFocused, setHasInputFocused] = useState(false); diff --git a/packages/react/src/components/Forms/Select/multi-simple.tsx b/packages/react/src/components/Forms/Select/multi-simple.tsx index ff901c4f9..a1a7be81e 100644 --- a/packages/react/src/components/Forms/Select/multi-simple.tsx +++ b/packages/react/src/components/Forms/Select/multi-simple.tsx @@ -13,6 +13,10 @@ import { Option, SelectHandle } from ":/components/Forms/Select/index"; export const SelectMultiSimple = forwardRef( (props, ref) => { + const arrayOptions: Option[] = Array.isArray(props.options) + ? props.options + : []; + const isSelected = (option: Option) => !!props.selectedItems.find((selectedItem) => optionsEqual(selectedItem, option), @@ -20,12 +24,12 @@ export const SelectMultiSimple = forwardRef( const options = React.useMemo(() => { if (props.monoline) { - return props.options.map((option) => ({ + return arrayOptions.map((option) => ({ ...option, highlighted: isSelected(option), })); } - return props.options.filter( + return arrayOptions.filter( getMultiOptionsFilter(props.selectedItems, ""), ); }, [props.selectedItems]); diff --git a/packages/react/src/components/Forms/Select/multi.tsx b/packages/react/src/components/Forms/Select/multi.tsx index 678435eac..aab6fe1ad 100644 --- a/packages/react/src/components/Forms/Select/multi.tsx +++ b/packages/react/src/components/Forms/Select/multi.tsx @@ -17,9 +17,11 @@ export const SelectMulti = forwardRef( (props, ref) => { const getSelectedItemsFromProps = () => { const valueToUse = props.defaultValue ?? props.value ?? []; - return props.options.filter((option) => - (valueToUse as string[]).includes(optionToValue(option)), - ); + return Array.isArray(props.options) + ? props.options.filter((option) => + (valueToUse as string[]).includes(optionToValue(option)), + ) + : []; }; const [selectedItems, setSelectedItems] = React.useState( diff --git a/packages/react/src/components/Forms/Select/stories-utils.tsx b/packages/react/src/components/Forms/Select/stories-utils.tsx index f4c50b2be..357e6dcf3 100644 --- a/packages/react/src/components/Forms/Select/stories-utils.tsx +++ b/packages/react/src/components/Forms/Select/stories-utils.tsx @@ -1,6 +1,11 @@ import { Controller, useFormContext } from "react-hook-form"; import React from "react"; -import { Select, SelectProps } from ":/components/Forms/Select/index"; +import { + ContextCallbackFetchOptions, + Option, + Select, + SelectProps, +} from ":/components/Forms/Select/index"; export const RhfSelect = (props: SelectProps & { name: string }) => { const { control, setValue } = useFormContext(); @@ -38,3 +43,25 @@ export const getCountryOption = (name: string, code: string) => ({ ), }); + +export const fetchOptions = async ( + context: ContextCallbackFetchOptions, + options: Option[], +): Promise => + new Promise((resolve) => { + // simulate a delayed response + setTimeout(() => { + const stringSearch = context?.search ?? undefined; + + const filterOptions = (arrayOptions: Option[], search: string) => + arrayOptions.filter((option) => + option.label.toLocaleLowerCase().includes(search.toLowerCase()), + ); + + const arrayOptions: Option[] = stringSearch + ? filterOptions(options, stringSearch) + : options; + + resolve(arrayOptions); + }, 500); + }); From cd38f693c7c3c2660470bd0c66dd5e32845bee71 Mon Sep 17 00:00:00 2001 From: daproclaima Date: Thu, 25 Apr 2024 13:49:37 +0200 Subject: [PATCH 02/27] =?UTF-8?q?=E2=9A=A1=EF=B8=8F(react)=20prevent=20une?= =?UTF-8?q?cessary=20async=20options=20fetching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevents useless triggering of async options fetching due to default value and inputFilter updates handling in mono-searchable.ts. --- .../Forms/Select/mono-searchable.tsx | 132 ++++++++++++------ .../components/Forms/Select/mono-simple.tsx | 30 +--- .../src/components/Forms/Select/mono.spec.tsx | 74 +--------- 3 files changed, 104 insertions(+), 132 deletions(-) diff --git a/packages/react/src/components/Forms/Select/mono-searchable.tsx b/packages/react/src/components/Forms/Select/mono-searchable.tsx index 0adef3eba..faae38492 100644 --- a/packages/react/src/components/Forms/Select/mono-searchable.tsx +++ b/packages/react/src/components/Forms/Select/mono-searchable.tsx @@ -16,13 +16,16 @@ import { SubProps, } from ":/components/Forms/Select/mono-common"; import { + CallbackFetchOptions, ContextCallbackFetchOptions, Option, - CallbackFetchOptions, SelectHandle, } from ":/components/Forms/Select"; import { isOptionWithRender } from ":/components/Forms/Select/utils"; +// https://react.dev/learn/you-might-not-need-an-effect#sharing-logic-between-event-handlers +let previousSearch: string | undefined; +let previousInputFilter: string | undefined; export const SelectMonoSearchable = forwardRef( ({ showLabelWhenSelected = true, ...props }, ref) => { const { t } = useCunningham(); @@ -53,14 +56,14 @@ export const SelectMonoSearchable = forwardRef( ): Promise => { const arrayOptions = await fetchOptions(context); - setOptionsToDisplay(arrayOptions); + setOptionsToDisplay(Array.isArray(arrayOptions) ? arrayOptions : []); }; const fetchOptions = async ( context: ContextCallbackFetchOptions, ): Promise => (props.options as CallbackFetchOptions)(context); - const computeDefaultOptionToSelect = async (defaultValue: string) => { + const computeInitialOptionToSelect = async (defaultValue: string) => { const arrayOptions = await fetchOptions({ search: defaultValue, }); @@ -74,6 +77,45 @@ export const SelectMonoSearchable = forwardRef( } }; + const computeInitialOption = async ( + defaultValue: string, + ): Promise => { + const arrayOptions = await fetchOptions({ + search: "", + }); + + if (arrayOptions) { + setOptionsToDisplay(arrayOptions); + + const defaultOption = defaultValue + ? arrayOptions.find((option) => String(option.value) === defaultValue) + : undefined; + + if (defaultOption) { + downshiftReturn.selectItem(defaultOption); + } + } + }; + + const onInputBlur = () => { + setHasInputFocused(false); + if (downshiftReturn.selectedItem) { + // Here the goal is to make sure that when the input in blurred then the input value + // has exactly the selectedItem label. Which is not the case by default. + downshiftReturn.selectItem(downshiftReturn.selectedItem); + } else { + // We want the input to be empty when no item is selected. + downshiftReturn.setInputValue(""); + } + }; + + const inputProps = downshiftReturn.getInputProps({ + ref: inputRef, + disabled: props.disabled, + }); + + const renderCustomSelectedOption = !showLabelWhenSelected; + useEffect(() => { if (hasInputFocused || downshiftReturn.inputValue) { setLabelAsPlaceholder(false); @@ -86,27 +128,18 @@ export const SelectMonoSearchable = forwardRef( downshiftReturn.inputValue, ]); + // When component is controlled, this useEffect will update the local selected item. useEffect(() => { if ( typeof props.defaultValue === "string" && typeof props.options === "function" ) { (async () => { - await computeDefaultOptionToSelect(String(props.defaultValue)); + await computeInitialOptionToSelect(String(props.defaultValue)); })(); } }, []); - useEffect(() => { - props.onSearchInputChange?.({ target: { value: inputFilter } }); - - if (typeof props.options === "function") { - (async () => { - await computeOptionsToDisplay({ search: inputFilter }); - })(); - } - }, [inputFilter]); - // Similar to: useKeepSelectedItemInSyncWithOptions ( see docs ) // The only difference is that it does not apply when there is an inputFilter. ( See below why ) useEffect(() => { @@ -136,18 +169,58 @@ export const SelectMonoSearchable = forwardRef( // Even there is already a value selected, when opening the combobox menu we want to display all available choices. useEffect(() => { + if (previousInputFilter !== inputFilter) { + props.onSearchInputChange?.({ target: { value: inputFilter } }); + previousInputFilter = inputFilter; + } + if (downshiftReturn.isOpen) { if (Array.isArray(props.options)) { - setOptionsToDisplay( - inputFilter - ? props.options.filter(getOptionsFilter(inputFilter)) - : props.options, - ); + const arrayFilteredOptions = inputFilter + ? props.options.filter(getOptionsFilter(inputFilter)) + : props.options; + + setOptionsToDisplay(arrayFilteredOptions); } } else { setInputFilter(undefined); } - }, [downshiftReturn.isOpen, props.options, inputFilter]); + + if (typeof props.options === "function") { + let fetchOptionsCallback = async ({ search }: { search: string }) => + computeOptionsToDisplay({ search }); + + const isInitialSearch = + inputFilter === undefined && previousSearch === undefined; + + const isInputFilterTouched = inputFilter !== undefined; + + let currentSearch; + + if (isInitialSearch) { + currentSearch = props.defaultValue ? String(props.defaultValue) : ""; + + fetchOptionsCallback = async ({ search }: { search: string }) => + computeInitialOption(search); + } else { + currentSearch = inputFilter ? String(inputFilter) : ""; + } + + const isNewSearch = previousSearch !== currentSearch; + + if ((isNewSearch && isInputFilterTouched) || isInitialSearch) { + (async () => { + await fetchOptionsCallback({ search: currentSearch }); + })(); + previousSearch = currentSearch; + } + } + }, [ + downshiftReturn.isOpen, + props.options, + inputFilter, + props.defaultValue, + ]); useImperativeHandle(ref, () => ({ blur: () => { @@ -156,25 +229,6 @@ export const SelectMonoSearchable = forwardRef( }, })); - const onInputBlur = () => { - setHasInputFocused(false); - if (downshiftReturn.selectedItem) { - // Here the goal is to make sure that when the input in blurred then the input value - // has exactly the selectedItem label. Which is not the case by default. - downshiftReturn.selectItem(downshiftReturn.selectedItem); - } else { - // We want the input to be empty when no item is selected. - downshiftReturn.setInputValue(""); - } - }; - - const inputProps = downshiftReturn.getInputProps({ - ref: inputRef, - disabled: props.disabled, - }); - - const renderCustomSelectedOption = !showLabelWhenSelected; - return ( , - props: Pick, + props: Pick, + arrayOptions: Option[], ) => { useEffect(() => { - const optionToSelect = props.options.find( + const optionToSelect = arrayOptions.find( (option) => optionToValue(option) === props.value, ); downshiftReturn.selectItem(optionToSelect ?? null); - }, [props.value, props.options]); + }, [props.value, arrayOptions]); }; export const SelectMonoSimple = forwardRef( @@ -44,28 +46,10 @@ export const SelectMonoSimple = forwardRef( itemToString: optionToString, }); - useKeepSelectedItemInSyncWithOptions(downshiftReturn, props); - - // When component is controlled, this useEffect will update the local selected item. - useEffect(() => { - const selectedItem = downshiftReturn.selectedItem - ? optionToValue(downshiftReturn.selectedItem) - : undefined; - - const optionToSelect = arrayOptions.find( - (option) => optionToValue(option) === props.value, - ); - - // Already selected - if (optionToSelect && selectedItem === props.value) { - return; - } - - downshiftReturn.selectItem(optionToSelect ?? null); - }, [props.value, arrayOptions]); - const wrapperRef = useRef(null); + useKeepSelectedItemInSyncWithOptions(downshiftReturn, props, arrayOptions); + useImperativeHandle(ref, () => ({ blur: () => { downshiftReturn.closeMenu(); diff --git a/packages/react/src/components/Forms/Select/mono.spec.tsx b/packages/react/src/components/Forms/Select/mono.spec.tsx index 63597ec2a..0a154e46d 100644 --- a/packages/react/src/components/Forms/Select/mono.spec.tsx +++ b/packages/react/src/components/Forms/Select/mono.spec.tsx @@ -9,6 +9,7 @@ import { SelectHandle, SelectProps, CallbackFetchOptions, + ContextCallbackFetchOptions, } from ":/components/Forms/Select/index"; import { Button } from ":/components/Button"; import { CunninghamProvider } from ":/components/Provider"; @@ -1021,75 +1022,6 @@ describe(" { - setValue(e.target.value as string); - setOnChangeCounts(onChangeCounts + 1); - }} - searchable={true} - /> - - - ); - }; - - const { rerender } = render(, { - wrapper: CunninghamProvider, - }); - - const input = screen.getByRole("combobox", { - name: "City", - }); - expect(input).toHaveValue("Paris"); - screen.getByText("Value = paris|"); - screen.getByText("onChangeCounts = 0|"); - - rerender( - , - ); - - await waitFor(() => expect(input).toHaveValue("Paname")); - screen.getByText("Value = paris|"); - screen.getByText("onChangeCounts = 0|"); - }); - it("gets the search term using onSearchInputChange through an async function provided as the options prop", async () => { type Spy = { asyncOptions: CallbackFetchOptions; @@ -1123,7 +1055,9 @@ describe("", () => { await user.click(option); expect(input).toHaveValue("Paris"); - expect(spiedAsyncOptions).toHaveBeenCalledTimes(5); + expect(spiedAsyncOptions).toHaveBeenCalledTimes(4); }); }); From c876a161c514f518733a0cd21164c40187fb7823 Mon Sep 17 00:00:00 2001 From: daproclaima Date: Tue, 30 Apr 2024 11:10:43 +0200 Subject: [PATCH 04/27] =?UTF-8?q?=E2=9C=A8(react)=20add=20use=20of=20loade?= =?UTF-8?q?r=20in=20mono=20searchable=20select?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add style for the loader of Select component when it uses async options callback fetching. --- .../src/components/Forms/Select/_index.scss | 9 +++ .../src/components/Forms/Select/index.tsx | 1 + .../Forms/Select/mono-searchable.tsx | 10 +++ .../components/Forms/Select/mono.stories.tsx | 76 ++++++++++++++----- .../components/Forms/Select/stories-utils.tsx | 3 +- 5 files changed, 80 insertions(+), 19 deletions(-) diff --git a/packages/react/src/components/Forms/Select/_index.scss b/packages/react/src/components/Forms/Select/_index.scss index b579853eb..71f19ecbe 100644 --- a/packages/react/src/components/Forms/Select/_index.scss +++ b/packages/react/src/components/Forms/Select/_index.scss @@ -3,6 +3,15 @@ .c__select { position: relative; + &__loader { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + cursor: wait; + } + &__wrapper { border-radius: var(--c--components--forms-select--border-radius); border-width: var(--c--components--forms-select--border-width); diff --git a/packages/react/src/components/Forms/Select/index.tsx b/packages/react/src/components/Forms/Select/index.tsx index 64d86e543..b8c098c12 100644 --- a/packages/react/src/components/Forms/Select/index.tsx +++ b/packages/react/src/components/Forms/Select/index.tsx @@ -53,6 +53,7 @@ export type SelectProps = PropsWithChildren & disabled?: boolean; clearable?: boolean; multi?: boolean; + isLoading?: boolean; showLabelWhenSelected?: boolean; monoline?: boolean; selectedItemsStyle?: "pills" | "text"; diff --git a/packages/react/src/components/Forms/Select/mono-searchable.tsx b/packages/react/src/components/Forms/Select/mono-searchable.tsx index faae38492..2ea82c2ab 100644 --- a/packages/react/src/components/Forms/Select/mono-searchable.tsx +++ b/packages/react/src/components/Forms/Select/mono-searchable.tsx @@ -22,6 +22,7 @@ import { SelectHandle, } from ":/components/Forms/Select"; import { isOptionWithRender } from ":/components/Forms/Select/utils"; +import { Loader } from ":/components/Loader"; // https://react.dev/learn/you-might-not-need-an-effect#sharing-logic-between-event-handlers let previousSearch: string | undefined; @@ -252,6 +253,15 @@ export const SelectMonoSearchable = forwardRef( labelAsPlaceholder={labelAsPlaceholder} options={optionsToDisplay} > + {props?.isLoading ? ( +
+ +
+ ) : null} + - fetchOptions(context, OPTIONS), - searchable: true, - }, -}; +export const SearchableUncontrolledWithAsyncOptionsFetching = () => { + const [isLoading, setIsLoading] = useState(true); -export const SearchableUncontrolledWithAsyncOptionsFetchingAndDefaultValue = { - render: Template, - args: { - label: "Select a city", - options: async (context: ContextCallbackFetchOptions) => - fetchOptions(context, OPTIONS), - defaultValue: OPTIONS[4].value, - searchable: true, - }, + const fetchAsyncOptions = async (context: ContextCallbackFetchOptions) => { + let arrayOptions: Option[] = []; + + setIsLoading(true); + try { + arrayOptions = await fetchOptions(context, OPTIONS, 1000); + } catch (error) { + /* empty */ + } + + setIsLoading(false); + return arrayOptions; + }; + + return ( +
+ +
+ ); + }; + export const SearchableDisabled = { render: Template, diff --git a/packages/react/src/components/Forms/Select/stories-utils.tsx b/packages/react/src/components/Forms/Select/stories-utils.tsx index 357e6dcf3..226d6e69f 100644 --- a/packages/react/src/components/Forms/Select/stories-utils.tsx +++ b/packages/react/src/components/Forms/Select/stories-utils.tsx @@ -47,6 +47,7 @@ export const getCountryOption = (name: string, code: string) => ({ export const fetchOptions = async ( context: ContextCallbackFetchOptions, options: Option[], + msTimeOut?: number, ): Promise => new Promise((resolve) => { // simulate a delayed response @@ -63,5 +64,5 @@ export const fetchOptions = async ( : options; resolve(arrayOptions); - }, 500); + }, msTimeOut || 500); }); From 8c576ba8065df2489379fbfb66e3eb6694677e9e Mon Sep 17 00:00:00 2001 From: daproclaima Date: Tue, 30 Apr 2024 11:14:14 +0200 Subject: [PATCH 05/27] =?UTF-8?q?=E2=9C=85(react)=20add=20test=20for=20sea?= =?UTF-8?q?rchable=20mono=20select?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update mono.spec tests to verify the use of the new isLoading and default value, async options fetching props and the loader. --- .../src/components/Forms/Select/mono.spec.tsx | 256 +++++++++++++----- .../components/Forms/Select/test-utils.tsx | 17 +- 2 files changed, 202 insertions(+), 71 deletions(-) diff --git a/packages/react/src/components/Forms/Select/mono.spec.tsx b/packages/react/src/components/Forms/Select/mono.spec.tsx index 45b423eed..3338b6b04 100644 --- a/packages/react/src/components/Forms/Select/mono.spec.tsx +++ b/packages/react/src/components/Forms/Select/mono.spec.tsx @@ -1,6 +1,6 @@ import userEvent from "@testing-library/user-event"; import { act, render, screen, waitFor } from "@testing-library/react"; -import { expect, vi } from "vitest"; +import { expect, Mock, vi } from "vitest"; import React, { createRef, FormEvent, useState } from "react"; import { within } from "@testing-library/dom"; import { @@ -9,10 +9,13 @@ import { SelectHandle, SelectProps, CallbackFetchOptions, + ContextCallbackFetchOptions, } from ":/components/Forms/Select/index"; import { Button } from ":/components/Button"; import { CunninghamProvider } from ":/components/Provider"; import { + expectLoaderNotToBeInTheDocument, + expectLoaderToBeVisible, expectMenuToBeClosed, expectMenuToBeOpen, expectNoOptions, @@ -23,8 +26,69 @@ import { } from ":/components/Forms/Select/test-utils"; import { Input } from ":/components/Forms/Input"; +const arrayCityOptions = [ + { + label: "Paris", + value: "paris", + }, + { + label: "Panama", + value: "panama", + }, + { + label: "London", + value: "london", + }, + { + label: "New York", + value: "new_york", + }, + { + label: "Tokyo", + value: "tokyo", + }, +]; + +const SearchableOptionsFetchingSelect = ({ + optionsCallback, + defaultValue, + label, +}: { + optionsCallback: (context: ContextCallbackFetchOptions) => Promise; + defaultValue?: string; + label: string; +}) => { + const [isLoading, setIsLoading] = useState(true); + + const localCallback: CallbackFetchOptions = async (context) => { + let arrayResults = []; + setIsLoading(true); + arrayResults = await optionsCallback(context); + setIsLoading(false); + + return arrayResults; + }; + + return ( + + ", () => { describe("Searchable", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + it("shows all options when clicking on the input", async () => { const user = userEvent.setup(); render( @@ -1021,99 +1085,88 @@ describe(" - , + , ); - expect(spiedAsyncOptions).toHaveBeenCalledTimes(1); - const input = screen.getByRole("combobox", { name: "City", }); - // It returns the input. - expect(input.tagName).toEqual("INPUT"); - const menu: HTMLDivElement = screen.getByRole("listbox", { name: "City", }); + expect(input.tagName).toEqual("INPUT"); + + expect(callbackFetchOptionsMock).toHaveBeenNthCalledWith(1, { + search: "", + }); + expectMenuToBeClosed(menu); - // Click on the input. await user.click(input); - expectMenuToBeOpen(menu); + expectMenuToBeOpen(menu); expectOptions(["Paris", "Panama", "London", "New York", "Tokyo"]); - // Verify that filtering works. - await user.type(input, "Pa"); + await user.type(input, "P"); + expect(callbackFetchOptionsMock).toHaveBeenNthCalledWith(2, { + search: "P", + }); + + expectMenuToBeOpen(menu); + expectOptions(["Paris", "Panama"]); - expect(spiedAsyncOptions).toHaveBeenCalledTimes(3); + await user.type(input, "a", { skipClick: true }); + expect(callbackFetchOptionsMock).toHaveBeenNthCalledWith(3, { + search: "Pa", + }); expectMenuToBeOpen(menu); expectOptions(["Paris", "Panama"]); await user.type(input, "r", { skipClick: true }); - expect(spiedAsyncOptions).toHaveBeenCalledTimes(4); + expect(callbackFetchOptionsMock).toHaveBeenNthCalledWith(4, { + search: "Par", + }); expectOptions(["Paris"]); // Select option. @@ -1123,7 +1176,70 @@ describe("", () => { expect(searchTerm).toBeUndefined(); }); - it("executes the provided callback in options prop to fetch and passes search term param to it using onSearchInputChange prop", async () => { + it("shows and hides the loader according to the loading status", async () => { + callbackFetchOptionsMock.mockResolvedValue(arrayCityOptions); + + expect(vi.isMockFunction(callbackFetchOptionsMock)).toBeTruthy(); + + render( + , + ); + + await expectLoaderToBeVisible(); + expect(callbackFetchOptionsMock).toHaveBeenNthCalledWith(1, { + search: "london", + }); + await expectLoaderNotToBeInTheDocument(); + }); + + it("gets new options asynchronously on search update when component is uncontrolled", async () => { const user = userEvent.setup(); callbackFetchOptionsMock @@ -1184,7 +1204,7 @@ describe("", () => { expectMenuToBeOpen(menu); expectOptions(["Paris", "Panama", "London", "New York", "Tokyo"]); }); - - it("shows and hides the loader according to the isLoading prop", async () => { - callbackFetchOptionsMock.mockResolvedValue(arrayCityOptions); - - expect(vi.isMockFunction(callbackFetchOptionsMock)).toBeTruthy(); - - render( - , - ); - - await expectLoaderToBeVisible(); - expect(callbackFetchOptionsMock).toHaveBeenNthCalledWith(1, { - search: "london", - }); - await expectLoaderNotToBeInTheDocument(); - }); }); describe("Simple", () => { From 7d25e3085ab3a9fc9f845a0b35b95dd61243790f Mon Sep 17 00:00:00 2001 From: daproclaima Date: Thu, 20 Jun 2024 14:57:49 +0200 Subject: [PATCH 09/27] =?UTF-8?q?=E2=99=BB=EF=B8=8F(react)=20gather=20opti?= =?UTF-8?q?ons=20fetching=20code=20in=20mono=20select?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This prepares the select component to work both in controlled and uncontrolled context when the component is searchable and has to fetch the options. Also refactor the related tests and add a skipped test for controlled context. --- .../Forms/Select/mono-searchable.tsx | 57 ++++-- .../src/components/Forms/Select/mono.spec.tsx | 183 +++++++++++++++++- 2 files changed, 218 insertions(+), 22 deletions(-) diff --git a/packages/react/src/components/Forms/Select/mono-searchable.tsx b/packages/react/src/components/Forms/Select/mono-searchable.tsx index 77d1982b1..2265c7ee9 100644 --- a/packages/react/src/components/Forms/Select/mono-searchable.tsx +++ b/packages/react/src/components/Forms/Select/mono-searchable.tsx @@ -112,9 +112,16 @@ export const SelectMonoSearchable = forwardRef( downshiftReturn.inputValue, ]); + const isAsyncOptionsFetching = typeof props.options === "function"; + useEffect(() => { - if (typeof props.options === "function") { - const search = props.defaultValue ? String(props.defaultValue) : ""; + if (isAsyncOptionsFetching) { + let search = props.defaultValue ? String(props.defaultValue) : ""; + + // a controlled select can not use props.defaultValue and props.value at the same time + if (!props.defaultValue) { + search = props.value ? String(props.value) : ""; + } (async () => { await computeOptionsToDisplayAndDefaultOption(search); @@ -123,9 +130,37 @@ export const SelectMonoSearchable = forwardRef( } }, []); - // Similar to: useKeepSelectedItemInSyncWithOptions ( see docs ) - // The only difference is that it does not apply when there is an inputFilter. ( See below why ) useEffect(() => { + if (isAsyncOptionsFetching) { + const isControlled = + props.value !== undefined && + inputFilter === undefined && + previousInputFilter === undefined; + + const isInitialSearch = previousSearch === undefined; + let search = ""; + let isNewSearch = false; + + if (!isInitialSearch) { + search = inputFilter ? String(inputFilter) : ""; + + if (isControlled) { + search = props.value ? String(props.value) : ""; + } + + isNewSearch = previousSearch !== search; + } + + if (isNewSearch) { + (async () => { + await computeOptionsToDisplay(search); + })(); + previousSearch = search; + } + } + + // Similar to: useKeepSelectedItemInSyncWithOptions ( see docs ) + // The only difference is that it does not apply when there is an inputFilter. ( See below why ) // If there is an inputFilter, using selectItem will trigger onInputValueChange that will sets inputFilter to // empty, and then ignoring the existing filter and displaying all options. if (inputFilter) { @@ -152,9 +187,6 @@ export const SelectMonoSearchable = forwardRef( // Even there is already a value selected, when opening the combobox menu we want to display all available choices. useEffect(() => { - const isInitialSearch = previousSearch === undefined; - const search = inputFilter ? String(inputFilter) : ""; - if (previousInputFilter !== inputFilter) { props.onSearchInputChange?.({ target: { value: inputFilter } }); previousInputFilter = inputFilter; @@ -171,17 +203,6 @@ export const SelectMonoSearchable = forwardRef( } else { setInputFilter(undefined); } - - if (typeof props.options === "function" && !isInitialSearch) { - const isNewSearch = previousSearch !== search; - - if (isNewSearch) { - (async () => { - await computeOptionsToDisplay(search); - })(); - previousSearch = search; - } - } }, [downshiftReturn.isOpen, props.options, inputFilter]); useImperativeHandle(ref, () => ({ diff --git a/packages/react/src/components/Forms/Select/mono.spec.tsx b/packages/react/src/components/Forms/Select/mono.spec.tsx index e3b7ca2a0..5ea42ffb1 100644 --- a/packages/react/src/components/Forms/Select/mono.spec.tsx +++ b/packages/react/src/components/Forms/Select/mono.spec.tsx @@ -49,7 +49,7 @@ const arrayCityOptions = [ }, ]; -const SearchableOptionsFetchingSelect = ({ +const UncontrolledSearchableFetchedOptionsSelectWrapper = ({ optionsCallback, defaultValue, label, @@ -1091,7 +1091,7 @@ describe("", () => { const user = userEvent.setup(); render( - ", () => { expectMenuToBeOpen(menu); expectOptions(["Paris", "Panama", "London", "New York", "Tokyo"]); }); + + it.skip("gets new options asynchronously on search update when component is controlled", async () => { + const ControlledSearchableFetchedOptionsSelectWrapper = ({ + optionsCallback, + defaultValue, + label, + }: { + optionsCallback: ( + context: ContextCallbackFetchOptions, + ) => Promise; + defaultValue?: string; + label: string; + }) => { + const [isLoading, setIsLoading] = useState(true); + const [value, setValue] = useState( + defaultValue, + ); + + const localCallback: CallbackFetchOptions = async (context) => { + let arrayResults = []; + setIsLoading(true); + arrayResults = await optionsCallback(context); + setIsLoading(false); + + return arrayResults; + }; + + return ( + +
+
Value = {value}|
+ + ", () => { expectOptions(["Paris", "Panama", "London", "New York", "Tokyo"]); }); - it.skip("gets new options asynchronously on search update when component is controlled", async () => { + it("gets new options asynchronously on search update when component is controlled", async () => { const ControlledSearchableFetchedOptionsSelectWrapper = ({ optionsCallback, defaultValue, @@ -1293,34 +1293,34 @@ describe("", () => { expect(input.tagName).toEqual("INPUT"); // Make sure value is selected. - screen.getByText("Value = london|"); - - expect(callbackFetchOptionsMock).toHaveBeenNthCalledWith(1, { - search: "london", - }); + expect(screen.getByText("Value = london|")).toBeVisible(); expect(input).toHaveValue("London"); expectMenuToBeClosed(menu); @@ -1363,6 +1359,7 @@ describe("", () => { await userEvent.click(clearButton); - // Make sure value is cleared. expect(input).toHaveValue(""); - screen.getByText("Value = |"); + expect(screen.getByText("Value = |")).toBeVisible(); - expect(callbackFetchOptionsMock).toHaveBeenCalledTimes(2); - expect(callbackFetchOptionsMock).toHaveBeenNthCalledWith(1, { + expectMenuToBeClosed(menu); + + await user.click(input); + + expectMenuToBeOpen(menu); + + expect(callbackFetchOptionsMock).toHaveBeenNthCalledWith(2, { search: "", }); - expectOptions(["Paris", "Panama", "London", "New York", "Tokyo"]); - // await user.type(input, "P"); - // expect(callbackFetchOptionsMock).toHaveBeenNthCalledWith(2, { - // search: "P", - // }); - // - // expectMenuToBeOpen(menu); - // expectOptions(["Paris", "Panama"]); - // - // await user.type(input, "a", { skipClick: true }); - // expect(callbackFetchOptionsMock).toHaveBeenNthCalledWith(3, { - // search: "Pa", - // }); - // expectMenuToBeOpen(menu); - // expectOptions(["Paris", "Panama"]); - // - // await user.type(input, "r", { skipClick: true }); - // expect(callbackFetchOptionsMock).toHaveBeenNthCalledWith(4, { - // search: "Par", - // }); - // expectOptions(["Paris"]); - // - // // Select option. - // const option: HTMLLIElement = screen.getByRole("option", { - // name: "Paris", - // }); - // await user.click(option); - // - // expect(input).toHaveValue("Paris"); - // - // await user.clear(input); - // expect(callbackFetchOptionsMock).toHaveBeenNthCalledWith(5, { - // search: "", - // }); - // expectOptions(["Paris", "Panama", "London", "New York", "Tokyo"]); + await user.type(input, "P"); + expect(callbackFetchOptionsMock).toHaveBeenNthCalledWith(3, { + search: "P", + }); + + expectMenuToBeOpen(menu); + expectOptions(["Paris", "Panama"]); + + await user.type(input, "a", { skipClick: true }); + expect(callbackFetchOptionsMock).toHaveBeenNthCalledWith(4, { + search: "Pa", + }); + expectMenuToBeOpen(menu); + expectOptions(["Paris", "Panama"]); + + await user.type(input, "r", { skipClick: true }); + expect(callbackFetchOptionsMock).toHaveBeenNthCalledWith(5, { + search: "Par", + }); + expectOptions(["Paris"]); + + // Select option. + const option: HTMLLIElement = screen.getByRole("option", { + name: "Paris", + }); + await user.click(option); + + expect(screen.getByText("Value = paris|")).toBeVisible(); + expect(input).toHaveValue("Paris"); + + await user.clear(input); + + expect(callbackFetchOptionsMock).toHaveBeenNthCalledWith(6, { + search: "", + }); + expect(screen.getByText("Value = |")).toBeVisible(); + expect(input).toHaveValue(""); + expectOptions(["Paris", "Panama", "London", "New York", "Tokyo"]); }); }); diff --git a/packages/react/src/components/Forms/Select/test-utils.tsx b/packages/react/src/components/Forms/Select/test-utils.tsx index 48be49fdf..19f3feba7 100644 --- a/packages/react/src/components/Forms/Select/test-utils.tsx +++ b/packages/react/src/components/Forms/Select/test-utils.tsx @@ -60,7 +60,9 @@ export const expectLoaderToBeVisible = async () => { }; export const expectLoaderNotToBeInTheDocument = async () => { await waitFor(() => { - const loader = screen.queryByRole("status"); + const loader = screen.queryByRole("status", { + name: "Loading data", + }); expect(loader).not.toBeInTheDocument(); }); }; From 193b0fe353c8fdeaf44d09967ded419807959b6f Mon Sep 17 00:00:00 2001 From: daproclaima Date: Fri, 21 Jun 2024 02:35:47 +0200 Subject: [PATCH 11/27] =?UTF-8?q?=F0=9F=93=9D(react)=20add=20story=20for?= =?UTF-8?q?=20Select=20mono?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add story for searchable controlled with async options fetching mono select. --- .../components/Forms/Select/mono.stories.tsx | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/react/src/components/Forms/Select/mono.stories.tsx b/packages/react/src/components/Forms/Select/mono.stories.tsx index 2b19f4bf6..c8f26aa26 100644 --- a/packages/react/src/components/Forms/Select/mono.stories.tsx +++ b/packages/react/src/components/Forms/Select/mono.stories.tsx @@ -18,6 +18,7 @@ import { } from ":/components/Forms/Select/stories-utils"; import { Modal, ModalSize, useModal } from ":/components/Modal"; import { Input } from ":/components/Forms/Input"; +import { CunninghamProvider } from ":/components/Provider"; export default { title: "Components/Forms/Select/Mono", @@ -241,6 +242,42 @@ export const SearchableUncontrolledWithAsyncOptionsFetchingAndDefaultValue = ); }; +export const SearchableControlledWithAsyncOptionsFetching = () => { + const [isLoading, setIsLoading] = useState(true); + const [value, setValue] = useState("woodbury"); + + const fetchAsyncOptions = async (context: ContextCallbackFetchOptions) => { + let arrayOptions: Option[] = []; + + setIsLoading(true); + try { + arrayOptions = await fetchOptions(context, OPTIONS, 1000); + } catch (error) { + /* empty */ + } + + setIsLoading(false); + return arrayOptions; + }; + + return ( + +
+
Value = {value}|
+ + +
Date: Thu, 27 Jun 2024 09:22:25 +0200 Subject: [PATCH 13/27] =?UTF-8?q?=F0=9F=92=84(react)=20move=20loader=20for?= =?UTF-8?q?=20select=20mono=20searchable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds or removes loader and actions buttons according to loading status when select mono searchable is fetching options. --- .../src/components/Forms/Select/_index.scss | 2 +- .../components/Forms/Select/mono-common.tsx | 78 ++++++++++--------- 2 files changed, 41 insertions(+), 39 deletions(-) diff --git a/packages/react/src/components/Forms/Select/_index.scss b/packages/react/src/components/Forms/Select/_index.scss index 71f19ecbe..667253dda 100644 --- a/packages/react/src/components/Forms/Select/_index.scss +++ b/packages/react/src/components/Forms/Select/_index.scss @@ -8,7 +8,7 @@ inset: 0; display: flex; align-items: center; - justify-content: center; + justify-content: right; cursor: wait; } diff --git a/packages/react/src/components/Forms/Select/mono-common.tsx b/packages/react/src/components/Forms/Select/mono-common.tsx index f50e95fd3..4dc031dc1 100644 --- a/packages/react/src/components/Forms/Select/mono-common.tsx +++ b/packages/react/src/components/Forms/Select/mono-common.tsx @@ -134,45 +134,47 @@ export const SelectMonoAux = ({ >
{children}
-
- {clearable && !disabled && downshiftReturn.selectedItem && ( - <> -
+
+ )}
From 19a0ca0837228ff0305b3253b819ef1667155df0 Mon Sep 17 00:00:00 2001 From: daproclaima Date: Thu, 27 Jun 2024 09:26:03 +0200 Subject: [PATCH 14/27] =?UTF-8?q?=F0=9F=90=9B(react)=20fix=20options=20fet?= =?UTF-8?q?ching=20in=20select=20mono=20searchable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Options fetching callback is now executed only on search input change event and when component is mounted if it uses a default value. Previously, even selecting an option used to trigger the options fetching. The select also now has the disabled status when initial fetch options callback is triggered. --- .../Forms/Select/mono-searchable.tsx | 121 +++++++++--------- 1 file changed, 58 insertions(+), 63 deletions(-) diff --git a/packages/react/src/components/Forms/Select/mono-searchable.tsx b/packages/react/src/components/Forms/Select/mono-searchable.tsx index 2265c7ee9..a078a2470 100644 --- a/packages/react/src/components/Forms/Select/mono-searchable.tsx +++ b/packages/react/src/components/Forms/Select/mono-searchable.tsx @@ -23,9 +23,6 @@ import { import { isOptionWithRender } from ":/components/Forms/Select/utils"; import { Loader } from ":/components/Loader"; -// https://react.dev/learn/you-might-not-need-an-effect#sharing-logic-between-event-handlers -let previousSearch: string | undefined; -let previousInputFilter: string | undefined; export const SelectMonoSearchable = forwardRef( ({ showLabelWhenSelected = true, ...props }, ref) => { const { t } = useCunningham(); @@ -34,6 +31,8 @@ export const SelectMonoSearchable = forwardRef( Array.isArray(props.options) ? props.options : [], ); const [hasInputFocused, setHasInputFocused] = useState(false); + const [isFetchingInitialOptions, setIsFetchingInitialOptions] = + useState(false); const [inputFilter, setInputFilter] = useState(); const inputRef = useRef(null); const downshiftReturn = useCombobox({ @@ -64,8 +63,12 @@ export const SelectMonoSearchable = forwardRef( }; const computeOptionsToDisplayAndDefaultOption = async ( - defaultValue: string, + defaultValue: Option["value"], ) => { + if (defaultValue === undefined) { + return; + } + const options = await computeOptionsToDisplay(defaultValue); if (Array.isArray(options)) { @@ -93,74 +96,67 @@ export const SelectMonoSearchable = forwardRef( } }; + const onInputChange = (event: React.ChangeEvent) => { + if (typeof inputProps.onChange === "function") { + inputProps.onChange(event); + } + + if (isAsyncOptionsFetching) { + computeOptionsToDisplay(event.target.value); + } + }; + const inputProps = downshiftReturn.getInputProps({ ref: inputRef, disabled: props.disabled, }); const renderCustomSelectedOption = !showLabelWhenSelected; + const isAsyncOptionsFetching = typeof props.options === "function"; useEffect(() => { - if (hasInputFocused || downshiftReturn.inputValue) { - setLabelAsPlaceholder(false); + if (!isAsyncOptionsFetching) { return; } - setLabelAsPlaceholder(!downshiftReturn.selectedItem); - }, [ - downshiftReturn.selectedItem, - hasInputFocused, - downshiftReturn.inputValue, - ]); - const isAsyncOptionsFetching = typeof props.options === "function"; + setIsFetchingInitialOptions(true); + let search = props.defaultValue ? String(props.defaultValue) : ""; - useEffect(() => { - if (isAsyncOptionsFetching) { - let search = props.defaultValue ? String(props.defaultValue) : ""; + // a controlled select can not use props.defaultValue and props.value at the same time + if (!props.defaultValue) { + search = props.value ? String(props.value) : ""; + } - // a controlled select can not use props.defaultValue and props.value at the same time - if (!props.defaultValue) { - search = props.value ? String(props.value) : ""; - } + computeOptionsToDisplayAndDefaultOption(search); - (async () => { - await computeOptionsToDisplayAndDefaultOption(search); - })(); - previousSearch = search; - } + setIsFetchingInitialOptions(false); }, []); useEffect(() => { if (isAsyncOptionsFetching) { - const isControlled = - props.value !== undefined && - inputFilter === undefined && - previousInputFilter === undefined; - - const isInitialSearch = previousSearch === undefined; - let search = ""; - let isNewSearch = false; + const toggleMenu = props.isLoading + ? () => downshiftReturn.closeMenu() + : () => downshiftReturn.openMenu(); - if (!isInitialSearch) { - search = inputFilter ? String(inputFilter) : ""; - - if (isControlled) { - search = props.value ? String(props.value) : ""; - } - - isNewSearch = previousSearch !== search; - } + toggleMenu(); + } + }, [props.isLoading]); - if (isNewSearch) { - (async () => { - await computeOptionsToDisplay(search); - })(); - previousSearch = search; - } + useEffect(() => { + if (hasInputFocused || downshiftReturn.inputValue) { + setLabelAsPlaceholder(false); + return; } + setLabelAsPlaceholder(!downshiftReturn.selectedItem); + }, [ + downshiftReturn.selectedItem, + hasInputFocused, + downshiftReturn.inputValue, + ]); - // Similar to: useKeepSelectedItemInSyncWithOptions ( see docs ) - // The only difference is that it does not apply when there is an inputFilter. ( See below why ) + // Similar to: useKeepSelectedItemInSyncWithOptions ( see docs ) + // The only difference is that it does not apply when there is an inputFilter. ( See below why ) + useEffect(() => { // If there is an inputFilter, using selectItem will trigger onInputValueChange that will sets inputFilter to // empty, and then ignoring the existing filter and displaying all options. if (inputFilter) { @@ -187,10 +183,7 @@ export const SelectMonoSearchable = forwardRef( // Even there is already a value selected, when opening the combobox menu we want to display all available choices. useEffect(() => { - if (previousInputFilter !== inputFilter) { - props.onSearchInputChange?.({ target: { value: inputFilter } }); - previousInputFilter = inputFilter; - } + props.onSearchInputChange?.({ target: { value: inputFilter } }); if (downshiftReturn.isOpen) { if (Array.isArray(props.options)) { @@ -215,6 +208,7 @@ export const SelectMonoSearchable = forwardRef( return ( ( labelAsPlaceholder={labelAsPlaceholder} options={optionsToDisplay} > - {props?.isLoading ? ( -
- -
- ) : null} - ( onBlur={() => { onInputBlur(); }} + onChange={onInputChange} /> {renderCustomSelectedOption && @@ -263,6 +249,15 @@ export const SelectMonoSearchable = forwardRef( downshiftReturn.selectedItem && isOptionWithRender(downshiftReturn.selectedItem) && downshiftReturn.selectedItem.render()} + + {props?.isLoading === true ? ( +
+ +
+ ) : null}
); }, From ab2524444aaa7114d8543e61ba7faa354462ca85 Mon Sep 17 00:00:00 2001 From: daproclaima Date: Thu, 27 Jun 2024 13:37:45 +0200 Subject: [PATCH 15/27] =?UTF-8?q?=F0=9F=90=9B(react)=20fix=20options=20res?= =?UTF-8?q?et=20for=20select=20mono=20searchable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allows to clear selected options when select mono searchable uses options fetching and is controlled. --- .../react/src/components/Forms/Select/mono-searchable.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/react/src/components/Forms/Select/mono-searchable.tsx b/packages/react/src/components/Forms/Select/mono-searchable.tsx index a078a2470..0de11d0a0 100644 --- a/packages/react/src/components/Forms/Select/mono-searchable.tsx +++ b/packages/react/src/components/Forms/Select/mono-searchable.tsx @@ -163,12 +163,16 @@ export const SelectMonoSearchable = forwardRef( return; } - if (Array.isArray(props.options)) { + const arrayOptions = Array.isArray(props.options) + ? props.options + : optionsToDisplay || undefined; + + if (arrayOptions) { const selectedItem = downshiftReturn.selectedItem ? optionToValue(downshiftReturn.selectedItem) : undefined; - const optionToSelect = props.options.find( + const optionToSelect = arrayOptions.find( (option) => optionToValue(option) === props.value, ); From 1965f80a1756a6f21bd119458925d5208b511e18 Mon Sep 17 00:00:00 2001 From: daproclaima Date: Thu, 27 Jun 2024 15:13:15 +0200 Subject: [PATCH 16/27] =?UTF-8?q?=F0=9F=90=9B(react)=20fix=20select=20mono?= =?UTF-8?q?=20searchable=20menu=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevents options menu to open again at click on arrow down button when select mono searchable uses options fetching --- packages/react/src/components/Forms/Select/mono-searchable.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react/src/components/Forms/Select/mono-searchable.tsx b/packages/react/src/components/Forms/Select/mono-searchable.tsx index 0de11d0a0..6379fb22c 100644 --- a/packages/react/src/components/Forms/Select/mono-searchable.tsx +++ b/packages/react/src/components/Forms/Select/mono-searchable.tsx @@ -222,6 +222,8 @@ export const SelectMonoSearchable = forwardRef( // when the menu is open, it will close and reopen immediately. if (!downshiftReturn.isOpen) { downshiftReturn.openMenu(); + } else if (isAsyncOptionsFetching) { + downshiftReturn.closeMenu(); } }, }, From 6f212af88c9ecf6f5f3943c9d7bebb6e0a312151 Mon Sep 17 00:00:00 2001 From: daproclaima Date: Thu, 27 Jun 2024 15:30:25 +0200 Subject: [PATCH 17/27] =?UTF-8?q?=E2=8F=AA=EF=B8=8F(react)=20revert=20sele?= =?UTF-8?q?ct=20mono=20searchable=20menu=20toggle=20change?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 87d9e7eeab7ec250f7b09dcb44df61394aca3fc1. --- packages/react/src/components/Forms/Select/mono-searchable.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/react/src/components/Forms/Select/mono-searchable.tsx b/packages/react/src/components/Forms/Select/mono-searchable.tsx index 6379fb22c..0de11d0a0 100644 --- a/packages/react/src/components/Forms/Select/mono-searchable.tsx +++ b/packages/react/src/components/Forms/Select/mono-searchable.tsx @@ -222,8 +222,6 @@ export const SelectMonoSearchable = forwardRef( // when the menu is open, it will close and reopen immediately. if (!downshiftReturn.isOpen) { downshiftReturn.openMenu(); - } else if (isAsyncOptionsFetching) { - downshiftReturn.closeMenu(); } }, }, From f883441c7d3ac2d1e3e906d383f023de17137225 Mon Sep 17 00:00:00 2001 From: daproclaima Date: Thu, 27 Jun 2024 15:35:07 +0200 Subject: [PATCH 18/27] =?UTF-8?q?=E2=8F=AA=EF=B8=8F(react)=20cancel=20opti?= =?UTF-8?q?ons=20reset=20for=20select=20mono=20searchable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit a1515a2bbb40f6c4287bc639ac68ba5737419ca4. --- .../react/src/components/Forms/Select/mono-searchable.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/react/src/components/Forms/Select/mono-searchable.tsx b/packages/react/src/components/Forms/Select/mono-searchable.tsx index 0de11d0a0..a078a2470 100644 --- a/packages/react/src/components/Forms/Select/mono-searchable.tsx +++ b/packages/react/src/components/Forms/Select/mono-searchable.tsx @@ -163,16 +163,12 @@ export const SelectMonoSearchable = forwardRef( return; } - const arrayOptions = Array.isArray(props.options) - ? props.options - : optionsToDisplay || undefined; - - if (arrayOptions) { + if (Array.isArray(props.options)) { const selectedItem = downshiftReturn.selectedItem ? optionToValue(downshiftReturn.selectedItem) : undefined; - const optionToSelect = arrayOptions.find( + const optionToSelect = props.options.find( (option) => optionToValue(option) === props.value, ); From 8051ba57299ef4a8861d03e096a5002382431b25 Mon Sep 17 00:00:00 2001 From: daproclaima Date: Thu, 27 Jun 2024 16:47:04 +0200 Subject: [PATCH 19/27] =?UTF-8?q?=F0=9F=90=9B(react)=20fix=20options=20res?= =?UTF-8?q?et=20for=20select=20mono=20searchable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allows to clear selected options when select mono searchable uses options fetching and is controlled. --- .../Forms/Select/mono-searchable.tsx | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/react/src/components/Forms/Select/mono-searchable.tsx b/packages/react/src/components/Forms/Select/mono-searchable.tsx index a078a2470..c8fae4253 100644 --- a/packages/react/src/components/Forms/Select/mono-searchable.tsx +++ b/packages/react/src/components/Forms/Select/mono-searchable.tsx @@ -163,6 +163,8 @@ export const SelectMonoSearchable = forwardRef( return; } + // this block will not be triggered when component uses options fetching (otherwise it will conflict with the value + // passed to onInputChange) if (Array.isArray(props.options)) { const selectedItem = downshiftReturn.selectedItem ? optionToValue(downshiftReturn.selectedItem) @@ -181,6 +183,27 @@ export const SelectMonoSearchable = forwardRef( } }, [props.value, props.options, inputFilter]); + useEffect(() => { + if (!isAsyncOptionsFetching) { + return; + } + + const selectedItem = downshiftReturn.selectedItem + ? optionToValue(downshiftReturn.selectedItem) + : undefined; + + const optionToSelect = optionsToDisplay.find( + (option) => optionToValue(option) === props.value, + ); + + // Already selected + if (optionToSelect && selectedItem === props.value) { + return; + } + + downshiftReturn.selectItem(optionToSelect ?? null); + }, [props.value]); + // Even there is already a value selected, when opening the combobox menu we want to display all available choices. useEffect(() => { props.onSearchInputChange?.({ target: { value: inputFilter } }); From 0fd628f37f7341cc62b64ba757f358ba9af674fb Mon Sep 17 00:00:00 2001 From: daproclaima Date: Fri, 28 Jun 2024 00:13:23 +0200 Subject: [PATCH 20/27] =?UTF-8?q?=F0=9F=93=9D(react)=20update=20stories=20?= =?UTF-8?q?for=20mono=20select?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stories with searchable and options fetching now fetch all options without filtering them with search term at initial options fetching --- .../react/src/components/Forms/Select/mono.stories.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/react/src/components/Forms/Select/mono.stories.tsx b/packages/react/src/components/Forms/Select/mono.stories.tsx index 54bf6a54a..23107cc82 100644 --- a/packages/react/src/components/Forms/Select/mono.stories.tsx +++ b/packages/react/src/components/Forms/Select/mono.stories.tsx @@ -184,13 +184,16 @@ export const SearchableUncontrolled = { export const SearchableUncontrolledWithAsyncOptionsFetching = () => { const [isLoading, setIsLoading] = useState(true); + const [isInitialOptionFetching, setIsInitialOptionFetching] = useState(true); const fetchAsyncOptions = async (context: ContextCallbackFetchOptions) => { let arrayOptions: Option[] = []; setIsLoading(true); try { + context.search = isInitialOptionFetching ? "" : context.search; arrayOptions = await fetchOptions(context, OPTIONS, 1000); + setIsInitialOptionFetching(false); } catch (error) { /* empty */ } @@ -214,13 +217,17 @@ export const SearchableUncontrolledWithAsyncOptionsFetching = () => { export const SearchableUncontrolledWithAsyncOptionsFetchingAndDefaultValue = () => { const [isLoading, setIsLoading] = useState(true); + const [isInitialOptionFetching, setIsInitialOptionFetching] = + useState(true); const fetchAsyncOptions = async (context: ContextCallbackFetchOptions) => { let arrayOptions: Option[] = []; setIsLoading(true); try { + context.search = isInitialOptionFetching ? "" : context.search; arrayOptions = await fetchOptions(context, OPTIONS, 1000); + setIsInitialOptionFetching(false); } catch (error) { /* empty */ } @@ -244,6 +251,7 @@ export const SearchableUncontrolledWithAsyncOptionsFetchingAndDefaultValue = export const SearchableControlledWithAsyncOptionsFetching = () => { const [isLoading, setIsLoading] = useState(true); + const [isInitialOptionFetching, setIsInitialOptionFetching] = useState(true); const [value, setValue] = useState("woodbury"); const fetchAsyncOptions = async (context: ContextCallbackFetchOptions) => { @@ -251,7 +259,9 @@ export const SearchableControlledWithAsyncOptionsFetching = () => { setIsLoading(true); try { + context.search = isInitialOptionFetching ? "" : context.search; arrayOptions = await fetchOptions(context, OPTIONS, 1000); + setIsInitialOptionFetching(false); } catch (error) { /* empty */ } From f7443903af32c0ce2acbb37276f41004d13c5ae7 Mon Sep 17 00:00:00 2001 From: daproclaima Date: Mon, 22 Jul 2024 20:29:44 +0200 Subject: [PATCH 21/27] =?UTF-8?q?=F0=9F=90=9B(react)=20select=20mono=20sea?= =?UTF-8?q?rchable=20work=20with=20loading=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix displaying and hidding of inner actions elements accordingly to loading state and apply display none on action arrow button - updates related component tests The arrow button needs to remain in the dom to not reset its react ref. That is why we apply a display none css rule to its visual element instead of taking the component out of the DOM. --- .../src/components/Forms/Select/_index.scss | 4 ++ .../Forms/Select/mono-searchable.tsx | 31 ------------ .../src/components/Forms/Select/mono.spec.tsx | 50 +++++++++++++++---- 3 files changed, 44 insertions(+), 41 deletions(-) diff --git a/packages/react/src/components/Forms/Select/_index.scss b/packages/react/src/components/Forms/Select/_index.scss index 667253dda..f32395ee2 100644 --- a/packages/react/src/components/Forms/Select/_index.scss +++ b/packages/react/src/components/Forms/Select/_index.scss @@ -108,6 +108,10 @@ } &__open { color: var(--c--theme--colors--greyscale-900); + + &--hidden { + display: none; + } } } } diff --git a/packages/react/src/components/Forms/Select/mono-searchable.tsx b/packages/react/src/components/Forms/Select/mono-searchable.tsx index c8fae4253..583b84d11 100644 --- a/packages/react/src/components/Forms/Select/mono-searchable.tsx +++ b/packages/react/src/components/Forms/Select/mono-searchable.tsx @@ -132,16 +132,6 @@ export const SelectMonoSearchable = forwardRef( setIsFetchingInitialOptions(false); }, []); - useEffect(() => { - if (isAsyncOptionsFetching) { - const toggleMenu = props.isLoading - ? () => downshiftReturn.closeMenu() - : () => downshiftReturn.openMenu(); - - toggleMenu(); - } - }, [props.isLoading]); - useEffect(() => { if (hasInputFocused || downshiftReturn.inputValue) { setLabelAsPlaceholder(false); @@ -183,27 +173,6 @@ export const SelectMonoSearchable = forwardRef( } }, [props.value, props.options, inputFilter]); - useEffect(() => { - if (!isAsyncOptionsFetching) { - return; - } - - const selectedItem = downshiftReturn.selectedItem - ? optionToValue(downshiftReturn.selectedItem) - : undefined; - - const optionToSelect = optionsToDisplay.find( - (option) => optionToValue(option) === props.value, - ); - - // Already selected - if (optionToSelect && selectedItem === props.value) { - return; - } - - downshiftReturn.selectItem(optionToSelect ?? null); - }, [props.value]); - // Even there is already a value selected, when opening the combobox menu we want to display all available choices. useEffect(() => { props.onSearchInputChange?.({ target: { value: inputFilter } }); diff --git a/packages/react/src/components/Forms/Select/mono.spec.tsx b/packages/react/src/components/Forms/Select/mono.spec.tsx index 8d757bbe8..1ced0b086 100644 --- a/packages/react/src/components/Forms/Select/mono.spec.tsx +++ b/packages/react/src/components/Forms/Select/mono.spec.tsx @@ -1,7 +1,7 @@ import userEvent from "@testing-library/user-event"; import { act, render, screen, waitFor } from "@testing-library/react"; import { expect, Mock, vi } from "vitest"; -import React, { createRef, FormEvent, useState } from "react"; +import React, { createRef, FormEvent, useEffect, useState } from "react"; import { within } from "@testing-library/dom"; import { Select, @@ -1085,7 +1085,7 @@ describe("", () => { />, ); + const user = userEvent.setup(); + + expect( + document.querySelector(".c__select__inner__actions__separator"), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { + name: "Clear selection", + }), + ).not.toBeInTheDocument(); await expectLoaderToBeVisible(); - expect(callbackFetchOptionsMock).toHaveBeenNthCalledWith(1, { - search: "london", - }); + await expectLoaderNotToBeInTheDocument(); + + const menu: HTMLDivElement = screen.getByRole("listbox", { + name: "Select a city", + }); + + expectMenuToBeClosed(menu); + + await user.click( + screen.getByRole("combobox", { + name: "Select a city", + }), + ); + + expectMenuToBeOpen(menu); + + expectOptions(["Paris", "Panama", "London", "New York", "Tokyo"]); + + expect( + document.querySelector(".c__select__inner__actions__separator"), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { + name: "Clear selection", + }), + ).toBeInTheDocument(); }); - it("gets new options asynchronously on search update when component is uncontrolled", async () => { + it("fetch options asynchronously on search update when component is uncontrolled", async () => { const user = userEvent.setup(); callbackFetchOptionsMock @@ -1156,15 +1189,12 @@ describe("", () => { expectOptions(["Paris", "Panama", "London", "New York", "Tokyo"]); }); - it("gets new options asynchronously on default value and search updates when component is uncontrolled", async () => { + it("fetch options asynchronously on default value and search updates when component is uncontrolled", async () => { callbackFetchOptionsMock.mockResolvedValue(arrayCityOptions); expect(vi.isMockFunction(callbackFetchOptionsMock)).toBeTruthy(); From 00d50ddb918bc11e53fee636f0bbe17da6fcd0d2 Mon Sep 17 00:00:00 2001 From: daproclaima Date: Mon, 22 Jul 2024 19:49:04 +0200 Subject: [PATCH 22/27] =?UTF-8?q?=F0=9F=9A=B8(react)=20change=20select=20m?= =?UTF-8?q?ono=20arrow=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - make arrow button keyboard navigable --- .../components/Forms/Select/mono-common.tsx | 47 ++++++++++--------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/packages/react/src/components/Forms/Select/mono-common.tsx b/packages/react/src/components/Forms/Select/mono-common.tsx index 4dc031dc1..a3369501e 100644 --- a/packages/react/src/components/Forms/Select/mono-common.tsx +++ b/packages/react/src/components/Forms/Select/mono-common.tsx @@ -134,9 +134,11 @@ export const SelectMonoAux = ({ >
{children}
- {!props.isLoading && ( -
- {clearable && !disabled && downshiftReturn.selectedItem && ( +
+ {!props.isLoading && + clearable && + !disabled && + downshiftReturn.selectedItem && ( <>
- )} +
From 6fd14cb7e59d71238efaf3a8f5e9e10b1703161a Mon Sep 17 00:00:00 2001 From: daproclaima Date: Thu, 25 Jul 2024 12:02:34 +0200 Subject: [PATCH 23/27] =?UTF-8?q?=E2=9C=A8(react)=20add=20options=20fetchi?= =?UTF-8?q?ng=20to=20controllable=20select=20mono=20searchable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - update related component tests - apply fixes to SelectMonoSearchable concerning loading state and refactors - prevent passing a string array in value prop to select mono - update storybook documentation and options fetching utils --- .../src/components/Forms/Select/index.tsx | 8 +- .../Forms/Select/mono-searchable.tsx | 436 +++++++++--------- .../src/components/Forms/Select/mono.spec.tsx | 29 +- .../components/Forms/Select/mono.stories.tsx | 25 +- .../src/components/Forms/Select/mono.tsx | 5 +- .../components/Forms/Select/stories-utils.tsx | 2 +- 6 files changed, 251 insertions(+), 254 deletions(-) diff --git a/packages/react/src/components/Forms/Select/index.tsx b/packages/react/src/components/Forms/Select/index.tsx index b8c098c12..fa6383a2f 100644 --- a/packages/react/src/components/Forms/Select/index.tsx +++ b/packages/react/src/components/Forms/Select/index.tsx @@ -24,7 +24,7 @@ export type OptionWithoutRender = Omit & { export type Option = OptionWithoutRender | OptionWithRender; export type ContextCallbackFetchOptions = { - search?: string; + search?: string | number; }; export type CallbackFetchOptions = ( @@ -72,6 +72,10 @@ export const Select = forwardRef((props, ref) => { return props.multi ? ( ) : ( - + ); }); diff --git a/packages/react/src/components/Forms/Select/mono-searchable.tsx b/packages/react/src/components/Forms/Select/mono-searchable.tsx index 583b84d11..db97a0bc7 100644 --- a/packages/react/src/components/Forms/Select/mono-searchable.tsx +++ b/packages/react/src/components/Forms/Select/mono-searchable.tsx @@ -19,238 +19,234 @@ import { CallbackFetchOptions, Option, SelectHandle, + SelectMonoProps, } from ":/components/Forms/Select"; import { isOptionWithRender } from ":/components/Forms/Select/utils"; import { Loader } from ":/components/Loader"; -export const SelectMonoSearchable = forwardRef( - ({ showLabelWhenSelected = true, ...props }, ref) => { - const { t } = useCunningham(); +type SelectMonoSearchableType = SubProps & SelectMonoProps; - const [optionsToDisplay, setOptionsToDisplay] = useState( - Array.isArray(props.options) ? props.options : [], - ); - const [hasInputFocused, setHasInputFocused] = useState(false); - const [isFetchingInitialOptions, setIsFetchingInitialOptions] = - useState(false); - const [inputFilter, setInputFilter] = useState(); - const inputRef = useRef(null); - const downshiftReturn = useCombobox({ - ...props.downshiftProps, - items: optionsToDisplay, - itemToString: optionToString, - onInputValueChange: (e) => { - setInputFilter(e.inputValue); - if (!e.inputValue) { - downshiftReturn.selectItem(null); - } - }, - }); - const [labelAsPlaceholder, setLabelAsPlaceholder] = useState( - !downshiftReturn.selectedItem, - ); - - const computeOptionsToDisplay = async ( - search: string, - ): Promise => { - const options = await (props.options as CallbackFetchOptions)({ search }); - - if (Array.isArray(options)) { - setOptionsToDisplay(options); - } - - return options; - }; - - const computeOptionsToDisplayAndDefaultOption = async ( - defaultValue: Option["value"], - ) => { - if (defaultValue === undefined) { - return; - } - - const options = await computeOptionsToDisplay(defaultValue); - - if (Array.isArray(options)) { - if (defaultValue) { - const defaultOption = options.find( - (option) => String(option.value) === defaultValue, - ); - - if (defaultOption) { - downshiftReturn.selectItem(defaultOption); - } - } - } - }; - - const onInputBlur = () => { - setHasInputFocused(false); - if (downshiftReturn.selectedItem) { - // Here the goal is to make sure that when the input in blurred then the input value - // has exactly the selectedItem label. Which is not the case by default. - downshiftReturn.selectItem(downshiftReturn.selectedItem); - } else { - // We want the input to be empty when no item is selected. - downshiftReturn.setInputValue(""); - } - }; - - const onInputChange = (event: React.ChangeEvent) => { - if (typeof inputProps.onChange === "function") { - inputProps.onChange(event); - } - - if (isAsyncOptionsFetching) { - computeOptionsToDisplay(event.target.value); - } - }; - - const inputProps = downshiftReturn.getInputProps({ - ref: inputRef, - disabled: props.disabled, - }); - - const renderCustomSelectedOption = !showLabelWhenSelected; - const isAsyncOptionsFetching = typeof props.options === "function"; - - useEffect(() => { - if (!isAsyncOptionsFetching) { - return; - } - - setIsFetchingInitialOptions(true); - let search = props.defaultValue ? String(props.defaultValue) : ""; - - // a controlled select can not use props.defaultValue and props.value at the same time - if (!props.defaultValue) { - search = props.value ? String(props.value) : ""; +export const SelectMonoSearchable = forwardRef< + SelectHandle, + SelectMonoSearchableType +>(({ showLabelWhenSelected = true, ...props }, ref) => { + const { t } = useCunningham(); + + const [arrayOptions, setArrayOptions] = useState( + Array.isArray(props.options) ? props.options : [], + ); + const [hasInputFocused, setHasInputFocused] = useState(false); + const [inputFilter, setInputFilter] = useState(); + const inputRef = useRef(null); + const downshiftReturn = useCombobox({ + ...props.downshiftProps, + items: arrayOptions, + itemToString: optionToString, + onInputValueChange: (e) => { + setInputFilter(e.inputValue); + if (!e.inputValue) { + downshiftReturn.selectItem(null); } + }, + }); + const [labelAsPlaceholder, setLabelAsPlaceholder] = useState( + !downshiftReturn.selectedItem, + ); + + const updateArrayOptions = async ( + search?: string | number, + ): Promise => { + const options = await (props.options as CallbackFetchOptions)({ search }); + + if (Array.isArray(options)) { + setArrayOptions(options); + } + + return options; + }; + + const updateArrayOptionsAndDefaultOption = async ( + defaultValue: Option["value"], + ) => { + const options = await updateArrayOptions(defaultValue); + let defaultOption; + + if (Array.isArray(options)) { + defaultOption = options.find( + (option) => String(option.value) === defaultValue, + ); + } + + if (defaultOption) { + downshiftReturn.selectItem(defaultOption); + } + }; + + const onInputBlur = () => { + setHasInputFocused(false); + if (downshiftReturn.selectedItem) { + // Here the goal is to make sure that when the input in blurred then the input value + // has exactly the selectedItem label. Which is not the case by default. + downshiftReturn.selectItem(downshiftReturn.selectedItem); + } else { + // We want the input to be empty when no item is selected. + downshiftReturn.setInputValue(""); + } + }; + + const onInputChange = (event: React.ChangeEvent) => { + if (typeof inputProps.onChange === "function") { + inputProps.onChange(event); + } + + if (isAsyncOptionsFetching) { + updateArrayOptions(event.target.value); + } + }; + + const inputProps = downshiftReturn.getInputProps({ + ref: inputRef, + disabled: props.disabled, + }); + + const renderCustomSelectedOption = !showLabelWhenSelected; + + const isAsyncOptionsFetching = typeof props.options === "function"; + + useEffect(() => { + if (!isAsyncOptionsFetching) { + return; + } + + let search; + + if (props.defaultValue) { + search = String(props.defaultValue); + } + + if (props.value) { + search = String(props.value); + } + + updateArrayOptionsAndDefaultOption(search); + }, []); + + useEffect(() => { + if (hasInputFocused || downshiftReturn.inputValue) { + setLabelAsPlaceholder(false); + return; + } + setLabelAsPlaceholder(!downshiftReturn.selectedItem); + }, [ + downshiftReturn.selectedItem, + hasInputFocused, + downshiftReturn.inputValue, + ]); + + // Similar to: useKeepSelectedItemInSyncWithOptions ( see docs ) + // The only difference is that it does not apply when there is an inputFilter. ( See below why ) + useEffect(() => { + // If there is an inputFilter, using selectItem will trigger onInputValueChange that will sets inputFilter to + // empty, and then ignoring the existing filter and displaying all options. + if (inputFilter) { + return; + } + + // this block will not be triggered when component uses options fetching (otherwise it will conflict with the value + // passed to onInputChange) + const selectedItem = downshiftReturn.selectedItem + ? optionToValue(downshiftReturn.selectedItem) + : undefined; + + const optionToSelect = arrayOptions.find( + (option) => optionToValue(option) === props.value, + ); - computeOptionsToDisplayAndDefaultOption(search); + // Already selected + if (optionToSelect && selectedItem === props.value) { + return; + } - setIsFetchingInitialOptions(false); - }, []); + downshiftReturn.selectItem(optionToSelect ?? null); + }, [props.value]); - useEffect(() => { - if (hasInputFocused || downshiftReturn.inputValue) { - setLabelAsPlaceholder(false); - return; - } - setLabelAsPlaceholder(!downshiftReturn.selectedItem); - }, [ - downshiftReturn.selectedItem, - hasInputFocused, - downshiftReturn.inputValue, - ]); - - // Similar to: useKeepSelectedItemInSyncWithOptions ( see docs ) - // The only difference is that it does not apply when there is an inputFilter. ( See below why ) - useEffect(() => { - // If there is an inputFilter, using selectItem will trigger onInputValueChange that will sets inputFilter to - // empty, and then ignoring the existing filter and displaying all options. - if (inputFilter) { - return; - } + // Even there is already a value selected, when opening the combobox menu we want to display all available choices. + useEffect(() => { + props.onSearchInputChange?.({ target: { value: inputFilter } }); - // this block will not be triggered when component uses options fetching (otherwise it will conflict with the value - // passed to onInputChange) + if (downshiftReturn.isOpen) { if (Array.isArray(props.options)) { - const selectedItem = downshiftReturn.selectedItem - ? optionToValue(downshiftReturn.selectedItem) - : undefined; - - const optionToSelect = props.options.find( - (option) => optionToValue(option) === props.value, - ); + const arrayFilteredOptions = inputFilter + ? props.options.filter(getOptionsFilter(inputFilter)) + : props.options; - // Already selected - if (optionToSelect && selectedItem === props.value) { - return; - } - - downshiftReturn.selectItem(optionToSelect ?? null); - } - }, [props.value, props.options, inputFilter]); - - // Even there is already a value selected, when opening the combobox menu we want to display all available choices. - useEffect(() => { - props.onSearchInputChange?.({ target: { value: inputFilter } }); - - if (downshiftReturn.isOpen) { - if (Array.isArray(props.options)) { - const arrayFilteredOptions = inputFilter - ? props.options.filter(getOptionsFilter(inputFilter)) - : props.options; - - setOptionsToDisplay(arrayFilteredOptions); - } - } else { - setInputFilter(undefined); + setArrayOptions(arrayFilteredOptions); } - }, [downshiftReturn.isOpen, props.options, inputFilter]); - - useImperativeHandle(ref, () => ({ - blur: () => { - downshiftReturn.closeMenu(); - inputRef.current?.blur(); - }, - })); - - return ( - { - inputRef.current?.focus(); - // This is important because if we don't check that: when clicking on the toggle button - // when the menu is open, it will close and reopen immediately. - if (!downshiftReturn.isOpen) { - downshiftReturn.openMenu(); - } - }, + } else { + setInputFilter(undefined); + } + }, [downshiftReturn.isOpen, props.options, inputFilter]); + + useImperativeHandle(ref, () => ({ + blur: () => { + downshiftReturn.closeMenu(); + inputRef.current?.blur(); + }, + })); + + return ( + { + inputRef.current?.focus(); + // This is important because if we don't check that: when clicking on the toggle button + // when the menu is open, it will close and reopen immediately. + if (!downshiftReturn.isOpen) { + downshiftReturn.openMenu(); + } }, - toggleButtonProps: downshiftReturn.getToggleButtonProps({ - disabled: props.disabled, - "aria-label": t("components.forms.select.toggle_button_aria_label"), - }), + }, + toggleButtonProps: downshiftReturn.getToggleButtonProps({ + disabled: props.disabled, + "aria-label": t("components.forms.select.toggle_button_aria_label"), + }), + }} + labelAsPlaceholder={labelAsPlaceholder} + options={arrayOptions} + > + { + setHasInputFocused(true); }} - labelAsPlaceholder={labelAsPlaceholder} - options={optionsToDisplay} - > - { - setHasInputFocused(true); - }} - onBlur={() => { - onInputBlur(); - }} - onChange={onInputChange} - /> - - {renderCustomSelectedOption && - !hasInputFocused && - downshiftReturn.selectedItem && - isOptionWithRender(downshiftReturn.selectedItem) && - downshiftReturn.selectedItem.render()} - - {props?.isLoading === true ? ( -
- -
- ) : null} -
- ); - }, -); + onBlur={() => { + onInputBlur(); + }} + onChange={onInputChange} + /> + + {renderCustomSelectedOption && + !hasInputFocused && + downshiftReturn.selectedItem && + isOptionWithRender(downshiftReturn.selectedItem) && + downshiftReturn.selectedItem.render()} + + {props?.isLoading === true ? ( +
+ +
+ ) : null} +
+ ); +}); diff --git a/packages/react/src/components/Forms/Select/mono.spec.tsx b/packages/react/src/components/Forms/Select/mono.spec.tsx index 1ced0b086..af66e3475 100644 --- a/packages/react/src/components/Forms/Select/mono.spec.tsx +++ b/packages/react/src/components/Forms/Select/mono.spec.tsx @@ -1,7 +1,7 @@ import userEvent from "@testing-library/user-event"; import { act, render, screen, waitFor } from "@testing-library/react"; import { expect, Mock, vi } from "vitest"; -import React, { createRef, FormEvent, useEffect, useState } from "react"; +import React, { createRef, FormEvent, useState } from "react"; import { within } from "@testing-library/dom"; import { Select, @@ -1138,7 +1138,7 @@ describe("", () => { expect(input.tagName).toEqual("INPUT"); expect(callbackFetchOptionsMock).toHaveBeenNthCalledWith(1, { - search: "", + search: undefined, }); expectMenuToBeClosed(menu); @@ -1234,7 +1234,7 @@ describe("", () => { expectOptions(["Paris", "Panama", "London", "New York", "Tokyo"]); }); - it("gets new options asynchronously on search update when component is controlled", async () => { + it("fetch new options on search update when component is controlled", async () => { const ControlledSearchableFetchedOptionsSelectWrapper = ({ optionsCallback, defaultValue, @@ -1317,12 +1317,6 @@ describe("", () => { expect(callbackFetchOptionsMock).toHaveBeenNthCalledWith(1, { search: "london", }); - expectOptions(["London"]); + expectOptions(["Paris", "Panama", "London", "New York", "Tokyo"]); await userEvent.click(clearButton); @@ -1406,13 +1400,10 @@ describe("", () => { expectOptions(["Paris", "Panama"]); await user.type(input, "a", { skipClick: true }); - expect(callbackFetchOptionsMock).toHaveBeenNthCalledWith(4, { + expect(callbackFetchOptionsMock).toHaveBeenNthCalledWith(3, { search: "Pa", }); expectMenuToBeOpen(menu); expectOptions(["Paris", "Panama"]); await user.type(input, "r", { skipClick: true }); - expect(callbackFetchOptionsMock).toHaveBeenNthCalledWith(5, { + expect(callbackFetchOptionsMock).toHaveBeenNthCalledWith(4, { search: "Par", }); expectOptions(["Paris"]); @@ -1443,7 +1434,7 @@ describe(" setValue(e.target.value as string)} + onChange={(e) => { + setValue(e.target.value as string); + }} /> +
); diff --git a/packages/react/src/components/Forms/Select/mono.tsx b/packages/react/src/components/Forms/Select/mono.tsx index 833169046..d2abafa91 100644 --- a/packages/react/src/components/Forms/Select/mono.tsx +++ b/packages/react/src/components/Forms/Select/mono.tsx @@ -5,7 +5,10 @@ import { SelectMonoSearchable } from ":/components/Forms/Select/mono-searchable" import { SelectMonoSimple } from ":/components/Forms/Select/mono-simple"; import { Option, SelectHandle, SelectProps } from ":/components/Forms/Select"; -export const SelectMono = forwardRef( +export type SelectMonoProps = Omit & { + value?: string | number; +}; +export const SelectMono = forwardRef( (props, ref) => { const { options } = props; diff --git a/packages/react/src/components/Forms/Select/stories-utils.tsx b/packages/react/src/components/Forms/Select/stories-utils.tsx index 226d6e69f..5d0547ced 100644 --- a/packages/react/src/components/Forms/Select/stories-utils.tsx +++ b/packages/react/src/components/Forms/Select/stories-utils.tsx @@ -60,7 +60,7 @@ export const fetchOptions = async ( ); const arrayOptions: Option[] = stringSearch - ? filterOptions(options, stringSearch) + ? filterOptions(options, String(stringSearch)) : options; resolve(arrayOptions); From 0f780aa0b99da9d195bfe5d8e664eb24e41adc8e Mon Sep 17 00:00:00 2001 From: daproclaima Date: Wed, 31 Jul 2024 12:49:33 +0200 Subject: [PATCH 24/27] =?UTF-8?q?=F0=9F=90=9B(react)=20update=20mono=20sel?= =?UTF-8?q?ect=20options=20fetching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - execute options fetching on select mono searchable when clear button is clicked - update related tests and a new one --- .../components/Forms/Select/mono-common.tsx | 6 + .../Forms/Select/mono-searchable.tsx | 11 +- .../src/components/Forms/Select/mono.spec.tsx | 137 +++++++++--------- 3 files changed, 84 insertions(+), 70 deletions(-) diff --git a/packages/react/src/components/Forms/Select/mono-common.tsx b/packages/react/src/components/Forms/Select/mono-common.tsx index a3369501e..7dfed079b 100644 --- a/packages/react/src/components/Forms/Select/mono-common.tsx +++ b/packages/react/src/components/Forms/Select/mono-common.tsx @@ -8,6 +8,7 @@ import { Button } from ":/components/Button"; import { Option, SelectProps } from ":/components/Forms/Select"; import { isOptionWithRender } from ":/components/Forms/Select/utils"; import { SelectMenu } from ":/components/Forms/Select/select-menu"; +import { UpdateArrayOptionsType } from ":/components/Forms/Select/mono-searchable"; export function getOptionsFilter(inputValue?: string) { return (option: Option) => { @@ -53,6 +54,7 @@ export interface SubProps extends SelectProps { export interface SelectAuxProps extends SubProps { options: Option[]; labelAsPlaceholder: boolean; + updateArrayOptions?: UpdateArrayOptionsType; downshiftReturn: { isOpen: boolean; wrapperProps?: HTMLAttributes; @@ -84,6 +86,7 @@ export const SelectMonoAux = ({ disabled, clearable = true, onBlur, + updateArrayOptions, ...props }: SelectAuxProps) => { const { t } = useCunningham(); @@ -149,6 +152,9 @@ export const SelectMonoAux = ({ className="c__select__inner__actions__clear" onClick={(e) => { downshiftReturn.selectItem(null); + if (typeof updateArrayOptions === "function") { + updateArrayOptions(undefined); + } e.stopPropagation(); }} icon={close} diff --git a/packages/react/src/components/Forms/Select/mono-searchable.tsx b/packages/react/src/components/Forms/Select/mono-searchable.tsx index db97a0bc7..de38129dd 100644 --- a/packages/react/src/components/Forms/Select/mono-searchable.tsx +++ b/packages/react/src/components/Forms/Select/mono-searchable.tsx @@ -26,6 +26,10 @@ import { Loader } from ":/components/Loader"; type SelectMonoSearchableType = SubProps & SelectMonoProps; +export type UpdateArrayOptionsType = ( + search?: string | number, +) => Promise; + export const SelectMonoSearchable = forwardRef< SelectHandle, SelectMonoSearchableType @@ -53,9 +57,7 @@ export const SelectMonoSearchable = forwardRef< !downshiftReturn.selectedItem, ); - const updateArrayOptions = async ( - search?: string | number, - ): Promise => { + const updateArrayOptions: UpdateArrayOptionsType = async (search) => { const options = await (props.options as CallbackFetchOptions)({ search }); if (Array.isArray(options)) { @@ -217,6 +219,9 @@ export const SelectMonoSearchable = forwardRef< }} labelAsPlaceholder={labelAsPlaceholder} options={arrayOptions} + updateArrayOptions={ + isAsyncOptionsFetching ? updateArrayOptions : undefined + } > ", () => { ).toBeInTheDocument(); }); - it("fetch new options on search update when component is uncontrolled", async () => { + it("fetch new options on button clear click", async () => { + callbackFetchOptionsMock.mockResolvedValue(arrayCityOptions); + + expect(vi.isMockFunction(callbackFetchOptionsMock)).toBeTruthy(); + + render( + , + ); + const user = userEvent.setup(); + const input = screen.getByRole("combobox", { + name: "Select a city", + }); + + expect(input.tagName).toEqual("INPUT"); + + await user.click(input); + expect(callbackFetchOptionsMock).toHaveBeenNthCalledWith(1, { + search: "london", + }); + expectOptions(["Paris", "Panama", "London", "New York", "Tokyo"]); + + await userEvent.click( + screen.getByRole("button", { + name: "Clear selection", + }), + ); + + expect(callbackFetchOptionsMock).toHaveBeenNthCalledWith(2, { + search: undefined, + }); + + await user.click(input); + expectOptions(["Paris", "Panama", "London", "New York", "Tokyo"]); + }); + + it("fetch new options on search update when component is uncontrolled", async () => { callbackFetchOptionsMock .mockResolvedValueOnce(arrayCityOptions) .mockResolvedValueOnce([ @@ -1180,22 +1219,18 @@ describe("", () => { search: "P", }); - expectMenuToBeOpen(menu); expectOptions(["Paris", "Panama"]); await user.type(input, "a", { skipClick: true }); + expect(callbackFetchOptionsMock).toHaveBeenNthCalledWith(3, { search: "Pa", }); - expectMenuToBeOpen(menu); expectOptions(["Paris", "Panama"]); await user.type(input, "r", { skipClick: true }); + expect(callbackFetchOptionsMock).toHaveBeenNthCalledWith(4, { search: "Par", }); expectOptions(["Paris"]); - // Select option. const option: HTMLLIElement = screen.getByRole("option", { name: "Paris", }); + await user.click(option); expect(input).toHaveValue("Paris"); await user.clear(input); + expect(callbackFetchOptionsMock).toHaveBeenNthCalledWith(5, { search: "", }); @@ -1239,8 +1275,6 @@ describe("", () => { await expectLoaderNotToBeInTheDocument(); expect(input).toHaveValue("London"); - // Click on the input. await user.click(input); expectMenuToBeOpen(menu); @@ -1273,6 +1307,23 @@ describe("", () => { ); }; - callbackFetchOptionsMock - .mockResolvedValueOnce(arrayCityOptions) - .mockResolvedValueOnce([ - { - label: "Paris", - value: "paris", - }, - { - label: "Panama", - value: "panama", - }, - ]) - .mockResolvedValueOnce([ - { - label: "Paris", - value: "paris", - }, - { - label: "Panama", - value: "panama", - }, - ]) - .mockResolvedValueOnce([ - { - label: "Paris", - value: "paris", - }, - ]) - .mockResolvedValueOnce(arrayCityOptions); - - expect(vi.isMockFunction(callbackFetchOptionsMock)).toBeTruthy(); - await act(async () => render( ", () => { const user = userEvent.setup(); expect(input.tagName).toEqual("INPUT"); - - // Make sure value is selected. expect(screen.getByText("Value = london|")).toBeVisible(); - expect(input).toHaveValue("London"); expectMenuToBeClosed(menu); await user.click(input); expectMenuToBeOpen(menu); - expect(callbackFetchOptionsMock).toHaveBeenNthCalledWith(1, { search: "london", }); @@ -1391,42 +1406,30 @@ describe("", () => { await user.clear(input); - expect(callbackFetchOptionsMock).toHaveBeenNthCalledWith(5, { + expect(callbackFetchOptionsMock).toHaveBeenNthCalledWith(4, { search: "", }); expect(screen.getByText("Value = |")).toBeVisible(); From 3a1607b4cd91d1131eb8d6fcd1520a6c9961ab9c Mon Sep 17 00:00:00 2001 From: daproclaima Date: Wed, 31 Jul 2024 13:16:39 +0200 Subject: [PATCH 25/27] =?UTF-8?q?=F0=9F=93=9D(react)=20update=20stories=20?= =?UTF-8?q?for=20mono=20select?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - update description for searchable select with options fetching --- packages/react/src/components/Forms/Select/mono.mdx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/react/src/components/Forms/Select/mono.mdx b/packages/react/src/components/Forms/Select/mono.mdx index 697dea80a..a0f72f93d 100644 --- a/packages/react/src/components/Forms/Select/mono.mdx +++ b/packages/react/src/components/Forms/Select/mono.mdx @@ -43,10 +43,15 @@ You can enable the text live filtering simply by using the `searchable` props. You can enable the text live filtering simply by using the `searchable` prop, and the options fetching by providing a callback (async or not) receiving the search term in its parameters in the `options` prop. This latter is triggered every time the search term is different from the previous one. An optional `isLoading` prop can be used to show -a loader while the options are being fetched. You can also provide an optional `defaultValue` prop. +a loader while the options are being fetched. You can also provide an optional `defaultValue` prop. It works both in +controlled and uncontrolled state. +### uncontrolled +### controlled + + ## States From ab61478d34415bdd7e5c766352424c2c096909cf Mon Sep 17 00:00:00 2001 From: daproclaima Date: Mon, 23 Sep 2024 20:27:07 +0200 Subject: [PATCH 26/27] =?UTF-8?q?=F0=9F=8E=A8(react)=20improve=20select=20?= =?UTF-8?q?mono-common=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - simplify the reading of the condition displaying the clear button - update CHANGELOG.md for release --- packages/react/CHANGELOG.md | 8 +++ .../components/Forms/Select/mono-common.tsx | 50 +++++++++---------- 2 files changed, 33 insertions(+), 25 deletions(-) diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index 8000d2614..40010f50b 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -1,5 +1,11 @@ # @openfun/cunningham-react +## 2.10.0 + +### Minor Changes + +- 0b45f51: add async option fetching mode to select mono + ## 2.9.4 ### Patch Changes @@ -425,6 +431,8 @@ - 4ebbf16: Add component's tokens handling [unreleased]: https://github.com/openfun/cunningham/compare/@openfun/cunningham-react@2.9.3...main +[2.10.0]: https://github.com/openfun/cunningham/compare/@openfun/cunningham-react@2.9.4...@openfun/cunningham-react@2.10.0 +[2.9.4]: https://github.com/openfun/cunningham/compare/@openfun/cunningham-react@2.9.3...@openfun/cunningham-react@2.9.4 [2.9.3]: https://github.com/openfun/cunningham/compare/@openfun/cunningham-react@2.9.2...@openfun/cunningham-react@2.9.3 [2.9.2]: https://github.com/openfun/cunningham/compare/@openfun/cunningham-react@2.9.1...@openfun/cunningham-react@2.9.2 [2.9.1]: https://github.com/openfun/cunningham/compare/@openfun/cunningham-react@2.9.0...@openfun/cunningham-react@2.9.1 diff --git a/packages/react/src/components/Forms/Select/mono-common.tsx b/packages/react/src/components/Forms/Select/mono-common.tsx index 7dfed079b..1dc085799 100644 --- a/packages/react/src/components/Forms/Select/mono-common.tsx +++ b/packages/react/src/components/Forms/Select/mono-common.tsx @@ -93,6 +93,9 @@ export const SelectMonoAux = ({ const labelProps = downshiftReturn.getLabelProps(); const ref = useRef(null); + const isToReset = + !props.isLoading && clearable && !disabled && downshiftReturn.selectedItem; + return ( <> @@ -138,31 +141,28 @@ export const SelectMonoAux = ({
{children}
- {!props.isLoading && - clearable && - !disabled && - downshiftReturn.selectedItem && ( - <> -