Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f2389a8
Refactor component binders to be schema-driven for child component re…
josemontespg Jun 4, 2026
52b69ae
Merge remote-tracking branch 'upstream/main' into schema-driven-child…
josemontespg Jun 5, 2026
a1b861f
Merge remote-tracking branch 'upstream/main' into schema-driven-child…
josemontespg Jun 5, 2026
dd1fd54
style: run formatter to fix formatting checks
josemontespg Jun 5, 2026
bf1f98a
fix(binders): improve type safety and object/array resolution correct…
josemontespg Jun 5, 2026
22accd5
fix(angular): add explicit type annotation to CatalogComponent.props …
josemontespg Jun 5, 2026
535a66b
refactor(binders): replace type helpers with distributive conditional…
josemontespg Jun 5, 2026
608d4bc
fix(binders): preserve ComponentId brand in ResolveA2uiProp and fix L…
josemontespg Jun 5, 2026
c71318d
fix(binders): fix React component child resolution compilation issues…
josemontespg Jun 5, 2026
856e2b4
Merge remote-tracking branch 'upstream/main' into schema-driven-child…
josemontespg Jun 5, 2026
9cada31
Fix failing PR actions: fix React adapter type imports and update tes…
josemontespg Jun 5, 2026
3b6e2a1
Update schema resolution to use decoupled metadata with Zod chained p…
josemontespg Jun 5, 2026
2a9c130
Fix failing PR actions: enforce code formatting in web_core and angul…
josemontespg Jun 5, 2026
a6ea54e
Update CHANGELOGs for web_core and angular with schema-driven child r…
josemontespg Jun 5, 2026
c0043a6
Revert Zod prototype extension in favor of safe functional decorator …
josemontespg Jun 5, 2026
a3a106a
style: enforce correct prettier formatting on schemas and tests
josemontespg Jun 8, 2026
a9dc0e0
chore: remove internal refactor bullet points from changelogs per review
josemontespg Jun 8, 2026
81d2592
Merge remote-tracking branch 'upstream/main' into schema-driven-child…
josemontespg Jun 8, 2026
f1121bd
Merge remote-tracking branch 'upstream/main' into schema-driven-child…
josemontespg Jun 8, 2026
2416efb
Merge branch 'upstream/main' into schema-driven-child-resolution
josemontespg Jun 8, 2026
439010a
Merge remote-tracking branch 'upstream/main' into schema-driven-child…
josemontespg Jun 9, 2026
069f2bc
Merge remote-tracking branch 'upstream/main' into schema-driven-child…
josemontespg Jun 9, 2026
a369271
Merge branch 'upstream/main' into schema-driven-child-resolution
josemontespg Jun 9, 2026
cce88de
Merge branch 'upstream/main' into schema-driven-child-resolution
josemontespg Jun 15, 2026
ae5186c
style: apply automated formatting
josemontespg Jun 15, 2026
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
1 change: 1 addition & 0 deletions renderers/angular/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
## Unreleased

