@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,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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
+
}
|
package/src/utils/filter.js
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
+
}
|
package/src/utils/options.js
CHANGED
|
@@ -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
|
+
}
|
package/src/utils/request.js
CHANGED
|
@@ -21,24 +21,16 @@ export async function request(api, {
|
|
|
21
21
|
signal = null,
|
|
22
22
|
timeout = api.timeout ?? null
|
|
23
23
|
}) {
|
|
24
|
-
const
|
|
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
|
-
...
|
|
29
|
+
...fetchOptions.headers,
|
|
35
30
|
...headers
|
|
36
31
|
},
|
|
37
|
-
credentials:
|
|
38
|
-
|
|
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
|
|
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 }) {
|
package/src/utils/route.js
CHANGED
|
@@ -33,21 +33,10 @@ export function formatQuery(query) {
|
|
|
33
33
|
)
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
}
|
package/src/utils/schema/data.js
CHANGED
|
@@ -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) {
|
|
@@ -527,6 +549,11 @@ export function processSchemaData(schema, data, {
|
|
|
527
549
|
if (wrapPrimitives && !isObject(item)) {
|
|
528
550
|
item = { [wrapPrimitives]: item }
|
|
529
551
|
}
|
|
552
|
+
if (!isObject(item)) {
|
|
553
|
+
// Values that aren't objects hold no data of the form's
|
|
554
|
+
// components, e.g. ones that `compute()` converts to objects.
|
|
555
|
+
return item
|
|
556
|
+
}
|
|
530
557
|
const itemDataPath =
|
|
531
558
|
index !== null
|
|
532
559
|
? getDataPath(componentDataPath, index)
|
|
@@ -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
|
+
}
|