mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 12:37:20 +00:00
Merge branch 'main' into cp/216-fix-users-link
This commit is contained in:
@@ -1,24 +1,62 @@
|
||||
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 "./options";
|
||||
|
||||
export type BkndAdminConfig = {
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
|
||||
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?: BkndAdminConfig;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export default function Admin({
|
||||
baseUrl: baseUrlOverride,
|
||||
withProvider = false,
|
||||
config: _config = {},
|
||||
children,
|
||||
}: BkndAdminProps) {
|
||||
const { theme } = useTheme();
|
||||
const Provider = ({ children }: any) =>
|
||||
@@ -47,7 +85,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>
|
||||
);
|
||||
|
||||
@@ -1,29 +1,33 @@
|
||||
import type { ModuleConfigs, ModuleSchemas } from "modules";
|
||||
import { getDefaultConfig, getDefaultSchema } from "modules/ModuleManager";
|
||||
import { createContext, startTransition, useContext, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
createContext,
|
||||
startTransition,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useApi } from "ui/client";
|
||||
import { type TSchemaActions, getSchemaActions } from "./schema/actions";
|
||||
import { AppReduced } from "./utils/AppReduced";
|
||||
import { Message } from "ui/components/display/Message";
|
||||
import { useNavigate } from "ui/lib/routes";
|
||||
import type { AdminBkndWindowContext } from "modules/server/AdminController";
|
||||
import type { BkndAdminProps } from "ui/Admin";
|
||||
import type { TPermission } from "auth/authorize/Permission";
|
||||
|
||||
export type BkndAdminOptions = Omit<
|
||||
AdminBkndWindowContext,
|
||||
"user" | "logout_route" | "admin_basepath"
|
||||
> & {
|
||||
admin_basepath?: string;
|
||||
};
|
||||
type BkndContext = {
|
||||
export type BkndContext = {
|
||||
version: number;
|
||||
readonly: boolean;
|
||||
schema: ModuleSchemas;
|
||||
config: ModuleConfigs;
|
||||
permissions: string[];
|
||||
permissions: TPermission[];
|
||||
hasSecrets: boolean;
|
||||
requireSecrets: () => Promise<void>;
|
||||
actions: ReturnType<typeof getSchemaActions>;
|
||||
app: AppReduced;
|
||||
options: BkndAdminOptions;
|
||||
options: BkndAdminProps["config"];
|
||||
fallback: boolean;
|
||||
};
|
||||
|
||||
@@ -45,11 +49,16 @@ export function BkndProvider({
|
||||
includeSecrets?: boolean;
|
||||
children: any;
|
||||
fallback?: React.ReactNode;
|
||||
options?: BkndAdminOptions;
|
||||
options?: BkndAdminProps["config"];
|
||||
}) {
|
||||
const [withSecrets, setWithSecrets] = useState<boolean>(includeSecrets);
|
||||
const [schema, setSchema] =
|
||||
useState<Pick<BkndContext, "version" | "schema" | "config" | "permissions" | "fallback">>();
|
||||
useState<
|
||||
Pick<
|
||||
BkndContext,
|
||||
"version" | "schema" | "config" | "permissions" | "fallback" | "readonly"
|
||||
>
|
||||
>();
|
||||
const [fetched, setFetched] = useState(false);
|
||||
const [error, setError] = useState<boolean>();
|
||||
const errorShown = useRef<boolean>(false);
|
||||
@@ -98,6 +107,7 @@ export function BkndProvider({
|
||||
? res.body
|
||||
: ({
|
||||
version: 0,
|
||||
mode: "db",
|
||||
schema: getDefaultSchema(),
|
||||
config: getDefaultConfig(),
|
||||
permissions: [],
|
||||
@@ -113,11 +123,14 @@ export function BkndProvider({
|
||||
fetching.current = Fetching.None;
|
||||
};
|
||||
|
||||
if ("startViewTransition" in document) {
|
||||
// disable view transitions for now
|
||||
// because it causes browser crash on heavy pages (e.g. schema)
|
||||
commit();
|
||||
/* if ("startViewTransition" in document) {
|
||||
document.startViewTransition(commit);
|
||||
} else {
|
||||
commit();
|
||||
}
|
||||
} */
|
||||
});
|
||||
}
|
||||
|
||||
@@ -166,11 +179,16 @@ 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 ?? {
|
||||
admin_basepath: "/",
|
||||
basepath: "/",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function SchemaEditable({ children }: { children: ReactNode }) {
|
||||
const { readonly } = useBknd();
|
||||
return !readonly ? children : null;
|
||||
}
|
||||
|
||||
@@ -53,9 +53,7 @@ export const ClientProvider = ({
|
||||
[JSON.stringify(apiProps)],
|
||||
);
|
||||
|
||||
const [authState, setAuthState] = useState<Partial<AuthState> | undefined>(
|
||||
apiProps.user ? api.getAuthState() : undefined,
|
||||
);
|
||||
const [authState, setAuthState] = useState<Partial<AuthState> | undefined>(api.getAuthState());
|
||||
|
||||
return (
|
||||
<ClientContext.Provider value={{ baseUrl: api.baseUrl, api, authState }}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Api } from "Api";
|
||||
import { FetchPromise, type ModuleApi, type ResponseObject } from "modules/ModuleApi";
|
||||
import useSWR, { type SWRConfiguration, useSWRConfig } from "swr";
|
||||
import useSWR, { type SWRConfiguration, useSWRConfig, type Middleware, type SWRHook } from "swr";
|
||||
import useSWRInfinite from "swr/infinite";
|
||||
import { useApi } from "ui/client";
|
||||
import { useState } from "react";
|
||||
@@ -35,7 +35,7 @@ export const useApiInfiniteQuery = <
|
||||
RefineFn extends (data: ResponseObject<Data>) => unknown = (data: ResponseObject<Data>) => Data,
|
||||
>(
|
||||
fn: (api: Api, page: number) => FetchPromise<Data>,
|
||||
options?: SWRConfiguration & { refine?: RefineFn },
|
||||
options?: SWRConfiguration & { refine?: RefineFn; pageSize?: number },
|
||||
) => {
|
||||
const [endReached, setEndReached] = useState(false);
|
||||
const api = useApi();
|
||||
@@ -47,7 +47,7 @@ export const useApiInfiniteQuery = <
|
||||
// @ts-ignore
|
||||
const swr = useSWRInfinite<RefinedData>(
|
||||
(index, previousPageData: any) => {
|
||||
if (previousPageData && !previousPageData.length) {
|
||||
if (index > 0 && previousPageData && previousPageData.length < (options?.pageSize ?? 0)) {
|
||||
setEndReached(true);
|
||||
return null; // reached the end
|
||||
}
|
||||
@@ -89,3 +89,25 @@ export const useInvalidate = (options?: { exact?: boolean }) => {
|
||||
return mutate((k) => typeof k === "string" && k.startsWith(key));
|
||||
};
|
||||
};
|
||||
|
||||
const mountOnceCache = new Map<string, any>();
|
||||
|
||||
/**
|
||||
* Simple middleware to only load on first mount.
|
||||
*/
|
||||
export const mountOnce: Middleware = (useSWRNext: SWRHook) => (key, fetcher, config) => {
|
||||
if (typeof key === "string") {
|
||||
if (mountOnceCache.has(key)) {
|
||||
return useSWRNext(key, fetcher, {
|
||||
...config,
|
||||
revalidateOnMount: false,
|
||||
});
|
||||
}
|
||||
const swr = useSWRNext(key, fetcher, config);
|
||||
if (swr.data) {
|
||||
mountOnceCache.set(key, true);
|
||||
}
|
||||
return swr;
|
||||
}
|
||||
return useSWRNext(key, fetcher, config);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import type { DB, PrimaryFieldType, EntityData, RepoQueryIn } from "bknd";
|
||||
import type {
|
||||
DB,
|
||||
PrimaryFieldType,
|
||||
EntityData,
|
||||
RepoQueryIn,
|
||||
RepositoryResult,
|
||||
ResponseObject,
|
||||
ModuleApi,
|
||||
} from "bknd";
|
||||
import { objectTransform, encodeSearch } from "bknd/utils";
|
||||
import type { RepositoryResult } from "data/entities";
|
||||
import type { Insertable, Selectable, Updateable } from "kysely";
|
||||
import type { FetchPromise, ModuleApi, ResponseObject } from "modules/ModuleApi";
|
||||
import useSWR, { type SWRConfiguration, type SWRResponse, mutate } from "swr";
|
||||
import { type Api, useApi } from "ui/client";
|
||||
|
||||
@@ -108,7 +114,7 @@ export function makeKey(
|
||||
);
|
||||
}
|
||||
|
||||
interface UseEntityQueryReturn<
|
||||
export interface UseEntityQueryReturn<
|
||||
Entity extends keyof DB | string,
|
||||
Id extends PrimaryFieldType | undefined = undefined,
|
||||
Data = Entity extends keyof DB ? Selectable<DB[Entity]> : EntityData,
|
||||
@@ -136,11 +142,11 @@ export const useEntityQuery = <
|
||||
const fetcher = () => read(query ?? {});
|
||||
|
||||
type T = Awaited<ReturnType<typeof fetcher>>;
|
||||
const swr = useSWR<T>(options?.enabled === false ? null : key, fetcher as any, {
|
||||
const swr = useSWR(options?.enabled === false ? null : key, fetcher as any, {
|
||||
revalidateOnFocus: false,
|
||||
keepPreviousData: true,
|
||||
...options,
|
||||
});
|
||||
}) as ReturnType<typeof useSWR<T>>;
|
||||
|
||||
const mutateFn = async (id?: PrimaryFieldType) => {
|
||||
const entityKey = makeKey(api, entity as string, id);
|
||||
@@ -156,6 +162,7 @@ export const useEntityQuery = <
|
||||
|
||||
// mutate all keys of entity by default
|
||||
if (options?.revalidateOnMutate !== false) {
|
||||
// don't use the id, to also update lists
|
||||
await mutateFn();
|
||||
}
|
||||
return res;
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { BkndProvider, type BkndAdminOptions, useBknd } from "./BkndProvider";
|
||||
export { BkndProvider, type BkndContext, useBknd, SchemaEditable } from "./BkndProvider";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type NotificationData, notifications } from "@mantine/notifications";
|
||||
import type { Api } from "Api";
|
||||
import { ucFirst } from "core/utils";
|
||||
import { ucFirst } from "bknd/utils";
|
||||
import type { ModuleConfigs } from "modules";
|
||||
import type { ResponseObject } from "modules/ModuleApi";
|
||||
import type { ConfigUpdateResponse } from "modules/server/SystemController";
|
||||
|
||||
@@ -16,8 +16,8 @@ type UseAuth = {
|
||||
verified: boolean;
|
||||
login: (data: LoginData) => Promise<AuthResponse>;
|
||||
register: (data: LoginData) => Promise<AuthResponse>;
|
||||
logout: () => void;
|
||||
verify: () => void;
|
||||
logout: () => Promise<void>;
|
||||
verify: () => Promise<void>;
|
||||
setToken: (token: string) => void;
|
||||
};
|
||||
|
||||
@@ -42,12 +42,13 @@ export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
api.updateToken(undefined);
|
||||
invalidate();
|
||||
await api.auth.logout();
|
||||
await invalidate();
|
||||
}
|
||||
|
||||
async function verify() {
|
||||
await api.verifyAuth();
|
||||
await invalidate();
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -49,7 +49,7 @@ export function useBkndAuth() {
|
||||
has_admin: Object.entries(config.auth.roles ?? {}).some(
|
||||
([name, role]) =>
|
||||
role.implicit_allow ||
|
||||
minimum_permissions.every((p) => role.permissions?.includes(p)),
|
||||
minimum_permissions.every((p) => role.permissions?.some((p) => p.permission === p)),
|
||||
),
|
||||
},
|
||||
routes: {
|
||||
|
||||
@@ -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,11 +20,7 @@ export class AppReduced {
|
||||
|
||||
constructor(
|
||||
protected appJson: AppType,
|
||||
protected _options: BkndAdminOptions & { basepath?: string } = {
|
||||
basepath: "/",
|
||||
admin_basepath: "",
|
||||
logo_return_path: "/",
|
||||
},
|
||||
protected _options: BkndAdminProps["config"] = {},
|
||||
) {
|
||||
//console.log("received appjson", _options);
|
||||
|
||||
|
||||
@@ -5,13 +5,15 @@ import { twMerge } from "tailwind-merge";
|
||||
import { Link } from "ui/components/wouter/Link";
|
||||
|
||||
const sizes = {
|
||||
smaller: "px-1.5 py-1 rounded-md gap-1 !text-xs",
|
||||
small: "px-2 py-1.5 rounded-md gap-1 text-sm",
|
||||
default: "px-3 py-2.5 rounded-md gap-1.5",
|
||||
large: "px-4 py-3 rounded-md gap-2.5 text-lg",
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
small: 12,
|
||||
smaller: 12,
|
||||
small: 14,
|
||||
default: 16,
|
||||
large: 20,
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ interface IconButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
}
|
||||
|
||||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
({ Icon, size, variant = "ghost", onClick, disabled, iconProps, ...rest }, ref) => {
|
||||
({ Icon, size, variant = "ghost", onClick, disabled, iconProps, tabIndex, ...rest }, ref) => {
|
||||
const style = styles[size ?? "md"];
|
||||
|
||||
return (
|
||||
@@ -36,6 +36,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
className={twMerge(style.className, rest.className)}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
tabIndex={tabIndex}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
75
app/src/ui/components/code/CodePreview.tsx
Normal file
75
app/src/ui/components/code/CodePreview.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTheme } from "ui/client/use-theme";
|
||||
import { cn, importDynamicBrowserModule } from "ui/lib/utils";
|
||||
|
||||
export type CodePreviewProps = {
|
||||
code: string;
|
||||
className?: string;
|
||||
lang?: string;
|
||||
theme?: string;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export const CodePreview = ({
|
||||
code,
|
||||
className,
|
||||
lang = "typescript",
|
||||
theme: _theme,
|
||||
enabled = true,
|
||||
}: CodePreviewProps) => {
|
||||
const [highlightedHtml, setHighlightedHtml] = useState<string | null>(null);
|
||||
const $theme = useTheme();
|
||||
const theme = (_theme ?? $theme.theme === "dark") ? "github-dark" : "github-light";
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
let cancelled = false;
|
||||
setHighlightedHtml(null);
|
||||
|
||||
async function highlightCode() {
|
||||
try {
|
||||
// Dynamically import Shiki from CDN
|
||||
const { codeToHtml } = await importDynamicBrowserModule(
|
||||
"shiki",
|
||||
"https://esm.sh/shiki@3.13.0",
|
||||
);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
const html = await codeToHtml(code, {
|
||||
lang,
|
||||
theme,
|
||||
structure: "inline",
|
||||
});
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
setHighlightedHtml(html);
|
||||
} catch (error) {
|
||||
console.error("Failed to load Shiki:", error);
|
||||
// Fallback to plain text if Shiki fails to load
|
||||
if (!cancelled) {
|
||||
setHighlightedHtml(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
highlightCode();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [code, enabled]);
|
||||
|
||||
if (!highlightedHtml) {
|
||||
return <pre className={cn("select-text cursor-text", className)}>{code}</pre>;
|
||||
}
|
||||
|
||||
return (
|
||||
<pre
|
||||
className={cn("select-text cursor-text", className)}
|
||||
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,19 +1,68 @@
|
||||
import { Suspense, lazy } from "react";
|
||||
import { Suspense, lazy, useEffect, useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import type { CodeEditorProps } from "./CodeEditor";
|
||||
import { useDebouncedCallback } from "@mantine/hooks";
|
||||
const CodeEditor = lazy(() => import("./CodeEditor"));
|
||||
|
||||
export function JsonEditor({ editable, className, ...props }: CodeEditorProps) {
|
||||
export type JsonEditorProps = Omit<CodeEditorProps, "value" | "onChange"> & {
|
||||
value?: object;
|
||||
onChange?: (value: object) => void;
|
||||
emptyAs?: any;
|
||||
onInvalid?: (error: Error) => void;
|
||||
};
|
||||
|
||||
export function JsonEditor({
|
||||
editable,
|
||||
className,
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
emptyAs = undefined,
|
||||
onInvalid,
|
||||
...props
|
||||
}: JsonEditorProps) {
|
||||
const [editorValue, setEditorValue] = useState<string | null | undefined>(
|
||||
value ? JSON.stringify(value, null, 2) : emptyAs,
|
||||
);
|
||||
const [error, setError] = useState<boolean>(false);
|
||||
const handleChange = useDebouncedCallback((given: string) => {
|
||||
try {
|
||||
setError(false);
|
||||
onChange?.(given ? JSON.parse(given) : emptyAs);
|
||||
} catch (e) {
|
||||
onInvalid?.(e as Error);
|
||||
setError(true);
|
||||
}
|
||||
}, 250);
|
||||
const handleBlur = (e) => {
|
||||
try {
|
||||
const formatted = JSON.stringify(value, null, 2);
|
||||
setEditorValue(formatted);
|
||||
} catch (e) {}
|
||||
|
||||
onBlur?.(e);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorValue) {
|
||||
setEditorValue(value ? JSON.stringify(value, null, 2) : emptyAs);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<CodeEditor
|
||||
className={twMerge(
|
||||
"flex w-full border border-muted",
|
||||
!editable && "opacity-70",
|
||||
error && "border-red-500",
|
||||
className,
|
||||
)}
|
||||
editable={editable}
|
||||
_extensions={{ json: true }}
|
||||
value={editorValue ?? undefined}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
{...props}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -2,6 +2,37 @@ import { TbCopy } from "react-icons/tb";
|
||||
import { JsonView } from "react-json-view-lite";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { IconButton } from "../buttons/IconButton";
|
||||
import ErrorBoundary from "ui/components/display/ErrorBoundary";
|
||||
import { forwardRef, useImperativeHandle, useState } from "react";
|
||||
import { formatNumber } from "bknd/utils";
|
||||
|
||||
export type JsonViewerProps = {
|
||||
json: object | null;
|
||||
title?: string;
|
||||
expand?: number;
|
||||
showSize?: boolean;
|
||||
showCopy?: boolean;
|
||||
copyIconProps?: any;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const style = {
|
||||
basicChildStyle: "pl-5 ml-1 border-l border-muted hover:border-primary/20",
|
||||
container: "ml-[-10px]",
|
||||
label: "text-primary/90 font-bold font-mono mr-2",
|
||||
stringValue:
|
||||
"text-emerald-600 dark:text-emerald-500 font-mono select-text text-wrap whitespace-wrap break-words",
|
||||
numberValue: "text-sky-500 dark:text-sky-400 font-mono select-text",
|
||||
nullValue: "text-zinc-400 font-mono",
|
||||
undefinedValue: "text-zinc-400 font-mono",
|
||||
otherValue: "text-zinc-400 font-mono",
|
||||
booleanValue: "text-orange-500 dark:text-orange-400 font-mono",
|
||||
punctuation: "text-zinc-400 font-bold font-mono m-0.5",
|
||||
collapsedContent: "text-zinc-400 font-mono after:content-['...']",
|
||||
collapseIcon: "text-zinc-400 font-mono font-bold text-lg after:content-['▾'] mr-1.5",
|
||||
expandIcon: "text-zinc-400 font-mono font-bold text-lg after:content-['▸'] mr-1.5",
|
||||
noQuotesForStringValues: false,
|
||||
} as any;
|
||||
|
||||
export const JsonViewer = ({
|
||||
json,
|
||||
@@ -11,16 +42,9 @@ export const JsonViewer = ({
|
||||
showCopy = false,
|
||||
copyIconProps = {},
|
||||
className,
|
||||
}: {
|
||||
json: object;
|
||||
title?: string;
|
||||
expand?: number;
|
||||
showSize?: boolean;
|
||||
showCopy?: boolean;
|
||||
copyIconProps?: any;
|
||||
className?: string;
|
||||
}) => {
|
||||
const size = showSize ? JSON.stringify(json).length : undefined;
|
||||
}: JsonViewerProps) => {
|
||||
const size = showSize ? (!json ? 0 : JSON.stringify(json).length) : undefined;
|
||||
const formattedSize = formatNumber.fileSize(size ?? 0);
|
||||
const showContext = size || title || showCopy;
|
||||
|
||||
function onCopy() {
|
||||
@@ -31,9 +55,10 @@ export const JsonViewer = ({
|
||||
<div className={twMerge("bg-primary/5 py-3 relative overflow-hidden", className)}>
|
||||
{showContext && (
|
||||
<div className="absolute right-4 top-3 font-mono text-zinc-400 flex flex-row gap-2 items-center">
|
||||
{(title || size) && (
|
||||
{(title || size !== undefined) && (
|
||||
<div className="flex flex-row">
|
||||
{title && <span>{title}</span>} {size && <span>({size} Bytes)</span>}
|
||||
{title && <span>{title}</span>}{" "}
|
||||
{size !== undefined && <span>({formattedSize})</span>}
|
||||
</div>
|
||||
)}
|
||||
{showCopy && (
|
||||
@@ -43,30 +68,66 @@ export const JsonViewer = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<JsonView
|
||||
data={json}
|
||||
shouldExpandNode={(level) => level < expand}
|
||||
style={
|
||||
{
|
||||
basicChildStyle: "pl-5 ml-1 border-l border-muted hover:border-primary/20",
|
||||
container: "ml-[-10px]",
|
||||
label: "text-primary/90 font-bold font-mono mr-2",
|
||||
stringValue: "text-emerald-500 font-mono select-text",
|
||||
numberValue: "text-sky-400 font-mono",
|
||||
nullValue: "text-zinc-400 font-mono",
|
||||
undefinedValue: "text-zinc-400 font-mono",
|
||||
otherValue: "text-zinc-400 font-mono",
|
||||
booleanValue: "text-orange-400 font-mono",
|
||||
punctuation: "text-zinc-400 font-bold font-mono m-0.5",
|
||||
collapsedContent: "text-zinc-400 font-mono after:content-['...']",
|
||||
collapseIcon:
|
||||
"text-zinc-400 font-mono font-bold text-lg after:content-['▾'] mr-1.5",
|
||||
expandIcon:
|
||||
"text-zinc-400 font-mono font-bold text-lg after:content-['▸'] mr-1.5",
|
||||
noQuotesForStringValues: false,
|
||||
} as any
|
||||
}
|
||||
/>
|
||||
<ErrorBoundary>
|
||||
<JsonView
|
||||
data={json as any}
|
||||
shouldExpandNode={(level) => level < expand}
|
||||
style={style}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type JsonViewerTabsProps = Omit<JsonViewerProps, "json"> & {
|
||||
selected?: string;
|
||||
tabs: {
|
||||
[key: string]: JsonViewerProps & {
|
||||
enabled?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type JsonViewerTabsRef = {
|
||||
setSelected: (selected: string) => void;
|
||||
};
|
||||
|
||||
export const JsonViewerTabs = forwardRef<JsonViewerTabsRef, JsonViewerTabsProps>(
|
||||
({ tabs: _tabs, ...defaultProps }, ref) => {
|
||||
const tabs = Object.fromEntries(
|
||||
Object.entries(_tabs).filter(([_, v]) => v.enabled !== false),
|
||||
);
|
||||
const [selected, setSelected] = useState(defaultProps.selected ?? Object.keys(tabs)[0]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
setSelected,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col bg-primary/5 rounded-md flex-shrink-0">
|
||||
<div className="flex flex-row gap-4 border-b px-3 border-primary/10 min-w-0">
|
||||
{Object.keys(tabs).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={twMerge(
|
||||
"flex flex-row text-sm cursor-pointer py-3 pt-3.5 px-1 border-b border-transparent -mb-px transition-opacity flex-shrink-0",
|
||||
selected === key ? "border-primary" : "opacity-50 hover:opacity-70",
|
||||
)}
|
||||
onClick={() => setSelected(key)}
|
||||
>
|
||||
<span className="font-mono leading-none truncate">{key}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* @ts-ignore */}
|
||||
<JsonViewer
|
||||
className="bg-transparent overflow-x-auto"
|
||||
{...defaultProps}
|
||||
{...tabs[selected as any]}
|
||||
title={undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ export type EmptyProps = {
|
||||
primary?: ButtonProps;
|
||||
secondary?: ButtonProps;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
export const Empty: React.FC<EmptyProps> = ({
|
||||
Icon = undefined,
|
||||
@@ -16,6 +17,7 @@ export const Empty: React.FC<EmptyProps> = ({
|
||||
primary,
|
||||
secondary,
|
||||
className,
|
||||
children,
|
||||
}) => (
|
||||
<div className={twMerge("flex flex-col h-full w-full justify-center items-center", className)}>
|
||||
<div className="flex flex-col gap-3 items-center max-w-80">
|
||||
@@ -27,6 +29,7 @@ export const Empty: React.FC<EmptyProps> = ({
|
||||
<div className="mt-1.5 flex flex-row gap-2">
|
||||
{secondary && <Button variant="default" {...secondary} />}
|
||||
{primary && <Button variant="primary" {...primary} />}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,7 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
<BaseError>{this.props.fallback}</BaseError>
|
||||
);
|
||||
}
|
||||
return <BaseError>Error1</BaseError>;
|
||||
return <BaseError>{this.state.error?.message ?? "Unknown error"}</BaseError>;
|
||||
}
|
||||
|
||||
override render() {
|
||||
@@ -61,7 +61,7 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
}
|
||||
|
||||
const BaseError = ({ children }: { children: ReactNode }) => (
|
||||
<div className="bg-red-700 text-white py-1 px-2 rounded-md leading-none font-mono">
|
||||
<div className="bg-red-700 text-white py-1 px-2 rounded-md leading-tight font-mono">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,14 @@ const Warning = ({ className, ...props }: IconProps) => (
|
||||
/>
|
||||
);
|
||||
|
||||
const Err = ({ className, ...props }: IconProps) => (
|
||||
<TbAlertCircle
|
||||
{...props}
|
||||
className={twMerge("dark:text-red-300 text-red-700 cursor-help", className)}
|
||||
/>
|
||||
);
|
||||
|
||||
export const Icon = {
|
||||
Warning,
|
||||
Err,
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import clsx from "clsx";
|
||||
import { getBrowser } from "core/utils";
|
||||
import type { Field } from "data/fields";
|
||||
@@ -6,6 +7,7 @@ import {
|
||||
type ComponentPropsWithoutRef,
|
||||
type ElementType,
|
||||
forwardRef,
|
||||
Fragment,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
@@ -26,8 +28,9 @@ export const Group = <E extends ElementType = "div">({
|
||||
return (
|
||||
<Tag
|
||||
{...props}
|
||||
data-role="group"
|
||||
className={twMerge(
|
||||
"flex flex-col gap-1.5",
|
||||
"flex flex-col gap-1.5 w-full",
|
||||
as === "fieldset" && "border border-primary/10 p-3 rounded-md",
|
||||
as === "fieldset" && error && "border-red-500",
|
||||
error && "text-red-500",
|
||||
@@ -66,17 +69,22 @@ export const ErrorMessage: React.FC<React.ComponentProps<"div">> = ({ className,
|
||||
<div {...props} className={twMerge("text-sm text-red-500", className)} />
|
||||
);
|
||||
|
||||
export const FieldLabel: React.FC<React.ComponentProps<"label"> & { field: Field }> = ({
|
||||
field,
|
||||
...props
|
||||
}) => {
|
||||
export const FieldLabel: React.FC<
|
||||
React.ComponentProps<"label"> & { field: Field; label?: string }
|
||||
> = ({ field, label, ...props }) => {
|
||||
const desc = field.getDescription();
|
||||
const Wrapper = desc
|
||||
? (p) => <Tooltip position="right" label={desc} {...p} />
|
||||
: (p) => <Fragment {...p} />;
|
||||
|
||||
return (
|
||||
<Label {...props} title={desc} className="flex flex-row gap-1 items-center">
|
||||
{field.getLabel()}
|
||||
{field.isRequired() && <span className="font-medium opacity-30">*</span>}
|
||||
{desc && <TbInfoCircle className="opacity-50" />}
|
||||
</Label>
|
||||
<Wrapper>
|
||||
<Label {...props} className="flex flex-row gap-1 items-center self-start">
|
||||
{label ?? field.getLabel()}
|
||||
{field.isRequired() && <span className="font-medium opacity-30">*</span>}
|
||||
{desc && <TbInfoCircle className="opacity-50" />}
|
||||
</Label>
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -88,7 +96,7 @@ export const Input = forwardRef<HTMLInputElement, React.ComponentProps<"input">>
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={twMerge(
|
||||
"bg-muted/40 h-11 rounded-md py-2.5 px-4 outline-none w-full",
|
||||
"bg-muted/40 h-11 rounded-md py-2.5 px-4 outline-none w-full disabled:cursor-not-allowed",
|
||||
disabledOrReadonly && "bg-muted/50 text-primary/50",
|
||||
!disabledOrReadonly &&
|
||||
"focus:bg-muted focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all",
|
||||
@@ -103,6 +111,12 @@ export const TypeAwareInput = forwardRef<HTMLInputElement, React.ComponentProps<
|
||||
if (props.type === "password") {
|
||||
return <Password {...props} ref={ref} />;
|
||||
}
|
||||
if ("data-type" in props) {
|
||||
if (props["data-type"] === "textarea") {
|
||||
// @ts-ignore
|
||||
return <Textarea {...props} ref={ref} />;
|
||||
}
|
||||
}
|
||||
|
||||
return <Input {...props} ref={ref} />;
|
||||
},
|
||||
|
||||
@@ -5,7 +5,13 @@ import { twMerge } from "tailwind-merge";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
import { FieldComponent, Field as FormField, type FieldProps as FormFieldProps } from "./Field";
|
||||
import { FormContextOverride, useDerivedFieldContext, useFormError } from "./Form";
|
||||
import {
|
||||
FormContextOverride,
|
||||
useDerivedFieldContext,
|
||||
useFormContext,
|
||||
useFormError,
|
||||
useFormValue,
|
||||
} from "./Form";
|
||||
import { getLabel, getMultiSchemaMatched } from "./utils";
|
||||
|
||||
export type AnyOfFieldRootProps = {
|
||||
@@ -47,7 +53,17 @@ const Root = ({ path = "", children }: AnyOfFieldRootProps) => {
|
||||
const errors = useFormError(path, { strict: true });
|
||||
if (!schema) return `AnyOfField(${path}): no schema ${pointer}`;
|
||||
const [_selected, setSelected] = useAtom(selectedAtom);
|
||||
const selected = _selected !== null ? _selected : matchedIndex > -1 ? matchedIndex : null;
|
||||
const {
|
||||
options: { anyOfNoneSelectedMode },
|
||||
} = useFormContext();
|
||||
const selected =
|
||||
_selected !== null
|
||||
? _selected
|
||||
: matchedIndex > -1
|
||||
? matchedIndex
|
||||
: anyOfNoneSelectedMode === "first"
|
||||
? 0
|
||||
: null;
|
||||
|
||||
const select = useEvent((index: number | null) => {
|
||||
setValue(path, index !== null ? lib.getTemplate(undefined, schemas[index]) : undefined);
|
||||
@@ -117,15 +133,27 @@ const Select = () => {
|
||||
const Field = ({ name, label, ...props }: Partial<FormFieldProps>) => {
|
||||
const { selected, selectedSchema, path, errors } = useAnyOfContext();
|
||||
if (selected === null) return null;
|
||||
|
||||
return (
|
||||
<FormContextOverride prefix={path} schema={selectedSchema}>
|
||||
<div className={twMerge(errors.length > 0 && "bg-red-500/10")}>
|
||||
<FormField key={`${path}_${selected}`} name={""} label={false} {...props} />
|
||||
{/* another wrap is required for primitive schemas */}
|
||||
<AnotherField key={`${path}_${selected}`} label={false} {...props} />
|
||||
</div>
|
||||
</FormContextOverride>
|
||||
);
|
||||
};
|
||||
|
||||
const AnotherField = (props: Partial<FormFieldProps>) => {
|
||||
const { value } = useFormValue("");
|
||||
|
||||
const inputProps = {
|
||||
// @todo: check, potentially just provide value
|
||||
value: ["string", "number", "boolean"].includes(typeof value) ? value : undefined,
|
||||
};
|
||||
return <FormField name={""} label={false} {...props} inputProps={inputProps} />;
|
||||
};
|
||||
|
||||
export const AnyOf = {
|
||||
Root,
|
||||
Select,
|
||||
|
||||
@@ -5,19 +5,29 @@ import { Button } from "ui/components/buttons/Button";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { Dropdown } from "ui/components/overlay/Dropdown";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
import { FieldComponent } from "./Field";
|
||||
import { FieldWrapper } from "./FieldWrapper";
|
||||
import { useDerivedFieldContext, useFormValue } from "./Form";
|
||||
import { Field, FieldComponent, type FieldProps } from "./Field";
|
||||
import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper";
|
||||
import { FormContextOverride, useDerivedFieldContext, useFormValue } from "./Form";
|
||||
import { coerce, getMultiSchema, getMultiSchemaMatched, isEqual, suffixPath } from "./utils";
|
||||
|
||||
export const ArrayField = ({ path = "" }: { path?: string }) => {
|
||||
export type ArrayFieldProps = {
|
||||
path?: string;
|
||||
labelAdd?: string;
|
||||
wrapperProps?: Omit<FieldwrapperProps, "name" | "children">;
|
||||
};
|
||||
|
||||
export const ArrayField = ({
|
||||
path = "",
|
||||
labelAdd = "Add",
|
||||
wrapperProps = { wrapper: "fieldset" },
|
||||
}: ArrayFieldProps) => {
|
||||
const { setValue, pointer, required, schema, ...ctx } = useDerivedFieldContext(path);
|
||||
if (!schema || typeof schema === "undefined") return `ArrayField(${path}): no schema ${pointer}`;
|
||||
|
||||
// if unique items with enum
|
||||
if (schema.uniqueItems && typeof schema.items === "object" && "enum" in schema.items) {
|
||||
return (
|
||||
<FieldWrapper name={path} schema={schema} wrapper="fieldset">
|
||||
<FieldWrapper {...wrapperProps} name={path} schema={schema}>
|
||||
<FieldComponent
|
||||
required
|
||||
name={path}
|
||||
@@ -35,7 +45,7 @@ export const ArrayField = ({ path = "" }: { path?: string }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<FieldWrapper name={path} schema={schema} wrapper="fieldset">
|
||||
<FieldWrapper {...wrapperProps} name={path} schema={schema}>
|
||||
<ArrayIterator name={path}>
|
||||
{({ value }) =>
|
||||
value?.map((v, index: number) => (
|
||||
@@ -44,17 +54,21 @@ export const ArrayField = ({ path = "" }: { path?: string }) => {
|
||||
}
|
||||
</ArrayIterator>
|
||||
<div className="flex flex-row">
|
||||
<ArrayAdd path={path} schema={schema} />
|
||||
<ArrayAdd path={path} schema={schema} label={labelAdd} />
|
||||
</div>
|
||||
</FieldWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const ArrayItem = memo(({ path, index, schema }: any) => {
|
||||
const { value, ...ctx } = useDerivedFieldContext(path, (ctx) => {
|
||||
const {
|
||||
value,
|
||||
path: absolutePath,
|
||||
...ctx
|
||||
} = useDerivedFieldContext(path, (ctx) => {
|
||||
return ctx.value?.[index];
|
||||
});
|
||||
const itemPath = suffixPath(path, index);
|
||||
const itemPath = suffixPath(absolutePath, index);
|
||||
let subschema = schema.items;
|
||||
const itemsMultiSchema = getMultiSchema(schema.items);
|
||||
if (itemsMultiSchema) {
|
||||
@@ -62,10 +76,6 @@ const ArrayItem = memo(({ path, index, schema }: any) => {
|
||||
subschema = _subschema;
|
||||
}
|
||||
|
||||
const handleUpdate = useEvent((pointer: string, value: any) => {
|
||||
ctx.setValue(pointer, value);
|
||||
});
|
||||
|
||||
const handleDelete = useEvent((pointer: string) => {
|
||||
ctx.deleteValue(pointer);
|
||||
});
|
||||
@@ -76,21 +86,26 @@ const ArrayItem = memo(({ path, index, schema }: any) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={itemPath} className="flex flex-row gap-2">
|
||||
<FieldComponent
|
||||
name={itemPath}
|
||||
schema={subschema!}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
handleUpdate(itemPath, coerce(e.target.value, subschema!));
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
{DeleteButton}
|
||||
</div>
|
||||
<FormContextOverride prefix={itemPath} schema={subschema!}>
|
||||
<div className="flex flex-row gap-2 w-full">
|
||||
{/* another wrap is required for primitive schemas */}
|
||||
<AnotherField label={false} />
|
||||
{DeleteButton}
|
||||
</div>
|
||||
</FormContextOverride>
|
||||
);
|
||||
}, isEqual);
|
||||
|
||||
const AnotherField = (props: Partial<FieldProps>) => {
|
||||
const { value } = useFormValue("");
|
||||
|
||||
const inputProps = {
|
||||
// @todo: check, potentially just provide value
|
||||
value: ["string", "number", "boolean"].includes(typeof value) ? value : undefined,
|
||||
};
|
||||
return <Field name={""} label={false} {...props} inputProps={inputProps} />;
|
||||
};
|
||||
|
||||
const ArrayIterator = memo(
|
||||
({ name, children }: any) => {
|
||||
return children(useFormValue(name));
|
||||
@@ -98,19 +113,25 @@ const ArrayIterator = memo(
|
||||
(prev, next) => prev.value?.length === next.value?.length,
|
||||
);
|
||||
|
||||
const ArrayAdd = ({ schema, path }: { schema: JsonSchema; path: string }) => {
|
||||
const ArrayAdd = ({
|
||||
schema,
|
||||
path: _path,
|
||||
label = "Add",
|
||||
}: { schema: JsonSchema; path: string; label?: string }) => {
|
||||
const {
|
||||
setValue,
|
||||
value: { currentIndex },
|
||||
path,
|
||||
...ctx
|
||||
} = useDerivedFieldContext(path, (ctx) => {
|
||||
} = useDerivedFieldContext(_path, (ctx) => {
|
||||
return { currentIndex: ctx.value?.length ?? 0 };
|
||||
});
|
||||
const itemsMultiSchema = getMultiSchema(schema.items);
|
||||
const options = { addOptionalProps: true };
|
||||
|
||||
function handleAdd(template?: any) {
|
||||
const newPath = suffixPath(path, currentIndex);
|
||||
setValue(newPath, template ?? ctx.lib.getTemplate(undefined, schema!.items));
|
||||
setValue(newPath, template ?? ctx.lib.getTemplate(undefined, schema!.items, options));
|
||||
}
|
||||
|
||||
if (itemsMultiSchema) {
|
||||
@@ -121,14 +142,14 @@ const ArrayAdd = ({ schema, path }: { schema: JsonSchema; path: string }) => {
|
||||
}}
|
||||
items={itemsMultiSchema.map((s, i) => ({
|
||||
label: s!.title ?? `Option ${i + 1}`,
|
||||
onClick: () => handleAdd(ctx.lib.getTemplate(undefined, s!)),
|
||||
onClick: () => handleAdd(ctx.lib.getTemplate(undefined, s!, options)),
|
||||
}))}
|
||||
onClickItem={console.log}
|
||||
>
|
||||
<Button IconLeft={IconLibraryPlus}>Add</Button>
|
||||
<Button IconLeft={IconLibraryPlus}>{label}</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
return <Button onClick={() => handleAdd()}>Add</Button>;
|
||||
return <Button onClick={() => handleAdd()}>{label}</Button>;
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ import * as Formy from "ui/components/form/Formy";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
import { ArrayField } from "./ArrayField";
|
||||
import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper";
|
||||
import { useDerivedFieldContext, useFormValue } from "./Form";
|
||||
import { useDerivedFieldContext, useFormValue, type DeriveFn } from "./Form";
|
||||
import { ObjectField } from "./ObjectField";
|
||||
import { coerce, firstDefined, isType, isTypeSchema } from "./utils";
|
||||
|
||||
@@ -72,7 +72,7 @@ const FieldImpl = ({
|
||||
);
|
||||
|
||||
if (isType(schema.type, "object")) {
|
||||
return <ObjectField path={name} />;
|
||||
return <ObjectField path={name} wrapperProps={props} />;
|
||||
}
|
||||
|
||||
if (isType(schema.type, "array")) {
|
||||
@@ -88,6 +88,7 @@ const FieldImpl = ({
|
||||
}, [inputProps?.defaultValue]);
|
||||
|
||||
const disabled = firstDefined(
|
||||
ctx.readOnly,
|
||||
inputProps?.disabled,
|
||||
props.disabled,
|
||||
schema.readOnly,
|
||||
@@ -107,13 +108,13 @@ const FieldImpl = ({
|
||||
return (
|
||||
<FieldWrapper name={name} required={required} schema={schema} fieldId={id} {...props}>
|
||||
<FieldComponent
|
||||
placeholder={placeholder}
|
||||
{...inputProps}
|
||||
id={id}
|
||||
schema={schema}
|
||||
name={name}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
onChange={onChange ?? handleChange}
|
||||
/>
|
||||
</FieldWrapper>
|
||||
@@ -130,7 +131,7 @@ export type FieldComponentProps = {
|
||||
schema: JsonSchema;
|
||||
render?: (props: Omit<FieldComponentProps, "render">) => ReactNode;
|
||||
"data-testId"?: string;
|
||||
} & ComponentPropsWithoutRef<"input">;
|
||||
} & ComponentPropsWithoutRef<"input"> & { [key: `data-${string}`]: string };
|
||||
|
||||
export const FieldComponent = ({ schema, render, ..._props }: FieldComponentProps) => {
|
||||
const { value } = useFormValue(_props.name!, { strict: true });
|
||||
@@ -139,10 +140,11 @@ export const FieldComponent = ({ schema, render, ..._props }: FieldComponentProp
|
||||
..._props,
|
||||
// allow override
|
||||
value: typeof _props.value !== "undefined" ? _props.value : value,
|
||||
placeholder:
|
||||
(_props.placeholder ?? typeof schema.default !== "undefined")
|
||||
? String(schema.default)
|
||||
: "",
|
||||
placeholder: _props.placeholder
|
||||
? _props.placeholder
|
||||
: typeof schema.default !== "undefined"
|
||||
? String(schema.default)
|
||||
: "",
|
||||
};
|
||||
|
||||
if (render) return render({ schema, ...props });
|
||||
@@ -201,3 +203,28 @@ export const FieldComponent = ({ schema, render, ..._props }: FieldComponentProp
|
||||
|
||||
return <Formy.TypeAwareInput {...props} value={props.value ?? ""} {...additional} />;
|
||||
};
|
||||
|
||||
export type CustomFieldProps<Data = any> = {
|
||||
path: string;
|
||||
valueStrict?: boolean;
|
||||
deriveFn?: DeriveFn<Data>;
|
||||
children: (
|
||||
props: Omit<ReturnType<typeof useDerivedFieldContext<Data>>, "setValue"> &
|
||||
ReturnType<typeof useFormValue> & {
|
||||
setValue: (value: any) => void;
|
||||
_setValue: (path: string, value: any) => void;
|
||||
},
|
||||
) => React.ReactNode;
|
||||
};
|
||||
|
||||
export function CustomField<Data = any>({
|
||||
path: _path,
|
||||
valueStrict = true,
|
||||
deriveFn,
|
||||
children,
|
||||
}: CustomFieldProps<Data>) {
|
||||
const ctx = useDerivedFieldContext(_path, deriveFn);
|
||||
const $value = useFormValue(_path, { strict: valueStrict });
|
||||
const setValue = (value: any) => ctx.setValue(ctx.path, value);
|
||||
return children({ ...ctx, ...$value, setValue, _setValue: ctx.setValue });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IconBug } from "@tabler/icons-react";
|
||||
import { IconBug, IconInfoCircle } from "@tabler/icons-react";
|
||||
import type { JsonSchema } from "json-schema-library";
|
||||
import { Children, type ReactElement, type ReactNode, cloneElement, isValidElement } from "react";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
} from "ui/components/form/json-schema-form/Form";
|
||||
import { Popover } from "ui/components/overlay/Popover";
|
||||
import { getLabel } from "./utils";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Tooltip } from "@mantine/core";
|
||||
|
||||
export type FieldwrapperProps = {
|
||||
name: string;
|
||||
@@ -23,8 +25,9 @@ export type FieldwrapperProps = {
|
||||
children: ReactElement | ReactNode;
|
||||
errorPlacement?: "top" | "bottom";
|
||||
description?: string;
|
||||
descriptionPlacement?: "top" | "bottom";
|
||||
descriptionPlacement?: "top" | "bottom" | "label";
|
||||
fieldId?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function FieldWrapper({
|
||||
@@ -38,6 +41,7 @@ export function FieldWrapper({
|
||||
descriptionPlacement = "bottom",
|
||||
children,
|
||||
fieldId,
|
||||
className,
|
||||
...props
|
||||
}: FieldwrapperProps) {
|
||||
const errors = useFormError(name, { strict: true });
|
||||
@@ -50,17 +54,23 @@ export function FieldWrapper({
|
||||
<Formy.ErrorMessage>{errors.map((e) => e.message).join(", ")}</Formy.ErrorMessage>
|
||||
);
|
||||
|
||||
const Description = description && (
|
||||
<Formy.Help className={descriptionPlacement === "top" ? "-mt-1 mb-1" : "mb-2"}>
|
||||
{description}
|
||||
</Formy.Help>
|
||||
);
|
||||
const Description = description ? (
|
||||
["top", "bottom"].includes(descriptionPlacement) ? (
|
||||
<Formy.Help className={descriptionPlacement === "top" ? "-mt-1 mb-1" : "mb-2"}>
|
||||
{description}
|
||||
</Formy.Help>
|
||||
) : (
|
||||
<Tooltip label={description}>
|
||||
<IconInfoCircle className="size-4 opacity-50" />
|
||||
</Tooltip>
|
||||
)
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Formy.Group
|
||||
error={errors.length > 0}
|
||||
as={wrapper === "fieldset" ? "fieldset" : "div"}
|
||||
className={hidden ? "hidden" : "relative"}
|
||||
className={twMerge(hidden ? "hidden" : "relative", className)}
|
||||
>
|
||||
{errorPlacement === "top" && Errors}
|
||||
<FieldDebug name={name} schema={schema} required={required} />
|
||||
@@ -69,14 +79,15 @@ export function FieldWrapper({
|
||||
<Formy.Label
|
||||
as={wrapper === "fieldset" ? "legend" : "label"}
|
||||
htmlFor={fieldId}
|
||||
className="self-start"
|
||||
className="self-start flex flex-row gap-1 items-center"
|
||||
>
|
||||
{label} {required && <span className="font-medium opacity-30">*</span>}
|
||||
{descriptionPlacement === "label" && Description}
|
||||
</Formy.Label>
|
||||
)}
|
||||
{descriptionPlacement === "top" && Description}
|
||||
|
||||
<div className="flex flex-row gap-2">
|
||||
<div className="flex flex-row flex-grow gap-2">
|
||||
<div className="flex flex-1 flex-col gap-3">
|
||||
{Children.count(children) === 1 && isValidElement(children)
|
||||
? cloneElement(children, {
|
||||
@@ -130,7 +141,7 @@ const FieldDebug = ({
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<IconButton Icon={IconBug} size="xs" className="opacity-30" />
|
||||
<IconButton Icon={IconBug} size="xs" className="opacity-30" tabIndex={-1} />
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -46,6 +46,7 @@ type FormState<Data = any> = {
|
||||
type FormOptions = {
|
||||
debug?: boolean;
|
||||
keepEmpty?: boolean;
|
||||
anyOfNoneSelectedMode?: "none" | "first";
|
||||
};
|
||||
|
||||
export type FormContext<Data> = {
|
||||
@@ -60,6 +61,7 @@ export type FormContext<Data> = {
|
||||
options: FormOptions;
|
||||
root: string;
|
||||
_formStateAtom: PrimitiveAtom<FormState<Data>>;
|
||||
readOnly: boolean;
|
||||
};
|
||||
|
||||
const FormContext = createContext<FormContext<any>>(undefined!);
|
||||
@@ -78,20 +80,24 @@ export function Form<
|
||||
onInvalidSubmit,
|
||||
validateOn = "submit",
|
||||
hiddenSubmit = true,
|
||||
beforeSubmit,
|
||||
ignoreKeys = [],
|
||||
options = {},
|
||||
readOnly = false,
|
||||
...props
|
||||
}: Omit<ComponentPropsWithoutRef<"form">, "onChange" | "onSubmit"> & {
|
||||
schema: Schema;
|
||||
validateOn?: "change" | "submit";
|
||||
initialOpts?: LibTemplateOptions;
|
||||
ignoreKeys?: string[];
|
||||
onChange?: (data: Partial<Data>, name: string, value: any) => void;
|
||||
onChange?: (data: Partial<Data>, name: string, value: any, context: FormContext<Data>) => void;
|
||||
beforeSubmit?: (data: Data) => Data;
|
||||
onSubmit?: (data: Data) => void | Promise<void>;
|
||||
onInvalidSubmit?: (errors: JsonError[], data: Partial<Data>) => void;
|
||||
hiddenSubmit?: boolean;
|
||||
options?: FormOptions;
|
||||
initialValues?: Schema extends JSONSchema ? FromSchema<Schema> : never;
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
const [schema, initial] = omitSchema(_schema, ignoreKeys, _initialValues);
|
||||
const lib = useMemo(() => new Draft2019(schema), [JSON.stringify(schema)]);
|
||||
@@ -108,7 +114,7 @@ export function Form<
|
||||
const formRef = useRef<HTMLFormElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialValues) {
|
||||
if (initialValues && validateOn === "change") {
|
||||
validate();
|
||||
}
|
||||
}, [initialValues]);
|
||||
@@ -124,7 +130,7 @@ export function Form<
|
||||
if (errors.length === 0) {
|
||||
await onSubmit(data as Data);
|
||||
} else {
|
||||
console.log("invalid", errors);
|
||||
console.error("form: invalid", { data, errors });
|
||||
onInvalidSubmit?.(errors, data);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -143,7 +149,7 @@ export function Form<
|
||||
setFormState((state) => {
|
||||
const prev = state.data;
|
||||
const changed = immutable.set(prev, path, value);
|
||||
onChange?.(changed, path, value);
|
||||
onChange?.(changed, path, value, context);
|
||||
return { ...state, data: changed };
|
||||
});
|
||||
check();
|
||||
@@ -153,7 +159,7 @@ export function Form<
|
||||
setFormState((state) => {
|
||||
const prev = state.data;
|
||||
const changed = immutable.del(prev, path);
|
||||
onChange?.(changed, path, undefined);
|
||||
onChange?.(changed, path, undefined, context);
|
||||
return { ...state, data: changed };
|
||||
});
|
||||
check();
|
||||
@@ -173,7 +179,8 @@ export function Form<
|
||||
});
|
||||
|
||||
const validate = useEvent((_data?: Partial<Data>) => {
|
||||
const actual = _data ?? getCurrentState()?.data;
|
||||
const before = beforeSubmit ?? ((a: any) => a);
|
||||
const actual = before((_data as any) ?? getCurrentState()?.data);
|
||||
const errors = lib.validate(actual, schema);
|
||||
setFormState((prev) => ({ ...prev, errors }));
|
||||
return { data: actual, errors };
|
||||
@@ -189,8 +196,9 @@ export function Form<
|
||||
options,
|
||||
root: "",
|
||||
path: "",
|
||||
readOnly,
|
||||
}),
|
||||
[schema, initialValues],
|
||||
[schema, initialValues, options, readOnly],
|
||||
) as any;
|
||||
|
||||
return (
|
||||
@@ -295,19 +303,20 @@ export function useFormStateSelector<Data = any, Reduced = Data>(
|
||||
return useAtom(selected)[0];
|
||||
}
|
||||
|
||||
type SelectorFn<Ctx = any, Refined = any> = (state: Ctx) => Refined;
|
||||
export type SelectorFn<Ctx = any, Refined = any> = (state: Ctx) => Refined;
|
||||
export type DeriveFn<Data = any, Reduced = undefined> = SelectorFn<
|
||||
FormContext<Data> & {
|
||||
pointer: string;
|
||||
required: boolean;
|
||||
value: any;
|
||||
path: string;
|
||||
},
|
||||
Reduced
|
||||
>;
|
||||
|
||||
export function useDerivedFieldContext<Data = any, Reduced = undefined>(
|
||||
path,
|
||||
deriveFn?: SelectorFn<
|
||||
FormContext<Data> & {
|
||||
pointer: string;
|
||||
required: boolean;
|
||||
value: any;
|
||||
path: string;
|
||||
},
|
||||
Reduced
|
||||
>,
|
||||
deriveFn?: DeriveFn<Data, Reduced>,
|
||||
_schema?: JSONSchema,
|
||||
): FormContext<Data> & {
|
||||
value: Reduced;
|
||||
@@ -372,5 +381,5 @@ export function FormDebug({ force = false }: { force?: boolean }) {
|
||||
if (options?.debug !== true && force !== true) return null;
|
||||
const ctx = useFormStateSelector((s) => s);
|
||||
|
||||
return <JsonViewer json={ctx} expand={99} />;
|
||||
return <JsonViewer json={ctx} expand={99} showCopy />;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { isTypeSchema } from "ui/components/form/json-schema-form/utils";
|
||||
import { AnyOfField } from "./AnyOfField";
|
||||
import { Field } from "./Field";
|
||||
import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper";
|
||||
import { type JSONSchema, useDerivedFieldContext } from "./Form";
|
||||
import { type JSONSchema, useDerivedFieldContext, useFormValue } from "./Form";
|
||||
import { JsonEditor } from "ui/components/code/JsonEditor";
|
||||
|
||||
export type ObjectFieldProps = {
|
||||
path?: string;
|
||||
@@ -11,7 +12,7 @@ export type ObjectFieldProps = {
|
||||
};
|
||||
|
||||
export const ObjectField = ({ path = "", label: _label, wrapperProps = {} }: ObjectFieldProps) => {
|
||||
const { schema, ...ctx } = useDerivedFieldContext(path);
|
||||
const { schema } = useDerivedFieldContext(path);
|
||||
if (!isTypeSchema(schema)) return `ObjectField "${path}": no schema`;
|
||||
const properties = Object.entries(schema.properties ?? {}) as [string, JSONSchema][];
|
||||
|
||||
@@ -24,7 +25,7 @@ export const ObjectField = ({ path = "", label: _label, wrapperProps = {} }: Obj
|
||||
{...wrapperProps}
|
||||
>
|
||||
{properties.length === 0 ? (
|
||||
<i className="opacity-50">No properties</i>
|
||||
<ObjectJsonField path={path} />
|
||||
) : (
|
||||
properties.map(([prop, schema]) => {
|
||||
const name = [path, prop].filter(Boolean).join(".");
|
||||
@@ -40,3 +41,9 @@ export const ObjectField = ({ path = "", label: _label, wrapperProps = {} }: Obj
|
||||
</FieldWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const ObjectJsonField = ({ path }: { path: string }) => {
|
||||
const { value } = useFormValue(path);
|
||||
const { setValue, path: absolutePath } = useDerivedFieldContext(path);
|
||||
return <JsonEditor value={value} onChange={(value) => setValue(absolutePath, value)} />;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { autoFormatString, omitKeys } from "core/utils";
|
||||
import { autoFormatString, omitKeys } from "bknd/utils";
|
||||
import { type Draft, Draft2019, type JsonSchema } from "json-schema-library";
|
||||
import type { JSONSchema } from "json-schema-to-ts";
|
||||
import type { JSONSchemaType } from "json-schema-to-ts/lib/types/definitions/jsonSchema";
|
||||
|
||||
export { isEqual, getPath } from "core/utils/objects";
|
||||
export { isEqual, getPath } from "bknd/utils";
|
||||
|
||||
export function isNotDefined(value: any) {
|
||||
return value === null || value === undefined || value === "";
|
||||
@@ -62,20 +62,30 @@ export function getParentPointer(pointer: string) {
|
||||
}
|
||||
|
||||
export function isRequired(lib: Draft, pointer: string, schema: JsonSchema, data?: any) {
|
||||
if (pointer === "#/" || !schema) {
|
||||
try {
|
||||
if (pointer === "#/" || !schema) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const childSchema = lib.getSchema({ pointer, data, schema });
|
||||
if (typeof childSchema === "object" && "const" in childSchema) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parentPointer = getParentPointer(pointer);
|
||||
const parentSchema = lib.getSchema({ pointer: parentPointer, data });
|
||||
const l = pointer.split("/").pop();
|
||||
const required = parentSchema?.required?.includes(l);
|
||||
|
||||
return !!required;
|
||||
} catch (e) {
|
||||
console.warn("isRequired", { pointer, schema, data, e });
|
||||
return false;
|
||||
}
|
||||
|
||||
const childSchema = lib.getSchema({ pointer, data, schema });
|
||||
if (typeof childSchema === "object" && "const" in childSchema) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parentPointer = getParentPointer(pointer);
|
||||
const parentSchema = lib.getSchema({ pointer: parentPointer, data });
|
||||
const required = parentSchema?.required?.includes(pointer.split("/").pop()!);
|
||||
|
||||
return !!required;
|
||||
}
|
||||
|
||||
export type IsTypeType =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as s from "jsonv-ts";
|
||||
import { s } from "bknd/utils";
|
||||
|
||||
import type {
|
||||
CustomValidator,
|
||||
|
||||
@@ -10,23 +10,13 @@ export default function JsonField({
|
||||
readonly,
|
||||
...props
|
||||
}: FieldProps) {
|
||||
const value = JSON.stringify(formData, null, 2);
|
||||
|
||||
function handleChange(data) {
|
||||
try {
|
||||
onChange(JSON.parse(data));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
const isDisabled = disabled || readonly;
|
||||
const id = props.idSchema.$id;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label label={props.name} id={id} />
|
||||
<JsonEditor value={value} editable={!isDisabled} onChange={handleChange} />
|
||||
<JsonEditor value={formData} editable={!isDisabled} onChange={onChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export type DataTableProps<Data> = {
|
||||
};
|
||||
|
||||
export function DataTable<Data extends Record<string, any> = Record<string, any>>({
|
||||
data = [],
|
||||
data: _data = [],
|
||||
columns,
|
||||
checkable,
|
||||
onClickRow,
|
||||
@@ -71,11 +71,14 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
|
||||
renderValue,
|
||||
onClickNew,
|
||||
}: DataTableProps<Data>) {
|
||||
const hasTotal = !!total;
|
||||
const data = Array.isArray(_data) ? _data.slice(0, perPage) : _data;
|
||||
total = total || data?.length || 0;
|
||||
page = page || 1;
|
||||
|
||||
const select = columns && columns.length > 0 ? columns : Object.keys(data?.[0] || {});
|
||||
const pages = Math.max(Math.ceil(total / perPage), 1);
|
||||
const hasNext = hasTotal ? pages > page : (_data?.length || 0) > perPage;
|
||||
const CellRender = renderValue || CellValue;
|
||||
|
||||
return (
|
||||
@@ -202,7 +205,7 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
|
||||
perPage={perPage}
|
||||
page={page}
|
||||
items={data?.length || 0}
|
||||
total={total}
|
||||
total={hasTotal ? total : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 md:gap-10 items-center">
|
||||
@@ -222,11 +225,17 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-primary/40">
|
||||
Page {page} of {pages}
|
||||
Page {page}
|
||||
{hasTotal ? <> of {pages}</> : ""}
|
||||
</div>
|
||||
{onClickPage && (
|
||||
<div className="flex flex-row gap-1.5">
|
||||
<TableNav current={page} total={pages} onClick={onClickPage} />
|
||||
<TableNav
|
||||
current={page}
|
||||
total={hasTotal ? pages : page + (hasNext ? 1 : 0)}
|
||||
onClick={onClickPage}
|
||||
hasLast={hasTotal}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -268,17 +277,23 @@ const SortIndicator = ({
|
||||
};
|
||||
|
||||
const TableDisplay = ({ perPage, page, items, total }) => {
|
||||
if (total === 0) {
|
||||
if (items === 0 && page === 1) {
|
||||
return <>No rows to show</>;
|
||||
}
|
||||
|
||||
if (total === 1) {
|
||||
return <>Showing 1 row</>;
|
||||
const start = Math.max(perPage * (page - 1), 1);
|
||||
|
||||
if (!total) {
|
||||
return (
|
||||
<>
|
||||
Showing {start}-{perPage * (page - 1) + items}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
Showing {perPage * (page - 1) + 1}-{perPage * (page - 1) + items} of {total} rows
|
||||
Showing {start}-{perPage * (page - 1) + items} of {total} rows
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -287,30 +302,44 @@ type TableNavProps = {
|
||||
current: number;
|
||||
total: number;
|
||||
onClick?: (page: number) => void;
|
||||
hasLast?: boolean;
|
||||
};
|
||||
|
||||
const TableNav: React.FC<TableNavProps> = ({ current, total, onClick }: TableNavProps) => {
|
||||
const TableNav: React.FC<TableNavProps> = ({
|
||||
current,
|
||||
total,
|
||||
onClick,
|
||||
hasLast = true,
|
||||
}: TableNavProps) => {
|
||||
const navMap = [
|
||||
{ value: 1, Icon: TbChevronsLeft, disabled: current === 1 },
|
||||
{ value: current - 1, Icon: TbChevronLeft, disabled: current === 1 },
|
||||
{ value: current + 1, Icon: TbChevronRight, disabled: current === total },
|
||||
{ value: total, Icon: TbChevronsRight, disabled: current === total },
|
||||
{ enabled: true, value: 1, Icon: TbChevronsLeft, disabled: current === 1 },
|
||||
{ enabled: true, value: current - 1, Icon: TbChevronLeft, disabled: current === 1 },
|
||||
{
|
||||
enabled: true,
|
||||
value: current + 1,
|
||||
Icon: TbChevronRight,
|
||||
disabled: current === total,
|
||||
},
|
||||
{ enabled: hasLast, value: total, Icon: TbChevronsRight, disabled: current === total },
|
||||
] as const;
|
||||
|
||||
return navMap.map((nav, key) => (
|
||||
<button
|
||||
role="button"
|
||||
type="button"
|
||||
key={key}
|
||||
disabled={nav.disabled}
|
||||
className="px-2 py-2 border-muted border rounded-md enabled:link text-lg enabled:hover:bg-primary/5 text-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={() => {
|
||||
const page = nav.value;
|
||||
const safePage = page < 1 ? 1 : page > total ? total : page;
|
||||
onClick?.(safePage);
|
||||
}}
|
||||
>
|
||||
<nav.Icon />
|
||||
</button>
|
||||
));
|
||||
return navMap.map(
|
||||
(nav, key) =>
|
||||
nav.enabled && (
|
||||
<button
|
||||
role="button"
|
||||
type="button"
|
||||
key={key}
|
||||
disabled={nav.disabled}
|
||||
className="px-2 py-2 border-muted border rounded-md enabled:link text-lg enabled:hover:bg-primary/5 text-primary/90 disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
|
||||
onClick={() => {
|
||||
const page = nav.value;
|
||||
const safePage = page < 1 ? 1 : page > total ? total : page;
|
||||
onClick?.(safePage);
|
||||
}}
|
||||
>
|
||||
<nav.Icon />
|
||||
</button>
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { isFileAccepted } from "bknd/utils";
|
||||
import { type FileWithPath, useDropzone } from "./use-dropzone";
|
||||
import { checkMaxReached } from "./helper";
|
||||
import { DropzoneInner } from "./DropzoneInner";
|
||||
@@ -42,28 +42,80 @@ export type DropzoneRenderProps = {
|
||||
showPlaceholder: boolean;
|
||||
onClick?: (file: { path: string }) => void;
|
||||
footer?: ReactNode;
|
||||
dropzoneProps: Pick<DropzoneProps, "maxItems" | "placeholder" | "autoUpload" | "flow">;
|
||||
dropzoneProps: Pick<
|
||||
DropzoneProps,
|
||||
"maxItems" | "placeholder" | "autoUpload" | "flow" | "allowedMimeTypes"
|
||||
>;
|
||||
};
|
||||
|
||||
export type DropzoneProps = {
|
||||
/**
|
||||
* Get the upload info for a file
|
||||
*/
|
||||
getUploadInfo: (file: { path: string }) => { url: string; headers?: Headers; method?: string };
|
||||
/**
|
||||
* Handle the deletion of a file
|
||||
*/
|
||||
handleDelete: (file: { path: string }) => Promise<boolean>;
|
||||
/**
|
||||
* The initial items to display
|
||||
*/
|
||||
initialItems?: FileState[];
|
||||
flow?: "start" | "end";
|
||||
/**
|
||||
* Maximum number of media items that can be uploaded
|
||||
*/
|
||||
maxItems?: number;
|
||||
/**
|
||||
* The allowed mime types
|
||||
*/
|
||||
allowedMimeTypes?: string[];
|
||||
/**
|
||||
* If true, the media item will be overwritten on entity media uploads if limit was reached
|
||||
*/
|
||||
overwrite?: boolean;
|
||||
/**
|
||||
* If true, the media items will be uploaded automatically
|
||||
*/
|
||||
autoUpload?: boolean;
|
||||
/**
|
||||
* Whether to add new items to the start or end of the list
|
||||
* @default "start"
|
||||
*/
|
||||
flow?: "start" | "end";
|
||||
/**
|
||||
* The on rejected callback
|
||||
*/
|
||||
onRejected?: (files: FileWithPath[]) => void;
|
||||
/**
|
||||
* The on deleted callback
|
||||
*/
|
||||
onDeleted?: (file: { path: string }) => void;
|
||||
/**
|
||||
* The on uploaded all callback
|
||||
*/
|
||||
onUploadedAll?: (files: FileStateWithData[]) => void;
|
||||
/**
|
||||
* The on uploaded callback
|
||||
*/
|
||||
onUploaded?: (file: FileStateWithData) => void;
|
||||
/**
|
||||
* The on clicked callback
|
||||
*/
|
||||
onClick?: (file: FileState) => void;
|
||||
/**
|
||||
* The placeholder to use
|
||||
*/
|
||||
placeholder?: {
|
||||
show?: boolean;
|
||||
text?: string;
|
||||
};
|
||||
/**
|
||||
* The footer to render
|
||||
*/
|
||||
footer?: ReactNode;
|
||||
/**
|
||||
* The children to render
|
||||
*/
|
||||
children?: ReactNode | ((props: DropzoneRenderProps) => ReactNode);
|
||||
};
|
||||
|
||||
@@ -102,6 +154,7 @@ export function Dropzone({
|
||||
const setIsOver = useStore(store, (state) => state.setIsOver);
|
||||
const uploading = useStore(store, (state) => state.uploading);
|
||||
const setFileState = useStore(store, (state) => state.setFileState);
|
||||
const overrideFile = useStore(store, (state) => state.overrideFile);
|
||||
const removeFile = useStore(store, (state) => state.removeFile);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -120,9 +173,16 @@ export function Dropzone({
|
||||
|
||||
return specs.every((spec) => {
|
||||
if (spec.kind !== "file") {
|
||||
console.warn("file not accepted: not a file", spec.kind);
|
||||
return false;
|
||||
}
|
||||
return !(allowedMimeTypes && !allowedMimeTypes.includes(spec.type));
|
||||
if (allowedMimeTypes && allowedMimeTypes.length > 0) {
|
||||
if (!isFileAccepted(i, allowedMimeTypes)) {
|
||||
console.warn("file not accepted: not allowed mimetype", spec.type);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -310,7 +370,7 @@ export function Dropzone({
|
||||
state: "uploaded",
|
||||
};
|
||||
|
||||
setFileState(file.path, newState.state);
|
||||
overrideFile(file.path, newState);
|
||||
resolve({ ...response, ...file, ...newState });
|
||||
} catch (e) {
|
||||
setFileState(file.path, "uploaded", 1);
|
||||
@@ -333,7 +393,9 @@ export function Dropzone({
|
||||
};
|
||||
|
||||
xhr.setRequestHeader("Accept", "application/json");
|
||||
xhr.send(file.body);
|
||||
const formData = new FormData();
|
||||
formData.append("file", file.body);
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -362,7 +424,9 @@ export function Dropzone({
|
||||
const openFileInput = useCallback(() => inputRef.current?.click(), [inputRef]);
|
||||
const showPlaceholder = useMemo(
|
||||
() =>
|
||||
Boolean(placeholder?.show === true || !maxItems || (maxItems && files.length < maxItems)),
|
||||
Boolean(
|
||||
placeholder?.show !== false && (!maxItems || (maxItems && files.length < maxItems)),
|
||||
),
|
||||
[placeholder, maxItems, files.length],
|
||||
);
|
||||
|
||||
@@ -375,6 +439,7 @@ export function Dropzone({
|
||||
type: "file",
|
||||
multiple: !maxItems || maxItems > 1,
|
||||
onChange: handleFileInputChange,
|
||||
accept: allowedMimeTypes?.join(","),
|
||||
},
|
||||
showPlaceholder,
|
||||
actions: {
|
||||
@@ -388,11 +453,12 @@ export function Dropzone({
|
||||
placeholder,
|
||||
autoUpload,
|
||||
flow,
|
||||
allowedMimeTypes,
|
||||
},
|
||||
onClick,
|
||||
footer,
|
||||
}),
|
||||
[maxItems, flow, placeholder, autoUpload, footer],
|
||||
[maxItems, files.length, flow, placeholder, autoUpload, footer, allowedMimeTypes],
|
||||
) as unknown as DropzoneRenderProps;
|
||||
|
||||
return (
|
||||
|
||||
@@ -10,15 +10,38 @@ import { mediaItemsToFileStates } from "./helper";
|
||||
import { useInViewport } from "@mantine/hooks";
|
||||
|
||||
export type DropzoneContainerProps = {
|
||||
/**
|
||||
* The initial items to display
|
||||
* @default []
|
||||
*/
|
||||
initialItems?: MediaFieldSchema[] | false;
|
||||
/**
|
||||
* Whether to use infinite scrolling
|
||||
* @default false
|
||||
*/
|
||||
infinite?: boolean;
|
||||
/**
|
||||
* If given, the initial media items fetched will be from this entity
|
||||
* @default undefined
|
||||
*/
|
||||
entity?: {
|
||||
name: string;
|
||||
id: PrimaryFieldType;
|
||||
field: string;
|
||||
};
|
||||
/**
|
||||
* The media config
|
||||
* @default undefined
|
||||
*/
|
||||
media?: Pick<TAppMediaConfig, "entity_name" | "storage">;
|
||||
/**
|
||||
* Query to filter the media items
|
||||
*/
|
||||
query?: RepoQueryIn;
|
||||
/**
|
||||
* Whether to use a random filename
|
||||
* @default false
|
||||
*/
|
||||
randomFilename?: boolean;
|
||||
} & Omit<Partial<DropzoneProps>, "initialItems">;
|
||||
|
||||
@@ -54,7 +77,9 @@ export function DropzoneContainer({
|
||||
});
|
||||
|
||||
const $q = infinite
|
||||
? useApiInfiniteQuery(selectApi, {})
|
||||
? useApiInfiniteQuery(selectApi, {
|
||||
pageSize,
|
||||
})
|
||||
: useApiQuery(selectApi, {
|
||||
enabled: initialItems !== false && !initialItems,
|
||||
revalidateOnFocus: false,
|
||||
@@ -85,31 +110,48 @@ export function DropzoneContainer({
|
||||
[]) as MediaFieldSchema[];
|
||||
const _initialItems = mediaItemsToFileStates(actualItems, { baseUrl });
|
||||
|
||||
const key = id + JSON.stringify(_initialItems);
|
||||
const key = id + JSON.stringify(initialItems);
|
||||
|
||||
// check if endpoint reeturns a total, then reaching end is easy
|
||||
const total = "_data" in $q ? $q._data?.[0]?.body.meta.count : undefined;
|
||||
let placeholderLength = 0;
|
||||
if (infinite && "setSize" in $q) {
|
||||
placeholderLength =
|
||||
typeof total === "number"
|
||||
? total
|
||||
: $q.endReached
|
||||
? _initialItems.length
|
||||
: _initialItems.length + pageSize;
|
||||
|
||||
// in case there is no total, we overfetch but SWR don't reflect an empty result
|
||||
// therefore we check if it stopped loading, but has a bigger page size than the total.
|
||||
// if that's the case, we assume we reached the end.
|
||||
if (!total && !$q.isValidating && pageSize * $q.size >= placeholderLength) {
|
||||
placeholderLength = _initialItems.length;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropzone
|
||||
key={id + key}
|
||||
getUploadInfo={getUploadInfo}
|
||||
handleDelete={handleDelete}
|
||||
/* onUploaded={refresh}
|
||||
onDeleted={refresh} */
|
||||
autoUpload
|
||||
initialItems={_initialItems}
|
||||
footer={
|
||||
infinite &&
|
||||
"setSize" in $q && (
|
||||
<Footer
|
||||
items={_initialItems.length}
|
||||
length={Math.min(
|
||||
$q._data?.[0]?.body.meta.count ?? 0,
|
||||
_initialItems.length + pageSize,
|
||||
)}
|
||||
onFirstVisible={() => $q.setSize($q.size + 1)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
<>
|
||||
<Dropzone
|
||||
key={key}
|
||||
getUploadInfo={getUploadInfo}
|
||||
handleDelete={handleDelete}
|
||||
autoUpload
|
||||
initialItems={_initialItems}
|
||||
footer={
|
||||
infinite &&
|
||||
"setSize" in $q && (
|
||||
<Footer
|
||||
items={_initialItems.length}
|
||||
length={placeholderLength}
|
||||
onFirstVisible={() => $q.setSize($q.size + 1)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
import { type ComponentPropsWithoutRef, memo, type ReactNode, useCallback, useMemo } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useRenderCount } from "ui/hooks/use-render-count";
|
||||
import { TbDots, TbExternalLink, TbTrash, TbUpload } from "react-icons/tb";
|
||||
import {
|
||||
TbDots,
|
||||
TbExternalLink,
|
||||
TbFileTypeCsv,
|
||||
TbFileText,
|
||||
TbJson,
|
||||
TbFileTypePdf,
|
||||
TbMarkdown,
|
||||
TbMusic,
|
||||
TbTrash,
|
||||
TbUpload,
|
||||
TbFileTypeTxt,
|
||||
TbFileTypeXml,
|
||||
TbZip,
|
||||
TbFileTypeSql,
|
||||
} from "react-icons/tb";
|
||||
import { Dropdown, type DropdownItem } from "ui/components/overlay/Dropdown";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { formatNumber } from "core/utils";
|
||||
@@ -22,7 +37,7 @@ export const DropzoneInner = ({
|
||||
inputProps,
|
||||
showPlaceholder,
|
||||
actions: { uploadFile, deleteFile, openFileInput },
|
||||
dropzoneProps: { placeholder, flow },
|
||||
dropzoneProps: { placeholder, flow, maxItems, allowedMimeTypes },
|
||||
onClick,
|
||||
footer,
|
||||
}: DropzoneRenderProps) => {
|
||||
@@ -85,7 +100,7 @@ const UploadPlaceholder = ({ onClick, text = "Upload files" }) => {
|
||||
);
|
||||
};
|
||||
|
||||
type ReducedFile = Pick<FileState, "body" | "type" | "path" | "name" | "size">;
|
||||
type ReducedFile = Omit<FileState, "state" | "progress">;
|
||||
export type PreviewComponentProps = {
|
||||
file: ReducedFile;
|
||||
fallback?: (props: { file: ReducedFile }) => ReactNode;
|
||||
@@ -159,9 +174,9 @@ const Preview = memo(
|
||||
<p className="truncate select-text w-[calc(100%-10px)]">{file.name}</p>
|
||||
<StateIndicator file={file} />
|
||||
</div>
|
||||
<div className="flex flex-row justify-between text-sm font-mono opacity-50 text-nowrap gap-2">
|
||||
<div className="flex flex-row justify-between text-xs md:text-sm font-mono opacity-50 text-nowrap gap-2">
|
||||
<span className="truncate select-text">{file.type}</span>
|
||||
<span>{formatNumber.fileSize(file.size)}</span>
|
||||
<span className="whitespace-nowrap">{formatNumber.fileSize(file.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -271,6 +286,59 @@ const VideoPreview = ({
|
||||
return <video {...props} src={objectUrl} />;
|
||||
};
|
||||
|
||||
const Previews = [
|
||||
{
|
||||
mime: "text/plain",
|
||||
Icon: TbFileTypeTxt,
|
||||
},
|
||||
{
|
||||
mime: "text/csv",
|
||||
Icon: TbFileTypeCsv,
|
||||
},
|
||||
{
|
||||
mime: /(text|application)\/xml/,
|
||||
Icon: TbFileTypeXml,
|
||||
},
|
||||
{
|
||||
mime: "text/markdown",
|
||||
Icon: TbMarkdown,
|
||||
},
|
||||
{
|
||||
mime: /^text\/.*$/,
|
||||
Icon: TbFileText,
|
||||
},
|
||||
{
|
||||
mime: "application/json",
|
||||
Icon: TbJson,
|
||||
},
|
||||
{
|
||||
mime: "application/pdf",
|
||||
Icon: TbFileTypePdf,
|
||||
},
|
||||
{
|
||||
mime: /^audio\/.*$/,
|
||||
Icon: TbMusic,
|
||||
},
|
||||
{
|
||||
mime: "application/zip",
|
||||
Icon: TbZip,
|
||||
},
|
||||
{
|
||||
mime: "application/sql",
|
||||
Icon: TbFileTypeSql,
|
||||
},
|
||||
];
|
||||
|
||||
const FallbackPreview = ({ file }: { file: ReducedFile }) => {
|
||||
return <div className="text-xs text-primary/50 text-center">{file.type}</div>;
|
||||
const previewIcon = Previews.find((p) =>
|
||||
p.mime instanceof RegExp ? p.mime.test(file.type) : p.mime === file.type,
|
||||
);
|
||||
if (previewIcon) {
|
||||
return <previewIcon.Icon className="size-10 text-gray-400" />;
|
||||
}
|
||||
return (
|
||||
<div className="text-xs text-primary/50 text-center font-mono leading-none max-w-[90%] truncate">
|
||||
{file.type}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -36,6 +36,10 @@ export const createDropzoneStore = () => {
|
||||
: f,
|
||||
),
|
||||
})),
|
||||
overrideFile: (path: string, newState: Partial<FileState>) =>
|
||||
set((state) => ({
|
||||
files: state.files.map((f) => (f.path === path ? { ...f, ...newState } : f)),
|
||||
})),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -51,13 +51,5 @@ describe("media helper", () => {
|
||||
added: 1,
|
||||
}),
|
||||
).toEqual({ reject: false, to_drop: 1 });
|
||||
console.log(
|
||||
checkMaxReached({
|
||||
maxItems: 6,
|
||||
current: 0,
|
||||
overwrite: true,
|
||||
added: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,10 +14,6 @@ export function useSearch<Schema extends s.Schema = s.Schema>(
|
||||
) {
|
||||
const searchString = useWouterSearch();
|
||||
const [location, navigate] = useLocation();
|
||||
const [value, setValue] = useState<s.StaticCoerced<Schema>>(
|
||||
options?.defaultValue ?? ({} as any),
|
||||
);
|
||||
|
||||
const defaults = useMemo(() => {
|
||||
return mergeObject(
|
||||
// @ts-ignore
|
||||
@@ -25,6 +21,7 @@ export function useSearch<Schema extends s.Schema = s.Schema>(
|
||||
options?.defaultValue ?? {},
|
||||
);
|
||||
}, [JSON.stringify({ schema, dflt: options?.defaultValue })]);
|
||||
const [value, setValue] = useState<s.StaticCoerced<Schema>>(defaults);
|
||||
|
||||
useEffect(() => {
|
||||
const initial =
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
export { default as Admin, type BkndAdminProps } from "./Admin";
|
||||
export { default as Admin, type BkndAdminProps, type BkndAdminConfig } from "./Admin";
|
||||
export * from "./components/form/json-schema-form";
|
||||
export { JsonViewer } from "./components/code/JsonViewer";
|
||||
export type * from "./options";
|
||||
|
||||
// bknd admin ui
|
||||
export { Button } from "./components/buttons/Button";
|
||||
export { IconButton } from "./components/buttons/IconButton";
|
||||
export * as Formy from "./components/form/Formy";
|
||||
export * as AppShell from "./layouts/AppShell/AppShell";
|
||||
export { Logo } from "./components/display/Logo";
|
||||
export * as Form from "./components/form/json-schema-form";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useClickOutside, useHotkeys } from "@mantine/hooks";
|
||||
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
|
||||
import { clampNumber } from "core/utils/numbers";
|
||||
import { transformObject, clampNumber } from "bknd/utils";
|
||||
import { throttle } from "lodash-es";
|
||||
import { ScrollArea } from "radix-ui";
|
||||
import {
|
||||
@@ -19,14 +19,20 @@ import { appShellStore } from "ui/store";
|
||||
import { useLocation } from "wouter";
|
||||
|
||||
export function Root({ children }: { children: React.ReactNode }) {
|
||||
const sidebarWidth = appShellStore((store) => store.sidebarWidth);
|
||||
const sidebarWidths = appShellStore((store) => store.sidebars);
|
||||
const style = transformObject(sidebarWidths, (value) => value.width);
|
||||
return (
|
||||
<AppShellProvider>
|
||||
<div
|
||||
id="app-shell"
|
||||
data-shell="root"
|
||||
className="flex flex-1 flex-col select-none h-dvh"
|
||||
style={{ "--sidebar-width": `${sidebarWidth}px` } as React.CSSProperties}
|
||||
style={Object.fromEntries(
|
||||
Object.entries(style).map(([key, value]) => [
|
||||
`--sidebar-width-${key}`,
|
||||
`${value}px`,
|
||||
]),
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
@@ -73,7 +79,7 @@ export function Content({ children, center }: { children: React.ReactNode; cente
|
||||
<main
|
||||
data-shell="content"
|
||||
className={twMerge(
|
||||
"flex flex-1 flex-row w-dvw h-full",
|
||||
"flex flex-1 flex-row max-w-screen h-full",
|
||||
center && "justify-center items-center",
|
||||
)}
|
||||
>
|
||||
@@ -97,10 +103,24 @@ export function Main({ children }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar({ children }) {
|
||||
const open = appShellStore((store) => store.sidebarOpen);
|
||||
const close = appShellStore((store) => store.closeSidebar);
|
||||
export function Sidebar({
|
||||
children,
|
||||
name = "default",
|
||||
handle = "right",
|
||||
minWidth,
|
||||
maxWidth,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
name?: string;
|
||||
handle?: "right" | "left";
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
}) {
|
||||
const open = appShellStore((store) => store.sidebars[name]?.open);
|
||||
const close = appShellStore((store) => store.closeSidebar(name));
|
||||
const width = appShellStore((store) => store.sidebars[name]?.width ?? 350);
|
||||
const ref = useClickOutside(close, ["mouseup", "touchend"]); //, [document.getElementById("header")]);
|
||||
const sidebarRef = useRef<HTMLDivElement>(null!);
|
||||
const [location] = useLocation();
|
||||
|
||||
const closeHandler = () => {
|
||||
@@ -115,53 +135,80 @@ export function Sidebar({ children }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{handle === "left" && (
|
||||
<SidebarResize
|
||||
name={name}
|
||||
handle={handle}
|
||||
sidebarRef={sidebarRef}
|
||||
minWidth={minWidth}
|
||||
maxWidth={maxWidth}
|
||||
/>
|
||||
)}
|
||||
<aside
|
||||
data-shell="sidebar"
|
||||
className="hidden md:flex flex-col basis-[var(--sidebar-width)] flex-shrink-0 flex-grow-0 h-full bg-muted/10"
|
||||
ref={sidebarRef}
|
||||
className="hidden md:flex flex-col flex-shrink-0 flex-grow-0 h-full bg-muted/10"
|
||||
style={{ width }}
|
||||
>
|
||||
{children}
|
||||
</aside>
|
||||
<SidebarResize />
|
||||
{handle === "right" && (
|
||||
<SidebarResize
|
||||
name={name}
|
||||
handle={handle}
|
||||
sidebarRef={sidebarRef}
|
||||
minWidth={minWidth}
|
||||
maxWidth={maxWidth}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
data-open={open}
|
||||
className="absolute w-full md:hidden data-[open=true]:translate-x-0 translate-x-[-100%] transition-transform z-10 backdrop-blur-sm"
|
||||
className="absolute w-full md:hidden data-[open=true]:translate-x-0 translate-x-[-100%] transition-transform z-10 backdrop-blur-sm max-w-[90%]"
|
||||
>
|
||||
<aside
|
||||
ref={ref}
|
||||
data-shell="sidebar"
|
||||
className="flex-col w-[var(--sidebar-width)] flex-shrink-0 flex-grow-0 h-full border-muted border-r bg-background"
|
||||
>
|
||||
{children}
|
||||
<MaxHeightContainer className="overflow-y-scroll md:overflow-y-hidden">
|
||||
{children}
|
||||
</MaxHeightContainer>
|
||||
</aside>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const SidebarResize = () => {
|
||||
const setSidebarWidth = appShellStore((store) => store.setSidebarWidth);
|
||||
const SidebarResize = ({
|
||||
name = "default",
|
||||
handle = "right",
|
||||
sidebarRef,
|
||||
minWidth = 250,
|
||||
maxWidth = window.innerWidth * 0.5,
|
||||
}: {
|
||||
name?: string;
|
||||
handle?: "right" | "left";
|
||||
sidebarRef: React.RefObject<HTMLDivElement>;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
}) => {
|
||||
const setSidebarWidth = appShellStore((store) => store.setSidebarWidth(name));
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [startX, setStartX] = useState(0);
|
||||
const [startWidth, setStartWidth] = useState(0);
|
||||
const [start, setStart] = useState(0);
|
||||
const [startWidth, setStartWidth] = useState(sidebarRef.current?.offsetWidth ?? 0);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsResizing(true);
|
||||
setStartX(e.clientX);
|
||||
setStartWidth(
|
||||
Number.parseInt(
|
||||
getComputedStyle(document.getElementById("app-shell")!)
|
||||
.getPropertyValue("--sidebar-width")
|
||||
.replace("px", ""),
|
||||
),
|
||||
);
|
||||
setStart(e.clientX);
|
||||
setStartWidth(sidebarRef.current?.offsetWidth ?? 0);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isResizing) return;
|
||||
|
||||
const diff = e.clientX - startX;
|
||||
const newWidth = clampNumber(startWidth + diff, 250, window.innerWidth * 0.5);
|
||||
const diff = handle === "right" ? e.clientX - start : start - e.clientX;
|
||||
const newWidth = clampNumber(startWidth + diff, minWidth, maxWidth);
|
||||
setSidebarWidth(newWidth);
|
||||
};
|
||||
|
||||
@@ -179,10 +226,11 @@ const SidebarResize = () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [isResizing, startX, startWidth]);
|
||||
}, [isResizing, start, startWidth, minWidth, maxWidth]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-shell="sidebar-resize"
|
||||
data-active={isResizing ? 1 : undefined}
|
||||
className="w-px h-full hidden md:flex bg-muted after:transition-colors relative after:absolute after:inset-0 after:-left-px after:w-[2px] select-none data-[active]:after:bg-sky-400 data-[active]:cursor-col-resize hover:after:bg-sky-400 hover:cursor-col-resize after:z-2"
|
||||
onMouseDown={handleMouseDown}
|
||||
@@ -291,15 +339,15 @@ export const SectionHeaderLink = <E extends React.ElementType = "a">({
|
||||
<Tag
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"hover:bg-primary/5 flex flex-row items-center justify-center gap-2.5 px-5 h-12 leading-none font-medium text-primary/80 rounded-tr-lg rounded-tl-lg",
|
||||
"hover:bg-primary/5 flex flex-row items-center justify-center gap-2.5 px-5 h-12 leading-none font-medium text-primary/80 rounded-tr-lg rounded-tl-lg cursor-pointer border border-transparent border-b-0",
|
||||
active
|
||||
? "bg-background hover:bg-background text-primary border border-muted border-b-0"
|
||||
: "link",
|
||||
? "bg-background hover:bg-background text-primary border-muted border-b-0"
|
||||
: "link opacity-80",
|
||||
badge && "pr-4",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<span className="truncate">{children}</span>
|
||||
{badge ? (
|
||||
<span className="px-3 py-1 rounded-full font-mono bg-primary/5 text-sm leading-none">
|
||||
{badge}
|
||||
@@ -317,23 +365,63 @@ export type SectionHeaderTabsProps = {
|
||||
};
|
||||
export const SectionHeaderTabs = ({ title, items }: SectionHeaderTabsProps) => {
|
||||
return (
|
||||
<SectionHeader className="mt-10 border-t pl-3 pb-0 items-end">
|
||||
<div className="flex flex-row items-center gap-6 -mb-px">
|
||||
{title && (
|
||||
<SectionHeaderTitle className="pl-2 hidden md:block">{title}</SectionHeaderTitle>
|
||||
)}
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
{items?.map(({ label, ...item }, key) => (
|
||||
<SectionHeaderLink key={key} {...item}>
|
||||
{label}
|
||||
</SectionHeaderLink>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="absolute left-0 right-0 bottom-0 z-0 h-px bg-muted" />
|
||||
<div className="overflow-x-scroll app-scrollbar mt-10 border-t border-t-muted">
|
||||
<SectionHeader className="pl-3 pb-0 items-end ">
|
||||
<div className="flex flex-row items-center gap-6 -mb-px">
|
||||
{title && (
|
||||
<SectionHeaderTitle className="pl-2 hidden md:block">
|
||||
{title}
|
||||
</SectionHeaderTitle>
|
||||
)}
|
||||
<div className="flex flex-row items-center gap-3 pr-3">
|
||||
{items?.map(({ label, ...item }, key) => (
|
||||
<SectionHeaderLink key={key} {...item} className="relative z-2">
|
||||
{label}
|
||||
</SectionHeaderLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SectionHeader>
|
||||
</div>
|
||||
</SectionHeader>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function MaxHeightContainer(props: ComponentPropsWithoutRef<"div">) {
|
||||
const scrollRef = useRef<React.ElementRef<"div">>(null);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [height, setHeight] = useState(window.innerHeight);
|
||||
|
||||
function updateHeaderHeight() {
|
||||
if (scrollRef.current) {
|
||||
// get offset to top of window
|
||||
const offset = scrollRef.current.getBoundingClientRect().top;
|
||||
const height = window.innerHeight;
|
||||
setOffset(offset);
|
||||
setHeight(height);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
updateHeaderHeight();
|
||||
const resize = throttle(updateHeaderHeight, 500);
|
||||
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", resize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} style={{ height: `${height - offset}px` }} {...props}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Scrollable({
|
||||
children,
|
||||
initialOffset = 64,
|
||||
@@ -346,7 +434,9 @@ export function Scrollable({
|
||||
|
||||
function updateHeaderHeight() {
|
||||
if (scrollRef.current) {
|
||||
setOffset(scrollRef.current.offsetTop);
|
||||
// get offset to top of window
|
||||
const offset = scrollRef.current.getBoundingClientRect().top;
|
||||
setOffset(offset);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -25,6 +25,8 @@ import { NavLink } from "./AppShell";
|
||||
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();
|
||||
@@ -105,9 +107,9 @@ export function HeaderNavigation() {
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarToggler() {
|
||||
const toggle = appShellStore((store) => store.toggleSidebar);
|
||||
const open = appShellStore((store) => store.sidebarOpen);
|
||||
function SidebarToggler({ name = "default" }: { name?: string }) {
|
||||
const toggle = appShellStore((store) => store.toggleSidebar(name));
|
||||
const open = appShellStore((store) => store.sidebars[name]?.open);
|
||||
return <IconButton id="toggle-sidebar" size="lg" Icon={open ? TbX : TbMenu2} onClick={toggle} />;
|
||||
}
|
||||
|
||||
@@ -132,7 +134,7 @@ export function Header({ hasSidebar = true }) {
|
||||
<HeaderNavigation />
|
||||
<div className="flex flex-grow" />
|
||||
<div className="flex md:hidden flex-row items-center pr-2 gap-2">
|
||||
<SidebarToggler />
|
||||
<SidebarToggler name="default" />
|
||||
<UserMenu />
|
||||
</div>
|
||||
<div className="hidden md:flex flex-row items-center px-4 gap-2">
|
||||
@@ -143,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();
|
||||
@@ -158,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",
|
||||
@@ -172,6 +177,14 @@ function UserMenu() {
|
||||
},
|
||||
];
|
||||
|
||||
if (config.server.mcp.enabled) {
|
||||
items.push({
|
||||
label: "MCP",
|
||||
onClick: () => navigate("/tools/mcp"),
|
||||
icon: McpIcon,
|
||||
});
|
||||
}
|
||||
|
||||
if (config.auth.enabled) {
|
||||
if (!auth.user) {
|
||||
items.push({ label: "Login", onClick: handleLogin, icon: IconUser });
|
||||
|
||||
@@ -95,7 +95,7 @@ export function useNavigate() {
|
||||
window.location.href = url;
|
||||
return;
|
||||
} else if ("target" in options) {
|
||||
const _url = window.location.origin + basepath + router.base + url;
|
||||
const _url = window.location.origin + router.base + url;
|
||||
window.open(_url, options.target);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,3 +3,30 @@ import { type ClassNameValue, twMerge } from "tailwind-merge";
|
||||
export function cn(...inputs: ClassNameValue[]) {
|
||||
return twMerge(inputs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically import a module from a URL in the browser in a way compatible with all react frameworks (nextjs doesn't support dynamic imports)
|
||||
*/
|
||||
export async function importDynamicBrowserModule<T = any>(name: string, url: string): Promise<T> {
|
||||
if (!(window as any)[name]) {
|
||||
const script = document.createElement("script");
|
||||
script.type = "module";
|
||||
script.async = true;
|
||||
script.textContent = `import * as ${name} from '${url}';window.${name} = ${name};`;
|
||||
document.head.appendChild(script);
|
||||
|
||||
// poll for the module to be available
|
||||
const maxAttempts = 50; // 5s
|
||||
let attempts = 0;
|
||||
while (!(window as any)[name] && attempts < maxAttempts) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (!(window as any)[name]) {
|
||||
throw new Error(`Browser module "${name}" failed to load`);
|
||||
}
|
||||
}
|
||||
|
||||
return (window as any)[name] as T;
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
@apply bg-background text-primary overflow-hidden h-dvh w-dvw;
|
||||
|
||||
::selection {
|
||||
@apply bg-muted;
|
||||
@apply bg-primary/15;
|
||||
}
|
||||
|
||||
input {
|
||||
@@ -115,3 +115,14 @@ body,
|
||||
@apply bg-primary/25;
|
||||
}
|
||||
}
|
||||
|
||||
@utility debug {
|
||||
@apply border-red-500 border;
|
||||
}
|
||||
@utility debug-blue {
|
||||
@apply border-blue-500 border;
|
||||
}
|
||||
@utility w-min-content {
|
||||
width: min-content;
|
||||
width: -webkit-min-content;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
import * as ReactDOM from "react-dom/client";
|
||||
import Admin from "./Admin";
|
||||
import "./main.css";
|
||||
//import "./main.css";
|
||||
import "./styles.css";
|
||||
|
||||
function render() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ContextModalProps } from "@mantine/modals";
|
||||
import type { ReactNode } from "react";
|
||||
import { type ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import { useEntityQuery } from "ui/client";
|
||||
import { type FileState, Media } from "ui/elements";
|
||||
import { autoFormatString, datetimeStringLocal, formatNumber } from "core/utils";
|
||||
@@ -43,7 +43,7 @@ export function MediaInfoModal({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row">
|
||||
<div className="flex w-full md:w-[calc(100%-300px)] justify-center items-center bg-lightest min-w-0">
|
||||
<div className="flex w-full md:w-[calc(100%-300px)] justify-center items-center bg-lightest min-w-50">
|
||||
<FilePreview file={file} />
|
||||
</div>
|
||||
<div className="w-full md:!w-[300px] flex flex-col">
|
||||
@@ -156,12 +156,38 @@ const Item = ({
|
||||
);
|
||||
};
|
||||
|
||||
const textFormats = [/^text\/.*$/, /application\/(x\-)?(json|json|yaml|javascript|xml|rtf|sql)/];
|
||||
|
||||
const FilePreview = ({ file }: { file: FileState }) => {
|
||||
const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body);
|
||||
|
||||
if (file.type.startsWith("image/") || file.type.startsWith("video/")) {
|
||||
// @ts-ignore
|
||||
return <Media.Preview file={file} className="max-h-[70dvh]" controls muted />;
|
||||
}
|
||||
|
||||
if (file.type === "application/pdf") {
|
||||
// use browser preview
|
||||
return (
|
||||
<iframe
|
||||
title="PDF preview"
|
||||
src={`${objectUrl}#view=fitH&zoom=page-width&toolbar=1`}
|
||||
className="w-250 max-w-[80dvw] h-[80dvh]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (textFormats.some((f) => f.test(file.type))) {
|
||||
return <TextPreview file={file} />;
|
||||
}
|
||||
|
||||
if (file.type.startsWith("audio/")) {
|
||||
return (
|
||||
<div className="p-5">
|
||||
<audio src={objectUrl} controls />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-96 min-h-48 flex justify-center items-center h-full max-h-[70dvh]">
|
||||
<span className="opacity-50 font-mono">No Preview Available</span>
|
||||
@@ -169,6 +195,44 @@ const FilePreview = ({ file }: { file: FileState }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const TextPreview = ({ file }: { file: FileState }) => {
|
||||
const [text, setText] = useState("");
|
||||
const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body);
|
||||
const maxBytes = 1024 * 256;
|
||||
const useRange = file.size > maxBytes;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (file) {
|
||||
fetch(objectUrl, {
|
||||
headers: useRange ? { Range: `bytes=0-${maxBytes - 1}` } : undefined,
|
||||
})
|
||||
.then((r) => r.text())
|
||||
.then((t) => {
|
||||
if (!cancelled) setText(t);
|
||||
});
|
||||
} else {
|
||||
setText("");
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [file, useRange]);
|
||||
|
||||
return (
|
||||
<pre className="text-sm font-mono whitespace-pre-wrap break-all overflow-y-scroll w-250 md:max-w-[80dvw] h-[60dvh] md:h-[80dvh] py-4 px-6">
|
||||
{text}
|
||||
|
||||
{useRange && (
|
||||
<div className="mt-3 opacity-50 text-xs text-center">
|
||||
Showing first {formatNumber.fileSize(maxBytes)}
|
||||
</div>
|
||||
)}
|
||||
</pre>
|
||||
);
|
||||
};
|
||||
|
||||
MediaInfoModal.defaultTitle = undefined;
|
||||
MediaInfoModal.modalProps = {
|
||||
withCloseButton: false,
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { FieldApi, ReactFormExtendedApi } from "@tanstack/react-form";
|
||||
import type { JSX } from "react";
|
||||
import { useStore } from "@tanstack/react-store";
|
||||
import { MediaField } from "media/MediaField";
|
||||
import { type ComponentProps, Suspense } from "react";
|
||||
import { type ComponentProps, Suspense, useState } from "react";
|
||||
import { JsonEditor } from "ui/components/code/JsonEditor";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
import { FieldLabel } from "ui/components/form/Formy";
|
||||
@@ -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,31 @@ 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;
|
||||
}
|
||||
}
|
||||
if (field.isHidden(action)) return;
|
||||
|
||||
return (
|
||||
<EntityFormField
|
||||
field={field}
|
||||
fieldApi={props}
|
||||
disabled={fieldsDisabled}
|
||||
tabIndex={key + 1}
|
||||
action={action}
|
||||
data={data}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
@@ -223,6 +240,10 @@ function EntityMediaFormField({
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
if (!entityId) return;
|
||||
const maxLimit = 50;
|
||||
const maxItems = field.getMaxItems();
|
||||
const isSingle = maxItems === 1;
|
||||
const limit = isSingle ? 1 : maxItems && maxItems > maxLimit ? maxLimit : maxItems;
|
||||
|
||||
const value = useStore(formApi.store, (state) => {
|
||||
const val = state.values[field.name];
|
||||
@@ -243,8 +264,9 @@ function EntityMediaFormField({
|
||||
<FieldLabel field={field} />
|
||||
<Media.Dropzone
|
||||
key={key}
|
||||
maxItems={field.getMaxItems()}
|
||||
initialItems={value} /* @todo: test if better be omitted, so it fetches */
|
||||
maxItems={maxItems}
|
||||
allowedMimeTypes={field.getAllowedMimeTypes()}
|
||||
initialItems={isSingle ? value : undefined}
|
||||
onClick={onClick}
|
||||
entity={{
|
||||
name: entity.name,
|
||||
@@ -253,6 +275,7 @@ function EntityMediaFormField({
|
||||
}}
|
||||
query={{
|
||||
sort: "-id",
|
||||
limit,
|
||||
}}
|
||||
/>
|
||||
</Formy.Group>
|
||||
@@ -264,33 +287,26 @@ function EntityJsonFormField({
|
||||
field,
|
||||
...props
|
||||
}: { fieldApi: TFieldApi; field: JsonField }) {
|
||||
const [error, setError] = useState<any>(null);
|
||||
const handleUpdate = useEvent((value: any) => {
|
||||
setError(null);
|
||||
fieldApi.handleChange(value);
|
||||
});
|
||||
|
||||
return (
|
||||
<Formy.Group>
|
||||
<Formy.Label htmlFor={fieldApi.name}>{field.getLabel()}</Formy.Label>
|
||||
<Formy.Group error={!!error}>
|
||||
<Formy.FieldLabel htmlFor={fieldApi.name} field={field} />
|
||||
<Suspense>
|
||||
<JsonEditor
|
||||
id={fieldApi.name}
|
||||
value={fieldApi.state.value}
|
||||
onChange={handleUpdate}
|
||||
onBlur={fieldApi.handleBlur}
|
||||
onInvalid={setError}
|
||||
minHeight="100"
|
||||
/*required={field.isRequired()}*/
|
||||
{...props}
|
||||
/>
|
||||
</Suspense>
|
||||
{/*<Formy.Textarea
|
||||
name={fieldApi.name}
|
||||
id={fieldApi.name}
|
||||
value={fieldApi.state.value}
|
||||
onBlur={fieldApi.handleBlur}
|
||||
onChange={handleUpdate}
|
||||
required={field.isRequired()}
|
||||
{...props}
|
||||
/>*/}
|
||||
</Formy.Group>
|
||||
);
|
||||
}
|
||||
@@ -306,7 +322,7 @@ function EntityEnumFormField({
|
||||
|
||||
return (
|
||||
<Formy.Group>
|
||||
<Formy.Label htmlFor={fieldApi.name}>{field.getLabel()}</Formy.Label>
|
||||
<Formy.FieldLabel htmlFor={fieldApi.name} field={field} />
|
||||
<Formy.Select
|
||||
name={fieldApi.name}
|
||||
id={fieldApi.name}
|
||||
@@ -317,8 +333,8 @@ function EntityEnumFormField({
|
||||
{...props}
|
||||
>
|
||||
{!field.isRequired() && <option value="">- Select -</option>}
|
||||
{field.getOptions().map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{field.getOptions().map((option, i) => (
|
||||
<option key={`${option.value}-${i}`} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
|
||||
@@ -44,7 +44,7 @@ export function EntityRelationalFormField({
|
||||
const ref = useRef<any>(null);
|
||||
const $q = useEntityQuery(field.target(), undefined, {
|
||||
select: query.select,
|
||||
limit: query.limit,
|
||||
limit: query.limit + 1 /* overfetch for softscan=false */,
|
||||
offset: (query.page - 1) * query.limit,
|
||||
});
|
||||
const [_value, _setValue] = useState<{ id: number | undefined; [key: string]: any }>();
|
||||
@@ -93,9 +93,11 @@ export function EntityRelationalFormField({
|
||||
|
||||
return (
|
||||
<Formy.Group>
|
||||
<Formy.Label htmlFor={fieldApi.name}>
|
||||
{field.getLabel({ fallback: false }) ?? entity.label}
|
||||
</Formy.Label>
|
||||
<Formy.FieldLabel
|
||||
htmlFor={fieldApi.name}
|
||||
field={field}
|
||||
label={field.getLabel({ fallback: false }) ?? entity.label}
|
||||
/>
|
||||
<div
|
||||
data-disabled={fetching || disabled ? 1 : undefined}
|
||||
className="data-[disabled]:opacity-70 data-[disabled]:pointer-events-none"
|
||||
@@ -232,7 +234,7 @@ const PopoverTable = ({
|
||||
data={container ?? []}
|
||||
entity={entity}
|
||||
select={query.select}
|
||||
total={container.meta?.count}
|
||||
total={container.body.meta?.count}
|
||||
page={query.page}
|
||||
onClickRow={onClickRow}
|
||||
onClickPage={onClickPage}
|
||||
|
||||
@@ -8,7 +8,9 @@ import { s } from "bknd/utils";
|
||||
import { cloneSchema } from "core/utils/schema";
|
||||
|
||||
const schema = s.object({
|
||||
name: entitySchema.properties.name,
|
||||
name: s.string({
|
||||
pattern: /^[a-z][a-zA-Z_]+$/,
|
||||
}),
|
||||
config: entitySchema.properties.config.partial().optional(),
|
||||
});
|
||||
type Schema = s.Static<typeof schema>;
|
||||
|
||||
@@ -94,8 +94,8 @@ export const TriggerNode = (props: NodeProps<Node<TAppFlowTriggerSchema & { labe
|
||||
control={control}
|
||||
/>
|
||||
</div>
|
||||
{data.type === "manual" && <Manual />}
|
||||
{data.type === "http" && (
|
||||
{data?.type === "manual" && <Manual />}
|
||||
{data?.type === "http" && (
|
||||
<Http form={{ watch, register, setValue, getValues, control }} />
|
||||
)}
|
||||
</form>
|
||||
|
||||
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";
|
||||
@@ -1,17 +1,48 @@
|
||||
import { useRef } from "react";
|
||||
import { TbDots } from "react-icons/tb";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { Message } from "ui/components/display/Message";
|
||||
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
|
||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "ui/lib/routes";
|
||||
import { isDebug } from "core/env";
|
||||
import { Dropdown } from "ui/components/overlay/Dropdown";
|
||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { TbAdjustments, TbDots, TbFilter, TbTrash, TbInfoCircle, TbCodeDots } from "react-icons/tb";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
|
||||
import { routes, useNavigate } from "ui/lib/routes";
|
||||
import { AuthRoleForm, type AuthRoleFormRef } from "ui/routes/auth/forms/role.form";
|
||||
import { routes } from "ui/lib/routes";
|
||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
import { ucFirst, s, transformObject, isObject, autoFormatString } from "bknd/utils";
|
||||
import type { ModuleSchemas } from "bknd";
|
||||
import {
|
||||
CustomField,
|
||||
Field,
|
||||
FieldWrapper,
|
||||
Form,
|
||||
FormContextOverride,
|
||||
FormDebug,
|
||||
ObjectJsonField,
|
||||
Subscribe,
|
||||
useDerivedFieldContext,
|
||||
useFormContext,
|
||||
useFormError,
|
||||
useFormValue,
|
||||
} from "ui/components/form/json-schema-form";
|
||||
import type { TPermission } from "auth/authorize/Permission";
|
||||
import type { RoleSchema } from "auth/authorize/Role";
|
||||
import { SegmentedControl, Tooltip } from "@mantine/core";
|
||||
import { Popover } from "ui/components/overlay/Popover";
|
||||
import { cn } from "ui/lib/utils";
|
||||
import { JsonViewer } from "ui/components/code/JsonViewer";
|
||||
import { mountOnce, useApiQuery } from "ui/client";
|
||||
import { CodePreview } from "ui/components/code/CodePreview";
|
||||
import type { JsonError } from "json-schema-library";
|
||||
import { Alert } from "ui/components/display/Alert";
|
||||
|
||||
export function AuthRolesEdit(props) {
|
||||
useBrowserTitle(["Auth", "Roles", props.params.role]);
|
||||
|
||||
const { hasSecrets } = useBknd({ withSecrets: true });
|
||||
if (!hasSecrets) {
|
||||
return <Message.MissingPermission what="Roles & Permissions" />;
|
||||
@@ -20,31 +51,69 @@ export function AuthRolesEdit(props) {
|
||||
return <AuthRolesEditInternal {...props} />;
|
||||
}
|
||||
|
||||
function AuthRolesEditInternal({ params }) {
|
||||
// currently for backward compatibility
|
||||
function getSchema(authSchema: ModuleSchemas["auth"]) {
|
||||
const roles = authSchema.properties.roles.additionalProperties;
|
||||
return {
|
||||
...roles,
|
||||
properties: {
|
||||
...roles.properties,
|
||||
permissions: {
|
||||
...roles.properties.permissions.anyOf[1],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const formConfig = {
|
||||
options: {
|
||||
debug: isDebug(),
|
||||
},
|
||||
};
|
||||
|
||||
function AuthRolesEditInternal({ params }: { params: { role: string } }) {
|
||||
const [navigate] = useNavigate();
|
||||
const { config, actions } = useBkndAuth();
|
||||
const { config, schema: authSchema, actions } = useBkndAuth();
|
||||
const [error, setError] = useState<JsonError[]>();
|
||||
const roleName = params.role;
|
||||
const role = config.roles?.[roleName];
|
||||
const formRef = useRef<AuthRoleFormRef>(null);
|
||||
const { readonly, permissions } = useBknd();
|
||||
const schema = getSchema(authSchema);
|
||||
const data = {
|
||||
...role,
|
||||
// this is to maintain array structure
|
||||
permissions: permissions.map((p) => {
|
||||
return role?.permissions?.find((v: any) => v.permission === p.name);
|
||||
}),
|
||||
};
|
||||
|
||||
async function handleUpdate() {
|
||||
console.log("data", formRef.current?.isValid());
|
||||
if (!formRef.current?.isValid()) return;
|
||||
const data = formRef.current?.getData();
|
||||
const success = await actions.roles.patch(roleName, data);
|
||||
async function handleDelete() {
|
||||
const success = await actions.roles.delete(roleName);
|
||||
if (success) {
|
||||
navigate(routes.auth.roles.list());
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (await actions.roles.delete(roleName)) {
|
||||
navigate(routes.auth.roles.list());
|
||||
}
|
||||
async function handleUpdate(data: any) {
|
||||
setError(undefined);
|
||||
await actions.roles.patch(roleName, data);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form
|
||||
schema={schema as any}
|
||||
initialValues={data}
|
||||
{...formConfig}
|
||||
beforeSubmit={(data) => {
|
||||
return {
|
||||
...data,
|
||||
permissions: [...Object.values(data.permissions).filter(Boolean)],
|
||||
};
|
||||
}}
|
||||
onSubmit={handleUpdate}
|
||||
onInvalidSubmit={(errors) => {
|
||||
setError(errors);
|
||||
}}
|
||||
>
|
||||
<AppShell.SectionHeader
|
||||
right={
|
||||
<>
|
||||
@@ -57,7 +126,7 @@ function AuthRolesEditInternal({ params }) {
|
||||
absolute: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
!readonly && {
|
||||
label: "Delete",
|
||||
onClick: handleDelete,
|
||||
destructive: true,
|
||||
@@ -67,9 +136,25 @@ function AuthRolesEditInternal({ params }) {
|
||||
>
|
||||
<IconButton Icon={TbDots} />
|
||||
</Dropdown>
|
||||
<Button variant="primary" onClick={handleUpdate}>
|
||||
Update
|
||||
</Button>
|
||||
{!readonly && (
|
||||
<Subscribe
|
||||
selector={(state) => ({
|
||||
dirty: state.dirty,
|
||||
errors: state.errors.length > 0,
|
||||
submitting: state.submitting,
|
||||
})}
|
||||
>
|
||||
{({ dirty, errors, submitting }) => (
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={!dirty || errors || submitting}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
)}
|
||||
</Subscribe>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
className="pl-3"
|
||||
@@ -82,8 +167,368 @@ function AuthRolesEditInternal({ params }) {
|
||||
/>
|
||||
</AppShell.SectionHeader>
|
||||
<AppShell.Scrollable>
|
||||
<AuthRoleForm ref={formRef} role={role} />
|
||||
{error && <Alert.Exception message={"Invalid form data"} />}
|
||||
<div className="flex flex-col flex-grow px-5 py-5 gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Permissions />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<Field
|
||||
label="Should this role be the default?"
|
||||
name="is_default"
|
||||
description="In case an user is not assigned any role, this role will be assigned by default."
|
||||
descriptionPlacement="top"
|
||||
/>
|
||||
<Field
|
||||
label="Implicit allow missing permissions?"
|
||||
name="implicit_allow"
|
||||
description="This should be only used for admins. If a permission is not explicitly denied, it will be allowed."
|
||||
descriptionPlacement="top"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FormDebug />
|
||||
</AppShell.Scrollable>
|
||||
</>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
type PermissionsData = Exclude<RoleSchema["permissions"], string[] | undefined>;
|
||||
type PermissionData = PermissionsData[number];
|
||||
|
||||
const Permissions = () => {
|
||||
const { permissions } = useBknd();
|
||||
|
||||
const grouped = permissions.reduce(
|
||||
(acc, permission, index) => {
|
||||
const [group, name] = permission.name.split(".") as [string, string];
|
||||
if (!acc[group]) acc[group] = [];
|
||||
acc[group].push({ index, permission });
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, { index: number; permission: TPermission }[]>,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
{Object.entries(grouped).map(([group, rows]) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-2" key={group}>
|
||||
<h3 className="font-semibold">{ucFirst(group)} Permissions</h3>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-3 items-start">
|
||||
{rows.map(({ index, permission }) => (
|
||||
<Permission key={permission.name} permission={permission} index={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Permission = ({ permission, index }: { permission: TPermission; index?: number }) => {
|
||||
const path = `permissions.${index}`;
|
||||
const { value } = useDerivedFieldContext("permissions", (ctx) => {
|
||||
const v = ctx.value;
|
||||
if (!Array.isArray(v)) return undefined;
|
||||
const v2 = v.find((v) => v && v.permission === permission.name);
|
||||
return {
|
||||
set: !!v2,
|
||||
policies: (v2?.policies?.length ?? 0) as number,
|
||||
};
|
||||
});
|
||||
const { setValue } = useFormContext();
|
||||
const [open, setOpen] = useState(false);
|
||||
const policiesCount = value?.policies ?? 0;
|
||||
const isSet = value?.set ?? false;
|
||||
|
||||
async function handleSwitch() {
|
||||
if (isSet) {
|
||||
setValue(path, undefined);
|
||||
setOpen(false);
|
||||
} else {
|
||||
setValue(path, {
|
||||
permission: permission.name,
|
||||
policies: [],
|
||||
effect: "allow",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function toggleOpen() {
|
||||
setOpen((o) => !o);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
key={permission.name}
|
||||
className={cn("flex flex-col border border-muted", open && "border-primary/20")}
|
||||
>
|
||||
<div className={cn("flex flex-row gap-2 justify-between", open && "bg-primary/5")}>
|
||||
<div className="py-4 px-4 font-mono leading-none flex flex-row gap-2 items-center">
|
||||
{permission.name}
|
||||
{permission.filterable && (
|
||||
<Tooltip label="Permission supports filtering">
|
||||
<TbFilter className="opacity-50" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-grow" />
|
||||
<div className="flex flex-row gap-1 items-center px-2">
|
||||
<div className="relative flex flex-row gap-1 items-center">
|
||||
{policiesCount > 0 && (
|
||||
<div className="bg-primary/80 text-background rounded-full size-5 flex items-center justify-center text-sm font-bold pointer-events-none">
|
||||
{policiesCount}
|
||||
</div>
|
||||
)}
|
||||
<IconButton
|
||||
size="md"
|
||||
variant="ghost"
|
||||
disabled={!isSet}
|
||||
Icon={TbAdjustments}
|
||||
className={cn("disabled:opacity-20")}
|
||||
onClick={toggleOpen}
|
||||
/>
|
||||
</div>
|
||||
<Formy.Switch size="sm" checked={isSet} onChange={handleSwitch} />
|
||||
</div>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="px-3.5 py-3.5">
|
||||
<Policies path={`permissions.${index}.policies`} permission={permission} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Policies = ({ path, permission }: { path: string; permission: TPermission }) => {
|
||||
const {
|
||||
setValue,
|
||||
schema: policySchema,
|
||||
lib,
|
||||
deleteValue,
|
||||
value,
|
||||
} = useDerivedFieldContext(path, ({ value }) => {
|
||||
return {
|
||||
policies: (value?.length ?? 0) as number,
|
||||
};
|
||||
});
|
||||
const policiesCount = value?.policies ?? 0;
|
||||
|
||||
function handleAdd() {
|
||||
setValue(
|
||||
`${path}.${policiesCount}`,
|
||||
lib.getTemplate(undefined, policySchema!.items, {
|
||||
addOptionalProps: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function handleDelete(index: number) {
|
||||
deleteValue(`${path}.${index}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col", policiesCount > 0 && "gap-8")}>
|
||||
<div className="flex flex-col gap-5">
|
||||
{policiesCount > 0 &&
|
||||
Array.from({ length: policiesCount }).map((_, i) => (
|
||||
<FormContextOverride key={i} prefix={`${path}.${i}`} schema={policySchema.items!}>
|
||||
{i > 0 && <div className="h-px bg-muted" />}
|
||||
<div className="flex flex-row gap-2 items-start">
|
||||
<div className="flex flex-col flex-grow w-full">
|
||||
<Policy permission={permission} />
|
||||
</div>
|
||||
<IconButton Icon={TbTrash} onClick={() => handleDelete(i)} size="sm" />
|
||||
</div>
|
||||
</FormContextOverride>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-row justify-center">
|
||||
<Button onClick={handleAdd}>Add Policy</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const mergeSchemas = (...schemas: object[]) => {
|
||||
return s.allOf(schemas.filter(Boolean).map(s.fromSchema));
|
||||
};
|
||||
|
||||
function replaceEntitiesEnum(schema: Record<string, any>, entities: string[]) {
|
||||
if (!isObject(schema) || !Array.isArray(entities) || entities.length === 0) return schema;
|
||||
return transformObject(schema, (sub, name) => {
|
||||
if (name === "properties") {
|
||||
return transformObject(sub as Record<string, any>, (propConfig, propKey) => {
|
||||
if (propKey === "entity" && propConfig.type === "string") {
|
||||
return {
|
||||
...propConfig,
|
||||
enum: entities,
|
||||
};
|
||||
}
|
||||
return propConfig;
|
||||
});
|
||||
}
|
||||
return sub;
|
||||
});
|
||||
}
|
||||
|
||||
const Policy = ({
|
||||
permission,
|
||||
}: {
|
||||
permission: TPermission;
|
||||
}) => {
|
||||
const { value } = useDerivedFieldContext("", ({ value }) => ({
|
||||
effect: (value?.effect ?? "allow") as "allow" | "deny" | "filter",
|
||||
}));
|
||||
const $bknd = useBknd();
|
||||
const $permissions = useApiQuery((api) => api.system.permissions(), {
|
||||
use: [mountOnce],
|
||||
});
|
||||
const entities = Object.keys($bknd.config.data.entities ?? {});
|
||||
const ctx = $permissions.data
|
||||
? mergeSchemas(
|
||||
$permissions.data.context,
|
||||
replaceEntitiesEnum(permission.context ?? null, entities),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Field name="description" />
|
||||
|
||||
<CustomFieldWrapper
|
||||
name="condition"
|
||||
label="Condition"
|
||||
description="The condition that must be met for the policy to be applied."
|
||||
schema={
|
||||
ctx && {
|
||||
name: "Context",
|
||||
content: s.toTypes(ctx, "Context"),
|
||||
}
|
||||
}
|
||||
>
|
||||
<ObjectJsonField path="condition" />
|
||||
</CustomFieldWrapper>
|
||||
|
||||
<CustomField path="effect">
|
||||
{({ value, setValue }) => (
|
||||
<FieldWrapper
|
||||
name="effect"
|
||||
label="Effect"
|
||||
descriptionPlacement="label"
|
||||
description="The effect of the policy to take effect on met condition."
|
||||
>
|
||||
<SegmentedControl
|
||||
className="border border-muted"
|
||||
defaultValue={value}
|
||||
onChange={(value) => setValue(value)}
|
||||
data={
|
||||
["allow", "deny", permission.filterable ? "filter" : undefined]
|
||||
.filter(Boolean)
|
||||
.map((effect) => ({
|
||||
label: ucFirst(effect ?? ""),
|
||||
value: effect,
|
||||
})) as any
|
||||
}
|
||||
/>
|
||||
</FieldWrapper>
|
||||
)}
|
||||
</CustomField>
|
||||
|
||||
{value?.effect === "filter" && (
|
||||
<CustomFieldWrapper
|
||||
name="filter"
|
||||
label="Filter"
|
||||
description="Filter to apply to all queries on met condition."
|
||||
schema={
|
||||
ctx && {
|
||||
name: "Variables",
|
||||
content: s.toTypes(ctx, "Variables"),
|
||||
}
|
||||
}
|
||||
>
|
||||
<ObjectJsonField path="filter" />
|
||||
</CustomFieldWrapper>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomFieldWrapper = ({
|
||||
children,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
schema,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
name: string;
|
||||
label: string;
|
||||
description: string;
|
||||
schema?: {
|
||||
name: string;
|
||||
content: string | object;
|
||||
};
|
||||
}) => {
|
||||
const errors = useFormError(name, { strict: true });
|
||||
const Errors = errors.length > 0 && (
|
||||
<Formy.ErrorMessage>{errors.map((e) => e.message).join(", ")}</Formy.ErrorMessage>
|
||||
);
|
||||
|
||||
return (
|
||||
<Formy.Group as="div">
|
||||
<Formy.Label
|
||||
as="label"
|
||||
htmlFor={name}
|
||||
className="flex flex-row gap-1 justify-between items-center"
|
||||
>
|
||||
<div className="flex flex-row gap-1 items-center">
|
||||
{label}
|
||||
{description && (
|
||||
<Tooltip label={description}>
|
||||
<TbInfoCircle className="size-4 opacity-50" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{schema && (
|
||||
<div>
|
||||
<Popover
|
||||
overlayProps={{
|
||||
className: "max-w-none",
|
||||
}}
|
||||
position="bottom-end"
|
||||
target={() =>
|
||||
typeof schema.content === "object" ? (
|
||||
<JsonViewer
|
||||
className="w-auto max-w-120 bg-background pr-3 text-sm"
|
||||
json={schema.content}
|
||||
title={schema.name}
|
||||
expand={5}
|
||||
/>
|
||||
) : (
|
||||
<CodePreview
|
||||
code={schema.content}
|
||||
lang="typescript"
|
||||
className="w-auto max-w-120 bg-background p-3 text-sm"
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Button variant="ghost" size="smaller" IconLeft={TbCodeDots}>
|
||||
{autoFormatString(schema.name)}
|
||||
</Button>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
</Formy.Label>
|
||||
{children}
|
||||
{Errors}
|
||||
</Formy.Group>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,15 +11,33 @@ import { Button } from "../../components/buttons/Button";
|
||||
import { CellValue, DataTable } from "../../components/table/DataTable";
|
||||
import * as AppShell from "../../layouts/AppShell/AppShell";
|
||||
import { routes, useNavigate } from "../../lib/routes";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||
import { Message } from "ui/components/display/Message";
|
||||
|
||||
export function AuthRolesList() {
|
||||
export function AuthRolesList(props) {
|
||||
useBrowserTitle(["Auth", "Roles"]);
|
||||
|
||||
const { hasSecrets } = useBknd({ withSecrets: true });
|
||||
if (!hasSecrets) {
|
||||
return <Message.MissingPermission what="Auth Roles" />;
|
||||
}
|
||||
|
||||
return <AuthRolesListInternal {...props} />;
|
||||
}
|
||||
|
||||
function AuthRolesListInternal() {
|
||||
const [navigate] = useNavigate();
|
||||
const { config, actions } = useBkndAuth();
|
||||
const { readonly } = useBknd();
|
||||
|
||||
const data = Object.values(
|
||||
transformObject(config.roles ?? {}, (role, name) => ({
|
||||
role: name,
|
||||
permissions: role.permissions,
|
||||
permissions: role.permissions?.map((p) => p.permission) as string[],
|
||||
policies: role.permissions
|
||||
?.flatMap((p) => p.policies?.length ?? 0)
|
||||
.reduce((acc, curr) => acc + curr, 0),
|
||||
is_default: role.is_default ?? false,
|
||||
implicit_allow: role.implicit_allow ?? false,
|
||||
})),
|
||||
@@ -30,6 +48,7 @@ export function AuthRolesList() {
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
if (readonly) return;
|
||||
bkndModals.open(
|
||||
"form",
|
||||
{
|
||||
@@ -59,9 +78,11 @@ export function AuthRolesList() {
|
||||
<>
|
||||
<AppShell.SectionHeader
|
||||
right={
|
||||
<Button variant="primary" onClick={openCreateModal}>
|
||||
Create new
|
||||
</Button>
|
||||
!readonly && (
|
||||
<Button variant="primary" onClick={openCreateModal}>
|
||||
Create new
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
Roles & Permissions
|
||||
@@ -89,6 +110,9 @@ const renderValue = ({ value, property }) => {
|
||||
if (["is_default", "implicit_allow"].includes(property)) {
|
||||
return value ? <span>Yes</span> : <span className="opacity-50">No</span>;
|
||||
}
|
||||
if (property === "policies") {
|
||||
return value ? <span>{value}</span> : <span className="opacity-50">0</span>;
|
||||
}
|
||||
|
||||
if (property === "permissions") {
|
||||
const max = 3;
|
||||
|
||||
@@ -52,6 +52,7 @@ const formConfig = {
|
||||
|
||||
function AuthSettingsInternal() {
|
||||
const { config, schema: _schema, actions, $auth } = useBkndAuth();
|
||||
const { readonly } = useBknd();
|
||||
const schema = JSON.parse(JSON.stringify(_schema));
|
||||
|
||||
schema.properties.jwt.required = ["alg"];
|
||||
@@ -61,7 +62,13 @@ function AuthSettingsInternal() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Form schema={schema} initialValues={config as any} onSubmit={onSubmit} {...formConfig}>
|
||||
<Form
|
||||
schema={schema}
|
||||
initialValues={config as any}
|
||||
onSubmit={onSubmit}
|
||||
{...formConfig}
|
||||
readOnly={readonly}
|
||||
>
|
||||
<Subscribe
|
||||
selector={(state) => ({
|
||||
dirty: state.dirty,
|
||||
@@ -73,13 +80,15 @@ function AuthSettingsInternal() {
|
||||
<AppShell.SectionHeader
|
||||
className="pl-4"
|
||||
right={
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={!dirty || errors || submitting}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
!readonly && (
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={!dirty || errors || submitting}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
Settings
|
||||
|
||||
@@ -60,6 +60,7 @@ const formOptions = {
|
||||
};
|
||||
|
||||
function AuthStrategiesListInternal() {
|
||||
const { readonly } = useBknd();
|
||||
const $auth = useBkndAuth();
|
||||
const config = $auth.config.strategies;
|
||||
const schema = $auth.schema.properties.strategies;
|
||||
@@ -80,6 +81,7 @@ function AuthStrategiesListInternal() {
|
||||
initialValues={config}
|
||||
onSubmit={handleSubmit}
|
||||
options={formOptions}
|
||||
readOnly={readonly}
|
||||
>
|
||||
<Subscribe
|
||||
selector={(state) => ({
|
||||
@@ -92,13 +94,15 @@ function AuthStrategiesListInternal() {
|
||||
<AppShell.SectionHeader
|
||||
className="pl-4"
|
||||
right={
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={!dirty || errors || submitting}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
!readonly && (
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={!dirty || errors || submitting}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
Strategies
|
||||
|
||||
@@ -34,7 +34,11 @@ export const AuthRoleForm = forwardRef<
|
||||
getValues,
|
||||
} = useForm({
|
||||
resolver: standardSchemaResolver(schema),
|
||||
defaultValues: role,
|
||||
defaultValues: {
|
||||
...role,
|
||||
// compat
|
||||
permissions: role?.permissions?.map((p) => p.permission),
|
||||
},
|
||||
});
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
@@ -47,7 +51,7 @@ export const AuthRoleForm = forwardRef<
|
||||
<div className="flex flex-col flex-grow px-5 py-5 gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
{/*<h3 className="font-semibold">Role Permissions</h3>*/}
|
||||
<Permissions control={control} permissions={permissions} />
|
||||
<Permissions control={control} permissions={permissions.map((p) => p.name)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input.Wrapper
|
||||
@@ -111,8 +115,6 @@ const Permissions = ({
|
||||
{} as Record<string, string[]>,
|
||||
);
|
||||
|
||||
console.log("grouped", grouped);
|
||||
//console.log("fieldState", fieldState, value);
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
{Object.entries(grouped).map(([group, permissions]) => {
|
||||
@@ -121,7 +123,7 @@ const Permissions = ({
|
||||
<h3 className="font-semibold">{ucFirst(group)} Permissions</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-3 gap-3">
|
||||
{permissions.map((permission) => {
|
||||
const selected = data.includes(permission);
|
||||
const selected = data.includes(permission as any);
|
||||
return (
|
||||
<div key={permission} className="flex flex-col border border-muted">
|
||||
<div className="flex flex-row gap-2 justify-between">
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
import { routes, useNavigate, useRouteNavigate } from "ui/lib/routes";
|
||||
import { testIds } from "ui/lib/config";
|
||||
import { SchemaEditable, useBknd } from "ui/client/bknd";
|
||||
|
||||
export function DataRoot({ children }) {
|
||||
// @todo: settings routes should be centralized
|
||||
@@ -73,9 +74,11 @@ export function DataRoot({ children }) {
|
||||
value={context}
|
||||
onChange={handleSegmentChange}
|
||||
/>
|
||||
<Tooltip label="New Entity">
|
||||
<IconButton Icon={TbDatabasePlus} onClick={$data.modals.createEntity} />
|
||||
</Tooltip>
|
||||
<SchemaEditable>
|
||||
<Tooltip label="New Entity">
|
||||
<IconButton Icon={TbDatabasePlus} onClick={$data.modals.createEntity} />
|
||||
</Tooltip>
|
||||
</SchemaEditable>
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -109,18 +112,27 @@ const EntityLinkList = ({
|
||||
suggestCreate = false,
|
||||
}: { entities: Entity[]; title?: string; context: "data" | "schema"; suggestCreate?: boolean }) => {
|
||||
const { $data } = useBkndData();
|
||||
const { readonly } = useBknd();
|
||||
const navigate = useRouteNavigate();
|
||||
|
||||
if (entities.length === 0) {
|
||||
return suggestCreate ? (
|
||||
<Empty
|
||||
className="py-10"
|
||||
description="Create your first entity to get started."
|
||||
secondary={{
|
||||
children: "Create entity",
|
||||
onClick: () => $data.modals.createEntity(),
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
if (suggestCreate) {
|
||||
if (readonly) {
|
||||
return <Empty className="py-10" description="No entities created." />;
|
||||
}
|
||||
return (
|
||||
<Empty
|
||||
className="py-10"
|
||||
description="Create your first entity to get started."
|
||||
secondary={{
|
||||
children: "Create entity",
|
||||
onClick: () => $data.modals.createEntity(),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleClick(entity: Entity) {
|
||||
@@ -160,7 +172,7 @@ const EntityLinkList = ({
|
||||
href={href}
|
||||
className="justify-between items-center"
|
||||
>
|
||||
{entity.label}
|
||||
<span className="truncate">{entity.label}</span>
|
||||
|
||||
{isLinkActive(href, 1) && (
|
||||
<Button
|
||||
@@ -203,7 +215,9 @@ const EntityContextMenu = ({
|
||||
href && {
|
||||
icon: IconExternalLink,
|
||||
label: "Open in tab",
|
||||
onClick: () => navigate(href, { target: "_blank" }),
|
||||
onClick: () => {
|
||||
navigate(href, { target: "_blank", absolute: true });
|
||||
},
|
||||
},
|
||||
separator,
|
||||
!$data.system(entity.name).any && {
|
||||
@@ -254,11 +268,26 @@ export function DataEmpty() {
|
||||
useBrowserTitle(["Data"]);
|
||||
const [navigate] = useNavigate();
|
||||
const { $data } = useBkndData();
|
||||
const { readonly } = useBknd();
|
||||
|
||||
function handleButtonClick() {
|
||||
navigate(routes.data.schema.root());
|
||||
}
|
||||
|
||||
if (readonly) {
|
||||
return (
|
||||
<Empty
|
||||
Icon={IconDatabase}
|
||||
title="No entity selected"
|
||||
description="Please select an entity from the left sidebar."
|
||||
primary={{
|
||||
children: "Go to schema",
|
||||
onClick: handleButtonClick,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Empty
|
||||
Icon={IconDatabase}
|
||||
|
||||
@@ -17,6 +17,8 @@ import { bkndModals } from "ui/modals";
|
||||
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} />;
|
||||
@@ -52,20 +54,32 @@ function DataEntityUpdateImpl({ params }) {
|
||||
},
|
||||
);
|
||||
|
||||
const options = useEntityAdminOptions(entity, "update", $q.data);
|
||||
const backHref = routes.data.entity.list(entity.name);
|
||||
const goBack = () => _goBack({ fallback: backHref });
|
||||
|
||||
async function onSubmitted(changeSet?: EntityData) {
|
||||
//return;
|
||||
if (!changeSet) {
|
||||
goBack();
|
||||
notifications.show({
|
||||
title: `Updating ${entity?.label}`,
|
||||
message: "No changes to update",
|
||||
color: "yellow",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await $q.update(changeSet);
|
||||
if (error) setError(null);
|
||||
goBack();
|
||||
notifications.show({
|
||||
title: `Updating ${entity?.label}`,
|
||||
message: `Successfully updated ID ${entityId}`,
|
||||
color: "green",
|
||||
});
|
||||
|
||||
// make sure form picks up the latest data
|
||||
Form.reset();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to update");
|
||||
}
|
||||
@@ -76,6 +90,11 @@ function DataEntityUpdateImpl({ params }) {
|
||||
try {
|
||||
await $q._delete();
|
||||
if (error) setError(null);
|
||||
notifications.show({
|
||||
title: `Deleting ${entity?.label}`,
|
||||
message: `Successfully deleted ID ${entityId}`,
|
||||
color: "green",
|
||||
});
|
||||
goBack();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to delete");
|
||||
@@ -111,6 +130,7 @@ function DataEntityUpdateImpl({ params }) {
|
||||
<Dropdown
|
||||
position="bottom-end"
|
||||
items={[
|
||||
...(options.actions?.context ?? []),
|
||||
{
|
||||
label: "Inspect",
|
||||
onClick: () => {
|
||||
@@ -142,6 +162,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]) => (
|
||||
@@ -171,6 +195,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}
|
||||
@@ -186,6 +211,8 @@ function DataEntityUpdateImpl({ params }) {
|
||||
action="update"
|
||||
className="flex flex-grow flex-col gap-3 p-3"
|
||||
/>
|
||||
{options.footer}
|
||||
|
||||
{targetRelations.length > 0 ? (
|
||||
<EntityDetailRelations
|
||||
id={entityId}
|
||||
@@ -233,6 +260,7 @@ function EntityDetailRelations({
|
||||
return {
|
||||
as: "button",
|
||||
type: "button",
|
||||
//label: ucFirst(other.entity.label),
|
||||
label: ucFirst(other.reference),
|
||||
onClick: () => handleClick(relation),
|
||||
active: selected?.other(entity).reference === other.reference,
|
||||
@@ -273,7 +301,11 @@ function EntityDetailInner({
|
||||
|
||||
// @todo: add custom key for invalidation
|
||||
const $q = useApiQuery(
|
||||
(api) => api.data.readManyByReference(entity.name, id, other.reference, search),
|
||||
(api) =>
|
||||
api.data.readManyByReference(entity.name, id, other.reference, {
|
||||
...search,
|
||||
limit: search.limit + 1 /* overfetch for softscan=false */,
|
||||
}),
|
||||
{
|
||||
keepPreviousData: true,
|
||||
revalidateOnFocus: true,
|
||||
@@ -292,7 +324,6 @@ function EntityDetailInner({
|
||||
navigate(routes.data.entity.create(other.entity.name), {
|
||||
query: ref.where,
|
||||
});
|
||||
//navigate(routes.data.entity.create(other.entity.name) + `?${query}`);
|
||||
};
|
||||
}
|
||||
} catch (e) {}
|
||||
@@ -302,6 +333,7 @@ function EntityDetailInner({
|
||||
}
|
||||
|
||||
const isUpdating = $q.isValidating || $q.isLoading;
|
||||
const meta = $q.data?.body.meta;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -316,7 +348,7 @@ function EntityDetailInner({
|
||||
onClickRow={handleClickRow}
|
||||
onClickNew={handleClickNew}
|
||||
page={Math.floor(search.offset / search.limit) + 1}
|
||||
total={$q.data?.body?.meta?.count ?? 1}
|
||||
total={meta?.count}
|
||||
onClickPage={(page) => {
|
||||
setSearch((s) => ({
|
||||
...s,
|
||||
|
||||
@@ -12,6 +12,11 @@ import { routes, useNavigate } from "ui/lib/routes";
|
||||
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();
|
||||
@@ -22,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"]);
|
||||
@@ -39,10 +45,18 @@ export function DataEntityCreate({ params }) {
|
||||
if (!changeSet) return;
|
||||
|
||||
try {
|
||||
await $q.create(changeSet);
|
||||
const result = await $q.create(changeSet);
|
||||
if (error) setError(null);
|
||||
// @todo: navigate to created?
|
||||
goBack();
|
||||
if (result.id) {
|
||||
notifications.show({
|
||||
title: `Creating ${entity?.label}`,
|
||||
message: `Successfully created with ID ${result.id}`,
|
||||
color: "green",
|
||||
});
|
||||
navigate(routes.data.entity.edit(params.entity, result.id));
|
||||
} else {
|
||||
goBack();
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to create");
|
||||
}
|
||||
@@ -62,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]) => (
|
||||
@@ -79,10 +102,15 @@ export function DataEntityCreate({ params }) {
|
||||
/>
|
||||
</>
|
||||
}
|
||||
className="pl-3"
|
||||
>
|
||||
<Breadcrumbs2 backTo={backHref} path={[{ label: entity.label }, { label: "Create" }]} />
|
||||
<Breadcrumbs2
|
||||
backTo={backHref}
|
||||
path={[{ label: entity.label, href: backHref }, { label: "Create" }]}
|
||||
/>
|
||||
</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}
|
||||
@@ -97,6 +125,7 @@ export function DataEntityCreate({ params }) {
|
||||
action="create"
|
||||
className="flex flex-grow flex-col gap-3 p-3"
|
||||
/>
|
||||
{options.footer}
|
||||
</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 { 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();
|
||||
@@ -59,7 +61,7 @@ function DataEntityListImpl({ params }) {
|
||||
(api) =>
|
||||
api.data.readMany(entity?.name as any, {
|
||||
select: search.value.select,
|
||||
limit: search.value.perPage,
|
||||
limit: search.value.perPage + 1 /* overfetch for softscan=false */,
|
||||
offset: (search.value.page - 1) * search.value.perPage,
|
||||
sort: `${search.value.sort.dir === "asc" ? "" : "-"}${search.value.sort.by}`,
|
||||
}),
|
||||
@@ -100,6 +102,7 @@ function DataEntityListImpl({ params }) {
|
||||
<>
|
||||
<Dropdown
|
||||
items={[
|
||||
...(options.actions?.context ?? []),
|
||||
{
|
||||
label: "Settings",
|
||||
onClick: () => navigate(routes.data.schema.entity(entity.name)),
|
||||
@@ -120,13 +123,18 @@ 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} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
{entity.label}
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -31,6 +31,7 @@ import { fieldSpecs } from "ui/modules/data/components/fields-specs";
|
||||
import { extractSchema } from "../settings/utils/schema";
|
||||
import { EntityFieldsForm, type EntityFieldsFormRef } from "./forms/entity.fields.form";
|
||||
import { RoutePathStateProvider } from "ui/hooks/use-route-path-state";
|
||||
import { SchemaEditable, useBknd } from "ui/client/bknd";
|
||||
|
||||
export function DataSchemaEntity({ params }) {
|
||||
const { $data } = useBkndData();
|
||||
@@ -67,29 +68,31 @@ export function DataSchemaEntity({ params }) {
|
||||
>
|
||||
<IconButton Icon={TbDots} />
|
||||
</Dropdown>
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
icon: TbCirclesRelation,
|
||||
label: "Add relation",
|
||||
onClick: () => $data.modals.createRelation(entity.name),
|
||||
},
|
||||
{
|
||||
icon: TbPhoto,
|
||||
label: "Add media",
|
||||
onClick: () => $data.modals.createMedia(entity.name),
|
||||
},
|
||||
() => <div className="h-px my-1 w-full bg-primary/5" />,
|
||||
{
|
||||
icon: TbDatabasePlus,
|
||||
label: "Create Entity",
|
||||
onClick: () => $data.modals.createEntity(),
|
||||
},
|
||||
]}
|
||||
position="bottom-end"
|
||||
>
|
||||
<Button IconRight={TbPlus}>Add</Button>
|
||||
</Dropdown>
|
||||
<SchemaEditable>
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
icon: TbCirclesRelation,
|
||||
label: "Add relation",
|
||||
onClick: () => $data.modals.createRelation(entity.name),
|
||||
},
|
||||
{
|
||||
icon: TbPhoto,
|
||||
label: "Add media",
|
||||
onClick: () => $data.modals.createMedia(entity.name),
|
||||
},
|
||||
() => <div className="h-px my-1 w-full bg-primary/5" />,
|
||||
{
|
||||
icon: TbDatabasePlus,
|
||||
label: "Create Entity",
|
||||
onClick: () => $data.modals.createEntity(),
|
||||
},
|
||||
]}
|
||||
position="bottom-end"
|
||||
>
|
||||
<Button IconRight={TbPlus}>Add</Button>
|
||||
</Dropdown>
|
||||
</SchemaEditable>
|
||||
</>
|
||||
}
|
||||
className="pl-3"
|
||||
@@ -149,6 +152,7 @@ const Fields = ({ entity }: { entity: Entity }) => {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [updates, setUpdates] = useState(0);
|
||||
const { actions, $data, config } = useBkndData();
|
||||
const { readonly } = useBknd();
|
||||
const [res, setRes] = useState<any>();
|
||||
const ref = useRef<EntityFieldsFormRef>(null);
|
||||
async function handleUpdate() {
|
||||
@@ -169,7 +173,7 @@ const Fields = ({ entity }: { entity: Entity }) => {
|
||||
title="Fields"
|
||||
ActiveIcon={IconAlignJustified}
|
||||
renderHeaderRight={({ open }) =>
|
||||
open ? (
|
||||
open && !readonly ? (
|
||||
<Button variant="primary" disabled={!open} onClick={handleUpdate}>
|
||||
Update
|
||||
</Button>
|
||||
@@ -181,11 +185,12 @@ const Fields = ({ entity }: { entity: Entity }) => {
|
||||
<div className="animate-fade-in absolute w-full h-full top-0 bottom-0 left-0 right-0 bg-background/65 z-50" />
|
||||
)}
|
||||
<EntityFieldsForm
|
||||
readonly={readonly}
|
||||
routePattern={`/entity/${entity.name}/fields/:sub?`}
|
||||
fields={initialFields}
|
||||
ref={ref}
|
||||
key={String(updates)}
|
||||
sortable
|
||||
sortable={!readonly}
|
||||
additionalFieldTypes={fieldSpecs
|
||||
.filter((f) => ["relation", "media"].includes(f.type))
|
||||
.map((i) => ({
|
||||
@@ -205,7 +210,7 @@ const Fields = ({ entity }: { entity: Entity }) => {
|
||||
isNew={false}
|
||||
/>
|
||||
|
||||
{isDebug() && (
|
||||
{isDebug() && !readonly && (
|
||||
<div>
|
||||
<div className="flex flex-row gap-1 justify-center">
|
||||
<Button size="small" onClick={() => setRes(ref.current?.isValid())}>
|
||||
@@ -237,6 +242,7 @@ const BasicSettings = ({ entity }: { entity: Entity }) => {
|
||||
const d = useBkndData();
|
||||
const config = d.entities?.[entity.name]?.config;
|
||||
const formRef = useRef<JsonSchemaFormRef>(null);
|
||||
const { readonly } = useBknd();
|
||||
|
||||
const schema = cloneDeep(
|
||||
// @ts-ignore
|
||||
@@ -264,7 +270,7 @@ const BasicSettings = ({ entity }: { entity: Entity }) => {
|
||||
title="Settings"
|
||||
ActiveIcon={IconSettings}
|
||||
renderHeaderRight={({ open }) =>
|
||||
open ? (
|
||||
open && !readonly ? (
|
||||
<Button variant="primary" disabled={!open} onClick={handleUpdate}>
|
||||
Update
|
||||
</Button>
|
||||
@@ -278,6 +284,7 @@ const BasicSettings = ({ entity }: { entity: Entity }) => {
|
||||
formData={_config}
|
||||
onSubmit={console.log}
|
||||
className="legacy hide-required-mark fieldset-alternative mute-root"
|
||||
readonly={readonly}
|
||||
/>
|
||||
</div>
|
||||
</AppShell.RouteAwareSectionHeaderAccordionItem>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Suspense, lazy } from "react";
|
||||
import { SchemaEditable } from "ui/client/bknd";
|
||||
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
@@ -15,9 +16,11 @@ export function DataSchemaIndex() {
|
||||
<>
|
||||
<AppShell.SectionHeader
|
||||
right={
|
||||
<Button type="button" variant="primary" onClick={$data.modals.createAny}>
|
||||
Create new
|
||||
</Button>
|
||||
<SchemaEditable>
|
||||
<Button type="button" variant="primary" onClick={$data.modals.createAny}>
|
||||
Create new
|
||||
</Button>
|
||||
</SchemaEditable>
|
||||
}
|
||||
>
|
||||
Schema Overview
|
||||
|
||||
@@ -29,6 +29,7 @@ import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelec
|
||||
import type { TPrimaryFieldFormat } from "data/fields/PrimaryField";
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import ErrorBoundary from "ui/components/display/ErrorBoundary";
|
||||
import { SchemaEditable } from "ui/client/bknd";
|
||||
|
||||
const fieldsSchemaObject = originalFieldsSchemaObject;
|
||||
const fieldsSchema = s.anyOf(Object.values(fieldsSchemaObject));
|
||||
@@ -64,6 +65,7 @@ export type EntityFieldsFormProps = {
|
||||
routePattern?: string;
|
||||
defaultPrimaryFormat?: TPrimaryFieldFormat;
|
||||
isNew?: boolean;
|
||||
readonly?: boolean;
|
||||
};
|
||||
|
||||
export type EntityFieldsFormRef = {
|
||||
@@ -76,7 +78,7 @@ export type EntityFieldsFormRef = {
|
||||
|
||||
export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsFormProps>(
|
||||
function EntityFieldsForm(
|
||||
{ fields: _fields, sortable, additionalFieldTypes, routePattern, isNew, ...props },
|
||||
{ fields: _fields, sortable, additionalFieldTypes, routePattern, isNew, readonly, ...props },
|
||||
ref,
|
||||
) {
|
||||
const entityFields = Object.entries(_fields).map(([name, field]) => ({
|
||||
@@ -162,6 +164,7 @@ export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsForm
|
||||
disableIndices={[0]}
|
||||
renderItem={({ dnd, ...props }, index) => (
|
||||
<EntityFieldMemo
|
||||
readonly={readonly}
|
||||
key={props.id}
|
||||
field={props as any}
|
||||
index={index}
|
||||
@@ -181,6 +184,7 @@ export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsForm
|
||||
<div>
|
||||
{fields.map((field, index) => (
|
||||
<EntityField
|
||||
readonly={readonly}
|
||||
key={field.id}
|
||||
field={field as any}
|
||||
index={index}
|
||||
@@ -197,20 +201,22 @@ export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsForm
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Popover
|
||||
className="flex flex-col w-full"
|
||||
target={({ toggle }) => (
|
||||
<SelectType
|
||||
additionalFieldTypes={additionalFieldTypes}
|
||||
onSelected={toggle}
|
||||
onSelect={(type) => {
|
||||
handleAppend(type as any);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<Button className="justify-center">Add Field</Button>
|
||||
</Popover>
|
||||
<SchemaEditable>
|
||||
<Popover
|
||||
className="flex flex-col w-full"
|
||||
target={({ toggle }) => (
|
||||
<SelectType
|
||||
additionalFieldTypes={additionalFieldTypes}
|
||||
onSelected={toggle}
|
||||
onSelect={(type) => {
|
||||
handleAppend(type as any);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<Button className="justify-center">Add Field</Button>
|
||||
</Popover>
|
||||
</SchemaEditable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -288,6 +294,7 @@ function EntityField({
|
||||
dnd,
|
||||
routePattern,
|
||||
primary,
|
||||
readonly,
|
||||
}: {
|
||||
field: FieldArrayWithId<TFieldsFormSchema, "fields", "id">;
|
||||
index: number;
|
||||
@@ -303,6 +310,7 @@ function EntityField({
|
||||
defaultFormat?: TPrimaryFieldFormat;
|
||||
editable?: boolean;
|
||||
};
|
||||
readonly?: boolean;
|
||||
}) {
|
||||
const prefix = `fields.${index}.field` as const;
|
||||
const type = field.field.type;
|
||||
@@ -393,6 +401,7 @@ function EntityField({
|
||||
<span className="text-xs text-primary/50 leading-none">Required</span>
|
||||
<MantineSwitch
|
||||
size="sm"
|
||||
disabled={readonly}
|
||||
name={`${prefix}.config.required`}
|
||||
control={control}
|
||||
/>
|
||||
@@ -433,6 +442,7 @@ function EntityField({
|
||||
<div className="flex flex-row">
|
||||
<MantineSwitch
|
||||
label="Required"
|
||||
disabled={readonly}
|
||||
name={`${prefix}.config.required`}
|
||||
control={control}
|
||||
/>
|
||||
@@ -440,11 +450,13 @@ function EntityField({
|
||||
<TextInput
|
||||
label="Label"
|
||||
placeholder="Label"
|
||||
disabled={readonly}
|
||||
{...register(`${prefix}.config.label`)}
|
||||
/>
|
||||
<Textarea
|
||||
label="Description"
|
||||
placeholder="Description"
|
||||
disabled={readonly}
|
||||
{...register(`${prefix}.config.description`)}
|
||||
/>
|
||||
{!hidden.includes("virtual") && (
|
||||
@@ -452,7 +464,7 @@ function EntityField({
|
||||
label="Virtual"
|
||||
name={`${prefix}.config.virtual`}
|
||||
control={control}
|
||||
disabled={disabled.includes("virtual")}
|
||||
disabled={disabled.includes("virtual") || readonly}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -468,6 +480,7 @@ function EntityField({
|
||||
...value,
|
||||
});
|
||||
}}
|
||||
readonly={readonly}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
@@ -478,16 +491,18 @@ function EntityField({
|
||||
return <JsonViewer json={json} expand={4} />;
|
||||
})()}
|
||||
</Tabs.Panel>
|
||||
<div className="flex flex-row justify-end">
|
||||
<Button
|
||||
IconLeft={TbTrash}
|
||||
onClick={handleDelete(index)}
|
||||
size="small"
|
||||
variant="subtlered"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
{!readonly && (
|
||||
<div className="flex flex-row justify-end">
|
||||
<Button
|
||||
IconLeft={TbTrash}
|
||||
onClick={handleDelete(index)}
|
||||
size="small"
|
||||
variant="subtlered"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
@@ -498,9 +513,11 @@ function EntityField({
|
||||
const SpecificForm = ({
|
||||
field,
|
||||
onChange,
|
||||
readonly,
|
||||
}: {
|
||||
field: FieldArrayWithId<TFieldsFormSchema, "fields", "id">;
|
||||
onChange: (value: any) => void;
|
||||
readonly?: boolean;
|
||||
}) => {
|
||||
const type = field.field.type;
|
||||
const specificData = omit(field.field.config, commonProps);
|
||||
@@ -513,6 +530,7 @@ const SpecificForm = ({
|
||||
uiSchema={dataFieldsUiSchema.config}
|
||||
className="legacy hide-required-mark fieldset-alternative mute-root"
|
||||
onChange={onChange}
|
||||
readonly={readonly}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import { FlashMessage } from "ui/modules/server/FlashMessage";
|
||||
import { AuthRegister } from "ui/routes/auth/auth.register";
|
||||
import { BkndModalsProvider } from "ui/modals";
|
||||
import { useBkndWindowContext } from "ui/client";
|
||||
import ToolsRoutes from "./tools";
|
||||
|
||||
// @ts-ignore
|
||||
const TestRoutes = lazy(() => import("./test"));
|
||||
@@ -19,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;
|
||||
@@ -43,6 +49,8 @@ export function Routes({
|
||||
</Suspense>
|
||||
</Route>
|
||||
|
||||
{children}
|
||||
|
||||
<Route path="/" component={RootEmpty} />
|
||||
<Route path="/data" nest>
|
||||
<Suspense fallback={null}>
|
||||
@@ -69,6 +77,11 @@ export function Routes({
|
||||
<SettingsRoutes />
|
||||
</Suspense>
|
||||
</Route>
|
||||
<Route path="/tools" nest>
|
||||
<Suspense fallback={null}>
|
||||
<ToolsRoutes />
|
||||
</Suspense>
|
||||
</Route>
|
||||
|
||||
<Route path="*" component={NotFound} />
|
||||
</Switch>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { IconAlertHexagon } from "@tabler/icons-react";
|
||||
import { TbSettings } from "react-icons/tb";
|
||||
import { useBknd } from "ui/client/BkndProvider";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { Icon } from "ui/components/display/Icon";
|
||||
import { Link } from "ui/components/wouter/Link";
|
||||
import { Media } from "ui/elements";
|
||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ const formConfig = {
|
||||
|
||||
function MediaSettingsInternal() {
|
||||
const { config, schema: _schema, actions } = useBkndMedia();
|
||||
const { readonly } = useBknd();
|
||||
const schema = JSON.parse(JSON.stringify(_schema));
|
||||
|
||||
schema.if = { properties: { enabled: { const: true } } };
|
||||
@@ -53,7 +54,13 @@ function MediaSettingsInternal() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form schema={schema} initialValues={config as any} onSubmit={onSubmit} {...formConfig}>
|
||||
<Form
|
||||
schema={schema}
|
||||
initialValues={config as any}
|
||||
onSubmit={onSubmit}
|
||||
{...formConfig}
|
||||
readOnly={readonly}
|
||||
>
|
||||
<Subscribe
|
||||
selector={(state) => ({
|
||||
dirty: state.dirty,
|
||||
@@ -64,13 +71,15 @@ function MediaSettingsInternal() {
|
||||
{({ dirty, errors, submitting }) => (
|
||||
<AppShell.SectionHeader
|
||||
right={
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={!dirty || errors || submitting}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
!readonly && (
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={!dirty || errors || submitting}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
Settings
|
||||
@@ -132,6 +141,7 @@ const AdapterIcon = ({ type }: { type: string }) => {
|
||||
|
||||
function Adapters() {
|
||||
const ctx = AnyOf.useContext();
|
||||
const { readonly } = useBknd();
|
||||
|
||||
return (
|
||||
<Formy.Group>
|
||||
@@ -139,7 +149,7 @@ function Adapters() {
|
||||
<span className="font-bold">Media Adapter:</span>
|
||||
{ctx.selected === null && <span className="opacity-70"> (Choose one)</span>}
|
||||
</Formy.Label>
|
||||
<div className="flex flex-row gap-1 mb-2">
|
||||
<div className="grid grid-cols-2 md:flex flex-row gap-1 mb-2 flex-wrap">
|
||||
{ctx.schemas?.map((schema: any, i) => (
|
||||
<Button
|
||||
key={i}
|
||||
@@ -150,6 +160,7 @@ function Adapters() {
|
||||
"flex flex-row items-center justify-center gap-3 border",
|
||||
ctx.selected === i && "border-primary",
|
||||
)}
|
||||
disabled={readonly}
|
||||
>
|
||||
<div>
|
||||
<AdapterIcon type={schema.properties.type.const} />
|
||||
@@ -165,7 +176,7 @@ function Adapters() {
|
||||
</div>
|
||||
{ctx.selected !== null && (
|
||||
<Formy.Group as="fieldset" error={ctx.errors.length > 0}>
|
||||
<Formy.Label as="legend" className="font-mono px-2">
|
||||
<Formy.Label as="legend" className="font-mono px-2 w-min-content">
|
||||
{autoFormatString(ctx.selectedSchema!.title!)}
|
||||
</Formy.Label>
|
||||
<FormContextOverride schema={ctx.selectedSchema} prefix={ctx.path}>
|
||||
|
||||
@@ -54,7 +54,7 @@ export function Setting<Schema extends s.ObjectSchema = s.ObjectSchema>({
|
||||
properties,
|
||||
}: SettingProps<Schema>) {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { actions } = useBknd();
|
||||
const { actions, readonly } = useBknd();
|
||||
const formRef = useRef<JsonSchemaFormRef>(null);
|
||||
const schemaLocalModalRef = useRef<SettingsSchemaModalRef>(null);
|
||||
const schemaModalRef = useRef<SettingsSchemaModalRef>(null);
|
||||
@@ -107,8 +107,8 @@ export function Setting<Schema extends s.ObjectSchema = s.ObjectSchema>({
|
||||
return;
|
||||
});
|
||||
|
||||
const deleteAllowed = options?.allowDelete?.(config) ?? true;
|
||||
const editAllowed = options?.allowEdit?.(config) ?? true;
|
||||
const deleteAllowed = (options?.allowDelete?.(config) ?? true) && !readonly;
|
||||
const editAllowed = (options?.allowEdit?.(config) ?? true) && !readonly;
|
||||
const showAlert = options?.showAlert?.(config) ?? undefined;
|
||||
|
||||
console.log("--setting", { schema, config, prefix, path, exclude });
|
||||
|
||||
@@ -30,7 +30,7 @@ export const SettingNewModal = ({
|
||||
const [location, navigate] = useLocation();
|
||||
const [formSchema, setFormSchema] = useState(schema);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { actions } = useBknd();
|
||||
const { actions, readonly } = useBknd();
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const isGeneratedKey = generateKey !== undefined;
|
||||
const isStaticGeneratedKey = typeof generateKey === "string";
|
||||
@@ -98,15 +98,17 @@ export const SettingNewModal = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row">
|
||||
{isAnyOf ? (
|
||||
<Dropdown position="top-start" items={anyOfItems} itemsClassName="gap-3">
|
||||
<Button>Add new</Button>
|
||||
</Dropdown>
|
||||
) : (
|
||||
<Button onClick={open}>Add new</Button>
|
||||
)}
|
||||
</div>
|
||||
{!readonly && (
|
||||
<div className="flex flex-row">
|
||||
{isAnyOf ? (
|
||||
<Dropdown position="top-start" items={anyOfItems} itemsClassName="gap-3">
|
||||
<Button>Add new</Button>
|
||||
</Dropdown>
|
||||
) : (
|
||||
<Button onClick={open}>Add new</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={opened}
|
||||
|
||||
@@ -63,10 +63,10 @@ export const AuthSettings = ({ schema: _unsafe_copy, config }) => {
|
||||
} catch (e) {}
|
||||
console.log("_s", _s);
|
||||
const roleSchema = _schema.properties.roles?.additionalProperties ?? { type: "object" };
|
||||
if (_s.permissions) {
|
||||
/* if (_s.permissions) {
|
||||
roleSchema.properties.permissions.items.enum = _s.permissions;
|
||||
roleSchema.properties.permissions.uniqueItems = true;
|
||||
}
|
||||
} */
|
||||
|
||||
return (
|
||||
<Route path="/auth" nest>
|
||||
|
||||
@@ -67,7 +67,7 @@ export const DataSettings = ({
|
||||
schema,
|
||||
config,
|
||||
}: { schema: ModuleSchemas["data"]; config: ModuleConfigs["data"] }) => {
|
||||
const { app } = useBknd();
|
||||
const { app, readonly } = useBknd();
|
||||
const prefix = app.getAbsolutePath("settings");
|
||||
const entities = Object.keys(config.entities ?? {});
|
||||
|
||||
@@ -105,7 +105,7 @@ export const DataSettings = ({
|
||||
options={{
|
||||
showAlert: (config: any) => {
|
||||
// it's weird, but after creation, the config is not set (?)
|
||||
if (config?.type === "primary") {
|
||||
if (config?.type === "primary" && !readonly) {
|
||||
return "Modifying the primary field may result in strange behaviors.";
|
||||
}
|
||||
return;
|
||||
@@ -137,7 +137,7 @@ export const DataSettings = ({
|
||||
config={config.entities?.[entity] as any}
|
||||
options={{
|
||||
showAlert: (config: any) => {
|
||||
if (config.type === "system") {
|
||||
if (config.type === "system" && !readonly) {
|
||||
return "Modifying the system entities may result in strange behaviors.";
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -26,6 +26,8 @@ import SchemaTest from "./tests/schema-test";
|
||||
import SortableTest from "./tests/sortable-test";
|
||||
import { SqlAiTest } from "./tests/sql-ai-test";
|
||||
import Themes from "./tests/themes";
|
||||
import ErrorBoundary from "ui/components/display/ErrorBoundary";
|
||||
import CodeEditorTest from "./tests/code-editor-test";
|
||||
|
||||
const tests = {
|
||||
DropdownTest,
|
||||
@@ -51,6 +53,7 @@ const tests = {
|
||||
JsonSchemaForm3,
|
||||
FormyTest,
|
||||
HtmlFormTest,
|
||||
CodeEditorTest,
|
||||
} as const;
|
||||
|
||||
export default function TestRoutes() {
|
||||
@@ -88,7 +91,9 @@ function TestRoot({ children }) {
|
||||
</div>
|
||||
</AppShell.Scrollable>
|
||||
</AppShell.Sidebar>
|
||||
<AppShell.Main>{children}</AppShell.Main>
|
||||
<AppShell.Main key={window.location.href}>
|
||||
<ErrorBoundary key={window.location.href}>{children}</ErrorBoundary>
|
||||
</AppShell.Main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
13
app/src/ui/routes/test/tests/code-editor-test.tsx
Normal file
13
app/src/ui/routes/test/tests/code-editor-test.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useState } from "react";
|
||||
import { JsonEditor } from "ui/components/code/JsonEditor";
|
||||
import { JsonViewer } from "ui/components/code/JsonViewer";
|
||||
|
||||
export default function CodeEditorTest() {
|
||||
const [value, setValue] = useState({});
|
||||
return (
|
||||
<div className="flex flex-col p-4">
|
||||
<JsonEditor value={value} onChange={setValue} />
|
||||
<JsonViewer json={value} expand={9} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { JSONSchema } from "json-schema-to-ts";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { s } from "bknd/utils";
|
||||
import {
|
||||
AnyOf,
|
||||
AnyOfField,
|
||||
@@ -55,6 +56,14 @@ const authSchema = {
|
||||
},
|
||||
} as const satisfies JSONSchema;
|
||||
|
||||
const objectCodeSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
config: { type: "object", properties: {} },
|
||||
},
|
||||
};
|
||||
|
||||
const formOptions = {
|
||||
debug: true,
|
||||
};
|
||||
@@ -73,7 +82,70 @@ export default function JsonSchemaForm3() {
|
||||
return (
|
||||
<Scrollable>
|
||||
<div className="flex flex-col p-3">
|
||||
<Form schema={_schema.auth.toJSON()} options={formOptions} />
|
||||
{/* <Form schema={_schema.auth.toJSON()} options={formOptions} /> */}
|
||||
|
||||
<Form
|
||||
schema={objectCodeSchema as any}
|
||||
options={formOptions}
|
||||
initialValues={{
|
||||
name: "Peter",
|
||||
config: {
|
||||
foo: "bar",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Form
|
||||
schema={s
|
||||
.object({
|
||||
name: s.string(),
|
||||
props: s.array(
|
||||
s.object({
|
||||
age: s.number(),
|
||||
config: s.object({}),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.toJSON()}
|
||||
options={formOptions}
|
||||
initialValues={{
|
||||
name: "Peter",
|
||||
props: [{ age: 20, config: { foo: "bar" } }],
|
||||
}}
|
||||
/>
|
||||
|
||||
<Form
|
||||
schema={s
|
||||
.object({
|
||||
name: s.string(),
|
||||
props: s.array(s.anyOf([s.string(), s.number()])),
|
||||
})
|
||||
.toJSON()}
|
||||
options={formOptions}
|
||||
/>
|
||||
|
||||
{/* <Form
|
||||
options={{
|
||||
anyOfNoneSelectedMode: "first",
|
||||
debug: true,
|
||||
}}
|
||||
initialValues={{ isd: "1", nested2: { name: "hello" } }}
|
||||
schema={s
|
||||
.object({
|
||||
isd: s
|
||||
.anyOf([s.string({ title: "String" }), s.number({ title: "Number" })])
|
||||
.optional(),
|
||||
email: s.string({ format: "email" }).optional(),
|
||||
nested: s
|
||||
.object({
|
||||
name: s.string(),
|
||||
})
|
||||
.optional(),
|
||||
nested2: s
|
||||
.anyOf([s.object({ name: s.string() }), s.object({ age: s.number() })])
|
||||
.optional(),
|
||||
})
|
||||
.toJSON()}
|
||||
/> */}
|
||||
|
||||
{/*<Form
|
||||
onChange={(data) => console.log("change", data)}
|
||||
|
||||
16
app/src/ui/routes/tools/index.tsx
Normal file
16
app/src/ui/routes/tools/index.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Empty } from "ui/components/display/Empty";
|
||||
import { Route } from "wouter";
|
||||
import ToolsMcp from "./mcp/mcp";
|
||||
|
||||
export default function ToolsRoutes() {
|
||||
return (
|
||||
<>
|
||||
<Route path="/" component={ToolsIndex} />
|
||||
<Route path="/mcp" component={ToolsMcp} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolsIndex() {
|
||||
return <Empty title="Tools" description="Select a tool to continue." />;
|
||||
}
|
||||
15
app/src/ui/routes/tools/mcp/components/mcp-icon.tsx
Normal file
15
app/src/ui/routes/tools/mcp/components/mcp-icon.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
export const McpIcon = () => (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
height="1em"
|
||||
style={{ flex: "none", lineHeight: "1" }}
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>ModelContextProtocol</title>
|
||||
<path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z" />
|
||||
<path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z" />
|
||||
</svg>
|
||||
);
|
||||
16
app/src/ui/routes/tools/mcp/hooks/use-mcp-client.ts
Normal file
16
app/src/ui/routes/tools/mcp/hooks/use-mcp-client.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { McpClient, type McpClientConfig } from "jsonv-ts/mcp";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
|
||||
const clients = new Map<string, McpClient>();
|
||||
|
||||
export function getClient(opts: McpClientConfig) {
|
||||
if (!clients.has(JSON.stringify(opts))) {
|
||||
clients.set(JSON.stringify(opts), new McpClient(opts));
|
||||
}
|
||||
return clients.get(JSON.stringify(opts))!;
|
||||
}
|
||||
|
||||
export function useMcpClient() {
|
||||
const { config } = useBknd();
|
||||
return getClient({ url: window.location.origin + config.server.mcp.path });
|
||||
}
|
||||
79
app/src/ui/routes/tools/mcp/mcp.tsx
Normal file
79
app/src/ui/routes/tools/mcp/mcp.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
import { useMcpStore } from "./state";
|
||||
import * as Tools from "./tools";
|
||||
import { TbWorld } from "react-icons/tb";
|
||||
import { McpIcon } from "./components/mcp-icon";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { Empty } from "ui/components/display/Empty";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { appShellStore } from "ui/store";
|
||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||
|
||||
export default function ToolsMcp() {
|
||||
useBrowserTitle(["MCP UI"]);
|
||||
|
||||
const { config, options } = useBknd();
|
||||
const feature = useMcpStore((state) => state.feature);
|
||||
const setFeature = useMcpStore((state) => state.setFeature);
|
||||
const content = useMcpStore((state) => state.content);
|
||||
const openSidebar = appShellStore((store) => store.toggleSidebar("default"));
|
||||
const mcpPath = config.server.mcp.path;
|
||||
|
||||
if (!config.server.mcp.enabled) {
|
||||
return (
|
||||
<Empty
|
||||
title="MCP not enabled"
|
||||
description="Please enable MCP in the settings to continue."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-grow max-w-screen">
|
||||
<AppShell.SectionHeader>
|
||||
<div className="flex flex-row gap-4 items-center">
|
||||
<McpIcon />
|
||||
<AppShell.SectionHeaderTitle className="whitespace-nowrap truncate">
|
||||
MCP UI
|
||||
</AppShell.SectionHeaderTitle>
|
||||
<div className="hidden md:flex flex-row gap-2 items-center bg-primary/5 rounded-full px-3 pr-3.5 py-2">
|
||||
<TbWorld />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-mono leading-none select-text">
|
||||
{window.location.origin + mcpPath}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell.SectionHeader>
|
||||
|
||||
<div className="flex h-full">
|
||||
<AppShell.Sidebar>
|
||||
<Tools.Sidebar open={feature === "tools"} toggle={() => setFeature("tools")} />
|
||||
<AppShell.SectionHeaderAccordionItem
|
||||
title="Resources"
|
||||
open={feature === "resources"}
|
||||
toggle={() => setFeature("resources")}
|
||||
>
|
||||
<div className="flex flex-col flex-grow p-3 gap-3 justify-center items-center opacity-40">
|
||||
<i>Resources</i>
|
||||
</div>
|
||||
</AppShell.SectionHeaderAccordionItem>
|
||||
</AppShell.Sidebar>
|
||||
{feature === "tools" && <Tools.Content />}
|
||||
|
||||
{!content && (
|
||||
<Empty title="No tool selected" description="Please select a tool to continue.">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => openSidebar()}
|
||||
className="block md:hidden"
|
||||
>
|
||||
Open Tools
|
||||
</Button>
|
||||
</Empty>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
app/src/ui/routes/tools/mcp/state.ts
Normal file
31
app/src/ui/routes/tools/mcp/state.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { create } from "zustand";
|
||||
import { combine } from "zustand/middleware";
|
||||
|
||||
import type { ToolJson } from "jsonv-ts/mcp";
|
||||
|
||||
const FEATURES = ["tools", "resources"] as const;
|
||||
export type Feature = (typeof FEATURES)[number];
|
||||
|
||||
export const useMcpStore = create(
|
||||
combine(
|
||||
{
|
||||
tools: [] as ToolJson[],
|
||||
feature: "tools" as Feature | null,
|
||||
content: null as ToolJson | null,
|
||||
history: [] as { type: "request" | "response"; data: any }[],
|
||||
historyLimit: 50,
|
||||
historyVisible: false,
|
||||
},
|
||||
(set) => ({
|
||||
setTools: (tools: ToolJson[]) => set({ tools }),
|
||||
setFeature: (feature: Feature) => set({ feature }),
|
||||
setContent: (content: ToolJson | null) => set({ content }),
|
||||
addHistory: (type: "request" | "response", data: any) =>
|
||||
set((state) => ({
|
||||
history: [{ type, data }, ...state.history.slice(0, state.historyLimit - 1)],
|
||||
})),
|
||||
setHistoryLimit: (limit: number) => set({ historyLimit: limit }),
|
||||
setHistoryVisible: (visible: boolean) => set({ historyVisible: visible }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
251
app/src/ui/routes/tools/mcp/tools.tsx
Normal file
251
app/src/ui/routes/tools/mcp/tools.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { useCallback, useEffect, useRef, useState, useTransition } from "react";
|
||||
import { getTemplate } from "./utils";
|
||||
import { useMcpStore } from "./state";
|
||||
import { AppShell } from "ui/layouts/AppShell";
|
||||
import { TbHistory, TbHistoryOff, TbRefresh } from "react-icons/tb";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { JsonViewer, JsonViewerTabs, type JsonViewerTabsRef } from "ui/components/code/JsonViewer";
|
||||
import { twMerge } from "ui/elements/mocks/tailwind-merge";
|
||||
import { Field, Form } from "ui/components/form/json-schema-form";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
import { appShellStore } from "ui/store";
|
||||
import { Icon } from "ui/components/display/Icon";
|
||||
import { useMcpClient } from "./hooks/use-mcp-client";
|
||||
import { Tooltip } from "@mantine/core";
|
||||
|
||||
export function Sidebar({ open, toggle }) {
|
||||
const client = useMcpClient();
|
||||
const closeSidebar = appShellStore((store) => store.closeSidebar("default"));
|
||||
const tools = useMcpStore((state) => state.tools);
|
||||
const setTools = useMcpStore((state) => state.setTools);
|
||||
const setContent = useMcpStore((state) => state.setContent);
|
||||
const content = useMcpStore((state) => state.content);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [query, setQuery] = useState<string>("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await client.listTools();
|
||||
if (res) setTools(res.tools);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError(String(e));
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
handleRefresh();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AppShell.SectionHeaderAccordionItem
|
||||
title="Tools"
|
||||
open={open}
|
||||
toggle={toggle}
|
||||
renderHeaderRight={() => (
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
{error && (
|
||||
<Tooltip label={error}>
|
||||
<Icon.Err className="size-5 pointer-events-auto" />
|
||||
</Tooltip>
|
||||
)}
|
||||
<span className="flex-inline bg-primary/10 px-2 py-1.5 rounded-xl text-sm font-mono leading-none">
|
||||
{tools.length}
|
||||
</span>
|
||||
<IconButton Icon={TbRefresh} disabled={!open || loading} onClick={handleRefresh} />
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col flex-grow p-3 gap-3">
|
||||
<Formy.Input
|
||||
type="text"
|
||||
placeholder="Search tools"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<nav className="flex flex-col flex-1 gap-1">
|
||||
{tools
|
||||
.filter((tool) => tool.name.toLowerCase().includes(query.toLowerCase()))
|
||||
.map((tool) => {
|
||||
return (
|
||||
<AppShell.SidebarLink
|
||||
key={tool.name}
|
||||
className={twMerge(
|
||||
"flex flex-col items-start h-auto py-3 gap-px",
|
||||
content?.name === tool.name ? "active" : "",
|
||||
)}
|
||||
onClick={() => {
|
||||
setContent(tool);
|
||||
closeSidebar();
|
||||
}}
|
||||
>
|
||||
<span className="font-mono">{tool.name}</span>
|
||||
<span className="text-sm text-primary/50">{tool.description}</span>
|
||||
</AppShell.SidebarLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</AppShell.SectionHeaderAccordionItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function Content() {
|
||||
const content = useMcpStore((state) => state.content);
|
||||
const addHistory = useMcpStore((state) => state.addHistory);
|
||||
const [payload, setPayload] = useState<object>(getTemplate(content?.inputSchema));
|
||||
const [result, setResult] = useState<object | null>(null);
|
||||
const historyVisible = useMcpStore((state) => state.historyVisible);
|
||||
const setHistoryVisible = useMcpStore((state) => state.setHistoryVisible);
|
||||
const client = useMcpClient();
|
||||
const jsonViewerTabsRef = useRef<JsonViewerTabsRef>(null);
|
||||
const hasInputSchema =
|
||||
content?.inputSchema && Object.keys(content.inputSchema.properties ?? {}).length > 0;
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
useEffect(() => {
|
||||
setPayload(getTemplate(content?.inputSchema));
|
||||
setResult(null);
|
||||
}, [content]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!content?.name) return;
|
||||
const request = {
|
||||
name: content.name,
|
||||
arguments: payload,
|
||||
};
|
||||
startTransition(async () => {
|
||||
addHistory("request", request);
|
||||
const res = await client.callTool(request);
|
||||
if (res) {
|
||||
setResult(res);
|
||||
addHistory("response", res);
|
||||
jsonViewerTabsRef.current?.setSelected("Result");
|
||||
}
|
||||
});
|
||||
}, [payload]);
|
||||
|
||||
if (!content) return null;
|
||||
|
||||
let readableResult = result;
|
||||
try {
|
||||
readableResult = result
|
||||
? (result as any).content?.[0].text
|
||||
? JSON.parse((result as any).content[0].text)
|
||||
: result
|
||||
: null;
|
||||
} catch (e) {}
|
||||
|
||||
return (
|
||||
<Form
|
||||
className="flex flex-grow flex-col min-w-0 max-w-screen"
|
||||
key={content.name}
|
||||
schema={{
|
||||
title: "InputSchema",
|
||||
...content?.inputSchema,
|
||||
}}
|
||||
validateOn="submit"
|
||||
initialValues={payload}
|
||||
hiddenSubmit={false}
|
||||
onChange={(value) => {
|
||||
setPayload(value);
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<AppShell.SectionHeader
|
||||
className="max-w-full min-w-0"
|
||||
right={
|
||||
<div className="flex flex-row gap-2">
|
||||
<IconButton
|
||||
Icon={historyVisible ? TbHistory : TbHistoryOff}
|
||||
onClick={() => setHistoryVisible(!historyVisible)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!content?.name || isPending}
|
||||
variant="primary"
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
Call Tool
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AppShell.SectionHeaderTitle className="leading-tight">
|
||||
<span className="opacity-50">
|
||||
Tools <span className="opacity-70">/</span>
|
||||
</span>{" "}
|
||||
<span className="truncate">{content?.name}</span>
|
||||
</AppShell.SectionHeaderTitle>
|
||||
</AppShell.SectionHeader>
|
||||
<div className="flex flex-grow flex-row w-vw">
|
||||
<div
|
||||
className="flex flex-grow flex-col max-w-full"
|
||||
style={{
|
||||
width: "calc(100% - var(--sidebar-width-right) - 1px)",
|
||||
}}
|
||||
>
|
||||
<AppShell.Scrollable>
|
||||
<div key={JSON.stringify(content)} className="flex flex-col py-4 px-5 gap-4">
|
||||
<p className="text-primary/80">{content?.description}</p>
|
||||
|
||||
{hasInputSchema && <Field name="" />}
|
||||
<JsonViewerTabs
|
||||
ref={jsonViewerTabsRef}
|
||||
expand={9}
|
||||
showCopy
|
||||
showSize
|
||||
tabs={{
|
||||
Arguments: {
|
||||
json: payload,
|
||||
title: "Payload",
|
||||
enabled: hasInputSchema,
|
||||
},
|
||||
Result: { json: readableResult, title: "Result" },
|
||||
Configuration: {
|
||||
json: content ?? null,
|
||||
title: "Configuration",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</AppShell.Scrollable>
|
||||
</div>
|
||||
{historyVisible && (
|
||||
<AppShell.Sidebar name="right" handle="left" maxWidth={window.innerWidth * 0.4}>
|
||||
<History />
|
||||
</AppShell.Sidebar>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
const History = () => {
|
||||
const history = useMcpStore((state) => state.history.slice(0, 50));
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppShell.SectionHeader>History</AppShell.SectionHeader>
|
||||
<AppShell.Scrollable>
|
||||
<div className="flex flex-col flex-grow p-3 gap-1">
|
||||
{history.map((item, i) => (
|
||||
<JsonViewer
|
||||
key={`${item.type}-${i}`}
|
||||
json={item.data}
|
||||
title={item.type.substring(0, 3)}
|
||||
expand={2}
|
||||
showCopy
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</AppShell.Scrollable>
|
||||
</>
|
||||
);
|
||||
};
|
||||
8
app/src/ui/routes/tools/mcp/utils.ts
Normal file
8
app/src/ui/routes/tools/mcp/utils.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Draft2019 } from "json-schema-library";
|
||||
|
||||
export function getTemplate(schema: object) {
|
||||
if (!schema || schema === undefined || schema === null) return undefined;
|
||||
|
||||
const lib = new Draft2019(schema);
|
||||
return lib.getTemplate(undefined, schema);
|
||||
}
|
||||
@@ -1,23 +1,73 @@
|
||||
import { create } from "zustand";
|
||||
import { combine, persist } from "zustand/middleware";
|
||||
|
||||
type SidebarState = {
|
||||
open: boolean;
|
||||
width: number;
|
||||
};
|
||||
|
||||
export const appShellStore = create(
|
||||
persist(
|
||||
combine(
|
||||
{
|
||||
sidebarOpen: false as boolean,
|
||||
sidebarWidth: 350 as number,
|
||||
sidebars: {
|
||||
default: {
|
||||
open: false,
|
||||
width: 350,
|
||||
},
|
||||
} as Record<string, SidebarState>,
|
||||
},
|
||||
(set) => ({
|
||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
closeSidebar: () => set({ sidebarOpen: false }),
|
||||
openSidebar: () => set({ sidebarOpen: true }),
|
||||
setSidebarWidth: (width: number) => set({ sidebarWidth: width }),
|
||||
resetSidebarWidth: () => set({ sidebarWidth: 350 }),
|
||||
toggleSidebar: (name: string) => () =>
|
||||
set((state) => {
|
||||
const sidebar = state.sidebars[name];
|
||||
if (!sidebar) return state;
|
||||
return {
|
||||
sidebars: {
|
||||
...state.sidebars,
|
||||
[name]: { ...sidebar, open: !sidebar.open },
|
||||
},
|
||||
};
|
||||
}),
|
||||
closeSidebar: (name: string) => () =>
|
||||
set((state) => {
|
||||
const sidebar = state.sidebars[name];
|
||||
if (!sidebar) return state;
|
||||
return {
|
||||
sidebars: { ...state.sidebars, [name]: { ...sidebar, open: false } },
|
||||
};
|
||||
}),
|
||||
setSidebarWidth: (name: string) => (width: number) =>
|
||||
set((state) => {
|
||||
const sidebar = state.sidebars[name];
|
||||
if (!sidebar)
|
||||
return { sidebars: { ...state.sidebars, [name]: { open: false, width } } };
|
||||
return { sidebars: { ...state.sidebars, [name]: { ...sidebar, width } } };
|
||||
}),
|
||||
resetSidebarWidth: (name: string) =>
|
||||
set((state) => {
|
||||
const sidebar = state.sidebars[name];
|
||||
if (!sidebar) return state;
|
||||
return { sidebars: { ...state.sidebars, [name]: { ...sidebar, width: 350 } } };
|
||||
}),
|
||||
|
||||
setSidebarState: (name: string, update: SidebarState) =>
|
||||
set((state) => ({ sidebars: { ...state.sidebars, [name]: update } })),
|
||||
}),
|
||||
),
|
||||
{
|
||||
name: "appshell",
|
||||
version: 1,
|
||||
migrate: () => {
|
||||
return {
|
||||
sidebars: {
|
||||
default: {
|
||||
open: false,
|
||||
width: 350,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -8,8 +8,10 @@ html.fixed,
|
||||
html.fixed body {
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 100dvh;
|
||||
width: 100dvw;
|
||||
position: fixed;
|
||||
overflow: hidden;
|
||||
overscroll-behavior-x: contain;
|
||||
|
||||
Reference in New Issue
Block a user