From 6694c6399094344bc67c488b7ac5cbf676cc5157 Mon Sep 17 00:00:00 2001 From: dswbx Date: Sat, 3 May 2025 11:05:38 +0200 Subject: [PATCH] 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. --- .../ui/components/list/CollapsibleList.tsx | 62 +++++++++++++ app/src/ui/hooks/use-route-path-state.tsx | 91 +++++++++++++++++++ app/src/ui/layouts/AppShell/AppShell.tsx | 32 +++++-- app/src/ui/routes/auth/auth.strategies.tsx | 69 +++++++------- app/src/ui/routes/auth/index.tsx | 2 +- .../ui/routes/data/data.schema.$entity.tsx | 55 ++++------- .../routes/data/forms/entity.fields.form.tsx | 27 ++++-- app/src/ui/routes/data/index.tsx | 2 +- 8 files changed, 250 insertions(+), 90 deletions(-) create mode 100644 app/src/ui/components/list/CollapsibleList.tsx create mode 100644 app/src/ui/hooks/use-route-path-state.tsx diff --git a/app/src/ui/components/list/CollapsibleList.tsx b/app/src/ui/components/list/CollapsibleList.tsx new file mode 100644 index 0000000..805ad54 --- /dev/null +++ b/app/src/ui/components/list/CollapsibleList.tsx @@ -0,0 +1,62 @@ +import type { ReactNode } from "react"; +import { twMerge } from "tailwind-merge"; + +export interface CollapsibleListRootProps extends React.HTMLAttributes {} + +const Root = ({ className, ...props }: CollapsibleListRootProps) => ( +
+); + +export interface CollapsibleListItemProps extends React.HTMLAttributes { + hasError?: boolean; + disabled?: boolean; +} + +const Item = ({ className, hasError, disabled, ...props }: CollapsibleListItemProps) => ( +
+); + +export interface CollapsibleListPreviewProps extends React.HTMLAttributes { + left?: ReactNode; + right?: ReactNode; +} + +const Preview = ({ className, left, right, children, ...props }: CollapsibleListPreviewProps) => ( +
+ {left &&
{left}
} +
{children}
+ {right &&
{right}
} +
+); + +export interface CollapsibleListDetailProps extends React.HTMLAttributes { + open?: boolean; +} + +const Detail = ({ className, open, ...props }: CollapsibleListDetailProps) => + open && ( +
+ ); + +export const CollapsibleList = { + Root, + Item, + Preview, + Detail, +}; diff --git a/app/src/ui/hooks/use-route-path-state.tsx b/app/src/ui/hooks/use-route-path-state.tsx new file mode 100644 index 0000000..620d1a8 --- /dev/null +++ b/app/src/ui/hooks/use-route-path-state.tsx @@ -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(undefined!); + +export function RoutePathStateProvider({ + children, + defaultIdentifier, + path, +}: Pick & { children: React.ReactNode }) { + const segment = extractPathSegment(path); + const routeIdentifier = useParams()[segment]; + const [activeIdentifier, setActiveIdentifier] = useState(routeIdentifier ?? defaultIdentifier); + return ( + + {children} + + ); +} + +function useRoutePathContext(path?: string) { + const ctx = use(RoutePathStateContext); + if (ctx && (!path || ctx.path === path)) { + return ctx; + } + return undefined; +} diff --git a/app/src/ui/layouts/AppShell/AppShell.tsx b/app/src/ui/layouts/AppShell/AppShell.tsx index 5d61bbf..9018e29 100644 --- a/app/src/ui/layouts/AppShell/AppShell.tsx +++ b/app/src/ui/layouts/AppShell/AppShell.tsx @@ -13,6 +13,7 @@ import { import type { IconType } from "react-icons"; import { twMerge } from "tailwind-merge"; 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 { appShellStore } from "ui/store"; 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 = ({ title, open, @@ -383,14 +393,7 @@ export const SectionHeaderAccordionItem = ({ ActiveIcon = IconChevronUp, children, renderHeaderRight, -}: { - title: string; - open: boolean; - toggle: () => void; - ActiveIcon?: any; - children?: React.ReactNode; - renderHeaderRight?: (props: { open: boolean }) => React.ReactNode; -}) => ( +}: SectionHeaderAccordionItemProps) => (
); +export const RouteAwareSectionHeaderAccordionItem = ({ + routePattern, + identifier, + ...props +}: Omit & { + // it's optional because it could be provided using the context + routePattern?: string; + identifier: string; +}) => { + const { active, toggle } = useRoutePathState(routePattern, identifier); + return ; +}; + export const Separator = ({ className, ...props }: ComponentPropsWithoutRef<"hr">) => (
); diff --git a/app/src/ui/routes/auth/auth.strategies.tsx b/app/src/ui/routes/auth/auth.strategies.tsx index 7e68a96..8fbc3b7 100644 --- a/app/src/ui/routes/auth/auth.strategies.tsx +++ b/app/src/ui/routes/auth/auth.strategies.tsx @@ -33,6 +33,8 @@ import { } from "ui/components/form/json-schema-form"; import { useBrowserTitle } from "ui/hooks/use-browser-title"; 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) { useBrowserTitle(["Auth", "Strategies"]); @@ -104,7 +106,7 @@ function AuthStrategiesListInternal() {

Allow users to sign in or sign up using different strategies.

-
+ @@ -113,7 +115,7 @@ function AuthStrategiesListInternal() { -
+
@@ -138,47 +140,40 @@ const Strategy = ({ type, name, unavailable }: StrategyProps) => { ]), ); const schema = schemas[type]; - const [open, setOpen] = useState(false); + + const { active, toggle } = useRoutePathState("/strategies/:strategy?", name); if (!schema) return null; return ( -
0 && "border-red-500", - )} + 0} + className={ + unavailable ? "opacity-20 pointer-events-none cursor-not-allowed" : undefined + } > -
-
- -
-
- {autoFormatString(name)} -
-
- - setOpen((o) => !o)} - /> -
-
- {open && ( -
- -
- )} -
+ } + right={ + <> + + toggle(!active)} + /> + + } + > + {autoFormatString(name)} + + + + +
); }; diff --git a/app/src/ui/routes/auth/index.tsx b/app/src/ui/routes/auth/index.tsx index 47bdfdc..c672d1b 100644 --- a/app/src/ui/routes/auth/index.tsx +++ b/app/src/ui/routes/auth/index.tsx @@ -14,7 +14,7 @@ export default function AuthRoutes() { - + ); diff --git a/app/src/ui/routes/data/data.schema.$entity.tsx b/app/src/ui/routes/data/data.schema.$entity.tsx index e93d7f2..0831284 100644 --- a/app/src/ui/routes/data/data.schema.$entity.tsx +++ b/app/src/ui/routes/data/data.schema.$entity.tsx @@ -30,14 +30,10 @@ import { routes, useNavigate } from "ui/lib/routes"; import { fieldSpecs } from "ui/modules/data/components/fields-specs"; import { extractSchema } from "../settings/utils/schema"; import { EntityFieldsForm, type EntityFieldsFormRef } from "./forms/entity.fields.form"; +import { RoutePathStateProvider } from "ui/hooks/use-route-path-state"; export function DataSchemaEntity({ params }) { const { $data } = useBkndData(); - const [value, setValue] = useState("fields"); - - function toggle(value) { - return () => setValue(value); - } const [navigate] = useNavigate(); const entity = $data.entity(params.entity as string)!; @@ -46,7 +42,7 @@ export function DataSchemaEntity({ params }) { } return ( - <> + @@ -109,13 +105,12 @@ export function DataSchemaEntity({ params }) {
- + - - + - - + - +
- + ); } -const Fields = ({ - entity, - open, - toggle, -}: { entity: Entity; open: boolean; toggle: () => void }) => { +const Fields = ({ entity }: { entity: Entity }) => { const [submitting, setSubmitting] = useState(false); const [updates, setUpdates] = useState(0); const { actions, $data } = useBkndData(); @@ -174,10 +164,9 @@ const Fields = ({ const initialFields = Object.fromEntries(entity.fields.map((f) => [f.name, f.toJSON()])) as any; return ( - open ? ( @@ -192,6 +181,7 @@ const Fields = ({
)} )}
-
+ ); }; -const BasicSettings = ({ - entity, - open, - toggle, -}: { entity: Entity; open: boolean; toggle: () => void }) => { +const BasicSettings = ({ entity }: { entity: Entity }) => { const d = useBkndData(); const config = d.entities?.[entity.name]?.config; const formRef = useRef(null); @@ -271,10 +257,9 @@ const BasicSettings = ({ } return ( - open ? ( @@ -293,6 +278,6 @@ const BasicSettings = ({ className="legacy hide-required-mark fieldset-alternative mute-root" />
- + ); }; diff --git a/app/src/ui/routes/data/forms/entity.fields.form.tsx b/app/src/ui/routes/data/forms/entity.fields.form.tsx index 2b7eb26..a0ca16c 100644 --- a/app/src/ui/routes/data/forms/entity.fields.form.tsx +++ b/app/src/ui/routes/data/forms/entity.fields.form.tsx @@ -27,6 +27,7 @@ import { Popover } from "ui/components/overlay/Popover"; import { type TFieldSpec, fieldSpecs } from "ui/modules/data/components/fields-specs"; import { dataFieldsUiSchema } from "../../settings/routes/data.settings"; import * as tbbox from "@sinclair/typebox"; +import { useRoutePathState } from "ui/hooks/use-route-path-state"; const { Type } = tbbox; const fieldsSchemaObject = originalFieldsSchemaObject; @@ -63,6 +64,7 @@ export type EntityFieldsFormProps = { onChange?: (formData: TAppDataEntityFields) => void; sortable?: boolean; additionalFieldTypes?: (TFieldSpec & { onClick: () => void })[]; + routePattern?: string; }; export type EntityFieldsFormRef = { @@ -74,7 +76,10 @@ export type EntityFieldsFormRef = { }; export const EntityFieldsForm = forwardRef( - 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]) => ({ name, field, @@ -166,6 +171,7 @@ export const EntityFieldsForm = forwardRef )} /> @@ -179,6 +185,7 @@ export const EntityFieldsForm = forwardRef ))}
@@ -273,6 +280,7 @@ function EntityField({ remove, errors, dnd, + routePattern, }: { field: FieldArrayWithId; index: number; @@ -283,11 +291,12 @@ function EntityField({ remove: (index: number) => void; errors: any; dnd?: SortableItemProps; + routePattern?: string; }) { - const [opened, handlers] = useDisclosure(false); const prefix = `fields.${index}.field` as const; const type = field.field.type; 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 || []; @@ -300,9 +309,11 @@ function EntityField({ return () => { if (name.length === 0) { 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`)); @@ -313,7 +324,7 @@ function EntityField({ key={field.id} className={twMerge( "flex flex-col border border-muted rounded bg-background mb-2", - opened && "mb-6", + active && "mb-6", hasErrors && "border-red-500 ", )} {...dndProps} @@ -371,13 +382,13 @@ function EntityField({ Icon={TbSettings} disabled={is_primary} iconProps={{ strokeWidth: 1.5 }} - onClick={handlers.toggle} - variant={opened ? "primary" : "ghost"} + onClick={() => toggle()} + variant={active ? "primary" : "ghost"} /> - {opened && ( + {active && (
{/*
{JSON.stringify(field, null, 2)}
*/} diff --git a/app/src/ui/routes/data/index.tsx b/app/src/ui/routes/data/index.tsx index bade982..f7735fd 100644 --- a/app/src/ui/routes/data/index.tsx +++ b/app/src/ui/routes/data/index.tsx @@ -17,7 +17,7 @@ export default function DataRoutes() { - +