@ditojs/admin 3.1.2 → 3.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ditojs/admin",
3
- "version": "3.1.2",
3
+ "version": "3.2.0",
4
4
  "type": "module",
5
5
  "description": "Dito.js Admin is a schema based admin interface for Dito.js Server, featuring auto-generated views and forms and built with Vue.js",
6
6
  "repository": "https://github.com/ditojs/dito/tree/main/packages/admin",
@@ -41,8 +41,8 @@
41
41
  "not ie_mob > 0"
42
42
  ],
43
43
  "dependencies": {
44
- "@ditojs/ui": "^3.1.2",
45
- "@ditojs/utils": "^3.1.2"
44
+ "@ditojs/ui": "^3.2.0",
45
+ "@ditojs/utils": "^3.2.0"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "@kyvg/vue3-notification": "^3.4.2",
@@ -87,7 +87,7 @@
87
87
  "vue-upload-component": "^3.2.2"
88
88
  },
89
89
  "devDependencies": {
90
- "@ditojs/build": "^3.1.2",
90
+ "@ditojs/build": "^3.2.0",
91
91
  "@kyvg/vue3-notification": "^3.4.2",
92
92
  "@tiptap/core": "^3.31.4",
93
93
  "@tiptap/extension-blockquote": "^3.31.4",
@@ -131,5 +131,5 @@
131
131
  "vue-router": "^5.3.1",
132
132
  "vue-upload-component": "^3.2.2"
133
133
  },
134
- "gitHead": "a87ccff01f65386d9363366fb19bb993d38e8fbc"
134
+ "gitHead": "de9b23e9ff13e463947028566c5c0c50ded4e151"
135
135
  }
@@ -4,7 +4,7 @@
4
4
  v-if="creatableForm"
5
5
  :type="isInlined ? 'button' : 'submit'"
6
6
  :disabled="disabled"
7
- v-bind="getButtonAttributes(verb, formLabel)"
7
+ v-bind="getButtonAttributes(verb, formLabel, text)"
8
8
  @click="createItem(creatableForm)"
9
9
  ) {{ text }}
