@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,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A2UIProvider Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for the combined A2UI provider component.
|
|
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 { A2UIProvider } from './A2UIProvider'
|
|
11
|
+
import { useSurfaceContext } from './SurfaceContext'
|
|
12
|
+
import { useDataModelContext } from './DataModelContext'
|
|
13
|
+
import { useActionContext } from './ActionContext'
|
|
14
|
+
import type { ReactNode } from 'react'
|
|
15
|
+
|
|
16
|
+
describe('A2UIProvider', () => {
|
|
17
|
+
describe('rendering', () => {
|
|
18
|
+
it('should render children', () => {
|
|
19
|
+
render(
|
|
20
|
+
<A2UIProvider>
|
|
21
|
+
<div data-testid="child">Child content</div>
|
|
22
|
+
</A2UIProvider>
|
|
23
|
+
)
|
|
24
|
+
expect(screen.getByTestId('child')).toBeInTheDocument()
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('context availability', () => {
|
|
29
|
+
// Wrapper for hooks
|
|
30
|
+
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
31
|
+
<A2UIProvider>{children}</A2UIProvider>
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
it('should provide SurfaceContext', () => {
|
|
35
|
+
const { result } = renderHook(() => useSurfaceContext(), { wrapper })
|
|
36
|
+
expect(result.current).toBeDefined()
|
|
37
|
+
expect(result.current.surfaces).toBeInstanceOf(Map)
|
|
38
|
+
expect(result.current.initSurface).toBeDefined()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('should provide DataModelContext', () => {
|
|
42
|
+
const { result } = renderHook(() => useDataModelContext(), { wrapper })
|
|
43
|
+
expect(result.current).toBeDefined()
|
|
44
|
+
expect(result.current.dataModels).toBeInstanceOf(Map)
|
|
45
|
+
expect(result.current.setDataValue).toBeDefined()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('should provide ActionContext', () => {
|
|
49
|
+
const { result } = renderHook(() => useActionContext(), { wrapper })
|
|
50
|
+
expect(result.current).toBeDefined()
|
|
51
|
+
expect(result.current.dispatchAction).toBeDefined()
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('onAction prop', () => {
|
|
56
|
+
it('should pass onAction to ActionProvider', () => {
|
|
57
|
+
const onAction = vi.fn()
|
|
58
|
+
|
|
59
|
+
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
60
|
+
<A2UIProvider onAction={onAction}>{children}</A2UIProvider>
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const { result } = renderHook(() => useActionContext(), { wrapper })
|
|
64
|
+
|
|
65
|
+
act(() => {
|
|
66
|
+
result.current.dispatchAction('surface-1', 'button-1', {
|
|
67
|
+
name: 'test',
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
expect(onAction).toHaveBeenCalledWith(
|
|
72
|
+
expect.objectContaining({
|
|
73
|
+
name: 'test',
|
|
74
|
+
surfaceId: 'surface-1',
|
|
75
|
+
sourceComponentId: 'button-1',
|
|
76
|
+
})
|
|
77
|
+
)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('should work without onAction prop', () => {
|
|
81
|
+
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
82
|
+
|
|
83
|
+
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
84
|
+
<A2UIProvider>{children}</A2UIProvider>
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
const { result } = renderHook(() => useActionContext(), { wrapper })
|
|
88
|
+
|
|
89
|
+
// Should not throw
|
|
90
|
+
expect(() => {
|
|
91
|
+
act(() => {
|
|
92
|
+
result.current.dispatchAction('surface-1', 'button-1', {
|
|
93
|
+
name: 'test',
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
}).not.toThrow()
|
|
97
|
+
|
|
98
|
+
consoleWarn.mockRestore()
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('integration', () => {
|
|
103
|
+
it('should allow interaction between contexts', () => {
|
|
104
|
+
const onAction = vi.fn()
|
|
105
|
+
|
|
106
|
+
// Test component that uses all contexts
|
|
107
|
+
function TestComponent() {
|
|
108
|
+
const { initSurface, updateSurface, getSurface } = useSurfaceContext()
|
|
109
|
+
const { setDataValue, getDataValue } = useDataModelContext()
|
|
110
|
+
const { dispatchAction } = useActionContext()
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div>
|
|
114
|
+
<button
|
|
115
|
+
onClick={() => {
|
|
116
|
+
initSurface('test-surface', 'root')
|
|
117
|
+
updateSurface('test-surface', [
|
|
118
|
+
{ id: 'text-1', component: { Text: {} } },
|
|
119
|
+
])
|
|
120
|
+
}}
|
|
121
|
+
>
|
|
122
|
+
Init Surface
|
|
123
|
+
</button>
|
|
124
|
+
<button
|
|
125
|
+
onClick={() => {
|
|
126
|
+
setDataValue('test-surface', '/name', 'John')
|
|
127
|
+
}}
|
|
128
|
+
>
|
|
129
|
+
Set Data
|
|
130
|
+
</button>
|
|
131
|
+
<button
|
|
132
|
+
onClick={() => {
|
|
133
|
+
dispatchAction('test-surface', 'button-1', {
|
|
134
|
+
name: 'submit',
|
|
135
|
+
context: [{ key: 'name', value: { path: '/name' } }],
|
|
136
|
+
})
|
|
137
|
+
}}
|
|
138
|
+
>
|
|
139
|
+
Dispatch Action
|
|
140
|
+
</button>
|
|
141
|
+
<span data-testid="surface-exists">
|
|
142
|
+
{getSurface('test-surface') ? 'yes' : 'no'}
|
|
143
|
+
</span>
|
|
144
|
+
<span data-testid="data-value">
|
|
145
|
+
{String(getDataValue('test-surface', '/name') ?? 'none')}
|
|
146
|
+
</span>
|
|
147
|
+
</div>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
render(
|
|
152
|
+
<A2UIProvider onAction={onAction}>
|
|
153
|
+
<TestComponent />
|
|
154
|
+
</A2UIProvider>
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
// Initially no surface
|
|
158
|
+
expect(screen.getByTestId('surface-exists').textContent).toBe('no')
|
|
159
|
+
expect(screen.getByTestId('data-value').textContent).toBe('none')
|
|
160
|
+
|
|
161
|
+
// Init surface
|
|
162
|
+
act(() => {
|
|
163
|
+
screen.getByText('Init Surface').click()
|
|
164
|
+
})
|
|
165
|
+
expect(screen.getByTestId('surface-exists').textContent).toBe('yes')
|
|
166
|
+
|
|
167
|
+
// Set data
|
|
168
|
+
act(() => {
|
|
169
|
+
screen.getByText('Set Data').click()
|
|
170
|
+
})
|
|
171
|
+
expect(screen.getByTestId('data-value').textContent).toBe('John')
|
|
172
|
+
|
|
173
|
+
// Dispatch action
|
|
174
|
+
act(() => {
|
|
175
|
+
screen.getByText('Dispatch Action').click()
|
|
176
|
+
})
|
|
177
|
+
expect(onAction).toHaveBeenCalledWith({
|
|
178
|
+
surfaceId: 'test-surface',
|
|
179
|
+
name: 'submit',
|
|
180
|
+
context: { name: 'John' },
|
|
181
|
+
sourceComponentId: 'button-1',
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('should maintain separate data for multiple surfaces', () => {
|
|
186
|
+
function TestComponent() {
|
|
187
|
+
const { initSurface } = useSurfaceContext()
|
|
188
|
+
const { setDataValue, getDataValue } = useDataModelContext()
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<div>
|
|
192
|
+
<button
|
|
193
|
+
onClick={() => {
|
|
194
|
+
initSurface('surface-1', 'root')
|
|
195
|
+
initSurface('surface-2', 'root')
|
|
196
|
+
setDataValue('surface-1', '/name', 'John')
|
|
197
|
+
setDataValue('surface-2', '/name', 'Jane')
|
|
198
|
+
}}
|
|
199
|
+
>
|
|
200
|
+
Setup
|
|
201
|
+
</button>
|
|
202
|
+
<span data-testid="surface-1-name">
|
|
203
|
+
{String(getDataValue('surface-1', '/name') ?? '')}
|
|
204
|
+
</span>
|
|
205
|
+
<span data-testid="surface-2-name">
|
|
206
|
+
{String(getDataValue('surface-2', '/name') ?? '')}
|
|
207
|
+
</span>
|
|
208
|
+
</div>
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
render(
|
|
213
|
+
<A2UIProvider>
|
|
214
|
+
<TestComponent />
|
|
215
|
+
</A2UIProvider>
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
act(() => {
|
|
219
|
+
screen.getByText('Setup').click()
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
expect(screen.getByTestId('surface-1-name').textContent).toBe('John')
|
|
223
|
+
expect(screen.getByTestId('surface-2-name').textContent).toBe('Jane')
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A2UIProvider - Combined provider for all A2UI contexts.
|
|
3
|
+
*
|
|
4
|
+
* This component wraps all the necessary context providers for A2UI rendering.
|
|
5
|
+
* It should be placed at the top level of any component tree that uses A2UI.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { type ReactNode } from 'react'
|
|
9
|
+
import { SurfaceProvider } from './SurfaceContext'
|
|
10
|
+
import { DataModelProvider } from './DataModelContext'
|
|
11
|
+
import { ActionProvider } from './ActionContext'
|
|
12
|
+
import type { ActionHandler } from '../types'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Props for A2UIProvider.
|
|
16
|
+
*/
|
|
17
|
+
export interface A2UIProviderProps {
|
|
18
|
+
/** Callback when an action is dispatched */
|
|
19
|
+
onAction?: ActionHandler
|
|
20
|
+
children: ReactNode
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Combined provider for all A2UI contexts.
|
|
25
|
+
*
|
|
26
|
+
* Provides:
|
|
27
|
+
* - SurfaceContext: Component tree management
|
|
28
|
+
* - DataModelContext: Data model state
|
|
29
|
+
* - ActionContext: Action dispatching
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```tsx
|
|
33
|
+
* function App() {
|
|
34
|
+
* const handleAction = (action) => {
|
|
35
|
+
* console.log('Action:', action);
|
|
36
|
+
* };
|
|
37
|
+
*
|
|
38
|
+
* return (
|
|
39
|
+
* <A2UIProvider onAction={handleAction}>
|
|
40
|
+
* <A2UIReactRenderer messages={messages} />
|
|
41
|
+
* </A2UIProvider>
|
|
42
|
+
* );
|
|
43
|
+
* }
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export function A2UIProvider({ onAction, children }: A2UIProviderProps) {
|
|
47
|
+
return (
|
|
48
|
+
<SurfaceProvider>
|
|
49
|
+
<DataModelProvider>
|
|
50
|
+
<ActionProvider onAction={onAction}>{children}</ActionProvider>
|
|
51
|
+
</DataModelProvider>
|
|
52
|
+
</SurfaceProvider>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ActionContext Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for the Action 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 { ActionProvider, useActionContext } from './ActionContext'
|
|
11
|
+
import { DataModelProvider, useDataModelContext } from './DataModelContext'
|
|
12
|
+
import type { ReactNode } from 'react'
|
|
13
|
+
import type { Action, ActionPayload } from '../types'
|
|
14
|
+
|
|
15
|
+
describe('ActionContext', () => {
|
|
16
|
+
// Helper to render hook with providers
|
|
17
|
+
const createWrapper =
|
|
18
|
+
(onAction?: (action: ActionPayload) => void) =>
|
|
19
|
+
({ children }: { children: ReactNode }) => (
|
|
20
|
+
<DataModelProvider>
|
|
21
|
+
<ActionProvider onAction={onAction}>{children}</ActionProvider>
|
|
22
|
+
</DataModelProvider>
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
describe('ActionProvider', () => {
|
|
26
|
+
it('should render children', () => {
|
|
27
|
+
render(
|
|
28
|
+
<DataModelProvider>
|
|
29
|
+
<ActionProvider>
|
|
30
|
+
<div data-testid="child">Child content</div>
|
|
31
|
+
</ActionProvider>
|
|
32
|
+
</DataModelProvider>
|
|
33
|
+
)
|
|
34
|
+
expect(screen.getByTestId('child')).toBeInTheDocument()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('should provide context value', () => {
|
|
38
|
+
const wrapper = createWrapper()
|
|
39
|
+
const { result } = renderHook(() => useActionContext(), { wrapper })
|
|
40
|
+
expect(result.current).toBeDefined()
|
|
41
|
+
expect(result.current.dispatchAction).toBeDefined()
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('useActionContext', () => {
|
|
46
|
+
it('should throw error when used outside provider', () => {
|
|
47
|
+
const consoleError = vi
|
|
48
|
+
.spyOn(console, 'error')
|
|
49
|
+
.mockImplementation(() => {})
|
|
50
|
+
|
|
51
|
+
expect(() => {
|
|
52
|
+
renderHook(() => useActionContext())
|
|
53
|
+
}).toThrow('useActionContext must be used within an ActionProvider')
|
|
54
|
+
|
|
55
|
+
consoleError.mockRestore()
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('dispatchAction', () => {
|
|
60
|
+
it('should call onAction with resolved payload', () => {
|
|
61
|
+
const onAction = vi.fn()
|
|
62
|
+
const wrapper = createWrapper(onAction)
|
|
63
|
+
|
|
64
|
+
const { result } = renderHook(() => useActionContext(), { wrapper })
|
|
65
|
+
|
|
66
|
+
const action: Action = {
|
|
67
|
+
name: 'submit',
|
|
68
|
+
context: [{ key: 'type', value: { literalString: 'form' } }],
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
act(() => {
|
|
72
|
+
result.current.dispatchAction('surface-1', 'button-1', action)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
expect(onAction).toHaveBeenCalledWith({
|
|
76
|
+
surfaceId: 'surface-1',
|
|
77
|
+
name: 'submit',
|
|
78
|
+
context: { type: 'form' },
|
|
79
|
+
sourceComponentId: 'button-1',
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('should resolve path references from data model', () => {
|
|
84
|
+
const onAction = vi.fn()
|
|
85
|
+
|
|
86
|
+
// Custom wrapper that sets up data model
|
|
87
|
+
const TestWrapper = ({ children }: { children: ReactNode }) => {
|
|
88
|
+
return (
|
|
89
|
+
<DataModelProvider>
|
|
90
|
+
<DataModelSetup>
|
|
91
|
+
<ActionProvider onAction={onAction}>{children}</ActionProvider>
|
|
92
|
+
</DataModelSetup>
|
|
93
|
+
</DataModelProvider>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Component to set up data model
|
|
98
|
+
function DataModelSetup({ children }: { children: ReactNode }) {
|
|
99
|
+
const { setDataValue } = useDataModelContext()
|
|
100
|
+
React.useEffect(() => {
|
|
101
|
+
setDataValue('surface-1', '/form/name', 'John')
|
|
102
|
+
setDataValue('surface-1', '/form/age', 30)
|
|
103
|
+
}, [setDataValue])
|
|
104
|
+
return <>{children}</>
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const { result } = renderHook(() => useActionContext(), {
|
|
108
|
+
wrapper: TestWrapper,
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const action: Action = {
|
|
112
|
+
name: 'submit',
|
|
113
|
+
context: [
|
|
114
|
+
{ key: 'userName', value: { path: '/form/name' } },
|
|
115
|
+
{ key: 'userAge', value: { path: '/form/age' } },
|
|
116
|
+
],
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
act(() => {
|
|
120
|
+
result.current.dispatchAction('surface-1', 'button-1', action)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
expect(onAction).toHaveBeenCalledWith({
|
|
124
|
+
surfaceId: 'surface-1',
|
|
125
|
+
name: 'submit',
|
|
126
|
+
context: { userName: 'John', userAge: 30 },
|
|
127
|
+
sourceComponentId: 'button-1',
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('should handle action without context', () => {
|
|
132
|
+
const onAction = vi.fn()
|
|
133
|
+
const wrapper = createWrapper(onAction)
|
|
134
|
+
|
|
135
|
+
const { result } = renderHook(() => useActionContext(), { wrapper })
|
|
136
|
+
|
|
137
|
+
const action: Action = {
|
|
138
|
+
name: 'cancel',
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
act(() => {
|
|
142
|
+
result.current.dispatchAction('surface-1', 'button-1', action)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
expect(onAction).toHaveBeenCalledWith({
|
|
146
|
+
surfaceId: 'surface-1',
|
|
147
|
+
name: 'cancel',
|
|
148
|
+
context: {},
|
|
149
|
+
sourceComponentId: 'button-1',
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('should handle empty context array', () => {
|
|
154
|
+
const onAction = vi.fn()
|
|
155
|
+
const wrapper = createWrapper(onAction)
|
|
156
|
+
|
|
157
|
+
const { result } = renderHook(() => useActionContext(), { wrapper })
|
|
158
|
+
|
|
159
|
+
const action: Action = {
|
|
160
|
+
name: 'reset',
|
|
161
|
+
context: [],
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
act(() => {
|
|
165
|
+
result.current.dispatchAction('surface-1', 'button-1', action)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
expect(onAction).toHaveBeenCalledWith({
|
|
169
|
+
surfaceId: 'surface-1',
|
|
170
|
+
name: 'reset',
|
|
171
|
+
context: {},
|
|
172
|
+
sourceComponentId: 'button-1',
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('should warn when no handler is registered', () => {
|
|
177
|
+
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
178
|
+
const wrapper = createWrapper() // No onAction
|
|
179
|
+
|
|
180
|
+
const { result } = renderHook(() => useActionContext(), { wrapper })
|
|
181
|
+
|
|
182
|
+
const action: Action = {
|
|
183
|
+
name: 'submit',
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
act(() => {
|
|
187
|
+
result.current.dispatchAction('surface-1', 'button-1', action)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
expect(consoleWarn).toHaveBeenCalledWith(
|
|
191
|
+
'A2UI: Action dispatched but no handler is registered'
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
consoleWarn.mockRestore()
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('should resolve literal values correctly', () => {
|
|
198
|
+
const onAction = vi.fn()
|
|
199
|
+
const wrapper = createWrapper(onAction)
|
|
200
|
+
|
|
201
|
+
const { result } = renderHook(() => useActionContext(), { wrapper })
|
|
202
|
+
|
|
203
|
+
const action: Action = {
|
|
204
|
+
name: 'test',
|
|
205
|
+
context: [
|
|
206
|
+
{ key: 'str', value: { literalString: 'hello' } },
|
|
207
|
+
{ key: 'num', value: { literalNumber: 42 } },
|
|
208
|
+
{ key: 'bool', value: { literalBoolean: true } },
|
|
209
|
+
],
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
act(() => {
|
|
213
|
+
result.current.dispatchAction('surface-1', 'button-1', action)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
expect(onAction).toHaveBeenCalledWith({
|
|
217
|
+
surfaceId: 'surface-1',
|
|
218
|
+
name: 'test',
|
|
219
|
+
context: { str: 'hello', num: 42, bool: true },
|
|
220
|
+
sourceComponentId: 'button-1',
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
describe('onAction property', () => {
|
|
226
|
+
it('should return null when no handler is registered', () => {
|
|
227
|
+
const wrapper = createWrapper()
|
|
228
|
+
const { result } = renderHook(() => useActionContext(), { wrapper })
|
|
229
|
+
expect(result.current.onAction).toBeNull()
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('should return handler when registered', () => {
|
|
233
|
+
const handler = vi.fn()
|
|
234
|
+
const wrapper = createWrapper(handler)
|
|
235
|
+
const { result } = renderHook(() => useActionContext(), { wrapper })
|
|
236
|
+
expect(result.current.onAction).toBe(handler)
|
|
237
|
+
})
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
// Import React for the test component
|
|
242
|
+
import React from 'react'
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ActionContext - Manages action dispatching for A2UI components.
|
|
3
|
+
*
|
|
4
|
+
* Actions are triggered by user interactions (button clicks, form changes, etc.)
|
|
5
|
+
* and are forwarded to the parent application for handling.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
createContext,
|
|
10
|
+
useContext,
|
|
11
|
+
useMemo,
|
|
12
|
+
useCallback,
|
|
13
|
+
type ReactNode,
|
|
14
|
+
} from 'react'
|
|
15
|
+
import type { Action, ActionPayload, ActionHandler } from '../types'
|
|
16
|
+
import { useDataModelContext } from './DataModelContext'
|
|
17
|
+
import { resolveActionContext } from '../utils/dataBinding'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Action context value interface.
|
|
21
|
+
*/
|
|
22
|
+
export interface ActionContextValue {
|
|
23
|
+
/** Dispatches an action with resolved context */
|
|
24
|
+
dispatchAction: (
|
|
25
|
+
surfaceId: string,
|
|
26
|
+
componentId: string,
|
|
27
|
+
action: Action
|
|
28
|
+
) => void
|
|
29
|
+
|
|
30
|
+
/** The action handler callback (if set) */
|
|
31
|
+
onAction: ActionHandler | null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Action context for A2UI rendering.
|
|
36
|
+
*/
|
|
37
|
+
export const ActionContext = createContext<ActionContextValue | null>(null)
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Props for ActionProvider.
|
|
41
|
+
*/
|
|
42
|
+
export interface ActionProviderProps {
|
|
43
|
+
/** Callback when an action is dispatched */
|
|
44
|
+
onAction?: ActionHandler
|
|
45
|
+
children: ReactNode
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Provider component for Action dispatching.
|
|
50
|
+
*/
|
|
51
|
+
export function ActionProvider({ onAction, children }: ActionProviderProps) {
|
|
52
|
+
const { getDataModel } = useDataModelContext()
|
|
53
|
+
|
|
54
|
+
const dispatchAction = useCallback(
|
|
55
|
+
(surfaceId: string, componentId: string, action: Action) => {
|
|
56
|
+
if (!onAction) {
|
|
57
|
+
console.warn('A2UI: Action dispatched but no handler is registered')
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Get the data model for this surface
|
|
62
|
+
const dataModel = getDataModel(surfaceId)
|
|
63
|
+
|
|
64
|
+
// Resolve the action context values
|
|
65
|
+
const resolvedContext = resolveActionContext(action.context, dataModel)
|
|
66
|
+
|
|
67
|
+
// Create the action payload
|
|
68
|
+
const payload: ActionPayload = {
|
|
69
|
+
surfaceId,
|
|
70
|
+
name: action.name,
|
|
71
|
+
context: resolvedContext,
|
|
72
|
+
sourceComponentId: componentId,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Call the handler
|
|
76
|
+
onAction(payload)
|
|
77
|
+
},
|
|
78
|
+
[onAction, getDataModel]
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
const value = useMemo<ActionContextValue>(
|
|
82
|
+
() => ({
|
|
83
|
+
dispatchAction,
|
|
84
|
+
onAction: onAction ?? null,
|
|
85
|
+
}),
|
|
86
|
+
[dispatchAction, onAction]
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<ActionContext.Provider value={value}>{children}</ActionContext.Provider>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Hook to access the Action context.
|
|
96
|
+
*
|
|
97
|
+
* @throws Error if used outside of ActionProvider
|
|
98
|
+
*/
|
|
99
|
+
export function useActionContext(): ActionContextValue {
|
|
100
|
+
const context = useContext(ActionContext)
|
|
101
|
+
if (!context) {
|
|
102
|
+
throw new Error('useActionContext must be used within an ActionProvider')
|
|
103
|
+
}
|
|
104
|
+
return context
|
|
105
|
+
}
|