From 5e52f6a3f61ce29b2074cbc9b768f06affc5e3ff Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 4 Jun 2026 22:00:04 -0700 Subject: [PATCH] feat(dashboard): shared schema-driven settings UI primitives --- .../__tests__/settings-primitives.test.tsx | 249 ++++++++++++++++++ .../components/settings/SettingsFieldRow.css | 94 +++++++ .../components/settings/SettingsFieldRow.tsx | 95 +++++++ .../components/settings/SettingsNumberRow.css | 5 + .../components/settings/SettingsNumberRow.tsx | 63 +++++ .../components/settings/SettingsSection.css | 38 +++ .../components/settings/SettingsSection.tsx | 30 +++ .../components/settings/SettingsSelectRow.css | 5 + .../components/settings/SettingsSelectRow.tsx | 55 ++++ .../components/settings/SettingsTextRow.css | 5 + .../components/settings/SettingsTextRow.tsx | 51 ++++ .../settings/SettingsTextareaRow.css | 7 + .../settings/SettingsTextareaRow.tsx | 51 ++++ .../components/settings/SettingsToggleRow.css | 14 + .../components/settings/SettingsToggleRow.tsx | 51 ++++ .../app/components/settings/index.ts | 25 ++ .../app/components/settings/types.ts | 45 ++++ 17 files changed, 883 insertions(+) create mode 100644 packages/dashboard/app/__tests__/settings-primitives.test.tsx create mode 100644 packages/dashboard/app/components/settings/SettingsFieldRow.css create mode 100644 packages/dashboard/app/components/settings/SettingsFieldRow.tsx create mode 100644 packages/dashboard/app/components/settings/SettingsNumberRow.css create mode 100644 packages/dashboard/app/components/settings/SettingsNumberRow.tsx create mode 100644 packages/dashboard/app/components/settings/SettingsSection.css create mode 100644 packages/dashboard/app/components/settings/SettingsSection.tsx create mode 100644 packages/dashboard/app/components/settings/SettingsSelectRow.css create mode 100644 packages/dashboard/app/components/settings/SettingsSelectRow.tsx create mode 100644 packages/dashboard/app/components/settings/SettingsTextRow.css create mode 100644 packages/dashboard/app/components/settings/SettingsTextRow.tsx create mode 100644 packages/dashboard/app/components/settings/SettingsTextareaRow.css create mode 100644 packages/dashboard/app/components/settings/SettingsTextareaRow.tsx create mode 100644 packages/dashboard/app/components/settings/SettingsToggleRow.css create mode 100644 packages/dashboard/app/components/settings/SettingsToggleRow.tsx create mode 100644 packages/dashboard/app/components/settings/index.ts create mode 100644 packages/dashboard/app/components/settings/types.ts diff --git a/packages/dashboard/app/__tests__/settings-primitives.test.tsx b/packages/dashboard/app/__tests__/settings-primitives.test.tsx new file mode 100644 index 0000000000..48c12dc0b6 --- /dev/null +++ b/packages/dashboard/app/__tests__/settings-primitives.test.tsx @@ -0,0 +1,249 @@ +// @vitest-environment jsdom +/** + * Settings UI primitives (U8 / KTD-10) — behavior + typing contract. + * + * Scope here is behavior and value typing (visual polish is verified in U9's + * browser pass): each primitive renders label/help/error, the scope badge + * renders, change events propagate with correctly-typed values (numbers not + * strings, booleans, the selected option value), and the clearable affordance + * emits the null-as-delete signal that preserves the modal's clear semantics. + */ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import * as jestDomMatchers from "@testing-library/jest-dom/matchers"; + +import { + SettingsFieldRow, + SettingsToggleRow, + SettingsNumberRow, + SettingsSelectRow, + SettingsTextRow, + SettingsTextareaRow, + SettingsSection, +} from "../components/settings"; + +expect.extend(jestDomMatchers); + +afterEach(() => cleanup()); + +describe("SettingsFieldRow", () => { + it("renders label, help, and error", () => { + render( + + + , + ); + expect(screen.getByText("Theme")).toBeInTheDocument(); + expect(screen.getByText("Pick a theme")).toBeInTheDocument(); + expect(screen.getByRole("alert")).toHaveTextContent("Required"); + }); + + it("renders a scope badge when scope is set", () => { + render( + + + , + ); + const badge = screen.getByTestId("settings-field-row-scope"); + expect(badge).toHaveTextContent("global"); + expect(badge).toHaveClass("settings-field-row-scope--global"); + }); + + it("renders no scope badge by default", () => { + render( + + + , + ); + expect(screen.queryByTestId("settings-field-row-scope")).not.toBeInTheDocument(); + }); + + it("renders the clear affordance and fires onClear when clearable", () => { + const onClear = vi.fn(); + render( + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Reset to default" })); + expect(onClear).toHaveBeenCalledTimes(1); + }); + + it("hides the clear affordance when not clearable", () => { + render( + + + , + ); + expect(screen.queryByRole("button", { name: "Reset to default" })).not.toBeInTheDocument(); + }); +}); + +describe("SettingsToggleRow", () => { + const descriptor = { key: "notify", label: "Notifications", help: "Toggle alerts" }; + + it("renders label and help and reflects value", () => { + render( {}} />); + expect(screen.getByText("Notifications")).toBeInTheDocument(); + expect(screen.getByText("Toggle alerts")).toBeInTheDocument(); + expect(screen.getByRole("checkbox")).toBeChecked(); + }); + + it("emits a boolean on change", () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole("checkbox")); + expect(onChange).toHaveBeenCalledWith(true); + expect(typeof onChange.mock.calls[0][0]).toBe("boolean"); + }); + + it("emits null when cleared", () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Reset to default" })); + expect(onChange).toHaveBeenCalledWith(null); + }); +}); + +describe("SettingsNumberRow", () => { + const descriptor = { key: "max", label: "Max parallel", min: 1, max: 10, step: 1 }; + + it("renders label and reflects value", () => { + render( {}} />); + expect(screen.getByText("Max parallel")).toBeInTheDocument(); + expect(screen.getByRole("spinbutton")).toHaveValue(4); + }); + + it("emits a number, not a string", () => { + const onChange = vi.fn(); + render(); + fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "7" } }); + expect(onChange).toHaveBeenCalledWith(7); + expect(typeof onChange.mock.calls[0][0]).toBe("number"); + }); + + it("emits null when emptied", () => { + const onChange = vi.fn(); + render(); + fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "" } }); + expect(onChange).toHaveBeenCalledWith(null); + }); + + it("emits null when cleared", () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Reset to default" })); + expect(onChange).toHaveBeenCalledWith(null); + }); + + it("shows an empty field for a null value", () => { + render( {}} />); + expect(screen.getByRole("spinbutton")).toHaveValue(null); + }); +}); + +describe("SettingsSelectRow", () => { + const descriptor = { + key: "theme", + label: "Theme", + options: [ + { value: "light", label: "Light" }, + { value: "dark", label: "Dark" }, + ], + }; + + it("renders all options", () => { + render( {}} />); + expect(screen.getByRole("option", { name: "Light" })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "Dark" })).toBeInTheDocument(); + expect(screen.getByRole("combobox")).toHaveValue("light"); + }); + + it("emits the selected value", () => { + const onChange = vi.fn(); + render(); + fireEvent.change(screen.getByRole("combobox"), { target: { value: "dark" } }); + expect(onChange).toHaveBeenCalledWith("dark"); + }); + + it("emits null when cleared", () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Reset to default" })); + expect(onChange).toHaveBeenCalledWith(null); + }); +}); + +describe("SettingsTextRow", () => { + const descriptor = { key: "name", label: "Display name", placeholder: "e.g. Ada" }; + + it("renders label and placeholder and reflects value", () => { + render( {}} />); + expect(screen.getByText("Display name")).toBeInTheDocument(); + const input = screen.getByRole("textbox"); + expect(input).toHaveValue("Ada"); + expect(input).toHaveAttribute("placeholder", "e.g. Ada"); + }); + + it("emits the string value", () => { + const onChange = vi.fn(); + render(); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "Grace" } }); + expect(onChange).toHaveBeenCalledWith("Grace"); + expect(typeof onChange.mock.calls[0][0]).toBe("string"); + }); + + it("emits null when cleared", () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Reset to default" })); + expect(onChange).toHaveBeenCalledWith(null); + }); +}); + +describe("SettingsTextareaRow", () => { + const descriptor = { key: "notes", label: "Notes", placeholder: "Anything..." }; + + it("renders label and reflects value", () => { + render( {}} />); + expect(screen.getByText("Notes")).toBeInTheDocument(); + expect(screen.getByRole("textbox")).toHaveValue("hello"); + }); + + it("emits the string value", () => { + const onChange = vi.fn(); + render(); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "line1\nline2" } }); + expect(onChange).toHaveBeenCalledWith("line1\nline2"); + expect(typeof onChange.mock.calls[0][0]).toBe("string"); + }); + + it("emits null when cleared", () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Reset to default" })); + expect(onChange).toHaveBeenCalledWith(null); + }); +}); + +describe("SettingsSection", () => { + it("renders title, description, and children", () => { + render( + +
content
+
, + ); + expect(screen.getByRole("heading", { name: "General" })).toBeInTheDocument(); + expect(screen.getByText("Top-level options")).toBeInTheDocument(); + expect(screen.getByTestId("child")).toBeInTheDocument(); + }); + + it("renders without a description", () => { + render( + +
content
+
, + ); + expect(screen.getByRole("heading", { name: "General" })).toBeInTheDocument(); + }); +}); diff --git a/packages/dashboard/app/components/settings/SettingsFieldRow.css b/packages/dashboard/app/components/settings/SettingsFieldRow.css new file mode 100644 index 0000000000..4ace13c67c --- /dev/null +++ b/packages/dashboard/app/components/settings/SettingsFieldRow.css @@ -0,0 +1,94 @@ +/* SettingsFieldRow (U8 / KTD-10) — base layout for a single settings control: + * label + optional scope badge on the leading line, the control slot beside or + * below it, then help text and an error band. Shared by every typed row so the + * redesigned SettingsModal and the WorkflowSettingsPanel read identically. + * Mirrors the token/class conventions of WorkflowFieldsPanel.css. */ + +.settings-field-row { + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding: var(--space-sm) 0; +} + +.settings-field-row.is-disabled { + opacity: 0.6; +} + +.settings-field-row-head { + display: flex; + align-items: center; + gap: var(--space-xs); + flex-wrap: wrap; +} + +.settings-field-row-label { + font-size: 0.8rem; + font-weight: 500; + color: var(--text); +} + +.settings-field-row-scope { + font-size: 0.6rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); + background: var(--surface-muted, rgba(255, 255, 255, 0.04)); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 1px 6px; +} + +.settings-field-row-scope--global { + color: var(--accent, #7c5cbf); + border-color: var(--accent, #7c5cbf); +} + +.settings-field-row-control { + display: flex; + align-items: center; + gap: var(--space-xs); +} + +.settings-field-row-control > input:not([type="checkbox"]), +.settings-field-row-control > select, +.settings-field-row-control > textarea { + flex: 1; + min-width: 0; +} + +.settings-field-row-clear { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + background: none; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-muted); + cursor: pointer; + padding: 2px; + transition: color var(--duration-fast) ease, border-color var(--duration-fast) ease; +} + +.settings-field-row-clear:hover:not(:disabled) { + color: var(--text); + border-color: var(--text-muted); +} + +.settings-field-row-clear:disabled { + cursor: default; + opacity: 0.5; +} + +.settings-field-row-help { + margin: 0; + font-size: 0.7rem; + color: var(--text-muted); +} + +.settings-field-row-error { + margin: 0; + font-size: 0.7rem; + color: var(--color-error, #f85149); +} diff --git a/packages/dashboard/app/components/settings/SettingsFieldRow.tsx b/packages/dashboard/app/components/settings/SettingsFieldRow.tsx new file mode 100644 index 0000000000..8b2c08e08e --- /dev/null +++ b/packages/dashboard/app/components/settings/SettingsFieldRow.tsx @@ -0,0 +1,95 @@ +/** + * SettingsFieldRow — the base layout primitive every typed settings row composes + * (U8 / KTD-10). It owns nothing about the control itself: callers pass the + * control as `children` and this row handles the surrounding chrome — label, + * scope badge (global/project), help text, error band, and an optional + * "reset to default" clear affordance. + * + * Strings are pre-translated by callers (the descriptor carries label/help), so + * this primitive hardcodes no user-facing copy. The only intrinsic string is the + * clear button's aria-label, sourced via useTranslation like neighboring + * components (e.g. WorkflowFieldsPanel). + */ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { RotateCcw } from "lucide-react"; +import "./SettingsFieldRow.css"; + +/** Which authority level a setting is being edited at. `undefined` renders no + * badge (the common case for a plain app/global setting). */ +export type SettingsScope = "global" | "project"; + +export interface SettingsFieldRowProps { + /** Stable id, used to associate the label with the control. */ + htmlFor?: string; + /** Pre-translated label text. */ + label: string; + /** Pre-translated help/description text rendered under the control. */ + help?: string; + /** Pre-translated validation message; renders the error band when set. */ + error?: string; + /** Scope badge to display next to the label. */ + scope?: SettingsScope; + /** Disables the clear affordance and dims the row. */ + disabled?: boolean; + /** When set, renders a clear/reset-to-default button that calls onClear. */ + clearable?: boolean; + /** Invoked when the user presses the clear affordance. */ + onClear?: () => void; + /** The control element (input/select/textarea/toggle). */ + children: ReactNode; +} + +export function SettingsFieldRow({ + htmlFor, + label, + help, + error, + scope, + disabled, + clearable, + onClear, + children, +}: SettingsFieldRowProps) { + const { t } = useTranslation("app"); + return ( +
+
+ + {scope && ( + + {scope} + + )} +
+
+ {children} + {clearable && ( + + )} +
+ {help &&

{help}

} + {error && ( +

+ {error} +

+ )} +
+ ); +} + +export default SettingsFieldRow; diff --git a/packages/dashboard/app/components/settings/SettingsNumberRow.css b/packages/dashboard/app/components/settings/SettingsNumberRow.css new file mode 100644 index 0000000000..bbb191470c --- /dev/null +++ b/packages/dashboard/app/components/settings/SettingsNumberRow.css @@ -0,0 +1,5 @@ +/* SettingsNumberRow (U8 / KTD-10) — numeric input control slot. */ + +.settings-number { + width: 100%; +} diff --git a/packages/dashboard/app/components/settings/SettingsNumberRow.tsx b/packages/dashboard/app/components/settings/SettingsNumberRow.tsx new file mode 100644 index 0000000000..e31726bb6b --- /dev/null +++ b/packages/dashboard/app/components/settings/SettingsNumberRow.tsx @@ -0,0 +1,63 @@ +/** + * SettingsNumberRow — numeric control composing SettingsFieldRow (U8 / KTD-10). + * Emits numbers (never strings) through onChange. An empty input emits null — + * the modal's null-as-delete signal — which is also what the clear affordance + * emits when `clearable` is set. + */ +import { SettingsFieldRow } from "./SettingsFieldRow"; +import type { SettingsNumberDescriptor } from "./types"; +import "./SettingsNumberRow.css"; + +export interface SettingsNumberRowProps { + descriptor: SettingsNumberDescriptor; + value: number | null; + onChange: (value: number | null) => void; + error?: string; + /** Renders a reset-to-default affordance that emits onChange(null). */ + clearable?: boolean; +} + +export function SettingsNumberRow({ + descriptor, + value, + onChange, + error, + clearable, +}: SettingsNumberRowProps) { + const { key, label, help, scope, disabled, min, max, step, placeholder } = descriptor; + return ( + onChange(null)} + > + { + const raw = e.target.value; + // Empty → null (delete). Otherwise coerce to a real number, never a + // string; ignore unparseable intermediate input. + if (raw === "") return onChange(null); + const n = Number(raw); + if (Number.isNaN(n)) return; + onChange(n); + }} + /> + + ); +} + +export default SettingsNumberRow; diff --git a/packages/dashboard/app/components/settings/SettingsSection.css b/packages/dashboard/app/components/settings/SettingsSection.css new file mode 100644 index 0000000000..d2d6ec7c17 --- /dev/null +++ b/packages/dashboard/app/components/settings/SettingsSection.css @@ -0,0 +1,38 @@ +/* SettingsSection (U8 / KTD-10) — titled grouping for settings rows. */ + +.settings-section { + display: flex; + flex-direction: column; + gap: var(--space-sm); + padding: var(--space-md) 0; + border-bottom: 1px solid var(--border); +} + +.settings-section:last-child { + border-bottom: none; +} + +.settings-section-head { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.settings-section-title { + margin: 0; + font-size: 0.9rem; + font-weight: 600; + color: var(--text); +} + +.settings-section-desc { + margin: 0; + font-size: 0.75rem; + color: var(--text-muted); +} + +.settings-section-body { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} diff --git a/packages/dashboard/app/components/settings/SettingsSection.tsx b/packages/dashboard/app/components/settings/SettingsSection.tsx new file mode 100644 index 0000000000..c1d52b30c8 --- /dev/null +++ b/packages/dashboard/app/components/settings/SettingsSection.tsx @@ -0,0 +1,30 @@ +/** + * SettingsSection — section scaffolding for grouped settings rows (U8 / KTD-10). + * Renders a titled block with optional description and consistent spacing; the + * redesigned SettingsModal and the WorkflowSettingsPanel both group their rows + * inside one. Title/description are pre-translated by the caller. + */ +import type { ReactNode } from "react"; +import "./SettingsSection.css"; + +export interface SettingsSectionProps { + /** Pre-translated section title. */ + title: string; + /** Pre-translated section description, rendered under the title. */ + description?: string; + children: ReactNode; +} + +export function SettingsSection({ title, description, children }: SettingsSectionProps) { + return ( +
+
+

{title}

+ {description &&

{description}

} +
+
{children}
+
+ ); +} + +export default SettingsSection; diff --git a/packages/dashboard/app/components/settings/SettingsSelectRow.css b/packages/dashboard/app/components/settings/SettingsSelectRow.css new file mode 100644 index 0000000000..b7bd8513ff --- /dev/null +++ b/packages/dashboard/app/components/settings/SettingsSelectRow.css @@ -0,0 +1,5 @@ +/* SettingsSelectRow (U8 / KTD-10) — select control slot. */ + +.settings-select { + width: 100%; +} diff --git a/packages/dashboard/app/components/settings/SettingsSelectRow.tsx b/packages/dashboard/app/components/settings/SettingsSelectRow.tsx new file mode 100644 index 0000000000..05ec201861 --- /dev/null +++ b/packages/dashboard/app/components/settings/SettingsSelectRow.tsx @@ -0,0 +1,55 @@ +/** + * SettingsSelectRow — single-select control composing SettingsFieldRow + * (U8 / KTD-10). Emits the selected option's string value, or null when cleared + * (the modal's null-as-delete signal) if `clearable` is set. + */ +import { SettingsFieldRow } from "./SettingsFieldRow"; +import type { SettingsSelectDescriptor } from "./types"; +import "./SettingsSelectRow.css"; + +export interface SettingsSelectRowProps { + descriptor: SettingsSelectDescriptor; + value: string | null; + onChange: (value: string | null) => void; + error?: string; + /** Renders a reset-to-default affordance that emits onChange(null). */ + clearable?: boolean; +} + +export function SettingsSelectRow({ + descriptor, + value, + onChange, + error, + clearable, +}: SettingsSelectRowProps) { + const { key, label, help, scope, disabled, options } = descriptor; + return ( + onChange(null)} + > + + + ); +} + +export default SettingsSelectRow; diff --git a/packages/dashboard/app/components/settings/SettingsTextRow.css b/packages/dashboard/app/components/settings/SettingsTextRow.css new file mode 100644 index 0000000000..c66dba888a --- /dev/null +++ b/packages/dashboard/app/components/settings/SettingsTextRow.css @@ -0,0 +1,5 @@ +/* SettingsTextRow (U8 / KTD-10) — single-line text input control slot. */ + +.settings-text { + width: 100%; +} diff --git a/packages/dashboard/app/components/settings/SettingsTextRow.tsx b/packages/dashboard/app/components/settings/SettingsTextRow.tsx new file mode 100644 index 0000000000..c18109fda5 --- /dev/null +++ b/packages/dashboard/app/components/settings/SettingsTextRow.tsx @@ -0,0 +1,51 @@ +/** + * SettingsTextRow — single-line text control composing SettingsFieldRow + * (U8 / KTD-10). Emits the string value, or null when cleared (the modal's + * null-as-delete signal) if `clearable` is set. + */ +import { SettingsFieldRow } from "./SettingsFieldRow"; +import type { SettingsTextDescriptor } from "./types"; +import "./SettingsTextRow.css"; + +export interface SettingsTextRowProps { + descriptor: SettingsTextDescriptor; + value: string | null; + onChange: (value: string | null) => void; + error?: string; + /** Renders a reset-to-default affordance that emits onChange(null). */ + clearable?: boolean; +} + +export function SettingsTextRow({ + descriptor, + value, + onChange, + error, + clearable, +}: SettingsTextRowProps) { + const { key, label, help, scope, disabled, placeholder } = descriptor; + return ( + onChange(null)} + > + onChange(e.target.value)} + /> + + ); +} + +export default SettingsTextRow; diff --git a/packages/dashboard/app/components/settings/SettingsTextareaRow.css b/packages/dashboard/app/components/settings/SettingsTextareaRow.css new file mode 100644 index 0000000000..e27d024e9f --- /dev/null +++ b/packages/dashboard/app/components/settings/SettingsTextareaRow.css @@ -0,0 +1,7 @@ +/* SettingsTextareaRow (U8 / KTD-10) — multi-line text input control slot. */ + +.settings-textarea { + width: 100%; + resize: vertical; + font-family: inherit; +} diff --git a/packages/dashboard/app/components/settings/SettingsTextareaRow.tsx b/packages/dashboard/app/components/settings/SettingsTextareaRow.tsx new file mode 100644 index 0000000000..a90362cade --- /dev/null +++ b/packages/dashboard/app/components/settings/SettingsTextareaRow.tsx @@ -0,0 +1,51 @@ +/** + * SettingsTextareaRow — multi-line text control composing SettingsFieldRow + * (U8 / KTD-10). Emits the string value, or null when cleared (the modal's + * null-as-delete signal) if `clearable` is set. + */ +import { SettingsFieldRow } from "./SettingsFieldRow"; +import type { SettingsTextDescriptor } from "./types"; +import "./SettingsTextareaRow.css"; + +export interface SettingsTextareaRowProps { + descriptor: SettingsTextDescriptor; + value: string | null; + onChange: (value: string | null) => void; + error?: string; + /** Renders a reset-to-default affordance that emits onChange(null). */ + clearable?: boolean; +} + +export function SettingsTextareaRow({ + descriptor, + value, + onChange, + error, + clearable, +}: SettingsTextareaRowProps) { + const { key, label, help, scope, disabled, placeholder } = descriptor; + return ( + onChange(null)} + > +