mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
admin: data/auth route-driven settings and collapsible components (#168)
introduced `useRoutePathState` for managing active states via routes, added `CollapsibleList` for reusable collapsible UI, and updated various components to leverage route awareness for improved navigation state handling. Also adjusted routing for entities, strategies, and schema to support optional sub-paths.
This commit is contained in:
62
app/src/ui/components/list/CollapsibleList.tsx
Normal file
62
app/src/ui/components/list/CollapsibleList.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export interface CollapsibleListRootProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||||
|
|
||||||
|
const Root = ({ className, ...props }: CollapsibleListRootProps) => (
|
||||||
|
<div className={twMerge("flex flex-col gap-2 max-w-4xl", className)} {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface CollapsibleListItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
hasError?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Item = ({ className, hasError, disabled, ...props }: CollapsibleListItemProps) => (
|
||||||
|
<div
|
||||||
|
className={twMerge(
|
||||||
|
"flex flex-col border border-muted rounded bg-background",
|
||||||
|
hasError && "border-error",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface CollapsibleListPreviewProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
left?: ReactNode;
|
||||||
|
right?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Preview = ({ className, left, right, children, ...props }: CollapsibleListPreviewProps) => (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={twMerge("flex flex-row justify-between p-3 gap-3 items-center", className)}
|
||||||
|
>
|
||||||
|
{left && <div className="flex flex-row items-center p-2 bg-primary/5 rounded">{left}</div>}
|
||||||
|
<div className="font-mono flex-grow flex flex-row gap-3">{children}</div>
|
||||||
|
{right && <div className="flex flex-row gap-4 items-center">{right}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface CollapsibleListDetailProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
open?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Detail = ({ className, open, ...props }: CollapsibleListDetailProps) =>
|
||||||
|
open && (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={twMerge(
|
||||||
|
"flex flex-col border-t border-t-muted px-4 pt-3 pb-4 bg-lightest/50 gap-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CollapsibleList = {
|
||||||
|
Root,
|
||||||
|
Item,
|
||||||
|
Preview,
|
||||||
|
Detail,
|
||||||
|
};
|
||||||
91
app/src/ui/hooks/use-route-path-state.tsx
Normal file
91
app/src/ui/hooks/use-route-path-state.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { use, createContext, useEffect } from "react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useLocation, useParams } from "wouter";
|
||||||
|
|
||||||
|
// extract path segment from path, e.g. /auth/strategies/:strategy? -> "strategy"
|
||||||
|
function extractPathSegment(path: string): string {
|
||||||
|
const match = path.match(/:(\w+)\??/);
|
||||||
|
return match?.[1] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// get url by replacing path segment with identifier
|
||||||
|
// e.g. /auth/strategies/:strategy? -> /auth/strategies/x
|
||||||
|
function getPath(path: string, identifier?: string) {
|
||||||
|
if (!identifier) {
|
||||||
|
return path.replace(/\/:\w+\??/, "");
|
||||||
|
}
|
||||||
|
return path.replace(/:\w+\??/, identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRoutePathState(_path?: string, identifier?: string) {
|
||||||
|
const ctx = useRoutePathContext(_path ?? "");
|
||||||
|
const path = _path ?? ctx?.path ?? "";
|
||||||
|
const segment = extractPathSegment(path);
|
||||||
|
const routeIdentifier = useParams()[segment];
|
||||||
|
const [localActive, setLocalActive] = useState(routeIdentifier === identifier);
|
||||||
|
const active = ctx ? identifier === ctx.activeIdentifier : localActive;
|
||||||
|
|
||||||
|
const [, navigate] = useLocation();
|
||||||
|
|
||||||
|
function toggle(_open?: boolean) {
|
||||||
|
const open = _open ?? !localActive;
|
||||||
|
|
||||||
|
if (ctx) {
|
||||||
|
ctx.setActiveIdentifier(identifier!);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path) {
|
||||||
|
if (open) {
|
||||||
|
navigate(getPath(path, identifier));
|
||||||
|
} else {
|
||||||
|
navigate(getPath(path));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setLocalActive(open);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ctx && _path && identifier) {
|
||||||
|
setLocalActive(routeIdentifier === identifier);
|
||||||
|
}
|
||||||
|
}, [routeIdentifier, identifier, _path]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
active,
|
||||||
|
toggle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoutePathStateContextType = {
|
||||||
|
defaultIdentifier: string;
|
||||||
|
path: string;
|
||||||
|
activeIdentifier: string;
|
||||||
|
setActiveIdentifier: (identifier: string) => void;
|
||||||
|
};
|
||||||
|
const RoutePathStateContext = createContext<RoutePathStateContextType>(undefined!);
|
||||||
|
|
||||||
|
export function RoutePathStateProvider({
|
||||||
|
children,
|
||||||
|
defaultIdentifier,
|
||||||
|
path,
|
||||||
|
}: Pick<RoutePathStateContextType, "path" | "defaultIdentifier"> & { children: React.ReactNode }) {
|
||||||
|
const segment = extractPathSegment(path);
|
||||||
|
const routeIdentifier = useParams()[segment];
|
||||||
|
const [activeIdentifier, setActiveIdentifier] = useState(routeIdentifier ?? defaultIdentifier);
|
||||||
|
return (
|
||||||
|
<RoutePathStateContext.Provider
|
||||||
|
value={{ defaultIdentifier, path, activeIdentifier, setActiveIdentifier }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</RoutePathStateContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useRoutePathContext(path?: string) {
|
||||||
|
const ctx = use(RoutePathStateContext);
|
||||||
|
if (ctx && (!path || ctx.path === path)) {
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import type { IconType } from "react-icons";
|
import type { IconType } from "react-icons";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { IconButton } from "ui/components/buttons/IconButton";
|
import { IconButton } from "ui/components/buttons/IconButton";
|
||||||
|
import { useRoutePathState } from "ui/hooks/use-route-path-state";
|
||||||
import { AppShellProvider, useAppShell } from "ui/layouts/AppShell/use-appshell";
|
import { AppShellProvider, useAppShell } from "ui/layouts/AppShell/use-appshell";
|
||||||
import { appShellStore } from "ui/store";
|
import { appShellStore } from "ui/store";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
@@ -376,6 +377,15 @@ export function Scrollable({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SectionHeaderAccordionItemProps = {
|
||||||
|
title: string;
|
||||||
|
open: boolean;
|
||||||
|
toggle: () => void;
|
||||||
|
ActiveIcon?: any;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
renderHeaderRight?: (props: { open: boolean }) => React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
export const SectionHeaderAccordionItem = ({
|
export const SectionHeaderAccordionItem = ({
|
||||||
title,
|
title,
|
||||||
open,
|
open,
|
||||||
@@ -383,14 +393,7 @@ export const SectionHeaderAccordionItem = ({
|
|||||||
ActiveIcon = IconChevronUp,
|
ActiveIcon = IconChevronUp,
|
||||||
children,
|
children,
|
||||||
renderHeaderRight,
|
renderHeaderRight,
|
||||||
}: {
|
}: SectionHeaderAccordionItemProps) => (
|
||||||
title: string;
|
|
||||||
open: boolean;
|
|
||||||
toggle: () => void;
|
|
||||||
ActiveIcon?: any;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
renderHeaderRight?: (props: { open: boolean }) => React.ReactNode;
|
|
||||||
}) => (
|
|
||||||
<div
|
<div
|
||||||
style={{ minHeight: 49 }}
|
style={{ minHeight: 49 }}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
@@ -422,6 +425,19 @@ export const SectionHeaderAccordionItem = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const RouteAwareSectionHeaderAccordionItem = ({
|
||||||
|
routePattern,
|
||||||
|
identifier,
|
||||||
|
...props
|
||||||
|
}: Omit<SectionHeaderAccordionItemProps, "open" | "toggle"> & {
|
||||||
|
// 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} />;
|
||||||
|
};
|
||||||
|
|
||||||
export const Separator = ({ className, ...props }: ComponentPropsWithoutRef<"hr">) => (
|
export const Separator = ({ className, ...props }: ComponentPropsWithoutRef<"hr">) => (
|
||||||
<hr {...props} className={twMerge("border-muted my-3", className)} />
|
<hr {...props} className={twMerge("border-muted my-3", className)} />
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import {
|
|||||||
} from "ui/components/form/json-schema-form";
|
} from "ui/components/form/json-schema-form";
|
||||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||||
import * as AppShell from "../../layouts/AppShell/AppShell";
|
import * as AppShell from "../../layouts/AppShell/AppShell";
|
||||||
|
import { CollapsibleList } from "ui/components/list/CollapsibleList";
|
||||||
|
import { useRoutePathState } from "ui/hooks/use-route-path-state";
|
||||||
|
|
||||||
export function AuthStrategiesList(props) {
|
export function AuthStrategiesList(props) {
|
||||||
useBrowserTitle(["Auth", "Strategies"]);
|
useBrowserTitle(["Auth", "Strategies"]);
|
||||||
@@ -104,7 +106,7 @@ function AuthStrategiesListInternal() {
|
|||||||
<p className="opacity-70">
|
<p className="opacity-70">
|
||||||
Allow users to sign in or sign up using different strategies.
|
Allow users to sign in or sign up using different strategies.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col gap-2 max-w-4xl">
|
<CollapsibleList.Root>
|
||||||
<Strategy type="password" name="password" />
|
<Strategy type="password" name="password" />
|
||||||
<Strategy type="oauth" name="google" />
|
<Strategy type="oauth" name="google" />
|
||||||
<Strategy type="oauth" name="github" />
|
<Strategy type="oauth" name="github" />
|
||||||
@@ -113,7 +115,7 @@ function AuthStrategiesListInternal() {
|
|||||||
<Strategy type="oauth" name="instagram" unavailable />
|
<Strategy type="oauth" name="instagram" unavailable />
|
||||||
<Strategy type="oauth" name="apple" unavailable />
|
<Strategy type="oauth" name="apple" unavailable />
|
||||||
<Strategy type="oauth" name="discord" unavailable />
|
<Strategy type="oauth" name="discord" unavailable />
|
||||||
</div>
|
</CollapsibleList.Root>
|
||||||
</div>
|
</div>
|
||||||
<FormDebug />
|
<FormDebug />
|
||||||
</AppShell.Scrollable>
|
</AppShell.Scrollable>
|
||||||
@@ -138,47 +140,40 @@ const Strategy = ({ type, name, unavailable }: StrategyProps) => {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
const schema = schemas[type];
|
const schema = schemas[type];
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
const { active, toggle } = useRoutePathState("/strategies/:strategy?", name);
|
||||||
|
|
||||||
if (!schema) return null;
|
if (!schema) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormContextOverride schema={schema} prefix={name}>
|
<FormContextOverride schema={schema} prefix={name}>
|
||||||
<div
|
<CollapsibleList.Item
|
||||||
className={twMerge(
|
hasError={errors.length > 0}
|
||||||
"flex flex-col border border-muted rounded bg-background",
|
className={
|
||||||
unavailable && "opacity-20 pointer-events-none cursor-not-allowed",
|
unavailable ? "opacity-20 pointer-events-none cursor-not-allowed" : undefined
|
||||||
errors.length > 0 && "border-red-500",
|
}
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div className="flex flex-row justify-between p-3 gap-3 items-center">
|
<CollapsibleList.Preview
|
||||||
<div className="flex flex-row items-center p-2 bg-primary/5 rounded">
|
left={<StrategyIcon type={type} provider={name} />}
|
||||||
<StrategyIcon type={type} provider={name} />
|
right={
|
||||||
</div>
|
<>
|
||||||
<div className="font-mono flex-grow flex flex-row gap-3">
|
<StrategyToggle type={type} />
|
||||||
<span className="leading-none">{autoFormatString(name)}</span>
|
<IconButton
|
||||||
</div>
|
Icon={TbSettings}
|
||||||
<div className="flex flex-row gap-4 items-center">
|
size="lg"
|
||||||
<StrategyToggle type={type} />
|
iconProps={{ strokeWidth: 1.5 }}
|
||||||
<IconButton
|
variant={active ? "primary" : "ghost"}
|
||||||
Icon={TbSettings}
|
onClick={() => toggle(!active)}
|
||||||
size="lg"
|
/>
|
||||||
iconProps={{ strokeWidth: 1.5 }}
|
</>
|
||||||
variant={open ? "primary" : "ghost"}
|
}
|
||||||
onClick={() => setOpen((o) => !o)}
|
>
|
||||||
/>
|
<span className="leading-none">{autoFormatString(name)}</span>
|
||||||
</div>
|
</CollapsibleList.Preview>
|
||||||
</div>
|
<CollapsibleList.Detail open={active}>
|
||||||
{open && (
|
<StrategyForm type={type} name={name} />
|
||||||
<div
|
</CollapsibleList.Detail>
|
||||||
className={twMerge(
|
</CollapsibleList.Item>
|
||||||
"flex flex-col border-t border-t-muted px-4 pt-3 pb-4 bg-lightest/50 gap-4",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<StrategyForm type={type} name={name} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</FormContextOverride>
|
</FormContextOverride>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default function AuthRoutes() {
|
|||||||
<Route path="/users" component={AuthUsersList} />
|
<Route path="/users" component={AuthUsersList} />
|
||||||
<Route path="/roles" component={AuthRolesList} />
|
<Route path="/roles" component={AuthRolesList} />
|
||||||
<Route path="/roles/edit/:role" component={AuthRolesEdit} />
|
<Route path="/roles/edit/:role" component={AuthRolesEdit} />
|
||||||
<Route path="/strategies" component={AuthStrategiesList} />
|
<Route path="/strategies/:strategy?" component={AuthStrategiesList} />
|
||||||
<Route path="/settings" component={AuthSettings} />
|
<Route path="/settings" component={AuthSettings} />
|
||||||
</AuthRoot>
|
</AuthRoot>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -30,14 +30,10 @@ import { routes, useNavigate } from "ui/lib/routes";
|
|||||||
import { fieldSpecs } from "ui/modules/data/components/fields-specs";
|
import { fieldSpecs } from "ui/modules/data/components/fields-specs";
|
||||||
import { extractSchema } from "../settings/utils/schema";
|
import { extractSchema } from "../settings/utils/schema";
|
||||||
import { EntityFieldsForm, type EntityFieldsFormRef } from "./forms/entity.fields.form";
|
import { EntityFieldsForm, type EntityFieldsFormRef } from "./forms/entity.fields.form";
|
||||||
|
import { RoutePathStateProvider } from "ui/hooks/use-route-path-state";
|
||||||
|
|
||||||
export function DataSchemaEntity({ params }) {
|
export function DataSchemaEntity({ params }) {
|
||||||
const { $data } = useBkndData();
|
const { $data } = useBkndData();
|
||||||
const [value, setValue] = useState("fields");
|
|
||||||
|
|
||||||
function toggle(value) {
|
|
||||||
return () => setValue(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [navigate] = useNavigate();
|
const [navigate] = useNavigate();
|
||||||
const entity = $data.entity(params.entity as string)!;
|
const entity = $data.entity(params.entity as string)!;
|
||||||
@@ -46,7 +42,7 @@ export function DataSchemaEntity({ params }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<RoutePathStateProvider path={`/entity/${entity.name}/:setting?`} defaultIdentifier="fields">
|
||||||
<AppShell.SectionHeader
|
<AppShell.SectionHeader
|
||||||
right={
|
right={
|
||||||
<>
|
<>
|
||||||
@@ -109,13 +105,12 @@ export function DataSchemaEntity({ params }) {
|
|||||||
</div>
|
</div>
|
||||||
</AppShell.SectionHeader>
|
</AppShell.SectionHeader>
|
||||||
<div className="flex flex-col h-full" key={entity.name}>
|
<div className="flex flex-col h-full" key={entity.name}>
|
||||||
<Fields entity={entity} open={value === "fields"} toggle={toggle("fields")} />
|
<Fields entity={entity} />
|
||||||
|
|
||||||
<BasicSettings entity={entity} open={value === "2"} toggle={toggle("2")} />
|
<BasicSettings entity={entity} />
|
||||||
<AppShell.SectionHeaderAccordionItem
|
<AppShell.RouteAwareSectionHeaderAccordionItem
|
||||||
|
identifier="relations"
|
||||||
title="Relations"
|
title="Relations"
|
||||||
open={value === "3"}
|
|
||||||
toggle={toggle("3")}
|
|
||||||
ActiveIcon={IconCirclesRelation}
|
ActiveIcon={IconCirclesRelation}
|
||||||
>
|
>
|
||||||
<Empty
|
<Empty
|
||||||
@@ -127,11 +122,10 @@ export function DataSchemaEntity({ params }) {
|
|||||||
navigate(routes.settings.path(["data", "relations"]), { absolute: true }),
|
navigate(routes.settings.path(["data", "relations"]), { absolute: true }),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</AppShell.SectionHeaderAccordionItem>
|
</AppShell.RouteAwareSectionHeaderAccordionItem>
|
||||||
<AppShell.SectionHeaderAccordionItem
|
<AppShell.RouteAwareSectionHeaderAccordionItem
|
||||||
|
identifier="indices"
|
||||||
title="Indices"
|
title="Indices"
|
||||||
open={value === "4"}
|
|
||||||
toggle={toggle("4")}
|
|
||||||
ActiveIcon={IconBolt}
|
ActiveIcon={IconBolt}
|
||||||
>
|
>
|
||||||
<Empty
|
<Empty
|
||||||
@@ -145,17 +139,13 @@ export function DataSchemaEntity({ params }) {
|
|||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</AppShell.SectionHeaderAccordionItem>
|
</AppShell.RouteAwareSectionHeaderAccordionItem>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</RoutePathStateProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const Fields = ({
|
const Fields = ({ entity }: { entity: Entity }) => {
|
||||||
entity,
|
|
||||||
open,
|
|
||||||
toggle,
|
|
||||||
}: { entity: Entity; open: boolean; toggle: () => void }) => {
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [updates, setUpdates] = useState(0);
|
const [updates, setUpdates] = useState(0);
|
||||||
const { actions, $data } = useBkndData();
|
const { actions, $data } = useBkndData();
|
||||||
@@ -174,10 +164,9 @@ const Fields = ({
|
|||||||
const initialFields = Object.fromEntries(entity.fields.map((f) => [f.name, f.toJSON()])) as any;
|
const initialFields = Object.fromEntries(entity.fields.map((f) => [f.name, f.toJSON()])) as any;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell.SectionHeaderAccordionItem
|
<AppShell.RouteAwareSectionHeaderAccordionItem
|
||||||
|
identifier="fields"
|
||||||
title="Fields"
|
title="Fields"
|
||||||
open={open}
|
|
||||||
toggle={toggle}
|
|
||||||
ActiveIcon={IconAlignJustified}
|
ActiveIcon={IconAlignJustified}
|
||||||
renderHeaderRight={({ open }) =>
|
renderHeaderRight={({ open }) =>
|
||||||
open ? (
|
open ? (
|
||||||
@@ -192,6 +181,7 @@ const Fields = ({
|
|||||||
<div className="animate-fade-in absolute w-full h-full top-0 bottom-0 left-0 right-0 bg-background/65 z-50" />
|
<div className="animate-fade-in absolute w-full h-full top-0 bottom-0 left-0 right-0 bg-background/65 z-50" />
|
||||||
)}
|
)}
|
||||||
<EntityFieldsForm
|
<EntityFieldsForm
|
||||||
|
routePattern={`/entity/${entity.name}/fields/:sub?`}
|
||||||
fields={initialFields}
|
fields={initialFields}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
key={String(updates)}
|
key={String(updates)}
|
||||||
@@ -237,15 +227,11 @@ const Fields = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</AppShell.SectionHeaderAccordionItem>
|
</AppShell.RouteAwareSectionHeaderAccordionItem>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const BasicSettings = ({
|
const BasicSettings = ({ entity }: { entity: Entity }) => {
|
||||||
entity,
|
|
||||||
open,
|
|
||||||
toggle,
|
|
||||||
}: { entity: Entity; open: boolean; toggle: () => void }) => {
|
|
||||||
const d = useBkndData();
|
const d = useBkndData();
|
||||||
const config = d.entities?.[entity.name]?.config;
|
const config = d.entities?.[entity.name]?.config;
|
||||||
const formRef = useRef<JsonSchemaFormRef>(null);
|
const formRef = useRef<JsonSchemaFormRef>(null);
|
||||||
@@ -271,10 +257,9 @@ const BasicSettings = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell.SectionHeaderAccordionItem
|
<AppShell.RouteAwareSectionHeaderAccordionItem
|
||||||
|
identifier="settings"
|
||||||
title="Settings"
|
title="Settings"
|
||||||
open={open}
|
|
||||||
toggle={toggle}
|
|
||||||
ActiveIcon={IconSettings}
|
ActiveIcon={IconSettings}
|
||||||
renderHeaderRight={({ open }) =>
|
renderHeaderRight={({ open }) =>
|
||||||
open ? (
|
open ? (
|
||||||
@@ -293,6 +278,6 @@ const BasicSettings = ({
|
|||||||
className="legacy hide-required-mark fieldset-alternative mute-root"
|
className="legacy hide-required-mark fieldset-alternative mute-root"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</AppShell.SectionHeaderAccordionItem>
|
</AppShell.RouteAwareSectionHeaderAccordionItem>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { Popover } from "ui/components/overlay/Popover";
|
|||||||
import { type TFieldSpec, fieldSpecs } from "ui/modules/data/components/fields-specs";
|
import { type TFieldSpec, fieldSpecs } from "ui/modules/data/components/fields-specs";
|
||||||
import { dataFieldsUiSchema } from "../../settings/routes/data.settings";
|
import { dataFieldsUiSchema } from "../../settings/routes/data.settings";
|
||||||
import * as tbbox from "@sinclair/typebox";
|
import * as tbbox from "@sinclair/typebox";
|
||||||
|
import { useRoutePathState } from "ui/hooks/use-route-path-state";
|
||||||
const { Type } = tbbox;
|
const { Type } = tbbox;
|
||||||
|
|
||||||
const fieldsSchemaObject = originalFieldsSchemaObject;
|
const fieldsSchemaObject = originalFieldsSchemaObject;
|
||||||
@@ -63,6 +64,7 @@ export type EntityFieldsFormProps = {
|
|||||||
onChange?: (formData: TAppDataEntityFields) => void;
|
onChange?: (formData: TAppDataEntityFields) => void;
|
||||||
sortable?: boolean;
|
sortable?: boolean;
|
||||||
additionalFieldTypes?: (TFieldSpec & { onClick: () => void })[];
|
additionalFieldTypes?: (TFieldSpec & { onClick: () => void })[];
|
||||||
|
routePattern?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EntityFieldsFormRef = {
|
export type EntityFieldsFormRef = {
|
||||||
@@ -74,7 +76,10 @@ export type EntityFieldsFormRef = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsFormProps>(
|
export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsFormProps>(
|
||||||
function EntityFieldsForm({ fields: _fields, sortable, additionalFieldTypes, ...props }, ref) {
|
function EntityFieldsForm(
|
||||||
|
{ fields: _fields, sortable, additionalFieldTypes, routePattern, ...props },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
const entityFields = Object.entries(_fields).map(([name, field]) => ({
|
const entityFields = Object.entries(_fields).map(([name, field]) => ({
|
||||||
name,
|
name,
|
||||||
field,
|
field,
|
||||||
@@ -166,6 +171,7 @@ export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsForm
|
|||||||
errors={errors}
|
errors={errors}
|
||||||
remove={remove}
|
remove={remove}
|
||||||
dnd={dnd}
|
dnd={dnd}
|
||||||
|
routePattern={routePattern}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -179,6 +185,7 @@ export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsForm
|
|||||||
form={formProps}
|
form={formProps}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
remove={remove}
|
remove={remove}
|
||||||
|
routePattern={routePattern}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -273,6 +280,7 @@ function EntityField({
|
|||||||
remove,
|
remove,
|
||||||
errors,
|
errors,
|
||||||
dnd,
|
dnd,
|
||||||
|
routePattern,
|
||||||
}: {
|
}: {
|
||||||
field: FieldArrayWithId<TFieldsFormSchema, "fields", "id">;
|
field: FieldArrayWithId<TFieldsFormSchema, "fields", "id">;
|
||||||
index: number;
|
index: number;
|
||||||
@@ -283,11 +291,12 @@ function EntityField({
|
|||||||
remove: (index: number) => void;
|
remove: (index: number) => void;
|
||||||
errors: any;
|
errors: any;
|
||||||
dnd?: SortableItemProps;
|
dnd?: SortableItemProps;
|
||||||
|
routePattern?: string;
|
||||||
}) {
|
}) {
|
||||||
const [opened, handlers] = useDisclosure(false);
|
|
||||||
const prefix = `fields.${index}.field` as const;
|
const prefix = `fields.${index}.field` as const;
|
||||||
const type = field.field.type;
|
const type = field.field.type;
|
||||||
const name = watch(`fields.${index}.name`);
|
const name = watch(`fields.${index}.name`);
|
||||||
|
const { active, toggle } = useRoutePathState(routePattern ?? "", name);
|
||||||
const fieldSpec = fieldSpecs.find((s) => s.type === type)!;
|
const fieldSpec = fieldSpecs.find((s) => s.type === type)!;
|
||||||
const specificData = omit(field.field.config, commonProps);
|
const specificData = omit(field.field.config, commonProps);
|
||||||
const disabled = fieldSpec.disabled || [];
|
const disabled = fieldSpec.disabled || [];
|
||||||
@@ -300,9 +309,11 @@ function EntityField({
|
|||||||
return () => {
|
return () => {
|
||||||
if (name.length === 0) {
|
if (name.length === 0) {
|
||||||
remove(index);
|
remove(index);
|
||||||
return;
|
toggle();
|
||||||
|
} else if (window.confirm(`Sure to delete "${name}"?`)) {
|
||||||
|
remove(index);
|
||||||
|
toggle();
|
||||||
}
|
}
|
||||||
window.confirm(`Sure to delete "${name}"?`) && remove(index);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
//console.log("register", register(`${prefix}.config.required`));
|
//console.log("register", register(`${prefix}.config.required`));
|
||||||
@@ -313,7 +324,7 @@ function EntityField({
|
|||||||
key={field.id}
|
key={field.id}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"flex flex-col border border-muted rounded bg-background mb-2",
|
"flex flex-col border border-muted rounded bg-background mb-2",
|
||||||
opened && "mb-6",
|
active && "mb-6",
|
||||||
hasErrors && "border-red-500 ",
|
hasErrors && "border-red-500 ",
|
||||||
)}
|
)}
|
||||||
{...dndProps}
|
{...dndProps}
|
||||||
@@ -371,13 +382,13 @@ function EntityField({
|
|||||||
Icon={TbSettings}
|
Icon={TbSettings}
|
||||||
disabled={is_primary}
|
disabled={is_primary}
|
||||||
iconProps={{ strokeWidth: 1.5 }}
|
iconProps={{ strokeWidth: 1.5 }}
|
||||||
onClick={handlers.toggle}
|
onClick={() => toggle()}
|
||||||
variant={opened ? "primary" : "ghost"}
|
variant={active ? "primary" : "ghost"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{opened && (
|
{active && (
|
||||||
<div className="flex flex-col border-t border-t-muted px-3 py-2 bg-lightest/50">
|
<div className="flex flex-col border-t border-t-muted px-3 py-2 bg-lightest/50">
|
||||||
{/*<pre>{JSON.stringify(field, null, 2)}</pre>*/}
|
{/*<pre>{JSON.stringify(field, null, 2)}</pre>*/}
|
||||||
<Tabs defaultValue="general">
|
<Tabs defaultValue="general">
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default function DataRoutes() {
|
|||||||
|
|
||||||
<Route path="/schema" nest>
|
<Route path="/schema" nest>
|
||||||
<Route path="/" component={DataSchemaIndex} />
|
<Route path="/" component={DataSchemaIndex} />
|
||||||
<Route path="/entity/:entity" component={DataSchemaEntity} />
|
<Route path="/entity/:entity/:setting?/:sub?" component={DataSchemaEntity} />
|
||||||
</Route>
|
</Route>
|
||||||
</Switch>
|
</Switch>
|
||||||
</DataRoot>
|
</DataRoot>
|
||||||
|
|||||||
Reference in New Issue
Block a user