This commit introduces the ability to duplicate products and recipes, including handling of associated files and unique naming conventions.
559 lines
15 KiB
TypeScript
559 lines
15 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 FormEvent,
|
|
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>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Button for DUPLICATE POST action.
|
|
* @param action ViewsType[number]
|
|
* @param modelRoute string
|
|
* @param model ActionableModel
|
|
* @param routes ConfigBackend["routes"]
|
|
* @param setOpen Dispatch<SetStateAction<boolean>>
|
|
* @returns Element
|
|
*/
|
|
function DuplicateAction({
|
|
action,
|
|
modelRoute,
|
|
model,
|
|
routes,
|
|
setOpen,
|
|
}: Readonly<{
|
|
action: ViewsType[number];
|
|
modelRoute: string;
|
|
model: ActionableModel;
|
|
routes: ConfigBackend["routes"];
|
|
setOpen: Dispatch<SetStateAction<boolean>>;
|
|
}>) {
|
|
const { post, processing } = useForm();
|
|
const actionLabelTrans = str(
|
|
trans(`uwil-backend::crud.actions.${action.label}`),
|
|
)
|
|
.ucFirst()
|
|
.value();
|
|
|
|
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
setOpen(false);
|
|
post(route(`${routes.name}${modelRoute}.${action.value}`, model.id));
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} className="w-full">
|
|
<Button
|
|
type="submit"
|
|
variant="ghost"
|
|
className="w-full"
|
|
disabled={processing}
|
|
>
|
|
<action.icon />
|
|
{actionLabelTrans}
|
|
</Button>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 isDuplicateAction = action.value === Views.DUPLICATION.value;
|
|
const isPostAction = postActionValues.has(action.value);
|
|
const showSeparator =
|
|
inDialog && isPostAction && action.value !== allowedActions[0].value;
|
|
|
|
let content: ReactNode;
|
|
if (isDuplicateAction) {
|
|
content = (
|
|
<DuplicateAction
|
|
action={action}
|
|
model={model}
|
|
modelRoute={modelRoute}
|
|
routes={routes}
|
|
setOpen={setOpen}
|
|
/>
|
|
);
|
|
} else if (isPostAction) {
|
|
content = (
|
|
<>
|
|
{showSeparator && <DropdownMenuSeparator />}
|
|
<PostAction
|
|
action={action}
|
|
model={model}
|
|
modelRoute={modelRoute}
|
|
setOpen={setOpen}
|
|
/>
|
|
</>
|
|
);
|
|
} else {
|
|
content = (
|
|
<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>
|
|
);
|
|
}
|