From 26d1f2b583b5299f9d6d8ba6ec5a7b74b3297e06 Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 19 Sep 2025 11:36:31 +0200 Subject: [PATCH 1/2] feat: add admin options for entities and app shell Introduced `BkndAdminEntitiesOptions` and `BkndAdminAppShellOptions` for advanced customization of entity actions, headers, footers, and app shell user menu. Updated related components, hooks, and types for seamless integration with the new configuration options. --- app/src/core/config.ts | 6 +- app/src/data/api/DataApi.ts | 3 + app/src/ui/Admin.tsx | 46 +++++++++- app/src/ui/client/BkndProvider.tsx | 15 ++-- app/src/ui/client/bknd.ts | 2 +- app/src/ui/client/utils/AppReduced.ts | 4 +- app/src/ui/layouts/AppShell/Header.tsx | 10 ++- .../ui/modules/data/components/EntityForm.tsx | 35 +++++--- app/src/ui/options/app-shell.ts | 12 +++ app/src/ui/options/entities.ts | 85 +++++++++++++++++++ app/src/ui/options/index.ts | 2 + app/src/ui/routes/data/data.$entity.$id.tsx | 13 ++- .../ui/routes/data/data.$entity.create.tsx | 16 ++++ app/src/ui/routes/data/data.$entity.index.tsx | 11 ++- app/src/ui/routes/index.tsx | 9 +- 15 files changed, 234 insertions(+), 35 deletions(-) create mode 100644 app/src/ui/options/app-shell.ts create mode 100644 app/src/ui/options/entities.ts create mode 100644 app/src/ui/options/index.ts diff --git a/app/src/core/config.ts b/app/src/core/config.ts index 99a9013..581bea1 100644 --- a/app/src/core/config.ts +++ b/app/src/core/config.ts @@ -11,10 +11,12 @@ export interface AppEntity { export interface DB { // make sure to make unknown as "any" - [key: string]: { + /* [key: string]: { id: PrimaryFieldType; [key: string]: any; - }; + }; */ + // @todo: that's not good, but required for admin options + [key: string]: any; } export const config = { diff --git a/app/src/data/api/DataApi.ts b/app/src/data/api/DataApi.ts index b4deb5d..de88812 100644 --- a/app/src/data/api/DataApi.ts +++ b/app/src/data/api/DataApi.ts @@ -42,6 +42,9 @@ export class DataApi extends ModuleApi { ) { type Data = E extends keyof DB ? Selectable : EntityData; type T = RepositoryResultJSON; + + // @todo: if none found, still returns meta... + return this.readMany(entity, { ...query, limit: 1, diff --git a/app/src/ui/Admin.tsx b/app/src/ui/Admin.tsx index df142ac..0da2b90 100644 --- a/app/src/ui/Admin.tsx +++ b/app/src/ui/Admin.tsx @@ -1,24 +1,60 @@ import { MantineProvider } from "@mantine/core"; import { Notifications } from "@mantine/notifications"; import React, { type ReactNode } from "react"; -import { BkndProvider, type BkndAdminOptions } from "ui/client/bknd"; -import { useTheme } from "ui/client/use-theme"; +import { BkndProvider } from "ui/client/bknd"; +import { useTheme, type AppTheme } from "ui/client/use-theme"; import { Logo } from "ui/components/display/Logo"; import * as AppShell from "ui/layouts/AppShell/AppShell"; import { ClientProvider, useBkndWindowContext, type ClientProviderProps } from "./client"; import { createMantineTheme } from "./lib/mantine/theme"; import { Routes } from "./routes"; +import type { BkndAdminAppShellOptions, BkndAdminEntitiesOptions } from "ui/options"; export type BkndAdminProps = { + /** + * Base URL of the API, only needed if you are not using the `withProvider` prop + */ baseUrl?: string; + /** + * Whether to wrap Admin in a + */ withProvider?: boolean | ClientProviderProps; - config?: BkndAdminOptions; + /** + * Admin UI customization options + */ + config?: { + /** + * Base path of the Admin UI + * @default `/` + */ + basepath?: string; + /** + * Path to return to when clicking the logo + * @default `/` + */ + logo_return_path?: string; + /** + * Theme of the Admin UI + * @default `system` + */ + theme?: AppTheme; + /** + * Entities configuration like headers, footers, actions, field renders, etc. + */ + entities?: BkndAdminEntitiesOptions; + /** + * App shell configuration like user menu actions. + */ + appShell?: BkndAdminAppShellOptions; + }; + children?: ReactNode; }; export default function Admin({ baseUrl: baseUrlOverride, withProvider = false, config: _config = {}, + children, }: BkndAdminProps) { const { theme } = useTheme(); const Provider = ({ children }: any) => @@ -47,7 +83,9 @@ export default function Admin({ - + + {children} + ); diff --git a/app/src/ui/client/BkndProvider.tsx b/app/src/ui/client/BkndProvider.tsx index fa8ed35..abb0020 100644 --- a/app/src/ui/client/BkndProvider.tsx +++ b/app/src/ui/client/BkndProvider.tsx @@ -12,16 +12,11 @@ import { import { useApi } from "ui/client"; import { type TSchemaActions, getSchemaActions } from "./schema/actions"; import { AppReduced } from "./utils/AppReduced"; -import type { AppTheme } from "ui/client/use-theme"; import { Message } from "ui/components/display/Message"; import { useNavigate } from "ui/lib/routes"; +import type { BkndAdminProps } from "ui/Admin"; -export type BkndAdminOptions = { - basepath?: string; - logo_return_path?: string; - theme?: AppTheme; -}; -type BkndContext = { +export type BkndContext = { version: number; readonly: boolean; schema: ModuleSchemas; @@ -31,7 +26,7 @@ type BkndContext = { requireSecrets: () => Promise; actions: ReturnType; app: AppReduced; - options: BkndAdminOptions; + options: BkndAdminProps["config"]; fallback: boolean; }; @@ -53,7 +48,7 @@ export function BkndProvider({ includeSecrets?: boolean; children: any; fallback?: React.ReactNode; - options?: BkndAdminOptions; + options?: BkndAdminProps["config"]; }) { const [withSecrets, setWithSecrets] = useState(includeSecrets); const [schema, setSchema] = @@ -180,7 +175,7 @@ export function useBknd({ withSecrets }: { withSecrets?: boolean } = {}): BkndCo return ctx; } -export function useBkndOptions(): BkndAdminOptions { +export function useBkndOptions(): BkndAdminProps["config"] { const ctx = useContext(BkndContext); return ( ctx.options ?? { diff --git a/app/src/ui/client/bknd.ts b/app/src/ui/client/bknd.ts index e384b07..12ceb63 100644 --- a/app/src/ui/client/bknd.ts +++ b/app/src/ui/client/bknd.ts @@ -1 +1 @@ -export { BkndProvider, type BkndAdminOptions, useBknd, SchemaEditable } from "./BkndProvider"; +export { BkndProvider, type BkndContext, useBknd, SchemaEditable } from "./BkndProvider"; diff --git a/app/src/ui/client/utils/AppReduced.ts b/app/src/ui/client/utils/AppReduced.ts index 2085d99..2ea5045 100644 --- a/app/src/ui/client/utils/AppReduced.ts +++ b/app/src/ui/client/utils/AppReduced.ts @@ -4,7 +4,7 @@ import type { EntityRelation } from "data/relations"; import { constructEntity, constructRelation } from "data/schema/constructor"; import { RelationAccessor } from "data/relations/RelationAccessor"; import { Flow, TaskMap } from "flows"; -import type { BkndAdminOptions } from "ui/client/BkndProvider"; +import type { BkndAdminProps } from "ui/Admin"; export type AppType = ReturnType; @@ -20,7 +20,7 @@ export class AppReduced { constructor( protected appJson: AppType, - protected _options: BkndAdminOptions = {}, + protected _options: BkndAdminProps["config"] = {}, ) { //console.log("received appjson", appJson); diff --git a/app/src/ui/layouts/AppShell/Header.tsx b/app/src/ui/layouts/AppShell/Header.tsx index 7e6036f..f53beb3 100644 --- a/app/src/ui/layouts/AppShell/Header.tsx +++ b/app/src/ui/layouts/AppShell/Header.tsx @@ -16,7 +16,7 @@ import { useTheme } from "ui/client/use-theme"; import { Button } from "ui/components/buttons/Button"; import { IconButton } from "ui/components/buttons/IconButton"; import { Logo } from "ui/components/display/Logo"; -import { Dropdown, type DropdownItem } from "ui/components/overlay/Dropdown"; +import { Dropdown, type DropdownProps } from "ui/components/overlay/Dropdown"; import { Link } from "ui/components/wouter/Link"; import { useEvent } from "ui/hooks/use-event"; import { useNavigate } from "ui/lib/routes"; @@ -26,6 +26,7 @@ import { autoFormatString } from "core/utils"; import { appShellStore } from "ui/store"; import { getVersion } from "core/env"; import { McpIcon } from "ui/routes/tools/mcp/components/mcp-icon"; +import { useAppShellAdminOptions } from "ui/options"; export function HeaderNavigation() { const [location, navigate] = useLocation(); @@ -144,7 +145,9 @@ export function Header({ hasSidebar = true }) { } function UserMenu() { - const { config, options } = useBknd(); + const { config } = useBknd(); + const uiOptions = useAppShellAdminOptions(); + const auth = useAuth(); const [navigate] = useNavigate(); const { logout_route } = useBkndWindowContext(); @@ -159,7 +162,8 @@ function UserMenu() { navigate("/auth/login"); } - const items: DropdownItem[] = [ + const items: DropdownProps["items"] = [ + ...(uiOptions.userMenu ?? []), { label: "Settings", onClick: () => navigate("/settings"), icon: IconSettings }, { label: "OpenAPI", diff --git a/app/src/ui/modules/data/components/EntityForm.tsx b/app/src/ui/modules/data/components/EntityForm.tsx index 63bad58..0b144bf 100644 --- a/app/src/ui/modules/data/components/EntityForm.tsx +++ b/app/src/ui/modules/data/components/EntityForm.tsx @@ -16,6 +16,7 @@ import { Alert } from "ui/components/display/Alert"; import { bkndModals } from "ui/modals"; import type { EnumField, JsonField, JsonSchemaField } from "data/fields"; import type { RelationField } from "data/relations"; +import { useEntityAdminOptions } from "ui/options"; // simplify react form types 🤦 export type FormApi = ReactFormExtendedApi; @@ -44,6 +45,7 @@ export function EntityForm({ action, }: EntityFormProps) { const fields = entity.getFillableFields(action, true); + const options = useEntityAdminOptions(entity, action); return (
@@ -107,16 +109,29 @@ export function EntityForm({ > ( - - )} + children={(props) => { + const fieldOptions = options.field(field.name); + if (fieldOptions?.render) { + const custom = fieldOptions.render(action, entity, field, { + handleChange: props.handleChange, + value: props.state.value, + data, + }); + if (custom) { + return custom; + } + } + return ( + + ); + }} /> ); diff --git a/app/src/ui/options/app-shell.ts b/app/src/ui/options/app-shell.ts new file mode 100644 index 0000000..6f3cc4b --- /dev/null +++ b/app/src/ui/options/app-shell.ts @@ -0,0 +1,12 @@ +import { useBknd } from "ui/client/BkndProvider"; +import type { DropdownProps } from "ui/components/overlay/Dropdown"; + +export type BkndAdminAppShellOptions = { + userMenu?: DropdownProps["items"]; +}; + +export function useAppShellAdminOptions() { + const { options } = useBknd(); + const userMenu = options?.appShell?.userMenu ?? []; + return { userMenu }; +} diff --git a/app/src/ui/options/entities.ts b/app/src/ui/options/entities.ts new file mode 100644 index 0000000..d08d377 --- /dev/null +++ b/app/src/ui/options/entities.ts @@ -0,0 +1,85 @@ +import type { DB, Field } from "bknd"; +import type { ReactNode } from "react"; +import type { Entity } from "data/entities"; +import { useBknd } from "ui/client/BkndProvider"; +import type { DropdownProps } from "ui/components/overlay/Dropdown"; +import type { ButtonProps } from "ui/components/buttons/Button"; + +export type BkndAdminEntityContext = "list" | "create" | "update"; + +export type BkndAdminEntitiesOptions = { + [E in keyof DB]?: BkndAdminEntityOptions; +}; + +export type BkndAdminEntityOptions = { + /** + * Header to be rendered depending on the context + */ + header?: ( + context: BkndAdminEntityContext, + entity: Entity, + data?: DB[E], + ) => ReactNode | void | undefined; + /** + * Footer to be rendered depending on the context + */ + footer?: ( + context: BkndAdminEntityContext, + entity: Entity, + data?: DB[E], + ) => ReactNode | void | undefined; + /** + * Actions to be rendered depending on the context + */ + actions?: ( + context: BkndAdminEntityContext, + entity: Entity, + data?: DB[E], + ) => { + /** + * Primary actions are always visible + */ + primary?: (ButtonProps | undefined | null | false)[]; + /** + * Context actions are rendered in a dropdown + */ + context?: DropdownProps["items"]; + }; + /** + * Field UI overrides + */ + fields?: { + [F in keyof DB[E]]?: BkndAdminEntityFieldOptions; + }; +}; + +export type BkndAdminEntityFieldOptions = { + /** + * Override the rendering of a certain field + */ + render?: ( + context: BkndAdminEntityContext, + entity: Entity, + field: Field, + ctx: { + data?: DB[E]; + value?: DB[E][keyof DB[E]]; + handleChange: (value: any) => void; + }, + ) => ReactNode | void | undefined; +}; + +export function useEntityAdminOptions(entity: Entity, context: BkndAdminEntityContext, data?: any) { + const b = useBknd(); + const opts = b.options?.entities?.[entity.name]; + const footer = opts?.footer?.(context, entity, data) ?? null; + const header = opts?.header?.(context, entity, data) ?? null; + const actions = opts?.actions?.(context, entity, data); + + return { + footer, + header, + field: (name: string) => opts?.fields?.[name], + actions, + }; +} diff --git a/app/src/ui/options/index.ts b/app/src/ui/options/index.ts new file mode 100644 index 0000000..a3f32c0 --- /dev/null +++ b/app/src/ui/options/index.ts @@ -0,0 +1,2 @@ +export * from "./entities"; +export * from "./app-shell"; diff --git a/app/src/ui/routes/data/data.$entity.$id.tsx b/app/src/ui/routes/data/data.$entity.$id.tsx index dd822b8..0b91fa5 100644 --- a/app/src/ui/routes/data/data.$entity.$id.tsx +++ b/app/src/ui/routes/data/data.$entity.$id.tsx @@ -18,6 +18,7 @@ import { EntityForm } from "ui/modules/data/components/EntityForm"; import { EntityTable2 } from "ui/modules/data/components/EntityTable2"; import { useEntityForm } from "ui/modules/data/hooks/useEntityForm"; import { notifications } from "@mantine/notifications"; +import { useEntityAdminOptions } from "ui/options"; export function DataEntityUpdate({ params }) { return ; @@ -53,6 +54,7 @@ function DataEntityUpdateImpl({ params }) { }, ); + const options = useEntityAdminOptions(entity, "update", $q.data); const backHref = routes.data.entity.list(entity.name); const goBack = () => _goBack({ fallback: backHref }); @@ -125,6 +127,7 @@ function DataEntityUpdateImpl({ params }) { { @@ -156,6 +159,10 @@ function DataEntityUpdateImpl({ params }) { > + {options.actions?.primary?.map( + (button, key) => + button && + {options.actions?.primary?.map( + (button, key) => + button && ++ ++ ++ ++ ++ ++}`}> ++
++ ++ ++ ++ ++ ++
++ ++ ++ ++ ++ ++ ++ ++ +\ No newline at end of file +Index: docs/tsconfig.json +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/docs/tsconfig.json b/docs/tsconfig.json +--- a/docs/tsconfig.json (revision 26d1f2b583b5299f9d6d8ba6ec5a7b74b3297e06) ++++ b/docs/tsconfig.json (date 1758275691058) +@@ -18,6 +18,7 @@ + "paths": { + "@/.source": ["./.source/index.ts"], + "@/bknd/*": ["../app/src/"], ++ "bknd-ui": ["../app/dist/ui/index.js"], + "@/*": ["./*"] + }, + +Index: docs/app/_components/BkndUi.tsx +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/docs/app/_components/BkndUi.tsx b/docs/app/_components/BkndUi.tsx +new file mode 100644 +--- /dev/null (date 1758280846757) ++++ b/docs/app/_components/BkndUi.tsx (date 1758280846757) +@@ -0,0 +1,36 @@ ++import { readFileSync } from "node:fs"; ++import { join } from "node:path"; ++import { BkndUiClient } from "./BkndUiClient"; ++ ++// Server component that reads CSS from disk ++export function BkndUi({ children, code }: { children: React.ReactNode; code?: string }) { ++ let bkndCss = ""; ++ ++ try { ++ // Try to read the CSS file from the app directory ++ const cssPath = join(process.cwd(), "node_modules", "bknd", "dist", "ui", "main.css"); ++ bkndCss = readFileSync(cssPath, "utf-8"); ++ } catch (error) { ++ console.warn("Could not read bknd CSS file:", error); ++ // Fallback CSS ++ bkndCss = ` ++ .bknd-admin { ++ --color-primary: #18181b; ++ --color-background: #fafafa; ++ --color-muted: #e4e4e7; ++ color: var(--color-primary); ++ background: var(--color-background); ++ padding: 16px; ++ border: 1px solid var(--color-muted); ++ border-radius: 8px; ++ font-family: system-ui, sans-serif; ++ } ++ `; ++ } ++ ++ return ( ++ ++ {children} ++ ++ ); ++} +Index: docs/next.config.mjs +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/docs/next.config.mjs b/docs/next.config.mjs +--- a/docs/next.config.mjs (revision 26d1f2b583b5299f9d6d8ba6ec5a7b74b3297e06) ++++ b/docs/next.config.mjs (date 1758281330764) +@@ -19,6 +19,9 @@ + webpack(config) { + config.resolve.alias["@/bknd"] = path.resolve(__dirname, "../app/src"); + config.resolve.alias["@"] = path.resolve(__dirname); ++ ++ // @todo: this doesn't work with turbo ++ config.resolve.alias["bknd-ui"] = path.resolve(__dirname, "../app/dist/ui/index.js"); + return config; + }, + eslint: { +Index: docs/app/_components/BkndUiClient.tsx +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/docs/app/_components/BkndUiClient.tsx b/docs/app/_components/BkndUiClient.tsx +new file mode 100644 +--- /dev/null (date 1758281176041) ++++ b/docs/app/_components/BkndUiClient.tsx (date 1758281176041) +@@ -0,0 +1,62 @@ ++"use client"; ++ ++import { useEffect, useRef, useState } from "react"; ++import { createPortal } from "react-dom"; ++import { Card } from "fumadocs-ui/components/card"; ++import { DynamicCodeBlock } from "fumadocs-ui/components/dynamic-codeblock"; ++import { useTheme } from "next-themes"; ++ ++interface BkndUiClientProps { ++ bkndCss: string; ++ children: React.ReactNode; ++ code?: string; ++} ++ ++export function BkndUiClient({ bkndCss, children, code }: BkndUiClientProps) { ++ const { theme } = useTheme(); ++ const containerRef = useRef(null); ++ const [shadowRoot, setShadowRoot] = useState(null); ++ const [mountPoint, setMountPoint] = useState(null); ++ ++ useEffect(() => { ++ if (!containerRef.current || shadowRoot) return; ++ ++ // Create shadow root ++ const shadow = containerRef.current.attachShadow({ mode: "open" }); ++ ++ // Create style element with the CSS passed from server ++ const style = document.createElement("style"); ++ style.textContent = ` ++ :host { ++ display: block; ++ isolation: isolate; ++ } ++ ${bkndCss} ++ `; ++ ++ shadow.appendChild(style); ++ ++ // Create mount point for React content ++ const mount = document.createElement("div"); ++ mount.className = `bknd-admin ${theme}`; ++ shadow.appendChild(mount); ++ ++ setShadowRoot(shadow); ++ setMountPoint(mount); ++ }, [shadowRoot, bkndCss]); ++ ++ return ( ++ <> ++ {/* @ts-ignore */} ++ ++
++
++
Loading...
++ {shadowRoot && mountPoint && createPortal(children, mountPoint)} ++
++
++ {code && } ++
++ ++ ); ++} +Index: docs/content/docs/(documentation)/usage/mcp/tools-resources.mdx +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/docs/content/docs/(documentation)/usage/mcp/tools-resources.mdx b/docs/content/docs/(documentation)/usage/mcp/tools-resources.mdx +--- a/docs/content/docs/(documentation)/usage/mcp/tools-resources.mdx (revision 26d1f2b583b5299f9d6d8ba6ec5a7b74b3297e06) ++++ b/docs/content/docs/(documentation)/usage/mcp/tools-resources.mdx (date 1758280630236) +@@ -1,5 +1,5 @@ + --- +-title: "MCP" ++title: "Tools & Resources" + description: "Tools & Resources of the built-in full featured MCP server." + tags: ["documentation"] + --- +@@ -24,7 +24,7 @@ + + Create a new user + +- ++ + + ### `auth_user_password_change` + +@@ -48,61 +48,61 @@ + + Delete many + +- ++ + + ### `data_entity_delete_one` + + Delete one + +- ++ + + ### `data_entity_fn_count` + + Count entities + +- ++ + + ### `data_entity_fn_exists` + + Check if entity exists + +- ++ + + ### `data_entity_info` + + Retrieve entity info + +- ++ + + ### `data_entity_insert` + + Insert one or many + +- ++ + + ### `data_entity_read_many` + + Query entities + +- ++ + + ### `data_entity_read_one` + + Read one + +- ++ + + ### `data_entity_update_many` + + Update many + +- ++ + + ### `data_entity_update_one` + + Update one + +- ++ + + ### `data_sync` + +@@ -306,7 +306,7 @@ + + Update Server configuration + +- ++ + + + ## Resources