mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 21:32:10 +03:00
feat(ui): collapsible provider tiles + ops + move Add provider to top
Reduces vertical footprint of the System-block Transform Pipeline card so long pipelines (cc-bridge ships 9 ops) do not dominate the Routing tab. - New Collapsible component (src/shared/components/Collapsible.tsx) used as a shared primitive. Open/closed state lives in local component state; does NOT persist across reloads (per UX brief: always-collapsed default). - Each provider tile is now collapsible (closed by default). - Each pipeline op inside a provider is collapsible (closed by default) — click to expand the per-kind editor. - 'Add provider' Select+Button moved from BOTTOM to TOP of the card with a dashed border, so it is the first thing the user sees. - Trailing controls (Toggle, Button) render as siblings of the toggle button (not nested), avoiding invalid <button> inside <button> HTML. 59/59 unit tests green.
This commit is contained in:
@@ -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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Add provider — moved to TOP per UX brief. */}
|
||||
<div className="mb-4 flex items-end gap-2 rounded-lg border border-dashed border-border/40 bg-surface/30 p-3">
|
||||
<Select
|
||||
label={t("systemTransformsAddProvider")}
|
||||
value={newProviderId}
|
||||
disabled={loading || availableProvidersToAdd.length === 0}
|
||||
onChange={(e) => setNewProviderId(e.target.value)}
|
||||
className="flex-1"
|
||||
>
|
||||
<option value="">
|
||||
{availableProvidersToAdd.length === 0
|
||||
? t("systemTransformsAddProviderAllConfigured")
|
||||
: t("systemTransformsAddProviderPlaceholder")}
|
||||
</option>
|
||||
{availableProvidersToAdd.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} ({p.id})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="add"
|
||||
disabled={loading || !newProviderId || !!systemTransforms.providers[newProviderId]}
|
||||
onClick={addProvider}
|
||||
>
|
||||
{t("systemTransformsAddProvider")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{Object.keys(systemTransforms.providers).length === 0 && (
|
||||
<p className="text-sm text-text-muted py-2">{t("systemTransformsNoProviders")}</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{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<string, unknown>)[providerId]
|
||||
);
|
||||
const isJsonOpen = showJsonEditor[providerId] ?? false;
|
||||
const enabled = providerCfg.enabled !== false;
|
||||
const selectedKind =
|
||||
(addOpKind[providerId] as TransformOpKind | undefined) ??
|
||||
"drop_paragraph_if_contains";
|
||||
|
||||
return (
|
||||
<div
|
||||
<Collapsible
|
||||
key={providerId}
|
||||
className="rounded-lg border border-border/50 bg-surface/20 p-4"
|
||||
>
|
||||
{/* Provider header row */}
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<code className="text-xs font-mono rounded bg-surface px-1.5 py-0.5">
|
||||
{providerId}
|
||||
</code>
|
||||
<span className="text-sm font-medium">{display.name}</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-1">{display.description}</p>
|
||||
<p className="text-[11px] text-text-muted mt-1">
|
||||
{opCount} op{opCount === 1 ? "" : "s"}
|
||||
</p>
|
||||
defaultOpen={false}
|
||||
title={
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<code className="text-xs font-mono rounded bg-surface px-1.5 py-0.5">
|
||||
{providerId}
|
||||
</code>
|
||||
<span className="text-sm font-medium">{display.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
}
|
||||
subtitle={`${opCount} op${opCount === 1 ? "" : "s"} · ${enabled ? "enabled" : "disabled"}`}
|
||||
trailing={
|
||||
<>
|
||||
<Toggle
|
||||
checked={providerCfg.enabled !== false}
|
||||
checked={enabled}
|
||||
onChange={(checked) => toggleProviderEnabled(providerId, checked)}
|
||||
disabled={loading}
|
||||
ariaLabel={`Enable ${display.name} transforms`}
|
||||
@@ -1030,59 +1061,65 @@ export default function RoutingTab() {
|
||||
onClick={() => removeProvider(providerId)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-xs text-text-muted mb-3">{display.description}</p>
|
||||
|
||||
{/* Pipeline op list with per-op editor */}
|
||||
{/* Pipeline op list — each op is itself collapsible. */}
|
||||
{opCount > 0 && (
|
||||
<ol className="flex flex-col gap-2 mb-3">
|
||||
{(providerCfg.pipeline as any[]).map((op, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="rounded border border-border/30 bg-background/30 p-2 text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-purple-500/10 text-[10px] font-semibold text-purple-400">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="font-mono text-purple-300 flex-1">{op?.kind}</span>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="keyboard_arrow_up"
|
||||
disabled={loading || index === 0}
|
||||
aria-label={t("systemTransformsOpMoveUp")}
|
||||
title={t("systemTransformsOpMoveUp")}
|
||||
onClick={() => moveOp(providerId, index, -1)}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="keyboard_arrow_down"
|
||||
disabled={loading || index === opCount - 1}
|
||||
aria-label={t("systemTransformsOpMoveDown")}
|
||||
title={t("systemTransformsOpMoveDown")}
|
||||
onClick={() => moveOp(providerId, index, 1)}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="delete"
|
||||
disabled={loading}
|
||||
aria-label={t("systemTransformsOpDelete")}
|
||||
title={t("systemTransformsOpDelete")}
|
||||
onClick={() => deleteOp(providerId, index)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-7">
|
||||
<li key={index}>
|
||||
<Collapsible
|
||||
variant="inline"
|
||||
defaultOpen={false}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-purple-500/10 text-[10px] font-semibold text-purple-400">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="font-mono text-purple-300 text-xs">{op?.kind}</span>
|
||||
</div>
|
||||
}
|
||||
trailing={
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="keyboard_arrow_up"
|
||||
disabled={loading || index === 0}
|
||||
aria-label={t("systemTransformsOpMoveUp")}
|
||||
title={t("systemTransformsOpMoveUp")}
|
||||
onClick={() => moveOp(providerId, index, -1)}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="keyboard_arrow_down"
|
||||
disabled={loading || index === opCount - 1}
|
||||
aria-label={t("systemTransformsOpMoveDown")}
|
||||
title={t("systemTransformsOpMoveDown")}
|
||||
onClick={() => moveOp(providerId, index, 1)}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="delete"
|
||||
disabled={loading}
|
||||
aria-label={t("systemTransformsOpDelete")}
|
||||
title={t("systemTransformsOpDelete")}
|
||||
onClick={() => deleteOp(providerId, index)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<OpEditor
|
||||
op={op}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateOp(providerId, index, next)}
|
||||
/>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
@@ -1171,45 +1208,11 @@ export default function RoutingTab() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{Object.keys(systemTransforms.providers).length === 0 && (
|
||||
<p className="text-sm text-text-muted py-2">{t("systemTransformsNoProviders")}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex items-end gap-2 border-t border-border/20 pt-4">
|
||||
<Select
|
||||
label={t("systemTransformsAddProvider")}
|
||||
value={newProviderId}
|
||||
disabled={loading || availableProvidersToAdd.length === 0}
|
||||
onChange={(e) => setNewProviderId(e.target.value)}
|
||||
className="flex-1"
|
||||
>
|
||||
<option value="">
|
||||
{availableProvidersToAdd.length === 0
|
||||
? t("systemTransformsAddProviderAllConfigured")
|
||||
: t("systemTransformsAddProviderPlaceholder")}
|
||||
</option>
|
||||
{availableProvidersToAdd.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} ({p.id})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="add"
|
||||
disabled={loading || !newProviderId || !!systemTransforms.providers[newProviderId]}
|
||||
onClick={addProvider}
|
||||
>
|
||||
{t("systemTransformsAddProvider")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-[11px] text-text-muted">
|
||||
All transform ops are idempotent on re-run. Changes take effect immediately on the next
|
||||
request.
|
||||
|
||||
93
src/shared/components/Collapsible.tsx
Normal file
93
src/shared/components/Collapsible.tsx
Normal file
@@ -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
|
||||
// <button> inside <button> (invalid HTML; breaks keyboard nav + ARIA).
|
||||
return (
|
||||
<div className={wrapperClasses}>
|
||||
<div className={headerRowClasses}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
className="flex items-center gap-3 flex-1 min-w-0 text-left -m-1 p-1 rounded"
|
||||
>
|
||||
<span
|
||||
className="material-symbols-outlined text-text-muted text-[20px] shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{open ? "expand_more" : "chevron_right"}
|
||||
</span>
|
||||
{icon && (
|
||||
<span
|
||||
className="material-symbols-outlined text-text-muted text-[18px] shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-text-main truncate">{title}</div>
|
||||
{subtitle && <div className="text-xs text-text-muted truncate">{subtitle}</div>}
|
||||
</div>
|
||||
</button>
|
||||
{trailing && <div className="flex items-center gap-2 shrink-0">{trailing}</div>}
|
||||
</div>
|
||||
{open && <div className={variant === "default" ? "p-4" : "p-3"}>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user