414 lines
13 KiB
TypeScript
414 lines
13 KiB
TypeScript
// * Packages
|
|
import { usePage } from "@inertiajs/react";
|
|
import { useEffect, useRef, 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>("");
|
|
|
|
// Search identification.
|
|
const requestIdRef = useRef(0);
|
|
|
|
// 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);
|
|
|
|
// Hack to block closing popover when changing pages.
|
|
const [blockingClosePopover, setBlockingClosePopover] = useState<boolean>(false);
|
|
|
|
/**
|
|
* 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) => {
|
|
// Increment search id.
|
|
const requestId = ++requestIdRef.current;
|
|
setLoading(true);
|
|
try {
|
|
const data = await fetchOptions(pageNumber) as {
|
|
items: Paginator,
|
|
field: string,
|
|
model: DynamicPageModel,
|
|
};
|
|
// Ignore obsolete answers.
|
|
if (requestId !== requestIdRef.current) return;
|
|
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) {
|
|
// Ignore obsolete errors.
|
|
if (requestId !== requestIdRef.current) return;
|
|
console.error(err);
|
|
setOptions([]);
|
|
setLastPage(1);
|
|
} finally {
|
|
// Only the last call can stop the loading.
|
|
if (requestId === requestIdRef.current) {
|
|
setLoading(false);
|
|
setTimeout(() => setBlockingClosePopover(false), 100)
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get specific page.
|
|
* @param pageNumber number
|
|
* @return void
|
|
*/
|
|
const goToPage = async (pageNumber: number) => {
|
|
if (pageNumber < 1 || pageNumber > lastPage) return;
|
|
setBlockingClosePopover(true);
|
|
await getData(pageNumber);
|
|
};
|
|
|
|
// Fetch with debounce.
|
|
useEffect(() => {
|
|
if (disabled) {
|
|
return;
|
|
}
|
|
|
|
// 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]);
|
|
|
|
// Update when selected is updated.
|
|
useEffect(() => {
|
|
if (selected !== null) {
|
|
handleSelect(selected);
|
|
} else {
|
|
setSelectedId(null);
|
|
setSelectedValue(null);
|
|
}
|
|
}, [selected]);
|
|
|
|
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}
|
|
{loading ? <LoaderCircleIcon className="animate-spin opacity-50 w-4 h-4" /> : <ChevronsUpDownIcon className="opacity-50 w-4 h-4" />}
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="w-[var(--radix-popover-trigger-width)] p-0"
|
|
onInteractOutside={(e) => {
|
|
if (blockingClosePopover) {
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
}}
|
|
>
|
|
<div className="relative">
|
|
<div className="absolute top-1/2 left-2 transform -translate-y-1/2 text-muted-foreground">
|
|
<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>
|
|
);
|
|
}
|