@ditojs/admin 2.3.2 → 2.4.1

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.
@@ -3,7 +3,7 @@ import ValidationMixin from './ValidationMixin.js'
3
3
  import { getSchemaAccessor } from '../utils/accessor.js'
4
4
  import { computeValue } from '../utils/schema.js'
5
5
  import { getItem, getParentItem } from '../utils/data.js'
6
- import { asArray, camelize } from '@ditojs/utils'
6
+ import { camelize } from '@ditojs/utils'
7
7
 
8
8
  // @vue/component
9
9
  export default {
@@ -238,24 +238,30 @@ export default {
238
238
  },
239
239
 
240
240
  // @overridable
241
- focusElement() {
242
- const [element] = asArray(this.$refs.element)
243
- if (element) {
244
- this.$nextTick(() => {
245
- element.focus()
246
- // If the element is disabled, `focus()` will likely not have the
247
- // desired effect. Use `scrollIntoView()` if available:
248
- if (this.disabled) {
249
- ;(element.$el || element).scrollIntoView?.()
250
- }
251
- })
252
- }
241
+ async scrollIntoView() {
242
+ await this.focusSchema()
243
+ const { element = this } = this.$refs
244
+ ;(element.$el || element).scrollIntoView?.({
245
+ behavior: 'smooth',
246
+ block: 'center'
247
+ })
253
248
  },
254
249
 
255
- focus() {
250
+ // @overridable
251
+ async focusElement() {
252
+ await this.focusSchema()
253
+ const { element = this } = this.$refs
254
+ ;(element.$el || element).focus?.()
255
+ },
256
+
257
+ async focusSchema() {
256
258
  // Also focus this component's schema and panel in case it's a tab.
257
- this.schemaComponent.focus()
258
- this.tabComponent?.focus()
259
+ await this.schemaComponent.focus()
260
+ await this.tabComponent?.focus()
261
+ },
262
+
263
+ focus() {
264
+ this.scrollIntoView()
259
265
  this.focusElement()
260
266
  },
261
267
 
@@ -15,6 +15,12 @@ export default {
15
15
  }
16
16
  },
17
17
 
18
+ computed: {
19
+ hasErrors() {
20
+ return !!this.errors
21
+ }
22
+ },
23
+
18
24
  methods: {
19
25
  resetValidation() {
20
26
  this.isTouched = false
@@ -60,6 +66,8 @@ export default {
60
66
 
61
67
  markTouched() {
62
68
  this.isTouched = true
69
+ // Clear currently displayed errors when focusing input.
70
+ this.clearErrors()
63
71
  },
64
72
 
65
73
  markDirty() {
@@ -87,7 +95,7 @@ export default {
87
95
  this.addError(message, true)
88
96
  }
89
97
  if (focus) {
90
- this.focus()
98
+ this.scrollIntoView()
91
99
  }
92
100
  return true
93
101
  },
@@ -6,11 +6,7 @@ export default {
6
6
 
7
7
  computed: {
8
8
  errors() {
9
- return this.schemaComponents.reduce(
10
- (result, { errors }) =>
11
- errors && result ? result.concat(errors) : errors,
12
- null
13
- )
9
+ return this.schemaComponents.flatMap(({ errors }) => errors || [])
14
10
  },
15
11
 
16
12
  isTouched() {
@@ -149,9 +149,9 @@
149
149
  // For now, nothing for these:
150
150
  // .dito-button-create:empty::before,
151
151
  // .dito-button-add:empty::before
152
- // Special .dito-button-add-upload without :empty:
153
- .dito-button-add-upload::before {
154
- @extend %icon-add;
152
+
153
+ .dito-button-upload:empty::before {
154
+ @extend %icon-upload;
155
155
  }
156
156
 
157
157
  .dito-button-delete:empty::before,
@@ -1,5 +1,3 @@
1
- @import 'tippy.js/animations/shift-away-subtle.css';
2
-
3
1
  .dito-info {
4
2
  --size: calc(1em * var(--line-height));
5
3
 
@@ -19,17 +17,3 @@
19
17
  width: var(--size);
20
18
  }
21
19
  }
22
-
23
- .tippy-box[data-theme~='info'] {
24
- background-color: $color-active;
25
- color: $color-white;
26
- filter: drop-shadow(0 2px 4px $color-shadow);
27
-
28
- > .tippy-arrow::before {
29
- color: $color-active;
30
- }
31
-
32
- .tippy-content {
33
- white-space: pre-line;
34
- }
35
- }
@@ -66,6 +66,9 @@ $menu-padding-ver: $header-padding-ver - $menu-spacing;
66
66
  $menu-padding-hor: $header-padding-hor - $menu-spacing;
67
67
  $menu-padding: $menu-padding-ver $menu-padding-hor;
68
68
 
69
+ // Drag & Drop
70
+ $drag-overlay-z-index: 2000;
71
+
69
72
  // Patterns
70
73
  $pattern-transparency-size: ($font-size - $border-width) * $input-height-factor;
71
74
  $pattern-transparency: repeating-conic-gradient(
@@ -0,0 +1,39 @@
1
+ @use 'sass:color';
2
+ @import 'tippy.js/animations/shift-away-subtle.css';
3
+
4
+ .tippy-box {
5
+ &[data-theme] {
6
+ --color: #{$color-active};
7
+
8
+ font-size: unset;
9
+ line-height: unset;
10
+ background-color: var(--color);
11
+ color: $color-white;
12
+ filter: drop-shadow(0 2px 4px $color-shadow);
13
+
14
+ .tippy-content {
15
+ white-space: pre-line;
16
+ padding: ($input-padding-ver + 2 * $border-width)
17
+ ($input-padding-hor + $border-width);
18
+ }
19
+
20
+ > .tippy-arrow::before {
21
+ color: var(--color);
22
+ }
23
+ }
24
+
25
+ &[data-theme~='info'] {
26
+ --color: #{color.adjust($color-active, $lightness: 5%)};
27
+ }
28
+
29
+ &[data-theme~='error'] {
30
+ --color: #{color.adjust($color-error, $lightness: 5%)};
31
+
32
+ cursor: pointer;
33
+
34
+ > .tippy-arrow {
35
+ transform: unset !important;
36
+ left: 16px !important;
37
+ }
38
+ }
39
+ }
@@ -6,5 +6,6 @@
6
6
  @import '_scroll';
7
7
  @import '_sortable';
8
8
  @import '_table';
9
- @import '_notifications';
10
9
  @import '_info';
10
+ @import '_tippy';
11
+ @import '_notifications';
@@ -80,7 +80,7 @@
80
80
  :dataPath="getDataPath(index)"
81
81
  :data="item"
82
82
  :meta="nestedMeta"
83
- :store="getChildStore(index)"
83
+ :store="getChildStore(getItemUid(schema, item), index)"
84
84
  :disabled="disabled || isLoading"
85
85
  :collapsed="collapsed"
86
86
  :collapsible="collapsible"
@@ -117,7 +117,7 @@
117
117
  :dataPath="getDataPath(index)"
118
118
  :data="item"
119
119
  :meta="nestedMeta"
120
- :store="getChildStore(index)"
120
+ :store="getChildStore(getItemUid(schema, item), index)"
121
121
  @delete="deleteItem(item, index)"
122
122
  )
123
123
  //- Render create buttons inside table when not in a single component view:
@@ -1,6 +1,6 @@
1
1
  <template lang="pug">
2
2
  .dito-markup(:id="dataPath")
3
- .dito-markup-toolbar(:editor="editor")
3
+ .dito-markup-toolbar
4
4
  .dito-buttons.dito-buttons-toolbar(
5
5
  v-if="groupedButtons.length > 0"
6
6
  )
@@ -379,6 +379,7 @@ export default DitoTypeComponent.register('markup', {
379
379
 
380
380
  getButtons(settingsName, descriptions) {
381
381
  const list = []
382
+ const { commands } = this.editor
382
383
 
383
384
  const addButton = ({ name, icon, attributes, ignoreActive, onClick }) => {
384
385
  list.push({
@@ -389,8 +390,11 @@ export default DitoTypeComponent.register('markup', {
389
390
  (ignoreActive == null || !ignoreActive())
390
391
  ),
391
392
  onClick: () => {
392
- const key = `toggle${camelize(name, true)}`
393
- if (this.editor.commands[key]) {
393
+ const key =
394
+ name in commands
395
+ ? name
396
+ : `toggle${camelize(name, true)}`
397
+ if (key in commands) {
394
398
  const command = attributes =>
395
399
  this.editor.chain()[key](attributes).focus().run()
396
400
  onClick
@@ -439,7 +443,6 @@ export default DitoTypeComponent.register('markup', {
439
443
  },
440
444
 
441
445
  focusElement() {
442
- this.$el.scrollIntoView?.()
443
446
  this.editor.focus()
444
447
  }
445
448
  }
@@ -1,11 +1,31 @@
1
1
  <template lang="pug">
2
2
  .dito-upload
3
+ //- In order to handle upload buttons in multiple possible places, depending
4
+ //- on whether they handle single or multiple uploads, render the upload
5
+ //- component invisibly at the root, and delegate the click events to it from
6
+ //- the buttons rendered further below. Luckily this works surprisingly well.
7
+ VueUpload.dito-upload-input(
8
+ ref="upload"
9
+ v-model="uploads"
10
+ :inputId="dataPath"
11
+ :name="dataPath"
12
+ :disabled="disabled"
13
+ :postAction="uploadPath"
14
+ :extensions="extensions"
15
+ :accept="accept"
16
+ :multiple="multiple"
17
+ :size="maxSize"
18
+ :drop="$el?.closest('.dito-container')"
19
+ :dropDirectory="true"
20
+ @input-filter="onInputFilter"
21
+ @input-file="onInputFile"
22
+ )
3
23
  table.dito-table.dito-table-separators.dito-table-background
4
- //- Styling comes from DitoTableHead
24
+ //- Styling comes from `DitoTableHead`
5
25
  thead.dito-table-head
6
26
  tr
7
27
  th
8
- span Name
28
+ span File
9
29
  th
10
30
  span Size
11
31
  th
@@ -18,48 +38,81 @@
18
38
  :options="getSortableOptions(false)"
19
39
  :draggable="draggable"
20
40
  )
21
- tr(
22
- v-for="(file, index) in files"
23
- :key="file.key"
41
+ template(
42
+ v-if="multiple || !isUploadActive"
24
43
  )
25
- td(
26
- v-html="renderFile(file, index)"
44
+ tr(
45
+ v-for="(file, index) in files"
46
+ :key="file.key"
27
47
  )
28
- td {{ formatFileSize(file.size) }}
29
- td
30
- template(
31
- v-if="file.upload"
48
+ td(
49
+ v-if="render"
50
+ v-html="renderFile(file, index)"
32
51
  )
33
- template(
34
- v-if="file.upload.error"
35
- )
36
- | Error: {{ file.upload.error }}
37
- template(
38
- v-else-if="file.upload.active"
39
- )
40
- | Uploading...
41
- template(
42
- v-else-if="file.upload.success"
52
+ td(
53
+ v-else-if="downloadUrls[index]"
54
+ )
55
+ a(
56
+ :download="file.name"
57
+ :href="downloadUrls[index]"
58
+ target="_blank"
59
+ @click.prevent="onClickDownload(file, index)"
43
60
  )
44
- | Uploaded
45
- template(
61
+ DitoUploadFile(
62
+ :file="file"
63
+ :thumbnail="thumbnails"
64
+ :thumbnailUrl="thumbnailUrls[index]"
65
+ )
66
+ td(
46
67
  v-else
47
68
  )
48
- | Stored
49
- td.dito-cell-edit-buttons
50
- .dito-buttons.dito-buttons-round
51
- //- Firefox doesn't like <button> here, so use <a> instead:
52
- a.dito-button(
53
- v-if="draggable"
54
- v-bind="getButtonAttributes(verbs.drag)"
69
+ DitoUploadFile(
70
+ :file="file"
71
+ :thumbnail="thumbnails"
72
+ :thumbnailUrl="thumbnailUrls[index]"
55
73
  )
56
- button.dito-button(
57
- v-if="deletable"
58
- type="button"
59
- v-bind="getButtonAttributes(verbs.delete)"
60
- @click="deleteFile(file, index)"
74
+ td {{ formatFileSize(file.size) }}
75
+ td
76
+ template(
77
+ v-if="file.upload"
61
78
  )
62
- tfoot
79
+ template(
80
+ v-if="file.upload.error"
81
+ )
82
+ | Error: {{ file.upload.error }}
83
+ template(
84
+ v-else-if="file.upload.active"
85
+ )
86
+ | Uploading...
87
+ template(
88
+ v-else-if="file.upload.success"
89
+ )
90
+ | Uploaded
91
+ template(
92
+ v-else
93
+ )
94
+ | Stored
95
+ td.dito-cell-edit-buttons
96
+ .dito-buttons.dito-buttons-round
97
+ button.dito-button.dito-button-upload(
98
+ v-if="!multiple"
99
+ :title="uploadTitle"
100
+ @click="onClickUpload"
101
+ )
102
+ //- Firefox doesn't like <button> here, so use <a> instead:
103
+ a.dito-button(
104
+ v-if="draggable"
105
+ v-bind="getButtonAttributes(verbs.drag)"
106
+ )
107
+ button.dito-button(
108
+ v-if="deletable"
109
+ type="button"
110
+ v-bind="getButtonAttributes(verbs.delete)"
111
+ @click="deleteFile(file, index)"
112
+ )
113
+ tfoot(
114
+ v-if="multiple || isUploadActive || !hasFiles"
115
+ )
63
116
  tr
64
117
  td(:colspan="4")
65
118
  .dito-upload-footer
@@ -73,26 +126,11 @@
73
126
  v-if="isUploadActive"
74
127
  type="button"
75
128
  @click.prevent="upload.active = false"
76
- ) Cancel All
77
- button.dito-button(
78
- v-else-if="isUploadReady"
79
- type="button"
80
- @click.prevent="upload.active = true"
81
- ) Upload All
82
- VueUpload.dito-button.dito-button-add-upload(
83
- ref="upload"
84
- v-model="uploads"
85
- :inputId="dataPath"
86
- :name="dataPath"
87
- :disabled="disabled"
88
- :postAction="uploadPath"
89
- :extensions="extensions"
90
- :accept="accept"
91
- :multiple="multiple"
92
- :size="maxSize"
93
- title="Upload Files"
94
- @input-filter="inputFilter"
95
- @input-file="inputFile"
129
+ ) Cancel
130
+ button.dito-button.dito-button-upload(
131
+ v-if="multiple || !hasFiles"
132
+ :title="uploadTitle"
133
+ @click="onClickUpload"
96
134
  )
97
135
  </template>
98
136
 
@@ -104,7 +142,7 @@ import parseFileSize from 'filesize-parser'
104
142
  import { getSchemaAccessor } from '../utils/accessor.js'
105
143
  import { formatFileSize } from '../utils/units.js'
106
144
  import { appendDataPath } from '../utils/data.js'
107
- import { isArray, asArray, escapeHtml } from '@ditojs/utils'
145
+ import { isArray, asArray } from '@ditojs/utils'
108
146
  import VueUpload from 'vue-upload-component'
109
147
 
110
148
  // @vue/component
@@ -125,10 +163,22 @@ export default DitoTypeComponent.register('upload', {
125
163
  return this.$refs.upload
126
164
  },
127
165
 
166
+ uploadTitle() {
167
+ return this.multiple ? 'Upload Files' : 'Upload File'
168
+ },
169
+
128
170
  files() {
129
171
  return asFiles(this.value)
130
172
  },
131
173
 
174
+ downloadUrls() {
175
+ return this.files.map((file, index) => this.getDownloadUrl(file, index))
176
+ },
177
+
178
+ thumbnailUrls() {
179
+ return this.files.map((file, index) => this.getThumbnailUrl(file, index))
180
+ },
181
+
132
182
  multiple: getSchemaAccessor('multiple', {
133
183
  type: Boolean,
134
184
  default: false,
@@ -167,20 +217,43 @@ export default DitoTypeComponent.register('upload', {
167
217
  default: false
168
218
  }),
169
219
 
220
+ render: getSchemaAccessor('render', {
221
+ type: Function,
222
+ default: null
223
+ }),
224
+
225
+ thumbnails: getSchemaAccessor('thumbnails', {
226
+ type: [Boolean, String],
227
+ default(thumbnails) {
228
+ return thumbnails ?? !!this.schema.thumbnailUrl
229
+ },
230
+ get(thumbnails) {
231
+ return thumbnails === true ? 'medium' : thumbnails || null
232
+ }
233
+ }),
234
+
235
+ hasFiles() {
236
+ return this.files.length > 0
237
+ },
238
+
239
+ hasUploads() {
240
+ return this.uploads.length > 0
241
+ },
242
+
170
243
  isUploadReady() {
171
244
  return (
172
- this.uploads.length &&
245
+ this.hasUploads &&
173
246
  !(this.upload.active || this.upload.uploaded)
174
247
  )
175
248
  },
176
249
 
177
250
  isUploadActive() {
178
- return this.uploads.length && this.upload.active
251
+ return this.hasUploads && this.upload.active
179
252
  },
180
253
 
181
254
  uploadProgress() {
182
255
  return (
183
- this.uploads.reduce((total, file) => total + file.progress, 0) /
256
+ this.uploads.reduce((total, file) => total + +file.progress, 0) /
184
257
  this.uploads.length
185
258
  )
186
259
  },
@@ -193,22 +266,59 @@ export default DitoTypeComponent.register('upload', {
193
266
  }
194
267
  },
195
268
 
269
+ watch: {
270
+ isUploadReady(ready) {
271
+ if (ready) {
272
+ // Auto-upload.
273
+ this.$nextTick(() => {
274
+ this.upload.active = true
275
+ })
276
+ }
277
+ }
278
+ },
279
+
196
280
  methods: {
197
281
  formatFileSize,
198
282
 
283
+ getFileContext(file, index) {
284
+ return this.multiple
285
+ ? new DitoContext(this, {
286
+ value: file,
287
+ data: this.files,
288
+ index,
289
+ dataPath: appendDataPath(this.dataPath, index)
290
+ })
291
+ : this.context
292
+ },
293
+
199
294
  renderFile(file, index) {
200
- const { render } = this.schema
201
- return render
202
- ? render.call(
203
- this,
204
- new DitoContext(this, {
205
- value: file,
206
- data: this.files,
207
- index,
208
- dataPath: appendDataPath(this.dataPath, index)
295
+ return this.render(this.getFileContext(file, index))
296
+ },
297
+
298
+ getDownloadUrl(file, index) {
299
+ return file.url
300
+ ? file.url
301
+ : !file.upload || file.upload.success
302
+ ? this.getSchemaValue('downloadUrl', {
303
+ type: 'String',
304
+ default: null,
305
+ context: this.getFileContext(file, index)
209
306
  })
307
+ : null
308
+ },
309
+
310
+ getThumbnailUrl(file, index) {
311
+ return !file.upload || file.upload.success
312
+ ? this.getSchemaValue('thumbnailUrl', {
313
+ type: 'String',
314
+ default: null,
315
+ context: this.getFileContext(file, index)
316
+ }) || (
317
+ file.type.startsWith('image/')
318
+ ? file.url
319
+ : null
210
320
  )
211
- : escapeHtml(file.name)
321
+ : null
212
322
  },
213
323
 
214
324
  deleteFile(file, index) {
@@ -274,7 +384,7 @@ export default DitoTypeComponent.register('upload', {
274
384
  this.replaceFile(file, null)
275
385
  },
276
386
 
277
- inputFile(newFile, oldFile) {
387
+ onInputFile(newFile, oldFile) {
278
388
  if (newFile && !oldFile) {
279
389
  const { id, name, size } = newFile
280
390
  this.addFile({ id, name, size, upload: newFile })
@@ -293,6 +403,7 @@ export default DitoTypeComponent.register('upload', {
293
403
  this.removeFile(newFile)
294
404
  }
295
405
  } else if (error) {
406
+ this.removeFile(newFile)
296
407
  const text = (
297
408
  {
298
409
  abort: 'Upload aborted',
@@ -310,16 +421,35 @@ export default DitoTypeComponent.register('upload', {
310
421
  title: 'File Upload Error',
311
422
  text
312
423
  })
313
- this.removeFile(newFile)
314
424
  }
315
425
  }
316
426
  },
317
427
 
318
- inputFilter(newFile /*, oldFile, prevent */) {
428
+ onInputFilter(newFile /*, oldFile, prevent */) {
319
429
  const xhr = newFile?.xhr
320
430
  if (this.api.cors?.credentials && xhr && !xhr.withCredentials) {
321
431
  xhr.withCredentials = true
322
432
  }
433
+ },
434
+
435
+ async onClickDownload(file, index) {
436
+ try {
437
+ const response = await fetch(this.downloadUrls[index])
438
+ const blob = await response.blob()
439
+ this.download({
440
+ filename: file.name,
441
+ url: URL.createObjectURL(blob)
442
+ })
443
+ } catch (error) {
444
+ console.error(error)
445
+ }
446
+ },
447
+
448
+ onClickUpload(event) {
449
+ // Delegate the click event to the hidden file input.
450
+ this.upload.$el.querySelector('input').dispatchEvent(
451
+ new event.constructor(event.type, event)
452
+ )
323
453
  }
324
454
  },
325
455
 
@@ -342,18 +472,16 @@ function asFiles(value) {
342
472
 
343
473
  .dito-upload {
344
474
  .dito-table {
345
- tr {
475
+ tr,
476
+ .dito-cell-edit-buttons {
346
477
  vertical-align: middle;
347
478
  }
348
479
  }
349
480
 
350
- .dito-button-add-upload {
351
- padding: 0;
352
-
353
- > * {
354
- position: absolute;
355
- cursor: pointer;
356
- }
481
+ .dito-upload-input {
482
+ // See `onClickUpload()` method for details.
483
+ position: absolute;
484
+ pointer-events: none;
357
485
  }
358
486
 
359
487
  .dito-upload-footer {
@@ -1042,5 +1042,5 @@ export function getItemUid(sourceSchema, item) {
1042
1042
  // either way, pass through `getUid()` so that the ids are associated with the
1043
1043
  // item through a weak map, as the ids can be filtered out in `processData()`
1044
1044
  // while the components that use the uids as key are still visible.
1045
- return getUid(item, getItemId(sourceSchema, item))
1045
+ return getUid(item, item => getItemId(sourceSchema, item))
1046
1046
  }
package/src/utils/uid.js CHANGED
@@ -1,12 +1,15 @@
1
+ import { toRaw } from 'vue'
2
+
1
3
  const uidMap = new WeakMap()
2
4
 
3
5
  // Generated and remembers unique ids per passed object using a weak map.
4
6
  let uid = 0
5
- export function getUid(object, itemId) {
6
- let id = uidMap.get(object)
7
+ export function getUid(item, getItemId = null) {
8
+ const raw = toRaw(item)
9
+ let id = uidMap.get(raw)
7
10
  if (!id) {
8
- id = itemId || `@${++uid}`
9
- uidMap.set(object, id)
11
+ id = getItemId?.(item) || `@${++uid}`
12
+ uidMap.set(raw, id)
10
13
  }
11
14
  return id
12
15
  }