mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-07 17:43:28 +01:00
feat(styles+uba): STYLE-005 wiring + UBA-001/002
STYLE-005: ElementStyleSectionHost wraps ElementStyleSection with SuggestionBanner. Uses useStyleSuggestions + executeSuggestionAction. propertyeditor.ts updated to use host (drop-in, zero API change). UBA-001: types.ts — Field discriminated union (8 types), BackendMetadata, Section, Condition, ParseResult<T>. Zero external deps. UBA-002: SchemaParser.ts validates pre-parsed objects against UBA v1. Forward-compat: unknown field types/versions produce warnings not errors. 22 unit tests in tests/models/UBASchemaParser.test.ts.
This commit is contained in:
369
packages/noodl-editor/src/editor/src/models/UBA/SchemaParser.ts
Normal file
369
packages/noodl-editor/src/editor/src/models/UBA/SchemaParser.ts
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
/**
|
||||||
|
* UBA-002: SchemaParser
|
||||||
|
*
|
||||||
|
* Validates an unknown (already-parsed) object against the UBA schema v1.0
|
||||||
|
* shape and returns a strongly-typed ParseResult<UBASchema>.
|
||||||
|
*
|
||||||
|
* Design: accepts a pre-parsed object rather than a raw YAML/JSON string.
|
||||||
|
* The calling layer (BackendDiscovery, AddBackendDialog) is responsible for
|
||||||
|
* deserialising the text. This keeps the parser 100% dep-free and testable.
|
||||||
|
*
|
||||||
|
* Validation is intentional-but-not-exhaustive:
|
||||||
|
* - Required fields are checked; extra unknown keys are allowed (forward compat)
|
||||||
|
* - Field array entries are validated individually; partial errors are collected
|
||||||
|
* - Warnings are issued for deprecated or advisory patterns
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AuthConfig,
|
||||||
|
BackendMetadata,
|
||||||
|
BaseField,
|
||||||
|
BooleanField,
|
||||||
|
Capabilities,
|
||||||
|
Condition,
|
||||||
|
DebugField,
|
||||||
|
DebugSchema,
|
||||||
|
Endpoints,
|
||||||
|
Field,
|
||||||
|
MultiSelectField,
|
||||||
|
NumberField,
|
||||||
|
ParseError,
|
||||||
|
ParseResult,
|
||||||
|
SecretField,
|
||||||
|
Section,
|
||||||
|
SelectField,
|
||||||
|
SelectOption,
|
||||||
|
StringField,
|
||||||
|
TextField,
|
||||||
|
UBASchema,
|
||||||
|
UrlField
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class SchemaParser {
|
||||||
|
/**
|
||||||
|
* Validate a pre-parsed object as a UBASchema.
|
||||||
|
*
|
||||||
|
* @param data - Already-parsed JavaScript object (from JSON.parse or yaml.load)
|
||||||
|
*/
|
||||||
|
parse(data: unknown): ParseResult<UBASchema> {
|
||||||
|
const errors: ParseError[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
if (!isObject(data)) {
|
||||||
|
return { success: false, errors: [{ path: '', message: 'Schema must be an object' }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// schema_version
|
||||||
|
if (!isString(data['schema_version'])) {
|
||||||
|
errors.push({
|
||||||
|
path: 'schema_version',
|
||||||
|
message: 'Required string field "schema_version" is missing or not a string'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const [major] = (data['schema_version'] as string).split('.');
|
||||||
|
if (major !== '1') {
|
||||||
|
warnings.push(
|
||||||
|
`schema_version "${data['schema_version']}" — only v1.x is supported; proceeding with best-effort parsing`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// backend
|
||||||
|
const backendErrors: ParseError[] = [];
|
||||||
|
const backend = parseBackendMetadata(data['backend'], backendErrors);
|
||||||
|
errors.push(...backendErrors);
|
||||||
|
|
||||||
|
// sections
|
||||||
|
const sectionsErrors: ParseError[] = [];
|
||||||
|
const sections = parseSections(data['sections'], sectionsErrors, warnings);
|
||||||
|
errors.push(...sectionsErrors);
|
||||||
|
|
||||||
|
// debug (optional)
|
||||||
|
let debugSchema: DebugSchema | undefined;
|
||||||
|
if (data['debug'] !== undefined) {
|
||||||
|
const debugErrors: ParseError[] = [];
|
||||||
|
debugSchema = parseDebugSchema(data['debug'], debugErrors);
|
||||||
|
errors.push(...debugErrors);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return { success: false, errors, ...(warnings.length > 0 ? { warnings } : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema: UBASchema = {
|
||||||
|
schema_version: data['schema_version'] as string,
|
||||||
|
backend: backend!,
|
||||||
|
sections: sections ?? [],
|
||||||
|
...(debugSchema ? { debug: debugSchema } : {})
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: schema,
|
||||||
|
...(warnings.length > 0 ? { warnings } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Internal validators ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function parseBackendMetadata(raw: unknown, errors: ParseError[]): BackendMetadata | undefined {
|
||||||
|
if (!isObject(raw)) {
|
||||||
|
errors.push({ path: 'backend', message: 'Required object "backend" is missing or not an object' });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredStrings = ['id', 'name', 'version'] as const;
|
||||||
|
for (const key of requiredStrings) {
|
||||||
|
if (!isString(raw[key])) {
|
||||||
|
errors.push({ path: `backend.${key}`, message: `Required string "backend.${key}" is missing` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpointErrors: ParseError[] = [];
|
||||||
|
const endpoints = parseEndpoints(raw['endpoints'], endpointErrors);
|
||||||
|
errors.push(...endpointErrors);
|
||||||
|
|
||||||
|
let auth: AuthConfig | undefined;
|
||||||
|
if (raw['auth'] !== undefined) {
|
||||||
|
if (!isObject(raw['auth'])) {
|
||||||
|
errors.push({ path: 'backend.auth', message: '"backend.auth" must be an object' });
|
||||||
|
} else {
|
||||||
|
const validTypes = ['none', 'bearer', 'api_key', 'basic'];
|
||||||
|
if (!validTypes.includes(raw['auth']['type'] as string)) {
|
||||||
|
errors.push({
|
||||||
|
path: 'backend.auth.type',
|
||||||
|
message: `"backend.auth.type" must be one of: ${validTypes.join(', ')}`
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
auth = { type: raw['auth']['type'] as AuthConfig['type'] };
|
||||||
|
if (isString(raw['auth']['header'])) auth.header = raw['auth']['header'] as string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let capabilities: Capabilities | undefined;
|
||||||
|
if (isObject(raw['capabilities'])) {
|
||||||
|
capabilities = {};
|
||||||
|
if (typeof raw['capabilities']['hot_reload'] === 'boolean')
|
||||||
|
capabilities.hot_reload = raw['capabilities']['hot_reload'] as boolean;
|
||||||
|
if (typeof raw['capabilities']['debug'] === 'boolean') capabilities.debug = raw['capabilities']['debug'] as boolean;
|
||||||
|
if (typeof raw['capabilities']['batch_config'] === 'boolean')
|
||||||
|
capabilities.batch_config = raw['capabilities']['batch_config'] as boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.some((e) => e.path.startsWith('backend'))) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: raw['id'] as string,
|
||||||
|
name: raw['name'] as string,
|
||||||
|
version: raw['version'] as string,
|
||||||
|
...(isString(raw['description']) ? { description: raw['description'] as string } : {}),
|
||||||
|
...(isString(raw['icon']) ? { icon: raw['icon'] as string } : {}),
|
||||||
|
...(isString(raw['homepage']) ? { homepage: raw['homepage'] as string } : {}),
|
||||||
|
endpoints: endpoints!,
|
||||||
|
...(auth ? { auth } : {}),
|
||||||
|
...(capabilities ? { capabilities } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEndpoints(raw: unknown, errors: ParseError[]): Endpoints | undefined {
|
||||||
|
if (!isObject(raw)) {
|
||||||
|
errors.push({ path: 'backend.endpoints', message: 'Required object "backend.endpoints" is missing' });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!isString(raw['config'])) {
|
||||||
|
errors.push({ path: 'backend.endpoints.config', message: 'Required string "backend.endpoints.config" is missing' });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
config: raw['config'] as string,
|
||||||
|
...(isString(raw['health']) ? { health: raw['health'] as string } : {}),
|
||||||
|
...(isString(raw['debug_stream']) ? { debug_stream: raw['debug_stream'] as string } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSections(raw: unknown, errors: ParseError[], warnings: string[]): Section[] {
|
||||||
|
if (!Array.isArray(raw)) {
|
||||||
|
errors.push({ path: 'sections', message: 'Required array "sections" is missing or not an array' });
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw
|
||||||
|
.map((item: unknown, i: number): Section | null => {
|
||||||
|
if (!isObject(item)) {
|
||||||
|
errors.push({ path: `sections[${i}]`, message: `Section at index ${i} must be an object` });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!isString(item['id'])) {
|
||||||
|
errors.push({ path: `sections[${i}].id`, message: `sections[${i}].id is required and must be a string` });
|
||||||
|
}
|
||||||
|
if (!isString(item['name'])) {
|
||||||
|
errors.push({ path: `sections[${i}].name`, message: `sections[${i}].name is required and must be a string` });
|
||||||
|
}
|
||||||
|
if (errors.some((e) => e.path === `sections[${i}].id` || e.path === `sections[${i}].name`)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldErrors: ParseError[] = [];
|
||||||
|
const fields = parseFields(item['fields'], `sections[${i}]`, fieldErrors, warnings);
|
||||||
|
errors.push(...fieldErrors);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item['id'] as string,
|
||||||
|
name: item['name'] as string,
|
||||||
|
...(isString(item['description']) ? { description: item['description'] as string } : {}),
|
||||||
|
...(isString(item['icon']) ? { icon: item['icon'] as string } : {}),
|
||||||
|
...(typeof item['collapsed'] === 'boolean' ? { collapsed: item['collapsed'] as boolean } : {}),
|
||||||
|
...(item['visible_when'] ? { visible_when: item['visible_when'] as Condition } : {}),
|
||||||
|
fields
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((s): s is Section => s !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFields(raw: unknown, path: string, errors: ParseError[], warnings: string[]): Field[] {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
|
||||||
|
return raw
|
||||||
|
.map((item: unknown, i: number): Field | null => {
|
||||||
|
if (!isObject(item)) {
|
||||||
|
errors.push({ path: `${path}.fields[${i}]`, message: `Field at index ${i} must be an object` });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldPath = `${path}.fields[${i}]`;
|
||||||
|
if (!isString(item['id'])) {
|
||||||
|
errors.push({ path: `${fieldPath}.id`, message: `${fieldPath}.id is required` });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!isString(item['name'])) {
|
||||||
|
errors.push({ path: `${fieldPath}.name`, message: `${fieldPath}.name is required` });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base: BaseField = {
|
||||||
|
id: item['id'] as string,
|
||||||
|
name: item['name'] as string,
|
||||||
|
...(isString(item['description']) ? { description: item['description'] as string } : {}),
|
||||||
|
...(typeof item['required'] === 'boolean' ? { required: item['required'] as boolean } : {}),
|
||||||
|
...(item['visible_when'] ? { visible_when: item['visible_when'] as Condition } : {})
|
||||||
|
};
|
||||||
|
|
||||||
|
const type = item['type'] as string;
|
||||||
|
switch (type) {
|
||||||
|
case 'string':
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
type: 'string',
|
||||||
|
...(isString(item['placeholder']) ? { placeholder: item['placeholder'] as string } : {}),
|
||||||
|
...(isString(item['default']) ? { default: item['default'] as string } : {})
|
||||||
|
} as StringField;
|
||||||
|
case 'text':
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
type: 'text',
|
||||||
|
...(isString(item['placeholder']) ? { placeholder: item['placeholder'] as string } : {}),
|
||||||
|
...(isString(item['default']) ? { default: item['default'] as string } : {}),
|
||||||
|
...(typeof item['rows'] === 'number' ? { rows: item['rows'] as number } : {})
|
||||||
|
} as TextField;
|
||||||
|
case 'number':
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
type: 'number',
|
||||||
|
...(typeof item['default'] === 'number' ? { default: item['default'] as number } : {}),
|
||||||
|
...(typeof item['min'] === 'number' ? { min: item['min'] as number } : {}),
|
||||||
|
...(typeof item['max'] === 'number' ? { max: item['max'] as number } : {})
|
||||||
|
} as NumberField;
|
||||||
|
case 'boolean':
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
type: 'boolean',
|
||||||
|
...(typeof item['default'] === 'boolean' ? { default: item['default'] as boolean } : {})
|
||||||
|
} as BooleanField;
|
||||||
|
case 'secret':
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
type: 'secret',
|
||||||
|
...(isString(item['placeholder']) ? { placeholder: item['placeholder'] as string } : {})
|
||||||
|
} as SecretField;
|
||||||
|
case 'url':
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
type: 'url',
|
||||||
|
...(isString(item['placeholder']) ? { placeholder: item['placeholder'] as string } : {}),
|
||||||
|
...(isString(item['default']) ? { default: item['default'] as string } : {})
|
||||||
|
} as UrlField;
|
||||||
|
case 'select': {
|
||||||
|
if (!Array.isArray(item['options'])) {
|
||||||
|
errors.push({
|
||||||
|
path: `${fieldPath}.options`,
|
||||||
|
message: `select field "${base.id}" requires an "options" array`
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const options = (item['options'] as unknown[]).map((o) =>
|
||||||
|
isObject(o)
|
||||||
|
? ({ value: String(o['value'] ?? ''), label: String(o['label'] ?? '') } as SelectOption)
|
||||||
|
: { value: '', label: '' }
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
type: 'select',
|
||||||
|
options,
|
||||||
|
...(isString(item['default']) ? { default: item['default'] as string } : {})
|
||||||
|
} as SelectField;
|
||||||
|
}
|
||||||
|
case 'multi_select': {
|
||||||
|
if (!Array.isArray(item['options'])) {
|
||||||
|
errors.push({
|
||||||
|
path: `${fieldPath}.options`,
|
||||||
|
message: `multi_select field "${base.id}" requires an "options" array`
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const options = (item['options'] as unknown[]).map((o) =>
|
||||||
|
isObject(o)
|
||||||
|
? ({ value: String(o['value'] ?? ''), label: String(o['label'] ?? '') } as SelectOption)
|
||||||
|
: { value: '', label: '' }
|
||||||
|
);
|
||||||
|
return { ...base, type: 'multi_select', options } as MultiSelectField;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
warnings.push(`Unknown field type "${type}" at ${fieldPath} — skipping`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((f): f is Field => f !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDebugSchema(raw: unknown, errors: ParseError[]): DebugSchema | undefined {
|
||||||
|
if (!isObject(raw)) {
|
||||||
|
errors.push({ path: 'debug', message: '"debug" must be an object' });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const enabled = typeof raw['enabled'] === 'boolean' ? (raw['enabled'] as boolean) : true;
|
||||||
|
const eventSchema: DebugField[] = Array.isArray(raw['event_schema'])
|
||||||
|
? (raw['event_schema'] as unknown[]).filter(isObject).map((f) => ({
|
||||||
|
id: String(f['id'] ?? ''),
|
||||||
|
name: String(f['name'] ?? ''),
|
||||||
|
type: (['string', 'number', 'boolean', 'json'].includes(f['type'] as string)
|
||||||
|
? f['type']
|
||||||
|
: 'string') as DebugField['type'],
|
||||||
|
...(isString(f['description']) ? { description: f['description'] as string } : {})
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
return { enabled, ...(eventSchema.length > 0 ? { event_schema: eventSchema } : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function isObject(v: unknown): v is Record<string, unknown> {
|
||||||
|
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isString(v: unknown): v is string {
|
||||||
|
return typeof v === 'string' && v.length > 0;
|
||||||
|
}
|
||||||
25
packages/noodl-editor/src/editor/src/models/UBA/index.ts
Normal file
25
packages/noodl-editor/src/editor/src/models/UBA/index.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export { SchemaParser } from './SchemaParser';
|
||||||
|
export type {
|
||||||
|
UBASchema,
|
||||||
|
BackendMetadata,
|
||||||
|
Section,
|
||||||
|
Field,
|
||||||
|
StringField,
|
||||||
|
TextField,
|
||||||
|
NumberField,
|
||||||
|
BooleanField,
|
||||||
|
SecretField,
|
||||||
|
UrlField,
|
||||||
|
SelectField,
|
||||||
|
MultiSelectField,
|
||||||
|
SelectOption,
|
||||||
|
BaseField,
|
||||||
|
Condition,
|
||||||
|
AuthConfig,
|
||||||
|
Endpoints,
|
||||||
|
Capabilities,
|
||||||
|
DebugSchema,
|
||||||
|
DebugField,
|
||||||
|
ParseResult,
|
||||||
|
ParseError
|
||||||
|
} from './types';
|
||||||
207
packages/noodl-editor/src/editor/src/models/UBA/types.ts
Normal file
207
packages/noodl-editor/src/editor/src/models/UBA/types.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* UBA-001 / UBA-002: Universal Backend Adapter — TypeScript type definitions
|
||||||
|
*
|
||||||
|
* These types mirror the UBA schema specification v1.0. The SchemaParser
|
||||||
|
* validates an unknown object against these shapes and returns a typed result.
|
||||||
|
*
|
||||||
|
* Design notes:
|
||||||
|
* - Field types use a discriminated union on `type` so exhaustive switch()
|
||||||
|
* statements work correctly in renderers.
|
||||||
|
* - Optional fields are marked `?` — do NOT add runtime defaults here;
|
||||||
|
* defaults are handled by the UI layer (buildInitialValues in ConfigPanel).
|
||||||
|
* - All arrays that could be omitted in the schema default to `[]` in the
|
||||||
|
* parsed output (see SchemaParser.normalise).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── Schema Root ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface UBASchema {
|
||||||
|
schema_version: string;
|
||||||
|
backend: BackendMetadata;
|
||||||
|
sections: Section[];
|
||||||
|
debug?: DebugSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Backend Metadata ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface BackendMetadata {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
version: string;
|
||||||
|
icon?: string;
|
||||||
|
homepage?: string;
|
||||||
|
endpoints: Endpoints;
|
||||||
|
auth?: AuthConfig;
|
||||||
|
capabilities?: Capabilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Endpoints {
|
||||||
|
config: string;
|
||||||
|
health?: string;
|
||||||
|
debug_stream?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthConfig {
|
||||||
|
type: 'none' | 'bearer' | 'api_key' | 'basic';
|
||||||
|
header?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Capabilities {
|
||||||
|
hot_reload?: boolean;
|
||||||
|
debug?: boolean;
|
||||||
|
batch_config?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Sections ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Section {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: string;
|
||||||
|
collapsed?: boolean;
|
||||||
|
visible_when?: Condition;
|
||||||
|
fields: Field[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Conditions ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Condition {
|
||||||
|
/** e.g. "auth.type" */
|
||||||
|
field: string;
|
||||||
|
/** e.g. "=" | "!=" | "in" | "not_in" */
|
||||||
|
operator: '=' | '!=' | 'in' | 'not_in' | 'exists' | 'not_exists';
|
||||||
|
value?: string | string[] | boolean | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Field Discriminated Union ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type Field =
|
||||||
|
| StringField
|
||||||
|
| TextField
|
||||||
|
| NumberField
|
||||||
|
| BooleanField
|
||||||
|
| SecretField
|
||||||
|
| UrlField
|
||||||
|
| SelectField
|
||||||
|
| MultiSelectField;
|
||||||
|
|
||||||
|
/** Common base shared by all field types */
|
||||||
|
export interface BaseField {
|
||||||
|
/** Unique identifier within the section */
|
||||||
|
id: string;
|
||||||
|
/** Display label */
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
required?: boolean;
|
||||||
|
visible_when?: Condition;
|
||||||
|
ui?: UIHints;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UIHints {
|
||||||
|
help_link?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
width?: 'full' | 'half' | 'third';
|
||||||
|
monospace?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Concrete Field Types ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface StringField extends BaseField {
|
||||||
|
type: 'string';
|
||||||
|
placeholder?: string;
|
||||||
|
default?: string;
|
||||||
|
validation?: StringValidation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StringValidation {
|
||||||
|
min_length?: number;
|
||||||
|
max_length?: number;
|
||||||
|
pattern?: string;
|
||||||
|
pattern_message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextField extends BaseField {
|
||||||
|
type: 'text';
|
||||||
|
placeholder?: string;
|
||||||
|
default?: string;
|
||||||
|
rows?: number;
|
||||||
|
validation?: StringValidation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NumberField extends BaseField {
|
||||||
|
type: 'number';
|
||||||
|
placeholder?: string;
|
||||||
|
default?: number;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
integer?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BooleanField extends BaseField {
|
||||||
|
type: 'boolean';
|
||||||
|
default?: boolean;
|
||||||
|
/** Text shown next to the toggle */
|
||||||
|
toggle_label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecretField extends BaseField {
|
||||||
|
type: 'secret';
|
||||||
|
placeholder?: string;
|
||||||
|
/** If true, disable copy-paste on the masked input */
|
||||||
|
no_paste?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UrlField extends BaseField {
|
||||||
|
type: 'url';
|
||||||
|
placeholder?: string;
|
||||||
|
default?: string;
|
||||||
|
/** Restrict to specific protocols, e.g. ['https', 'wss'] */
|
||||||
|
protocols?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectField extends BaseField {
|
||||||
|
type: 'select';
|
||||||
|
options: SelectOption[];
|
||||||
|
default?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MultiSelectField extends BaseField {
|
||||||
|
type: 'multi_select';
|
||||||
|
options: SelectOption[];
|
||||||
|
default?: string[];
|
||||||
|
max_selections?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Debug Schema ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface DebugSchema {
|
||||||
|
enabled: boolean;
|
||||||
|
event_schema?: DebugField[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DebugField {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: 'string' | 'number' | 'boolean' | 'json';
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Parser Result Types ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type ParseResult<T> =
|
||||||
|
| { success: true; data: T; warnings?: string[] }
|
||||||
|
| { success: false; errors: ParseError[]; warnings?: string[] };
|
||||||
|
|
||||||
|
export interface ParseError {
|
||||||
|
path: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* STYLE-005: ElementStyleSectionHost
|
||||||
|
*
|
||||||
|
* Editor-side wrapper that combines ElementStyleSection (variant + size picker)
|
||||||
|
* with the SuggestionBanner. Lives in noodl-editor (not noodl-core-ui) so it
|
||||||
|
* can import editor-specific hooks and services.
|
||||||
|
*
|
||||||
|
* Keeps its own StyleTokensModel instance for suggestion actions. Multiple
|
||||||
|
* instances are safe — they sync via ProjectModel.metadataChanged events.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useStyleSuggestions } from '@noodl-hooks/useStyleSuggestions';
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { StyleTokensModel } from '@noodl-models/StyleTokensModel';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ElementStyleSection,
|
||||||
|
ElementStyleSectionProps
|
||||||
|
} from '@noodl-core-ui/components/propertyeditor/ElementStyleSection';
|
||||||
|
import { SuggestionBanner } from '@noodl-core-ui/components/StyleSuggestions';
|
||||||
|
|
||||||
|
import { executeSuggestionAction } from '../../../../../services/StyleAnalyzer/SuggestionActionHandler';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop-in replacement for ElementStyleSection in propertyeditor.ts.
|
||||||
|
* Adds an optional SuggestionBanner beneath the style controls when
|
||||||
|
* the StyleAnalyzer finds something worth suggesting.
|
||||||
|
*/
|
||||||
|
export function ElementStyleSectionHost(props: ElementStyleSectionProps) {
|
||||||
|
const [tokenModel] = useState<StyleTokensModel>(() => new StyleTokensModel());
|
||||||
|
|
||||||
|
// Dispose the model when the host unmounts to avoid listener leaks
|
||||||
|
useEffect(() => {
|
||||||
|
return () => tokenModel.dispose();
|
||||||
|
}, [tokenModel]);
|
||||||
|
|
||||||
|
const { activeSuggestion, dismissSession, dismissPermanent, refresh } = useStyleSuggestions();
|
||||||
|
|
||||||
|
const handleAccept = useCallback(() => {
|
||||||
|
if (!activeSuggestion) return;
|
||||||
|
executeSuggestionAction(activeSuggestion, { tokenModel, onComplete: refresh });
|
||||||
|
}, [activeSuggestion, tokenModel, refresh]);
|
||||||
|
|
||||||
|
const handleDismiss = useCallback(() => {
|
||||||
|
if (!activeSuggestion) return;
|
||||||
|
dismissSession(activeSuggestion.id);
|
||||||
|
}, [activeSuggestion, dismissSession]);
|
||||||
|
|
||||||
|
const handleNeverShow = useCallback(() => {
|
||||||
|
if (!activeSuggestion) return;
|
||||||
|
dismissPermanent(activeSuggestion.id);
|
||||||
|
}, [activeSuggestion, dismissPermanent]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ElementStyleSection {...props} />
|
||||||
|
{activeSuggestion && (
|
||||||
|
<SuggestionBanner
|
||||||
|
suggestion={activeSuggestion}
|
||||||
|
onAccept={handleAccept}
|
||||||
|
onDismiss={handleDismiss}
|
||||||
|
onNeverShow={handleNeverShow}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ElementStyleSectionHost } from './ElementStyleSectionHost';
|
||||||
@@ -6,12 +6,11 @@ import { createRoot, Root } from 'react-dom/client';
|
|||||||
import { NodeGraphNode } from '@noodl-models/nodegraphmodel';
|
import { NodeGraphNode } from '@noodl-models/nodegraphmodel';
|
||||||
import { UndoQueue, UndoActionGroup } from '@noodl-models/undo-queue-model';
|
import { UndoQueue, UndoActionGroup } from '@noodl-models/undo-queue-model';
|
||||||
|
|
||||||
import { ElementStyleSection } from '@noodl-core-ui/components/propertyeditor/ElementStyleSection';
|
|
||||||
|
|
||||||
import View from '../../../../../shared/view';
|
import View from '../../../../../shared/view';
|
||||||
import { ElementConfigRegistry } from '../../../models/ElementConfigs/ElementConfigRegistry';
|
import { ElementConfigRegistry } from '../../../models/ElementConfigs/ElementConfigRegistry';
|
||||||
import { ProjectModel } from '../../../models/projectmodel';
|
import { ProjectModel } from '../../../models/projectmodel';
|
||||||
import { ToastLayer } from '../../ToastLayer/ToastLayer';
|
import { ToastLayer } from '../../ToastLayer/ToastLayer';
|
||||||
|
import { ElementStyleSectionHost } from './components/ElementStyleSectionHost';
|
||||||
import { VariantsEditor } from './components/VariantStates';
|
import { VariantsEditor } from './components/VariantStates';
|
||||||
import { VisualStates } from './components/VisualStates';
|
import { VisualStates } from './components/VisualStates';
|
||||||
import { Ports } from './DataTypes/Ports';
|
import { Ports } from './DataTypes/Ports';
|
||||||
@@ -142,7 +141,7 @@ export class PropertyEditor extends View {
|
|||||||
if (!this.elementStyleRoot) {
|
if (!this.elementStyleRoot) {
|
||||||
this.elementStyleRoot = createRoot(container);
|
this.elementStyleRoot = createRoot(container);
|
||||||
}
|
}
|
||||||
this.elementStyleRoot.render(React.createElement(ElementStyleSection, props));
|
this.elementStyleRoot.render(React.createElement(ElementStyleSectionHost, props));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
311
packages/noodl-editor/tests/models/UBASchemaParser.test.ts
Normal file
311
packages/noodl-editor/tests/models/UBASchemaParser.test.ts
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
/**
|
||||||
|
* UBA-002: Unit tests for SchemaParser
|
||||||
|
*
|
||||||
|
* Tests run against pure JS objects (no YAML parsing needed).
|
||||||
|
* Covers: happy path, required field errors, optional fields,
|
||||||
|
* field type validation, warnings for unknown types/versions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from '@jest/globals';
|
||||||
|
|
||||||
|
import { SchemaParser } from '../../src/editor/src/models/UBA/SchemaParser';
|
||||||
|
|
||||||
|
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const minimalValid = {
|
||||||
|
schema_version: '1.0',
|
||||||
|
backend: {
|
||||||
|
id: 'test-backend',
|
||||||
|
name: 'Test Backend',
|
||||||
|
version: '1.0.0',
|
||||||
|
endpoints: {
|
||||||
|
config: 'https://example.com/config'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sections: []
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeValid(overrides: Record<string, unknown> = {}) {
|
||||||
|
return { ...minimalValid, ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('SchemaParser', () => {
|
||||||
|
let parser: SchemaParser;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
parser = new SchemaParser();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Root validation ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('root validation', () => {
|
||||||
|
it('rejects null input', () => {
|
||||||
|
const result = parser.parse(null);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.errors[0].path).toBe('');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects array input', () => {
|
||||||
|
const result = parser.parse([]);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects missing schema_version', () => {
|
||||||
|
const { schema_version: _sv, ...noVersion } = minimalValid;
|
||||||
|
const result = parser.parse(noVersion);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.errors.some((e) => e.path === 'schema_version')).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns on unknown major version', () => {
|
||||||
|
const result = parser.parse(makeValid({ schema_version: '2.0' }));
|
||||||
|
// Should still succeed (best-effort) but include a warning
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.warnings?.some((w) => w.includes('2.0'))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a minimal valid schema', () => {
|
||||||
|
const result = parser.parse(minimalValid);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data.schema_version).toBe('1.0');
|
||||||
|
expect(result.data.backend.id).toBe('test-backend');
|
||||||
|
expect(result.data.sections).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Backend validation ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('backend validation', () => {
|
||||||
|
it('errors when backend is missing', () => {
|
||||||
|
const { backend: _b, ...noBackend } = minimalValid;
|
||||||
|
const result = parser.parse(noBackend);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.errors.some((e) => e.path === 'backend')).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('errors when backend.id is missing', () => {
|
||||||
|
const data = makeValid({ backend: { ...minimalValid.backend, id: undefined } });
|
||||||
|
const result = parser.parse(data);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('errors when backend.endpoints.config is missing', () => {
|
||||||
|
const data = makeValid({
|
||||||
|
backend: { ...minimalValid.backend, endpoints: { health: '/health' } }
|
||||||
|
});
|
||||||
|
const result = parser.parse(data);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.errors.some((e) => e.path === 'backend.endpoints.config')).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts optional backend fields', () => {
|
||||||
|
const data = makeValid({
|
||||||
|
backend: {
|
||||||
|
...minimalValid.backend,
|
||||||
|
description: 'My backend',
|
||||||
|
icon: 'https://example.com/icon.png',
|
||||||
|
homepage: 'https://example.com',
|
||||||
|
auth: { type: 'bearer' },
|
||||||
|
capabilities: { hot_reload: true, debug: false }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const result = parser.parse(data);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data.backend.description).toBe('My backend');
|
||||||
|
expect(result.data.backend.auth?.type).toBe('bearer');
|
||||||
|
expect(result.data.backend.capabilities?.hot_reload).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('errors on invalid auth type', () => {
|
||||||
|
const data = makeValid({
|
||||||
|
backend: { ...minimalValid.backend, auth: { type: 'oauth2' } }
|
||||||
|
});
|
||||||
|
const result = parser.parse(data);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.errors.some((e) => e.path === 'backend.auth.type')).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Sections validation ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('sections validation', () => {
|
||||||
|
it('errors when sections is not an array', () => {
|
||||||
|
const data = makeValid({ sections: 'not-an-array' });
|
||||||
|
const result = parser.parse(data);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a section with minimal fields', () => {
|
||||||
|
const data = makeValid({
|
||||||
|
sections: [{ id: 'general', name: 'General', fields: [] }]
|
||||||
|
});
|
||||||
|
const result = parser.parse(data);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data.sections[0].id).toBe('general');
|
||||||
|
expect(result.data.sections[0].fields).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips section without id and collects error', () => {
|
||||||
|
const data = makeValid({
|
||||||
|
sections: [{ name: 'Missing ID', fields: [] }]
|
||||||
|
});
|
||||||
|
const result = parser.parse(data);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.errors.some((e) => e.path.includes('id'))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Field type validation ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('field types', () => {
|
||||||
|
function sectionWith(fields: unknown[]) {
|
||||||
|
return makeValid({ sections: [{ id: 's', name: 'S', fields }] });
|
||||||
|
}
|
||||||
|
|
||||||
|
it('parses a string field', () => {
|
||||||
|
const result = parser.parse(sectionWith([{ id: 'host', name: 'Host', type: 'string', default: 'localhost' }]));
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
const field = result.data.sections[0].fields[0];
|
||||||
|
expect(field.type).toBe('string');
|
||||||
|
if (field.type === 'string') expect(field.default).toBe('localhost');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a boolean field', () => {
|
||||||
|
const result = parser.parse(sectionWith([{ id: 'ssl', name: 'SSL', type: 'boolean', default: true }]));
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
const field = result.data.sections[0].fields[0];
|
||||||
|
expect(field.type).toBe('boolean');
|
||||||
|
if (field.type === 'boolean') expect(field.default).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a select field with options', () => {
|
||||||
|
const result = parser.parse(
|
||||||
|
sectionWith([
|
||||||
|
{
|
||||||
|
id: 'region',
|
||||||
|
name: 'Region',
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{ value: 'eu-west-1', label: 'EU West' },
|
||||||
|
{ value: 'us-east-1', label: 'US East' }
|
||||||
|
],
|
||||||
|
default: 'eu-west-1'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
const field = result.data.sections[0].fields[0];
|
||||||
|
expect(field.type).toBe('select');
|
||||||
|
if (field.type === 'select') {
|
||||||
|
expect(field.options).toHaveLength(2);
|
||||||
|
expect(field.default).toBe('eu-west-1');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('errors when select field has no options array', () => {
|
||||||
|
const result = parser.parse(sectionWith([{ id: 'x', name: 'X', type: 'select' }]));
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns on unknown field type and skips it', () => {
|
||||||
|
const result = parser.parse(sectionWith([{ id: 'x', name: 'X', type: 'color_picker' }]));
|
||||||
|
// Section-level parse succeeds (unknown field is skipped, not fatal)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data.sections[0].fields).toHaveLength(0);
|
||||||
|
expect(result.warnings?.some((w) => w.includes('color_picker'))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a number field with min/max', () => {
|
||||||
|
const result = parser.parse(
|
||||||
|
sectionWith([{ id: 'port', name: 'Port', type: 'number', default: 5432, min: 1, max: 65535 }])
|
||||||
|
);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
const field = result.data.sections[0].fields[0];
|
||||||
|
if (field.type === 'number') {
|
||||||
|
expect(field.default).toBe(5432);
|
||||||
|
expect(field.min).toBe(1);
|
||||||
|
expect(field.max).toBe(65535);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a secret field', () => {
|
||||||
|
const result = parser.parse(sectionWith([{ id: 'api_key', name: 'API Key', type: 'secret' }]));
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
const field = result.data.sections[0].fields[0];
|
||||||
|
expect(field.type).toBe('secret');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a multi_select field', () => {
|
||||||
|
const result = parser.parse(
|
||||||
|
sectionWith([
|
||||||
|
{
|
||||||
|
id: 'roles',
|
||||||
|
name: 'Roles',
|
||||||
|
type: 'multi_select',
|
||||||
|
options: [
|
||||||
|
{ value: 'read', label: 'Read' },
|
||||||
|
{ value: 'write', label: 'Write' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
const field = result.data.sections[0].fields[0];
|
||||||
|
expect(field.type).toBe('multi_select');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Debug schema ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('debug schema', () => {
|
||||||
|
it('parses an enabled debug block', () => {
|
||||||
|
const data = makeValid({
|
||||||
|
debug: {
|
||||||
|
enabled: true,
|
||||||
|
event_schema: [{ id: 'query_time', name: 'Query Time', type: 'number' }]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const result = parser.parse(data);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data.debug?.enabled).toBe(true);
|
||||||
|
expect(result.data.debug?.event_schema).toHaveLength(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user