diff --git a/docs/code/admin-next.md b/docs/code/admin-next.md index f60a90a..ffde318 100644 --- a/docs/code/admin-next.md +++ b/docs/code/admin-next.md @@ -91,3 +91,41 @@ defaulting to `view`. For generic table resources, rows with a `dynamicPath` navigate to `{basePath}/{resourceId}/{dynamicPath}` and render a read-only detail view from the resource's table schema plus the detail response. + +## Customizing the header + +`AdminApp` renders a full-width app header above the sidebar and content. Two +optional props customize it so the host does not need to render its own header +(which would produce a duplicated bar): + +- `title`: brand shown at the top-left. Accepts a string or any React node + (e.g. a logo). Defaults to `"Admin"`. +- `headerActions`: content rendered at the top-right — typically the signed-in + user and a sign-out control. Because `AdminApp` is a Server Component, you can + pass a server-action `
` directly. + +```tsx +import { AdminApp } from "@rxtech-lab/admin-generator-next/server"; + +export default function AdminPage(props) { + return ( + { + "use server"; + await signOut({ redirectTo: "/login" }); + }} + > + + + } + /> + ); +} +``` diff --git a/packages/admin-next/src/components/admin-shell.tsx b/packages/admin-next/src/components/admin-shell.tsx index 02558a5..ea36ff5 100644 --- a/packages/admin-next/src/components/admin-shell.tsx +++ b/packages/admin-next/src/components/admin-shell.tsx @@ -28,6 +28,10 @@ export interface AdminShellProps { }; actions: AdminActions; error?: string; + /** Brand shown at the top-left of the app header. Defaults to "Admin". */ + title?: React.ReactNode; + /** Custom content rendered at the top-right of the app header. */ + headerActions?: React.ReactNode; } /** Look up a lucide icon by its kebab/pascal name, with a sane fallback. */ @@ -50,54 +54,62 @@ export function AdminShell({ initialView, actions, error, + title, + headerActions, }: AdminShellProps): React.JSX.Element { return ( -
- +
+
+
{title ?? "Admin"}
+ {headerActions ? ( +
{headerActions}
+ ) : null} +
+ +
+ -
- {error ? ( -
- {error} -
- ) : initialView ? ( - r.id === initialView.resourceId)} - resourceId={initialView.resourceId} - action={initialView.action} - dynamicPath={initialView.dynamicPath} - schema={initialView.schema} - initialData={initialView.initialData} - initialDetail={initialView.initialDetail} - actions={actions} - /> - ) : ( - - )} -
+
+ {error ? ( +
+ {error} +
+ ) : initialView ? ( + r.id === initialView.resourceId)} + resourceId={initialView.resourceId} + action={initialView.action} + dynamicPath={initialView.dynamicPath} + schema={initialView.schema} + initialData={initialView.initialData} + initialDetail={initialView.initialDetail} + actions={actions} + /> + ) : ( + + )} +
+
); diff --git a/packages/admin-next/src/components/resource-view.test.tsx b/packages/admin-next/src/components/resource-view.test.tsx new file mode 100644 index 0000000..eb28654 --- /dev/null +++ b/packages/admin-next/src/components/resource-view.test.tsx @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import type { ResourceInfo, TableSchema } from "../types.js"; +import type { AdminActions } from "../server/actions.js"; +import { ResourceView } from "./resource-view.js"; + +const schema: TableSchema = { + uiType: "table", + type: "view", + columns: [{ name: "id", label: "ID", type: "number", pinned: true }], +}; + +const noopActions: AdminActions = { + listResources: vi.fn(), + getSchema: vi.fn(), + fetchAction: vi.fn(), + fetchUrl: vi.fn(), + submitAction: vi.fn(), +}; + +describe("ResourceView", () => { + // A form/custom resource can return a nil SupportedActions slice, which + // serializes to JSON null. Rendering must not crash on `.some` of null. + it("renders a table resource whose supportedActions is null", () => { + const resource = { + id: "users", + name: "Users", + description: "User balances.", + icon: "users", + type: "table", + dataUrl: "/admin/resources/users/action?action=view", + defaultAction: "view", + supportedActions: null, + } as unknown as ResourceInfo; + + render( + , + ); + + expect(screen.getByText("Users")).toBeTruthy(); + }); + + it("shows the + Create button when a create action is supported", () => { + const resource: ResourceInfo = { + id: "posts", + name: "Posts", + description: "", + icon: "file", + type: "table", + dataUrl: "/admin/resources/posts/action?action=view", + defaultAction: "view", + supportedActions: [ + { + type: "primary", + label: "Create", + icon: "plus", + behavior: "openSheet", + actionType: "create", + onClick: "/admin/resources/posts/action?action=create", + }, + ], + }; + + render( + , + ); + + expect(screen.getByText("+ Create")).toBeTruthy(); + }); +}); diff --git a/packages/admin-next/src/components/resource-view.tsx b/packages/admin-next/src/components/resource-view.tsx index 12c8aa2..3741f6e 100644 --- a/packages/admin-next/src/components/resource-view.tsx +++ b/packages/admin-next/src/components/resource-view.tsx @@ -110,7 +110,7 @@ export function ResourceView(props: ResourceViewProps): React.JSX.Element { title={resource?.name ?? resourceId} description={resource?.description} /> - {resource?.supportedActions.some((a) => a.actionType === "create") && ( + {resource?.supportedActions?.some((a) => a.actionType === "create") && ( diff --git a/packages/admin-next/src/server/admin-app.tsx b/packages/admin-next/src/server/admin-app.tsx index c50f8b6..d96da33 100644 --- a/packages/admin-next/src/server/admin-app.tsx +++ b/packages/admin-next/src/server/admin-app.tsx @@ -18,6 +18,17 @@ export interface AdminAppProps { /** Next.js dynamic route params: the catch-all slug segments. */ params: Promise<{ slug?: string[] }>; searchParams?: Promise>; + /** + * Brand shown at the top-left of the app header. Defaults to "Admin". + * Pass a string or any React node (e.g. a logo + name). + */ + title?: React.ReactNode; + /** + * Custom content rendered at the top-right of the app header — e.g. the + * signed-in user's email and a Sign out button. Because AdminApp is a Server + * Component you can pass a server-action
here directly. + */ + headerActions?: React.ReactNode; } interface InitialView { @@ -41,6 +52,8 @@ export async function AdminApp({ actions, params, searchParams, + title, + headerActions, }: AdminAppProps): Promise { const { slug } = await params; const sp = (await searchParams) ?? {}; @@ -94,6 +107,8 @@ export async function AdminApp({ initialView={initialView} actions={plainActions} error={error} + title={title} + headerActions={headerActions} /> ); }