- 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.
492 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|