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/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/_index.scss b/packages/react/src/components/Forms/Select/_index.scss index b579853eb..f32395ee2 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: right; + cursor: wait; + } + &__wrapper { border-radius: var(--c--components--forms-select--border-radius); border-width: var(--c--components--forms-select--border-width); @@ -99,6 +108,10 @@ } &__open { color: var(--c--theme--colors--greyscale-900); + + &--hidden { + display: none; + } } } } diff --git a/packages/react/src/components/Forms/Select/index.tsx b/packages/react/src/components/Forms/Select/index.tsx index 931e9e2a4..fa6383a2f 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 | number; +}; + +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[]; @@ -45,6 +53,7 @@ export type SelectProps = PropsWithChildren & disabled?: boolean; clearable?: boolean; multi?: boolean; + isLoading?: boolean; showLabelWhenSelected?: boolean; monoline?: boolean; selectedItemsStyle?: "pills" | "text"; @@ -63,6 +72,10 @@ export const Select = forwardRef((props, ref) => { return props.multi ? ( ) : ( - + ); }); diff --git a/packages/react/src/components/Forms/Select/mono-common.tsx b/packages/react/src/components/Forms/Select/mono-common.tsx index f50e95fd3..1dc085799 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,12 +86,16 @@ export const SelectMonoAux = ({ disabled, clearable = true, onBlur, + updateArrayOptions, ...props }: SelectAuxProps) => { const { t } = useCunningham(); const labelProps = downshiftReturn.getLabelProps(); const ref = useRef(null); + const isToReset = + !props.isLoading && clearable && !disabled && downshiftReturn.selectedItem; + return ( <> @@ -135,7 +141,7 @@ export const SelectMonoAux = ({
{children}
- {clearable && !disabled && downshiftReturn.selectedItem && ( + {isToReset && ( <> +
+ ); +}; + +export const SearchableUncontrolledWithAsyncOptionsFetchingAndDefaultValue = + () => { + const [isLoading, setIsLoading] = useState(true); + const [isInitialOptionFetching, setIsInitialOptionFetching] = + useState(true); + + const fetchAsyncOptions = async (context: ContextCallbackFetchOptions) => { + let arrayOptions: Option[] = []; + + setIsLoading(true); + context.search = isInitialOptionFetching ? "" : context.search; + arrayOptions = await fetchOptions(context, OPTIONS, 1000); + setIsInitialOptionFetching(false); + + setIsLoading(false); + return arrayOptions; + }; + + return ( +
+ { + setValue(e.target.value as string); + }} + /> + +
+ + ); +}; + 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..d2abafa91 100644 --- a/packages/react/src/components/Forms/Select/mono.tsx +++ b/packages/react/src/components/Forms/Select/mono.tsx @@ -5,13 +5,24 @@ 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 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 +69,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..5d0547ced 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,26 @@ 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 + 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, String(stringSearch)) + : options; + + resolve(arrayOptions); + }, msTimeOut || 500); + }); diff --git a/packages/react/src/components/Forms/Select/test-utils.tsx b/packages/react/src/components/Forms/Select/test-utils.tsx index f32348d98..19f3feba7 100644 --- a/packages/react/src/components/Forms/Select/test-utils.tsx +++ b/packages/react/src/components/Forms/Select/test-utils.tsx @@ -1,5 +1,5 @@ // eslint-disable-next-line import/no-extraneous-dependencies -import { screen } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; export const expectMenuToBeOpen = (menu: HTMLDivElement) => { expect(Array.from(menu.classList)).contains("c__select__menu--opened"); @@ -49,3 +49,20 @@ export const expectSelectedOptionsText = (expectedOptions: string[]) => { const valueElement = document.querySelector(".c__select__inner__value"); expect(valueElement?.textContent).toEqual(expectedOptions.join(", ")); }; + +export const expectLoaderToBeVisible = async () => { + await waitFor(() => { + const loader = screen.queryByRole("status", { + name: "Loading data", + }); + expect(loader).toBeVisible(); + }); +}; +export const expectLoaderNotToBeInTheDocument = async () => { + await waitFor(() => { + const loader = screen.queryByRole("status", { + name: "Loading data", + }); + expect(loader).not.toBeInTheDocument(); + }); +}; diff --git a/packages/react/src/locales/en-US.json b/packages/react/src/locales/en-US.json index bbe2d6405..ac1102af7 100644 --- a/packages/react/src/locales/en-US.json +++ b/packages/react/src/locales/en-US.json @@ -34,7 +34,8 @@ "toggle_button_aria_label": "Toggle dropdown", "clear_button_aria_label": "Clear selection", "clear_all_button_aria_label": "Clear all selections", - "menu_empty_placeholder": "No options available" + "menu_empty_placeholder": "No options available", + "loader_aria": "Loading data" }, "file_uploader": { "delete_file_name": "Delete file {name}", diff --git a/packages/react/src/locales/fr-FR.json b/packages/react/src/locales/fr-FR.json index 5c4f0b471..2a02421b6 100644 --- a/packages/react/src/locales/fr-FR.json +++ b/packages/react/src/locales/fr-FR.json @@ -26,7 +26,8 @@ "toggle_button_aria_label": "Ouvrir le menu", "clear_button_aria_label": "Effacer la sélection", "clear_all_button_aria_label": "Effacer toutes les sélections", - "menu_empty_placeholder": "Aucun choix disponible" + "menu_empty_placeholder": "Aucun choix disponible", + "loader_aria": "Données en cours de chargement" }, "file_uploader": { "delete_file_name": "Supprimer le fichier {name}", diff --git a/vitest.workspace.ts b/vitest.workspace.ts new file mode 100644 index 000000000..e6d52ba77 --- /dev/null +++ b/vitest.workspace.ts @@ -0,0 +1 @@ +export default ["packages/react"];