From c04bf2e6cb0a674fab0dbc6b57ccdfc703a32e73 Mon Sep 17 00:00:00 2001 From: Richard Osborne Date: Wed, 18 Feb 2026 18:23:10 +0100 Subject: [PATCH] feat(uba): UBA-003/004 field renderers + ConfigPanel + Conditions --- .../phase-6-uba-system/PROGRESS-richard.md | 124 ++++--- .../src/editor/src/models/UBA/Conditions.ts | 115 ++++++ .../src/editor/src/models/UBA/index.ts | 1 + .../src/views/UBA/ConfigPanel.module.scss | 173 +++++++++ .../src/editor/src/views/UBA/ConfigPanel.tsx | 161 +++++++++ .../src/views/UBA/ConfigSection.module.scss | 55 +++ .../editor/src/views/UBA/ConfigSection.tsx | 84 +++++ .../src/views/UBA/fields/BooleanField.tsx | 43 +++ .../src/views/UBA/fields/FieldRenderer.tsx | 136 ++++++++ .../src/views/UBA/fields/FieldWrapper.tsx | 43 +++ .../src/views/UBA/fields/MultiSelectField.tsx | 97 ++++++ .../src/views/UBA/fields/NumberField.tsx | 71 ++++ .../src/views/UBA/fields/SecretField.tsx | 73 ++++ .../src/views/UBA/fields/SelectField.tsx | 54 +++ .../src/views/UBA/fields/StringField.tsx | 41 +++ .../editor/src/views/UBA/fields/TextField.tsx | 41 +++ .../editor/src/views/UBA/fields/UrlField.tsx | 71 ++++ .../src/views/UBA/fields/fields.module.scss | 329 ++++++++++++++++++ .../src/editor/src/views/UBA/fields/index.ts | 24 ++ .../src/views/UBA/hooks/useConfigForm.ts | 170 +++++++++ .../src/editor/src/views/UBA/index.ts | 14 + .../tests/models/UBAConditions.test.ts | 175 ++++++++++ 22 files changed, 2045 insertions(+), 50 deletions(-) create mode 100644 packages/noodl-editor/src/editor/src/models/UBA/Conditions.ts create mode 100644 packages/noodl-editor/src/editor/src/views/UBA/ConfigPanel.module.scss create mode 100644 packages/noodl-editor/src/editor/src/views/UBA/ConfigPanel.tsx create mode 100644 packages/noodl-editor/src/editor/src/views/UBA/ConfigSection.module.scss create mode 100644 packages/noodl-editor/src/editor/src/views/UBA/ConfigSection.tsx create mode 100644 packages/noodl-editor/src/editor/src/views/UBA/fields/BooleanField.tsx create mode 100644 packages/noodl-editor/src/editor/src/views/UBA/fields/FieldRenderer.tsx create mode 100644 packages/noodl-editor/src/editor/src/views/UBA/fields/FieldWrapper.tsx create mode 100644 packages/noodl-editor/src/editor/src/views/UBA/fields/MultiSelectField.tsx create mode 100644 packages/noodl-editor/src/editor/src/views/UBA/fields/NumberField.tsx create mode 100644 packages/noodl-editor/src/editor/src/views/UBA/fields/SecretField.tsx create mode 100644 packages/noodl-editor/src/editor/src/views/UBA/fields/SelectField.tsx create mode 100644 packages/noodl-editor/src/editor/src/views/UBA/fields/StringField.tsx create mode 100644 packages/noodl-editor/src/editor/src/views/UBA/fields/TextField.tsx create mode 100644 packages/noodl-editor/src/editor/src/views/UBA/fields/UrlField.tsx create mode 100644 packages/noodl-editor/src/editor/src/views/UBA/fields/fields.module.scss create mode 100644 packages/noodl-editor/src/editor/src/views/UBA/fields/index.ts create mode 100644 packages/noodl-editor/src/editor/src/views/UBA/hooks/useConfigForm.ts create mode 100644 packages/noodl-editor/src/editor/src/views/UBA/index.ts create mode 100644 packages/noodl-editor/tests/models/UBAConditions.test.ts diff --git a/dev-docs/tasks/phase-6-uba-system/PROGRESS-richard.md b/dev-docs/tasks/phase-6-uba-system/PROGRESS-richard.md index 114476d..65d2c84 100644 --- a/dev-docs/tasks/phase-6-uba-system/PROGRESS-richard.md +++ b/dev-docs/tasks/phase-6-uba-system/PROGRESS-richard.md @@ -1,67 +1,91 @@ -# Phase 6: UBA System — Richard's Progress +# UBA System — Progress (Richard) -## Sprint 1 Session 2 — 18 Feb 2026 +## Sprint 1 Session — 18 Feb 2026 -### Completed this session +### Completed -#### STYLE-005: SuggestionBanner wiring (property panel) — COMPLETE - -- Created `ElementStyleSectionHost` (editor-side React wrapper) - - Combines `ElementStyleSection` + `SuggestionBanner` - - Uses `useStyleSuggestions()` for the active suggestion - - Creates local `StyleTokensModel` instance (multiple instances safe — sync via ProjectModel events) - - Calls `executeSuggestionAction()` on accept, `dismissSession`/`dismissPermanent` on ignore/never -- Wired into `propertyeditor.ts` — drop-in replacement, zero API change -- All TS errors resolved - -#### UBA-001: TypeScript type definitions — COMPLETE +#### UBA-001: Types - `packages/noodl-editor/src/editor/src/models/UBA/types.ts` -- Full discriminated union on `Field.type` (string, text, number, boolean, secret, url, select, multi_select) -- `BackendMetadata`, `Endpoints`, `AuthConfig`, `Capabilities`, `Section`, `Condition`, `DebugSchema` -- `ParseResult` discriminated union + `ParseError` type -- Zero external dependencies +- Full discriminated union for all 8 field types +- `ParseResult` / `ParseError` types +- `Condition` with operator-based structure -#### UBA-002: SchemaParser — COMPLETE +#### UBA-002: SchemaParser - `packages/noodl-editor/src/editor/src/models/UBA/SchemaParser.ts` -- Accepts pre-parsed JS object (JSON.parse / yaml.load output) — no YAML dep required -- Validates: schema_version, backend (id/name/version/endpoints/auth/capabilities), sections, fields, debug -- Unknown field types → warning, not error (forward-compat) -- Unknown schema major version → warning with best-effort parsing -- All field types validated individually; partial errors collected before failing -- `packages/noodl-editor/src/editor/src/models/UBA/index.ts` — clean barrel exports +- Validates unknown input against the UBA schema spec +- Returns `ParseResult` with structured errors + warnings +- Tests: `packages/noodl-editor/tests/models/UBASchemaParser.test.ts` -#### UBA-002: SchemaParser unit tests — COMPLETE +#### UBA-003: Field Renderers -- `packages/noodl-editor/tests/models/UBASchemaParser.test.ts` -- 22 test cases covering: null/array rejection, missing version, version warning, minimal happy path -- Backend: missing, missing id, missing endpoints.config, optional fields, invalid auth type -- Sections: not array, minimal section, missing id -- Field types: string, boolean, number (min/max), secret, select (+ no-options error), multi_select, unknown type warning -- Debug schema with event_schema +All 8 field components + FieldWrapper + FieldRenderer factory: -### Next session priorities +| File | Component | +| --------------------------------------- | ------------------------------------ | +| `views/UBA/fields/FieldWrapper.tsx` | Shared label/error/description shell | +| `views/UBA/fields/StringField.tsx` | Single-line text input | +| `views/UBA/fields/TextField.tsx` | Multi-line textarea | +| `views/UBA/fields/NumberField.tsx` | Numeric input with clamping | +| `views/UBA/fields/BooleanField.tsx` | Toggle switch | +| `views/UBA/fields/SecretField.tsx` | Password input with show/hide | +| `views/UBA/fields/UrlField.tsx` | URL input with protocol validation | +| `views/UBA/fields/SelectField.tsx` | Native select dropdown | +| `views/UBA/fields/MultiSelectField.tsx` | Tag-based multi-select | +| `views/UBA/fields/FieldRenderer.tsx` | Factory dispatcher | +| `views/UBA/fields/fields.module.scss` | Shared SCSS (CSS vars only) | +| `views/UBA/fields/index.ts` | Barrel | -#### UBA-003: Field renderers +#### UBA-004: ConfigPanel + Conditions -- 8 field renderer components (one per field type) -- `FieldRenderer` factory component (dispatches on `field.type`) -- Shared styles (`fields.module.scss`) -- `FieldWrapper` with label, description, required indicator +Complete form shell with section tabs, validation, dirty state: -#### UBA-004: ConfigPanel shell +| File | Purpose | +| ------------------------------------- | ------------------------------------------------------------------ | +| `models/UBA/Conditions.ts` | `evaluateCondition`, `getNestedValue`, `setNestedValue`, `isEmpty` | +| `views/UBA/hooks/useConfigForm.ts` | Form state, dirty tracking, `validateRequired`, `flatToNested` | +| `views/UBA/ConfigSection.tsx` | Single section with `visible_when` filtering | +| `views/UBA/ConfigPanel.tsx` | Tabbed panel, save/reset, error banners | +| `views/UBA/ConfigSection.module.scss` | Section styles | +| `views/UBA/ConfigPanel.module.scss` | Panel styles | +| `views/UBA/index.ts` | Barrel | +| `models/UBA/index.ts` | Updated to export Conditions | -- `ConfigPanel.tsx` — renders sections + fields from a parsed `UBASchema` -- `useConfigForm` hook — manages form values, validation, dirty state -- Section collapsing, `visible_when` condition evaluation +#### Tests -### Tracking +- `tests/models/UBAConditions.test.ts` — 22 cases covering all 6 operators + path utils +- `tests/models/UBASchemaParser.test.ts` — existing tests (unchanged) -| Task | Status | -| ---------------------------------- | ------- | -| STYLE-005: SuggestionBanner wiring | ✅ DONE | -| UBA-001: TypeScript types | ✅ DONE | -| UBA-002: SchemaParser + tests | ✅ DONE | -| UBA-003: Field renderers | 🔲 NEXT | -| UBA-004: ConfigPanel + hook | 🔲 TODO | +> ⚠️ Tests unverified this session — port 8081 in use by dev server. Run after stopping the app. + +--- + +## Next Up (Sprint 2) + +### UBA-005: Backend HTTP Client + +- `services/UBAClient.ts` — `configure(values)`, `health()`, `debugStream()` +- Typed around `BackendMetadata.endpoints` + +### UBA-006: UBAPanel Integration + +- Mount `ConfigPanel` inside a proper editor panel +- Wire to project settings persistence +- Schema loading from backend `config` endpoint + +### UBA-007: Debug Stream Panel + +- SSE event viewer using `debug_stream` endpoint +- Live log display with `DebugSchema` field rendering + +--- + +## Architecture Decisions + +- Values stored as **flat dot-path map** (`section.field`) for simplicity +- `flatToNested()` converts to nested object before sending to backends +- `evaluateCondition` uses flat-path map directly (no nesting required in form state) +- Section visibility filtered in `ConfigPanel` via `visible_when` on sections +- Field visibility handled in `ConfigSection` — hidden fields return `null` (unmounted) +- `useConfigForm` intentionally computes `initial` only on mount — `reset()` handles re-init diff --git a/packages/noodl-editor/src/editor/src/models/UBA/Conditions.ts b/packages/noodl-editor/src/editor/src/models/UBA/Conditions.ts new file mode 100644 index 0000000..be58098 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/models/UBA/Conditions.ts @@ -0,0 +1,115 @@ +/** + * UBA-003/UBA-004: Condition evaluation utilities + * + * Evaluates `visible_when` and `depends_on` conditions from the UBA schema, + * driving dynamic field/section visibility and dependency messaging. + * + * The `Condition` type uses an operator-based structure: + * { field: "section_id.field_id", operator: "=", value: "some_value" } + * + * Field paths support dot-notation for nested lookups (section.field). + */ + +import { Condition } from './types'; + +// ─── Path utilities ─────────────────────────────────────────────────────────── + +/** + * Reads a value from a nested object using dot-notation path. + * Returns `undefined` if any segment is missing. + * + * @example getNestedValue({ auth: { type: 'bearer' } }, 'auth.type') // 'bearer' + */ +export function getNestedValue(obj: Record, path: string): unknown { + if (!obj || !path) return undefined; + return path.split('.').reduce((acc, key) => { + if (acc !== null && acc !== undefined && typeof acc === 'object') { + return (acc as Record)[key]; + } + return undefined; + }, obj); +} + +/** + * Sets a value in a nested object using dot-notation path. + * Creates intermediate objects as needed. Returns a new object (shallow copy at each level). + */ +export function setNestedValue(obj: Record, path: string, value: unknown): Record { + const keys = path.split('.'); + const result = { ...obj }; + + if (keys.length === 1) { + result[keys[0]] = value; + return result; + } + + const [head, ...rest] = keys; + const nested = (result[head] && typeof result[head] === 'object' ? result[head] : {}) as Record; + + result[head] = setNestedValue(nested, rest.join('.'), value); + return result; +} + +// ─── isEmpty helper ─────────────────────────────────────────────────────────── + +/** + * Returns true for null, undefined, empty string, empty array. + */ +export function isEmpty(value: unknown): boolean { + if (value === null || value === undefined) return true; + if (typeof value === 'string') return value.trim() === ''; + if (Array.isArray(value)) return value.length === 0; + return false; +} + +// ─── Condition evaluation ───────────────────────────────────────────────────── + +/** + * Evaluates a single `Condition` object against the current form values. + * + * Operators: + * - `=` exact equality + * - `!=` inequality + * - `in` value is in the condition's value array + * - `not_in` value is NOT in the condition's value array + * - `exists` field is non-empty + * - `not_exists` field is empty / absent + * + * Returns `true` if the condition is met (field should be visible/enabled). + * Returns `true` if `condition` is undefined (no restriction). + */ +export function evaluateCondition(condition: Condition | undefined, values: Record): boolean { + if (!condition) return true; + + const fieldValue = getNestedValue(values, condition.field); + + switch (condition.operator) { + case '=': + return fieldValue === condition.value; + + case '!=': + return fieldValue !== condition.value; + + case 'in': { + const allowed = condition.value; + if (!Array.isArray(allowed)) return false; + return allowed.includes(fieldValue as string); + } + + case 'not_in': { + const disallowed = condition.value; + if (!Array.isArray(disallowed)) return true; + return !disallowed.includes(fieldValue as string); + } + + case 'exists': + return !isEmpty(fieldValue); + + case 'not_exists': + return isEmpty(fieldValue); + + default: + // Unknown operator — don't block visibility + return true; + } +} diff --git a/packages/noodl-editor/src/editor/src/models/UBA/index.ts b/packages/noodl-editor/src/editor/src/models/UBA/index.ts index f3e7e57..de97c04 100644 --- a/packages/noodl-editor/src/editor/src/models/UBA/index.ts +++ b/packages/noodl-editor/src/editor/src/models/UBA/index.ts @@ -1,4 +1,5 @@ export { SchemaParser } from './SchemaParser'; +export { evaluateCondition, getNestedValue, setNestedValue, isEmpty } from './Conditions'; export type { UBASchema, BackendMetadata, diff --git a/packages/noodl-editor/src/editor/src/views/UBA/ConfigPanel.module.scss b/packages/noodl-editor/src/editor/src/views/UBA/ConfigPanel.module.scss new file mode 100644 index 0000000..19b4b8d --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/UBA/ConfigPanel.module.scss @@ -0,0 +1,173 @@ +/* UBA-004: ConfigPanel styles */ + +.configPanel { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + background: var(--theme-color-bg-2); +} + +/* ─── Header ──────────────────────────────────────────────────────────────── */ + +.header { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + border-bottom: 1px solid var(--theme-color-border-default); + background: var(--theme-color-bg-2); + flex-shrink: 0; +} + +.headerInfo { + flex: 1; + min-width: 0; +} + +.headerName { + font-size: 13px; + font-weight: 600; + color: var(--theme-color-fg-highlight); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin: 0; +} + +.headerMeta { + font-size: 11px; + color: var(--theme-color-fg-default-shy); + margin: 2px 0 0; +} + +.headerActions { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.resetButton { + padding: 4px 10px; + border: 1px solid var(--theme-color-border-default); + border-radius: 4px; + background: none; + color: var(--theme-color-fg-default); + font-size: 12px; + cursor: pointer; + transition: background 0.1s ease; + + &:hover:not(:disabled) { + background: var(--theme-color-bg-3); + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } +} + +.saveButton { + padding: 4px 12px; + border: none; + border-radius: 4px; + background: var(--theme-color-primary); + color: #ffffff; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: opacity 0.1s ease; + + &:hover:not(:disabled) { + opacity: 0.85; + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } +} + +/* ─── Save error banner ───────────────────────────────────────────────────── */ + +.saveError { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + background: color-mix(in srgb, var(--theme-color-danger) 12%, transparent); + border-bottom: 1px solid color-mix(in srgb, var(--theme-color-danger) 30%, transparent); + font-size: 12px; + color: var(--theme-color-danger); + flex-shrink: 0; +} + +/* ─── Section tabs ────────────────────────────────────────────────────────── */ + +.sectionTabs { + display: flex; + gap: 0; + border-bottom: 1px solid var(--theme-color-border-default); + flex-shrink: 0; + overflow-x: auto; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +} + +.tab { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + border: none; + border-bottom: 2px solid transparent; + background: none; + color: var(--theme-color-fg-default-shy); + font-size: 12px; + cursor: pointer; + white-space: nowrap; + transition: color 0.15s ease, border-color 0.15s ease; + position: relative; + + &:hover { + color: var(--theme-color-fg-default); + } + + &.active { + color: var(--theme-color-fg-highlight); + border-bottom-color: var(--theme-color-primary); + } +} + +.tabErrorDot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--theme-color-danger); + flex-shrink: 0; +} + +/* ─── Section content ─────────────────────────────────────────────────────── */ + +.sectionContent { + flex: 1; + overflow-y: auto; +} + +/* ─── Empty state ─────────────────────────────────────────────────────────── */ + +.emptyState { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 24px; + text-align: center; + color: var(--theme-color-fg-default-shy); + font-size: 12px; + gap: 8px; +} diff --git a/packages/noodl-editor/src/editor/src/views/UBA/ConfigPanel.tsx b/packages/noodl-editor/src/editor/src/views/UBA/ConfigPanel.tsx new file mode 100644 index 0000000..8da2d56 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/UBA/ConfigPanel.tsx @@ -0,0 +1,161 @@ +/** + * UBA-004: ConfigPanel + * + * Top-level panel that renders a UBASchema as a tabbed configuration form. + * Tabs = sections; fields rendered by ConfigSection. + * + * Usage: + * { await pushToBackend(values); }} + * /> + */ + +import React, { useState } from 'react'; + +import { evaluateCondition } from '../../models/UBA/Conditions'; +import { UBASchema } from '../../models/UBA/types'; +import css from './ConfigPanel.module.scss'; +import { ConfigSection, sectionHasErrors } from './ConfigSection'; +import { flatToNested, useConfigForm, validateRequired } from './hooks/useConfigForm'; + +export interface ConfigPanelProps { + schema: UBASchema; + /** Previously saved config values (flat-path or nested object) */ + initialValues?: Record; + /** Called with a nested-object representation of the form on save */ + onSave: (values: Record) => Promise; + onReset?: () => void; + disabled?: boolean; +} + +export function ConfigPanel({ schema, initialValues, onSave, onReset, disabled }: ConfigPanelProps) { + const { values, errors, isDirty, setValue, setErrors, reset } = useConfigForm(schema, initialValues); + + const [activeSection, setActiveSection] = useState(schema.sections[0]?.id ?? ''); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + // Filter to sections whose visible_when is met + const visibleSections = schema.sections.filter((section) => + evaluateCondition(section.visible_when, values as Record) + ); + + // Ensure active tab stays valid if sections change + const validActive = visibleSections.find((s) => s.id === activeSection)?.id ?? visibleSections[0]?.id ?? ''; + + const handleSave = async () => { + // Synchronous required-field validation + const validationErrors = validateRequired(schema, values); + if (Object.keys(validationErrors).length > 0) { + setErrors(validationErrors); + // Switch to first tab with an error + const firstErrorSection = schema.sections.find((s) => + Object.keys(validationErrors).some((p) => p.startsWith(`${s.id}.`)) + ); + if (firstErrorSection) setActiveSection(firstErrorSection.id); + return; + } + + setSaving(true); + setSaveError(null); + + try { + await onSave(flatToNested(values)); + } catch (err) { + setSaveError(err instanceof Error ? err.message : 'Save failed'); + } finally { + setSaving(false); + } + }; + + const handleReset = () => { + reset(); + setSaveError(null); + onReset?.(); + }; + + return ( +
+ {/* ── Header ─────────────────────────────────────────────────────── */} +
+
+

