From 0c8093310653c7a8035a3cfea7dfe2f0c85a34b5 Mon Sep 17 00:00:00 2001 From: Facundo Spira Date: Mon, 9 Sep 2024 15:16:19 +0200 Subject: [PATCH] feat: add ability to define custom fields (#406) * feat: allow adding custom fields * feat: add example on how to use * chore: allow input to be optional and add format * chore: fix issue with password field * chore: add changeset * fix: fix test cases * fix: e2e tests * fix: missing check to e2e * chore: change example for page router --- .changeset/light-ladybugs-camp.md | 5 +++ apps/example/components/PasswordInput.tsx | 42 +++++++++++++++++++ apps/example/e2e/001-crud.spec.ts | 3 ++ apps/example/e2e/utils.ts | 4 ++ apps/example/options.tsx | 26 ++++++++++-- apps/example/pageRouterOptions.tsx | 41 ++++++++++++++---- .../prisma/json-schema/json-schema.json | 6 ++- .../migration.sql | 8 ++++ apps/example/prisma/schema.prisma | 23 +++++----- apps/example/prisma/seed.ts | 1 + packages/next-admin/src/components/Form.tsx | 9 ++-- .../next-admin/src/components/NextAdmin.tsx | 4 +- packages/next-admin/src/tests/options.test.ts | 1 - .../next-admin/src/tests/serverUtils.test.ts | 2 + packages/next-admin/src/types.ts | 16 ++++++- packages/next-admin/src/utils/options.ts | 25 ++++++----- packages/next-admin/src/utils/server.ts | 30 ++++++++++++- 17 files changed, 202 insertions(+), 44 deletions(-) create mode 100644 .changeset/light-ladybugs-camp.md create mode 100644 apps/example/components/PasswordInput.tsx create mode 100644 apps/example/prisma/migrations/20240907035434_add_hashed_password_field/migration.sql diff --git a/.changeset/light-ladybugs-camp.md b/.changeset/light-ladybugs-camp.md new file mode 100644 index 00000000..5dcb4d1b --- /dev/null +++ b/.changeset/light-ladybugs-camp.md @@ -0,0 +1,5 @@ +--- +"@premieroctet/next-admin": minor +--- + +add ability to define custom fields in edit diff --git a/apps/example/components/PasswordInput.tsx b/apps/example/components/PasswordInput.tsx new file mode 100644 index 00000000..84def783 --- /dev/null +++ b/apps/example/components/PasswordInput.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useState } from "react"; +import type { CustomInputProps } from "@premieroctet/next-admin"; + +const PasswordInput = (props: CustomInputProps) => { + const [changePassword, setChangePassword] = useState(false); + + if (props.mode === "create") { + return ; + } + + return ( +
+ {changePassword && } + +
+ ); +}; + +const PasswordBaseInput = (props: CustomInputProps & {}) => ( + +); + +export default PasswordInput; diff --git a/apps/example/e2e/001-crud.spec.ts b/apps/example/e2e/001-crud.spec.ts index 3efb000a..36208312 100644 --- a/apps/example/e2e/001-crud.spec.ts +++ b/apps/example/e2e/001-crud.spec.ts @@ -36,6 +36,9 @@ test.describe("user validation", () => { await page.goto(`${process.env.BASE_URL}/User/new`); await page.fill('input[id="name"]', dataTest.User.name); await page.fill('input[id="email"]', "invalidemail"); + if (await page.isVisible('input[name="newPassword"]')) { + await page.fill('input[name="newPassword"]', dataTest.User.newPassword); + } await page.click('button:has-text("Save and continue editing")'); await page.waitForURL(`${process.env.BASE_URL}/User/new`); await test.expect(page.getByText("Invalid email")).toBeVisible(); diff --git a/apps/example/e2e/utils.ts b/apps/example/e2e/utils.ts index 409c8e68..f9b708f5 100644 --- a/apps/example/e2e/utils.ts +++ b/apps/example/e2e/utils.ts @@ -15,6 +15,7 @@ export const dataTest: DataTest = { User: { email: "my-user+e2e@premieroctet.com", name: "MY_USER", + newPassword: "newPassword", }, Post: { title: "MY_POST", @@ -97,6 +98,9 @@ export const fillForm = async ( case "User": await page.fill('input[id="email"]', dataTest.User.email); await page.fill('input[id="name"]', dataTest.User.name); + if (await page.isVisible('input[name="newPassword"]')) { + await page.fill('input[name="newPassword"]', dataTest.User.newPassword); + } await page.setInputFiles('input[type="file"]', { name: "test.txt", mimeType: "text/plain", diff --git a/apps/example/options.tsx b/apps/example/options.tsx index 9bcc338c..1c1080a1 100644 --- a/apps/example/options.tsx +++ b/apps/example/options.tsx @@ -1,5 +1,6 @@ import { NextAdminOptions } from "@premieroctet/next-admin"; import DatePicker from "./components/DatePicker"; +import PasswordInput from "./components/PasswordInput"; export const options: NextAdminOptions = { title: "⚡️ My Admin", @@ -14,6 +15,7 @@ export const options: NextAdminOptions = { id: "ID", name: "Full name", birthDate: "Date of birth", + newPassword: "Password", }, list: { exports: { @@ -74,13 +76,15 @@ export const options: NextAdminOptions = { "birthDate", "avatar", "metadata", + "newPassword", ], styles: { _form: "grid-cols-3 gap-4 md:grid-cols-4", id: "col-span-2 row-start-1", name: "col-span-2 row-start-1", - "email-notice": "col-span-4 row-start-3", - email: "col-span-4 md:col-span-2 row-start-4", + "email-notice": "col-span-4 row-start-2", + email: "col-span-4 md:col-span-2 row-start-3", + newPassword: "col-span-3 row-start-4", posts: "col-span-4 md:col-span-2 row-start-5", role: "col-span-4 md:col-span-3 row-start-6", birthDate: "col-span-3 row-start-7", @@ -131,6 +135,22 @@ export const options: NextAdminOptions = { }, }, }, + customFields: { + newPassword: { + input: , + required: true, + }, + }, + hooks: { + beforeDb: async (data, mode, request) => { + const newPassword = data.newPassword; + if (newPassword) { + data.hashedPassword = `hashed-${newPassword}`; + } + + return data; + }, + }, }, actions: [ { @@ -210,7 +230,7 @@ export const options: NextAdminOptions = { async afterDb(response, mode, request) { console.log("intercept afterdb", response, mode, request); - return response + return response; }, }, }, diff --git a/apps/example/pageRouterOptions.tsx b/apps/example/pageRouterOptions.tsx index 6ee400e0..4532b325 100644 --- a/apps/example/pageRouterOptions.tsx +++ b/apps/example/pageRouterOptions.tsx @@ -1,5 +1,6 @@ import { NextAdminOptions } from "@premieroctet/next-admin"; import DatePicker from "./components/DatePicker"; +import PasswordInput from "./components/PasswordInput"; export const options: NextAdminOptions = { title: "⚡️ My Admin Page Router", @@ -8,6 +9,9 @@ export const options: NextAdminOptions = { toString: (user) => `${user.name} (${user.email})`, title: "Users", icon: "UsersIcon", + aliases: { + newPassword: "Password", + }, list: { display: ["id", "name", "email", "posts", "role", "birthDate"], search: ["name", "email"], @@ -41,6 +45,7 @@ export const options: NextAdminOptions = { display: [ "id", "name", + "newPassword", "email", "posts", "role", @@ -48,15 +53,17 @@ export const options: NextAdminOptions = { "avatar", ], styles: { - _form: "grid-cols-3 gap-2 md:grid-cols-4", - id: "col-span-2", - name: "col-span-2 row-start-2", - email: "col-span-2 row-start-3", - posts: "col-span-2 row-start-4", - role: "col-span-2 row-start-4", - birthDate: "col-span-3 row-start-5", - avatar: "col-span-1 row-start-5", - metadata: "col-span-4 row-start-6", + _form: "grid-cols-3 gap-4 md:grid-cols-4", + id: "col-span-2 row-start-1", + name: "col-span-2 row-start-1", + "email-notice": "col-span-4 row-start-2", + email: "col-span-4 md:col-span-2 row-start-3", + newPassword: "col-span-3 row-start-4", + posts: "col-span-4 md:col-span-2 row-start-5", + role: "col-span-4 md:col-span-3 row-start-6", + birthDate: "col-span-3 row-start-7", + avatar: "col-span-4 row-start-8", + metadata: "col-span-4 row-start-9", }, fields: { name: { @@ -82,6 +89,22 @@ export const options: NextAdminOptions = { }, }, }, + customFields: { + newPassword: { + input: , + required: true, + }, + }, + hooks: { + beforeDb: async (data) => { + const newPassword = data.newPassword; + if (newPassword) { + data.hashedPassword = `hashed-${newPassword}`; + } + + return data; + }, + }, }, actions: [ { diff --git a/apps/example/prisma/json-schema/json-schema.json b/apps/example/prisma/json-schema/json-schema.json index 911df355..c3728f3b 100644 --- a/apps/example/prisma/json-schema/json-schema.json +++ b/apps/example/prisma/json-schema/json-schema.json @@ -10,6 +10,9 @@ "email": { "type": "string" }, + "hashedPassword": { + "type": "string" + }, "name": { "type": [ "string", @@ -73,7 +76,8 @@ } }, "required": [ - "email" + "email", + "hashedPassword" ] }, "Post": { diff --git a/apps/example/prisma/migrations/20240907035434_add_hashed_password_field/migration.sql b/apps/example/prisma/migrations/20240907035434_add_hashed_password_field/migration.sql new file mode 100644 index 00000000..d0bf0324 --- /dev/null +++ b/apps/example/prisma/migrations/20240907035434_add_hashed_password_field/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `hashedPassword` to the `User` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "hashedPassword" TEXT NOT NULL; diff --git a/apps/example/prisma/schema.prisma b/apps/example/prisma/schema.prisma index 405bfd83..eafb0b8a 100644 --- a/apps/example/prisma/schema.prisma +++ b/apps/example/prisma/schema.prisma @@ -23,17 +23,18 @@ enum Role { } model User { - 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 - birthDate DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt - role Role @default(USER) - avatar String? - metadata Json? + id Int @id @default(autoincrement()) + email String @unique + hashedPassword String + name String? + 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) + avatar String? + metadata Json? } model Post { diff --git a/apps/example/prisma/seed.ts b/apps/example/prisma/seed.ts index cc634ef2..1deb0306 100644 --- a/apps/example/prisma/seed.ts +++ b/apps/example/prisma/seed.ts @@ -19,6 +19,7 @@ async function main() { create: { email: `user${i}@nextadmin.io`, name: `User ${i}`, + hashedPassword: "password", ...(i === 0 ? { role: "ADMIN" } : {}), }, }); diff --git a/packages/next-admin/src/components/Form.tsx b/packages/next-admin/src/components/Form.tsx index 13c95959..00c310e8 100644 --- a/packages/next-admin/src/components/Form.tsx +++ b/packages/next-admin/src/components/Form.tsx @@ -334,7 +334,8 @@ const Form = ({ modelOptions?.edit?.styles?.[id as Field]; const tooltip = - modelOptions?.edit?.fields?.[id as Field]?.tooltip; + modelOptions?.edit?.fields?.[id as Field]?.tooltip || + modelOptions?.edit?.customFields?.[id]?.tooltip; const sanitizedClassNames = classNames ?.split(",") @@ -411,8 +412,9 @@ const Form = ({ onChange(val === "" ? options.emptyValue || "" : val); }; - if (customInputs?.[props.name as Field]) { - return cloneElement(customInputs[props.name as Field]!, { + const customInput = customInputs?.[props.name as Field]; + if (customInput) { + return cloneElement(customInput, { value: props.value, onChange: onChangeOverride || onTextChange, readonly, @@ -420,6 +422,7 @@ const Form = ({ name: props.name, required: props.required, disabled: props.disabled, + mode: edit ? "edit" : "create", }); } diff --git a/packages/next-admin/src/components/NextAdmin.tsx b/packages/next-admin/src/components/NextAdmin.tsx index 825df457..d88dacb8 100644 --- a/packages/next-admin/src/components/NextAdmin.tsx +++ b/packages/next-admin/src/components/NextAdmin.tsx @@ -1,4 +1,4 @@ -import dynamic from 'next/dynamic' +import dynamic from "next/dynamic"; import { AdminComponentProps, CustomUIProps } from "../types"; import { getSchemaForResource } from "../utils/jsonSchema"; import { getCustomInputs } from "../utils/options"; @@ -8,7 +8,7 @@ import List from "./List"; import { MainLayout } from "./MainLayout"; import PageLoader from "./PageLoader"; -const Head = dynamic(() => import('next/head')); +const Head = dynamic(() => import("next/head")); // Components export function NextAdmin({ diff --git a/packages/next-admin/src/tests/options.test.ts b/packages/next-admin/src/tests/options.test.ts index db3caa85..dd4e1ab1 100644 --- a/packages/next-admin/src/tests/options.test.ts +++ b/packages/next-admin/src/tests/options.test.ts @@ -6,7 +6,6 @@ describe("Options", () => { const customInputs = getCustomInputs("User", options); expect(Object.keys(customInputs).length).toBe(1); - // @ts-expect-error expect(customInputs?.email).toBeDefined(); }); }); diff --git a/packages/next-admin/src/tests/serverUtils.test.ts b/packages/next-admin/src/tests/serverUtils.test.ts index c87b9029..0287c09a 100644 --- a/packages/next-admin/src/tests/serverUtils.test.ts +++ b/packages/next-admin/src/tests/serverUtils.test.ts @@ -18,6 +18,7 @@ describe("fillRelationInSchema", () => { role: "ADMIN", avatar: null, metadata: null, + hashedPassword: "", }, { id: 2, @@ -29,6 +30,7 @@ describe("fillRelationInSchema", () => { role: "ADMIN", avatar: null, metadata: null, + hashedPassword: "", }, ]); const result = await fillRelationInSchema("Post")(schema); diff --git a/packages/next-admin/src/types.ts b/packages/next-admin/src/types.ts index 134bf668..7c216466 100644 --- a/packages/next-admin/src/types.ts +++ b/packages/next-admin/src/types.ts @@ -488,7 +488,7 @@ export type EditOptions = { * an array of fields that are displayed in the form. It can also be an object that will be displayed in the form of a notice. * @default all scalar */ - display?: Array | NoticeField>; + display?: Array | NoticeField | (string & {})>; /** * an object containing the styles of the form. */ @@ -514,6 +514,17 @@ export type EditOptions = { * a set of hooks to call before and after the form data insertion into the database. */ hooks?: EditModelHooks; + customFields?: CustomFieldsType; +}; + +type CustomFieldsType = { + [key: string]: { + input?: React.ReactElement; + tooltip?: string; + format?: FormatOptions; + helperText?: string; + required?: boolean; + }; }; export type ActionStyle = "default" | "destructive"; @@ -559,7 +570,7 @@ export type ModelOptions = { /** * an object containing the aliases of the model fields as keys, and the field name. */ - aliases?: Partial, string>>; + aliases?: Partial, string>> & { [key: string]: string }; actions?: ModelAction[]; /** * the outline HeroIcon name displayed in the sidebar and pages title @@ -858,6 +869,7 @@ export type CustomInputProps = Partial<{ rawErrors: string[]; disabled: boolean; required?: boolean; + mode: "create" | "edit"; }>; export type TranslationKeys = diff --git a/packages/next-admin/src/utils/options.ts b/packages/next-admin/src/utils/options.ts index 99518786..58a7b8e3 100644 --- a/packages/next-admin/src/utils/options.ts +++ b/packages/next-admin/src/utils/options.ts @@ -10,15 +10,20 @@ export const getCustomInputs = ( options?: NextAdminOptions ) => { const editFields = options?.model?.[model]?.edit?.fields; + const customFields = options?.model?.[model]?.edit?.customFields; - return Object.keys(editFields ?? {}).reduce( - (acc, field) => { - const input = editFields?.[field as keyof typeof editFields]?.input; - if (input) { - acc[field as Field] = input; - } - return acc; - }, - {} as Record, React.ReactElement | undefined> - ); + const inputs: Record = { + ...editFields, + ...customFields, + }; + + return Object.keys(inputs ?? {}).reduce< + Record + >((acc, field) => { + const input = inputs?.[field]?.input; + if (input) { + acc[field as Field] = input; + } + return acc; + }, {}); }; diff --git a/packages/next-admin/src/utils/server.ts b/packages/next-admin/src/utils/server.ts index 1f111ba5..9da6e8c2 100644 --- a/packages/next-admin/src/utils/server.ts +++ b/packages/next-admin/src/utils/server.ts @@ -136,7 +136,8 @@ const orderSchema = const propertiesOrdered = {} as Record; display.forEach((property) => { if (typeof property === "string") { - propertiesOrdered[property] = properties[property]; + propertiesOrdered[property] = + properties[property as Field]; } else { propertiesOrdered[property.id] = { type: "null", @@ -168,7 +169,6 @@ export const fillRelationInSchema = const display = options?.model?.[modelName]?.edit?.display; let fields; if (model?.fields && display) { - // @ts-expect-error fields = model.fields?.filter((field) => display.includes(field.name)); } else { fields = model?.fields; @@ -914,6 +914,7 @@ export const transformSchema = ( changeFormatInSchema(resource, edit), fillRelationInSchema(resource, options), fillDescriptionInSchema(resource, edit), + addCustomProperties(resource, edit), orderSchema(resource, options) ); @@ -1010,6 +1011,31 @@ export const removeHiddenProperties = return schema; }; +export const addCustomProperties = + (resource: M, editOptions: EditOptions) => + (schema: Schema) => { + const customFieldKeys = Object.keys(editOptions.customFields ?? {}); + + customFieldKeys.forEach((property) => { + const fieldOptions = editOptions?.customFields?.[property]; + if (fieldOptions) { + schema.definitions[resource].properties[ + property as Field + ] = { + type: "string", + description: fieldOptions?.helperText ?? "", + format: fieldOptions?.format, + }; + + if (fieldOptions.required) { + schema.definitions[resource].required?.push(property); + } + } + }); + + return schema; + }; + export const getResourceFromParams = ( params: string[], resources: Prisma.ModelName[]