@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,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useDataBinding Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for the useDataBinding, useDataModel, and useFormBinding hooks.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
8
|
+
import { renderHook, act } from '@testing-library/react'
|
|
9
|
+
import { useDataBinding, useDataModel, useFormBinding } from './useDataBinding'
|
|
10
|
+
import {
|
|
11
|
+
DataModelProvider,
|
|
12
|
+
useDataModelContext,
|
|
13
|
+
} from '../contexts/DataModelContext'
|
|
14
|
+
import type { ReactNode } from 'react'
|
|
15
|
+
import type { ValueSource } from '../types'
|
|
16
|
+
|
|
17
|
+
describe('useDataBinding', () => {
|
|
18
|
+
// Helper to render hook with provider
|
|
19
|
+
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
20
|
+
<DataModelProvider>{children}</DataModelProvider>
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
describe('useDataBinding', () => {
|
|
24
|
+
it('should return default value when source is undefined', () => {
|
|
25
|
+
const { result } = renderHook(
|
|
26
|
+
() => useDataBinding<string>('surface-1', undefined, 'default'),
|
|
27
|
+
{ wrapper }
|
|
28
|
+
)
|
|
29
|
+
expect(result.current).toBe('default')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('should resolve literalString', () => {
|
|
33
|
+
const source: ValueSource = { literalString: 'Hello' }
|
|
34
|
+
const { result } = renderHook(
|
|
35
|
+
() => useDataBinding<string>('surface-1', source),
|
|
36
|
+
{ wrapper }
|
|
37
|
+
)
|
|
38
|
+
expect(result.current).toBe('Hello')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('should resolve literalNumber', () => {
|
|
42
|
+
const source: ValueSource = { literalNumber: 42 }
|
|
43
|
+
const { result } = renderHook(
|
|
44
|
+
() => useDataBinding<number>('surface-1', source),
|
|
45
|
+
{ wrapper }
|
|
46
|
+
)
|
|
47
|
+
expect(result.current).toBe(42)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('should resolve literalBoolean', () => {
|
|
51
|
+
const source: ValueSource = { literalBoolean: true }
|
|
52
|
+
const { result } = renderHook(
|
|
53
|
+
() => useDataBinding<boolean>('surface-1', source),
|
|
54
|
+
{ wrapper }
|
|
55
|
+
)
|
|
56
|
+
expect(result.current).toBe(true)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('should resolve literalArray', () => {
|
|
60
|
+
const source: ValueSource = { literalArray: ['a', 'b', 'c'] }
|
|
61
|
+
const { result } = renderHook(
|
|
62
|
+
() => useDataBinding<string[]>('surface-1', source),
|
|
63
|
+
{ wrapper }
|
|
64
|
+
)
|
|
65
|
+
expect(result.current).toEqual(['a', 'b', 'c'])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('should resolve path reference from data model', () => {
|
|
69
|
+
const { result } = renderHook(
|
|
70
|
+
() => {
|
|
71
|
+
const value = useDataBinding<string>(
|
|
72
|
+
'surface-1',
|
|
73
|
+
{ path: '/user/name' },
|
|
74
|
+
''
|
|
75
|
+
)
|
|
76
|
+
const { setDataValue } = useDataModelContext()
|
|
77
|
+
return { value, setDataValue }
|
|
78
|
+
},
|
|
79
|
+
{ wrapper }
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
// Initially empty
|
|
83
|
+
expect(result.current.value).toBe('')
|
|
84
|
+
|
|
85
|
+
// Set data
|
|
86
|
+
act(() => {
|
|
87
|
+
result.current.setDataValue('surface-1', '/user/name', 'John')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
expect(result.current.value).toBe('John')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('should return default when path not found', () => {
|
|
94
|
+
const { result } = renderHook(
|
|
95
|
+
() =>
|
|
96
|
+
useDataBinding<string>(
|
|
97
|
+
'surface-1',
|
|
98
|
+
{ path: '/nonexistent' },
|
|
99
|
+
'default'
|
|
100
|
+
),
|
|
101
|
+
{ wrapper }
|
|
102
|
+
)
|
|
103
|
+
expect(result.current).toBe('default')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('should update when data model changes', () => {
|
|
107
|
+
const { result } = renderHook(
|
|
108
|
+
() => {
|
|
109
|
+
const value = useDataBinding<number>(
|
|
110
|
+
'surface-1',
|
|
111
|
+
{ path: '/count' },
|
|
112
|
+
0
|
|
113
|
+
)
|
|
114
|
+
const { setDataValue } = useDataModelContext()
|
|
115
|
+
return { value, setDataValue }
|
|
116
|
+
},
|
|
117
|
+
{ wrapper }
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
expect(result.current.value).toBe(0)
|
|
121
|
+
|
|
122
|
+
act(() => {
|
|
123
|
+
result.current.setDataValue('surface-1', '/count', 10)
|
|
124
|
+
})
|
|
125
|
+
expect(result.current.value).toBe(10)
|
|
126
|
+
|
|
127
|
+
act(() => {
|
|
128
|
+
result.current.setDataValue('surface-1', '/count', 20)
|
|
129
|
+
})
|
|
130
|
+
expect(result.current.value).toBe(20)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('should throw error when used outside provider', () => {
|
|
134
|
+
const consoleError = vi
|
|
135
|
+
.spyOn(console, 'error')
|
|
136
|
+
.mockImplementation(() => {})
|
|
137
|
+
|
|
138
|
+
expect(() => {
|
|
139
|
+
renderHook(() => useDataBinding('test', undefined))
|
|
140
|
+
}).toThrow('useDataModelContext must be used within a DataModelProvider')
|
|
141
|
+
|
|
142
|
+
consoleError.mockRestore()
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
describe('useDataModel', () => {
|
|
147
|
+
it('should return empty object for non-existent surface', () => {
|
|
148
|
+
const { result } = renderHook(() => useDataModel('non-existent'), {
|
|
149
|
+
wrapper,
|
|
150
|
+
})
|
|
151
|
+
expect(result.current).toEqual({})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('should return data model for surface', () => {
|
|
155
|
+
const { result } = renderHook(
|
|
156
|
+
() => {
|
|
157
|
+
const dataModel = useDataModel('surface-1')
|
|
158
|
+
const { setDataValue } = useDataModelContext()
|
|
159
|
+
return { dataModel, setDataValue }
|
|
160
|
+
},
|
|
161
|
+
{ wrapper }
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
act(() => {
|
|
165
|
+
result.current.setDataValue('surface-1', '/name', 'John')
|
|
166
|
+
result.current.setDataValue('surface-1', '/age', 30)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
expect(result.current.dataModel).toEqual({ name: 'John', age: 30 })
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('should update when data model changes', () => {
|
|
173
|
+
const { result } = renderHook(
|
|
174
|
+
() => {
|
|
175
|
+
const dataModel = useDataModel('surface-1')
|
|
176
|
+
const { setDataValue } = useDataModelContext()
|
|
177
|
+
return { dataModel, setDataValue }
|
|
178
|
+
},
|
|
179
|
+
{ wrapper }
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
act(() => {
|
|
183
|
+
result.current.setDataValue('surface-1', '/name', 'John')
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
expect(result.current.dataModel).toEqual({ name: 'John' })
|
|
187
|
+
|
|
188
|
+
act(() => {
|
|
189
|
+
result.current.setDataValue('surface-1', '/name', 'Jane')
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
expect(result.current.dataModel).toEqual({ name: 'Jane' })
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
describe('useFormBinding', () => {
|
|
197
|
+
it('should return value and setter', () => {
|
|
198
|
+
const { result } = renderHook(
|
|
199
|
+
() => useFormBinding<string>('surface-1', { path: '/name' }, ''),
|
|
200
|
+
{ wrapper }
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
expect(result.current).toHaveLength(2)
|
|
204
|
+
expect(typeof result.current[0]).toBe('string')
|
|
205
|
+
expect(typeof result.current[1]).toBe('function')
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('should return default value initially', () => {
|
|
209
|
+
const { result } = renderHook(
|
|
210
|
+
() => useFormBinding<string>('surface-1', { path: '/name' }, 'default'),
|
|
211
|
+
{ wrapper }
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
expect(result.current[0]).toBe('default')
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('should update data model when setValue is called', () => {
|
|
218
|
+
const { result } = renderHook(
|
|
219
|
+
() => {
|
|
220
|
+
const [value, setValue] = useFormBinding<string>(
|
|
221
|
+
'surface-1',
|
|
222
|
+
{ path: '/name' },
|
|
223
|
+
''
|
|
224
|
+
)
|
|
225
|
+
const { getDataValue } = useDataModelContext()
|
|
226
|
+
return { value, setValue, getDataValue }
|
|
227
|
+
},
|
|
228
|
+
{ wrapper }
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
act(() => {
|
|
232
|
+
result.current.setValue('John')
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
expect(result.current.value).toBe('John')
|
|
236
|
+
expect(result.current.getDataValue('surface-1', '/name')).toBe('John')
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('should not update when source is literal', () => {
|
|
240
|
+
const { result } = renderHook(
|
|
241
|
+
() => {
|
|
242
|
+
const [value, setValue] = useFormBinding<string>(
|
|
243
|
+
'surface-1',
|
|
244
|
+
{ literalString: 'constant' },
|
|
245
|
+
''
|
|
246
|
+
)
|
|
247
|
+
const { getDataModel } = useDataModelContext()
|
|
248
|
+
return { value, setValue, getDataModel }
|
|
249
|
+
},
|
|
250
|
+
{ wrapper }
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
expect(result.current.value).toBe('constant')
|
|
254
|
+
|
|
255
|
+
// Try to set value - should not throw but also not update
|
|
256
|
+
act(() => {
|
|
257
|
+
result.current.setValue('new value')
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
// Value should still be the literal
|
|
261
|
+
expect(result.current.value).toBe('constant')
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('should not update when source is undefined', () => {
|
|
265
|
+
const { result } = renderHook(
|
|
266
|
+
() => {
|
|
267
|
+
const [value, setValue] = useFormBinding<string>(
|
|
268
|
+
'surface-1',
|
|
269
|
+
undefined,
|
|
270
|
+
'default'
|
|
271
|
+
)
|
|
272
|
+
return { value, setValue }
|
|
273
|
+
},
|
|
274
|
+
{ wrapper }
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
expect(result.current.value).toBe('default')
|
|
278
|
+
|
|
279
|
+
// Try to set value - should not throw
|
|
280
|
+
act(() => {
|
|
281
|
+
result.current.setValue('new value')
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
// Value should still be default
|
|
285
|
+
expect(result.current.value).toBe('default')
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('should work with different value types', () => {
|
|
289
|
+
// Number
|
|
290
|
+
const { result: numberResult } = renderHook(
|
|
291
|
+
() => useFormBinding<number>('surface-1', { path: '/count' }, 0),
|
|
292
|
+
{ wrapper }
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
act(() => {
|
|
296
|
+
numberResult.current[1](42)
|
|
297
|
+
})
|
|
298
|
+
expect(numberResult.current[0]).toBe(42)
|
|
299
|
+
|
|
300
|
+
// Boolean
|
|
301
|
+
const { result: boolResult } = renderHook(
|
|
302
|
+
() => useFormBinding<boolean>('surface-2', { path: '/active' }, false),
|
|
303
|
+
{ wrapper }
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
act(() => {
|
|
307
|
+
boolResult.current[1](true)
|
|
308
|
+
})
|
|
309
|
+
expect(boolResult.current[0]).toBe(true)
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('should reflect external data model changes', () => {
|
|
313
|
+
const { result } = renderHook(
|
|
314
|
+
() => {
|
|
315
|
+
const [value, setValue] = useFormBinding<string>(
|
|
316
|
+
'surface-1',
|
|
317
|
+
{ path: '/name' },
|
|
318
|
+
''
|
|
319
|
+
)
|
|
320
|
+
const { setDataValue } = useDataModelContext()
|
|
321
|
+
return { value, setValue, setDataValue }
|
|
322
|
+
},
|
|
323
|
+
{ wrapper }
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
// Set via external setDataValue
|
|
327
|
+
act(() => {
|
|
328
|
+
result.current.setDataValue('surface-1', '/name', 'External')
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
expect(result.current.value).toBe('External')
|
|
332
|
+
})
|
|
333
|
+
})
|
|
334
|
+
})
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useDataBinding - Hook for resolving data bindings in components.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { useMemo } from 'react'
|
|
6
|
+
import type { ValueSource, DataModel } from '../types'
|
|
7
|
+
import { useDataModelContext } from '../contexts/DataModelContext'
|
|
8
|
+
import { resolveValue } from '../utils/dataBinding'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Resolves a ValueSource to its actual value.
|
|
12
|
+
*
|
|
13
|
+
* @param surfaceId - The surface ID for data model lookup
|
|
14
|
+
* @param source - The value source (literal or path reference)
|
|
15
|
+
* @param defaultValue - Default value if source is undefined or path not found
|
|
16
|
+
* @returns The resolved value
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```tsx
|
|
20
|
+
* function TextComponent({ surfaceId, text }) {
|
|
21
|
+
* const textValue = useDataBinding<string>(surfaceId, text, '');
|
|
22
|
+
* return <span>{textValue}</span>;
|
|
23
|
+
* }
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export function useDataBinding<T = unknown>(
|
|
27
|
+
surfaceId: string,
|
|
28
|
+
source: ValueSource | undefined,
|
|
29
|
+
defaultValue?: T
|
|
30
|
+
): T {
|
|
31
|
+
const { getDataModel } = useDataModelContext()
|
|
32
|
+
|
|
33
|
+
return useMemo(() => {
|
|
34
|
+
const dataModel = getDataModel(surfaceId)
|
|
35
|
+
return resolveValue<T>(source, dataModel, defaultValue)
|
|
36
|
+
}, [getDataModel, surfaceId, source, defaultValue])
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Gets the full data model for a surface.
|
|
41
|
+
* Useful for components that need access to multiple values.
|
|
42
|
+
*
|
|
43
|
+
* @param surfaceId - The surface ID
|
|
44
|
+
* @returns The data model for this surface
|
|
45
|
+
*/
|
|
46
|
+
export function useDataModel(surfaceId: string): DataModel {
|
|
47
|
+
const { getDataModel } = useDataModelContext()
|
|
48
|
+
|
|
49
|
+
return useMemo(() => {
|
|
50
|
+
return getDataModel(surfaceId)
|
|
51
|
+
}, [getDataModel, surfaceId])
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Hook for two-way data binding in form components.
|
|
56
|
+
* Returns both the current value and a setter function.
|
|
57
|
+
*
|
|
58
|
+
* @param surfaceId - The surface ID
|
|
59
|
+
* @param source - The value source (must be a path reference for setting)
|
|
60
|
+
* @param defaultValue - Default value if not found
|
|
61
|
+
* @returns Tuple of [value, setValue]
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* ```tsx
|
|
65
|
+
* function TextFieldComponent({ surfaceId, text }) {
|
|
66
|
+
* const [value, setValue] = useFormBinding<string>(surfaceId, text, '');
|
|
67
|
+
*
|
|
68
|
+
* return (
|
|
69
|
+
* <input
|
|
70
|
+
* value={value}
|
|
71
|
+
* onChange={(e) => setValue(e.target.value)}
|
|
72
|
+
* />
|
|
73
|
+
* );
|
|
74
|
+
* }
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
export function useFormBinding<T = unknown>(
|
|
78
|
+
surfaceId: string,
|
|
79
|
+
source: ValueSource | undefined,
|
|
80
|
+
defaultValue?: T
|
|
81
|
+
): [T, (value: T) => void] {
|
|
82
|
+
const { getDataModel, setDataValue } = useDataModelContext()
|
|
83
|
+
|
|
84
|
+
const value = useMemo(() => {
|
|
85
|
+
const dataModel = getDataModel(surfaceId)
|
|
86
|
+
return resolveValue<T>(source, dataModel, defaultValue)
|
|
87
|
+
}, [getDataModel, surfaceId, source, defaultValue])
|
|
88
|
+
|
|
89
|
+
const setValue = useMemo(() => {
|
|
90
|
+
return (newValue: T) => {
|
|
91
|
+
// Only path references can be updated
|
|
92
|
+
if (source && 'path' in source) {
|
|
93
|
+
setDataValue(surfaceId, source.path, newValue)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}, [setDataValue, surfaceId, source])
|
|
97
|
+
|
|
98
|
+
return [value, setValue]
|
|
99
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useDispatchAction Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for the useDispatchAction hooks.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
8
|
+
import { renderHook, act } from '@testing-library/react'
|
|
9
|
+
import { useDispatchAction } from './useDispatchAction'
|
|
10
|
+
import { ActionProvider } from '../contexts/ActionContext'
|
|
11
|
+
import { DataModelProvider } from '../contexts/DataModelContext'
|
|
12
|
+
import type { ReactNode } from 'react'
|
|
13
|
+
import type { Action, ActionPayload } from '../types'
|
|
14
|
+
|
|
15
|
+
describe('useDispatchAction', () => {
|
|
16
|
+
// Helper to create wrapper 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
|
+
it('should return dispatch function', () => {
|
|
26
|
+
const wrapper = createWrapper()
|
|
27
|
+
const { result } = renderHook(() => useDispatchAction(), { wrapper })
|
|
28
|
+
expect(typeof result.current).toBe('function')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('should dispatch action with correct payload', () => {
|
|
32
|
+
const onAction = vi.fn()
|
|
33
|
+
const wrapper = createWrapper(onAction)
|
|
34
|
+
|
|
35
|
+
const { result } = renderHook(() => useDispatchAction(), { wrapper })
|
|
36
|
+
|
|
37
|
+
const action: Action = {
|
|
38
|
+
name: 'submit',
|
|
39
|
+
context: [{ key: 'type', value: { literalString: 'form' } }],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
act(() => {
|
|
43
|
+
result.current('surface-1', 'button-1', action)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
expect(onAction).toHaveBeenCalledWith({
|
|
47
|
+
surfaceId: 'surface-1',
|
|
48
|
+
name: 'submit',
|
|
49
|
+
context: { type: 'form' },
|
|
50
|
+
sourceComponentId: 'button-1',
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('should dispatch action without context', () => {
|
|
55
|
+
const onAction = vi.fn()
|
|
56
|
+
const wrapper = createWrapper(onAction)
|
|
57
|
+
|
|
58
|
+
const { result } = renderHook(() => useDispatchAction(), { wrapper })
|
|
59
|
+
|
|
60
|
+
const action: Action = { name: 'cancel' }
|
|
61
|
+
|
|
62
|
+
act(() => {
|
|
63
|
+
result.current('surface-1', 'button-1', action)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
expect(onAction).toHaveBeenCalledWith({
|
|
67
|
+
surfaceId: 'surface-1',
|
|
68
|
+
name: 'cancel',
|
|
69
|
+
context: {},
|
|
70
|
+
sourceComponentId: 'button-1',
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('should throw error when used outside provider', () => {
|
|
75
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
76
|
+
|
|
77
|
+
expect(() => {
|
|
78
|
+
renderHook(() => useDispatchAction())
|
|
79
|
+
}).toThrow('useActionContext must be used within an ActionProvider')
|
|
80
|
+
|
|
81
|
+
consoleError.mockRestore()
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useDispatchAction - Hook for dispatching actions from components.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Action } from '../types'
|
|
6
|
+
import { useActionContext } from '../contexts/ActionContext'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Returns a function to dispatch actions.
|
|
10
|
+
*
|
|
11
|
+
* @returns A function that dispatches actions
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```tsx
|
|
15
|
+
* function ButtonComponent({ surfaceId, componentId, action }) {
|
|
16
|
+
* const dispatchAction = useDispatchAction();
|
|
17
|
+
*
|
|
18
|
+
* const handleClick = () => {
|
|
19
|
+
* if (action) {
|
|
20
|
+
* dispatchAction(surfaceId, componentId, action);
|
|
21
|
+
* }
|
|
22
|
+
* };
|
|
23
|
+
*
|
|
24
|
+
* return <button onClick={handleClick}>Click me</button>;
|
|
25
|
+
* }
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export function useDispatchAction(): (
|
|
29
|
+
surfaceId: string,
|
|
30
|
+
componentId: string,
|
|
31
|
+
action: Action
|
|
32
|
+
) => void {
|
|
33
|
+
const { dispatchAction } = useActionContext()
|
|
34
|
+
return dispatchAction
|
|
35
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useSurface Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for the useSurface hook.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
8
|
+
import { renderHook, act } from '@testing-library/react'
|
|
9
|
+
import { useSurface } from './useSurface'
|
|
10
|
+
import { SurfaceProvider, useSurfaceContext } from '../contexts/SurfaceContext'
|
|
11
|
+
import type { ReactNode } from 'react'
|
|
12
|
+
|
|
13
|
+
describe('useSurface', () => {
|
|
14
|
+
// Helper to render hook with provider
|
|
15
|
+
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
16
|
+
<SurfaceProvider>{children}</SurfaceProvider>
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
it('should return undefined for non-existent surface', () => {
|
|
20
|
+
const { result } = renderHook(() => useSurface('non-existent'), { wrapper })
|
|
21
|
+
expect(result.current).toBeUndefined()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('should return surface when it exists', () => {
|
|
25
|
+
// Custom wrapper that initializes a surface
|
|
26
|
+
const TestWrapper = ({ children }: { children: ReactNode }) => (
|
|
27
|
+
<SurfaceProvider>
|
|
28
|
+
<SurfaceInitializer>{children}</SurfaceInitializer>
|
|
29
|
+
</SurfaceProvider>
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
function SurfaceInitializer({ children }: { children: ReactNode }) {
|
|
33
|
+
const { initSurface } = useSurfaceContext()
|
|
34
|
+
React.useEffect(() => {
|
|
35
|
+
initSurface('test-surface', 'root-component', { font: 'Arial' })
|
|
36
|
+
}, [initSurface])
|
|
37
|
+
return <>{children}</>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const { result } = renderHook(() => useSurface('test-surface'), {
|
|
41
|
+
wrapper: TestWrapper,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
expect(result.current).toBeDefined()
|
|
45
|
+
expect(result.current?.surfaceId).toBe('test-surface')
|
|
46
|
+
expect(result.current?.root).toBe('root-component')
|
|
47
|
+
expect(result.current?.styles?.font).toBe('Arial')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('should update when surface changes', () => {
|
|
51
|
+
const { result } = renderHook(
|
|
52
|
+
() => {
|
|
53
|
+
const surface = useSurface('test-surface')
|
|
54
|
+
const { initSurface, updateSurface } = useSurfaceContext()
|
|
55
|
+
return { surface, initSurface, updateSurface }
|
|
56
|
+
},
|
|
57
|
+
{ wrapper }
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
// Initially undefined
|
|
61
|
+
expect(result.current.surface).toBeUndefined()
|
|
62
|
+
|
|
63
|
+
// Initialize surface
|
|
64
|
+
act(() => {
|
|
65
|
+
result.current.initSurface('test-surface', 'root')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
expect(result.current.surface).toBeDefined()
|
|
69
|
+
expect(result.current.surface?.root).toBe('root')
|
|
70
|
+
|
|
71
|
+
// Update surface with components
|
|
72
|
+
act(() => {
|
|
73
|
+
result.current.updateSurface('test-surface', [
|
|
74
|
+
{ id: 'comp-1', component: { Text: {} } },
|
|
75
|
+
])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
expect(result.current.surface?.components.size).toBe(1)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('should memoize result based on surfaceId', () => {
|
|
82
|
+
const { result, rerender } = renderHook(
|
|
83
|
+
({ surfaceId }) => {
|
|
84
|
+
const surface = useSurface(surfaceId)
|
|
85
|
+
const { initSurface } = useSurfaceContext()
|
|
86
|
+
return { surface, initSurface }
|
|
87
|
+
},
|
|
88
|
+
{ wrapper, initialProps: { surfaceId: 'surface-1' } }
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
act(() => {
|
|
92
|
+
result.current.initSurface('surface-1', 'root-1')
|
|
93
|
+
result.current.initSurface('surface-2', 'root-2')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
expect(result.current.surface?.root).toBe('root-1')
|
|
97
|
+
|
|
98
|
+
// Change surfaceId
|
|
99
|
+
rerender({ surfaceId: 'surface-2' })
|
|
100
|
+
expect(result.current.surface?.root).toBe('root-2')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('should throw error when used outside provider', () => {
|
|
104
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
105
|
+
|
|
106
|
+
expect(() => {
|
|
107
|
+
renderHook(() => useSurface('test'))
|
|
108
|
+
}).toThrow('useSurfaceContext must be used within a SurfaceProvider')
|
|
109
|
+
|
|
110
|
+
consoleError.mockRestore()
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
import React from 'react'
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useSurface - Hook to get a Surface by ID.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { useMemo } from 'react'
|
|
6
|
+
import type { Surface } from '../types'
|
|
7
|
+
import { useSurfaceContext } from '../contexts/SurfaceContext'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Gets a Surface by its ID.
|
|
11
|
+
*
|
|
12
|
+
* @param surfaceId - The surface ID to look up
|
|
13
|
+
* @returns The Surface, or undefined if not found
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```tsx
|
|
17
|
+
* function MySurface({ surfaceId }) {
|
|
18
|
+
* const surface = useSurface(surfaceId);
|
|
19
|
+
*
|
|
20
|
+
* if (!surface) {
|
|
21
|
+
* return <div>Surface not found</div>;
|
|
22
|
+
* }
|
|
23
|
+
*
|
|
24
|
+
* return <ComponentRenderer surfaceId={surfaceId} componentId={surface.root} />;
|
|
25
|
+
* }
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export function useSurface(surfaceId: string): Surface | undefined {
|
|
29
|
+
const { surfaces } = useSurfaceContext()
|
|
30
|
+
|
|
31
|
+
return useMemo(() => {
|
|
32
|
+
return surfaces.get(surfaceId)
|
|
33
|
+
}, [surfaces, surfaceId])
|
|
34
|
+
}
|