Skip to content

Commit

Permalink
feat: add custom pages configuration
Browse files Browse the repository at this point in the history
  • Loading branch information
foyarash committed Dec 4, 2023
1 parent 216ba52 commit 2298002
Show file tree
Hide file tree
Showing 10 changed files with 171 additions and 50 deletions.
18 changes: 18 additions & 0 deletions .changeset/calm-ears-provide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@premieroctet/next-admin": minor
---

📄 add option for custom pages

In the `options`, add

```tsx
pages: {
"/custom": {
title: "Custom page",
component: CustomPage,
},
},
```

In the above example, navigating to `<basePath>/custom` will render the `CustomPage` component, in addition with the persistent Next Admin components (header, sidebar, message).
9 changes: 9 additions & 0 deletions apps/docs/pages/docs/api-docs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,15 @@ For the `edit` property, it can take the following:
| handler.get | a function that takes the field value as a parameter and returns a transformed value displayed in the form |
| handler.upload | an async function that is used only for formats `file` and `data-url`. It takes a buffer as parameter and must return a string. Useful to upload a file to a remote provider |

### `pages`

`pages` is an object that maps a path to a component. It is an object where the key is the path (without the base path) and the value is an object with the following properties:

| Name | Description |
| ----------- | ------------------------------------------------- |
| `title` | the title of the page displayed on the sidebar |
| `component` | the component to render when the path is matched. |

Here is an example of using `NextAdminOptions` for the following schema :

