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"],