@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.
Files changed (161) hide show
  1. package/.claude/commands/speckit.analyze.md +184 -0
  2. package/.claude/commands/speckit.checklist.md +294 -0
  3. package/.claude/commands/speckit.clarify.md +181 -0
  4. package/.claude/commands/speckit.constitution.md +82 -0
  5. package/.claude/commands/speckit.implement.md +135 -0
  6. package/.claude/commands/speckit.plan.md +89 -0
  7. package/.claude/commands/speckit.specify.md +256 -0
  8. package/.claude/commands/speckit.tasks.md +137 -0
  9. package/.claude/commands/speckit.taskstoissues.md +30 -0
  10. package/.github/workflows/deploy.yml +69 -0
  11. package/.husky/pre-commit +1 -0
  12. package/.prettierignore +4 -0
  13. package/.prettierrc +7 -0
  14. package/.specify/memory/constitution.md +73 -0
  15. package/.specify/scripts/bash/check-prerequisites.sh +166 -0
  16. package/.specify/scripts/bash/common.sh +156 -0
  17. package/.specify/scripts/bash/create-new-feature.sh +297 -0
  18. package/.specify/scripts/bash/setup-plan.sh +61 -0
  19. package/.specify/scripts/bash/update-agent-context.sh +799 -0
  20. package/.specify/templates/agent-file-template.md +28 -0
  21. package/.specify/templates/checklist-template.md +40 -0
  22. package/.specify/templates/plan-template.md +105 -0
  23. package/.specify/templates/spec-template.md +115 -0
  24. package/.specify/templates/tasks-template.md +250 -0
  25. package/CLAUDE.md +105 -0
  26. package/CONTRIBUTING.md +97 -0
  27. package/README.md +126 -0
  28. package/components.json +21 -0
  29. package/eslint.config.js +25 -0
  30. package/netlify.toml +50 -0
  31. package/package.json +94 -0
  32. package/playground/README.md +75 -0
  33. package/playground/index.html +22 -0
  34. package/playground/package.json +32 -0
  35. package/playground/public/favicon.svg +8 -0
  36. package/playground/src/App.css +256 -0
  37. package/playground/src/App.tsx +115 -0
  38. package/playground/src/assets/react.svg +1 -0
  39. package/playground/src/components/ErrorDisplay.tsx +13 -0
  40. package/playground/src/components/ExampleSelector.tsx +64 -0
  41. package/playground/src/components/Header.tsx +47 -0
  42. package/playground/src/components/JsonEditor.tsx +32 -0
  43. package/playground/src/components/Preview.tsx +78 -0
  44. package/playground/src/components/ThemeToggle.tsx +19 -0
  45. package/playground/src/data/examples.ts +1571 -0
  46. package/playground/src/hooks/useTheme.ts +55 -0
  47. package/playground/src/index.css +220 -0
  48. package/playground/src/main.tsx +10 -0
  49. package/playground/tsconfig.app.json +34 -0
  50. package/playground/tsconfig.json +13 -0
  51. package/playground/tsconfig.node.json +26 -0
  52. package/playground/vite.config.ts +31 -0
  53. package/specs/001-a2ui-renderer/checklists/requirements.md +41 -0
  54. package/specs/001-a2ui-renderer/data-model.md +140 -0
  55. package/specs/001-a2ui-renderer/plan.md +123 -0
  56. package/specs/001-a2ui-renderer/quickstart.md +141 -0
  57. package/specs/001-a2ui-renderer/research.md +140 -0
  58. package/specs/001-a2ui-renderer/spec.md +165 -0
  59. package/specs/001-a2ui-renderer/tasks.md +310 -0
  60. package/specs/002-playground/checklists/requirements.md +37 -0
  61. package/specs/002-playground/contracts/components.md +120 -0
  62. package/specs/002-playground/data-model.md +149 -0
  63. package/specs/002-playground/plan.md +73 -0
  64. package/specs/002-playground/quickstart.md +158 -0
  65. package/specs/002-playground/research.md +117 -0
  66. package/specs/002-playground/spec.md +109 -0
  67. package/specs/002-playground/tasks.md +224 -0
  68. package/src/0.8/A2UIRender.test.tsx +793 -0
  69. package/src/0.8/A2UIRender.tsx +142 -0
  70. package/src/0.8/components/ComponentRenderer.test.tsx +373 -0
  71. package/src/0.8/components/ComponentRenderer.tsx +163 -0
  72. package/src/0.8/components/UnknownComponent.tsx +49 -0
  73. package/src/0.8/components/display/AudioPlayerComponent.tsx +37 -0
  74. package/src/0.8/components/display/DividerComponent.tsx +23 -0
  75. package/src/0.8/components/display/IconComponent.tsx +137 -0
  76. package/src/0.8/components/display/ImageComponent.tsx +57 -0
  77. package/src/0.8/components/display/TextComponent.tsx +56 -0
  78. package/src/0.8/components/display/VideoComponent.tsx +31 -0
  79. package/src/0.8/components/display/display.test.tsx +660 -0
  80. package/src/0.8/components/display/index.ts +10 -0
  81. package/src/0.8/components/index.ts +14 -0
  82. package/src/0.8/components/interactive/ButtonComponent.tsx +44 -0
  83. package/src/0.8/components/interactive/CheckBoxComponent.tsx +45 -0
  84. package/src/0.8/components/interactive/DateTimeInputComponent.tsx +176 -0
  85. package/src/0.8/components/interactive/MultipleChoiceComponent.tsx +157 -0
  86. package/src/0.8/components/interactive/SliderComponent.tsx +53 -0
  87. package/src/0.8/components/interactive/TextFieldComponent.tsx +65 -0
  88. package/src/0.8/components/interactive/index.ts +10 -0
  89. package/src/0.8/components/interactive/interactive.test.tsx +618 -0
  90. package/src/0.8/components/layout/CardComponent.tsx +30 -0
  91. package/src/0.8/components/layout/ColumnComponent.tsx +93 -0
  92. package/src/0.8/components/layout/ListComponent.tsx +81 -0
  93. package/src/0.8/components/layout/ModalComponent.tsx +41 -0
  94. package/src/0.8/components/layout/RowComponent.tsx +94 -0
  95. package/src/0.8/components/layout/TabsComponent.tsx +59 -0
  96. package/src/0.8/components/layout/index.ts +10 -0
  97. package/src/0.8/components/layout/layout.test.tsx +558 -0
  98. package/src/0.8/contexts/A2UIProvider.test.tsx +226 -0
  99. package/src/0.8/contexts/A2UIProvider.tsx +54 -0
  100. package/src/0.8/contexts/ActionContext.test.tsx +242 -0
  101. package/src/0.8/contexts/ActionContext.tsx +105 -0
  102. package/src/0.8/contexts/ComponentsMapContext.tsx +125 -0
  103. package/src/0.8/contexts/DataModelContext.test.tsx +335 -0
  104. package/src/0.8/contexts/DataModelContext.tsx +184 -0
  105. package/src/0.8/contexts/SurfaceContext.test.tsx +339 -0
  106. package/src/0.8/contexts/SurfaceContext.tsx +197 -0
  107. package/src/0.8/hooks/useA2UIMessageHandler.test.tsx +399 -0
  108. package/src/0.8/hooks/useA2UIMessageHandler.ts +123 -0
  109. package/src/0.8/hooks/useComponent.test.tsx +148 -0
  110. package/src/0.8/hooks/useComponent.ts +39 -0
  111. package/src/0.8/hooks/useDataBinding.test.tsx +334 -0
  112. package/src/0.8/hooks/useDataBinding.ts +99 -0
  113. package/src/0.8/hooks/useDispatchAction.test.tsx +83 -0
  114. package/src/0.8/hooks/useDispatchAction.ts +35 -0
  115. package/src/0.8/hooks/useSurface.test.tsx +114 -0
  116. package/src/0.8/hooks/useSurface.ts +34 -0
  117. package/src/0.8/index.ts +38 -0
  118. package/src/0.8/schemas/client_to_server.json +50 -0
  119. package/src/0.8/schemas/server_to_client.json +148 -0
  120. package/src/0.8/schemas/standard_catalog_definition.json +661 -0
  121. package/src/0.8/types/index.ts +448 -0
  122. package/src/0.8/utils/dataBinding.test.ts +443 -0
  123. package/src/0.8/utils/dataBinding.ts +212 -0
  124. package/src/0.8/utils/pathUtils.test.ts +353 -0
  125. package/src/0.8/utils/pathUtils.ts +200 -0
  126. package/src/components/ui/button.tsx +62 -0
  127. package/src/components/ui/calendar.tsx +220 -0
  128. package/src/components/ui/card.tsx +92 -0
  129. package/src/components/ui/checkbox.tsx +30 -0
  130. package/src/components/ui/dialog.tsx +141 -0
  131. package/src/components/ui/input.tsx +21 -0
  132. package/src/components/ui/label.tsx +22 -0
  133. package/src/components/ui/native-select.tsx +53 -0
  134. package/src/components/ui/popover.tsx +46 -0
  135. package/src/components/ui/select.tsx +188 -0
  136. package/src/components/ui/separator.tsx +26 -0
  137. package/src/components/ui/slider.tsx +61 -0
  138. package/src/components/ui/tabs.tsx +64 -0
  139. package/src/components/ui/textarea.tsx +18 -0
  140. package/src/index.ts +1 -0
  141. package/src/lib/utils.ts +6 -0
  142. package/tsconfig.json +28 -0
  143. package/vite.config.ts +29 -0
  144. package/vitest.config.ts +22 -0
  145. package/vitest.setup.ts +8 -0
  146. package/website/README.md +4 -0
  147. package/website/assets/favicon.svg +8 -0
  148. package/website/content/.gitkeep +0 -0
  149. package/website/content/index.md +122 -0
  150. package/website/global.d.ts +9 -0
  151. package/website/package.json +17 -0
  152. package/website/plain.config.js +28 -0
  153. package/website/serve.json +6 -0
  154. package/website/src/client/color-mode-switch.css +47 -0
  155. package/website/src/client/index.js +61 -0
  156. package/website/src/client/moon.svg +1 -0
  157. package/website/src/client/sun.svg +1 -0
  158. package/website/src/components/Footer.jsx +9 -0
  159. package/website/src/components/Header.jsx +44 -0
  160. package/website/src/components/Page.jsx +28 -0
  161. 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
+ }