10
10
  template(
@@ -13,7 +13,10 @@
13
13
  button.dito-button(
14
14
  type="button"
15
15
  :disabled="disabled"
16
- v-bind="{ ...pulldownTriggerAttributes, ...getButtonAttributes(verb) }"
16
+ v-bind=`{
17
+ ...pulldownTriggerAttributes,
18
+ ...getButtonAttributes(verb, null, text)
19
+ }`
17
20
  @mousedown.stop="onPulldownMouseDown()"
18
21
  ) {{ text }}
19
22
  ul.dito-pulldown(
@@ -295,13 +295,18 @@ export default {
295
295
  : labelize(name) || ''
296
296
  },
297
297
 
298
- getButtonAttributes(verb, subject = null) {
299
- // Name buttons by what they act on if known, e.g. 'Add Section'.
300
- const label = subject ? `${labelize(verb)} ${subject}` : labelize(verb)
298
+ getButtonAttributes(verb, subject = null, text = null) {
299
+ // Buttons that display text are named by it, others by what they act on
300
+ // if known, e.g. 'Add Section'.
301
+ const label = text
302
+ ? null
303
+ : `${labelize(verb)}${subject ? ` ${subject}` : ''}`
301
304
  return {
302
- 'class': `dito-button--${verb}`,
303
- 'title': label,
304
- 'aria-label': label
305
+ class: `dito-button--${verb}`,
306
+ ...(label && {
307
+ 'title': label,
308
+ 'aria-label': label
309
+ })
305
310
  }
306
311
  },
307
312
 
@@ -82,11 +82,6 @@ export default {
82
82
  return this.getOptionForValue(this.selectedValue)
83
83
  },
84
84
 
85
- // The form model of the data, owned by the dialog or route component.
86
- formModel() {
87
- return (this.dialogComponent ?? this.routeComponent).formModel
88
- },
89
-
90
85
  // The resolver of the options in the form model of the data, which loads
91
86
  // them and shares them with the computes of the form. It is looked up with
92
87
  // the entry of this component, in the shape of the entries of
@@ -424,7 +424,7 @@ export default {
424
424
  // @override ResourceMixin.clearData()
425
425
  clearData() {
426
426
  this.total = 0
427
- this.value = null
427
+ this.setLoadedValue(null)
428
428
  },
429
429
 
430
430
  // @override ResourceMixin.setData()
@@ -437,7 +437,7 @@ export default {
437
437
  if (this.isListSource && isArray(data)) {
438
438
  this.setLoadedListItems(data)
439
439
  } else if (!data || this.isObjectSource && isObject(data)) {
440
- this.value = data
440
+ this.setLoadedValue(data)
441
441
  } else if (this.unwrapListData(data)) {
442
442
  // The format didn't match, see if we received a `{ results, total }`
443
443
  // object, in which case `this.value` was already set by
@@ -483,7 +483,21 @@ export default {
483
483
  // current order. Lists in the data of forms, views and dialogs are
484
484
  // numbered by `FormModel`, see `initializeData()`.
485
485
  setLoadedListItems(items) {
486
- this.value = updateOrder(this.sourceSchema, items, this.paginationRange)
486
+ this.setLoadedValue(
487
+ updateOrder(this.sourceSchema, items, this.paginationRange)
488
+ )
489
+ },
490
+
491
+ // Writes the value loaded through the source's resource. Loading isn't an
492
+ // edit of the data, so the value is written as a clean change, which
493
+ // doesn't make the form dirty, e.g. for sources whose value is stored,
494
+ // like the order of their items, see `FormModel.applyCleanChanges()`.
495
+ setLoadedValue(value) {
496
+ this.formModel
497
+ .applyCleanChanges(() => {
498
+ this.value = value
499
+ })
500
+ .catch(console.error)
487
501
  },
488
502
 
489
503
  createItem(schema, type, index = null) {
@@ -37,6 +37,11 @@ export default {
37
37
  return this.schema.name
38
38
  },
39
39
 
40
+ // The form model of the data, owned by the dialog or route component.
41
+ formModel() {
42
+ return (this.dialogComponent ?? this.routeComponent).formModel
43
+ },
44
+
40
45
  type() {
41
46
  return this.schema.type
42
47
  },
@@ -184,6 +189,7 @@ export default {
184
189
  created() {
185
190
  this._register(true)
186
191
  this.setupSchemaFields()
192
+ this.warnAboutUncomputedValue()
187
193
  },
188
194
 
189
195
  unmounted() {
@@ -191,6 +197,21 @@ export default {
191
197
  },
192
198
 
193
199
  methods: {
200
+ // Only the form model calls `compute()`, see `computeValue()`. Components
201
+ // that it doesn't visit, e.g. in panels with their own data, never get
202
+ // their values computed.
203
+ warnAboutUncomputedValue() {
204
+ if (
205
+ this.schema.compute &&
206
+ !this.formModel.hasComputedValueEntry(this.componentPath)
207
+ ) {
208
+ console.warn(
209
+ `The value of the component at '${this.componentPath}' isn't ` +
210
+ `computed, as its data isn't part of a form model.`
211
+ )
212
+ }
213
+ },
214
+
194
215
  _register(add) {
195
216
  // Provide component to container for schema accessor evaluation.
196
217
  this.$emit('update:component', add ? this : null)
@@ -1,17 +1,25 @@
1
1
  import { isFunction } from '@ditojs/utils'
2
2
  import DitoContext from '../DitoContext.js'
3
- import { computeValue } from '../utils/schema/data.js'
3
+ import { getValueOrDefault } from '../utils/schema/data.js'
4
4
 
5
5
  export default {
6
6
  computed: {
7
7
  value: {
8
8
  get() {
9
- const value = computeValue(
9
+ // `FormModel` writes computed values into the data, see
10
+ // `computeValue()`.
11
+ const value = getValueOrDefault(
10
12
  this.schema,
11
13
  this.data,
12
14
  this.name,
13
- this.dataPath,
14
- { component: this }
15
+ () =>
16
+ DitoContext.createForSchema(this, {
17
+ schema: this.schema,
18
+ name: this.name,
19
+ data: this.data,
20
+ dataPath: this.dataPath,
21
+ rootData: this.rootData
22
+ })
15
23
  )
16
24
  // Only call `format()` if it's a function, as some types use `format`
17
25
  // for other purposes, e.g. `DitoTypeColor` for the color format.
@@ -251,9 +251,11 @@ export default DitoTypeComponent.register('markup', {
251
251
  const onFocus = () => this.onFocus()
252
252
 
253
253
  const onBlur = () => {
254
- // Write the value first, so that validating on blur validates it.
254
+ // Write the value first, so that validating on blur validates it, and
255
+ // emit the change once the editor isn't focused anymore.
255
256
  updateValue()
256
257
  this.onBlur()
258
+ emitChange()
257
259
  }
258
260
 
259
261
  const onUpdate = () => {
@@ -275,9 +277,16 @@ export default DitoTypeComponent.register('markup', {
275
277
  ignoreWatch = true
276
278
  this.value = value
277
279
  }
278
- if (!this.focused && changed) {
279
- this.onChange()
280
+ if (!this.focused) {
281
+ emitChange()
282
+ }
283
+ }
284
+
285
+ // Emits the change of the value, once the editing is done.
286
+ const emitChange = () => {
287
+ if (changed) {
280
288
  changed = false
289
+ this.onChange()
281
290
  }
282
291
  }
283
292
 
@@ -92,7 +92,7 @@ export class FormModel {
92
92
  watch(
93
93
  [getSchema, getData],
94
94
  ([schema, data]) => this.initializeData(schema, data),
95
- { immediate: true }
95
+ modelWatchOptions
96
96
  )
97
97
  // The entries are read through a computed property, so that the watchers
98
98
  // of the entries can check synchronously whether their entry is still
@@ -101,21 +101,23 @@ export class FormModel {
101
101
  watch(
102
102
  () => this.dataEntries.value.computedValueEntries,
103
103
  entries => this.updateComputedValueRecords(entries),
104
- { immediate: true }
104
+ modelWatchOptions
105
105
  )
106
106
  // Values that go missing after the data was set up, e.g. in items that
107
107
  // code adds, get their defaults too:
108
108
  watch(
109
109
  () => this.dataEntries.value.entriesWithMissingValues,
110
110
  entries => this.setDefaultValues(entries),
111
- { immediate: true }
111
+ modelWatchOptions
112
112
  )
113
113
  if (getSourceSchema) {
114
114
  // Data that is set up, e.g. loaded, saved or applied, isn't dirty:
115
+ // After the data is set up, as the watchers run in the order in which
116
+ // they're created, see `modelWatchOptions`.
115
117
  watch(
116
118
  getData,
117
119
  () => this.takeProcessedDataSnapshot().catch(console.error),
118
- { immediate: true }
120
+ modelWatchOptions
119
121
  )
120
122
  }
121
123
  })
@@ -234,14 +236,14 @@ export class FormModel {
234
236
  const data = this.getData()
235
237
  return data && !isEmptySchema(schema)
236
238
  ? processData(schema, this.getSourceSchema(), data, this.dataPath, {
237
- // Like `getComputedValue()`:
239
+ // Like `getComputedValueResult()`:
238
240
  component: this.component.mainSchemaComponent ?? this.component,
239
241
  rootData: this.rootData,
240
242
  schemaOnly: true,
241
243
  target: 'clipboard',
242
- // Reading the dirty state doesn't call `compute()` and `process()`,
243
- // see `processData()`:
244
- shouldCallComputeAndProcess: false
244
+ // Reading the dirty state doesn't call `process()`, see
245
+ // `processData()`:
246
+ shouldCallProcess: false
245
247
  })
246
248
  : null
247
249
  }
@@ -417,15 +419,13 @@ export class FormModel {
417
419
  // Return a new object each time, so that the value is also written
418
420
  // when only the value in the data changed, e.g. through user input.
419
421
  () =>
420
- isEntryCurrent.value
421
- ? { value: this.getComputedValue(entry) }
422
- : null,
422
+ isEntryCurrent.value ? this.getComputedValueResult(entry) : null,
423
423
  computedResult => {
424
424
  if (computedResult) {
425
425
  this.writeComputedValue(entry, computedResult.value)
426
426
  }
427
427
  },
428
- { immediate: true }
428
+ modelWatchOptions
429
429
  )
430
430
  }
431
431
  if (hasValueFromDataSchema(entry.schema)) {
@@ -443,13 +443,19 @@ export class FormModel {
443
443
  this.writeComputedValue(entry, resolved.value)
444
444
  }
445
445
  },
446
- { immediate: true }
446
+ modelWatchOptions
447
447
  )
448
448
  }
449
449
  })
450
450
  return scope
451
451
  }
452
452
 
453
+ // Returns whether the model computes the value of the component at the
454
+ // component path, i.e. whether its walk of the data visits the component.
455
+ hasComputedValueEntry(componentPath) {
456
+ return this.dataEntries.value.computedValueEntries.has(componentPath)
457
+ }
458
+
453
459
  // Returns whether the entry is still one of the current entries, with the
454
460
  // same schema and data, see `getDataEntries()`.
455
461
  isEntryCurrent({ componentPath, schema, data }) {
@@ -460,14 +466,37 @@ export class FormModel {
460
466
 
461
467
  // Calls `schema.compute()` with the main schema component of the data, like
462
468
  // `processData()`, and with the options resolved by the model, so that the
463
- // result doesn't depend on whether the component is rendered.
464
- getComputedValue(entry) {
469
+ // result doesn't depend on whether the component is rendered. Returns the
470
+ // value as `{ value }`, or `null` if `compute()` reads options that aren't
471
+ // loaded, which aborts it, so that the current value is kept until they are
472
+ // and `compute()` can rely on them. The watcher that calls it depends on the
473
+ // options through reading them, and calls it again once they're loaded.
474
+ getComputedValueResult(entry) {
465
475
  const { schema, data, name, dataPath } = entry
466
- return computeValue(schema, data, name, dataPath, {
467
- component: this.component.mainSchemaComponent ?? this.component,
468
- rootData: this.rootData,
469
- getOptions: schema.options ? () => this.getOptions(entry) : null
470
- })
476
+ try {
477
+ const value = computeValue(schema, data, name, dataPath, {
478
+ component: this.component.mainSchemaComponent ?? this.component,
479
+ rootData: this.rootData,
480
+ getOptions: schema.options ? () => this.getLoadedOptions(entry) : null
481
+ })
482
+ return { value }
483
+ } catch (error) {
484
+ if (error === optionsNotLoaded) {
485
+ return null
486
+ }
487
+ throw error
488
+ }
489
+ }
490
+
491
+ // Returns the options of the component of the entry, or aborts the
492
+ // `compute()` that reads them, while they aren't loaded, see
493
+ // `getComputedValueResult()`.
494
+ getLoadedOptions(entry) {
495
+ const options = this.getOptions(entry)
496
+ if (options === undefined) {
497
+ throw optionsNotLoaded
498
+ }
499
+ return options
471
500
  }
472
501
 
473
502
  // Returns the options of the component of the entry, resolved from
@@ -612,6 +641,19 @@ function takeOverChangedValues(target, before, after) {
612
641
 
613
642
  const notFound = Symbol('notFound')
614
643
 
644
+ // The watchers of the model run after the post-flush hooks, so that they write
645
+ // the values that they derive from the data after the components that display
646
+ // them are mounted. Otherwise, the `mounted` hooks of `v-model` directives
647
+ // would restore the values that inputs had when they were mounted, see
648
+ // https://github.com/vuejs/core/issues/15774. Vue processes the updates that
649
+ // the written values cause in the same flush, before the browser renders them.
650
+ // The watchers run in the order in which they're created.
651
+ const modelWatchOptions = { immediate: true, flush: 'post' }
652
+
653
+ // Aborts `compute()` when it reads options that aren't loaded, see
654
+ // `FormModel.getComputedValueResult()`.
655
+ const optionsNotLoaded = Symbol('optionsNotLoaded')
656
+
615
657
  // Returns whether the schema is a source of computed values, through
616
658
  // `schema.compute()` or a data schema of the `computed` types.
617
659
  function hasComputedValueSource(schema) {
@@ -227,9 +227,9 @@ export function initializeData(schema, data = {}, component, {
227
227
  * Returns the value of the component described by `schema` and `name` in
228
228
  * `data`: the result of `schema.compute()` if it returns a value, else the
229
229
  * value in `data`, or its default if it's missing. Never writes into `data`:
230
- * Computed values, including the defaults of components with `compute()`, are
231
- * written by `FormModel`, and other defaults when the data is set up, see
232
- * `initializeData()`, or when they go missing later, see `FormModel`. If
230
+ * `FormModel` is the only caller and writes the computed values, including the
231
+ * defaults of components with `compute()`. Other defaults are written when the
232
+ * data is set up, see `initializeData()`, or when they go missing later. If
233
233
  * provided, `getOptions()` returns the options for `context.options`, called
234
234
  * only when they're read.
235
235
  */
@@ -287,8 +287,10 @@ export function computeValue(schema, data, name, dataPath, {
287
287
  }
288
288
 
289
289
  // Returns the value in `data`, or its default if it's missing. `context` can be
290
- // a function that creates the context, called only when it's needed.
291
- function getValueOrDefault(schema, data, name, context) {
290
+ // a function that creates the context, called only when it's needed. Computed
291
+ // values are in `data` already, as `FormModel` writes them, see
292
+ // `computeValue()`.
293
+ export function getValueOrDefault(schema, data, name, context) {
292
294
  const shouldUseDefault = (
293
295
  isMissingValue(schema, data, name, context) &&
294
296
  !shouldIgnoreMissingValue(schema, context)
@@ -322,35 +324,33 @@ export function processData(schema, sourceSchema, data, dataPath, {
322
324
  rootData,
323
325
  schemaOnly, // whether to only include data covered by the schema, or all data
324
326
  target,
325
- // Whether to call the schema callbacks `compute()` and `process()`. Data that
326
- // is compared while it's edited, e.g. by `FormModel.isDirty`, isn't validated
327
- // yet, which the callbacks may rely on, and `FormModel` writes the computed
328
- // values into the data. Without them, the values in the data are used, or
329
- // their defaults if they're missing, the types still process them through
330
- // `processValue()`, and the excluded values of components with `process()`
331
- // are kept, as `process()` may store them elsewhere through `processedItem`.
332
- shouldCallComputeAndProcess = true
327
+ // Whether to call the schema callbacks `process()`. Data that is compared
328
+ // while it's edited, e.g. by `FormModel.isDirty`, isn't validated yet, which
329
+ // the callbacks may rely on. Without them, the types still process the values
330
+ // through `processValue()`, and the excluded values of components with
331
+ // `process()` are kept, as `process()` may store them elsewhere through
332
+ // `processedItem`. Computed values are in the data already, as `FormModel`
333
+ // writes them, see `computeValue()`.
334
+ shouldCallProcess = true
333
335
  } = {}) {
334
336
  const options = { component, rootData, schemaOnly, target }
335
337
  const processedData = cloneItem(sourceSchema, data, options)
336
338
  const graph = new SchemaGraph()
337
339
 
338
340
  const before = ({ schema, data, name, dataPath, processedData }) => {
339
- let value = shouldCallComputeAndProcess
340
- ? computeValue(schema, data, name, dataPath, options)
341
- : getValueOrDefault(
341
+ let value = getValueOrDefault(
342
+ schema,
343
+ data,
344
+ name,
345
+ () =>
346
+ new DitoContext(component, {
342
347
  schema,
343
- data,
344
348
  name,
345
- () =>
346
- new DitoContext(component, {
347
- schema,
348
- name,
349
- data,
350
- dataPath,
351
- rootData: options.rootData
352
- })
353
- )
349
+ data,
350
+ dataPath,
351
+ rootData: options.rootData
352
+ })
353
+ )
354
354
  // The schema expects the `wrapPrimitives` transformations to be present on
355
355
  // the data that it is applied on, so warp before and unwrap after.
356
356
  if (isArray(value)) {
@@ -400,13 +400,13 @@ export function processData(schema, sourceSchema, data, dataPath, {
400
400
 
401
401
  // Handle the user's `process()` callback next, if one is provided, so that
402
402
  // it can modify data in `processedData` even if it provides `exclude: true`
403
- if (process && shouldCallComputeAndProcess) {
403
+ if (process && shouldCallProcess) {
404
404
  value = process(getContext(context))
405
405
  }
406
406
 
407
407
  // Without calling `process()`, keep the excluded values that it may store
408
- // elsewhere, see `shouldCallComputeAndProcess`:
409
- const shouldKeepExcludedValue = !!process && !shouldCallComputeAndProcess
408
+ // elsewhere, see `shouldCallProcess`:
409
+ const shouldKeepExcludedValue = !!process && !shouldCallProcess
410
410
  if (!shouldKeepExcludedValue && shouldExcludeValue(schema, context)) {
411
411
  delete processedData[name]
412
412
  } else {
package/types/index.d.ts CHANGED
@@ -343,7 +343,11 @@ export interface BaseSchema<$Item>
343
343
  * Computes and sets the field value reactively, also
344
344
  * when the field isn't rendered, unless its `if`
345
345
  * evaluates to `false`. If the callback returns
346
- * `undefined`, the current value is preserved.
346
+ * `undefined`, the current value is preserved. The
347
+ * value is computed into the data, which is what is
348
+ * displayed and saved. As the callback runs whenever
349
+ * the data it reads changes, it should only return a
350
+ * value and not change data.
347
351
  *
348
352
  * As it doesn't depend on the field's component, the
349
353
  * context's component properties, e.g. `component`
@@ -1995,9 +1999,10 @@ export type DitoContext<$Item = any> = {
1995
1999
  option: any
1996
2000
  /**
1997
2001
  * All available options in a select. In `compute()`, they are
1998
- * loaded when first read, also when the field isn't rendered,
1999
- * and are `undefined` while loading; the callback runs again
2000
- * once they are loaded.
2002
+ * loaded when first read, also when the field isn't rendered.
2003
+ * Reading them while they load ends the callback, which keeps
2004
+ * the current value and runs again once they are loaded, so
2005
+ * they are always available in it.
2001
2006
  */
2002
2007
  options: any
2003
2008
  /**