-
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 27 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,107 @@ | ||
| import { z } from 'zod'; | ||
|
|
||
| export const DataBinding = z.object({ | ||
| path: z.string().describe('A JSON Pointer path to a value in the data model.') | ||
| }); | ||
|
|
||
| export const FunctionCall = z.object({ | ||
| call: z.string().describe('The name of the function to call.'), | ||
| args: z.record(z.any()).describe('Arguments passed to the function.'), | ||
| returnType: z.enum(['string', 'number', 'boolean', 'array', 'object', 'any', 'void']).default('boolean') | ||
| }); | ||
|
|
||
| const LogicExpression: z.ZodType<any> = z.lazy(() => z.union([ | ||
| z.object({ and: z.array(LogicExpression).min(1) }), | ||
| z.object({ or: z.array(LogicExpression).min(1) }), | ||
| z.object({ not: LogicExpression }), | ||
| z.intersection(FunctionCall, z.object({ returnType: z.literal('boolean').optional() })), // FunctionCall returning boolean | ||
| z.object({ true: z.literal(true) }), | ||
| z.object({ false: z.literal(false) }) | ||
| ])); | ||
|
|
||
| const DynamicString = z.union([ | ||
| z.string(), | ||
| DataBinding, | ||
| // FunctionCall returning string (simplified schema for Zod, stricter in JSON Schema) | ||
| FunctionCall | ||
| ]); | ||
|
|
||
| const DynamicNumber = z.union([ | ||
| z.number(), | ||
| DataBinding, | ||
| FunctionCall | ||
| ]); | ||
|
|
||
| const DynamicBoolean = z.union([ | ||
| z.boolean(), | ||
| DataBinding, | ||
| LogicExpression | ||
| ]); | ||
|
|
||
| const DynamicStringList = z.union([ | ||
| z.array(z.string()), | ||
| DataBinding, | ||
| FunctionCall | ||
| ]); | ||
|
|
||
| export const DynamicValue = z.union([ | ||
| z.string(), | ||
| z.number(), | ||
| z.boolean(), | ||
| z.array(z.any()), | ||
| DataBinding, | ||
| FunctionCall | ||
| ]); | ||
|
|
||
| export type DataBindingDef = z.infer<typeof DataBinding>; | ||
| export type FunctionCallDef = z.infer<typeof FunctionCall>; | ||
| export type DynamicValueDef = z.infer<typeof DynamicValue>; | ||
|
|
||
| const ComponentId = z.string().describe('The unique identifier for a component.'); | ||
|
|
||
| const ChildList = z.union([ | ||
| z.array(ComponentId).describe('A static list of child component IDs.'), | ||
| z.object({ | ||
| componentId: ComponentId, | ||
| path: z.string().describe('The path to the list of component property objects in the data model.') | ||
| }).describe('A template for generating a dynamic list of children.') | ||
| ]); | ||
|
|
||
| const Action = z.union([ | ||
| z.object({ | ||
| event: z.object({ | ||
| name: z.string(), | ||
| context: z.record(DynamicValue).optional() | ||
| }) | ||
| }).describe('Triggers a server-side event.'), | ||
| z.object({ | ||
| functionCall: FunctionCall | ||
| }).describe('Executes a local client-side function.') | ||
| ]); | ||
|
|
||
| const CheckRule = z.intersection( | ||
| LogicExpression, | ||
| z.object({ | ||
| message: z.string().describe('The error message to display if the check fails.') | ||
| }) | ||
| ); | ||
|
|
||
| const Checkable = z.object({ | ||
| checks: z.array(CheckRule).optional().describe('A list of checks to perform.') | ||
| }); | ||
|
|
||
| export const CommonTypes = { | ||
| ComponentId, | ||
| ChildList, | ||
| DataBinding, | ||
| DynamicValue, | ||
| DynamicString, | ||
| DynamicNumber, | ||
| DynamicBoolean, | ||
| DynamicStringList, | ||
| FunctionCall, | ||
| LogicExpression, | ||
| CheckRule, | ||
| Checkable, | ||
| Action, | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
|
|
||
| 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 **custom properties** of this component. | ||
| * | ||
| * - MUST include catalog-specific common properties (e.g. 'weight'). | ||
| * - MUST NOT include 'component', 'id', or 'accessibility' as those are | ||
| * handled by the framework/envelope. | ||
| */ | ||
| readonly schema: z.ZodType<any>; | ||
| } | ||
|
|
||
| export interface CatalogApi { | ||
| id: string; | ||
|
|
||
| /** | ||
| * A map of available components. | ||
| * This is readonly to encourage immutable extension patterns. | ||
| */ | ||
| readonly components: ReadonlyMap<string, ComponentApi>; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
|
|
||
| export * from './state/data-model.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'; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
|
|
||
| import assert from 'node:assert'; | ||
| import { test, describe, it, beforeEach } from 'node:test'; | ||
| import { MessageProcessor } from './message-processor.js'; | ||
| import { CatalogApi } from '../catalog/types.js'; | ||
|
|
||
| describe('MessageProcessor', () => { | ||
| let processor: MessageProcessor<CatalogApi>; | ||
| let testCatalog: CatalogApi; | ||
| let actions: any[] = []; | ||
|
|
||
| beforeEach(() => { | ||
| actions = []; | ||
| testCatalog = { | ||
| id: 'test-catalog', | ||
| components: new Map() | ||
| }; | ||
| processor = new MessageProcessor<CatalogApi>([testCatalog], async (a) => { actions.push(a); }); | ||
| }); | ||
|
|
||
| it('creates surface', () => { | ||
| processor.processMessages([{ | ||
| createSurface: { | ||
| surfaceId: 's1', | ||
| catalogId: 'test-catalog', | ||
| theme: {} | ||
| } | ||
| }]); | ||
| const surface = processor.getSurfaceModel('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.getSurfaceModel('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.getSurfaceModel('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.getSurfaceModel('s1')); | ||
|
|
||
| processor.processMessages([{ | ||
| deleteSurface: { surfaceId: 's1' } | ||
| }]); | ||
| assert.strictEqual(processor.getSurfaceModel('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.getSurfaceModel('s1'); | ||
| assert.strictEqual(surface?.dataModel.get('/foo'), 'bar'); | ||
| }); | ||
|
|
||
| it('notifies lifecycle listeners', () => { | ||
| let created: any = null; | ||
| let deletedId: string | null = null; | ||
|
|
||
| const unsubscribe = processor.addLifecycleListener({ | ||
| onSurfaceCreated: (s) => created = s, | ||
| 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; | ||
| unsubscribe(); | ||
| processor.processMessages([{ | ||
| createSurface: { surfaceId: 's2', catalogId: 'test-catalog' } | ||
| }]); | ||
| assert.strictEqual(created, null); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.