feat(duplicate): add product and recipe duplication functionality

This commit introduces the ability to duplicate products and recipes,
including handling of associated files and unique naming conventions.
This commit is contained in:
Jérémy Munsch 2026-11-05 17:46:14 +01:00
parent 531a5c626a
commit db5c95f9a1
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
16 changed files with 408 additions and 132 deletions

View file

@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Products\Duplicate;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Lib\PublicS3Disk;
use App\Models\Product;
use Domain\Models\ValueObjects\Entities\ProductId;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Ramsey\Uuid\Uuid;
use Uwil\Storage\Lib\ValueObjects\StorageFilePath;
use Webmozart\Assert\Assert;
/**
* phpcs:disable Generic.Files.LineLength.TooLong
*
* @implements \App\Contracts\Action<\App\Cqs\Actions\Products\Duplicate\DuplicateProductInput, \Domain\Models\ValueObjects\Entities\ProductId|\App\Cqs\Actions\Products\Duplicate\FailedToDuplicateProduct>
* 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);
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Products\Duplicate;
use App\Contracts\ActionInput;
use Domain\Models\ValueObjects\Entities\ProductId;
final readonly class DuplicateProductInput implements ActionInput
{
public function __construct(
public ProductId $productId,
) {
}
}

View file

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Products\Duplicate;
final readonly class FailedToDuplicateProduct
{
}

View file

@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Recipes\Duplicate;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Lib\PublicS3Disk;
use App\Models\Recipe;
use Domain\Models\ValueObjects\Entities\RecipeId;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Ramsey\Uuid\Uuid;
use Uwil\Storage\Lib\ValueObjects\StorageFilePath;
use Webmozart\Assert\Assert;
/**
* phpcs:disable Generic.Files.LineLength.TooLong
*
* @implements \App\Contracts\Action<\App\Cqs\Actions\Recipes\Duplicate\DuplicateRecipeInput, \Domain\Models\ValueObjects\Entities\RecipeId|\App\Cqs\Actions\Recipes\Duplicate\FailedToDuplicateRecipe>
* 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);
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Recipes\Duplicate;
use App\Contracts\ActionInput;
use Domain\Models\ValueObjects\Entities\RecipeId;
final readonly class DuplicateRecipeInput implements ActionInput
{
public function __construct(
public RecipeId $recipeId,
) {
}
}

View file

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Recipes\Duplicate;
final readonly class FailedToDuplicateRecipe
{
}

View file

@ -8,6 +8,8 @@ use App\Contracts\Executor;
use App\Cqs\Actions\Products\Create\CreateProduct;
use App\Cqs\Actions\Products\Delete\DeleteProduct;
use App\Cqs\Actions\Products\Delete\DeleteProductInput;
use App\Cqs\Actions\Products\Duplicate\DuplicateProduct;
use App\Cqs\Actions\Products\Duplicate\DuplicateProductInput;
use App\Cqs\Actions\Products\ForceDelete\ForceDeleteProduct;
use App\Cqs\Actions\Products\ForceDelete\ForceDeleteProductInput;
use App\Cqs\Actions\Products\Restore\RestoreProduct;
@ -140,11 +142,22 @@ class ProductController extends BackendController
};
}
public function duplicate(ProductDto $product): Response
public function duplicate(ProductDto $product, DuplicateProduct $duplicateProduct): RedirectResponse
{
$model = Product::withTrashed()->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

View file

@ -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

View file

@ -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',
];

View file

@ -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é',
];

View file

@ -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<ImageCropperProps>) {
const { routes } = usePage().props.configBackend as ConfigBackend;
const { errors } = usePage().props;
const aspect = 1;
const { isDesktop } = useDeviceType();
const imgRef = useRef<HTMLImageElement | null>(null);
const isDuplicating = route().current(`${routes.name}*.duplicate`);
// Zone of the crop in %.
const [cropZone, setCropZone] = useState<Crop>();
const [croppedImage, setCroppedImage] = useState<string>("");
@ -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

View file

@ -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<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[]
@ -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 && <DropdownMenuSeparator />}
<PostAction
let content: ReactNode;
if (isDuplicateAction) {
content = (
<DuplicateAction
action={action}
model={model}
modelRoute={modelRoute}
routes={routes}
setOpen={setOpen}
/>
</>
) : (
<GetAction
action={action}
model={model}
modelRoute={modelRoute}
routes={routes}
/>
);
);
} 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">

View file

@ -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 (

View file

@ -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 (

View file

@ -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)

View file

@ -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')));