@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
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { shallowRef } from 'vue'
|
|
2
|
+
|
|
3
|
+
// FileDropTargets tracks the files that are dragged over the document, and the
|
|
4
|
+
// elements that accept them, e.g. the ones of `DitoTypeUpload`, which handle
|
|
5
|
+
// the dropped files themselves. The root of the admin creates one instance and
|
|
6
|
+
// passes it the drag events of the document, see `DitoRoot`. A drag of files
|
|
7
|
+
// only begins while there are targets, and only targets let files be dropped,
|
|
8
|
+
// so that the browser doesn't open files dropped elsewhere.
|
|
9
|
+
// `isDraggingFiles` is reactive.
|
|
10
|
+
|
|
11
|
+
export class FileDropTargets {
|
|
12
|
+
#targets = new Set()
|
|
13
|
+
#isDraggingFiles = shallowRef(false)
|
|
14
|
+
// The number of elements that the drag entered and didn't leave yet, as
|
|
15
|
+
// `dragenter` and `dragleave` are triggered for each element.
|
|
16
|
+
#enteredElementCount = 0
|
|
17
|
+
|
|
18
|
+
get isDraggingFiles() {
|
|
19
|
+
return this.#isDraggingFiles.value
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
get hasTargets() {
|
|
23
|
+
return this.#targets.size > 0
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Adds `element` as a target, and returns the function that removes it.
|
|
27
|
+
add(element) {
|
|
28
|
+
this.#targets.add(element)
|
|
29
|
+
return () => {
|
|
30
|
+
this.#targets.delete(element)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Returns whether `node` is a target or inside one.
|
|
35
|
+
isInTarget(node) {
|
|
36
|
+
for (const target of this.#targets) {
|
|
37
|
+
if (target.contains(node)) {
|
|
38
|
+
return true
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Returns the handlers of the drag events of the document, to be added to it
|
|
45
|
+
// by the owner of the instance.
|
|
46
|
+
getDocumentEventHandlers() {
|
|
47
|
+
return {
|
|
48
|
+
dragenter: event => this.#onDragEnter(event),
|
|
49
|
+
dragleave: event => this.#onDragLeave(event),
|
|
50
|
+
dragover: event => this.#onDragOver(event),
|
|
51
|
+
drop: event => this.#onDrop(event)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#onDragEnter(event) {
|
|
56
|
+
if (isDraggingFiles(event)) {
|
|
57
|
+
this.#enteredElementCount++
|
|
58
|
+
if (this.#enteredElementCount === 1 && this.hasTargets) {
|
|
59
|
+
this.#isDraggingFiles.value = true
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
#onDragLeave(event) {
|
|
65
|
+
if (isDraggingFiles(event) && this.#enteredElementCount > 0) {
|
|
66
|
+
this.#enteredElementCount--
|
|
67
|
+
if (this.#enteredElementCount === 0) {
|
|
68
|
+
this.#isDraggingFiles.value = false
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
#onDragOver(event) {
|
|
74
|
+
if (isDraggingFiles(event)) {
|
|
75
|
+
if (this.isInTarget(event.target)) {
|
|
76
|
+
// The target allows the drop by preventing the default itself.
|
|
77
|
+
event.dataTransfer.dropEffect = 'copy'
|
|
78
|
+
} else {
|
|
79
|
+
// Prevent the default, which opens the files in the browser, and show
|
|
80
|
+
// that they can't be dropped here instead.
|
|
81
|
+
event.preventDefault()
|
|
82
|
+
event.dataTransfer.dropEffect = 'none'
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
#onDrop(event) {
|
|
88
|
+
if (isDraggingFiles(event)) {
|
|
89
|
+
this.#enteredElementCount = 0
|
|
90
|
+
this.#isDraggingFiles.value = false
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isDraggingFiles(event) {
|
|
96
|
+
return !!event.dataTransfer?.types?.includes('Files')
|
|
97
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { markRaw, reactive } from 'vue'
|
|
2
|
+
|
|
3
|
+
// The stores of the items of a source, see `DitoMixin.getStore()`, keyed by
|
|
4
|
+
// the uids of the items, see `getItemUid()`, so that each item keeps its store
|
|
5
|
+
// when items are inserted, moved or removed. They are kept in the store of the
|
|
6
|
+
// source, so that they live as long as it.
|
|
7
|
+
export class ItemStores {
|
|
8
|
+
#storesByUid = new Map()
|
|
9
|
+
|
|
10
|
+
// Returns the item stores kept in `store`, the store of a source.
|
|
11
|
+
static getFromStore(store) {
|
|
12
|
+
store.$itemStores ??= markRaw(new ItemStores())
|
|
13
|
+
return store.$itemStores
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Returns the store of the item with `uid`, created on first access. This
|
|
17
|
+
// happens while rendering the item, which is safe as the map of the stores
|
|
18
|
+
// isn't reactive, only the stores themselves are.
|
|
19
|
+
getStore(uid) {
|
|
20
|
+
let store = this.#storesByUid.get(uid)
|
|
21
|
+
if (!store) {
|
|
22
|
+
store = reactive({})
|
|
23
|
+
this.#storesByUid.set(uid, store)
|
|
24
|
+
}
|
|
25
|
+
return store
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
removeStore(uid) {
|
|
29
|
+
this.#storesByUid.delete(uid)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { pickBy } from '@ditojs/utils'
|
|
2
|
+
import { formatQuery } from './route.js'
|
|
3
|
+
|
|
4
|
+
// The query of a source that loads its list through its resource, e.g. its
|
|
5
|
+
// scope, page, order and filters, see `ResourceMixin.queryParams`. The route
|
|
6
|
+
// holds the query, so that the list can be linked and reloaded, and the store
|
|
7
|
+
// of the source remembers it, so that the list shows it again when navigating
|
|
8
|
+
// back to it, see `DitoMixin.getStore()`. All changes of the query go through
|
|
9
|
+
// the router, and the source loads its list when the route query changes, see
|
|
10
|
+
// the `$route` watcher in `SourceMixin`.
|
|
11
|
+
export class ListQuery {
|
|
12
|
+
constructor({ router, getRoute, getSourceStore, getDefaultQuery }) {
|
|
13
|
+
this.router = router
|
|
14
|
+
this.getRoute = getRoute
|
|
15
|
+
this.getSourceStore = getSourceStore
|
|
16
|
+
this.getDefaultQuery = getDefaultQuery
|
|
17
|
+
// The navigation of the last `update()` while it is pending, with the
|
|
18
|
+
// query that it navigates to, see `whenNavigated()`.
|
|
19
|
+
this.pendingNavigation = null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The stored query, with the defaults of the source for missing values.
|
|
23
|
+
get query() {
|
|
24
|
+
return this.withDefaults(this.getSourceStore().query)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Navigates to the query with `changes`, e.g. `{ scope: 'published' }`.
|
|
28
|
+
// With `resetPage`, a set page is reset to the first one if the changes
|
|
29
|
+
// change the query, as the list then has other pages.
|
|
30
|
+
update(changes, { resetPage = false } = {}) {
|
|
31
|
+
const { query } = this
|
|
32
|
+
const changedQuery = removeEmptyValues({ ...query, ...changes })
|
|
33
|
+
if (isSameQuery(changedQuery, query)) {
|
|
34
|
+
return Promise.resolve()
|
|
35
|
+
}
|
|
36
|
+
if (resetPage && changedQuery.page != null) {
|
|
37
|
+
changedQuery.page = 0
|
|
38
|
+
}
|
|
39
|
+
const promise = this.router.push({
|
|
40
|
+
query: changedQuery,
|
|
41
|
+
// Preserve the hash for tabs:
|
|
42
|
+
hash: this.getRoute().hash
|
|
43
|
+
})
|
|
44
|
+
const navigation = { query: changedQuery, promise }
|
|
45
|
+
this.pendingNavigation = navigation
|
|
46
|
+
const settle = () => {
|
|
47
|
+
if (this.pendingNavigation === navigation) {
|
|
48
|
+
this.pendingNavigation = null
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
promise.then(settle, settle)
|
|
52
|
+
return promise
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Returns `null` if no navigation of `update()` is pending, or the route
|
|
56
|
+
// already holds its query. Otherwise returns a promise that resolves once
|
|
57
|
+
// it settled, to whether the route then holds its query, as the navigation
|
|
58
|
+
// may fail or be superseded, e.g. by a navigation guard.
|
|
59
|
+
whenNavigated() {
|
|
60
|
+
const navigation = this.pendingNavigation
|
|
61
|
+
if (!navigation || this.isRouteQuery(navigation.query)) {
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
const hasNavigated = () => this.isRouteQuery(navigation.query)
|
|
65
|
+
return navigation.promise.then(hasNavigated, hasNavigated)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
isRouteQuery(query) {
|
|
69
|
+
return isSameQuery(query, this.getRoute().query)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Merges the stored query with the route query, so that the list shows the
|
|
73
|
+
// same query again when navigating back to it, while the route query can
|
|
74
|
+
// override it, e.g. through links. The route query is replaced with the
|
|
75
|
+
// merged query, so that it holds the defaults and the stored values too.
|
|
76
|
+
syncWithRoute() {
|
|
77
|
+
const route = this.getRoute()
|
|
78
|
+
const query = this.withDefaults({
|
|
79
|
+
...this.getSourceStore().query,
|
|
80
|
+
...route.query
|
|
81
|
+
})
|
|
82
|
+
this.getSourceStore().query = query
|
|
83
|
+
if (!isSameQuery(query, route.query)) {
|
|
84
|
+
this.router.replace({ query, hash: route.hash }).catch(console.error)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Stores the query of the route after it changed, and returns whether it
|
|
89
|
+
// changed the stored query, which the source then needs to load.
|
|
90
|
+
setFromRoute(routeQuery) {
|
|
91
|
+
const query = this.withDefaults(routeQuery)
|
|
92
|
+
const hasChanged = !isSameQuery(query, this.query)
|
|
93
|
+
this.getSourceStore().query = query
|
|
94
|
+
return hasChanged
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
withDefaults(query) {
|
|
98
|
+
return removeEmptyValues({ ...this.getDefaultQuery(), ...query })
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function removeEmptyValues(query) {
|
|
103
|
+
return pickBy(query, value => value != null)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Compares queries like the route does, where all values are strings, and
|
|
107
|
+
// regardless of the order of their keys.
|
|
108
|
+
function isSameQuery(query1, query2) {
|
|
109
|
+
const getSearchParams = query => {
|
|
110
|
+
const searchParams = new URLSearchParams(formatQuery(query))
|
|
111
|
+
searchParams.sort()
|
|
112
|
+
return searchParams.toString()
|
|
113
|
+
}
|
|
114
|
+
return getSearchParams(query1) === getSearchParams(query2)
|
|
115
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { shallowRef } from 'vue'
|
|
2
|
+
|
|
3
|
+
// LoadingTracker counts the pending operations of a scope, e.g. the requests of
|
|
4
|
+
// a resource component, of all components in a view, or of the whole admin.
|
|
5
|
+
// Each operation is forwarded to the tracker of the enclosing scope, so that a
|
|
6
|
+
// scope is loading as long as any operation within it is pending, regardless
|
|
7
|
+
// of the order in which they finish. `isLoading` is reactive.
|
|
8
|
+
|
|
9
|
+
export class LoadingTracker {
|
|
10
|
+
#pendingCount = shallowRef(0)
|
|
11
|
+
|
|
12
|
+
constructor(parent = null) {
|
|
13
|
+
this.parent = parent
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
get isLoading() {
|
|
17
|
+
return this.#pendingCount.value > 0
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// The tracker of the outermost scope, e.g. to show that something is loading
|
|
21
|
+
// without disabling the scopes in between.
|
|
22
|
+
get root() {
|
|
23
|
+
return this.parent?.root ?? this
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Begins an operation in this scope and its enclosing ones, and returns the
|
|
27
|
+
// function that ends it. Ending an operation more than once has no effect.
|
|
28
|
+
begin() {
|
|
29
|
+
this.#pendingCount.value++
|
|
30
|
+
const endInParent = this.parent?.begin()
|
|
31
|
+
let isEnded = false
|
|
32
|
+
return () => {
|
|
33
|
+
if (!isEnded) {
|
|
34
|
+
isEnded = true
|
|
35
|
+
this.#pendingCount.value--
|
|
36
|
+
endInParent?.()
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Tracks the operation that `callback()` performs until its returned promise
|
|
42
|
+
// settles, and returns its result.
|
|
43
|
+
async track(callback) {
|
|
44
|
+
const end = this.begin()
|
|
45
|
+
try {
|
|
46
|
+
return await callback()
|
|
47
|
+
} finally {
|
|
48
|
+
end()
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A loading operation that is switched on and off instead of begun and ended,
|
|
54
|
+
// for the deprecated `setLoading(isLoading)` methods. Switching it on while it
|
|
55
|
+
// is on has no effect, as does switching it off while it is off.
|
|
56
|
+
export class LoadingSwitch {
|
|
57
|
+
#tracker
|
|
58
|
+
#endOperation = null
|
|
59
|
+
|
|
60
|
+
constructor(tracker) {
|
|
61
|
+
this.#tracker = tracker
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get isOn() {
|
|
65
|
+
return !!this.#endOperation
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
set(isOn) {
|
|
69
|
+
if (isOn) {
|
|
70
|
+
this.#endOperation ??= this.#tracker.begin()
|
|
71
|
+
} else {
|
|
72
|
+
this.#endOperation?.()
|
|
73
|
+
this.#endOperation = null
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/utils/SchemaGraph.js
CHANGED
|
@@ -113,9 +113,10 @@ export class SchemaGraph {
|
|
|
113
113
|
let id = value?.[idKey]
|
|
114
114
|
if (id != null) {
|
|
115
115
|
if (removeId) {
|
|
116
|
+
// Unrelated sources don't need their ids on the clipboard, not
|
|
117
|
+
// even temporary ones, since nothing references them.
|
|
116
118
|
delete value[idKey]
|
|
117
|
-
}
|
|
118
|
-
if (referenceId || isTemporaryId(id)) {
|
|
119
|
+
} else if (referenceId || isTemporaryId(id)) {
|
|
119
120
|
if (isTemporaryId(id)) {
|
|
120
121
|
id = id.slice(1)
|
|
121
122
|
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import appState from '../appState.js'
|
|
2
|
+
import DitoUser from '../DitoUser.js'
|
|
3
|
+
|
|
4
|
+
// Session holds the logged in user of the admin, see `user`, and logs users in
|
|
5
|
+
// and out through the `api.users` resources. The views depend on the user, so
|
|
6
|
+
// the session resolves them for each login and clears them once the user is
|
|
7
|
+
// logged out, see `ViewRegistry`.
|
|
8
|
+
//
|
|
9
|
+
// Logging in asks for the credentials through the user interface that the
|
|
10
|
+
// admin's root component attaches, see `attachUserInterface()`, which also
|
|
11
|
+
// notifies of authentication errors.
|
|
12
|
+
|
|
13
|
+
export class Session {
|
|
14
|
+
#api
|
|
15
|
+
#viewRegistry
|
|
16
|
+
#redirectAfterLogin
|
|
17
|
+
#userInterface = null
|
|
18
|
+
|
|
19
|
+
constructor({ api, viewRegistry, redirectAfterLogin = null }) {
|
|
20
|
+
this.#api = api
|
|
21
|
+
this.#viewRegistry = viewRegistry
|
|
22
|
+
this.#redirectAfterLogin = redirectAfterLogin
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The logged in user, or `null`. Reactive, as it's kept in the app state.
|
|
26
|
+
get user() {
|
|
27
|
+
return appState.user
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Attaches the user interface of the session:
|
|
31
|
+
// - `requestLoginData()` asks for the credentials, and returns them, or
|
|
32
|
+
// `null` if the user cancels.
|
|
33
|
+
// - `notify(options)` shows a notification.
|
|
34
|
+
// Returns the function that detaches it again.
|
|
35
|
+
attachUserInterface({ requestLoginData, notify }) {
|
|
36
|
+
const userInterface = { requestLoginData, notify }
|
|
37
|
+
this.#userInterface = userInterface
|
|
38
|
+
return () => {
|
|
39
|
+
if (this.#userInterface === userInterface) {
|
|
40
|
+
this.#userInterface = null
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Starts the session with the user of the server's session if there is one,
|
|
46
|
+
// or else asks to log in.
|
|
47
|
+
async start() {
|
|
48
|
+
if (await this.fetchUser()) {
|
|
49
|
+
await this.#resolveViews()
|
|
50
|
+
} else {
|
|
51
|
+
await this.login()
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Asks for the credentials and logs in with them, again after each failure,
|
|
56
|
+
// until it succeeds or the user cancels. Returns the user, or `null`.
|
|
57
|
+
async login() {
|
|
58
|
+
const loginData = await this.#userInterface?.requestLoginData()
|
|
59
|
+
if (!loginData) {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
let user
|
|
63
|
+
try {
|
|
64
|
+
const response = await this.#request(this.#api.users.login, loginData)
|
|
65
|
+
user = response.data.user
|
|
66
|
+
} catch (error) {
|
|
67
|
+
this.#notifyError(error)
|
|
68
|
+
return this.login()
|
|
69
|
+
}
|
|
70
|
+
if (this.#redirectAfterLogin) {
|
|
71
|
+
location.replace(this.#redirectAfterLogin)
|
|
72
|
+
} else {
|
|
73
|
+
this.#setUser(user)
|
|
74
|
+
await this.#resolveViews()
|
|
75
|
+
}
|
|
76
|
+
return user
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async logout() {
|
|
80
|
+
try {
|
|
81
|
+
const response = await this.#request(this.#api.users.logout)
|
|
82
|
+
if (response.data.success) {
|
|
83
|
+
this.#setUser(null)
|
|
84
|
+
}
|
|
85
|
+
} catch (error) {
|
|
86
|
+
console.error(error)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Fetches the user of the server's session, and returns it, or `null` if
|
|
91
|
+
// there is none, e.g. as the session expired.
|
|
92
|
+
async fetchUser() {
|
|
93
|
+
let user = null
|
|
94
|
+
try {
|
|
95
|
+
const response = await this.#request(this.#api.users.session)
|
|
96
|
+
user = response.data.user || null
|
|
97
|
+
} catch (error) {
|
|
98
|
+
this.#notifyError(error)
|
|
99
|
+
}
|
|
100
|
+
this.#setUser(user)
|
|
101
|
+
return user
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Makes sure that a user is logged in before requesting the API, and asks to
|
|
105
|
+
// log in if the session expired.
|
|
106
|
+
async ensureUser() {
|
|
107
|
+
if (!(await this.fetchUser())) {
|
|
108
|
+
await this.login()
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#setUser(user) {
|
|
113
|
+
// Responses loaded with `cache: 'global'` belong to the user that loaded
|
|
114
|
+
// them, so they are dropped when the user logs out or another one logs in,
|
|
115
|
+
// but kept when the same user is fetched again, see `ensureUser()`:
|
|
116
|
+
if (!user || user.id !== appState.user?.id) {
|
|
117
|
+
appState.loadCache = {}
|
|
118
|
+
}
|
|
119
|
+
appState.user = user && Object.setPrototypeOf(user, DitoUser.prototype)
|
|
120
|
+
if (!user) {
|
|
121
|
+
this.#viewRegistry.clear()
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Resolves the views for the logged in user, and asks to log in again if
|
|
126
|
+
// they can't be resolved, e.g. as the API refuses the user.
|
|
127
|
+
async #resolveViews() {
|
|
128
|
+
if (!(await this.#viewRegistry.resolve())) {
|
|
129
|
+
await this.login()
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
#request(resource, data = null) {
|
|
134
|
+
const url = this.#api.getApiUrl({ url: this.#api.resources.any(resource) })
|
|
135
|
+
return this.#api.request({ method: resource.method, url, data })
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#notifyError(error) {
|
|
139
|
+
const responseError = error.response?.data?.error || error
|
|
140
|
+
if (this.#userInterface) {
|
|
141
|
+
this.#userInterface.notify({
|
|
142
|
+
type: 'error',
|
|
143
|
+
error: responseError,
|
|
144
|
+
title: 'Authentication Error',
|
|
145
|
+
text: responseError
|
|
146
|
+
})
|
|
147
|
+
} else {
|
|
148
|
+
console.error(responseError)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { shallowRef } from 'vue'
|
|
2
|
+
import { mapConcurrently } from '@ditojs/utils'
|
|
3
|
+
import { resolveViews, setupView } from './schema/setup.js'
|
|
4
|
+
|
|
5
|
+
// ViewRegistry resolves the schemas of the admin's views, sets up their routes,
|
|
6
|
+
// and adds them to the router, replacing the routes of the previous resolve.
|
|
7
|
+
// `Session` resolves the views anew for each login and clears them once the
|
|
8
|
+
// user is logged out, as the schemas may depend on the user, e.g. on functions
|
|
9
|
+
// or modules that request the API. `views` and `hasRoutes` are reactive.
|
|
10
|
+
|
|
11
|
+
export class ViewRegistry {
|
|
12
|
+
#api
|
|
13
|
+
#router
|
|
14
|
+
#unresolvedViews
|
|
15
|
+
#viewComponent
|
|
16
|
+
#views = shallowRef({})
|
|
17
|
+
#removeRoutes = shallowRef(null)
|
|
18
|
+
|
|
19
|
+
constructor({ api, router, unresolvedViews, viewComponent }) {
|
|
20
|
+
this.#api = api
|
|
21
|
+
this.#router = router
|
|
22
|
+
this.#unresolvedViews = unresolvedViews
|
|
23
|
+
this.#viewComponent = viewComponent
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The resolved views, provided as `$views`, or an empty object until they
|
|
27
|
+
// are resolved and once they are cleared.
|
|
28
|
+
get views() {
|
|
29
|
+
return this.#views.value
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Whether the routes of the views were added to the router.
|
|
33
|
+
get hasRoutes() {
|
|
34
|
+
return !!this.#removeRoutes.value
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Resolves the views and replaces the routes of the previous resolve with
|
|
38
|
+
// theirs, keeping the current path. Returns `false` if the views couldn't be
|
|
39
|
+
// resolved, e.g. because the API refused a request of their schemas.
|
|
40
|
+
async resolve() {
|
|
41
|
+
let views
|
|
42
|
+
try {
|
|
43
|
+
views = await resolveViews(this.#unresolvedViews)
|
|
44
|
+
} catch (error) {
|
|
45
|
+
console.error(error)
|
|
46
|
+
return false
|
|
47
|
+
}
|
|
48
|
+
const routes = await mapConcurrently(
|
|
49
|
+
Object.entries(views),
|
|
50
|
+
([name, schema]) =>
|
|
51
|
+
setupView(this.#viewComponent, this.#api, schema, name)
|
|
52
|
+
)
|
|
53
|
+
// Only provide the views once they are set up, as `DitoMenu` links to
|
|
54
|
+
// their paths.
|
|
55
|
+
this.#views.value = views
|
|
56
|
+
const { fullPath } = this.#router.currentRoute.value
|
|
57
|
+
this.#removeRoutes.value?.()
|
|
58
|
+
this.#removeRoutes.value = addRoutes(this.#router, [
|
|
59
|
+
{
|
|
60
|
+
name: 'root',
|
|
61
|
+
path: '/',
|
|
62
|
+
components: {}
|
|
63
|
+
},
|
|
64
|
+
...routes.flat()
|
|
65
|
+
])
|
|
66
|
+
this.#router.replace(fullPath)
|
|
67
|
+
return true
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Clears the views, e.g. once the user is logged out, and navigates home.
|
|
71
|
+
// Their routes stay until the views are resolved again.
|
|
72
|
+
clear() {
|
|
73
|
+
this.#views.value = {}
|
|
74
|
+
return this.#router.push('/')
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function addRoutes(router, routes) {
|
|
79
|
+
const removers = routes.map(route => router.addRoute(route))
|
|
80
|
+
return () => {
|
|
81
|
+
for (const remove of removers) {
|
|
82
|
+
remove()
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
package/src/utils/accessor.js
CHANGED
|
@@ -5,6 +5,14 @@ import {
|
|
|
5
5
|
normalizeDataPath
|
|
6
6
|
} from '@ditojs/utils'
|
|
7
7
|
|
|
8
|
+
// Returns an accessor for computed properties that reads `keyOrDataPath` from
|
|
9
|
+
// the schema, which can be overridden by setting the property. `get(value)`
|
|
10
|
+
// can process the schema value further. Getters only receive the value if
|
|
11
|
+
// they declare it as a parameter, as told by their `length`, so that getters
|
|
12
|
+
// that don't need it don't evaluate the schema value. NOTE: Parameters with
|
|
13
|
+
// default values and rest parameters don't count towards the `length`, so
|
|
14
|
+
// getters need to declare the value as a plain parameter, e.g. `get(value)`
|
|
15
|
+
// and not `get(value = null)`.
|
|
8
16
|
export function getSchemaAccessor(
|
|
9
17
|
keyOrDataPath,
|
|
10
18
|
{ type, default: def, get, set, callback = true } = {}
|
|
@@ -47,6 +55,10 @@ export function getSchemaAccessor(
|
|
|
47
55
|
}
|
|
48
56
|
}
|
|
49
57
|
|
|
58
|
+
// Returns an accessor for computed properties that reads and writes `name` in
|
|
59
|
+
// the component's store, see `DitoMixin.getStore()`. The default value is
|
|
60
|
+
// returned for missing values without storing it, as getters shouldn't change
|
|
61
|
+
// state. Only values that are set are stored.
|
|
50
62
|
export function getStoreAccessor(name, { default: def, get, set } = {}) {
|
|
51
63
|
return {
|
|
52
64
|
get() {
|
|
@@ -54,10 +66,6 @@ export function getStoreAccessor(name, { default: def, get, set } = {}) {
|
|
|
54
66
|
if (value === undefined && def !== undefined) {
|
|
55
67
|
// Support `default()` functions:
|
|
56
68
|
value = isFunction(def) ? def.call(this) : def
|
|
57
|
-
// Trigger setter by setting value and accessor to default:
|
|
58
|
-
this[name] = value
|
|
59
|
-
// Now access store again, for reactivity tracking
|
|
60
|
-
this.getStore(name)
|
|
61
69
|
}
|
|
62
70
|
// Allow the provided getter to further change or process the value
|
|
63
71
|
// retrieved from the store:
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Returns the props of the `DitoInputAffixes` of the input of a type
|
|
2
|
+
// component, by position: The prefix and suffix items come from
|
|
3
|
+
// `schema.prefix` and `schema.suffix`. The suffix also holds the clear button
|
|
4
|
+
// of clearable components with a value, and their info when they have no label
|
|
5
|
+
// to show it, as the label component shows the info otherwise.
|
|
6
|
+
export function getInputAffixesProps({
|
|
7
|
+
schema,
|
|
8
|
+
disabled,
|
|
9
|
+
clearable,
|
|
10
|
+
value,
|
|
11
|
+
label,
|
|
12
|
+
info,
|
|
13
|
+
context
|
|
14
|
+
}) {
|
|
15
|
+
return {
|
|
16
|
+
prefix: {
|
|
17
|
+
items: schema.prefix ?? null,
|
|
18
|
+
disabled,
|
|
19
|
+
clearable: false,
|
|
20
|
+
hasValue: false,
|
|
21
|
+
inlineInfo: null,
|
|
22
|
+
parentContext: context
|
|
23
|
+
},
|
|
24
|
+
suffix: {
|
|
25
|
+
items: schema.suffix ?? null,
|
|
26
|
+
disabled,
|
|
27
|
+
clearable: !!clearable,
|
|
28
|
+
hasValue: value != null,
|
|
29
|
+
inlineInfo: (!label && info) || null,
|
|
30
|
+
parentContext: context
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Emits the change of a value that the user edits only once the editing is
|
|
2
|
+
// done, e.g. once the input lost its focus, and not on every edit while it is
|
|
3
|
+
// still ongoing. Values changed by code don't emit change events, as only the
|
|
4
|
+
// edits marked through `markEdited()` count.
|
|
5
|
+
// `isEditing()` returns whether the editing is still ongoing, and
|
|
6
|
+
// `emitChange()` emits the change event.
|
|
7
|
+
export function createChangeOnceEdited({ isEditing, emitChange }) {
|
|
8
|
+
// Whether the value was edited since the last change event. Not reactive,
|
|
9
|
+
// as nothing renders it.
|
|
10
|
+
let isEdited = false
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
// Marks the value as edited, to emit its change once the editing is done.
|
|
14
|
+
markEdited() {
|
|
15
|
+
isEdited = true
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
// Emits the change if the value was edited and the editing is done. Call
|
|
19
|
+
// it after each edit and whenever the editing may have ended, e.g. on blur.
|
|
20
|
+
emitIfDone() {
|
|
21
|
+
if (isEdited && !isEditing()) {
|
|
22
|
+
isEdited = false
|
|
23
|
+
emitChange()
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
// Forgets the edits, e.g. when they are replaced by a value that emits its
|
|
28
|
+
// own change event.
|
|
29
|
+
reset() {
|
|
30
|
+
isEdited = false
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|