diff --git a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx index fbce204352..10880603d1 100644 --- a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { Button, Card, Input, Select, Toggle } from "@/shared/components"; +import { Button, Card, Collapsible, Input, Select, Toggle } from "@/shared/components"; import { useTranslations } from "next-intl"; import FallbackChainsEditor from "./FallbackChainsEditor"; import { @@ -973,7 +973,42 @@ export default function RoutingTab() { -
+ {/* Add provider โ€” moved to TOP per UX brief. */} +
+ + +
+ + {Object.keys(systemTransforms.providers).length === 0 && ( +

{t("systemTransformsNoProviders")}

+ )} + +
{Object.entries(systemTransforms.providers).map(([providerId, providerCfg]) => { const isBuiltin = BUILTIN_PROVIDERS.has(providerId); const display = PROVIDER_TILE_DISPLAY[providerId] ?? { @@ -989,32 +1024,28 @@ export default function RoutingTab() { (DEFAULT_SYSTEM_TRANSFORMS_CLIENT.providers as Record)[providerId] ); const isJsonOpen = showJsonEditor[providerId] ?? false; + const enabled = providerCfg.enabled !== false; const selectedKind = (addOpKind[providerId] as TransformOpKind | undefined) ?? "drop_paragraph_if_contains"; return ( -
- {/* Provider header row */} -
-
-
- - {providerId} - - {display.name} -
-

{display.description}

-

- {opCount} op{opCount === 1 ? "" : "s"} -

+ defaultOpen={false} + title={ +
+ + {providerId} + + {display.name}
-
+ } + subtitle={`${opCount} op${opCount === 1 ? "" : "s"} ยท ${enabled ? "enabled" : "disabled"}`} + trailing={ + <> toggleProviderEnabled(providerId, checked)} disabled={loading} ariaLabel={`Enable ${display.name} transforms`} @@ -1030,59 +1061,65 @@ export default function RoutingTab() { onClick={() => removeProvider(providerId)} /> )} -
-
+ + } + > +

{display.description}

- {/* Pipeline op list with per-op editor */} + {/* Pipeline op list โ€” each op is itself collapsible. */} {opCount > 0 && (
    {(providerCfg.pipeline as any[]).map((op, index) => ( -
  1. -
    - - {index + 1} - - {op?.kind} -
    -
    -
    -
    +
  2. + + + {index + 1} + + {op?.kind} +
+ } + trailing={ + <> +
+ ))} @@ -1171,45 +1208,11 @@ export default function RoutingTab() {
)}
- + ); })} - {Object.keys(systemTransforms.providers).length === 0 && ( -

{t("systemTransformsNoProviders")}

- )} - -
- - -
-

All transform ops are idempotent on re-run. Changes take effect immediately on the next request. diff --git a/src/shared/components/Collapsible.tsx b/src/shared/components/Collapsible.tsx new file mode 100644 index 0000000000..3a3a63b21d --- /dev/null +++ b/src/shared/components/Collapsible.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useState, type ReactNode } from "react"; +import { cn } from "@/shared/utils/cn"; + +interface CollapsibleProps { + /** Header content (always visible, click-to-toggle). */ + title: ReactNode; + /** Optional secondary line under the title. */ + subtitle?: ReactNode; + /** Material symbol name shown left of the title. */ + icon?: string; + /** Trailing element rendered next to the chevron (badges, op counts, etc). */ + trailing?: ReactNode; + /** Whether the section is open on first render. Defaults to false. */ + defaultOpen?: boolean; + /** Visual variant. `default` for top-level sections; `inline` for nested rows. */ + variant?: "default" | "inline"; + /** Custom class for the wrapper. */ + className?: string; + /** Content rendered when expanded. */ + children: ReactNode; +} + +/** + * Minimal click-to-expand section. Stateless from the caller's perspective + * (open/closed lives in local state โ€” does NOT survive page refresh, per the + * UX brief). Uses material-symbols-outlined chevrons to match the rest of + * the OmniRoute UI. + */ +export default function Collapsible({ + title, + subtitle, + icon, + trailing, + defaultOpen = false, + variant = "default", + className, + children, +}: CollapsibleProps) { + const [open, setOpen] = useState(defaultOpen); + + const wrapperClasses = cn( + variant === "default" + ? "rounded-lg border border-black/5 dark:border-white/5 bg-surface" + : "rounded-md border border-black/5 dark:border-white/5 bg-black/[0.02] dark:bg-white/[0.02]", + className + ); + + const headerRowClasses = cn( + "flex items-center gap-3", + variant === "default" ? "p-4" : "p-3", + "hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors", + open && "border-b border-black/5 dark:border-white/5" + ); + + // The chevron + title region is the click target. Trailing interactive + // controls (Toggle, Button) live OUTSIDE the toggle button so we never nest + // + {trailing &&

{trailing}
} + + {open &&
{children}
} + + ); +} diff --git a/src/shared/components/index.tsx b/src/shared/components/index.tsx index 568c9e44e0..1039804883 100644 --- a/src/shared/components/index.tsx +++ b/src/shared/components/index.tsx @@ -3,6 +3,7 @@ export { default as Button } from "./Button"; export { default as Input } from "./Input"; export { default as Select } from "./Select"; export { default as Card } from "./Card"; +export { default as Collapsible } from "./Collapsible"; export { default as Modal, ConfirmModal } from "./Modal"; export { default as Loading, Spinner, PageLoading, Skeleton, CardSkeleton } from "./Loading"; export { default as Avatar } from "./Avatar";