Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions libs/chat/src/lib/a2ui/surface-to-spec.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,21 @@ describe('surfaceToSpec (v1)', () => {
expect(spec.elements['root'].props['text']).toBe('Hi');
});

it('resolves DynamicString path prop against dataModel', () => {
it('leaves DynamicString path prop as $bindState marker for json-render', () => {
// Path refs preserve their dynamic resolution: surface-to-spec emits
// a `$bindState` marker so json-render reads the current value from
// its state store on every render. This is what enables user input
// (TextField, MultipleChoice, etc.) to write back through the
// a2ui:datamodel:<path>:<value> emit protocol and have the UI
// reflect those writes immediately.
const surface = makeSurface(
[{ id: 'root', component: { Text: { text: { path: '/greeting' } } } }],
{ greeting: 'Hello World' },
);
const spec = surfaceToSpec(surface)!;
expect(spec.elements['root'].props['text']).toBe('Hello World');
expect(spec.elements['root'].props['text']).toEqual({ $bindState: '/greeting' });
// Spec.state seeds the json-render store with the initial value.
expect(spec.state).toEqual({ greeting: 'Hello World' });
});

it('returns null when surface has no components', () => {
Expand Down Expand Up @@ -175,7 +183,11 @@ describe('surfaceToSpec (v1)', () => {
{ name: 'Alice' },
);
const spec = surfaceToSpec(surface)!;
expect(spec.elements['root'].props['text']).toBe('Alice');
// Path refs become $bindState markers (see "leaves DynamicString
// path prop" test above). _bindings still maps prop name → path so
// catalog components emit a2ui:datamodel:<path>:<value> on user
// input.
expect(spec.elements['root'].props['text']).toEqual({ $bindState: '/name' });
expect(spec.elements['root'].props['_bindings']).toEqual({ text: '/name' });
});

Expand Down
18 changes: 16 additions & 2 deletions libs/chat/src/lib/a2ui/surface-to-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,22 @@ export function surfaceToSpec(surface: A2uiSurface): Spec | null {

for (const [key, value] of Object.entries(rawProps)) {
if (RESERVED_PROP_KEYS.has(key)) continue;
if (isPathRef(value)) bindings[key] = (value as { path: string }).path;
resolvedProps[key] = resolveDynamic(value, surface.dataModel);
if (isPathRef(value)) {
// Leave path refs as json-render two-way binding markers so the
// render lib resolves them against its state store on every
// render. Without this, the catalog component receives a static
// snapshot taken at conversion time and never reflects user
// input writes back into the store. The `_bindings` map below
// tells the catalog component which prop names map to which
// paths so its emit() callback can write back via the
// a2ui:datamodel:<path>:<value> magic-string protocol that
// render-element's emitFn intercepts.
const path = (value as { path: string }).path;
bindings[key] = path;
resolvedProps[key] = { $bindState: path };
} else {
resolvedProps[key] = resolveDynamic(value, surface.dataModel);
}
}
if (Object.keys(bindings).length > 0) {
resolvedProps['_bindings'] = bindings;
Expand Down
68 changes: 67 additions & 1 deletion libs/render/src/lib/render-element.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,33 @@ import type { RepeatScope } from './contexts/repeat-scope';
import { buildPropResolutionContext } from './internals/prop-signal';
import type { AngularComponentRenderer } from './render.types';

/** Magic prefix on `emit()` strings that catalog components use to
* write back to the data model (binding `path` and the new value). The
* render-element's emitFn intercepts this and writes via the state
* store, sidestepping the normal `el.on[event]` handler binding which
* the catalog components have no way to declare for arbitrary paths. */
const A2UI_DATAMODEL_PREFIX = 'a2ui:datamodel:';

/** Best-effort string→typed coercion for datamodel writes. Catalog
* components emit raw string values; the underlying state may have
* been declared as number/boolean/array, and consumers reading the
* resolved props expect the correct type. */
function coerceValue(raw: string): unknown {
if (raw === '') return '';
if (raw === 'true') return true;
if (raw === 'false') return false;
// JSON-array passthrough (MultipleChoice emits stringified arrays)
if (raw.startsWith('[') && raw.endsWith(']')) {
try { return JSON.parse(raw); } catch { /* fall through */ }
}
// Numeric — only if the entire string parses cleanly as a number
if (/^-?\d+(?:\.\d+)?$/.test(raw)) {
const n = Number(raw);
if (!Number.isNaN(n)) return n;
}
return raw;
}

/**
* Recursive element renderer.
*
Expand Down Expand Up @@ -126,8 +153,28 @@ export class RenderElementComponent implements OnInit {
return evaluateVisibility(el.visible, this.propCtx());
});

/** Emit function that delegates to context handlers. */
/** Emit function that delegates to context handlers AND handles the
* canonical `a2ui:datamodel:<path>:<value>` write-back protocol that
* input components (TextField, MultipleChoice, CheckBox, Slider,
* DateTimeInput) emit when the user changes their value. The render
* lib's state store is the single source of truth for in-surface UI
* state; writing through it triggers re-render with the new value
* and re-evaluates any path-bound props (validation, computed
* visibility, etc.).
*
* The string format is `a2ui:datamodel:<path>:<value>` where:
* - `<path>` is a JSON-Pointer-style path (e.g. `/name`, `/form/email`)
* - `<value>` is the raw value rendered as a string. We attempt to
* coerce numeric and boolean literals back to their typed form
* so downstream consumers see correct types; arrays come through
* as JSON-stringified payloads (catalog components emit them via
* `JSON.stringify`).
*/
private readonly emitFn = (event: string) => {
if (event.startsWith(A2UI_DATAMODEL_PREFIX)) {
this.applyDatamodelWrite(event);
return;
}
const el = this.element();
if (!el?.on) return;
const binding = el.on[event];
Expand All @@ -143,6 +190,25 @@ export class RenderElementComponent implements OnInit {
}
};

private applyDatamodelWrite(event: string): void {
// Strip the prefix, then split path and value at the last `:` —
// path may itself contain `:` characters (rare but legal in
// JSON-Pointer per RFC 6901), and values can certainly contain
// them (URLs, time strings). Catalog components emit
// `a2ui:datamodel:<path>:<value>` where path is the binding's
// path-ref (usually starts with `/`); split the LAST `:` because
// the value is the only field guaranteed to come last.
const rest = event.slice(A2UI_DATAMODEL_PREFIX.length);
const lastColon = rest.lastIndexOf(':');
if (lastColon === -1) return;
const path = rest.slice(0, lastColon);
const rawValue = rest.slice(lastColon + 1);
if (!path) return;
const store = this.ctx.store;
if (!store) return;
store.set(path, coerceValue(rawValue));
}

/** Resolved inputs for non-repeat elements. */
readonly resolvedInputs = computed(() => {
const el = this.element();
Expand Down
Loading