From d03a3e0f4b2fb114f8a0862718c64fad98c01fb9 Mon Sep 17 00:00:00 2001 From: foyarash <11079152+foyarash@users.noreply.github.com> Date: Mon, 20 Nov 2023 15:15:02 +0100 Subject: [PATCH 1/7] chore: :art: apply prettier to all files --- .env => apps/example/.env | 0 .env.example => apps/example/.env.example | 0 apps/example/e2e/table.spec.ts | 8 +- apps/example/pages/admin/[[...nextadmin]].tsx | 56 +- apps/example/playwright.config.ts | 8 +- apps/example/prisma/schema.prisma | 39 +- packages/next-admin/src/components/Cell.tsx | 19 +- .../next-admin/src/components/DataTable.tsx | 40 +- packages/next-admin/src/components/List.tsx | 95 ++-- .../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/SelectWidget.tsx | 4 +- .../next-admin/src/context/ConfigContext.tsx | 35 +- packages/next-admin/src/index.tsx | 2 +- packages/next-admin/src/router.tsx | 531 +++++++++--------- .../next-admin/src/tests/prismaUtils.test.ts | 15 +- .../next-admin/src/tests/serverUtils.test.ts | 22 +- packages/next-admin/src/tests/singleton.ts | 37 +- packages/next-admin/src/types.ts | 89 ++- packages/next-admin/src/utils/prisma.ts | 66 ++- packages/next-admin/src/utils/server.ts | 84 ++- packages/next-admin/src/utils/tools.ts | 4 +- packages/next-admin/src/utils/validator.ts | 8 +- yarn.lock | 7 +- 25 files changed, 838 insertions(+), 675 deletions(-) rename .env => apps/example/.env (100%) rename .env.example => apps/example/.env.example (100%) diff --git a/.env b/apps/example/.env similarity index 100% rename from .env rename to apps/example/.env 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 6bfdae99..4b649227 100644 --- a/apps/example/pages/admin/[[...nextadmin]].tsx +++ b/apps/example/pages/admin/[[...nextadmin]].tsx @@ -25,9 +25,11 @@ const options: NextAdminOptions = { }, birthDate: { formatter: (date) => { - return new Date(date as unknown as string)?.toLocaleString().split(" ")[0]; - } - } + return new Date(date as unknown as string) + ?.toLocaleString() + .split(" ")[0]; + }, + }, }, }, edit: { @@ -37,22 +39,23 @@ const options: NextAdminOptions = { validate: (email) => email.includes("@") || "Invalid email", }, birthDate: { - format: "date", - handler: { - //This getter is used to format the date in the form and match with the format of the input - get: (value) => { - return value?.toISOString().split("T")[0]; - }, - } - } + format: "date-time", + }, }, }, }, Post: { toString: (post) => `${post.title}`, list: { - display: ['id', 'title', 'content', 'published', 'author', 'categories'], - search: ['title', 'content'], + display: [ + "id", + "title", + "content", + "published", + "author", + "categories", + ], + search: ["title", "content"], fields: { author: { formatter: (author) => { @@ -62,30 +65,31 @@ const options: NextAdminOptions = { }, }, edit: { - display: ['id', 'title', 'content', 'published', 'authorId', 'categories'], - } + display: [ + "id", + "title", + "content", + "published", + "authorId", + "categories", + ], + }, }, Category: { toString: (category) => `${category.name}`, list: { - display: ['name', 'posts'], - search: ['name'], + display: ["name", "posts"], + search: ["name"], }, edit: { - display: ['name', 'posts'], - } + display: ["name", "posts"], + }, }, }, }; export default function Admin(props: AdminComponentProps) { - return ( - - ); + return ; } export const getServerSideProps: GetServerSideProps = async ({ req, res }) => { 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/schema.prisma b/apps/example/prisma/schema.prisma index 70b1f118..a6d665d4 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,28 @@ 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) } 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 +54,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/packages/next-admin/src/components/Cell.tsx b/packages/next-admin/src/components/Cell.tsx index 97fef2f0..44d78461 100644 --- a/packages/next-admin/src/components/Cell.tsx +++ b/packages/next-admin/src/components/Cell.tsx @@ -6,16 +6,18 @@ import clsx from "clsx"; import { useConfig } from "../context/ConfigContext"; type Props = { - cell: ListDataFieldValue | ReactNode - formatter: (cell: any) => ReactNode + cell: ListDataFieldValue | ReactNode; + formatter: (cell: any) => ReactNode; }; export default function Cell({ cell, formatter }: Props) { - const { basePath } = useConfig() + 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; @@ -60,8 +62,9 @@ export default function Cell({ cell, formatter }: Props) {

{formatter(cell.toString())}

diff --git a/packages/next-admin/src/components/DataTable.tsx b/packages/next-admin/src/components/DataTable.tsx index 28b52647..46ec1c39 100644 --- a/packages/next-admin/src/components/DataTable.tsx +++ b/packages/next-admin/src/components/DataTable.tsx @@ -15,26 +15,40 @@ import { TableRow, } from "./radix/Table"; import { useRouter } from "next/compat/router"; -import { ListData, ListDataItem, ModelName, Field, NextAdminOptions } from "../types"; +import { + ListData, + ListDataItem, + ModelName, + Field, + NextAdminOptions, +} from "../types"; import { useConfig } from "../context/ConfigContext"; interface DataTableProps { columns: ColumnDef>[]; data: ListData; resource: ModelName; - options: (Required)['model'][ModelName] + options: Required["model"][ModelName]; } -export function DataTable({ columns, data, resource, options }: 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 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, @@ -58,9 +72,9 @@ export function DataTable({ columns, data, resource, options }: 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/List.tsx b/packages/next-admin/src/components/List.tsx index 4d36214c..9e21b285 100644 --- a/packages/next-admin/src/components/List.tsx +++ b/packages/next-admin/src/components/List.tsx @@ -3,7 +3,13 @@ import debounce from "lodash/debounce"; import { useRouter } from "next/compat/router"; import { ChangeEvent, useTransition } from "react"; import { ITEMS_PER_PAGE } from "../config"; -import { ListData, ListDataItem, ListFieldsOptions, ModelName, NextAdminOptions } from "../types"; +import { + ListData, + ListDataItem, + ListFieldsOptions, + ModelName, + NextAdminOptions, +} from "../types"; import Cell from "./Cell"; import { DataTable } from "./DataTable"; import ListHeader from "./ListHeader"; @@ -22,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) { @@ -47,47 +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 = 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 { + 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 ( @@ -113,7 +122,7 @@ function List({ resource, data, total, options }: ListProps) { /> {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/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 7b25d562..2a88db62 100644 --- a/packages/next-admin/src/router.tsx +++ b/packages/next-admin/src/router.tsx @@ -25,7 +25,7 @@ import { getResources, parseFormData, transformData, - transformSchema + transformSchema, } from "./utils/server"; import { validate } from "./utils/validator"; @@ -38,295 +38,324 @@ 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 ( + 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); - 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; - }, { id: true } as Select); - - schema = await fillRelationInSchema( - schema, - prisma, - resource, - requestOptions, - options - ); - const edit = options?.model?.[resource]?.edit as EditOptions; - const editDisplayedKeys = edit && edit.display - const editSelect = editDisplayedKeys?.reduce((acc, column) => { - acc[column] = true; - return acc; - }, { id: true } 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 = transformSchema(schema, resource, edit); - data = transformData(data, resource, edit); + 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; + + // 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 = transformSchema(schema, resource, edit); + data = transformData(data, resource, edit); + 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 { data, total, error } = await getMappedDataList( + prisma, + resource, + options, + searchParams + ); return { props: { ...defaultProps, resource, data, + total, + error, schema, - dmmfSchema: dmmfSchema?.fields, + dmmfSchema, }, }; - } - // New - if (req.url!.includes("/new")) { - return { - props: { - ...defaultProps, - resource, - schema, - dmmfSchema: dmmfSchema?.fields, + }) + .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; }, - }; - } - - // List - const searchParams = new URLSearchParams(req.url!.split("?")[1]); - const { data, total, error } = await getMappedDataList(prisma, resource, options, searchParams); - return { - props: { - ...defaultProps, + { id: true } as Select + ); + + schema = await fillRelationInSchema( + schema, + prisma, resource, - data, - total, - error, + 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, - 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) => { + prisma, + resource, + requestOptions, + options + ); + schema = transformSchema(schema, resource, edit); + await getBody(req, res); + // @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; - 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); - 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 { data, total } = await getMappedDataList(prisma, resource, options, searchParams); + const { id, ...formData } = req.body as Body>; - return { - props: { - ...defaultProps, + 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 { data, total } = await getMappedDataList( + prisma, resource, - message: { - type: "success", - content: "Deleted successfully", + options, + searchParams + ); + + return { + props: { + ...defaultProps, + resource, + message: { + type: "success", + content: "Deleted successfully", + }, + total, + data, }, - total, - data, - }, - }; - } + }; + } - // Delete - if (resourceId !== undefined && formData.action === "delete") { - // @ts-expect-error - await prisma[resource].delete({ - where: { - id: resourceId, - }, - }); + // 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, - }, - }; - } + return { + redirect: { + destination: `${options.basePath}/${resource}`, + permanent: false, + }, + }; + } - // Update - let data; + // Update + let data; - const fields = options.model?.[resource]?.edit?.fields as EditFieldsOptions; + const fields = options.model?.[resource]?.edit + ?.fields as EditFieldsOptions; - // Validate - validate(parsedFormData, fields) + // Validate + validate(parsedFormData, fields); - if (resourceId !== undefined) { + if (resourceId !== undefined) { + // @ts-expect-error + data = await prisma[resource].update({ + where: { + id: resourceId, + }, + data: formattedFormData( + formData, + dmmfSchema?.fields!, + schema, + resource, + false + ), + select: selectedFields, + }); + + 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, + }, + }; + } + + // Create // @ts-expect-error - data = await prisma[resource].update({ - where: { - id: resourceId, - }, + data = await prisma[resource].create({ data: formattedFormData( formData, dmmfSchema?.fields!, schema, resource, - false + true ), select: selectedFields, }); 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, + redirect: { + destination: `${options.basePath}/${resource}/${data.id}`, + permanent: false, }, }; - } - - // Create - // @ts-expect-error - data = await prisma[resource].create({ - data: formattedFormData( - formData, - dmmfSchema?.fields!, - schema, - resource, - true - ), - 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; + } 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); + } - 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]; + }); + } - // 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, + }, + }; } - 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 index 2a16c7ea..9b9fa576 100644 --- a/packages/next-admin/src/tests/prismaUtils.test.ts +++ b/packages/next-admin/src/tests/prismaUtils.test.ts @@ -3,8 +3,6 @@ import { options, prismaMock } from "./singleton"; describe("getMappedDataList", () => { it("should return the data list, total and error", async () => { - - const postData = [ { id: 1, @@ -19,14 +17,19 @@ describe("getMappedDataList", () => { content: "Content 2", published: true, authorId: 1, - } - ] + }, + ]; - prismaMock.post.findMany.mockResolvedValueOnce(postData) + prismaMock.post.findMany.mockResolvedValueOnce(postData); prismaMock.post.count.mockResolvedValueOnce(2); - const result = await getMappedDataList(prismaMock, "Post", options, new URLSearchParams()); + const result = await getMappedDataList( + prismaMock, + "Post", + options, + new URLSearchParams() + ); expect(result).toEqual({ data: postData, total: postData.length, diff --git a/packages/next-admin/src/tests/serverUtils.test.ts b/packages/next-admin/src/tests/serverUtils.test.ts index bbbd8288..e7de9da2 100644 --- a/packages/next-admin/src/tests/serverUtils.test.ts +++ b/packages/next-admin/src/tests/serverUtils.test.ts @@ -1,4 +1,8 @@ -import { changeFormatInSchema, fillRelationInSchema, removeHiddenProperties } from "../utils/server"; +import { + changeFormatInSchema, + fillRelationInSchema, + removeHiddenProperties, +} from "../utils/server"; import { options, prismaMock, schema } from "./singleton"; describe("fillRelationInSchema", () => { @@ -31,17 +35,19 @@ describe("fillRelationInSchema", () => { }); }); -describe('transformSchema', () => { +describe("transformSchema", () => { const userEditOptions = options.model?.User?.edit!; - it('should return the schema with the new format', async () => { + 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'); + expect(result.definitions.User.properties.birthDate?.format).toEqual( + "date" + ); }); - it('should return the schema without the hidden properties', async () => { + 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'); + expect(result.definitions.User.properties).not.toHaveProperty("createdAt"); + expect(result.definitions.User.properties).not.toHaveProperty("updatedAt"); }); -}) \ No newline at end of file +}); diff --git a/packages/next-admin/src/tests/singleton.ts b/packages/next-admin/src/tests/singleton.ts index c43f4443..4aa91781 100644 --- a/packages/next-admin/src/tests/singleton.ts +++ b/packages/next-admin/src/tests/singleton.ts @@ -283,15 +283,15 @@ export const schema: Schema = { post: { $ref: "#/definitions/Post" }, createdAt: { type: "string", - format: "date-time" + format: "date-time", }, updatedAt: { type: "string", - format: "date-time" - } + format: "date-time", + }, }, - required: ["content", "postId"] - } + required: ["content", "postId"], + }, }, }; @@ -303,7 +303,6 @@ export const options: NextAdminOptions = { list: { display: ["id", "name", "email", "posts", "role"], search: ["name", "email"], - }, edit: { display: ["id", "name", "email", "posts", "role", "birthDate"], @@ -313,22 +312,36 @@ export const options: NextAdminOptions = { }, birthDate: { format: "date", - } + }, }, }, }, Post: { toString: (post) => `${post.title}`, list: { - display: ['id', 'title', 'content', 'published', 'author', 'categories'], - search: ['title', 'content'], + display: [ + "id", + "title", + "content", + "published", + "author", + "categories", + ], + search: ["title", "content"], }, edit: { - display: ['id', 'title', 'content', 'published', 'authorId', 'categories'], - } + display: [ + "id", + "title", + "content", + "published", + "authorId", + "categories", + ], + }, }, Category: { toString: (category) => `${category.name}`, }, }, -}; \ No newline at end of file +}; diff --git a/packages/next-admin/src/types.ts b/packages/next-admin/src/types.ts index a3750461..574b774b 100644 --- a/packages/next-admin/src/types.ts +++ b/packages/next-admin/src/types.ts @@ -7,15 +7,29 @@ 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 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 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; @@ -36,22 +50,40 @@ export type EditFieldsOptions = { validate?: (value: ModelWithoutRelationships[P]) => true | string; format?: FormatOptions[P]>; handler?: Handler[P]>; - } + }; }; -export type Handler, T extends Model[P]> = { +export type Handler< + M extends ModelName, + P extends Field, + T extends Model[P], +> = { get?: (input: T) => any; -} +}; -export type FormatOptions = - | T extends string ? "textarea" | "password" | "color" | "email" | "uri" | "data-url" | "date" | "date-time" | "time" | "alt-datetime" | "alt-date" : never - | T extends Date ? "date" | "date-time" | "time" : never - | T extends number ? "updown" | "range" : never; +export type FormatOptions = T extends string + ? + | "textarea" + | "password" + | "color" + | "email" + | "uri" + | "data-url" + | "date" + | "date-time" + | "time" + | "alt-datetime" + | "alt-date" + : never | T extends Date + ? "date" | "date-time" | "time" + : never | T extends number + ? "updown" | "range" + : never; export type ListOptions = { display?: Field[]; search?: Field[]; - fields?: ListFieldsOptions + fields?: ListFieldsOptions; }; export type EditOptions = { @@ -72,7 +104,6 @@ export type NextAdminOptions = { model?: ModelOptions; }; - /** Type for Schema */ export type SchemaProperty = { @@ -93,8 +124,6 @@ export type Schema = Partial> & { definitions: SchemaDefinitions; }; - - export type FormData = { [P in Field]?: string; }; @@ -144,16 +173,16 @@ export type ListDataFieldValue = | boolean | { type: "count"; value: number } | { - type: "link"; - value: { - label: string; - url: string; - }; - } + type: "link"; + value: { + label: string; + url: string; + }; + } | { - type: "date"; - value: Date; - }; + type: "date"; + value: Date; + }; export type AdminComponentProps = { basePath: string; @@ -174,4 +203,4 @@ export type AdminComponentProps = { 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 b3d8b26a..ec1637a6 100644 --- a/packages/next-admin/src/utils/prisma.ts +++ b/packages/next-admin/src/utils/prisma.ts @@ -19,21 +19,21 @@ export const createWherePredicate = ( ) => { return search ? { - OR: fieldsFiltered - ?.filter((field) => field.kind === "scalar") - .map((field) => { - if (field.type === "String") { - return { - [field.name]: { contains: search, mode: "insensitive" }, - }; - } - if (field.type === "Int" && !isNaN(Number(search))) { - return { [field.name]: Number(search) }; - } - return null; - }) - .filter(Boolean), - } + OR: fieldsFiltered + ?.filter((field) => field.kind === "scalar") + .map((field) => { + if (field.type === "String") { + return { + [field.name]: { contains: search, mode: "insensitive" }, + }; + } + if (field.type === "Int" && !isNaN(Number(search))) { + return { [field.name]: Number(search) }; + } + return null; + }) + .filter(Boolean), + } : {}; }; @@ -63,18 +63,21 @@ export const preparePrismaListRequest = ( let fieldsFiltered = model?.fields; const list = options?.model?.[resource]?.list as ListOptions; if (list) { - 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); + 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( @@ -92,7 +95,12 @@ export const preparePrismaListRequest = ( }; }; -export const getMappedDataList = async (prisma: PrismaClient, resource: ModelName, options: NextAdminOptions, searchParams: URLSearchParams) => { +export const getMappedDataList = async ( + prisma: PrismaClient, + resource: ModelName, + options: NextAdminOptions, + searchParams: URLSearchParams +) => { const prismaListRequest = preparePrismaListRequest( resource, searchParams, @@ -129,4 +137,4 @@ export const getMappedDataList = async (prisma: PrismaClient, resource: ModelNam total, error, }; -}; \ No newline at end of file +}; diff --git a/packages/next-admin/src/utils/server.ts b/packages/next-admin/src/utils/server.ts index 80fa669e..e83dcaae 100644 --- a/packages/next-admin/src/utils/server.ts +++ b/packages/next-admin/src/utils/server.ts @@ -11,7 +11,7 @@ import { ModelWithoutRelationships, NextAdminOptions, ScalarField, - Schema + Schema, } from "../types"; import { createWherePredicate } from "./prisma"; import { isNativeFunction, uncapitalize } from "./tools"; @@ -63,7 +63,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,14 +76,14 @@ export const fillRelationInSchema = async ( const remoteModel = models.find( (model) => model.name === modelNameRelation ); - const listOptions = options?.model?.[modelNameRelation]?.list as ListOptions; + const listOptions = options?.model?.[modelNameRelation] + ?.list as ListOptions; const optionsForRelations = - listOptions?.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) => - (optionsForRelations as string[])?.includes(field.name) + const fieldsFiltered = remoteModel?.fields.filter( + (field) => (optionsForRelations as string[])?.includes(field.name) ); const search = requestOptions[`${relationProperty}search`]; const where = createWherePredicate(fieldsFiltered, search); @@ -121,7 +121,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[] = []; @@ -159,7 +159,14 @@ export const fillRelationInSchema = async ( return schema; }; -export const transformData = (data: any, resource: M, editOptions: EditOptions) => { +/** + * 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; @@ -167,9 +174,9 @@ export const transformData = (data: any, resource: M, editO return Object.keys(data).reduce((acc, key) => { const field = model.fields.find((field) => field.name === key); const fieldKind = field?.kind; - const get = editOptions?.fields?.[key as Field]?.handler?.get + const get = editOptions?.fields?.[key as Field]?.handler?.get; if (get) { - acc[key] = get(data[key]) + acc[key] = get(data[key]); } else { if (fieldKind === "object") { // Flat relationships to id @@ -201,10 +208,10 @@ export const transformData = (data: any, resource: M, editO * */ export const findRelationInData = async ( data: any[], - dmmfSchema?: Prisma.DMMF.Field[], + dmmfSchema?: Prisma.DMMF.Field[] ) => { dmmfSchema?.forEach((dmmfProperty) => { - const dmmfPropertyName = dmmfProperty.name + const dmmfPropertyName = dmmfProperty.name; const dmmfPropertyType = dmmfProperty.type; const dmmfPropertyKind = dmmfProperty.kind; const dmmfPropertyRelationFromFields = dmmfProperty.relationFromFields; @@ -221,7 +228,9 @@ export const findRelationInData = async ( type: "link", value: { label: item[dmmfPropertyName], - url: `${dmmfProperty.type as ModelName}/${item[dmmfPropertyName]["id"]}`, + url: `${dmmfProperty.type as ModelName}/${ + item[dmmfPropertyName]["id"] + }`, }, }; } else { @@ -256,7 +265,6 @@ export const findRelationInData = async ( return data; }; - export const parseFormData = ( formData: FormData, dmmfSchema: Prisma.DMMF.Field[] @@ -269,25 +277,36 @@ export const parseFormData = ( const dmmfPropertyKind = dmmfProperty.kind; if (dmmfPropertyKind === "object") { if (Boolean(formData[dmmfPropertyName])) { - parsedData[dmmfPropertyName] = JSON.parse(formData[dmmfPropertyName] as string) as ModelWithoutRelationships[typeof 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] + ? new Date(formData[dmmfPropertyName]!) + : null + ) 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 @@ -314,7 +333,7 @@ export const formattedFormData = ( const dmmfPropertyTypeTyped = dmmfPropertyType as Prisma.ModelName; const fieldValue = schema.definitions[modelName].properties[ - dmmfPropertyName as Field + dmmfPropertyName as Field ]; const model = models.find((model) => model.name === dmmfPropertyType); const formatId = (value?: string) => @@ -346,7 +365,9 @@ export const formattedFormData = ( } else if (dmmfPropertyType === "Boolean") { formattedData[dmmfPropertyName] = formData[dmmfPropertyName] === "on"; } else if (dmmfPropertyType === "DateTime") { - formattedData[dmmfPropertyName] = formData[dmmfPropertyName] ? new Date(formData[dmmfPropertyName]!) : null; + formattedData[dmmfPropertyName] = formData[dmmfPropertyName] + ? new Date(formData[dmmfPropertyName]!) + : null; } else { formattedData[dmmfPropertyName] = formData[dmmfPropertyName]; } @@ -386,14 +407,21 @@ export const transformSchema = ( return schema; }; -export const changeFormatInSchema = (schema: Schema, resource: M, editOptions: EditOptions) => { +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]; + const fieldValue = + schema.definitions[modelName].properties[ + dmmfPropertyName as Field + ]; if (fieldValue) { fieldValue.format = editOptions?.fields?.[dmmfPropertyName]?.format; } 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.ts b/packages/next-admin/src/utils/validator.ts index 999c4770..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, @@ -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 db2adafb..1dd47845 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7650,11 +7650,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" From 70e51e8d0e6ce45a2db26dce204fd54f0a3f6a21 Mon Sep 17 00:00:00 2001 From: foyarash <11079152+foyarash@users.noreply.github.com> Date: Mon, 20 Nov 2023 16:26:00 +0100 Subject: [PATCH 2/7] Remove Date instanciation --- packages/next-admin/src/utils/server.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/next-admin/src/utils/server.ts b/packages/next-admin/src/utils/server.ts index e83dcaae..9c4bebdb 100644 --- a/packages/next-admin/src/utils/server.ts +++ b/packages/next-admin/src/utils/server.ts @@ -82,8 +82,8 @@ export const fillRelationInSchema = async ( listOptions?.search ?? remoteModel?.fields.map((field) => field.name); const relationProperty: Field = (relationFromFields?.[0] as Field) ?? fieldName; - const fieldsFiltered = remoteModel?.fields.filter( - (field) => (optionsForRelations as string[])?.includes(field.name) + const fieldsFiltered = remoteModel?.fields.filter((field) => + (optionsForRelations as string[])?.includes(field.name) ); const search = requestOptions[`${relationProperty}search`]; const where = createWherePredicate(fieldsFiltered, search); @@ -293,11 +293,14 @@ export const parseFormData = ( 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] + // ? new Date(formData[dmmfPropertyName]!) + // : null + // ) as ModelWithoutRelationships[typeof dmmfPropertyName]; + parsedData[dmmfPropertyName] = formData[ + dmmfPropertyName + ] as unknown as ModelWithoutRelationships[typeof dmmfPropertyName]; } else { parsedData[dmmfPropertyName] = formData[ dmmfPropertyName From fbc4f57b4e779f18944db7d00b64df1639534912 Mon Sep 17 00:00:00 2001 From: foyarash <11079152+foyarash@users.noreply.github.com> Date: Mon, 20 Nov 2023 16:45:38 +0100 Subject: [PATCH 3/7] Add datetime widget, send date as iso string --- packages/next-admin/src/components/Form.tsx | 2 + .../src/components/inputs/DateTimeWidget.tsx | 44 +++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 packages/next-admin/src/components/inputs/DateTimeWidget.tsx diff --git a/packages/next-admin/src/components/Form.tsx b/packages/next-admin/src/components/Form.tsx index fe97afcc..f543300f 100644 --- a/packages/next-admin/src/components/Form.tsx +++ b/packages/next-admin/src/components/Form.tsx @@ -17,6 +17,7 @@ import ArrayField from "./inputs/ArrayField"; import CheckboxWidget from "./inputs/CheckboxWidget"; import SelectWidget from "./inputs/SelectWidget"; import Button from "./radix/Button"; +import DateTimeWidget from "./inputs/DateTimeWidget"; // Override Form functions to not prevent the submit class CustomForm extends RjsfForm { @@ -44,6 +45,7 @@ const fields: CustomForm["props"]["fields"] = { }; const widgets: CustomForm["props"]["widgets"] = { + DateTimeWidget: DateTimeWidget, SelectWidget: SelectWidget, CheckboxWidget: CheckboxWidget, }; 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..be80e9b2 --- /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 new Date(value).toISOString() + }, [value]) + + return ( + <> + + { + onChange(localToUTC(value)) + }} + /> + + ); +} \ No newline at end of file From 97c4471c3c557ef503c7f98cca6880000eca016f Mon Sep 17 00:00:00 2001 From: foyarash <11079152+foyarash@users.noreply.github.com> Date: Mon, 20 Nov 2023 17:15:25 +0100 Subject: [PATCH 4/7] Remove comment, revert example config --- apps/example/pages/admin/[[...nextadmin]].tsx | 2 +- packages/next-admin/src/utils/server.ts | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/example/pages/admin/[[...nextadmin]].tsx b/apps/example/pages/admin/[[...nextadmin]].tsx index 4b649227..f476d235 100644 --- a/apps/example/pages/admin/[[...nextadmin]].tsx +++ b/apps/example/pages/admin/[[...nextadmin]].tsx @@ -39,7 +39,7 @@ const options: NextAdminOptions = { validate: (email) => email.includes("@") || "Invalid email", }, birthDate: { - format: "date-time", + format: "date", }, }, }, diff --git a/packages/next-admin/src/utils/server.ts b/packages/next-admin/src/utils/server.ts index 9c4bebdb..e387eb1c 100644 --- a/packages/next-admin/src/utils/server.ts +++ b/packages/next-admin/src/utils/server.ts @@ -293,11 +293,6 @@ export const parseFormData = ( 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]; From 752ada722f1c2c5984feeb23489a4af14376a897 Mon Sep 17 00:00:00 2001 From: foyarash <11079152+foyarash@users.noreply.github.com> Date: Mon, 20 Nov 2023 17:26:40 +0100 Subject: [PATCH 5/7] Fix tests --- packages/next-admin/src/components/inputs/DateTimeWidget.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next-admin/src/components/inputs/DateTimeWidget.tsx b/packages/next-admin/src/components/inputs/DateTimeWidget.tsx index be80e9b2..2e32f181 100644 --- a/packages/next-admin/src/components/inputs/DateTimeWidget.tsx +++ b/packages/next-admin/src/components/inputs/DateTimeWidget.tsx @@ -23,7 +23,7 @@ export default function DateTimeWidget< const BaseInputTemplate = getTemplate<'BaseInputTemplate', T, S, F>('BaseInputTemplate', registry, options); const hiddenValue = useMemo(() => { - return new Date(value).toISOString() + return value ? new Date(value).toISOString() : new Date().toISOString() }, [value]) return ( From 65a2f9a62343f47208154350dcc2b595b6c1189d Mon Sep 17 00:00:00 2001 From: foyarash <11079152+foyarash@users.noreply.github.com> Date: Mon, 20 Nov 2023 17:27:51 +0100 Subject: [PATCH 6/7] Default date time widget value to empty string --- packages/next-admin/src/components/inputs/DateTimeWidget.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next-admin/src/components/inputs/DateTimeWidget.tsx b/packages/next-admin/src/components/inputs/DateTimeWidget.tsx index 2e32f181..b4ae1ace 100644 --- a/packages/next-admin/src/components/inputs/DateTimeWidget.tsx +++ b/packages/next-admin/src/components/inputs/DateTimeWidget.tsx @@ -23,7 +23,7 @@ export default function DateTimeWidget< const BaseInputTemplate = getTemplate<'BaseInputTemplate', T, S, F>('BaseInputTemplate', registry, options); const hiddenValue = useMemo(() => { - return value ? new Date(value).toISOString() : new Date().toISOString() + return value ? new Date(value).toISOString() : "" }, [value]) return ( From 6b73d906568bec826e1efa649c249bce07bc82cc Mon Sep 17 00:00:00 2001 From: foyarash <11079152+foyarash@users.noreply.github.com> Date: Mon, 20 Nov 2023 17:40:54 +0100 Subject: [PATCH 7/7] Handle date input --- packages/next-admin/src/components/Form.tsx | 2 ++ .../src/components/inputs/DateWidget.tsx | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 packages/next-admin/src/components/inputs/DateWidget.tsx diff --git a/packages/next-admin/src/components/Form.tsx b/packages/next-admin/src/components/Form.tsx index f543300f..ff4dc1f2 100644 --- a/packages/next-admin/src/components/Form.tsx +++ b/packages/next-admin/src/components/Form.tsx @@ -18,6 +18,7 @@ import CheckboxWidget from "./inputs/CheckboxWidget"; import SelectWidget from "./inputs/SelectWidget"; import Button from "./radix/Button"; import DateTimeWidget from "./inputs/DateTimeWidget"; +import DateWidget from "./inputs/DateWidget"; // Override Form functions to not prevent the submit class CustomForm extends RjsfForm { @@ -45,6 +46,7 @@ const fields: CustomForm["props"]["fields"] = { }; const widgets: CustomForm["props"]["widgets"] = { + DateWidget: DateWidget, DateTimeWidget: DateTimeWidget, SelectWidget: SelectWidget, CheckboxWidget: CheckboxWidget, 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