@ditojs/admin 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ditojs/admin",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "type": "module",
5
5
  "description": "Dito.js Admin is a schema based admin interface for Dito.js Server, featuring auto-generated views and forms and built with Vue.js",
6
6
  "repository": "https://github.com/ditojs/dito/tree/main/packages/admin",
@@ -22,7 +22,9 @@
22
22
  "files": [
23
23
  "src/",
24
24
  "dist/",
25
- "types/"
25
+ "types/",
26
+ "!src/**/*.test.js",
27
+ "!src/**/__snapshots__/"
26
28
  ],
27
29
  "scripts": {
28
30
  "build": "vite build",
@@ -39,8 +41,8 @@
39
41
  "not ie_mob > 0"
40
42
  ],
41
43
  "dependencies": {
42
- "@ditojs/ui": "^3.0.0",
43
- "@ditojs/utils": "^3.0.0"
44
+ "@ditojs/ui": "^3.1.0",
45
+ "@ditojs/utils": "^3.1.0"
44
46
  },
45
47
  "peerDependencies": {
46
48
  "@kyvg/vue3-notification": "^3.4.2",
@@ -85,7 +87,7 @@
85
87
  "vue-upload-component": "^3.2.2"
86
88
  },
87
89
  "devDependencies": {
88
- "@ditojs/build": "^3.0.0",
90
+ "@ditojs/build": "^3.1.0",
89
91
  "@kyvg/vue3-notification": "^3.4.2",
90
92
  "@tiptap/core": "^3.31.4",
91
93
  "@tiptap/extension-blockquote": "^3.31.4",
@@ -129,5 +131,5 @@
129
131
  "vue-router": "^5.3.1",
130
132
  "vue-upload-component": "^3.2.2"
131
133
  },
132
- "gitHead": "399f683a6ca227a03cfc0978fee9055383e6b3d8"
134
+ "gitHead": "97314265c5cbac6a248d7350f43fea8ea92b995f"
133
135
  }
package/src/DitoAdmin.js CHANGED
@@ -3,7 +3,6 @@ import { createRouter, createWebHistory } from 'vue-router'
3
3
  import VueNotifications from '@kyvg/vue3-notification'
