@ditojs/admin 2.100.0 → 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 +3421 -2662
  3. package/dist/dito-admin.umd.js +6 -6
  4. package/package.json +81 -45
  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,808 @@
1
+ import { vi } from 'vitest'
2
+ import { reactive, ref, toRaw, nextTick } from 'vue'
3
+ import { registerTypeComponent } from './schema/types.js'
4
+ import { FormModel } from './FormModel.js'
5
+ import { updateOrder } from './schema/data.js'
6
+
7
+ // Register minimal type options, as the actual type components can't be
8
+ // imported without a Vue SFC compiler:
9
+ registerTypeComponent('text', { defaultNested: true })
10
+ registerTypeComponent('select', { defaultNested: true })
11
+ registerTypeComponent('computed', {
12
+ defaultNested: true,
13
+ defaultValue: () => undefined,
14
+ valueFromDataSchema: true
15
+ })
16
+ registerTypeComponent('section', { defaultNested: false })
17
+ registerTypeComponent('list', {
18
+ defaultNested: true,
19
+ defaultValue: () => [],
20
+ getSourceType: () => 'list'
21
+ })
22
+ registerTypeComponent('object', {
23
+ defaultNested: true,
24
+ getSourceType: () => 'object'
25
+ })
26
+ // Like `DitoTypeUpload`, which leaves out files that aren't uploaded yet:
27
+ registerTypeComponent('upload', {
28
+ defaultNested: true,
29
+ processValue: ({ value }) => (value?.isUploaded ? value.name : null)
30
+ })
31
+
32
+ function createFormModel(schema, data, { getSourceSchema } = {}) {
33
+ const dataRef = ref(reactive(data))
34
+ const formModel = new FormModel({
35
+ component: { dataPath: '', componentPath: '' },
36
+ getSchema: () => schema,
37
+ getData: () => dataRef.value,
38
+ getSourceSchema
39
+ })
40
+ return { formModel, dataRef, data: dataRef.value }
41
+ }
42
+
43
+ const slug = {
44
+ type: 'computed',
45
+ compute: ({ item }) => item.title?.toLowerCase().replaceAll(' ', '-')
46
+ }
47
+
48
+ describe('FormModel', () => {
49
+ it('sets the defaults of missing values when the data is set up', () => {
50
+ const schema = {
51
+ type: 'form',
52
+ components: {
53
+ title: { type: 'text', default: 'Untitled' },
54
+ tags: { type: 'list', form: { type: 'form', components: {} } }
55
+ }
56
+ }
57
+ const { formModel, dataRef, data } = createFormModel(schema, {
58
+ tags: null
59
+ })
60
+ expect(data).toEqual({ title: 'Untitled', tags: null })
61
+ formModel.stop()
62
+ dataRef.value = reactive({})
63
+ expect(dataRef.value).toEqual({})
64
+ })
65
+
66
+ it('sets the defaults of replaced data', async () => {
67
+ const schema = {
68
+ type: 'form',
69
+ components: { title: { type: 'text', default: 'Untitled' } }
70
+ }
71
+ const { formModel, dataRef } = createFormModel(schema, {})
72
+ dataRef.value = reactive({})
73
+ await nextTick()
74
+ expect(dataRef.value).toEqual({ title: 'Untitled' })
75
+ formModel.stop()
76
+ })
77
+
78
+ it('writes computed values and keeps them current', async () => {
79
+ const schema = {
80
+ type: 'form',
81
+ components: { title: { type: 'text' }, slug }
82
+ }
83
+ const { formModel, data } = createFormModel(schema, {
84
+ title: 'Hello World'
85
+ })
86
+ expect(data.slug).toBe('hello-world')
87
+ data.title = 'Good Bye'
88
+ await nextTick()
89
+ expect(data.slug).toBe('good-bye')
90
+ formModel.stop()
91
+ data.title = 'Stopped'
92
+ await nextTick()
93
+ expect(data.slug).toBe('good-bye')
94
+ })
95
+
96
+ it('writes computed values of fields in tabs and unnested sections', () => {
97
+ const schema = {
98
+ type: 'form',
99
+ components: { title: { type: 'text' } },
100
+ tabs: {
101
+ meta: {
102
+ type: 'tab',
103
+ components: {
104
+ seo: { type: 'section', components: { slug } }
105
+ }
106
+ }
107
+ }
108
+ }
109
+ const { formModel, data } = createFormModel(schema, { title: 'Hello' })
110
+ expect(data.slug).toBe('hello')
111
+ formModel.stop()
112
+ })
113
+
114
+ it('writes the computed value back when the value changes', async () => {
115
+ const schema = {
116
+ type: 'form',
117
+ components: {
118
+ tags: { type: 'text', compute: ({ value }) => value ?? [] }
119
+ }
120
+ }
121
+ const { formModel, data } = createFormModel(schema, { tags: null })
122
+ expect(data.tags).toEqual([])
123
+ data.tags = null
124
+ await nextTick()
125
+ expect(data.tags).toEqual([])
126
+ formModel.stop()
127
+ })
128
+
129
+ it('keeps values that `compute()` returns `undefined` for', async () => {
130
+ const schema = {
131
+ type: 'form',
132
+ components: { title: { type: 'text', compute: () => undefined } }
133
+ }
134
+ const { formModel, data } = createFormModel(schema, { title: 'Hello' })
135
+ data.title = 'Changed'
136
+ await nextTick()
137
+ expect(data.title).toBe('Changed')
138
+ formModel.stop()
139
+ })
140
+
141
+ it('writes computed values that are new objects only once', async () => {
142
+ const compute = vi.fn(({ value }) =>
143
+ value.map(entry => ({ ...entry, upper: entry.name.toUpperCase() }))
144
+ )
145
+ const schema = {
146
+ type: 'form',
147
+ components: { entries: { type: 'text', compute } }
148
+ }
149
+ const { formModel, data } = createFormModel(schema, {
150
+ entries: [{ name: 'One' }]
151
+ })
152
+ await nextTick()
153
+ // Once initially, and once more after writing the new array, which is
154
+ // equal and therefore not written again.
155
+ expect(compute).toHaveBeenCalledTimes(2)
156
+ expect(data.entries).toEqual([{ name: 'One', upper: 'ONE' }])
157
+ formModel.stop()
158
+ })
159
+
160
+ it("doesn't recompute values whose `compute()` doesn't read them", async () => {
161
+ // Like lineto's access tokens, which are random for each call.
162
+ let callCount = 0
163
+ const schema = {
164
+ type: 'form',
165
+ components: {
166
+ title: { type: 'text' },
167
+ token: {
168
+ type: 'text',
169
+ compute: ({ item }) => `${item.title}-${++callCount}`
170
+ }
171
+ }
172
+ }
173
+ const { formModel, data } = createFormModel(schema, { title: 'Hello' })
174
+ expect(data.token).toBe('Hello-1')
175
+ data.title = 'Changed'
176
+ await nextTick()
177
+ expect(data.token).toBe('Changed-2')
178
+ await nextTick()
179
+ expect(callCount).toBe(2)
180
+ formModel.stop()
181
+ })
182
+
183
+ it('skips components whose `if` evaluates to `false`', async () => {
184
+ const schema = {
185
+ type: 'form',
186
+ components: {
187
+ title: { type: 'text' },
188
+ hasSlug: { type: 'text' },
189
+ slug: { ...slug, if: ({ item }) => item.hasSlug }
190
+ }
191
+ }
192
+ const { formModel, data } = createFormModel(schema, {
193
+ title: 'Hello',
194
+ hasSlug: false
195
+ })
196
+ expect(data.slug).toBe(undefined)
197
+ data.hasSlug = true
198
+ await nextTick()
199
+ expect(data.slug).toBe('hello')
200
+ data.hasSlug = false
201
+ data.title = 'Changed'
202
+ await nextTick()
203
+ expect(data.slug).toBe('hello')
204
+ formModel.stop()
205
+ })
206
+
207
+ it('writes computed values of list items, including added ones', async () => {
208
+ const schema = {
209
+ type: 'form',
210
+ components: {
211
+ chapters: {
212
+ type: 'list',
213
+ form: {
214
+ type: 'form',
215
+ components: { title: { type: 'text' }, slug }
216
+ }
217
+ }
218
+ }
219
+ }
220
+ const { formModel, data } = createFormModel(schema, {
221
+ chapters: [{ title: 'One' }]
222
+ })
223
+ expect(data.chapters[0].slug).toBe('one')
224
+ data.chapters.push({ title: 'Two' })
225
+ await nextTick()
226
+ expect(data.chapters[1].slug).toBe('two')
227
+ // Items that moved are computed with their new data path:
228
+ data.chapters.reverse()
229
+ data.chapters[0].title = 'Two Again'
230
+ await nextTick()
231
+ expect(data.chapters.map(chapter => chapter.slug)).toEqual([
232
+ 'two-again',
233
+ 'one'
234
+ ])
235
+ formModel.stop()
236
+ })
237
+
238
+ it('skips sources with their own resource', () => {
239
+ const schema = {
240
+ type: 'form',
241
+ components: {
242
+ chapters: {
243
+ type: 'list',
244
+ resource: 'chapters',
245
+ form: { type: 'form', components: { title: { type: 'text' }, slug } }
246
+ }
247
+ }
248
+ }
249
+ const { formModel, data } = createFormModel(schema, {
250
+ chapters: [{ title: 'One' }]
251
+ })
252
+ expect(data.chapters[0].slug).toBe(undefined)
253
+ formModel.stop()
254
+ })
255
+
256
+ it('resolves values from data schemas', async () => {
257
+ const schema = {
258
+ type: 'form',
259
+ components: {
260
+ title: { type: 'text' },
261
+ upper: {
262
+ type: 'computed',
263
+ data: ({ item }) => item.title.toUpperCase()
264
+ },
265
+ copy: { type: 'computed', dataPath: '../title' },
266
+ length: {
267
+ type: 'computed',
268
+ data: ({ item }) => {
269
+ const { title } = item
270
+ return async () => title.length
271
+ }
272
+ }
273
+ }
274
+ }
275
+ const { formModel, data } = createFormModel(schema, { title: 'Hello' })
276
+ expect(data.upper).toBe('HELLO')
277
+ expect(data.copy).toBe('Hello')
278
+ await formModel.waitForPendingLoads()
279
+ expect(data.length).toBe(5)
280
+ data.title = 'Bye'
281
+ await nextTick()
282
+ expect(data.upper).toBe('BYE')
283
+ expect(data.copy).toBe('Bye')
284
+ await formModel.waitForPendingLoads()
285
+ expect(data.length).toBe(3)
286
+ formModel.stop()
287
+ })
288
+
289
+ it('ignores outdated values loaded by data schemas', async () => {
290
+ const resolvers = []
291
+ const schema = {
292
+ type: 'form',
293
+ components: {
294
+ title: { type: 'text' },
295
+ loaded: {
296
+ type: 'computed',
297
+ data: ({ item }) => {
298
+ const { title } = item
299
+ return new Promise(resolve => resolvers.push(() => resolve(title)))
300
+ }
301
+ }
302
+ }
303
+ }
304
+ const { formModel, data } = createFormModel(schema, { title: 'First' })
305
+ data.title = 'Second'
306
+ await nextTick()
307
+ expect(resolvers).toHaveLength(2)
308
+ resolvers[1]()
309
+ resolvers[0]()
310
+ await formModel.waitForPendingLoads()
311
+ expect(data.loaded).toBe('Second')
312
+ formModel.stop()
313
+ })
314
+
315
+ describe('options', () => {
316
+ // Like lineto's `getValidOrDefaultOption()`: Keeps valid values, and
317
+ // selects the first option otherwise, once the options are loaded.
318
+ const getValidOrFirstOption = ({ value, options }) =>
319
+ options
320
+ ? options.some(option => option.value === value)
321
+ ? value
322
+ : options[0]?.value ?? null
323
+ : value
324
+
325
+ const topicsByCategory = {
326
+ news: [{ value: 'politics' }, { value: 'economy' }],
327
+ sports: [{ value: 'football' }]
328
+ }
329
+
330
+ const createTopicSchema = loadTopics => ({
331
+ type: 'form',
332
+ components: {
333
+ category: {
334
+ type: 'select',
335
+ options: {
336
+ data: async () => [{ value: 'news' }, { value: 'sports' }]
337
+ },
338
+ compute: getValidOrFirstOption
339
+ },
340
+ topic: {
341
+ type: 'select',
342
+ options: {
343
+ // The curried pattern: The outer function tracks the
344
+ // dependencies, the returned one loads.
345
+ data: ({ item }) => {
346
+ const { category } = item
347
+ return async () => loadTopics(category)
348
+ }
349
+ },
350
+ compute: getValidOrFirstOption
351
+ }
352
+ }
353
+ })
354
+
355
+ it('reselects dependent options when options of other fields load', async () => {
356
+ const loadTopics = vi.fn(category => topicsByCategory[category] ?? [])
357
+ const schema = createTopicSchema(loadTopics)
358
+ const { formModel, data } = createFormModel(schema, {})
359
+ await formModel.waitForPendingLoads()
360
+ expect(data).toEqual({ category: 'news', topic: 'politics' })
361
+ data.category = 'sports'
362
+ await nextTick()
363
+ await formModel.waitForPendingLoads()
364
+ expect(data.topic).toBe('football')
365
+ expect(loadTopics.mock.calls).toEqual([[undefined], ['news'], ['sports']])
366
+ formModel.stop()
367
+ })
368
+
369
+ it('shares the option objects with the components', async () => {
370
+ const schema = {
371
+ type: 'form',
372
+ components: {
373
+ size: {
374
+ type: 'select',
375
+ options: { data: async () => [{ id: 1 }, { id: 2 }] },
376
+ compute: ({ value, options }) => value ?? options?.[0]
377
+ }
378
+ }
379
+ }
380
+ const { formModel, data } = createFormModel(schema, {})
381
+ await formModel.waitForPendingLoads()
382
+ const options = formModel.getOptions({
383
+ schema: schema.components.size,
384
+ data,
385
+ name: 'size',
386
+ dataPath: 'size',
387
+ componentPath: 'size'
388
+ })
389
+ expect(options).toEqual([{ id: 1 }, { id: 2 }])
390
+ expect(toRaw(data.size)).toBe(options[0])
391
+ formModel.stop()
392
+ })
393
+
394
+ it("doesn't load options that aren't read", async () => {
395
+ const data = vi.fn(async () => [{ value: 'news' }])
396
+ const schema = {
397
+ type: 'form',
398
+ components: {
399
+ category: { type: 'select', options: { data } }
400
+ }
401
+ }
402
+ const { formModel } = createFormModel(schema, {})
403
+ await formModel.waitForPendingLoads()
404
+ expect(data).not.toHaveBeenCalled()
405
+ formModel.stop()
406
+ })
407
+ })
408
+
409
+ describe('dirty state', () => {
410
+ const getSourceSchema = () => ({ type: 'list' })
411
+
412
+ // Resolves once the model settled after the snapshot was taken, see
413
+ // `FormModel.takeProcessedDataSnapshot()`:
414
+ async function waitForProcessedDataSnapshot(formModel) {
415
+ await nextTick()
416
+ await formModel.waitForPendingLoads()
417
+ await nextTick()
418
+ }
419
+
420
+ it('compares the processed data with its snapshot', async () => {
421
+ const schema = {
422
+ type: 'form',
423
+ components: { title: { type: 'text' }, slug }
424
+ }
425
+ const { formModel, data } = createFormModel(
426
+ schema,
427
+ { title: 'Hello' },
428
+ { getSourceSchema }
429
+ )
430
+ await waitForProcessedDataSnapshot(formModel)
431
+ expect(formModel.isDirty).toBe(false)
432
+ data.title = 'Changed'
433
+ expect(formModel.isDirty).toBe(true)
434
+ data.title = 'Hello'
435
+ expect(formModel.isDirty).toBe(false)
436
+ formModel.stop()
437
+ })
438
+
439
+ it('counts changes made while the model settles', async () => {
440
+ // `size` selects its first option once the options are loaded, which is
441
+ // derived and doesn't count, while the edit of `title` does.
442
+ let resolveOptions
443
+ const schema = {
444
+ type: 'form',
445
+ components: {
446
+ title: { type: 'text' },
447
+ size: {
448
+ type: 'select',
449
+ options: {
450
+ data: () =>
451
+ new Promise(resolve => {
452
+ resolveOptions = resolve
453
+ })
454
+ },
455
+ compute: ({ value, options }) => value ?? options?.[0].id
456
+ }
457
+ }
458
+ }
459
+ const { formModel, data } = createFormModel(
460
+ schema,
461
+ { title: 'Hello' },
462
+ { getSourceSchema }
463
+ )
464
+ await nextTick()
465
+ data.title = 'Changed'
466
+ expect(formModel.isDirty).toBe(true)
467
+ resolveOptions([{ id: 1 }, { id: 2 }])
468
+ await waitForProcessedDataSnapshot(formModel)
469
+ expect(data.size).toBe(1)
470
+ expect(formModel.isDirty).toBe(true)
471
+ data.title = 'Hello'
472
+ expect(formModel.isDirty).toBe(false)
473
+ formModel.stop()
474
+ })
475
+
476
+ it("doesn't count values derived after loads as changes", async () => {
477
+ const schema = {
478
+ type: 'form',
479
+ components: {
480
+ title: { type: 'text' },
481
+ length: {
482
+ type: 'computed',
483
+ data: ({ item }) => {
484
+ const { title } = item
485
+ return async () => title.length
486
+ }
487
+ },
488
+ size: {
489
+ type: 'select',
490
+ options: { data: async () => [{ id: 1 }, { id: 2 }] },
491
+ compute: ({ value, options }) => value ?? options?.[0].id
492
+ }
493
+ }
494
+ }
495
+ const { formModel, data } = createFormModel(
496
+ schema,
497
+ { title: 'Hello' },
498
+ { getSourceSchema }
499
+ )
500
+ await waitForProcessedDataSnapshot(formModel)
501
+ expect(data).toEqual({ title: 'Hello', length: 5, size: 1 })
502
+ expect(formModel.isDirty).toBe(false)
503
+ data.size = 2
504
+ expect(formModel.isDirty).toBe(true)
505
+ formModel.stop()
506
+ })
507
+
508
+ it('takes a new snapshot when the data is replaced', async () => {
509
+ const schema = {
510
+ type: 'form',
511
+ components: { title: { type: 'text' } }
512
+ }
513
+ const { formModel, dataRef, data } = createFormModel(
514
+ schema,
515
+ { title: 'Hello' },
516
+ { getSourceSchema }
517
+ )
518
+ await waitForProcessedDataSnapshot(formModel)
519
+ data.title = 'Changed'
520
+ expect(formModel.isDirty).toBe(true)
521
+ dataRef.value = reactive({ title: 'Saved' })
522
+ await waitForProcessedDataSnapshot(formModel)
523
+ expect(formModel.isDirty).toBe(false)
524
+ formModel.stop()
525
+ })
526
+
527
+ it("doesn't compare replaced data with the snapshot of its predecessor", async () => {
528
+ const schema = {
529
+ type: 'form',
530
+ components: { title: { type: 'text' } }
531
+ }
532
+ const { formModel, dataRef } = createFormModel(
533
+ schema,
534
+ { title: 'Hello' },
535
+ { getSourceSchema }
536
+ )
537
+ await waitForProcessedDataSnapshot(formModel)
538
+ // Until its own snapshot is taken, the replaced data isn't dirty:
539
+ dataRef.value = reactive({ title: 'Saved' })
540
+ expect(formModel.isDirty).toBe(false)
541
+ formModel.stop()
542
+ })
543
+
544
+ it('detects in-place changes of nested values against the snapshot', async () => {
545
+ const schema = {
546
+ type: 'form',
547
+ components: {
548
+ chapters: {
549
+ type: 'list',
550
+ form: { type: 'form', components: { title: { type: 'text' } } }
551
+ },
552
+ settings: {
553
+ type: 'object',
554
+ form: { type: 'form', components: { color: { type: 'text' } } }
555
+ }
556
+ }
557
+ }
558
+ const { formModel, data } = createFormModel(
559
+ schema,
560
+ { chapters: [{ title: 'One' }], settings: { color: 'red' } },
561
+ { getSourceSchema }
562
+ )
563
+ await waitForProcessedDataSnapshot(formModel)
564
+ data.chapters[0].title = 'Changed'
565
+ expect(formModel.isDirty).toBe(true)
566
+ data.chapters[0].title = 'One'
567
+ expect(formModel.isDirty).toBe(false)
568
+ data.settings.color = 'blue'
569
+ expect(formModel.isDirty).toBe(true)
570
+ data.settings.color = 'red'
571
+ expect(formModel.isDirty).toBe(false)
572
+ formModel.stop()
573
+ })
574
+
575
+ it("doesn't count changes of excluded values", async () => {
576
+ const schema = {
577
+ type: 'form',
578
+ components: { search: { type: 'text', exclude: true } }
579
+ }
580
+ const { formModel, data } = createFormModel(
581
+ schema,
582
+ { search: '' },
583
+ { getSourceSchema }
584
+ )
585
+ await waitForProcessedDataSnapshot(formModel)
586
+ data.search = 'Changed'
587
+ expect(formModel.isDirty).toBe(false)
588
+ formModel.stop()
589
+ })
590
+
591
+ it("doesn't call `compute()` and `process()`", async () => {
592
+ const compute = vi.fn(() => 'Computed')
593
+ const process = vi.fn(({ value }) => value)
594
+ const schema = {
595
+ type: 'form',
596
+ components: {
597
+ title: { type: 'text', process },
598
+ subtitle: { type: 'text', compute }
599
+ }
600
+ }
601
+ const { formModel, data } = createFormModel(
602
+ schema,
603
+ { title: 'Hello' },
604
+ { getSourceSchema }
605
+ )
606
+ await waitForProcessedDataSnapshot(formModel)
607
+ const computeCallCount = compute.mock.calls.length
608
+ data.title = 'Changed'
609
+ expect(formModel.isDirty).toBe(true)
610
+ expect(compute).toHaveBeenCalledTimes(computeCallCount)
611
+ expect(process).not.toHaveBeenCalled()
612
+ formModel.stop()
613
+ })
614
+
615
+ it('reads the dirty state of data that `process()` rejects', async () => {
616
+ // Data that is edited isn't validated yet, see `processData()`.
617
+ const schema = {
618
+ type: 'form',
619
+ components: {
620
+ version: {
621
+ type: 'text',
622
+ process: ({ value }) => value.split('.').map(Number)
623
+ }
624
+ }
625
+ }
626
+ const { formModel, data } = createFormModel(
627
+ schema,
628
+ { version: '1.0' },
629
+ { getSourceSchema }
630
+ )
631
+ await waitForProcessedDataSnapshot(formModel)
632
+ data.version = null
633
+ expect(formModel.isDirty).toBe(true)
634
+ data.version = '1.0'
635
+ expect(formModel.isDirty).toBe(false)
636
+ formModel.stop()
637
+ })
638
+
639
+ it('counts changes of excluded values that `process()` stores', async () => {
640
+ const schema = {
641
+ type: 'form',
642
+ components: {
643
+ tagsText: {
644
+ type: 'text',
645
+ exclude: true,
646
+ process: ({ value, processedItem }) => {
647
+ processedItem.tags = value.split(',')
648
+ }
649
+ }
650
+ }
651
+ }
652
+ const { formModel, data } = createFormModel(
653
+ schema,
654
+ { tagsText: 'a' },
655
+ { getSourceSchema }
656
+ )
657
+ await waitForProcessedDataSnapshot(formModel)
658
+ data.tagsText = 'a,b'
659
+ expect(formModel.isDirty).toBe(true)
660
+ data.tagsText = 'a'
661
+ expect(formModel.isDirty).toBe(false)
662
+ formModel.stop()
663
+ })
664
+
665
+ it('compares values as processed by their types', async () => {
666
+ const schema = {
667
+ type: 'form',
668
+ components: { file: { type: 'upload' } }
669
+ }
670
+ const { formModel, data } = createFormModel(
671
+ schema,
672
+ { file: null },
673
+ { getSourceSchema }
674
+ )
675
+ await waitForProcessedDataSnapshot(formModel)
676
+ data.file = { name: 'font.zip', isUploaded: false }
677
+ expect(formModel.isDirty).toBe(false)
678
+ data.file.isUploaded = true
679
+ expect(formModel.isDirty).toBe(true)
680
+ formModel.stop()
681
+ })
682
+
683
+ it("doesn't count order keys numbered on setup as changes", async () => {
684
+ const schema = {
685
+ type: 'form',
686
+ components: {
687
+ items: {
688
+ type: 'list',
689
+ orderKey: 'order',
690
+ form: { type: 'form', components: { name: { type: 'text' } } }
691
+ }
692
+ }
693
+ }
694
+ const { formModel, dataRef, data } = createFormModel(
695
+ schema,
696
+ {
697
+ items: [
698
+ { id: 1, name: 'A', order: 4 },
699
+ { id: 2, name: 'B', order: 7 }
700
+ ]
701
+ },
702
+ { getSourceSchema }
703
+ )
704
+ await waitForProcessedDataSnapshot(formModel)
705
+ expect(data.items.map(item => item.order)).toEqual([0, 1])
706
+ expect(formModel.isDirty).toBe(false)
707
+ data.items.reverse()
708
+ updateOrder(schema.components.items, data.items)
709
+ expect(formModel.isDirty).toBe(true)
710
+ // Replaced data is numbered too.
711
+ dataRef.value = reactive({ items: [{ id: 3, name: 'C', order: 8 }] })
712
+ await waitForProcessedDataSnapshot(formModel)
713
+ expect(dataRef.value.items[0].order).toBe(0)
714
+ expect(formModel.isDirty).toBe(false)
715
+ formModel.stop()
716
+ })
717
+
718
+ describe('applyCleanChanges()', () => {
719
+ const schema = {
720
+ type: 'form',
721
+ components: {
722
+ title: { type: 'text' },
723
+ chapters: {
724
+ type: 'list',
725
+ form: { type: 'form', components: { title: { type: 'text' } } }
726
+ }
727
+ }
728
+ }
729
+
730
+ it("doesn't count clean changes, but other changes", async () => {
731
+ const { formModel, data } = createFormModel(
732
+ schema,
733
+ { title: 'Hello', chapters: [{ title: 'One' }] },
734
+ { getSourceSchema }
735
+ )
736
+ await waitForProcessedDataSnapshot(formModel)
737
+ data.title = 'Changed'
738
+ await formModel.applyCleanChanges(() => {
739
+ data.chapters[0].title = 'Saved'
740
+ data.chapters.push({ title: 'Two' })
741
+ })
742
+ expect(formModel.isDirty).toBe(true)
743
+ data.title = 'Hello'
744
+ expect(formModel.isDirty).toBe(false)
745
+ data.chapters[1].title = 'Changed'
746
+ expect(formModel.isDirty).toBe(true)
747
+ formModel.stop()
748
+ })
749
+
750
+ it('keeps values derived from clean changes clean', async () => {
751
+ const { formModel, data } = createFormModel(
752
+ { type: 'form', components: { title: { type: 'text' }, slug } },
753
+ { title: 'Hello' },
754
+ { getSourceSchema }
755
+ )
756
+ await waitForProcessedDataSnapshot(formModel)
757
+ await formModel.applyCleanChanges(() => {
758
+ data.title = 'Saved Title'
759
+ })
760
+ expect(data.slug).toBe('saved-title')
761
+ expect(formModel.isDirty).toBe(false)
762
+ formModel.stop()
763
+ })
764
+
765
+ it('takes over clean changes of nested values in place', async () => {
766
+ // Values without forms are processed as they are, sharing their
767
+ // nested values with the data.
768
+ const { formModel, data } = createFormModel(
769
+ { type: 'form', components: { settings: { type: 'text' } } },
770
+ { settings: { color: 'red' } },
771
+ { getSourceSchema }
772
+ )
773
+ await waitForProcessedDataSnapshot(formModel)
774
+ await formModel.applyCleanChanges(() => {
775
+ data.settings.color = 'blue'
776
+ })
777
+ expect(formModel.isDirty).toBe(false)
778
+ formModel.stop()
779
+ })
780
+
781
+ it('includes clean changes in snapshots that are pending', async () => {
782
+ const { formModel, data } = createFormModel(
783
+ schema,
784
+ { title: 'Hello', chapters: [] },
785
+ { getSourceSchema }
786
+ )
787
+ await formModel.applyCleanChanges(() => {
788
+ data.title = 'Saved'
789
+ })
790
+ await waitForProcessedDataSnapshot(formModel)
791
+ expect(formModel.isDirty).toBe(false)
792
+ formModel.stop()
793
+ })
794
+ })
795
+
796
+ it("isn't tracked without a source schema", async () => {
797
+ const schema = {
798
+ type: 'form',
799
+ components: { title: { type: 'text' } }
800
+ }
801
+ const { formModel, data } = createFormModel(schema, { title: 'Hello' })
802
+ await waitForProcessedDataSnapshot(formModel)
803
+ data.title = 'Changed'
804
+ expect(formModel.isDirty).toBe(false)
805
+ formModel.stop()
806
+ })
807
+ })
808
+ })