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:
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 { evaluateCondition, getNestedValue, setNestedValue, isEmpty } from './Conditions';
|
||||
export type {
|
||||
UBASchema,
|
||||
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