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:
dswbx
2025-05-03 11:05:38 +02:00
committed by GitHub
parent b3f95f9552
commit 6694c63990
8 changed files with 250 additions and 90 deletions

View 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,
};

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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