@ditojs/admin 3.2.6 → 3.3.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 (112) hide show
  1. package/dist/dito-admin.css +1 -1
  2. package/dist/dito-admin.es.js +6012 -5827
  3. package/dist/dito-admin.umd.js +3 -7
  4. package/package.json +18 -17
  5. package/src/DitoAdmin.js +44 -24
  6. package/src/DitoContext.js +28 -2
  7. package/src/DitoTypeComponent.js +1 -0
  8. package/src/appState.js +6 -3
  9. package/src/components/DitoAccount.vue +35 -27
  10. package/src/components/DitoAffixes.vue +9 -2
  11. package/src/components/DitoClipboard.vue +59 -93
  12. package/src/components/DitoConfirmMessage.vue +18 -0
  13. package/src/components/DitoContainer.vue +56 -18
  14. package/src/components/DitoCreateButton.vue +36 -84
  15. package/src/components/DitoDialog.vue +20 -15
  16. package/src/components/DitoDragHandle.vue +44 -0
  17. package/src/components/DitoDraggable.vue +43 -16
  18. package/src/components/DitoEditButtons.vue +48 -24
  19. package/src/components/DitoErrors.vue +9 -56
  20. package/src/components/DitoForm.vue +82 -42
  21. package/src/components/DitoInputAffixes.vue +50 -0
  22. package/src/components/DitoLabel.vue +1 -15
  23. package/src/components/DitoMenu.vue +45 -52
  24. package/src/components/DitoNotifications.vue +38 -14
  25. package/src/components/DitoOptionList.vue +81 -0
  26. package/src/components/DitoOptionsEditButtons.vue +57 -0
  27. package/src/components/DitoPagination.vue +1 -8
  28. package/src/components/DitoPane.vue +51 -39
  29. package/src/components/DitoPanel.vue +12 -26
  30. package/src/components/DitoPanels.vue +11 -5
  31. package/src/components/DitoResizeHandle.vue +47 -0
  32. package/src/components/DitoRoot.vue +113 -320
  33. package/src/components/DitoSchema.vue +72 -63
  34. package/src/components/DitoSchemaInlined.vue +8 -9
  35. package/src/components/DitoScopes.vue +14 -18
  36. package/src/components/DitoSidebar.vue +4 -29
  37. package/src/components/DitoSourceItem.vue +114 -0
  38. package/src/components/DitoTableCell.vue +4 -0
  39. package/src/components/DitoTableHead.vue +15 -17
  40. package/src/components/DitoTabs.vue +25 -0
  41. package/src/components/DitoTrail.vue +25 -25
  42. package/src/components/DitoTreeItem.vue +102 -141
  43. package/src/components/DitoUploadFile.vue +5 -2
  44. package/src/components/DitoView.vue +33 -24
  45. package/src/index.js +4 -1
  46. package/src/mixins/DitoMixin.js +77 -110
  47. package/src/mixins/DomMixin.js +12 -14
  48. package/src/mixins/EmitterMixin.js +3 -2
  49. package/src/mixins/ItemMixin.js +38 -94
  50. package/src/mixins/OptionsMixin.js +38 -25
  51. package/src/mixins/ResourceMixin.js +182 -111
  52. package/src/mixins/RouteMixin.js +61 -44
  53. package/src/mixins/SchemaParentMixin.js +4 -1
  54. package/src/mixins/SourceMixin.js +118 -100
  55. package/src/mixins/TypeMixin.js +42 -40
  56. package/src/mixins/ValidationMixin.js +16 -4
  57. package/src/mixins/ValidatorMixin.js +1 -1
  58. package/src/styles/_settings.scss +4 -0
  59. package/src/styles/_tippy.scss +0 -11
  60. package/src/styles/style.scss +0 -1
  61. package/src/types/DitoTypeButton.vue +11 -14
  62. package/src/types/DitoTypeCheckboxes.vue +35 -17
  63. package/src/types/DitoTypeCode.vue +19 -41
  64. package/src/types/DitoTypeColor.vue +67 -19
  65. package/src/types/DitoTypeDate.vue +11 -14
  66. package/src/types/DitoTypeList.vue +50 -62
  67. package/src/types/DitoTypeMarkup.vue +41 -54
  68. package/src/types/DitoTypeMultiselect.vue +82 -52
  69. package/src/types/DitoTypeNumber.vue +12 -15
  70. package/src/types/DitoTypeObject.vue +4 -38
  71. package/src/types/DitoTypeRadio.vue +19 -6
  72. package/src/types/DitoTypeSection.vue +23 -9
  73. package/src/types/DitoTypeSelect.vue +39 -23
  74. package/src/types/DitoTypeSlider.vue +9 -1
  75. package/src/types/DitoTypeSwitch.vue +1 -1
  76. package/src/types/DitoTypeText.vue +11 -14
  77. package/src/types/DitoTypeTreeList.vue +46 -8
  78. package/src/types/DitoTypeUpload.vue +190 -67
  79. package/src/utils/DataModel.js +368 -146
  80. package/src/utils/FileDropTargets.js +97 -0
  81. package/src/utils/ItemStores.js +31 -0
  82. package/src/utils/ListQuery.js +115 -0
  83. package/src/utils/LoadingTracker.js +76 -0
  84. package/src/utils/SchemaGraph.js +3 -2
  85. package/src/utils/Session.js +151 -0
  86. package/src/utils/ViewRegistry.js +85 -0
  87. package/src/utils/accessor.js +12 -4
  88. package/src/utils/affixes.js +33 -0
  89. package/src/utils/changeOnceEdited.js +33 -0
  90. package/src/utils/clipboard.js +55 -0
  91. package/src/utils/data.js +10 -5
  92. package/src/utils/dialogs.js +59 -0
  93. package/src/utils/dragResize.js +61 -0
  94. package/src/utils/filter.js +36 -2
  95. package/src/utils/html.js +9 -0
  96. package/src/utils/list.js +10 -0
  97. package/src/utils/options.js +4 -0
  98. package/src/utils/registration.js +18 -0
  99. package/src/utils/request.js +108 -13
  100. package/src/utils/route.js +6 -17
  101. package/src/utils/schema/data.js +29 -2
  102. package/src/utils/schema/events.js +19 -0
  103. package/src/utils/schema/item.js +114 -0
  104. package/src/utils/schema/lookup.js +39 -14
  105. package/src/utils/schema/setup.js +5 -5
  106. package/src/utils/schema/validation.js +16 -2
  107. package/src/utils/uid.js +105 -8
  108. package/src/validators/_decimals.js +16 -5
  109. package/types/index.d.ts +168 -41
  110. package/src/mixins/LoadingMixin.js +0 -23
  111. package/src/mixins/PulldownMixin.js +0 -72
  112. package/src/styles/_pulldown.scss +0 -38
