@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.
- package/dist/dito-admin.css +1 -1
- package/dist/dito-admin.es.js +6012 -5827
- package/dist/dito-admin.umd.js +3 -7
- package/package.json +18 -17
- package/src/DitoAdmin.js +44 -24
- package/src/DitoContext.js +28 -2
- package/src/DitoTypeComponent.js +1 -0
- package/src/appState.js +6 -3
- package/src/components/DitoAccount.vue +35 -27
- package/src/components/DitoAffixes.vue +9 -2
- package/src/components/DitoClipboard.vue +59 -93
- package/src/components/DitoConfirmMessage.vue +18 -0
- package/src/components/DitoContainer.vue +56 -18
- package/src/components/DitoCreateButton.vue +36 -84
- package/src/components/DitoDialog.vue +20 -15
- package/src/components/DitoDragHandle.vue +44 -0
- package/src/components/DitoDraggable.vue +43 -16
- package/src/components/DitoEditButtons.vue +48 -24
- package/src/components/DitoErrors.vue +9 -56
- package/src/components/DitoForm.vue +82 -42
- package/src/components/DitoInputAffixes.vue +50 -0
- package/src/components/DitoLabel.vue +1 -15
- package/src/components/DitoMenu.vue +45 -52
- package/src/components/DitoNotifications.vue +38 -14
- package/src/components/DitoOptionList.vue +81 -0
- package/src/components/DitoOptionsEditButtons.vue +57 -0
- package/src/components/DitoPagination.vue +1 -8
- package/src/components/DitoPane.vue +51 -39
- package/src/components/DitoPanel.vue +12 -26
- package/src/components/DitoPanels.vue +11 -5
- package/src/components/DitoResizeHandle.vue +47 -0
- package/src/components/DitoRoot.vue +113 -320
- package/src/components/DitoSchema.vue +72 -63
- package/src/components/DitoSchemaInlined.vue +8 -9
- package/src/components/DitoScopes.vue +14 -18
- package/src/components/DitoSidebar.vue +4 -29
- package/src/components/DitoSourceItem.vue +114 -0
- package/src/components/DitoTableCell.vue +4 -0
- package/src/components/DitoTableHead.vue +15 -17
- package/src/components/DitoTabs.vue +25 -0
- package/src/components/DitoTrail.vue +25 -25
- package/src/components/DitoTreeItem.vue +102 -141
- package/src/components/DitoUploadFile.vue +5 -2
- package/src/components/DitoView.vue +33 -24
- package/src/index.js +4 -1
- package/src/mixins/DitoMixin.js +77 -110
- package/src/mixins/DomMixin.js +12 -14
- package/src/mixins/EmitterMixin.js +3 -2
- package/src/mixins/ItemMixin.js +38 -94
- package/src/mixins/OptionsMixin.js +38 -25
- package/src/mixins/ResourceMixin.js +182 -111
- package/src/mixins/RouteMixin.js +61 -44
- package/src/mixins/SchemaParentMixin.js +4 -1
- package/src/mixins/SourceMixin.js +118 -100
- package/src/mixins/TypeMixin.js +42 -40
- package/src/mixins/ValidationMixin.js +16 -4
- package/src/mixins/ValidatorMixin.js +1 -1
- package/src/styles/_settings.scss +4 -0
- package/src/styles/_tippy.scss +0 -11
- package/src/styles/style.scss +0 -1
- package/src/types/DitoTypeButton.vue +11 -14
- package/src/types/DitoTypeCheckboxes.vue +35 -17
- package/src/types/DitoTypeCode.vue +19 -41
- package/src/types/DitoTypeColor.vue +67 -19
- package/src/types/DitoTypeDate.vue +11 -14
- package/src/types/DitoTypeList.vue +50 -62
- package/src/types/DitoTypeMarkup.vue +41 -54
- package/src/types/DitoTypeMultiselect.vue +82 -52
- package/src/types/DitoTypeNumber.vue +12 -15
- package/src/types/DitoTypeObject.vue +4 -38
- package/src/types/DitoTypeRadio.vue +19 -6
- package/src/types/DitoTypeSection.vue +23 -9
- package/src/types/DitoTypeSelect.vue +39 -23
- package/src/types/DitoTypeSlider.vue +9 -1
- package/src/types/DitoTypeSwitch.vue +1 -1
- package/src/types/DitoTypeText.vue +11 -14
- package/src/types/DitoTypeTreeList.vue +46 -8
- package/src/types/DitoTypeUpload.vue +190 -67
- package/src/utils/DataModel.js +368 -146
- package/src/utils/FileDropTargets.js +97 -0
- package/src/utils/ItemStores.js +31 -0
- package/src/utils/ListQuery.js +115 -0
- package/src/utils/LoadingTracker.js +76 -0
- package/src/utils/SchemaGraph.js +3 -2
- package/src/utils/Session.js +151 -0
- package/src/utils/ViewRegistry.js +85 -0
- package/src/utils/accessor.js +12 -4
- package/src/utils/affixes.js +33 -0
- package/src/utils/changeOnceEdited.js +33 -0
- package/src/utils/clipboard.js +55 -0
- package/src/utils/data.js +10 -5
- package/src/utils/dialogs.js +59 -0
- package/src/utils/dragResize.js +61 -0
- package/src/utils/filter.js +36 -2
- package/src/utils/html.js +9 -0
- package/src/utils/list.js +10 -0
- package/src/utils/options.js +4 -0
- package/src/utils/registration.js +18 -0
- package/src/utils/request.js +108 -13
- package/src/utils/route.js +6 -17
- package/src/utils/schema/data.js +29 -2
- package/src/utils/schema/events.js +19 -0
- package/src/utils/schema/item.js +114 -0
- package/src/utils/schema/lookup.js +39 -14
- package/src/utils/schema/setup.js +5 -5
- package/src/utils/schema/validation.js +16 -2
- package/src/utils/uid.js +105 -8
- package/src/validators/_decimals.js +16 -5
- package/types/index.d.ts +168 -41
- package/src/mixins/LoadingMixin.js +0 -23
- package/src/mixins/PulldownMixin.js +0 -72
- 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 {
|
|
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
|
|
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
|
-
|
|
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(
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
if (
|
|
220
|
-
data
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
this.
|
|
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
|
-
|
|
280
|
-
|
|
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
|
-
|
|
283
|
-
|
|
339
|
+
let response = null
|
|
340
|
+
let error = null
|
|
284
341
|
try {
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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 (
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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 }) {
|
package/src/mixins/RouteMixin.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import ValidatorMixin from '../mixins/ValidatorMixin.js'
|
|
2
2
|
import { markRaw } from 'vue'
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
|
167
|
+
return this ? this.beforeRouteChange(to, from) : true
|
|
149
168
|
},
|
|
150
169
|
|
|
151
170
|
beforeRouteLeave(to, from) {
|
|
152
|
-
return this
|
|
171
|
+
return this ? this.beforeRouteChange(to, from) : true
|
|
153
172
|
},
|
|
154
173
|
|
|
155
174
|
created() {
|
|
156
|
-
//
|
|
157
|
-
// labels. Can't rely on
|
|
158
|
-
// as instances aren't immediately ready, and instances is
|
|
159
|
-
|
|
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
|
-
|
|
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
|
|
173
|
-
// well, by matching the
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
196
|
-
// wants to persist data first.
|
|
197
|
-
if (this.
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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.
|
|
29
|
+
const index = schemaComponents.indexOf(schemaComponent)
|
|
30
|
+
if (index >= 0) {
|
|
31
|
+
schemaComponents.splice(index, 1)
|
|
32
|
+
}
|
|
30
33
|
}
|
|
31
34
|
}
|
|
32
35
|
}
|