ldc-ovoteam/resources/ts/back/Components/Inputs/CompleteInputFile.tsx
Jérémy Munsch ca9c0fbc4e
feat(policy): allow ProductDto and RecipeDto in policies
Updated ProductPolicy and RecipePolicy to accept ProductDto in addition to Product. This change enhances the flexibility of the policies by allowing DTOs to be used for authorization checks.

refactor(provider): simplify service provider bindings

Refactored AppServiceProvider to simplify the binding of ProductDirectory and RecipeDirectory. Removed unnecessary dependencies and improved clarity.

feat(route): update route bindings for front-end

Changed route bindings for products and recipes to use frontProduct and frontRecipe. This improves clarity and consistency in route handling.

fix(breadcrumbs): update breadcrumb checks for DTOs

Updated breadcrumb generation to check for ProductDto and RecipeDto instead of their model counterparts. This ensures breadcrumbs function correctly with the new DTO structure.

refactor(types): remove FilePath type and update schemas

Removed the FilePath type definition and updated related schemas to use string directly. This simplifies type handling for file paths.

style(entities): standardize naming conventions

Updated naming conventions in Product and Recipe entities to use snake_case for consistency across the codebase.

feat(frontend): update product and recipe pages for new DTO structure

Modified front-end components to accommodate changes in the product and recipe data structures, ensuring compatibility with the new DTOs.
2026-11-05 16:49:42 +01:00

411 lines
13 KiB
TypeScript

