Merge branch 'main' into cp/216-fix-users-link
This commit is contained in:
+22
-20
@@ -5,7 +5,7 @@ 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 { ClientProvider, useBkndWindowContext, type ClientProviderProps } from "bknd/client";
|
||||
import { createMantineTheme } from "./lib/mantine/theme";
|
||||
import { Routes } from "./routes";
|
||||
import type { BkndAdminAppShellOptions, BkndAdminEntitiesOptions } from "./options";
|
||||
@@ -52,26 +52,30 @@ export type BkndAdminProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export default function Admin({
|
||||
baseUrl: baseUrlOverride,
|
||||
withProvider = false,
|
||||
config: _config = {},
|
||||
children,
|
||||
}: BkndAdminProps) {
|
||||
const { theme } = useTheme();
|
||||
export default function Admin(props: BkndAdminProps) {
|
||||
const Provider = ({ children }: any) =>
|
||||
withProvider ? (
|
||||
props.withProvider ? (
|
||||
<ClientProvider
|
||||
baseUrl={baseUrlOverride}
|
||||
{...(typeof withProvider === "object" ? withProvider : {})}
|
||||
baseUrl={props.baseUrl}
|
||||
{...(typeof props.withProvider === "object" ? props.withProvider : {})}
|
||||
>
|
||||
{children}
|
||||
</ClientProvider>
|
||||
) : (
|
||||
children
|
||||
);
|
||||
|
||||
return (
|
||||
<Provider>
|
||||
<AdminInner {...props} />
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminInner(props: BkndAdminProps) {
|
||||
const { theme } = useTheme();
|
||||
const config = {
|
||||
..._config,
|
||||
...props.config,
|
||||
...useBkndWindowContext(),
|
||||
};
|
||||
|
||||
@@ -82,14 +86,12 @@ export default function Admin({
|
||||
);
|
||||
|
||||
return (
|
||||
<Provider>
|
||||
<MantineProvider {...createMantineTheme(theme as any)}>
|
||||
<Notifications position="top-right" />
|
||||
<Routes BkndWrapper={BkndWrapper} basePath={config?.basepath}>
|
||||
{children}
|
||||
</Routes>
|
||||
</MantineProvider>
|
||||
</Provider>
|
||||
<MantineProvider {...createMantineTheme(theme as any)}>
|
||||
<Notifications position="top-right" />
|
||||
<Routes BkndWrapper={BkndWrapper} basePath={config?.basepath}>
|
||||
{props.children}
|
||||
</Routes>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useApi } from "ui/client";
|
||||
import { useApi } from "bknd/client";
|
||||
import { type TSchemaActions, getSchemaActions } from "./schema/actions";
|
||||
import { AppReduced } from "./utils/AppReduced";
|
||||
import { Message } from "ui/components/display/Message";
|
||||
|
||||
@@ -14,18 +14,20 @@ const ClientContext = createContext<BkndClientContext>(undefined!);
|
||||
export type ClientProviderProps = {
|
||||
children?: ReactNode;
|
||||
baseUrl?: string;
|
||||
api?: Api;
|
||||
} & ApiOptions;
|
||||
|
||||
export const ClientProvider = ({
|
||||
children,
|
||||
host,
|
||||
baseUrl: _baseUrl = host,
|
||||
api: _api,
|
||||
...props
|
||||
}: ClientProviderProps) => {
|
||||
const winCtx = useBkndWindowContext();
|
||||
const _ctx = useClientContext();
|
||||
let actualBaseUrl = _baseUrl ?? _ctx?.baseUrl ?? "";
|
||||
let user: any = undefined;
|
||||
let user: any;
|
||||
|
||||
if (winCtx) {
|
||||
user = winCtx.user;
|
||||
@@ -40,6 +42,7 @@ export const ClientProvider = ({
|
||||
const apiProps = { user, ...props, host: actualBaseUrl };
|
||||
const api = useMemo(
|
||||
() =>
|
||||
_api ??
|
||||
new Api({
|
||||
...apiProps,
|
||||
verbose: isDebug(),
|
||||
@@ -50,7 +53,7 @@ export const ClientProvider = ({
|
||||
}
|
||||
},
|
||||
}),
|
||||
[JSON.stringify(apiProps)],
|
||||
[_api, JSON.stringify(apiProps)],
|
||||
);
|
||||
|
||||
const [authState, setAuthState] = useState<Partial<AuthState> | undefined>(api.getAuthState());
|
||||
@@ -64,9 +67,14 @@ export const ClientProvider = ({
|
||||
|
||||
export const useApi = (host?: ApiOptions["host"]): Api => {
|
||||
const context = useContext(ClientContext);
|
||||
|
||||
if (!context?.api || (host && host.length > 0 && host !== context.baseUrl)) {
|
||||
console.info("creating new api", { host });
|
||||
return new Api({ host: host ?? "" });
|
||||
}
|
||||
if (!context) {
|
||||
throw new Error("useApi must be used within a ClientProvider");
|
||||
}
|
||||
|
||||
return context.api;
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Api } from "Api";
|
||||
import { FetchPromise, type ModuleApi, type ResponseObject } from "modules/ModuleApi";
|
||||
import useSWR, { type SWRConfiguration, useSWRConfig, type Middleware, type SWRHook } from "swr";
|
||||
import useSWRInfinite from "swr/infinite";
|
||||
import { useApi } from "ui/client";
|
||||
import { useApi } from "../ClientProvider";
|
||||
import { useState } from "react";
|
||||
|
||||
export const useApiQuery = <
|
||||
|
||||
@@ -8,9 +8,9 @@ import type {
|
||||
ModuleApi,
|
||||
} from "bknd";
|
||||
import { objectTransform, encodeSearch } from "bknd/utils";
|
||||
import type { Insertable, Selectable, Updateable } from "kysely";
|
||||
import type { Insertable, Selectable, Updateable, Generated } from "kysely";
|
||||
import useSWR, { type SWRConfiguration, type SWRResponse, mutate } from "swr";
|
||||
import { type Api, useApi } from "ui/client";
|
||||
import { type Api, useApi } from "bknd/client";
|
||||
|
||||
export class UseEntityApiError<Payload = any> extends Error {
|
||||
constructor(
|
||||
@@ -33,6 +33,7 @@ interface UseEntityReturn<
|
||||
Entity extends keyof DB | string,
|
||||
Id extends PrimaryFieldType | undefined,
|
||||
Data = Entity extends keyof DB ? DB[Entity] : EntityData,
|
||||
ActualId = Data extends { id: infer I } ? (I extends Generated<infer T> ? T : I) : never,
|
||||
Response = ResponseObject<RepositoryResult<Selectable<Data>>>,
|
||||
> {
|
||||
create: (input: Insertable<Data>) => Promise<Response>;
|
||||
@@ -42,9 +43,11 @@ interface UseEntityReturn<
|
||||
ResponseObject<RepositoryResult<Id extends undefined ? Selectable<Data>[] : Selectable<Data>>>
|
||||
>;
|
||||
update: Id extends undefined
|
||||
? (input: Updateable<Data>, id: Id) => Promise<Response>
|
||||
? (input: Updateable<Data>, id: ActualId) => Promise<Response>
|
||||
: (input: Updateable<Data>) => Promise<Response>;
|
||||
_delete: Id extends undefined ? (id: Id) => Promise<Response> : () => Promise<Response>;
|
||||
_delete: Id extends undefined
|
||||
? (id: PrimaryFieldType) => Promise<Response>
|
||||
: () => Promise<Response>;
|
||||
}
|
||||
|
||||
export const useEntity = <
|
||||
|
||||
@@ -4,6 +4,7 @@ export {
|
||||
type ClientProviderProps,
|
||||
useApi,
|
||||
useBaseUrl,
|
||||
useClientContext
|
||||
} from "./ClientProvider";
|
||||
|
||||
export * from "./api/use-api";
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { AuthState } from "Api";
|
||||
import type { AuthResponse } from "bknd";
|
||||
import { useApi, useInvalidate } from "ui/client";
|
||||
import { useClientContext } from "ui/client/ClientProvider";
|
||||
import { useApi, useInvalidate, useClientContext } from "bknd/client";
|
||||
|
||||
type LoginData = {
|
||||
email: string;
|
||||
@@ -19,6 +18,7 @@ type UseAuth = {
|
||||
logout: () => Promise<void>;
|
||||
verify: () => Promise<void>;
|
||||
setToken: (token: string) => void;
|
||||
local: boolean;
|
||||
};
|
||||
|
||||
export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
|
||||
@@ -61,5 +61,6 @@ export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
|
||||
logout,
|
||||
setToken,
|
||||
verify,
|
||||
local: !!api.options.storage,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type React from "react";
|
||||
import { Children } from "react";
|
||||
import { forwardRef } from "react";
|
||||
import { Children, forwardRef } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Link } from "ui/components/wouter/Link";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import clsx from "clsx";
|
||||
import { getBrowser } from "core/utils";
|
||||
import { getBrowser } from "bknd/utils";
|
||||
import type { Field } from "data/fields";
|
||||
import { Switch as RadixSwitch } from "radix-ui";
|
||||
import {
|
||||
|
||||
@@ -16,15 +16,18 @@ import {
|
||||
setPath,
|
||||
} from "./utils";
|
||||
|
||||
export type NativeFormProps = {
|
||||
export type NativeFormProps = Omit<ComponentPropsWithoutRef<"form">, "onChange" | "onSubmit"> & {
|
||||
hiddenSubmit?: boolean;
|
||||
validateOn?: "change" | "submit";
|
||||
errorFieldSelector?: <K extends keyof HTMLElementTagNameMap>(name: string) => any | null;
|
||||
errorFieldSelector?: (selector: string) => any | null;
|
||||
reportValidity?: boolean;
|
||||
onSubmit?: (data: any, ctx: { event: FormEvent<HTMLFormElement> }) => Promise<void> | void;
|
||||
onSubmit?: (
|
||||
data: any,
|
||||
ctx: { event: FormEvent<HTMLFormElement>; form: HTMLFormElement },
|
||||
) => Promise<void> | void;
|
||||
onSubmitInvalid?: (
|
||||
errors: InputError[],
|
||||
ctx: { event: FormEvent<HTMLFormElement> },
|
||||
ctx: { event: FormEvent<HTMLFormElement>; form: HTMLFormElement },
|
||||
) => Promise<void> | void;
|
||||
onError?: (errors: InputError[]) => void;
|
||||
disableSubmitOnError?: boolean;
|
||||
@@ -33,7 +36,7 @@ export type NativeFormProps = {
|
||||
ctx: { event: ChangeEvent<HTMLFormElement>; key: string; value: any; errors: InputError[] },
|
||||
) => Promise<void> | void;
|
||||
clean?: CleanOptions | true;
|
||||
} & Omit<ComponentPropsWithoutRef<"form">, "onChange" | "onSubmit">;
|
||||
};
|
||||
|
||||
export type InputError = {
|
||||
name: string;
|
||||
@@ -188,12 +191,12 @@ export function NativeForm({
|
||||
|
||||
const errors = validate({ report: true });
|
||||
if (errors.length > 0) {
|
||||
onSubmitInvalid?.(errors, { event: e });
|
||||
onSubmitInvalid?.(errors, { event: e, form });
|
||||
return;
|
||||
}
|
||||
|
||||
if (onSubmit) {
|
||||
await onSubmit(getFormValues(), { event: e });
|
||||
await onSubmit(getFormValues(), { event: e, form });
|
||||
} else {
|
||||
form.submit();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useInsertionEffect, useRef } from "react";
|
||||
import { type LinkProps, Link as WouterLink, useRoute, useRouter } from "wouter";
|
||||
import { type LinkProps, Link as WouterLink, useRouter } from "wouter";
|
||||
import { useEvent } from "../../hooks/use-event";
|
||||
|
||||
/*
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import type { AppAuthOAuthStrategy, AppAuthSchema } from "auth/auth-schema";
|
||||
import clsx from "clsx";
|
||||
import { NativeForm } from "ui/components/form/native-form/NativeForm";
|
||||
import { transform } from "lodash-es";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import { transformObject } from "bknd/utils";
|
||||
import { useEffect, useState, type ComponentPropsWithoutRef, type FormEvent } from "react";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { Group, Input, Password, Label } from "ui/components/form/Formy/components";
|
||||
import { SocialLink } from "./SocialLink";
|
||||
import { useAuth } from "bknd/client";
|
||||
import { Alert } from "ui/components/display/Alert";
|
||||
import { useLocation } from "wouter";
|
||||
|
||||
export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit" | "action"> & {
|
||||
export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "action"> & {
|
||||
className?: string;
|
||||
formData?: any;
|
||||
action: "login" | "register";
|
||||
@@ -23,25 +26,50 @@ export function AuthForm({
|
||||
action,
|
||||
auth,
|
||||
buttonLabel = action === "login" ? "Sign in" : "Sign up",
|
||||
onSubmit: _onSubmit,
|
||||
...props
|
||||
}: LoginFormProps) {
|
||||
const $auth = useAuth();
|
||||
const basepath = auth?.basepath ?? "/api/auth";
|
||||
const [error, setError] = useState<string>();
|
||||
const [, navigate] = useLocation();
|
||||
const password = {
|
||||
action: `${basepath}/password/${action}`,
|
||||
strategy: auth?.strategies?.password ?? ({ type: "password" } as const),
|
||||
};
|
||||
|
||||
const oauth = transform(
|
||||
auth?.strategies ?? {},
|
||||
(result, value, key) => {
|
||||
if (value.type !== "password") {
|
||||
result[key] = value.config;
|
||||
}
|
||||
},
|
||||
{},
|
||||
) as Record<string, AppAuthOAuthStrategy>;
|
||||
const oauth = transformObject(auth?.strategies ?? {}, (value) => {
|
||||
return value.type !== "password" ? value.config : undefined;
|
||||
}) as Record<string, AppAuthOAuthStrategy>;
|
||||
const has_oauth = Object.keys(oauth).length > 0;
|
||||
|
||||
async function onSubmit(
|
||||
data: any,
|
||||
ctx: { event: FormEvent<HTMLFormElement>; form: HTMLFormElement },
|
||||
) {
|
||||
if ($auth?.local) {
|
||||
ctx.event.preventDefault();
|
||||
|
||||
const res = await $auth.login(data);
|
||||
if ("token" in res) {
|
||||
navigate("/");
|
||||
} else {
|
||||
setError((res as any).error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await _onSubmit?.(ctx.event);
|
||||
// submit form
|
||||
ctx.form.submit();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if ($auth.user) {
|
||||
navigate("/");
|
||||
}
|
||||
}, [$auth.user]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
{has_oauth && (
|
||||
@@ -63,17 +91,19 @@ export function AuthForm({
|
||||
<NativeForm
|
||||
method={method}
|
||||
action={password.action}
|
||||
onSubmit={onSubmit}
|
||||
{...(props as any)}
|
||||
validateOn="change"
|
||||
className={clsx("flex flex-col gap-3 w-full", className)}
|
||||
>
|
||||
{error && <Alert.Exception message={error} className="justify-center" />}
|
||||
<Group>
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
<Input type="email" name="email" required />
|
||||
</Group>
|
||||
<Group>
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Password name="password" required minLength={8} />
|
||||
<Password name="password" required minLength={1} />
|
||||
</Group>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
|
||||
import { ucFirstAllSnakeToPascalWithSpaces } from "bknd/utils";
|
||||
import type { ReactNode } from "react";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import type { IconType } from "ui/components/buttons/IconButton";
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { AppAuthSchema } from "auth/auth-schema";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useApi } from "ui/client";
|
||||
import { useApi } from "bknd/client";
|
||||
|
||||
type AuthStrategyData = Pick<AppAuthSchema, "strategies" | "basepath">;
|
||||
export const useAuthStrategies = (options?: { baseUrl?: string }): Partial<AuthStrategyData> & {
|
||||
export const useAuthStrategies = (options?: {
|
||||
baseUrl?: string;
|
||||
}): Partial<AuthStrategyData> & {
|
||||
loading: boolean;
|
||||
} => {
|
||||
const [data, setData] = useState<AuthStrategyData>();
|
||||
|
||||
@@ -14,7 +14,7 @@ import { isFileAccepted } from "bknd/utils";
|
||||
import { type FileWithPath, useDropzone } from "./use-dropzone";
|
||||
import { checkMaxReached } from "./helper";
|
||||
import { DropzoneInner } from "./DropzoneInner";
|
||||
import { createDropzoneStore } from "ui/elements/media/dropzone-state";
|
||||
import { createDropzoneStore } from "./dropzone-state";
|
||||
import { useStore } from "zustand";
|
||||
|
||||
export type FileState = {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { Api } from "bknd/client";
|
||||
import type { PrimaryFieldType, RepoQueryIn } from "bknd";
|
||||
import type { MediaFieldSchema } from "media/AppMedia";
|
||||
import type { TAppMediaConfig } from "media/media-schema";
|
||||
import { useId, useEffect, useRef, useState } from "react";
|
||||
import { useApi, useApiInfiniteQuery, useApiQuery, useInvalidate } from "bknd/client";
|
||||
import { type Api, useApi, useApiInfiniteQuery, useApiQuery, useInvalidate } from "bknd/client";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
import { Dropzone, type DropzoneProps } from "./Dropzone";
|
||||
import { mediaItemsToFileStates } from "./helper";
|
||||
@@ -132,26 +131,24 @@ export function DropzoneContainer({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@ import {
|
||||
} 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";
|
||||
import type { DropzoneRenderProps, FileState } from "ui/elements";
|
||||
import { formatNumber } from "bknd/utils";
|
||||
import type { DropzoneRenderProps, FileState } from "./Dropzone";
|
||||
import { useDropzoneFileState, useDropzoneState } from "./Dropzone";
|
||||
|
||||
function handleUploadError(e: unknown) {
|
||||
|
||||
@@ -474,6 +474,7 @@ type SectionHeaderAccordionItemProps = {
|
||||
ActiveIcon?: any;
|
||||
children?: React.ReactNode;
|
||||
renderHeaderRight?: (props: { open: boolean }) => React.ReactNode;
|
||||
scrollContainerRef?: React.RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
export const SectionHeaderAccordionItem = ({
|
||||
@@ -483,6 +484,7 @@ export const SectionHeaderAccordionItem = ({
|
||||
ActiveIcon = IconChevronUp,
|
||||
children,
|
||||
renderHeaderRight,
|
||||
scrollContainerRef,
|
||||
}: SectionHeaderAccordionItemProps) => (
|
||||
<div
|
||||
style={{ minHeight: 49 }}
|
||||
@@ -493,6 +495,8 @@ export const SectionHeaderAccordionItem = ({
|
||||
: "flex-initial cursor-pointer hover:bg-primary/5",
|
||||
)}
|
||||
>
|
||||
{/** biome-ignore lint/a11y/noStaticElementInteractions: . */}
|
||||
{/** biome-ignore lint/a11y/useKeyWithClickEvents: . */}
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex flex-row bg-muted/10 border-muted border-b h-14 py-4 pr-4 pl-2 items-center gap-2",
|
||||
@@ -501,14 +505,12 @@ export const SectionHeaderAccordionItem = ({
|
||||
>
|
||||
<IconButton Icon={open ? ActiveIcon : IconChevronDown} disabled={open} />
|
||||
<h2 className="text-lg dark:font-bold font-semibold select-text">{title}</h2>
|
||||
<div className="flex flex-grow" />
|
||||
<div className="flex grow" />
|
||||
{renderHeaderRight?.({ open })}
|
||||
</div>
|
||||
<div
|
||||
className={twMerge(
|
||||
"overflow-y-scroll transition-all",
|
||||
open ? " flex-grow" : "h-0 opacity-0",
|
||||
)}
|
||||
ref={scrollContainerRef}
|
||||
className={twMerge("overflow-y-scroll transition-all", open ? " grow" : "h-0 opacity-0")}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
@@ -518,14 +520,25 @@ export const SectionHeaderAccordionItem = ({
|
||||
export const RouteAwareSectionHeaderAccordionItem = ({
|
||||
routePattern,
|
||||
identifier,
|
||||
renderHeaderRight,
|
||||
...props
|
||||
}: Omit<SectionHeaderAccordionItemProps, "open" | "toggle"> & {
|
||||
}: Omit<SectionHeaderAccordionItemProps, "open" | "toggle" | "renderHeaderRight"> & {
|
||||
renderHeaderRight?: (props: { open: boolean; active: boolean }) => React.ReactNode;
|
||||
// it's optional because it could be provided using the context
|
||||
routePattern?: string;
|
||||
identifier: string;
|
||||
}) => {
|
||||
const { active, toggle } = useRoutePathState(routePattern, identifier);
|
||||
return <SectionHeaderAccordionItem {...props} open={active} toggle={toggle} />;
|
||||
return (
|
||||
<SectionHeaderAccordionItem
|
||||
{...props}
|
||||
open={active}
|
||||
toggle={toggle}
|
||||
renderHeaderRight={
|
||||
renderHeaderRight && ((props) => renderHeaderRight?.({ open: props.open, active }))
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Separator = ({ className, ...props }: ComponentPropsWithoutRef<"hr">) => (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SegmentedControl, Tooltip } from "@mantine/core";
|
||||
import { SegmentedControl } from "@mantine/core";
|
||||
import { IconApi, IconBook, IconKeyOff, IconSettings, IconUser } from "@tabler/icons-react";
|
||||
import {
|
||||
TbDatabase,
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
TbUser,
|
||||
TbX,
|
||||
} from "react-icons/tb";
|
||||
import { useAuth, useBkndWindowContext } from "ui/client";
|
||||
import { useAuth, useBkndWindowContext } from "bknd/client";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { useTheme } from "ui/client/use-theme";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
@@ -24,34 +24,35 @@ import { useLocation } from "wouter";
|
||||
import { NavLink } from "./AppShell";
|
||||
import { autoFormatString } from "core/utils";
|
||||
import { appShellStore } from "ui/store";
|
||||
import { getVersion } from "core/env";
|
||||
import { getVersion, isDebug } 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();
|
||||
const { config } = useBknd();
|
||||
|
||||
const items: {
|
||||
label: string;
|
||||
href: string;
|
||||
Icon: any;
|
||||
Icon?: any;
|
||||
exact?: boolean;
|
||||
tooltip?: string;
|
||||
disabled?: boolean;
|
||||
}[] = [
|
||||
/*{
|
||||
label: "Base",
|
||||
href: "#",
|
||||
exact: true,
|
||||
Icon: TbLayoutDashboard,
|
||||
disabled: true,
|
||||
tooltip: "Coming soon"
|
||||
},*/
|
||||
{ label: "Data", href: "/data", Icon: TbDatabase },
|
||||
{ label: "Auth", href: "/auth", Icon: TbFingerprint },
|
||||
{ label: "Media", href: "/media", Icon: TbPhoto },
|
||||
{ label: "Flows", href: "/flows", Icon: TbHierarchy2 },
|
||||
];
|
||||
|
||||
if (isDebug() || Object.keys(config.flows?.flows ?? {}).length > 0) {
|
||||
items.push({ label: "Flows", href: "/flows", Icon: TbHierarchy2 });
|
||||
}
|
||||
|
||||
if (config.server.mcp.enabled) {
|
||||
items.push({ label: "MCP", href: "/tools/mcp", Icon: McpIcon });
|
||||
}
|
||||
|
||||
const activeItem = items.find((item) =>
|
||||
item.exact ? location === item.href : location.startsWith(item.href),
|
||||
);
|
||||
@@ -154,8 +155,10 @@ function UserMenu() {
|
||||
|
||||
async function handleLogout() {
|
||||
await auth.logout();
|
||||
// @todo: grab from somewhere constant
|
||||
navigate(logout_route, { reload: true });
|
||||
|
||||
if (!auth.local) {
|
||||
navigate(logout_route, { reload: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ContextModalProps } from "@mantine/modals";
|
||||
import { type ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import { useEntityQuery } from "ui/client";
|
||||
import { useEntityQuery } from "bknd/client";
|
||||
import { type FileState, Media } from "ui/elements";
|
||||
import { autoFormatString, datetimeStringLocal, formatNumber } from "core/utils";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useApi, useInvalidate } from "ui/client";
|
||||
import { useApi, useInvalidate } from "bknd/client";
|
||||
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
|
||||
import { routes, useNavigate } from "ui/lib/routes";
|
||||
import { bkndModals } from "ui/modals";
|
||||
|
||||
@@ -19,9 +19,10 @@ 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>;
|
||||
// biome-ignore format: ...
|
||||
export type TFieldApi = FieldApi<any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any>;
|
||||
export type FormApi = ReactFormExtendedApi<any, any, any, any, any, any, any, any, any, any, any, any>;
|
||||
// biome-ignore format: ...
|
||||
export type TFieldApi = FieldApi<any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any>;
|
||||
|
||||
type EntityFormProps = {
|
||||
entity: Entity;
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { EntityData } from "bknd";
|
||||
import type { RelationField } from "data/relations";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { TbEye } from "react-icons/tb";
|
||||
import { useEntityQuery } from "ui/client";
|
||||
import { useEntityQuery } from "bknd/client";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import clsx from "clsx";
|
||||
import { TbArrowRight, TbCircle, TbCircleCheckFilled, TbFingerprint } from "react-icons/tb";
|
||||
import { useApiQuery } from "ui/client";
|
||||
import { useApiQuery } from "bknd/client";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
|
||||
import { ButtonLink, type ButtonLinkProps } from "ui/components/buttons/Button";
|
||||
|
||||
@@ -35,7 +35,7 @@ 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 { mountOnce, useApiQuery } from "bknd/client";
|
||||
import { CodePreview } from "ui/components/code/CodePreview";
|
||||
import type { JsonError } from "json-schema-library";
|
||||
import { Alert } from "ui/components/display/Alert";
|
||||
@@ -378,11 +378,7 @@ function replaceEntitiesEnum(schema: Record<string, any>, entities: string[]) {
|
||||
});
|
||||
}
|
||||
|
||||
const Policy = ({
|
||||
permission,
|
||||
}: {
|
||||
permission: TPermission;
|
||||
}) => {
|
||||
const Policy = ({ permission }: { permission: TPermission }) => {
|
||||
const { value } = useDerivedFieldContext("", ({ value }) => ({
|
||||
effect: (value?.effect ?? "allow") as "allow" | "deny" | "filter",
|
||||
}));
|
||||
@@ -503,22 +499,24 @@ const CustomFieldWrapper = ({
|
||||
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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
target={() => (
|
||||
<div className="w-auto max-w-[80vw] md:max-w-120 bg-background overflow-scroll">
|
||||
{typeof schema.content === "object" ? (
|
||||
<JsonViewer
|
||||
className="pr-3 text-sm"
|
||||
json={schema.content}
|
||||
title={schema.name}
|
||||
expand={5}
|
||||
/>
|
||||
) : (
|
||||
<CodePreview
|
||||
code={schema.content}
|
||||
lang="typescript"
|
||||
className="p-3 text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Button variant="ghost" size="smaller" IconLeft={TbCodeDots}>
|
||||
{autoFormatString(schema.name)}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ucFirst } from "bknd/utils";
|
||||
import type { Entity, EntityData, EntityRelation } from "bknd";
|
||||
import { Fragment, useState } from "react";
|
||||
import { TbDots } from "react-icons/tb";
|
||||
import { useApiQuery, useEntityQuery } from "ui/client";
|
||||
import { useApiQuery, useEntityQuery } from "bknd/client";
|
||||
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
@@ -207,7 +207,7 @@ function DataEntityUpdateImpl({ params }) {
|
||||
handleSubmit={handleSubmit}
|
||||
fieldsDisabled={fieldsDisabled}
|
||||
data={data ?? undefined}
|
||||
Form={Form}
|
||||
Form={Form as any}
|
||||
action="update"
|
||||
className="flex flex-grow flex-col gap-3 p-3"
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { EntityData } from "bknd";
|
||||
import { useState } from "react";
|
||||
import { useEntityMutate } from "ui/client";
|
||||
import { useEntityMutate } from "bknd/client";
|
||||
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { Message } from "ui/components/display/Message";
|
||||
@@ -121,7 +121,7 @@ export function DataEntityCreate({ params }) {
|
||||
handleSubmit={handleSubmit}
|
||||
fieldsDisabled={fieldsDisabled}
|
||||
data={search.value}
|
||||
Form={Form}
|
||||
Form={Form as any}
|
||||
action="create"
|
||||
className="flex flex-grow flex-col gap-3 p-3"
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Entity } from "bknd";
|
||||
import { repoQuery } from "data/server/query";
|
||||
import { Fragment } from "react";
|
||||
import { TbDots } from "react-icons/tb";
|
||||
import { useApiQuery } from "ui/client";
|
||||
import { useApiQuery } from "bknd/client";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
|
||||
@@ -5,12 +5,12 @@ import {
|
||||
ucFirstAllSnakeToPascalWithSpaces,
|
||||
s,
|
||||
stringIdentifier,
|
||||
pickKeys,
|
||||
} from "bknd/utils";
|
||||
import {
|
||||
type TAppDataEntityFields,
|
||||
fieldsSchemaObject as originalFieldsSchemaObject,
|
||||
} from "data/data-schema";
|
||||
import { omit } from "lodash-es";
|
||||
import { forwardRef, memo, useEffect, useImperativeHandle } from "react";
|
||||
import { type FieldArrayWithId, type UseFormReturn, useFieldArray, useForm } from "react-hook-form";
|
||||
import { TbGripVertical, TbSettings, TbTrash } from "react-icons/tb";
|
||||
@@ -317,7 +317,6 @@ function EntityField({
|
||||
const name = watch(`fields.${index}.name`);
|
||||
const { active, toggle } = useRoutePathState(routePattern ?? "", name);
|
||||
const fieldSpec = fieldSpecs.find((s) => s.type === type)!;
|
||||
const specificData = omit(field.field.config, commonProps);
|
||||
const disabled = fieldSpec.disabled || [];
|
||||
const hidden = fieldSpec.hidden || [];
|
||||
const dragDisabled = index === 0;
|
||||
@@ -476,7 +475,7 @@ function EntityField({
|
||||
field={field}
|
||||
onChange={(value) => {
|
||||
setValue(`${prefix}.config`, {
|
||||
...getValues([`fields.${index}.config`])[0],
|
||||
...pickKeys(getValues([`${prefix}.config`])[0], commonProps),
|
||||
...value,
|
||||
});
|
||||
}}
|
||||
@@ -520,7 +519,7 @@ const SpecificForm = ({
|
||||
readonly?: boolean;
|
||||
}) => {
|
||||
const type = field.field.type;
|
||||
const specificData = omit(field.field.config, commonProps);
|
||||
const specificData = omitKeys(field.field.config ?? {}, commonProps);
|
||||
|
||||
return (
|
||||
<JsonSchemaForm
|
||||
|
||||
@@ -11,11 +11,16 @@ import SettingsRoutes from "./settings";
|
||||
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 { useBkndWindowContext } from "bknd/client";
|
||||
import ToolsRoutes from "./tools";
|
||||
|
||||
// @ts-ignore
|
||||
const TestRoutes = lazy(() => import("./test"));
|
||||
let TestRoutes: any;
|
||||
try {
|
||||
if (import.meta.env.DEV) {
|
||||
TestRoutes = lazy(() => import("./test"));
|
||||
}
|
||||
} catch {}
|
||||
|
||||
export function Routes({
|
||||
BkndWrapper,
|
||||
@@ -43,11 +48,13 @@ export function Routes({
|
||||
<Route path="/" nest>
|
||||
<Root>
|
||||
<Switch>
|
||||
<Route path="/test*" nest>
|
||||
<Suspense fallback={null}>
|
||||
<TestRoutes />
|
||||
</Suspense>
|
||||
</Route>
|
||||
{TestRoutes && (
|
||||
<Route path="/test*" nest>
|
||||
<Suspense fallback={null}>
|
||||
<TestRoutes />
|
||||
</Suspense>
|
||||
</Route>
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { IconPhoto } from "@tabler/icons-react";
|
||||
import { useBknd } from "ui/client/BkndProvider";
|
||||
import { Empty } from "ui/components/display/Empty";
|
||||
import { type FileState, Media } from "ui/elements";
|
||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
import { useLocation } from "wouter";
|
||||
import { bkndModals } from "ui/modals";
|
||||
import { DropzoneContainer } from "ui/elements/media/DropzoneContainer";
|
||||
import type { FileState } from "ui/elements/media/Dropzone";
|
||||
|
||||
export function MediaIndex() {
|
||||
const { config } = useBknd();
|
||||
@@ -35,7 +36,7 @@ export function MediaIndex() {
|
||||
return (
|
||||
<AppShell.Scrollable>
|
||||
<div className="flex flex-1 p-3">
|
||||
<Media.Dropzone onClick={onClick} infinite query={{ sort: "-id" }} />
|
||||
<DropzoneContainer onClick={onClick} infinite query={{ sort: "-id" }} />
|
||||
</div>
|
||||
</AppShell.Scrollable>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { IconHome } from "@tabler/icons-react";
|
||||
import { useEffect } from "react";
|
||||
import { useAuth } from "ui/client";
|
||||
import { useAuth } from "bknd/client";
|
||||
import { useEffectOnce } from "ui/hooks/use-effect";
|
||||
import { Empty } from "../components/display/Empty";
|
||||
import { useBrowserTitle } from "../hooks/use-browser-title";
|
||||
|
||||
@@ -49,19 +49,21 @@ export const AuthSettings = ({ schema: _unsafe_copy, config }) => {
|
||||
try {
|
||||
const user_entity = config.entity_name ?? "users";
|
||||
const entities = _s.config.data.entities ?? {};
|
||||
console.log("entities", entities, user_entity);
|
||||
const user_fields = Object.entries(entities[user_entity]?.fields ?? {})
|
||||
.map(([name, field]) => (!field.config?.virtual ? name : undefined))
|
||||
.filter(Boolean);
|
||||
|
||||
if (user_fields.length > 0) {
|
||||
console.log("user_fields", user_fields);
|
||||
_schema.properties.jwt.properties.fields.items.enum = user_fields;
|
||||
_schema.properties.jwt.properties.fields.uniqueItems = true;
|
||||
uiSchema.jwt.fields["ui:widget"] = "checkboxes";
|
||||
}
|
||||
} catch (e) {}
|
||||
console.log("_s", _s);
|
||||
|
||||
const roles = Object.keys(config.roles ?? {});
|
||||
if (roles.length > 0) {
|
||||
_schema.properties.default_role_register.enum = roles;
|
||||
}
|
||||
} catch (_e) {}
|
||||
const roleSchema = _schema.properties.roles?.additionalProperties ?? { type: "object" };
|
||||
/* if (_s.permissions) {
|
||||
roleSchema.properties.permissions.items.enum = _s.permissions;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { JSONSchema7 } from "json-schema";
|
||||
import type { JSONSchema } from "json-schema-to-ts";
|
||||
import { omitKeys, type s } from "bknd/utils";
|
||||
|
||||
export function extractSchema<
|
||||
@@ -10,7 +10,7 @@ export function extractSchema<
|
||||
config: Config,
|
||||
keys: Keys[],
|
||||
): [
|
||||
JSONSchema7,
|
||||
Exclude<JSONSchema, boolean | null | undefined>,
|
||||
Partial<Config>,
|
||||
{
|
||||
[K in Keys]: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useApi, useApiQuery } from "ui/client";
|
||||
import { useApi, useApiQuery } from "bknd/client";
|
||||
import { Scrollable } from "ui/layouts/AppShell/AppShell";
|
||||
|
||||
function Bla() {
|
||||
|
||||
@@ -6,7 +6,7 @@ export default function ToolsRoutes() {
|
||||
return (
|
||||
<>
|
||||
<Route path="/" component={ToolsIndex} />
|
||||
<Route path="/mcp" component={ToolsMcp} />
|
||||
<Route path="/mcp*" component={ToolsMcp} nest />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { McpClient, type McpClientConfig } from "jsonv-ts/mcp";
|
||||
import { useApi } from "bknd/client";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
|
||||
const clients = new Map<string, McpClient>();
|
||||
@@ -12,5 +13,14 @@ export function getClient(opts: McpClientConfig) {
|
||||
|
||||
export function useMcpClient() {
|
||||
const { config } = useBknd();
|
||||
return getClient({ url: window.location.origin + config.server.mcp.path });
|
||||
const api = useApi();
|
||||
const token = api.getAuthState().token;
|
||||
const headers =
|
||||
api.token_transport === "header" && token ? { Authorization: `Bearer ${token}` } : undefined;
|
||||
|
||||
return getClient({
|
||||
url: window.location.origin + config.server.mcp.path,
|
||||
fetch: api.fetcher,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,14 +8,13 @@ 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";
|
||||
import { RoutePathStateProvider } from "ui/hooks/use-route-path-state";
|
||||
import { Route, Switch } from "wouter";
|
||||
|
||||
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 { config } = useBknd();
|
||||
const openSidebar = appShellStore((store) => store.toggleSidebar("default"));
|
||||
const mcpPath = config.server.mcp.path;
|
||||
|
||||
@@ -29,51 +28,57 @@ export default function ToolsMcp() {
|
||||
}
|
||||
|
||||
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>
|
||||
<RoutePathStateProvider path={"/:type?"} defaultIdentifier="tools">
|
||||
<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>
|
||||
</div>
|
||||
</AppShell.SectionHeader>
|
||||
</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"
|
||||
<div className="flex grow h-full">
|
||||
<AppShell.Sidebar>
|
||||
<Tools.Sidebar />
|
||||
<AppShell.RouteAwareSectionHeaderAccordionItem
|
||||
title="Resources"
|
||||
identifier="resources"
|
||||
>
|
||||
Open Tools
|
||||
</Button>
|
||||
</Empty>
|
||||
)}
|
||||
<div className="flex flex-col flex-grow p-3 gap-3 justify-center items-center opacity-40">
|
||||
<i>Resources</i>
|
||||
</div>
|
||||
</AppShell.RouteAwareSectionHeaderAccordionItem>
|
||||
</AppShell.Sidebar>
|
||||
|
||||
<Switch>
|
||||
<Route path="/tools/:toolName?" component={Tools.Content} />
|
||||
<Route path="*">
|
||||
<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>
|
||||
</Route>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RoutePathStateProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,23 +3,16 @@ 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)],
|
||||
|
||||
@@ -5,7 +5,6 @@ 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";
|
||||
@@ -13,17 +12,18 @@ import { appShellStore } from "ui/store";
|
||||
import { Icon } from "ui/components/display/Icon";
|
||||
import { useMcpClient } from "./hooks/use-mcp-client";
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import { Link } from "ui/components/wouter/Link";
|
||||
import { useParams } from "wouter";
|
||||
|
||||
export function Sidebar({ open, toggle }) {
|
||||
export function Sidebar() {
|
||||
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 scrollContainerRef = useRef<HTMLDivElement>(undefined!);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -39,15 +39,22 @@ export function Sidebar({ open, toggle }) {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
handleRefresh();
|
||||
handleRefresh().then(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
const selectedTool = scrollContainerRef.current.querySelector(".active");
|
||||
if (selectedTool) {
|
||||
selectedTool.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AppShell.SectionHeaderAccordionItem
|
||||
<AppShell.RouteAwareSectionHeaderAccordionItem
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
title="Tools"
|
||||
open={open}
|
||||
toggle={toggle}
|
||||
renderHeaderRight={() => (
|
||||
identifier="tools"
|
||||
renderHeaderRight={({ active }) => (
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
{error && (
|
||||
<Tooltip label={error}>
|
||||
@@ -57,7 +64,7 @@ export function Sidebar({ open, toggle }) {
|
||||
<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} />
|
||||
<IconButton Icon={TbRefresh} disabled={!active || loading} onClick={handleRefresh} />
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
@@ -76,12 +83,11 @@ export function Sidebar({ open, toggle }) {
|
||||
return (
|
||||
<AppShell.SidebarLink
|
||||
key={tool.name}
|
||||
className={twMerge(
|
||||
"flex flex-col items-start h-auto py-3 gap-px",
|
||||
content?.name === tool.name ? "active" : "",
|
||||
)}
|
||||
className="flex flex-col items-start h-auto py-3 gap-px"
|
||||
as={Link}
|
||||
href={`/tools/${tool.name}`}
|
||||
onClick={() => {
|
||||
setContent(tool);
|
||||
//setContent(tool);
|
||||
closeSidebar();
|
||||
}}
|
||||
>
|
||||
@@ -92,32 +98,34 @@ export function Sidebar({ open, toggle }) {
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</AppShell.SectionHeaderAccordionItem>
|
||||
</AppShell.RouteAwareSectionHeaderAccordionItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function Content() {
|
||||
const content = useMcpStore((state) => state.content);
|
||||
const { toolName } = useParams();
|
||||
const tools = useMcpStore((state) => state.tools);
|
||||
const tool = tools.find((tool) => tool.name === toolName);
|
||||
const addHistory = useMcpStore((state) => state.addHistory);
|
||||
const [payload, setPayload] = useState<object>(getTemplate(content?.inputSchema));
|
||||
const [payload, setPayload] = useState<object>(getTemplate(tool?.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;
|
||||
tool?.inputSchema && Object.keys(tool.inputSchema.properties ?? {}).length > 0;
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
useEffect(() => {
|
||||
setPayload(getTemplate(content?.inputSchema));
|
||||
setPayload(getTemplate(tool?.inputSchema));
|
||||
setResult(null);
|
||||
}, [content]);
|
||||
}, [toolName]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!content?.name) return;
|
||||
if (!tool?.name) return;
|
||||
const request = {
|
||||
name: content.name,
|
||||
name: tool.name,
|
||||
arguments: payload,
|
||||
};
|
||||
startTransition(async () => {
|
||||
@@ -131,7 +139,7 @@ export function Content() {
|
||||
});
|
||||
}, [payload]);
|
||||
|
||||
if (!content) return null;
|
||||
if (!tool) return null;
|
||||
|
||||
let readableResult = result;
|
||||
try {
|
||||
@@ -144,11 +152,11 @@ export function Content() {
|
||||
|
||||
return (
|
||||
<Form
|
||||
className="flex flex-grow flex-col min-w-0 max-w-screen"
|
||||
key={content.name}
|
||||
className="flex grow flex-col min-w-0 max-w-screen"
|
||||
key={tool.name}
|
||||
schema={{
|
||||
title: "InputSchema",
|
||||
...content?.inputSchema,
|
||||
...tool?.inputSchema,
|
||||
}}
|
||||
validateOn="submit"
|
||||
initialValues={payload}
|
||||
@@ -163,12 +171,12 @@ export function Content() {
|
||||
right={
|
||||
<div className="flex flex-row gap-2">
|
||||
<IconButton
|
||||
Icon={historyVisible ? TbHistory : TbHistoryOff}
|
||||
Icon={historyVisible ? TbHistoryOff : TbHistory}
|
||||
onClick={() => setHistoryVisible(!historyVisible)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!content?.name || isPending}
|
||||
disabled={!tool?.name || isPending}
|
||||
variant="primary"
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
@@ -181,19 +189,19 @@ export function Content() {
|
||||
<span className="opacity-50">
|
||||
Tools <span className="opacity-70">/</span>
|
||||
</span>{" "}
|
||||
<span className="truncate">{content?.name}</span>
|
||||
<span className="truncate">{tool?.name}</span>
|
||||
</AppShell.SectionHeaderTitle>
|
||||
</AppShell.SectionHeader>
|
||||
<div className="flex flex-grow flex-row w-vw">
|
||||
<div className="flex grow flex-row w-vw">
|
||||
<div
|
||||
className="flex flex-grow flex-col max-w-full"
|
||||
className="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>
|
||||
<div key={JSON.stringify(tool)} className="flex flex-col py-4 px-5 gap-4">
|
||||
<p className="text-primary/80">{tool?.description}</p>
|
||||
|
||||
{hasInputSchema && <Field name="" />}
|
||||
<JsonViewerTabs
|
||||
@@ -209,7 +217,7 @@ export function Content() {
|
||||
},
|
||||
Result: { json: readableResult, title: "Result" },
|
||||
Configuration: {
|
||||
json: content ?? null,
|
||||
json: tool ?? null,
|
||||
title: "Configuration",
|
||||
},
|
||||
}}
|
||||
@@ -234,7 +242,7 @@ const History = () => {
|
||||
<>
|
||||
<AppShell.SectionHeader>History</AppShell.SectionHeader>
|
||||
<AppShell.Scrollable>
|
||||
<div className="flex flex-col flex-grow p-3 gap-1">
|
||||
<div className="flex grow flex-col p-3 gap-1">
|
||||
{history.map((item, i) => (
|
||||
<JsonViewer
|
||||
key={`${item.type}-${i}`}
|
||||
|
||||
Reference in New Issue
Block a user