feat: native @a2ui/angular rendering — custom catalog - #121
Conversation
|
Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA). View this failed invocation of the CLA check for more information. For the most up to date status, view the checks section at the bottom of the pull request. |
There was a problem hiding this comment.
Code Review
This pull request introduces several new features and architectural updates, including a native /custom-catalog route with assembled demo components, a /catalog-reference route for component documentation and source viewing, a /theater route for protocol playback, and a masonry /gallery showcase. The existing components gallery has been moved to /basic-catalog. Feedback focuses on improving code robustness and aligning with Angular best practices. Specifically, the path resolution in the gallery codegen script should be made more resilient to standard clone structures, and Angular effects used for propagating state changes and writing to signals in CustomCatalog and CatalogReference should be replaced with explicit state updates in event handlers and view-switching methods.
| const defaultSource = resolve( | ||
| scriptDir, | ||
| '../../../../private_a2ui_demo/apps/widget-builder/src/data/gallery/v09/generated.ts', | ||
| ); | ||
| const sourceFile = process.argv[2] ? resolve(process.argv[2]) : defaultSource; |
There was a problem hiding this comment.
The hardcoded relative path ../../../../private_a2ui_demo assumes a specific nested worktree directory structure (e.g., composer-wt/ux-overhaul) and will break for other developers who clone the repository normally (where private_a2ui_demo is a sibling of the repository root).
We can make this robust for both standard clones and nested worktrees by checking if the standard sibling path exists first, and falling back to the worktree path if not.
const standardSource = resolve(
shellDir,
'../../private_a2ui_demo/apps/widget-builder/src/data/gallery/v09/generated.ts',
);
const worktreeSource = resolve(
scriptDir,
'../../../../private_a2ui_demo/apps/widget-builder/src/data/gallery/v09/generated.ts',
);
const defaultSource = existsSync(standardSource) ? standardSource : worktreeSource;
const sourceFile = process.argv[2] ? resolve(process.argv[2]) : defaultSource;| constructor() { | ||
| // Re-render the surface whenever the selected example or data state changes, | ||
| // and reset the editor to that state's data model. Writes here don't feed | ||
| // the effect (untracked), so this runs once per selection, not per keystroke. | ||
| effect(() => { | ||
| const example = this.activeExample(); | ||
| const index = this.activeStateIndex(); | ||
| const data = example.states[index]?.data ?? {}; | ||
| untracked(() => { | ||
| this.editorValue.set(JSON.stringify(data, null, 2)); | ||
| this.applyData(example, data); | ||
| }); | ||
| }); | ||
| } |
There was a problem hiding this comment.
Using Angular effect to propagate state changes and trigger imperative side effects (like writing to other signals or calling services) is generally discouraged. According to Angular best practices, side effects and state updates should be handled directly in event listeners/handlers whenever possible to prevent cascading updates and maintain clear data flow.
Since the active example and state index are only changed via user interaction (selectExample and selectState), we can initialize the state in the constructor and handle updates explicitly in those methods. This completely eliminates the need for the effect.
constructor() {
const example = this.activeExample();
const data = example.states[0]?.data ?? {};
this.editorValue.set(JSON.stringify(data, null, 2));
this.applyData(example, data);
}| /** Selects a demo tree, resetting to its first data state. */ | ||
| protected selectExample(id: string): void { | ||
| if (id === this.activeExampleId()) return; | ||
| this.activeStateIndex.set(0); | ||
| this.activeExampleId.set(id); | ||
| } | ||
|
|
||
| /** Selects one of the active demo's preset data states. */ | ||
| protected selectState(index: number): void { | ||
| this.activeStateIndex.set(index); | ||
| } |
There was a problem hiding this comment.
In conjunction with removing the effect from the constructor, update selectExample and selectState to explicitly update the editor value and apply the new data model. This makes the data flow much more predictable and easier to debug.
/** Selects a demo tree, resetting to its first data state. */
protected selectExample(id: string): void {
if (id === this.activeExampleId()) return;
this.activeStateIndex.set(0);
this.activeExampleId.set(id);
const example = this.examples.find(e => e.id === id) ?? this.examples[0];
const data = example.states[0]?.data ?? {};
this.editorValue.set(JSON.stringify(data, null, 2));
this.applyData(example, data);
}
/** Selects one of the active demo's preset data states. */
protected selectState(index: number): void {
this.activeStateIndex.set(index);
const example = this.activeExample();
const data = example.states[index]?.data ?? {};
this.editorValue.set(JSON.stringify(data, null, 2));
this.applyData(example, data);
}| // Default-select the first file whenever a source view is entered (or its | ||
| // file list changes) and nothing valid is selected. | ||
| effect(() => { | ||
| const files = this.sourceFiles(); | ||
| if (this.viewMode() === 'components') return; | ||
| const hasSelection = untracked(() => | ||
| files.some(file => file.path === this.selectedSourcePath()), | ||
| ); | ||
| if (hasSelection) return; | ||
| const first = files[0]; | ||
| if (first) untracked(() => this.selectedSourcePath.set(first.path)); | ||
| }); |
There was a problem hiding this comment.
Writing to signals inside an effect is generally discouraged in Angular as it can lead to cascading updates, infinite loops, and runtime errors (and is disallowed by default in Angular 17+ without allowSignalWrites: true).
Since CATALOG_SOURCE is static data, the file list (sourceFiles) only changes when viewMode changes. We can completely eliminate this effect by setting the default selected file directly in setViewMode when switching views.
| protected setViewMode(mode: ReferenceView): void { | ||
| this.viewMode.set(mode); | ||
| } |
There was a problem hiding this comment.
In conjunction with removing the signal-writing effect from the constructor, update setViewMode to automatically select the first file when switching to a source view.
protected setViewMode(mode: ReferenceView): void {
this.viewMode.set(mode);
if (mode !== 'components') {
const files = mode === 'renderers' ? CATALOG_SOURCE.renderers : CATALOG_SOURCE.definitions;
if (files[0]) {
this.selectedSourcePath.set(files[0].path);
}
}
}ef6deb1 to
3f56de9
Compare
Enables in-app (no-iframe) rendering of custom catalogs. Pin shell zod to
3.25.76 so app-authored catalog schemas share the exact zod instance the
@a2ui/web_core binder introspects (Angular's own zod 4 stays nested/isolated).
Validated end-to-end: a shell-authored custom Title resolves a {path} binding
via <a2ui-v09-surface>.
Port of the react-flight-catalog dashboard catalog to native @a2ui/angular renderers (Row/Column/Title/Badge/Metric/DashboardCard/FlightCard/DataTable/ Button + hand-rolled SVG PieChart/BarChart), themed via --cpk-* tokens. buildDashboardCatalog() registers all 11 into an AngularCatalog. Surface-render tests green (children, chart shapes, path bindings, action dispatch).
Native /custom-catalog page: renders the Flight Card + Sales Dashboard trees via <a2ui-v09-surface> (no iframe) from the 11-component dashboard catalog, with an editable Monaco data-model panel. Example toggle + data-state tabs (Morning/Afternoon, Q1-Q4); editing the JSON live-updates the surface via updateDataModel. Surfaces are built once per example (createSurface+ updateComponents), so data changes push updateDataModel only. Nav item added to Google's list; shell spec nav counts updated for the new route.
2b5db7d to
41d0120
Compare
Adds native (no-iframe) A2UI v0.9 rendering via
@a2ui/angular, with an 11-component custom catalog as the first showcase. This is the foundation of a three-PR set — everything here is what the other two build on.What
@a2ui/angular+@a2ui/web_corefor native A2UI rendering, alongside the existing iframe sandbox./custom-catalog): two example trees assembled from the one catalog, data-model editable with live re-bind.startupGuard/iframe).Why
Demonstrates the native rendering path and provides a reusable component catalog that the follow-up PRs build directly on.
Testing
ng build(lint + AOT) green on a clean build; custom-catalog + shell unit tests pass (25 specs).Split note
Originally opened as one large PR spanning custom catalog + catalog reference + theater. Per review-size feedback it's now split into three stacked PRs, all based on
main:Because cross-repo PRs must target
main, #129 and #130 include this PR's commits in their diffs until this one merges.Contributed by CopilotKit.
Screenshots
Native
@a2ui/angularrendering (no iframe) — 11-component catalog with SVG charts: