@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,55 @@
1
+ import { reactive } from 'vue'
2
+
3
+ // The clipboard data that the admin knows of, shared by all clipboard buttons:
4
+ // The data last copied, or read from the system clipboard. Copied data also
5
+ // serves as fallback when the system clipboard can't be read.
6
+ const clipboardState = reactive({
7
+ data: null
8
+ })
9
+
10
+ // Counts the copies and the reads of the system clipboard, so that the result
11
+ // of a read is ignored when a newer copy or read started in the meantime.
12
+ let clipboardVersion = 0
13
+
14
+ // Returns the clipboard data if it was copied from a schema named
15
+ // `schemaName`, without the `$schema` marker, otherwise `null`. Reactive, so
16
+ // it can be used in computed properties.
17
+ export function getClipboardData(schemaName) {
18
+ if (!clipboardState.data) {
19
+ return null
20
+ }
21
+ const { $schema, ...data } = clipboardState.data
22
+ return $schema === schemaName ? data : null
23
+ }
24
+
25
+ export function hasClipboardData(schemaName) {
26
+ return !!getClipboardData(schemaName)
27
+ }
28
+
29
+ // Copies `data` of the schema named `schemaName`, marked with `$schema` so
30
+ // that it can only be pasted into schemas of the same name.
31
+ export async function copyClipboardData(schemaName, data) {
32
+ const clipboardData = data ? { $schema: schemaName, ...data } : null
33
+ clipboardVersion++
34
+ clipboardState.data = clipboardData
35
+ try {
36
+ const json = JSON.stringify(clipboardData, null, 2)
37
+ await navigator.clipboard?.writeText?.(json)
38
+ } catch (error) {
39
+ // The copied data remains available inside the admin.
40
+ console.error(error, error.name, error.message)
41
+ }
42
+ }
43
+
44
+ // Reads the system clipboard into the clipboard state. Throws a `SyntaxError`
45
+ // when its content isn't JSON, and the errors of the Clipboard API, e.g. when
46
+ // reading isn't permitted. In both cases, the state remains unchanged.
47
+ export async function readClipboardData() {
48
+ const version = ++clipboardVersion
49
+ const json = await navigator.clipboard?.readText?.()
50
+ // Ignore the result if a newer copy or read started in the meantime, as it
51
+ // may be outdated, and keep the state if the clipboard is empty.
52
+ if (json && version === clipboardVersion) {
53
+ clipboardState.data = JSON.parse(json)
54
+ }
55
+ }
package/src/utils/data.js CHANGED
@@ -66,23 +66,28 @@ export function parseParentItemDataPath(dataPath, nested = false) {
66
66
  return null
67
67
  }
68
68
 
69
+ // Returns `null` for items without a parent item, e.g. the root item.
69
70
  export function getParentItemDataPath(dataPath, nested = false) {
70
- return normalizeDataPath(parseParentItemDataPath(dataPath, nested))
71
+ const path = parseParentItemDataPath(dataPath, nested)
72
+ return path ? normalizeDataPath(path) : null
71
73
  }
72
74
 
75
+ // Returns `null` if there's no item at the data path, e.g. for removed items.
73
76
  export function getItem(rootItem, dataPath, nested = false) {
74
77
  const path = parseItemDataPath(dataPath, nested)
75
- return path ? getValueAtDataPath(rootItem, path) : null
78
+ return path ? getValueAtDataPath(rootItem, path, () => null) : null
76
79
  }
77
80
 
81
+ // Returns `null` if there's no parent item at the data path.
78
82
  export function getParentItem(rootItem, dataPath, nested = false) {
79
83
  const path = parseParentItemDataPath(dataPath, nested)
80
- return path ? getValueAtDataPath(rootItem, path) : null
84
+ return path ? getValueAtDataPath(rootItem, path, () => null) : null
81
85
  }
82
86
 
83
87
  export function getLastDataPathToken(dataPath) {
84
- const path = parseDataPath(dataPath)
85
- return path[path.length - 1]
88
+ // `dataPath` is `null` for items without a parent, see
89
+ // `getParentItemDataPath()`.
90
+ return parseDataPath(dataPath)?.at(-1)
86
91
  }
