ldc-ovoteam/resources/ts/back/Components/ModelActions.tsx
Jérémy Munsch cd2d8ed397
refactor(i18n): replace GetTranslation with trans function
- Updated translation utility to improve consistency and reduce redundancy.
- Removed deprecated useTrans hook and related code.
- Introduced a new Zustand store for managing translations and locale settings.
- Cleaned up unused hooks and files related to localization.
2026-10-19 17:43:33 +02:00

492 lines
13 KiB
TypeScript

import type { VisitOptions } from "@inertiajs/core";
import { Link, useForm, usePage } from "@inertiajs/react";
import { MoreHorizontalIcon } from "lucide-react";
import {
useState,
type Dispatch,
type ReactNode,
type SetStateAction,
type SubmitEvent,
} from "react";
import Views from "@/back/lib/actions";
import type {
ConfigBackend,
DefaultModel,
Views as ViewsType,
} from "@/back/types/global";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
type ToastActionElement,
type ToastProps,
} from "@/components/ui/toast";
import str from "@/hooks/use-string";
import { useToast } from "@/hooks/use-toast";
import trans from "@/hooks/use-translations";
import { formatDate } from "@/lib/utils";
interface Translations {
successfulArchivation: string;
successfulArchivationDetails: string;
canceledArchivation: string;
canceledArchivationDetails: string;
successfulRestoration: string;
successfulRestorationDetails: string;
canceledRestoration: string;
canceledRestorationDetails: string;
successfulDeletion: string;
successfulDeletionDetails: string;
canceledDeletion: string;
canceledDeletionDetails: string;
}
type ToasterToast = ToastProps & {
id: string;
title?: ReactNode;
description?: ReactNode;
action?: ToastActionElement;
};
type ActionableModel = Omit<DefaultModel, "id"> & {
id: number | string;
slug?: unknown;
};
/**
* Button for GET action.
* @param action ViewsType[number]
* @param modelRoute string
* @param model ActionableModel
* @returns Element
*/
function GetAction({
action,
modelRoute,
model,
routes,
}: Readonly<{
action: ViewsType[number];
modelRoute: string;
model: ActionableModel;
routes: ConfigBackend["routes"];
}>) {
const actionLabelTrans = str(
trans(`uwil-backend::crud.actions.${action.label}`),
)
.ucFirst()
.value();
const isFrontView =
action.value === "show" && ["products", "recipes"].includes(modelRoute);
if (isFrontView) {
const routeName = `front.${modelRoute}.show`;
const slug = typeof model.slug === "string" ? model.slug : model.id;
return (
<Button asChild variant="ghost" className="w-full">
<a
href={route(routeName, slug)}
className="w-full"
target="_blank"
rel="noopener noreferrer"
>
<action.icon />
{actionLabelTrans}
</a>
</Button>
);
}
return (
<Button asChild variant="ghost" className="w-full">
<Link
href={route(`${routes.name}${modelRoute}.${action.value}`, model.id)}
className="w-full"
>
<action.icon />
{actionLabelTrans}
</Link>
</Button>
);
}
/**
* Action call on model.
* @param event SubmitEvent
* @param action ViewsType[number]
* @param modelRoute string
* @param model ActionableModel
* @param setOpen (value: boolean) => void
* @param translations Translations
* @param toast
*/
const actionOnModel = (
event: SubmitEvent,
action: ViewsType[number],
nameRoute: string,
modelRoute: string,
model: ActionableModel,
setOpen: (value: boolean) => void,
translations: Translations,
toast: ({ ...props }: Omit<ToasterToast, "id">) => {
id: string;
dismiss: () => void;
update: (propsChild: ToasterToast) => void;
},
destroy: (url: string, options?: VisitOptions) => void,
patch: (url: string, options?: VisitOptions) => void,
) => {
event.preventDefault();
const routeAction = route(
`${nameRoute}${modelRoute}.${action.value}`,
model.id,
);
// All post actions mapping.
const actionMap: Record<
ViewsType[number]["label"],
{
method: typeof destroy | typeof patch;
success: { title: string; description: string };
error: { title: string; description: string };
}
> = {
[Views.ARCHIVATION.label]: {
method: destroy,
success: {
title: translations.successfulArchivation,
description: translations.successfulArchivationDetails,
},
error: {
title: translations.canceledArchivation,
description: translations.canceledArchivationDetails,
},
},
[Views.RESTORATION.label]: {
method: patch,
success: {
title: translations.successfulRestoration,
description: translations.successfulRestorationDetails,
},
error: {
title: translations.canceledRestoration,
description: translations.canceledRestorationDetails,
},
},
[Views.DELETION.label || Views.FORCEDELETION.label]: {
method: destroy,
success: {
title: translations.successfulDeletion,
description: translations.successfulDeletionDetails,
},
error: {
title: translations.canceledDeletion,
description: translations.canceledDeletionDetails,
},
},
};
// Check if route exist.
const config = actionMap[action.label];
// Close the dropdown.
setOpen(false);
// Execute the call.
config.method(routeAction, {
preserveScroll: true,
onSuccess: () => toast(config.success),
onError: () => toast({ variant: "destructive", ...config.error }),
});
};
/**
* Button for POST action.
* @param action ViewsType[number]
* @param modelRoute string
* @param model ActionableModel
* @param setOpen (value: boolean) => void
* @returns Element
*/
function PostAction({
action,
modelRoute,
model,
setOpen,
}: Readonly<{
action: ViewsType[number];
modelRoute: string;
model: ActionableModel;
setOpen: (value: boolean) => void;
}>) {
const { delete: destroy, patch } = useForm();
const { name: nameRoute } = (usePage().props.configBackend as ConfigBackend)
.routes;
const isRestoreAction = action.label === Views.RESTORATION.label;
const isArchiveAction = action.label === Views.ARCHIVATION.label;
const btnVariantType = isRestoreAction ? "ghost" : "destructive";
let translationKeyBtnConfirm: string;
const { toast } = useToast();
if (isRestoreAction) {
translationKeyBtnConfirm = "uwil-backend::back.yes_restore";
} else if (isArchiveAction) {
translationKeyBtnConfirm = "uwil-backend::back.yes_archive";
} else {
translationKeyBtnConfirm = "uwil-backend::back.yes_delete";
}
const btnConfirm = trans(translationKeyBtnConfirm);
let translationKeyBtnDesc = "uwil-backend::back.lost_data";
if (isRestoreAction) {
translationKeyBtnDesc = "uwil-backend::back.restore_data";
} else if (isArchiveAction) {
translationKeyBtnDesc = "uwil-backend::back.trash_data";
}
const btnDesc = trans(translationKeyBtnDesc);
// Translations for actionOnModel.
const translations: Translations = {
successfulArchivation: trans(
"uwil-backend::crud.messages.successful_archivation",
),
successfulArchivationDetails: trans(
"uwil-backend::crud.messages.successful_archivation_details",
),
canceledArchivation: trans(
"uwil-backend::crud.messages.canceled_archivation",
),
canceledArchivationDetails: trans(
"uwil-backend::crud.messages.canceled_archivation_details",
),
successfulRestoration: trans(
"uwil-backend::crud.messages.successful_restoration",
),
successfulRestorationDetails: trans(
"uwil-backend::crud.messages.successful_restoration_details",
),
canceledRestoration: trans(
"uwil-backend::crud.messages.canceled_restoration",
),
canceledRestorationDetails: trans(
"uwil-backend::crud.messages.canceled_restoration_details",
),
successfulDeletion: trans(
"uwil-backend::crud.messages.successful_deletion",
),
successfulDeletionDetails: trans(
"uwil-backend::crud.messages.successful_deletion_details",
),
canceledDeletion: trans("uwil-backend::crud.messages.canceled_deletion"),
canceledDeletionDetails: trans(
"uwil-backend::crud.messages.canceled_deletion_details",
),
};
return (
<Dialog>
<DialogTrigger asChild>
<Button variant={btnVariantType} className="w-full">
<action.icon />
{str(trans(`uwil-backend::crud.actions.${action.label}`))
.ucFirst()
.value()}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{trans("uwil-backend::back.are_you_sure")}</DialogTitle>
<DialogDescription>{btnDesc}</DialogDescription>
</DialogHeader>
<DialogFooter className="items-center sm:justify-start">
<DialogClose asChild>
<Button variant="secondary">
{trans("uwil-backend::back.no_cancel")}
</Button>
</DialogClose>
<form
onSubmit={(event) => {
actionOnModel(
event,
action,
nameRoute,
modelRoute,
model,
setOpen,
translations,
toast,
destroy,
patch,
);
}}
className="w-fit mb-2 sm:mb-0"
>
<Button type="submit" variant={btnVariantType}>
{btnConfirm}
</Button>
</form>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/**
* Get all action buttons.
* @returns Element[]
*/
const AllBtnActions = ({
allowedActions,
model,
modelRoute,
routes,
inDialog,
setOpen,
}: {
allowedActions: ViewsType;
model: ActionableModel;
modelRoute: string;
routes: ConfigBackend["routes"];
inDialog: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
}) => {
const postActionValues = new Set([
Views.ARCHIVATION.value,
Views.RESTORATION.value,
Views.DELETION.value,
Views.FORCEDELETION.value,
]);
return allowedActions.map((action) => {
const isPostAction = postActionValues.has(action.value);
const showSeparator =
inDialog && isPostAction && action.value !== allowedActions[0].value;
const content = isPostAction ? (
<>
{showSeparator && <DropdownMenuSeparator />}
<PostAction
action={action}
model={model}
modelRoute={modelRoute}
setOpen={setOpen}
/>
</>
) : (
<GetAction
action={action}
model={model}
modelRoute={modelRoute}
routes={routes}
/>
);
return (
<div key={`${action.value}-${action.label}`} className="w-full">
{content}
</div>
);
});
};
interface ModelActionsProps {
actions?: ViewsType;
modelRoute: string;
model: ActionableModel;
modelUpdatedAt?: string;
inDialog?: boolean;
message?: string;
}
export default function ModelActions({
actions = [],
modelRoute,
model,
modelUpdatedAt = "",
inDialog = false,
message = "uwil-backend::back.model_action_updated_at",
}: Readonly<ModelActionsProps>) {
const { routes } = usePage().props.configBackend as ConfigBackend;
const [open, setOpen] = useState<boolean>(false);
const messageTrans = trans(message);
const formattedDate = formatDate(modelUpdatedAt);
const allowedActions = actions.filter(
(action) => model.policies[action.policy as keyof typeof model.policies],
);
return (
<div
className={`flex justify-between items-center ${
!inDialog ? "flex-col xl:flex-row mb-4" : "flex-col"
}`}
>
{!inDialog && modelUpdatedAt.length > 0 && (
<div className="flex flex-col sm:flex-row justify-start items-center">
<p className="text-center xl:text-left">{messageTrans}</p>
<Badge variant="secondary" className="text-center ms-1 mt-1">
{formattedDate}
</Badge>
</div>
)}
{Boolean(allowedActions.length) && (
<div
className={`flex justify-between items-center ${
!inDialog
? "flex-col sm:flex-row mt-4 xl:mt-0 gap-1.5"
: "flex-col w-full"
}`}
>
{inDialog ? (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="icon">
<MoreHorizontalIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start">
<AllBtnActions
allowedActions={allowedActions}
model={model}
modelRoute={modelRoute}
routes={routes}
setOpen={setOpen}
inDialog={inDialog}
/>
</DropdownMenuContent>
</DropdownMenu>
) : (
<AllBtnActions
allowedActions={allowedActions}
model={model}
modelRoute={modelRoute}
routes={routes}
setOpen={setOpen}
inDialog={inDialog}
/>
)}
</div>
)}
</div>
);
}