From dbb5a3e610439d39fe63a40abeabc19de67f3674 Mon Sep 17 00:00:00 2001 From: Regourd Colin Date: Wed, 22 Nov 2023 18:09:13 +0100 Subject: [PATCH] V2 (#59) * Add formatting system to relationship column and fix relationshsip one-to-many issue * Create early-suns-tan.md * changes changeset * Fix ID field doesn't display * centralize option for next admin * change options stucture * Create short-emus-move.md * fix formatter type * add test for getMappedDataList function * change type and add formatter for example * remove unused stringify for formatter * restore formatter in example * add format for date * Create young-chicken-beam.md * restore custom formatter in list for date format * add handler props and fix date utc * add changeset * add test and change function * change options * fix: store correct date time (#70) * chore: :art: apply prettier to all files * Remove Date instanciation * Add datetime widget, send date as iso string * Remove comment, revert example config * Fix tests * Default date time widget value to empty string * Handle date input * feat: add file upload widget (#71) * feat: add file upload widget * Fix crash on home page * Fix crash * Try fixing e2e * Add e2e artifacts * Use file format * Add file preview * Improve deletion behavior * Fix wording * Update documentation * Fix typo --------- Co-authored-by: Hugo FOYART <11079152+foyarash@users.noreply.github.com> --- .changeset/chilly-avocados-wink.md | 2 - .changeset/early-suns-tan.md | 5 + .changeset/short-emus-move.md | 13 + .changeset/young-chicken-beam.md | 7 + .github/workflows/e2e.yml | 6 + apps/docs/pages/docs/api-docs.mdx | 169 ++--- .env => apps/example/.env | 3 +- .env.example => apps/example/.env.example | 0 apps/example/e2e/table.spec.ts | 8 +- apps/example/pages/admin/[[...nextadmin]].tsx | 202 +++--- apps/example/playwright.config.ts | 8 +- .../prisma/json-schema/json-schema.json | 6 + .../20231121135427_user_avatar/migration.sql | 2 + apps/example/prisma/schema.prisma | 40 +- package.json | 3 +- packages/next-admin/package.json | 2 + packages/next-admin/src/components/Cell.tsx | 32 +- .../next-admin/src/components/DataTable.tsx | 40 +- packages/next-admin/src/components/Form.tsx | 23 +- packages/next-admin/src/components/List.tsx | 90 +-- .../next-admin/src/components/ListHeader.tsx | 2 +- packages/next-admin/src/components/Menu.tsx | 340 +++++----- .../next-admin/src/components/NextAdmin.tsx | 2 +- .../src/components/inputs/DateTimeWidget.tsx | 44 ++ .../src/components/inputs/DateWidget.tsx | 19 + .../src/components/inputs/FileWidget.tsx | 167 +++++ .../src/components/inputs/SelectWidget.tsx | 4 +- .../next-admin/src/context/ConfigContext.tsx | 35 +- packages/next-admin/src/index.tsx | 2 +- packages/next-admin/src/router.tsx | 605 +++++++++--------- .../next-admin/src/tests/prismaUtils.test.ts | 39 ++ .../next-admin/src/tests/serverUtils.test.ts | 28 +- packages/next-admin/src/tests/singleton.ts | 94 ++- packages/next-admin/src/types.ts | 131 ++-- packages/next-admin/src/utils/prisma.ts | 91 ++- packages/next-admin/src/utils/server.ts | 343 +++++++--- packages/next-admin/src/utils/tools.ts | 4 +- .../next-admin/src/utils/validator.test.ts | 2 +- packages/next-admin/src/utils/validator.ts | 10 +- yarn.lock | 111 +++- 40 files changed, 1738 insertions(+), 996 deletions(-) delete mode 100644 .changeset/chilly-avocados-wink.md create mode 100644 .changeset/early-suns-tan.md create mode 100644 .changeset/short-emus-move.md create mode 100644 .changeset/young-chicken-beam.md rename .env => apps/example/.env (94%) rename .env.example => apps/example/.env.example (100%) create mode 100644 apps/example/prisma/migrations/20231121135427_user_avatar/migration.sql create mode 100644 packages/next-admin/src/components/inputs/DateTimeWidget.tsx create mode 100644 packages/next-admin/src/components/inputs/DateWidget.tsx create mode 100644 packages/next-admin/src/components/inputs/FileWidget.tsx create mode 100644 packages/next-admin/src/tests/prismaUtils.test.ts diff --git a/.changeset/chilly-avocados-wink.md b/.changeset/chilly-avocados-wink.md deleted file mode 100644 index a845151c..00000000 --- a/.changeset/chilly-avocados-wink.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/early-suns-tan.md b/.changeset/early-suns-tan.md new file mode 100644 index 00000000..caae90d3 --- /dev/null +++ b/.changeset/early-suns-tan.md @@ -0,0 +1,5 @@ +--- +"@premieroctet/next-admin": patch +--- + +Add formatting system to relationship column diff --git a/.changeset/short-emus-move.md b/.changeset/short-emus-move.md new file mode 100644 index 00000000..e04445e6 --- /dev/null +++ b/.changeset/short-emus-move.md @@ -0,0 +1,13 @@ +--- +"@premieroctet/next-admin": major +--- + +- Always fetch `id` property from items +- Hide `id`column in list if it doesn't have `display: true` options +- Mutualize `NextAdminOptions` to reduce useless duplication of code + +⚠️ **Breaking Changes** + +To reduce the complexity and duplication of next-admin options, this PR contains major changes to the structure of the options. + +- Changed structure for `display` and `search` properties: fields are now entered as an array diff --git a/.changeset/young-chicken-beam.md b/.changeset/young-chicken-beam.md new file mode 100644 index 00000000..3f26307b --- /dev/null +++ b/.changeset/young-chicken-beam.md @@ -0,0 +1,7 @@ +--- +"@premieroctet/next-admin": patch +--- + +Add ability to choose format type for Date property between date-time and date in edit options +Introduce handler object to handle custom logic for a property in edit mode +Add ability to use input variant in edit mode diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index fa6b38b4..1fe1dd97 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -34,3 +34,9 @@ jobs: run: yarn start:example & - name: Run tests run: yarn turbo test:e2e + - uses: actions/upload-artifact@v3 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/apps/docs/pages/docs/api-docs.mdx b/apps/docs/pages/docs/api-docs.mdx index 516fe594..f209f573 100644 --- a/apps/docs/pages/docs/api-docs.mdx +++ b/apps/docs/pages/docs/api-docs.mdx @@ -21,8 +21,8 @@ export const getServerSideProps: GetServerSideProps = async ({ req, res }) => { It takes 3 parameters: -- Your Prisma client instance, _required -- Your Prisma schema, _required +- Your Prisma client instance, _required_ +- Your Prisma schema, _required_ and an _optional_ object of type [`NextAdminOptions`](#nextadminoptions) to customize your admin with the following properties: @@ -30,8 +30,8 @@ and an _optional_ object of type [`NextAdminOptions`](#nextadminoptions) to cust import { NextAdminOptions } from "@premieroctet/next-admin"; const options: NextAdminOptions = { - modelOptions: { - user: { + model: { + User: { toString: (user) => `${user.email} / ${user.name}`, }, }, @@ -51,19 +51,19 @@ The library does not provide an authentication system. If you want to add your o export const getServerSideProps: GetServerSideProps = async ({ req, res }) => { const session = await getServerSession(req, res, authOptions); - const isAdmin = session?.user?.role === 'SUPERADMIN'; // your role check + const isAdmin = session?.user?.role === "SUPERADMIN"; // your role check if (!isAdmin) { return { redirect: { - destination: '/', + destination: "/", permanent: false, }, }; } const { nextAdminRouter } = await import( - '@premieroctet/next-admin/dist/nextAdminRouter' + "@premieroctet/next-admin/dist/nextAdminRouter" ); return nextAdminRouter(client).run(req, res); }; @@ -72,6 +72,7 @@ export const getServerSideProps: GetServerSideProps = async ({ req, res }) => { ## `` component `` is a React component that contains the entire UI of Next Admin. It can take several props: + - `AdminComponentProps`, which are passed by the [router function](#nextadminrouter-function) via getServerSideProps - `options` used to customize the UI, like field formatters for example - `dashboard` used to customize the rendered dashboard @@ -86,21 +87,36 @@ import Dashboard from "../../components/CustomDashboard"; export default function Admin(props: AdminComponentProps) { /* Props are passed from the nextAdminRouter function via getServerSideProps */ - return { - return {user.role as string}; + return ( + { + return {role.toString()}; + }, + }, + birthDate: { + formatter: (date) => { + return new Date(date as unknown as string) + ?.toLocaleString() + .split(" ")[0]; + }, + }, }, - } - } - } - } - } - }} />; + }, + }, + }, + }} + /> + ); } ``` @@ -110,49 +126,58 @@ Next Admin options is the third parameter of the [router function](#nextadminrou ### `model` -`model` is an object that represents the customization options for each model in your schema. +`model` is an object that represents the customization options for each model in your schema. It takes as **_key_** a model name of your schema as **_value_** an object to customize your it. By default if no models are defined, they will all be displayed in the admin. If you want more control, you have to define each model individually as empty objects or with the following properties: -| Name | Description | Default value | -| ----------------- | -------------------------------------------------------------- | ------------- | -| [fields](#fields) | an object that define options for the fields of your model | undefined | -| toString | a function that is used to display your record in related list | `id` field | +| Name | Description | Default value | +| ---------- | -------------------------------------------------------------- | ------------- | +| `toString` | a function that is used to display your record in related list | `id` field | You can customize the following for each model: + - ##### `list` property This property determines how your data is displayed in the [List View](/docs/glossary#list-view) -You can disable a feature for any field in the `fields` that follow this form: - -| Name | Description | Default value | -| ------- | ------------------------------------------------------------------------------ | ------------- | -| search | a boolean that define wether this field is searchable | true | -| display | a boolean that define wether this field should be visible in the list view | true (*) | +| Name | Description | Default value | +| ------- | ----------------------------------------------------------------------- | ------------------------- | +| search | an array of searchable fields | all fields are searchable | +| display | an array of fields that are displayed in the list | all fields are displayed | +| fields | an object containing the model fields as keys, and customization values | undefined | > Note that the `search` property is only available for `scalar` fields. -> (*) If `display` isn't set anywhere, all fields are displayed; once you set a display property to true, all fields are hidden by default except the ones you set to `display: true. - -> 💡 By default, all fields are searchable and visible, use the `list` property to return a limited subset of fields instead of all fields in the list view. - - ##### `edit` property This property determines how your data is displayed in the [edit view](/docs/glossary#edit-view) -You can disable a property for any field in the `fields` that follow this form: +| Name | Description | Default value | +| ------- | ----------------------------------------------------------------------- | ------------------------ | +| display | an array of fields that are displayed in the form | all fields are displayed | +| fields | an object containing the model fields as keys, and customization values | undefined | + +##### `fields` property + +The `fields` property is available in both `list` and `edit` properties. -| Name | Description | Default value | -| ------- | ---------------------------------------------------------- | ------------- | -| display | a boolean that define whether this field should be editable | true (*) | -| validate | a function to validate on the server the form value. It should returns true or the error message | undefined | +For the `list` property, it can take the following: -> (*) If `display` is not set anywhere, all fields are displayed; once you set a display property to true, all fields are hidden by default except the ones you've set to `display: true`. +| Name | Description | +| --------- | -------------------------------------------------------------------------------- | +| formatter | a function that takes the field value as a parameter, and that return a JSX node | -> 💡 By default all fields are searchable and visible, use the `edit` property to return a limited subset of fields instead of all fields in the edit view. +For the `edit` property, it can take the following: + +| Name | Description | +| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| validate | a function that takes the field value as a parameter, and that returns a boolean | +| format | a string defining an OpenAPI field format, overriding the one set in the generator. An extra `file` format can be used to be able to have a file input | +| handler | an object that can take the following properties | +| handler.get | a function that takes the field value as a parameter and returns a transformed value displayed in the form | +| handler.upload | an async function that is used only for formats `file` and `data-url`. It takes a buffer as parameter and must return a string. Useful to upload a file to a remote provider | Here is an example of using `NextAdminOptions` for the following schema : @@ -180,53 +205,44 @@ model User { ```tsx // pages/api/admin/[[...nextadmin]].ts const options: NextAdminOptions = { + basePath: "/admin", model: { - user: { + User: { toString: (user) => `${user.name} (${user.email})`, list: { + display: ["id", "name", "email", "posts", "role", "birthDate"], + search: ["name", "email"], fields: { - id: { - search: true, - display: true, - }, - name: { - search: true, - display: true, - }, - email: { - search: true, - display: true, - }, role: { - search: true, - display: true, + formatter: (role) => { + return {role.toString()}; + }, }, - posts: { - search: true, - display: true, + birthDate: { + formatter: (date) => { + return new Date(date as unknown as string) + ?.toLocaleString() + .split(" ")[0]; + }, }, }, }, edit: { + display: ["id", "name", "email", "posts", "role", "birthDate"], fields: { - id: { - display: true, - }, - name: { - display: true, - }, email: { - display: true, validate: (email) => email.includes("@") || "Invalid email", }, - role: { - display: true, - }, - posts: { - display: true, + birthDate: { + format: "date", }, - profile: { - display: true, + avatar: { + format: "file", + handler: { + upload: async (file: Buffer) => { + return "https://www.gravatar.com/avatar/00000000000000000000000000000000"; + }, + }, }, }, }, @@ -236,6 +252,3 @@ const options: NextAdminOptions = { const adminRouter = await nextAdminRouter(prisma, schema, options); ``` - -> Why do we have `@ts-expect-error` comments in the code above? -> We don't know if a relational field belongs to a model because of the Prisma type constraint. So if you want to use a relational field in the `fields` property, you need to add the `@ts-expect-error` comment to avoid TypeScript errors. \ No newline at end of file diff --git a/.env b/apps/example/.env similarity index 94% rename from .env rename to apps/example/.env index 92517f0f..9c1abb48 100644 --- a/.env +++ b/apps/example/.env @@ -7,5 +7,4 @@ POSTGRES_PRISMA_URL="postgresql://next-admin:next-admin@localhost:5432/next-admin?schema=public" POSTGRES_URL_NON_POOLING="postgresql://next-admin:next-admin@localhost:5432/next-admin?schema=public" BASE_URL="http://localhost:3000/admin" -BASE_DOMAIN="http://localhost:3000" -``` +BASE_DOMAIN="http://localhost:3000" \ No newline at end of file diff --git a/.env.example b/apps/example/.env.example similarity index 100% rename from .env.example rename to apps/example/.env.example diff --git a/apps/example/e2e/table.spec.ts b/apps/example/e2e/table.spec.ts index cc41028b..ec4fecb1 100644 --- a/apps/example/e2e/table.spec.ts +++ b/apps/example/e2e/table.spec.ts @@ -1,7 +1,7 @@ -import { test } from '@playwright/test'; -import { pagination, search, sort } from './utils'; +import { test } from "@playwright/test"; +import { pagination, search, sort } from "./utils"; -test.describe.serial('table test', () => { +test.describe.serial("table test", () => { test(`search (on user)`, async ({ page }) => { await search(page); }); @@ -13,4 +13,4 @@ test.describe.serial('table test', () => { test(`pagination (on user)`, async ({ page }) => { await pagination(page); }); -}); \ No newline at end of file +}); diff --git a/apps/example/pages/admin/[[...nextadmin]].tsx b/apps/example/pages/admin/[[...nextadmin]].tsx index 1c487b08..e1075854 100644 --- a/apps/example/pages/admin/[[...nextadmin]].tsx +++ b/apps/example/pages/admin/[[...nextadmin]].tsx @@ -1,130 +1,122 @@ -import { GetServerSideProps, GetServerSidePropsResult } from "next"; - -import { prisma } from "../../prisma"; -import schema from "../../prisma/json-schema/json-schema.json"; -import "@premieroctet/next-admin/dist/styles.css"; import { AdminComponentProps, NextAdmin, NextAdminOptions, } from "@premieroctet/next-admin"; +import "@premieroctet/next-admin/dist/styles.css"; +import { GetServerSideProps, GetServerSidePropsResult } from "next"; import Dashboard from "../../components/Dashboard"; +import { prisma } from "../../prisma"; +import schema from "../../prisma/json-schema/json-schema.json"; -export default function Admin(props: AdminComponentProps) { - return ( - { - return {user.role as string}; - }, - }, - }, +const options: NextAdminOptions = { + basePath: "/admin", + model: { + User: { + toString: (user) => `${user.name} (${user.email})`, + list: { + display: ["id", "name", "email", "posts", "role", "birthDate"], + search: ["name", "email"], + fields: { + role: { + formatter: (role) => { + return {role.toString()}; }, }, - }, - }} - /> - ); -} - -export const getServerSideProps: GetServerSideProps = async ({ req, res }) => { - const { nextAdminRouter } = await import( - "@premieroctet/next-admin/dist/router" - ); - - const options: NextAdminOptions = { - basePath: "/admin", - model: { - User: { - toString: (user) => `${user.name} (${user.email})`, - list: { - fields: { - id: { - search: true, - display: true, - }, - name: { - search: true, - display: true, - }, - email: { - search: true, - display: true, - }, - role: { - search: true, - display: true, - }, - posts: { - search: true, - display: true, + birthDate: { + formatter: (date) => { + return new Date(date as unknown as string) + ?.toLocaleString() + .split(" ")[0]; }, }, }, - edit: { - fields: { - id: { - display: true, - }, - name: { - display: true, - }, - email: { - display: true, - validate: (email) => email.includes("@") || "Invalid email", - }, - role: { - display: true, - }, - posts: { - display: true, - }, - profile: { - display: true, + }, + edit: { + display: [ + "id", + "name", + "email", + "posts", + "role", + "birthDate", + "avatar", + ], + fields: { + email: { + validate: (email) => email.includes("@") || "Invalid email", + }, + birthDate: { + format: "date", + }, + avatar: { + format: "file", + handler: { + /* + * Include your own upload handler here, + * for example you can upload the file to an S3 bucket. + * Make sure to return a string. + */ + upload: async (file: Buffer) => { + return "https://www.gravatar.com/avatar/00000000000000000000000000000000"; + }, }, }, }, }, - Post: { - toString: (post) => `${post.title}`, - list: { - fields: { - id: { - search: true, - display: true, - }, - title: { - search: true, - display: true, - }, - content: { - search: true, - display: true, - }, - published: { - display: true, - }, - authorId: { - display: true, - }, - categories: { - display: true, + }, + Post: { + toString: (post) => `${post.title}`, + list: { + display: [ + "id", + "title", + "content", + "published", + "author", + "categories", + ], + search: ["title", "content"], + fields: { + author: { + formatter: (author) => { + return {author.name}; }, }, }, }, - Category: { - toString: (category) => `${category.name}`, + edit: { + display: [ + "id", + "title", + "content", + "published", + "authorId", + "categories", + ], }, }, - }; + Category: { + toString: (category) => `${category.name}`, + list: { + display: ["name", "posts"], + search: ["name"], + }, + edit: { + display: ["name", "posts"], + }, + }, + }, +}; + +export default function Admin(props: AdminComponentProps) { + return ; +} + +export const getServerSideProps: GetServerSideProps = async ({ req, res }) => { + const { nextAdminRouter } = await import( + "@premieroctet/next-admin/dist/router" + ); const adminRouter = await nextAdminRouter(prisma, schema, options); return adminRouter.run(req, res) as Promise< diff --git a/apps/example/playwright.config.ts b/apps/example/playwright.config.ts index 76944939..f7109804 100644 --- a/apps/example/playwright.config.ts +++ b/apps/example/playwright.config.ts @@ -1,6 +1,6 @@ -import { defineConfig } from '@playwright/test'; +import { defineConfig } from "@playwright/test"; export default defineConfig({ - testDir: 'e2e', - workers: 1, -}); \ No newline at end of file + testDir: "e2e", + workers: 1, +}); diff --git a/apps/example/prisma/json-schema/json-schema.json b/apps/example/prisma/json-schema/json-schema.json index 1f7e75ce..1750a451 100644 --- a/apps/example/prisma/json-schema/json-schema.json +++ b/apps/example/prisma/json-schema/json-schema.json @@ -54,6 +54,12 @@ "USER", "ADMIN" ] + }, + "avatar": { + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/apps/example/prisma/migrations/20231121135427_user_avatar/migration.sql b/apps/example/prisma/migrations/20231121135427_user_avatar/migration.sql new file mode 100644 index 00000000..3766c233 --- /dev/null +++ b/apps/example/prisma/migrations/20231121135427_user_avatar/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "avatar" TEXT; diff --git a/apps/example/prisma/schema.prisma b/apps/example/prisma/schema.prisma index 70b1f118..52e30851 100644 --- a/apps/example/prisma/schema.prisma +++ b/apps/example/prisma/schema.prisma @@ -6,7 +6,7 @@ generator client { } generator jsonSchema { - provider = "prisma-json-schema-generator" + provider = "prisma-json-schema-generator" includeRequiredFields = "true" } @@ -20,27 +20,29 @@ enum Role { USER ADMIN } + model User { - id Int @id @default(autoincrement()) - email String @unique + id Int @id @default(autoincrement()) + email String @unique name String? - posts Post[] @relation("author") // One-to-many relation - profile Profile? @relation("profile") // One-to-one relation + posts Post[] @relation("author") // One-to-many relation + profile Profile? @relation("profile") // One-to-one relation birthDate DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt - role Role @default(USER) + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + role Role @default(USER) + avatar String? } model Post { - id Int @id @default(autoincrement()) - title String - content String? - published Boolean @default(false) - author User @relation("author", fields: [authorId], references: [id]) - authorId Int - categories Category[] @relation("category") // implicit Many-to-many relation - comments post_comment[] @relation("comments") // One-to-many relation + id Int @id @default(autoincrement()) + title String + content String? + published Boolean @default(false) + author User @relation("author", fields: [authorId], references: [id]) + authorId Int + categories Category[] @relation("category") // implicit Many-to-many relation + comments post_comment[] @relation("comments") // One-to-many relation } model Profile { @@ -53,15 +55,15 @@ model Profile { } model Category { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) name String - posts Post[] @relation("category") // implicit Many-to-many relation + posts Post[] @relation("category") // implicit Many-to-many relation createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt } model post_comment { - id String @id @default(uuid()) + id String @id @default(uuid()) content String post Post @relation("comments", fields: [postId], references: [id]) postId Int diff --git a/package.json b/package.json index c22f9c72..4b0f9389 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "build": "turbo run build", "build:example": "turbo run build --filter=example", "dev": "turbo run dev", + "dev:docs": "turbo run dev --filter=docs", "lint": "turbo run lint", "test": "turbo run test", "test:e2e": "dotenv turbo test:e2e", @@ -20,9 +21,9 @@ "publish-package": "turbo run build && changeset publish" }, "devDependencies": { + "dotenv-cli": "7.2.1", "eslint-config-custom": "*", "prettier": "latest", - "dotenv-cli": "7.2.1", "turbo": "latest" }, "engines": { diff --git a/packages/next-admin/package.json b/packages/next-admin/package.json index 04feec3f..20330482 100644 --- a/packages/next-admin/package.json +++ b/packages/next-admin/package.json @@ -27,11 +27,13 @@ "@rjsf/utils": "^5.3.0", "@rjsf/validator-ajv8": "^5.3.0", "@tanstack/react-table": "^8.9.2", + "@types/formidable": "^3.4.5", "@types/node": "^18.15.3", "@types/react-dom": "18.0.11", "body-parser": "^1.20.2", "class-variance-authority": "^0.6.0", "clsx": "^1.2.1", + "formidable": "^3.5.1", "lodash.range": "^3.2.0", "next": "13.2.4", "next-connect": "^1.0.0-next.3", diff --git a/packages/next-admin/src/components/Cell.tsx b/packages/next-admin/src/components/Cell.tsx index 7d3ee5f6..44d78461 100644 --- a/packages/next-admin/src/components/Cell.tsx +++ b/packages/next-admin/src/components/Cell.tsx @@ -1,20 +1,23 @@ import React, { ReactNode } from "react"; -import { ListDataFieldValue } from "../types"; +import { ListDataFieldValue, ListDataItem, ModelName } from "../types"; import Link from "next/link"; import clsx from "clsx"; import { useConfig } from "../context/ConfigContext"; type Props = { cell: ListDataFieldValue | ReactNode; + formatter: (cell: any) => ReactNode; }; -export default function Cell({ cell }: Props) { - const { basePath } = useConfig() +export default function Cell({ cell, formatter }: Props) { + const { basePath } = useConfig(); - const isReactNode = (cell: ListDataFieldValue | ReactNode): cell is ReactNode => { - return React.isValidElement(cell) - } + const isReactNode = ( + cell: ListDataFieldValue | ReactNode + ): cell is ReactNode => { + return React.isValidElement(cell); + }; if (cell && cell !== null) { if (React.isValidElement(cell)) { return cell; @@ -26,32 +29,32 @@ export default function Cell({ cell }: Props) { href={`${basePath}/${cell.value.url}`} className="hover:underline cursor-pointer text-indigo-700 hover:text-indigo-900 font-semibold" > - {cell.value.label} + {formatter(cell.value.label)} ); } else if (cell.type === "count") { return (
-

{cell.value}

+

{formatter(cell.value)}

); } else if (cell.type === "date") { return (
-

{cell.value.toString()}

+

{formatter(cell.value.toString())}

); } } else if (typeof cell === "string") { return (
-

{cell.toString()}

+

{formatter(cell.toString())}

); } else if (typeof cell === "number") { return (
-

{cell.toString()}

+

{formatter(cell.toString())}

); } else if (typeof cell === "boolean") { @@ -59,11 +62,12 @@ export default function Cell({ cell }: Props) {
-

{cell.toString()}

+

{formatter(cell.toString())}

); } diff --git a/packages/next-admin/src/components/DataTable.tsx b/packages/next-admin/src/components/DataTable.tsx index 106c9067..46ec1c39 100644 --- a/packages/next-admin/src/components/DataTable.tsx +++ b/packages/next-admin/src/components/DataTable.tsx @@ -15,23 +15,49 @@ import { TableRow, } from "./radix/Table"; import { useRouter } from "next/compat/router"; -import { ListData, ListDataItem, ModelName } from "../types"; +import { + ListData, + ListDataItem, + ModelName, + Field, + NextAdminOptions, +} from "../types"; import { useConfig } from "../context/ConfigContext"; interface DataTableProps { - columns: ColumnDef, { id: string }>[]; + columns: ColumnDef>[]; data: ListData; resource: ModelName; + options: Required["model"][ModelName]; } -export function DataTable({ columns, data, resource }: DataTableProps) { +export function DataTable({ + columns, + data, + resource, + options, +}: DataTableProps) { const router = useRouter(); - const { basePath } = useConfig() + const { basePath } = useConfig(); + const hasDisplayField = options?.list?.display?.length ? true : false; + const columnsVisibility = columns.reduce( + (acc, column) => { + // @ts-expect-error + const key = column.accessorKey as Field; + acc[key] = options?.list?.display?.includes(key) ? true : false; + return acc; + }, + {} as Record, boolean> + ); + const table = useReactTable({ data, manualSorting: true, columns, getCoreRowModel: getCoreRowModel(), + initialState: { + columnVisibility: !hasDisplayField ? {} : columnsVisibility, + }, }); return ( @@ -46,9 +72,9 @@ export function DataTable({ columns, data, resource }: DataTableProps) { {header.isPlaceholder ? null : flexRender( - header.column.columnDef.header, - header.getContext() - )} + header.column.columnDef.header, + header.getContext() + )} ); })} diff --git a/packages/next-admin/src/components/Form.tsx b/packages/next-admin/src/components/Form.tsx index b3cde4d7..cbca8c4a 100644 --- a/packages/next-admin/src/components/Form.tsx +++ b/packages/next-admin/src/components/Form.tsx @@ -1,3 +1,4 @@ +import { Prisma } from "@prisma/client"; import RjsfForm from "@rjsf/core"; import { BaseInputTemplateProps, @@ -7,16 +8,18 @@ import { getSubmitButtonOptions, } from "@rjsf/utils"; import validator from "@rjsf/validator-ajv8"; -import { ChangeEvent } from "react"; -import Button from "./radix/Button"; import clsx from "clsx"; -import { Schemas, getSchemas } from "../utils/jsonSchema"; -import { Prisma } from "@prisma/client"; -import SelectWidget from "./inputs/SelectWidget"; +import { ChangeEvent } from "react"; +import { PropertyValidationError } from "../exceptions/ValidationError"; import { ModelName } from "../types"; +import { Schemas, getSchemas } from "../utils/jsonSchema"; import ArrayField from "./inputs/ArrayField"; import CheckboxWidget from "./inputs/CheckboxWidget"; -import { PropertyValidationError } from "../exceptions/ValidationError"; +import SelectWidget from "./inputs/SelectWidget"; +import Button from "./radix/Button"; +import DateTimeWidget from "./inputs/DateTimeWidget"; +import DateWidget from "./inputs/DateWidget"; +import FileWidget from "./inputs/FileWidget"; // Override Form functions to not prevent the submit class CustomForm extends RjsfForm { @@ -44,8 +47,11 @@ const fields: CustomForm["props"]["fields"] = { }; const widgets: CustomForm["props"]["widgets"] = { + DateWidget: DateWidget, + DateTimeWidget: DateTimeWidget, SelectWidget: SelectWidget, CheckboxWidget: CheckboxWidget, + FileWidget: FileWidget, }; const templates: CustomForm["props"]["templates"] = { @@ -145,9 +151,9 @@ const Form = ({ {edit && ( @@ -180,6 +186,7 @@ const Form = ({ method="post" idPrefix="" idSeparator="" + enctype="multipart/form-data" {...schemas} formData={data} validator={validator} diff --git a/packages/next-admin/src/components/List.tsx b/packages/next-admin/src/components/List.tsx index ff624121..9e21b285 100644 --- a/packages/next-admin/src/components/List.tsx +++ b/packages/next-admin/src/components/List.tsx @@ -2,9 +2,14 @@ import { ColumnDef } from "@tanstack/react-table"; import debounce from "lodash/debounce"; import { useRouter } from "next/compat/router"; import { ChangeEvent, useTransition } from "react"; - import { ITEMS_PER_PAGE } from "../config"; -import { AdminComponentOptions, ListData, ListDataItem, ListFieldsOptions, ModelName } from "../types"; +import { + ListData, + ListDataItem, + ListFieldsOptions, + ModelName, + NextAdminOptions, +} from "../types"; import Cell from "./Cell"; import { DataTable } from "./DataTable"; import ListHeader from "./ListHeader"; @@ -23,7 +28,7 @@ export type ListProps = { resource: ModelName; data: ListData; total: number; - options?: (Required>)['model'][ModelName] + options?: Required["model"][ModelName]; }; function List({ resource, data, total, options }: ListProps) { @@ -48,40 +53,50 @@ function List({ resource, data, total, options }: ListProps) { const columns: ColumnDef>[] = data && data?.length > 0 ? Object.keys(data[0]).map((property) => { - return { - accessorKey: property, - header: () => { - return ( - { - router?.push({ - pathname: location.pathname, - query: { - ...router?.query, - sortColumn: property, - sortDirection: - router?.query.sortDirection === "asc" - ? "desc" - : "asc", - }, - }); - }} - /> - ); - }, - cell: ({ row }) => { - const modelData = row.original; - const cellData = options?.list?.fields[property as keyof ListFieldsOptions]?.formatter?.(modelData) ?? modelData[property]; + return { + accessorKey: property, + header: () => { + return ( + { + router?.push({ + pathname: location.pathname, + query: { + ...router?.query, + sortColumn: property, + sortDirection: + router?.query.sortDirection === "asc" + ? "desc" + : "asc", + }, + }); + }} + /> + ); + }, + cell: ({ row }) => { + const modelData = row.original; + const cellData = + modelData[property as keyof ListFieldsOptions]; + const dataFormatter = + options?.list?.fields?.[ + property as keyof ListFieldsOptions + ]?.formatter || + ((cell: any) => { + if (typeof cell === "object") { + return cell.id; + } else { + return cell; + } + }); - return ( - - ); - }, - }; - }) + return ; + }, + }; + }) : []; return ( @@ -103,10 +118,11 @@ function List({ resource, data, total, options }: ListProps) { resource={resource} data={data} columns={columns} + options={options} /> {data.length ? (
-
+
diff --git a/packages/next-admin/src/components/Menu.tsx b/packages/next-admin/src/components/Menu.tsx index 2b05fb3e..8ff818f6 100644 --- a/packages/next-admin/src/components/Menu.tsx +++ b/packages/next-admin/src/components/Menu.tsx @@ -8,183 +8,189 @@ import { ModelName } from "../types"; import { useConfig } from "../context/ConfigContext"; export type MenuProps = { - resource: ModelName; - resources?: ModelName[]; + resource: ModelName; + resources?: ModelName[]; }; -export default function Menu({ resources, resource: currentResource }: MenuProps) { - const [sidebarOpen, setSidebarOpen] = useState(false); - const { basePath } = useConfig() - const navigation: Array<{ - name: string; - href: string; - current: boolean; - icon?: React.ElementType; - }> = - resources?.map((resource) => ({ - name: resource, - href: `${basePath}/${resource}`, - current: resource === currentResource, - })) || []; +export default function Menu({ + resources, + resource: currentResource, +}: MenuProps) { + const [sidebarOpen, setSidebarOpen] = useState(false); + const { basePath } = useConfig(); + const navigation: Array<{ + name: string; + href: string; + current: boolean; + icon?: React.ElementType; + }> = + resources?.map((resource) => ({ + name: resource, + href: `${basePath}/${resource}`, + current: resource === currentResource, + })) || []; - return <> - - + + + +
+ + +
+ + -
- - -
- + -
- - {/* Sidebar component, swap this element with another sidebar if you like */} -
-
- - - -
- -
- - -
-
-
- - {/* Static sidebar for desktop */} -
- {/* Sidebar component, swap this element with another sidebar if you like */} -
-
+ Close sidebar +
+ + {/* Sidebar component, swap this element with another sidebar if you like */} +
+
- + -
-
+ -
+ +
+ + +
+ + + + {/* Static sidebar for desktop */} +
+ {/* Sidebar component, swap this element with another sidebar if you like */} +
+
+ + + +
+
+
-
- -
- Dashboard -
-
-} \ No newline at end of file +
+ +
+ Dashboard +
+
+ + ); +} diff --git a/packages/next-admin/src/components/NextAdmin.tsx b/packages/next-admin/src/components/NextAdmin.tsx index 77fba2f4..4f071841 100644 --- a/packages/next-admin/src/components/NextAdmin.tsx +++ b/packages/next-admin/src/components/NextAdmin.tsx @@ -1,6 +1,6 @@ import Head from "next/head"; import Link from "next/link"; -import NextNProgress from 'nextjs-progressbar'; +import NextNProgress from "nextjs-progressbar"; import { AdminComponentProps, CustomUIProps } from "../types"; import { getSchemaForResource } from "../utils/jsonSchema"; import Dashboard from "./Dashboard"; diff --git a/packages/next-admin/src/components/inputs/DateTimeWidget.tsx b/packages/next-admin/src/components/inputs/DateTimeWidget.tsx new file mode 100644 index 00000000..b4ae1ace --- /dev/null +++ b/packages/next-admin/src/components/inputs/DateTimeWidget.tsx @@ -0,0 +1,44 @@ +import { + getTemplate, + localToUTC, + utcToLocal, + FormContextType, + RJSFSchema, + StrictRJSFSchema, + WidgetProps, +} from '@rjsf/utils'; +import { useMemo } from 'react'; + +/** The `DateTimeWidget` component uses the `BaseInputTemplate` changing the type to `datetime-local` and transforms + * the value to/from utc using the appropriate utility functions. + * + * @param props - The `WidgetProps` for this component + */ +export default function DateTimeWidget< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>({ name, ...props }: WidgetProps) { + const { onChange, value, options, registry } = props; + const BaseInputTemplate = getTemplate<'BaseInputTemplate', T, S, F>('BaseInputTemplate', registry, options); + + const hiddenValue = useMemo(() => { + return value ? new Date(value).toISOString() : "" + }, [value]) + + return ( + <> + + { + onChange(localToUTC(value)) + }} + /> + + ); +} \ No newline at end of file diff --git a/packages/next-admin/src/components/inputs/DateWidget.tsx b/packages/next-admin/src/components/inputs/DateWidget.tsx new file mode 100644 index 00000000..761bd93f --- /dev/null +++ b/packages/next-admin/src/components/inputs/DateWidget.tsx @@ -0,0 +1,19 @@ +import { useCallback } from 'react'; +import { getTemplate, FormContextType, RJSFSchema, StrictRJSFSchema, WidgetProps } from '@rjsf/utils'; + +/** The `DateWidget` component uses the `BaseInputTemplate` changing the type to `date` and transforms + * the value to undefined when it is falsy during the `onChange` handling. + * + * @param props - The `WidgetProps` for this component + */ +export default function DateWidget( + props: WidgetProps +) { + const { onChange, options, registry, value } = props; + const BaseInputTemplate = getTemplate<'BaseInputTemplate', T, S, F>('BaseInputTemplate', registry, options); + const handleChange = useCallback((value: any) => onChange(value || undefined), [onChange]); + + const inputValue = value ? new Date(value).toISOString().split('T')[0] : undefined; + + return ; +} \ No newline at end of file diff --git a/packages/next-admin/src/components/inputs/FileWidget.tsx b/packages/next-admin/src/components/inputs/FileWidget.tsx new file mode 100644 index 00000000..1479d1ca --- /dev/null +++ b/packages/next-admin/src/components/inputs/FileWidget.tsx @@ -0,0 +1,167 @@ +import { + ArrowPathIcon, + CloudArrowUpIcon, + DocumentIcon, +} from "@heroicons/react/24/outline"; +import { WidgetProps } from "@rjsf/utils"; +import clsx from "clsx"; +import { ChangeEvent, useEffect, useRef, useState } from "react"; +import Button from "../radix/Button"; + +const FileWidget = (props: WidgetProps) => { + const [fileInfo, setFileInfo] = useState( + props.value ? props.value : null + ); + const [hasChanged, setHasChanged] = useState(false); + const inputRef = useRef(null); + const [fileIsImage, setFileIsImage] = useState(false); + const [fileImage, setFileImage] = useState(props.value); + const [fileName, setFileName] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + + const handleFileChange = (event: ChangeEvent) => { + setHasChanged(true); + const selectedFile = event.target.files?.[0]; + if (selectedFile) { + const { size } = selectedFile; + setFileInfo(size); + setFileName(selectedFile.name); + + if (selectedFile.type.includes("image")) { + const reader = new FileReader(); + + reader.onload = () => { + setFileIsImage(true); + setFileImage(reader.result as string); + }; + + reader.readAsDataURL(selectedFile); + } else { + setFileImage(null); + setFileIsImage(false); + } + } + }; + + const getFileType = async (signal: AbortSignal) => { + if (!props.value) { + return; + } + try { + const response = await fetch(props.value, { + signal, + }); + setFileIsImage( + response.headers.get("Content-Type")?.includes("image") ?? false + ); + setFileImage(props.value); + } catch (error) { + setFileIsImage(false); + } + }; + + const onCheckDelete = (evt: ChangeEvent) => { + setIsDeleting(evt.target.checked); + if (inputRef.current) { + inputRef.current.value = ""; + } + }; + + useEffect(() => { + const abortController = new AbortController(); + + getFileType(abortController.signal); + + return () => { + abortController.abort(); + }; + }, []); + + let isLink = false; + + try { + isLink = Boolean(new URL(props.value as string)); + } catch (error) { + isLink = false; + } + + return ( +
+
+ {fileInfo && ( + + )} +
+ + {fileInfo && ( +
+
+ +
+
+ +
+
+ )} +
+
+
+ ); +}; + +export default FileWidget; diff --git a/packages/next-admin/src/components/inputs/SelectWidget.tsx b/packages/next-admin/src/components/inputs/SelectWidget.tsx index 6f0c0868..6dc58644 100644 --- a/packages/next-admin/src/components/inputs/SelectWidget.tsx +++ b/packages/next-admin/src/components/inputs/SelectWidget.tsx @@ -15,7 +15,7 @@ const SelectWidget = ({ options, onChange, value, ...props }: WidgetProps) => { const containerRef = useRef(null); useCloseOnOutsideClick(containerRef, () => setOpen(false)); - function onWindowClick() { } + function onWindowClick() {} useEffect(() => { window.addEventListener("click", onWindowClick); @@ -48,7 +48,7 @@ const SelectWidget = ({ options, onChange, value, ...props }: WidgetProps) => { value={ value ? enumOptions?.find((option: any) => option.value === value) - ?.label + ?.label : "" } onMouseDown={() => setOpen(!open)} diff --git a/packages/next-admin/src/context/ConfigContext.tsx b/packages/next-admin/src/context/ConfigContext.tsx index 4ccf435e..2bf74682 100644 --- a/packages/next-admin/src/context/ConfigContext.tsx +++ b/packages/next-admin/src/context/ConfigContext.tsx @@ -1,29 +1,30 @@ -import React, { useContext } from 'react'; +import React, { useContext } from "react"; export type ConfigContextType = { - basePath: string; + basePath: string; }; - -const ConfigContext = React.createContext({} as ConfigContextType); +const ConfigContext = React.createContext( + {} as ConfigContextType +); type ProviderProps = { - basePath: string; - children: React.ReactNode; -} + basePath: string; + children: React.ReactNode; +}; export const ConfigProvider = ({ children, basePath }: ProviderProps) => { - return ( - - {children} - - ); + return ( + + {children} + + ); }; export const useConfig = () => { - return useContext(ConfigContext); + return useContext(ConfigContext); }; diff --git a/packages/next-admin/src/index.tsx b/packages/next-admin/src/index.tsx index 361444cd..12e0baa6 100644 --- a/packages/next-admin/src/index.tsx +++ b/packages/next-admin/src/index.tsx @@ -1,2 +1,2 @@ export * from "./components/NextAdmin"; -export * from "./types"; \ No newline at end of file +export * from "./types"; diff --git a/packages/next-admin/src/router.tsx b/packages/next-admin/src/router.tsx index d4d9e230..599d83ff 100644 --- a/packages/next-admin/src/router.tsx +++ b/packages/next-admin/src/router.tsx @@ -5,28 +5,26 @@ import { } from "@prisma/client/runtime/library"; import { createRouter } from "next-connect"; +import { + EditFieldsOptions, + EditOptions, + NextAdminOptions, + Select, +} from "./types"; +import { getMappedDataList } from "./utils/prisma"; import { fillRelationInSchema, - findRelationInData, - flatRelationInData, formatSearchFields, formattedFormData, - getBody, + getFormDataValues, getPrismaModelForResource, getResourceFromUrl, getResourceIdFromUrl, - removeHiddenProperties, getResources, parseFormData, + transformData, + transformSchema, } from "./utils/server"; -import { - NextAdminOptions, - FormData, - Body, - Select, - EditFieldsOptions, -} from "./types"; -import { preparePrismaListRequest } from "./utils/prisma"; import { validate } from "./utils/validator"; // Router @@ -38,340 +36,327 @@ export const nextAdminRouter = async ( const resources = getResources(options); const defaultProps = { resources, basePath: options.basePath }; - return createRouter() - // Error handling middleware - .use(async (req, res, next) => { - try { - return await next(); - } catch (e: any) { - if (process.env.NODE_ENV === "development") { - throw e; - } - - return { - props: { ...defaultProps, error: e.message }, - }; - } - }) - .get(async (req, res) => { - const resource = getResourceFromUrl(req.url!, resources); - const requestOptions = formatSearchFields(req.url!); - - // Dashboard - if (!resource) { - return { props: defaultProps }; - } - const model = getPrismaModelForResource(resource); - - let selectedFields = model?.fields.reduce((acc, field) => { - // @ts-expect-error - acc[field.name] = true; - return acc; - }, {} as Select); - - schema = await fillRelationInSchema( - schema, - prisma, - resource, - requestOptions, - options - ); - const edit = options?.model?.[resource]?.edit - ?.fields as EditFieldsOptions; - const editKeys = - edit && - (Object.keys(edit) as Array>); - const editSelect = editKeys?.reduce((acc, column) => { - if (edit[column]?.display) acc[column] = true; - return acc; - }, {} as Select); - selectedFields = editSelect ?? selectedFields; - - // Edit - const resourceId = getResourceIdFromUrl(req.url!, resource); - - const dmmfSchema = getPrismaModelForResource(resource); - if (resourceId !== undefined) { - // @ts-expect-error - let data = await prisma[resource].findUniqueOrThrow({ - where: { id: resourceId }, - select: selectedFields, - }); - schema = removeHiddenProperties(schema, edit, resource); - data = flatRelationInData(data, resource); - return { - props: { - ...defaultProps, - resource, - data, - schema, - dmmfSchema: dmmfSchema?.fields, - }, - }; - } - // New - if (req.url!.includes("/new")) { - return { - props: { - ...defaultProps, - resource, - schema, - dmmfSchema: dmmfSchema?.fields, - }, - }; - } - - // List - const searchParams = new URLSearchParams(req.url!.split("?")[1]); - const prismaListRequest = preparePrismaListRequest( - resource, - searchParams, - options - ); - let data: any[] = []; - let total: number; - let error = null; - - try { - // @ts-expect-error - data = await prisma[resource].findMany(prismaListRequest); - // @ts-expect-error - total = await prisma[resource].count({ - where: prismaListRequest.where, - }); - } catch (e: any) { - const { skip, take, orderBy } = prismaListRequest; - // @ts-expect-error - data = await prisma[resource].findMany({ - skip, - take, - orderBy, - }); - // @ts-expect-error - total = await prisma[resource].count(); - error = e.message ? e.message : e; - console.error(e); - } - data = await findRelationInData(data, dmmfSchema?.fields); - - return { - props: { - ...defaultProps, - resource, - data, - total, - error, - schema, - dmmfSchema, - }, - }; - }) - .post(async (req, res) => { - const resource = getResourceFromUrl(req.url!, resources); - const requestOptions = formatSearchFields(req.url!); - - if (!resource) { - return { notFound: true }; - } - const resourceId = getResourceIdFromUrl(req.url!, resource); - const model = getPrismaModelForResource(resource); - - let selectedFields = model?.fields.reduce((acc, field) => { - // @ts-expect-error - acc[field.name] = true; - return acc; - }, {} as Select); - - schema = await fillRelationInSchema( - schema, - prisma, - resource, - requestOptions, - options - ); - const edit = options?.model?.[resource]?.edit - ?.fields as EditFieldsOptions; - const editKeys = - edit && - (Object.keys(edit) as Array>); - const editSelect = editKeys?.reduce((acc, column) => { - if (edit[column]?.display) acc[column] = true; - return acc; - }, {} as Select); - selectedFields = editSelect ?? selectedFields; - - schema = await fillRelationInSchema( - schema, - prisma, - resource, - requestOptions, - options - ); - schema = removeHiddenProperties(schema, edit, resource); - await getBody(req, res); - - // @ts-expect-error - const { id, ...formData } = req.body as Body>; - - const dmmfSchema = getPrismaModelForResource(resource); - - const parsedFormData = parseFormData(formData, dmmfSchema?.fields!); - - try { - // Delete redirect, display the list (this is needed because next keeps the HTTP method on redirects) - if (resourceId === undefined && formData.action === "delete") { - const searchParams = new URLSearchParams(req.url!.split("?")[1]); - const prismaListRequest = preparePrismaListRequest( - resource, - searchParams, - options - ); - // @ts-expect-error - let data = await prisma[resource].findMany(prismaListRequest); - data = await findRelationInData(data, dmmfSchema?.fields); - // @ts-expect-error - const total = await prisma[resource].count(); + return ( + createRouter() + // Error handling middleware + .use(async (req, res, next) => { + try { + return await next(); + } catch (e: any) { + if (process.env.NODE_ENV === "development") { + throw e; + } return { - props: { - ...defaultProps, - resource, - message: { - type: "success", - content: "Deleted successfully", - }, - total, - data, - }, + props: { ...defaultProps, error: e.message }, }; } - - // Delete - if (resourceId !== undefined && formData.action === "delete") { - // @ts-expect-error - await prisma[resource].delete({ - where: { - id: resourceId, - }, - }); - - return { - redirect: { - destination: `${options.basePath}/${resource}`, - permanent: false, - }, - }; + }) + .get(async (req, res) => { + const resource = getResourceFromUrl(req.url!, resources); + const requestOptions = formatSearchFields(req.url!); + + // Dashboard + if (!resource) { + return { props: defaultProps }; } + const model = getPrismaModelForResource(resource); - // Update - let data; + let selectedFields = model?.fields.reduce( + (acc, field) => { + // @ts-expect-error + acc[field.name] = true; + return acc; + }, + { id: true } as Select + ); - const fields = options.model?.[resource]?.edit?.fields as EditFieldsOptions; + schema = await fillRelationInSchema( + schema, + prisma, + resource, + requestOptions, + options + ); + const edit = options?.model?.[resource]?.edit as EditOptions< + typeof resource + >; + const editDisplayedKeys = edit && edit.display; + const editSelect = editDisplayedKeys?.reduce( + (acc, column) => { + acc[column] = true; + return acc; + }, + { id: true } as Select + ); + selectedFields = editSelect ?? selectedFields; - // Validate - validate(parsedFormData, fields) + // Edit + const resourceId = getResourceIdFromUrl(req.url!, resource); + const dmmfSchema = getPrismaModelForResource(resource); if (resourceId !== undefined) { // @ts-expect-error - data = await prisma[resource].update({ - where: { - id: resourceId, - }, - data: formattedFormData( - formData, - dmmfSchema?.fields!, - schema, - resource, - false - ), + let data = await prisma[resource].findUniqueOrThrow({ + where: { id: resourceId }, select: selectedFields, }); - - data = flatRelationInData(data, resource); - const fromCreate = req.headers.referer - ?.split("?")[0] - .endsWith(`${options.basePath}/${resource}/new`); - const message = fromCreate - ? { - type: "success", - content: "Created successfully", - } - : { - type: "success", - content: "Updated successfully", - }; - + schema = transformSchema(schema, resource, edit); + data = transformData(data, resource, edit); return { props: { ...defaultProps, resource, data, - message, + schema, + dmmfSchema: dmmfSchema?.fields, + }, + }; + } + // New + if (req.url!.includes("/new")) { + return { + props: { + ...defaultProps, + resource, schema, dmmfSchema: dmmfSchema?.fields, }, }; } - // Create - // @ts-expect-error - data = await prisma[resource].create({ - data: formattedFormData( - formData, - dmmfSchema?.fields!, - schema, - resource, - true - ), - select: selectedFields, - }); - - data = flatRelationInData(data, resource); + // List + const searchParams = new URLSearchParams(req.url!.split("?")[1]); + const { data, total, error } = await getMappedDataList( + prisma, + resource, + options, + searchParams + ); return { - redirect: { - destination: `${options.basePath}/${resource}/${data.id}`, - permanent: false, + props: { + ...defaultProps, + resource, + data, + total, + error, + schema, + dmmfSchema, }, }; - } catch (error: any) { - if ( - error.constructor.name === PrismaClientValidationError.name || - error.constructor.name === PrismaClientKnownRequestError.name || - error.name === "ValidationError" - ) { - let data = parsedFormData; + }) + .post(async (req, res) => { + const resource = getResourceFromUrl(req.url!, resources); + const requestOptions = formatSearchFields(req.url!); + + if (!resource) { + return { notFound: true }; + } + const resourceId = getResourceIdFromUrl(req.url!, resource); + const model = getPrismaModelForResource(resource); + + let selectedFields = model?.fields.reduce( + (acc, field) => { + // @ts-expect-error + acc[field.name] = true; + return acc; + }, + { id: true } as Select + ); + + schema = await fillRelationInSchema( + schema, + prisma, + resource, + requestOptions, + options + ); + const edit = options?.model?.[resource]?.edit as EditOptions< + typeof resource + >; + const editDisplayedKeys = edit && edit.display; + const editSelect = editDisplayedKeys?.reduce( + (acc, column) => { + acc[column] = true; + return acc; + }, + { id: true } as Select + ); + selectedFields = editSelect ?? selectedFields; + + schema = await fillRelationInSchema( + schema, + prisma, + resource, + requestOptions, + options + ); + schema = transformSchema(schema, resource, edit); + const { + __admin_action: action, + id, + ...formData + } = await getFormDataValues(req); + + const dmmfSchema = getPrismaModelForResource(resource); + + const parsedFormData = parseFormData(formData, dmmfSchema?.fields!); + + try { + // Delete redirect, display the list (this is needed because next keeps the HTTP method on redirects) + if (resourceId === undefined && action === "delete") { + const searchParams = new URLSearchParams(req.url!.split("?")[1]); + const { data, total } = await getMappedDataList( + prisma, + resource, + options, + searchParams + ); + + return { + props: { + ...defaultProps, + resource, + message: { + type: "success", + content: "Deleted successfully", + }, + total, + data, + }, + }; + } + + // Delete + if (resourceId !== undefined && action === "delete") { + // @ts-expect-error + await prisma[resource].delete({ + where: { + id: resourceId, + }, + }); + + return { + redirect: { + destination: `${options.basePath}/${resource}`, + permanent: false, + }, + }; + } + + // Update + let data; + + const fields = options.model?.[resource]?.edit + ?.fields as EditFieldsOptions; + + // Validate + validate(parsedFormData, fields); if (resourceId !== undefined) { // @ts-expect-error - data = await prisma[resource].findUnique({ - where: { id: resourceId }, + data = await prisma[resource].update({ + where: { + id: resourceId, + }, + data: await formattedFormData( + formData, + dmmfSchema?.fields!, + schema, + resource, + false, + fields + ), select: selectedFields, }); - data = flatRelationInData(data, resource); - } - // TODO This could be improved by merging form values but it's breaking stuff - if (error.name === "ValidationError") { - error.errors.map((error: any) => { - // @ts-expect-error - data[error.property] = formData[error.property] - }) + data = transformData(data, resource, edit); + const fromCreate = req.headers.referer + ?.split("?")[0] + .endsWith(`${options.basePath}/${resource}/new`); + const message = fromCreate + ? { + type: "success", + content: "Created successfully", + } + : { + type: "success", + content: "Updated successfully", + }; + + return { + props: { + ...defaultProps, + resource, + data, + message, + schema, + dmmfSchema: dmmfSchema?.fields, + }, + }; } - return { - props: { - ...defaultProps, - resource, + // Create + // @ts-expect-error + data = await prisma[resource].create({ + data: await formattedFormData( + formData, + dmmfSchema?.fields!, schema, - dmmfSchema: dmmfSchema?.fields, - error: error.message, - validation: error.errors, - data, + resource, + true, + fields + ), + select: selectedFields, + }); + + data = transformData(data, resource, edit); + return { + redirect: { + destination: `${options.basePath}/${resource}/${data.id}`, + permanent: false, }, }; - } + } catch (error: any) { + if ( + error.constructor.name === PrismaClientValidationError.name || + error.constructor.name === PrismaClientKnownRequestError.name || + error.name === "ValidationError" + ) { + let data = parsedFormData; + + if (resourceId !== undefined) { + // @ts-expect-error + data = await prisma[resource].findUnique({ + where: { id: resourceId }, + select: selectedFields, + }); + data = transformData(data, resource, edit); + } + + // TODO This could be improved by merging form values but it's breaking stuff + if (error.name === "ValidationError") { + error.errors.map((error: any) => { + // @ts-expect-error + data[error.property] = formData[error.property]; + }); + } + + return { + props: { + ...defaultProps, + resource, + schema, + dmmfSchema: dmmfSchema?.fields, + error: error.message, + validation: error.errors, + data, + }, + }; + } - throw error; - } - }) -} + throw error; + } + }) + ); +}; diff --git a/packages/next-admin/src/tests/prismaUtils.test.ts b/packages/next-admin/src/tests/prismaUtils.test.ts new file mode 100644 index 00000000..9b9fa576 --- /dev/null +++ b/packages/next-admin/src/tests/prismaUtils.test.ts @@ -0,0 +1,39 @@ +import { getMappedDataList } from "../utils/prisma"; +import { options, prismaMock } from "./singleton"; + +describe("getMappedDataList", () => { + it("should return the data list, total and error", async () => { + const postData = [ + { + id: 1, + title: "Post 1", + content: "Content 1", + published: true, + authorId: 1, + }, + { + id: 2, + title: "Post 2", + content: "Content 2", + published: true, + authorId: 1, + }, + ]; + + prismaMock.post.findMany.mockResolvedValueOnce(postData); + + prismaMock.post.count.mockResolvedValueOnce(2); + + const result = await getMappedDataList( + prismaMock, + "Post", + options, + new URLSearchParams() + ); + expect(result).toEqual({ + data: postData, + total: postData.length, + error: null, + }); + }); +}); diff --git a/packages/next-admin/src/tests/serverUtils.test.ts b/packages/next-admin/src/tests/serverUtils.test.ts index 9cb7d928..93a2d186 100644 --- a/packages/next-admin/src/tests/serverUtils.test.ts +++ b/packages/next-admin/src/tests/serverUtils.test.ts @@ -1,5 +1,9 @@ -import { fillRelationInSchema } from "../utils/server"; -import { prismaMock, schema } from "./singleton"; +import { + changeFormatInSchema, + fillRelationInSchema, + removeHiddenProperties, +} from "../utils/server"; +import { options, prismaMock, schema } from "./singleton"; describe("fillRelationInSchema", () => { it("should return the schema with the relation property", async () => { @@ -12,6 +16,7 @@ describe("fillRelationInSchema", () => { updatedAt: new Date(), birthDate: new Date(), role: "ADMIN", + avatar: null, }, { id: 2, @@ -21,9 +26,9 @@ describe("fillRelationInSchema", () => { updatedAt: new Date(), birthDate: new Date(), role: "ADMIN", + avatar: null }, ]); - // @ts-expect-error const result = await fillRelationInSchema(schema, prismaMock, "Post", {}); expect(result.definitions.Post.properties.authorId?.enum).toEqual([ { label: 1, value: 1 }, @@ -31,3 +36,20 @@ describe("fillRelationInSchema", () => { ]); }); }); + +describe("transformSchema", () => { + const userEditOptions = options.model?.User?.edit!; + + it("should return the schema with the new format", async () => { + const result = changeFormatInSchema(schema, "User", userEditOptions); + expect(result.definitions.User.properties.birthDate?.format).toEqual( + "date" + ); + }); + + it("should return the schema without the hidden properties", async () => { + const result = removeHiddenProperties(schema, "User", userEditOptions); + expect(result.definitions.User.properties).not.toHaveProperty("createdAt"); + expect(result.definitions.User.properties).not.toHaveProperty("updatedAt"); + }); +}); diff --git a/packages/next-admin/src/tests/singleton.ts b/packages/next-admin/src/tests/singleton.ts index f05a37be..4aa91781 100644 --- a/packages/next-admin/src/tests/singleton.ts +++ b/packages/next-admin/src/tests/singleton.ts @@ -1,8 +1,9 @@ // https://www.prisma.io/docs/guides/testing/unit-testing#singleton import { PrismaClient } from "@prisma/client"; -import { mockDeep, mockReset, DeepMockProxy } from "jest-mock-extended"; +import { DeepMockProxy, mockDeep, mockReset } from "jest-mock-extended"; import prisma from "@prisma/client"; +import { NextAdminOptions, Schema } from "../types"; jest.mock("@prisma/client", () => ({ __esModule: true, @@ -101,6 +102,19 @@ jest.mock("@prisma/client", () => ({ isGenerated: false, isUpdatedAt: true, }, + { + name: "birthDate", + kind: "scalar", + isList: false, + isRequired: false, + isUnique: false, + isId: false, + isReadOnly: false, + hasDefaultValue: false, + type: "DateTime", + isGenerated: false, + isUpdatedAt: false, + }, ], primaryKey: null, uniqueFields: [], @@ -203,6 +217,11 @@ jest.mock("@prisma/client", () => ({ ], }, }, + SortOrder: { + __typename: "SortOrder", + asc: "asc", + desc: "desc", + }, }, default: mockDeep(), })); @@ -213,7 +232,7 @@ beforeEach(() => { export const prismaMock = prisma as unknown as DeepMockProxy; -export const schema = { +export const schema: Schema = { $schema: "http://json-schema.org/draft-07/schema#", type: "object", definitions: { @@ -225,7 +244,7 @@ export const schema = { role: { type: "string" }, createdAt: { type: "string", format: "date-time" }, updatedAt: { type: "string", format: "date-time" }, - birthDate: { type: "string", format: "date" }, + birthDate: { type: "string", format: "date-time" }, }, }, Post: { @@ -256,14 +275,73 @@ export const schema = { updatedAt: { type: "string", format: "date-time" }, }, }, - Relation: { + post_comment: { + type: "object", properties: { id: { type: "string" }, - category: { $ref: "#/definitions/Category" }, - categoryId: { type: "integer" }, - posts: { $ref: "#/definitions/Post" }, - postId: { type: "integer" }, + content: { type: "string" }, + post: { $ref: "#/definitions/Post" }, + createdAt: { + type: "string", + format: "date-time", + }, + updatedAt: { + type: "string", + format: "date-time", + }, }, + required: ["content", "postId"], + }, + }, +}; + +export const options: NextAdminOptions = { + basePath: "/admin", + model: { + User: { + toString: (user) => `${user.name} (${user.email})`, + list: { + display: ["id", "name", "email", "posts", "role"], + search: ["name", "email"], + }, + edit: { + display: ["id", "name", "email", "posts", "role", "birthDate"], + fields: { + email: { + validate: (email) => email.includes("@") || "Invalid email", + }, + birthDate: { + format: "date", + }, + }, + }, + }, + Post: { + toString: (post) => `${post.title}`, + list: { + display: [ + "id", + "title", + "content", + "published", + "author", + "categories", + ], + search: ["title", "content"], + }, + edit: { + display: [ + "id", + "title", + "content", + "published", + "authorId", + "categories", + ], + }, + }, + Category: { + toString: (category) => `${category.name}`, }, }, }; diff --git a/packages/next-admin/src/types.ts b/packages/next-admin/src/types.ts index 5b6938b9..7a7f9524 100644 --- a/packages/next-admin/src/types.ts +++ b/packages/next-admin/src/types.ts @@ -7,18 +7,31 @@ import { PropertyValidationError } from "./exceptions/ValidationError"; export type ModelName = Prisma.ModelName; -export type ScalarField = Prisma.TypeMap["model"][T]["payload"]["scalars"]; -export type ObjectField = Prisma.TypeMap["model"][T]["payload"]["objects"]; - -export type Model = ScalarField & { - [P in keyof ObjectField]: - ObjectField[P] extends { scalars: infer S } ? T extends never ? S : T - : ObjectField[P] extends { scalars: infer S } | null ? T extends never ? S | null : T | null - : ObjectField[P] extends { scalars: infer S }[] ? T extends never ? S[] : T[] - : never; -} - -export type ModelWithoutRelationships = Model; +export type ScalarField = + Prisma.TypeMap["model"][T]["payload"]["scalars"]; +export type ObjectField = + Prisma.TypeMap["model"][T]["payload"]["objects"]; + +export type Model< + M extends ModelName, + T extends object | number = object +> = ScalarField & { + [P in keyof ObjectField]: ObjectField[P] extends { scalars: infer S } + ? T extends object + ? S + : T + : never | ObjectField[P] extends { scalars: infer S }[] + ? T extends object + ? S[] + : T[] + : never | ObjectField[P] extends { scalars: infer S } | null + ? T extends object + ? S | null + : T | null + : never; +}; + +export type ModelWithoutRelationships = Model; export type Field

= keyof Model

; @@ -28,27 +41,63 @@ export type Field

= keyof Model

; export type ListFieldsOptions = { [P in Field]?: { - display?: true; - search?: true; + formatter?: (item: Model[P]) => ReactNode; }; }; export type EditFieldsOptions = { [P in Field]?: { - display?: boolean; validate?: (value: ModelWithoutRelationships[P]) => true | string; + format?: FormatOptions[P]>; + handler?: Handler[P]>; }; }; +export type Handler< + M extends ModelName, + P extends Field, + T extends Model[P] +> = { + get?: (input: T) => any; + upload?: (file: Buffer) => Promise; +}; + +export type FormatOptions = T extends string + ? + | "textarea" + | "password" + | "color" + | "email" + | "uri" + | "data-url" + | "date" + | "date-time" + | "time" + | "alt-datetime" + | "alt-date" + | "file" + : never | T extends Date + ? "date" | "date-time" | "time" + : never | T extends number + ? "updown" | "range" + : never; + +export type ListOptions = { + display?: Field[]; + search?: Field[]; + fields?: ListFieldsOptions; +}; + +export type EditOptions = { + display?: Field[]; + fields?: EditFieldsOptions; +}; + export type ModelOptions = { [P in T]?: { toString?: (item: Model

) => string; - list?: { - fields: ListFieldsOptions

; - }; - edit?: { - fields: EditFieldsOptions

; - }; + list?: ListOptions

; + edit?: EditOptions

; }; }; @@ -57,7 +106,6 @@ export type NextAdminOptions = { model?: ModelOptions; }; - /** Type for Schema */ export type SchemaProperty = { @@ -78,8 +126,6 @@ export type Schema = Partial> & { definitions: SchemaDefinitions; }; - - export type FormData = { [P in Field]?: string; }; @@ -129,33 +175,16 @@ export type ListDataFieldValue = | boolean | { type: "count"; value: number } | { - type: "link"; - value: { - label: string; - url: string; - }; - } - | { - type: "date"; - value: Date; - }; - -export type ListComponentFieldsOptions = { - [P in Field]?: { - formatter?: (item: ListDataItem) => ReactNode; - }; -}; - -export type AdminComponentOptions = { - model?: { - [P in T]?: { - toString?: (item: Model

) => string; - list?: { - fields: ListComponentFieldsOptions

; + type: "link"; + value: { + label: string; + url: string; }; + } + | { + type: "date"; + value: Date; }; - }; -}; export type AdminComponentProps = { basePath: string; @@ -171,9 +200,9 @@ export type AdminComponentProps = { resources?: ModelName[]; total?: number; dmmfSchema: Prisma.DMMF.Field[]; - options?: AdminComponentOptions; + options?: NextAdminOptions; }; export type CustomUIProps = { dashboard?: JSX.Element | (() => JSX.Element); -}; \ No newline at end of file +}; diff --git a/packages/next-admin/src/utils/prisma.ts b/packages/next-admin/src/utils/prisma.ts index 7033cd2d..ec1637a6 100644 --- a/packages/next-admin/src/utils/prisma.ts +++ b/packages/next-admin/src/utils/prisma.ts @@ -1,16 +1,17 @@ -import { Prisma } from "@prisma/client"; -import { getPrismaModelForResource } from "./server"; +import { Prisma, PrismaClient } from "@prisma/client"; +import { ITEMS_PER_PAGE } from "../config"; import { - ListFieldsOptions, + EditOptions, + Field, + ListOptions, ModelName, NextAdminOptions, Order, PrismaListRequest, Select, - UField, } from "../types"; -import { ITEMS_PER_PAGE } from "../config"; -import { capitalize } from "./tools"; +import { findRelationInData, getPrismaModelForResource } from "./server"; +import { capitalize, uncapitalize } from "./tools"; export const createWherePredicate = ( fieldsFiltered?: Prisma.DMMF.Field[], @@ -48,7 +49,7 @@ export const preparePrismaListRequest = ( Number(searchParams.get("itemsPerPage")) || ITEMS_PER_PAGE; let orderBy: Order = {}; - const sortParam = searchParams.get("sortColumn") as UField; + const sortParam = searchParams.get("sortColumn") as Field; const orderValue = searchParams.get("sortDirection") as Prisma.SortOrder; if ( orderValue in Prisma.SortOrder && @@ -60,25 +61,27 @@ export const preparePrismaListRequest = ( let select: Select | undefined; let where = {}; let fieldsFiltered = model?.fields; - const list = options?.model?.[resource]?.list - ?.fields as ListFieldsOptions; + const list = options?.model?.[resource]?.list as ListOptions; if (list) { - const listKeys = Object.keys(list) as Array>; - select = listKeys.reduce((acc, column) => { - const field = model?.fields.find(({ name }) => name === column); - if (field?.kind === "object") { - if (!acc._count) acc._count = { select: {} }; - acc._count.select = { ...acc._count.select, [column]: true }; - } else { - // @ts-expect-error - acc[column] = true; - } - return acc; - }, {} as Select); + const listDisplayedKeys = list.display; + select = listDisplayedKeys?.reduce( + (acc, column) => { + const field = model?.fields.find(({ name }) => name === column); + if (field?.kind === "object" && field?.isList === true) { + if (!acc._count) acc._count = { select: {} }; + acc._count.select = { ...acc._count.select, [column]: true }; + } else { + // @ts-expect-error + acc[column] = true; + } + return acc; + }, + { id: true } as Select + ); fieldsFiltered = model?.fields.filter( - ({ name }) => list[name as keyof ListFieldsOptions]?.search + ({ name }) => list.search?.includes(name as Field) ) ?? fieldsFiltered; } where = createWherePredicate(fieldsFiltered, search); @@ -91,3 +94,47 @@ export const preparePrismaListRequest = ( take: itemsPerPage, }; }; + +export const getMappedDataList = async ( + prisma: PrismaClient, + resource: ModelName, + options: NextAdminOptions, + searchParams: URLSearchParams +) => { + const prismaListRequest = preparePrismaListRequest( + resource, + searchParams, + options + ); + let data: any[] = []; + let total: number; + let error = null; + const dmmfSchema = getPrismaModelForResource(resource); + + try { + // @ts-expect-error + data = await prisma[uncapitalize(resource)].findMany(prismaListRequest); + // @ts-expect-error + total = await prisma[uncapitalize(resource)].count({ + where: prismaListRequest.where, + }); + } catch (e: any) { + const { skip, take, orderBy } = prismaListRequest; + // @ts-expect-error + data = await prisma[uncapitalize(resource)].findMany({ + skip, + take, + orderBy, + }); + // @ts-expect-error + total = await prisma[uncapitalize(resource)].count(); + error = e.message ? e.message : e; + console.error(e); + } + data = await findRelationInData(data, dmmfSchema?.fields); + return { + data, + total, + error, + }; +}; diff --git a/packages/next-admin/src/utils/server.ts b/packages/next-admin/src/utils/server.ts index 2b82bca1..cef6a484 100644 --- a/packages/next-admin/src/utils/server.ts +++ b/packages/next-admin/src/utils/server.ts @@ -2,19 +2,24 @@ import { Prisma, PrismaClient } from "@prisma/client"; import bodyParser from "body-parser"; import util from "util"; import { + EditFieldsOptions, + EditOptions, + Enumeration, Field, - ModelName, - NextAdminOptions, - Schema, FormData, - Enumeration, - ListFieldsOptions, - EditFieldsOptions, + ListOptions, + ModelName, ModelWithoutRelationships, + NextAdminOptions, ScalarField, + Schema, } from "../types"; import { createWherePredicate } from "./prisma"; import { isNativeFunction, uncapitalize } from "./tools"; +import { IncomingMessage } from "http"; +import formidable from "formidable"; +import { Writable } from "stream"; + export const getBody = util.promisify(bodyParser.urlencoded()); export const models = Prisma.dmmf.datamodel.models; @@ -63,7 +68,7 @@ export const fillRelationInSchema = async ( if (fieldKind === "enum") { const fieldValue = schema.definitions[modelName].properties[ - field.name as Field + field.name as Field ]; if (fieldValue) { fieldValue.enum = fieldValue.enum?.map((item) => @@ -76,17 +81,10 @@ export const fillRelationInSchema = async ( const remoteModel = models.find( (model) => model.name === modelNameRelation ); - type ListFieldOptionsModel = ListFieldsOptions< - typeof modelNameRelation - >; - const listFields = options?.model?.[modelNameRelation]?.list - ?.fields as ListFieldOptionsModel; - const listKeys = - listFields && - (Object.keys(listFields) as Array); + const listOptions = options?.model?.[modelNameRelation] + ?.list as ListOptions; const optionsForRelations = - listKeys?.filter((key) => listFields[key]?.search) ?? - remoteModel?.fields.map((field) => field.name); + listOptions?.search ?? remoteModel?.fields.map((field) => field.name); const relationProperty: Field = (relationFromFields?.[0] as Field) ?? fieldName; const fieldsFiltered = remoteModel?.fields.filter((field) => @@ -128,7 +126,7 @@ export const fillRelationInSchema = async ( } else { const fieldValue = schema.definitions[modelName].properties[ - field.name as Field + field.name as Field ]; if (fieldValue) { let enumeration: Enumeration[] = []; @@ -166,7 +164,14 @@ export const fillRelationInSchema = async ( return schema; }; -export const flatRelationInData = (data: any, resource: ModelName) => { +/** + * This is used to transform the data from server to client + */ +export const transformData = ( + data: any, + resource: M, + editOptions: EditOptions +) => { const modelName = resource; const model = models.find((model) => model.name === modelName); if (!model) return data; @@ -174,18 +179,24 @@ export const flatRelationInData = (data: any, resource: ModelName) => { return Object.keys(data).reduce((acc, key) => { const field = model.fields.find((field) => field.name === key); const fieldKind = field?.kind; - if (fieldKind === "object") { - if (Array.isArray(data[key])) { - acc[key] = data[key].map((item: any) => item.id); - } else { - acc[key] = data[key] ? data[key].id : null; - } + const get = editOptions?.fields?.[key as Field]?.handler?.get; + if (get) { + acc[key] = get(data[key]); } else { - const fieldTypes = field?.type; - if (fieldTypes === "DateTime") { - acc[key] = data[key] ? data[key].toISOString() : null; + if (fieldKind === "object") { + // Flat relationships to id + if (Array.isArray(data[key])) { + acc[key] = data[key].map((item: any) => item.id); + } else { + acc[key] = data[key] ? data[key].id : null; + } } else { - acc[key] = data[key] ? data[key] : null; + const fieldTypes = field?.type; + if (fieldTypes === "DateTime") { + acc[key] = data[key] ? data[key].toISOString() : null; + } else { + acc[key] = data[key] ? data[key] : null; + } } } return acc; @@ -205,6 +216,7 @@ export const findRelationInData = async ( dmmfSchema?: Prisma.DMMF.Field[] ) => { dmmfSchema?.forEach((dmmfProperty) => { + const dmmfPropertyName = dmmfProperty.name; const dmmfPropertyType = dmmfProperty.type; const dmmfPropertyKind = dmmfProperty.kind; const dmmfPropertyRelationFromFields = dmmfProperty.relationFromFields; @@ -215,15 +227,15 @@ export const findRelationInData = async ( dmmfPropertyRelationFromFields!.length > 0 && dmmfPropertyRelationToFields!.length > 0 ) { - const relationProperty = dmmfPropertyRelationFromFields![0]; data.map((item) => { - if (item[relationProperty]) { - item[relationProperty] = { + if (item[dmmfPropertyName]) { + item[dmmfPropertyName] = { type: "link", value: { - label: item[relationProperty], - url: `${dmmfProperty.type as ModelName}/${item[relationProperty] - }`, + label: item[dmmfPropertyName], + url: `${dmmfProperty.type as ModelName}/${ + item[dmmfPropertyName]["id"] + }`, }, }; } else { @@ -247,7 +259,7 @@ export const findRelationInData = async ( if (item[dmmfProperty.name]) { item[dmmfProperty.name] = { type: "date", - value: new Date(item[dmmfProperty.name]).toLocaleString("fr-FR"), + value: item[dmmfProperty.name].toISOString(), }; } else { return item; @@ -258,7 +270,6 @@ export const findRelationInData = async ( return data; }; - export const parseFormData = ( formData: FormData, dmmfSchema: Prisma.DMMF.Field[] @@ -270,26 +281,35 @@ export const parseFormData = ( const dmmfPropertyType = dmmfProperty.type; const dmmfPropertyKind = dmmfProperty.kind; if (dmmfPropertyKind === "object") { - if(Boolean(formData[dmmfPropertyName])) { - parsedData[dmmfPropertyName] = JSON.parse(formData[dmmfPropertyName] as string) as ModelWithoutRelationships[typeof dmmfPropertyName]; + if (Boolean(formData[dmmfPropertyName])) { + parsedData[dmmfPropertyName] = JSON.parse( + formData[dmmfPropertyName] as string + ) as ModelWithoutRelationships[typeof dmmfPropertyName]; } else { - parsedData[dmmfPropertyName] = null as ModelWithoutRelationships[typeof dmmfPropertyName]; + parsedData[dmmfPropertyName] = + null as ModelWithoutRelationships[typeof dmmfPropertyName]; } } else if (dmmfPropertyType === "Int") { const value = Number(formData[dmmfPropertyName]) as number; - parsedData[dmmfPropertyName] = isNaN(value) ? undefined : value as ModelWithoutRelationships[typeof dmmfPropertyName]; + parsedData[dmmfPropertyName] = isNaN(value) + ? undefined + : (value as ModelWithoutRelationships[typeof dmmfPropertyName]); } else if (dmmfPropertyType === "Boolean") { - parsedData[dmmfPropertyName] = (formData[dmmfPropertyName] === "on") as ModelWithoutRelationships[typeof dmmfPropertyName]; + parsedData[dmmfPropertyName] = (formData[dmmfPropertyName] === + "on") as unknown as ModelWithoutRelationships[typeof dmmfPropertyName]; } else if (dmmfPropertyType === "DateTime") { - parsedData[dmmfPropertyName] = (formData[dmmfPropertyName] ? new Date(formData[dmmfPropertyName]!) : null) as ModelWithoutRelationships[typeof dmmfPropertyName]; + parsedData[dmmfPropertyName] = formData[ + dmmfPropertyName + ] as unknown as ModelWithoutRelationships[typeof dmmfPropertyName]; } else { - parsedData[dmmfPropertyName] = formData[dmmfPropertyName] as ModelWithoutRelationships[typeof dmmfPropertyName]; + parsedData[dmmfPropertyName] = formData[ + dmmfPropertyName + ] as unknown as ModelWithoutRelationships[typeof dmmfPropertyName]; } } - } - ); + }); return parsedData; -} +}; /** * Convert the form data to the format expected by Prisma @@ -298,63 +318,99 @@ export const parseFormData = ( * @param dmmfSchema * */ -export const formattedFormData = ( +export const formattedFormData = async ( formData: FormData, dmmfSchema: Prisma.DMMF.Field[], schema: Schema, resource: M, - creating: boolean + creating: boolean, + editOptions?: EditFieldsOptions ) => { const formattedData: any = {}; const modelName = resource; - dmmfSchema.forEach((dmmfProperty) => { - if (dmmfProperty.name in formData) { - const dmmfPropertyName = dmmfProperty.name as Field; - const dmmfPropertyType = dmmfProperty.type; - const dmmfPropertyKind = dmmfProperty.kind; - if (dmmfPropertyKind === "object") { - const dmmfPropertyTypeTyped = dmmfPropertyType as Prisma.ModelName; - const fieldValue = - schema.definitions[modelName].properties[ - dmmfPropertyName as Field - ]; - const model = models.find((model) => model.name === dmmfPropertyType); - const formatId = (value?: string) => - model?.fields.find((field) => field.name === "id")?.type === "Int" - ? Number(value) - : value; - if (fieldValue?.type === "array") { - formData[dmmfPropertyName] = JSON.parse(formData[dmmfPropertyName]!); - formattedData[dmmfPropertyName] = { - // @ts-expect-error - [creating ? "connect" : "set"]: formData[dmmfPropertyName].map( - (item: any) => ({ id: formatId(item) }) - ), - }; - } else { - const connect = Boolean(formData[dmmfPropertyName]); - if (!creating) - formattedData[dmmfPropertyName] = connect - ? { connect: { id: formatId(formData[dmmfPropertyName]) } } - : { disconnect: true }; - } - } else { - if (dmmfPropertyType === "Int") { - formattedData[dmmfPropertyName] = !isNaN( - Number(formData[dmmfPropertyName]) - ) - ? Number(formData[dmmfPropertyName]) - : undefined; - } else if (dmmfPropertyType === "Boolean") { - formattedData[dmmfPropertyName] = formData[dmmfPropertyName] === "on"; - } else if (dmmfPropertyType === "DateTime") { - formattedData[dmmfPropertyName] = formData[dmmfPropertyName] ? new Date(formData[dmmfPropertyName]!) : null; + await Promise.allSettled( + dmmfSchema.map(async (dmmfProperty) => { + if (dmmfProperty.name in formData) { + const dmmfPropertyName = dmmfProperty.name as Field; + const dmmfPropertyType = dmmfProperty.type; + const dmmfPropertyKind = dmmfProperty.kind; + if (dmmfPropertyKind === "object") { + const dmmfPropertyTypeTyped = dmmfPropertyType as Prisma.ModelName; + const fieldValue = + schema.definitions[modelName].properties[ + dmmfPropertyName as Field + ]; + const model = models.find((model) => model.name === dmmfPropertyType); + const formatId = (value?: string) => + model?.fields.find((field) => field.name === "id")?.type === "Int" + ? Number(value) + : value; + if (fieldValue?.type === "array") { + formData[dmmfPropertyName] = JSON.parse( + formData[dmmfPropertyName]! + ); + formattedData[dmmfPropertyName] = { + // @ts-expect-error + [creating ? "connect" : "set"]: formData[dmmfPropertyName].map( + (item: any) => ({ id: formatId(item) }) + ), + }; + } else { + const connect = Boolean(formData[dmmfPropertyName]); + if (!creating) + formattedData[dmmfPropertyName] = connect + ? { connect: { id: formatId(formData[dmmfPropertyName]) } } + : { disconnect: true }; + } } else { - formattedData[dmmfPropertyName] = formData[dmmfPropertyName]; + if (dmmfPropertyType === "Int") { + formattedData[dmmfPropertyName] = !isNaN( + Number(formData[dmmfPropertyName]) + ) + ? Number(formData[dmmfPropertyName]) + : undefined; + } else if (dmmfPropertyType === "Boolean") { + formattedData[dmmfPropertyName] = + formData[dmmfPropertyName] === "on"; + } else if (dmmfPropertyType === "DateTime") { + formattedData[dmmfPropertyName] = formData[dmmfPropertyName] + ? new Date(formData[dmmfPropertyName]!) + : null; + } else if ( + dmmfPropertyType === "String" && + ["data-url", "file"].includes( + editOptions?.[dmmfPropertyName]?.format ?? "" + ) && + formData[dmmfPropertyName] instanceof Buffer + ) { + const uploadHandler = + editOptions?.[dmmfPropertyName]?.handler?.upload; + + if (!uploadHandler) { + console.warn( + "You need to provide an upload handler for data-url format" + ); + } else { + const uploadResult = await uploadHandler( + formData[dmmfPropertyName] as unknown as Buffer + ); + if (typeof uploadResult !== "string") { + console.warn( + "Upload handler must return a string, fallback to no-op for field " + + dmmfPropertyName.toString() + ); + } else { + formattedData[dmmfPropertyName] = uploadResult; + } + } + } else { + formattedData[dmmfPropertyName] = formData[dmmfPropertyName]; + } } } - } - }); + }) + ); + return formattedData; }; @@ -378,15 +434,52 @@ export const formattedFormData = ( export const formatSearchFields = (uri: string) => Object.fromEntries(new URLSearchParams(uri)); +export const transformSchema = ( + schema: Schema, + resource: M, + editOptions: EditOptions +) => { + schema = removeHiddenProperties(schema, resource, editOptions); + schema = changeFormatInSchema(schema, resource, editOptions); + return schema; +}; + +export const changeFormatInSchema = ( + schema: Schema, + resource: M, + editOptions: EditOptions +) => { + const modelName = resource; + const model = models.find((model) => model.name === modelName); + if (!model) return schema; + model.fields.forEach((dmmfProperty) => { + const dmmfPropertyName = dmmfProperty.name as Field; + if (editOptions?.fields?.[dmmfPropertyName]?.format) { + const fieldValue = + schema.definitions[modelName].properties[ + dmmfPropertyName as Field + ]; + if (fieldValue) { + if (editOptions?.fields?.[dmmfPropertyName]?.format === "file") { + fieldValue.format = "data-url"; + } else { + fieldValue.format = editOptions?.fields?.[dmmfPropertyName]?.format; + } + } + } + }); + return schema; +}; + export const removeHiddenProperties = ( schema: Schema, - editOptions: EditFieldsOptions, - resource: M + resource: M, + editOptions: EditOptions ) => { if (!editOptions) return schema; const properties = schema.definitions[resource].properties; Object.keys(properties).forEach((property) => { - if (!editOptions[property as Field]?.display) { + if (!editOptions.display?.includes(property as Field)) { delete properties[property as Field]; } }); @@ -421,3 +514,59 @@ export const getResourceIdFromUrl = ( return matching ? matching[1] : undefined; }; + +export const getFormDataValues = async (req: IncomingMessage) => { + const form = formidable({ + allowEmptyFiles: true, + minFileSize: 0, + fileWriteStreamHandler: () => { + return new Writable({ + write(_chunk, _encoding, callback) { + callback(); + }, + }); + }, + }); + return new Promise>( + (resolve, reject) => { + const files = {} as Record; + + form.on("fileBegin", (name, file) => { + // @ts-expect-error + file.createFileWriteStream = () => { + const chunks = [] as Buffer[]; + return new Writable({ + write(chunk, _encoding, callback) { + chunks.push(chunk); + callback(); + }, + final(callback) { + if (!file.originalFilename) { + files[name] = [null]; + } else { + files[name] = [Buffer.concat(chunks)]; + } + callback(); + }, + }); + }; + }); + + form.parse(req, (err, fields) => { + if (err) { + reject(err); + } + const joinedFormData = Object.entries({ ...fields, ...files }).reduce( + (acc, [key, value]) => { + if (Array.isArray(value)) { + acc[key] = value[0]; + } + return acc; + }, + {} as Record + ); + resolve(joinedFormData); + }); + } + ); +}; diff --git a/packages/next-admin/src/utils/tools.ts b/packages/next-admin/src/utils/tools.ts index 982f3fd3..1ff3e32a 100644 --- a/packages/next-admin/src/utils/tools.ts +++ b/packages/next-admin/src/utils/tools.ts @@ -9,5 +9,5 @@ export const uncapitalize = (str: T): Uncapitalize => { }; export const isNativeFunction = (fn: Function) => { - return (/\{\s*\[native code\]\s*\}/).test(fn.toString()) -} \ No newline at end of file + return /\{\s*\[native code\]\s*\}/.test(fn.toString()); +}; diff --git a/packages/next-admin/src/utils/validator.test.ts b/packages/next-admin/src/utils/validator.test.ts index 196f7e31..383f90af 100644 --- a/packages/next-admin/src/utils/validator.test.ts +++ b/packages/next-admin/src/utils/validator.test.ts @@ -14,7 +14,7 @@ describe("validator", () => { expect(() => validate<"User">( { - id: "1", + id: 1, email: "bob.com", }, { diff --git a/packages/next-admin/src/utils/validator.ts b/packages/next-admin/src/utils/validator.ts index 319a77f3..5cbcb77d 100644 --- a/packages/next-admin/src/utils/validator.ts +++ b/packages/next-admin/src/utils/validator.ts @@ -1,4 +1,8 @@ -import { EditFieldsOptions, ModelName, ModelWithoutRelationships } from "../types"; +import { + EditFieldsOptions, + ModelName, + ModelWithoutRelationships, +} from "../types"; import { PropertyValidationError, ValidationError, @@ -15,7 +19,7 @@ export const validate = ( const errors: PropertyValidationError[] = []; let property: keyof typeof fieldsOptions; for (property in fieldsOptions) { - if (fieldsOptions[property]?.validate) { + if (fieldsOptions[property]?.validate && formData[property]) { const validation = fieldsOptions[property]!.validate!( // @ts-ignore formData[property] @@ -33,4 +37,4 @@ export const validate = ( if (errors.length > 0) { throw new ValidationError("Validation error", errors); } -} +}; diff --git a/yarn.lock b/yarn.lock index d1a92d58..bcdfcca6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -104,11 +104,11 @@ "@babel/types" "^7.18.6" "@babel/helper-module-imports@^7.13.12": - version "7.18.6" - resolved "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz" - integrity sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA== + version "7.22.15" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz#16146307acdc40cc00c3b2c647713076464bdbf0" + integrity sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w== dependencies: - "@babel/types" "^7.18.6" + "@babel/types" "^7.22.15" "@babel/helper-module-imports@^7.21.4": version "7.21.4" @@ -160,11 +160,21 @@ resolved "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.21.5.tgz" integrity sha512-5pTUx3hAJaZIdW99sJ6ZUUgWq/Y+Hja7TowEnLNMm1VivRgZQL3vpBY3qUACVsvw+yQU6+YgfBVmcbLaZtrA1w== +"@babel/helper-string-parser@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz#533f36457a25814cf1df6488523ad547d784a99f" + integrity sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw== + "@babel/helper-validator-identifier@^7.18.6", "@babel/helper-validator-identifier@^7.19.1": version "7.19.1" resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz" integrity sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w== +"@babel/helper-validator-identifier@^7.22.20": + version "7.22.20" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz#c4ae002c61d2879e724581d96665583dbc1dc0e0" + integrity sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A== + "@babel/helper-validator-option@^7.21.0": version "7.21.0" resolved "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.21.0.tgz" @@ -353,7 +363,16 @@ "@babel/helper-validator-identifier" "^7.19.1" to-fast-properties "^2.0.0" -"@babel/types@^7.13.17", "@babel/types@^7.18.6", "@babel/types@^7.20.7", "@babel/types@^7.21.0": +"@babel/types@^7.13.17", "@babel/types@^7.22.15": + version "7.23.0" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.23.0.tgz#8c1f020c9df0e737e4e247c0619f58c68458aaeb" + integrity sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg== + dependencies: + "@babel/helper-string-parser" "^7.22.5" + "@babel/helper-validator-identifier" "^7.22.20" + to-fast-properties "^2.0.0" + +"@babel/types@^7.18.6", "@babel/types@^7.20.7", "@babel/types@^7.21.0": version "7.21.3" resolved "https://registry.npmjs.org/@babel/types/-/types-7.21.3.tgz" integrity sha512-sBGdETxC+/M4o/zKC0sl6sjWv62WFR/uzxrJ6uYyMLZOUlPnwzw0tKgVHOXxaAd5l2g8pEDM5RZ495GPQI77kg== @@ -1965,6 +1984,13 @@ resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz" integrity sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA== +"@types/formidable@^3.4.5": + version "3.4.5" + resolved "https://registry.yarnpkg.com/@types/formidable/-/formidable-3.4.5.tgz#8e45c053cac5868e2b71cc7410e2bd92872f6b9c" + integrity sha512-s7YPsNVfnsng5L8sKnG/Gbb2tiwwJTY1conOkJzTMRvJAlLFW1nEua+ADsJQu8N1c0oTHx9+d5nqg10WuT9gHQ== + dependencies: + "@types/node" "*" + "@types/graceful-fs@^4.1.3": version "4.1.6" resolved "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.6.tgz" @@ -2478,6 +2504,11 @@ arrify@^1.0.1: resolved "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz" integrity sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA== +asap@^2.0.0: + version "2.0.6" + resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" + integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== + ast-types-flow@^0.0.7: version "0.0.7" resolved "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz" @@ -2563,7 +2594,7 @@ babel-plugin-jest-hoist@^29.5.0: babel-plugin-superjson-next@^0.4.5: version "0.4.5" - resolved "https://registry.npmjs.org/babel-plugin-superjson-next/-/babel-plugin-superjson-next-0.4.5.tgz" + resolved "https://registry.yarnpkg.com/babel-plugin-superjson-next/-/babel-plugin-superjson-next-0.4.5.tgz#988ca073fb2059f1dc0c2d439b42e82a478b8cd2" integrity sha512-k7S99Qpsbi3OSdlCMXEiklzxepM6QbYEIUsrjgSkpx+ksT0iNfdY2r1kCzBK2UjG8fLN6NZEKpDA8XpG2pbDSA== dependencies: "@babel/helper-module-imports" "^7.13.12" @@ -2782,15 +2813,10 @@ camelcase@^6.2.0: resolved "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz" integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== -caniuse-lite@^1.0.30001406, caniuse-lite@^1.0.30001449: - version "1.0.30001469" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001469.tgz" - integrity sha512-Rcp7221ScNqQPP3W+lVOYDyjdR6dC+neEQCttoNr5bAyz54AboB4iwpnWgyi8P4YUsPybVzT4LgWiBbI3drL4g== - -caniuse-lite@^1.0.30001464: - version "1.0.30001470" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001470.tgz" - integrity sha512-065uNwY6QtHCBOExzbV6m236DDhYCCtPmQUCoQtwkVqzud8v5QPidoMr6CoMkC2nfp6nksjttqWQRRh75LqUmA== +caniuse-lite@^1.0.30001406, caniuse-lite@^1.0.30001449, caniuse-lite@^1.0.30001464: + version "1.0.30001553" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001553.tgz" + integrity sha512-N0ttd6TrFfuqKNi+pMgWJTb9qrdJu4JSpgPFLe/lrD19ugC6fZgF0pUewRowDwzdDnb9V41mFcdlYgl/PyKf4A== ccount@^2.0.0: version "2.0.1" @@ -3103,9 +3129,9 @@ convert-source-map@^2.0.0: integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== copy-anything@^3.0.2: - version "3.0.3" - resolved "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.3.tgz" - integrity sha512-fpW2W/BqEzqPp29QS+MwwfisHCQZtiduTe/m8idFo0xbti9fIZ2WVhAsCv4ggFVH3AgCkVdpoOCtQC6gBrdhjw== + version "3.0.5" + resolved "https://registry.yarnpkg.com/copy-anything/-/copy-anything-3.0.5.tgz#2d92dce8c498f790fa7ad16b01a1ae5a45b020a0" + integrity sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w== dependencies: is-what "^4.1.8" @@ -3662,6 +3688,14 @@ detective@^5.2.1: defined "^1.0.0" minimist "^1.2.6" +dezalgo@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/dezalgo/-/dezalgo-1.0.4.tgz#751235260469084c132157dfa857f386d4c33d81" + integrity sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig== + dependencies: + asap "^2.0.0" + wrappy "1" + didyoumean@^1.2.2: version "1.2.2" resolved "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz" @@ -4468,6 +4502,15 @@ for-each@^0.3.3: dependencies: is-callable "^1.1.3" +formidable@^3.5.1: + version "3.5.1" + resolved "https://registry.yarnpkg.com/formidable/-/formidable-3.5.1.tgz#9360a23a656f261207868b1484624c4c8d06ee1a" + integrity sha512-WJWKelbRHN41m5dumb0/k8TeAx7Id/y3a+Z7QfhxP/htI9Js5zYaEDtG8uMgG0vM0lOlqnmjE99/kfpOYi/0Og== + dependencies: + dezalgo "^1.0.4" + hexoid "^1.0.0" + once "^1.4.0" + fp-ts@2.13.1: version "2.13.1" resolved "https://registry.npmjs.org/fp-ts/-/fp-ts-2.13.1.tgz" @@ -4932,9 +4975,14 @@ heap@^0.2.6: resolved "https://registry.npmjs.org/heap/-/heap-0.2.7.tgz" integrity sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg== +hexoid@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/hexoid/-/hexoid-1.0.0.tgz#ad10c6573fb907de23d9ec63a711267d9dc9bc18" + integrity sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g== + hoist-non-react-statics@^3.3.2: version "3.3.2" - resolved "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== dependencies: react-is "^16.7.0" @@ -5399,9 +5447,9 @@ is-weakset@^2.0.1: get-intrinsic "^1.1.1" is-what@^4.1.8: - version "4.1.8" - resolved "https://registry.npmjs.org/is-what/-/is-what-4.1.8.tgz" - integrity sha512-yq8gMao5upkPoGEU9LsB2P+K3Kt8Q3fQFCGyNCWOAnJAMzEXVV9drYb0TXr42TTliLLhKIBvulgAXgtLLnwzGA== + version "4.1.15" + resolved "https://registry.yarnpkg.com/is-what/-/is-what-4.1.15.tgz#de43a81090417a425942d67b1ae86e7fae2eee0e" + integrity sha512-uKua1wfy3Yt+YqsD6mTUEa2zSi3G1oPlqTflgaPJ7z63vUGN5pxFpnQfeSLMFnJDEsdvOtkp1rUWkYjB4YfhgA== is-windows@^1.0.0, is-windows@^1.0.2: version "1.0.2" @@ -7002,9 +7050,9 @@ next-seo@^6.0.0: integrity sha512-jKKt1p1z4otMA28AyeoAONixVjdYmgFCWwpEFtu+DwRHQDllVX3RjtyXbuCQiUZEfQ9rFPBpAI90vDeLZlMBdg== next-superjson-plugin@^0.5.6: - version "0.5.6" - resolved "https://registry.npmjs.org/next-superjson-plugin/-/next-superjson-plugin-0.5.6.tgz" - integrity sha512-vokzkgtr3axon5uXobhXJWN6/YlPOHdcAb/BeR+d0rXA3h1b5h98A+V0TgMUf+vcy9RvBqte/ppaJfkOwi4NGA== + version "0.5.9" + resolved "https://registry.yarnpkg.com/next-superjson-plugin/-/next-superjson-plugin-0.5.9.tgz#2b4dc19799d205d33e5c648a7e48a2cef79b3052" + integrity sha512-xEU+SvrSDk8PGAQ2TrJM/OhkZBcWjWWqSjpUtlxTAI9ovTjKBxxQumcFt7oXzoBLJsRtq0BWTHrcnzL/9izXPQ== dependencies: hoist-non-react-statics "^3.3.2" @@ -7636,11 +7684,16 @@ prelude-ls@^1.2.1: resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== -prettier@^2.7.1, prettier@latest: +prettier@^2.7.1: version "2.8.7" resolved "https://registry.npmjs.org/prettier/-/prettier-2.8.7.tgz" integrity sha512-yPngTo3aXUUmyuTjeTUT75txrf+aMh9FiD7q9ZE/i6r0bPb22g4FsE6Y338PQX1bmfy08i9QQCB7/rcUAVntfw== +prettier@latest: + version "3.1.0" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.1.0.tgz#c6d16474a5f764ea1a4a373c593b779697744d5e" + integrity sha512-TQLvXjq5IAibjh8EpBIkNKxO749UEWABoiIZehEPiY4GNpVdhaFKqSTu+QrlU6D2dPAfubRmtJTi4K4YkQ5eXw== + pretty-format@^29.0.0, pretty-format@^29.5.0: version "29.5.0" resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-29.5.0.tgz" @@ -8676,9 +8729,9 @@ sucrase@^3.32.0: ts-interface-checker "^0.1.9" superjson@^1.12.2: - version "1.12.2" - resolved "https://registry.npmjs.org/superjson/-/superjson-1.12.2.tgz" - integrity sha512-ugvUo9/WmvWOjstornQhsN/sR9mnGtWGYeTxFuqLb4AiT4QdUavjGFRALCPKWWnAiUJ4HTpytj5e0t5HoMRkXg== + version "1.13.3" + resolved "https://registry.yarnpkg.com/superjson/-/superjson-1.13.3.tgz#3bd64046f6c0a47062850bb3180ef352a471f930" + integrity sha512-mJiVjfd2vokfDxsQPOwJ/PtanO87LhpYY88ubI5dUB1Ab58Txbyje3+jpm+/83R/fevaq/107NNhtYBLuoTrFg== dependencies: copy-anything "^3.0.2"