ldc-groupe/resources/ts/back/Components/PageConfigs/ImportConfigJson.tsx
2026-02-12 16:13:54 +01:00

380 lines
13 KiB
TypeScript

// * Packages
import { ChangeEvent, DragEvent, useRef, useState } from "react";
import {
JsonEditor,
IconReplacements,
LocalisedStrings,
} from "json-edit-react";
import {
CheckIcon,
ChevronDownIcon,
CopyIcon,
PencilIcon,
PlusIcon,
Trash2Icon,
UploadIcon,
XIcon,
} from "lucide-react";
import z from "zod";
// * Components shadcn
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
// * Other
import SimpleSwitch from "@/back/Components/Inputs/SimpleSwitch";
import InputError from "@/back/Components/InputError";
import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip";
import { cn } from "@/lib/utils";
import str from "@/hooks/use-string";
import useTrans from "@/hooks/use-translations";
type JsonPrimitive = string | number | boolean | null;
type JsonObject = {
[key: string]: JsonPrimitive | JsonObject | JsonObject[];
};
type ImportConfigJsonProps = {
// eslint-disable-next-line no-unused-vars
onConfirm: (data: JsonObject[], overwrite: boolean) => void;
};
// Zod schemas.
const jsonObjectSchema: z.ZodType<JsonObject> = z.lazy(() =>
z.record(
z.string(),
z.union([
z.string(),
z.number(),
z.boolean(),
z.null(),
jsonObjectSchema,
z.array(jsonObjectSchema),
]),
),
);
const jsonArraySchema = z.array(jsonObjectSchema);
export default function ImportConfigJson({ onConfirm }: ImportConfigJsonProps) {
// Json content.
const [jsonText, setJsonText] = useState<JsonObject[]>([]);
// Modale state.
const [isOpen, setIsOpen] = useState<boolean>(false);
// Event state.
const [isDragging, setIsDragging] = useState<boolean>(false);
// Overwrite state.
const [overwrite, setOverwrite] = useState<boolean>(false);
// Local errors.
const [globalErrors, setGlobalErrors] = useState<Record<string, string>>(
{},
);
const [editorErrors, setEditorErrors] = useState<Record<string, string>>(
{},
);
// Input file reference.
const inputRef = useRef<HTMLInputElement>(null);
// Maximum file size accepted (2MB).
const maxSize = 2 * 1024 * 1024;
/**
* Get translation of specific key.
*
* @param key string
* @return string
*/
const useGetTranslation = (key: string): string =>
str(useTrans(key)).ucFirst().value();
const transJsonEditorMaxSize = useTrans(
"back.json_editor.validation.max_size",
{
params: { value: "2MB" },
},
);
const transJsonEditorMimes = useTrans("back.json_editor.validation.mimes", {
params: { values: ".json" },
});
const transJsonEditorInvalidFileJson = useTrans(
"back.json_editor.validation.invalid_file_json",
);
const transInvalidContentJson = useTrans(
"back.json_editor.validation.invalid_content_json",
);
// Custom icons.
const icons: IconReplacements = {
add: <PlusIcon className="w-4 h-4" />,
edit: <PencilIcon className="w-4 h-4" />,
delete: <Trash2Icon className="w-4 h-4" />,
copy: <CopyIcon className="w-4 h-4" />,
ok: <CheckIcon className="w-4 h-4" />,
cancel: <XIcon className="w-4 h-4" />,
chevron: <ChevronDownIcon className="cursor-pointer w-4 h-4" />,
};
// Custom translations.
const translations: LocalisedStrings = {
ITEM_SINGLE: useGetTranslation("back.json_editor.item_single"),
ITEMS_MULTIPLE: useGetTranslation("back.json_editor.item_multiple"),
KEY_NEW: useGetTranslation("back.json_editor.key_new"),
KEY_SELECT: useGetTranslation("back.json_editor.key_select"),
NO_KEY_OPTIONS: useGetTranslation("back.json_editor.no_key_options"),
ERROR_KEY_EXISTS: useGetTranslation(
"back.json_editor.error_key_exists",
),
ERROR_INVALID_JSON: useGetTranslation(
"back.json_editor.error_invalid_json",
),
ERROR_UPDATE: useGetTranslation("back.json_editor.error_update"),
ERROR_DELETE: useGetTranslation("back.json_editor.error_delete"),
ERROR_ADD: useGetTranslation("back.json_editor.error_add"),
DEFAULT_STRING: useGetTranslation("back.json_editor.default_string"),
DEFAULT_NEW_KEY: useGetTranslation("back.json_editor.default_new_key"),
SHOW_LESS: useGetTranslation("back.json_editor.show_less"),
EMPTY_STRING: useGetTranslation("back.json_editor.empty_string"),
TOOLTIP_COPY: useGetTranslation("back.json_editor.copy"),
TOOLTIP_EDIT: useGetTranslation("back.json_editor.edit"),
TOOLTIP_DELETE: useGetTranslation("back.json_editor.delete"),
TOOLTIP_ADD: useGetTranslation("back.json_editor.add"),
};
/**
* Simulates click on input file.
*
* @return void
*/
const openFilePicker = (): void => {
inputRef.current?.click();
};
/**
* Set json from content file.
*
* @param file File
* @return Promise<void>
*/
const processFile = async (file: File): Promise<void> => {
if (file.size > maxSize) {
setGlobalErrors((prev) => ({
...prev,
"global-error": transJsonEditorMaxSize,
}));
return;
}
if (!file.name.endsWith(".json")) {
setGlobalErrors((prev) => ({
...prev,
"global-error": transJsonEditorMimes,
}));
return;
}
try {
const text = await file.text();
const parsed = JSON.parse(text);
// Validate json with zod.
jsonArraySchema.parse(parsed);
setJsonText(parsed);
setGlobalErrors({});
setIsOpen(true);
} catch {
setGlobalErrors((prev) => ({
...prev,
"global-error": transJsonEditorInvalidFileJson,
}));
}
};
/**
* Reset component.
*
* @return void
*/
const resetComponent = (): void => {
setIsOpen(false);
setTimeout(() => {
setJsonText([]);
setGlobalErrors({});
setEditorErrors({});
setOverwrite(false);
}, 200);
};
/**
* Action when changing file.
*
* @param event
* @return void
*/
const handleFileChange = (e: ChangeEvent<HTMLInputElement>): void => {
const file = e.target.files?.[0];
if (file) processFile(file);
};
/**
* Action when droping file.
*
* @param event
* @return void
*/
const handleDrop = (event: DragEvent<HTMLDivElement>): void => {
event.preventDefault();
setIsDragging(false);
const file = event.dataTransfer.files?.[0];
if (file) processFile(file);
};
/**
* Confirm the json.
*
* @return void
*/
const handleConfirm = (): void => {
try {
// Validate json with zod.
jsonArraySchema.parse(jsonText);
onConfirm(jsonText, overwrite);
resetComponent();
} catch {
setEditorErrors((prev) => ({
...prev,
"editor-error": transInvalidContentJson,
}));
}
};
return (
<>
{/* Dropzone */}
<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",
{ "text-accent-foreground bg-accent": isDragging },
{
"border-destructive mb-1.5":
Object.values(globalErrors).length,
},
)}
onClick={openFilePicker}
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
// @ts-expect-error function return are not compatible for ts
onDrop={handleDrop}
>
<input
ref={inputRef}
type="file"
accept=".json,application/json"
className="hidden"
onChange={handleFileChange}
/>
<div className="flex flex-col justify-center items-center">
<UploadIcon />
<p className="text-center break-normal w-full mt-2">
{useTrans("back.json_editor.select-file")}
</p>
<small>
{str(
useTrans("back.json_editor.allowed_format", {
params: { format: ".json" },
}),
)
.ucFirst()
.value()}
.{" "}
{str(
useTrans("back.json_editor.rules_weight", {
params: { size: "2MB" },
}),
)
.ucFirst()
.value()}
</small>
</div>
</Button>
<InputError message={globalErrors["global-error"]} />
{/* Modal */}
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>
{useGetTranslation("back.json_editor.modal_title")}
</DialogTitle>
<DialogDescription>
{useGetTranslation(
"back.json_editor.modal_description",
)}
</DialogDescription>
</DialogHeader>
<div className="rounded-md w-full max-h-[350px] overflow-scroll">
{/* JSON editor */}
<JsonEditor
data={jsonText}
setData={(val) => setJsonText(val as JsonObject[])}
icons={icons}
translations={translations}
showIconTooltips
collapseAnimationTime={200}
maxWidth="100%"
searchFilter="all"
/>
</div>
<InputError message={editorErrors["editor-error"]} />
<DialogFooter className="sm:justify-between">
<SimpleSwitch
label={useGetTranslation(
"back.json_editor.overwrite",
)}
name="overwrite"
checked={overwrite}
setData={(_, value) => setOverwrite(value)}
/>
<div className="flex justify-center items-center gap-1.5">
<BtnOptionnalLinkWithTooltip
content={useGetTranslation(
"back.json_editor.cancel",
)}
tooltipContent={useGetTranslation(
"back.json_editor.tooltip_cancel",
)}
type="button"
variant="secondary"
size="default"
onClick={resetComponent}
/>
<BtnOptionnalLinkWithTooltip
content={useGetTranslation(
"back.json_editor.validate",
)}
tooltipContent={useGetTranslation(
"back.json_editor.tooltip_validate",
)}
type="button"
size="default"
onClick={handleConfirm}
/>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}