diff --git a/resources/ts/back/Components/Inputs/ModelSelect.tsx b/resources/ts/back/Components/Inputs/ModelSelect.tsx index 84487864..09c852a4 100644 --- a/resources/ts/back/Components/Inputs/ModelSelect.tsx +++ b/resources/ts/back/Components/Inputs/ModelSelect.tsx @@ -24,7 +24,7 @@ import { ChevronLeftIcon, ChevronRightIcon, } from "lucide-react"; -import axios from "axios"; +import axios, { isAxiosError } from "axios"; import { route } from "ziggy-js"; // * Components shadcn import { Separator } from "@/components/ui/separator"; @@ -48,9 +48,8 @@ import { Skeleton } from "@/components/ui/skeleton"; // * Other import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip"; import InputError from "@/back/Components/InputError"; -import useTrans from "@/hooks/use-translations"; import str from "@/hooks/use-string"; -import { cn } from "@/lib/utils"; +import { cn, GetTranslation } from "@/lib/utils"; import { AppEnv, DefaultModel, Paginator } from "@/back/types/global"; import { TranslationField } from "@/back/types/i18n"; @@ -111,13 +110,15 @@ interface ModelSelectProps * Callback function triggered when the selected values change. * Receives an array of the new selected values. */ + // eslint-disable-next-line no-unused-vars onValueChange?: (value: number[]) => void; /** * Callback function triggered when the selected values change. * Receives a single or array of selected Model(s). */ - onModelChange?: (value: Model[]) => void; + // eslint-disable-next-line no-unused-vars + onModelChange?: (value: DefaultModel[]) => void; /** The default selected values when the component mounts. */ defaultValues?: number[]; @@ -157,6 +158,7 @@ interface ModelSelectProps * If true, renders the multi-select component as a child of another component. * Optional, defaults to false. */ + // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars asChild?: boolean; /** @@ -290,6 +292,7 @@ interface ModelSelectProps /** * The fonction to set the data in useForm inertia */ + // eslint-disable-next-line no-unused-vars setData: (name: string, value: number[] | number | null) => void; /** @@ -353,6 +356,7 @@ export interface ModelSelectRef { /** * Set selected values programmatically */ + // eslint-disable-next-line no-unused-vars setSelectedValues: (values: number[]) => void; /** * Clear all selected values @@ -384,6 +388,7 @@ export const ModelSelect = forwardRef( animationConfig, maxCount = 3, modalPopover = false, + // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars asChild = false, className, hideSelectAll = false, @@ -411,7 +416,9 @@ export const ModelSelect = forwardRef( const [selectedValues, setSelectedValues] = useState(defaultValues); - const [selectedModels, setSelectedModels] = useState([]); + const [selectedModels, setSelectedModels] = useState( + [], + ); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [isAnimating, setIsAnimating] = useState(false); @@ -441,8 +448,8 @@ export const ModelSelect = forwardRef( const [loading, setLoading] = useState(false); const [initialLoading, setInitialLoading] = useState(true); - const [options, setOptions] = useState([]); - const [optionsMerged, setOptionsMerged] = useState([]); + const [options, setOptions] = useState([]); + const [optionsMerged, setOptionsMerged] = useState([]); // Custom by field parameter or predefined values (title, name or label). const [dynamicField, setDynamicField] = useState( field ?? null, @@ -485,16 +492,6 @@ export const ModelSelect = forwardRef( return response.data; }; - /** - * Get specific page. - * @param pageNumber number - * @return void - */ - const goToPage = async (pageNumber: number) => { - if (pageNumber < 1 || pageNumber > lastPage) return; - await getData(pageNumber); - }; - /** * Get new options list with his target field. * @param pageNumber number @@ -502,27 +499,30 @@ export const ModelSelect = forwardRef( */ const getData = async (pageNumber = 1) => { // Increment search id. - const requestId = ++requestIdRef.current; + requestIdRef.current += 1; + const requestId = requestIdRef.current; setLoading(true); try { const data = (await fetchOptions(pageNumber)) as { items: Paginator; field: string; - model: Model[]; + model: DefaultModel[]; }; // Ignore obsolete answers. if (requestId !== requestIdRef.current) return; - const dataModel: Model[] = data.model + const dataModel: DefaultModel[] = data.model ? Object.values(data.model) : []; - const dataItems: Model[] = Array.isArray(data?.items?.data) + const dataItems: DefaultModel[] = Array.isArray( + data?.items?.data, + ) ? data.items.data : []; setOptionsMerged(() => { const merged = [...dataModel, ...dataItems] // Supprime les éléments undefined ou sans id .filter( - (item): item is Model => + (item): item is DefaultModel => !!item && item.id !== undefined, ) // Supprime les doublons par id @@ -538,11 +538,14 @@ export const ModelSelect = forwardRef( setOptions(data.items.data ?? []); setPage(data.items.current_page); setLastPage(data.items.last_page ?? 1); - } catch (err: any) { + } catch (err: unknown) { // Ignore obsolete errors. if (requestId !== requestIdRef.current) return; console.error(err); - setErrorsAjax(err.response?.data?.errors); + if (isAxiosError(err) && err.response?.data?.errors) { + setErrorsAjax(err.response?.data?.errors); + } + setOptions([]); setLastPage(1); } finally { @@ -553,23 +556,42 @@ export const ModelSelect = forwardRef( setInitialLoading(false); } }; + + /** + * Get specific page. + * @param pageNumber number + * @return void + */ + const goToPage = async (pageNumber: number) => { + if (pageNumber < 1 || pageNumber > lastPage) return; + await getData(pageNumber); + }; + useEffect(() => { if (disabled) { - return; + return undefined; } // Reset options list if empty search or null if (targetModel && searchValue.length === 0) { getData(); - return; + return undefined; } // Nothing to do if search is below 3 caracters. - if (searchValue.length < 3) return; + if (searchValue.length < 3) return undefined; // Get updated options list with search. const handler = setTimeout(() => getData(), 300); - return () => clearTimeout(handler); + + function cleanup(): void { + if (handler) { + clearTimeout(handler); + } + } + + return cleanup; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchValue, targetModel]); const announce = useCallback( @@ -591,15 +613,19 @@ export const ModelSelect = forwardRef( const selectedCountId = `${multiSelectId}-count`; // Translations. - const transDelete = useTrans("crud.actions.delete"); - const transClose = useTrans("back.close"); - const transClear = useTrans("back.clear"); - const transNoResults = useTrans("back.table_no_results"); - const transSearchOptions = useTrans("back.multi_select.search_options"); - const transSelectAll = useTrans("back.multi_select.select_all"); - const transMore = useTrans("back.multi_select.more"); - const transClosePopover = useTrans("back.multi_select.close_popover"); - const transClearList = useTrans("back.multi_select.clear_list"); + const transDelete = GetTranslation("crud.actions.delete"); + const transClose = GetTranslation("back.close"); + const transClear = GetTranslation("back.clear"); + const transNoResults = GetTranslation("back.table_no_results"); + const transSearchOptions = GetTranslation( + "back.multi_select.search_options", + ); + const transSelectAll = GetTranslation("back.multi_select.select_all"); + const transMore = GetTranslation("back.multi_select.more"); + const transClosePopover = GetTranslation( + "back.multi_select.close_popover", + ); + const transClearList = GetTranslation("back.multi_select.clear_list"); const prevDefaultValueRef = useRef(defaultValues); @@ -618,6 +644,7 @@ export const ModelSelect = forwardRef( onValueChange(defaultValues); } setData(name, multiple ? defaultValues : defaultValues[0]); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [defaultValues, onValueChange]); const buttonRef = useRef(null); @@ -661,6 +688,7 @@ export const ModelSelect = forwardRef( } }, }), + // eslint-disable-next-line react-hooks/exhaustive-deps [resetToDefault, selectedValues, onValueChange], ); @@ -669,7 +697,7 @@ export const ModelSelect = forwardRef( >("desktop"); useEffect(() => { - if (typeof window === "undefined") return; + if (typeof window === "undefined") return undefined; const handleResize = () => { const width = window.innerWidth; if (width < 640) { @@ -776,13 +804,12 @@ export const ModelSelect = forwardRef( return ""; }; - const getAllOptions = useCallback((): Model[] => { + const getAllOptions = useCallback((): DefaultModel[] => { if (optionsMerged.length === 0) return []; - let allOptions: Model[]; - allOptions = optionsMerged; + const allOptions: DefaultModel[] = optionsMerged; const valueSet = new Set(); const duplicates: string[] = []; - const uniqueOptions: Model[] = []; + const uniqueOptions: DefaultModel[] = []; allOptions.forEach((option) => { if (valueSet.has(option.id.toString())) { duplicates.push(option.id.toString()); @@ -813,13 +840,11 @@ export const ModelSelect = forwardRef( ); } return deduplicateOptions ? uniqueOptions : allOptions; - }, [options, deduplicateOptions]); + }, [optionsMerged, deduplicateOptions]); const getOptionByValue = useCallback( - (id: number): Model | undefined => { - const option = getAllOptions().find( - (option) => option.id === id, - ); + (id: number): DefaultModel | undefined => { + const option = getAllOptions().find((opt) => opt.id === id); if (!option && process.env.NODE_ENV === "development") { console.warn( `MultiSelect: Option with value "${id}" not found in options list`, @@ -856,7 +881,7 @@ export const ModelSelect = forwardRef( } }; - const toggleOption = (option: Model) => { + const toggleOption = (option: DefaultModel) => { if (disabled) return; // MODE SINGLE SELECT if (!multiple) { @@ -1063,73 +1088,77 @@ export const ModelSelect = forwardRef( } }, [isPopoverOpen]); - useEffect(() => { - const selectedCount = selectedValues.length; - const allOptions = getAllOptions(); - const totalOptions = allOptions.length; - if (selectedCount !== prevSelectedCount.current) { - const diff = selectedCount - prevSelectedCount.current; - if (diff > 0) { - const addedItems = selectedValues.slice(-diff); - const addedLabels = addedItems - .map( - (value) => - allOptions.find((opt) => opt.id === value)?.[ - dynamicField as keyof Model - ], - ) - .filter(Boolean); + useEffect( + () => { + const selectedCount = selectedValues.length; + const allOptions = getAllOptions(); + const totalOptions = allOptions.length; + if (selectedCount !== prevSelectedCount.current) { + const diff = selectedCount - prevSelectedCount.current; + if (diff > 0) { + const addedItems = selectedValues.slice(-diff); + const addedLabels = addedItems + .map( + (value) => + allOptions.find( + (opt) => opt.id === value, + )?.[dynamicField as keyof DefaultModel], + ) + .filter(Boolean); - if (addedLabels.length === 1) { + if (addedLabels.length === 1) { + announce( + `${addedLabels[0]} selected. ${selectedCount} of ${totalOptions} options selected.`, + ); + } else { + announce( + `${addedLabels.length} options selected. ${selectedCount} of ${totalOptions} total selected.`, + ); + } + } else if (diff < 0) { announce( - `${addedLabels[0]} selected. ${selectedCount} of ${totalOptions} options selected.`, - ); - } else { - announce( - `${addedLabels.length} options selected. ${selectedCount} of ${totalOptions} total selected.`, + `Option removed. ${selectedCount} of ${totalOptions} options selected.`, ); } - } else if (diff < 0) { - announce( - `Option removed. ${selectedCount} of ${totalOptions} options selected.`, - ); + prevSelectedCount.current = selectedCount; } - prevSelectedCount.current = selectedCount; - } - if (isPopoverOpen !== prevIsOpen.current) { - if (isPopoverOpen) { - announce( - `Dropdown opened. ${totalOptions} options available. Use arrow keys to navigate.`, - ); - } else { - announce("Dropdown closed."); + if (isPopoverOpen !== prevIsOpen.current) { + if (isPopoverOpen) { + announce( + `Dropdown opened. ${totalOptions} options available. Use arrow keys to navigate.`, + ); + } else { + announce("Dropdown closed."); + } + prevIsOpen.current = isPopoverOpen; } - prevIsOpen.current = isPopoverOpen; - } - if ( - searchValue !== prevSearchValue.current && - searchValue !== undefined - ) { - if (searchValue && isPopoverOpen) { - const filteredCount = allOptions.length; + if ( + searchValue !== prevSearchValue.current && + searchValue !== undefined + ) { + if (searchValue && isPopoverOpen) { + const filteredCount = allOptions.length; - announce( - `${filteredCount} option${ - filteredCount === 1 ? "" : "s" - } found for "${searchValue}"`, - ); + announce( + `${filteredCount} option${ + filteredCount === 1 ? "" : "s" + } found for "${searchValue}"`, + ); + } + prevSearchValue.current = searchValue; } - prevSearchValue.current = searchValue; - } - }, [ - selectedValues, - isPopoverOpen, - searchValue, - announce, - getAllOptions, - ]); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + selectedValues, + isPopoverOpen, + searchValue, + announce, + getAllOptions, + ], + ); useEffect(() => { if ( @@ -1157,10 +1186,10 @@ export const ModelSelect = forwardRef( } }, [minSelection, maxSelection, multiple]); - const getModelField = (option: Model): string => { - const value = option[dynamicField as keyof Model] as unknown as - | TranslationField - | string; + const getModelField = (option: DefaultModel): string => { + const value = option[ + dynamicField as keyof DefaultModel + ] as unknown as TranslationField | string; if (typeof value === "object") return String(value[locale] ?? ""); return String(value ?? ""); }; @@ -1205,7 +1234,7 @@ export const ModelSelect = forwardRef( } selected: ${selectedValues .map((value) => { const option = getOptionByValue(value); - if (!option) return; + if (!option) return undefined; return getModelField(option); }) .filter(Boolean) @@ -1214,6 +1243,7 @@ export const ModelSelect = forwardRef( - )} - - ( content={ <> - {useTrans( + {GetTranslation( "pagination.previous", )} } tooltipContent={

