mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-08 01:53:30 +01:00
feat(uba): UBA-003/004 field renderers + ConfigPanel + Conditions
This commit is contained in:
@@ -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
|
#### UBA-001: Types
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|
||||||
- `packages/noodl-editor/src/editor/src/models/UBA/types.ts`
|
- `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)
|
- Full discriminated union for all 8 field types
|
||||||
- `BackendMetadata`, `Endpoints`, `AuthConfig`, `Capabilities`, `Section`, `Condition`, `DebugSchema`
|
- `ParseResult<T>` / `ParseError` types
|
||||||
- `ParseResult<T>` discriminated union + `ParseError` type
|
- `Condition` with operator-based structure
|
||||||
- Zero external dependencies
|
|
||||||
|
|
||||||
#### UBA-002: SchemaParser — COMPLETE
|
#### UBA-002: SchemaParser
|
||||||
|
|
||||||
- `packages/noodl-editor/src/editor/src/models/UBA/SchemaParser.ts`
|
- `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 unknown input against the UBA schema spec
|
||||||
- Validates: schema_version, backend (id/name/version/endpoints/auth/capabilities), sections, fields, debug
|
- Returns `ParseResult<UBASchema>` with structured errors + warnings
|
||||||
- Unknown field types → warning, not error (forward-compat)
|
- Tests: `packages/noodl-editor/tests/models/UBASchemaParser.test.ts`
|
||||||
- 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
|
|
||||||
|
|
||||||
#### UBA-002: SchemaParser unit tests — COMPLETE
|
#### UBA-003: Field Renderers
|
||||||
|
|
||||||
- `packages/noodl-editor/tests/models/UBASchemaParser.test.ts`
|
All 8 field components + FieldWrapper + FieldRenderer factory:
|
||||||
- 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
|
|
||||||
|
|
||||||
### 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)
|
Complete form shell with section tabs, validation, dirty state:
|
||||||
- `FieldRenderer` factory component (dispatches on `field.type`)
|
|
||||||
- Shared styles (`fields.module.scss`)
|
|
||||||
- `FieldWrapper` with label, description, required indicator
|
|
||||||
|
|
||||||
#### 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`
|
#### Tests
|
||||||
- `useConfigForm` hook — manages form values, validation, dirty state
|
|
||||||
- Section collapsing, `visible_when` condition evaluation
|
|
||||||
|
|
||||||
### Tracking
|
- `tests/models/UBAConditions.test.ts` — 22 cases covering all 6 operators + path utils
|
||||||
|
- `tests/models/UBASchemaParser.test.ts` — existing tests (unchanged)
|
||||||
|
|
||||||
| Task | Status |
|
> ⚠️ Tests unverified this session — port 8081 in use by dev server. Run after stopping the app.
|
||||||
| ---------------------------------- | ------- |
|
|
||||||
| STYLE-005: SuggestionBanner wiring | ✅ DONE |
|
---
|
||||||
| UBA-001: TypeScript types | ✅ DONE |
|
|
||||||
| UBA-002: SchemaParser + tests | ✅ DONE |
|
## Next Up (Sprint 2)
|
||||||
| UBA-003: Field renderers | 🔲 NEXT |
|
|
||||||
| UBA-004: ConfigPanel + hook | 🔲 TODO |
|
### 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
|
||||||
|
|||||||
115
packages/noodl-editor/src/editor/src/models/UBA/Conditions.ts
Normal file
115
packages/noodl-editor/src/editor/src/models/UBA/Conditions.ts
Normal file
@@ -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<string, unknown>, path: string): unknown {
|
||||||
|
if (!obj || !path) return undefined;
|
||||||
|
return path.split('.').reduce<unknown>((acc, key) => {
|
||||||
|
if (acc !== null && acc !== undefined && typeof acc === 'object') {
|
||||||
|
return (acc as Record<string, unknown>)[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<string, unknown>, path: string, value: unknown): Record<string, unknown> {
|
||||||
|
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<string, unknown>;
|
||||||
|
|
||||||
|
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<string, unknown>): 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export { SchemaParser } from './SchemaParser';
|
export { SchemaParser } from './SchemaParser';
|
||||||
|
export { evaluateCondition, getNestedValue, setNestedValue, isEmpty } from './Conditions';
|
||||||
export type {
|
export type {
|
||||||
UBASchema,
|
UBASchema,
|
||||||
BackendMetadata,
|
BackendMetadata,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
161
packages/noodl-editor/src/editor/src/views/UBA/ConfigPanel.tsx
Normal file
161
packages/noodl-editor/src/editor/src/views/UBA/ConfigPanel.tsx
Normal file
@@ -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:
|
||||||
|
* <ConfigPanel
|
||||||
|
* schema={parsedSchema}
|
||||||
|
* initialValues={savedConfig}
|
||||||
|
* onSave={async (values) => { 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<string, unknown>;
|
||||||
|
/** Called with a nested-object representation of the form on save */
|
||||||
|
onSave: (values: Record<string, unknown>) => Promise<void>;
|
||||||
|
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<string>(schema.sections[0]?.id ?? '');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Filter to sections whose visible_when is met
|
||||||
|
const visibleSections = schema.sections.filter((section) =>
|
||||||
|
evaluateCondition(section.visible_when, values as Record<string, unknown>)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className={css.configPanel}>
|
||||||
|
{/* ── Header ─────────────────────────────────────────────────────── */}
|
||||||
|
<div className={css.header}>
|
||||||
|
<div className={css.headerInfo}>
|
||||||
|
<h2 className={css.headerName}>{schema.backend.name}</h2>
|
||||||
|
<p className={css.headerMeta}>
|
||||||
|
v{schema.backend.version}
|
||||||
|
{schema.backend.description ? ` · ${schema.backend.description}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={css.headerActions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={css.resetButton}
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={!isDirty || saving || disabled}
|
||||||
|
title="Reset to saved values"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={css.saveButton}
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!isDirty || saving || disabled}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Save error banner ───────────────────────────────────────────── */}
|
||||||
|
{saveError && (
|
||||||
|
<div className={css.saveError} role="alert">
|
||||||
|
{saveError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Section tabs ────────────────────────────────────────────────── */}
|
||||||
|
{visibleSections.length > 1 && (
|
||||||
|
<div className={css.sectionTabs} role="tablist">
|
||||||
|
{visibleSections.map((section) => {
|
||||||
|
const hasErrors = sectionHasErrors(section.id, errors);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={section.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={validActive === section.id}
|
||||||
|
className={`${css.tab}${validActive === section.id ? ` ${css.active}` : ''}`}
|
||||||
|
onClick={() => setActiveSection(section.id)}
|
||||||
|
>
|
||||||
|
{section.name}
|
||||||
|
{hasErrors && <span className={css.tabErrorDot} aria-label="has errors" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Section content ─────────────────────────────────────────────── */}
|
||||||
|
<div className={css.sectionContent}>
|
||||||
|
{visibleSections.length === 0 ? (
|
||||||
|
<div className={css.emptyState}>No configuration sections available.</div>
|
||||||
|
) : (
|
||||||
|
visibleSections.map((section) => (
|
||||||
|
<ConfigSection
|
||||||
|
key={section.id}
|
||||||
|
section={section}
|
||||||
|
values={values}
|
||||||
|
errors={errors}
|
||||||
|
onChange={setValue}
|
||||||
|
visible={validActive === section.id}
|
||||||
|
disabled={disabled || saving}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<string, unknown>);
|
||||||
|
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 (
|
||||||
|
<div className={css.section} style={visible ? undefined : { display: 'none' }} aria-hidden={!visible}>
|
||||||
|
{(section.description || section.name) && (
|
||||||
|
<div className={css.sectionHeader}>
|
||||||
|
<h3 className={css.sectionTitle}>{section.name}</h3>
|
||||||
|
{section.description && <p className={css.sectionDescription}>{section.description}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={css.sectionFields}>
|
||||||
|
{section.fields.map((field) => {
|
||||||
|
const { visible: fieldVisible, enabled } = resolveFieldVisibility(field, values);
|
||||||
|
|
||||||
|
if (!fieldVisible) return null;
|
||||||
|
|
||||||
|
const path = `${section.id}.${field.id}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={field.id} className={`${css.fieldContainer}${!enabled ? ` ${css.disabled}` : ''}`}>
|
||||||
|
<FieldRenderer
|
||||||
|
field={field}
|
||||||
|
value={values[path]}
|
||||||
|
onChange={(value) => onChange(path, value)}
|
||||||
|
error={errors[path]}
|
||||||
|
disabled={disabled || !enabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<FieldWrapper field={field}>
|
||||||
|
<label className={css.booleanWrapper}>
|
||||||
|
<span className={css.toggleInput}>
|
||||||
|
<input
|
||||||
|
id={field.id}
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<span className={css.toggleTrack}>
|
||||||
|
<span className={css.toggleThumb} />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{field.toggle_label && <span className={css.toggleLabel}>{field.toggle_label}</span>}
|
||||||
|
</label>
|
||||||
|
</FieldWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<StringField
|
||||||
|
field={field}
|
||||||
|
value={value as string | undefined}
|
||||||
|
onChange={onChange}
|
||||||
|
error={error}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'text':
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
field={field}
|
||||||
|
value={value as string | undefined}
|
||||||
|
onChange={onChange}
|
||||||
|
error={error}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'number':
|
||||||
|
return (
|
||||||
|
<NumberField
|
||||||
|
field={field}
|
||||||
|
value={value as number | undefined}
|
||||||
|
onChange={onChange as (v: number) => void}
|
||||||
|
error={error}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'boolean':
|
||||||
|
return (
|
||||||
|
<BooleanField
|
||||||
|
field={field}
|
||||||
|
value={value as boolean | undefined}
|
||||||
|
onChange={onChange as (v: boolean) => void}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'secret':
|
||||||
|
return (
|
||||||
|
<SecretField
|
||||||
|
field={field}
|
||||||
|
value={value as string | undefined}
|
||||||
|
onChange={onChange as (v: string) => void}
|
||||||
|
error={error}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'url':
|
||||||
|
return (
|
||||||
|
<UrlField
|
||||||
|
field={field}
|
||||||
|
value={value as string | undefined}
|
||||||
|
onChange={onChange as (v: string) => void}
|
||||||
|
error={error}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'select':
|
||||||
|
return (
|
||||||
|
<SelectField
|
||||||
|
field={field}
|
||||||
|
value={value as string | undefined}
|
||||||
|
onChange={onChange as (v: string) => void}
|
||||||
|
error={error}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'multi_select':
|
||||||
|
return (
|
||||||
|
<MultiSelectField
|
||||||
|
field={field}
|
||||||
|
value={value as string[] | undefined}
|
||||||
|
onChange={onChange as (v: string[]) => 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 (
|
||||||
|
<StringField
|
||||||
|
field={{ ...unknownField, type: 'string' } as Parameters<typeof StringField>[0]['field']}
|
||||||
|
value={value as string | undefined}
|
||||||
|
onChange={onChange as (v: string) => void}
|
||||||
|
error={error}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className={css.fieldWrapper} data-field-id={field.id}>
|
||||||
|
<label className={css.fieldLabel} htmlFor={field.id}>
|
||||||
|
{field.name}
|
||||||
|
{field.required && <span className={css.required}>*</span>}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{field.description && <p className={css.fieldDescription}>{field.description}</p>}
|
||||||
|
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{error && <p className={css.fieldError}>{error}</p>}
|
||||||
|
{warning && !error && <p className={css.fieldWarning}>{warning}</p>}
|
||||||
|
|
||||||
|
{field.ui?.help_link && (
|
||||||
|
<a href={field.ui.help_link} target="_blank" rel="noreferrer" className={css.helpLink}>
|
||||||
|
Learn more ↗
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* UBA-003: MultiSelectField
|
||||||
|
* A native <select> for picking additional items, rendered as a tag list.
|
||||||
|
* The dropdown only shows unselected options; already-selected items appear
|
||||||
|
* as removable tags above the dropdown.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { MultiSelectField as MultiSelectFieldType } from '../../../models/UBA/types';
|
||||||
|
import css from './fields.module.scss';
|
||||||
|
import { FieldWrapper } from './FieldWrapper';
|
||||||
|
|
||||||
|
/** Minimal X SVG */
|
||||||
|
const CloseIcon = () => (
|
||||||
|
<svg viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<path d="M1 1l8 8M9 1L1 9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface MultiSelectFieldProps {
|
||||||
|
field: MultiSelectFieldType;
|
||||||
|
value: string[] | undefined;
|
||||||
|
onChange: (value: string[]) => void;
|
||||||
|
error?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MultiSelectField({ field, value, onChange, error, disabled }: MultiSelectFieldProps) {
|
||||||
|
const selected = value ?? field.default ?? [];
|
||||||
|
const atMax = field.max_selections !== undefined && selected.length >= field.max_selections;
|
||||||
|
|
||||||
|
const available = field.options.filter((opt) => !selected.includes(opt.value));
|
||||||
|
|
||||||
|
const handleAdd = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
const newVal = e.target.value;
|
||||||
|
if (!newVal || selected.includes(newVal)) return;
|
||||||
|
onChange([...selected, newVal]);
|
||||||
|
// Reset the select back to placeholder
|
||||||
|
e.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = (val: string) => {
|
||||||
|
onChange(selected.filter((v) => v !== val));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLabel = (val: string) => field.options.find((o) => o.value === val)?.label ?? val;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FieldWrapper field={field} error={error}>
|
||||||
|
<div className={css.multiSelectContainer}>
|
||||||
|
{selected.length > 0 && (
|
||||||
|
<div className={css.selectedTags}>
|
||||||
|
{selected.map((val) => (
|
||||||
|
<span key={val} className={css.tag}>
|
||||||
|
<span className={css.tagLabel}>{getLabel(val)}</span>
|
||||||
|
{!disabled && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={css.tagRemove}
|
||||||
|
onClick={() => handleRemove(val)}
|
||||||
|
title={`Remove ${getLabel(val)}`}
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!atMax && (
|
||||||
|
<select
|
||||||
|
id={field.id}
|
||||||
|
onChange={handleAdd}
|
||||||
|
disabled={disabled || available.length === 0}
|
||||||
|
value=""
|
||||||
|
className={`${css.multiSelectDropdown}${error ? ` ${css.hasError}` : ''}`}
|
||||||
|
>
|
||||||
|
<option value="">{available.length === 0 ? 'All options selected' : '+ Add...'}</option>
|
||||||
|
{available.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value} title={opt.description}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{atMax && (
|
||||||
|
<p className={css.maxWarning}>
|
||||||
|
Maximum {field.max_selections} selection{field.max_selections === 1 ? '' : 's'} reached
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FieldWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string>(
|
||||||
|
value !== undefined ? String(value) : field.default !== undefined ? String(field.default) : ''
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<FieldWrapper field={field} error={error}>
|
||||||
|
<input
|
||||||
|
id={field.id}
|
||||||
|
type="number"
|
||||||
|
value={raw}
|
||||||
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
min={field.min}
|
||||||
|
max={field.max}
|
||||||
|
step={field.step ?? (field.integer ? 1 : 'any')}
|
||||||
|
className={`${css.numberInput}${error ? ` ${css.hasError}` : ''}`}
|
||||||
|
/>
|
||||||
|
</FieldWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 = () => (
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<path d="M1 8s2.5-5 7-5 7 5 7 5-2.5 5-7 5-7-5-7-5z" stroke="currentColor" strokeWidth="1.25" />
|
||||||
|
<circle cx="8" cy="8" r="2" stroke="currentColor" strokeWidth="1.25" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const EyeOffIcon = () => (
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M2 2l12 12M6.5 6.6A3 3 0 0 0 9.4 9.5M4.1 4.2C2.7 5.1 1 8 1 8s2.5 5 7 5c1.3 0 2.5-.4 3.5-1M7 3.1C7.3 3 7.7 3 8 3c4.5 0 7 5 7 5s-.6 1.2-1.7 2.4"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.25"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export function SecretField({ field, value, onChange, onBlur, error, disabled }: SecretFieldProps) {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const placeholder = field.placeholder ?? field.ui?.placeholder ?? '••••••••••••';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FieldWrapper field={field} error={error}>
|
||||||
|
<div className={css.secretWrapper}>
|
||||||
|
<input
|
||||||
|
id={field.id}
|
||||||
|
type={visible ? 'text' : 'password'}
|
||||||
|
value={value ?? ''}
|
||||||
|
onChange={(e) => 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}` : ''}`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVisible((v) => !v)}
|
||||||
|
className={css.visibilityToggle}
|
||||||
|
title={visible ? 'Hide' : 'Show'}
|
||||||
|
tabIndex={-1}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{visible ? <EyeOffIcon /> : <EyeIcon />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</FieldWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 = () => (
|
||||||
|
<svg viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<path d="M2 4l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<FieldWrapper field={field} error={error}>
|
||||||
|
<div className={css.selectWrapper}>
|
||||||
|
<select
|
||||||
|
id={field.id}
|
||||||
|
value={current}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`${css.selectInput}${error ? ` ${css.hasError}` : ''}`}
|
||||||
|
>
|
||||||
|
{!current && <option value="">-- Select --</option>}
|
||||||
|
{field.options.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value} title={opt.description}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className={css.selectChevron}>
|
||||||
|
<ChevronDown />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</FieldWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<FieldWrapper field={field} error={error}>
|
||||||
|
<input
|
||||||
|
id={field.id}
|
||||||
|
type="text"
|
||||||
|
value={value ?? field.default ?? ''}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</FieldWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<FieldWrapper field={field} error={error}>
|
||||||
|
<textarea
|
||||||
|
id={field.id}
|
||||||
|
value={value ?? field.default ?? ''}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onBlur={onBlur}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
rows={rows}
|
||||||
|
maxLength={field.validation?.max_length}
|
||||||
|
className={`${css.textArea}${error ? ` ${css.hasError}` : ''}${monospace ? ` ${css.monoInput}` : ''}`}
|
||||||
|
/>
|
||||||
|
</FieldWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* UBA-003: UrlField
|
||||||
|
* URL input with optional protocol restriction.
|
||||||
|
* Validates on blur — shows an error if the URL is malformed or protocol not allowed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
import { UrlField as UrlFieldType } from '../../../models/UBA/types';
|
||||||
|
import css from './fields.module.scss';
|
||||||
|
import { FieldWrapper } from './FieldWrapper';
|
||||||
|
|
||||||
|
export interface UrlFieldProps {
|
||||||
|
field: UrlFieldType;
|
||||||
|
value: string | undefined;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onBlur?: () => void;
|
||||||
|
error?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateUrl(value: string, protocols?: string[]): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
if (protocols && protocols.length > 0) {
|
||||||
|
const scheme = url.protocol.replace(':', '');
|
||||||
|
if (!protocols.includes(scheme)) {
|
||||||
|
return `URL must use one of: ${protocols.join(', ')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return 'Please enter a valid URL (e.g. https://example.com)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UrlField({ field, value, onChange, onBlur, error, disabled }: UrlFieldProps) {
|
||||||
|
const [localError, setLocalError] = useState<string | null>(null);
|
||||||
|
const placeholder = field.placeholder ?? field.ui?.placeholder ?? 'https://';
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
if (value) {
|
||||||
|
setLocalError(validateUrl(value, field.protocols));
|
||||||
|
} else {
|
||||||
|
setLocalError(null);
|
||||||
|
}
|
||||||
|
onBlur?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayError = error ?? localError ?? undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FieldWrapper field={field} error={displayError}>
|
||||||
|
<input
|
||||||
|
id={field.id}
|
||||||
|
type="url"
|
||||||
|
value={value ?? field.default ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(e.target.value);
|
||||||
|
if (localError) setLocalError(null);
|
||||||
|
}}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`${css.textInput}${displayError ? ` ${css.hasError}` : ''}`}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</FieldWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
/* UBA-003: Shared field styles — CSS variables only, no hardcoded colours */
|
||||||
|
|
||||||
|
/* ─── Field wrapper ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.fieldWrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldLabel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--theme-color-fg-default);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: var(--theme-color-danger);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldDescription {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--theme-color-fg-default-shy);
|
||||||
|
line-height: 1.4;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldError {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--theme-color-danger);
|
||||||
|
line-height: 1.4;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldWarning {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--theme-color-warning, #d97706);
|
||||||
|
line-height: 1.4;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.helpLink {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--theme-color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Base inputs ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.textInput {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid var(--theme-color-border-default);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--theme-color-bg-3);
|
||||||
|
color: var(--theme-color-fg-default);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--theme-color-fg-default-shy);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--theme-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
background: var(--theme-color-bg-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.hasError {
|
||||||
|
border-color: var(--theme-color-danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.textArea {
|
||||||
|
composes: textInput;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 72px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monoInput {
|
||||||
|
font-family: 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Number input ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.numberInput {
|
||||||
|
composes: textInput;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
/* Remove browser spinner arrows */
|
||||||
|
&::-webkit-inner-spin-button,
|
||||||
|
&::-webkit-outer-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Boolean / toggle ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.booleanWrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleInput {
|
||||||
|
position: relative;
|
||||||
|
width: 32px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleTrack {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: var(--theme-color-bg-1);
|
||||||
|
border: 1px solid var(--theme-color-border-default);
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
.toggleInput:has(input:checked) & {
|
||||||
|
background: var(--theme-color-primary);
|
||||||
|
border-color: var(--theme-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleInput:has(input:disabled) & {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleThumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--theme-color-fg-default);
|
||||||
|
transition: transform 0.15s ease, background 0.15s ease;
|
||||||
|
|
||||||
|
.toggleInput:has(input:checked) & {
|
||||||
|
transform: translateX(14px);
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleLabel {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--theme-color-fg-default);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Secret input ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.secretWrapper {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secretInput {
|
||||||
|
composes: textInput;
|
||||||
|
padding-right: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visibilityToggle {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--theme-color-fg-default-shy);
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--theme-color-fg-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Select ────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.selectWrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectInput {
|
||||||
|
composes: textInput;
|
||||||
|
appearance: none;
|
||||||
|
padding-right: 28px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectChevron {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
pointer-events: none;
|
||||||
|
color: var(--theme-color-fg-default-shy);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Multi-select ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.multiSelectContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiSelectDropdown {
|
||||||
|
composes: textInput;
|
||||||
|
appearance: none;
|
||||||
|
padding-right: 28px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedTags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--theme-color-bg-1);
|
||||||
|
border: 1px solid var(--theme-color-border-default);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--theme-color-fg-default);
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagLabel {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagRemove {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--theme-color-fg-default-shy);
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 2px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--theme-color-danger);
|
||||||
|
background: var(--theme-color-bg-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.maxWarning {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--theme-color-fg-default-shy);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* UBA-003: Field renderers — barrel export
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { BooleanField } from './BooleanField';
|
||||||
|
export type { BooleanFieldProps } from './BooleanField';
|
||||||
|
export { FieldRenderer } from './FieldRenderer';
|
||||||
|
export type { FieldRendererProps } from './FieldRenderer';
|
||||||
|
export { FieldWrapper } from './FieldWrapper';
|
||||||
|
export type { FieldWrapperProps } from './FieldWrapper';
|
||||||
|
export { MultiSelectField } from './MultiSelectField';
|
||||||
|
export type { MultiSelectFieldProps } from './MultiSelectField';
|
||||||
|
export { NumberField } from './NumberField';
|
||||||
|
export type { NumberFieldProps } from './NumberField';
|
||||||
|
export { SecretField } from './SecretField';
|
||||||
|
export type { SecretFieldProps } from './SecretField';
|
||||||
|
export { SelectField } from './SelectField';
|
||||||
|
export type { SelectFieldProps } from './SelectField';
|
||||||
|
export { StringField } from './StringField';
|
||||||
|
export type { StringFieldProps } from './StringField';
|
||||||
|
export { TextField } from './TextField';
|
||||||
|
export type { TextFieldProps } from './TextField';
|
||||||
|
export { UrlField } from './UrlField';
|
||||||
|
export type { UrlFieldProps } from './UrlField';
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* UBA-004: useConfigForm
|
||||||
|
*
|
||||||
|
* Form state management for the UBA ConfigPanel.
|
||||||
|
* Tracks field values, validation errors, and dirty state.
|
||||||
|
* Values are keyed by dot-notation paths: "section_id.field_id"
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { getNestedValue, setNestedValue } from '../../../models/UBA/Conditions';
|
||||||
|
import { UBASchema } from '../../../models/UBA/types';
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Flat map of dot-path → value */
|
||||||
|
export type FormValues = Record<string, unknown>;
|
||||||
|
|
||||||
|
/** Flat map of dot-path → error message */
|
||||||
|
export type FormErrors = Record<string, string>;
|
||||||
|
|
||||||
|
export interface ConfigFormState {
|
||||||
|
/** Current flat-path values, e.g. { "auth.api_key": "abc", "connection.url": "https://..." } */
|
||||||
|
values: FormValues;
|
||||||
|
/** Validation errors keyed by the same flat paths */
|
||||||
|
errors: FormErrors;
|
||||||
|
/** True if values differ from initialValues */
|
||||||
|
isDirty: boolean;
|
||||||
|
/** Set a single field value (clears its error) */
|
||||||
|
setValue: (path: string, value: unknown) => void;
|
||||||
|
/** Programmatically set a field error (used by ConfigPanel after failed saves) */
|
||||||
|
setFieldError: (path: string, error: string) => void;
|
||||||
|
/** Bulk-set errors (used by form-level validation before save) */
|
||||||
|
setErrors: (errors: FormErrors) => void;
|
||||||
|
/** Reset to initial values and clear all errors */
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Initial value builder ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flattens a UBASchema's default values and merges with provided values.
|
||||||
|
* Priority: provided > schema defaults > empty
|
||||||
|
*
|
||||||
|
* Returns a flat-path map, e.g. { "auth.api_key": "", "connection.url": "" }
|
||||||
|
*/
|
||||||
|
function buildInitialValues(schema: UBASchema, provided: Record<string, unknown> = {}): FormValues {
|
||||||
|
const values: FormValues = {};
|
||||||
|
|
||||||
|
for (const section of schema.sections) {
|
||||||
|
for (const field of section.fields) {
|
||||||
|
const path = `${section.id}.${field.id}`;
|
||||||
|
|
||||||
|
// Check provided (supports both flat-path and nested object)
|
||||||
|
const providedFlat = provided[path];
|
||||||
|
const providedNested = getNestedValue(provided as Record<string, unknown>, path);
|
||||||
|
const providedValue = providedFlat !== undefined ? providedFlat : providedNested;
|
||||||
|
|
||||||
|
if (providedValue !== undefined) {
|
||||||
|
values[path] = providedValue;
|
||||||
|
} else if ('default' in field && field.default !== undefined) {
|
||||||
|
values[path] = field.default;
|
||||||
|
} else {
|
||||||
|
// Set typed empty values so controlled inputs don't flip uncontrolled→controlled
|
||||||
|
switch (field.type) {
|
||||||
|
case 'boolean':
|
||||||
|
values[path] = false;
|
||||||
|
break;
|
||||||
|
case 'multi_select':
|
||||||
|
values[path] = [];
|
||||||
|
break;
|
||||||
|
case 'number':
|
||||||
|
values[path] = undefined;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
values[path] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useConfigForm(schema: UBASchema, initialValues?: Record<string, unknown>): ConfigFormState {
|
||||||
|
// Initial values computed once at mount — reset() handles subsequent re-init
|
||||||
|
const initial = useMemo(() => buildInitialValues(schema, initialValues), []); // intentional mount-only
|
||||||
|
|
||||||
|
const [values, setValues] = useState<FormValues>(initial);
|
||||||
|
const [errors, setErrorsState] = useState<FormErrors>({});
|
||||||
|
|
||||||
|
const isDirty = useMemo(() => {
|
||||||
|
for (const key of Object.keys(initial)) {
|
||||||
|
if (JSON.stringify(values[key]) !== JSON.stringify(initial[key])) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Also catch new keys not in initial
|
||||||
|
for (const key of Object.keys(values)) {
|
||||||
|
if (!(key in initial) && values[key] !== undefined && values[key] !== '') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, [values, initial]);
|
||||||
|
|
||||||
|
const setValue = useCallback((path: string, value: unknown) => {
|
||||||
|
setValues((prev) => ({ ...prev, [path]: value }));
|
||||||
|
// Clear error on change
|
||||||
|
setErrorsState((prev) => {
|
||||||
|
if (!prev[path]) return prev;
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[path];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setFieldError = useCallback((path: string, error: string) => {
|
||||||
|
setErrorsState((prev) => ({ ...prev, [path]: error }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setErrors = useCallback((newErrors: FormErrors) => {
|
||||||
|
setErrorsState(newErrors);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
const fresh = buildInitialValues(schema, initialValues);
|
||||||
|
setValues(fresh);
|
||||||
|
setErrorsState({});
|
||||||
|
}, [schema, initialValues]);
|
||||||
|
|
||||||
|
return { values, errors, isDirty, setValue, setFieldError, setErrors, reset };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers (used by ConfigPanel before save) ─────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs synchronous required-field validation.
|
||||||
|
* Returns a flat-path → error map (empty = all valid).
|
||||||
|
*/
|
||||||
|
export function validateRequired(schema: UBASchema, values: FormValues): FormErrors {
|
||||||
|
const errors: FormErrors = {};
|
||||||
|
|
||||||
|
for (const section of schema.sections) {
|
||||||
|
for (const field of section.fields) {
|
||||||
|
if (!field.required) continue;
|
||||||
|
const path = `${section.id}.${field.id}`;
|
||||||
|
const value = values[path];
|
||||||
|
if (value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0)) {
|
||||||
|
errors[path] = `${field.name} is required`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts flat-path values map back to a nested object for sending to backends.
|
||||||
|
* e.g. { "auth.api_key": "abc" } → { auth: { api_key: "abc" } }
|
||||||
|
*/
|
||||||
|
export function flatToNested(values: FormValues): Record<string, unknown> {
|
||||||
|
let result: Record<string, unknown> = {};
|
||||||
|
for (const [path, value] of Object.entries(values)) {
|
||||||
|
result = setNestedValue(result, path, value);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
14
packages/noodl-editor/src/editor/src/views/UBA/index.ts
Normal file
14
packages/noodl-editor/src/editor/src/views/UBA/index.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* UBA-003 / UBA-004: View layer — barrel export
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { ConfigPanel } from './ConfigPanel';
|
||||||
|
export type { ConfigPanelProps } from './ConfigPanel';
|
||||||
|
export { ConfigSection, sectionHasErrors } from './ConfigSection';
|
||||||
|
export type { ConfigSectionProps } from './ConfigSection';
|
||||||
|
export { FieldRenderer } from './fields/FieldRenderer';
|
||||||
|
export type { FieldRendererProps } from './fields/FieldRenderer';
|
||||||
|
export { FieldWrapper } from './fields/FieldWrapper';
|
||||||
|
export type { FieldWrapperProps } from './fields/FieldWrapper';
|
||||||
|
export { useConfigForm, validateRequired, flatToNested } from './hooks/useConfigForm';
|
||||||
|
export type { ConfigFormState, FormValues, FormErrors } from './hooks/useConfigForm';
|
||||||
175
packages/noodl-editor/tests/models/UBAConditions.test.ts
Normal file
175
packages/noodl-editor/tests/models/UBAConditions.test.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* UBA-003/UBA-004: Unit tests for Conditions.ts
|
||||||
|
*
|
||||||
|
* Tests:
|
||||||
|
* - getNestedValue dot-path lookups
|
||||||
|
* - setNestedValue immutable path writes
|
||||||
|
* - isEmpty edge cases
|
||||||
|
* - evaluateCondition — all 6 operators
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from '@jest/globals';
|
||||||
|
|
||||||
|
import { evaluateCondition, getNestedValue, isEmpty, setNestedValue } from '../../src/editor/src/models/UBA/Conditions';
|
||||||
|
|
||||||
|
// ─── getNestedValue ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('getNestedValue', () => {
|
||||||
|
it('returns top-level value', () => {
|
||||||
|
expect(getNestedValue({ foo: 'bar' }, 'foo')).toBe('bar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns nested value via dot path', () => {
|
||||||
|
expect(getNestedValue({ auth: { type: 'bearer' } }, 'auth.type')).toBe('bearer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for missing path', () => {
|
||||||
|
expect(getNestedValue({ auth: {} }, 'auth.type')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for deeply missing path', () => {
|
||||||
|
expect(getNestedValue({}, 'a.b.c')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for empty path', () => {
|
||||||
|
expect(getNestedValue({ foo: 'bar' }, '')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── setNestedValue ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('setNestedValue', () => {
|
||||||
|
it('sets top-level key', () => {
|
||||||
|
const result = setNestedValue({}, 'foo', 'bar');
|
||||||
|
expect(result).toEqual({ foo: 'bar' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets nested key', () => {
|
||||||
|
const result = setNestedValue({}, 'auth.type', 'bearer');
|
||||||
|
expect(result).toEqual({ auth: { type: 'bearer' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges with existing nested object', () => {
|
||||||
|
const result = setNestedValue({ auth: { key: 'abc' } }, 'auth.type', 'bearer');
|
||||||
|
expect(result).toEqual({ auth: { key: 'abc', type: 'bearer' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mutate the original', () => {
|
||||||
|
const original = { foo: 'bar' };
|
||||||
|
setNestedValue(original, 'foo', 'baz');
|
||||||
|
expect(original.foo).toBe('bar');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── isEmpty ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('isEmpty', () => {
|
||||||
|
it.each([
|
||||||
|
[null, true],
|
||||||
|
[undefined, true],
|
||||||
|
['', true],
|
||||||
|
[' ', true],
|
||||||
|
[[], true],
|
||||||
|
['hello', false],
|
||||||
|
[0, false],
|
||||||
|
[false, false],
|
||||||
|
[['a'], false],
|
||||||
|
[{}, false]
|
||||||
|
])('isEmpty(%o) === %s', (value, expected) => {
|
||||||
|
expect(isEmpty(value)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── evaluateCondition ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('evaluateCondition', () => {
|
||||||
|
const values = {
|
||||||
|
'auth.type': 'bearer',
|
||||||
|
'auth.token': 'abc123',
|
||||||
|
'auth.enabled': true,
|
||||||
|
'features.list': ['a', 'b'],
|
||||||
|
'features.empty': []
|
||||||
|
};
|
||||||
|
|
||||||
|
it('returns true when condition is undefined', () => {
|
||||||
|
expect(evaluateCondition(undefined, values)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('operator "="', () => {
|
||||||
|
it('returns true when field matches value', () => {
|
||||||
|
expect(evaluateCondition({ field: 'auth.type', operator: '=', value: 'bearer' }, values)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when field does not match', () => {
|
||||||
|
expect(evaluateCondition({ field: 'auth.type', operator: '=', value: 'api_key' }, values)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('operator "!="', () => {
|
||||||
|
it('returns true when field differs', () => {
|
||||||
|
expect(evaluateCondition({ field: 'auth.type', operator: '!=', value: 'api_key' }, values)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when field matches', () => {
|
||||||
|
expect(evaluateCondition({ field: 'auth.type', operator: '!=', value: 'bearer' }, values)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('operator "in"', () => {
|
||||||
|
it('returns true when value is in array', () => {
|
||||||
|
expect(evaluateCondition({ field: 'auth.type', operator: 'in', value: ['bearer', 'api_key'] }, values)).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when value is not in array', () => {
|
||||||
|
expect(evaluateCondition({ field: 'auth.type', operator: 'in', value: ['basic', 'none'] }, values)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when condition value is not an array', () => {
|
||||||
|
expect(evaluateCondition({ field: 'auth.type', operator: 'in', value: 'bearer' }, values)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('operator "not_in"', () => {
|
||||||
|
it('returns true when value is not in array', () => {
|
||||||
|
expect(evaluateCondition({ field: 'auth.type', operator: 'not_in', value: ['basic', 'none'] }, values)).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when value is in the array', () => {
|
||||||
|
expect(evaluateCondition({ field: 'auth.type', operator: 'not_in', value: ['bearer', 'api_key'] }, values)).toBe(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('operator "exists"', () => {
|
||||||
|
it('returns true when field has a value', () => {
|
||||||
|
expect(evaluateCondition({ field: 'auth.token', operator: 'exists' }, values)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when field is missing', () => {
|
||||||
|
expect(evaluateCondition({ field: 'auth.missing', operator: 'exists' }, values)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when field is empty array', () => {
|
||||||
|
expect(evaluateCondition({ field: 'features.empty', operator: 'exists' }, values)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('operator "not_exists"', () => {
|
||||||
|
it('returns true when field is missing', () => {
|
||||||
|
expect(evaluateCondition({ field: 'auth.missing', operator: 'not_exists' }, values)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when field has a value', () => {
|
||||||
|
expect(evaluateCondition({ field: 'auth.token', operator: 'not_exists' }, values)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when field is empty array', () => {
|
||||||
|
expect(evaluateCondition({ field: 'features.empty', operator: 'not_exists' }, values)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user