mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
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:
@@ -11,10 +11,12 @@ export interface AppEntity<IdType = number | string> {
|
|||||||
|
|
||||||
export interface DB {
|
export interface DB {
|
||||||
// make sure to make unknown as "any"
|
// make sure to make unknown as "any"
|
||||||
[key: string]: {
|
/* [key: string]: {
|
||||||
id: PrimaryFieldType;
|
id: PrimaryFieldType;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
};
|
}; */
|
||||||
|
// @todo: that's not good, but required for admin options
|
||||||
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ export class DataApi extends ModuleApi<DataApiOptions> {
|
|||||||
) {
|
) {
|
||||||
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
|
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
|
||||||
type T = RepositoryResultJSON<Data>;
|
type T = RepositoryResultJSON<Data>;
|
||||||
|
|
||||||
|
// @todo: if none found, still returns meta...
|
||||||
|
|
||||||
return this.readMany(entity, {
|
return this.readMany(entity, {
|
||||||
...query,
|
...query,
|
||||||
limit: 1,
|
limit: 1,
|
||||||
|
|||||||
@@ -1,24 +1,60 @@
|
|||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { Notifications } from "@mantine/notifications";
|
import { Notifications } from "@mantine/notifications";
|
||||||
import React, { type ReactNode } from "react";
|
import React, { type ReactNode } from "react";
|
||||||
import { BkndProvider, type BkndAdminOptions } from "ui/client/bknd";
|
import { BkndProvider } from "ui/client/bknd";
|
||||||
import { useTheme } from "ui/client/use-theme";
|
import { useTheme, type AppTheme } from "ui/client/use-theme";
|
||||||
import { Logo } from "ui/components/display/Logo";
|
import { Logo } from "ui/components/display/Logo";
|
||||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||||
import { ClientProvider, useBkndWindowContext, type ClientProviderProps } from "./client";
|
import { ClientProvider, useBkndWindowContext, type ClientProviderProps } from "./client";
|
||||||
import { createMantineTheme } from "./lib/mantine/theme";
|
import { createMantineTheme } from "./lib/mantine/theme";
|
||||||
import { Routes } from "./routes";
|
import { Routes } from "./routes";
|
||||||
|
import type { BkndAdminAppShellOptions, BkndAdminEntitiesOptions } from "ui/options";
|
||||||
|
|
||||||
export type BkndAdminProps = {
|
export type BkndAdminProps = {
|
||||||
|
/**
|
||||||
|
* Base URL of the API, only needed if you are not using the `withProvider` prop
|
||||||
|
*/
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
|
/**
|
||||||
|
* Whether to wrap Admin in a <ClientProvider />
|
||||||
|
*/
|
||||||
withProvider?: boolean | ClientProviderProps;
|
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({
|
export default function Admin({
|
||||||
baseUrl: baseUrlOverride,
|
baseUrl: baseUrlOverride,
|
||||||
withProvider = false,
|
withProvider = false,
|
||||||
config: _config = {},
|
config: _config = {},
|
||||||
|
children,
|
||||||
}: BkndAdminProps) {
|
}: BkndAdminProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const Provider = ({ children }: any) =>
|
const Provider = ({ children }: any) =>
|
||||||
@@ -47,7 +83,9 @@ export default function Admin({
|
|||||||
<Provider>
|
<Provider>
|
||||||
<MantineProvider {...createMantineTheme(theme as any)}>
|
<MantineProvider {...createMantineTheme(theme as any)}>
|
||||||
<Notifications position="top-right" />
|
<Notifications position="top-right" />
|
||||||
<Routes BkndWrapper={BkndWrapper} basePath={config?.basepath} />
|
<Routes BkndWrapper={BkndWrapper} basePath={config?.basepath}>
|
||||||
|
{children}
|
||||||
|
</Routes>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
</Provider>
|
</Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,16 +12,11 @@ import {
|
|||||||
import { useApi } from "ui/client";
|
import { useApi } from "ui/client";
|
||||||
import { type TSchemaActions, getSchemaActions } from "./schema/actions";
|
import { type TSchemaActions, getSchemaActions } from "./schema/actions";
|
||||||
import { AppReduced } from "./utils/AppReduced";
|
import { AppReduced } from "./utils/AppReduced";
|
||||||
import type { AppTheme } from "ui/client/use-theme";
|
|
||||||
import { Message } from "ui/components/display/Message";
|
import { Message } from "ui/components/display/Message";
|
||||||
import { useNavigate } from "ui/lib/routes";
|
import { useNavigate } from "ui/lib/routes";
|
||||||
|
import type { BkndAdminProps } from "ui/Admin";
|
||||||
|
|
||||||
export type BkndAdminOptions = {
|
export type BkndContext = {
|
||||||
basepath?: string;
|
|
||||||
logo_return_path?: string;
|
|
||||||
theme?: AppTheme;
|
|
||||||
};
|
|
||||||
type BkndContext = {
|
|
||||||
version: number;
|
version: number;
|
||||||
readonly: boolean;
|
readonly: boolean;
|
||||||
schema: ModuleSchemas;
|
schema: ModuleSchemas;
|
||||||
@@ -31,7 +26,7 @@ type BkndContext = {
|
|||||||
requireSecrets: () => Promise<void>;
|
requireSecrets: () => Promise<void>;
|
||||||
actions: ReturnType<typeof getSchemaActions>;
|
actions: ReturnType<typeof getSchemaActions>;
|
||||||
app: AppReduced;
|
app: AppReduced;
|
||||||
options: BkndAdminOptions;
|
options: BkndAdminProps["config"];
|
||||||
fallback: boolean;
|
fallback: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,7 +48,7 @@ export function BkndProvider({
|
|||||||
includeSecrets?: boolean;
|
includeSecrets?: boolean;
|
||||||
children: any;
|
children: any;
|
||||||
fallback?: React.ReactNode;
|
fallback?: React.ReactNode;
|
||||||
options?: BkndAdminOptions;
|
options?: BkndAdminProps["config"];
|
||||||
}) {
|
}) {
|
||||||
const [withSecrets, setWithSecrets] = useState<boolean>(includeSecrets);
|
const [withSecrets, setWithSecrets] = useState<boolean>(includeSecrets);
|
||||||
const [schema, setSchema] =
|
const [schema, setSchema] =
|
||||||
@@ -180,7 +175,7 @@ export function useBknd({ withSecrets }: { withSecrets?: boolean } = {}): BkndCo
|
|||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBkndOptions(): BkndAdminOptions {
|
export function useBkndOptions(): BkndAdminProps["config"] {
|
||||||
const ctx = useContext(BkndContext);
|
const ctx = useContext(BkndContext);
|
||||||
return (
|
return (
|
||||||
ctx.options ?? {
|
ctx.options ?? {
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export { BkndProvider, type BkndAdminOptions, useBknd, SchemaEditable } from "./BkndProvider";
|
export { BkndProvider, type BkndContext, useBknd, SchemaEditable } from "./BkndProvider";
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { EntityRelation } from "data/relations";
|
|||||||
import { constructEntity, constructRelation } from "data/schema/constructor";
|
import { constructEntity, constructRelation } from "data/schema/constructor";
|
||||||
import { RelationAccessor } from "data/relations/RelationAccessor";
|
import { RelationAccessor } from "data/relations/RelationAccessor";
|
||||||
import { Flow, TaskMap } from "flows";
|
import { Flow, TaskMap } from "flows";
|
||||||
import type { BkndAdminOptions } from "ui/client/BkndProvider";
|
import type { BkndAdminProps } from "ui/Admin";
|
||||||
|
|
||||||
export type AppType = ReturnType<App["toJSON"]>;
|
export type AppType = ReturnType<App["toJSON"]>;
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ export class AppReduced {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
protected appJson: AppType,
|
protected appJson: AppType,
|
||||||
protected _options: BkndAdminOptions = {},
|
protected _options: BkndAdminProps["config"] = {},
|
||||||
) {
|
) {
|
||||||
//console.log("received appjson", appJson);
|
//console.log("received appjson", appJson);
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { useTheme } from "ui/client/use-theme";
|
|||||||
import { Button } from "ui/components/buttons/Button";
|
import { Button } from "ui/components/buttons/Button";
|
||||||
import { IconButton } from "ui/components/buttons/IconButton";
|
import { IconButton } from "ui/components/buttons/IconButton";
|
||||||
import { Logo } from "ui/components/display/Logo";
|
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 { Link } from "ui/components/wouter/Link";
|
||||||
import { useEvent } from "ui/hooks/use-event";
|
import { useEvent } from "ui/hooks/use-event";
|
||||||
import { useNavigate } from "ui/lib/routes";
|
import { useNavigate } from "ui/lib/routes";
|
||||||
@@ -26,6 +26,7 @@ import { autoFormatString } from "core/utils";
|
|||||||
import { appShellStore } from "ui/store";
|
import { appShellStore } from "ui/store";
|
||||||
import { getVersion } from "core/env";
|
import { getVersion } from "core/env";
|
||||||
import { McpIcon } from "ui/routes/tools/mcp/components/mcp-icon";
|
import { McpIcon } from "ui/routes/tools/mcp/components/mcp-icon";
|
||||||
|
import { useAppShellAdminOptions } from "ui/options";
|
||||||
|
|
||||||
export function HeaderNavigation() {
|
export function HeaderNavigation() {
|
||||||
const [location, navigate] = useLocation();
|
const [location, navigate] = useLocation();
|
||||||
@@ -144,7 +145,9 @@ export function Header({ hasSidebar = true }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function UserMenu() {
|
function UserMenu() {
|
||||||
const { config, options } = useBknd();
|
const { config } = useBknd();
|
||||||
|
const uiOptions = useAppShellAdminOptions();
|
||||||
|
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const [navigate] = useNavigate();
|
const [navigate] = useNavigate();
|
||||||
const { logout_route } = useBkndWindowContext();
|
const { logout_route } = useBkndWindowContext();
|
||||||
@@ -159,7 +162,8 @@ function UserMenu() {
|
|||||||
navigate("/auth/login");
|
navigate("/auth/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
const items: DropdownItem[] = [
|
const items: DropdownProps["items"] = [
|
||||||
|
...(uiOptions.userMenu ?? []),
|
||||||
{ label: "Settings", onClick: () => navigate("/settings"), icon: IconSettings },
|
{ label: "Settings", onClick: () => navigate("/settings"), icon: IconSettings },
|
||||||
{
|
{
|
||||||
label: "OpenAPI",
|
label: "OpenAPI",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { Alert } from "ui/components/display/Alert";
|
|||||||
import { bkndModals } from "ui/modals";
|
import { bkndModals } from "ui/modals";
|
||||||
import type { EnumField, JsonField, JsonSchemaField } from "data/fields";
|
import type { EnumField, JsonField, JsonSchemaField } from "data/fields";
|
||||||
import type { RelationField } from "data/relations";
|
import type { RelationField } from "data/relations";
|
||||||
|
import { useEntityAdminOptions } from "ui/options";
|
||||||
|
|
||||||
// simplify react form types 🤦
|
// simplify react form types 🤦
|
||||||
export type FormApi = ReactFormExtendedApi<any, any, any, any, any, any, any, any, any, any>;
|
export type FormApi = ReactFormExtendedApi<any, any, any, any, any, any, any, any, any, any>;
|
||||||
@@ -44,6 +45,7 @@ export function EntityForm({
|
|||||||
action,
|
action,
|
||||||
}: EntityFormProps) {
|
}: EntityFormProps) {
|
||||||
const fields = entity.getFillableFields(action, true);
|
const fields = entity.getFillableFields(action, true);
|
||||||
|
const options = useEntityAdminOptions(entity, action);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
@@ -107,16 +109,29 @@ export function EntityForm({
|
|||||||
>
|
>
|
||||||
<Form.Field
|
<Form.Field
|
||||||
name={field.name}
|
name={field.name}
|
||||||
children={(props) => (
|
children={(props) => {
|
||||||
<EntityFormField
|
const fieldOptions = options.field(field.name);
|
||||||
field={field}
|
if (fieldOptions?.render) {
|
||||||
fieldApi={props}
|
const custom = fieldOptions.render(action, entity, field, {
|
||||||
disabled={fieldsDisabled}
|
handleChange: props.handleChange,
|
||||||
tabIndex={key + 1}
|
value: props.state.value,
|
||||||
action={action}
|
data,
|
||||||
data={data}
|
});
|
||||||
/>
|
if (custom) {
|
||||||
)}
|
return custom;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<EntityFormField
|
||||||
|
field={field}
|
||||||
|
fieldApi={props}
|
||||||
|
disabled={fieldsDisabled}
|
||||||
|
tabIndex={key + 1}
|
||||||
|
action={action}
|
||||||
|
data={data}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|||||||
12
app/src/ui/options/app-shell.ts
Normal file
12
app/src/ui/options/app-shell.ts
Normal 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 };
|
||||||
|
}
|
||||||
85
app/src/ui/options/entities.ts
Normal file
85
app/src/ui/options/entities.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
2
app/src/ui/options/index.ts
Normal file
2
app/src/ui/options/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./entities";
|
||||||
|
export * from "./app-shell";
|
||||||
@@ -18,6 +18,7 @@ import { EntityForm } from "ui/modules/data/components/EntityForm";
|
|||||||
import { EntityTable2 } from "ui/modules/data/components/EntityTable2";
|
import { EntityTable2 } from "ui/modules/data/components/EntityTable2";
|
||||||
import { useEntityForm } from "ui/modules/data/hooks/useEntityForm";
|
import { useEntityForm } from "ui/modules/data/hooks/useEntityForm";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { useEntityAdminOptions } from "ui/options";
|
||||||
|
|
||||||
export function DataEntityUpdate({ params }) {
|
export function DataEntityUpdate({ params }) {
|
||||||
return <DataEntityUpdateImpl params={params} key={params.entity} />;
|
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 backHref = routes.data.entity.list(entity.name);
|
||||||
const goBack = () => _goBack({ fallback: backHref });
|
const goBack = () => _goBack({ fallback: backHref });
|
||||||
|
|
||||||
@@ -125,6 +127,7 @@ function DataEntityUpdateImpl({ params }) {
|
|||||||
<Dropdown
|
<Dropdown
|
||||||
position="bottom-end"
|
position="bottom-end"
|
||||||
items={[
|
items={[
|
||||||
|
...(options.actions?.context ?? []),
|
||||||
{
|
{
|
||||||
label: "Inspect",
|
label: "Inspect",
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
@@ -156,6 +159,10 @@ function DataEntityUpdateImpl({ params }) {
|
|||||||
>
|
>
|
||||||
<IconButton Icon={TbDots} />
|
<IconButton Icon={TbDots} />
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
{options.actions?.primary?.map(
|
||||||
|
(button, key) =>
|
||||||
|
button && <Button variant="primary" {...button} type="button" key={key} />,
|
||||||
|
)}
|
||||||
<Form.Subscribe
|
<Form.Subscribe
|
||||||
selector={(state) => [state.canSubmit, state.isSubmitting]}
|
selector={(state) => [state.canSubmit, state.isSubmitting]}
|
||||||
children={([canSubmit, isSubmitting]) => (
|
children={([canSubmit, isSubmitting]) => (
|
||||||
@@ -185,6 +192,7 @@ function DataEntityUpdateImpl({ params }) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<AppShell.Scrollable>
|
<AppShell.Scrollable>
|
||||||
|
{options.header}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex flex-row dark:bg-red-950 bg-red-100 p-4">
|
<div className="flex flex-row dark:bg-red-950 bg-red-100 p-4">
|
||||||
<b className="mr-2">Update failed: </b> {error}
|
<b className="mr-2">Update failed: </b> {error}
|
||||||
@@ -200,6 +208,8 @@ function DataEntityUpdateImpl({ params }) {
|
|||||||
action="update"
|
action="update"
|
||||||
className="flex flex-grow flex-col gap-3 p-3"
|
className="flex flex-grow flex-col gap-3 p-3"
|
||||||
/>
|
/>
|
||||||
|
{options.footer}
|
||||||
|
|
||||||
{targetRelations.length > 0 ? (
|
{targetRelations.length > 0 ? (
|
||||||
<EntityDetailRelations
|
<EntityDetailRelations
|
||||||
id={entityId}
|
id={entityId}
|
||||||
@@ -247,7 +257,8 @@ function EntityDetailRelations({
|
|||||||
return {
|
return {
|
||||||
as: "button",
|
as: "button",
|
||||||
type: "button",
|
type: "button",
|
||||||
label: ucFirst(other.entity.label),
|
//label: ucFirst(other.entity.label),
|
||||||
|
label: ucFirst(other.reference),
|
||||||
onClick: () => handleClick(relation),
|
onClick: () => handleClick(relation),
|
||||||
active: selected?.other(entity).reference === other.reference,
|
active: selected?.other(entity).reference === other.reference,
|
||||||
badge: relation.type(),
|
badge: relation.type(),
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import { EntityForm } from "ui/modules/data/components/EntityForm";
|
|||||||
import { useEntityForm } from "ui/modules/data/hooks/useEntityForm";
|
import { useEntityForm } from "ui/modules/data/hooks/useEntityForm";
|
||||||
import { s } from "bknd/utils";
|
import { s } from "bknd/utils";
|
||||||
import { notifications } from "@mantine/notifications";
|
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 }) {
|
export function DataEntityCreate({ params }) {
|
||||||
const { $data } = useBkndData();
|
const { $data } = useBkndData();
|
||||||
@@ -23,6 +27,7 @@ export function DataEntityCreate({ params }) {
|
|||||||
} else if (entity.type === "system") {
|
} else if (entity.type === "system") {
|
||||||
return <Message.NotAllowed description={`Entity "${params.entity}" cannot be created.`} />;
|
return <Message.NotAllowed description={`Entity "${params.entity}" cannot be created.`} />;
|
||||||
}
|
}
|
||||||
|
const options = useEntityAdminOptions(entity, "create");
|
||||||
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
useBrowserTitle(["Data", entity.label, "Create"]);
|
useBrowserTitle(["Data", entity.label, "Create"]);
|
||||||
@@ -71,7 +76,16 @@ export function DataEntityCreate({ params }) {
|
|||||||
<AppShell.SectionHeader
|
<AppShell.SectionHeader
|
||||||
right={
|
right={
|
||||||
<>
|
<>
|
||||||
|
{options.actions?.context && (
|
||||||
|
<Dropdown position="bottom-end" items={options.actions.context}>
|
||||||
|
<IconButton Icon={TbDots} />
|
||||||
|
</Dropdown>
|
||||||
|
)}
|
||||||
<Button onClick={goBack}>Cancel</Button>
|
<Button onClick={goBack}>Cancel</Button>
|
||||||
|
{options.actions?.primary?.map(
|
||||||
|
(button, key) =>
|
||||||
|
button && <Button {...button} type="button" key={key} variant="primary" />,
|
||||||
|
)}
|
||||||
<Form.Subscribe
|
<Form.Subscribe
|
||||||
selector={(state) => [state.canSubmit, state.isSubmitting]}
|
selector={(state) => [state.canSubmit, state.isSubmitting]}
|
||||||
children={([canSubmit, isSubmitting]) => (
|
children={([canSubmit, isSubmitting]) => (
|
||||||
@@ -96,6 +110,7 @@ export function DataEntityCreate({ params }) {
|
|||||||
/>
|
/>
|
||||||
</AppShell.SectionHeader>
|
</AppShell.SectionHeader>
|
||||||
<AppShell.Scrollable key={entity.name}>
|
<AppShell.Scrollable key={entity.name}>
|
||||||
|
{options.header}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex flex-row dark:bg-red-950 bg-red-100 p-4">
|
<div className="flex flex-row dark:bg-red-950 bg-red-100 p-4">
|
||||||
<b className="mr-2">Create failed: </b> {error}
|
<b className="mr-2">Create failed: </b> {error}
|
||||||
@@ -110,6 +125,7 @@ export function DataEntityCreate({ params }) {
|
|||||||
action="create"
|
action="create"
|
||||||
className="flex flex-grow flex-col gap-3 p-3"
|
className="flex flex-grow flex-col gap-3 p-3"
|
||||||
/>
|
/>
|
||||||
|
{options.footer}
|
||||||
</AppShell.Scrollable>
|
</AppShell.Scrollable>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { useCreateUserModal } from "ui/modules/auth/hooks/use-create-user-modal"
|
|||||||
import { EntityTable2 } from "ui/modules/data/components/EntityTable2";
|
import { EntityTable2 } from "ui/modules/data/components/EntityTable2";
|
||||||
import { s } from "bknd/utils";
|
import { s } from "bknd/utils";
|
||||||
import { pick } from "core/utils/objects";
|
import { pick } from "core/utils/objects";
|
||||||
|
import { useEntityAdminOptions } from "ui/options";
|
||||||
|
|
||||||
const searchSchema = s.partialObject({
|
const searchSchema = s.partialObject({
|
||||||
...pick(repoQuery.properties, ["select", "where", "sort"]),
|
...pick(repoQuery.properties, ["select", "where", "sort"]),
|
||||||
@@ -36,6 +37,7 @@ function DataEntityListImpl({ params }) {
|
|||||||
if (!entity) {
|
if (!entity) {
|
||||||
return <Message.NotFound description={`Entity "${params.entity}" doesn't exist.`} />;
|
return <Message.NotFound description={`Entity "${params.entity}" doesn't exist.`} />;
|
||||||
}
|
}
|
||||||
|
const options = useEntityAdminOptions(entity, "list");
|
||||||
|
|
||||||
useBrowserTitle(["Data", entity?.label ?? params.entity]);
|
useBrowserTitle(["Data", entity?.label ?? params.entity]);
|
||||||
const [navigate] = useNavigate();
|
const [navigate] = useNavigate();
|
||||||
@@ -100,6 +102,7 @@ function DataEntityListImpl({ params }) {
|
|||||||
<>
|
<>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
items={[
|
items={[
|
||||||
|
...(options.actions?.context ?? []),
|
||||||
{
|
{
|
||||||
label: "Settings",
|
label: "Settings",
|
||||||
onClick: () => navigate(routes.data.schema.entity(entity.name)),
|
onClick: () => navigate(routes.data.schema.entity(entity.name)),
|
||||||
@@ -120,6 +123,10 @@ function DataEntityListImpl({ params }) {
|
|||||||
>
|
>
|
||||||
<IconButton Icon={TbDots} />
|
<IconButton Icon={TbDots} />
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
{options.actions?.primary?.map(
|
||||||
|
(button, key) =>
|
||||||
|
button && <Button variant="primary" {...button} type="button" key={key} />,
|
||||||
|
)}
|
||||||
<EntityCreateButton entity={entity} />
|
<EntityCreateButton entity={entity} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -127,6 +134,7 @@ function DataEntityListImpl({ params }) {
|
|||||||
<AppShell.SectionHeaderTitle>{entity.label}</AppShell.SectionHeaderTitle>
|
<AppShell.SectionHeaderTitle>{entity.label}</AppShell.SectionHeaderTitle>
|
||||||
</AppShell.SectionHeader>
|
</AppShell.SectionHeader>
|
||||||
<AppShell.Scrollable key={entity.name}>
|
<AppShell.Scrollable key={entity.name}>
|
||||||
|
{options.header}
|
||||||
<div className="flex flex-col flex-grow p-3 gap-3">
|
<div className="flex flex-col flex-grow p-3 gap-3">
|
||||||
{/*<div className="w-64">
|
{/*<div className="w-64">
|
||||||
<SearchInput placeholder={`Filter ${entity.label}`} />
|
<SearchInput placeholder={`Filter ${entity.label}`} />
|
||||||
@@ -134,7 +142,7 @@ function DataEntityListImpl({ params }) {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
data-updating={isUpdating ? 1 : undefined}
|
data-updating={isUpdating ? 1 : undefined}
|
||||||
className="data-[updating]:opacity-50 transition-opacity pb-10"
|
className="data-[updating]:opacity-50 transition-opacity"
|
||||||
>
|
>
|
||||||
<EntityTable2
|
<EntityTable2
|
||||||
data={data ?? null}
|
data={data ?? null}
|
||||||
@@ -152,6 +160,7 @@ function DataEntityListImpl({ params }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{options.footer}
|
||||||
</AppShell.Scrollable>
|
</AppShell.Scrollable>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,7 +20,12 @@ const TestRoutes = lazy(() => import("./test"));
|
|||||||
export function Routes({
|
export function Routes({
|
||||||
BkndWrapper,
|
BkndWrapper,
|
||||||
basePath = "",
|
basePath = "",
|
||||||
}: { BkndWrapper: ComponentType<{ children: ReactNode }>; basePath?: string }) {
|
children,
|
||||||
|
}: {
|
||||||
|
BkndWrapper: ComponentType<{ children: ReactNode }>;
|
||||||
|
basePath?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
}) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const ctx = useBkndWindowContext();
|
const ctx = useBkndWindowContext();
|
||||||
const actualBasePath = basePath || ctx.admin_basepath;
|
const actualBasePath = basePath || ctx.admin_basepath;
|
||||||
@@ -44,6 +49,8 @@ export function Routes({
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
|
||||||
<Route path="/" component={RootEmpty} />
|
<Route path="/" component={RootEmpty} />
|
||||||
<Route path="/data" nest>
|
<Route path="/data" nest>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
|
|||||||
Reference in New Issue
Block a user