diff --git a/app/Cqs/Actions/Products/Duplicate/DuplicateProduct.php b/app/Cqs/Actions/Products/Duplicate/DuplicateProduct.php new file mode 100644 index 00000000..16da0f8c --- /dev/null +++ b/app/Cqs/Actions/Products/Duplicate/DuplicateProduct.php @@ -0,0 +1,111 @@ + + * phpcs:enable + */ +final class DuplicateProduct implements Action +{ + public function __construct( + private readonly PublicS3Disk $s3Disk, + ) { + } + + /** + * @param \App\Cqs\Actions\Products\Duplicate\DuplicateProductInput $input + */ + public function __invoke(ActionInput $input): ProductId | FailedToDuplicateProduct + { + Assert::isInstanceOf($input, DuplicateProductInput::class); + + $original = Product::withTrashed()->findOrFail((string) $input->productId->value); + + $clone = $original->replicate(); + $clone->name = $this->generateUniqueName($original->name); + $clone->slug = Str::slug($clone->name); + + $duplicatedPicture = $this->duplicateFile($original->picture); + if ($duplicatedPicture !== null) { + $clone->picture = $duplicatedPicture; + } + $clone->pdf = $this->duplicateFile($original->pdf); + + if (! $clone->save()) { + return new FailedToDuplicateProduct(); + } + + return new ProductId(Uuid::fromString($clone->id)); + } + + private function generateUniqueName(string $originalName): string + { + $rootName = $originalName; + if (preg_match('/^(.*?)(?: \(Copie(?: (\d+))?\))?$/', $originalName, $matches)) { + $rootName = $matches[1]; + } + + $baseName = "{$rootName} (Copie)"; + if ( + ! Product::withTrashed() + ->where('name', $baseName) + ->orWhere('slug', Str::slug($baseName)) + ->exists() + ) { + return $baseName; + } + + $i = 2; + while ( + Product::withTrashed() + ->where('name', "{$rootName} (Copie {$i})") + ->orWhere('slug', Str::slug("{$rootName} (Copie {$i})")) + ->exists() + ) { + $i++; + } + + return "{$rootName} (Copie {$i})"; + } + + private function duplicateFile(?StorageFilePath $filePath): ?StorageFilePath + { + if ($filePath === null) { + return null; + } + + $sourcePath = $filePath->value; + $disk = Storage::disk($this->s3Disk->disk->value); + + if (! $disk->exists($sourcePath)) { + return null; + } + + $extension = pathinfo($sourcePath, PATHINFO_EXTENSION); + $dirname = pathinfo($sourcePath, PATHINFO_DIRNAME); + $newFilename = Str::uuid()->toString() . ($extension !== '' ? '.' . $extension : ''); + $destinationPath = ($dirname !== '.' && $dirname !== '') ? "{$dirname}/{$newFilename}" : $newFilename; + + if (! $disk->copy($sourcePath, $destinationPath)) { + return null; + } + + return new StorageFilePath($destinationPath); + } +} diff --git a/app/Cqs/Actions/Products/Duplicate/DuplicateProductInput.php b/app/Cqs/Actions/Products/Duplicate/DuplicateProductInput.php new file mode 100644 index 00000000..4bb42500 --- /dev/null +++ b/app/Cqs/Actions/Products/Duplicate/DuplicateProductInput.php @@ -0,0 +1,16 @@ + + * phpcs:enable + */ +final class DuplicateRecipe implements Action +{ + public function __construct( + private readonly PublicS3Disk $s3Disk, + ) { + } + + /** + * @param \App\Cqs\Actions\Recipes\Duplicate\DuplicateRecipeInput $input + */ + public function __invoke(ActionInput $input): RecipeId | FailedToDuplicateRecipe + { + Assert::isInstanceOf($input, DuplicateRecipeInput::class); + + $original = Recipe::withTrashed()->with('products')->findOrFail((string) $input->recipeId->value); + + $clone = $original->replicate(); + $clone->name = $this->generateUniqueName($original->name); + $clone->slug = Str::slug($clone->name); + + $duplicatedPicture = $this->duplicateFile($original->picture); + if ($duplicatedPicture !== null) { + $clone->picture = $duplicatedPicture; + } + + if (! $clone->save()) { + return new FailedToDuplicateRecipe(); + } + + $productIds = $original->products->pluck('id')->all(); + if (! empty($productIds)) { + $clone->products()->sync($productIds); + } + + return new RecipeId(Uuid::fromString($clone->id)); + } + + private function generateUniqueName(string $originalName): string + { + $rootName = $originalName; + if (preg_match('/^(.*?)(?: \(Copie(?: (\d+))?\))?$/', $originalName, $matches)) { + $rootName = $matches[1]; + } + + $baseName = "{$rootName} (Copie)"; + if ( + ! Recipe::withTrashed() + ->where('name', $baseName) + ->orWhere('slug', Str::slug($baseName)) + ->exists() + ) { + return $baseName; + } + + $i = 2; + while ( + Recipe::withTrashed() + ->where('name', "{$rootName} (Copie {$i})") + ->orWhere('slug', Str::slug("{$rootName} (Copie {$i})"))->exists() + ) { + $i++; + } + + return "{$rootName} (Copie {$i})"; + } + + private function duplicateFile(?StorageFilePath $filePath): ?StorageFilePath + { + if ($filePath === null) { + return null; + } + + $sourcePath = $filePath->value; + $disk = Storage::disk($this->s3Disk->disk->value); + + if (! $disk->exists($sourcePath)) { + return null; + } + + $extension = pathinfo($sourcePath, PATHINFO_EXTENSION); + $dirname = pathinfo($sourcePath, PATHINFO_DIRNAME); + $newFilename = Str::uuid()->toString() . ($extension !== '' ? '.' . $extension : ''); + $destinationPath = ($dirname !== '.' && $dirname !== '') ? "{$dirname}/{$newFilename}" : $newFilename; + + if (! $disk->copy($sourcePath, $destinationPath)) { + return null; + } + + return new StorageFilePath($destinationPath); + } +} diff --git a/app/Cqs/Actions/Recipes/Duplicate/DuplicateRecipeInput.php b/app/Cqs/Actions/Recipes/Duplicate/DuplicateRecipeInput.php new file mode 100644 index 00000000..96693c25 --- /dev/null +++ b/app/Cqs/Actions/Recipes/Duplicate/DuplicateRecipeInput.php @@ -0,0 +1,16 @@ +findOrFail($product->id); + $this->authorize('duplicate', $product); - return $this->create($model->replicate()); + $result = $this->executor->execute($duplicateProduct, new DuplicateProductInput($product->getId())); + + return match (true) { + $result instanceof ProductId => redirect()->route('back.products.edit', $result) + ->with('success', trans('back.has_been_duplicated', [ + 'model' => Str::ucfirst(trans_choice('models.classes.product', 1)), + ])), + default => redirect()->back() + ->with('error', trans('back.cannot_be_duplicated', [ + 'model' => Str::ucfirst(trans_choice('models.classes.product', 1)), + ])), + }; } public function restore(string $id, RestoreProduct $restoreProduct): RedirectResponse diff --git a/app/Http/Controllers/Back/RecipeController.php b/app/Http/Controllers/Back/RecipeController.php index 071c0a20..ae79da5d 100644 --- a/app/Http/Controllers/Back/RecipeController.php +++ b/app/Http/Controllers/Back/RecipeController.php @@ -9,6 +9,8 @@ use App\Contracts\QueryInputEmpty; use App\Cqs\Actions\Recipes\Create\CreateRecipe; use App\Cqs\Actions\Recipes\Delete\DeleteRecipe; use App\Cqs\Actions\Recipes\Delete\DeleteRecipeInput; +use App\Cqs\Actions\Recipes\Duplicate\DuplicateRecipe; +use App\Cqs\Actions\Recipes\Duplicate\DuplicateRecipeInput; use App\Cqs\Actions\Recipes\ForceDelete\ForceDeleteRecipe; use App\Cqs\Actions\Recipes\ForceDelete\ForceDeleteRecipeInput; use App\Cqs\Actions\Recipes\Restore\RestoreRecipe; @@ -156,13 +158,22 @@ class RecipeController extends BackendController }; } - public function duplicate(RecipeDto $recipe, GetProductsSummariesQuery $getProductsSummaries): Response + public function duplicate(RecipeDto $recipe, DuplicateRecipe $duplicateRecipe): RedirectResponse { $this->authorize('duplicate', $recipe); - $model = Recipe::withTrashed()->findOrFail($recipe->id); + $result = $this->executor->execute($duplicateRecipe, new DuplicateRecipeInput($recipe->getId())); - return $this->create($model->replicate(), $getProductsSummaries); + return match (true) { + $result instanceof RecipeId => redirect()->route('back.recipes.edit', $result) + ->with('success', trans('back.has_been_duplicated', [ + 'model' => Str::ucfirst(trans_choice('models.classes.recipe', 1)), + ])), + default => redirect()->back() + ->with('error', trans('back.cannot_be_duplicated', [ + 'model' => Str::ucfirst(trans_choice('models.classes.recipe', 1)), + ])), + }; } public function restore(string $id, RestoreRecipe $restoreRecipe): RedirectResponse diff --git a/lang/en/back.php b/lang/en/back.php index 7570e5eb..88fe2156 100644 --- a/lang/en/back.php +++ b/lang/en/back.php @@ -3,5 +3,6 @@ declare(strict_types=1); return [ - // "dashboard" => "Tableau de bord", + 'has_been_duplicated' => ':model has been duplicated', + 'cannot_be_duplicated' => ':model cannot be duplicated', ]; diff --git a/lang/fr/back.php b/lang/fr/back.php index 7570e5eb..94a7887d 100644 --- a/lang/fr/back.php +++ b/lang/fr/back.php @@ -3,5 +3,6 @@ declare(strict_types=1); return [ - // "dashboard" => "Tableau de bord", + 'has_been_duplicated' => ':model a été dupliqué', + 'cannot_be_duplicated' => ':model ne peut être dupliqué', ]; diff --git a/resources/ts/back/Components/Inputs/ImageCropper.tsx b/resources/ts/back/Components/Inputs/ImageCropper.tsx index 31eebdf4..e042ff49 100644 --- a/resources/ts/back/Components/Inputs/ImageCropper.tsx +++ b/resources/ts/back/Components/Inputs/ImageCropper.tsx @@ -13,7 +13,6 @@ import { ZoomOutIcon, } from "lucide-react"; import { - useEffect, useRef, useState, type Dispatch, @@ -25,7 +24,6 @@ import { type FileWithPath } from "react-dropzone"; import ReactCrop, { type Crop, type PixelCrop } from "react-image-crop"; import "react-image-crop/dist/ReactCrop.css"; -import type { ConfigBackend } from "@/back/types/global"; import { Button } from "@/components/ui/button"; import { Collapsible, @@ -87,18 +85,14 @@ export default function ImageCropper({ reset, setIsReset, isReset, - url, finalFileName, }: Readonly) { - const { routes } = usePage().props.configBackend as ConfigBackend; const { errors } = usePage().props; const aspect = 1; const { isDesktop } = useDeviceType(); const imgRef = useRef(null); - const isDuplicating = route().current(`${routes.name}*.duplicate`); - // Zone of the crop in %. const [cropZone, setCropZone] = useState(); const [croppedImage, setCroppedImage] = useState(""); @@ -231,34 +225,6 @@ export default function ImageCropper({ return canvas.toDataURL("image/png"); } - /** - * Generate base64 from an url. - * @param targetUrl string - * @param callback (base64: string | ArrayBuffer | null) => void - * @param errorCallback (error: string) => void - */ - function urlToBase64( - targetUrl: string, - - callback: (base64: string | ArrayBuffer | null) => void, - - errorCallback?: (error: string) => void, - ): void { - fetch(targetUrl) - .then((response) => response.blob()) - .then((blob) => { - const reader = new FileReader(); - - reader.onloadend = () => { - callback(reader.result); - }; - reader.readAsDataURL(blob); - }) - .catch((error: unknown) => { - if (errorCallback) errorCallback(String(error)); - }); - } - /** * Generate file from the base64. * @param dataUrl base64 string @@ -302,28 +268,6 @@ export default function ImageCropper({ setIsDragging(false); } - useEffect(() => { - if (!value || !isDuplicating) return; - const rawPath = typeof value === "object" ? value?.name : value; - if (!rawPath) return; - const pathFileBase = /^https?:\/\//i.test(rawPath) - ? rawPath - : `${url}/${rawPath.replace(/^\/+/, "")}`; - urlToBase64( - pathFileBase, - (base64) => { - if (typeof base64 === "string") { - const file = base64ToFile(base64); - setData(name, file); - } - }, - (err) => { - console.error(err); - }, - ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - /** * Crop the image with all options. * @return void diff --git a/resources/ts/back/Components/ModelActions.tsx b/resources/ts/back/Components/ModelActions.tsx index 97afc1e4..d0377248 100644 --- a/resources/ts/back/Components/ModelActions.tsx +++ b/resources/ts/back/Components/ModelActions.tsx @@ -4,6 +4,7 @@ import { MoreHorizontalIcon } from "lucide-react"; import { useState, type Dispatch, + type FormEvent, type ReactNode, type SetStateAction, type SubmitEvent, @@ -352,6 +353,56 @@ function PostAction({ ); } +/** + * Button for DUPLICATE POST action. + * @param action ViewsType[number] + * @param modelRoute string + * @param model ActionableModel + * @param routes ConfigBackend["routes"] + * @param setOpen Dispatch> + * @returns Element + */ +function DuplicateAction({ + action, + modelRoute, + model, + routes, + setOpen, +}: Readonly<{ + action: ViewsType[number]; + modelRoute: string; + model: ActionableModel; + routes: ConfigBackend["routes"]; + setOpen: Dispatch>; +}>) { + const { post, processing } = useForm(); + const actionLabelTrans = str( + trans(`uwil-backend::crud.actions.${action.label}`), + ) + .ucFirst() + .value(); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + setOpen(false); + post(route(`${routes.name}${modelRoute}.${action.value}`, model.id)); + }; + + return ( +
+ +
+ ); +} + /** * Get all action buttons. * @returns Element[] @@ -379,28 +430,44 @@ const AllBtnActions = ({ ]); 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; - const content = isPostAction ? ( - <> - {showSeparator && } - - - ) : ( - - ); + ); + } else if (isPostAction) { + content = ( + <> + {showSeparator && } + + + ); + } else { + content = ( + + ); + } return (
diff --git a/resources/ts/back/Pages/Products/Create.tsx b/resources/ts/back/Pages/Products/Create.tsx index 468d4daf..5cb31142 100644 --- a/resources/ts/back/Pages/Products/Create.tsx +++ b/resources/ts/back/Pages/Products/Create.tsx @@ -7,11 +7,7 @@ import AuthenticatedLayout from "@/back/Layouts/AuthenticatedLayout"; import { Actions, Methods } from "@/back/lib/enums"; import Form from "@/back/Pages/Products/Form"; import { SingleResource } from "@/back/types/global"; -import { - RawProductModel, - validateAsProductModel, - validateAsRawProductModel, -} from "@/back/types/products"; +import { RawProductModel, validateAsProductModel } from "@/back/types/products"; import usePermissions from "@/hooks/use-permissions"; import trans from "@/hooks/use-translations"; @@ -30,11 +26,7 @@ export default function Create({ ? data.data.length > 0 : Object.keys(data.data).length > 0); if (hasData) { - if (route().current(`back.products.duplicate`)) { - product = validateAsRawProductModel(data.data); - } else { - product = validateAsProductModel(data.data); - } + product = validateAsProductModel(data.data); } } catch (error) { console.warn("Zod validation failed, falling back to empty model.", error); @@ -42,18 +34,13 @@ export default function Create({ // Translations. const transBackToList = trans("uwil-backend::back.crud_back_to_list"); - const transTitle = trans( - route().current(`back.products.duplicate`) - ? "uwil-backend::crud.helpers.duplication_model" - : "uwil-backend::crud.helpers.creation_model", - { - params: { - model: trans(`models.classes.product`, { - choice: 1, - }), - }, + const transTitle = trans("uwil-backend::crud.helpers.creation_model", { + params: { + model: trans(`models.classes.product`, { + choice: 1, + }), }, - ); + }); const beforeNode = () => { return ( diff --git a/resources/ts/back/Pages/Recipes/Create.tsx b/resources/ts/back/Pages/Recipes/Create.tsx index 5c4a153e..813a46b1 100644 --- a/resources/ts/back/Pages/Recipes/Create.tsx +++ b/resources/ts/back/Pages/Recipes/Create.tsx @@ -5,11 +5,7 @@ import AuthenticatedLayout from "@/back/Layouts/AuthenticatedLayout"; import { Actions, Methods } from "@/back/lib/enums"; import Form from "@/back/Pages/Recipes/Form"; import { SingleResource } from "@/back/types/global"; -import { - RawRecipeModel, - validateAsRawRecipeModel, - validateAsRecipeModel, -} from "@/back/types/recipes"; +import { RawRecipeModel, validateAsRecipeModel } from "@/back/types/recipes"; import usePermissions from "@/hooks/use-permissions"; import trans, { UseTransOptions } from "@/hooks/use-translations"; import { ArrowLeftIcon } from "lucide-react"; @@ -29,29 +25,20 @@ export default function Create({ ? data.data.length > 0 : Object.keys(data.data).length > 0); if (hasData) { - if (route().current(`back.recipes.duplicate`)) { - recipe = validateAsRawRecipeModel(data.data); - } else { - recipe = validateAsRecipeModel(data.data); - } + recipe = validateAsRecipeModel(data.data); } } catch (error) { console.warn("Zod validation failed, falling back to empty model.", error); } const transBackToList = trans("uwil-backend::back.crud_back_to_list"); - const title = trans( - route().current(`back.recipes.duplicate`) - ? "uwil-backend::crud.helpers.duplication_model" - : "uwil-backend::crud.helpers.creation_model", - { - params: { - model: trans(`models.classes.recipe`, { - choice: 1, - }), - }, - } as UseTransOptions, - ); + const title = trans("uwil-backend::crud.helpers.creation_model", { + params: { + model: trans(`models.classes.recipe`, { + choice: 1, + }), + }, + } as UseTransOptions); const beforeNode = () => { return ( diff --git a/routes/back/web-back.php b/routes/back/web-back.php index 2e683f93..7a40e525 100644 --- a/routes/back/web-back.php +++ b/routes/back/web-back.php @@ -8,7 +8,7 @@ use Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests; use Illuminate\Support\Facades\Route; Route::get('recipes/trash', [RecipeController::class, 'index'])->name('recipes.trash'); -Route::get('recipes/{recipe}/duplicate', [RecipeController::class, 'duplicate'])->name('recipes.duplicate'); +Route::post('recipes/{recipe}/duplicate', [RecipeController::class, 'duplicate'])->name('recipes.duplicate'); Route::patch('recipes/{id}/restore', [RecipeController::class, 'restore'])->name('recipes.restore'); Route::delete('recipes/{id}/forceDestroy', [RecipeController::class, 'forceDestroy'])->name('recipes.forceDestroy'); Route::resource('recipes', RecipeController::class) @@ -16,7 +16,7 @@ Route::resource('recipes', RecipeController::class) ->middleware(['htmlsanitize:description,details,ingredients_list,preparation_steps,tips_and_advices', HandlePrecognitiveRequests::class]); Route::get('products/trash', [ProductController::class, 'index'])->name('products.trash'); -Route::get('products/{product}/duplicate', [ProductController::class, 'duplicate'])->name('products.duplicate'); +Route::post('products/{product}/duplicate', [ProductController::class, 'duplicate'])->name('products.duplicate'); Route::patch('products/{id}/restore', [ProductController::class, 'restore'])->name('products.restore'); Route::delete('products/{id}/forceDestroy', [ProductController::class, 'forceDestroy'])->name('products.forceDestroy'); Route::resource('products', ProductController::class) diff --git a/routes/breadcrumbs/back.php b/routes/breadcrumbs/back.php index 6ee456d0..8ba78820 100644 --- a/routes/breadcrumbs/back.php +++ b/routes/breadcrumbs/back.php @@ -38,11 +38,6 @@ Breadcrumbs::for('back.products.edit', function (Generator $trail) { ); }); -Breadcrumbs::for('back.products.duplicate', function (Generator $trail) { - $trail->parent('back.products.index'); - $trail->push(Str::ucfirst(trans('uwil-backend::crud.actions.duplicate'))); -}); - Breadcrumbs::for('back.products.trash', function (Generator $trail) { $trail->parent('back.products.index'); $trail->push(Str::ucfirst(trans('uwil-backend::back.archives'))); @@ -78,11 +73,6 @@ Breadcrumbs::for('back.recipes.edit', function (Generator $trail) { ); }); -Breadcrumbs::for('back.recipes.duplicate', function (Generator $trail) { - $trail->parent('back.recipes.index'); - $trail->push(Str::ucfirst(trans('uwil-backend::crud.actions.duplicate'))); -}); - Breadcrumbs::for('back.recipes.trash', function (Generator $trail) { $trail->parent('back.recipes.index'); $trail->push(Str::ucfirst(trans('uwil-backend::back.archives')));