ldc-groupe/resources/ts/back/Components/PageConfigs/ImportConfigJson.tsx

315 lines
9.7 KiB
TypeScript

// * Packages
import { 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 = {
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 getTranslation = (key: string): string => str(useTrans(key)).ucFirst().value();
// 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: getTranslation('back.json_editor.item_single'),
ITEMS_MULTIPLE: getTranslation('back.json_editor.item_multiple'),
KEY_NEW: getTranslation('back.json_editor.key_new'),
KEY_SELECT: getTranslation('back.json_editor.key_select'),
NO_KEY_OPTIONS: getTranslation('back.json_editor.no_key_options'),
ERROR_KEY_EXISTS: getTranslation('back.json_editor.error_key_exists'),
ERROR_INVALID_JSON: getTranslation('back.json_editor.error_invalid_json'),
ERROR_UPDATE: getTranslation('back.json_editor.error_update'),
ERROR_DELETE: getTranslation('back.json_editor.error_delete'),
ERROR_ADD: getTranslation('back.json_editor.error_add'),
DEFAULT_STRING: getTranslation('back.json_editor.default_string'),
DEFAULT_NEW_KEY: getTranslation('back.json_editor.default_new_key'),
SHOW_LESS: getTranslation('back.json_editor.show_less'),
EMPTY_STRING: getTranslation('back.json_editor.empty_string'),
TOOLTIP_COPY: getTranslation('back.json_editor.copy'),
TOOLTIP_EDIT: getTranslation('back.json_editor.edit'),
TOOLTIP_DELETE: getTranslation('back.json_editor.delete'),
TOOLTIP_ADD: getTranslation('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': useTrans('back.json_editor.validation.max_size', {
params: { value: '2MB' }
}) }));
return;
}
if (!file.name.endsWith(".json")) {
setGlobalErrors(prev => ({ ...prev, 'global-error': useTrans('back.json_editor.validation.mimes', {
params: { values: '.json' }
}) }));
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': useTrans('back.json_editor.validation.invalid_file_json') }));
}
};
/**
* Action when changing file.
*
* @param event
* @return void
*/
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
const file = e.target.files?.[0];
if (file) processFile(file);
};
/**
* Action when droping file.
*
* @param event
* @return void
*/
const handleDrop = (event: React.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': useTrans('back.json_editor.validation.invalid_content_json') }));
}
};
/**
* Reset component.
*
* @return void
*/
const resetComponent = (): void => {
setIsOpen(false);
setTimeout(() => {
setJsonText([]);
setGlobalErrors({});
setEditorErrors({});
setOverwrite(false);
}, 200);
}
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-ignore
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>{getTranslation('back.json_editor.modal_title')}</DialogTitle>
<DialogDescription>
{getTranslation('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={true}
collapseAnimationTime={200}
maxWidth="100%"
searchFilter="all"
/>
</div>
<InputError message={editorErrors["editor-error"]} />
<DialogFooter className="sm:justify-between">
<SimpleSwitch
label={getTranslation("back.json_editor.overwrite")}
name="overwrite"
checked={overwrite}
setData={(_, value) => setOverwrite(value)}
/>
<div className="flex justify-center items-center gap-1.5">
<BtnOptionnalLinkWithTooltip
content={getTranslation('back.json_editor.cancel')}
tooltipContent={getTranslation('back.json_editor.tooltip_cancel')}
type="button"
variant="secondary"
size="default"
onClick={resetComponent}
/>
<BtnOptionnalLinkWithTooltip
content={getTranslation('back.json_editor.validate')}
tooltipContent={getTranslation('back.json_editor.tooltip_validate')}
type="button"
size="default"
onClick={handleConfirm}
/>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}