@ditojs/admin 2.99.1 → 3.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 (84) hide show
  1. package/dist/dito-admin.css +1 -1
  2. package/dist/dito-admin.es.js +3532 -2763
  3. package/dist/dito-admin.umd.js +6 -6
  4. package/package.json +82 -46
  5. package/src/DitoComponent.js +1 -1
  6. package/src/DitoContext.js +51 -0
  7. package/src/DitoTypeComponent.js +6 -1
  8. package/src/components/DitoAffixes.vue +6 -3
  9. package/src/components/DitoContainer.vue +19 -10
  10. package/src/components/DitoCreateButton.vue +11 -2
  11. package/src/components/DitoDialog.vue +21 -2
  12. package/src/components/DitoForm.vue +156 -83
  13. package/src/components/DitoLabel.vue +3 -2
  14. package/src/components/DitoPane.vue +55 -11
  15. package/src/components/DitoPanel.vue +33 -5
  16. package/src/components/DitoPanels.vue +2 -1
  17. package/src/components/DitoRoot.vue +13 -6
  18. package/src/components/DitoSchema.vue +278 -145
  19. package/src/components/DitoSchemaInlined.vue +1 -1
  20. package/src/components/DitoTabs.vue +16 -18
  21. package/src/components/DitoTrail.vue +5 -4
  22. package/src/components/DitoTreeItem.vue +17 -6
  23. package/src/components/DitoView.vue +25 -2
  24. package/src/mixins/DitoMixin.js +42 -7
  25. package/src/mixins/ItemMixin.js +3 -6
  26. package/src/mixins/NumberMixin.js +47 -31
  27. package/src/mixins/OptionsMixin.js +122 -71
  28. package/src/mixins/ResourceMixin.js +5 -2
  29. package/src/mixins/RouteMixin.js +39 -1
  30. package/src/mixins/SortableMixin.js +0 -13
  31. package/src/mixins/SourceMixin.js +142 -95
  32. package/src/mixins/TextMixin.js +1 -1
  33. package/src/mixins/TypeMixin.js +37 -22
  34. package/src/mixins/ValidationMixin.js +9 -27
  35. package/src/mixins/ValidatorMixin.js +5 -7
  36. package/src/mixins/ValueMixin.js +5 -2
  37. package/src/types/DitoTypeButton.vue +1 -1
  38. package/src/types/DitoTypeCheckbox.vue +1 -1
  39. package/src/types/DitoTypeCheckboxes.vue +1 -1
  40. package/src/types/DitoTypeCode.vue +8 -1
  41. package/src/types/DitoTypeColor.vue +20 -34
  42. package/src/types/DitoTypeComponent.vue +1 -1
  43. package/src/types/DitoTypeComputed.vue +4 -27
  44. package/src/types/DitoTypeDate.vue +16 -5
  45. package/src/types/DitoTypeLabel.vue +1 -4
  46. package/src/types/DitoTypeList.vue +9 -5
  47. package/src/types/DitoTypeMarkup.vue +25 -13
  48. package/src/types/DitoTypeMultiselect.vue +26 -6
  49. package/src/types/DitoTypeNumber.vue +1 -1
  50. package/src/types/DitoTypeObject.vue +10 -7
  51. package/src/types/DitoTypePanel.vue +19 -6
  52. package/src/types/DitoTypeProgress.vue +1 -1
  53. package/src/types/DitoTypeRadio.vue +1 -1
  54. package/src/types/DitoTypeSection.vue +32 -4
  55. package/src/types/DitoTypeSelect.vue +1 -1
  56. package/src/types/DitoTypeSlider.vue +1 -1
  57. package/src/types/DitoTypeSwitch.vue +1 -1
  58. package/src/types/DitoTypeText.vue +13 -15
  59. package/src/types/DitoTypeTextarea.vue +1 -1
  60. package/src/types/DitoTypeTreeList.vue +12 -7
  61. package/src/types/DitoTypeUpload.vue +1 -2
  62. package/src/utils/DataSchemaResolver.js +141 -0
  63. package/src/utils/DataSchemaResolver.test.js +132 -0
  64. package/src/utils/FormModel.js +556 -0
  65. package/src/utils/FormModel.test.js +808 -0
  66. package/src/utils/data.js +20 -0
  67. package/src/utils/data.test.js +28 -0
  68. package/src/utils/filter.js +3 -2
  69. package/src/utils/options.js +3 -0
  70. package/src/utils/schema/data.js +617 -0
  71. package/src/utils/schema/data.test.js +471 -0
  72. package/src/utils/schema/lookup.js +266 -0
  73. package/src/utils/schema/lookup.test.js +61 -0
  74. package/src/utils/schema/setup.js +354 -0
  75. package/src/utils/schema/structure.js +172 -0
  76. package/src/utils/schema/structure.test.js +57 -0
  77. package/src/utils/schema/types.js +34 -0
  78. package/src/utils/schema/validation.js +142 -0
  79. package/src/utils/schema/validation.test.js +395 -0
  80. package/src/utils/units.js +70 -3
  81. package/src/utils/units.test.js +71 -0
  82. package/types/index.d.ts +149 -59
  83. package/src/mixins/DataMixin.js +0 -131
  84. package/src/utils/schema.js +0 -1121
