@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,443 @@
1
+ /**
2
+ * dataBinding Tests
3
+ *
4
+ * Tests for data binding utility functions used in A2UI.
5
+ */
6
+
7
+ import { describe, it, expect } from 'vitest'
8
+ import {
9
+ resolveValue,
10
+ isPathReference,
11
+ getPath,
12
+ contentsToObject,
13
+ literalString,
14
+ literalNumber,
15
+ literalBoolean,
16
+ pathRef,
17
+ resolveActionContext,
18
+ } from './dataBinding'
19
+ import type { DataModel, ValueSource, DataEntry } from '../types'
20
+
21
+ describe('dataBinding', () => {
22
+ describe('resolveValue', () => {
23
+ const testModel: DataModel = {
24
+ user: {
25
+ name: 'John',
26
+ age: 30,
27
+ active: true,
28
+ },
29
+ items: ['a', 'b', 'c'],
30
+ count: 42,
31
+ }
32
+
33
+ describe('with undefined/null source', () => {
34
+ it('should return default value when source is undefined', () => {
35
+ expect(resolveValue(undefined, testModel, 'default')).toBe('default')
36
+ })
37
+
38
+ it('should return default value when source is null', () => {
39
+ expect(
40
+ resolveValue(null as unknown as ValueSource, testModel, 'default')
41
+ ).toBe('default')
42
+ })
43
+
44
+ it('should return undefined when no default provided and source is undefined', () => {
45
+ expect(resolveValue(undefined, testModel)).toBeUndefined()
46
+ })
47
+ })
48
+
49
+ describe('with literal values', () => {
50
+ it('should resolve literalString', () => {
51
+ const source: ValueSource = { literalString: 'Hello' }
52
+ expect(resolveValue<string>(source, testModel)).toBe('Hello')
53
+ })
54
+
55
+ it('should resolve empty literalString', () => {
56
+ const source: ValueSource = { literalString: '' }
57
+ expect(resolveValue<string>(source, testModel, 'default')).toBe('')
58
+ })
59
+
60
+ it('should resolve literalNumber', () => {
61
+ const source: ValueSource = { literalNumber: 42 }
62
+ expect(resolveValue<number>(source, testModel)).toBe(42)
63
+ })
64
+
65
+ it('should resolve zero literalNumber', () => {
66
+ const source: ValueSource = { literalNumber: 0 }
67
+ expect(resolveValue<number>(source, testModel, 99)).toBe(0)
68
+ })
69
+
70
+ it('should resolve negative literalNumber', () => {
71
+ const source: ValueSource = { literalNumber: -10 }
72
+ expect(resolveValue<number>(source, testModel)).toBe(-10)
73
+ })
74
+
75
+ it('should resolve literalBoolean true', () => {
76
+ const source: ValueSource = { literalBoolean: true }
77
+ expect(resolveValue<boolean>(source, testModel)).toBe(true)
78
+ })
79
+
80
+ it('should resolve literalBoolean false', () => {
81
+ const source: ValueSource = { literalBoolean: false }
82
+ expect(resolveValue<boolean>(source, testModel, true)).toBe(false)
83
+ })
84
+
85
+ it('should resolve literalArray', () => {
86
+ const source: ValueSource = { literalArray: ['x', 'y', 'z'] }
87
+ expect(resolveValue<string[]>(source, testModel)).toEqual([
88
+ 'x',
89
+ 'y',
90
+ 'z',
91
+ ])
92
+ })
93
+
94
+ it('should resolve empty literalArray', () => {
95
+ const source: ValueSource = { literalArray: [] }
96
+ expect(resolveValue<string[]>(source, testModel, ['default'])).toEqual(
97
+ []
98
+ )
99
+ })
100
+ })
101
+
102
+ describe('with path references', () => {
103
+ it('should resolve path to string value', () => {
104
+ const source: ValueSource = { path: '/user/name' }
105
+ expect(resolveValue<string>(source, testModel)).toBe('John')
106
+ })
107
+
108
+ it('should resolve path to number value', () => {
109
+ const source: ValueSource = { path: '/count' }
110
+ expect(resolveValue<number>(source, testModel)).toBe(42)
111
+ })
112
+
113
+ it('should resolve path to boolean value', () => {
114
+ const source: ValueSource = { path: '/user/active' }
115
+ expect(resolveValue<boolean>(source, testModel)).toBe(true)
116
+ })
117
+
118
+ it('should resolve path to nested object', () => {
119
+ const source: ValueSource = { path: '/user' }
120
+ expect(resolveValue(source, testModel)).toEqual({
121
+ name: 'John',
122
+ age: 30,
123
+ active: true,
124
+ })
125
+ })
126
+
127
+ it('should resolve path to array', () => {
128
+ const source: ValueSource = { path: '/items' }
129
+ expect(resolveValue<string[]>(source, testModel)).toEqual([
130
+ 'a',
131
+ 'b',
132
+ 'c',
133
+ ])
134
+ })
135
+
136
+ it('should return default when path not found', () => {
137
+ const source: ValueSource = { path: '/nonexistent' }
138
+ expect(resolveValue<string>(source, testModel, 'default')).toBe(
139
+ 'default'
140
+ )
141
+ })
142
+
143
+ it('should return undefined when path not found and no default', () => {
144
+ const source: ValueSource = { path: '/nonexistent' }
145
+ expect(resolveValue(source, testModel)).toBeUndefined()
146
+ })
147
+
148
+ it('should handle empty data model', () => {
149
+ const source: ValueSource = { path: '/user/name' }
150
+ expect(resolveValue<string>(source, {}, 'default')).toBe('default')
151
+ })
152
+ })
153
+
154
+ describe('with unknown source structure', () => {
155
+ it('should return default value for unknown source structure', () => {
156
+ const source = { unknown: 'value' } as unknown as ValueSource
157
+ expect(resolveValue(source, testModel, 'default')).toBe('default')
158
+ })
159
+ })
160
+ })
161
+
162
+ describe('isPathReference', () => {
163
+ it('should return true for path reference', () => {
164
+ expect(isPathReference({ path: '/user/name' })).toBe(true)
165
+ })
166
+
167
+ it('should return false for literalString', () => {
168
+ expect(isPathReference({ literalString: 'Hello' })).toBe(false)
169
+ })
170
+
171
+ it('should return false for literalNumber', () => {
172
+ expect(isPathReference({ literalNumber: 42 })).toBe(false)
173
+ })
174
+
175
+ it('should return false for literalBoolean', () => {
176
+ expect(isPathReference({ literalBoolean: true })).toBe(false)
177
+ })
178
+
179
+ it('should return false for literalArray', () => {
180
+ expect(isPathReference({ literalArray: ['a', 'b'] })).toBe(false)
181
+ })
182
+
183
+ it('should return false for undefined', () => {
184
+ expect(isPathReference(undefined)).toBe(false)
185
+ })
186
+
187
+ it('should return false for null', () => {
188
+ expect(isPathReference(null as unknown as ValueSource)).toBe(false)
189
+ })
190
+ })
191
+
192
+ describe('getPath', () => {
193
+ it('should return path from path reference', () => {
194
+ expect(getPath({ path: '/user/name' })).toBe('/user/name')
195
+ })
196
+
197
+ it('should return undefined for literalString', () => {
198
+ expect(getPath({ literalString: 'Hello' })).toBeUndefined()
199
+ })
200
+
201
+ it('should return undefined for literalNumber', () => {
202
+ expect(getPath({ literalNumber: 42 })).toBeUndefined()
203
+ })
204
+
205
+ it('should return undefined for literalBoolean', () => {
206
+ expect(getPath({ literalBoolean: true })).toBeUndefined()
207
+ })
208
+
209
+ it('should return undefined for literalArray', () => {
210
+ expect(getPath({ literalArray: ['a'] })).toBeUndefined()
211
+ })
212
+
213
+ it('should return undefined for undefined source', () => {
214
+ expect(getPath(undefined)).toBeUndefined()
215
+ })
216
+ })
217
+
218
+ describe('contentsToObject', () => {
219
+ it('should convert string entry', () => {
220
+ const contents: DataEntry[] = [{ key: 'name', valueString: 'John' }]
221
+ expect(contentsToObject(contents)).toEqual({ name: 'John' })
222
+ })
223
+
224
+ it('should convert number entry', () => {
225
+ const contents: DataEntry[] = [{ key: 'age', valueNumber: 30 }]
226
+ expect(contentsToObject(contents)).toEqual({ age: 30 })
227
+ })
228
+
229
+ it('should convert boolean entry', () => {
230
+ const contents: DataEntry[] = [{ key: 'active', valueBoolean: true }]
231
+ expect(contentsToObject(contents)).toEqual({ active: true })
232
+ })
233
+
234
+ it('should convert false boolean entry', () => {
235
+ const contents: DataEntry[] = [{ key: 'active', valueBoolean: false }]
236
+ expect(contentsToObject(contents)).toEqual({ active: false })
237
+ })
238
+
239
+ it('should convert zero number entry', () => {
240
+ const contents: DataEntry[] = [{ key: 'count', valueNumber: 0 }]
241
+ expect(contentsToObject(contents)).toEqual({ count: 0 })
242
+ })
243
+
244
+ it('should convert empty string entry', () => {
245
+ const contents: DataEntry[] = [{ key: 'name', valueString: '' }]
246
+ expect(contentsToObject(contents)).toEqual({ name: '' })
247
+ })
248
+
249
+ it('should convert nested map entry', () => {
250
+ const contents: DataEntry[] = [
251
+ {
252
+ key: 'user',
253
+ valueMap: [
254
+ { key: 'name', valueString: 'John' },
255
+ { key: 'age', valueNumber: 30 },
256
+ ],
257
+ },
258
+ ]
259
+ expect(contentsToObject(contents)).toEqual({
260
+ user: { name: 'John', age: 30 },
261
+ })
262
+ })
263
+
264
+ it('should convert deeply nested map entry', () => {
265
+ const contents: DataEntry[] = [
266
+ {
267
+ key: 'user',
268
+ valueMap: [
269
+ {
270
+ key: 'profile',
271
+ valueMap: [{ key: 'email', valueString: 'john@example.com' }],
272
+ },
273
+ ],
274
+ },
275
+ ]
276
+ expect(contentsToObject(contents)).toEqual({
277
+ user: { profile: { email: 'john@example.com' } },
278
+ })
279
+ })
280
+
281
+ it('should convert multiple entries', () => {
282
+ const contents: DataEntry[] = [
283
+ { key: 'name', valueString: 'John' },
284
+ { key: 'age', valueNumber: 30 },
285
+ { key: 'active', valueBoolean: true },
286
+ ]
287
+ expect(contentsToObject(contents)).toEqual({
288
+ name: 'John',
289
+ age: 30,
290
+ active: true,
291
+ })
292
+ })
293
+
294
+ it('should normalize path keys to last segment', () => {
295
+ const contents: DataEntry[] = [
296
+ { key: '/form/name', valueString: 'John' },
297
+ { key: '/form/age', valueNumber: 30 },
298
+ ]
299
+ expect(contentsToObject(contents)).toEqual({
300
+ name: 'John',
301
+ age: 30,
302
+ })
303
+ })
304
+
305
+ it('should handle empty contents array', () => {
306
+ expect(contentsToObject([])).toEqual({})
307
+ })
308
+
309
+ it('should handle entry with no value type', () => {
310
+ const contents: DataEntry[] = [{ key: 'empty' }]
311
+ expect(contentsToObject(contents)).toEqual({})
312
+ })
313
+ })
314
+
315
+ describe('literal factory functions', () => {
316
+ describe('literalString', () => {
317
+ it('should create literalString value source', () => {
318
+ expect(literalString('Hello')).toEqual({ literalString: 'Hello' })
319
+ })
320
+
321
+ it('should create empty literalString', () => {
322
+ expect(literalString('')).toEqual({ literalString: '' })
323
+ })
324
+ })
325
+
326
+ describe('literalNumber', () => {
327
+ it('should create literalNumber value source', () => {
328
+ expect(literalNumber(42)).toEqual({ literalNumber: 42 })
329
+ })
330
+
331
+ it('should create zero literalNumber', () => {
332
+ expect(literalNumber(0)).toEqual({ literalNumber: 0 })
333
+ })
334
+
335
+ it('should create negative literalNumber', () => {
336
+ expect(literalNumber(-10)).toEqual({ literalNumber: -10 })
337
+ })
338
+
339
+ it('should create float literalNumber', () => {
340
+ expect(literalNumber(3.14)).toEqual({ literalNumber: 3.14 })
341
+ })
342
+ })
343
+
344
+ describe('literalBoolean', () => {
345
+ it('should create true literalBoolean', () => {
346
+ expect(literalBoolean(true)).toEqual({ literalBoolean: true })
347
+ })
348
+
349
+ it('should create false literalBoolean', () => {
350
+ expect(literalBoolean(false)).toEqual({ literalBoolean: false })
351
+ })
352
+ })
353
+
354
+ describe('pathRef', () => {
355
+ it('should create path reference', () => {
356
+ expect(pathRef('/user/name')).toEqual({ path: '/user/name' })
357
+ })
358
+
359
+ it('should create root path reference', () => {
360
+ expect(pathRef('/')).toEqual({ path: '/' })
361
+ })
362
+
363
+ it('should create path without leading slash', () => {
364
+ expect(pathRef('user/name')).toEqual({ path: 'user/name' })
365
+ })
366
+ })
367
+ })
368
+
369
+ describe('resolveActionContext', () => {
370
+ const testModel: DataModel = {
371
+ user: {
372
+ name: 'John',
373
+ age: 30,
374
+ },
375
+ selectedId: 'item-123',
376
+ }
377
+
378
+ it('should return empty object for undefined context', () => {
379
+ expect(resolveActionContext(undefined, testModel)).toEqual({})
380
+ })
381
+
382
+ it('should return empty object for empty context array', () => {
383
+ expect(resolveActionContext([], testModel)).toEqual({})
384
+ })
385
+
386
+ it('should resolve literalString values', () => {
387
+ const context = [{ key: 'action', value: { literalString: 'submit' } }]
388
+ expect(resolveActionContext(context, testModel)).toEqual({
389
+ action: 'submit',
390
+ })
391
+ })
392
+
393
+ it('should resolve literalNumber values', () => {
394
+ const context = [{ key: 'count', value: { literalNumber: 5 } }]
395
+ expect(resolveActionContext(context, testModel)).toEqual({ count: 5 })
396
+ })
397
+
398
+ it('should resolve literalBoolean values', () => {
399
+ const context = [{ key: 'confirmed', value: { literalBoolean: true } }]
400
+ expect(resolveActionContext(context, testModel)).toEqual({
401
+ confirmed: true,
402
+ })
403
+ })
404
+
405
+ it('should resolve path references', () => {
406
+ const context = [{ key: 'userName', value: { path: '/user/name' } }]
407
+ expect(resolveActionContext(context, testModel)).toEqual({
408
+ userName: 'John',
409
+ })
410
+ })
411
+
412
+ it('should resolve multiple context items', () => {
413
+ const context = [
414
+ { key: 'action', value: { literalString: 'update' } },
415
+ { key: 'userId', value: { path: '/selectedId' } },
416
+ { key: 'confirmed', value: { literalBoolean: true } },
417
+ ]
418
+ expect(resolveActionContext(context, testModel)).toEqual({
419
+ action: 'update',
420
+ userId: 'item-123',
421
+ confirmed: true,
422
+ })
423
+ })
424
+
425
+ it('should return undefined for non-existent path', () => {
426
+ const context = [{ key: 'missing', value: { path: '/nonexistent' } }]
427
+ expect(resolveActionContext(context, testModel)).toEqual({
428
+ missing: undefined,
429
+ })
430
+ })
431
+
432
+ it('should resolve nested path references', () => {
433
+ const context = [
434
+ { key: 'name', value: { path: '/user/name' } },
435
+ { key: 'age', value: { path: '/user/age' } },
436
+ ]
437
+ expect(resolveActionContext(context, testModel)).toEqual({
438
+ name: 'John',
439
+ age: 30,
440
+ })
441
+ })
442
+ })
443
+ })
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Data binding utility functions for A2UI.
3
+ * Handles resolving value sources and converting data entries.
4
+ */
5
+
6
+ import type {
7
+ ValueSource,
8
+ DataModel,
9
+ DataEntry,
10
+ DataModelValue,
11
+ } from '../types'
12
+ import { getValueByPath } from './pathUtils'
13
+
14
+ /**
15
+ * Resolves a ValueSource to its actual value.
16
+ *
17
+ * @param source - The value source (literal or path reference)
18
+ * @param dataModel - The data model for path lookups
19
+ * @param defaultValue - Default value if source is undefined or path not found
20
+ * @returns The resolved value
21
+ *
22
+ * @example
23
+ * // Literal values
24
+ * resolveValue({ literalString: "Hello" }, {}); // "Hello"
25
+ * resolveValue({ literalNumber: 42 }, {}); // 42
26
+ *
27
+ * // Path references
28
+ * const model = { user: { name: "John" } };
29
+ * resolveValue({ path: "/user/name" }, model); // "John"
30
+ * resolveValue({ path: "/user/age" }, model, 0); // 0 (default)
31
+ */
32
+ export function resolveValue<T = unknown>(
33
+ source: ValueSource | undefined,
34
+ dataModel: DataModel,
35
+ defaultValue?: T
36
+ ): T {
37
+ if (source === undefined || source === null) {
38
+ return defaultValue as T
39
+ }
40
+
41
+ if ('literalString' in source) {
42
+ return source.literalString as T
43
+ }
44
+
45
+ if ('literalNumber' in source) {
46
+ return source.literalNumber as T
47
+ }
48
+
49
+ if ('literalBoolean' in source) {
50
+ return source.literalBoolean as T
51
+ }
52
+
53
+ if ('literalArray' in source) {
54
+ return source.literalArray as T
55
+ }
56
+
57
+ if ('path' in source) {
58
+ const value = getValueByPath(dataModel, source.path)
59
+ if (value === undefined) {
60
+ return defaultValue as T
61
+ }
62
+ return value as T
63
+ }
64
+
65
+ return defaultValue as T
66
+ }
67
+
68
+ /**
69
+ * Checks if a value source is a path reference.
70
+ *
71
+ * @param source - The value source to check
72
+ * @returns True if the source is a path reference
73
+ */
74
+ export function isPathReference(
75
+ source: ValueSource | undefined
76
+ ): source is { path: string } {
77
+ return source !== undefined && source !== null && 'path' in source
78
+ }
79
+
80
+ /**
81
+ * Gets the path from a value source, or undefined if it's not a path reference.
82
+ *
83
+ * @param source - The value source
84
+ * @returns The path string or undefined
85
+ */
86
+ export function getPath(source: ValueSource | undefined): string | undefined {
87
+ if (isPathReference(source)) {
88
+ return source.path
89
+ }
90
+ return undefined
91
+ }
92
+
93
+ /**
94
+ * Converts a DataEntry array to a plain object.
95
+ * This is used for processing dataModelUpdate message contents.
96
+ *
97
+ * @param contents - Array of data entries from the server
98
+ * @returns A plain object with the converted values
99
+ *
100
+ * @example
101
+ * contentsToObject([
102
+ * { key: "name", valueString: "John" },
103
+ * { key: "age", valueNumber: 30 },
104
+ * { key: "active", valueBoolean: true },
105
+ * { key: "profile", valueMap: [
106
+ * { key: "email", valueString: "john@example.com" }
107
+ * ]}
108
+ * ]);
109
+ * // Returns: { name: "John", age: 30, active: true, profile: { email: "john@example.com" } }
110
+ */
111
+ export function contentsToObject(
112
+ contents: DataEntry[]
113
+ ): Record<string, DataModelValue> {
114
+ const result: Record<string, DataModelValue> = {}
115
+
116
+ for (const entry of contents) {
117
+ const key = normalizeKey(entry.key)
118
+
119
+ if (entry.valueString !== undefined) {
120
+ result[key] = entry.valueString
121
+ } else if (entry.valueNumber !== undefined) {
122
+ result[key] = entry.valueNumber
123
+ } else if (entry.valueBoolean !== undefined) {
124
+ result[key] = entry.valueBoolean
125
+ } else if (entry.valueMap !== undefined) {
126
+ result[key] = contentsToObject(entry.valueMap)
127
+ }
128
+ }
129
+
130
+ return result
131
+ }
132
+
133
+ /**
134
+ * Normalizes a key from the data entry format.
135
+ * Keys can come as "/form/name" or just "name".
136
+ *
137
+ * @param key - The key to normalize
138
+ * @returns The normalized key (last segment)
139
+ */
140
+ function normalizeKey(key: string): string {
141
+ // If key contains path separators, take the last segment
142
+ if (key.includes('/')) {
143
+ const segments = key.split('/').filter(Boolean)
144
+ return segments[segments.length - 1] || key
145
+ }
146
+ return key
147
+ }
148
+
149
+ /**
150
+ * Creates a literal string value source.
151
+ *
152
+ * @param value - The string value
153
+ * @returns A ValueSource with literalString
154
+ */
155
+ export function literalString(value: string): ValueSource {
156
+ return { literalString: value }
157
+ }
158
+
159
+ /**
160
+ * Creates a literal number value source.
161
+ *
162
+ * @param value - The number value
163
+ * @returns A ValueSource with literalNumber
164
+ */
165
+ export function literalNumber(value: number): ValueSource {
166
+ return { literalNumber: value }
167
+ }
168
+
169
+ /**
170
+ * Creates a literal boolean value source.
171
+ *
172
+ * @param value - The boolean value
173
+ * @returns A ValueSource with literalBoolean
174
+ */
175
+ export function literalBoolean(value: boolean): ValueSource {
176
+ return { literalBoolean: value }
177
+ }
178
+
179
+ /**
180
+ * Creates a path reference value source.
181
+ *
182
+ * @param path - The data model path
183
+ * @returns A ValueSource with path
184
+ */
185
+ export function pathRef(path: string): ValueSource {
186
+ return { path }
187
+ }
188
+
189
+ /**
190
+ * Resolves action context items to a plain object.
191
+ * This is used when dispatching actions to resolve all context values.
192
+ *
193
+ * @param context - Array of action context items
194
+ * @param dataModel - The data model for path lookups
195
+ * @returns A plain object with resolved context values
196
+ */
197
+ export function resolveActionContext(
198
+ context: Array<{ key: string; value: ValueSource }> | undefined,
199
+ dataModel: DataModel
200
+ ): Record<string, unknown> {
201
+ if (!context) {
202
+ return {}
203
+ }
204
+
205
+ const result: Record<string, unknown> = {}
206
+
207
+ for (const item of context) {
208
+ result[item.key] = resolveValue(item.value, dataModel)
209
+ }
210
+
211
+ return result
212
+ }