diff --git a/apps/example/options.tsx b/apps/example/options.tsx index 3138f5c5..2d1c678c 100644 --- a/apps/example/options.tsx +++ b/apps/example/options.tsx @@ -84,6 +84,7 @@ export const options: NextAdminOptions = { } as const, "email", "posts", + "coPosts", "role", "birthDate", "avatar", @@ -121,6 +122,9 @@ export const options: NextAdminOptions = { display: "list", orderField: "order", }, + role: { + //disabled: true, + }, avatar: { format: "file", handler: { @@ -157,7 +161,7 @@ export const options: NextAdminOptions = { }, hooks: { beforeDb: async (data, mode, request) => { - const newPassword = data.newPassword; + const newPassword = data.password; if (newPassword) { data.hashedPassword = `hashed-${newPassword}`; } @@ -277,6 +281,17 @@ export const options: NextAdminOptions = { }, }, edit: { + styles: { + _form: "grid-cols-3 gap-4 md:grid-cols-4", + id: "col-span-2 row-start-1", + title: "col-span-2 row-start-1", + content: "col-span-4 row-start-2", + published: "col-span-4 md:col-span-2 row-start-3", + categories: "col-span-4 md:col-span-2 row-start-4", + author: "col-span-4 md:col-span-3 row-start-5", + rate: "col-span-3 row-start-6", + tags: "col-span-4 row-start-7", + }, fields: { content: { format: "richtext-html", @@ -297,6 +312,7 @@ export const options: NextAdminOptions = { "published", "categories", "author", + "coAuthors", "rate", "tags", ], diff --git a/apps/example/prisma/json-schema/json-schema.json b/apps/example/prisma/json-schema/json-schema.json new file mode 100644 index 00000000..1aae53cc --- /dev/null +++ b/apps/example/prisma/json-schema/json-schema.json @@ -0,0 +1,250 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "User": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "email": { + "type": "string" + }, + "hashedPassword": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "posts": { + "type": "array", + "items": { + "$ref": "#/definitions/Post" + } + }, + "coPosts": { + "type": "array", + "items": { + "$ref": "#/definitions/Post" + } + }, + "profile": { + "anyOf": [ + { + "$ref": "#/definitions/Profile" + }, + { + "type": "null" + } + ] + }, + "birthDate": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "role": { + "type": "string", + "default": "USER", + "enum": [ + "USER", + "ADMIN" + ] + }, + "avatar": { + "type": [ + "string", + "null" + ] + }, + "metadata": { + "type": [ + "number", + "string", + "boolean", + "object", + "array", + "null" + ] + } + }, + "required": [ + "email" + ] + }, + "Post": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "content": { + "type": [ + "string", + "null" + ] + }, + "published": { + "type": "boolean", + "default": false + }, + "author": { + "$ref": "#/definitions/User" + }, + "coAuthors": { + "type": "array", + "items": { + "$ref": "#/definitions/User" + } + }, + "categories": { + "type": "array", + "items": { + "$ref": "#/definitions/CategoriesOnPosts" + } + }, + "rate": { + "type": [ + "number", + "null" + ] + }, + "order": { + "type": "integer", + "default": 0 + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "title", + "authorId", + "tags" + ] + }, + "Profile": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "bio": { + "type": [ + "string", + "null" + ] + }, + "user": { + "anyOf": [ + { + "$ref": "#/definitions/User" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [] + }, + "Category": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "posts": { + "type": "array", + "items": { + "$ref": "#/definitions/CategoriesOnPosts" + } + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "name" + ] + }, + "CategoriesOnPosts": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "post": { + "$ref": "#/definitions/Post" + }, + "category": { + "$ref": "#/definitions/Category" + }, + "order": { + "type": "integer", + "default": 0 + } + }, + "required": [ + "postId", + "categoryId" + ] + } + }, + "type": "object", + "properties": { + "user": { + "$ref": "#/definitions/User" + }, + "post": { + "$ref": "#/definitions/Post" + }, + "profile": { + "$ref": "#/definitions/Profile" + }, + "category": { + "$ref": "#/definitions/Category" + }, + "categoriesOnPosts": { + "$ref": "#/definitions/CategoriesOnPosts" + } + } +} \ No newline at end of file diff --git a/apps/example/prisma/migrations/20240919142125_add_coauthors/migration.sql b/apps/example/prisma/migrations/20240919142125_add_coauthors/migration.sql new file mode 100644 index 00000000..e5c69b9b --- /dev/null +++ b/apps/example/prisma/migrations/20240919142125_add_coauthors/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "_coAuthors" ( + "A" INTEGER NOT NULL, + "B" INTEGER NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "_coAuthors_AB_unique" ON "_coAuthors"("A", "B"); + +-- CreateIndex +CREATE INDEX "_coAuthors_B_index" ON "_coAuthors"("B"); + +-- AddForeignKey +ALTER TABLE "_coAuthors" ADD CONSTRAINT "_coAuthors_A_fkey" FOREIGN KEY ("A") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_coAuthors" ADD CONSTRAINT "_coAuthors_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/example/prisma/schema.prisma b/apps/example/prisma/schema.prisma index d0e2fd3d..7179d30b 100644 --- a/apps/example/prisma/schema.prisma +++ b/apps/example/prisma/schema.prisma @@ -27,6 +27,7 @@ model User { hashedPassword String? name String? posts Post[] @relation("author") // One-to-many relation + coPosts Post[] @relation("coAuthors") // Many-to-many relation profile Profile? @relation("profile") // One-to-one relation birthDate DateTime? createdAt DateTime @default(now()) @@ -43,6 +44,7 @@ model Post { published Boolean @default(false) author User @relation("author", fields: [authorId], references: [id]) // Many-to-one relation authorId Int + coAuthors User[] @relation("coAuthors") // Many-to-many relation categories CategoriesOnPosts[] rate Decimal? @db.Decimal(5, 2) order Int @default(0) diff --git a/packages/next-admin/src/appHandler.ts b/packages/next-admin/src/appHandler.ts index ca6bd4b9..a9a4f1a1 100644 --- a/packages/next-admin/src/appHandler.ts +++ b/packages/next-admin/src/appHandler.ts @@ -11,7 +11,7 @@ import { ServerAction, } from "./types"; import { hasPermission } from "./utils/permissions"; -import { getRawData } from "./utils/prisma"; +import { getDataItem, getRawData } from "./utils/prisma"; import { formatId, getFormValuesFromFormData, @@ -80,6 +80,50 @@ export const createHandler =
({ return NextResponse.json(data); }) + .get(`${apiBasePath}/:model/:id?`, async (req, ctx) => { + const resource = getResourceFromParams(ctx.params[paramKey], resources); + + if (!resource) { + return NextResponse.json( + { error: "Resource not found" }, + { status: 404 } + ); + } + + const id = + ctx.params[paramKey].length === 2 + ? formatId(resource, ctx.params[paramKey].at(-1)!) + : undefined; + + if ( + (id && hasPermission(options?.model?.[resource], Permission.EDIT)) || + (!id && hasPermission(options?.model?.[resource], Permission.CREATE)) + ) { + let data; + if (id) { + data = await getDataItem({ + prisma, + resource, + resourceId: id, + options, + }); + } + + let { uiSchema, schema: modelSchema } = await transformSchema( + resource, + schema, + options, + data + ); + + return NextResponse.json({ data: data ?? {}, modelSchema, uiSchema }); + } else { + return NextResponse.json( + { error: "You don't have permission to view this resource" }, + { status: 403 } + ); + } + }) .post(`${apiBasePath}/:model/actions/:id`, async (req, ctx) => { const id = ctx.params[paramKey].at(-1)!; @@ -151,6 +195,18 @@ export const createHandler =
({
? formatId(resource, ctx.params[paramKey].at(-1)!)
: undefined;
+ console.log("id", id);
+
+ if (
+ (id && !hasPermission(options?.model?.[resource], Permission.EDIT)) ||
+ (!id && !hasPermission(options?.model?.[resource], Permission.CREATE))
+ ) {
+ return NextResponse.json(
+ { error: "You don't have permission to edit this resource" },
+ { status: 403 }
+ );
+ }
+
const editOptions = options?.model?.[resource]?.edit;
const mode = !!id ? "edit" : "create";
diff --git a/packages/next-admin/src/components/Form.tsx b/packages/next-admin/src/components/Form.tsx
index a39c24fd..2c0e035d 100644
--- a/packages/next-admin/src/components/Form.tsx
+++ b/packages/next-admin/src/components/Form.tsx
@@ -11,6 +11,7 @@ import {
FieldTemplateProps,
getSubmitButtonOptions,
ObjectFieldTemplateProps,
+ RJSFSchema,
SubmitButtonProps,
} from "@rjsf/utils";
import validator from "@rjsf/validator-ajv8";
@@ -30,21 +31,24 @@ import React, {
import { twMerge } from "tailwind-merge";
import ClientActionDialogProvider from "../context/ClientActionDialogContext";
import { useConfig } from "../context/ConfigContext";
-import FormDataProvider, { useFormData } from "../context/FormDataContext";
+import FormDataProvider, {
+ FormDataConsumer,
+ useFormData,
+} from "../context/FormDataContext";
import FormStateProvider, { useFormState } from "../context/FormStateContext";
import { useI18n } from "../context/I18nContext";
import { MessageProvider, useMessage } from "../context/MessageContext";
+import ResourceProvider, { useResource } from "../context/ResourceContext";
import { useDeleteAction } from "../hooks/useDeleteAction";
import { useRouterInternal } from "../hooks/useRouterInternal";
import {
- EditFieldsOptions,
Field,
FormProps,
+ FormWrapperProps,
ModelName,
ModelOptions,
Permission,
} from "../types";
-import { getSchemas } from "../utils/jsonSchema";
import { formatLabel, slugify } from "../utils/tools";
import FormHeader from "./FormHeader";
import ArrayField from "./inputs/ArrayField";
@@ -85,15 +89,17 @@ const widgets: RjsfForm["props"]["widgets"] = {
TextareaWidget: TextareaWidget,
};
-const Form = ({
+export const Form =