From 26d1f2b583b5299f9d6d8ba6ec5a7b74b3297e06 Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 19 Sep 2025 11:36:31 +0200 Subject: [PATCH] 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 &&