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[]