- Make component binders schema-driven, removing the restriction of hardcoded `child`, `children`, `trigger`, and `content` property names. Component properties nested inside objects or arrays (e.g. `Tabs.tabs[].child`) are now recursively resolved based on Zod metadata.
- (v0_9) Fix null de-referencing TypeError in `ComponentBinder` when `children` property is null or undefined. [#1472](https://github.com/a2ui-project/a2ui/pull/1472)
- (v0_8) Fix Icon component to handle camelCase and TitleCase names by converting them to snake_case for `g-icon`.
- (v0_8) Fix Modal component styling and position fixed for overlay.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {Component, input} from '@angular/core';
import {ButtonComponent} from './button.component';
import {Action, ComponentModel} from '@a2ui/web_core/v0_9';
import {ComponentModel} from '@a2ui/web_core/v0_9';
import {A2uiRendererService} from '../../core/a2ui-renderer.service';
import {ComponentBinder, Child} from '../../core/component-binder.service';
import {By} from '@angular/platform-browser';
Expand Down Expand Up @@ -90,7 +90,7 @@ describe('ButtonComponent', () => {
defaultProps = {
variant: createBoundProperty('primary' as const),
child: createBoundProperty<Child>({id: 'child1', basePath: '/'}),
action: createBoundProperty<Action>({
action: createBoundProperty({
event: {name: 'test-action'},
}),
isValid: createBoundProperty(true),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {signal as angularSignal} from '@angular/core';
import {ChoicePickerComponent} from './choice-picker.component';
import {DynamicString} from '@a2ui/web_core/v0_9';
import {A2uiRendererService} from '../../core/a2ui-renderer.service';
import {ComponentBinder} from '../../core/component-binder.service';
import {setComponentProps, createBoundProperty, ComponentToProps} from '../../core/test-utils';
Expand Down Expand Up @@ -57,7 +56,7 @@ describe('ChoicePickerComponent', () => {

defaultProps = {
label: createBoundProperty<string | undefined>(''),
options: createBoundProperty<{label: DynamicString; value: string}[]>([]),
options: createBoundProperty<{label: string; value: string}[]>([]),
value: createBoundProperty<string[]>([]),
variant: createBoundProperty<'multipleSelection' | 'mutuallyExclusive' | undefined>(
'mutuallyExclusive',
Expand All @@ -78,7 +77,7 @@ describe('ChoicePickerComponent', () => {
setComponentProps(fixture, {
...defaultProps,
label: createBoundProperty<string | undefined>('Pick one'),
options: createBoundProperty<{label: DynamicString; value: string}[]>([
options: createBoundProperty<{label: string; value: string}[]>([
{label: 'Opt 1', value: '1'},
{label: 'Opt 2', value: '2'},
]),
Expand All @@ -95,7 +94,7 @@ describe('ChoicePickerComponent', () => {
setComponentProps(fixture, {
...defaultProps,
label: createBoundProperty<string | undefined>('Pick one'),
options: createBoundProperty<{label: DynamicString; value: string}[]>([
options: createBoundProperty<{label: string; value: string}[]>([
{label: 'Opt 1', value: '1'},
{label: 'Opt 2', value: '2'},
]),
Expand All @@ -111,7 +110,7 @@ describe('ChoicePickerComponent', () => {
const onUpdateSpy = jasmine.createSpy('onUpdate');
setComponentProps(fixture, {
...defaultProps,
options: createBoundProperty<{label: DynamicString; value: string}[]>([
options: createBoundProperty<{label: string; value: string}[]>([
{label: 'Chip 1', value: 'c1'},
{label: 'Chip 2', value: 'c2'},
]),
Expand Down
14 changes: 7 additions & 7 deletions renderers/angular/src/v0_9/catalog/basic/tabs.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ import {ComponentFixture, TestBed} from '@angular/core/testing';
import {Component, input} from '@angular/core';
import {By} from '@angular/platform-browser';
import {TabsComponent} from './tabs.component';
import {ComponentModel, DynamicString} from '@a2ui/web_core/v0_9';
import {ComponentModel} from '@a2ui/web_core/v0_9';
import {A2uiRendererService} from '../../core/a2ui-renderer.service';
import {ComponentBinder} from '../../core/component-binder.service';
import {ComponentBinder, Child} from '../../core/component-binder.service';
import {setComponentProps, createBoundProperty, ComponentToProps} from '../../core/test-utils';

@Component({
Expand Down Expand Up @@ -72,7 +72,7 @@ describe('TabsComponent', () => {
fixture.componentRef.setInput('dataContextPath', '/');

defaultProps = {
tabs: createBoundProperty<{title: DynamicString; child: string}[]>([]),
tabs: createBoundProperty<{title: string; child: Child}[]>([]),
};
setComponentProps(fixture, defaultProps);
});
Expand All @@ -85,9 +85,9 @@ describe('TabsComponent', () => {
it('should render tabs and switch content', () => {
setComponentProps(fixture, {
...defaultProps,
tabs: createBoundProperty<{title: DynamicString; child: string}[]>([
{title: 'Tab 1', child: 'content-1'},
{title: 'Tab 2', child: 'content-2'},
tabs: createBoundProperty<{title: string; child: Child}[]>([
{title: 'Tab 1', child: {id: 'content-1', basePath: '/'}},
{title: 'Tab 2', child: {id: 'content-2', basePath: '/'}},
]),
});
fixture.detectChanges();
Expand All @@ -113,7 +113,7 @@ describe('TabsComponent', () => {
it('should handle empty tabs array', () => {
setComponentProps(fixture, {
...defaultProps,
tabs: createBoundProperty<{title: DynamicString; child: string}[]>([]),
tabs: createBoundProperty<{title: string; child: Child}[]>([]),
});
fixture.detectChanges();
expect(component.tabs()).toEqual([]);
Expand Down
7 changes: 1 addition & 6 deletions renderers/angular/src/v0_9/catalog/basic/tabs.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,7 @@ export class TabsComponent extends BasicCatalogComponent<typeof TabsApi> {
readonly activeTab = computed(() => this.tabs()[this.activeTabIndex()]);

protected readonly normalizedActiveTabChild = computed(() => {
const child = this.activeTab()?.child;
if (!child) return null;
if (typeof child === 'object' && child !== null && 'id' in child) {
return child as {id: string; basePath: string};
}
return {id: child as string, basePath: this.dataContextPath()};
return this.activeTab()?.child || null;
});

setActiveTab(index: number) {
Expand Down
6 changes: 4 additions & 2 deletions renderers/angular/src/v0_9/core/catalog_component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
*/

import {ComponentApi} from '@a2ui/web_core/v0_9';
import {Directive, input} from '@angular/core';
import {Directive, input, InputSignal} from '@angular/core';
import {ComponentApiToProps} from './types';
import {CatalogComponentInstance} from './catalog_component_instance';

Expand All @@ -33,7 +33,9 @@ export abstract class CatalogComponent<
/**
* Reactive properties resolved from the A2UI ComponentModel.
*/
readonly props = input<ComponentApiToProps<Api>>({} as ComponentApiToProps<Api>);
readonly props: InputSignal<ComponentApiToProps<Api>> = input<ComponentApiToProps<Api>>(
{} as ComponentApiToProps<Api>,
);
readonly surfaceId = input.required<string>();
readonly componentId = input.required<string>();
readonly dataContextPath = input<string>('/');
Expand Down
70 changes: 53 additions & 17 deletions renderers/angular/src/v0_9/core/component-binder.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,14 @@

import {TestBed} from '@angular/core/testing';
import {ComponentBinder} from './component-binder.service';
import {Catalog, ComponentContext, ComponentModel, SurfaceModel} from '@a2ui/web_core/v0_9';
import {
Catalog,
ComponentContext,
ComponentModel,
SurfaceModel,
CommonSchemas,
} from '@a2ui/web_core/v0_9';
import {z} from 'zod';

/**
* Helper to construct instances of SurfaceModel, ComponentModel, and ComponentContext.
Expand Down Expand Up @@ -73,7 +80,15 @@ describe('ComponentBinder', () => {
},
});

const bound = binder.bind(context);
const schema = z.object({
text: CommonSchemas.DynamicString,
count: CommonSchemas.DynamicNumber,
enabled: CommonSchemas.DynamicBoolean,
config: z.object({theme: z.string()}),
empty: z.any().nullable().optional(),
});

const bound = binder.bind(context, schema);

expect(bound['text'].value()).toBe('Hello World');
expect(bound['count'].value()).toBe(42);
Expand All @@ -91,7 +106,11 @@ describe('ComponentBinder', () => {
data: {'/data/text': 'initial-value'},
});

const bound = binder.bind(context);
const schema = z.object({
value: CommonSchemas.DynamicString,
});

const bound = binder.bind(context, schema);

expect(bound['value'].value()).toBe('initial-value');
expect(bound['value'].onUpdate).toBeDefined();
Expand All @@ -109,7 +128,11 @@ describe('ComponentBinder', () => {
},
});

const bound = binder.bind(context);
const schema = z.object({
text: CommonSchemas.DynamicString,
});

const bound = binder.bind(context, schema);

expect(bound['text'].value()).toBe('literal-text');
expect(bound['text'].onUpdate).toBeDefined();
Expand All @@ -122,19 +145,23 @@ describe('ComponentBinder', () => {

describe('single child bindings (child, trigger, content)', () => {
const testSingleChildKey = (key: 'child' | 'trigger' | 'content') => {
const schema = z.object({
[key]: CommonSchemas.ComponentId.optional().nullable(),
});

it(`should handle null/falsy value for ${key}`, () => {
const {context} = createComponentContext({
properties: {[key]: null},
});
const bound = binder.bind(context);
const bound = binder.bind(context, schema);
expect(bound[key].value()).toBeNull();
});

it(`should resolve string ID to Child object for ${key}`, () => {
const {context} = createComponentContext({
properties: {[key]: 'my-child-id'},
});
const bound = binder.bind(context);
const bound = binder.bind(context, schema);
expect(bound[key].value()).toEqual({
id: 'my-child-id',
basePath: '/',
Expand All @@ -149,7 +176,7 @@ describe('ComponentBinder', () => {
const {context} = createComponentContext({
properties: {[key]: existingChild},
});
const bound = binder.bind(context);
const bound = binder.bind(context, schema);
expect(bound[key].value()).toEqual(existingChild);
});
};
Expand All @@ -160,19 +187,24 @@ describe('ComponentBinder', () => {
});

describe('children list bindings', () => {
const schema = z.object({
children: CommonSchemas.ChildList,
anotherProp: z.string().optional(),
});

it('should handle null/falsy or non-array values by returning an empty array', () => {
const {context} = createComponentContext({
properties: {children: null},
});
const bound = binder.bind(context);
const bound = binder.bind(context, schema);
expect(bound['children'].value()).toEqual([]);
});

it('should bind a static array of string child IDs', () => {
const {context} = createComponentContext({
properties: {children: ['child-1', 'child-2']},
});
const bound = binder.bind(context);
const bound = binder.bind(context, schema);
expect(bound['children'].value()).toEqual([
{id: 'child-1', basePath: '/'},
{id: 'child-2', basePath: '/'},
Expand All @@ -185,7 +217,7 @@ describe('ComponentBinder', () => {
properties: {children: {path: '/dynamic/list'}},
data: {'/dynamic/list': ['child-a', 'child-b']},
});
const bound = binder.bind(context);
const bound = binder.bind(context, schema);
expect(bound['children'].value()).toEqual([
{id: 'child-a', basePath: '/'},
{id: 'child-b', basePath: '/'},
Expand All @@ -203,7 +235,7 @@ describe('ComponentBinder', () => {
],
},
});
const bound = binder.bind(context);
const bound = binder.bind(context, schema);
expect(bound['children'].value()).toEqual([
{id: 'child-x', basePath: '/custom/x'},
{id: 'child-y', basePath: '/custom/y'},
Expand All @@ -215,7 +247,7 @@ describe('ComponentBinder', () => {
properties: {children: {componentId: 'item-card', path: '/items'}},
data: {'/items': ['item1', 'item2', 'item3']},
});
const bound = binder.bind(context);
const bound = binder.bind(context, schema);
expect(bound['children'].value()).toEqual([
{id: 'item-card', basePath: '/items/0'},
{id: 'item-card', basePath: '/items/1'},
Expand All @@ -236,7 +268,7 @@ describe('ComponentBinder', () => {
},
data: {'/items': ['item1']},
});
const bound = binder.bind(context);
const bound = binder.bind(context, schema);

expect(bound['children'].template).toEqual({
id: 'item-card',
Expand All @@ -247,11 +279,15 @@ describe('ComponentBinder', () => {
});

describe('validation checks (checks)', () => {
const schema = z.object({
checks: CommonSchemas.Checkable.shape.checks,
});

it('should return isValid=true and empty errors when checks is null/empty', () => {
const {context} = createComponentContext({
properties: {checks: []},
});
const bound = binder.bind(context);
const bound = binder.bind(context, schema);

expect(bound['isValid']).toBeDefined();
expect(bound['validationErrors']).toBeDefined();
Expand All @@ -274,7 +310,7 @@ describe('ComponentBinder', () => {
},
});

const bound = binder.bind(context);
const bound = binder.bind(context, schema);

// Since one rule resolves to false in the DataModel:
expect(bound['isValid'].value()).toBe(false);
Expand All @@ -296,7 +332,7 @@ describe('ComponentBinder', () => {
data: {'/form/singleCheck': false},
});

const bound = binder.bind(context);
const bound = binder.bind(context, schema);

expect(bound['isValid'].value()).toBe(false);
expect(bound['validationErrors'].value()).toEqual(['Validation failed']); // default message
Expand All @@ -322,7 +358,7 @@ describe('ComponentBinder', () => {
},
});

const bound = binder.bind(context);
const bound = binder.bind(context, schema);

expect(bound['isValid'].value()).toBe(false);
expect(bound['validationErrors'].value()).toEqual(['Error 2', 'Error 3']);
Expand Down
Loading
Loading