@premieroctet/next-admin
Version:
Next-Admin provides a customizable and turnkey admin dashboard for applications built with Next.js and powered by the Prisma ORM. It aims to simplify the development process by providing a turnkey admin system that can be easily integrated into your proje
475 lines (474 loc) • 22.5 kB
JavaScript
"use client";
import { jsx, jsxs } from "react/jsx-runtime";
import { CheckCircleIcon, InformationCircleIcon, TrashIcon } from "@heroicons/react/24/outline";
import core_default from "@rjsf/core";
import validator_ajv8_default from "@rjsf/validator-ajv8";
import external_clsx_default from "clsx";
import external_react_default, { Suspense, cloneElement, forwardRef, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { twMerge } from "tailwind-merge";
import ClientActionDialogContext_mjs_default from "../context/ClientActionDialogContext.mjs";
import { useConfig } from "../context/ConfigContext.mjs";
import FormDataContext_mjs_default, { useFormData } from "../context/FormDataContext.mjs";
import FormStateContext_mjs_default, { useFormState } from "../context/FormStateContext.mjs";
import { useI18n } from "../context/I18nContext.mjs";
import { useMessage } from "../context/MessageContext.mjs";
import { useDeleteAction } from "../hooks/useDeleteAction.mjs";
import { useRouterInternal } from "../hooks/useRouterInternal.mjs";
import { Permission } from "../types.mjs";
import { getSchemas } from "../utils/jsonSchema.mjs";
import { formatLabel, isFileUploadFormat, slugify } from "../utils/tools.mjs";
import external_FormHeader_mjs_default from "./FormHeader.mjs";
import ArrayField_mjs_default from "./inputs/ArrayField.mjs";
import BaseInput_mjs_default from "./inputs/BaseInput.mjs";
import CheckboxWidget_mjs_default from "./inputs/CheckboxWidget.mjs";
import DateTimeWidget_mjs_default from "./inputs/DateTimeWidget.mjs";
import DateWidget_mjs_default from "./inputs/DateWidget.mjs";
import FileWidget_mjs_default from "./inputs/FileWidget/FileWidget.mjs";
import JsonField_mjs_default from "./inputs/JsonField.mjs";
import NullField_mjs_default from "./inputs/NullField.mjs";
import SelectWidget_mjs_default from "./inputs/SelectWidget.mjs";
import TextareaWidget_mjs_default from "./inputs/TextareaWidget.mjs";
import { Message } from "./Message.mjs";
import Button_mjs_default from "./radix/Button.mjs";
import { TooltipContent, TooltipPortal, TooltipProvider, TooltipRoot, TooltipTrigger } from "./radix/Tooltip.mjs";
import { getSubmitButtonOptions } from "../utils/rjsf.mjs";
import { getClonableElement } from "../utils/react19-compat.mjs";
const RichTextField = /*#__PURE__*/ lazy(()=>import("./inputs/RichText/RichTextField.mjs"));
const widgets = {
DateWidget: DateWidget_mjs_default,
DateTimeWidget: DateTimeWidget_mjs_default,
SelectWidget: SelectWidget_mjs_default,
CheckboxWidget: CheckboxWidget_mjs_default,
FileWidget: FileWidget_mjs_default,
TextareaWidget: TextareaWidget_mjs_default
};
const Form = ({ data, schema, resource, validation: validationProp, customInputs })=>{
const [validation, setValidation] = useState(validationProp);
const { basePath, options, apiBasePath } = useConfig();
const modelOptions = options?.model?.[resource];
const canDelete = !modelOptions?.permissions || modelOptions?.permissions?.includes(Permission.DELETE);
const canEdit = !modelOptions?.permissions || modelOptions?.permissions?.includes(Permission.EDIT);
const canCreate = !modelOptions?.permissions || modelOptions?.permissions?.includes(Permission.CREATE);
const { edit, id, ...schemas } = getSchemas(data, schema, modelOptions?.edit?.fields);
const { router } = useRouterInternal();
const { t } = useI18n();
const formRef = useRef(null);
const [isPending, setIsPending] = useState(false);
const allDisabled = edit && !canEdit;
const { runSingleDeletion } = useDeleteAction(resource);
const { showMessage } = useMessage();
const { cleanAll } = useFormState();
const { setFormData } = useFormData();
useEffect(()=>{
if (!edit && !canCreate) router.replace({
pathname: "/"
});
}, [
canCreate,
edit,
router
]);
const submitButton = useMemo(()=>(props)=>{
const { uiSchema } = props;
const { norender, props: buttonProps } = getSubmitButtonOptions(uiSchema);
if (norender) return null;
return /*#__PURE__*/ jsxs("div", {
className: "align-center mt-4 flex flex-wrap justify-between gap-2",
children: [
(edit && canEdit || !edit && canCreate) && /*#__PURE__*/ jsxs("div", {
className: "order-2 flex gap-2",
children: [
/*#__PURE__*/ jsxs(Button_mjs_default, {
...buttonProps,
className: "order-2 flex gap-2",
type: "submit",
name: "__admin_redirect",
value: "list",
loading: isPending,
children: [
!isPending && /*#__PURE__*/ jsx(CheckCircleIcon, {
className: "h-6 w-6"
}),
t("form.button.save.label")
]
}),
/*#__PURE__*/ jsx(Button_mjs_default, {
...buttonProps,
variant: "ghost",
className: "order-1 hidden sm:block",
tabIndex: -1,
type: "submit",
loading: isPending,
name: "save_edit",
children: t("form.button.save_edit.label")
})
]
}),
/*#__PURE__*/ jsx("div", {
className: "order-1",
children: edit && canDelete && /*#__PURE__*/ jsxs(Button_mjs_default, {
variant: "destructiveOutline",
className: "flex gap-2",
formNoValidate: true,
tabIndex: -1,
onClick: async (e)=>{
if (confirm(t("form.delete.alert"))) try {
setIsPending(true);
await runSingleDeletion(id);
router.replace({
pathname: `${basePath}/${slugify(resource)}`,
query: {
message: JSON.stringify({
type: "success",
message: t("form.delete.succeed")
})
}
});
} catch (e) {
showMessage({
type: "error",
message: t(e.message)
});
} finally{
setIsPending(false);
}
else e.preventDefault();
},
loading: isPending,
children: [
!isPending && /*#__PURE__*/ jsx(TrashIcon, {
className: "h-4 w-4"
}),
t("form.button.delete.label")
]
})
})
]
});
}, [
isPending,
id
]);
const extraErrors = validation?.reduce((acc, curr)=>{
acc[curr.property] = {
__errors: [
curr.message
]
};
return acc;
}, {});
const onSubmit = useCallback(async (formData)=>{
try {
setIsPending(true);
const response = await fetch(`${apiBasePath}/${slugify(resource)}${id ? `/${id}` : ""}`, {
method: "POST",
body: formData
});
const result = await response.json();
result?.validation ? setValidation(result.validation) : setValidation(void 0);
if (result?.data) {
setFormData(result.data);
cleanAll();
}
if (result?.deleted) return router.replace({
pathname: `${basePath}/${slugify(resource)}`,
query: {
message: JSON.stringify({
type: "success",
message: t("form.delete.succeed")
})
}
});
if (result?.created) {
const pathname = result?.redirect ? `${basePath}/${slugify(resource)}` : `${basePath}/${slugify(resource)}/${result.createdId}`;
return router.push({
pathname,
query: {
message: JSON.stringify({
type: "success",
message: t("form.create.succeed")
})
}
});
}
if (result?.updated) {
const pathname = result?.redirect ? `${basePath}/${slugify(resource)}` : location.pathname;
if (pathname === location.pathname) {
router.refresh();
showMessage({
type: "success",
message: t("form.update.succeed")
});
} else router.push({
pathname,
query: {
message: JSON.stringify({
type: "success",
message: t("form.update.succeed")
})
}
});
}
if (result?.error) showMessage({
type: "error",
message: t(result.error)
});
} finally{
setIsPending(false);
}
}, [
apiBasePath,
id
]);
const fields = useMemo(()=>({
ArrayField: (props)=>{
const customInput = customInputs?.[props.name];
const improvedCustomInput = customInput ? /*#__PURE__*/ cloneElement(getClonableElement(customInput), {
...getClonableElement(customInput).props,
mode: edit ? "edit" : "create"
}) : void 0;
return /*#__PURE__*/ jsx(ArrayField_mjs_default, {
...props,
customInput: improvedCustomInput
});
},
NullField: NullField_mjs_default
}), [
customInputs,
edit
]);
const templates = {
FieldTemplate: (props)=>{
const { id, classNames, style, label, help, required, description, errors, children, schema } = props;
const labelAlias = modelOptions?.aliases?.[id] || formatLabel(label);
const labelName = t(`model.${resource}.fields.${id}`, {}, labelAlias);
let styleField = modelOptions?.edit?.styles?.[id];
const tooltip = modelOptions?.edit?.fields?.[id]?.tooltip || modelOptions?.edit?.customFields?.[id]?.tooltip;
const sanitizedClassNames = classNames?.split(",").filter((className)=>"null" !== className).join(" ");
return /*#__PURE__*/ jsxs("div", {
style: style,
className: external_clsx_default(sanitizedClassNames, styleField),
children: [
"null" !== schema.type && /*#__PURE__*/ jsxs("label", {
className: external_clsx_default("text-nextadmin-content-inverted dark:text-dark-nextadmin-content-inverted mb-2 flex items-center gap-2 text-sm font-medium leading-6"),
htmlFor: id,
children: [
labelName,
required ? "*" : null,
!!tooltip && /*#__PURE__*/ jsx(TooltipProvider, {
children: /*#__PURE__*/ jsxs(TooltipRoot, {
children: [
/*#__PURE__*/ jsx(TooltipTrigger, {
asChild: true,
children: /*#__PURE__*/ jsx(InformationCircleIcon, {
className: "text-nextadmin-content-default dark:text-dark-nextadmin-content-default h-4 w-4"
})
}),
/*#__PURE__*/ jsx(TooltipPortal, {
children: /*#__PURE__*/ jsx(TooltipContent, {
side: "right",
className: "px-2 py-1",
sideOffset: 4,
children: tooltip
})
})
]
})
})
]
}),
children,
description,
errors,
help
]
});
},
ObjectFieldTemplate: (props)=>{
const styleForm = modelOptions?.edit?.styles?._form;
return /*#__PURE__*/ jsx("div", {
className: twMerge("grid gap-4", styleForm),
children: props.properties.map((element)=>element.content)
});
},
BaseInputTemplate: ({ onChange, options, onChangeOverride, uiSchema, readonly, formContext, autofocus, rawErrors, schema, registry, hideError, hideLabel, ...props })=>{
const { setFieldDirty } = useFormState();
const onTextChange = ({ target: { value: val } })=>{
setFieldDirty(props.name);
onChange("" === val ? options.emptyValue || "" : val);
};
let customInput = customInputs?.[props.name];
if (customInput) return /*#__PURE__*/ cloneElement(getClonableElement(customInput), {
value: props.value,
onChange: onChangeOverride || onTextChange,
readonly,
rawErrors,
name: props.name,
required: props.required,
disabled: props.disabled,
mode: edit ? "edit" : "create",
item: data
});
if (schema?.format === "json") return /*#__PURE__*/ jsx(JsonField_mjs_default, {
onChange: onChangeOverride || onTextChange,
readonly: readonly,
rawErrors: rawErrors,
...props
});
if (schema?.format?.startsWith("richtext-")) return /*#__PURE__*/ jsx(Suspense, {
fallback: /*#__PURE__*/ jsx("div", {
className: "h-48 animate-pulse rounded bg-gray-500"
}),
children: /*#__PURE__*/ jsx(RichTextField, {
onChange: onChangeOverride || onTextChange,
readonly: readonly,
rawErrors: rawErrors,
name: props.name,
value: props.value,
schema: schema,
disabled: props.disabled,
required: props.required
})
});
if ([
"time",
"time-second"
].includes(schema?.format)) return /*#__PURE__*/ jsx(BaseInput_mjs_default, {
type: "time",
onChange: onChangeOverride || onTextChange,
...props,
value: props.value.split(":").slice(0, schema?.format === "time-second" ? 3 : 2).join(":") ?? "",
className: external_clsx_default({
"ring-red-600 dark:ring-red-400": rawErrors
}),
step: schema?.format === "time-second" ? 1 : void 0
});
return /*#__PURE__*/ jsx(BaseInput_mjs_default, {
onChange: onChangeOverride || onTextChange,
...props,
value: props.value ?? "",
className: external_clsx_default({
"ring-red-600 dark:ring-red-400": rawErrors
})
});
},
ErrorListTemplate: ()=>null,
FieldErrorTemplate: ({ errors })=>errors ? /*#__PURE__*/ jsx("div", {
className: "mt-1 text-sm text-red-600 dark:text-red-400",
children: errors.map((error, idx)=>{
if ("string" == typeof error) return /*#__PURE__*/ jsx(external_react_default.Fragment, {
children: t(error)
}, idx);
return /*#__PURE__*/ jsx(external_react_default.Fragment, {
children: error
}, idx);
})
}) : null,
DescriptionFieldTemplate: ({ description, schema })=>description && "null" !== schema.type ? /*#__PURE__*/ jsx("span", {
className: "text-nextadmin-content-emphasis dark:text-dark-nextadmin-content-emphasis text-sm",
children: description
}) : null,
ButtonTemplates: {
SubmitButton: submitButton
}
};
const CustomForm = useMemo(()=>/*#__PURE__*/ forwardRef((props, ref)=>{
const { dirtyFields } = useFormState();
const { formData } = useFormData();
return /*#__PURE__*/ jsx("form", {
...props,
ref: ref,
onSubmit: (e)=>{
e.preventDefault();
const formValues = new FormData(e.target);
const data = new FormData();
dirtyFields.forEach((field)=>{
const schemaProperties = schema.properties[field];
const isFieldArrayOfFiles = schemaProperties?.type === "array" && isFileUploadFormat(schemaProperties.format ?? "");
if (isFieldArrayOfFiles) {
const files = formValues.getAll(field).filter((file)=>"string" == typeof file || file instanceof File && !!file.name);
const values = formData[field];
values.forEach((val)=>{
data.append(field, val);
});
if (files.length > 0) files.forEach((file)=>{
data.append(field, file);
});
else data.append(field, new File([], ""));
return;
}
data.append(field, formValues.get(field));
});
const submitter = e.nativeEvent.submitter;
data.append(submitter.name, submitter.value);
onSubmit(data);
}
});
}), [
onSubmit,
schema
]);
const RjsfFormComponent = useMemo(()=>/*#__PURE__*/ forwardRef((_props, ref)=>{
const { formData } = useFormData();
return /*#__PURE__*/ jsx(core_default, {
tagName: CustomForm,
idPrefix: "",
idSeparator: "",
...schemas,
onChange: (e)=>{
setFormData(e.formData);
},
formData: formData,
validator: validator_ajv8_default,
extraErrors: extraErrors,
fields: fields,
disabled: allDisabled,
formContext: {
isPending,
schema
},
templates: templates,
widgets: widgets,
ref: ref,
className: "relative"
});
}), [
submitButton
]);
return /*#__PURE__*/ jsx("div", {
className: "relative h-full",
children: /*#__PURE__*/ jsxs("div", {
className: "bg-nextadmin-background-default dark:bg-dark-nextadmin-background-default max-w-full p-4 align-middle sm:p-8",
children: [
/*#__PURE__*/ jsx(Message, {
className: "-mt-2 mb-2 sm:-mt-4 sm:mb-4"
}),
/*#__PURE__*/ jsx("div", {
className: "bg-nextadmin-background-default dark:bg-dark-nextadmin-background-emphasis border-nextadmin-border-default dark:border-dark-nextadmin-border-default max-w-screen-md rounded-lg border p-4 sm:p-8",
children: /*#__PURE__*/ jsx(RjsfFormComponent, {
ref: formRef
})
})
]
})
});
};
const FormWrapper = ({ clientActionsComponents, relationshipsRawData, ...props })=>/*#__PURE__*/ jsxs(ClientActionDialogContext_mjs_default, {
componentsMap: clientActionsComponents,
children: [
/*#__PURE__*/ jsx(external_FormHeader_mjs_default, {
...props
}),
/*#__PURE__*/ jsx(FormDataContext_mjs_default, {
data: props.data,
relationshipsRawData: relationshipsRawData,
children: /*#__PURE__*/ jsx(FormStateContext_mjs_default, {
children: /*#__PURE__*/ jsx(Form, {
...props
})
})
})
]
});
const components_Form = FormWrapper;
export { components_Form as default };