@@ -1,13 +1,21 @@
1
+ import { markRaw } from 'vue'
1
2
  import ItemMixin from './ItemMixin.js'
2
- import LoadingMixin from './LoadingMixin.js'
3
3
  import { initializeData } from '../utils/schema/data.js'
4
- import { assignDeeply, isObject, isString, labelize } from '@ditojs/utils'
4
+ import {
5
+ assignDeeply,
6
+ deprecate,
7
+ isObject,
8
+ isString,
9
+ labelize
10
+ } from '@ditojs/utils'
5
11
  import { getResource } from '../utils/resource.js'
12
+ import { transferUids } from '../utils/uid.js'
13
+ import { LoadingTracker, LoadingSwitch } from '../utils/LoadingTracker.js'
6
14
  import DitoContext from '../DitoContext.js'
7
15
 
8
16
  // @vue/component
9
17
  export default {
10
- mixins: [ItemMixin, LoadingMixin],
18
+ mixins: [ItemMixin],
11
19
 
12
20
  provide() {
13
21
  return {
@@ -19,9 +27,16 @@ export default {
19
27
  },
20
28
 
21
29
  data() {
30
+ // Tracks the pending requests of the component, see `handleRequest()`,
31
+ // and forwards them to the tracker of the enclosing scope, e.g. a view.
32
+ const loadingTracker = new LoadingTracker(this.$loadingTracker())
22
33
  return {
23
34
  loadedData: null,
24
- abortController: null
35
+ // The abort controller of the pending data load, see `requestData()`.
36
+ loadAbortController: null,
37
+ loadingTracker: markRaw(loadingTracker),
38
+ // The loading operation of the deprecated `setLoading()`.
39
+ setLoadingSwitch: markRaw(new LoadingSwitch(loadingTracker))
25
40
  }
26
41
  },
27
42
 
@@ -36,6 +51,10 @@ export default {
36
51
  return this
37
52
  },
38
53
 
54
+ isLoading() {
55
+ return this.loadingTracker.isLoading
56
+ },
57
+
39
58
  resource() {
40
59
  return this.getResource()
41
60
  },
@@ -71,15 +90,6 @@ export default {
71
90
  )
72
91
  },
73
92
 
74
- transientNote() {
75
- return (
76
- this.isTransient && (
77
- '<b>Note</b>: the parent still needs to be saved ' +
78
- 'in order to persist this change.'
79
- )
80
- )
81
- },
82
-
83
93
  shouldLoad() {
84
94
  return (
85
95
  !this.isTransient &&
@@ -131,7 +141,34 @@ export default {
131
141
  }
132
142
  },
133
143
 
144
+ unmounted() {
145
+ // Settle the pending data load without calling its callback, so that
146
+ // nothing is applied to the unmounted component. Other requests, e.g.
147
+ // submits, change data on the server and are completed, so that their
148
+ // outcome is still notified.
149
+ this.loadAbortController?.abort()
150
+ // Don't leave the enclosing scopes loading after the component is gone,
151
+ // see the deprecated `setLoading()`.
152
+ this.setLoadingSwitch.set(false)
153
+ },
154
+
134
155
  methods: {
156
+ /**
157
+ * @deprecated `isLoading` is tracked by `loadingTracker` now. Use
158
+ * `loadingTracker.begin()` and call the function that it returns, or
159
+ * `loadingTracker.track(callback)` instead.
160
+ */
161
+ setLoading(isLoading) {
162
+ deprecate(
163
+ 'setLoading() is deprecated, use `loadingTracker.begin()` or ' +
164
+ '`loadingTracker.track()` instead.'
165
+ )
166
+ // The former `options.updateRoot` and `options.updateView` are ignored,
167
+ // since the tracker forwards its operations to the trackers of the
168
+ // enclosing view and the root anyway.
169
+ this.setLoadingSwitch.set(isLoading)
170
+ },
171
+
135
172
  getResource({ method = 'get', child } = {}) {
136
173
  // Returns the resource object representing the resource for the
137
174
  // associated source schema.
@@ -212,36 +249,50 @@ export default {
212
249
 
213
250
  requestData() {
214
251
  const query = this.queryParams
215
- this.handleRequest({ method: 'get', query }, (err, response) => {
216
- if (err) {
217
- if (response) {
218
- const { data } = response
219
- if (
220
- data?.type === 'FilterValidation' &&
221
- this.onFilterErrors?.(data.errors)
222
- ) {
223
- return true
224
- } else if (
225
- this.isUnauthorizedError(response) &&
226
- this.isInsideForm
227
- ) {
228
- // The user is logged in, see `sendRequest()`, but may not access
229
- // the resource of a component in a form, e.g. a list that loads
230
- // its own data. Show it on the component, as several components
231
- // of the form may be affected. Others notify like for errors.
232
- this.addUnauthorizedError()
233
- return true
252
+ return this.handleRequest(
253
+ { method: 'get', query, isDataLoad: true },
254
+ async (err, response) => {
255
+ if (err) {
256
+ if (response) {
257
+ const { data } = response
258
+ if (
259
+ data?.type === 'FilterValidation' &&
260
+ this.onFilterErrors?.(data.errors)
261
+ ) {
262
+ return true
263
+ } else if (
264
+ this.isUnauthorizedError(response) &&
265
+ this.isInsideForm
266
+ ) {
267
+ // The user is logged in, see `sendRequest()`, but may not
268
+ // access the resource of a component in a form, e.g. a list
269
+ // that loads its own data. Show it on the component, as
270
+ // several components of the form may be affected. Others
271
+ // notify like for errors.
272
+ this.addUnauthorizedError()
273
+ return true
274
+ }
234
275
  }
276
+ } else {
277
+ // Skip applying response data on a dirty form: `setData` would
278
+ // wholesale-replace `loadedData` and clobber any local edits the
279
+ // user made while the GET was in flight. The `'load'` event still
280
+ // fires — the GET *did* complete; we just chose not to apply it.
281
+ if (!(this.isForm && this.isDirty)) {
282
+ this.setData(response.data)
283
+ }
284
+ // Await the handlers, so that their errors are notified below.
285
+ await this.emitSchemaEvent('load')
235
286
  }
236
- } else {
237
- // Skip applying response data on a dirty form: `setData` would
238
- // wholesale-replace `loadedData` and clobber any local edits the
239
- // user made while the GET was in flight. The `'load'` event still
240
- // fires — the GET *did* complete; we just chose not to apply it.
241
- if (!(this.isForm && this.isDirty)) {
242
- this.setData(response.data)
243
- }
244
- this.emitSchemaEvent('load')
287
+ }
288
+ ).catch(error => {
289
+ // Notify the errors thrown while applying the loaded data, e.g. by
290
+ // `setData()` or the `'load'` event handlers, which the emitter
291
+ // aggregates, like request errors. Aborted loads don't call the
292
+ // callback, see `handleRequest()`.
293
+ const errors = error instanceof AggregateError ? error.errors : [error]
294
+ for (const err of errors) {
295
+ this.notifyRequestError(err)
245
296
  }
246
297
  })
247
298
  },
@@ -261,52 +312,73 @@ export default {
261
312
  }
262
313
  },
263
314
 
315
+ // Sends the request and awaits `callback(error, response)` with its
316
+ // outcome. If the callback returns `true` for an error, the error was
317
+ // handled, otherwise it is notified. Errors that the callback throws are
318
+ // passed on to the caller. Aborted requests settle without calling the
319
+ // callback: Each data load aborts the previous one, see `requestData()`,
320
+ // and unmounting aborts the pending one. Other requests, e.g. submits,
321
+ // aren't aborted.
264
322
  async handleRequest(
265
323
  {
266
324
  method,
267
325
  resource = this.getResource({ method }),
268
326
  query,
269
- data
327
+ data,
328
+ isDataLoad = false
270
329
  },
271
330
  callback
272
331
  ) {
273
- const loadingOptions = {
274
- updateRoot: true, // Display spinner in header when loading in resources
275
- updateView: this.isInView // Notify view of loading for view components
276
- }
277
- this.abortController?.abort()
278
332
  const controller = new AbortController()
279
- this.abortController = controller
280
- const { signal } = controller
333
+ if (isDataLoad) {
334
+ this.loadAbortController?.abort()
335
+ this.loadAbortController = controller
336
+ }
337
+ const endLoading = this.loadingTracker.begin()
281
338
  method = resource.method || method
282
- const request = { method, resource, query, data, signal }
283
- this.setLoading(true, loadingOptions)
339
+ let response = null
340
+ let error = null
284
341
  try {
285
- const response = await this.sendRequest(request)
286
- // Pass both request and response to the callback, so they can be
287
- // exposed to further callbacks through DitoContext.
288
- callback(null, response)
289
- } catch (error) {
290
- if (error.name !== 'AbortError') {
291
- // If callback returns true, errors were already handled.
292
- const { response } = error
293
- if (!callback(error, response)) {
294
- const data = response?.data
295
- const title = isString(data?.type)
296
- ? labelize(data.type)
297
- : 'Error'
298
- const text = data?.message ?? error
299
- this.notify({ type: 'error', error, title, text })
300
- }
342
+ response = await this.sendRequest({
343
+ method,
344
+ resource,
345
+ query,
346
+ data,
347
+ signal: controller.signal
348
+ })
349
+ } catch (err) {
350
+ error = err
351
+ } finally {
352
+ if (this.loadAbortController === controller) {
353
+ this.loadAbortController = null
301
354
  }
355
+ endLoading()
302
356
  }
303
- if (this.abortController === controller) {
304
- // Only clear the loading state if this is still the current request.
305
- this.abortController = null
306
- this.setLoading(false, loadingOptions)
357
+ if (controller.signal.aborted || error?.name === 'AbortError') {
358
+ return
359
+ }
360
+ if (error) {
361
+ const { response } = error
362
+ // If the callback returns true, the error was already handled.
363
+ if (!(await callback(error, response))) {
364
+ this.notifyRequestError(error)
365
+ }
366
+ } else {
367
+ // Pass both request and response to the callback, so they can be
368
+ // exposed to further callbacks through DitoContext.
369
+ await callback(null, response)
307
370
  }
308
371
  },
309
372
 
373
+ // Notifies the error with the type and message of the response data of
374
+ // request errors, see `RequestError`, or else the error itself.
375
+ notifyRequestError(error) {
376
+ const data = error?.response?.data
377
+ const title = isString(data?.type) ? labelize(data.type) : 'Error'
378
+ const text = data?.message ?? error
379
+ this.notify({ type: 'error', error, title, text })
380
+ },
381
+
310
382
  getPayloadData(button, method) {
311
383
  // Convention: only post, put and patch requests pass the data as payload.
312
384
  return (
@@ -356,50 +428,49 @@ export default {
356
428
  ]
357
429
  })
358
430
  } = {}) {
359
- return new Promise(resolve => {
360
- this.handleRequest(
361
- { method, resource, data },
362
- async (err, response) => {
363
- const data = response?.data
364
- if (err) {
365
- // See if we're dealing with a Dito.js validation error:
366
- const errors = this.isValidationError(response) && data.errors
367
- if (errors) {
368
- await this.showValidationErrors(errors, true)
369
- } else {
370
- const error = isObject(data) ? data : err
371
- onError?.(error)
372
- await this.emitButtonEvent(button, 'error', {
373
- notify: notifyError,
374
- error
375
- })
376
- }
377
- resolve(false)
431
+ // Resolves to `false` for errors.
432
+ let isSuccessful = false
433
+ await this.handleRequest(
434
+ { method, resource, data },
435
+ async (err, response) => {
436
+ const data = response?.data
437
+ if (err) {
438
+ // See if we're dealing with a Dito.js validation error:
439
+ const errors = this.isValidationError(response) && data.errors
440
+ if (errors) {
441
+ await this.showValidationErrors(errors, true)
378
442
  } else {
379
- // Update the underlying data before calling `notify()` or
380
- // `this.itemLabel`, so id is set after creating new items.
381
- if (setData && data) {
382
- // Preserve the foreign data entries when updating the data.
383
- const { foreignData } = this.mainSchemaComponent.filterData(
384
- this.data
385
- )
386
- // Tell the parent route to reload its data, so that it can
387
- // update its foreign data entries.
388
- const parentMeta = this.parentRouteComponent?.routeRecord?.meta
389
- if (parentMeta) {
390
- parentMeta.reload = true
391
- }
392
- this.setData(assignDeeply({}, foreignData, data))
393
- }
394
- onSuccess?.()
395
- await this.emitButtonEvent(button, 'success', {
396
- notify: notifySuccess
443
+ const error = isObject(data) ? data : err
444
+ onError?.(error)
445
+ await this.emitButtonEvent(button, 'error', {
446
+ notify: notifyError,
447
+ error
397
448
  })
398
- resolve(true)
399
449
  }
450
+ return true // The error was handled.
451
+ } else {
452
+ // Update the underlying data before calling `notify()` or
453
+ // `this.itemLabel`, so id is set after creating new items.
454
+ if (setData && data) {
455
+ // Preserve the foreign data entries when updating the data.
456
+ const { foreignData } = this.mainSchemaComponent.filterData(
457
+ this.data
458
+ )
459
+ const savedData = assignDeeply({}, foreignData, data)
460
+ // Keep the uids of new items, which key their components and
461
+ // stores, see `transferUids()`.
462
+ transferUids(this.data, savedData)
463
+ this.setData(savedData)
464
+ }
465
+ onSuccess?.()
466
+ await this.emitButtonEvent(button, 'success', {
467
+ notify: notifySuccess
468
+ })
469
+ isSuccessful = true
400
470
  }
401
- )
402
- })
471
+ }
472
+ )
473
+ return isSuccessful
403
474
  },
404
475
 
405
476
  async emitButtonEvent(button, event, { notify, error }) {
@@ -1,6 +1,7 @@
1
1
  import ValidatorMixin from '../mixins/ValidatorMixin.js'
2
2
  import { markRaw } from 'vue'
3
- import { getCommonPrefix } from '@ditojs/utils'
3
+ import { isPathWithin } from '../utils/route.js'
4
+ import { confirm } from '../utils/dialogs.js'
4
5
 
5
6
  // @vue/component
6
7
  export default {
@@ -14,15 +15,23 @@ export default {
14
15
 
15
16
  data() {
16
17
  return {
17
- reload: false,
18
18
  // The record of the route that this component renders, see the
19
19
  // `matchedRouteRecord` watcher:
20
20
  routeRecord: null,
21
+ // Whether leaving the route was prevented because the data is invalid,
22
+ // so that the next attempt can leave, see `beforeRouteChange()`:
23
+ hasPreventedLeavingInvalidData: false,
24
+ // The pending confirmation to discard the unsaved changes, shared by the
25
+ // navigations that happen while its dialog is open, e.g. through the
26
+ // browser's back button, see `beforeRouteChange()`:
27
+ discardConfirmation: null,
21
28
  // Each route-component defines a store that gets passed on to its
22
29
  // child components, so they can store values in them that live beyond
23
30
  // their life-cycle. See: DitoPane, SourceMixin
24
31
  store: {},
25
- loadCache: {} // See TypeMixin.load()
32
+ // The responses of `request({ cache: 'local' })` of the components that
33
+ // the route component renders, released with it, see `DitoMixin`.
34
+ loadCache: {}
26
35
  }
27
36
  },
28
37
 
@@ -116,6 +125,13 @@ export default {
116
125
  // @overridable, see DitoForm
117
126
  isMutating() {
118
127
  return false
128
+ },
129
+
130
+ // Whether the component has changes that get lost when it's left, see
131
+ // `beforeRouteChange()` and `DitoRoot`. Directly mutating forms aren't
132
+ // dirty, as their parent's data keeps the changes, see `DitoForm.isDirty`.
133
+ hasUnsavedChanges() {
134
+ return this.isDirty
119
135
  }
120
136
  },
121
137
 
@@ -144,62 +160,69 @@ export default {
144
160
  }
145
161
  },
146
162
 
163
+ // NOTE: vue-router binds the guards to the record's instance only when it
164
+ // calls them, after the previous guards, which may have unmounted it in the
165
+ // meantime. There's nothing left to guard then.
147
166
  beforeRouteUpdate(to, from) {
148
- return this?.beforeRouteChange(to, from)
167
+ return this ? this.beforeRouteChange(to, from) : true
149
168
  },
150
169
 
151
170
  beforeRouteLeave(to, from) {
152
- return this?.beforeRouteChange(to, from)
171
+ return this ? this.beforeRouteChange(to, from) : true
153
172
  },
154
173
 
155
174
  created() {
156
- // Keep a shared stack of root components for DitoTrail to use to render
157
- // labels. Can't rely on $route.matched[i].instances.default unfortunately,
158
- // as instances aren't immediately ready, and instances is not reactive.
159
- this.appState.routeComponents.push(this)
175
+ // Register in the shared route components by route level, for DitoTrail to
176
+ // render the labels. Can't rely on `$route.matched[i].instances.default`
177
+ // unfortunately, as instances aren't immediately ready, and `instances` is
178
+ // not reactive. Components of new routes replace the ones of the previous
179
+ // routes at their level, which are unmounted after, see `unmounted()`.
180
+ this.appState.routeComponents[this.routeLevel] = this
160
181
  },
161
182
 
162
183
  unmounted() {
163
184
  const { routeComponents } = this.appState
164
- routeComponents.splice(routeComponents.indexOf(this), 1)
185
+ if (routeComponents[this.routeLevel] === this) {
186
+ // Also drop the deeper levels, which aren't replaced by those of a new
187
+ // route either, as their components are unmounted before their parents.
188
+ routeComponents.length = this.routeLevel
189
+ }
165
190
  },
166
191
 
167
192
  methods: {
168
- beforeRouteChange(to, from) {
193
+ async beforeRouteChange(to, from) {
169
194
  let ok = true
170
195
  const isClosing = (
171
196
  // Only handle this route change if the form is actually mapped to the
172
- // `from` route, but include parent forms of closing nested forms as as
173
- // well, by matching the the start of from/to path against `this.path`:
174
- from.path.startsWith(this.path) &&
175
- !to.path.startsWith(this.path) &&
176
- // Exclude hash changes only (= tab changes):
177
- from.path !== to.path && (
178
- this.isFullRouteChange(to, from) ||
179
- // Decide if we're moving towards a new nested form, or closing /
180
- // replacing an already open one by comparing path lengths.
181
- // The case of `=` matches the replacing of an already open one.
182
- to.path.length <= from.path.length
183
- )
197
+ // `from` route, but include parent forms of closing nested forms as
198
+ // well, by matching the from/to paths against `this.path`. Hash changes
199
+ // only (= tab changes) and nested forms that open stay within it, while
200
+ // other items, e.g. `/items/12` for `/items/1`, are outside of it.
201
+ isPathWithin(from.path, this.path) &&
202
+ !isPathWithin(to.path, this.path)
184
203
  )
185
204
  if (isClosing) {
186
205
  if (this.isMutating) {
187
- // For active directly mutating (nested) forms that were not validated
188
- // yet, validate them once. If the user then still wants to leave
189
- // them, they can click close / navigate away again.
190
- ok = (
191
- this.isValidated ||
192
- this.validateAll()
193
- )
206
+ // Directly mutating (nested) forms validate their data once, which
207
+ // includes the fields that aren't mounted, e.g. in collapsed
208
+ // schemas. If the user then still wants to leave them, they can
209
+ // click close / navigate away again.
210
+ ok = this.hasPreventedLeavingInvalidData || this.validateAll()
211
+ this.hasPreventedLeavingInvalidData = !ok
194
212
  } else {
195
- // The form doesn't directly mutate data. If it is dirty, ask if user
196
- // wants to persist data first.
197
- if (this.isDirty) {
198
- ok = window.confirm(
199
- `You have unsaved changes. Do you really want to ${
200
- this.verbs.cancel
201
- }?`
202
- )
213
+ // The form or view doesn't directly mutate data. If it has unsaved
214
+ // changes, ask if the user wants to persist the data first.
215
+ if (this.hasUnsavedChanges) {
216
+ this.discardConfirmation ??= confirm(this, {
217
+ message: (
218
+ 'You have unsaved changes. ' +
219
+ `Do you really want to ${this.verbs.cancel}?`
220
+ ),
221
+ verb: 'discard'
222
+ }).finally(() => {
223
+ this.discardConfirmation = null
224
+ })
225
+ ok = await this.discardConfirmation
203
226
  }
204
227
  }
205
228
  }
@@ -221,12 +244,6 @@ export default {
221
244
 
222
245
  getChildPath(path) {
223
246
  return `${this.path}/${path}`
224
- },
225
-
226
- isFullRouteChange(to, from) {
227
- // The route path is the path up to the first / (excluding the initial /):
228
- const rootPath = this.path.match(/^(\/[^/]*)/)[1]
229
- return !getCommonPrefix(to.path, from.path).startsWith(rootPath)
230
247
  }
231
248
  }
232
249
  }
@@ -26,7 +26,10 @@ export default {
26
26
  if (add) {
27
27
  schemaComponents.push(schemaComponent)
28
28
  } else {
29
- schemaComponents.splice(schemaComponents.indexOf(schemaComponent), 1)
29
+ const index = schemaComponents.indexOf(schemaComponent)
30
+ if (index >= 0) {
31
+ schemaComponents.splice(index, 1)
32
+ }
30
33
  }
31
34
  }
32
35
  }