```prisma
Expand Down
9 changes: 9 additions & 0 deletions apps/example/components/pages/CustomPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const CustomPage = () => {
return (
<div>
<p className="text-xl font-medium">This is a custom page</p>
</div>
);
};

export default CustomPage;
11 changes: 11 additions & 0 deletions apps/example/e2e/custom_pages.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { test, expect } from "@playwright/test";

test.describe("Custom pages", () => {
test("Custom page should be visible and clickable", async ({ page }) => {
await page.goto(process.env.BASE_URL!);

await page.click(`a[href$="/custom"]`);

await expect(page.getByText("This is a custom page")).toBeVisible();
});
});
7 changes: 7 additions & 0 deletions apps/example/options.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { NextAdminOptions } from "@premieroctet/next-admin";
import CustomPage from "./components/pages/CustomPage";

export const options: NextAdminOptions = {
basePath: "/admin",
Expand Down Expand Up @@ -98,4 +99,10 @@ export const options: NextAdminOptions = {
},
},
},
pages: {
"/custom": {
title: "Custom page",
component: CustomPage,
},
},
};
102 changes: 54 additions & 48 deletions packages/next-admin/src/components/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,24 @@ import { clsx } from "clsx";
import Link from "next/link";
import { Fragment, useState } from "react";

import { ModelName } from "../types";
import { AdminComponentProps, ModelName } from "../types";
import { useConfig } from "../context/ConfigContext";
import { useRouterInternal } from "../hooks/useRouterInternal";

export type MenuProps = {
resource: ModelName;
resource?: ModelName;
resources?: ModelName[];
customPages?: AdminComponentProps["customPages"];
};

export default function Menu({
resources,
resource: currentResource,
customPages,
}: MenuProps) {
const [sidebarOpen, setSidebarOpen] = useState(false);
const { basePath } = useConfig();
const { pathname } = useRouterInternal();
const navigation: Array<{
name: string;
href: string;
Expand All @@ -31,6 +35,44 @@ export default function Menu({
current: resource === currentResource,
})) || [];

const customPagesNavigation = customPages?.map((page) => ({
name: page.title,
href: `${basePath}${page.path}`,
current: pathname === `${basePath}${page.path}`,
}));

const renderNavigationItem = (item: {
name: string;
href: string;
current: boolean;
icon?: React.ElementType;
}) => {
return (
<Link
href={item.href}
className={clsx(
item.current
? "bg-gray-50 text-indigo-600"
: "text-gray-700 hover:text-indigo-600 hover:bg-gray-50",
"group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold"
)}
>
{item.icon && (
<item.icon
className={clsx(
item.current
? "text-indigo-600"
: "text-gray-400 group-hover:text-indigo-600",
"h-6 w-6 shrink-0"
)}
aria-hidden="true"
/>
)}
{item.name}
</Link>
);
};

return (
<>
<Transition.Root show={sidebarOpen} as={Fragment}>
Expand Down Expand Up @@ -98,28 +140,12 @@ export default function Menu({
<ul role="list" className="-mx-2 space-y-1">
{navigation.map((item) => (
<li key={item.name}>
<Link
href={item.href}
className={clsx(
item.current
? "bg-gray-50 text-indigo-600"
: "text-gray-700 hover:text-indigo-600 hover:bg-gray-50",
"group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold"
)}
>
{item.icon && (
<item.icon
className={clsx(
item.current
? "text-indigo-600"
: "text-gray-400 group-hover:text-indigo-600",
"h-6 w-6 shrink-0"
)}
aria-hidden="true"
/>
)}
{item.name}
</Link>
{renderNavigationItem(item)}
</li>
))}
{customPagesNavigation?.map((item) => (
<li key={item.name}>
{renderNavigationItem(item)}
</li>
))}
</ul>
Expand Down Expand Up @@ -147,30 +173,10 @@ export default function Menu({
<li>
<ul role="list" className="-mx-2 space-y-1">
{navigation.map((item) => (
<li key={item.name}>
<Link
href={item.href}
className={clsx(
item.current
? "bg-gray-50 text-indigo-600"
: "text-gray-700 hover:text-indigo-600 hover:bg-gray-50",
"group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold"
)}
>
{item.icon && (
<item.icon
className={clsx(
item.current
? "text-indigo-600"
: "text-gray-400 group-hover:text-indigo-600",
"h-6 w-6 shrink-0"
)}
aria-hidden="true"
/>
)}
{item.name}
</Link>
</li>
<li key={item.name}>{renderNavigationItem(item)}</li>
))}
{customPagesNavigation?.map((item) => (
<li key={item.name}>{renderNavigationItem(item)}</li>
))}
</ul>
</li>
Expand Down
18 changes: 17 additions & 1 deletion packages/next-admin/src/components/NextAdmin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ export function NextAdmin({
isAppDir,
action,
options,
pageComponent: PageComponent,
customPages,
currentPath,
}: AdminComponentProps & CustomUIProps) {
if (!isAppDir && !options) {
throw new Error(
Expand All @@ -37,6 +40,15 @@ export function NextAdmin({
resource && schema ? getSchemaForResource(schema, resource) : undefined;

const renderMainComponent = () => {
if (!isAppDir && options?.pages?.[currentPath]) {
const Page = options.pages[currentPath].component;
return <Page />;
}

if (PageComponent) {
return <PageComponent />;
}

if (Array.isArray(data) && resource && typeof total != "undefined") {
return (
<List
Expand Down Expand Up @@ -77,7 +89,11 @@ export function NextAdmin({
<link rel="icon" href="/favicon.ico" />
</Head>
<div className="w-full">
<Menu resources={resources} resource={resource!} />
<Menu
resources={resources}
resource={resource}
customPages={customPages}
/>

<main className="py-10 lg:pl-72">
<div className="px-4 sm:px-12 lg:px-20 space-y-4">
Expand Down
9 changes: 8 additions & 1 deletion packages/next-admin/src/hooks/useRouterInternal.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { NextRouter, useRouter as usePageRouter } from "next/router";
import { useRouter as useAppRouter, useSearchParams } from "next/navigation";
import {
useRouter as useAppRouter,
usePathname,
useSearchParams,
} from "next/navigation";
import qs from "querystring";
import { useConfig } from "../context/ConfigContext";

Expand Down Expand Up @@ -50,5 +54,8 @@ export const useRouterInternal = () => {
return {
router: { push, replace, refresh },
query: Object.fromEntries(query),
pathname: isAppDir
? usePathname()
: (router as NextRouter).asPath.split("?")[0],
};
};
7 changes: 7 additions & 0 deletions packages/next-admin/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export type ModelOptions<T extends ModelName> = {
export type NextAdminOptions = {
basePath: string;
model?: ModelOptions<ModelName>;
pages?: Record<string, { title: string; component: React.ComponentType }>;
};

/** Type for Schema */
Expand Down Expand Up @@ -212,6 +213,12 @@ export type AdminComponentProps = {
* Mandatory for page router
*/
options?: NextAdminOptions;
/**
* App router only
*/
pageComponent?: React.ComponentType;
customPages?: Array<{ title: string; path: string }>;
currentPath: string;
};

export type CustomUIProps = {
Expand Down
31 changes: 31 additions & 0 deletions packages/next-admin/src/utils/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,18 @@ export async function getPropsFromParams({
}: GetPropsFromParamsParams): Promise<
| AdminComponentProps
| Omit<AdminComponentProps, "dmmfSchema" | "schema" | "resource" | "action">
| Pick<
AdminComponentProps,
| "pageComponent"
| "basePath"
| "isAppDir"
| "message"
| "resources"
| "error"
>
> {
const pathFromParams = "/" + params?.join("/");

const resources = getResources(options);

let message = undefined;
Expand All @@ -58,6 +69,24 @@ export async function getPropsFromParams({
: null;
} catch {}

const customPages = Object.keys(options.pages ?? {}).map((path) => ({
title: options.pages![path as keyof typeof options.pages].title,
path: path,
}));

if (isAppDir && options.pages?.[pathFromParams]) {
return {
pageComponent: options.pages[pathFromParams].component,
basePath: options.basePath,
isAppDir,
resources,
message,
error: searchParams?.error as string,
customPages,
currentPath: pathFromParams,
};
}

if (isAppDir && !action) {
throw new Error("action is required when using App router");
}
Expand All @@ -71,6 +100,8 @@ export async function getPropsFromParams({
: undefined,
message,
error: searchParams?.error as string,
customPages,
currentPath: pathFromParams,
};

if (!params) return defaultProps;
Expand Down

0 comments on commit 2298002

Please sign in to comment.