import { usePage } from "@inertiajs/react";
import { FileCheck, RefreshCwIcon, Trash, UploadIcon } from "lucide-react";
import { type ReactNode, useCallback, useEffect, useState } from "react";
import { ErrorCode, type FileWithPath, useDropzone } from "react-dropzone";
import "react-image-crop/dist/ReactCrop.css";
import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip";
import InputError from "@/back/Components/InputError";
import ImageCropper from "@/back/Components/Inputs/ImageCropper";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import str from "@/hooks/use-string";
import { useToast } from "@/hooks/use-toast";
import trans from "@/hooks/use-translations";
import { cn } from "@/lib/utils";
type FileWithPreview = FileWithPath & {
preview: string;
};
interface FileValidation {
validationRules?: {
width: string | null;
height: string | null;
} | null;
maxFileSize: string | null;
allowedFormats: string | null;
inputType?: "image" | "file";
}
type CompleteInputFileProps = FileValidation & {
name: string;
value?: string | File | null;
/** Original data is the data directly from the model, not in the form state */
originalData?: string | undefined;
/** Add custom event when refresh to the originalData */
onRefreshFile?: () => void;
label?: ReactNode | string;
required?: boolean;
fileName?: string;
hasRefreshBtn?: boolean;
parentErrors?: Record<string, string> | null;
setData: (name: string, value: File | string | null) => void;
reset: (...fields: string[]) => void;
hasFile?: (hasFile: boolean) => void;
};
export default function CompleteInputFile(props: CompleteInputFileProps) {
const {
value,
setData,
originalData,
name,
onRefreshFile,
label = "defaultLabel",
required = false,
maxFileSize,
validationRules,
allowedFormats,
reset,
inputType = "image",
fileName = "image",
hasRefreshBtn = true,
hasFile,
parentErrors,
} = props;
const { toast } = useToast();
const { errors } = usePage().props;
const { url } = usePage().props.appEnv as AppEnv;
const [isReset, setIsReset] = useState<boolean>(false);
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
const finalFileName = `${fileName}.png`;
const isRefreshDisabled = value != null && typeof value === "string";
const localErrors = { ...errors, ...parentErrors };
const localValidationRules = {
width: validationRules?.width
? Number.parseInt(validationRules.width, 10)
: 250,
height: validationRules?.height
? Number.parseInt(validationRules.height, 10)
: 250,
};
const localMaxFileSize = maxFileSize
? Number.parseInt(maxFileSize, 10) * 1024
: 50000;
/**
* Get the fileName from the path given by the database value
* @param path string|null
* @return string|null
*/
const getFileNameFromPath = (path?: string | null): string | null => {
if (!path || typeof path !== "string") return null;
const cleanPath = path.replace(/\/+$/, "");
const segments = cleanPath.split("/");
const last = segments[segments.length - 1] || "";
return last || null;
};
const transValueIntoFile = (localData: string | undefined | null) => {
if (localData) {
const currentFileName = getFileNameFromPath(localData);
const preview = /^https?:\/\//i.test(localData)
? localData
: `${url}/${localData.replace(/^\/+/, "")}`;
return {
preview,
name: currentFileName ?? "",
} as FileWithPreview;
}
return null;
};
const [selectedFile, setSelectedFile] = useState<FileWithPreview | null>(
() => {
// When there is an image selected but not stored yet.
if (value instanceof File) {
return Object.assign(value, {
preview: URL.createObjectURL(value),
});
}
// When there is already an image stored.
return transValueIntoFile(value);
},
);
// Fonction utilitaire pour formater la taille de fichier
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
const currentSize = sizes[i];
const currentBytes = parseFloat((bytes / k ** i).toFixed(2));
return `${String(currentBytes)} ${currentSize}`;
};
// Translations
const transSelectImgZone = trans(
inputType === "file"
? "uwil-backend::back.image_cropper.select_file_zone"
: "uwil-backend::back.image_cropper.select_img_zone",
);
const transValidationAllFormats = str(
trans("uwil-backend::back.image_cropper.validation.all_formats"),
)
.ucFirst()
.value();
const transCropperFileUploaded = trans(
"uwil-backend::back.image_cropper.state_file_uploaded",
);
const transCancel = trans("uwil-backend::back.cancel");
const transRulesWeight = trans(
"uwil-backend::back.image_cropper.validation.rules_weight",
{
params: {
size: formatFileSize(localMaxFileSize),
},
},
);
const transRefresh = trans(
"uwil-backend::back.image_cropper.refresh_original",
);
const getTransValidationAllowedFormats = (targetExtensions: string) => {
return str(
trans("uwil-backend::back.image_cropper.validation.allowed_formats", {
params: {
formats: targetExtensions,
},
}),
)
.ucFirst()
.value();
};
function buildDropzoneAccept(allowedFormats: string | null): {
accept: Record<string, string[]>;
extensions: string;
} {
if (!allowedFormats) return { accept: {}, extensions: "" };
const currentExtensions = allowedFormats.split(",");
const mimeMap: Record<string, string> = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
gif: "image/gif",
pdf: "application/pdf",
svg: "image/svg+xml",
webp: "image/webp",
};
const result: Record<string, Set<string>> = {};
currentExtensions.forEach((ext) => {
const cleanExt = ext.trim().toLowerCase();
const mime = mimeMap[cleanExt];
if (mime) {
result[mime] ??= new Set();
result[mime].add(`.${cleanExt}`);
}
});
// Convert Set to Array for React Dropzone
const accept: Record<string, string[]> = {};
Object.entries(result).forEach(([mime, extSet]) => {
accept[mime] = Array.from(extSet);
});
return { accept, extensions: allowedFormats };
}
const { accept: initialAccept, extensions: initialExtensions } =
buildDropzoneAccept(allowedFormats);
const [accept] = useState<Record<string, string[]>>(initialAccept);
const [extensions] = useState<string>(initialExtensions);
useEffect(() => {
hasFile?.(!!selectedFile);
}, [selectedFile, hasFile]);
// This error message for the useDropZone can not be used directly in the hook because or react hook error,
// they have to be used as they actually are.
const dropErrorMessages = {
noFile: str(trans("uwil-backend::back.image_cropper.toast.no_file"))
.ucFirst()
.value(),
tooLarge: str(
trans("uwil-backend::back.image_cropper.toast.too_big", {
params: {
maxSize: formatFileSize(localMaxFileSize),
},
}),
)
.ucFirst()
.value(),
forbidden: str(
trans("uwil-backend::back.image_cropper.toast.forbidden_file"),
)
.ucFirst()
.value(),
tooMany: str(trans("uwil-backend::back.image_cropper.toast.too_many"))
.ucFirst()
.value(),
default: str(trans("uwil-backend::back.image_cropper.toast.defaultError"))
.ucFirst()
.value(),
};
const onDrop = useCallback(
(acceptedFiles: FileWithPath[]) => {
const file = acceptedFiles[0] as FileWithPath | null;
if (!file) {
toast({
title: "Oups !",
description: dropErrorMessages.noFile,
});
return;
}
const fileWithPreview = Object.assign(file, {
preview: URL.createObjectURL(file),
});
setSelectedFile(fileWithPreview);
if (inputType === "file") {
setData(name, file);
}
setIsDialogOpen(true);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[maxFileSize, formatFileSize],
);
const { getRootProps, getInputProps } = useDropzone({
onDrop,
accept,
multiple: false,
maxSize: localMaxFileSize,
onDropRejected: (fileRejections) => {
const rejection = fileRejections[0];
const rejectionCode = rejection.errors[0]?.code as ErrorCode | undefined;
if (rejectionCode === ErrorCode.FileTooLarge) {
toast({
title: "Oups !",
description: `${dropErrorMessages.tooLarge} (${formatFileSize(rejection.file.size)}).`,
});
} else if (rejectionCode === ErrorCode.FileInvalidType) {
toast({
title: "Oups !",
description: dropErrorMessages.forbidden,
});
} else if (rejectionCode === ErrorCode.TooManyFiles) {
toast({
title: "Oups !",
description: dropErrorMessages.tooMany,
});
} else {
toast({
title: "Oups !",
description: dropErrorMessages.default,
});
}
},
});
return (
<div className="flex flex-col gap-1.5 w-full">
<div className="w-full flex gap-1 items-end">
<div className="w-full">
<Label htmlFor={name} className="font-bold">
{label}
{required && " *"}
</Label>
{selectedFile ? (
<div className="relative">
{inputType === "image" && (
<ImageCropper
dialogOpen={isDialogOpen}
value={value}
reset={reset}
setData={setData}
setDialogOpen={setIsDialogOpen}
selectedFile={selectedFile}
validationRules={{
width: localValidationRules.width,
height: localValidationRules.height,
}}
name={name}
url={url}
isReset={isReset}
setIsReset={setIsReset}
finalFileName={finalFileName}
/>
)}
{inputType === "file" && (
<div
className={cn(
"text-sm flex justify-center border border-dashed overflow-hidden whitespace-normal [&_svg]:!size-auto [&_svg]:!shrink text-muted-foreground w-full h-36",
)}
>
<div className="flex flex-col justify-center items-center">
<FileCheck />
<p className="text-center break-normal w-full mt-2">
{str(transCropperFileUploaded).ucFirst().value()}
</p>
<small>{selectedFile.name}</small>
</div>
</div>
)}
<BtnOptionnalLinkWithTooltip
className="absolute top-2 right-2"
variant="destructive"
content={<Trash className="w-4 h-4 m-auto" />}
onClick={() => {
setSelectedFile(null);
setData(name, null);
}}
tooltipContent={str(transCancel).ucFirst().value()}
/>
</div>
) : (
<Button
variant="ghost"
type="button"
className={cn(
"flex border border-dashed overflow-hidden whitespace-normal [&_svg]:!pointer-events-auto [&_svg]:!size-auto [&_svg]:!shrink text-muted-foreground w-full h-36",
{ "border-destructive": localErrors[name] },
)}
{...getRootProps()}
>
<input name={name} {...getInputProps()} />
<div className="flex flex-col justify-center items-center">
<UploadIcon />
<p className="text-center break-normal w-full mt-2">
{transSelectImgZone}
</p>
<small>
{extensions
? getTransValidationAllowedFormats(extensions)
: transValidationAllFormats}
. {str(transRulesWeight).ucFirst().value()}
</small>
</div>
</Button>
)}
</div>
{hasRefreshBtn && originalData && (
<BtnOptionnalLinkWithTooltip
disabled={isRefreshDisabled}
className="h-36"
variant="secondary"
onClick={() => {
setData(name, originalData);
setSelectedFile(transValueIntoFile(originalData));
if (onRefreshFile) {
onRefreshFile();
}
}}
content={<RefreshCwIcon />}
tooltipContent={str(transRefresh).ucFirst().value()}
/>
)}
</div>
<InputError message={localErrors[name]} />
</div>
);
}