{schema.backend.name}

+

+ v{schema.backend.version} + {schema.backend.description ? ` · ${schema.backend.description}` : ''} +

+
+ +
+ + +
+
+ + {/* ── Save error banner ───────────────────────────────────────────── */} + {saveError && ( +
+ {saveError} +
+ )} + + {/* ── Section tabs ────────────────────────────────────────────────── */} + {visibleSections.length > 1 && ( +
+ {visibleSections.map((section) => { + const hasErrors = sectionHasErrors(section.id, errors); + return ( + + ); + })} +
+ )} + + {/* ── Section content ─────────────────────────────────────────────── */} +
+ {visibleSections.length === 0 ? ( +
No configuration sections available.
+ ) : ( + visibleSections.map((section) => ( + + )) + )} +
+
+ ); +} diff --git a/packages/noodl-editor/src/editor/src/views/UBA/ConfigSection.module.scss b/packages/noodl-editor/src/editor/src/views/UBA/ConfigSection.module.scss new file mode 100644 index 0000000..ba15016 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/UBA/ConfigSection.module.scss @@ -0,0 +1,55 @@ +/* UBA-004: ConfigSection styles */ + +.section { + display: flex; + flex-direction: column; +} + +.sectionHeader { + display: flex; + flex-direction: column; + gap: 4px; + padding: 16px 16px 12px; + border-bottom: 1px solid var(--theme-color-border-default); + margin-bottom: 16px; +} + +.sectionTitle { + font-size: 13px; + font-weight: 600; + color: var(--theme-color-fg-highlight); + margin: 0; +} + +.sectionDescription { + font-size: 11px; + color: var(--theme-color-fg-default-shy); + margin: 0; + line-height: 1.4; +} + +.sectionFields { + display: flex; + flex-direction: column; + padding: 0 16px 16px; +} + +.fieldContainer { + &.disabled { + opacity: 0.5; + pointer-events: none; + } +} + +.dependencyMessage { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + border-radius: 4px; + background: var(--theme-color-bg-2); + border: 1px solid var(--theme-color-border-default); + font-size: 11px; + color: var(--theme-color-fg-default-shy); + margin-bottom: 8px; +} diff --git a/packages/noodl-editor/src/editor/src/views/UBA/ConfigSection.tsx b/packages/noodl-editor/src/editor/src/views/UBA/ConfigSection.tsx new file mode 100644 index 0000000..e45588a --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/UBA/ConfigSection.tsx @@ -0,0 +1,84 @@ +/** + * UBA-004: ConfigSection + * + * Renders a single schema section — header + all its fields. + * Fields that fail their `visible_when` condition are omitted. + * Fields that fail a dependency condition are rendered but disabled. + * + * Hidden via CSS (display:none) when `visible` is false so the section + * stays mounted and preserves form values, but only the active tab is shown. + */ + +import React from 'react'; + +import { evaluateCondition } from '../../models/UBA/Conditions'; +import { Field, Section } from '../../models/UBA/types'; +import css from './ConfigSection.module.scss'; +import { FieldRenderer } from './fields/FieldRenderer'; +import { FormErrors, FormValues } from './hooks/useConfigForm'; + +export interface ConfigSectionProps { + section: Section; + values: FormValues; + errors: FormErrors; + onChange: (path: string, value: unknown) => void; + /** Whether this section's tab is currently active */ + visible: boolean; + disabled?: boolean; +} + +interface FieldVisibility { + visible: boolean; + /** If false, field is rendered but disabled */ + enabled: boolean; +} + +/** + * Evaluates field visibility + enabled state based on its conditions. + * We don't have a full `depends_on` in the current type spec, + * so we only handle `visible_when` here (enough for UBA-004 scope). + */ +function resolveFieldVisibility(field: Field, values: FormValues): FieldVisibility { + const visible = evaluateCondition(field.visible_when, values as Record); + return { visible, enabled: visible }; +} + +/** Returns true if any errors exist for fields in this section */ +export function sectionHasErrors(sectionId: string, errors: FormErrors): boolean { + return Object.keys(errors).some((path) => path.startsWith(`${sectionId}.`)); +} + +export function ConfigSection({ section, values, errors, onChange, visible, disabled }: ConfigSectionProps) { + return ( +
+ {(section.description || section.name) && ( +
+

{section.name}

+ {section.description &&

{section.description}

} +
+ )} + +
+ {section.fields.map((field) => { + const { visible: fieldVisible, enabled } = resolveFieldVisibility(field, values); + + if (!fieldVisible) return null; + + const path = `${section.id}.${field.id}`; + + return ( +
+ onChange(path, value)} + error={errors[path]} + disabled={disabled || !enabled} + /> +
+ ); + })} +
+
+ ); +} diff --git a/packages/noodl-editor/src/editor/src/views/UBA/fields/BooleanField.tsx b/packages/noodl-editor/src/editor/src/views/UBA/fields/BooleanField.tsx new file mode 100644 index 0000000..243dc73 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/UBA/fields/BooleanField.tsx @@ -0,0 +1,43 @@ +/** + * UBA-003: BooleanField + * Toggle switch with an optional label beside it. + * Uses CSS :has() for checked/disabled track styling — see fields.module.scss. + */ + +import React from 'react'; + +import { BooleanField as BooleanFieldType } from '../../../models/UBA/types'; +import css from './fields.module.scss'; +import { FieldWrapper } from './FieldWrapper'; + +export interface BooleanFieldProps { + field: BooleanFieldType; + value: boolean | undefined; + onChange: (value: boolean) => void; + disabled?: boolean; +} + +export function BooleanField({ field, value, onChange, disabled }: BooleanFieldProps) { + const checked = value ?? field.default ?? false; + + return ( + + + + ); +} diff --git a/packages/noodl-editor/src/editor/src/views/UBA/fields/FieldRenderer.tsx b/packages/noodl-editor/src/editor/src/views/UBA/fields/FieldRenderer.tsx new file mode 100644 index 0000000..4b62710 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/UBA/fields/FieldRenderer.tsx @@ -0,0 +1,136 @@ +/** + * UBA-003: FieldRenderer + * + * Factory component — dispatches to the correct field renderer based on `field.type`. + * Unknown field types fall back to StringField with a console warning so forward-compat + * schemas don't hard-crash the panel. + */ + +import React from 'react'; + +import { Field } from '../../../models/UBA/types'; +import { BooleanField } from './BooleanField'; +import { MultiSelectField } from './MultiSelectField'; +import { NumberField } from './NumberField'; +import { SecretField } from './SecretField'; +import { SelectField } from './SelectField'; +import { StringField } from './StringField'; +import { TextField } from './TextField'; +import { UrlField } from './UrlField'; + +export interface FieldRendererProps { + field: Field; + /** Current value — the type depends on field.type */ + value: unknown; + onChange: (value: unknown) => void; + error?: string; + disabled?: boolean; +} + +export function FieldRenderer({ field, value, onChange, error, disabled }: FieldRendererProps) { + switch (field.type) { + case 'string': + return ( + + ); + + case 'text': + return ( + + ); + + case 'number': + return ( + void} + error={error} + disabled={disabled} + /> + ); + + case 'boolean': + return ( + void} + disabled={disabled} + /> + ); + + case 'secret': + return ( + void} + error={error} + disabled={disabled} + /> + ); + + case 'url': + return ( + void} + error={error} + disabled={disabled} + /> + ); + + case 'select': + return ( + void} + error={error} + disabled={disabled} + /> + ); + + case 'multi_select': + return ( + void} + error={error} + disabled={disabled} + /> + ); + + default: { + // Forward-compat fallback: unknown field types render as plain text + const unknownField = field as Field & { type: string }; + console.warn( + `[UBA] Unknown field type "${unknownField.type}" for field "${unknownField.id}" — rendering as string` + ); + return ( + [0]['field']} + value={value as string | undefined} + onChange={onChange as (v: string) => void} + error={error} + disabled={disabled} + /> + ); + } + } +} diff --git a/packages/noodl-editor/src/editor/src/views/UBA/fields/FieldWrapper.tsx b/packages/noodl-editor/src/editor/src/views/UBA/fields/FieldWrapper.tsx new file mode 100644 index 0000000..feb41b3 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/UBA/fields/FieldWrapper.tsx @@ -0,0 +1,43 @@ +/** + * UBA-003: FieldWrapper + * + * Common shell for all UBA field renderers. + * Renders: label, required indicator, description, children (the input), + * error message, warning message, and an optional help link. + */ + +import React from 'react'; + +import { BaseField } from '../../../models/UBA/types'; +import css from './fields.module.scss'; + +export interface FieldWrapperProps { + field: BaseField; + error?: string; + warning?: string; + children: React.ReactNode; +} + +export function FieldWrapper({ field, error, warning, children }: FieldWrapperProps) { + return ( +
+ + + {field.description &&

{field.description}

} + + {children} + + {error &&

{error}

} + {warning && !error &&

{warning}

} + + {field.ui?.help_link && ( + + Learn more ↗ + + )} +
+ ); +} diff --git a/packages/noodl-editor/src/editor/src/views/UBA/fields/MultiSelectField.tsx b/packages/noodl-editor/src/editor/src/views/UBA/fields/MultiSelectField.tsx new file mode 100644 index 0000000..8983539 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/UBA/fields/MultiSelectField.tsx @@ -0,0 +1,97 @@ +/** + * UBA-003: MultiSelectField + * A native + + {available.map((opt) => ( + + ))} + + )} + + {atMax && ( +

+ Maximum {field.max_selections} selection{field.max_selections === 1 ? '' : 's'} reached +

+ )} + + + ); +} diff --git a/packages/noodl-editor/src/editor/src/views/UBA/fields/NumberField.tsx b/packages/noodl-editor/src/editor/src/views/UBA/fields/NumberField.tsx new file mode 100644 index 0000000..b28a70f --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/UBA/fields/NumberField.tsx @@ -0,0 +1,71 @@ +/** + * UBA-003: NumberField + * Numeric input with optional min / max / step constraints. + * Strips leading zeros on blur; handles integer-only mode. + */ + +import React, { useState } from 'react'; + +import { NumberField as NumberFieldType } from '../../../models/UBA/types'; +import css from './fields.module.scss'; +import { FieldWrapper } from './FieldWrapper'; + +export interface NumberFieldProps { + field: NumberFieldType; + value: number | undefined; + onChange: (value: number) => void; + onBlur?: () => void; + error?: string; + disabled?: boolean; +} + +export function NumberField({ field, value, onChange, onBlur, error, disabled }: NumberFieldProps) { + const placeholder = field.placeholder ?? field.ui?.placeholder; + + // Internal string state so the user can type partial numbers (e.g. "-" or "1.") + const [raw, setRaw] = useState( + value !== undefined ? String(value) : field.default !== undefined ? String(field.default) : '' + ); + + const handleChange = (e: React.ChangeEvent) => { + const text = e.target.value; + setRaw(text); + + const parsed = field.integer ? parseInt(text, 10) : parseFloat(text); + if (!Number.isNaN(parsed)) { + onChange(parsed); + } + }; + + const handleBlur = () => { + // Normalise display value + const parsed = field.integer ? parseInt(raw, 10) : parseFloat(raw); + if (Number.isNaN(parsed)) { + setRaw(''); + } else { + // Clamp if bounds present + const clamped = Math.min(field.max ?? Infinity, Math.max(field.min ?? -Infinity, parsed)); + setRaw(String(clamped)); + onChange(clamped); + } + onBlur?.(); + }; + + return ( + + + + ); +} diff --git a/packages/noodl-editor/src/editor/src/views/UBA/fields/SecretField.tsx b/packages/noodl-editor/src/editor/src/views/UBA/fields/SecretField.tsx new file mode 100644 index 0000000..7efe283 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/UBA/fields/SecretField.tsx @@ -0,0 +1,73 @@ +/** + * UBA-003: SecretField + * Password-masked text input with a show/hide visibility toggle. + * Respects `no_paste` to prevent pasting (for high-security secrets). + */ + +import React, { useState } from 'react'; + +import { SecretField as SecretFieldType } from '../../../models/UBA/types'; +import css from './fields.module.scss'; +import { FieldWrapper } from './FieldWrapper'; + +export interface SecretFieldProps { + field: SecretFieldType; + value: string | undefined; + onChange: (value: string) => void; + onBlur?: () => void; + error?: string; + disabled?: boolean; +} + +/** Minimal eye / eye-off SVGs — no external icon dep required */ +const EyeIcon = () => ( + +); + +const EyeOffIcon = () => ( + +); + +export function SecretField({ field, value, onChange, onBlur, error, disabled }: SecretFieldProps) { + const [visible, setVisible] = useState(false); + const placeholder = field.placeholder ?? field.ui?.placeholder ?? '••••••••••••'; + + return ( + +
+ onChange(e.target.value)} + onBlur={onBlur} + placeholder={placeholder} + disabled={disabled} + autoComplete="new-password" + onPaste={field.no_paste ? (e) => e.preventDefault() : undefined} + className={`${css.secretInput}${error ? ` ${css.hasError}` : ''}`} + /> + +
+
+ ); +} diff --git a/packages/noodl-editor/src/editor/src/views/UBA/fields/SelectField.tsx b/packages/noodl-editor/src/editor/src/views/UBA/fields/SelectField.tsx new file mode 100644 index 0000000..0739960 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/UBA/fields/SelectField.tsx @@ -0,0 +1,54 @@ +/** + * UBA-003: SelectField + * Native select dropdown. Renders all options from `field.options`. + * Empty option is prepended unless a default is set. + */ + +import React from 'react'; + +import { SelectField as SelectFieldType } from '../../../models/UBA/types'; +import css from './fields.module.scss'; +import { FieldWrapper } from './FieldWrapper'; + +/** Minimal chevron SVG */ +const ChevronDown = () => ( + +); + +export interface SelectFieldProps { + field: SelectFieldType; + value: string | undefined; + onChange: (value: string) => void; + error?: string; + disabled?: boolean; +} + +export function SelectField({ field, value, onChange, error, disabled }: SelectFieldProps) { + const current = value ?? field.default ?? ''; + + return ( + +
+ + + + +
+
+ ); +} diff --git a/packages/noodl-editor/src/editor/src/views/UBA/fields/StringField.tsx b/packages/noodl-editor/src/editor/src/views/UBA/fields/StringField.tsx new file mode 100644 index 0000000..1456a07 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/UBA/fields/StringField.tsx @@ -0,0 +1,41 @@ +/** + * UBA-003: StringField + * Single-line text input with optional max-length enforcement. + */ + +import React from 'react'; + +import { StringField as StringFieldType } from '../../../models/UBA/types'; +import css from './fields.module.scss'; +import { FieldWrapper } from './FieldWrapper'; + +export interface StringFieldProps { + field: StringFieldType; + value: string | undefined; + onChange: (value: string) => void; + onBlur?: () => void; + error?: string; + disabled?: boolean; +} + +export function StringField({ field, value, onChange, onBlur, error, disabled }: StringFieldProps) { + const placeholder = field.placeholder ?? field.ui?.placeholder; + const monospace = field.ui?.monospace; + + return ( + + onChange(e.target.value)} + onBlur={onBlur} + placeholder={placeholder} + disabled={disabled} + maxLength={field.validation?.max_length} + className={`${css.textInput}${error ? ` ${css.hasError}` : ''}${monospace ? ` ${css.monoInput}` : ''}`} + autoComplete="off" + /> + + ); +} diff --git a/packages/noodl-editor/src/editor/src/views/UBA/fields/TextField.tsx b/packages/noodl-editor/src/editor/src/views/UBA/fields/TextField.tsx new file mode 100644 index 0000000..ea7f44b --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/UBA/fields/TextField.tsx @@ -0,0 +1,41 @@ +/** + * UBA-003: TextField + * Multi-line textarea, optionally monospaced. + */ + +import React from 'react'; + +import { TextField as TextFieldType } from '../../../models/UBA/types'; +import css from './fields.module.scss'; +import { FieldWrapper } from './FieldWrapper'; + +export interface TextFieldProps { + field: TextFieldType; + value: string | undefined; + onChange: (value: string) => void; + onBlur?: () => void; + error?: string; + disabled?: boolean; +} + +export function TextField({ field, value, onChange, onBlur, error, disabled }: TextFieldProps) { + const placeholder = field.placeholder ?? field.ui?.placeholder; + const monospace = field.ui?.monospace; + const rows = field.rows ?? 4; + + return ( + +