@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,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pathUtils Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for path utility functions used in A2UI data model operations.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect } from 'vitest'
|
|
8
|
+
import {
|
|
9
|
+
getValueByPath,
|
|
10
|
+
setValueByPath,
|
|
11
|
+
mergeAtPath,
|
|
12
|
+
normalizePath,
|
|
13
|
+
joinPaths,
|
|
14
|
+
} from './pathUtils'
|
|
15
|
+
import type { DataModel } from '../types'
|
|
16
|
+
|
|
17
|
+
describe('pathUtils', () => {
|
|
18
|
+
describe('getValueByPath', () => {
|
|
19
|
+
const testModel: DataModel = {
|
|
20
|
+
user: {
|
|
21
|
+
name: 'John',
|
|
22
|
+
age: 30,
|
|
23
|
+
profile: {
|
|
24
|
+
email: 'john@example.com',
|
|
25
|
+
active: true,
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
items: ['a', 'b', 'c'],
|
|
29
|
+
count: 42,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
it('should return entire data model for empty path', () => {
|
|
33
|
+
expect(getValueByPath(testModel, '')).toEqual(testModel)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('should return entire data model for root path', () => {
|
|
37
|
+
expect(getValueByPath(testModel, '/')).toEqual(testModel)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('should get top-level string value', () => {
|
|
41
|
+
const model = { name: 'test' }
|
|
42
|
+
expect(getValueByPath(model, '/name')).toBe('test')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('should get top-level number value', () => {
|
|
46
|
+
expect(getValueByPath(testModel, '/count')).toBe(42)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('should get top-level array value', () => {
|
|
50
|
+
expect(getValueByPath(testModel, '/items')).toEqual(['a', 'b', 'c'])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('should get nested object value', () => {
|
|
54
|
+
expect(getValueByPath(testModel, '/user')).toEqual({
|
|
55
|
+
name: 'John',
|
|
56
|
+
age: 30,
|
|
57
|
+
profile: {
|
|
58
|
+
email: 'john@example.com',
|
|
59
|
+
active: true,
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('should get deeply nested string value', () => {
|
|
65
|
+
expect(getValueByPath(testModel, '/user/name')).toBe('John')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('should get deeply nested number value', () => {
|
|
69
|
+
expect(getValueByPath(testModel, '/user/age')).toBe(30)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('should get deeply nested object value', () => {
|
|
73
|
+
expect(getValueByPath(testModel, '/user/profile')).toEqual({
|
|
74
|
+
email: 'john@example.com',
|
|
75
|
+
active: true,
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('should get very deeply nested value', () => {
|
|
80
|
+
expect(getValueByPath(testModel, '/user/profile/email')).toBe(
|
|
81
|
+
'john@example.com'
|
|
82
|
+
)
|
|
83
|
+
expect(getValueByPath(testModel, '/user/profile/active')).toBe(true)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('should return undefined for non-existent path', () => {
|
|
87
|
+
expect(getValueByPath(testModel, '/nonexistent')).toBeUndefined()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('should return undefined for non-existent nested path', () => {
|
|
91
|
+
expect(getValueByPath(testModel, '/user/nonexistent')).toBeUndefined()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('should return undefined for path through non-object', () => {
|
|
95
|
+
expect(getValueByPath(testModel, '/count/nested')).toBeUndefined()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('should return undefined when intermediate value is null', () => {
|
|
99
|
+
const model = { user: null }
|
|
100
|
+
expect(getValueByPath(model, '/user/name')).toBeUndefined()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('should return undefined when intermediate value is undefined', () => {
|
|
104
|
+
const model = { user: undefined }
|
|
105
|
+
expect(
|
|
106
|
+
getValueByPath(model as unknown as DataModel, '/user/name')
|
|
107
|
+
).toBeUndefined()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('should handle paths without leading slash', () => {
|
|
111
|
+
expect(getValueByPath(testModel, 'user/name')).toBe('John')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('should handle empty data model', () => {
|
|
115
|
+
expect(getValueByPath({}, '/user/name')).toBeUndefined()
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('setValueByPath', () => {
|
|
120
|
+
it('should return merged object for empty path with object value', () => {
|
|
121
|
+
const model = { a: 1 }
|
|
122
|
+
const result = setValueByPath(model, '', { b: 2 })
|
|
123
|
+
expect(result).toEqual({ a: 1, b: 2 })
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('should return merged object for root path with object value', () => {
|
|
127
|
+
const model = { a: 1 }
|
|
128
|
+
const result = setValueByPath(model, '/', { b: 2 })
|
|
129
|
+
expect(result).toEqual({ a: 1, b: 2 })
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('should return original model for root path with non-object value', () => {
|
|
133
|
+
const model = { a: 1 }
|
|
134
|
+
const result = setValueByPath(model, '/', 'string')
|
|
135
|
+
expect(result).toEqual({ a: 1 })
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('should return original model for root path with array value', () => {
|
|
139
|
+
const model = { a: 1 }
|
|
140
|
+
const result = setValueByPath(model, '/', [1, 2, 3])
|
|
141
|
+
expect(result).toEqual({ a: 1 })
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('should set top-level value', () => {
|
|
145
|
+
const model = { a: 1 }
|
|
146
|
+
const result = setValueByPath(model, '/b', 2)
|
|
147
|
+
expect(result).toEqual({ a: 1, b: 2 })
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('should update existing top-level value', () => {
|
|
151
|
+
const model = { a: 1 }
|
|
152
|
+
const result = setValueByPath(model, '/a', 2)
|
|
153
|
+
expect(result).toEqual({ a: 2 })
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('should set nested value in existing object', () => {
|
|
157
|
+
const model = { user: { name: 'John' } }
|
|
158
|
+
const result = setValueByPath(model, '/user/age', 30)
|
|
159
|
+
expect(result).toEqual({ user: { name: 'John', age: 30 } })
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('should update existing nested value', () => {
|
|
163
|
+
const model = { user: { name: 'John' } }
|
|
164
|
+
const result = setValueByPath(model, '/user/name', 'Jane')
|
|
165
|
+
expect(result).toEqual({ user: { name: 'Jane' } })
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('should create nested structure if not exists', () => {
|
|
169
|
+
const model = {}
|
|
170
|
+
const result = setValueByPath(
|
|
171
|
+
model,
|
|
172
|
+
'/user/profile/email',
|
|
173
|
+
'test@test.com'
|
|
174
|
+
)
|
|
175
|
+
expect(result).toEqual({
|
|
176
|
+
user: { profile: { email: 'test@test.com' } },
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('should replace non-object with object when setting nested path', () => {
|
|
181
|
+
const model = { user: 'string' }
|
|
182
|
+
const result = setValueByPath(model, '/user/name', 'John')
|
|
183
|
+
expect(result).toEqual({ user: { name: 'John' } })
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('should be immutable - not modify original model', () => {
|
|
187
|
+
const model = { user: { name: 'John' } }
|
|
188
|
+
const result = setValueByPath(model, '/user/name', 'Jane')
|
|
189
|
+
expect(model).toEqual({ user: { name: 'John' } })
|
|
190
|
+
expect(result).toEqual({ user: { name: 'Jane' } })
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('should handle setting null value', () => {
|
|
194
|
+
const model = { user: { name: 'John' } }
|
|
195
|
+
const result = setValueByPath(model, '/user/name', null)
|
|
196
|
+
expect(result).toEqual({ user: { name: null } })
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('should handle setting array value', () => {
|
|
200
|
+
const model = { items: [] }
|
|
201
|
+
const result = setValueByPath(model, '/items', ['a', 'b'])
|
|
202
|
+
expect(result).toEqual({ items: ['a', 'b'] })
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('should handle setting object value', () => {
|
|
206
|
+
const model = { user: null }
|
|
207
|
+
const result = setValueByPath(model, '/user', { name: 'John' })
|
|
208
|
+
expect(result).toEqual({ user: { name: 'John' } })
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
describe('mergeAtPath', () => {
|
|
213
|
+
it('should merge at root for empty path', () => {
|
|
214
|
+
const model = { a: 1 }
|
|
215
|
+
const result = mergeAtPath(model, '', { b: 2 })
|
|
216
|
+
expect(result).toEqual({ a: 1, b: 2 })
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('should merge at root for root path', () => {
|
|
220
|
+
const model = { a: 1 }
|
|
221
|
+
const result = mergeAtPath(model, '/', { b: 2 })
|
|
222
|
+
expect(result).toEqual({ a: 1, b: 2 })
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('should overwrite existing keys at root', () => {
|
|
226
|
+
const model = { a: 1, b: 2 }
|
|
227
|
+
const result = mergeAtPath(model, '/', { b: 3, c: 4 })
|
|
228
|
+
expect(result).toEqual({ a: 1, b: 3, c: 4 })
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('should merge at nested path', () => {
|
|
232
|
+
const model = { user: { name: 'John' } }
|
|
233
|
+
const result = mergeAtPath(model, '/user', { age: 30 })
|
|
234
|
+
expect(result).toEqual({ user: { name: 'John', age: 30 } })
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('should overwrite existing keys at nested path', () => {
|
|
238
|
+
const model = { user: { name: 'John', age: 25 } }
|
|
239
|
+
const result = mergeAtPath(model, '/user', { age: 30 })
|
|
240
|
+
expect(result).toEqual({ user: { name: 'John', age: 30 } })
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('should create path if not exists', () => {
|
|
244
|
+
const model = {}
|
|
245
|
+
const result = mergeAtPath(model, '/user', { name: 'John' })
|
|
246
|
+
expect(result).toEqual({ user: { name: 'John' } })
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('should replace non-object at path with merged result', () => {
|
|
250
|
+
const model = { user: 'string' }
|
|
251
|
+
const result = mergeAtPath(model, '/user', { name: 'John' })
|
|
252
|
+
expect(result).toEqual({ user: { name: 'John' } })
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('should handle array at path by treating as non-object', () => {
|
|
256
|
+
const model = { items: [1, 2, 3] }
|
|
257
|
+
const result = mergeAtPath(model, '/items', { a: 1 })
|
|
258
|
+
expect(result).toEqual({ items: { a: 1 } })
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('should be immutable - not modify original model', () => {
|
|
262
|
+
const model = { user: { name: 'John' } }
|
|
263
|
+
const result = mergeAtPath(model, '/user', { age: 30 })
|
|
264
|
+
expect(model).toEqual({ user: { name: 'John' } })
|
|
265
|
+
expect(result).toEqual({ user: { name: 'John', age: 30 } })
|
|
266
|
+
})
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
describe('normalizePath', () => {
|
|
270
|
+
it('should add leading slash if missing', () => {
|
|
271
|
+
expect(normalizePath('user/name')).toBe('/user/name')
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('should keep existing leading slash', () => {
|
|
275
|
+
expect(normalizePath('/user/name')).toBe('/user/name')
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('should remove trailing slash', () => {
|
|
279
|
+
expect(normalizePath('/user/name/')).toBe('/user/name')
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('should remove trailing slash but keep single root slash', () => {
|
|
283
|
+
expect(normalizePath('/')).toBe('/')
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('should handle single slash path', () => {
|
|
287
|
+
expect(normalizePath('/')).toBe('/')
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('should add leading and remove trailing slash', () => {
|
|
291
|
+
expect(normalizePath('user/name/')).toBe('/user/name')
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('should trim whitespace', () => {
|
|
295
|
+
expect(normalizePath(' /user/name ')).toBe('/user/name')
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('should handle empty string', () => {
|
|
299
|
+
expect(normalizePath('')).toBe('/')
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('should handle whitespace only', () => {
|
|
303
|
+
expect(normalizePath(' ')).toBe('/')
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('should handle multiple trailing slashes', () => {
|
|
307
|
+
expect(normalizePath('/user/name//')).toBe('/user/name/')
|
|
308
|
+
// Note: only removes one trailing slash
|
|
309
|
+
})
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
describe('joinPaths', () => {
|
|
313
|
+
it('should join base and relative paths', () => {
|
|
314
|
+
expect(joinPaths('/user', 'name')).toBe('/user/name')
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('should handle leading slash in relative path', () => {
|
|
318
|
+
expect(joinPaths('/user', '/name')).toBe('/user/name')
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('should handle trailing slash in base path', () => {
|
|
322
|
+
expect(joinPaths('/user/', 'name')).toBe('/user/name')
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it('should handle both leading and trailing slashes', () => {
|
|
326
|
+
expect(joinPaths('/user/', '/name/')).toBe('/user/name')
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
it('should return base path for empty relative path', () => {
|
|
330
|
+
expect(joinPaths('/user', '')).toBe('/user')
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('should handle root base path', () => {
|
|
334
|
+
expect(joinPaths('/', 'user')).toBe('/user')
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
it('should handle root base path with leading slash in relative', () => {
|
|
338
|
+
expect(joinPaths('/', '/user')).toBe('/user')
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('should normalize base path without leading slash', () => {
|
|
342
|
+
expect(joinPaths('user', 'name')).toBe('/user/name')
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('should handle multi-segment relative path', () => {
|
|
346
|
+
expect(joinPaths('/base', 'a/b/c')).toBe('/base/a/b/c')
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
it('should handle whitespace in relative path', () => {
|
|
350
|
+
expect(joinPaths('/user', ' name ')).toBe('/user/name')
|
|
351
|
+
})
|
|
352
|
+
})
|
|
353
|
+
})
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path utility functions for A2UI data model operations.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { DataModel, DataModelValue } from '../types'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Gets a value from the data model by path.
|
|
9
|
+
*
|
|
10
|
+
* @param dataModel - The data model to read from
|
|
11
|
+
* @param path - The path to the value (e.g., "/user/name")
|
|
12
|
+
* @returns The value at the path, or undefined if not found
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* const model = { user: { name: "John" } };
|
|
16
|
+
* getValueByPath(model, "/user/name"); // "John"
|
|
17
|
+
* getValueByPath(model, "/user/age"); // undefined
|
|
18
|
+
*/
|
|
19
|
+
export function getValueByPath(
|
|
20
|
+
dataModel: DataModel,
|
|
21
|
+
path: string
|
|
22
|
+
): DataModelValue | undefined {
|
|
23
|
+
if (!path || path === '/') {
|
|
24
|
+
return dataModel as DataModelValue
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Split path: "/user/name" -> ["user", "name"]
|
|
28
|
+
const keys = path.split('/').filter(Boolean)
|
|
29
|
+
let current: unknown = dataModel
|
|
30
|
+
|
|
31
|
+
for (const key of keys) {
|
|
32
|
+
if (current === null || current === undefined) {
|
|
33
|
+
return undefined
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (typeof current !== 'object') {
|
|
37
|
+
return undefined
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
current = (current as Record<string, unknown>)[key]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return current as DataModelValue | undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Sets a value in the data model by path, returning a new data model.
|
|
48
|
+
* This function is immutable - it does not modify the original data model.
|
|
49
|
+
*
|
|
50
|
+
* @param dataModel - The data model to update
|
|
51
|
+
* @param path - The path to set (e.g., "/user/name")
|
|
52
|
+
* @param value - The value to set
|
|
53
|
+
* @returns A new data model with the value set
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* const model = { user: { name: "John" } };
|
|
57
|
+
* setValueByPath(model, "/user/name", "Jane");
|
|
58
|
+
* // Returns: { user: { name: "Jane" } }
|
|
59
|
+
*/
|
|
60
|
+
export function setValueByPath(
|
|
61
|
+
dataModel: DataModel,
|
|
62
|
+
path: string,
|
|
63
|
+
value: unknown
|
|
64
|
+
): DataModel {
|
|
65
|
+
if (!path || path === '/') {
|
|
66
|
+
// Replace the entire data model
|
|
67
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
68
|
+
return { ...dataModel, ...(value as DataModel) }
|
|
69
|
+
}
|
|
70
|
+
return dataModel
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Split path: "/user/name" -> ["user", "name"]
|
|
74
|
+
const keys = path.split('/').filter(Boolean)
|
|
75
|
+
|
|
76
|
+
// Recursive helper to build the new object structure
|
|
77
|
+
function setNested(
|
|
78
|
+
obj: DataModel,
|
|
79
|
+
remainingKeys: string[],
|
|
80
|
+
val: unknown
|
|
81
|
+
): DataModel {
|
|
82
|
+
if (remainingKeys.length === 0) {
|
|
83
|
+
// This shouldn't happen, but handle gracefully
|
|
84
|
+
if (typeof val === 'object' && val !== null && !Array.isArray(val)) {
|
|
85
|
+
return { ...obj, ...(val as DataModel) }
|
|
86
|
+
}
|
|
87
|
+
return obj
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const [key, ...rest] = remainingKeys
|
|
91
|
+
|
|
92
|
+
if (rest.length === 0) {
|
|
93
|
+
// Last key - set the value
|
|
94
|
+
return {
|
|
95
|
+
...obj,
|
|
96
|
+
[key]: val as DataModelValue,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Intermediate key - recurse
|
|
101
|
+
const existingValue = obj[key]
|
|
102
|
+
const nestedObj =
|
|
103
|
+
typeof existingValue === 'object' &&
|
|
104
|
+
existingValue !== null &&
|
|
105
|
+
!Array.isArray(existingValue)
|
|
106
|
+
? (existingValue as DataModel)
|
|
107
|
+
: {}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
...obj,
|
|
111
|
+
[key]: setNested(nestedObj, rest, val),
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return setNested(dataModel, keys, value)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Merges data into the data model at a given path.
|
|
120
|
+
* This is used for dataModelUpdate messages where contents are merged.
|
|
121
|
+
*
|
|
122
|
+
* @param dataModel - The data model to update
|
|
123
|
+
* @param path - The path to merge at (e.g., "/form")
|
|
124
|
+
* @param data - The data to merge
|
|
125
|
+
* @returns A new data model with the data merged
|
|
126
|
+
*/
|
|
127
|
+
export function mergeAtPath(
|
|
128
|
+
dataModel: DataModel,
|
|
129
|
+
path: string,
|
|
130
|
+
data: Record<string, unknown>
|
|
131
|
+
): DataModel {
|
|
132
|
+
if (!path || path === '/') {
|
|
133
|
+
// Merge at root
|
|
134
|
+
return { ...dataModel, ...data } as DataModel
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Get current value at path
|
|
138
|
+
const current = getValueByPath(dataModel, path)
|
|
139
|
+
const currentObj =
|
|
140
|
+
typeof current === 'object' && current !== null && !Array.isArray(current)
|
|
141
|
+
? (current as DataModel)
|
|
142
|
+
: {}
|
|
143
|
+
|
|
144
|
+
// Merge and set
|
|
145
|
+
const merged = { ...currentObj, ...data }
|
|
146
|
+
return setValueByPath(dataModel, path, merged)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Normalizes a path to ensure it starts with "/" and has no trailing "/".
|
|
151
|
+
*
|
|
152
|
+
* @param path - The path to normalize
|
|
153
|
+
* @returns The normalized path
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* normalizePath("user/name"); // "/user/name"
|
|
157
|
+
* normalizePath("/user/name/"); // "/user/name"
|
|
158
|
+
*/
|
|
159
|
+
export function normalizePath(path: string): string {
|
|
160
|
+
let normalized = path.trim()
|
|
161
|
+
|
|
162
|
+
// Ensure starts with /
|
|
163
|
+
if (!normalized.startsWith('/')) {
|
|
164
|
+
normalized = '/' + normalized
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Remove trailing / (except for root path)
|
|
168
|
+
if (normalized.length > 1 && normalized.endsWith('/')) {
|
|
169
|
+
normalized = normalized.slice(0, -1)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return normalized
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Joins two paths together.
|
|
177
|
+
*
|
|
178
|
+
* @param basePath - The base path
|
|
179
|
+
* @param relativePath - The relative path to join
|
|
180
|
+
* @returns The joined path
|
|
181
|
+
*
|
|
182
|
+
* @example
|
|
183
|
+
* joinPaths("/user", "name"); // "/user/name"
|
|
184
|
+
* joinPaths("/user", "/name"); // "/user/name"
|
|
185
|
+
* joinPaths("/user/", "/name/"); // "/user/name"
|
|
186
|
+
*/
|
|
187
|
+
export function joinPaths(basePath: string, relativePath: string): string {
|
|
188
|
+
const base = normalizePath(basePath)
|
|
189
|
+
const relative = relativePath.trim().replace(/^\/+/, '').replace(/\/+$/, '')
|
|
190
|
+
|
|
191
|
+
if (!relative) {
|
|
192
|
+
return base
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (base === '/') {
|
|
196
|
+
return '/' + relative
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return base + '/' + relative
|
|
200
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { Slot } from '@radix-ui/react-slot'
|
|
3
|
+
import { cva, type VariantProps } from 'class-variance-authority'
|
|
4
|
+
|
|
5
|
+
import { cn } from '@/lib/utils'
|
|
6
|
+
|
|
7
|
+
const buttonVariants = cva(
|
|
8
|
+
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
|
9
|
+
{
|
|
10
|
+
variants: {
|
|
11
|
+
variant: {
|
|
12
|
+
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
|
13
|
+
destructive:
|
|
14
|
+
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
|
15
|
+
outline:
|
|
16
|
+
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
|
17
|
+
secondary:
|
|
18
|
+
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
|
19
|
+
ghost:
|
|
20
|
+
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
|
21
|
+
link: 'text-primary underline-offset-4 hover:underline',
|
|
22
|
+
},
|
|
23
|
+
size: {
|
|
24
|
+
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
|
25
|
+
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
|
26
|
+
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
|
27
|
+
icon: 'size-9',
|
|
28
|
+
'icon-sm': 'size-8',
|
|
29
|
+
'icon-lg': 'size-10',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
defaultVariants: {
|
|
33
|
+
variant: 'default',
|
|
34
|
+
size: 'default',
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
function Button({
|
|
40
|
+
className,
|
|
41
|
+
variant = 'default',
|
|
42
|
+
size = 'default',
|
|
43
|
+
asChild = false,
|
|
44
|
+
...props
|
|
45
|
+
}: React.ComponentProps<'button'> &
|
|
46
|
+
VariantProps<typeof buttonVariants> & {
|
|
47
|
+
asChild?: boolean
|
|
48
|
+
}) {
|
|
49
|
+
const Comp = asChild ? Slot : 'button'
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<Comp
|
|
53
|
+
data-slot="button"
|
|
54
|
+
data-variant={variant}
|
|
55
|
+
data-size={size}
|
|
56
|
+
className={cn(buttonVariants({ variant, size, className }))}
|
|
57
|
+
{...props}
|
|
58
|
+
/>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export { Button, buttonVariants }
|