87
92
 
88
93
  export function getLastDataPathName(dataPath) {
@@ -0,0 +1,59 @@
1
+ // Confirmations, built on the promise-based and focus-trapped dialogs of
2
+ // `DitoRoot.showDialog()`. `component` is the Dito component that asks, for
3
+ // its `showDialog()`, `notify()` and `verbs`, see `DitoMixin`.
4
+ import { labelize } from '@ditojs/utils'
5
+ import DitoConfirmMessage from '../components/DitoConfirmMessage.vue'
6
+ import { getTextFromHtml } from './html.js'
7
+
8
+ export const transientNote = (
9
+ '<b>Note</b>: the parent still needs to be saved ' +
10
+ 'in order to persist this change.'
11
+ )
12
+
13
+ // Asks the user to confirm the HTML `message` with a button for `verb`, and
14
+ // resolves to whether they did. Cancel comes first, to be focused first.
15
+ export async function confirm(component, { message, verb }) {
16
+ const result = await component.showDialog({
17
+ components: {
18
+ message: {
19
+ type: 'component',
20
+ component: DitoConfirmMessage,
21
+ label: false
22
+ }
23
+ },
24
+ buttons: {
25
+ cancel: {},
26
+ confirm: { type: 'submit', text: labelize(verb) }
27
+ },
28
+ data: { message },
29
+ settings: { label: getTextFromHtml(message) }
30
+ })
31
+ // The dialog resolves with its data when submitted, and with `undefined`
32
+ // when cancelled, see `DitoDialog.cancel()`.
33
+ return result !== undefined
34
+ }
35
+
36
+ // Asks the user to confirm the removal of the item with the HTML `label`, then
37
+ // awaits `remove()`, which returns false if it failed, and notifies of the
38
+ // removal. Transient removals still need the parent to be saved, so they are
39
+ // confirmed with the verbs for removing instead of deleting, and note that.
40
+ export async function confirmAndRemove(
41
+ component,
42
+ { label, isTransient, remove }
43
+ ) {
44
+ const { verbs } = component
45
+ const [verb, pastVerb] = isTransient
46
+ ? [verbs.remove, verbs.removed]
47
+ : [verbs.delete, verbs.deleted]
48
+ const isConfirmed = await confirm(component, {
49
+ message: `Do you really want to ${verb} ${label}?`,
50
+ verb
51
+ })
52
+ if (isConfirmed && (await remove()) !== false) {
53
+ component.notify({
54
+ type: isTransient ? 'info' : 'success',
55
+ title: 'Successfully Removed',
56
+ html: [`${label} was ${pastVerb}.`, isTransient && transientNote]
57
+ })
58
+ }
59
+ }
@@ -0,0 +1,61 @@
1
+ // Resizes the height of `element` through a resize handle, either by dragging
2
+ // it with the pointer or with the arrow keys while it is focused. `onResize()`
3
+ // receives each new height in pixels.
4
+
5
+ // Starts dragging the resize handle from its `pointerdown` event. The pointer
6
+ // is captured by the handle, so that the drag continues outside of it, and the
7
+ // handlers are removed again when the drag ends.
8
+ export function startDragResize(event, { element, onResize }) {
9
+ if (event.button !== 0) return
10
+ const handle = event.currentTarget
11
+ const { pointerId, clientY: startY } = event
12
+ const startHeight = getHeight(element)
13
+ const controller = new AbortController()
14
+ const { signal } = controller
15
+
16
+ const resize = event => {
17
+ if (event.pointerId === pointerId) {
18
+ onResize(Math.max(startHeight + event.clientY - startY, 0))
19
+ }
20
+ }
21
+
22
+ const stop = event => {
23
+ if (event.pointerId === pointerId) {
24
+ controller.abort()
25
+ }
26
+ }
27
+
28
+ handle.setPointerCapture(pointerId)
29
+ handle.addEventListener('pointermove', resize, { signal })
30
+ handle.addEventListener(
31
+ 'pointerup',
32
+ event => {
33
+ resize(event)
34
+ stop(event)
35
+ },
36
+ { signal }
37
+ )
38
+ handle.addEventListener('pointercancel', stop, { signal })
39
+ handle.addEventListener('lostpointercapture', stop, { signal })
40
+ }
41
+
42
+ const resizeDirectionsByKey = {
43
+ ArrowUp: -1,
44
+ ArrowDown: 1
45
+ }
46
+
47
+ // Resizes by one step of the element's font size per `ArrowUp` / `ArrowDown`
48
+ // `keydown` event on the focused resize handle, or by ten steps with Shift.
49
+ export function resizeByArrowKey(event, { element, onResize }) {
50
+ const direction = resizeDirectionsByKey[event.key]
51
+ if (direction) {
52
+ event.preventDefault()
53
+ const fontSize = parseFloat(getComputedStyle(element).fontSize)
54
+ const step = event.shiftKey ? 10 * fontSize : fontSize
55
+ onResize(Math.max(getHeight(element) + direction * step, 0))
56
+ }
57
+ }
58
+
59
+ function getHeight(element) {
60
+ return parseFloat(getComputedStyle(element).height)
61
+ }
@@ -1,4 +1,11 @@
1
- import { isArray, asArray, labelize } from '@ditojs/utils'
1
+ import {
2
+ isArray,
3
+ isString,
4
+ asArray,
5
+ labelize,
6
+ normalizeDataPath,
7
+ parseDataPath
8
+ } from '@ditojs/utils'
2
9
  import { getNamedSchemas } from './schema/lookup.js'
3
10
  import { applyNestedSchemaDefaults } from './schema/setup.js'
4
11
 
@@ -157,6 +164,27 @@ function getDataName(filterName) {
157
164
  return `$${filterName}`
158
165
  }
159
166
 
167
+ // The server keys the validation errors of filters by filter name, e.g.
168
+ // `title/text`, see the server's `models/definitions/filters.js`, while the
169
+ // filters panel keys the filter data by data name, e.g. `$title`. Returns the
170
+ // errors keyed by the data paths of the panel. Paths that already start with a
171
+ // data name, and empty paths, are kept.
172
+ export function getFiltersDataErrors(errors) {
173
+ const dataErrors = {}
174
+ for (const [dataPath, errs] of Object.entries(errors)) {
175
+ const [token, ...rest] = parseDataPath(normalizeDataPath(dataPath))
176
+ const filtersDataPath = [
177
+ !token || token.startsWith('$') ? token : getDataName(token),
178
+ ...rest
179
+ ].join('/')
180
+ dataErrors[filtersDataPath] = [
181
+ ...(dataErrors[filtersDataPath] ?? []),
182
+ ...errs
183
+ ]
184
+ }
185
+ return dataErrors
186
+ }
187
+
160
188
  function getFilterName(dataName) {
161
189
  return dataName.startsWith('$') ? dataName.slice(1) : null
162
190
  }
@@ -240,7 +268,13 @@ function parseFiltersData(schema, query) {
240
268
  // from $route.query back to param lists per filter:
241
269
  if (query) {
242
270
  for (const filter of asArray(query.filter)) {
243
- const [, filterName, json] = filter.match(/^(\w+):(.*)$/)
271
+ // Query values without a value, e.g. `?filter`, are `null`:
272
+ const match = isString(filter) ? filter.match(/^(\w+):(.*)$/) : null
273
+ if (!match) {
274
+ console.warn(`Ignoring malformed filter: '${filter}'`)
275
+ continue
276
+ }
277
+ const [, filterName, json] = match
244
278
  try {
245
279
  filters[filterName] = asArray(JSON.parse(`[${json}]`))
246
280
  } catch {}
@@ -0,0 +1,9 @@
1
+ // Returns the text of `html`, e.g. of labels, which are HTML with escaped item
2
+ // values, see `getItemLabel()`: Unlike `stripHtml()`, this also unescapes the
3
+ // escaped characters. Documents that `DOMParser` creates are inert: they don't
4
+ // run scripts or load images. Returns `null` for `null` or `undefined`.
5
+ export function getTextFromHtml(html) {
6
+ if (html == null) return null
7
+ const { body } = new DOMParser().parseFromString(html, 'text/html')
8
+ return body.textContent.trim()
9
+ }
@@ -0,0 +1,10 @@
1
+ // Returns a copy of `list` with `item` moved by `delta` positions, e.g. by -1
2
+ // to move it up by one, or `null` if `item` isn't in `list` or can't move that
3
+ // far. The item is looked up by identity, see `DitoDragHandle`.
4
+ export function getListWithMovedItem(list, item, delta) {
5
+ const index = list.indexOf(item)
6
+ const newIndex = index + delta
7
+ return index >= 0 && delta !== 0 && newIndex >= 0 && newIndex < list.length
8
+ ? list.toSpliced(index, 1).toSpliced(newIndex, 0, item)
9
+ : null
10
+ }
@@ -33,7 +33,11 @@ const ditoOptionKeys = [
33
33
  'defaultValue',
34
34
  'defaultNested',
35
35
  'defaultVisible',
36
+ 'defaultMultiple',
37
+ 'defaultWidth',
38
+ 'getSourceType',
36
39
  'generateLabel',
40
+ 'rendersOwnLabel',
37
41
  'excludeValue',
38
42
  'ignoreMissingValue',
39
43
  'treatNullAsMissing',
@@ -0,0 +1,18 @@
1
+ // Registers `component` under its `componentPath` by calling
2
+ // `register(add, componentPath)`, and registers it again under the new path
3
+ // whenever its component path changes, e.g. when list items move, see
4
+ // `DitoSchema._registerEntry()`. Returns a function that unregisters it, to be
5
+ // called when the component is unmounted.
6
+ export function trackRegistration(component, register) {
7
+ let registeredComponentPath = component.componentPath
8
+ register(true, registeredComponentPath)
9
+ const unwatch = component.$watch('componentPath', componentPath => {
10
+ register(false, registeredComponentPath)
11
+ registeredComponentPath = componentPath
12
+ register(true, componentPath)
13
+ })
14
+ return () => {
15
+ unwatch()
16
+ register(false, registeredComponentPath)
17
+ }
18
+ }
@@ -21,24 +21,16 @@ export async function request(api, {
21
21
  signal = null,
22
22
  timeout = api.timeout ?? null
23
23
  }) {
24
- const isApiUrl = api.isApiUrl(url)
25
- // Abort requests that take longer than the timeout, if there is one, so that
26
- // nothing waits for them forever, e.g. submitting for pending loads:
27
- const signals = [signal, timeout && AbortSignal.timeout(timeout)]
28
- signal = AbortSignal.any(signals.filter(Boolean))
29
-
24
+ const fetchOptions = getFetchOptions(api, url)
30
25
  const response = await fetch(api.getApiUrl({ url, query }), {
31
26
  method: method.toUpperCase(),
32
27
  ...(data && { body: JSON.stringify(data) }),
33
28
  headers: {
34
- ...(isApiUrl && api.headers),
29
+ ...fetchOptions.headers,
35
30
  ...headers
36
31
  },
37
- credentials:
38
- isApiUrl && api.cors?.credentials
39
- ? 'include'
40
- : 'same-origin',
41
- signal
32
+ credentials: fetchOptions.credentials,
33
+ signal: getRequestSignal({ signal, timeout })
42
34
  })
43
35
 
44
36
  if (response.headers.get('Content-Type')?.includes('application/json')) {
@@ -51,8 +43,111 @@ export async function request(api, {
51
43
  return response
52
44
  }
53
45
 
46
+ // Returns the key under which the `request()` method of components, see
47
+ // `DitoMixin`, caches the response of a request.
48
+ export function getRequestCacheKey({ method, url, query, data }) {
49
+ return [
50
+ method || 'get',
51
+ url,
52
+ JSON.stringify(query || ''),
53
+ JSON.stringify(data || '')
54
+ ].join(' ')
55
+ }
56
+
57
+ // Returns the URL that downloads of `url` fetch and link to: Relative URLs
58
+ // such as `files/1` are resolved against the API, while absolute and
59
+ // root-relative URLs such as `/files/1` stay as they are, unlike in
60
+ // `getApiUrl()`, which prefixes root-relative URLs with the API URL too.
61
+ export function resolveDownloadUrl(api, url) {
62
+ return isAbsoluteUrl(url) || url.startsWith('/')
63
+ ? url
64
+ : api.getApiUrl({ url })
65
+ }
66
+
67
+ // Fetches the file at `url` as a blob, with the same authentication as
68
+ // `request()`, e.g. for downloads of files that the API protects. `url` is
69
+ // resolved through `resolveDownloadUrl()`.
70
+ export async function fetchBlob(api, url) {
71
+ const downloadUrl = resolveDownloadUrl(api, url)
72
+ // Decide on the authentication with the absolute URL, as `isApiUrl()` treats
73
+ // all root-relative URLs as API URLs, the way `getApiUrl()` resolves them.
74
+ // Without `api.timeout`, as it would also cut off downloads of large files
75
+ // that take longer than any request.
76
+ const response = await fetch(
77
+ downloadUrl,
78
+ getFetchOptions(api, getAbsoluteUrl(downloadUrl))
79
+ )
80
+ if (!response.ok) {
81
+ throw new RequestError(response)
82
+ }
83
+ return response.blob()
84
+ }
85
+
86
+ // Returns the options for uploads to `url` through `XMLHttpRequest`, with the
87
+ // same authentication as `request()`.
88
+ export function getUploadOptions(api, url) {
89
+ const { headers, credentials } = getFetchOptions(api, url)
90
+ // Leave out the default JSON `Content-Type`, as uploads send multipart form
91
+ // data, the boundary of which `XMLHttpRequest` needs to set itself.
92
+ return {
93
+ headers: Object.fromEntries(
94
+ Object.entries(headers).filter(
95
+ ([name]) => name.toLowerCase() !== 'content-type'
96
+ )
97
+ ),
98
+ withCredentials: credentials === 'include'
99
+ }
100
+ }
101
+
102
+ // Returns the headers and credentials mode that requests to `url` send: Only
103
+ // requests to the API receive its headers and, with CORS, its credentials.
104
+ function getFetchOptions(api, url) {
105
+ const isApiUrl = api.isApiUrl(url)
106
+ return {
107
+ headers: { ...(isApiUrl && api.headers) },
108
+ credentials:
109
+ isApiUrl && api.cors?.credentials
110
+ ? 'include'
111
+ : 'same-origin'
112
+ }
113
+ }
114
+
115
+ // Returns a signal that aborts with `signal`, and after the timeout if there is
116
+ // one, so that nothing waits for requests forever, e.g. submitting for pending
117
+ // loads.
118
+ function getRequestSignal({ signal = null, timeout = null }) {
119
+ const signals = [signal, timeout && AbortSignal.timeout(timeout)]
120
+ return AbortSignal.any(signals.filter(Boolean))
121
+ }
122
+
123
+ // Returns whether requests to `url` go to the API: Relative and root-relative
124
+ // URLs do, as `getApiUrl()` resolves them against the API, and absolute URLs
125
+ // do if they are under the API URL, compared in their absolute forms so that
126
+ // a root-relative API URL such as `/api` matches too.
54
127
  export function isApiUrl(api, url) {
55
- return !isAbsoluteUrl(url) || url.startsWith(api.url)
128
+ return (
129
+ !isAbsoluteUrl(url) ||
130
+ isUrlInside(getAbsoluteUrl(url), getAbsoluteUrl(api.url))
131
+ )
132
+ }
133
+
134
+ // Returns `url` resolved against the document's base URL.
135
+ function getAbsoluteUrl(url) {
136
+ return new URL(url, document.baseURI).href
137
+ }
138
+
139
+ // Returns whether `url` is `baseUrl` or a URL under it, e.g. `/api/books` in
140
+ // `/api`, but not `/api-docs`.
141
+ function isUrlInside(url, baseUrl) {
142
+ if (!url.startsWith(baseUrl)) {
143
+ return false
144
+ }
145
+ const nextCharacter = url[baseUrl.length]
146
+ return (
147
+ baseUrl.endsWith('/') ||
148
+ nextCharacter === undefined ||
149
+ '/?#'.includes(nextCharacter)
150
+ )
56
151
  }
57
152
 
58
153
  export function getApiUrl(api, { url, query }) {
@@ -33,21 +33,10 @@ export function formatQuery(query) {
33
33
  )
34
34
  }
35
35
 
36
- export function replaceRoute({ path, query, hash }) {
37
- // Preserve `history.state`, see:
38
- // https://router.vuejs.org/guide/migration/#usage-of-history-state
39
- const { location } = window
40
- history.replaceState(
41
- history.state,
42
- null,
43
- `${
44
- location.origin
45
- }${
46
- path ?? location.pathname
47
- }?${
48
- query ? formatQuery(query) : location.search.slice(1)
49
- }${
50
- hash ?? location.hash
51
- }`
52
- )
36
+ // Returns true if `path` is `basePath` or one of its sub-paths, comparing
37
+ // whole path segments, so that `/items/12` and `/items-archive` aren't within
38
+ // `/items/1` and `/items`.
39
+ export function isPathWithin(path, basePath) {
40
+ const prefix = basePath.endsWith('/') ? basePath : `${basePath}/`
41
+ return path === basePath || path.startsWith(prefix)
53
42
  }
@@ -91,6 +91,24 @@ export function shouldRenderSchema(schema, context) {
91
91
  )
92
92
  }
93
93
 
94
+ // Returns the forms of `schema` for which items can be created, by type. Forms
95
+ // are excluded by `if: false` or `creatable: false`.
96
+ export function getCreatableForms(schema, context) {
97
+ return Object.fromEntries(
98
+ Object.entries(getFormSchemas(schema, context)).filter(
99
+ ([, form]) => (
100
+ shouldRenderSchema(form, context) &&
101
+ getSchemaValue('creatable', {
102
+ type: Boolean,
103
+ default: true,
104
+ schema: form,
105
+ context
106
+ })
107
+ )
108
+ )
109
+ )
110
+ }
111
+
94
112
  function getContext(context) {
95
113
  return isFunction(context) ? context() : context
96
114
  }
@@ -242,7 +260,9 @@ export function initializeData(schema, data = {}, component, {
242
260
  * defaults of components with `compute()`. Other defaults are written when the
243
261
  * data is set up, see `initializeData()`, or when they go missing later. If
244
262
  * provided, `getOptions()` returns the options for `context.options`, called
245
- * only when they're read.
263
+ * only when they're read. `dataPath` can also be a function that returns it,
264
+ * called only when `compute()` reads the data path or values derived from it,
265
+ * e.g. `index` or `parentItem`, so that only these computes depend on it.
246
266
  */
247
267
  export function computeValue(schema, data, name, dataPath, {
248
268
  component = null,
@@ -264,7 +284,9 @@ export function computeValue(schema, data, name, dataPath, {
264
284
  },
265
285
  name,
266
286
  data,
267
- dataPath,
287
+ get dataPath() {
288
+ return isFunction(dataPath) ? dataPath() : dataPath
289
+ },
268
290
  rootData
269
291
  }
270
292
  if (getOptions) {
@@ -0,0 +1,19 @@
1
+ import { hyphenate } from '@ditojs/utils'
2
+
3
+ // Returns the event handlers that `schema` defines, both in `events` and as
4
+ // `on[A-Z]` callbacks, e.g. `events: { mouseenter }` and `onMouseenter`, with
5
+ // the hyphenated event names that they are registered and emitted under.
6
+ // TODO: Deprecate one format or the other, in favour of only one way of
7
+ // doing things. Decide which one to remove.
8
+ export function getSchemaEventEntries(schema) {
9
+ const entries = []
10
+ for (const [key, callback] of Object.entries(schema.events || {})) {
11
+ entries.push({ key, event: hyphenate(key), callback })
12
+ }
13
+ for (const [key, callback] of Object.entries(schema)) {
14
+ if (/^on[A-Z]/.test(key)) {
15
+ entries.push({ key, event: hyphenate(key.slice(2)), callback })
16
+ }
17
+ }
18
+ return entries
19
+ }
@@ -0,0 +1,114 @@
1
+ import {
2
+ isObject,
3
+ isString,
4
+ isNumber,
5
+ isFunction,
6
+ escapeHtml
7
+ } from '@ditojs/utils'
8
+ import { isListSource } from './structure.js'
9
+ import { getItemId } from './data.js'
10
+
11
+ // Returns the id of `item`, by which routes address it. Transient items aren't
12
+ // stored yet and may lack ids, so they're addressed by their `index` instead,
13
+ // when it's known.
14
+ export function getItemIdOrIndex(
15
+ sourceSchema,
16
+ item,
17
+ { index = null, isTransient }
18
+ ) {
19
+ return isTransient && index !== null
20
+ ? String(index)
21
+ : getItemId(sourceSchema, item)
22
+ }
23
+
24
+ // Returns the index of the item in `items` that `itemId` addresses, see
25
+ // `getItemIdOrIndex()`, or `null` if there is none. The ids of transient items
26
+ // are their indices already, and are returned as they are.
27
+ export function findItemIndexById(sourceSchema, items, itemId, {
28
+ isTransient
29
+ }) {
30
+ const index = isTransient
31
+ ? itemId
32
+ : items?.findIndex(item => getItemId(sourceSchema, item) === itemId)
33
+ return index != null && index !== -1 ? index : null
34
+ }
35
+
36
+ // Returns the label of `item` as HTML: Values of the item are escaped,
37
+ // while labels that the schema provides, e.g. through `itemLabel()`, can
38
+ // contain HTML, so they need to escape the values that they include.
39
+ // The label depends on the component that displays the item through two
40
+ // callbacks: `evaluateItemLabel()` calls `sourceSchema.itemLabel()` with the
41
+ // item's context, and `getFormLabel()` returns the label of the item's form.
42
+ export function getItemLabel(
43
+ sourceSchema,
44
+ item,
45
+ {
46
+ index = null,
47
+ extended = false,
48
+ asObject = false,
49
+ evaluateItemLabel,
50
+ getFormLabel
51
+ }
52
+ ) {
53
+ const { itemLabel } = sourceSchema
54
+ if (!item || !extended && itemLabel === false) {
55
+ return null
56
+ }
57
+
58
+ let text
59
+ let prefix
60
+ let suffix
61
+ if (isFunction(itemLabel)) {
62
+ const label = evaluateItemLabel()
63
+ if (isObject(label)) {
64
+ ;({ text, prefix, suffix } = label)
65
+ } else {
66
+ text = label
67
+ }
68
+ // It's up to `itemLabel()` entirely to produce the label:
69
+ extended = false
70
+ } else if (isString(itemLabel) && !(itemLabel in item)) {
71
+ // `itemLabel` can be both a key, or simply a label.
72
+ text = itemLabel
73
+ } else {
74
+ // Look up the name on the item, by these rules:
75
+ // 1. If `itemLabel` is a string, use it as the property key
76
+ // 2. Otherwise, if there are columns, use the value of the first
77
+ // 3. Otherwise, see if the item has a property named 'name'
78
+ const { columns } = sourceSchema
79
+ const key = (
80
+ isString(itemLabel) && itemLabel ||
81
+ isListSource(sourceSchema) && columns && Object.keys(columns)[0] ||
82
+ 'name'
83
+ )
84
+ const value = item[key]
85
+ // Only primitives display as a label. If the property holds an array
86
+ // or object, fall through to the auto-generated label. Strings are
87
+ // escaped, as the label is rendered as HTML.
88
+ text = isString(value)
89
+ ? escapeHtml(value)
90
+ : isNumber(value)
91
+ ? value
92
+ : null
93
+ }
94
+ const hadLabel = !!text
95
+ // If no label was found so far, try to produce one from the index.
96
+ if (text == null) {
97
+ // Always use extended style when auto-generating labels from index/id:
98
+ extended = true
99
+ text = isListSource(sourceSchema) && index !== null ? `${index + 1}` : ''
100
+ }
101
+ if (extended) {
102
+ const formLabel = getFormLabel()
103
+ if (formLabel) {
104
+ // If a label was provided, put in quotes when prefixed with the
105
+ // form label for the extended style:
106
+ text = `${formLabel} ${hadLabel ? `'${text}'` : text}`
107
+ }
108
+ }
109
+ return asObject
110
+ ? text || prefix || suffix
111
+ ? { text, prefix, suffix }
112
+ : null
113
+ : text
114
+ }