diff --git a/apps/docs/next-env.d.ts b/apps/docs/next-env.d.ts
index 3cd7048e..52e831b4 100644
--- a/apps/docs/next-env.d.ts
+++ b/apps/docs/next-env.d.ts
@@ -1,6 +1,5 @@
///
///
-///
// NOTE: This file should not be edited
-// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
diff --git a/apps/docs/pages/docs/i18n.mdx b/apps/docs/pages/docs/i18n.mdx
index 8e3729b5..601fdc3f 100644
--- a/apps/docs/pages/docs/i18n.mdx
+++ b/apps/docs/pages/docs/i18n.mdx
@@ -146,6 +146,30 @@ The following keys are accepted:
"The text displayed in the multiselect widget in list display mode to toggle the select dialog",
defaultValue: '"Select items"',
},
+ {
+ name: "form.widgets.multiselect.create",
+ description:
+ "The text displayed in the multiselect widget create button when allowCreate is enabled",
+ defaultValue: '"Create item"',
+ },
+ {
+ name: "form.widgets.select.create",
+ description:
+ "The text displayed in the select widget create button when allowCreate is enabled",
+ defaultValue: '"Create item"',
+ },
+ {
+ name: "form.widgets.multiselect.edit",
+ description:
+ "The text displayed in the multiselect widget edit button when allowEdit is enabled",
+ defaultValue: '"Edit item"',
+ },
+ {
+ name: "form.widgets.select.edit",
+ description:
+ "The text displayed in the select widget edit button when allowEdit is enabled",
+ defaultValue: '"Edit item"',
+ },
{
name: "form.widgets.scalar_array.add",
description:
diff --git a/packages/examples-common/messages/fr.ts b/packages/examples-common/messages/fr.ts
index f796a84e..124bc614 100644
--- a/packages/examples-common/messages/fr.ts
+++ b/packages/examples-common/messages/fr.ts
@@ -105,6 +105,12 @@ export default {
},
multiselect: {
select: "Sélectionner",
+ create: "Créer un élément",
+ edit: "Modifier un élément",
+ },
+ select: {
+ create: "Créer un élément",
+ edit: "Modifier un élément",
},
},
user: {
diff --git a/packages/examples-common/options.tsx b/packages/examples-common/options.tsx
index 6a822c09..99fd0ef3 100644
--- a/packages/examples-common/options.tsx
+++ b/packages/examples-common/options.tsx
@@ -121,6 +121,7 @@ export const createOptions = (
},
posts: {
display: "table",
+ allowCreate: true,
},
avatar: {
format: "file",
@@ -297,6 +298,7 @@ export const createOptions = (
display: "list",
orderField: "order",
relationshipSearchField: "category",
+ allowCreate: true,
},
images: {
format: "file",
@@ -355,6 +357,8 @@ export const createOptions = (
display: "list",
relationshipSearchField: "post",
orderField: "order",
+ allowCreate: true,
+ allowEdit: true,
},
},
},
diff --git a/packages/examples-common/package.json b/packages/examples-common/package.json
index d76c0457..55029d2c 100644
--- a/packages/examples-common/package.json
+++ b/packages/examples-common/package.json
@@ -4,7 +4,8 @@
"scripts": {
"dev": "tsc -w",
"build": "tsc",
- "clean": "rm -rf dist"
+ "clean": "rm -rf dist",
+ "typecheck": "tsc --noEmit"
},
"exports": {
"./components": "./dist/components/index.js",
diff --git a/packages/next-admin/src/appHandler.ts b/packages/next-admin/src/appHandler.ts
index 1d3a3acf..988df688 100644
--- a/packages/next-admin/src/appHandler.ts
+++ b/packages/next-admin/src/appHandler.ts
@@ -1,3 +1,4 @@
+import cloneDeep from "lodash.clonedeep";
import { createEdgeRouter } from "next-connect";
import { HookError } from "./exceptions/HookError";
import { handleOptionsSearch } from "./handlers/options";
@@ -12,17 +13,18 @@ import {
RequestContext,
ServerAction,
} from "./types";
+import { getSchema, initGlobals } from "./utils/globals";
+import { getSchemaForResource, getSchemas } from "./utils/jsonSchema";
import { hasPermission } from "./utils/permissions";
-import { getRawData } from "./utils/prisma";
+import { getDataItem, getRawData } from "./utils/prisma";
import {
formatId,
getFormValuesFromFormData,
getModelIdProperty,
getResourceFromParams,
getResources,
+ transformSchema,
} from "./utils/server";
-import { getSchema, initGlobals } from "./utils/globals";
-import { PrismaClient } from "./types-prisma";
export const createHandler =
({
apiBasePath,
@@ -52,6 +54,88 @@ export const createHandler =
({
}
router
+ .get(`${apiBasePath}/:model/schema/:id?`, async (req, ctx) => {
+ try {
+ const resources = getResources(options);
+ const params = await ctx.params;
+ const resource = getResourceFromParams(
+ [params[paramKey][0]],
+ resources
+ );
+
+ if (!resource) {
+ return Response.json(
+ { error: "Resource not found" },
+ { status: 404 }
+ );
+ }
+
+ const id =
+ params[paramKey].length > 2 ? params[paramKey][2] : undefined;
+ const edit = options?.model?.[resource]?.edit;
+
+ let deepCopySchema = await transformSchema(
+ resource,
+ //@ts-expect-error
+ edit,
+ options
+ )(cloneDeep(getSchema()));
+
+ const resourceSchema = getSchemaForResource(deepCopySchema, resource);
+
+ if (id) {
+ const formattedId = formatId(resource, id);
+
+ const { data, relationshipsRawData } = await getDataItem({
+ prisma,
+ resource,
+ resourceId: formattedId,
+ options,
+ });
+
+ const { uiSchema, schema } = getSchemas(
+ data,
+ resourceSchema,
+ edit?.fields as EditFieldsOptions
+ );
+
+ return Response.json({
+ data,
+ modelSchema: schema,
+ uiSchema,
+ relationshipsRawData,
+ resource,
+ });
+ }
+
+ const relationshipsRawData = await getRawData({
+ prisma,
+ resource,
+ resourceIds: [],
+ maxDepth: 2,
+ });
+
+ const { uiSchema, schema } = getSchemas(
+ null,
+ resourceSchema,
+ edit?.fields as EditFieldsOptions
+ );
+
+ return Response.json({
+ data: null,
+ modelSchema: schema,
+ uiSchema,
+ relationshipsRawData,
+ resource,
+ });
+ } catch (e) {
+ console.error("Error in GET schema endpoint:", e);
+ return Response.json(
+ { error: (e as Error)?.message || "Unknown error occurred" },
+ { status: 500 }
+ );
+ }
+ })
.get(`${apiBasePath}/:model/raw`, async (req, ctx) => {
const resources = getResources(options);
const params = await ctx.params;
@@ -89,6 +173,9 @@ export const createHandler = ({
return Response.json(data);
})
+ .get(`${apiBasePath}/:model/:id`, async (req, ctx) => {
+ return Response.json({ error: "Not implemented" }, { status: 200 });
+ })
.post(`${apiBasePath}/:model/actions/:id`, async (req, ctx) => {
const resources = getResources(options);
const params = await ctx.params;
diff --git a/packages/next-admin/src/components/Form.tsx b/packages/next-admin/src/components/Form.tsx
index f4ce352c..d7c53d93 100644
--- a/packages/next-admin/src/components/Form.tsx
+++ b/packages/next-admin/src/components/Form.tsx
@@ -4,8 +4,8 @@ import {
InformationCircleIcon,
TrashIcon,
} from "@heroicons/react/24/outline";
-import RjsfForm from "@rjsf/core";
import type { FormProps as RjsfFormProps } from "@rjsf/core";
+import RjsfForm from "@rjsf/core";
import {
BaseInputTemplateProps,
ErrorSchema,
@@ -48,6 +48,7 @@ import {
Permission,
} from "../types";
import { getSchemas } from "../utils/jsonSchema";
+import { getSubmitButtonOptions } from "../utils/rjsf";
import { formatLabel, isFileUploadFormat, slugify } from "../utils/tools";
import FormHeader from "./FormHeader";
import ArrayField from "./inputs/ArrayField";
@@ -69,7 +70,6 @@ import {
TooltipRoot,
TooltipTrigger,
} from "./radix/Tooltip";
-import { getSubmitButtonOptions } from "../utils/rjsf";
const RichTextField = lazy(() => import("./inputs/RichText/RichTextField"));
@@ -82,12 +82,14 @@ const widgets: RjsfFormProps["widgets"] = {
TextareaWidget: TextareaWidget,
};
-const Form = ({
+export const Form = ({
data,
schema,
resource,
validation: validationProp,
customInputs,
+ onSubmitCallback,
+ isEmbedded,
}: FormProps) => {
const [validation, setValidation] = useState(validationProp);
const { basePath, options, apiBasePath } = useConfig();
@@ -229,6 +231,7 @@ const Form = ({
body: formData,
}
);
+ debugger;
const result = await response.json();
if (result?.validation) {
setValidation(result.validation);
@@ -240,6 +243,12 @@ const Form = ({
cleanAll();
}
if (result?.deleted) {
+
+ if (onSubmitCallback) {
+ onSubmitCallback(result);
+ return;
+ }
+
return router.replace({
pathname: `${basePath}/${slugify(resource)}`,
query: {
@@ -251,6 +260,12 @@ const Form = ({
});
}
if (result?.created) {
+
+ if (onSubmitCallback) {
+ onSubmitCallback(result);
+ return;
+ }
+
const pathname = result?.redirect
? `${basePath}/${slugify(resource)}`
: `${basePath}/${slugify(resource)}/${result.createdId}`;
@@ -266,6 +281,12 @@ const Form = ({
});
}
if (result?.updated) {
+
+ if (onSubmitCallback) {
+ onSubmitCallback(result);
+ return;
+ }
+
const pathname = result?.redirect
? `${basePath}/${slugify(resource)}`
: location.pathname;
@@ -306,9 +327,9 @@ const Form = ({
const customInput = customInputs?.[props.name as Field];
const improvedCustomInput = customInput
? cloneElement(customInput, {
- ...customInput.props,
- mode: edit ? "edit" : "create",
- })
+ ...customInput.props,
+ mode: edit ? "edit" : "create",
+ })
: undefined;
return ;
},
@@ -605,7 +626,7 @@ const Form = ({
extraErrors={extraErrors}
fields={fields}
disabled={allDisabled}
- formContext={{ isPending, schema }}
+ formContext={{ isPending, schema, parentId: id }}
templates={templates}
widgets={widgets}
ref={ref}
@@ -618,9 +639,9 @@ const Form = ({
return (
-
+
-
@@ -635,7 +656,7 @@ const FormWrapper = ({
}: FormProps) => {
return (
-
+ {props?.isEmbedded ? null : }
}
@@ -22,7 +22,7 @@ const ArrayField = (
const field =
resourceDefinition.properties[
- name as keyof typeof resourceDefinition.properties
+ name as keyof typeof resourceDefinition.properties
];
if (field?.__nextadmin?.kind === "scalar" && field?.__nextadmin?.isList) {
@@ -66,6 +66,7 @@ const ArrayField = (
required={required}
schema={schema}
options={options}
+ formContext={formContext}
/>
);
};
diff --git a/packages/next-admin/src/components/inputs/EmbeddedForm/EmbeddedFormModal.tsx b/packages/next-admin/src/components/inputs/EmbeddedForm/EmbeddedFormModal.tsx
new file mode 100644
index 00000000..3e58fc08
--- /dev/null
+++ b/packages/next-admin/src/components/inputs/EmbeddedForm/EmbeddedFormModal.tsx
@@ -0,0 +1,215 @@
+import { Transition, TransitionChild } from "@headlessui/react";
+import { XMarkIcon } from "@heroicons/react/24/outline";
+import { UiSchema } from "@rjsf/utils";
+import { Fragment, useCallback, useEffect, useState } from "react";
+import Loader from "../../../assets/icons/Loader";
+import { ConfigProvider, useConfig } from "../../../context/ConfigContext";
+import { useI18n } from "../../../context/I18nContext";
+import { useMessage } from "../../../context/MessageContext";
+import { ModelName, SchemaModel } from "../../../types";
+import Form from "../../Form";
+import {
+ DialogClose,
+ DialogContent,
+ DialogOverlay,
+ DialogPortal,
+ DialogRoot,
+ DialogTitle
+} from "../../radix/Dialog";
+
+const EmbeddedFormModal = ({
+ originalResource,
+ resource,
+ id,
+ parentId,
+ onClose,
+ onSuccess,
+}: {
+ originalResource: O;
+ resource: M;
+ id?: string;
+ parentId?: string;
+ onClose: () => void;
+ onSuccess?: (data: any) => void;
+}) => {
+ const { t } = useI18n();
+ const { apiBasePath, basePath, resourcesIdProperty, schema, options, isAppDir } = useConfig();
+ const { showMessage } = useMessage();
+ const isCreateMode = !id;
+
+ const [resourceData, setResourceData] = useState<{
+ data: any;
+ modelSchema: SchemaModel;
+ uiSchema: UiSchema;
+ relationshipsRawData: any;
+ resource: M;
+ } | null>(null);
+
+ // Helper function to find the field that relates to the parent resource
+ const findParentRelationField = (modelSchema: SchemaModel, parentResource: O) => {
+ const properties = modelSchema.properties;
+
+ for (const [fieldName, fieldSchema] of Object.entries(properties)) {
+ if (typeof fieldSchema === 'object' && fieldSchema !== null && 'relation' in fieldSchema) {
+ // Check if this field relates to the parent resource
+ if (fieldSchema.relation === parentResource) {
+ return fieldName;
+ }
+ }
+ }
+
+ return null;
+ };
+ useEffect(() => {
+ const fetchData = async () => {
+ const response = await fetch(
+ `${apiBasePath}/${resource}/schema${id ? `/${id}` : ""}`
+ );
+
+ if (!response.ok) {
+ const error = await response.json();
+ showMessage({
+ type: "error",
+ message: error.error || "Failed to fetch resource data",
+ });
+ return;
+ }
+
+ const { data, modelSchema, uiSchema, relationshipsRawData, resource: resourceName } = await response.json();
+
+ // Auto-fill parent relation field when creating new resource
+ let modifiedData = data;
+ let modifiedUiSchema = uiSchema;
+
+ if (isCreateMode && parentId && originalResource) {
+ // Find the field that relates to the original resource
+ const parentFieldName = findParentRelationField(modelSchema, originalResource);
+
+ if (parentFieldName) {
+ // Auto-fill the parent relation field
+ modifiedData = {
+ ...data,
+ [parentFieldName]: {
+ value: parentId,
+ label: `${originalResource} ${parentId}`, // This could be improved with actual parent data
+ }
+ };
+
+ // Disable the parent relation field in UI schema
+ modifiedUiSchema = {
+ ...uiSchema,
+ [parentFieldName]: {
+ ...uiSchema[parentFieldName],
+ "ui:disabled": true,
+ "ui:help": t("form.embedded.parent_relation_locked"),
+ }
+ };
+ }
+ }
+
+ setResourceData({
+ data: modifiedData,
+ modelSchema,
+ uiSchema: modifiedUiSchema,
+ relationshipsRawData,
+ resource: resourceName,
+ });
+ };
+
+ fetchData();
+ }, [apiBasePath, id, resource, originalResource, isCreateMode, parentId, showMessage, t]);
+
+
+
+ const handleFormSubmitCallback = useCallback((result: any) => {
+ onClose();
+ if (result.created || result.updated) {
+ showMessage({
+ type: "success",
+ message: t("form.embedded.success"),
+ });
+ }
+ }, [onClose, showMessage, t]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {isCreateMode ? t("actions.create.label") : t("actions.edit.label")} {resource}
+
+
+
+
+
+
+ {resourceData ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
+ );
+};
+
+export default EmbeddedFormModal;
diff --git a/packages/next-admin/src/components/inputs/MultiSelect/MultiSelectWidget.tsx b/packages/next-admin/src/components/inputs/MultiSelect/MultiSelectWidget.tsx
index f8363cdc..31e9e651 100644
--- a/packages/next-admin/src/components/inputs/MultiSelect/MultiSelectWidget.tsx
+++ b/packages/next-admin/src/components/inputs/MultiSelect/MultiSelectWidget.tsx
@@ -1,5 +1,6 @@
import { RJSFSchema } from "@rjsf/utils";
import clsx from "clsx";
+import { ReactNode, useState } from "react";
import DoubleArrow from "../../../assets/icons/DoubleArrow";
import { useConfig } from "../../../context/ConfigContext";
import { useFormState } from "../../../context/FormStateContext";
@@ -12,12 +13,13 @@ import {
ModelName,
RelationshipPagination,
} from "../../../types";
+import { useFormData } from "../../../utils";
import Button from "../../radix/Button";
+import EmbeddedFormModal from "../EmbeddedForm/EmbeddedFormModal";
import { Selector } from "../Selector";
import MultiSelectDisplayList from "./MultiSelectDisplayList";
import MultiSelectDisplayTable from "./MultiSelectDisplayTable";
import MultiSelectItem from "./MultiSelectItem";
-import { useFormData } from "../../../utils";
type Props = {
options?: Enumeration[];
@@ -27,19 +29,25 @@ type Props = {
disabled: boolean;
required?: boolean;
schema: RJSFSchema;
+ formContext?: any;
};
const MultiSelectWidget = (props: Props) => {
- const { options: globalOptions, resource } = useConfig();
+ const { options: globalOptions, resource, resourcesIdProperty } = useConfig();
const { onToggle, isOpen, onClose } = useDisclosure();
const containerRef = useClickOutside(() => onClose());
- const { formData, onChange, options, name, schema } = props;
+ const { formData, onChange, options, name, schema, formContext } = props;
const { t } = useI18n();
const { setFieldDirty } = useFormState();
const fieldOptions =
globalOptions?.model?.[resource!]?.edit?.fields?.[name as Field];
const { relationshipsRawData } = useFormData();
+ // State for embedded form modal
+ const [showCreateModal, setShowCreateModal] = useState(false);
+ const [showEditModal, setShowEditModal] = useState(false);
+ const [editingItemId, setEditingItemId] = useState(null);
+
const onRemoveClick = (value: any) => {
setFieldDirty(name);
onChange(formData?.filter((item: Enumeration) => item.value !== value));
@@ -61,6 +69,59 @@ const MultiSelectWidget = (props: Props) => {
// @ts-expect-error
displayMode === "list" && (!!fieldOptions?.orderField || !!schema.enum);
+ // Check if allowCreate is enabled for this field
+ const allowCreate = !!fieldOptions && "allowCreate" in fieldOptions && fieldOptions.allowCreate;
+
+ // Check if allowEdit is enabled for this field
+ const allowEdit = !!fieldOptions && "allowEdit" in fieldOptions && fieldOptions.allowEdit;
+
+ // Get the related model name from schema
+ // @ts-expect-error
+ const relatedModel = schema.items?.relation as ModelName;
+ const parentId = formContext?.parentId;
+
+ // Handle successful creation of new item
+ const handleCreateSuccess = (newItemData: any) => {
+ if (newItemData && resourcesIdProperty) {
+ const idProperty = resourcesIdProperty[relatedModel];
+ const newItem: Enumeration = {
+ value: newItemData[idProperty],
+ label: newItemData.toString?.() || newItemData[idProperty]?.toString() || "New Item",
+ data: newItemData,
+ };
+
+ setFieldDirty(name);
+ onChange([...(formData || []), newItem]);
+ }
+ setShowCreateModal(false);
+ };
+
+ // Handle successful editing of existing item
+ const handleEditSuccess = (updatedItemData: any) => {
+ if (updatedItemData && resourcesIdProperty && editingItemId) {
+ const idProperty = resourcesIdProperty[relatedModel];
+ const updatedItem: Enumeration = {
+ value: updatedItemData[idProperty],
+ label: updatedItemData.toString?.() || updatedItemData[idProperty]?.toString() || "Updated Item",
+ data: updatedItemData,
+ };
+
+ setFieldDirty(name);
+ // Replace the edited item in the existing list
+ const updatedFormData = (formData || []).map((item: Enumeration) =>
+ item.value === editingItemId ? updatedItem : item
+ );
+ onChange(updatedFormData);
+ }
+ setShowEditModal(false);
+ setEditingItemId(null);
+ };
+
+ const handleEditItem = (itemValue: string) => {
+ setEditingItemId(itemValue);
+ setShowEditModal(true);
+ };
+
const select = (
);
+ const createButton: ReactNode = allowCreate && relatedModel && !props.disabled ? (
+
+ ) : null;
+
+ const editButton: ReactNode = allowEdit && relatedModel && !props.disabled && formData?.length > 0 ? (
+
+ ) : null;
+
return (
{displayMode === "select" && (
-
- {select}
- {formData?.map(
- (value: any, index: number) =>
- value && (
-
- )
- )}
- {!props.disabled && (
-
-
-
- )}
+
+
+ {select}
+ {formData?.map(
+ (value: any, index: number) =>
+ value && (
+
+ )
+ )}
+ {!props.disabled && (
+
+
+
+ )}
+
+ {createButton}
+ {editButton}
)}
{displayMode === "list" && (
@@ -132,15 +227,19 @@ const MultiSelectWidget = (props: Props) => {
pagination={fieldPagination}
/>
-
+
+
+ {createButton}
+ {editButton}
+
)}
{displayMode === "table" && (
@@ -154,15 +253,19 @@ const MultiSelectWidget = (props: Props) => {
rawData={relationshipsRawData?.[name]}
/>
-
+
+
+ {createButton}
+ {editButton}
+
)}
@@ -177,6 +280,31 @@ const MultiSelectWidget = (props: Props) => {
}}
selectedOptions={selectedValues}
/>
+
+ {/* Embedded Form Modal for creating new items */}
+ {showCreateModal && allowCreate && relatedModel ? (
+ setShowCreateModal(false)}
+ onSuccess={handleCreateSuccess}
+ />
+ ) : null}
+ {/* Embedded Form Modal for editing items */}
+ {showEditModal && allowEdit && relatedModel && editingItemId ? (
+ {
+ setShowEditModal(false);
+ setEditingItemId(null);
+ }}
+ onSuccess={handleEditSuccess}
+ />
+ ) : null}
);
};
diff --git a/packages/next-admin/src/components/inputs/SelectWidget.tsx b/packages/next-admin/src/components/inputs/SelectWidget.tsx
index b861042e..9a58d51b 100644
--- a/packages/next-admin/src/components/inputs/SelectWidget.tsx
+++ b/packages/next-admin/src/components/inputs/SelectWidget.tsx
@@ -4,15 +4,18 @@ import {
} from "@heroicons/react/24/outline";
import { WidgetProps } from "@rjsf/utils";
import clsx from "clsx";
-import Link from "../common/Link";
-import { useMemo } from "react";
+import { ReactNode, useMemo, useState } from "react";
import DoubleArrow from "../../assets/icons/DoubleArrow";
import { useConfig } from "../../context/ConfigContext";
import { useFormState } from "../../context/FormStateContext";
+import { useI18n } from "../../context/I18nContext";
import useClickOutside from "../../hooks/useCloseOnOutsideClick";
import { useDisclosure } from "../../hooks/useDisclosure";
-import { Enumeration } from "../../types";
+import { Enumeration, Field, ModelName } from "../../types";
import { slugify } from "../../utils/tools";
+import Link from "../common/Link";
+import Button from "../radix/Button";
+import EmbeddedFormModal from "./EmbeddedForm/EmbeddedFormModal";
import { Selector } from "./Selector";
const SelectWidget = ({
@@ -21,6 +24,7 @@ const SelectWidget = ({
value,
disabled,
required,
+ formContext,
...props
}: WidgetProps) => {
const { isOpen, onToggle, onClose } = useDisclosure();
@@ -33,8 +37,23 @@ const SelectWidget = ({
(option: any) => option.value as Enumeration
);
const { setFieldDirty } = useFormState();
+ const { t } = useI18n();
+
+ const { basePath, options: globalOptions, resource, resourcesIdProperty } = useConfig();
+ const [showCreateModal, setShowCreateModal] = useState(false);
+ const [showEditModal, setShowEditModal] = useState(false);
+
+ const fieldOptions =
+ globalOptions?.model?.[resource!]?.edit?.fields?.[name as Field
];
+
+ // Check if allowCreate is enabled for this field
+ const allowCreate = !!fieldOptions && "allowCreate" in fieldOptions && fieldOptions.allowCreate;
- const { basePath } = useConfig();
+ // Check if allowEdit is enabled for this field
+ const allowEdit = !!fieldOptions && "allowEdit" in fieldOptions && fieldOptions.allowEdit;
+
+ const relatedModel = props.schema.relation as ModelName;
+ const parentId = formContext?.parentId;
const handleChange = (option: Enumeration | null) => {
setFieldDirty(props.name);
@@ -42,10 +61,66 @@ const SelectWidget = ({
onClose();
};
+ const handleCreateSuccess = (newItemData: any) => {
+ if (newItemData && resourcesIdProperty && relatedModel) {
+ const idProperty = resourcesIdProperty[relatedModel];
+ const newItem: Enumeration = {
+ value: newItemData[idProperty],
+ label: newItemData.toString?.() || newItemData[idProperty]?.toString() || "New Item",
+ data: newItemData,
+ };
+
+ setFieldDirty(name);
+ onChange(newItem);
+ }
+ setShowCreateModal(false);
+ };
+
+ const handleEditSuccess = (updatedItemData: any) => {
+ if (updatedItemData && resourcesIdProperty && relatedModel) {
+ const idProperty = resourcesIdProperty[relatedModel];
+ const updatedItem: Enumeration = {
+ value: updatedItemData[idProperty],
+ label: updatedItemData.toString?.() || updatedItemData[idProperty]?.toString() || "Updated Item",
+ data: updatedItemData,
+ };
+
+ setFieldDirty(name);
+ onChange(updatedItem);
+ }
+ setShowEditModal(false);
+ };
+
const hasValue = useMemo(() => {
return Object.keys(value || {}).length > 0;
}, [value]);
+ const createButton: ReactNode = allowCreate && relatedModel && !disabled ? (
+
+
+
+ ) : null;
+
+ const editButton: ReactNode = allowEdit && relatedModel && !disabled && hasValue ? (
+
+
+
+ ) : null;
+
return (
+ {createButton}
+ {editButton}
+ {showCreateModal && allowCreate && relatedModel ? (
+
setShowCreateModal(false)}
+ onSuccess={handleCreateSuccess}
+ />
+ ) : null}
+ {showEditModal && allowEdit && relatedModel && hasValue ? (
+ setShowEditModal(false)}
+ onSuccess={handleEditSuccess}
+ />
+ ) : null}
);
};
diff --git a/packages/next-admin/src/context/FormStateContext.tsx b/packages/next-admin/src/context/FormStateContext.tsx
index f446170c..051a6ac9 100644
--- a/packages/next-admin/src/context/FormStateContext.tsx
+++ b/packages/next-admin/src/context/FormStateContext.tsx
@@ -8,8 +8,8 @@ import {
const FormStateContext = createContext({
dirtyFields: [] as string[],
- setFieldDirty: (_name: string) => {},
- cleanAll: () => {},
+ setFieldDirty: (_name: string) => { },
+ cleanAll: () => { },
});
export const useFormState = () => useContext(FormStateContext);
diff --git a/packages/next-admin/src/context/ResourceContext.tsx b/packages/next-admin/src/context/ResourceContext.tsx
new file mode 100644
index 00000000..dea4623f
--- /dev/null
+++ b/packages/next-admin/src/context/ResourceContext.tsx
@@ -0,0 +1,47 @@
+"use client";
+import { UiSchema } from "@rjsf/utils";
+import { createContext, PropsWithChildren, useContext } from "react";
+import { ModelName, SchemaModel } from "../types";
+
+export type ResourceContextType = {
+ resource: M;
+ modelSchema: SchemaModel;
+ uiSchema: UiSchema;
+};
+
+const ResourceContext = createContext(null);
+
+type ResourceProviderProps = PropsWithChildren<{
+ resource: M;
+ modelSchema: SchemaModel;
+ uiSchema: UiSchema;
+}>;
+
+const ResourceProvider = ({
+ children,
+ resource,
+ modelSchema,
+ uiSchema,
+}: ResourceProviderProps) => {
+ const contextValue: ResourceContextType = {
+ resource,
+ modelSchema,
+ uiSchema,
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useResource = () => {
+ const context = useContext(ResourceContext);
+ if (!context) {
+ throw new Error("useResource must be used within a ResourceProvider");
+ }
+ return context as ResourceContextType;
+};
+
+export default ResourceProvider;
\ No newline at end of file
diff --git a/packages/next-admin/src/i18n.ts b/packages/next-admin/src/i18n.ts
index bcb8ef4c..56232d59 100644
--- a/packages/next-admin/src/i18n.ts
+++ b/packages/next-admin/src/i18n.ts
@@ -31,7 +31,13 @@ export const defaultTranslations: Translations = {
"form.widgets.file_upload.drag_and_drop": "or drag and drop",
"form.widgets.file_upload.delete": "Delete",
"form.widgets.multiselect.select": "Select items",
+ "form.widgets.multiselect.create": "Create item",
+ "form.widgets.select.create": "Create item",
+ "form.widgets.multiselect.edit": "Edit item",
+ "form.widgets.select.edit": "Edit item",
"form.widgets.scalar_array.add": "Add new item",
+ "form.embedded.parent_relation_locked":
+ "This field is automatically filled based on the parent record and cannot be modified.",
"form.create.succeed": "Created successfully",
"form.update.succeed": "Updated successfully",
"form.delete.succeed": "Deleted successfully",
diff --git a/packages/next-admin/src/pageHandler.ts b/packages/next-admin/src/pageHandler.ts
index e1bf7f15..8c33509c 100644
--- a/packages/next-admin/src/pageHandler.ts
+++ b/packages/next-admin/src/pageHandler.ts
@@ -1,4 +1,5 @@
-import { PrismaClient } from "./types-prisma";
+import type { PrismaClient } from "./types-prisma";
+import cloneDeep from "lodash.clonedeep";
import type { NextApiRequest, NextApiResponse } from "next";
import { NextHandler, createRouter } from "next-connect";
import { HookError } from "./exceptions/HookError";
@@ -10,16 +11,19 @@ import {
Permission,
ServerAction,
} from "./types";
+import { getSchema, initGlobals } from "./utils/globals";
+import { getSchemas } from "./utils/jsonSchema";
import { hasPermission } from "./utils/permissions";
-import { getRawData } from "./utils/prisma";
+import { getDataItem, getRawData } from "./utils/prisma";
import {
+ applyVisiblePropertiesInSchema,
formatId,
getFormDataValues,
getJsonBody,
getResourceFromParams,
getResources,
+ transformSchema,
} from "./utils/server";
-import { getSchema, initGlobals } from "./utils/globals";
type CreateAppHandlerParams = {
/**
@@ -172,6 +176,67 @@ export const createHandler =
({
return res.json(data);
})
+ .get(`${apiBasePath}/:model/schema/:id`, async (req, res) => {
+ const resources = getResources(options);
+
+ const resource = getResourceFromParams(
+ [req.query[paramKey]![0]],
+ resources
+ );
+
+ if (!resource) {
+ return res.status(404).json({ error: "Resource not found" });
+ }
+
+ const isSchemaRequest = req.query[paramKey]![1] === "schema";
+ if (!isSchemaRequest) {
+ return res.status(400).json({ error: "Invalid schema request" });
+ }
+
+ const id = req.query[paramKey]![2]
+ ? formatId(resource, req.query[paramKey]![2])
+ : undefined;
+
+ const edit = options?.model?.[resource]?.edit;
+
+ try {
+ let deepCopySchema = await transformSchema(
+ resource,
+ //@ts-expect-error
+ edit,
+ options
+ )(cloneDeep(getSchema()));
+
+ let data = null;
+ let relationshipsRawData = null;
+
+ if (id) {
+ const result = await getDataItem({
+ prisma,
+ resource,
+ resourceId: id,
+ options,
+ });
+ data = result.data;
+ relationshipsRawData = result.relationshipsRawData;
+
+ //@ts-expect-error
+ applyVisiblePropertiesInSchema(resource, edit, data, deepCopySchema);
+ }
+
+ //@ts-expect-error
+ const { uiSchema } = getSchemas(data, deepCopySchema, edit?.fields);
+
+ return res.json({
+ data,
+ modelSchema: deepCopySchema,
+ uiSchema,
+ relationshipsRawData,
+ });
+ } catch (e) {
+ return res.status(500).json({ error: (e as Error).message });
+ }
+ })
.post(`${apiBasePath}/:model/:id?`, async (req, res) => {
const resources = getResources(options);
diff --git a/packages/next-admin/src/types.ts b/packages/next-admin/src/types.ts
index a6a5c033..3b07fe1d 100644
--- a/packages/next-admin/src/types.ts
+++ b/packages/next-admin/src/types.ts
@@ -234,6 +234,18 @@ type OptionFormatterFromRelationshipSearch<
* model name on which to execute a research. Useful in case the field is related to an explicit many-to-many table
*/
relationshipSearchField?: S;
+ /**
+ * Allow creation of new items in the related model
+ * @default false
+ * @type boolean
+ */
+ allowCreate?: boolean;
+ /**
+ * Allow editing of the selected item in the related model
+ * @default false
+ * @type boolean
+ */
+ allowEdit?: boolean;
}
| {
/**
@@ -242,6 +254,18 @@ type OptionFormatterFromRelationshipSearch<
* @returns
*/
optionFormatter?: (item: ModelFromProperty) => string;
+ /**
+ * Allow creation of new items in the related model
+ * @default false
+ * @type boolean
+ */
+ allowCreate?: boolean;
+ /**
+ * Allow editing of the selected item in the related model
+ * @default false
+ * @type boolean
+ */
+ allowEdit?: boolean;
};
}[RelationshipSearch>["field"]];
@@ -1097,6 +1121,10 @@ export type TranslationKeys =
| "form.widgets.file_upload.drag_and_drop"
| "form.widgets.file_upload.delete"
| "form.widgets.multiselect.select"
+ | "form.widgets.multiselect.create"
+ | "form.widgets.select.create"
+ | "form.widgets.multiselect.edit"
+ | "form.widgets.select.edit"
| "form.widgets.scalar_array.add"
| "selector.loading"
| "theme.dark"
@@ -1244,6 +1272,8 @@ export type FormProps = {
resourcesIdProperty: Record;
clientActionsComponents?: AdminComponentProps["dialogComponents"];
relationshipsRawData?: RelationshipsRawData;
+ onSubmitCallback?: (data: any) => void;
+ isEmbedded?: boolean;
};
export type ClientActionDialogContentProps = Partial<{
diff --git a/packages/next-admin/src/utils/jsonSchema.ts b/packages/next-admin/src/utils/jsonSchema.ts
index f6d648f3..364dd1a5 100644
--- a/packages/next-admin/src/utils/jsonSchema.ts
+++ b/packages/next-admin/src/utils/jsonSchema.ts
@@ -25,6 +25,16 @@ function filterProperties(properties: any): Record {
) {
// @ts-expect-error
filteredProperties[property] = attributes;
+ } else if (
+ // Allow relation fields that have been processed by fillRelationInSchema
+ // These fields will have a 'relation' property and 'enum' array, even if they still have some $ref traces
+ attributes &&
+ (attributes.relation ||
+ (attributes.items && attributes.items.relation)) &&
+ (attributes.enum || (attributes.items && attributes.items.enum))
+ ) {
+ // @ts-expect-error
+ filteredProperties[property] = attributes;
}
}
);
@@ -44,6 +54,13 @@ export function getSchemaForResource(schema: any, resource: string) {
return resourceSchema;
}
+export function getSchemasForResource(
+ schema: any,
+ resource: string,
+ edit: boolean,
+ editFieldsOptions?: EditFieldsOptions
+) {}
+
export function getSchemas(
data: any,
schema: SchemaDefinitions[M],
diff --git a/turbo.json b/turbo.json
index a8c21bfc..4d60b370 100644
--- a/turbo.json
+++ b/turbo.json
@@ -17,7 +17,7 @@
"dependsOn": ["@premieroctet/next-admin-generator-prisma#build"]
},
"example#build": {
- "dependsOn": ["database#generate"]
+ "dependsOn": ["database#generate", "examples-common#build"]
},
"build": {
"dependsOn": ["^build"],