-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Add a framework-agnostic data layer to the web core library #631
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6ab973e
89db879
4f43a8e
3b9ce30
d2f663b
aa1196d
ca866e3
a01b828
53e9469
5e62b6d
b77455c
a15804d
d82673e
7647e3d
310fc80
951fa0b
98b26c6
8a088f2
b412047
14e2ca5
6e15d44
e3b2f65
68efefc
bb1e362
95ab135
dd34140
d70d5ee
b38c982
b331957
5733cef
248e574
93bec2b
48edaed
f5e3f2d
ed611f1
b13accd
a4fa67b
144f703
4839444
6a5b9b6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import { z } from 'zod'; | ||
|
|
||
| /** | ||
| * A definition of a UI component's API. | ||
| * This interface defines the contract for a component's capabilities and properties, | ||
| * independent of any specific rendering implementation. | ||
| */ | ||
| export interface ComponentApi { | ||
| /** The name of the component as it appears in the A2UI JSON (e.g., 'Button'). */ | ||
| name: string; | ||
|
|
||
| /** | ||
| * The Zod schema describing the **properties** of this component. | ||
| * | ||
| * - MUST include catalog-specific common properties (e.g. 'weight', 'accessibility'). | ||
| * - MUST NOT include 'component' or 'id' as those are handled by the framework/envelope. | ||
| */ | ||
| readonly schema: z.ZodType<any>; | ||
| } | ||
|
|
||
| export class Catalog<T extends ComponentApi> { | ||
| readonly id: string; | ||
|
|
||
| /** | ||
| * A map of available components. | ||
| * This is readonly to encourage immutable extension patterns. | ||
| */ | ||
| readonly components: ReadonlyMap<string, T>; | ||
|
|
||
| constructor(id: string, components: T[]) { | ||
| this.id = id; | ||
| const map = new Map<string, T>(); | ||
| for (const comp of components) { | ||
| map.set(comp.name, comp); | ||
| } | ||
| this.components = map; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| /** Standard cleanup interface returned by all subscriptions. */ | ||
| export interface Subscription { | ||
| unsubscribe(): void; | ||
| } | ||
|
|
||
| /** The listener function signature. */ | ||
| export type EventListener<T> = (data: T) => void | Promise<void>; | ||
|
|
||
| /** | ||
| * Public interface exposed by models. | ||
| * Allows ONLY subscribing to events. | ||
| */ | ||
| export interface EventSource<T> { | ||
| subscribe(listener: EventListener<T>): Subscription; | ||
| } | ||
|
|
||
| /** | ||
| * Internal implementation used by the model. | ||
| * Implements EventSource but also provides the 'emit' method. | ||
| */ | ||
| export class EventEmitter<T> implements EventSource<T> { | ||
| private listeners = new Set<EventListener<T>>(); | ||
|
|
||
| subscribe(listener: EventListener<T>): Subscription { | ||
| this.listeners.add(listener); | ||
| return { | ||
| unsubscribe: () => this.listeners.delete(listener) | ||
| }; | ||
| } | ||
|
|
||
| async emit(data: T): Promise<void> { | ||
| for (const listener of this.listeners) { | ||
| try { | ||
| await listener(data); | ||
| } catch (e) { | ||
| console.error('EventEmitter error:', e); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| dispose(): void { | ||
| this.listeners.clear(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
|
|
||
| export * from './state/data-model.js'; | ||
| export * from './common/events.js'; | ||
| export * from './rendering/data-context.js'; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should each of these be a different library, so you can import from
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This makes sense. Maybe we can figure it out later though? |
||
| export * from './state/surface-model.js'; | ||
| export * from './processing/message-processor.js'; | ||
| export * from './catalog/types.js'; | ||
| export * from './rendering/component-context.js'; | ||
| export * from './state/surface-group-model.js'; | ||
| export * from './state/surface-components-model.js'; | ||
| export * from './schema/index.js'; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| import assert from 'node:assert'; | ||
| import { test, describe, it, beforeEach } from 'node:test'; | ||
| import { MessageProcessor } from './message-processor.js'; | ||
| import { Catalog, ComponentApi } from '../catalog/types.js'; | ||
|
|
||
| describe('MessageProcessor', () => { | ||
| let processor: MessageProcessor<ComponentApi>; | ||
| let testCatalog: Catalog<ComponentApi>; | ||
| let actions: any[] = []; | ||
|
|
||
| beforeEach(() => { | ||
| actions = []; | ||
| testCatalog = new Catalog('test-catalog', []); | ||
| processor = new MessageProcessor<ComponentApi>([testCatalog], async (a) => { actions.push(a); }); | ||
| }); | ||
|
|
||
| it('creates surface', () => { | ||
| processor.processMessages([{ | ||
| createSurface: { | ||
| surfaceId: 's1', | ||
| catalogId: 'test-catalog', | ||
| theme: {} | ||
| } | ||
| }]); | ||
| const surface = processor.model.getSurface('s1'); | ||
| assert.ok(surface); | ||
| assert.strictEqual(surface.id, 's1'); | ||
| }); | ||
|
|
||
| it('updates components on correct surface', () => { | ||
| processor.processMessages([{ | ||
| createSurface: { surfaceId: 's1', catalogId: 'test-catalog' } | ||
| }]); | ||
|
|
||
| processor.processMessages([{ | ||
| updateComponents: { | ||
| surfaceId: 's1', | ||
| components: [{ id: 'root', component: 'Box' }] | ||
| } | ||
| }]); | ||
|
|
||
| const surface = processor.model.getSurface('s1'); | ||
| assert.ok(surface?.componentsModel.get('root')); | ||
| }); | ||
|
|
||
| it('updates existing components via message', () => { | ||
| processor.processMessages([{ | ||
| createSurface: { surfaceId: 's1', catalogId: 'test-catalog' } | ||
| }]); | ||
|
|
||
| // Create | ||
| processor.processMessages([{ | ||
| updateComponents: { | ||
| surfaceId: 's1', | ||
| components: [{ id: 'btn', component: 'Button', label: 'Initial' }] | ||
| } | ||
| }]); | ||
|
|
||
| const surface = processor.model.getSurface('s1'); | ||
| const btn = surface?.componentsModel.get('btn'); | ||
| assert.strictEqual(btn?.properties.label, 'Initial'); | ||
|
|
||
| // Update | ||
| processor.processMessages([{ | ||
| updateComponents: { | ||
| surfaceId: 's1', | ||
| components: [{ id: 'btn', component: 'Button', label: 'Updated' }] | ||
| } | ||
| }]); | ||
|
|
||
| assert.strictEqual(btn?.properties.label, 'Updated'); | ||
| }); | ||
|
|
||
| it('deletes surface', () => { | ||
| processor.processMessages([{ | ||
| createSurface: { surfaceId: 's1', catalogId: 'test-catalog' } | ||
| }]); | ||
| assert.ok(processor.model.getSurface('s1')); | ||
|
|
||
| processor.processMessages([{ | ||
| deleteSurface: { surfaceId: 's1' } | ||
| }]); | ||
| assert.strictEqual(processor.model.getSurface('s1'), undefined); | ||
| }); | ||
|
|
||
| it('routes data model updates', () => { | ||
| processor.processMessages([{ | ||
| createSurface: { surfaceId: 's1', catalogId: 'test-catalog' } | ||
| }]); | ||
|
|
||
| processor.processMessages([{ | ||
| updateDataModel: { | ||
| surfaceId: 's1', | ||
| path: '/foo', | ||
| value: 'bar' | ||
| } | ||
| }]); | ||
|
|
||
| const surface = processor.model.getSurface('s1'); | ||
| assert.strictEqual(surface?.dataModel.get('/foo'), 'bar'); | ||
| }); | ||
|
|
||
| it('notifies lifecycle listeners', () => { | ||
| let created: any = null; | ||
| let deletedId: string | null = null; | ||
|
|
||
| const sub = processor.onSurfaceCreated((s) => { created = s; }); | ||
| const sub2 = processor.onSurfaceDeleted((id) => { deletedId = id; }); | ||
|
|
||
| // Create | ||
| processor.processMessages([{ | ||
| createSurface: { surfaceId: 's1', catalogId: 'test-catalog' } | ||
| }]); | ||
| assert.ok(created); | ||
| assert.strictEqual(created.id, 's1'); | ||
|
|
||
| // Delete | ||
| processor.processMessages([{ | ||
| deleteSurface: { surfaceId: 's1' } | ||
| }]); | ||
| assert.strictEqual(deletedId, 's1'); | ||
|
|
||
| // Test Unsubscribe | ||
| created = null; | ||
| sub.unsubscribe(); | ||
| processor.processMessages([{ | ||
| createSurface: { surfaceId: 's2', catalogId: 'test-catalog' } | ||
| }]); | ||
| assert.strictEqual(created, null); | ||
|
|
||
| sub2.unsubscribe(); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.