@easyops-cn/a2ui-react 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/speckit.analyze.md +184 -0
- package/.claude/commands/speckit.checklist.md +294 -0
- package/.claude/commands/speckit.clarify.md +181 -0
- package/.claude/commands/speckit.constitution.md +82 -0
- package/.claude/commands/speckit.implement.md +135 -0
- package/.claude/commands/speckit.plan.md +89 -0
- package/.claude/commands/speckit.specify.md +256 -0
- package/.claude/commands/speckit.tasks.md +137 -0
- package/.claude/commands/speckit.taskstoissues.md +30 -0
- package/.github/workflows/deploy.yml +69 -0
- package/.husky/pre-commit +1 -0
- package/.prettierignore +4 -0
- package/.prettierrc +7 -0
- package/.specify/memory/constitution.md +73 -0
- package/.specify/scripts/bash/check-prerequisites.sh +166 -0
- package/.specify/scripts/bash/common.sh +156 -0
- package/.specify/scripts/bash/create-new-feature.sh +297 -0
- package/.specify/scripts/bash/setup-plan.sh +61 -0
- package/.specify/scripts/bash/update-agent-context.sh +799 -0
- package/.specify/templates/agent-file-template.md +28 -0
- package/.specify/templates/checklist-template.md +40 -0
- package/.specify/templates/plan-template.md +105 -0
- package/.specify/templates/spec-template.md +115 -0
- package/.specify/templates/tasks-template.md +250 -0
- package/CLAUDE.md +105 -0
- package/CONTRIBUTING.md +97 -0
- package/README.md +126 -0
- package/components.json +21 -0
- package/eslint.config.js +25 -0
- package/netlify.toml +50 -0
- package/package.json +94 -0
- package/playground/README.md +75 -0
- package/playground/index.html +22 -0
- package/playground/package.json +32 -0
- package/playground/public/favicon.svg +8 -0
- package/playground/src/App.css +256 -0
- package/playground/src/App.tsx +115 -0
- package/playground/src/assets/react.svg +1 -0
- package/playground/src/components/ErrorDisplay.tsx +13 -0
- package/playground/src/components/ExampleSelector.tsx +64 -0
- package/playground/src/components/Header.tsx +47 -0
- package/playground/src/components/JsonEditor.tsx +32 -0
- package/playground/src/components/Preview.tsx +78 -0
- package/playground/src/components/ThemeToggle.tsx +19 -0
- package/playground/src/data/examples.ts +1571 -0
- package/playground/src/hooks/useTheme.ts +55 -0
- package/playground/src/index.css +220 -0
- package/playground/src/main.tsx +10 -0
- package/playground/tsconfig.app.json +34 -0
- package/playground/tsconfig.json +13 -0
- package/playground/tsconfig.node.json +26 -0
- package/playground/vite.config.ts +31 -0
- package/specs/001-a2ui-renderer/checklists/requirements.md +41 -0
- package/specs/001-a2ui-renderer/data-model.md +140 -0
- package/specs/001-a2ui-renderer/plan.md +123 -0
- package/specs/001-a2ui-renderer/quickstart.md +141 -0
- package/specs/001-a2ui-renderer/research.md +140 -0
- package/specs/001-a2ui-renderer/spec.md +165 -0
- package/specs/001-a2ui-renderer/tasks.md +310 -0
- package/specs/002-playground/checklists/requirements.md +37 -0
- package/specs/002-playground/contracts/components.md +120 -0
- package/specs/002-playground/data-model.md +149 -0
- package/specs/002-playground/plan.md +73 -0
- package/specs/002-playground/quickstart.md +158 -0
- package/specs/002-playground/research.md +117 -0
- package/specs/002-playground/spec.md +109 -0
- package/specs/002-playground/tasks.md +224 -0
- package/src/0.8/A2UIRender.test.tsx +793 -0
- package/src/0.8/A2UIRender.tsx +142 -0
- package/src/0.8/components/ComponentRenderer.test.tsx +373 -0
- package/src/0.8/components/ComponentRenderer.tsx +163 -0
- package/src/0.8/components/UnknownComponent.tsx +49 -0
- package/src/0.8/components/display/AudioPlayerComponent.tsx +37 -0
- package/src/0.8/components/display/DividerComponent.tsx +23 -0
- package/src/0.8/components/display/IconComponent.tsx +137 -0
- package/src/0.8/components/display/ImageComponent.tsx +57 -0
- package/src/0.8/components/display/TextComponent.tsx +56 -0
- package/src/0.8/components/display/VideoComponent.tsx +31 -0
- package/src/0.8/components/display/display.test.tsx +660 -0
- package/src/0.8/components/display/index.ts +10 -0
- package/src/0.8/components/index.ts +14 -0
- package/src/0.8/components/interactive/ButtonComponent.tsx +44 -0
- package/src/0.8/components/interactive/CheckBoxComponent.tsx +45 -0
- package/src/0.8/components/interactive/DateTimeInputComponent.tsx +176 -0
- package/src/0.8/components/interactive/MultipleChoiceComponent.tsx +157 -0
- package/src/0.8/components/interactive/SliderComponent.tsx +53 -0
- package/src/0.8/components/interactive/TextFieldComponent.tsx +65 -0
- package/src/0.8/components/interactive/index.ts +10 -0
- package/src/0.8/components/interactive/interactive.test.tsx +618 -0
- package/src/0.8/components/layout/CardComponent.tsx +30 -0
- package/src/0.8/components/layout/ColumnComponent.tsx +93 -0
- package/src/0.8/components/layout/ListComponent.tsx +81 -0
- package/src/0.8/components/layout/ModalComponent.tsx +41 -0
- package/src/0.8/components/layout/RowComponent.tsx +94 -0
- package/src/0.8/components/layout/TabsComponent.tsx +59 -0
- package/src/0.8/components/layout/index.ts +10 -0
- package/src/0.8/components/layout/layout.test.tsx +558 -0
- package/src/0.8/contexts/A2UIProvider.test.tsx +226 -0
- package/src/0.8/contexts/A2UIProvider.tsx +54 -0
- package/src/0.8/contexts/ActionContext.test.tsx +242 -0
- package/src/0.8/contexts/ActionContext.tsx +105 -0
- package/src/0.8/contexts/ComponentsMapContext.tsx +125 -0
- package/src/0.8/contexts/DataModelContext.test.tsx +335 -0
- package/src/0.8/contexts/DataModelContext.tsx +184 -0
- package/src/0.8/contexts/SurfaceContext.test.tsx +339 -0
- package/src/0.8/contexts/SurfaceContext.tsx +197 -0
- package/src/0.8/hooks/useA2UIMessageHandler.test.tsx +399 -0
- package/src/0.8/hooks/useA2UIMessageHandler.ts +123 -0
- package/src/0.8/hooks/useComponent.test.tsx +148 -0
- package/src/0.8/hooks/useComponent.ts +39 -0
- package/src/0.8/hooks/useDataBinding.test.tsx +334 -0
- package/src/0.8/hooks/useDataBinding.ts +99 -0
- package/src/0.8/hooks/useDispatchAction.test.tsx +83 -0
- package/src/0.8/hooks/useDispatchAction.ts +35 -0
- package/src/0.8/hooks/useSurface.test.tsx +114 -0
- package/src/0.8/hooks/useSurface.ts +34 -0
- package/src/0.8/index.ts +38 -0
- package/src/0.8/schemas/client_to_server.json +50 -0
- package/src/0.8/schemas/server_to_client.json +148 -0
- package/src/0.8/schemas/standard_catalog_definition.json +661 -0
- package/src/0.8/types/index.ts +448 -0
- package/src/0.8/utils/dataBinding.test.ts +443 -0
- package/src/0.8/utils/dataBinding.ts +212 -0
- package/src/0.8/utils/pathUtils.test.ts +353 -0
- package/src/0.8/utils/pathUtils.ts +200 -0
- package/src/components/ui/button.tsx +62 -0
- package/src/components/ui/calendar.tsx +220 -0
- package/src/components/ui/card.tsx +92 -0
- package/src/components/ui/checkbox.tsx +30 -0
- package/src/components/ui/dialog.tsx +141 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +22 -0
- package/src/components/ui/native-select.tsx +53 -0
- package/src/components/ui/popover.tsx +46 -0
- package/src/components/ui/select.tsx +188 -0
- package/src/components/ui/separator.tsx +26 -0
- package/src/components/ui/slider.tsx +61 -0
- package/src/components/ui/tabs.tsx +64 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/index.ts +1 -0
- package/src/lib/utils.ts +6 -0
- package/tsconfig.json +28 -0
- package/vite.config.ts +29 -0
- package/vitest.config.ts +22 -0
- package/vitest.setup.ts +8 -0
- package/website/README.md +4 -0
- package/website/assets/favicon.svg +8 -0
- package/website/content/.gitkeep +0 -0
- package/website/content/index.md +122 -0
- package/website/global.d.ts +9 -0
- package/website/package.json +17 -0
- package/website/plain.config.js +28 -0
- package/website/serve.json +6 -0
- package/website/src/client/color-mode-switch.css +47 -0
- package/website/src/client/index.js +61 -0
- package/website/src/client/moon.svg +1 -0
- package/website/src/client/sun.svg +1 -0
- package/website/src/components/Footer.jsx +9 -0
- package/website/src/components/Header.jsx +44 -0
- package/website/src/components/Page.jsx +28 -0
- package/website/src/global.css +423 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ComponentsMapContext - Context for custom component overrides.
|
|
3
|
+
*
|
|
4
|
+
* This context allows users to provide custom component implementations
|
|
5
|
+
* that override or extend the default component registry.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
createContext,
|
|
10
|
+
useContext,
|
|
11
|
+
useMemo,
|
|
12
|
+
type ReactNode,
|
|
13
|
+
type ComponentType,
|
|
14
|
+
} from 'react'
|
|
15
|
+
import type { BaseComponentProps } from '../types'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Type for a component in the components map.
|
|
19
|
+
*/
|
|
20
|
+
export type A2UIComponent = ComponentType<
|
|
21
|
+
BaseComponentProps & Record<string, unknown>
|
|
22
|
+
>
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Map of component type names to React components.
|
|
26
|
+
*/
|
|
27
|
+
export type ComponentsMap = Map<string, A2UIComponent>
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Context value for ComponentsMapContext.
|
|
31
|
+
*/
|
|
32
|
+
export interface ComponentsMapContextValue {
|
|
33
|
+
/** Custom components provided by the user */
|
|
34
|
+
customComponents: ComponentsMap
|
|
35
|
+
/** Get a component by type name (custom first, then default) */
|
|
36
|
+
getComponent: (type: string) => A2UIComponent | undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Context for custom component overrides.
|
|
41
|
+
*/
|
|
42
|
+
export const ComponentsMapContext =
|
|
43
|
+
createContext<ComponentsMapContextValue | null>(null)
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Props for ComponentsMapProvider.
|
|
47
|
+
*/
|
|
48
|
+
export interface ComponentsMapProviderProps {
|
|
49
|
+
/** Custom components to override or extend defaults */
|
|
50
|
+
components?: ComponentsMap
|
|
51
|
+
/** Default component registry */
|
|
52
|
+
defaultComponents: Record<string, A2UIComponent>
|
|
53
|
+
children: ReactNode
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Provider for custom component overrides.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```tsx
|
|
61
|
+
* const customComponents = new Map([
|
|
62
|
+
* ['Button', CustomButton],
|
|
63
|
+
* ['Switch', CustomSwitch],
|
|
64
|
+
* ])
|
|
65
|
+
*
|
|
66
|
+
* <ComponentsMapProvider components={customComponents} defaultComponents={defaultRegistry}>
|
|
67
|
+
* <App />
|
|
68
|
+
* </ComponentsMapProvider>
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
export function ComponentsMapProvider({
|
|
72
|
+
components,
|
|
73
|
+
defaultComponents,
|
|
74
|
+
children,
|
|
75
|
+
}: ComponentsMapProviderProps) {
|
|
76
|
+
const value = useMemo<ComponentsMapContextValue>(() => {
|
|
77
|
+
const customComponents = components ?? new Map()
|
|
78
|
+
|
|
79
|
+
const getComponent = (type: string): A2UIComponent | undefined => {
|
|
80
|
+
// Custom components take precedence over defaults
|
|
81
|
+
if (customComponents.has(type)) {
|
|
82
|
+
return customComponents.get(type)
|
|
83
|
+
}
|
|
84
|
+
return defaultComponents[type]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
customComponents,
|
|
89
|
+
getComponent,
|
|
90
|
+
}
|
|
91
|
+
}, [components, defaultComponents])
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<ComponentsMapContext.Provider value={value}>
|
|
95
|
+
{children}
|
|
96
|
+
</ComponentsMapContext.Provider>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Hook to access the ComponentsMap context.
|
|
102
|
+
*
|
|
103
|
+
* @throws Error if used outside of ComponentsMapProvider
|
|
104
|
+
*/
|
|
105
|
+
export function useComponentsMapContext(): ComponentsMapContextValue {
|
|
106
|
+
const context = useContext(ComponentsMapContext)
|
|
107
|
+
if (!context) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
'useComponentsMapContext must be used within a ComponentsMapProvider'
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
return context
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Hook to get a component by type name.
|
|
117
|
+
* Returns custom component if available, otherwise default.
|
|
118
|
+
*
|
|
119
|
+
* @param type - The component type name
|
|
120
|
+
* @returns The component or undefined if not found
|
|
121
|
+
*/
|
|
122
|
+
export function useComponentFromMap(type: string): A2UIComponent | undefined {
|
|
123
|
+
const { getComponent } = useComponentsMapContext()
|
|
124
|
+
return getComponent(type)
|
|
125
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataModelContext Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for the DataModel context provider and hook.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
8
|
+
import { render, screen, act } from '@testing-library/react'
|
|
9
|
+
import { renderHook } from '@testing-library/react'
|
|
10
|
+
import { DataModelProvider, useDataModelContext } from './DataModelContext'
|
|
11
|
+
import type { ReactNode } from 'react'
|
|
12
|
+
|
|
13
|
+
describe('DataModelContext', () => {
|
|
14
|
+
// Helper to render hook with provider
|
|
15
|
+
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
16
|
+
<DataModelProvider>{children}</DataModelProvider>
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
describe('DataModelProvider', () => {
|
|
20
|
+
it('should render children', () => {
|
|
21
|
+
render(
|
|
22
|
+
<DataModelProvider>
|
|
23
|
+
<div data-testid="child">Child content</div>
|
|
24
|
+
</DataModelProvider>
|
|
25
|
+
)
|
|
26
|
+
expect(screen.getByTestId('child')).toBeInTheDocument()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('should provide context value', () => {
|
|
30
|
+
const { result } = renderHook(() => useDataModelContext(), { wrapper })
|
|
31
|
+
expect(result.current).toBeDefined()
|
|
32
|
+
expect(result.current.dataModels).toBeInstanceOf(Map)
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('useDataModelContext', () => {
|
|
37
|
+
it('should throw error when used outside provider', () => {
|
|
38
|
+
const consoleError = vi
|
|
39
|
+
.spyOn(console, 'error')
|
|
40
|
+
.mockImplementation(() => {})
|
|
41
|
+
|
|
42
|
+
expect(() => {
|
|
43
|
+
renderHook(() => useDataModelContext())
|
|
44
|
+
}).toThrow('useDataModelContext must be used within a DataModelProvider')
|
|
45
|
+
|
|
46
|
+
consoleError.mockRestore()
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('initDataModel', () => {
|
|
51
|
+
it('should initialize empty data model for surface', () => {
|
|
52
|
+
const { result } = renderHook(() => useDataModelContext(), { wrapper })
|
|
53
|
+
|
|
54
|
+
act(() => {
|
|
55
|
+
result.current.initDataModel('surface-1')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
expect(result.current.getDataModel('surface-1')).toEqual({})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('should not overwrite existing data model', () => {
|
|
62
|
+
const { result } = renderHook(() => useDataModelContext(), { wrapper })
|
|
63
|
+
|
|
64
|
+
act(() => {
|
|
65
|
+
result.current.initDataModel('surface-1')
|
|
66
|
+
result.current.setDataValue('surface-1', '/name', 'John')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
act(() => {
|
|
70
|
+
result.current.initDataModel('surface-1')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
expect(result.current.getDataValue('surface-1', '/name')).toBe('John')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('should initialize multiple surfaces independently', () => {
|
|
77
|
+
const { result } = renderHook(() => useDataModelContext(), { wrapper })
|
|
78
|
+
|
|
79
|
+
act(() => {
|
|
80
|
+
result.current.initDataModel('surface-1')
|
|
81
|
+
result.current.initDataModel('surface-2')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
act(() => {
|
|
85
|
+
result.current.setDataValue('surface-1', '/name', 'John')
|
|
86
|
+
result.current.setDataValue('surface-2', '/name', 'Jane')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
expect(result.current.getDataValue('surface-1', '/name')).toBe('John')
|
|
90
|
+
expect(result.current.getDataValue('surface-2', '/name')).toBe('Jane')
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('getDataModel', () => {
|
|
95
|
+
it('should return empty object for non-existent surface', () => {
|
|
96
|
+
const { result } = renderHook(() => useDataModelContext(), { wrapper })
|
|
97
|
+
expect(result.current.getDataModel('non-existent')).toEqual({})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('should return data model for existing surface', () => {
|
|
101
|
+
const { result } = renderHook(() => useDataModelContext(), { wrapper })
|
|
102
|
+
|
|
103
|
+
act(() => {
|
|
104
|
+
result.current.initDataModel('surface-1')
|
|
105
|
+
result.current.updateDataModel('surface-1', '/', {
|
|
106
|
+
name: 'John',
|
|
107
|
+
age: 30,
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
expect(result.current.getDataModel('surface-1')).toEqual({
|
|
112
|
+
name: 'John',
|
|
113
|
+
age: 30,
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('getDataValue', () => {
|
|
119
|
+
it('should return undefined for non-existent surface', () => {
|
|
120
|
+
const { result } = renderHook(() => useDataModelContext(), { wrapper })
|
|
121
|
+
expect(
|
|
122
|
+
result.current.getDataValue('non-existent', '/name')
|
|
123
|
+
).toBeUndefined()
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('should return undefined for non-existent path', () => {
|
|
127
|
+
const { result } = renderHook(() => useDataModelContext(), { wrapper })
|
|
128
|
+
|
|
129
|
+
act(() => {
|
|
130
|
+
result.current.initDataModel('surface-1')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
expect(result.current.getDataValue('surface-1', '/name')).toBeUndefined()
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('should return value at path', () => {
|
|
137
|
+
const { result } = renderHook(() => useDataModelContext(), { wrapper })
|
|
138
|
+
|
|
139
|
+
act(() => {
|
|
140
|
+
result.current.initDataModel('surface-1')
|
|
141
|
+
result.current.updateDataModel('surface-1', '/', {
|
|
142
|
+
user: { name: 'John' },
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
expect(result.current.getDataValue('surface-1', '/user/name')).toBe(
|
|
147
|
+
'John'
|
|
148
|
+
)
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
describe('setDataValue', () => {
|
|
153
|
+
it('should set value at path', () => {
|
|
154
|
+
const { result } = renderHook(() => useDataModelContext(), { wrapper })
|
|
155
|
+
|
|
156
|
+
act(() => {
|
|
157
|
+
result.current.setDataValue('surface-1', '/name', 'John')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
expect(result.current.getDataValue('surface-1', '/name')).toBe('John')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('should create nested path structure', () => {
|
|
164
|
+
const { result } = renderHook(() => useDataModelContext(), { wrapper })
|
|
165
|
+
|
|
166
|
+
act(() => {
|
|
167
|
+
result.current.setDataValue(
|
|
168
|
+
'surface-1',
|
|
169
|
+
'/user/profile/email',
|
|
170
|
+
'john@example.com'
|
|
171
|
+
)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
expect(
|
|
175
|
+
result.current.getDataValue('surface-1', '/user/profile/email')
|
|
176
|
+
).toBe('john@example.com')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('should update existing value', () => {
|
|
180
|
+
const { result } = renderHook(() => useDataModelContext(), { wrapper })
|
|
181
|
+
|
|
182
|
+
act(() => {
|
|
183
|
+
result.current.setDataValue('surface-1', '/name', 'John')
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
act(() => {
|
|
187
|
+
result.current.setDataValue('surface-1', '/name', 'Jane')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
expect(result.current.getDataValue('surface-1', '/name')).toBe('Jane')
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('should create surface if not exists', () => {
|
|
194
|
+
const { result } = renderHook(() => useDataModelContext(), { wrapper })
|
|
195
|
+
|
|
196
|
+
act(() => {
|
|
197
|
+
result.current.setDataValue('new-surface', '/name', 'John')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
expect(result.current.getDataValue('new-surface', '/name')).toBe('John')
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
describe('updateDataModel', () => {
|
|
205
|
+
it('should merge data at root path', () => {
|
|
206
|
+
const { result } = renderHook(() => useDataModelContext(), { wrapper })
|
|
207
|
+
|
|
208
|
+
act(() => {
|
|
209
|
+
result.current.initDataModel('surface-1')
|
|
210
|
+
result.current.updateDataModel('surface-1', '/', { name: 'John' })
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
act(() => {
|
|
214
|
+
result.current.updateDataModel('surface-1', '/', { age: 30 })
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
expect(result.current.getDataModel('surface-1')).toEqual({
|
|
218
|
+
name: 'John',
|
|
219
|
+
age: 30,
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('should merge data at nested path', () => {
|
|
224
|
+
const { result } = renderHook(() => useDataModelContext(), { wrapper })
|
|
225
|
+
|
|
226
|
+
act(() => {
|
|
227
|
+
result.current.initDataModel('surface-1')
|
|
228
|
+
result.current.updateDataModel('surface-1', '/user', { name: 'John' })
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
act(() => {
|
|
232
|
+
result.current.updateDataModel('surface-1', '/user', { age: 30 })
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
expect(result.current.getDataValue('surface-1', '/user')).toEqual({
|
|
236
|
+
name: 'John',
|
|
237
|
+
age: 30,
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('should create surface if not exists', () => {
|
|
242
|
+
const { result } = renderHook(() => useDataModelContext(), { wrapper })
|
|
243
|
+
|
|
244
|
+
act(() => {
|
|
245
|
+
result.current.updateDataModel('new-surface', '/user', { name: 'John' })
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
expect(result.current.getDataValue('new-surface', '/user/name')).toBe(
|
|
249
|
+
'John'
|
|
250
|
+
)
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
describe('deleteDataModel', () => {
|
|
255
|
+
it('should delete data model for surface', () => {
|
|
256
|
+
const { result } = renderHook(() => useDataModelContext(), { wrapper })
|
|
257
|
+
|
|
258
|
+
act(() => {
|
|
259
|
+
result.current.initDataModel('surface-1')
|
|
260
|
+
result.current.setDataValue('surface-1', '/name', 'John')
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
act(() => {
|
|
264
|
+
result.current.deleteDataModel('surface-1')
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
expect(result.current.getDataModel('surface-1')).toEqual({})
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('should not affect other surfaces', () => {
|
|
271
|
+
const { result } = renderHook(() => useDataModelContext(), { wrapper })
|
|
272
|
+
|
|
273
|
+
act(() => {
|
|
274
|
+
result.current.initDataModel('surface-1')
|
|
275
|
+
result.current.initDataModel('surface-2')
|
|
276
|
+
result.current.setDataValue('surface-1', '/name', 'John')
|
|
277
|
+
result.current.setDataValue('surface-2', '/name', 'Jane')
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
act(() => {
|
|
281
|
+
result.current.deleteDataModel('surface-1')
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
expect(result.current.getDataModel('surface-1')).toEqual({})
|
|
285
|
+
expect(result.current.getDataValue('surface-2', '/name')).toBe('Jane')
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('should handle deleting non-existent surface gracefully', () => {
|
|
289
|
+
const { result } = renderHook(() => useDataModelContext(), { wrapper })
|
|
290
|
+
|
|
291
|
+
expect(() => {
|
|
292
|
+
act(() => {
|
|
293
|
+
result.current.deleteDataModel('non-existent')
|
|
294
|
+
})
|
|
295
|
+
}).not.toThrow()
|
|
296
|
+
})
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
describe('clearDataModels', () => {
|
|
300
|
+
it('should clear all data models', () => {
|
|
301
|
+
const { result } = renderHook(() => useDataModelContext(), { wrapper })
|
|
302
|
+
|
|
303
|
+
act(() => {
|
|
304
|
+
result.current.initDataModel('surface-1')
|
|
305
|
+
result.current.initDataModel('surface-2')
|
|
306
|
+
result.current.setDataValue('surface-1', '/name', 'John')
|
|
307
|
+
result.current.setDataValue('surface-2', '/name', 'Jane')
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
act(() => {
|
|
311
|
+
result.current.clearDataModels()
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
expect(result.current.dataModels.size).toBe(0)
|
|
315
|
+
expect(result.current.getDataModel('surface-1')).toEqual({})
|
|
316
|
+
expect(result.current.getDataModel('surface-2')).toEqual({})
|
|
317
|
+
})
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
describe('dataModels state', () => {
|
|
321
|
+
it('should provide access to all data models via dataModels property', () => {
|
|
322
|
+
const { result } = renderHook(() => useDataModelContext(), { wrapper })
|
|
323
|
+
|
|
324
|
+
act(() => {
|
|
325
|
+
result.current.initDataModel('surface-1')
|
|
326
|
+
result.current.setDataValue('surface-1', '/name', 'John')
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
expect(result.current.dataModels.has('surface-1')).toBe(true)
|
|
330
|
+
expect(result.current.dataModels.get('surface-1')).toEqual({
|
|
331
|
+
name: 'John',
|
|
332
|
+
})
|
|
333
|
+
})
|
|
334
|
+
})
|
|
335
|
+
})
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataModelContext - Manages the data model state for A2UI rendering.
|
|
3
|
+
*
|
|
4
|
+
* The data model is a hierarchical key-value store.
|
|
5
|
+
* Components reference data using paths like "/user/name".
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
createContext,
|
|
10
|
+
useContext,
|
|
11
|
+
useState,
|
|
12
|
+
useMemo,
|
|
13
|
+
useCallback,
|
|
14
|
+
type ReactNode,
|
|
15
|
+
} from 'react'
|
|
16
|
+
import type { DataModel, DataModelValue } from '../types'
|
|
17
|
+
import { getValueByPath, setValueByPath, mergeAtPath } from '../utils/pathUtils'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* DataModel context value interface.
|
|
21
|
+
*/
|
|
22
|
+
export interface DataModelContextValue {
|
|
23
|
+
/** Map of data models by surfaceId */
|
|
24
|
+
dataModels: Map<string, DataModel>
|
|
25
|
+
|
|
26
|
+
/** Updates the data model at a path with merge behavior */
|
|
27
|
+
updateDataModel: (
|
|
28
|
+
surfaceId: string,
|
|
29
|
+
path: string,
|
|
30
|
+
data: Record<string, unknown>
|
|
31
|
+
) => void
|
|
32
|
+
|
|
33
|
+
/** Gets a value from the data model */
|
|
34
|
+
getDataValue: (surfaceId: string, path: string) => DataModelValue | undefined
|
|
35
|
+
|
|
36
|
+
/** Sets a value in the data model (used by form inputs) */
|
|
37
|
+
setDataValue: (surfaceId: string, path: string, value: unknown) => void
|
|
38
|
+
|
|
39
|
+
/** Gets the entire data model for a surface */
|
|
40
|
+
getDataModel: (surfaceId: string) => DataModel
|
|
41
|
+
|
|
42
|
+
/** Initializes the data model for a surface */
|
|
43
|
+
initDataModel: (surfaceId: string) => void
|
|
44
|
+
|
|
45
|
+
/** Deletes the data model for a surface */
|
|
46
|
+
deleteDataModel: (surfaceId: string) => void
|
|
47
|
+
|
|
48
|
+
/** Clears all data models */
|
|
49
|
+
clearDataModels: () => void
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* DataModel context for A2UI rendering.
|
|
54
|
+
*/
|
|
55
|
+
export const DataModelContext = createContext<DataModelContextValue | null>(
|
|
56
|
+
null
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Props for DataModelProvider.
|
|
61
|
+
*/
|
|
62
|
+
export interface DataModelProviderProps {
|
|
63
|
+
children: ReactNode
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Provider component for DataModel state management.
|
|
68
|
+
*/
|
|
69
|
+
export function DataModelProvider({ children }: DataModelProviderProps) {
|
|
70
|
+
const [dataModels, setDataModels] = useState<Map<string, DataModel>>(
|
|
71
|
+
new Map()
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
const updateDataModel = useCallback(
|
|
75
|
+
(surfaceId: string, path: string, data: Record<string, unknown>) => {
|
|
76
|
+
setDataModels((prev) => {
|
|
77
|
+
const next = new Map(prev)
|
|
78
|
+
const current = next.get(surfaceId) ?? {}
|
|
79
|
+
const updated = mergeAtPath(current, path, data)
|
|
80
|
+
next.set(surfaceId, updated)
|
|
81
|
+
return next
|
|
82
|
+
})
|
|
83
|
+
},
|
|
84
|
+
[]
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
const getDataValue = useCallback(
|
|
88
|
+
(surfaceId: string, path: string): DataModelValue | undefined => {
|
|
89
|
+
const model = dataModels.get(surfaceId)
|
|
90
|
+
if (!model) {
|
|
91
|
+
return undefined
|
|
92
|
+
}
|
|
93
|
+
return getValueByPath(model, path)
|
|
94
|
+
},
|
|
95
|
+
[dataModels]
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
const setDataValue = useCallback(
|
|
99
|
+
(surfaceId: string, path: string, value: unknown) => {
|
|
100
|
+
setDataModels((prev) => {
|
|
101
|
+
const next = new Map(prev)
|
|
102
|
+
const current = next.get(surfaceId) ?? {}
|
|
103
|
+
const updated = setValueByPath(current, path, value)
|
|
104
|
+
next.set(surfaceId, updated)
|
|
105
|
+
return next
|
|
106
|
+
})
|
|
107
|
+
},
|
|
108
|
+
[]
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
const getDataModel = useCallback(
|
|
112
|
+
(surfaceId: string): DataModel => {
|
|
113
|
+
return dataModels.get(surfaceId) ?? {}
|
|
114
|
+
},
|
|
115
|
+
[dataModels]
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
const initDataModel = useCallback((surfaceId: string) => {
|
|
119
|
+
setDataModels((prev) => {
|
|
120
|
+
if (prev.has(surfaceId)) {
|
|
121
|
+
return prev
|
|
122
|
+
}
|
|
123
|
+
const next = new Map(prev)
|
|
124
|
+
next.set(surfaceId, {})
|
|
125
|
+
return next
|
|
126
|
+
})
|
|
127
|
+
}, [])
|
|
128
|
+
|
|
129
|
+
const deleteDataModel = useCallback((surfaceId: string) => {
|
|
130
|
+
setDataModels((prev) => {
|
|
131
|
+
const next = new Map(prev)
|
|
132
|
+
next.delete(surfaceId)
|
|
133
|
+
return next
|
|
134
|
+
})
|
|
135
|
+
}, [])
|
|
136
|
+
|
|
137
|
+
const clearDataModels = useCallback(() => {
|
|
138
|
+
setDataModels(new Map())
|
|
139
|
+
}, [])
|
|
140
|
+
|
|
141
|
+
const value = useMemo<DataModelContextValue>(
|
|
142
|
+
() => ({
|
|
143
|
+
dataModels,
|
|
144
|
+
updateDataModel,
|
|
145
|
+
getDataValue,
|
|
146
|
+
setDataValue,
|
|
147
|
+
getDataModel,
|
|
148
|
+
initDataModel,
|
|
149
|
+
deleteDataModel,
|
|
150
|
+
clearDataModels,
|
|
151
|
+
}),
|
|
152
|
+
[
|
|
153
|
+
dataModels,
|
|
154
|
+
updateDataModel,
|
|
155
|
+
getDataValue,
|
|
156
|
+
setDataValue,
|
|
157
|
+
getDataModel,
|
|
158
|
+
initDataModel,
|
|
159
|
+
deleteDataModel,
|
|
160
|
+
clearDataModels,
|
|
161
|
+
]
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<DataModelContext.Provider value={value}>
|
|
166
|
+
{children}
|
|
167
|
+
</DataModelContext.Provider>
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Hook to access the DataModel context.
|
|
173
|
+
*
|
|
174
|
+
* @throws Error if used outside of DataModelProvider
|
|
175
|
+
*/
|
|
176
|
+
export function useDataModelContext(): DataModelContextValue {
|
|
177
|
+
const context = useContext(DataModelContext)
|
|
178
|
+
if (!context) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
'useDataModelContext must be used within a DataModelProvider'
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
return context
|
|
184
|
+
}
|