- {useTrans( + {GetTranslation( "pagination.specific_page", { params: { @@ -1688,13 +1709,15 @@ export const ModelSelect = forwardRef( - {useTrans("pagination.next")} + {GetTranslation( + "pagination.next", + )} } tooltipContent={

- {useTrans( + {GetTranslation( "pagination.specific_page", { params: { diff --git a/resources/ts/back/Components/Inputs/MultiSelect.tsx b/resources/ts/back/Components/Inputs/MultiSelect.tsx index 4989f8f2..64636e32 100644 --- a/resources/ts/back/Components/Inputs/MultiSelect.tsx +++ b/resources/ts/back/Components/Inputs/MultiSelect.tsx @@ -142,6 +142,7 @@ interface MultiSelectProps * Callback function triggered when the selected values change. * Receives an array of the new selected values. */ + // eslint-disable-next-line no-unused-vars onValueChange?: (value: string[]) => void; /** The default selected values when the component mounts. */ @@ -182,6 +183,7 @@ interface MultiSelectProps * If true, renders the multi-select component as a child of another component. * Optional, defaults to false. */ + // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars asChild?: boolean; /** @@ -315,6 +317,7 @@ interface MultiSelectProps /** * The fonction to set the data in useForm inertia */ + // eslint-disable-next-line no-unused-vars setData: (name: string, value: string[]) => void; /** @@ -362,6 +365,7 @@ export interface MultiSelectRef { /** * Set selected values programmatically */ + // eslint-disable-next-line no-unused-vars setSelectedValues: (values: string[]) => void; /** * Clear all selected values @@ -393,6 +397,7 @@ export const MultiSelect = forwardRef( animationConfig, maxCount = 3, modalPopover = false, + // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars asChild = false, className, hideSelectAll = false, @@ -482,6 +487,7 @@ export const MultiSelect = forwardRef( onValueChange(defaultValue); } setData(name, defaultValue); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [defaultValue, onValueChange]); const buttonRef = useRef(null); @@ -525,6 +531,7 @@ export const MultiSelect = forwardRef( } }, }), + // eslint-disable-next-line react-hooks/exhaustive-deps [resetToDefault, selectedValues, onValueChange], ); @@ -533,7 +540,7 @@ export const MultiSelect = forwardRef( >("desktop"); useEffect(() => { - if (typeof window === "undefined") return; + if (typeof window === "undefined") return undefined; const handleResize = () => { const width = window.innerWidth; if (width < 640) { @@ -686,7 +693,7 @@ export const MultiSelect = forwardRef( const getOptionByValue = useCallback( (value: string): MultiSelectOption | undefined => { const option = getAllOptions().find( - (option) => option.value === value, + (opt) => opt.value === value, ); if (!option && process.env.NODE_ENV === "development") { console.warn( @@ -1053,6 +1060,7 @@ export const MultiSelect = forwardRef(