Skip to content

Commit

Permalink
feat: add ability to define custom fields (#406)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
FacundoSpira authored Sep 9, 2024
1 parent 4e25899 commit 0c80933
Show file tree
Hide file tree
Showing 17 changed files with 202 additions and 44 deletions.
5 changes: 5 additions & 0 deletions .changeset/light-ladybugs-camp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@premieroctet/next-admin": minor
---

add ability to define custom fields in edit
42 changes: 42 additions & 0 deletions apps/example/components/PasswordInput.tsx
Original file line number Diff line number Diff line change
@@ -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 <PasswordBaseInput {...props} />;
}

return (
<div className="flex flex-col items-start gap-4">
{changePassword && <PasswordBaseInput {...props} />}
<button
onClick={() => {
setChangePassword((value) => !value);
// @ts-expect-error
props?.onChange({ target: { value: undefined } });
}}
type="button"
className="rounded-md bg-indigo-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
{changePassword ? "Close" : "Change Password"}
</button>
</div>
);
};

const PasswordBaseInput = (props: CustomInputProps & {}) => (
<input
type="password"
onChange={props.onChange}
name={props.name}
required={props.required}
value={props.value ?? ""}
className="dark:bg-dark-nextadmin-background-subtle text-nextadmin-content-inverted dark:text-dark-nextadmin-content-inverted ring-nextadmin-border-default focus:ring-nextadmin-brand-default dark:focus:ring-dark-nextadmin-brand-default dark:ring-dark-nextadmin-border-strong block w-full rounded-md border-0 px-2 py-1.5 text-sm shadow-sm ring-1 ring-inset transition-colors duration-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 sm:leading-6"
/>
);

export default PasswordInput;
3 changes: 3 additions & 0 deletions apps/example/e2e/001-crud.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
4 changes: 4 additions & 0 deletions apps/example/e2e/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const dataTest: DataTest = {
User: {
email: "[email protected]",
name: "MY_USER",
newPassword: "newPassword",
},
Post: {
title: "MY_POST",
Expand Down Expand Up @@ -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",
Expand Down
26 changes: 23 additions & 3 deletions apps/example/options.tsx
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -14,6 +15,7 @@ export const options: NextAdminOptions = {
id: "ID",
name: "Full name",
birthDate: "Date of birth",
newPassword: "Password",
},
list: {
exports: {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -131,6 +135,22 @@ export const options: NextAdminOptions = {
},
},
},
customFields: {
newPassword: {
input: <PasswordInput />,
required: true,
},
},
hooks: {
beforeDb: async (data, mode, request) => {
const newPassword = data.newPassword;
if (newPassword) {
data.hashedPassword = `hashed-${newPassword}`;
}

return data;
},
},
},
actions: [
{
Expand Down Expand Up @@ -210,7 +230,7 @@ export const options: NextAdminOptions = {
async afterDb(response, mode, request) {
console.log("intercept afterdb", response, mode, request);

return response
return response;
},
},
},
Expand Down
41 changes: 32 additions & 9 deletions apps/example/pageRouterOptions.tsx
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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"],
Expand Down Expand Up @@ -41,22 +45,25 @@ export const options: NextAdminOptions = {
display: [
"id",
"name",
"newPassword",
"email",
"posts",
"role",
"birthDate",
"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: {
Expand All @@ -82,6 +89,22 @@ export const options: NextAdminOptions = {
},
},
},
customFields: {
newPassword: {
input: <PasswordInput />,
required: true,
},
},
hooks: {
beforeDb: async (data) => {
const newPassword = data.newPassword;
if (newPassword) {
data.hashedPassword = `hashed-${newPassword}`;
}

return data;
},
},
},
actions: [
{
Expand Down
6 changes: 5 additions & 1 deletion apps/example/prisma/json-schema/json-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
"email": {
"type": "string"
},
"hashedPassword": {
"type": "string"
},
"name": {
"type": [
"string",
Expand Down Expand Up @@ -73,7 +76,8 @@
}
},
"required": [
"email"
"email",
"hashedPassword"
]
},
"Post": {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
23 changes: 12 additions & 11 deletions apps/example/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions apps/example/prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ async function main() {
create: {
email: `user${i}@nextadmin.io`,
name: `User ${i}`,
hashedPassword: "password",
...(i === 0 ? { role: "ADMIN" } : {}),
},
});
Expand Down
9 changes: 6 additions & 3 deletions packages/next-admin/src/components/Form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,8 @@ const Form = ({
modelOptions?.edit?.styles?.[id as Field<typeof resource>];

const tooltip =
modelOptions?.edit?.fields?.[id as Field<typeof resource>]?.tooltip;
modelOptions?.edit?.fields?.[id as Field<typeof resource>]?.tooltip ||
modelOptions?.edit?.customFields?.[id]?.tooltip;

const sanitizedClassNames = classNames
?.split(",")
Expand Down Expand Up @@ -411,15 +412,17 @@ const Form = ({
onChange(val === "" ? options.emptyValue || "" : val);
};

if (customInputs?.[props.name as Field<ModelName>]) {
return cloneElement(customInputs[props.name as Field<ModelName>]!, {
const customInput = customInputs?.[props.name as Field<ModelName>];
if (customInput) {
return cloneElement(customInput, {
value: props.value,
onChange: onChangeOverride || onTextChange,
readonly,
rawErrors,
name: props.name,
required: props.required,
disabled: props.disabled,
mode: edit ? "edit" : "create",
});
}

Expand Down
4 changes: 2 additions & 2 deletions packages/next-admin/src/components/NextAdmin.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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({
Expand Down
1 change: 0 additions & 1 deletion packages/next-admin/src/tests/options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
2 changes: 2 additions & 0 deletions packages/next-admin/src/tests/serverUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ describe("fillRelationInSchema", () => {
role: "ADMIN",
avatar: null,
metadata: null,
hashedPassword: "",
},
{
id: 2,
Expand All @@ -29,6 +30,7 @@ describe("fillRelationInSchema", () => {
role: "ADMIN",
avatar: null,
metadata: null,
hashedPassword: "",
},
]);
const result = await fillRelationInSchema("Post")(schema);
Expand Down
16 changes: 14 additions & 2 deletions packages/next-admin/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,7 @@ export type EditOptions<T extends ModelName> = {
* 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<Field<T> | NoticeField>;
display?: Array<Field<T> | NoticeField | (string & {})>;
/**
* an object containing the styles of the form.
*/
Expand All @@ -514,6 +514,17 @@ export type EditOptions<T extends ModelName> = {
* 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<any>;
helperText?: string;
required?: boolean;
};
};

export type ActionStyle = "default" | "destructive";
Expand Down Expand Up @@ -559,7 +570,7 @@ export type ModelOptions<T extends ModelName> = {
/**
* an object containing the aliases of the model fields as keys, and the field name.
*/
aliases?: Partial<Record<Field<P>, string>>;
aliases?: Partial<Record<Field<P>, string>> & { [key: string]: string };
actions?: ModelAction[];
/**
* the outline HeroIcon name displayed in the sidebar and pages title
Expand Down Expand Up @@ -858,6 +869,7 @@ export type CustomInputProps = Partial<{
rawErrors: string[];
disabled: boolean;
required?: boolean;
mode: "create" | "edit";
}>;

export type TranslationKeys =
Expand Down
Loading

0 comments on commit 0c80933

Please sign in to comment.