diff --git a/.github/workflows/check_license.yml b/.github/workflows/check_license.yml index efd701a3c..2ae3dabb4 100644 --- a/.github/workflows/check_license.yml +++ b/.github/workflows/check_license.yml @@ -38,7 +38,12 @@ jobs: - name: Check license headers run: | - addlicense -check \ + if ! addlicense -check \ -l apache \ -c "Google LLC" \ - . + .; then + echo "License check failed. To fix this, install addlicense and run it:" + echo " go install github.com/google/addlicense@latest" + echo " addlicense -l apache -c \"Google LLC\" ." + exit 1 + fi diff --git a/.gitignore b/.gitignore index 30d7cac9a..928ba0687 100644 --- a/.gitignore +++ b/.gitignore @@ -22,5 +22,5 @@ coverage/ a2a_agents/python/a2ui_agent/src/a2ui/assets/**/*.json ## new agent SDK path agent_sdks/python/src/a2ui/assets/**/*.json -## Generated JS file from the strictly-typed `sandbox.ts`. +## Generated JS file from the strictly-typed `sandbox.ts`. samples/client/angular/projects/orchestrator/public/sandbox_iframe/sandbox.js diff --git a/docs/guides/client-setup.md b/docs/guides/client-setup.md index 4a006ea52..5f019e9f5 100644 --- a/docs/guides/client-setup.md +++ b/docs/guides/client-setup.md @@ -54,18 +54,44 @@ TODO: Add verified setup example. > coming days. ```bash -npm install @a2ui/angular @a2ui/web-lib +npm install @a2ui/angular @a2ui/web_core ``` The Angular renderer provides: -- **`provideA2UI()` function**: Configures A2UI in your app config -- **`Surface` component**: Renders A2UI surfaces -- **`MessageProcessor` service**: Handles incoming A2UI messages - -TODO: Add verified setup example. +- **`A2uiRendererService`**: A service that manages the A2UI message processor and reactive model. +- **`a2ui-v09-component-host` component**: A dynamic component host that renders A2UI components from a surface. +- **`A2UI_RENDERER_CONFIG` token**: Used to configure the renderer with catalogs and action handlers. + +### Setup Example (v0.9) + +A2UI uses versioned imports for its protocol-specific implementations. For v0.9, configure your application providers as follows: + +```typescript +import { ApplicationConfig } from '@angular/core'; +import { + A2UI_RENDERER_CONFIG, + A2uiRendererService, + minimalCatalog +} from '@a2ui/angular/v0_9'; + +export const appConfig: ApplicationConfig = { + providers: [ + { + provide: A2UI_RENDERER_CONFIG, + useValue: { + catalogs: [minimalCatalog], + actionHandler: (action) => { + console.log('Action dispatched:', action); + } + } + }, + A2uiRendererService + ] +}; +``` -**See working example:** [Angular restaurant sample](https://github.com/google/a2ui/tree/main/samples/client/angular/projects/restaurant) +**See working example:** [Angular v0.9 Explorer](https://github.com/google/a2ui/tree/main/renderers/angular/a2ui_explorer) ## React diff --git a/docs/roadmap.md b/docs/roadmap.md index 2656b78ae..4edafb517 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -9,7 +9,7 @@ This roadmap outlines the current state and future plans for the A2UI project. T | Version | Status | Notes | |---------|--------|-------| | **v0.8** | ✅ Stable | Initial public release | -| **v0.9** | 🚧 In Progress | Draft specification improvements | +| **v0.9** | 🚧 Draft | Prompt-First specification improvements | Key features: @@ -225,6 +225,6 @@ Want to contribute to the roadmap? --- -**Last Updated:** December 2025 +**Last Updated:** March 2026 Have questions about the roadmap? [Start a discussion on GitHub](https://github.com/google/A2UI/discussions). diff --git a/renderers/angular/.npmignore b/renderers/angular/.npmignore new file mode 100644 index 000000000..9736b564e --- /dev/null +++ b/renderers/angular/.npmignore @@ -0,0 +1 @@ +a2ui_explorer/ diff --git a/renderers/angular/README.md b/renderers/angular/README.md index 8afc14b19..d9779d15f 100644 --- a/renderers/angular/README.md +++ b/renderers/angular/README.md @@ -1,9 +1,84 @@ -Angular implementation of A2UI. +# A2UI Angular Renderer -Important: The sample code provided is for demonstration purposes and illustrates the mechanics of A2UI and the Agent-to-Agent (A2A) protocol. When building production applications, it is critical to treat any agent operating outside of your direct control as a potentially untrusted entity. +The Angular implementation of the A2UI framework, providing seamless integration of agent-generated UI into Angular applications. + +## Getting Started + +### Installation + +```bash +npm install @a2ui/angular @a2ui/web_core +``` + +### Protocol Versioning + +A2UI supports multiple protocol versions. To use a specific version, use the versioned import path: + +```typescript +// Use the v0.9 implementation +import { A2uiRendererService, A2UI_RENDERER_CONFIG } from '@a2ui/angular/v0_9'; +import { minimalCatalog } from '@a2ui/angular/v0_9'; +``` + +## Basic Setup + +Configure the renderer in your `app.config.ts` using the `A2UI_RENDERER_CONFIG` injection token: + +```typescript +import { ApplicationConfig } from '@angular/core'; +import { A2UI_RENDERER_CONFIG, A2uiRendererService, minimalCatalog } from '@a2ui/angular/v0_9'; + +export const appConfig: ApplicationConfig = { + providers: [ + { + provide: A2UI_RENDERER_CONFIG, + useValue: { + catalogs: [minimalCatalog], + actionHandler: (action) => { + console.log('Action received:', action); + } + } + }, + A2uiRendererService + ] +}; +``` + +## Rendering Surfaces + +Use the `a2ui-v09-component-host` component to render individual A2UI components within your application: + +```typescript +import { Component } from '@angular/core'; +import { ComponentHostComponent } from '@a2ui/angular/v0_9'; + +@Component({ + selector: 'app-root', + standalone: true, + imports: [ComponentHostComponent], + template: ` + + ` +}) +export class AppComponent {} +``` + +## Core Concepts + +- **A2uiRendererService**: The central service that manages the connection to the A2UI Message Processor and tracks surface state. +- **ComponentHostComponent**: A wrapper component that dynamically renders A2UI components based on the current surface model. +- **Catalogs**: Collections of Angular components mapped to A2UI component types. + +## Security + +> [!IMPORTANT] +> The sample code provided is for demonstration purposes and illustrates the mechanics of A2UI and the Agent-to-Agent (A2A) protocol. When building production applications, it is critical to treat any agent operating outside of your direct control as a potentially untrusted entity. All operational data received from an external agent—including its AgentCard, messages, artifacts, and task statuses—should be handled as untrusted input. For example, a malicious agent could provide crafted data in its fields (e.g., name, skills.description) that, if used without sanitization to construct prompts for a Large Language Model (LLM), could expose your application to prompt injection attacks. Similarly, any UI definition or data stream received must be treated as untrusted. Malicious agents could attempt to spoof legitimate interfaces to deceive users (phishing), inject malicious scripts via property values (XSS), or generate excessive layout complexity to degrade client performance (DoS). If your application supports optional embedded content (such as iframes or web views), additional care must be taken to prevent exposure to malicious external sites. -Developer Responsibility: Failure to properly validate data and strictly sandbox rendered content can introduce severe vulnerabilities. Developers are responsible for implementing appropriate security measures—such as input sanitization, Content Security Policies (CSP), strict isolation for optional embedded content, and secure credential handling—to protect their systems and users. \ No newline at end of file +**Developer Responsibility**: Failure to properly validate data and strictly sandbox rendered content can introduce severe vulnerabilities. Developers are responsible for implementing appropriate security measures—such as input sanitization, Content Security Policies (CSP), strict isolation for optional embedded content, and secure credential handling—to protect their systems and users. diff --git a/renderers/angular/a2ui_explorer/public/favicon.ico b/renderers/angular/a2ui_explorer/public/favicon.ico new file mode 100644 index 000000000..57614f9c9 Binary files /dev/null and b/renderers/angular/a2ui_explorer/public/favicon.ico differ diff --git a/renderers/angular/a2ui_explorer/src/app/action-dispatcher.service.ts b/renderers/angular/a2ui_explorer/src/app/action-dispatcher.service.ts new file mode 100644 index 000000000..4a70e99a2 --- /dev/null +++ b/renderers/angular/a2ui_explorer/src/app/action-dispatcher.service.ts @@ -0,0 +1,29 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Injectable } from '@angular/core'; +import { Subject } from 'rxjs'; +import { SurfaceGroupAction } from '@a2ui/web_core/v0_9'; + +@Injectable({ providedIn: 'root' }) +export class ActionDispatcher { + private action$ = new Subject(); + actions = this.action$.asObservable(); + + dispatch(action: SurfaceGroupAction) { + this.action$.next(action); + } +} diff --git a/renderers/angular/a2ui_explorer/src/app/agent-stub.service.ts b/renderers/angular/a2ui_explorer/src/app/agent-stub.service.ts new file mode 100644 index 000000000..8ce9d764d --- /dev/null +++ b/renderers/angular/a2ui_explorer/src/app/agent-stub.service.ts @@ -0,0 +1,126 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Injectable } from '@angular/core'; +import { A2uiRendererService } from '@a2ui/angular/v0_9'; +import { SurfaceGroupAction, A2uiMessage } from '@a2ui/web_core/v0_9'; +import { ActionDispatcher } from './action-dispatcher.service'; + +/** + * Context for the 'update_property' event. + */ +interface UpdatePropertyContext { + path: string; + value: any; + surfaceId?: string; +} + +/** + * Context for the 'submit_form' event. + */ +interface SubmitFormContext { + [key: string]: any; + name?: string; +} + +/** + * A stub service that simulates an A2UI agent. + * It listens for actions and responds with data model updates or new surfaces. + */ +@Injectable({ + providedIn: 'root', +}) +export class AgentStubService { + /** Log of actions received from the surface. */ + actionsLog: Array<{ timestamp: Date; action: SurfaceGroupAction }> = []; + + constructor( + private rendererService: A2uiRendererService, + private dispatcher: ActionDispatcher, + ) { + // Subscribe to actions dispatched by the renderer + this.dispatcher.actions.subscribe((action) => this.handleAction(action)); + } + + /** + * Pushes actions triggered from the rendered Canvas frame through simulation. + * - Logs actions into inspector event frame aggregates. + * - Emulates generic server-side evaluation triggers delaying deferred updates. + * - Dispatch subsequent node-tree node triggers back over `A2uiRendererService`. + */ + handleAction(action: SurfaceGroupAction) { + console.log('[AgentStub] handleAction action:', action); + this.actionsLog.push({ timestamp: new Date(), action }); + + // Simulate server processing delay + setTimeout(() => { + if ('event' in action) { + const { name, context } = action.event; + if (name === 'update_property' && context) { + const { path, value, surfaceId } = context as unknown as UpdatePropertyContext; + console.log( + '[AgentStub] update_property path:', + path, + 'value:', + value, + 'surfaceId:', + surfaceId, + ); + this.rendererService.processMessages([ + { + version: 'v0.9', + updateDataModel: { + surfaceId: surfaceId || action.surfaceId, + path: path, + value: value, + }, + }, + ]); + } else if (name === 'submit_form' && context) { + const formData = context as unknown as SubmitFormContext; + const nameValue = formData.name || 'Anonymous'; + + // Respond with an update to the data model in v0.9 layout + this.rendererService.processMessages([ + { + version: 'v0.9', + updateDataModel: { + surfaceId: action.surfaceId, + path: '/form/submitted', + value: true, + }, + }, + { + version: 'v0.9', + updateDataModel: { + surfaceId: action.surfaceId, + path: '/form/responseMessage', + value: `Hello, ${nameValue}! Your form has been processed.`, + }, + }, + ]); + } + } + }, 50); // Shorter delay for property updates + } + + /** + * Initializes a demo session with an initial set of messages. + */ + initializeDemo(initialMessages: A2uiMessage[]) { + this.rendererService.processMessages(initialMessages); + } +} diff --git a/renderers/angular/a2ui_explorer/src/app/app.config.ts b/renderers/angular/a2ui_explorer/src/app/app.config.ts new file mode 100644 index 000000000..5ed1b0963 --- /dev/null +++ b/renderers/angular/a2ui_explorer/src/app/app.config.ts @@ -0,0 +1,21 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; + +export const appConfig: ApplicationConfig = { + providers: [provideBrowserGlobalErrorListeners()], +}; diff --git a/renderers/angular/a2ui_explorer/src/app/app.spec.ts b/renderers/angular/a2ui_explorer/src/app/app.spec.ts new file mode 100644 index 000000000..e905d1ab9 --- /dev/null +++ b/renderers/angular/a2ui_explorer/src/app/app.spec.ts @@ -0,0 +1,45 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { App } from './app'; + +describe('App', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [App], + }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(App); + const app = fixture.componentInstance; + expect(app).toBeTruthy(); + + fixture.detectChanges(); // Trigger ngOnInit + + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('.canvas-frame')).toBeTruthy(); + }); + + it('should render title', () => { + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('h3')?.textContent).toContain('A2UI Examples'); + }); +}); diff --git a/renderers/angular/a2ui_explorer/src/app/app.ts b/renderers/angular/a2ui_explorer/src/app/app.ts new file mode 100644 index 000000000..23665e10d --- /dev/null +++ b/renderers/angular/a2ui_explorer/src/app/app.ts @@ -0,0 +1,31 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component } from '@angular/core'; +import { DemoComponent } from './demo.component'; + +/** + * Root Component of the A2UI Angular Demo app. + * + * This component acts as a direct container that embeds the `` dashboard. + * All dynamic canvas layout and agent rendering behavior is handled inside `DemoComponent`. + */ +@Component({ + selector: 'app-root', + imports: [DemoComponent], + template: '', +}) +export class App {} diff --git a/renderers/angular/a2ui_explorer/src/app/card.component.ts b/renderers/angular/a2ui_explorer/src/app/card.component.ts new file mode 100644 index 000000000..d4089e5ee --- /dev/null +++ b/renderers/angular/a2ui_explorer/src/app/card.component.ts @@ -0,0 +1,47 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, Input, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ComponentHostComponent } from '@a2ui/angular/v0_9'; +import { BoundProperty } from '@a2ui/angular/v0_9'; + +/** + * A simple card component for the demo. + */ +@Component({ + selector: 'demo-card', + standalone: true, + imports: [CommonModule, ComponentHostComponent], + template: ` +
+ + +
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CardComponent { + @Input() props: Record = {}; + @Input() surfaceId!: string; +} diff --git a/renderers/angular/a2ui_explorer/src/app/custom-slider.component.ts b/renderers/angular/a2ui_explorer/src/app/custom-slider.component.ts new file mode 100644 index 000000000..bc8d6915f --- /dev/null +++ b/renderers/angular/a2ui_explorer/src/app/custom-slider.component.ts @@ -0,0 +1,62 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, Input, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { BoundProperty } from '@a2ui/angular/v0_9'; + +/** + * A custom component not part of any catalog, used to verify the renderer's + * ability to handle external component types. + */ +@Component({ + selector: 'a2ui-custom-slider', + standalone: true, + imports: [CommonModule], + template: ` +
+ + +
+ `, + styles: [ + ` + .custom-slider-container { + padding: 10px; + border: 1px dashed blue; + border-radius: 4px; + } + input { + width: 100%; + } + `, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CustomSliderComponent { + @Input() props: Record = {}; + + handleInput(event: Event) { + const val = Number((event.target as HTMLInputElement).value); + this.props['value']?.onUpdate(val); + } +} diff --git a/renderers/angular/a2ui_explorer/src/app/demo-catalog.ts b/renderers/angular/a2ui_explorer/src/app/demo-catalog.ts new file mode 100644 index 000000000..94cc88e55 --- /dev/null +++ b/renderers/angular/a2ui_explorer/src/app/demo-catalog.ts @@ -0,0 +1,61 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Injectable } from '@angular/core'; +import { z } from 'zod'; +import { BaseMinimalCatalog, MINIMAL_COMPONENTS, MINIMAL_FUNCTIONS } from '@a2ui/angular/v0_9'; +import { CustomSliderComponent } from './custom-slider.component'; +import { CardComponent } from './card.component'; +import { AngularComponentImplementation } from '@a2ui/angular/v0_9'; +import { BASIC_FUNCTIONS } from '@a2ui/web_core/v0_9/basic_catalog'; + +/** + * A catalog specific to the demo, extending the minimal catalog with custom components. + */ +@Injectable({ + providedIn: 'root', +}) +export class DemoCatalog extends BaseMinimalCatalog { + constructor() { + const customSliderApi: AngularComponentImplementation = { + name: 'CustomSlider', + schema: z.object({ + label: z.string().optional(), + value: z.number().optional(), + min: z.number().optional(), + max: z.number().optional(), + }) as any, + component: CustomSliderComponent, + }; + + const cardApi: AngularComponentImplementation = { + name: 'Card', + schema: z.object({ + child: z.string().optional(), + }) as any, + component: CardComponent, + }; + + const components = [...MINIMAL_COMPONENTS, customSliderApi, cardApi]; + const functions = [...BASIC_FUNCTIONS, ...MINIMAL_FUNCTIONS]; + + super( + 'https://a2ui.org/specification/v0_9/catalogs/minimal/minimal_catalog.json', + components, + functions, + ); + } +} diff --git a/renderers/angular/a2ui_explorer/src/app/demo.component.ts b/renderers/angular/a2ui_explorer/src/app/demo.component.ts new file mode 100644 index 000000000..bafa523fd --- /dev/null +++ b/renderers/angular/a2ui_explorer/src/app/demo.component.ts @@ -0,0 +1,441 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ChangeDetectorRef, Component, OnInit, inject, OnDestroy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { A2uiRendererService, A2UI_RENDERER_CONFIG } from '@a2ui/angular/v0_9'; +import { AgentStubService } from './agent-stub.service'; +import { ComponentHostComponent, SurfaceComponent } from '@a2ui/angular/v0_9'; +import { AngularCatalog } from '@a2ui/angular/v0_9'; +import { DemoCatalog } from './demo-catalog'; +import { SurfaceGroupAction, CreateSurfaceMessage } from '@a2ui/web_core/v0_9'; +import { EXAMPLES } from './examples-bundle'; +import { Example } from './types'; +import { ActionDispatcher } from './action-dispatcher.service'; + +/** + * Main dashboard component for A2UI v0.9 Angular Renderer. + * It provides a sidebar of examples, a canvas for rendering, + * and inspector tools for state auditing. + */ +@Component({ + selector: 'a2ui-v0-9-demo', + standalone: true, + imports: [CommonModule, ComponentHostComponent, SurfaceComponent], + template: ` + +
+ + + + +
+
+

{{ selectedExample.name }}

+

{{ selectedExample.description }}

+
+
+
+ + +
+
+ Select an example from the sidebar to view. +
+
+
+ + +
+
+
+

Data Model

+ Live +
+
+
{{ currentDataModel | json }}
+
No data model loaded.
+
+
+ +
+
+

Events Log

+ +
+
+
+
+ {{ ev.timestamp | date: 'HH:mm:ss.SSS' }} + {{ getActionType(ev.action) }} +
+
{{ ev.action | json }}
+
+
No events recorded.
+
+
+
+
+ `, + styles: [ + ` + /* styles omitted for brevity, keeping same */ + .dashboard { + display: flex; + height: 100vh; + font-family: 'Inter', system-ui, sans-serif; + background-color: #121212; + color: #e0e0e0; + overflow: hidden; + } + + /* Sidebar */ + .sidebar { + width: 260px; + background-color: #1e1e1e; + border-right: 1px solid #333; + display: flex; + flex-direction: column; + } + .sidebar-header { + padding: 16px; + border-bottom: 1px solid #333; + background-color: #1a1a1a; + } + .sidebar-header h3 { + margin: 0; + color: #4dabf7; + font-size: 1.1rem; + } + .example-list { + list-style: none; + padding: 0; + margin: 0; + flex: 1; + overflow-y: auto; + } + .example-list li { + padding: 12px 16px; + border-bottom: 1px solid #2a2a2a; + cursor: pointer; + transition: background-color 0.2s; + } + .example-list li:hover { + background-color: #2c2c2c; + } + .example-list li.active { + background-color: #334155; + border-left: 4px solid #3b82f6; + padding-left: 12px; + } + .ex-name { + font-weight: 500; + color: #f8fafc; + font-size: 0.95rem; + } + .ex-desc { + font-size: 0.75rem; + color: #94a3b8; + margin-top: 4px; + } + + /* Canvas Area */ + .canvas-area { + flex: 1; + display: flex; + flex-direction: column; + background-color: #0f172a; + overflow: hidden; + } + .canvas-header { + padding: 16px; + background-color: #1e293b; + border-bottom: 1px solid #334155; + } + .canvas-header h2 { + margin: 0; + font-size: 1.25rem; + color: #f8fafc; + } + .subtitle { + margin: 4px 0 0; + font-size: 0.85rem; + color: #94a3b8; + } + .canvas-frame { + flex: 1; + padding: 24px; + overflow-y: auto; + display: flex; + justify-content: center; + align-items: flex-start; + } + .rendered-content { + width: 100%; + max-width: 800px; + background-color: #ffffff; + color: #333; + border-radius: 8px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4); + padding: 24px; + } + .empty-canvas { + align-self: center; + margin: 0 auto; + color: #64748b; + font-style: italic; + } + + /* Inspect Panel */ + .inspect-area { + width: 380px; + background-color: #0f172a; + border-left: 1px solid #1e293b; + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + } + .inspect-section { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-height: 0; + } + .data-section { + border-bottom: 1px solid #1e293b; + height: 50%; + } + .events-section { + height: 50%; + } + + .section-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 16px; + background-color: #1e293b; + border-bottom: 1px solid #334155; + } + .section-header h4 { + margin: 0; + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #94a3b8; + } + .section-content { + flex: 1; + overflow-y: auto; + padding: 12px; + font-family: 'JetBrains Mono', 'Fira Code', monospace; + font-size: 0.75rem; + } + + .badge { + background-color: #064e3b; + color: #34d399; + font-size: 0.65rem; + padding: 2px 6px; + border-radius: 4px; + font-weight: 600; + text-transform: uppercase; + } + .clear-btn { + background: none; + border: 1px solid #334155; + color: #94a3b8; + font-size: 0.7rem; + padding: 2px 8px; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; + } + .clear-btn:hover { + background-color: #334155; + color: #f8fafc; + } + + pre { + margin: 0; + white-space: pre-wrap; + word-break: break-all; + color: #a7f3d0; + background-color: #0c111b; + padding: 12px; + border-radius: 4px; + border: 1px solid #1e293b; + line-height: 1.4; + } + .log-item { + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid #1e293b; + } + .log-header { + display: flex; + justify-content: space-between; + font-size: 0.7rem; + color: #64748b; + margin-bottom: 6px; + } + .log-time { + color: #3b82f6; + font-weight: 500; + } + .log-type { + padding: 1px 4px; + background-color: #064e3b; + color: #6ee7b7; + border-radius: 2px; + } + .log-details { + background-color: #020617; + border-color: #1e293b; + color: #94a3b8; + font-size: 0.7rem; + } + .empty-state { + text-align: center; + color: #475569; + margin-top: 40px; + font-style: italic; + } + `, + ], + providers: [ + A2uiRendererService, + { provide: AngularCatalog, useClass: DemoCatalog }, + ActionDispatcher, + AgentStubService, + { + provide: A2UI_RENDERER_CONFIG, + useFactory: (catalog: AngularCatalog, dispatcher: ActionDispatcher) => ({ + catalogs: [catalog], + actionHandler: (action: SurfaceGroupAction) => dispatcher.dispatch(action), + }), + deps: [AngularCatalog, ActionDispatcher], + }, + ], +}) +export class DemoComponent implements OnInit, OnDestroy { + private rendererService = inject(A2uiRendererService); + private agentStub = inject(AgentStubService); + private cdr = inject(ChangeDetectorRef); + + examples = EXAMPLES; + selectedExample: Example | undefined = undefined; + surfaceId: string | null = null; + inspectTab: 'data' | 'events' = 'data'; + + currentDataModel: Record = {}; + eventsLog: Array<{ timestamp: Date; action: SurfaceGroupAction }> = []; + + private actionSub?: { unsubscribe: () => void }; + private dataModelSub?: { unsubscribe: () => void }; + + ngOnInit(): void { + if (this.examples.length > 0) { + this.selectExample(this.examples[0]); + } + } + + /** + * Loads a selected example configuration into the dashboard canvas dashboard workspace. + * - Resets surface identifiers and data payloads triggers. + * - Re-initializes incremental playback state sequence into `AgentStubService`. + * - Subscribes to path `/` enabling live model inspection updates. + */ + selectExample(example: Example) { + this.selectedExample = example; + this.surfaceId = null; + this.currentDataModel = {}; + this.eventsLog = []; + this.cdr.detectChanges(); + + // Clean up previous subscriptions + if (this.dataModelSub) { + this.dataModelSub.unsubscribe(); + } + + this.agentStub.initializeDemo(example.messages); + + // Look for the surfaceId in the first message or use default + const createMsg = example.messages.find((m): m is CreateSurfaceMessage => 'createSurface' in m); + this.surfaceId = createMsg ? createMsg.createSurface.surfaceId : 'demo-surface'; + + this.cdr.detectChanges(); + + // Subscribe to DataModel updates + const surface = this.rendererService.surfaceGroup?.getSurface(this.surfaceId!); + if (surface) { + // Subscribe to root changes + this.dataModelSub = surface.dataModel.subscribe('/', (data) => { + this.currentDataModel = data as Record; + this.cdr.detectChanges(); + }); + // Set initial data model + this.currentDataModel = surface.dataModel.get('/'); + } + + // Subscribe to Actions for Events log + if (this.rendererService.surfaceGroup) { + if (this.actionSub) { + this.actionSub.unsubscribe(); + } + this.actionSub = this.rendererService.surfaceGroup.onAction.subscribe((action) => { + this.eventsLog.unshift({ timestamp: new Date(), action }); + this.cdr.detectChanges(); + }); + } + } + + /** Gets a display string for the action type. */ + getActionType(action: SurfaceGroupAction): string { + if ('event' in action) { + return action.event.name; + } + if ('functionCall' in action) { + return `Call: ${action.functionCall.call}`; + } + return 'Action'; + } + + ngOnDestroy(): void { + if (this.dataModelSub) { + this.dataModelSub.unsubscribe(); + } + if (this.actionSub) { + this.actionSub.unsubscribe(); + } + } +} diff --git a/renderers/angular/a2ui_explorer/src/app/examples-bundle.ts b/renderers/angular/a2ui_explorer/src/app/examples-bundle.ts new file mode 100644 index 000000000..ea2eac233 --- /dev/null +++ b/renderers/angular/a2ui_explorer/src/app/examples-bundle.ts @@ -0,0 +1,549 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Example } from './types'; + +export const EXAMPLES: Example[] = [ + { + name: 'Simple Text', + description: 'Basic text rendering', + messages: [ + { + version: 'v0.9', + createSurface: { + surfaceId: 'example_1', + catalogId: 'https://a2ui.org/specification/v0_9/catalogs/minimal/minimal_catalog.json', + }, + }, + { + version: 'v0.9', + updateComponents: { + surfaceId: 'example_1', + components: [ + { + id: 'root', + component: 'Text', + text: 'Hello, Minimal Catalog!', + variant: 'h1', + }, + ], + }, + }, + ], + }, + { + name: 'Row Layout', + description: 'Two components side-by-side', + messages: [ + { + version: 'v0.9', + createSurface: { + surfaceId: 'example_2', + catalogId: 'https://a2ui.org/specification/v0_9/catalogs/minimal/minimal_catalog.json', + }, + }, + { + version: 'v0.9', + updateComponents: { + surfaceId: 'example_2', + components: [ + { + id: 'root', + component: 'Row', + children: ['left_text', 'right_text'], + justify: 'spaceBetween', + align: 'center', + }, + { + id: 'left_text', + component: 'Text', + text: 'Left Content', + variant: 'body', + }, + { + id: 'right_text', + component: 'Text', + text: 'Right Content', + variant: 'caption', + }, + ], + }, + }, + ], + }, + { + name: 'Interactive Button', + description: 'Button with click event', + messages: [ + { + version: 'v0.9', + createSurface: { + surfaceId: 'example_3', + catalogId: 'https://a2ui.org/specification/v0_9/catalogs/minimal/minimal_catalog.json', + }, + }, + { + version: 'v0.9', + updateComponents: { + surfaceId: 'example_3', + components: [ + { + id: 'root', + component: 'Column', + children: ['title', 'action_button'], + justify: 'center', + align: 'center', + }, + { + id: 'title', + component: 'Text', + text: 'Click the button below', + variant: 'body', + }, + { + id: 'action_button', + component: 'Button', + child: 'button_label', + variant: 'primary', + action: { + event: { + name: 'button_clicked', + context: {}, + }, + }, + }, + { + id: 'button_label', + component: 'Text', + text: 'Click Me', + }, + ], + }, + }, + ], + }, + { + name: 'Login Form', + description: 'Form with input fields and action', + messages: [ + { + version: 'v0.9', + createSurface: { + surfaceId: 'example_4', + catalogId: 'https://a2ui.org/specification/v0_9/catalogs/minimal/minimal_catalog.json', + sendDataModel: true, + }, + }, + { + version: 'v0.9', + updateComponents: { + surfaceId: 'example_4', + components: [ + { + id: 'root', + component: 'Column', + children: ['form_title', 'username_field', 'password_field', 'submit_button'], + justify: 'start', + align: 'stretch', + }, + { + id: 'form_title', + component: 'Text', + text: 'Login', + variant: 'h2', + }, + { + id: 'username_field', + component: 'TextField', + label: 'Username', + value: { + path: '/username', + }, + variant: 'shortText', + }, + { + id: 'password_field', + component: 'TextField', + label: 'Password', + value: { + path: '/password', + }, + variant: 'obscured', + }, + { + id: 'submit_button', + component: 'Button', + child: 'submit_label', + variant: 'primary', + action: { + event: { + name: 'login_submitted', + context: { + user: { + path: '/username', + }, + pass: { + path: '/password', + }, + }, + }, + }, + }, + { + id: 'submit_label', + component: 'Text', + text: 'Sign In', + }, + ], + }, + }, + ], + }, + { + name: 'Complex Layout', + description: 'Nested rows and columns', + messages: [ + { + version: 'v0.9', + createSurface: { + surfaceId: 'example_5', + catalogId: 'https://a2ui.org/specification/v0_9/catalogs/minimal/minimal_catalog.json', + }, + }, + { + version: 'v0.9', + updateComponents: { + surfaceId: 'example_5', + components: [ + { + id: 'root', + component: 'Column', + children: ['header', 'form_row', 'footer'], + justify: 'spaceBetween', + align: 'stretch', + }, + { + id: 'header', + component: 'Text', + text: 'User Profile Form', + variant: 'h1', + }, + { + id: 'form_row', + component: 'Row', + children: ['first_name', 'last_name'], + justify: 'start', + align: 'start', + }, + { + id: 'first_name', + component: 'TextField', + label: 'First Name', + value: { + path: '/firstName', + }, + weight: 1, + }, + { + id: 'last_name', + component: 'TextField', + label: 'Last Name', + value: { + path: '/lastName', + }, + weight: 1, + }, + { + id: 'footer', + component: 'Text', + text: 'Please fill out all fields.', + variant: 'caption', + }, + ], + }, + }, + ], + }, + { + name: 'Capitalized Text', + description: 'Client-side function example', + messages: [ + { + version: 'v0.9', + createSurface: { + surfaceId: 'example_6', + catalogId: 'https://a2ui.org/specification/v0_9/catalogs/minimal/minimal_catalog.json', + sendDataModel: true, + }, + }, + { + version: 'v0.9', + updateComponents: { + surfaceId: 'example_6', + components: [ + { + id: 'root', + component: 'Column', + children: ['input_field', 'result_label', 'result_text', 'submit_button'], + justify: 'start', + align: 'stretch', + }, + { + id: 'input_field', + component: 'TextField', + label: 'Type something in lowercase:', + value: { + path: '/inputValue', + }, + variant: 'shortText', + }, + { + id: 'result_label', + component: 'Text', + text: 'Capitalized output:', + variant: 'caption', + }, + { + id: 'result_text', + component: 'Text', + text: { + call: 'capitalize', + args: { + value: { + path: '/inputValue', + }, + }, + returnType: 'string', + }, + variant: 'h2', + }, + { + id: 'submit_button', + component: 'Button', + child: 'submit_label', + variant: 'primary', + action: { + event: { + name: 'capitalized_submit', + context: { + value: { + call: 'capitalize', + args: { + value: { + path: '/inputValue', + }, + }, + }, + }, + }, + }, + }, + { + id: 'submit_label', + component: 'Text', + text: 'Submit', + }, + ], + }, + }, + ], + }, + { + name: 'Incremental List', + description: + 'Demonstrates progressive rendering of a list with templates and data model reactivity.', + messages: [ + { + version: 'v0.9', + createSurface: { + surfaceId: 'example_7', + catalogId: 'https://a2ui.org/specification/v0_9/catalogs/minimal/minimal_catalog.json', + }, + }, + { + version: 'v0.9', + updateDataModel: { + surfaceId: 'example_7', + path: '/', + value: { + restaurants: [ + { + title: 'The Golden Fork', + subtitle: 'Fine Dining & Spirits', + address: '123 Gastronomy Lane', + }, + { + title: "Ocean's Bounty", + subtitle: 'Fresh Daily Seafood', + address: '456 Shoreline Dr', + }, + { + title: 'Pizzeria Roma', + subtitle: 'Authentic Wood-Fired Pizza', + address: '789 Napoli Way', + }, + ], + }, + }, + }, + { + version: 'v0.9', + updateComponents: { + surfaceId: 'example_7', + components: [ + { + id: 'root', + component: 'Column', + children: { + path: '/restaurants', + componentId: 'restaurant_card', + }, + }, + ], + }, + }, + { + version: 'v0.9', + updateComponents: { + surfaceId: 'example_7', + components: [ + { + id: 'restaurant_card', + component: 'Column', + children: ['rc_title', 'rc_subtitle', 'rc_address'], + }, + { + id: 'rc_title', + component: 'Text', + text: { + path: 'title', + }, + }, + { + id: 'rc_subtitle', + component: 'Text', + text: { + path: 'subtitle', + }, + }, + { + id: 'rc_address', + component: 'Text', + text: { + path: 'address', + }, + }, + ], + }, + }, + { + version: 'v0.9', + updateDataModel: { + surfaceId: 'example_7', + path: '/restaurants/3', + value: { + title: 'Spice Route', + subtitle: 'Exotic Flavors from the East', + address: '101 Silk Road St', + }, + }, + }, + { + version: 'v0.9', + updateComponents: { + surfaceId: 'example_7', + components: [ + { + id: 'restaurant_card', + component: 'Column', + children: ['rc_title', 'rc_subtitle', 'rc_address', 'rc_button'], + }, + { + id: 'rc_button', + component: 'Button', + child: 'rc_button_label', + action: { + event: { + name: 'book_now', + context: { + restaurantName: { + path: 'title', + }, + }, + }, + }, + }, + { + id: 'rc_button_label', + component: 'Text', + text: 'Book now', + }, + ], + }, + }, + ], + }, + { + name: 'Custom Price Slider', + description: 'Interactive price selection using a custom slider component', + messages: [ + { + version: 'v0.9', + createSurface: { + surfaceId: 'example_8', + catalogId: 'https://a2ui.org/specification/v0_9/catalogs/minimal/minimal_catalog.json', + }, + }, + { + version: 'v0.9', + updateDataModel: { + surfaceId: 'example_8', + path: '/price', + value: 75, + }, + }, + { + version: 'v0.9', + updateComponents: { + surfaceId: 'example_8', + components: [ + { + id: 'root', + component: 'Column', + children: ['price_slider', 'price_field'], + }, + { + id: 'price_slider', + component: 'CustomSlider', + label: 'Dollars', + value: { path: '/price' }, + min: 0, + max: 200, + }, + { + id: 'price_field', + component: 'Text', + text: { + call: 'formatString', + args: { value: 'Price: $${/price}' }, + }, + }, + ], + }, + }, + ], + }, +]; diff --git a/renderers/angular/a2ui_explorer/src/app/kitchen-sink-surface.ts b/renderers/angular/a2ui_explorer/src/app/kitchen-sink-surface.ts new file mode 100644 index 000000000..1e137d033 --- /dev/null +++ b/renderers/angular/a2ui_explorer/src/app/kitchen-sink-surface.ts @@ -0,0 +1,151 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * A complex A2UI surface definition for the v0.9 demo. + */ +export const KITCHEN_SINK_SURFACE = [ + { + version: 'v0.9', + createSurface: { + surfaceId: 'demo-surface', + catalogId: 'demo', + }, + }, + { + version: 'v0.9', + updateDataModel: { + surfaceId: 'demo-surface', + path: '/', + value: { + user: { + name: 'Guest', + email: '', + }, + form: { + submitted: false, + responseMessage: '', + }, + settings: { + theme: 'light', + }, + }, + }, + }, + { + version: 'v0.9', + updateComponents: { + surfaceId: 'demo-surface', + components: [ + { + id: 'root', + component: 'Column', + align: 'start', + justify: 'start', + children: ['header', 'form-section', 'footer'], + }, + { + id: 'header', + component: 'Row', + children: ['logo', 'welcome-text'], + align: 'center', + }, + { + id: 'logo', + component: 'Text', + text: 'A2UI v0.9', + weight: 700, + }, + { + id: 'welcome-text', + component: 'Text', + text: { + call: 'formatString', + args: { + value: 'Welcome, {{/user/name}}!', + }, + }, + }, + { + id: 'form-section', + component: 'Card', + child: 'form-column', + }, + { + id: 'form-column', + component: 'Column', + children: ['name-field', 'email-field', 'submit-btn', 'result-msg'], + }, + { + id: 'name-field', + component: 'TextField', + label: 'Your Name', + value: { path: '/user/name' }, + }, + { + id: 'satisfaction-slider', + component: 'CustomSlider', + label: 'Satisfaction Level', + value: { path: '/user/satisfaction' }, + min: 0, + max: 10, + }, + { + id: 'email-field', + component: 'TextField', + label: 'Email Address', + value: { path: '/user/email' }, + variant: 'shortText', + }, + { + id: 'submit-btn', + component: 'Button', + child: 'submit-text', + variant: 'primary', + action: { + event: { + name: 'submit_form', + context: { + name: { path: '/user/name' }, + email: { path: '/user/email' }, + }, + }, + }, + }, + { + id: 'submit-text', + component: 'Text', + text: 'Submit', + }, + { + id: 'result-msg', + component: 'Text', + text: { path: '/form/responseMessage' }, + }, + { + id: 'footer', + component: 'Row', + children: ['copy-text'], + }, + { + id: 'copy-text', + component: 'Text', + text: 'Powered by web_core v0.9', + }, + ], + }, + }, +]; diff --git a/renderers/angular/a2ui_explorer/src/app/types.ts b/renderers/angular/a2ui_explorer/src/app/types.ts new file mode 100644 index 000000000..208d46691 --- /dev/null +++ b/renderers/angular/a2ui_explorer/src/app/types.ts @@ -0,0 +1,29 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { A2uiMessage } from '@a2ui/web_core/v0_9'; + +/** + * Represents a demo example configuration. + */ +export interface Example { + /** The name of the example, displayed in the sidebar. */ + name: string; + /** A short description of what the example demonstrates. */ + description: string; + /** The sequence of A2UI messages to send to the renderer. */ + messages: A2uiMessage[]; +} diff --git a/renderers/angular/a2ui_explorer/src/index.html b/renderers/angular/a2ui_explorer/src/index.html new file mode 100644 index 000000000..b21692da8 --- /dev/null +++ b/renderers/angular/a2ui_explorer/src/index.html @@ -0,0 +1,29 @@ + + + + + + + DemoApp + + + + + + + + diff --git a/renderers/angular/a2ui_explorer/src/main.ts b/renderers/angular/a2ui_explorer/src/main.ts new file mode 100644 index 000000000..94303b012 --- /dev/null +++ b/renderers/angular/a2ui_explorer/src/main.ts @@ -0,0 +1,21 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { App } from './app/app'; + +bootstrapApplication(App, appConfig).catch((err) => console.error(err)); diff --git a/renderers/angular/a2ui_explorer/src/styles.css b/renderers/angular/a2ui_explorer/src/styles.css new file mode 100644 index 000000000..3a438a46d --- /dev/null +++ b/renderers/angular/a2ui_explorer/src/styles.css @@ -0,0 +1,19 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +body { + margin: 0; +} diff --git a/renderers/angular/a2ui_explorer/tsconfig.app.json b/renderers/angular/a2ui_explorer/tsconfig.app.json new file mode 100644 index 000000000..3709811fa --- /dev/null +++ b/renderers/angular/a2ui_explorer/tsconfig.app.json @@ -0,0 +1,12 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "../out-tsc/app", + "types": [], + "moduleResolution": "bundler" + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts"] +} diff --git a/renderers/angular/a2ui_explorer/tsconfig.spec.json b/renderers/angular/a2ui_explorer/tsconfig.spec.json new file mode 100644 index 000000000..5f3f11f7d --- /dev/null +++ b/renderers/angular/a2ui_explorer/tsconfig.spec.json @@ -0,0 +1,12 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "../out-tsc/spec", + "composite": true, + "declaration": true, + "types": ["vitest/globals"] + }, + "include": ["src/**/*.d.ts", "src/**/*.spec.ts"] +} diff --git a/renderers/angular/angular.json b/renderers/angular/angular.json index 6fc268bef..8f1e3b360 100644 --- a/renderers/angular/angular.json +++ b/renderers/angular/angular.json @@ -27,6 +27,67 @@ } } } + }, + "a2ui_explorer": { + "projectType": "application", + "schematics": {}, + "root": "a2ui_explorer", + "sourceRoot": "a2ui_explorer/src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "browser": "a2ui_explorer/src/main.ts", + "tsConfig": "a2ui_explorer/tsconfig.app.json", + "assets": [ + { + "glob": "**/*", + "input": "a2ui_explorer/public" + } + ], + "styles": ["a2ui_explorer/src/styles.css"] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kB", + "maximumError": "1MB" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "4kB", + "maximumError": "8kB" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular/build:dev-server", + "configurations": { + "production": { + "buildTarget": "a2ui_explorer:build:production" + }, + "development": { + "buildTarget": "a2ui_explorer:build:development" + } + }, + "defaultConfiguration": "development" + }, + "test": { + "builder": "@angular/build:unit-test" + } + } } }, "cli": { diff --git a/renderers/angular/ng-package.json b/renderers/angular/ng-package.json index d08974fc0..37f030ef2 100644 --- a/renderers/angular/ng-package.json +++ b/renderers/angular/ng-package.json @@ -4,5 +4,5 @@ "lib": { "entryFile": "src/public-api.ts" }, - "allowedNonPeerDependencies": ["@a2ui/web_core"] + "allowedNonPeerDependencies": ["@a2ui/web_core", "zod"] } diff --git a/renderers/angular/package-lock.json b/renderers/angular/package-lock.json index de56501f8..bad1a0f43 100644 --- a/renderers/angular/package-lock.json +++ b/renderers/angular/package-lock.json @@ -10,7 +10,8 @@ "license": "Apache-2.0", "dependencies": { "@a2ui/web_core": "file:../web_core", - "tslib": "^2.3.0" + "tslib": "^2.3.0", + "zod": "^3.25.76" }, "devDependencies": { "@angular/build": "^21.2.0", @@ -57,6 +58,7 @@ }, "devDependencies": { "@types/node": "^24.11.0", + "c8": "^11.0.0", "typescript": "^5.8.3", "wireit": "^0.15.0-pre.2", "zod-to-json-schema": "^3.25.1" @@ -476,6 +478,15 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@angular/cli/node_modules/zod": { + "version": "4.3.6", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@angular/common": { "version": "21.2.1", "integrity": "sha512-xhv2i1Q9s1kpGbGsfj+o36+XUC/TQLcZyRuRxn3GwaN7Rv34FabC88ycpvoE+sW/txj4JRx9yPA0dRSZjwZ+Gg==", @@ -12165,9 +12176,8 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "dev": true, + "version": "3.25.76", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/renderers/angular/package.json b/renderers/angular/package.json index b99db8be9..5d57fd1a8 100644 --- a/renderers/angular/package.json +++ b/renderers/angular/package.json @@ -3,11 +3,15 @@ "version": "0.8.5", "license": "Apache-2.0", "scripts": { - "build": "ng build && node postprocess-build.mjs" + "build": "ng build && node postprocess-build.mjs", + "demo": "ng serve a2ui_explorer", + "test": "ng test", + "test:ci": "ng test --watch=false --browsers=ChromeHeadless" }, "dependencies": { "@a2ui/web_core": "file:../web_core", - "tslib": "^2.3.0" + "tslib": "^2.3.0", + "zod": "^3.25.76" }, "peerDependencies": { "@angular/common": "^21.2.0", @@ -55,4 +59,4 @@ } ] } -} +} \ No newline at end of file diff --git a/renderers/angular/postprocess-build.mjs b/renderers/angular/postprocess-build.mjs index 34cc75586..c495e4f9e 100644 --- a/renderers/angular/postprocess-build.mjs +++ b/renderers/angular/postprocess-build.mjs @@ -33,7 +33,7 @@ const packageJson = parsePackageJson(packageJsonPath); if (!packageJson.dependencies['@a2ui/web_core']) { throw new Error( 'Angular package does not depend on the Core library. ' + - 'Either update the package.json or remove this script.', + 'Either update the package.json or remove this script.', ); } diff --git a/renderers/angular/src/public-api.ts b/renderers/angular/src/public-api.ts index b5570054d..1426dd356 100644 --- a/renderers/angular/src/public-api.ts +++ b/renderers/angular/src/public-api.ts @@ -14,4 +14,5 @@ * limitations under the License. */ +export const A2UI_ANGULAR_VERSION = '0.9.0'; export * from './v0_8/public-api'; diff --git a/renderers/angular/src/v0_8/data/processor.ts b/renderers/angular/src/v0_8/data/processor.ts index 82ab05707..4a83955cf 100644 --- a/renderers/angular/src/v0_8/data/processor.ts +++ b/renderers/angular/src/v0_8/data/processor.ts @@ -14,11 +14,10 @@ * limitations under the License. */ -import { Injectable, inject } from '@angular/core'; +import { Injectable } from '@angular/core'; import { Subject, Observable } from 'rxjs'; import * as WebCore from '@a2ui/web_core/v0_8'; -import { Catalog } from '../rendering/catalog'; -import { Theme } from '../rendering/theming'; + import { Types } from '../types'; export interface A2UIClientEvent { @@ -32,7 +31,7 @@ export type DispatchedEvent = A2UIClientEvent; providedIn: 'root', }) export class MessageProcessor { - private readonly catalog = inject(Catalog); + private baseProcessor: WebCore.A2uiMessageProcessor; private readonly eventsSubject = new Subject(); diff --git a/renderers/angular/src/v0_8/rendering/renderer.ts b/renderers/angular/src/v0_8/rendering/renderer.ts index 47e0d970c..241e3f2e0 100644 --- a/renderers/angular/src/v0_8/rendering/renderer.ts +++ b/renderers/angular/src/v0_8/rendering/renderer.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Component, effect, inject, input, viewChild, ViewContainerRef, Type, Binding } from '@angular/core'; +import { Component, effect, inject, input, viewChild, ViewContainerRef, Type } from '@angular/core'; import { Catalog } from './catalog'; import { Types } from '../types'; @@ -57,7 +57,7 @@ export class Renderer { private async render(container: ViewContainerRef, node: Types.AnyComponentNode, config: any) { let componentType: Type | null = null; - let componentBindings: Binding[] | null = null; + if (typeof config === 'function') { const res = config(); @@ -70,27 +70,11 @@ export class Renderer { componentType = config.type; } - if (typeof config.bindings === 'function') { - componentBindings = config.bindings(node as any); - } + } if (componentType) { - // Evaluate custom bindings from CatalogEntry if they are present - const bindingsObject: Record = {}; - if (componentBindings) { - for (const binding of componentBindings) { - // binding is from inputBinding, which typically is { provide: ..., useFactory: ... } or similar in older angular - // Wait, inputBinding in old angular constructed a Binding array. - // Standard input binding from @angular/core could be mapped or used. - // However, if we just want to support older layouts without complex evaluation hookup here, - // we can evaluate bindings that are function/factory structure if possible, - // or we can fallback to just applying properties. - // Let's check how bindings were applied on main: - // `bindings` was array of standard angular bindings provider! - // Angular's createComponent takes array of bindings in options. - } - } + const componentRef = container.createComponent(componentType); componentRef.setInput('surfaceId', this.surfaceId()); diff --git a/renderers/angular/src/v0_9/catalog/minimal/button.component.spec.ts b/renderers/angular/src/v0_9/catalog/minimal/button.component.spec.ts new file mode 100644 index 000000000..9866f4e5b --- /dev/null +++ b/renderers/angular/src/v0_9/catalog/minimal/button.component.spec.ts @@ -0,0 +1,152 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Component, Input, signal } from '@angular/core'; +import { ButtonComponent } from './button.component'; +import { A2uiRendererService } from '../../core/a2ui-renderer.service'; +import { ComponentBinder } from '../../core/component-binder.service'; +import { By } from '@angular/platform-browser'; + +describe('ButtonComponent', () => { + let component: ButtonComponent; + let fixture: ComponentFixture; + let mockRendererService: any; + let mockSurface: any; + let mockSurfaceGroup: any; + + beforeEach(async () => { + mockSurface = { + dispatchAction: jasmine.createSpy('dispatchAction'), + componentsModel: new Map([ + ['child1', { id: 'child1', type: 'Text', properties: { text: 'Child Content' } }], + ]), + catalog: { + id: 'test-catalog', + components: new Map([ + [ + 'Text', + { + component: (() => { + @Component({ + standalone: true, + selector: 'dummy-text', + template: 'Dummy Text', + }) + class DummyText { + @Input() props: any; + @Input() surfaceId?: string; + @Input() dataContextPath?: string; + } + return DummyText; + })(), + }, + ], + ]), + }, + }; + + mockSurfaceGroup = { + getSurface: jasmine.createSpy('getSurface').and.returnValue(mockSurface), + }; + + mockRendererService = { + surfaceGroup: mockSurfaceGroup, + }; + + const mockBinder = jasmine.createSpyObj('ComponentBinder', ['bind']); + mockBinder.bind.and.returnValue({ text: { value: () => 'bound text' } }); + + await TestBed.configureTestingModule({ + imports: [ButtonComponent], + providers: [ + { provide: A2uiRendererService, useValue: mockRendererService }, + { provide: ComponentBinder, useValue: mockBinder }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ButtonComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('surfaceId', 'surf1'); + fixture.componentRef.setInput('props', { + variant: { value: signal('primary'), raw: 'primary', onUpdate: () => {} }, + child: { value: signal('child1'), raw: 'child1', onUpdate: () => {} }, + action: { + value: signal({ type: 'test-action', data: {} }), + raw: { type: 'test-action', data: {} }, + onUpdate: () => {}, + }, + }); + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('should set button type to submit for primary variant', () => { + fixture.detectChanges(); + const button = fixture.debugElement.query(By.css('button')); + expect(button.nativeElement.type).toBe('submit'); + }); + + it('should set button type to button for non-primary variant', () => { + fixture.componentRef.setInput('props', { + ...component.props(), + variant: { + value: signal('secondary'), + raw: 'secondary', + onUpdate: () => {}, + }, + }); + fixture.detectChanges(); + const button = fixture.debugElement.query(By.css('button')); + expect(button.nativeElement.type).toBe('button'); + }); + + it('should apply variant class', () => { + fixture.detectChanges(); + const button = fixture.debugElement.query(By.css('button')); + expect(button.nativeElement.classList).toContain('primary'); + }); + + it('should handle click and dispatch action', () => { + fixture.detectChanges(); + const button = fixture.debugElement.query(By.css('button')); + button.triggerEventHandler('click', null); + + expect(mockRendererService.surfaceGroup.getSurface).toHaveBeenCalledWith('surf1'); + expect(mockSurface.dispatchAction).toHaveBeenCalled(); + }); + + it('should show child component host if child prop is present', () => { + fixture.detectChanges(); + const host = fixture.debugElement.query(By.css('a2ui-v09-component-host')); + expect(host).toBeTruthy(); + // componentId is now a signal, so we access it via () + expect(host.componentInstance.componentId()).toBe('child1'); + }); + + it('should not show child component host if child prop is absent', () => { + fixture.componentRef.setInput('props', { + ...component.props(), + child: { value: signal(null), raw: null, onUpdate: () => {} }, + }); + fixture.detectChanges(); + const host = fixture.debugElement.query(By.css('a2ui-v09-component-host')); + expect(host).toBeFalsy(); + }); +}); diff --git a/renderers/angular/src/v0_9/catalog/minimal/button.component.ts b/renderers/angular/src/v0_9/catalog/minimal/button.component.ts new file mode 100644 index 000000000..2f92161b6 --- /dev/null +++ b/renderers/angular/src/v0_9/catalog/minimal/button.component.ts @@ -0,0 +1,96 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, ChangeDetectionStrategy, inject, input, computed } from '@angular/core'; +import { ComponentHostComponent } from '../../core/component-host.component'; +import { DataContext } from '@a2ui/web_core/v0_9'; +import { A2uiRendererService } from '../../core/a2ui-renderer.service'; +import { BoundProperty } from '../../core/types'; + +/** + * A standard button component providing primary, default, and borderless variants. + * + * It supports child A2UI components (e.g., icons or text) and dispatches actions + * when clicked. + */ +@Component({ + selector: 'a2ui-v09-button', + imports: [ComponentHostComponent], + template: ` + + `, + styles: [ + ` + .a2ui-button { + padding: 8px 16px; + border-radius: 4px; + border: 1px solid #ccc; + cursor: pointer; + } + .a2ui-button.primary { + background-color: #007bff; + color: white; + border-color: #0069d9; + } + .a2ui-button.borderless { + background: none; + border: none; + padding: 0; + color: #007bff; + } + `, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ButtonComponent { + /** + * Bound properties. + */ + props = input>({}); + surfaceId = input.required(); + dataContextPath = input('/'); + + private rendererService = inject(A2uiRendererService); + + variant = computed(() => this.props()['variant']?.value() ?? 'default'); + child = computed(() => this.props()['child']?.value()); + action = computed(() => this.props()['action']?.value()); + + handleClick() { + const action = this.action(); + if (action) { + const surface = this.rendererService.surfaceGroup?.getSurface(this.surfaceId()); + if (surface) { + const dataContext = new DataContext(surface, this.dataContextPath()); + const resolvedAction = dataContext.resolveAction(action); + surface.dispatchAction(resolvedAction); + } + } + } +} diff --git a/renderers/angular/src/v0_9/catalog/minimal/column.component.spec.ts b/renderers/angular/src/v0_9/catalog/minimal/column.component.spec.ts new file mode 100644 index 000000000..0c934bd54 --- /dev/null +++ b/renderers/angular/src/v0_9/catalog/minimal/column.component.spec.ts @@ -0,0 +1,131 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Component, Input } from '@angular/core'; +import { ColumnComponent } from './column.component'; +import { signal } from '@angular/core'; +import { A2uiRendererService } from '../../core/a2ui-renderer.service'; +import { ComponentBinder } from '../../core/component-binder.service'; +import { By } from '@angular/platform-browser'; + +@Component({ + standalone: true, + selector: 'dummy-child', + template: 'Dummy Child', +}) +class DummyChild { + @Input() props: any; + @Input() surfaceId?: string; + @Input() dataContextPath?: string; +} + +describe('ColumnComponent', () => { + let component: ColumnComponent; + let fixture: ComponentFixture; + let mockRendererService: any; + let mockSurface: any; + let mockSurfaceGroup: any; + let mockBinder: any; + + beforeEach(async () => { + mockSurface = { + componentsModel: new Map([ + ['child1', { id: 'child1', type: 'Child', properties: {} }], + ['child2', { id: 'child2', type: 'Child', properties: {} }], + ['template1', { id: 'template1', type: 'Child', properties: {} }], + ]), + catalog: { + id: 'test-catalog', + components: new Map([['Child', { component: DummyChild }]]), + }, + }; + + mockSurfaceGroup = { + getSurface: jasmine.createSpy('getSurface').and.returnValue(mockSurface), + }; + + mockRendererService = { + surfaceGroup: mockSurfaceGroup, + }; + + mockBinder = jasmine.createSpyObj('ComponentBinder', ['bind']); + mockBinder.bind.and.returnValue({ text: { value: () => 'bound' } }); + + await TestBed.configureTestingModule({ + imports: [ColumnComponent], + providers: [ + { provide: A2uiRendererService, useValue: mockRendererService }, + { provide: ComponentBinder, useValue: mockBinder }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ColumnComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('surfaceId', 'surf1'); + fixture.componentRef.setInput('props', { + justify: { value: signal('start'), raw: 'start', onUpdate: () => {} }, + align: { value: signal('stretch'), raw: 'stretch', onUpdate: () => {} }, + children: { + value: signal(['child1', 'child2']), + raw: ['child1', 'child2'], + onUpdate: () => {}, + }, + }); + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('should apply flex styles from props', () => { + fixture.detectChanges(); + const div = fixture.debugElement.query(By.css('.a2ui-column')); + expect(div.styles['justify-content']).toBe('start'); + expect(div.styles['align-items']).toBe('stretch'); + }); + + it('should render non-repeating children', () => { + fixture.detectChanges(); + const hosts = fixture.debugElement.queryAll(By.css('a2ui-v09-component-host')); + expect(hosts.length).toBe(2); + expect(hosts[0].componentInstance.componentId()).toBe('child1'); + expect(hosts[1].componentInstance.componentId()).toBe('child2'); + }); + + it('should render repeating children', () => { + fixture.componentRef.setInput('props', { + ...component.props(), + children: { + value: signal([{}, {}]), + raw: { + componentId: 'template1', + path: 'items', + }, + onUpdate: () => {}, + }, + }); + fixture.detectChanges(); + + const hosts = fixture.debugElement.queryAll(By.css('a2ui-v09-component-host')); + expect(hosts.length).toBe(2); + expect(hosts[0].componentInstance.componentId()).toBe('template1'); + expect(hosts[0].componentInstance.dataContextPath()).toBe('/items/0'); + expect(hosts[1].componentInstance.componentId()).toBe('template1'); + expect(hosts[1].componentInstance.dataContextPath()).toBe('/items/1'); + }); +}); diff --git a/renderers/angular/src/v0_9/catalog/minimal/column.component.ts b/renderers/angular/src/v0_9/catalog/minimal/column.component.ts new file mode 100644 index 000000000..6747d0507 --- /dev/null +++ b/renderers/angular/src/v0_9/catalog/minimal/column.component.ts @@ -0,0 +1,90 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, ChangeDetectionStrategy, computed, input } from '@angular/core'; +import { ComponentHostComponent } from '../../core/component-host.component'; +import { BoundProperty } from '../../core/types'; + +import { getNormalizedPath } from '../../core/utils'; + +/** + * A vertical layout container that aligns children in a flex column. + * + * It supports both static children and repeating templates bound to a data path. + */ +@Component({ + selector: 'a2ui-v09-column', + imports: [ComponentHostComponent], + template: ` +
+ @if (!isRepeating()) { + @for (childId of children(); track childId) { + + + } + } + + @if (isRepeating()) { + @for (item of children(); track $index) { + + + } + } +
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ColumnComponent { + /** + * Bound properties. + */ + props = input>({}); + surfaceId = input.required(); + dataContextPath = input('/'); + + protected justify = computed(() => this.props()['justify']?.value()); + protected align = computed(() => this.props()['align']?.value()); + + protected children = computed(() => { + const raw = this.props()['children']?.value() || []; + return Array.isArray(raw) ? raw : []; + }); + + protected isRepeating = computed(() => { + return !!this.props()['children']?.raw?.componentId; + }); + + protected templateId = computed(() => { + return this.props()['children']?.raw?.componentId; + }); + + protected getNormalizedPath(index: number) { + return getNormalizedPath(this.props()['children']?.raw?.path, this.dataContextPath(), index); + } +} diff --git a/renderers/angular/src/v0_9/catalog/minimal/minimal-catalog.ts b/renderers/angular/src/v0_9/catalog/minimal/minimal-catalog.ts new file mode 100644 index 000000000..37806811f --- /dev/null +++ b/renderers/angular/src/v0_9/catalog/minimal/minimal-catalog.ts @@ -0,0 +1,88 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Injectable } from '@angular/core'; +import { z } from 'zod'; +import { createFunctionImplementation } from '@a2ui/web_core/v0_9'; +import { AngularCatalog, AngularComponentImplementation } from '../types'; +import { TextComponent } from './text.component'; +import { RowComponent } from './row.component'; +import { ColumnComponent } from './column.component'; +import { ButtonComponent } from './button.component'; +import { TextFieldComponent } from './text-field.component'; +import { + TextApi, + RowApi, + ColumnApi, + ButtonApi, + TextFieldApi, +} from '@a2ui/web_core/v0_9/basic_catalog'; +import { FunctionImplementation } from '@a2ui/web_core/v0_9'; +import { FormatStringImplementation } from '@a2ui/web_core/v0_9/basic_catalog'; + +export const MINIMAL_COMPONENTS: AngularComponentImplementation[] = [ + { ...TextApi, component: TextComponent }, + { ...RowApi, component: RowComponent }, + { ...ColumnApi, component: ColumnComponent }, + { ...ButtonApi, component: ButtonComponent }, + { ...TextFieldApi, component: TextFieldComponent }, +]; + +export const MINIMAL_FUNCTIONS: FunctionImplementation[] = [ + createFunctionImplementation( + { + name: 'capitalize', + returnType: 'string', + schema: z.object({ value: z.string().optional() }), + }, + (args) => { + console.log('[MinimalCatalog] capitalize called with args:', args); + const value = String(args.value || ''); + const res = value.charAt(0).toUpperCase() + value.slice(1); + console.log('[MinimalCatalog] capitalize result:', res); + return res; + }, + ), + FormatStringImplementation, +]; + +/** + * Provides a base implementation for catalogs that include minimal components. + */ +export class BaseMinimalCatalog extends AngularCatalog { + constructor( + id: string = 'minimal', + components: AngularComponentImplementation[] = MINIMAL_COMPONENTS, + functions: FunctionImplementation[] = MINIMAL_FUNCTIONS, + ) { + super(id, components, functions); + } +} + +/** + * Defines a minimal catalog of components and functions for A2UI v0.9. + * + * This catalog includes basic layout (Row, Column) and UI elements (Text, Button, TextField) + * along with standard functions like string formatting. + */ +@Injectable({ + providedIn: 'root', +}) +export class MinimalCatalog extends BaseMinimalCatalog { + constructor() { + super(); + } +} diff --git a/renderers/angular/src/v0_9/catalog/minimal/row.component.spec.ts b/renderers/angular/src/v0_9/catalog/minimal/row.component.spec.ts new file mode 100644 index 000000000..667064f33 --- /dev/null +++ b/renderers/angular/src/v0_9/catalog/minimal/row.component.spec.ts @@ -0,0 +1,131 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Component, Input } from '@angular/core'; +import { RowComponent } from './row.component'; +import { signal } from '@angular/core'; +import { A2uiRendererService } from '../../core/a2ui-renderer.service'; +import { ComponentBinder } from '../../core/component-binder.service'; +import { By } from '@angular/platform-browser'; + +@Component({ + standalone: true, + selector: 'dummy-child', + template: 'Dummy Child', +}) +class DummyChild { + @Input() props: any; + @Input() surfaceId?: string; + @Input() dataContextPath?: string; +} + +describe('RowComponent', () => { + let component: RowComponent; + let fixture: ComponentFixture; + let mockRendererService: any; + let mockSurface: any; + let mockSurfaceGroup: any; + let mockBinder: any; + + beforeEach(async () => { + mockSurface = { + componentsModel: new Map([ + ['child1', { id: 'child1', type: 'Child', properties: {} }], + ['child2', { id: 'child2', type: 'Child', properties: {} }], + ['template1', { id: 'template1', type: 'Child', properties: {} }], + ]), + catalog: { + id: 'test-catalog', + components: new Map([['Child', { component: DummyChild }]]), + }, + }; + + mockSurfaceGroup = { + getSurface: jasmine.createSpy('getSurface').and.returnValue(mockSurface), + }; + + mockRendererService = { + surfaceGroup: mockSurfaceGroup, + }; + + mockBinder = jasmine.createSpyObj('ComponentBinder', ['bind']); + mockBinder.bind.and.returnValue({ text: { value: () => 'bound' } }); + + await TestBed.configureTestingModule({ + imports: [RowComponent], + providers: [ + { provide: A2uiRendererService, useValue: mockRendererService }, + { provide: ComponentBinder, useValue: mockBinder }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(RowComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('surfaceId', 'surf1'); + fixture.componentRef.setInput('props', { + justify: { value: signal('center'), raw: 'center', onUpdate: () => {} }, + align: { value: signal('baseline'), raw: 'baseline', onUpdate: () => {} }, + children: { + value: signal(['child1', 'child2']), + raw: ['child1', 'child2'], + onUpdate: () => {}, + }, + }); + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('should apply flex styles from props', () => { + fixture.detectChanges(); + const div = fixture.debugElement.query(By.css('.a2ui-row')); + expect(div.styles['justify-content']).toBe('center'); + expect(div.styles['align-items']).toBe('baseline'); + }); + + it('should render non-repeating children', () => { + fixture.detectChanges(); + const hosts = fixture.debugElement.queryAll(By.css('a2ui-v09-component-host')); + expect(hosts.length).toBe(2); + expect(hosts[0].componentInstance.componentId()).toBe('child1'); + expect(hosts[1].componentInstance.componentId()).toBe('child2'); + }); + + it('should render repeating children', () => { + fixture.componentRef.setInput('props', { + ...component.props(), + children: { + value: signal([{}, {}]), // two items + raw: { + componentId: 'template1', + path: 'items', + }, + onUpdate: () => {}, + }, + }); + fixture.detectChanges(); + + const hosts = fixture.debugElement.queryAll(By.css('a2ui-v09-component-host')); + expect(hosts.length).toBe(2); + expect(hosts[0].componentInstance.componentId()).toBe('template1'); + expect(hosts[0].componentInstance.dataContextPath()).toBe('/items/0'); + expect(hosts[1].componentInstance.componentId()).toBe('template1'); + expect(hosts[1].componentInstance.dataContextPath()).toBe('/items/1'); + }); +}); diff --git a/renderers/angular/src/v0_9/catalog/minimal/row.component.ts b/renderers/angular/src/v0_9/catalog/minimal/row.component.ts new file mode 100644 index 000000000..d0848a214 --- /dev/null +++ b/renderers/angular/src/v0_9/catalog/minimal/row.component.ts @@ -0,0 +1,89 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, ChangeDetectionStrategy, computed, input } from '@angular/core'; +import { ComponentHostComponent } from '../../core/component-host.component'; +import { BoundProperty } from '../../core/types'; +import { getNormalizedPath } from '../../core/utils'; + +/** + * A horizontal layout container that aligns children in a flex row. + * + * It supports both static children and repeating templates bound to a data path. + */ +@Component({ + selector: 'a2ui-v09-row', + imports: [ComponentHostComponent], + template: ` +
+ @if (!isRepeating()) { + @for (childId of children(); track childId) { + + + } + } + + @if (isRepeating()) { + @for (item of children(); track $index) { + + + } + } +
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RowComponent { + /** + * Bound properties. + */ + props = input>({}); + surfaceId = input.required(); + dataContextPath = input('/'); + + protected justify = computed(() => this.props()['justify']?.value()); + protected align = computed(() => this.props()['align']?.value()); + + protected children = computed(() => { + const raw = this.props()['children']?.value() || []; + return Array.isArray(raw) ? raw : []; + }); + + protected isRepeating = computed(() => { + return !!this.props()['children']?.raw?.componentId; + }); + + protected templateId = computed(() => { + return this.props()['children']?.raw?.componentId; + }); + + protected getNormalizedPath(index: number) { + return getNormalizedPath(this.props()['children']?.raw?.path, this.dataContextPath(), index); + } +} diff --git a/renderers/angular/src/v0_9/catalog/minimal/text-field.component.spec.ts b/renderers/angular/src/v0_9/catalog/minimal/text-field.component.spec.ts new file mode 100644 index 000000000..432fa17a7 --- /dev/null +++ b/renderers/angular/src/v0_9/catalog/minimal/text-field.component.spec.ts @@ -0,0 +1,103 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TextFieldComponent } from './text-field.component'; +import { signal } from '@angular/core'; +import { A2uiRendererService } from '../../core/a2ui-renderer.service'; +import { By } from '@angular/platform-browser'; + +describe('TextFieldComponent', () => { + let component: TextFieldComponent; + let fixture: ComponentFixture; + let mockRendererService: any; + + beforeEach(async () => { + mockRendererService = {}; + + await TestBed.configureTestingModule({ + imports: [TextFieldComponent], + providers: [{ provide: A2uiRendererService, useValue: mockRendererService }], + }).compileComponents(); + + fixture = TestBed.createComponent(TextFieldComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('props', { + label: { value: signal('Username'), raw: 'Username', onUpdate: () => {} }, + value: { + value: signal('testuser'), + raw: 'testuser', + onUpdate: jasmine.createSpy('onUpdate'), + }, + placeholder: { value: signal('Enter username'), raw: 'Enter username', onUpdate: () => {} }, + variant: { value: signal('text'), raw: 'text', onUpdate: () => {} }, + }); + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('should render label if provided', () => { + fixture.detectChanges(); + const label = fixture.debugElement.query(By.css('label')); + expect(label.nativeElement.textContent).toBe('Username'); + }); + + it('should not render label if not provided', () => { + fixture.componentRef.setInput('props', { + ...component.props(), + label: { value: signal(null), raw: null, onUpdate: () => {} }, + }); + fixture.detectChanges(); + const label = fixture.debugElement.query(By.css('label')); + expect(label).toBeFalsy(); + }); + + it('should render input with correct value and placeholder', () => { + fixture.detectChanges(); + const input = fixture.debugElement.query(By.css('input')); + expect(input.nativeElement.value).toBe('testuser'); + expect(input.nativeElement.placeholder).toBe('Enter username'); + }); + + it('should return correct input type based on variant', () => { + // inputType is now a computed signal + expect(component.inputType()).toBe('text'); + + fixture.componentRef.setInput('props', { + ...component.props(), + variant: { value: signal('obscured'), raw: 'obscured', onUpdate: () => {} }, + }); + expect(component.inputType()).toBe('password'); + + fixture.componentRef.setInput('props', { + ...component.props(), + variant: { value: signal('number'), raw: 'number', onUpdate: () => {} }, + }); + expect(component.inputType()).toBe('number'); + }); + + it('should call onUpdate when input changes', () => { + fixture.detectChanges(); + const input = fixture.debugElement.query(By.css('input')); + input.nativeElement.value = 'newuser'; + input.triggerEventHandler('input', { target: input.nativeElement }); + + expect(component.props()['value'].onUpdate).toHaveBeenCalledWith('newuser'); + }); +}); diff --git a/renderers/angular/src/v0_9/catalog/minimal/text-field.component.ts b/renderers/angular/src/v0_9/catalog/minimal/text-field.component.ts new file mode 100644 index 000000000..c4af8a867 --- /dev/null +++ b/renderers/angular/src/v0_9/catalog/minimal/text-field.component.ts @@ -0,0 +1,99 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, ChangeDetectionStrategy, input, computed } from '@angular/core'; +import { BoundProperty } from '../../core/types'; + + +/** + * An interactive text input component that supports labels, placeholders, and variants. + * + * It maps its value to a bound data path and supports 'obscured' (password) and + * 'number' variants. + */ +@Component({ + selector: 'a2ui-v09-text-field', + imports: [], + template: ` +
+ @if (label()) { + + } + + +
+ `, + styles: [ + ` + :host { + display: block; + flex: 1; + width: 100%; + } + .a2ui-text-field-container { + display: flex; + flex-direction: column; + gap: 4px; + margin: 4px; + } + input { + padding: 8px; + border: 1px solid #ccc; + border-radius: 4px; + } + `, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TextFieldComponent { + /** + * Bound properties. + */ + props = input>({}); + surfaceId = input(); + componentId = input(); + dataContextPath = input(); + + + + variant = computed(() => this.props()['variant']?.value()); + label = computed(() => this.props()['label']?.value()); + value = computed(() => this.props()['value']?.value() ?? ''); + placeholder = computed(() => this.props()['placeholder']?.value() ?? ''); + + inputType = computed(() => { + switch (this.variant()) { + case 'obscured': + return 'password'; + case 'number': + return 'number'; + default: + return 'text'; + } + }); + + handleInput(event: Event) { + const value = (event.target as HTMLInputElement).value; + // Update the data path. If anything is listening to this path, it will be + // notified. + this.props()['value']?.onUpdate(value); + } +} diff --git a/renderers/angular/src/v0_9/catalog/minimal/text.component.spec.ts b/renderers/angular/src/v0_9/catalog/minimal/text.component.spec.ts new file mode 100644 index 000000000..fc639d2f5 --- /dev/null +++ b/renderers/angular/src/v0_9/catalog/minimal/text.component.spec.ts @@ -0,0 +1,57 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TextComponent } from './text.component'; +import { By } from '@angular/platform-browser'; +import { signal } from '@angular/core'; + +describe('TextComponent', () => { + let component: TextComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TextComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TextComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('props', { + text: { value: signal('Hello World'), raw: 'Hello World', onUpdate: () => {} }, + weight: { value: signal('bold'), raw: 'bold', onUpdate: () => {} }, + style: { value: signal('italic'), raw: 'italic', onUpdate: () => {} }, + }); + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('should render the text', () => { + fixture.detectChanges(); + const span = fixture.debugElement.query(By.css('span')); + expect(span.nativeElement.textContent.trim()).toBe('Hello World'); + }); + + it('should apply font-weight and font-style', () => { + fixture.detectChanges(); + const span = fixture.debugElement.query(By.css('span')); + expect(span.styles['font-weight']).toBe('bold'); + expect(span.styles['font-style']).toBe('italic'); + }); +}); diff --git a/renderers/angular/src/v0_9/catalog/minimal/text.component.ts b/renderers/angular/src/v0_9/catalog/minimal/text.component.ts new file mode 100644 index 000000000..0121751a7 --- /dev/null +++ b/renderers/angular/src/v0_9/catalog/minimal/text.component.ts @@ -0,0 +1,48 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, ChangeDetectionStrategy, input, computed } from '@angular/core'; +import { BoundProperty } from '../../core/types'; + +/** + * A basic text component that supports font weights and styles. + */ +@Component({ + selector: 'a2ui-v09-text', + imports: [], + template: ` + + {{ text() }} + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TextComponent { + /** + * Bound properties. + */ + props = input>({}); + surfaceId = input(); + componentId = input(); + dataContextPath = input(); + + text = computed(() => this.props()['text']?.value() ?? ''); + fontWeight = computed(() => this.props()['weight']?.value()); + fontStyle = computed(() => this.props()['style']?.value()); +} diff --git a/renderers/angular/src/v0_9/catalog/types.ts b/renderers/angular/src/v0_9/catalog/types.ts new file mode 100644 index 000000000..9e9afd9a0 --- /dev/null +++ b/renderers/angular/src/v0_9/catalog/types.ts @@ -0,0 +1,31 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Type } from '@angular/core'; +import { Catalog, ComponentApi } from '@a2ui/web_core/v0_9'; + +/** + * Extends the generic ComponentApi to include Angular-specific component types. + */ +export interface AngularComponentImplementation extends ComponentApi { + /** The Angular component class used to render this component. */ + readonly component: Type; +} + +/** + * Base class for Angular-specific component catalogs. + */ +export class AngularCatalog extends Catalog {} diff --git a/renderers/angular/src/v0_9/core/a2ui-renderer.service.spec.ts b/renderers/angular/src/v0_9/core/a2ui-renderer.service.spec.ts new file mode 100644 index 000000000..dd5caa803 --- /dev/null +++ b/renderers/angular/src/v0_9/core/a2ui-renderer.service.spec.ts @@ -0,0 +1,87 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestBed } from '@angular/core/testing'; +import { A2uiRendererService, A2UI_RENDERER_CONFIG } from './a2ui-renderer.service'; +import { AngularCatalog } from '../catalog/types'; + +describe('A2uiRendererService', () => { + let service: A2uiRendererService; + let mockCatalog: any; + + beforeEach(() => { + mockCatalog = { + components: new Map(), + functions: new Map(), + get invoker() { + return (name: string, args: any, ctx: any, ab?: any) => { + const fn = mockCatalog.functions.get(name); + if (fn) return fn(args, ctx, ab); + console.warn(`Function "${name}" not found in catalog`); + return undefined; + }; + }, + }; + + TestBed.configureTestingModule({ + providers: [ + A2uiRendererService, + { + provide: A2UI_RENDERER_CONFIG, + useValue: { catalogs: [mockCatalog] }, + }, + ], + }); + + service = TestBed.inject(A2uiRendererService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('initialization', () => { + it('should create surfaceGroup', () => { + expect(service.surfaceGroup).toBeDefined(); + }); + }); + + describe('processMessages', () => { + it('should delegate to MessageProcessor', () => { + // Access private _messageProcessor via bracket notation for testing if needed, + // or verify indirectly by inspecting surfaceGroup after messages. + // Since MessageProcessor is complex, we can just verify it doesn't crash + // and updates model if we pass valid messages. + // For a pure unit test, we might consider mocking MessageProcessor if it was injected, + // but it's instantiated via 'new'. + // Let's pass an empty array to verify delegate runs without error. + expect(() => service.processMessages([])).not.toThrow(); + }); + }); + + describe('ngOnDestroy', () => { + it('should dispose surfaceGroup', () => { + const surfaceGroup = service.surfaceGroup; + expect(surfaceGroup).toBeDefined(); + + const disposeSpy = spyOn(surfaceGroup as any, 'dispose'); + + service.ngOnDestroy(); + + expect(disposeSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/renderers/angular/src/v0_9/core/a2ui-renderer.service.ts b/renderers/angular/src/v0_9/core/a2ui-renderer.service.ts new file mode 100644 index 000000000..0367b7ac4 --- /dev/null +++ b/renderers/angular/src/v0_9/core/a2ui-renderer.service.ts @@ -0,0 +1,83 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Injectable, OnDestroy, InjectionToken, Inject } from '@angular/core'; +import { + MessageProcessor, + SurfaceGroupModel, + ActionListener as ActionHandler, + A2uiMessage, + SurfaceGroupAction, +} from '@a2ui/web_core/v0_9'; +import { AngularComponentImplementation, AngularCatalog } from '../catalog/types'; + +/** + * Configuration for the A2UI renderer. + */ +export interface RendererConfiguration { + /** The catalogs containing the available components and functions. */ + catalogs: AngularCatalog[]; + /** Optional handler for actions dispatched from any surface. */ + actionHandler?: (action: SurfaceGroupAction) => void; +} + +/** + * Injection token for the A2UI renderer configuration. + */ +export const A2UI_RENDERER_CONFIG = new InjectionToken( + 'A2UI_RENDERER_CONFIG', +); + +/** + * Manages A2UI v0.9 rendering sessions by bridging the MessageProcessor to Angular. + * + * This service is responsible for processing incoming A2UI messages and making the + * resulting surface models available to Angular components. + */ +@Injectable() +export class A2uiRendererService implements OnDestroy { + private _messageProcessor: MessageProcessor; + private _catalogs: AngularCatalog[] = []; + + constructor(@Inject(A2UI_RENDERER_CONFIG) private config: RendererConfiguration) { + this._catalogs = this.config.catalogs; + + this._messageProcessor = new MessageProcessor( + this._catalogs, + this.config.actionHandler as ActionHandler, + ); + } + + /** + * Processes a list of A2UI messages and updates the internal model. + * + * @param messages The list of messages to process. + */ + processMessages(messages: A2uiMessage[]): void { + this._messageProcessor.processMessages(messages); + } + + /** + * The current surface group model containing all active surfaces. + */ + get surfaceGroup(): SurfaceGroupModel { + return this._messageProcessor.model; + } + + ngOnDestroy(): void { + this._messageProcessor.model.dispose(); + } +} diff --git a/renderers/angular/src/v0_9/core/component-binder.service.spec.ts b/renderers/angular/src/v0_9/core/component-binder.service.spec.ts new file mode 100644 index 000000000..483e4ea67 --- /dev/null +++ b/renderers/angular/src/v0_9/core/component-binder.service.spec.ts @@ -0,0 +1,143 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestBed } from '@angular/core/testing'; +import { DestroyRef } from '@angular/core'; +import { signal as preactSignal } from '@preact/signals-core'; +import { ComponentContext } from '@a2ui/web_core/v0_9'; +import { ComponentBinder } from './component-binder.service'; + +describe('ComponentBinder', () => { + let binder: ComponentBinder; + let mockDestroyRef: jasmine.SpyObj; + let onDestroyCallback: () => void; + + beforeEach(() => { + onDestroyCallback = () => {}; + mockDestroyRef = jasmine.createSpyObj('DestroyRef', ['onDestroy']); + mockDestroyRef.onDestroy.and.callFake((callback: () => void) => { + onDestroyCallback = callback; + return () => {}; // Return unregister function + }); + + TestBed.configureTestingModule({ + providers: [ComponentBinder, { provide: DestroyRef, useValue: mockDestroyRef }], + }); + + binder = TestBed.inject(ComponentBinder); + }); + + it('should be created', () => { + expect(binder).toBeTruthy(); + }); + + it('should bind properties to Angular signals', () => { + const mockComponentModel = { + properties: { + text: 'Hello', + visible: true, + }, + }; + + const mockpSigText = preactSignal('Hello'); + const mockpSigVisible = preactSignal(true); + + const mockDataContext = { + resolveSignal: jasmine.createSpy('resolveSignal').and.callFake((val: any) => { + if (val === 'Hello') return mockpSigText; + if (val === true) return mockpSigVisible; + return preactSignal(val); + }), + set: jasmine.createSpy('set'), + }; + + const mockContext = { + componentModel: mockComponentModel, + dataContext: mockDataContext, + } as unknown as ComponentContext; + + const bound = binder.bind(mockContext); + + expect(bound['text']).toBeDefined(); + expect(bound['visible']).toBeDefined(); + expect(bound['text'].value()).toBe('Hello'); + expect(bound['visible'].value()).toBe(true); + + // Verify resolveSignal was called + expect(mockDataContext.resolveSignal).toHaveBeenCalledWith('Hello'); + expect(mockDataContext.resolveSignal).toHaveBeenCalledWith(true); + }); + + it('should add update() method for data bindings (two-way binding)', () => { + const mockComponentModel = { + properties: { + value: { path: '/data/text' }, + }, + }; + + const mockpSig = preactSignal('initial'); + const mockDataContext = { + resolveSignal: jasmine.createSpy('resolveSignal').and.returnValue(mockpSig), + set: jasmine.createSpy('set'), + }; + + const mockContext = { + componentModel: mockComponentModel, + dataContext: mockDataContext, + } as unknown as ComponentContext; + + const bound = binder.bind(mockContext); + + expect(bound['value']).toBeDefined(); + expect(bound['value'].value()).toBe('initial'); + expect(bound['value'].onUpdate).toBeDefined(); + + // Call update + bound['value'].onUpdate('new-value'); + + // Verify set was called on DataContext + expect(mockDataContext.set).toHaveBeenCalledWith('/data/text', 'new-value'); + }); + + it('should NOT add update() method for literals', () => { + const mockComponentModel = { + properties: { + text: 'Literal String', + }, + }; + + const mockpSig = preactSignal('Literal String'); + const mockDataContext = { + resolveSignal: jasmine.createSpy('resolveSignal').and.returnValue(mockpSig), + set: jasmine.createSpy('set'), + }; + + const mockContext = { + componentModel: mockComponentModel, + dataContext: mockDataContext, + } as unknown as ComponentContext; + + const bound = binder.bind(mockContext); + + expect(bound['text']).toBeDefined(); + expect(bound['text'].value()).toBe('Literal String'); + expect(bound['text'].onUpdate).toBeDefined(); // No-op for literals + + // Call onUpdate on literal, should not crash or call set + bound['text'].onUpdate('new'); + expect(mockDataContext.set).not.toHaveBeenCalled(); + }); +}); diff --git a/renderers/angular/src/v0_9/core/component-binder.service.ts b/renderers/angular/src/v0_9/core/component-binder.service.ts new file mode 100644 index 000000000..54e8f295b --- /dev/null +++ b/renderers/angular/src/v0_9/core/component-binder.service.ts @@ -0,0 +1,63 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DestroyRef, Injectable, inject, NgZone } from '@angular/core'; +import { ComponentContext } from '@a2ui/web_core/v0_9'; +import { toAngularSignal } from './utils'; +import { BoundProperty } from './types'; + +/** + * Binds A2UI ComponentModel properties to reactive Angular Signals. + * + * This service resolves data bindings from the A2UI DataContext and exposes them + * as Angular Signals for use in renderer components. + */ +@Injectable({ + providedIn: 'root', +}) +export class ComponentBinder { + private destroyRef = inject(DestroyRef); + private ngZone = inject(NgZone); + + /** + * Binds all properties of a component to an object of Angular Signals. + * + * @param context The ComponentContext containing the model and data context. + * @returns An object where each key corresponds to a component prop and its value is an Angular Signal. + */ + bind(context: ComponentContext): Record { + const props = context.componentModel.properties; + const bound: Record = {}; + + for (const key of Object.keys(props)) { + const value = props[key]; + const preactSig = context.dataContext.resolveSignal(value); + const angSig = toAngularSignal(preactSig as any, this.destroyRef, this.ngZone); + + const isBoundPath = value && typeof value === 'object' && 'path' in value; + + bound[key] = { + value: angSig, + raw: value, + onUpdate: isBoundPath + ? (newValue: any) => context.dataContext.set(value.path, newValue) + : () => {}, // No-op for non-bound values + }; + } + + return bound; + } +} diff --git a/renderers/angular/src/v0_9/core/component-host.component.spec.ts b/renderers/angular/src/v0_9/core/component-host.component.spec.ts new file mode 100644 index 000000000..6dcc4e062 --- /dev/null +++ b/renderers/angular/src/v0_9/core/component-host.component.spec.ts @@ -0,0 +1,182 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { ComponentHostComponent } from './component-host.component'; +import { A2uiRendererService } from './a2ui-renderer.service'; +import { AngularCatalog } from '../catalog/types'; +import { ComponentBinder } from './component-binder.service'; +import { ComponentContext } from '@a2ui/web_core/v0_9'; +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'test-child', + template: '
Child Component
', +}) +class TestChildComponent { + @Input() props: any; + @Input() surfaceId?: string; + @Input() componentId?: string; + @Input() dataContextPath?: string; +} + +describe('ComponentHostComponent', () => { + let component: ComponentHostComponent; + let fixture: ComponentFixture; + let mockRendererService: any; + let mockCatalog: any; + let mockBinder: jasmine.SpyObj; + let mockSurface: any; + let mockSurfaceGroup: any; + + beforeEach(async () => { + mockCatalog = { + id: 'test-catalog', + components: new Map([['TestType', { component: TestChildComponent }]]), + }; + + mockSurface = { + componentsModel: new Map([ + ['comp1', { id: 'comp1', type: 'TestType', properties: { text: 'Hello' } }], + ]), + catalog: mockCatalog, + }; + + mockSurfaceGroup = { + getSurface: jasmine.createSpy('getSurface').and.returnValue(mockSurface), + }; + + mockRendererService = { + surfaceGroup: mockSurfaceGroup, + }; + + mockBinder = jasmine.createSpyObj('ComponentBinder', ['bind']); + mockBinder.bind.and.returnValue({ + text: { value: () => 'bound-hello', onUpdate: () => {} } as any, + }); + + await TestBed.configureTestingModule({ + imports: [ComponentHostComponent], + providers: [ + { provide: A2uiRendererService, useValue: mockRendererService }, + { provide: ComponentBinder, useValue: mockBinder }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ComponentHostComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('componentId', 'comp1'); + fixture.componentRef.setInput('surfaceId', 'surf1'); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + }); + + describe('ngOnInit', () => { + it('should resolve component type and bind props', () => { + fixture.detectChanges(); // Triggers ngOnInit + + // @ts-ignore - Accessing protected property + expect(component.componentType).toBe(TestChildComponent); + // @ts-ignore - Accessing protected property + expect(component.props).toEqual({ + text: jasmine.objectContaining({ value: jasmine.any(Function) }) as any, + }); + + expect(mockSurfaceGroup.getSurface).toHaveBeenCalledWith('surf1'); + expect(mockBinder.bind).toHaveBeenCalled(); + + // Verify context creation implicitly by checking if bind was called with a ComponentContext + const bindArg = mockBinder.bind.calls.mostRecent().args[0]; + expect(bindArg).toBeInstanceOf(ComponentContext); + expect(bindArg.componentModel.id).toBe('comp1'); + expect(bindArg.dataContext.path).toBe('/'); + }); + + it('should use provided dataContextPath for ComponentContext', () => { + fixture.componentRef.setInput('dataContextPath', '/nested/path'); + fixture.detectChanges(); + + const bindArg = mockBinder.bind.calls.mostRecent().args[0]; + expect(bindArg.dataContext.path).toBe('/nested/path'); + }); + + it('should warn and return if surface not found', () => { + const consoleWarnSpy = spyOn(console, 'warn'); + mockSurfaceGroup.getSurface.and.returnValue(null); + + fixture.detectChanges(); + + // @ts-ignore + expect(component.componentType).toBeNull(); + expect(consoleWarnSpy).toHaveBeenCalledWith('Surface surf1 not found'); + }); + + it('should warn and return if component model not found', () => { + const consoleWarnSpy = spyOn(console, 'warn'); + mockSurface.componentsModel.clear(); + + fixture.detectChanges(); + + // @ts-ignore + expect(component.componentType).toBeNull(); + expect(consoleWarnSpy).toHaveBeenCalledWith('Component comp1 not found in surface surf1'); + }); + + it('should error and return if component type not in catalog', () => { + const consoleErrorSpy = spyOn(console, 'error'); + mockCatalog.components.clear(); + + fixture.detectChanges(); + + // @ts-ignore + expect(component.componentType).toBeNull(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Component type "TestType" not found in catalog "test-catalog"', + ); + }); + + it('should trigger destroyRef on destroy', () => { + fixture.detectChanges(); // Trigger ngOnInit + + // Destroy fixture + fixture.destroy(); + + // Implicitly verifies no crash on destroy + expect(component).toBeTruthy(); + }); + }); + + describe('Template rendering', () => { + it('should render the resolved component', () => { + fixture.detectChanges(); // Triggers ngOnInit and render + + const compiled = fixture.nativeElement; + expect(compiled.innerHTML).toContain('Child Component'); + }); + it('should pass dataContextPath to the rendered component', () => { + fixture.componentRef.setInput('dataContextPath', '/some/path'); + fixture.detectChanges(); + + const childDebugElement = fixture.debugElement.query(By.directive(TestChildComponent)); + expect(childDebugElement).toBeTruthy(); + const childInstance = childDebugElement.componentInstance as TestChildComponent; + expect(childInstance.dataContextPath).toBe('/some/path'); + }); + }); +}); diff --git a/renderers/angular/src/v0_9/core/component-host.component.ts b/renderers/angular/src/v0_9/core/component-host.component.ts new file mode 100644 index 000000000..6ab897d35 --- /dev/null +++ b/renderers/angular/src/v0_9/core/component-host.component.ts @@ -0,0 +1,100 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + OnInit, + Type, + inject, + input, +} from '@angular/core'; +import { NgComponentOutlet } from '@angular/common'; +import { ComponentContext } from '@a2ui/web_core/v0_9'; +import { A2uiRendererService } from './a2ui-renderer.service'; +import { AngularCatalog } from '../catalog/types'; +import { ComponentBinder } from './component-binder.service'; + +/** + * Dynamically renders an A2UI component as defined in the current surface model. + * + * This component acts as a bridge between the A2UI surface model and Angular components, + * resolving the appropriate component from the catalog and binding its properties. + */ +@Component({ + selector: 'a2ui-v09-component-host', + imports: [NgComponentOutlet], + template: ` + @if (componentType) { + + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ComponentHostComponent implements OnInit { + componentId = input('root'); + surfaceId = input.required(); + dataContextPath = input('/'); + + private rendererService = inject(A2uiRendererService); + private binder = inject(ComponentBinder); + private destroyRef = inject(DestroyRef); + + protected componentType: Type | null = null; + protected props: any = {}; + private context?: ComponentContext; + + ngOnInit(): void { + const surface = this.rendererService.surfaceGroup?.getSurface(this.surfaceId()); + + if (!surface) { + console.warn(`Surface ${this.surfaceId()} not found`); + return; + } + + const componentModel = surface.componentsModel.get(this.componentId()); + + if (!componentModel) { + console.warn(`Component ${this.componentId()} not found in surface ${this.surfaceId()}`); + return; + } + + // Resolve component from the surface's catalog + const catalog = surface.catalog as AngularCatalog; + const api = catalog.components.get(componentModel.type); + + if (!api) { + console.error(`Component type "${componentModel.type}" not found in catalog "${catalog.id}"`); + return; + } + this.componentType = api.component; + + // Create context + this.context = new ComponentContext(surface, this.componentId(), this.dataContextPath()); + this.props = this.binder.bind(this.context); + + this.destroyRef.onDestroy(() => { + // ComponentContext itself doesn't have a dispose, but its inner components might. + // However, SurfaceModel takes care of component disposal. + }); + } +} diff --git a/renderers/angular/src/v0_9/core/function_binding.spec.ts b/renderers/angular/src/v0_9/core/function_binding.spec.ts new file mode 100644 index 000000000..ce7641e8a --- /dev/null +++ b/renderers/angular/src/v0_9/core/function_binding.spec.ts @@ -0,0 +1,130 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DataContext, SurfaceModel } from '@a2ui/web_core/v0_9'; +import { DestroyRef } from '@angular/core'; +import { MinimalCatalog } from '../catalog/minimal/minimal-catalog'; +import { toAngularSignal } from './utils'; + +describe('Function Bindings', () => { + let mockDestroyRef: jasmine.SpyObj; + + beforeEach(() => { + mockDestroyRef = jasmine.createSpyObj('DestroyRef', ['onDestroy']); + mockDestroyRef.onDestroy.and.returnValue(() => {}); + }); + + describe('capitalize', () => { + it('should update output correctly when bound input updates using function call binding', () => { + const catalog = new MinimalCatalog(); + + // Create Surface Model and DataContext + const surface = new SurfaceModel('surface_1', catalog); + const dataModel = surface.dataModel; + const context = new DataContext(surface, '/'); + + const callValue = { + call: 'capitalize', + args: { + value: { + path: '/inputValue', + }, + }, + returnType: 'string', + }; + + // 1. Resolve Signal + const resSig = context.resolveSignal(callValue as any); + + // 2. Convert to Angular Signal + const angSig = toAngularSignal(resSig, mockDestroyRef); + + // 3. Initial state + expect(angSig()).toBe(''); + + // 4. Update data model Simulation typing + dataModel.set('/inputValue', 'regression test'); + + // 5. Verify reactive updates + expect(angSig()).toBe('Regression test'); + + // 6. Update again to confirm reactive stream remains healthy + dataModel.set('/inputValue', 'another test'); + expect(angSig()).toBe('Another test'); + }); + }); + + describe('formatString', () => { + it('should correctly format string with dynamic path and dollar sign', () => { + const catalog = new MinimalCatalog(); + + // Create Surface Model and DataContext + const surface = new SurfaceModel('surface_1', catalog); + const dataModel = surface.dataModel; + const context = new DataContext(surface, '/'); + + // formatString with path binding: '$${/price}' + const callValue = { + call: 'formatString', + args: { + value: '$${/price}', + }, + returnType: 'string', + }; + + // 1. Resolve Signal (Preact) + const resSig = context.resolveSignal(callValue as any); + + // 2. Convert to Angular Signal + const angSig = toAngularSignal(resSig, mockDestroyRef); + + // 3. Initial state (price is undefined, so should be '$') + expect(angSig()).toBe('$'); + + // 4. Update data model + dataModel.set('/price', 42); + + // 5. Verify reactive updates - should be '$42' + // Regression check: This previously would have returned the Signal object + // stringified as '[object Object]' due to instanceof failures across packages. + expect(angSig()).toBe('$42'); + expect(typeof angSig()).toBe('string'); + }); + + it('should handle multiple path interpolations correctly', () => { + const catalog = new MinimalCatalog(); + const surface = new SurfaceModel('surface_1', catalog); + const dataModel = surface.dataModel; + const context = new DataContext(surface, '/'); + + const callValue = { + call: 'formatString', + args: { + value: '${/firstName} ${/lastName}', + }, + returnType: 'string', + }; + + const resSig = context.resolveSignal(callValue as any); + const angSig = toAngularSignal(resSig, mockDestroyRef); + + dataModel.set('/firstName', 'A2UI'); + dataModel.set('/lastName', 'Renderer'); + + expect(angSig()).toBe('A2UI Renderer'); + }); + }); +}); diff --git a/renderers/angular/src/v0_9/core/surface.component.ts b/renderers/angular/src/v0_9/core/surface.component.ts new file mode 100644 index 000000000..82be811d2 --- /dev/null +++ b/renderers/angular/src/v0_9/core/surface.component.ts @@ -0,0 +1,44 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { ComponentHostComponent } from './component-host.component'; + +/** + * High-level component for rendering an entire A2UI surface. + * + * It automatically renders the 'root' component of the specified surface. + */ +@Component({ + selector: 'a2ui-v09-surface', + standalone: true, + imports: [ComponentHostComponent], + template: ` + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SurfaceComponent { + /** The unique identifier of the surface to render. */ + surfaceId = input.required(); + + /** + * The path within the surface's data model that represents the current state. + * Defaults to the root ('/'). + */ + dataContextPath = input('/'); +} diff --git a/renderers/angular/src/v0_9/core/types.ts b/renderers/angular/src/v0_9/core/types.ts new file mode 100644 index 000000000..b72b4018a --- /dev/null +++ b/renderers/angular/src/v0_9/core/types.ts @@ -0,0 +1,29 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Signal } from '@angular/core'; + +/** + * Represents a component property bound to an Angular Signal and update logic. + */ +export interface BoundProperty { + /** The reactive Angular Signal */ + readonly value: Signal; + /** The raw value from the ComponentModel */ + readonly raw: any; + /** Callback to update the value in the DataContext (if bound) */ + readonly onUpdate: (newValue: T) => void; +} diff --git a/renderers/angular/src/v0_9/core/utils.spec.ts b/renderers/angular/src/v0_9/core/utils.spec.ts new file mode 100644 index 000000000..82c3232e1 --- /dev/null +++ b/renderers/angular/src/v0_9/core/utils.spec.ts @@ -0,0 +1,96 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DestroyRef } from '@angular/core'; +import { signal as preactSignal } from '@preact/signals-core'; +import { toAngularSignal, getNormalizedPath } from './utils'; + +describe('toAngularSignal', () => { + let mockDestroyRef: jasmine.SpyObj; + let onDestroyCallback: () => void; + + beforeEach(() => { + onDestroyCallback = () => {}; + mockDestroyRef = jasmine.createSpyObj('DestroyRef', ['onDestroy']); + mockDestroyRef.onDestroy.and.callFake((callback: () => void) => { + onDestroyCallback = callback; + return () => {}; // Return unregister function + }); + }); + + it('should initialize with the current value of Preact signal', () => { + const pSig = preactSignal('initial'); + const angSig = toAngularSignal(pSig, mockDestroyRef); + + expect(angSig()).toBe('initial'); + }); + + it('should update Angular signal when Preact signal changes', () => { + const pSig = preactSignal('initial'); + const angSig = toAngularSignal(pSig, mockDestroyRef); + + expect(angSig()).toBe('initial'); + + pSig.value = 'updated'; + expect(angSig()).toBe('updated'); + }); + + it('should dispose Preact effect when DestroyRef triggers', () => { + const pSig = preactSignal('initial'); + const angSig = toAngularSignal(pSig, mockDestroyRef); + + expect(angSig()).toBe('initial'); + + // Trigger cleanup + onDestroyCallback(); + + pSig.value = 'updated'; + // Angular signal should NOT update after disposal + expect(angSig()).toBe('initial'); + }); + + it('should call unsubscribe on Preact signal if available on destroy', () => { + const pSig = preactSignal('initial') as any; + const unsubscribeSpy = jasmine.createSpy('unsubscribe'); + pSig.unsubscribe = unsubscribeSpy; + + toAngularSignal(pSig, mockDestroyRef); + + expect(unsubscribeSpy).not.toHaveBeenCalled(); + + // Trigger cleanup + onDestroyCallback(); + + expect(unsubscribeSpy).toHaveBeenCalled(); + }); +}); + +describe('getNormalizedPath', () => { + it('should handle absolute paths', () => { + expect(getNormalizedPath('/absolute', '/', 0)).toBe('/absolute/0'); + expect(getNormalizedPath('/absolute/', '/base', 5)).toBe('/absolute/5'); + }); + + it('should resolve relative paths against dataContextPath', () => { + expect(getNormalizedPath('relative', '/', 2)).toBe('/relative/2'); + expect(getNormalizedPath('relative', '/base', 3)).toBe('/base/relative/3'); + }); + + it('should handle empty paths', () => { + expect(getNormalizedPath('', '/', 1)).toBe('/1'); + expect(getNormalizedPath('', '/base', 4)).toBe('/base/4'); + }); +}); diff --git a/renderers/angular/src/v0_9/core/utils.ts b/renderers/angular/src/v0_9/core/utils.ts new file mode 100644 index 000000000..060203571 --- /dev/null +++ b/renderers/angular/src/v0_9/core/utils.ts @@ -0,0 +1,80 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DestroyRef, Signal, signal as angularSignal } from '@angular/core'; +import { Signal as PreactSignal, effect } from '@a2ui/web_core/v0_9'; + +/** + * Bridges a Preact Signal to a reactive Angular Signal. + * + * This utility handles the lifecycle mapping between Preact and Angular, + * ensuring that updates are propagated correctly and resources are cleaned up. + * + * @param preactSignal The source Preact Signal. + * @param destroyRef Angular DestroyRef for lifecycle management. + * @param ngZone Optional NgZone to ensure updates run within the Angular zone. + * @returns A read-only Angular Signal. + */ +import { NgZone } from '@angular/core'; + +export function toAngularSignal( + preactSignal: PreactSignal, + destroyRef: DestroyRef, + ngZone?: NgZone, +): Signal { + const s = angularSignal(preactSignal.peek()); + + const dispose = effect(() => { + if (ngZone) { + ngZone.run(() => s.set(preactSignal.value)); + } else { + s.set(preactSignal.value); + } + }); + + destroyRef.onDestroy(() => { + dispose(); + // Some signals returned by DataContext.resolveSignal have a custom unsubscribe for AbortControllers + if ((preactSignal as any).unsubscribe) { + (preactSignal as any).unsubscribe(); + } + }); + + return s.asReadonly(); +} + +/** + * Normalizes a data model path by combining a relative path with a base context. + * + * This is used to create unique absolute paths for components within a repeating + * collection or nested structure. + * + * @param path The relative or absolute path from the component properties. + * @param dataContextPath The current base data context path. + * @param index The index of the child component. + * @returns A fully normalized absolute path for the indexed child. + */ +export function getNormalizedPath(path: string, dataContextPath: string, index: number): string { + let normalized = path || ''; + if (!normalized.startsWith('/')) { + const base = dataContextPath === '/' ? '' : dataContextPath; + normalized = `${base}/${normalized}`; + } + if (normalized.endsWith('/')) { + normalized = normalized.slice(0, -1); + } + return `${normalized}/${index}`; +} diff --git a/renderers/angular/src/v0_9/ng-package.json b/renderers/angular/src/v0_9/ng-package.json new file mode 100644 index 000000000..ed278942e --- /dev/null +++ b/renderers/angular/src/v0_9/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "public-api.ts" + } +} diff --git a/renderers/angular/src/v0_9/public-api.ts b/renderers/angular/src/v0_9/public-api.ts new file mode 100644 index 000000000..d580eec08 --- /dev/null +++ b/renderers/angular/src/v0_9/public-api.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Core Services and Components +export * from './core/a2ui-renderer.service'; +export * from './core/component-host.component'; +export * from './core/surface.component'; +export * from './core/component-binder.service'; +export * from './core/types'; +export * from './core/utils'; + +// Catalog Types and Implementations +export * from './catalog/types'; +export * from './catalog/minimal/minimal-catalog'; + +// Minimal Catalog Components +export * from './catalog/minimal/text.component'; +export * from './catalog/minimal/row.component'; +export * from './catalog/minimal/column.component'; +export * from './catalog/minimal/button.component'; +export * from './catalog/minimal/text-field.component'; diff --git a/renderers/angular/tsconfig.json b/renderers/angular/tsconfig.json index 9f6412a72..a28822add 100644 --- a/renderers/angular/tsconfig.json +++ b/renderers/angular/tsconfig.json @@ -11,7 +11,13 @@ "experimentalDecorators": true, "importHelpers": true, "target": "ES2022", - "module": "preserve" + "baseUrl": ".", + "moduleResolution": "bundler", + "paths": { + "@a2ui/angular/v0_8": ["src/v0_8/public-api.ts"], + "@a2ui/angular/v0_9": ["src/v0_9/public-api.ts"], + "@preact/signals-core": ["../web_core/node_modules/@preact/signals-core"] + } }, "angularCompilerOptions": { "enableI18nLegacyMessageIdFormat": false, diff --git a/renderers/angular/tsconfig.lib.json b/renderers/angular/tsconfig.lib.json index 6984a0e01..a7bc21849 100644 --- a/renderers/angular/tsconfig.lib.json +++ b/renderers/angular/tsconfig.lib.json @@ -7,10 +7,6 @@ "inlineSources": true, "types": [] }, - "include": [ - "src/**/*.ts" - ], - "exclude": [ - "**/*.spec.ts" - ] + "include": ["src/**/*.ts"], + "exclude": ["**/*.spec.ts"] } diff --git a/renderers/angular/tsconfig.spec.json b/renderers/angular/tsconfig.spec.json index 79ee881a8..d427f4c3a 100644 --- a/renderers/angular/tsconfig.spec.json +++ b/renderers/angular/tsconfig.spec.json @@ -2,11 +2,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/spec", - "types": [ - "jasmine" - ] + "types": ["jasmine"] }, - "include": [ - "src/**/*.ts" - ] + "include": ["src/**/*.ts"] } diff --git a/renderers/angular/v0_9 b/renderers/angular/v0_9 new file mode 120000 index 000000000..2b358cef5 --- /dev/null +++ b/renderers/angular/v0_9 @@ -0,0 +1 @@ +src/v0_9 \ No newline at end of file diff --git a/renderers/markdown/markdown-it/package-lock.json b/renderers/markdown/markdown-it/package-lock.json index 0b5559b64..5f9b24ebf 100644 --- a/renderers/markdown/markdown-it/package-lock.json +++ b/renderers/markdown/markdown-it/package-lock.json @@ -39,6 +39,7 @@ }, "devDependencies": { "@types/node": "^24.11.0", + "c8": "^11.0.0", "typescript": "^5.8.3", "wireit": "^0.15.0-pre.2", "zod-to-json-schema": "^3.25.1" diff --git a/renderers/web_core/package.json b/renderers/web_core/package.json index 3e983139c..0f26b0420 100644 --- a/renderers/web_core/package.json +++ b/renderers/web_core/package.json @@ -42,6 +42,7 @@ "build:tsc": "wireit", "test": "wireit", "test:coverage": "c8 node --test \"dist/**/*.test.js\"" + }, "wireit": { "copy-spec": { diff --git a/samples/client/angular/package-lock.json b/samples/client/angular/package-lock.json index 3bcccd7e7..164c28232 100644 --- a/samples/client/angular/package-lock.json +++ b/samples/client/angular/package-lock.json @@ -36,6 +36,7 @@ "ng2-charts": "^8.0.0", "tslib": "^2.3.0", "uuid": "^13.0.0", + "zod": "^3.25.76", "zone.js": "~0.15.0" }, "devDependencies": { @@ -1163,6 +1164,7 @@ }, "devDependencies": { "@types/node": "^24.11.0", + "c8": "^11.0.0", "typescript": "^5.8.3", "wireit": "^0.15.0-pre.2", "zod-to-json-schema": "^3.25.1" @@ -2130,6 +2132,15 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@angular/cli/node_modules/zod": { + "version": "4.3.6", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@angular/common": { "version": "21.2.0", "license": "MIT", @@ -13989,7 +14000,8 @@ } }, "node_modules/zod": { - "version": "4.3.6", + "version": "3.25.76", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/samples/client/angular/package.json b/samples/client/angular/package.json index 8e967a56b..114ae3741 100644 --- a/samples/client/angular/package.json +++ b/samples/client/angular/package.json @@ -31,9 +31,8 @@ "private": true, "dependencies": { "@a2a-js/sdk": "^0.3.4", - "@modelcontextprotocol/ext-apps": "^1.2.0", - "@a2ui/web_core": "file:../../../renderers/web_core", "@a2ui/markdown-it": "file:../../../renderers/markdown/markdown-it", + "@a2ui/web_core": "file:../../../renderers/web_core", "@angular/cdk": "^20.2.10", "@angular/common": "^21.2.0", "@angular/compiler": "^21.2.0", @@ -45,6 +44,7 @@ "@angular/platform-server": "^21.2.0", "@angular/ssr": "^21.2.0", "@google/genai": "^1.22.0", + "@modelcontextprotocol/ext-apps": "^1.2.0", "chart.js": "^4.5.1", "chartjs-plugin-datalabels": "^2.2.0", "excalibur": "0.31.0", @@ -53,6 +53,7 @@ "ng2-charts": "^8.0.0", "tslib": "^2.3.0", "uuid": "^13.0.0", + "zod": "^3.25.76", "zone.js": "~0.15.0" }, "devDependencies": {