ldc-groupe/resources/ts/back/Components/Inputs/ModelSelect.tsx
2025-12-15 15:10:45 +01:00

377 lines
12 KiB
TypeScript

// * Packages
import { usePage } from "@inertiajs/react";
import { useEffect, useState } from "react";
import axios from "axios";
import { CheckIcon, ChevronLeftIcon, ChevronRightIcon, ChevronsUpDownIcon, LoaderCircleIcon, SearchIcon, XIcon } from "lucide-react";
import { route } from "ziggy-js";
// * Components shadcn
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
// * Other
import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip";
import InputError from "@/back/Components/InputError";
import {
AppEnv,
DynamicPageModel,
Model,
Paginator,
TranslationField,
} from "@/back/types/global";
import { cn } from "@/lib/utils";
import useTrans from "@/hooks/use-translations";
import str from "@/hooks/use-string";
type ModelSelectProps = {
name: string;
label: string;
targetModel?: string|null;
placeholder?: string;
field?: string;
exclude?: number|null;
selected?: number|null;
required?: boolean;
disabled?: boolean;
parentErrors?: Record<string, string> | null,
setData: (name: string, value: number | null) => void;
onSelectValue?: (value: Model | null) => void;
};
export default function ModelSelect({
name,
label,
targetModel = null,
placeholder = "ex: Magnifique libellé",
field,
exclude = null,
selected = null,
required = true,
disabled = false,
parentErrors = null,
setData,
onSelectValue,
}: ModelSelectProps) {
const { locale } = usePage().props.appEnv as AppEnv;
const { errors } = usePage().props;
const localErrors = parentErrors ?? errors;
// List of options.
const [options, setOptions] = useState<Model[]>([]);
// Field used to identify option.
// Custom by field parameter or predefined values (title, name or label).
const [dynamicField, setDynamicField] = useState<string|null>(field ?? null);
// Model key selected.
const initial = selected === 0 || selected === null || selected === undefined ? null : Number(selected);
const [selectedId, setSelectedId] = useState<number|null>(initial);
// String research in models collection.
const [search, setSearch] = useState<string>("");
// Field value of the selected model.
const [selectedValue, setSelectedValue] = useState<string|null>(null);
// Component states.
const [open, setOpen] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
// Pagination.
const [page, setPage] = useState<number>(1);
const [pageSize] = useState<number>(5);
const [lastPage, setLastPage] = useState<number>(1);
/**
* Get the identification field of specific option.
* @param option Model
* @return string
*/
const getModelField = (option: Model): string => {
const value = option[dynamicField as keyof Model] as unknown as TranslationField | string;
if (typeof value === "object") return String(value[locale] ?? "");
return String(value ?? "");
};
/**
* Updates when selecting new value.
* @param id number
* @return void
*/
const handleSelect = (id: number) => {
const selectedModel = options.find((model) => model.id === id);
setSelectedValue(selectedModel ? getModelField(selectedModel) : null);
setSelectedId(id);
setData(name, id);
onSelectValue?.(selectedModel ?? null);
setOpen(false);
};
/**
* Updates when clear the selected value.
* @return void
*/
const handleClear = () => {
setSelectedValue(null);
setSelectedId(null);
setData(name, null);
onSelectValue?.(null);
};
/**
* Reset the search.
* @return void
*/
const handleClearSearch = () => {
setSearch("");
};
/**
* Set call ajax.
* @param pageNumber number
* @return
*/
const fetchOptions = async (pageNumber: number) => {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || "";
const response = await axios.post(
route("back.model.list"),
{
model: targetModel,
selectedId,
search,
field: dynamicField,
page: pageNumber,
pageSize,
exclude,
},
{
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"X-Requested-With": "XMLHttpRequest",
"X-Inertia": "false",
"X-CSRF-TOKEN": csrfToken,
},
}
);
return response.data;
};
/**
* Get the localized value.
* @param item DynamicPageModel
* @param field string
* @param locale string
* @return string
*/
function extractLocalized(
item: DynamicPageModel,
field: string,
locale: string
): string {
const raw = item[field as keyof DynamicPageModel];
if (typeof raw === "object" && raw !== null) {
return (raw as TranslationField<string>)[locale] ?? "";
}
return String(raw ?? "");
}
/**
* Get new options list with his target field.
* @param pageNumber number
* @return void
*/
const getData = async (pageNumber = 1) => {
setLoading(true);
try {
const data = await fetchOptions(pageNumber) as {
items: Paginator,
field: string,
model: DynamicPageModel,
};
if (data.model) {
const value = extractLocalized(data.model, data.field, locale);
setSelectedValue(value);
onSelectValue?.(data.model);
setSelectedId(data.model['id']);
}
setDynamicField(data.field);
setOptions(data.items.data ?? []);
setPage(data.items.current_page);
setLastPage(data.items.last_page ?? 1);
} catch (err) {
console.error(err);
setOptions([]);
setLastPage(1);
} finally {
setLoading(false);
}
};
/**
* Get specific page.
* @param pageNumber number
* @return void
*/
const goToPage = async (pageNumber: number) => {
if (pageNumber < 1 || pageNumber > lastPage) return;
getData(pageNumber);
};
// Fetch with debounce.
useEffect(() => {
setDynamicField(field ?? null);
// Reset options list if empty search or null
if (targetModel && search.length === 0) {
getData();
return;
}
// Nothing to do if search is below 3 caracters.
if (search.length < 3) return;
// Get updated options list with search.
const handler = setTimeout(() => getData(), 300);
return () => clearTimeout(handler);
}, [search, targetModel, field]);
return (
<div className="flex flex-col w-full gap-1.5">
<Label htmlFor={name}>
{label} {required && "*"}
</Label>
<div className="flex items-center gap-1.5 w-full">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled || loading}
className={cn(
"w-full h-auto min-h-10 justify-between whitespace-normal text-start",
{ "text-muted-foreground": !selectedId },
{ "border-destructive/50": localErrors[name] },
)}
>
{selectedValue ?? placeholder}
<ChevronsUpDownIcon className="opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<div className="relative">
<div className="absolute top-1/2 left-2 transform -translate-y-1/2 text-muted-foreground">
{loading ? <LoaderCircleIcon className="animate-spin w-4 h-4" /> : <SearchIcon className="w-4 h-4" />}
</div>
<Input
type="text"
placeholder={placeholder}
value={search}
disabled={disabled}
onChange={(e) => setSearch(e.target.value)}
className="w-[calc(100%-2.5rem-40px)] border-0 mx-9"
/>
<BtnOptionnalLinkWithTooltip
content={<XIcon />}
tooltipContent={<p>{str(useTrans("crud.actions.delete")).ucFirst().value()}</p>}
type="button"
variant="ghost"
size="icon"
className="absolute top-1/2 right-0 transform -translate-y-1/2 text-muted-foreground"
onClick={handleClearSearch}
disabled={search.length < 1 || disabled || loading}
/>
</div>
<div
className="overflow-auto border-t flex flex-col justify-between p-1 gap-1"
style={{ minHeight: options.length > 0 && lastPage !== 1 ? '265px' : '225px' }}
>
{!loading && options.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-5">{useTrans("back.table_no_results")}</p>
)}
{loading ? (
<>
{[...Array(pageSize)].map(() => (
<Skeleton className="h-10" />
))}
</>
) : (
<div className="flex flex-col justify-between gap-1">
{options.map((option) => (
<Button
key={option.id}
className="w-full h-auto min-h-10 justify-between whitespace-normal text-start"
variant="ghost"
disabled={loading}
onClick={() => handleSelect(option.id)}
>
<span>{getModelField(option)}</span>
{selectedId === option.id && <CheckIcon />}
</Button>
))}
</div>
)}
{/* Pagination boutons */}
{options.length > 0 && lastPage !== 1 && (
<div className="flex flex-col xl:flex-row justify-center items-center gap-1.5">
<BtnOptionnalLinkWithTooltip
content={<>
<ChevronLeftIcon />
{useTrans('pagination.previous')}
</>}
tooltipContent={<p>{useTrans('pagination.specific_page', { params: { id: String(page - 1) } })}</p>}
type="button"
variant="outline"
size="sm"
onClick={() => goToPage(page - 1)}
disabled={page <= 1 || loading}
/>
{loading ? (
<Skeleton className="w-10 h-5" />
) : (
<div className="w-10">
<p className="text-sm text-center text-muted-foreground">{page}/{lastPage}</p>
</div>
)}
<BtnOptionnalLinkWithTooltip
content={<>
{useTrans('pagination.next')}
<ChevronRightIcon />
</>}
tooltipContent={<p>{useTrans('pagination.specific_page', { params: { id: String(page + 1) } })}</p>}
type="button"
variant="outline"
size="sm"
onClick={() => goToPage(page + 1)}
disabled={page >= lastPage || loading}
/>
</div>
)}
</div>
</PopoverContent>
</Popover>
{!required && selectedId !== null && (
<BtnOptionnalLinkWithTooltip
content={<XIcon />}
tooltipContent={<p>{str(useTrans("crud.actions.delete")).ucFirst().value()}</p>}
type="button"
variant="destructive"
size="icon"
onClick={handleClear}
disabled={disabled || loading}
/>
)}
</div>
<InputError message={localErrors[name]} />
</div>
);
}