4
4
  import {
5
5
  isString,
6
- isAbsoluteUrl,
7
6
  assignDeeply,
8
7
  hyphenate,
9
8
  camelize,
@@ -15,7 +14,7 @@ import DitoRoot from './components/DitoRoot.vue'
15
14
  import DitoTypeComponent from './DitoTypeComponent.js'
16
15
  import ResizeDirective from './directives/resize.js'
17
16
  import { getResource } from './utils/resource.js'
18
- import { formatQuery } from './utils/route.js'
17
+ import { request, getApiUrl, isApiUrl } from './utils/request.js'
19
18
  import verbs from './verbs.js'
20
19
 
21
20
  export default class DitoAdmin {
@@ -228,66 +227,3 @@ export default class DitoAdmin {
228
227
  return DitoTypeComponent.register(type, options)
229
228
  }
230
229
  }
231
-
232
- class RequestError extends Error {
233
- constructor(response) {
234
- super(
235
- `Request failed with status code: ${response.status} (${
236
- response.statusText
237
- })`
238
- )
239
- this.response = response
240
- }
241
- }
242
-
243
- async function request(api, {
244
- url,
245
- method = 'get',
246
- query = null,
247
- headers = null,
248
- data = null,
249
- signal = null
250
- }) {
251
- const isApiUrl = api.isApiUrl(url)
252
-
253
- const response = await fetch(api.getApiUrl({ url, query }), {
254
- method: method.toUpperCase(),
255
- ...(data && { body: JSON.stringify(data) }),
256
- headers: {
257
- ...(isApiUrl && api.headers),
258
- ...headers
259
- },
260
- credentials:
261
- isApiUrl && api.cors?.credentials
262
- ? 'include'
263
- : 'same-origin',
264
- signal
265
- })
266
-
267
- if (response.headers.get('Content-Type')?.includes('application/json')) {
268
- response.data = await response.json()
269
- }
270
-
271
- if (!response.ok) {
272
- throw new RequestError(response)
273
- }
274
- return response
275
- }
276
-
277
- function isApiUrl(api, url) {
278
- return !isAbsoluteUrl(url) || url.startsWith(api.url)
279
- }
280
-
281
- function getApiUrl(api, { url, query }) {
282
- if (!url.startsWith(api.url) && !isAbsoluteUrl(url)) {
283
- url = combineUrls(api.url, url)
284
- }
285
- // Support optional query parameters, to be are added to the URL.
286
- const search = formatQuery(query)
287
- return search ? `${url}${url.includes('?') ? '&' : '?'}${search}` : url
288
- }
289
-
290
- function combineUrls(baseUrl, relativeUrl) {
291
- // Use same approach as axios `combineURLs()` to join baseUrl & relativeUrl:
292
- return `${baseUrl.replace(/\/+$/, '')}/${relativeUrl.replace(/^\/+/, '')}`
293
- }
@@ -35,6 +35,15 @@ export default {
35
35
 
36
36
  register(types, definition = {}) {
37
37
  types = asArray(types)
38
+ if (hasValidationsMethod(definition)) {
39
+ // Validation doesn't depend on components anymore, so that it also
40
+ // covers fields that aren't rendered, see `utils/schema/validation.js`.
41
+ console.warn(
42
+ `Type '${types[0]}': The \`getValidations()\` method isn't supported ` +
43
+ `anymore, use the static \`getTypeValidations(schema, context)\` ` +
44
+ `option instead.`
45
+ )
46
+ }
38
47
  const component = this.component(
39
48
  `DitoType${camelize(types[0], true)}`,
40
49
  definition
@@ -45,3 +54,11 @@ export default {
45
54
  return component
46
55
  }
47
56
  }
57
+
58
+ // Returns whether the component definition or one of its mixins defines the
59
+ // `getValidations()` method that types used to override.
60
+ function hasValidationsMethod(definition) {
61
+ return [definition, ...(definition.mixins ?? [])].some(
62
+ ({ methods }) => !!methods?.getValidations
63
+ )
64
+ }
@@ -48,10 +48,23 @@ export default DitoComponent.component('DitoButtons', {
48
48
  meta: { type: Object, default: () => ({}) },
49
49
  store: { type: Object, default: () => ({}) },
50
50
  nested: { type: Boolean, default: true },
51
- disabled: { type: Boolean, default: false }
51
+ disabled: { type: Boolean, default: false },
52
+ // The name of the group of buttons, e.g. `panelButtons`, see
53
+ // `componentPath`.
54
+ buttonsName: { type: String, default: 'buttons' }
52
55
  },
53
56
 
54
57
  computed: {
58
+ componentPath() {
59
+ // Add the name of the group, so that buttons don't share their component
60
+ // paths with components of the same name, e.g. a `publish` button with a
61
+ // `publish` field, as component paths are used for registries.
62
+ return appendDataPath(
63
+ this.continueParentComponentPath(),
64
+ `$${this.buttonsName}`
65
+ )
66
+ },
67
+
55
68
  buttonSchemas() {
56
69
  // Compute a buttons list which has the dataPath baked into its keys.
57
70
  const { dataPath, buttons } = this
@@ -179,7 +179,7 @@ export default DitoComponent.component('DitoDialog', {
179
179
 
180
180
  async submit() {
181
181
  // Wait for computed values that depend on pending loads, e.g. options.
182
- await this.formModel.waitForPendingLoads()
182
+ await this.waitForPendingLoadsOf(this.formModel)
183
183
  this.resolve(this.dialogData)
184
184
  },
185
185
 
@@ -5,16 +5,11 @@
5
5
  )
6
6
  //- Only render a router-view here if this isn't the last data route and not a
7
7
  //- nested form route, which will appear elsewhere in its own view.
8
- RouterView(
8
+ DitoRouterView(
9
9
  v-if="!isLastUnnestedRoute && !isNestedRoute"
10
10
  v-show="!isActiveRoute"
11
- v-slot="{ Component, route }"
11
+ :routeLevel="routeLevel + 1"
12
12
  )
13
- component(
14
- v-if="Component"
15
- :is="Component"
16
- :key="getRouteComponentKey(route, routeLevel + 1)"
17
- )
18
13
  //- NOTE: Nested form components are kept alive by using `v-show` instead of
19
14
  //- `v-if` here, so event handling and other things still work with nested
20
15
  //- editing.
@@ -467,7 +462,7 @@ export default DitoComponent.component('DitoForm', {
467
462
 
468
463
  async submit(button, { validate = true, closeForm = false } = {}) {
469
464
  // Wait for computed values that depend on pending loads, e.g. options.
470
- await this.formModel.waitForPendingLoads()
465
+ await this.waitForPendingLoadsOf(this.formModel)
471
466
  if (validate && !this.validateAll()) {
472
467
  return false
473
468
  }
@@ -25,6 +25,7 @@ component.dito-panel(
25
25
  span {{ getLabel(schema) }}
26
26
  DitoButtons.dito-buttons--small(
27
27
  :buttons="panelButtonSchemas"
28
+ buttonsName="panelButtons"
28
29
  :dataPath="panelDataPath"
29
30
  :data="panelData"
30
31
  :meta="meta"
@@ -70,7 +71,7 @@ export default DitoComponent.component('DitoPanel', {
70
71
  store: { type: Object, required: true },
71
72
  disabled: { type: Boolean, required: true },
72
73
  panelTabComponent: { type: Object, default: null },
73
- // The component path of the panel's entry, see `getPanelEntry()`:
74
+ // The component path of the panel's entry, see `getAllPanelEntries()`:
74
75
  panelComponentPath: { type: String, required: true }
75
76
  },
76
77
 
@@ -83,7 +84,7 @@ export default DitoComponent.component('DitoPanel', {
83
84
  computed: {
84
85
  nested() {
85
86
  // For `ContextMixin`: The panel's address, `dataPath`, points into the
86
- // item that it displays, see `getPanelEntry()`.
87
+ // item that it displays, see `getAllPanelEntries()`.
87
88
  return true
88
89
  },
89
90
 
@@ -28,14 +28,7 @@
28
28
  :spinner="options.spinner"
29
29
  :isLoading="isLoading"
30
30
  )
31
- RouterView(
32
- v-slot="{ Component, route }"
33
- )
34
- component(
35
- v-if="Component"
36
- :is="Component"
37
- :key="getRouteComponentKey(route, 0)"
38
- )
31
+ DitoRouterView(:routeLevel="0")
39
32
  DitoSidebar
40
33
  DitoAccount(
41
34
  v-if="user"
@@ -0,0 +1,27 @@
1
+ <script>
2
+ import { h } from 'vue'
3
+ import { RouterView } from 'vue-router'
4
+ import DitoComponent from '../DitoComponent.js'
5
+
6
+ // Renders the route components of `routeLevel` through `RouterView`, keyed by
7
+ // their route record, so that each record gets its own component instance, as
8
+ // their schema and data belong to it, while the component is reused when only
9
+ // the parameters change, e.g. to edit another item.
10
+ // NOTE: This is a functional component, so that it doesn't become the parent
11
+ // component of the route components, see `DitoMixin.provide()`.
12
+ // @vue/component
13
+ function DitoRouterView({ routeLevel }) {
14
+ return h(RouterView, null, {
15
+ default: ({ Component, route }) =>
16
+ Component
17
+ ? h(Component, { key: route.matched[routeLevel]?.path })
18
+ : null
19
+ })
20
+ }
21
+
22
+ DitoRouterView.props = {
23
+ routeLevel: { type: Number, required: true }
24
+ }
25
+
26
+ export default DitoComponent.component('DitoRouterView', DitoRouterView)
27
+ </script>
@@ -6,15 +6,10 @@ template(
6
6
  //- instead, to nest further route components.
7
7
  //- NOTE: This is different from the handling in DitoForm, where `v-show` is
8
8
  //- used to always render forms even when other nested forms are present.
9
- RouterView(
9
+ DitoRouterView(
10
10
  v-if="!isLastRoute"
11
- v-slot="{ Component, route }"
11
+ :routeLevel="routeLevel + 1"
12
12
  )
13
- component(
14
- v-if="Component"
15
- :is="Component"
16
- :key="getRouteComponentKey(route, routeLevel + 1)"
17
- )
18
13
  .dito-view.dito-scroll-parent(
19
14
  v-else
20
15
  :data-resource="sourceSchema.path"
@@ -24,6 +24,7 @@ export { default as DitoButtons } from './DitoButtons.vue'
24
24
  export { default as DitoEditButtons } from './DitoEditButtons.vue'
25
25
  export { default as DitoCreateButton } from './DitoCreateButton.vue'
26
26
  export { default as DitoClipboard } from './DitoClipboard.vue'
27
+ export { default as DitoRouterView } from './DitoRouterView.vue'
27
28
  export { default as DitoView } from './DitoView.vue'
28
29
  export { default as DitoForm } from './DitoForm.vue'
29
30
  export { default as DitoFormInner } from './DitoFormInner.vue'
@@ -107,18 +107,11 @@ export default {
107
107
  // is used for registries and DOM ids. Components continue the path of
108
108
  // their parent with the item indices that their data path adds to the
109
109
  // parent's, e.g. inlined list items. `DitoContainer`, `DitoPane` and
110
- // `DitoPanel` add the names of components, tabs and panels, `DitoForm`
111
- // and `DitoTreeItem` continue the path of the source of their item.
110
+ // `DitoPanel` add the names of components, tabs and panels, `DitoButtons`
111
+ // the names of button groups, `DitoForm` and `DitoTreeItem` continue the
112
+ // path of the source of their item.
112
113
  componentPath() {
113
- const { parentComponent } = this
114
- const parentComponentPath = parentComponent?.componentPath ?? ''
115
- const relativeDataPath = getRelativeDataPath(
116
- this.dataPath,
117
- parentComponent?.dataPathForComponentPath
118
- )
119
- return relativeDataPath
120
- ? appendDataPath(parentComponentPath, relativeDataPath)
121
- : parentComponentPath
114
+ return this.continueParentComponentPath()
122
115
  },
123
116
 
124
117
  // The data path that `componentPath` corresponds to, which children
@@ -478,12 +471,30 @@ export default {
478
471
  body.removeChild(a)
479
472
  },
480
473
 
481
- // Returns the key of the route component that `RouterView` renders at
482
- // `routeLevel`, so that each route record gets its own component instance,
483
- // as their schema and data belong to it, while the component is reused
484
- // when only the parameters change, e.g. to edit another item.
485
- getRouteComponentKey(route, routeLevel) {
486
- return route.matched[routeLevel]?.path
474
+ // Returns the component path of the parent, continued with the item
475
+ // indices that the data path of this component adds to the parent's, see
476
+ // `componentPath`.
477
+ continueParentComponentPath() {
478
+ const { parentComponent } = this
479
+ const parentComponentPath = parentComponent?.componentPath ?? ''
480
+ const relativeDataPath = getRelativeDataPath(
481
+ this.dataPath,
482
+ parentComponent?.dataPathForComponentPath
483
+ )
484
+ return relativeDataPath
485
+ ? appendDataPath(parentComponentPath, relativeDataPath)
486
+ : parentComponentPath
487
+ },
488
+
489
+ // Waits for the pending loads of the form model, e.g. before submitting,
490
+ // showing the spinner in the header in the meantime, like requests do.
491
+ async waitForPendingLoadsOf(formModel) {
492
+ this.rootComponent.registerLoading(true)
493
+ try {
494
+ await formModel.waitForPendingLoads()
495
+ } finally {
496
+ this.rootComponent.registerLoading(false)
497
+ }
487
498
  },
488
499
 
489
500
  notify(options) {
@@ -82,13 +82,17 @@ export default {
82
82
  return this.getOptionForValue(this.selectedValue)
83
83
  },
84
84
 
85
+ // The form model of the data, owned by the dialog or route component.
86
+ formModel() {
87
+ return (this.dialogComponent ?? this.routeComponent).formModel
88
+ },
89
+
85
90
  // The resolver of the options in the form model of the data, which loads
86
91
  // them and shares them with the computes of the form. It is looked up with
87
92
  // the entry of this component, in the shape of the entries of
88
93
  // `processSchemaData()`, see `FormModel`.
89
94
  optionsResolver() {
90
- const formModel = (this.dialogComponent ?? this.routeComponent).formModel
91
- return formModel.getOptionsResolver({
95
+ return this.formModel.getOptionsResolver({
92
96
  schema: this.schema,
93
97
  data: this.data,
94
98
  name: this.name,
@@ -243,10 +247,16 @@ export default {
243
247
 
244
248
  // Writes the selected value back into the data, through the setter of
245
249
  // `selectedValue`, which converts it to the value, e.g. a reference id to
246
- // its option with `relate`.
250
+ // its option with `relate`, or a value whose option disappeared to `null`.
251
+ // This is derived from the options, not a change by the user, so it is
252
+ // made as a clean change, which doesn't make the form dirty.
247
253
  replaceValueWithSelectedValue() {
248
254
  const { selectedValue } = this
249
- this.selectedValue = selectedValue
255
+ this.formModel
256
+ .applyCleanChanges(() => {
257
+ this.selectedValue = selectedValue
258
+ })
259
+ .catch(console.error)
250
260
  },
251
261
 
252
262
  // Groups the options by the `groupBy` key, see `groupByLabel` and
@@ -126,7 +126,7 @@ export default {
126
126
  flush: 'sync',
127
127
  handler(routeRecord) {
128
128
  // Components belong to the record that they're rendered for, see
129
- // `getRouteComponentKey()`. Records of other paths, or none, mean that
129
+ // `DitoRouterView`. Records of other paths, or none, mean that
130
130
  // the component is being left, and it keeps its record until it's
131
131
  // unmounted, as its schema and meta are still read in the meantime,
132
132
  // e.g. by the watchers of its form model. Records of the same path
@@ -781,10 +781,22 @@ export default {
781
781
 
782
782
  processValue({ schema, value, dataPath }, graph) {
783
783
  graph.addSource(dataPath, schema)
784
- return value
784
+ // `SchemaGraph.process()` changes the ids of the items in the processed
785
+ // data. The items of sources with forms are processed into copies, but the
786
+ // ones of sources without forms are still the items of the data.
787
+ return hasFormSchema(schema) ? value : copyItems(value)
785
788
  }
786
789
  }
787
790
 
791
+ // Returns shallow copies of the items of a list, or of an object.
792
+ function copyItems(value) {
793
+ return isArray(value)
794
+ ? value.map(item => (isObject(item) ? { ...item } : item))
795
+ : isObject(value)
796
+ ? { ...value }
797
+ : value
798
+ }
799
+
788
800
  function getPathWithParam(path, param) {
789
801
  return param
790
802
  ? path
@@ -81,6 +81,8 @@ export default DitoTypeComponent.register('multiselect', {
81
81
  return {
82
82
  searchedOptions: null,
83
83
  isLoadingSearchedOptions: false,
84
+ // The term of the current search, see `onSearchChange()`:
85
+ currentSearchTerm: null,
84
86
  populate: false
85
87
  }
86
88
  },
@@ -229,34 +231,43 @@ export default DitoTypeComponent.register('multiselect', {
229
231
  },
230
232
 
231
233
  // Returns the options that `searchFilter()` returns for the search term,
232
- // showing the loading state while it's pending, and `null` if it fails.
234
+ // or `null` if it fails. Waits for the options, as the filter receives
235
+ // them, e.g. to search them when the search term is entered while they are
236
+ // still loading.
233
237
  async loadSearchedOptions(searchTerm) {
234
- // Use a timeout to allow already resolved promises to return options
235
- // without showing a loading indicator.
236
- const timer = setTimeout(() => {
237
- this.isLoadingSearchedOptions = true
238
- }, 0)
239
- let options = null
240
238
  try {
241
- options = await this.searchFilter(new DitoContext(this, { searchTerm }))
239
+ await this.optionsResolver.waitForValue()
240
+ return await this.searchFilter(new DitoContext(this, { searchTerm }))
242
241
  } catch (error) {
243
242
  this.addError(error.message || error)
243
+ return null
244
244
  }
245
- clearTimeout(timer)
246
- this.isLoadingSearchedOptions = false
247
- return options
248
245
  },
249
246
 
250
247
  async onSearchChange(searchTerm) {
251
248
  if (this.searchFilter) {
249
+ this.currentSearchTerm = searchTerm
252
250
  if (searchTerm) {
253
251
  // Set `searchedOptions` to an empty array, before it will be
254
252
  // populated asynchronously with the actual results.
255
253
  this.searchedOptions = []
256
- this.searchedOptions = await this.loadSearchedOptions(searchTerm)
254
+ // Use a timeout to allow already resolved promises to return options
255
+ // without showing a loading indicator.
256
+ const timer = setTimeout(() => {
257
+ this.isLoadingSearchedOptions = true
258
+ }, 0)
259
+ const options = await this.loadSearchedOptions(searchTerm)
260
+ clearTimeout(timer)
261
+ // Searches that were replaced by newer ones in the meantime neither
262
+ // show their options nor end the loading state.
263
+ if (searchTerm === this.currentSearchTerm) {
264
+ this.searchedOptions = options
265
+ this.isLoadingSearchedOptions = false
266
+ }
257
267
  } else {
258
268
  // Clear `searchedOptions` when the query is cleared.
259
269
  this.searchedOptions = null
270
+ this.isLoadingSearchedOptions = false
260
271
  }
261
272
  }
262
273
  }
@@ -13,7 +13,8 @@ export default DitoTypeComponent.register('panel', {
13
13
  getPanelSchema(api, schema) {
14
14
  // For a TypePanel, the component schema is also the panel schema. Its name
15
15
  // is added to the panel's data path and component path, see
16
- // `getPanelEntry()`, as unnested components don't add it to data paths.
16
+ // `getAllPanelEntries()`, as unnested components don't add it to data
17
+ // paths.
17
18
  return schema
18
19
  },
19
20
 
@@ -18,14 +18,7 @@
18
18
  v-if="editPath && hasEditableForms"
19
19
  )
20
20
  //- Include a router-view for the optional DitoFormInlined
21
- RouterView(
22
- v-slot="{ Component, route }"
23
- )
24
- component(
25
- v-if="Component"
26
- :is="Component"
27
- :key="getRouteComponentKey(route, routeComponent.routeLevel + 1)"
28
- )
21
+ DitoRouterView(:routeLevel="routeComponent.routeLevel + 1")
29
22
  </template>
30
23
 
31
24
  <script>
@@ -32,6 +32,8 @@ import {
32
32
  export class DataSchemaResolver {
33
33
  // The promise or loading function of the current load, see `startLoad()`:
34
34
  currentPromiseOrLoader = null
35
+ // The promise of the current load, which resolves once it finished:
36
+ currentLoadPromise = null
35
37
  // The promise or loading function of the last finished load, with its value
36
38
  // or error, see `finishLoad()`:
37
39
  lastFinishedLoad = shallowRef({
@@ -68,6 +70,16 @@ export class DataSchemaResolver {
68
70
  )
69
71
  }
70
72
 
73
+ // Returns the value, once the load of it finished if it is pending, also
74
+ // after loads that replace outdated ones, e.g. when the dependencies of the
75
+ // data schema change in the meantime. Returns `undefined` if loading fails.
76
+ async waitForValue() {
77
+ while (this.isLoading) {
78
+ await this.currentLoadPromise
79
+ }
80
+ return this.value
81
+ }
82
+
71
83
  // The error of the last load, if it failed and isn't outdated:
72
84
  get lastLoadError() {
73
85
  const { promiseOrLoader, error } = this.lastFinishedLoad.value
@@ -132,6 +144,7 @@ export class DataSchemaResolver {
132
144
  console.error(error)
133
145
  finishLoad(undefined, error)
134
146
  })
147
+ this.currentLoadPromise = promise
135
148
  this.onLoadStart?.(promise)
136
149
  }
137
150
  }