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 e1104728b..30d7cac9a 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ site/ # Python virtual environment .venv/ +coverage/ # Generated spec assets in the agent SDK ## old agent SDK path diff --git a/renderers/angular/.npmrc b/renderers/angular/.npmrc index 06b0eef7e..2d963e011 100644 --- a/renderers/angular/.npmrc +++ b/renderers/angular/.npmrc @@ -1,2 +1 @@ @a2ui:registry=https://us-npm.pkg.dev/oss-exit-gate-prod/a2ui--npm/ -//us-npm.pkg.dev/oss-exit-gate-prod/a2ui--npm/:always-auth=true diff --git a/renderers/angular/CHANGELOG.md b/renderers/angular/CHANGELOG.md index 4923db5df..a65a95d98 100644 --- a/renderers/angular/CHANGELOG.md +++ b/renderers/angular/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.9 + +- Implement renderer for v0.9 of A2UI. + ## 0.8.5 - Handle `TextField.type` renamed to `TextField.textFieldType`. diff --git a/renderers/angular/README.md b/renderers/angular/README.md index 8afc14b19..1ba7568d1 100644 --- a/renderers/angular/README.md +++ b/renderers/angular/README.md @@ -1,9 +1,122 @@ -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 `@a2ui/angular` package provides the Angular implementation for rendering A2UI surfaces, bridging the Agent-to-Agent (A2A) protocol to Angular-friendly components. + +## Architecture & Versions + +The package contains evolving architectures to support different A2UI specification versions: + +- **`v0_8`**: Initial approach utilizing dedicated, static Angular components for each element type (e.g., ``). +- **`v0_9`**: Dynamic approach centering around a single generic host component (`ComponentHostComponent`) coupled with extensible `Catalog` registries. **This is the recommended architecture for modern integrations.** + +--- + +## Getting Started (`v0_9`) + +The `v0_9` model decouples rendering mechanics from static templates by binding model state dynamically through dynamic component allocation. + +### 1. Register Components in a Catalog + +Extend `AngularCatalog` or use preset catalogs like `MinimalCatalog`. Define your custom elements by passing them to the base constructor: + +```typescript +import { Injectable } from '@angular/core'; +import { MinimalCatalog } from '@a2ui/angular/lib/v0_9/catalog/minimal/minimal-catalog'; +import { CustomComponent } from './custom-component'; + +@Injectable({ providedIn: 'root' }) +export class MyCatalog extends MinimalCatalog { + constructor() { + const customComponents = [ + { + name: 'CustomComponent', + schema: { ... }, // Zod schema spec + component: CustomComponent, + }, + ]; + + super('my-catalog', customComponents, []); + } +} +``` + +### 2. Provide Renderer Infrastructure + +In your dashboard component or module providers tier, provide the `A2uiRendererService`: + +```typescript +import { Component } from '@angular/core'; +import { A2uiRendererService } from '@a2ui/angular/lib/v0_9/core/a2ui-renderer.service'; +import { MyCatalog } from './my-catalog'; + +@Component({ + selector: 'app-dashboard', + providers: [ + A2uiRendererService, + // MyCatalog is providedIn: 'root', otherwise provide it here + ] +}) +``` + +### 3. Initialize Layout and Render + +Prepare the service on load by providing a `RendererConfiguration`. This defines the catalogs to use and an optional global action handler: + +```typescript +export class DashboardComponent implements OnInit { + private rendererService = inject(A2uiRendererService); + private myCatalog = inject(MyCatalog); + surfaceId = 'dashboard-surface'; + + ngOnInit() { + this.rendererService.initialize({ + catalogs: [this.myCatalog], + actionHandler: (action) => { + console.log('Global action handler received:', action); + }, + }); + } + + onMessagesReceived(messages: any[]) { + this.rendererService.processMessages(messages); + } +} +``` + +Place the `` component in your template pointing to the desired layout node: + +```html + +``` + +--- + +## Building and Development + +### Building the Package + +Distributes the library bundle utilizing `ng-packagr` outputting to `./dist`: + +```bash +npm run build +``` + +### Running the Demo + +Starts a dev environment rendering local samples containing live inspectors reviewing data pipelines: + +```bash +npm run demo +``` + +--- + +## Legal Notice + +**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/angular.json b/renderers/angular/angular.json index 6fc268bef..72ff1465c 100644 --- a/renderers/angular/angular.json +++ b/renderers/angular/angular.json @@ -27,6 +27,67 @@ } } } + }, + "demo-app": { + "projectType": "application", + "schematics": {}, + "root": "demo-app", + "sourceRoot": "demo-app/src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "browser": "demo-app/src/main.ts", + "tsConfig": "demo-app/tsconfig.app.json", + "assets": [ + { + "glob": "**/*", + "input": "demo-app/public" + } + ], + "styles": ["demo-app/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": "demo-app:build:production" + }, + "development": { + "buildTarget": "demo-app:build:development" + } + }, + "defaultConfiguration": "development" + }, + "test": { + "builder": "@angular/build:unit-test" + } + } } }, "cli": { diff --git a/renderers/angular/demo-app/public/favicon.ico b/renderers/angular/demo-app/public/favicon.ico new file mode 100644 index 000000000..57614f9c9 Binary files /dev/null and b/renderers/angular/demo-app/public/favicon.ico differ diff --git a/renderers/angular/demo-app/src/app/agent-stub.service.ts b/renderers/angular/demo-app/src/app/agent-stub.service.ts new file mode 100644 index 000000000..1982d0c3f --- /dev/null +++ b/renderers/angular/demo-app/src/app/agent-stub.service.ts @@ -0,0 +1,127 @@ +/** + * 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 '../../../v0_9/core/a2ui-renderer.service'; +import { AngularCatalog } from '../../../v0_9/catalog/types'; +import { SurfaceGroupAction, A2uiMessage } from '@a2ui/web_core/v0_9'; + +/** + * 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 catalog: AngularCatalog, + ) {} + + /** + * 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.initialize({ + catalogs: [this.catalog], + actionHandler: (action) => this.handleAction(action), + }); + this.rendererService.processMessages(initialMessages); + } +} diff --git a/renderers/angular/demo-app/src/app/app.config.ts b/renderers/angular/demo-app/src/app/app.config.ts new file mode 100644 index 000000000..5ed1b0963 --- /dev/null +++ b/renderers/angular/demo-app/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/demo-app/src/app/app.spec.ts b/renderers/angular/demo-app/src/app/app.spec.ts new file mode 100644 index 000000000..e905d1ab9 --- /dev/null +++ b/renderers/angular/demo-app/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/demo-app/src/app/app.ts b/renderers/angular/demo-app/src/app/app.ts new file mode 100644 index 000000000..23665e10d --- /dev/null +++ b/renderers/angular/demo-app/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/demo-app/src/app/card.component.ts b/renderers/angular/demo-app/src/app/card.component.ts new file mode 100644 index 000000000..36d724046 --- /dev/null +++ b/renderers/angular/demo-app/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 '../../../v0_9/core/component-host.component'; +import { BoundProperty } from '../../../v0_9/core/types'; + +/** + * 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/demo-app/src/app/custom-slider.component.ts b/renderers/angular/demo-app/src/app/custom-slider.component.ts new file mode 100644 index 000000000..30afadbcd --- /dev/null +++ b/renderers/angular/demo-app/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 '../../../v0_9/core/types'; + +/** + * 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/demo-app/src/app/demo-catalog.ts b/renderers/angular/demo-app/src/app/demo-catalog.ts new file mode 100644 index 000000000..b33e8ea2e --- /dev/null +++ b/renderers/angular/demo-app/src/app/demo-catalog.ts @@ -0,0 +1,65 @@ +/** + * 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 '../../../v0_9/catalog/minimal/minimal-catalog'; +import { CustomSliderComponent } from './custom-slider.component'; +import { CardComponent } from './card.component'; +import { AngularComponentApi } from '../../../v0_9/catalog/types'; +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: AngularComponentApi = { + 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: AngularComponentApi = { + 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/demo-app/src/app/demo.component.ts b/renderers/angular/demo-app/src/app/demo.component.ts new file mode 100644 index 000000000..218beb486 --- /dev/null +++ b/renderers/angular/demo-app/src/app/demo.component.ts @@ -0,0 +1,436 @@ +/** + * 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 } from '../../../v0_9/core/a2ui-renderer.service'; +import { AgentStubService } from './agent-stub.service'; +import { ComponentHostComponent } from '../../../v0_9/core/component-host.component'; +import { AngularCatalog } from '../../../v0_9/catalog/types'; +import { DemoCatalog } from './demo-catalog'; +import { SurfaceGroupAction, A2uiMessage, CreateSurfaceMessage } from '@a2ui/web_core/v0_9'; +import { EXAMPLES } from './examples-bundle'; +import { Example } from './types'; +import { Subscription } from 'rxjs'; + +/** + * 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], + 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: [ + ` + .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; + } + `, + ], + /** + * Component-scoped providers for the demo workspace dashboard: + * - A2uiRendererService: Coordinates structural updates to dynamic views and layouts. + * - AngularCatalog: Maps standalone schemas triggers with dynamic component loading. + * - AgentStubService: Pushes reactive messages simulating AI pushes. + */ + providers: [ + A2uiRendererService, + { provide: AngularCatalog, useClass: DemoCatalog }, + AgentStubService, + ], +}) +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/demo-app/src/app/examples-bundle.ts b/renderers/angular/demo-app/src/app/examples-bundle.ts new file mode 100644 index 000000000..ea2eac233 --- /dev/null +++ b/renderers/angular/demo-app/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/demo-app/src/app/kitchen-sink-surface.ts b/renderers/angular/demo-app/src/app/kitchen-sink-surface.ts new file mode 100644 index 000000000..1e137d033 --- /dev/null +++ b/renderers/angular/demo-app/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/demo-app/src/app/types.ts b/renderers/angular/demo-app/src/app/types.ts new file mode 100644 index 000000000..208d46691 --- /dev/null +++ b/renderers/angular/demo-app/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/demo-app/src/index.html b/renderers/angular/demo-app/src/index.html new file mode 100644 index 000000000..b21692da8 --- /dev/null +++ b/renderers/angular/demo-app/src/index.html @@ -0,0 +1,29 @@ + + + + + + + DemoApp + + + + + + + + diff --git a/renderers/angular/demo-app/src/main.ts b/renderers/angular/demo-app/src/main.ts new file mode 100644 index 000000000..94303b012 --- /dev/null +++ b/renderers/angular/demo-app/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/demo-app/src/styles.css b/renderers/angular/demo-app/src/styles.css new file mode 100644 index 000000000..3a438a46d --- /dev/null +++ b/renderers/angular/demo-app/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/demo-app/tsconfig.app.json b/renderers/angular/demo-app/tsconfig.app.json new file mode 100644 index 000000000..845cc4c54 --- /dev/null +++ b/renderers/angular/demo-app/tsconfig.app.json @@ -0,0 +1,14 @@ +/* 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", + "composite": true, + "declaration": true, + "types": [], + "rootDir": "../" + }, + "include": ["src/**/*.ts", "../v0_9/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "../v0_8/**/*.spec.ts", "../v0_9/**/*.spec.ts"] +} diff --git a/renderers/angular/demo-app/tsconfig.spec.json b/renderers/angular/demo-app/tsconfig.spec.json new file mode 100644 index 000000000..5f3f11f7d --- /dev/null +++ b/renderers/angular/demo-app/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/ng-package.json b/renderers/angular/ng-package.json index d08974fc0..20a96cbd2 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", "@preact/signals-core", "rxjs"] } diff --git a/renderers/angular/package-lock.json b/renderers/angular/package-lock.json index 7af8037d8..75692fa36 100644 --- a/renderers/angular/package-lock.json +++ b/renderers/angular/package-lock.json @@ -10,6 +10,7 @@ "license": "Apache-2.0", "dependencies": { "@a2ui/web_core": "file:../web_core", + "@preact/signals-core": "^1.13.0", "tslib": "^2.3.0" }, "devDependencies": { @@ -23,6 +24,8 @@ "@types/node": "^20.17.19", "@types/uuid": "^10.0.0", "@vitest/browser": "^4.0.15", + "ajv": "^8.18.0", + "ajv-formats": "^3.0.1", "cypress": "^15.6.0", "google-artifactregistry-auth": "^3.5.0", "jasmine-core": "~5.9.0", @@ -476,6 +479,14 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@angular/cli/node_modules/zod": { + "version": "4.3.6", + "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==", @@ -2343,9 +2354,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2362,9 +2370,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2381,9 +2386,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2400,9 +2402,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2419,9 +2418,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2438,9 +2434,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2457,9 +2450,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2927,9 +2917,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2950,9 +2937,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2973,9 +2957,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2996,9 +2977,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3019,9 +2997,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3042,9 +3017,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3131,6 +3103,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@preact/signals-core": { + "version": "1.14.0", + "integrity": "sha512-AowtCcCU/33lFlh1zRFf/u+12rfrhtNakj7UpaGEsmMwUKpKWMVvcktOGcwBBNiB4lWrZWc01LhiyyzVklJyaQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.4", "integrity": "sha512-vRq9f4NzvbdZavhQbjkJBx7rRebDKYR9zHfO/Wg486+I7bSecdUapzCm5cyXoK+LHokTxgSq7A5baAXUZkIz0w==", @@ -3218,9 +3199,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3237,9 +3215,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3256,9 +3231,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3275,9 +3247,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3490,9 +3459,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3506,9 +3472,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3522,9 +3485,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3538,9 +3498,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3554,9 +3511,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3570,9 +3524,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3586,9 +3537,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3602,9 +3550,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3618,9 +3563,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3634,9 +3576,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3650,9 +3589,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3666,9 +3602,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3682,9 +3615,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6780,7 +6710,6 @@ "version": "2.3.3", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, - "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ @@ -10062,7 +9991,6 @@ "version": "2.3.2", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, - "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ diff --git a/renderers/angular/package.json b/renderers/angular/package.json index b99db8be9..c38342b59 100644 --- a/renderers/angular/package.json +++ b/renderers/angular/package.json @@ -1,18 +1,23 @@ { "name": "@a2ui/angular", - "version": "0.8.5", + "version": "0.9.0", "license": "Apache-2.0", "scripts": { - "build": "ng build && node postprocess-build.mjs" + "build": "ng build && node postprocess-build.mjs", + "demo": "ng serve demo-app", + "test": "ng test lib --watch=false" }, "dependencies": { "@a2ui/web_core": "file:../web_core", + "@preact/signals-core": "^1.13.0", + "rxjs": "^7.8.2", "tslib": "^2.3.0" }, "peerDependencies": { "@angular/common": "^21.2.0", "@angular/core": "^21.2.0", - "@angular/platform-browser": "^21.2.0" + "@angular/platform-browser": "^21.2.0", + "rxjs": "^7.8.1" }, "devDependencies": { "@angular/build": "^21.2.0", @@ -25,6 +30,8 @@ "@types/node": "^20.17.19", "@types/uuid": "^10.0.0", "@vitest/browser": "^4.0.15", + "ajv": "^8.18.0", + "ajv-formats": "^3.0.1", "cypress": "^15.6.0", "google-artifactregistry-auth": "^3.5.0", "jasmine-core": "~5.9.0", 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 07f26a00d..07a6da8da 100644 --- a/renderers/angular/src/public-api.ts +++ b/renderers/angular/src/public-api.ts @@ -1,5 +1,5 @@ /* - * Copyright 2025 Google LLC + * 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. @@ -14,8 +14,10 @@ * limitations under the License. */ -export * from './lib/rendering/index'; -export * from './lib/data/index'; -export * from './lib/config'; -export * from './lib/catalog/default'; -export { Surface } from './lib/catalog/surface'; +/** + * The version of @a2ui/angular package. + * Consumers are encouraged to use version subpaths instead: + * - @a2ui/angular/v0_8 + * - @a2ui/angular/v0_9 + */ +export const A2UI_ANGULAR_VERSION = '0.9.0'; diff --git a/renderers/angular/tsconfig.json b/renderers/angular/tsconfig.json index 9f6412a72..96377acf3 100644 --- a/renderers/angular/tsconfig.json +++ b/renderers/angular/tsconfig.json @@ -11,7 +11,12 @@ "experimentalDecorators": true, "importHelpers": true, "target": "ES2022", - "module": "preserve" + "module": "preserve", + "baseUrl": ".", + "paths": { + "@a2ui/angular": ["src/public-api.ts"], + "@preact/signals-core": ["../node_modules/@preact/signals-core"] + } }, "angularCompilerOptions": { "enableI18nLegacyMessageIdFormat": false, @@ -19,5 +24,13 @@ "strictInputAccessModifiers": true, "typeCheckHostBindings": true, "strictTemplates": true - } + }, + "references": [ + { + "path": "./demo-app/tsconfig.app.json" + }, + { + "path": "./demo-app/tsconfig.spec.json" + } + ] } 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_8/catalog/catalog.spec.ts b/renderers/angular/v0_8/catalog/catalog.spec.ts new file mode 100644 index 000000000..319b2d6c9 --- /dev/null +++ b/renderers/angular/v0_8/catalog/catalog.spec.ts @@ -0,0 +1,325 @@ +/* + * 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 + * + * https://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, computed, inputBinding } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { Row } from '../components/row'; +import { Column } from '../components/column'; +import { Text as TextComponent } from '../components/text'; +import { Button } from '../components/button'; +import { List } from '../components/list'; +import { TextField } from '../components/text-field'; + +import { DynamicComponent } from '../rendering/dynamic-component'; +import { Renderer } from '../rendering/renderer'; +import { Types } from '../types'; +import { MarkdownRenderer } from '../data/markdown'; + +import { Theme } from '../rendering/theming'; +import { MessageProcessor } from '../data/processor'; +import { Catalog } from '../rendering/catalog'; + +// Mock context will be handled by MessageProcessor mock +const mockContext = { + resolveData: (path: string) => { + if (path === '/data/text') return 'Dynamic Text'; + if (path === '/data/label') return 'Dynamic Label'; + return null; + }, +}; + +@Component({ + selector: 'test-host', + imports: [Row, Column, TextComponent, Button, List, TextField], + template: ` + @if (type === 'Row') { + + } @else if (type === 'Column') { + + } @else if (type === 'Text') { + + } @else if (type === 'Button') { + + } @else if (type === 'List') { + + } @else if (type === 'TextField') { + + } + `, +}) +class TestHostComponent { + @Input() type = 'Row'; + @Input() componentData: any; +} + +describe('Catalog Components', () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + let mockMessageProcessor: any; + let mockDataModel: any; + let mockSurfaceModel: any; + + beforeEach(async () => { + mockDataModel = { + get: jasmine.createSpy('get').and.callFake((path: string) => { + if (path === '/data/text') return 'Dynamic Text'; + if (path === '/data/label') return 'Dynamic Label'; + if (path === '/data/items') return ['Item 1', 'Item 2']; + return null; + }), + subscribe: jasmine.createSpy('subscribe').and.returnValue({ + unsubscribe: () => {}, + }), + }; + + mockSurfaceModel = { + dataModel: mockDataModel, + componentsModel: new Map([ + ['child1', { id: 'child1', type: 'Text', properties: { text: { literal: 'Child Text' } } }], + ['item1', { id: 'item1', type: 'Text', properties: { text: { literal: 'Item 1' } } }], + ['item2', { id: 'item2', type: 'Text', properties: { text: { literal: 'Item 2' } } }], + ]), + }; + + const surfaceSignal = () => mockSurfaceModel; + + mockMessageProcessor = { + model: { + getSurface: (id: string) => mockSurfaceModel, + }, + getDataModel: jasmine.createSpy('getDataModel').and.returnValue(mockDataModel), + getData: (node: any, path: string) => mockDataModel.get(path), + getSurfaceSignal: () => surfaceSignal, + sendAction: jasmine.createSpy('sendAction'), + getSurfaces: () => new Map([['test-surface', mockSurfaceModel]]), + }; + + await TestBed.configureTestingModule({ + imports: [TestHostComponent, Row, Column, TextComponent, Button, List], + providers: [ + { provide: MarkdownRenderer, useValue: { render: (s: string) => Promise.resolve(s) } }, + + { provide: MessageProcessor, useValue: mockMessageProcessor }, + { + provide: Theme, + useValue: { + components: { + Text: { all: {}, h1: { 'h1-class': true }, body: { 'body-class': true } }, + Row: { 'row-class': true }, + Column: { 'column-class': true }, + Button: { 'button-class': true }, + List: { 'list-class': true }, + TextField: { + container: { 'tf-container': true }, + label: { 'tf-label': true }, + element: { 'tf-element': true }, + }, + }, + additionalStyles: {}, + }, + }, + { provide: MessageProcessor, useValue: mockMessageProcessor }, + { + provide: Catalog, + useValue: { + Text: { + type: async () => TextComponent, + bindings: (node: any) => [ + inputBinding('text', () => node.properties.text), + inputBinding('usageHint', () => node.properties.usageHint || null), + ], + }, + Row: { type: async () => Row, bindings: () => [] }, + Column: { type: async () => Column, bindings: () => [] }, + Button: { type: async () => Button, bindings: () => [] }, + List: { type: async () => List, bindings: () => [] }, + TextField: { type: async () => TextField, bindings: () => [] }, + } as any, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + }); + + describe('Row', () => { + it('should map justify and align properties correctly', () => { + host.type = 'Row'; + host.componentData = { + id: 'row1', + type: 'Row', + properties: { + children: [], + justify: 'spaceBetween', + align: 'center', + }, + } as Types.RowNode; + fixture.detectChanges(); + + const rowEl = fixture.debugElement.query(By.css('a2ui-row')); + const section = rowEl.query(By.css('section')); + expect(section.classes['distribute-spaceBetween']).toBeTrue(); + expect(section.classes['align-center']).toBeTrue(); + }); + }); + + describe('Column', () => { + it('should map justify and align properties correctly', () => { + host.type = 'Column'; + host.componentData = { + id: 'col1', + type: 'Column', + properties: { + children: [], + justify: 'end', + align: 'start', + }, + } as Types.ColumnNode; + fixture.detectChanges(); + + const colEl = fixture.debugElement.query(By.css('a2ui-column')); + const section = colEl.query(By.css('section')); + expect(section.classes['distribute-end']).toBeTrue(); + expect(section.classes['align-start']).toBeTrue(); + }); + }); + + describe('Text', () => { + it('should resolve text content', async () => { + host.type = 'Text'; + host.componentData = { + id: 'txt1', + type: 'Text', + properties: { + text: { literal: 'Hello World' }, + usageHint: 'h1', + }, + } as Types.TextNode; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const textEl = fixture.debugElement.query(By.css('a2ui-text')); + expect(textEl.nativeElement.innerHTML).toContain('# Hello World'); + }); + }); + + describe('Button', () => { + it('should render child component', () => { + // Mock Renderer Service/Context because Button uses a2ui-renderer for child + // For this unit test, we might just check if it tries to resolve the child. + // But Button uses + // We need to provide a mock SurfaceModel to the Button via the Context? + // Actually DynamicComponent uses `inject(ElementRef)` etc. + // Let's keep it simple for now and verify existence. + host.type = 'Button'; + host.componentData = { + id: 'btn1', + type: 'Button', + properties: { + child: 'child1', + label: 'Legacy Label', + }, + } as any; + fixture.detectChanges(); + const btnEl = fixture.debugElement.query(By.css('button')); + expect(btnEl).toBeTruthy(); + }); + }); + + describe('List', () => { + it('should render items', async () => { + host.type = 'List'; + host.componentData = { + id: 'list1', + type: 'List', + properties: { + children: [ + { + id: '1', + type: 'Text', + properties: { text: { literal: 'Item 1' }, variant: { literal: 'body' } }, + }, + { + id: '2', + type: 'Text', + properties: { text: { literal: 'Item 2' }, variant: { literal: 'body' } }, + }, + ], + }, + } as any; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const listEl = fixture.debugElement.query(By.css('a2ui-list')); + const items = listEl.queryAll(By.css('a2ui-text')); // Assuming items render as Text + expect(items.length).toBe(2); + expect(items[0].nativeElement.textContent).toContain('Item 1'); + }); + }); + + describe('TextField', () => { + it('should render input with value', () => { + host.type = 'TextField'; + host.componentData = { + id: 'tf1', + type: 'TextField', + properties: { + label: { literal: 'My Input' }, + value: { path: '/data/text' }, + }, + } as any; + fixture.detectChanges(); + + const inputEl = fixture.debugElement.query(By.css('input')); + // Component might use [value] or ngModel + // Let's check native element value if bound + // If it uses Custom Input implementation, check that. + // TextField usually has a label and an input. + expect(inputEl.nativeElement.value).toBe('Dynamic Text'); + }); + }); +}); diff --git a/renderers/angular/src/lib/catalog/default.ts b/renderers/angular/v0_8/catalog/index.ts similarity index 61% rename from renderers/angular/src/lib/catalog/default.ts rename to renderers/angular/v0_8/catalog/index.ts index a794a323a..1cd6d1318 100644 --- a/renderers/angular/src/lib/catalog/default.ts +++ b/renderers/angular/v0_8/catalog/index.ts @@ -15,16 +15,13 @@ */ import { inputBinding } from '@angular/core'; -import * as Types from '@a2ui/web_core/types/types'; +import { Types } from '../types'; import { Catalog } from '../rendering/catalog'; -import { Row } from './row'; -import { Column } from './column'; -import { Text } from './text'; -export const DEFAULT_CATALOG: Catalog = { +export const CATALOG: Catalog = { Row: { - type: () => Row, - bindings: (node) => { + type: () => import('../components/row').then((r) => r.Row), + bindings: (node: Types.AnyComponentNode) => { const properties = (node as Types.RowNode).properties; return [ inputBinding('alignment', () => properties.alignment ?? 'stretch'), @@ -34,8 +31,8 @@ export const DEFAULT_CATALOG: Catalog = { }, Column: { - type: () => Column, - bindings: (node) => { + type: () => import('../components/column').then((r) => r.Column), + bindings: (node: Types.AnyComponentNode) => { const properties = (node as Types.ColumnNode).properties; return [ inputBinding('alignment', () => properties.alignment ?? 'stretch'), @@ -45,18 +42,18 @@ export const DEFAULT_CATALOG: Catalog = { }, List: { - type: () => import('./list').then((r) => r.List), - bindings: (node) => { + type: () => import('../components/list').then((r) => r.List), + bindings: (node: Types.AnyComponentNode) => { const properties = (node as Types.ListNode).properties; return [inputBinding('direction', () => properties.direction ?? 'vertical')]; }, }, - Card: () => import('./card').then((r) => r.Card), + Card: () => import('../components/card').then((r) => r.Card), Image: { - type: () => import('./image').then((r) => r.Image), - bindings: (node) => { + type: () => import('../components/image').then((r) => r.Image), + bindings: (node: Types.AnyComponentNode) => { const properties = (node as Types.ImageNode).properties; return [ inputBinding('url', () => properties.url), @@ -67,80 +64,77 @@ export const DEFAULT_CATALOG: Catalog = { }, Icon: { - type: () => import('./icon').then((r) => r.Icon), - bindings: (node) => { + type: () => import('../components/icon').then((r) => r.Icon), + bindings: (node: Types.AnyComponentNode) => { const properties = (node as Types.IconNode).properties; return [inputBinding('name', () => properties.name)]; }, }, Video: { - type: () => import('./video').then((r) => r.Video), - bindings: (node) => { + type: () => import('../components/video').then((r) => r.Video), + bindings: (node: Types.AnyComponentNode) => { const properties = (node as Types.VideoNode).properties; return [inputBinding('url', () => properties.url)]; }, }, AudioPlayer: { - type: () => import('./audio').then((r) => r.Audio), - bindings: (node) => { + type: () => import('../components/audio').then((r) => r.Audio), + bindings: (node: Types.AnyComponentNode) => { const properties = (node as Types.AudioPlayerNode).properties; return [inputBinding('url', () => properties.url)]; }, }, Text: { - type: () => Text, - bindings: (node) => { + type: () => import('../components/text').then((r) => r.Text), + bindings: (node: Types.AnyComponentNode) => { const properties = (node as Types.TextNode).properties; return [ inputBinding('text', () => properties.text), - inputBinding('usageHint', () => properties.usageHint || null), + inputBinding('usageHint', () => properties.usageHint), ]; }, }, Button: { - type: () => import('./button').then((r) => r.Button), - bindings: (node) => { + type: () => import('../components/button').then((r) => r.Button), + bindings: (node: Types.AnyComponentNode) => { const properties = (node as Types.ButtonNode).properties; - return [ - inputBinding('action', () => properties.action), - inputBinding('primary', () => properties.primary), - ]; + return [inputBinding('action', () => properties.action)]; }, }, - Divider: () => import('./divider').then((r) => r.Divider), + Divider: () => import('../components/divider').then((r) => r.Divider), MultipleChoice: { - type: () => import('./multiple-choice').then((r) => r.MultipleChoice), - bindings: (node) => { + type: () => import('../components/multiple-choice').then((r) => r.MultipleChoice), + bindings: (node: Types.AnyComponentNode) => { const properties = (node as Types.MultipleChoiceNode).properties; return [ inputBinding('options', () => properties.options || []), inputBinding('value', () => properties.selections), - inputBinding('description', () => 'Select an item'), // TODO: this should be defined in the properties + inputBinding('description', () => 'Select an item'), ]; }, }, TextField: { - type: () => import('./text-field').then((r) => r.TextField), - bindings: (node) => { + type: () => import('../components/text-field').then((r) => r.TextField), + bindings: (node: Types.AnyComponentNode) => { const properties = (node as Types.TextFieldNode).properties; return [ inputBinding('text', () => properties.text ?? null), inputBinding('label', () => properties.label), - inputBinding('textFieldType', () => properties.textFieldType), + inputBinding('inputType', () => properties.textFieldType), ]; }, }, DateTimeInput: { - type: () => import('./datetime-input').then((r) => r.DatetimeInput), - bindings: (node) => { + type: () => import('../components/datetime-input').then((r) => r.DatetimeInput), + bindings: (node: Types.AnyComponentNode) => { const properties = (node as Types.DateTimeInputNode).properties; return [ inputBinding('enableDate', () => properties.enableDate), @@ -151,8 +145,8 @@ export const DEFAULT_CATALOG: Catalog = { }, CheckBox: { - type: () => import('./checkbox').then((r) => r.Checkbox), - bindings: (node) => { + type: () => import('../components/checkbox').then((r) => r.Checkbox), + bindings: (node: Types.AnyComponentNode) => { const properties = (node as Types.CheckboxNode).properties; return [ inputBinding('label', () => properties.label), @@ -162,28 +156,30 @@ export const DEFAULT_CATALOG: Catalog = { }, Slider: { - type: () => import('./slider').then((r) => r.Slider), - bindings: (node) => { + type: () => import('../components/slider').then((r) => r.Slider), + bindings: (node: Types.AnyComponentNode) => { const properties = (node as Types.SliderNode).properties; return [ inputBinding('value', () => properties.value), inputBinding('minValue', () => properties.minValue), inputBinding('maxValue', () => properties.maxValue), - inputBinding('label', () => ''), // TODO: this should be defined in the properties + inputBinding('label', () => ''), ]; }, }, Tabs: { - type: () => import('./tabs').then((r) => r.Tabs), - bindings: (node) => { + type: () => import('../components/tabs').then((r) => r.Tabs), + bindings: (node: Types.AnyComponentNode) => { const properties = (node as Types.TabsNode).properties; return [inputBinding('tabs', () => properties.tabItems)]; }, }, Modal: { - type: () => import('./modal').then((r) => r.Modal), + type: () => import('../components/modal').then((r) => r.Modal), bindings: () => [], }, }; + +export const V0_8_CATALOG = CATALOG; diff --git a/renderers/angular/v0_8/components/audio.spec.ts b/renderers/angular/v0_8/components/audio.spec.ts new file mode 100644 index 000000000..9ddaf92ec --- /dev/null +++ b/renderers/angular/v0_8/components/audio.spec.ts @@ -0,0 +1,106 @@ +/* + * 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 + * + * https://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 { Audio } from './audio'; +import { MessageProcessor } from '../data/processor'; +import { Theme } from '../rendering/theming'; +import { Catalog } from '../rendering/catalog'; +import { MarkdownRenderer } from '../data/markdown'; +import { PLATFORM_ID } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; + +describe('Audio', () => { + let component: Audio; + let fixture: ComponentFixture