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.
This commit is contained in:
dswbx
2025-09-19 11:36:31 +02:00
parent 95d114ea68
commit 26d1f2b583
15 changed files with 234 additions and 35 deletions

View File

@@ -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 <ClientProvider />
*/
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({
<Provider>
<MantineProvider {...createMantineTheme(theme as any)}>
<Notifications position="top-right" />
<Routes BkndWrapper={BkndWrapper} basePath={config?.basepath} />
<Routes BkndWrapper={BkndWrapper} basePath={config?.basepath}>
{children}
</Routes>
</MantineProvider>
</Provider>
);

View File

@@ -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<void>;
actions: ReturnType<typeof getSchemaActions>;
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<boolean>(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 ?? {

View File

@@ -1 +1 @@
export { BkndProvider, type BkndAdminOptions, useBknd, SchemaEditable } from "./BkndProvider";
export { BkndProvider, type BkndContext, useBknd, SchemaEditable } from "./BkndProvider";

View File

@@ -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<App["toJSON"]>;
@@ -20,7 +20,7 @@ export class AppReduced {
constructor(
protected appJson: AppType,
protected _options: BkndAdminOptions = {},
protected _options: BkndAdminProps["config"] = {},
) {
//console.log("received appjson", appJson);

View File

@@ -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",

View File

@@ -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<any, any, any, any, any, any, any, any, any, any>;
@@ -44,6 +45,7 @@ export function EntityForm({
action,
}: EntityFormProps) {
const fields = entity.getFillableFields(action, true);
const options = useEntityAdminOptions(entity, action);
return (
<form onSubmit={handleSubmit}>
@@ -107,16 +109,29 @@ export function EntityForm({
>
<Form.Field
name={field.name}
children={(props) => (
<EntityFormField
field={field}
fieldApi={props}
disabled={fieldsDisabled}
tabIndex={key + 1}
action={action}
data={data}
/>
)}
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 (
<EntityFormField
field={field}
fieldApi={props}
disabled={fieldsDisabled}
tabIndex={key + 1}
action={action}
data={data}
/>
);
}}
/>
</ErrorBoundary>
);

View File

@@ -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 };
}

View File

@@ -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<E>;
};
export type BkndAdminEntityOptions<E extends keyof DB | string> = {
/**
* 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<E>;
};
};
export type BkndAdminEntityFieldOptions<E extends keyof DB | string> = {
/**
* 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,
};
}

View File

@@ -0,0 +1,2 @@
export * from "./entities";
export * from "./app-shell";

View File

@@ -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 <DataEntityUpdateImpl params={params} key={params.entity} />;
@@ -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 }) {
<Dropdown
position="bottom-end"
items={[
...(options.actions?.context ?? []),
{
label: "Inspect",
onClick: () => {
@@ -156,6 +159,10 @@ function DataEntityUpdateImpl({ params }) {
>
<IconButton Icon={TbDots} />
</Dropdown>
{options.actions?.primary?.map(
(button, key) =>
button && <Button variant="primary" {...button} type="button" key={key} />,
)}
<Form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
children={([canSubmit, isSubmitting]) => (
@@ -185,6 +192,7 @@ function DataEntityUpdateImpl({ params }) {
</div>
) : (
<AppShell.Scrollable>
{options.header}
{error && (
<div className="flex flex-row dark:bg-red-950 bg-red-100 p-4">
<b className="mr-2">Update failed: </b> {error}
@@ -200,6 +208,8 @@ function DataEntityUpdateImpl({ params }) {
action="update"
className="flex flex-grow flex-col gap-3 p-3"
/>
{options.footer}
{targetRelations.length > 0 ? (
<EntityDetailRelations
id={entityId}
@@ -247,7 +257,8 @@ function EntityDetailRelations({
return {
as: "button",
type: "button",
label: ucFirst(other.entity.label),
//label: ucFirst(other.entity.label),
label: ucFirst(other.reference),
onClick: () => handleClick(relation),
active: selected?.other(entity).reference === other.reference,
badge: relation.type(),

View File

@@ -13,6 +13,10 @@ import { EntityForm } from "ui/modules/data/components/EntityForm";
import { useEntityForm } from "ui/modules/data/hooks/useEntityForm";
import { s } from "bknd/utils";
import { notifications } from "@mantine/notifications";
import { useEntityAdminOptions } from "ui/options";
import { Dropdown } from "ui/components/overlay/Dropdown";
import { TbDots } from "react-icons/tb";
import { IconButton } from "ui/components/buttons/IconButton";
export function DataEntityCreate({ params }) {
const { $data } = useBkndData();
@@ -23,6 +27,7 @@ export function DataEntityCreate({ params }) {
} else if (entity.type === "system") {
return <Message.NotAllowed description={`Entity "${params.entity}" cannot be created.`} />;
}
const options = useEntityAdminOptions(entity, "create");
const [error, setError] = useState<string | null>(null);
useBrowserTitle(["Data", entity.label, "Create"]);
@@ -71,7 +76,16 @@ export function DataEntityCreate({ params }) {
<AppShell.SectionHeader
right={
<>
{options.actions?.context && (
<Dropdown position="bottom-end" items={options.actions.context}>
<IconButton Icon={TbDots} />
</Dropdown>
)}
<Button onClick={goBack}>Cancel</Button>
{options.actions?.primary?.map(
(button, key) =>
button && <Button {...button} type="button" key={key} variant="primary" />,
)}
<Form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
children={([canSubmit, isSubmitting]) => (
@@ -96,6 +110,7 @@ export function DataEntityCreate({ params }) {
/>
</AppShell.SectionHeader>
<AppShell.Scrollable key={entity.name}>
{options.header}
{error && (
<div className="flex flex-row dark:bg-red-950 bg-red-100 p-4">
<b className="mr-2">Create failed: </b> {error}
@@ -110,6 +125,7 @@ export function DataEntityCreate({ params }) {
action="create"
className="flex flex-grow flex-col gap-3 p-3"
/>
{options.footer}
</AppShell.Scrollable>
</>
);

View File

@@ -17,6 +17,7 @@ import { useCreateUserModal } from "ui/modules/auth/hooks/use-create-user-modal"
import { EntityTable2 } from "ui/modules/data/components/EntityTable2";
import { s } from "bknd/utils";
import { pick } from "core/utils/objects";
import { useEntityAdminOptions } from "ui/options";
const searchSchema = s.partialObject({
...pick(repoQuery.properties, ["select", "where", "sort"]),
@@ -36,6 +37,7 @@ function DataEntityListImpl({ params }) {
if (!entity) {
return <Message.NotFound description={`Entity "${params.entity}" doesn't exist.`} />;
}
const options = useEntityAdminOptions(entity, "list");
useBrowserTitle(["Data", entity?.label ?? params.entity]);
const [navigate] = useNavigate();
@@ -100,6 +102,7 @@ function DataEntityListImpl({ params }) {
<>
<Dropdown
items={[
...(options.actions?.context ?? []),
{
label: "Settings",
onClick: () => navigate(routes.data.schema.entity(entity.name)),
@@ -120,6 +123,10 @@ function DataEntityListImpl({ params }) {
>
<IconButton Icon={TbDots} />
</Dropdown>
{options.actions?.primary?.map(
(button, key) =>
button && <Button variant="primary" {...button} type="button" key={key} />,
)}
<EntityCreateButton entity={entity} />
</>
}
@@ -127,6 +134,7 @@ function DataEntityListImpl({ params }) {
<AppShell.SectionHeaderTitle>{entity.label}</AppShell.SectionHeaderTitle>
</AppShell.SectionHeader>
<AppShell.Scrollable key={entity.name}>
{options.header}
<div className="flex flex-col flex-grow p-3 gap-3">
{/*<div className="w-64">
<SearchInput placeholder={`Filter ${entity.label}`} />
@@ -134,7 +142,7 @@ function DataEntityListImpl({ params }) {
<div
data-updating={isUpdating ? 1 : undefined}
className="data-[updating]:opacity-50 transition-opacity pb-10"
className="data-[updating]:opacity-50 transition-opacity"
>
<EntityTable2
data={data ?? null}
@@ -152,6 +160,7 @@ function DataEntityListImpl({ params }) {
/>
</div>
</div>
{options.footer}
</AppShell.Scrollable>
</Fragment>
);

View File

@@ -20,7 +20,12 @@ const TestRoutes = lazy(() => import("./test"));
export function Routes({
BkndWrapper,
basePath = "",
}: { BkndWrapper: ComponentType<{ children: ReactNode }>; basePath?: string }) {
children,
}: {
BkndWrapper: ComponentType<{ children: ReactNode }>;
basePath?: string;
children?: ReactNode;
}) {
const { theme } = useTheme();
const ctx = useBkndWindowContext();
const actualBasePath = basePath || ctx.admin_basepath;
@@ -44,6 +49,8 @@ export function Routes({
</Suspense>
</Route>
{children}
<Route path="/" component={RootEmpty} />
<Route path="/data" nest>
<Suspense fallback={null}>