@conduction/nextcloud-vue 2.98.0 → 2.100.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/esm/components/CnActionsMenu/CnActionsMenu.vue2.js +12 -0
- package/dist/esm/components/CnActionsMenu/CnActionsMenu.vue2.js.map +1 -1
- package/dist/esm/components/CnActionsMenu/CnActionsMenu.vue3.js +11 -2
- package/dist/esm/components/CnActionsMenu/CnActionsMenu.vue3.js.map +1 -1
- package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue.js +1 -1
- package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue2.js +6 -1
- package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue2.js.map +1 -1
- package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue3.js +2 -1
- package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue3.js.map +1 -1
- package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue.js +1 -1
- package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue2.js.map +1 -1
- package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js +1 -1
- package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js.map +1 -1
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +25 -8
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +7 -4
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue.js +1 -1
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js +11 -8
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js.map +1 -1
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js +13 -4
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js.map +1 -1
- package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue.js +1 -1
- package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js +54 -3
- package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js +16 -2
- package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnFlowsPage/CnFlowBoard.vue.js +1 -1
- package/dist/esm/components/CnFlowsPage/CnFlowBoard.vue2.js +42 -7
- package/dist/esm/components/CnFlowsPage/CnFlowBoard.vue2.js.map +1 -1
- package/dist/esm/components/CnFlowsPage/CnFlowBoard.vue3.js +121 -57
- package/dist/esm/components/CnFlowsPage/CnFlowBoard.vue3.js.map +1 -1
- package/dist/esm/components/CnFlowsPage/CnFlowEditorPage.vue2.js +10 -0
- package/dist/esm/components/CnFlowsPage/CnFlowEditorPage.vue2.js.map +1 -1
- package/dist/esm/components/CnFlowsPage/CnFlowEditorPage.vue3.js +2 -1
- package/dist/esm/components/CnFlowsPage/CnFlowEditorPage.vue3.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +136 -7
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
- package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue.js +1 -1
- package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue2.js +26 -7
- package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue2.js.map +1 -1
- package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue3.js +78 -17
- package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue3.js.map +1 -1
- package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +1 -1
- package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +68 -7
- package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js +256 -240
- package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnSettingsField/CnSettingsField.vue.js +9 -0
- package/dist/esm/components/CnSettingsField/CnSettingsField.vue.js.map +1 -0
- package/dist/esm/components/CnSettingsField/CnSettingsField.vue2.js +265 -0
- package/dist/esm/components/CnSettingsField/CnSettingsField.vue2.js.map +1 -0
- package/dist/esm/components/CnSettingsField/CnSettingsField.vue3.js +191 -0
- package/dist/esm/components/CnSettingsField/CnSettingsField.vue3.js.map +1 -0
- package/dist/esm/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue.js +9 -0
- package/dist/esm/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue.js.map +1 -0
- package/dist/esm/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue2.js +537 -0
- package/dist/esm/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue2.js.map +1 -0
- package/dist/esm/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue3.js +99 -0
- package/dist/esm/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue3.js.map +1 -0
- package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue.js +1 -1
- package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue2.js +18 -70
- package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue2.js.map +1 -1
- package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue3.js +6 -44
- package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue3.js.map +1 -1
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue.js +1 -1
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js +16 -2
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js.map +1 -1
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js +40 -25
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js.map +1 -1
- package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
- package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js.map +1 -1
- package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue.js +1 -1
- package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue2.js +7 -7
- package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue2.js.map +1 -1
- package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue3.js.map +1 -1
- package/dist/esm/components/CnWorkingCalendarWidget/CnWorkingCalendarWidget.vue.js +9 -0
- package/dist/esm/components/CnWorkingCalendarWidget/CnWorkingCalendarWidget.vue.js.map +1 -0
- package/dist/esm/components/CnWorkingCalendarWidget/CnWorkingCalendarWidget.vue2.js +472 -0
- package/dist/esm/components/CnWorkingCalendarWidget/CnWorkingCalendarWidget.vue2.js.map +1 -0
- package/dist/esm/components/CnWorkingCalendarWidget/CnWorkingCalendarWidget.vue3.js +231 -0
- package/dist/esm/components/CnWorkingCalendarWidget/CnWorkingCalendarWidget.vue3.js.map +1 -0
- package/dist/esm/components/CnWorkingCalendarWidget/index.js +27 -0
- package/dist/esm/components/CnWorkingCalendarWidget/index.js.map +1 -0
- package/dist/esm/composables/useFlowStore.js +2 -1
- package/dist/esm/composables/useFlowStore.js.map +1 -1
- package/dist/esm/index.js +3 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/l10n/en.json.js +101 -1
- package/dist/esm/l10n/en.json.js.map +1 -1
- package/dist/esm/l10n/nl.json.js +115 -1
- package/dist/esm/l10n/nl.json.js.map +1 -1
- package/dist/esm/library-style.js +1 -1
- package/dist/esm/nextcloud-vue.css +999 -233
- package/dist/esm/utils/libraryWidgetKeys.js +1 -0
- package/dist/esm/utils/libraryWidgetKeys.js.map +1 -1
- package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
- package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
- package/dist/esm/utils/workingCalendarMonth.js +85 -0
- package/dist/esm/utils/workingCalendarMonth.js.map +1 -0
- package/dist/nextcloud-vue.cjs.js +10314 -7778
- package/dist/nextcloud-vue.cjs.js.map +2 -2
- package/dist/nextcloud-vue.css +999 -233
- package/l10n/en.json +101 -1
- package/l10n/nl.json +115 -1
- package/package.json +1 -1
- package/src/components/CnActionsMenu/CnActionsMenu.vue +14 -1
- package/src/components/CnAdminSettingsShell/CnAdminSettingsShell.vue +7 -1
- package/src/components/CnBreadcrumbs/CnBreadcrumbs.vue +1 -0
- package/src/components/CnDetailPage/CnDetailPage.vue +29 -9
- package/src/components/CnDetailWidgetHost/CnDetailWidgetHost.vue +13 -4
- package/src/components/CnFactsWidget/CnFactsWidget.vue +65 -3
- package/src/components/CnFlowsPage/CnFlowBoard.vue +77 -2
- package/src/components/CnFlowsPage/CnFlowEditorPage.vue +11 -0
- package/src/components/CnIndexPage/CnIndexPage.vue +136 -7
- package/src/components/CnOrderedListField/CnOrderedListField.vue +81 -9
- package/src/components/CnRecordListWidget/CnRecordListWidget.vue +114 -7
- package/src/components/CnSettingsField/CnSettingsField.vue +449 -0
- package/src/components/CnSettingsField/index.js +4 -0
- package/src/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue +642 -0
- package/src/components/CnSettingsFieldsSection/index.js +4 -0
- package/src/components/CnSettingsPage/CnSettingsPage.vue +6 -86
- package/src/components/CnSetupWizard/CnSetupWizard.vue +37 -3
- package/src/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
- package/src/components/CnWizardDialog/CnWizardDialog.vue +7 -7
- package/src/components/CnWorkingCalendarWidget/CnWorkingCalendarWidget.vue +725 -0
- package/src/components/CnWorkingCalendarWidget/index.js +26 -0
- package/src/components/index.js +3 -0
- package/src/composables/useFlowStore.js +2 -1
- package/src/css/badge.css +32 -16
- package/src/css/detail-page.css +15 -0
- package/src/css/dialog.css +48 -0
- package/src/css/look-board-detail.css +80 -8
- package/src/css/look-board-index.css +52 -8
- package/src/css/look-board.css +301 -1
- package/src/index.js +3 -0
- package/src/schemas/app-manifest-v2.schema.json +25 -1
- package/src/utils/libraryWidgetKeys.js +1 -0
- package/src/utils/validateManifestV2.compiled.js +3 -3
- package/src/utils/workingCalendarMonth.js +82 -0
|
@@ -17,6 +17,22 @@
|
|
|
17
17
|
<p v-else-if="rows.length === 0" class="cn-record-list__empty" data-testid="cn-record-list-empty">
|
|
18
18
|
{{ emptyLabel }}
|
|
19
19
|
</p>
|
|
20
|
+
<!-- notice: the rows as sentences in one toned box ("2 waarschuwingen: ..."). -->
|
|
21
|
+
<div
|
|
22
|
+
v-else-if="presentation === 'notice'"
|
|
23
|
+
class="cn-record-list__notice"
|
|
24
|
+
:class="`cn-record-list__notice--${noticeVariant}`"
|
|
25
|
+
role="note"
|
|
26
|
+
data-testid="cn-record-list-notice">
|
|
27
|
+
<CnIcon
|
|
28
|
+
:name="noticeIcon"
|
|
29
|
+
:size="18"
|
|
30
|
+
class="cn-record-list__notice-icon"
|
|
31
|
+
aria-hidden="true" />
|
|
32
|
+
<p class="cn-record-list__notice-text">
|
|
33
|
+
{{ noticeText }}
|
|
34
|
+
</p>
|
|
35
|
+
</div>
|
|
20
36
|
<ul v-else class="cn-record-list__list">
|
|
21
37
|
<li
|
|
22
38
|
v-for="item in items"
|
|
@@ -210,7 +226,22 @@ import { readPath } from '../../utils/readPath.js'
|
|
|
210
226
|
import { dropOptionalUnresolved, hasUnresolvedTokens, resolveFilterTokens } from '../../utils/resolveFilterTokens.js'
|
|
211
227
|
|
|
212
228
|
const PAGE_REFRESH_CHANNEL = 'cn:page:refresh'
|
|
213
|
-
const PRESENTATIONS = ['rows', 'feed', 'inline', 'cards']
|
|
229
|
+
const PRESENTATIONS = ['rows', 'feed', 'inline', 'cards', 'notice']
|
|
230
|
+
|
|
231
|
+
/** The tones a `notice` list takes. */
|
|
232
|
+
const NOTICE_VARIANTS = ['info', 'warning', 'error']
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* A row that is a plain string or number (an endpoint answering `["...", "..."]`)
|
|
236
|
+
* read as `{ value }`, so the templates reach it as `{value}` and a list
|
|
237
|
+
* without a `title` shows the value itself. An object row is returned as is.
|
|
238
|
+
*
|
|
239
|
+
* @param {(object|string|number)} row The row.
|
|
240
|
+
* @return {(object|string|number)} The row, or `{ value }` for a string or number.
|
|
241
|
+
*/
|
|
242
|
+
function scalarRow(row) {
|
|
243
|
+
return typeof row === 'string' || typeof row === 'number' ? { value: String(row) } : row
|
|
244
|
+
}
|
|
214
245
|
|
|
215
246
|
/**
|
|
216
247
|
* Unwrap a value that may be a ref.
|
|
@@ -266,7 +297,7 @@ export default {
|
|
|
266
297
|
props: {
|
|
267
298
|
/**
|
|
268
299
|
* The list config: `{ register, schema, filter?, sort?, limit?, extend?,
|
|
269
|
-
* presentation?, title?, meta?, text?, label?, pill?, trailing?, icon?,
|
|
300
|
+
* presentation?, variant?, lead?, title?, meta?, text?, label?, pill?, trailing?, icon?,
|
|
270
301
|
* rowRoute?, rowIdField?, headerLink?, headerNote?, emptyText?, errorText?,
|
|
271
302
|
* valueLabels?, endpointSource?, bar? }`. `bar` (`{ valueField, maxField?,
|
|
272
303
|
* overVariant? }`) draws a thin track under each row: filled to
|
|
@@ -275,7 +306,14 @@ export default {
|
|
|
275
306
|
* largest row. `endpointSource` (`{ url, method?,
|
|
276
307
|
* params?, responsePath? }`) reads the rows from an app endpoint instead
|
|
277
308
|
* of `register` / `schema`. `valueLabels` maps a field's stored values to the labels
|
|
278
|
-
* the templates show.
|
|
309
|
+
* the templates show. `headerNote` and `headerLink.label` fill `{total}`
|
|
310
|
+
* and `{count:<field>=<value>}`, the loaded rows with that value.
|
|
311
|
+
* A row that is a plain string or number (an endpoint
|
|
312
|
+
* answering `["…", "…"]`) is read as `{ value }`: templates reach it as
|
|
313
|
+
* `{value}`, and without `title` a row shows the value itself.
|
|
314
|
+
* `presentation: "notice"` draws the rows as sentences in one box toned by
|
|
315
|
+
* `variant` (`info`, `warning` or `error`), after an optional `lead`
|
|
316
|
+
* (`{total}` filled in).
|
|
279
317
|
*
|
|
280
318
|
* @type {object}
|
|
281
319
|
*/
|
|
@@ -334,6 +372,35 @@ export default {
|
|
|
334
372
|
|
|
335
373
|
computed: {
|
|
336
374
|
/** @return {string} `rows`, `feed`, `inline` or `cards`. */
|
|
375
|
+
/**
|
|
376
|
+
* The tone of a `notice` list (`content.variant`): info, warning or error.
|
|
377
|
+
*
|
|
378
|
+
* @return {string} The variant, `info` when unset or unknown.
|
|
379
|
+
* @spec openspec/changes/record-list-string-rows/specs/record-list-string-rows/spec.md#requirement-a-record-list-draws-rows-that-are-plain-strings
|
|
380
|
+
*/
|
|
381
|
+
noticeVariant() {
|
|
382
|
+
const value = this.content?.variant
|
|
383
|
+
return NOTICE_VARIANTS.includes(value) ? value : 'info'
|
|
384
|
+
},
|
|
385
|
+
|
|
386
|
+
/** @return {string} The icon of the notice tone. */
|
|
387
|
+
noticeIcon() {
|
|
388
|
+
return this.noticeVariant === 'info' ? 'InformationOutline' : 'AlertOutline'
|
|
389
|
+
},
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* The rows of a `notice` list as one paragraph: each row's title, as
|
|
393
|
+
* sentences, after the optional `lead` with `{total}` filled in.
|
|
394
|
+
*
|
|
395
|
+
* @return {string} The text.
|
|
396
|
+
* @spec openspec/changes/record-list-string-rows/specs/record-list-string-rows/spec.md#requirement-a-record-list-draws-rows-that-are-plain-strings
|
|
397
|
+
*/
|
|
398
|
+
noticeText() {
|
|
399
|
+
const parts = this.items.map((item) => item.title).filter((text) => text !== '')
|
|
400
|
+
const lead = typeof this.content?.lead === 'string' && this.content.lead !== '' ? this.withTotal(this.content.lead) : ''
|
|
401
|
+
return [lead, ...parts].filter(Boolean).join(' ')
|
|
402
|
+
},
|
|
403
|
+
|
|
337
404
|
presentation() {
|
|
338
405
|
const value = this.content?.presentation
|
|
339
406
|
return PRESENTATIONS.includes(value) ? value : 'rows'
|
|
@@ -421,7 +488,8 @@ export default {
|
|
|
421
488
|
items() {
|
|
422
489
|
const c = this.content || {}
|
|
423
490
|
const barScale = this.barScale
|
|
424
|
-
return this.rows.slice(0, this.limit).map((
|
|
491
|
+
return this.rows.slice(0, this.limit).map((raw, index) => {
|
|
492
|
+
const row = scalarRow(raw)
|
|
425
493
|
const link = this.rowLink(row)
|
|
426
494
|
return {
|
|
427
495
|
key: (row && (row.id || row['@self']?.id)) || `row-${index}`,
|
|
@@ -430,7 +498,7 @@ export default {
|
|
|
430
498
|
tag: link ? 'router-link' : 'div',
|
|
431
499
|
attrs: link ? { to: link } : {},
|
|
432
500
|
initials: this.initialsFor(row),
|
|
433
|
-
title: this.fill(c.title, row),
|
|
501
|
+
title: this.fill(c.title || (row !== raw ? '{value}' : ''), row),
|
|
434
502
|
meta: this.metaOf(row),
|
|
435
503
|
text: this.fill(c.text, row),
|
|
436
504
|
label: this.fill(c.label, row),
|
|
@@ -638,13 +706,22 @@ export default {
|
|
|
638
706
|
},
|
|
639
707
|
|
|
640
708
|
/**
|
|
641
|
-
*
|
|
709
|
+
* Fill a translated label's counts: `{total}` is the list's total, and
|
|
710
|
+
* `{count:<field>=<value>}` the loaded rows whose field reads that
|
|
711
|
+
* value ("{count:status=done} van {total} stappen klaar"). The count
|
|
712
|
+
* reads the rows the list loaded, so it is exact when `limit` covers
|
|
713
|
+
* the list.
|
|
642
714
|
*
|
|
643
715
|
* @param {string} label The label.
|
|
644
716
|
* @return {string} The text.
|
|
645
717
|
*/
|
|
646
718
|
withTotal(label) {
|
|
647
|
-
return this.translate(label)
|
|
719
|
+
return this.translate(label)
|
|
720
|
+
.replace(/\{total\}/g, String(this.total))
|
|
721
|
+
.replace(/\{count:([^=}]+)=([^}]*)\}/g, (match, field, value) => {
|
|
722
|
+
const wanted = value.trim()
|
|
723
|
+
return String(this.rows.filter((row) => String(readPath(row, field.trim())) === wanted).length)
|
|
724
|
+
})
|
|
648
725
|
},
|
|
649
726
|
|
|
650
727
|
/**
|
|
@@ -1125,6 +1202,36 @@ export default {
|
|
|
1125
1202
|
margin: 0;
|
|
1126
1203
|
}
|
|
1127
1204
|
|
|
1205
|
+
.cn-record-list__notice {
|
|
1206
|
+
display: flex;
|
|
1207
|
+
gap: 10px;
|
|
1208
|
+
align-items: flex-start;
|
|
1209
|
+
padding: 12px 14px;
|
|
1210
|
+
border-radius: var(--border-radius-large, 10px);
|
|
1211
|
+
background: var(--color-primary-element-light, var(--color-background-dark));
|
|
1212
|
+
color: var(--color-main-text);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
.cn-record-list__notice--warning {
|
|
1216
|
+
background: var(--color-warning-light, rgba(255, 196, 0, 0.12));
|
|
1217
|
+
color: var(--color-warning-text, var(--color-main-text));
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
.cn-record-list__notice--error {
|
|
1221
|
+
background: var(--color-error-light, rgba(219, 0, 0, 0.08));
|
|
1222
|
+
color: var(--color-error-text, var(--color-main-text));
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
.cn-record-list__notice-icon {
|
|
1226
|
+
flex: 0 0 auto;
|
|
1227
|
+
margin-top: 1px;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
.cn-record-list__notice-text {
|
|
1231
|
+
margin: 0;
|
|
1232
|
+
line-height: 1.5;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1128
1235
|
.cn-record-list__loading {
|
|
1129
1236
|
display: flex;
|
|
1130
1237
|
justify-content: center;
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
- SPDX-FileCopyrightText: 2026 Conduction B.V.
|
|
3
|
+
- SPDX-License-Identifier: EUPL-1.2
|
|
4
|
+
-->
|
|
5
|
+
|
|
6
|
+
<template>
|
|
7
|
+
<div class="cn-settings-field" :data-testid="`cn-settings-field-${field.key}`">
|
|
8
|
+
<NcCheckboxRadioSwitch
|
|
9
|
+
v-if="kind === 'switch' || kind === 'boolean'"
|
|
10
|
+
:type="kind === 'switch' ? 'switch' : 'checkbox'"
|
|
11
|
+
:modelValue="!!modelValue"
|
|
12
|
+
:disabled="field.readOnly"
|
|
13
|
+
:aria-describedby="describedBy"
|
|
14
|
+
@update:modelValue="emitValue($event)">
|
|
15
|
+
{{ label }}
|
|
16
|
+
</NcCheckboxRadioSwitch>
|
|
17
|
+
<div v-else-if="kind === 'image'"
|
|
18
|
+
class="cn-settings-field__image"
|
|
19
|
+
role="group"
|
|
20
|
+
:aria-labelledby="`${inputId}-label`">
|
|
21
|
+
<span :id="`${inputId}-label`" class="cn-settings-field__image-label">{{ label }}</span>
|
|
22
|
+
<div class="cn-settings-field__image-row">
|
|
23
|
+
<span class="cn-settings-field__image-tile" :data-testid="`cn-settings-field-image-${field.key}`">
|
|
24
|
+
<img v-if="stringValue" :src="stringValue" :alt="t('nextcloud-vue', 'Current {name}', { name: label })">
|
|
25
|
+
<span v-else class="cn-settings-field__image-initials" aria-hidden="true">{{ field.initials || '' }}</span>
|
|
26
|
+
</span>
|
|
27
|
+
<input
|
|
28
|
+
:id="inputId"
|
|
29
|
+
ref="file"
|
|
30
|
+
class="hidden-visually"
|
|
31
|
+
type="file"
|
|
32
|
+
:accept="field.accept || 'image/*'"
|
|
33
|
+
tabindex="-1"
|
|
34
|
+
:aria-labelledby="`${inputId}-label`"
|
|
35
|
+
:aria-describedby="describedBy"
|
|
36
|
+
@change="upload($event.target.files && $event.target.files[0])">
|
|
37
|
+
<NcButton
|
|
38
|
+
v-if="!field.readOnly && field.uploadUrl"
|
|
39
|
+
:disabled="uploading"
|
|
40
|
+
:aria-describedby="describedBy"
|
|
41
|
+
:data-testid="`cn-settings-field-upload-${field.key}`"
|
|
42
|
+
@click="$refs.file && $refs.file.click()">
|
|
43
|
+
<template v-if="uploading" #icon>
|
|
44
|
+
<NcLoadingIcon :size="20" />
|
|
45
|
+
</template>
|
|
46
|
+
{{ field.uploadLabel || t('nextcloud-vue', 'Upload {name}', { name: label }) }}
|
|
47
|
+
</NcButton>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
<NcSelect
|
|
51
|
+
v-else-if="kind === 'select'"
|
|
52
|
+
:inputId="inputId"
|
|
53
|
+
:modelValue="selectedOption"
|
|
54
|
+
:options="normalizedOptions"
|
|
55
|
+
:inputLabel="label"
|
|
56
|
+
:placeholder="field.placeholder || ''"
|
|
57
|
+
:loading="optionsLoading"
|
|
58
|
+
:disabled="field.readOnly"
|
|
59
|
+
:clearable="!field.required"
|
|
60
|
+
label="label"
|
|
61
|
+
:aria-describedby="describedBy"
|
|
62
|
+
@update:modelValue="emitValue(optionValue($event))" />
|
|
63
|
+
<NcTextArea
|
|
64
|
+
v-else-if="kind === 'textarea'"
|
|
65
|
+
:id="inputId"
|
|
66
|
+
:label="label"
|
|
67
|
+
:modelValue="stringValue"
|
|
68
|
+
:rows="field.rows || 4"
|
|
69
|
+
:placeholder="field.placeholder || ''"
|
|
70
|
+
:readonly="field.readOnly"
|
|
71
|
+
:error="!!error"
|
|
72
|
+
:aria-describedby="describedBy"
|
|
73
|
+
@update:modelValue="emitValue($event)" />
|
|
74
|
+
<NcTextField
|
|
75
|
+
v-else-if="kind === 'secret'"
|
|
76
|
+
:id="inputId"
|
|
77
|
+
:label="label"
|
|
78
|
+
type="password"
|
|
79
|
+
autocomplete="new-password"
|
|
80
|
+
:modelValue="stringValue"
|
|
81
|
+
:placeholder="field.secretSet ? secretSetPlaceholder : (field.placeholder || '')"
|
|
82
|
+
:readonly="field.readOnly"
|
|
83
|
+
:error="!!error"
|
|
84
|
+
:aria-describedby="describedBy"
|
|
85
|
+
@update:modelValue="emitValue($event)" />
|
|
86
|
+
<NcTextField
|
|
87
|
+
v-else-if="kind === 'number'"
|
|
88
|
+
:id="inputId"
|
|
89
|
+
:label="label"
|
|
90
|
+
type="number"
|
|
91
|
+
:min="field.min"
|
|
92
|
+
:max="field.max"
|
|
93
|
+
:modelValue="stringValue"
|
|
94
|
+
:readonly="field.readOnly"
|
|
95
|
+
:error="!!error"
|
|
96
|
+
:aria-describedby="describedBy"
|
|
97
|
+
@update:modelValue="emitValue($event === '' ? null : Number($event))" />
|
|
98
|
+
<NcTextField
|
|
99
|
+
v-else
|
|
100
|
+
:id="inputId"
|
|
101
|
+
:label="label"
|
|
102
|
+
:type="kind === 'password' ? 'password' : 'text'"
|
|
103
|
+
:modelValue="stringValue"
|
|
104
|
+
:placeholder="field.placeholder || ''"
|
|
105
|
+
:readonly="field.readOnly"
|
|
106
|
+
:error="!!error"
|
|
107
|
+
:aria-describedby="describedBy"
|
|
108
|
+
@update:modelValue="emitValue($event)" />
|
|
109
|
+
<p v-if="shownError"
|
|
110
|
+
:id="`${inputId}-error`"
|
|
111
|
+
class="cn-settings-field__error"
|
|
112
|
+
:data-testid="`cn-settings-field-error-${field.key}`">
|
|
113
|
+
{{ shownError }}
|
|
114
|
+
</p>
|
|
115
|
+
<p v-else-if="hint" :id="`${inputId}-hint`" class="cn-settings-field__hint">
|
|
116
|
+
{{ hint }}
|
|
117
|
+
</p>
|
|
118
|
+
</div>
|
|
119
|
+
</template>
|
|
120
|
+
|
|
121
|
+
<script>
|
|
122
|
+
import axios from '@nextcloud/axios'
|
|
123
|
+
import { translate as t } from '@nextcloud/l10n'
|
|
124
|
+
import { NcButton, NcCheckboxRadioSwitch, NcLoadingIcon, NcSelect, NcTextArea, NcTextField } from '@nextcloud/vue'
|
|
125
|
+
import { buildHeaders, prefixUrl } from '../../utils/headers.js'
|
|
126
|
+
import { readPath } from '../../utils/readPath.js'
|
|
127
|
+
import { serverErrorMessage } from '../../utils/serverErrorMessage.js'
|
|
128
|
+
|
|
129
|
+
let fieldUid = 0
|
|
130
|
+
|
|
131
|
+
/** Field types and the names CnSettingsPage's manifest uses for them. */
|
|
132
|
+
const KIND_ALIASES = { enum: 'select', string: 'text' }
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* CnSettingsField - one settings field: the control for its type with its
|
|
136
|
+
* label, and under it an error or a hint. Shared by CnSettingsPage and
|
|
137
|
+
* CnSettingsFieldsSection.
|
|
138
|
+
*
|
|
139
|
+
* Types: `text` (default), `textarea`, `number`, `switch`, `boolean` (a checkbox),
|
|
140
|
+
* `select` (`enum`) with static `options` or options read from `optionsUrl`,
|
|
141
|
+
* `password` (a masked input that shows its value back) and `secret`
|
|
142
|
+
* (replace-only: the input stays empty, and with `secretSet` says a value is
|
|
143
|
+
* stored; the host sends it only when something is typed), and `image` (a
|
|
144
|
+
* preview tile, the initials without an image, and an upload button that
|
|
145
|
+
* posts the file to `uploadUrl` at once and emits `uploaded`).
|
|
146
|
+
*
|
|
147
|
+
* ```vue
|
|
148
|
+
* <CnSettingsField
|
|
149
|
+
* :field="{ key: 'apiKey', label: 'API key', type: 'secret', secretSet: true }"
|
|
150
|
+
* v-model="form.apiKey" />
|
|
151
|
+
* ```
|
|
152
|
+
*/
|
|
153
|
+
export default {
|
|
154
|
+
name: 'CnSettingsField',
|
|
155
|
+
|
|
156
|
+
components: { NcButton, NcCheckboxRadioSwitch, NcLoadingIcon, NcSelect, NcTextArea, NcTextField },
|
|
157
|
+
|
|
158
|
+
props: {
|
|
159
|
+
/**
|
|
160
|
+
* The field: `{ key, label, type?, options?, optionsUrl?, optionsPath?,
|
|
161
|
+
* optionLabel?, optionValue?, placeholder?, rows?, min?, max?, required?,
|
|
162
|
+
* readOnly?, secretSet? }`. `label` arrives translated.
|
|
163
|
+
*
|
|
164
|
+
* @type {object}
|
|
165
|
+
*/
|
|
166
|
+
field: {
|
|
167
|
+
type: Object,
|
|
168
|
+
required: true,
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The value: text, a number, a boolean or a select value. Any type,
|
|
173
|
+
* because a Boolean in a type list would read an empty string as true.
|
|
174
|
+
*/
|
|
175
|
+
modelValue: {
|
|
176
|
+
type: null,
|
|
177
|
+
default: null,
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
/** A validation message, drawn under the control and tied to it. */
|
|
181
|
+
error: {
|
|
182
|
+
type: String,
|
|
183
|
+
default: '',
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
/** A hint under the control (not drawn while there is an error). */
|
|
187
|
+
hint: {
|
|
188
|
+
type: String,
|
|
189
|
+
default: '',
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
emits: ['update:modelValue', 'uploaded'],
|
|
194
|
+
|
|
195
|
+
data() {
|
|
196
|
+
fieldUid += 1
|
|
197
|
+
return {
|
|
198
|
+
uid: fieldUid,
|
|
199
|
+
fetchedOptions: null,
|
|
200
|
+
optionsLoading: false,
|
|
201
|
+
uploading: false,
|
|
202
|
+
uploadError: '',
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
computed: {
|
|
207
|
+
/** @return {string} The control kind. */
|
|
208
|
+
kind() {
|
|
209
|
+
const type = String(this.field.type || 'text')
|
|
210
|
+
return KIND_ALIASES[type] || type
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
/** @return {string} The label. */
|
|
214
|
+
label() {
|
|
215
|
+
return String(this.field.label || this.field.key || '')
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
/** @return {string} The control's id. */
|
|
219
|
+
inputId() {
|
|
220
|
+
return `cn-settings-field-${this.uid}-${String(this.field.key).replace(/[^A-Za-z0-9_-]/g, '-')}`
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
/** @return {string} The message shown under the control: an upload failure, else `error`. */
|
|
224
|
+
shownError() {
|
|
225
|
+
return this.uploadError || this.error
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
/** @return {string|null} The id of the error or hint the control is described by. */
|
|
229
|
+
describedBy() {
|
|
230
|
+
if (this.shownError) {
|
|
231
|
+
return `${this.inputId}-error`
|
|
232
|
+
}
|
|
233
|
+
return this.hint ? `${this.inputId}-hint` : null
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
/** @return {string} The value as text for a text control. */
|
|
237
|
+
stringValue() {
|
|
238
|
+
return this.modelValue === null || this.modelValue === undefined ? '' : String(this.modelValue)
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
/** @return {string} The placeholder of a secret that is stored. */
|
|
242
|
+
secretSetPlaceholder() {
|
|
243
|
+
return t('nextcloud-vue', 'Stored. Type a new value to replace it.')
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The select options as `{ label, value }`: the static `options` (strings,
|
|
248
|
+
* numbers or objects), or those read from `optionsUrl`.
|
|
249
|
+
*
|
|
250
|
+
* @return {Array<{label: string, value: (string|number|boolean)}>}
|
|
251
|
+
*/
|
|
252
|
+
normalizedOptions() {
|
|
253
|
+
const raw = Array.isArray(this.field.options) ? this.field.options : (this.fetchedOptions || [])
|
|
254
|
+
return raw.map((option) => (option && typeof option === 'object'
|
|
255
|
+
? { ...option, label: String(option.label ?? option.value ?? ''), value: option.value }
|
|
256
|
+
: { label: String(option), value: option }))
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
/** @return {object|null} The option the value selects, for NcSelect. */
|
|
260
|
+
selectedOption() {
|
|
261
|
+
if (this.modelValue === null || this.modelValue === undefined || this.modelValue === '') {
|
|
262
|
+
return null
|
|
263
|
+
}
|
|
264
|
+
return this.normalizedOptions.find((option) => option.value === this.modelValue)
|
|
265
|
+
|| { label: String(this.modelValue), value: this.modelValue }
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
watch: {
|
|
270
|
+
'field.optionsUrl': {
|
|
271
|
+
handler() {
|
|
272
|
+
this.loadOptions()
|
|
273
|
+
},
|
|
274
|
+
|
|
275
|
+
immediate: true,
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
|
|
279
|
+
methods: {
|
|
280
|
+
t,
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Upload a chosen image to `uploadUrl` as multipart form data (the
|
|
284
|
+
* file under `uploadField`, default `image`, plus `uploadParams`), then
|
|
285
|
+
* take the new image url from the answer at `uploadValuePath` (or a
|
|
286
|
+
* cache-busted `previewUrl`).
|
|
287
|
+
*
|
|
288
|
+
* @param {File|undefined} file The chosen file.
|
|
289
|
+
* @return {Promise<void>}
|
|
290
|
+
* @spec openspec/changes/settings-fields-section/specs/settings-fields-section/spec.md#requirement-an-image-field-uploads-at-once
|
|
291
|
+
*/
|
|
292
|
+
async upload(file) {
|
|
293
|
+
if (!file || !this.field.uploadUrl) {
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
const form = new FormData()
|
|
297
|
+
for (const [name, value] of Object.entries(this.field.uploadParams || {})) {
|
|
298
|
+
form.append(name, value)
|
|
299
|
+
}
|
|
300
|
+
form.append(this.field.uploadField || 'image', file)
|
|
301
|
+
this.uploading = true
|
|
302
|
+
this.uploadError = ''
|
|
303
|
+
try {
|
|
304
|
+
const response = await axios.post(prefixUrl(this.field.uploadUrl), form)
|
|
305
|
+
const fromAnswer = this.field.uploadValuePath ? readPath(response?.data, this.field.uploadValuePath) : ''
|
|
306
|
+
const next = fromAnswer || (this.field.previewUrl ? `${prefixUrl(this.field.previewUrl)}${this.field.previewUrl.includes('?') ? '&' : '?'}v=${Date.now()}` : '')
|
|
307
|
+
/**
|
|
308
|
+
* @event uploaded An image was uploaded. Payload: its new url.
|
|
309
|
+
*/
|
|
310
|
+
this.$emit('uploaded', next)
|
|
311
|
+
} catch (error) {
|
|
312
|
+
this.uploadError = t('nextcloud-vue', 'The image could not be uploaded: {reason}', { reason: serverErrorMessage(error) })
|
|
313
|
+
} finally {
|
|
314
|
+
this.uploading = false
|
|
315
|
+
if (this.$refs.file) {
|
|
316
|
+
this.$refs.file.value = ''
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Report a new value.
|
|
323
|
+
*
|
|
324
|
+
* @param {unknown} value The value.
|
|
325
|
+
* @return {void}
|
|
326
|
+
*/
|
|
327
|
+
emitValue(value) {
|
|
328
|
+
/**
|
|
329
|
+
* @event update:modelValue The value changed. Payload: the new value.
|
|
330
|
+
*/
|
|
331
|
+
this.$emit('update:modelValue', value)
|
|
332
|
+
},
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* The storable value of what NcSelect emits.
|
|
336
|
+
*
|
|
337
|
+
* @param {object|string|number|null} emitted An option, or null on clear.
|
|
338
|
+
* @return {string|number|boolean|null}
|
|
339
|
+
*/
|
|
340
|
+
optionValue(emitted) {
|
|
341
|
+
if (emitted === null || emitted === undefined) {
|
|
342
|
+
return null
|
|
343
|
+
}
|
|
344
|
+
return typeof emitted === 'object' && 'value' in emitted ? emitted.value : emitted
|
|
345
|
+
},
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Read a sourced select's options: a list (or the list at
|
|
349
|
+
* `optionsPath`, or under `results`) mapped through `optionLabel` and
|
|
350
|
+
* `optionValue` (dotted paths; default `label`/`title`/`name` and `value`/`id`).
|
|
351
|
+
*
|
|
352
|
+
* @return {Promise<void>}
|
|
353
|
+
*/
|
|
354
|
+
async loadOptions() {
|
|
355
|
+
const url = this.kind === 'select' && !Array.isArray(this.field.options) ? this.field.optionsUrl : ''
|
|
356
|
+
if (!url) {
|
|
357
|
+
return
|
|
358
|
+
}
|
|
359
|
+
this.optionsLoading = true
|
|
360
|
+
try {
|
|
361
|
+
const response = await fetch(prefixUrl(url), { headers: buildHeaders() })
|
|
362
|
+
if (!response.ok) {
|
|
363
|
+
this.fetchedOptions = []
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
const data = await response.json()
|
|
367
|
+
const list = this.field.optionsPath ? readPath(data, this.field.optionsPath) : (Array.isArray(data) ? data : data?.results)
|
|
368
|
+
this.fetchedOptions = (Array.isArray(list) ? list : []).map((item) => {
|
|
369
|
+
if (!item || typeof item !== 'object') {
|
|
370
|
+
return { label: String(item), value: item }
|
|
371
|
+
}
|
|
372
|
+
const value = this.field.optionValue ? readPath(item, this.field.optionValue) : (item.value ?? item.id)
|
|
373
|
+
const label = this.field.optionLabel ? readPath(item, this.field.optionLabel) : (item.label ?? item.title ?? item.name ?? value)
|
|
374
|
+
return { label: String(label ?? ''), value }
|
|
375
|
+
})
|
|
376
|
+
} catch {
|
|
377
|
+
this.fetchedOptions = []
|
|
378
|
+
} finally {
|
|
379
|
+
this.optionsLoading = false
|
|
380
|
+
}
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
}
|
|
384
|
+
</script>
|
|
385
|
+
|
|
386
|
+
<style scoped>
|
|
387
|
+
.cn-settings-field {
|
|
388
|
+
display: flex;
|
|
389
|
+
flex-direction: column;
|
|
390
|
+
gap: 4px;
|
|
391
|
+
min-width: 0;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
.cn-settings-field__hint,
|
|
395
|
+
.cn-settings-field__error {
|
|
396
|
+
margin: 0;
|
|
397
|
+
font-size: 13px;
|
|
398
|
+
line-height: 1.4;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
.cn-settings-field__image {
|
|
402
|
+
display: flex;
|
|
403
|
+
flex-direction: column;
|
|
404
|
+
gap: 6px;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
.cn-settings-field__image-label {
|
|
408
|
+
font-weight: 600;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
.cn-settings-field__image-row {
|
|
412
|
+
display: flex;
|
|
413
|
+
flex-wrap: wrap;
|
|
414
|
+
align-items: center;
|
|
415
|
+
gap: 12px;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
.cn-settings-field__image-tile {
|
|
419
|
+
display: inline-flex;
|
|
420
|
+
align-items: center;
|
|
421
|
+
justify-content: center;
|
|
422
|
+
width: 64px;
|
|
423
|
+
height: 64px;
|
|
424
|
+
overflow: hidden;
|
|
425
|
+
border: 1px solid var(--color-border);
|
|
426
|
+
border-radius: var(--border-radius-large);
|
|
427
|
+
background: var(--color-primary-element-light);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
.cn-settings-field__image-tile img {
|
|
431
|
+
max-width: 100%;
|
|
432
|
+
max-height: 100%;
|
|
433
|
+
object-fit: contain;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
.cn-settings-field__image-initials {
|
|
437
|
+
font-size: 20px;
|
|
438
|
+
font-weight: 700;
|
|
439
|
+
color: var(--color-primary-element-light-text, var(--color-primary-element));
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
.cn-settings-field__hint {
|
|
443
|
+
color: var(--color-text-maxcontrast);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
.cn-settings-field__error {
|
|
447
|
+
color: var(--color-error-text, var(--color-error));
|
|
448
|
+
}
|
|
449
|
+
</style>
|