feat(uba): UBA-003/004 field renderers + ConfigPanel + Conditions

This commit is contained in:
Richard Osborne
2026-02-18 18:23:10 +01:00
parent ab34ee4d50
commit c04bf2e6cb
22 changed files with 2045 additions and 50 deletions

View 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;
}
}

View File

@@ -1,4 +1,5 @@
export { SchemaParser } from './SchemaParser';
export { evaluateCondition, getNestedValue, setNestedValue, isEmpty } from './Conditions';
export type {
UBASchema,
BackendMetadata,

View File

@@ -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;
}

View 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>
);
}

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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}
/>
);
}
}
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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';

View File

@@ -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;
}

View 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';

View 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);
});
});
});