@ditojs/admin 3.2.7 → 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 +6011 -5826
  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 +24 -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
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }