@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.
- package/dist/dito-admin.css +1 -1
- package/dist/dito-admin.es.js +3421 -2662
- package/dist/dito-admin.umd.js +6 -6
- package/package.json +81 -45
- package/src/DitoComponent.js +1 -1
- package/src/DitoContext.js +51 -0
- package/src/DitoTypeComponent.js +6 -1
- package/src/components/DitoAffixes.vue +6 -3
- package/src/components/DitoContainer.vue +19 -10
- package/src/components/DitoCreateButton.vue +11 -2
- package/src/components/DitoDialog.vue +21 -2
- package/src/components/DitoForm.vue +156 -83
- package/src/components/DitoLabel.vue +3 -2
- package/src/components/DitoPane.vue +55 -11
- package/src/components/DitoPanel.vue +33 -5
- package/src/components/DitoPanels.vue +2 -1
- package/src/components/DitoRoot.vue +13 -6
- package/src/components/DitoSchema.vue +278 -145
- package/src/components/DitoSchemaInlined.vue +1 -1
- package/src/components/DitoTabs.vue +16 -18
- package/src/components/DitoTrail.vue +5 -4
- package/src/components/DitoTreeItem.vue +17 -6
- package/src/components/DitoView.vue +25 -2
- package/src/mixins/DitoMixin.js +42 -7
- package/src/mixins/ItemMixin.js +3 -6
- package/src/mixins/NumberMixin.js +47 -31
- package/src/mixins/OptionsMixin.js +122 -71
- package/src/mixins/ResourceMixin.js +5 -2
- package/src/mixins/RouteMixin.js +39 -1
- package/src/mixins/SortableMixin.js +0 -13
- package/src/mixins/SourceMixin.js +142 -95
- package/src/mixins/TextMixin.js +1 -1
- package/src/mixins/TypeMixin.js +37 -22
- package/src/mixins/ValidationMixin.js +9 -27
- package/src/mixins/ValidatorMixin.js +5 -7
- package/src/mixins/ValueMixin.js +5 -2
- package/src/types/DitoTypeButton.vue +1 -1
- package/src/types/DitoTypeCheckbox.vue +1 -1
- package/src/types/DitoTypeCheckboxes.vue +1 -1
- package/src/types/DitoTypeCode.vue +8 -1
- package/src/types/DitoTypeColor.vue +20 -34
- package/src/types/DitoTypeComponent.vue +1 -1
- package/src/types/DitoTypeComputed.vue +4 -27
- package/src/types/DitoTypeDate.vue +16 -5
- package/src/types/DitoTypeLabel.vue +1 -4
- package/src/types/DitoTypeList.vue +9 -5
- package/src/types/DitoTypeMarkup.vue +25 -13
- package/src/types/DitoTypeMultiselect.vue +26 -6
- package/src/types/DitoTypeNumber.vue +1 -1
- package/src/types/DitoTypeObject.vue +10 -7
- package/src/types/DitoTypePanel.vue +19 -6
- package/src/types/DitoTypeProgress.vue +1 -1
- package/src/types/DitoTypeRadio.vue +1 -1
- package/src/types/DitoTypeSection.vue +32 -4
- package/src/types/DitoTypeSelect.vue +1 -1
- package/src/types/DitoTypeSlider.vue +1 -1
- package/src/types/DitoTypeSwitch.vue +1 -1
- package/src/types/DitoTypeText.vue +13 -15
- package/src/types/DitoTypeTextarea.vue +1 -1
- package/src/types/DitoTypeTreeList.vue +12 -7
- package/src/types/DitoTypeUpload.vue +1 -2
- package/src/utils/DataSchemaResolver.js +141 -0
- package/src/utils/DataSchemaResolver.test.js +132 -0
- package/src/utils/FormModel.js +556 -0
- package/src/utils/FormModel.test.js +808 -0
- package/src/utils/data.js +20 -0
- package/src/utils/data.test.js +28 -0
- package/src/utils/filter.js +3 -2
- package/src/utils/options.js +3 -0
- package/src/utils/schema/data.js +617 -0
- package/src/utils/schema/data.test.js +471 -0
- package/src/utils/schema/lookup.js +266 -0
- package/src/utils/schema/lookup.test.js +61 -0
- package/src/utils/schema/setup.js +354 -0
- package/src/utils/schema/structure.js +172 -0
- package/src/utils/schema/structure.test.js +57 -0
- package/src/utils/schema/types.js +34 -0
- package/src/utils/schema/validation.js +142 -0
- package/src/utils/schema/validation.test.js +395 -0
- package/src/utils/units.js +70 -3
- package/src/utils/units.test.js +71 -0
- package/types/index.d.ts +149 -59
- package/src/mixins/DataMixin.js +0 -131
- 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
|
+
})
|