Skip to content

Commit 2964cbd

Browse files
authored
Port Angular Restaurant Sample to 0.9 (a2ui-project#1189)
Updated the restaurant sample app to use version 0.9 of the a2ui protocol. This change includes supporting the new streaming capabilities and making sure that the app renders correctly in different scenarios.
1 parent 212a50d commit 2964cbd

21 files changed

Lines changed: 336 additions & 272 deletions

renderers/angular/src/v0_9/catalog/basic/button.component.spec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import { ComponentFixture, TestBed } from '@angular/core/testing';
1818
import { Component, input, signal } from '@angular/core';
1919
import { ButtonComponent } from './button.component';
20+
import { ComponentModel } from '@a2ui/web_core/v0_9';
2021
import { A2uiRendererService } from '../../core/a2ui-renderer.service';
2122
import { ComponentBinder } from '../../core/component-binder.service';
2223
import { By } from '@angular/platform-browser';
@@ -32,7 +33,7 @@ describe('ButtonComponent', () => {
3233
mockSurface = {
3334
dispatchAction: jasmine.createSpy('dispatchAction'),
3435
componentsModel: new Map([
35-
['child1', { id: 'child1', type: 'Text', properties: { text: 'Child Content' } }],
36+
['child1', new ComponentModel('child1', 'Text', { text: 'Child Content' })],
3637
]),
3738
catalog: {
3839
id: 'test-catalog',

renderers/angular/src/v0_9/catalog/basic/column.component.spec.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import { ComponentFixture, TestBed } from '@angular/core/testing';
1818
import { Component, input, signal } from '@angular/core';
1919
import { ColumnComponent } from './column.component';
20+
import { ComponentModel } from '@a2ui/web_core/v0_9';
2021
import { A2uiRendererService } from '../../core/a2ui-renderer.service';
2122
import { ComponentBinder } from '../../core/component-binder.service';
2223
import { By } from '@angular/platform-browser';
@@ -44,9 +45,9 @@ describe('ColumnComponent', () => {
4445
beforeEach(async () => {
4546
mockSurface = {
4647
componentsModel: new Map([
47-
['child1', { id: 'child1', type: 'Child', properties: {} }],
48-
['child2', { id: 'child2', type: 'Child', properties: {} }],
49-
['template1', { id: 'template1', type: 'Child', properties: {} }],
48+
['child1', new ComponentModel('child1', 'Child', {})],
49+
['child2', new ComponentModel('child2', 'Child', {})],
50+
['template1', new ComponentModel('template1', 'Child', {})],
5051
]),
5152
catalog: {
5253
id: 'test-catalog',
@@ -94,10 +95,9 @@ describe('ColumnComponent', () => {
9495

9596
it('should apply flex styles from props', () => {
9697
fixture.detectChanges();
97-
const div = fixture.debugElement.query(By.css('.a2ui-column'));
98-
expect(div.styles['justify-content']).toBe('start');
99-
expect(div.styles['align-items']).toBe('stretch');
100-
expect(div.styles['gap']).toBe('4px');
98+
const style = window.getComputedStyle(fixture.debugElement.nativeElement);
99+
expect(style.justifyContent).toBe('flex-start');
100+
expect(style.alignItems).toBe('stretch');
101101
});
102102

103103
it('should render non-repeating children', () => {
@@ -161,7 +161,7 @@ describe('ColumnComponent', () => {
161161
},
162162
});
163163
fixture.detectChanges();
164-
const div = fixture.debugElement.query(By.css('.a2ui-column'));
164+
const div = fixture.debugElement;
165165
expect(div.styles['justify-content']).toBeFalsy();
166166
expect(div.styles['align-items']).toBeFalsy();
167167
});

renderers/angular/src/v0_9/catalog/basic/complex-components.spec.ts

Lines changed: 7 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { SliderComponent } from './slider.component';
2222
import { DateTimeInputComponent } from './date-time-input.component';
2323
import { ListComponent } from './list.component';
2424
import { TabsComponent } from './tabs.component';
25+
import { ComponentModel } from '@a2ui/web_core/v0_9';
2526
import { ModalComponent } from './modal.component';
2627
import { BoundProperty } from '../../core/types';
2728
import { A2uiRendererService } from '../../core/a2ui-renderer.service';
@@ -37,30 +38,12 @@ describe('Complex Components', () => {
3738
surfaceGroup: {
3839
getSurface: jasmine.createSpy('getSurface').and.returnValue({
3940
componentsModel: new Map([
40-
[
41-
'child-1',
42-
{ id: 'child-1', type: 'Text', properties: { text: { value: 'Child 1' } } },
43-
],
44-
[
45-
'child-2',
46-
{ id: 'child-2', type: 'Text', properties: { text: { value: 'Child 2' } } },
47-
],
48-
[
49-
'content-1',
50-
{ id: 'content-1', type: 'Text', properties: { text: { value: 'Content 1' } } },
51-
],
52-
[
53-
'content-2',
54-
{ id: 'content-2', type: 'Text', properties: { text: { value: 'Content 2' } } },
55-
],
56-
[
57-
'trigger-btn',
58-
{ id: 'trigger-btn', type: 'Text', properties: { text: { value: 'Open' } } },
59-
],
60-
[
61-
'modal-content',
62-
{ id: 'modal-content', type: 'Text', properties: { text: { value: 'Modal' } } },
63-
],
41+
['child-1', new ComponentModel('child-1', 'Text', { text: { value: 'Child 1' } })],
42+
['child-2', new ComponentModel('child-2', 'Text', { text: { value: 'Child 2' } })],
43+
['content-1', new ComponentModel('content-1', 'Text', { text: { value: 'Content 1' } })],
44+
['content-2', new ComponentModel('content-2', 'Text', { text: { value: 'Content 2' } })],
45+
['trigger-btn', new ComponentModel('trigger-btn', 'Text', { text: { value: 'Open' } })],
46+
['modal-content', new ComponentModel('modal-content', 'Text', { text: { value: 'Modal' } })],
6447
]),
6548
catalog: {
6649
id: 'mock-catalog',

renderers/angular/src/v0_9/catalog/basic/list.component.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { Component, input, computed, ChangeDetectionStrategy } from '@angular/co
1818
import { ComponentHostComponent } from '../../core/component-host.component';
1919
import { BoundProperty } from '../../core/types';
2020
import { BasicCatalogComponent } from './basic-catalog-component';
21+
import { Child } from '../../core/component-binder.service';
2122

2223
/**
2324
* Angular implementation of the A2UI List component (v0.9).
@@ -37,7 +38,7 @@ import { BasicCatalogComponent } from './basic-catalog-component';
3738
@switch (listTag()) {
3839
@case ('ol') {
3940
<ol [class]="'a2ui-list ' + orientation()" [style.list-style-type]="styleType()">
40-
@for (child of children(); track child.id) {
41+
@for (child of children(); track trackBy($index, child)) {
4142
<li>
4243
<a2ui-v09-component-host
4344
[componentKey]="child"
@@ -50,7 +51,7 @@ import { BasicCatalogComponent } from './basic-catalog-component';
5051
}
5152
@case ('ul') {
5253
<ul [class]="'a2ui-list ' + orientation()" [style.list-style-type]="styleType()">
53-
@for (child of children(); track child.id) {
54+
@for (child of children(); track trackBy($index, child)) {
5455
<li>
5556
<a2ui-v09-component-host
5657
[componentKey]="child"
@@ -63,7 +64,7 @@ import { BasicCatalogComponent } from './basic-catalog-component';
6364
}
6465
@default {
6566
<div [class]="'a2ui-list ' + orientation()" style="list-style-type: none;">
66-
@for (child of children(); track child.id) {
67+
@for (child of children(); track trackBy($index, child)) {
6768
<div class="a2ui-list-item-none">
6869
<a2ui-v09-component-host
6970
[componentKey]="child"
@@ -123,8 +124,6 @@ export class ListComponent extends BasicCatalogComponent {
123124
return Array.isArray(raw) ? raw : [];
124125
});
125126

126-
127-
128127
listTag = computed(() => {
129128
const style = this.listStyle();
130129
if (style === 'ordered') return 'ol';
@@ -137,4 +136,10 @@ export class ListComponent extends BasicCatalogComponent {
137136
if (style === 'none') return 'none';
138137
return '';
139138
});
139+
140+
/**
141+
* Track-by function to ensure stable change detection for list items.
142+
* Uses the full resolved path (`basePath/id`) to uniquely identify items.
143+
*/
144+
readonly trackBy = (index: number, item: Child) => `${item.basePath}/${item.id}`;
140145
}

renderers/angular/src/v0_9/catalog/basic/row.component.spec.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import { ComponentFixture, TestBed } from '@angular/core/testing';
1818
import { Component, input, signal } from '@angular/core';
1919
import { RowComponent } from './row.component';
20+
import { ComponentModel } from '@a2ui/web_core/v0_9';
2021
import { A2uiRendererService } from '../../core/a2ui-renderer.service';
2122
import { ComponentBinder } from '../../core/component-binder.service';
2223
import { By } from '@angular/platform-browser';
@@ -44,9 +45,9 @@ describe('RowComponent', () => {
4445
beforeEach(async () => {
4546
mockSurface = {
4647
componentsModel: new Map([
47-
['child1', { id: 'child1', type: 'Child', properties: {} }],
48-
['child2', { id: 'child2', type: 'Child', properties: {} }],
49-
['template1', { id: 'template1', type: 'Child', properties: {} }],
48+
['child1', new ComponentModel('child1', 'Child', {})],
49+
['child2', new ComponentModel('child2', 'Child', {})],
50+
['template1', new ComponentModel('template1', 'Child', {})],
5051
]),
5152
catalog: {
5253
id: 'test-catalog',
@@ -94,10 +95,9 @@ describe('RowComponent', () => {
9495

9596
it('should apply flex styles from props', () => {
9697
fixture.detectChanges();
97-
const div = fixture.debugElement.query(By.css('.a2ui-row'));
98-
expect(div.styles['justify-content']).toBe('center');
99-
expect(div.styles['align-items']).toBe('baseline');
100-
expect(div.styles['gap']).toBe('4px');
98+
const style = window.getComputedStyle(fixture.debugElement.nativeElement);
99+
expect(style.justifyContent).toBe('center');
100+
expect(style.alignItems).toBe('baseline');
101101
});
102102

103103
it('should render non-repeating children', () => {
@@ -161,7 +161,7 @@ describe('RowComponent', () => {
161161
},
162162
});
163163
fixture.detectChanges();
164-
const div = fixture.debugElement.query(By.css('.a2ui-row'));
164+
const div = fixture.debugElement;
165165
expect(div.styles['justify-content']).toBeFalsy();
166166
expect(div.styles['align-items']).toBeFalsy();
167167
});

renderers/angular/src/v0_9/catalog/basic/simple-components.spec.ts

Lines changed: 7 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { ImageComponent } from './image.component';
2222
import { IconComponent } from './icon.component';
2323
import { VideoComponent } from './video.component';
2424
import { AudioPlayerComponent } from './audio-player.component';
25+
import { ComponentModel } from '@a2ui/web_core/v0_9';
2526
import { CardComponent } from './card.component';
2627
import { BoundProperty } from '../../core/types';
2728
import { A2uiRendererService } from '../../core/a2ui-renderer.service';
@@ -36,30 +37,12 @@ describe('Simple Components', () => {
3637
surfaceGroup: {
3738
getSurface: jasmine.createSpy('getSurface').and.returnValue({
3839
componentsModel: new Map([
39-
[
40-
'child-1',
41-
{ id: 'child-1', type: 'Text', properties: { text: { value: 'Child 1' } } },
42-
],
43-
[
44-
'child-2',
45-
{ id: 'child-2', type: 'Text', properties: { text: { value: 'Child 2' } } },
46-
],
47-
[
48-
'content-1',
49-
{ id: 'content-1', type: 'Text', properties: { text: { value: 'Content 1' } } },
50-
],
51-
[
52-
'content-2',
53-
{ id: 'content-2', type: 'Text', properties: { text: { value: 'Content 2' } } },
54-
],
55-
[
56-
'trigger-btn',
57-
{ id: 'trigger-btn', type: 'Text', properties: { text: { value: 'Open' } } },
58-
],
59-
[
60-
'modal-content',
61-
{ id: 'modal-content', type: 'Text', properties: { text: { value: 'Modal' } } },
62-
],
40+
['child-1', new ComponentModel('child-1', 'Text', { text: { value: 'Child 1' } })],
41+
['child-2', new ComponentModel('child-2', 'Text', { text: { value: 'Child 2' } })],
42+
['content-1', new ComponentModel('content-1', 'Text', { text: { value: 'Content 1' } })],
43+
['content-2', new ComponentModel('content-2', 'Text', { text: { value: 'Content 2' } })],
44+
['trigger-btn', new ComponentModel('trigger-btn', 'Text', { text: { value: 'Open' } })],
45+
['modal-content', new ComponentModel('modal-content', 'Text', { text: { value: 'Modal' } })],
6346
]),
6447
catalog: {
6548
id: 'mock-catalog',

renderers/angular/src/v0_9/core/component-binder.service.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ import { ComponentContext, computed } from '@a2ui/web_core/v0_9';
1919
import { toAngularSignal } from './utils';
2020
import { BoundProperty } from './types';
2121

22+
/** Represents a reference to a child component. */
23+
export interface Child {
24+
id: string;
25+
basePath: string;
26+
}
27+
2228
/**
2329
* Binds A2UI ComponentModel properties to reactive Angular Signals.
2430
*
@@ -46,11 +52,11 @@ export class ComponentBinder {
4652

4753
for (const key of Object.keys(props)) {
4854
const value = props[key];
49-
55+
5056
let preactSig;
5157
const isChildListTemplate = value && typeof value === 'object' && 'componentId' in value && 'path' in value;
5258
const isBoundPath = value && typeof value === 'object' && 'path' in value && !('componentId' in value);
53-
59+
5460
if (isChildListTemplate) {
5561
const listSig = context.dataContext.resolveSignal({ path: value.path });
5662
const listContext = context.dataContext.nested(value.path);
@@ -102,7 +108,7 @@ export class ComponentBinder {
102108

103109
if (key === 'checks') {
104110
const checksArray = Array.isArray(value) ? value : [];
105-
111+
106112
const ruleResults = checksArray.map((rule: any) => {
107113
const condition = rule.condition || rule;
108114
const message = rule.message || 'Validation failed';

0 commit comments

Comments
 (0)