@@ -0,0 +1,471 @@
1
+ import { vi } from 'vitest'
2
+ import DitoContext from '../../DitoContext.js'
3
+ import { registerTypeComponent } from './types.js'
4
+ import {
5
+ processSchemaData,
6
+ getComponentPathByDataPath,
7
+ computeValue,
8
+ processData,
9
+ initializeData,
10
+ shouldRenderSchema
11
+ } from './data.js'
12
+
13
+ // Register minimal type options, as the actual type components can't be
14
+ // imported without a Vue SFC compiler:
15
+ registerTypeComponent('text', { defaultNested: true })
16
+ registerTypeComponent('section', {
17
+ defaultNested: false,
18
+ defaultValue: ({ schema }) => (schema.nested ? {} : undefined),
19
+ treatNullAsMissing: ({ schema }) => !!schema.nested
20
+ })
21
+ registerTypeComponent('panel', { defaultNested: false })
22
+ registerTypeComponent('reference', {
23
+ defaultNested: true,
24
+ processValue: ({ value }) => value && { id: value.id }
25
+ })
26
+ registerTypeComponent('list', {
27
+ defaultNested: true,
28
+ getSourceType: () => 'list'
29
+ })
30
+
31
+ const chapters = {
32
+ type: 'list',
33
+ form: {
34
+ type: 'form',
35
+ components: { title: { type: 'text' } }
36
+ }
37
+ }
38
+
39
+ const schema = {
40
+ type: 'form',
41
+ components: {
42
+ title: { type: 'text' },
43
+ publishing: {
44
+ type: 'section',
45
+ components: { publisher: { type: 'text' } }
46
+ }
47
+ },
48
+ tabs: {
49
+ structure: { type: 'tab', components: { chapters } },
50
+ layout: { type: 'tab', components: { chapters } }
51
+ },
52
+ panels: {
53
+ info: { type: 'panel', components: { note: { type: 'text' } } }
54
+ }
55
+ }
56
+
57
+ const data = {
58
+ title: 'Book',
59
+ publisher: 'Publisher',
60
+ note: 'Note',
61
+ chapters: [{ title: 'One' }, { title: 'Two' }]
62
+ }
63
+
64
+ describe('processSchemaData()', () => {
65
+ it('passes the component paths of components, tabs and panels', () => {
66
+ const componentPaths = {}
67
+ processSchemaData(schema, data, {
68
+ dataPath: '',
69
+ componentPath: 'book',
70
+ shouldProcess: ({ componentPath, dataPath }) => {
71
+ componentPaths[componentPath] = dataPath
72
+ return true
73
+ },
74
+ options: { component: null }
75
+ })
76
+ expect(componentPaths).toEqual({
77
+ 'book/title': 'title',
78
+ 'book/publishing': '',
79
+ 'book/publishing/publisher': 'publisher',
80
+ 'book/structure': '',
81
+ 'book/structure/chapters': 'chapters',
82
+ 'book/structure/chapters/0/title': 'chapters/0/title',
83
+ 'book/structure/chapters/1/title': 'chapters/1/title',
84
+ 'book/layout': '',
85
+ 'book/layout/chapters': 'chapters',
86
+ 'book/layout/chapters/0/title': 'chapters/0/title',
87
+ 'book/layout/chapters/1/title': 'chapters/1/title',
88
+ 'book/info': '',
89
+ 'book/info/note': 'note'
90
+ })
91
+ })
92
+ })
93
+
94
+ describe('processSchemaData() with panel components', () => {
95
+ it('walks the components of panel components with their data', () => {
96
+ const componentPaths = {}
97
+ processSchemaData(
98
+ {
99
+ type: 'form',
100
+ components: {
101
+ links: {
102
+ type: 'panel',
103
+ components: { website: { type: 'text' } }
104
+ }
105
+ }
106
+ },
107
+ { website: 'https://example.com' },
108
+ {
109
+ dataPath: '',
110
+ componentPath: 'book',
111
+ shouldProcess: ({ componentPath, dataPath }) => {
112
+ componentPaths[componentPath] = dataPath
113
+ return true
114
+ },
115
+ options: { component: null }
116
+ }
117
+ )
118
+ expect(componentPaths).toEqual({
119
+ 'book/links': '',
120
+ 'book/links/website': 'website'
121
+ })
122
+ })
123
+ })
124
+
125
+ describe('processSchemaData() with panels that have their own data', () => {
126
+ it("doesn't walk their components with the data of the schema", () => {
127
+ const componentPaths = []
128
+ processSchemaData(
129
+ {
130
+ type: 'form',
131
+ components: {
132
+ search: {
133
+ type: 'panel',
134
+ data: () => ({ term: null }),
135
+ components: { term: { type: 'text' } }
136
+ }
137
+ },
138
+ panels: {
139
+ filters: {
140
+ type: 'panel',
141
+ data: () => ({ query: null }),
142
+ components: { query: { type: 'text' } }
143
+ }
144
+ }
145
+ },
146
+ {},
147
+ {
148
+ dataPath: '',
149
+ componentPath: '',
150
+ shouldProcess: ({ componentPath }) => {
151
+ componentPaths.push(componentPath)
152
+ return true
153
+ },
154
+ options: { component: null }
155
+ }
156
+ )
157
+ expect(componentPaths).toEqual(['search', 'filters'])
158
+ })
159
+ })
160
+
161
+ describe('getComponentPathByDataPath()', () => {
162
+ const getComponentPath = (dataPath, options) =>
163
+ getComponentPathByDataPath(schema, data, dataPath, options)
164
+
165
+ it('adds the names of tabs and unnested components', () => {
166
+ expect(getComponentPath('title')).toBe('title')
167
+ expect(getComponentPath('publisher')).toBe('publishing/publisher')
168
+ expect(getComponentPath('note')).toBe('info/note')
169
+ })
170
+
171
+ it('appends the item indices of list items', () => {
172
+ expect(getComponentPath('chapters/1')).toBe('structure/chapters/1')
173
+ expect(getComponentPath('chapters/1/title')).toBe(
174
+ 'structure/chapters/1/title'
175
+ )
176
+ })
177
+
178
+ it('uses the first component in schema order', () => {
179
+ expect(getComponentPath('chapters')).toBe('structure/chapters')
180
+ })
181
+
182
+ it('appends the data path of values without component', () => {
183
+ expect(getComponentPath('unknown/0')).toBe('unknown/0')
184
+ expect(getComponentPath('chapters/0/unknown')).toBe(
185
+ 'structure/chapters/0/unknown'
186
+ )
187
+ })
188
+
189
+ it('continues `componentPath`', () => {
190
+ expect(getComponentPath('chapters/0', { componentPath: 'main' })).toBe(
191
+ 'main/structure/chapters/0'
192
+ )
193
+ expect(getComponentPathByDataPath(schema, null, 'chapters/0')).toBe(
194
+ 'chapters/0'
195
+ )
196
+ })
197
+ })
198
+
199
+ describe('computeValue()', () => {
200
+ const schema = {
201
+ type: 'text',
202
+ compute: ({ item }) => item.title?.toUpperCase()
203
+ }
204
+
205
+ it('returns the computed value without writing it into the data', () => {
206
+ const data = { title: 'Book' }
207
+ expect(computeValue(schema, data, 'upper', 'upper')).toBe('BOOK')
208
+ expect(data).toEqual({ title: 'Book' })
209
+ })
210
+
211
+ it('returns the value if `compute()` returns `undefined`', () => {
212
+ const data = { upper: 'Kept' }
213
+ expect(computeValue(schema, data, 'upper', 'upper')).toBe('Kept')
214
+ })
215
+
216
+ it('returns the default of missing values without writing it', () => {
217
+ const data = {}
218
+ const schema = { type: 'text', default: 'Untitled' }
219
+ expect(computeValue(schema, data, 'title', 'title')).toBe('Untitled')
220
+ expect(data).toEqual({})
221
+ })
222
+
223
+ it('returns the defaults of nested sections that are `null`', () => {
224
+ const chooser = { type: 'section', nested: true, components: {} }
225
+ expect(
226
+ computeValue(chooser, { chooser: null }, 'chooser', 'chooser')
227
+ ).toEqual(
228
+ {}
229
+ )
230
+ })
231
+
232
+ it('provides `context.options` through `getOptions()` when read', () => {
233
+ const options = ['Small', 'Large']
234
+ const getOptions = vi.fn(() => options)
235
+ const schema = {
236
+ type: 'text',
237
+ compute: context => context.value ?? context.options[0]
238
+ }
239
+ expect(
240
+ computeValue(schema, { size: 'Large' }, 'size', 'size', { getOptions })
241
+ ).toBe('Large')
242
+ expect(getOptions).not.toHaveBeenCalled()
243
+ expect(computeValue(schema, {}, 'size', 'size', { getOptions })).toBe(
244
+ 'Small'
245
+ )
246
+ expect(getOptions).toHaveBeenCalledTimes(1)
247
+ })
248
+ })
249
+
250
+ describe('processData()', () => {
251
+ it('processes computed values without writing into the data', () => {
252
+ const schema = {
253
+ type: 'form',
254
+ components: {
255
+ title: { type: 'text' },
256
+ upper: {
257
+ type: 'text',
258
+ compute: ({ item }) => item.title.toUpperCase()
259
+ },
260
+ subtitle: { type: 'text', default: 'None' }
261
+ }
262
+ }
263
+ const data = { title: 'Book' }
264
+ expect(
265
+ processData(schema, { type: 'list' }, data, '', { target: 'server' })
266
+ ).toEqual({ title: 'Book', upper: 'BOOK', subtitle: 'None' })
267
+ expect(data).toEqual({ title: 'Book' })
268
+ })
269
+
270
+ describe('with `shouldCallComputeAndProcess: false`', () => {
271
+ // `DitoContext` needs a component to inherit from:
272
+ const component = {}
273
+ const options = {
274
+ component,
275
+ target: 'clipboard',
276
+ shouldCallComputeAndProcess: false
277
+ }
278
+
279
+ it('falls back to defaults without calling `compute()`', () => {
280
+ const compute = vi.fn(() => 'Computed')
281
+ const process = vi.fn(({ value }) => value)
282
+ const schema = {
283
+ type: 'form',
284
+ components: {
285
+ title: { type: 'text', process },
286
+ upper: { type: 'text', compute, default: 'None' }
287
+ }
288
+ }
289
+ expect(
290
+ processData(schema, { type: 'list' }, { title: 'Book' }, '', options)
291
+ ).toEqual({ title: 'Book', upper: 'None' })
292
+ expect(compute).not.toHaveBeenCalled()
293
+ expect(process).not.toHaveBeenCalled()
294
+ })
295
+
296
+ it("processes values through their types' `processValue()`", () => {
297
+ const schema = {
298
+ type: 'form',
299
+ components: { author: { type: 'reference' } }
300
+ }
301
+ const data = { author: { id: 1, name: 'Author' } }
302
+ expect(processData(schema, { type: 'list' }, data, '', options)).toEqual({
303
+ author: { id: 1 }
304
+ })
305
+ })
306
+
307
+ it('keeps excluded values of components with `process()`', () => {
308
+ const schema = {
309
+ type: 'form',
310
+ components: {
311
+ tagsText: {
312
+ type: 'text',
313
+ exclude: true,
314
+ process: ({ value, processedItem }) => {
315
+ processedItem.tags = value.split(',')
316
+ }
317
+ },
318
+ search: { type: 'text', exclude: true }
319
+ }
320
+ }
321
+ const data = { tagsText: 'a,b', search: 'Search' }
322
+ expect(processData(schema, { type: 'list' }, data, '', options)).toEqual({
323
+ tagsText: 'a,b'
324
+ })
325
+ expect(
326
+ processData(schema, { type: 'list' }, data, '', {
327
+ component,
328
+ target: 'server'
329
+ })
330
+ ).toEqual({ tags: ['a', 'b'] })
331
+ })
332
+ })
333
+ })
334
+
335
+ describe('shouldRenderSchema()', () => {
336
+ it("doesn't evaluate the components of sources for their items", () => {
337
+ // `DitoContext` needs a component to inherit from:
338
+ const context = new DitoContext({}, { data: { links: null }, dataPath: '' })
339
+ const condition = vi.fn(({ item }) => !!item.url)
340
+ const links = {
341
+ type: 'list',
342
+ components: { url: { type: 'text', if: condition } }
343
+ }
344
+ expect(shouldRenderSchema(links, context)).toBe(true)
345
+ expect(condition).not.toHaveBeenCalled()
346
+ })
347
+
348
+ it('hides sections whose components are all hidden', () => {
349
+ const context = new DitoContext({}, { data: {}, dataPath: '' })
350
+ const section = {
351
+ type: 'section',
352
+ components: { url: { type: 'text', if: () => false } }
353
+ }
354
+ expect(shouldRenderSchema(section, context)).toBe(false)
355
+ })
356
+ })
357
+
358
+ describe('computeValue() with if', () => {
359
+ it("doesn't compute values of components whose if is false", () => {
360
+ const schema = {
361
+ type: 'text',
362
+ if: ({ item }) => !!item.preview,
363
+ compute: ({ item }) => item.preview.name
364
+ }
365
+ expect(computeValue(schema, { name: 'Stored' }, 'name', 'name')).toBe(
366
+ 'Stored'
367
+ )
368
+ expect(
369
+ computeValue(schema, { preview: { name: 'Preview' } }, 'name', 'name')
370
+ ).toBe('Preview')
371
+ })
372
+ })
373
+
374
+ describe('initializeData()', () => {
375
+ it('passes through primitive values of lists with wrapPrimitives', () => {
376
+ const prices = {
377
+ type: 'list',
378
+ wrapPrimitives: 'price',
379
+ form: {
380
+ type: 'form',
381
+ components: { price: { type: 'text', default: 0 } }
382
+ }
383
+ }
384
+ const data = { prices: [100, 200] }
385
+ initializeData({ type: 'form', components: { prices } }, data)
386
+ expect(data).toEqual({ prices: [100, 200] })
387
+ })
388
+
389
+ it('sets the objects of nested sections that are `null`', () => {
390
+ const chooser = {
391
+ type: 'section',
392
+ nested: true,
393
+ components: { topic: { type: 'text' } }
394
+ }
395
+ const data = { chooser: null }
396
+ initializeData({ type: 'form', components: { chooser } }, data, {})
397
+ // The section's components are initialized in its object too:
398
+ expect(data.chooser).toHaveProperty('topic')
399
+ })
400
+
401
+ it('can leave the defaults of computed values to `computeValue()`', () => {
402
+ const pricing = {
403
+ type: 'text',
404
+ default: 'auto',
405
+ compute: ({ value, item }) => (
406
+ value ?? (item.customFactor != null ? 'factor' : undefined)
407
+ )
408
+ }
409
+ const schema = { type: 'form', components: { pricing } }
410
+ const data = { customFactor: 10 }
411
+ initializeData(schema, data, null, {
412
+ shouldSetDefaultsOfComponentsWithCompute: false
413
+ })
414
+ expect(data).toEqual({ customFactor: 10 })
415
+ // New data starts with all defaults:
416
+ expect(initializeData(schema, {})).toEqual({ pricing: 'auto' })
417
+ expect(computeValue(pricing, data, 'pricing', 'pricing')).toBe('factor')
418
+ expect(computeValue(pricing, {}, 'pricing', 'pricing')).toBe('auto')
419
+ })
420
+
421
+ it('numbers the items of lists with `orderKey`, also nested ones', () => {
422
+ const parts = {
423
+ type: 'list',
424
+ orderKey: 'order',
425
+ form: { type: 'form', components: { name: { type: 'text' } } }
426
+ }
427
+ const items = {
428
+ type: 'list',
429
+ orderKey: 'order',
430
+ // Lists with `compute()` are numbered too.
431
+ compute: ({ value }) => value,
432
+ form: { type: 'form', components: { parts } }
433
+ }
434
+ const data = {
435
+ items: [
436
+ { order: 5, parts: [{ name: 'A', order: 2 }, { name: 'B', order: 7 }] },
437
+ { order: 9, parts: [] }
438
+ ]
439
+ }
440
+ initializeData(
441
+ { type: 'form', tabs: { main: { type: 'tab', components: { items } } } },
442
+ data,
443
+ // `DitoContext` needs a component to inherit from:
444
+ {}
445
+ )
446
+ expect(data.items.map(item => item.order)).toEqual([0, 1])
447
+ expect(data.items[0].parts.map(part => part.order)).toEqual([0, 1])
448
+ })
449
+
450
+ it('leaves lists without `orderKey` and of primitives untouched', () => {
451
+ const form = { type: 'form', components: { name: { type: 'text' } } }
452
+ const schema = {
453
+ type: 'form',
454
+ components: {
455
+ items: { type: 'list', form },
456
+ prices: {
457
+ type: 'list',
458
+ orderKey: 'order',
459
+ wrapPrimitives: 'price',
460
+ form: { type: 'form', components: { price: { type: 'text' } } }
461
+ }
462
+ }
463
+ }
464
+ const data = { items: [{ name: 'A', order: 5 }], prices: [100, 200] }
465
+ initializeData(schema, data, {})
466
+ expect(data).toEqual({
467
+ items: [{ name: 'A', order: 5 }],
468
+ prices: [100, 200]
469
+ })
470
+ })
471
+ })