@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.
Files changed (141) hide show
  1. package/dist/esm/components/CnActionsMenu/CnActionsMenu.vue2.js +12 -0
  2. package/dist/esm/components/CnActionsMenu/CnActionsMenu.vue2.js.map +1 -1
  3. package/dist/esm/components/CnActionsMenu/CnActionsMenu.vue3.js +11 -2
  4. package/dist/esm/components/CnActionsMenu/CnActionsMenu.vue3.js.map +1 -1
  5. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue.js +1 -1
  6. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue2.js +6 -1
  7. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue2.js.map +1 -1
  8. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue3.js +2 -1
  9. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue3.js.map +1 -1
  10. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue.js +1 -1
  11. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue2.js.map +1 -1
  12. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js +1 -1
  13. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js.map +1 -1
  14. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +25 -8
  15. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  16. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +7 -4
  17. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  18. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue.js +1 -1
  19. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js +11 -8
  20. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js.map +1 -1
  21. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js +13 -4
  22. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js.map +1 -1
  23. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue.js +1 -1
  24. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js +54 -3
  25. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js.map +1 -1
  26. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js +16 -2
  27. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js.map +1 -1
  28. package/dist/esm/components/CnFlowsPage/CnFlowBoard.vue.js +1 -1
  29. package/dist/esm/components/CnFlowsPage/CnFlowBoard.vue2.js +42 -7
  30. package/dist/esm/components/CnFlowsPage/CnFlowBoard.vue2.js.map +1 -1
  31. package/dist/esm/components/CnFlowsPage/CnFlowBoard.vue3.js +121 -57
  32. package/dist/esm/components/CnFlowsPage/CnFlowBoard.vue3.js.map +1 -1
  33. package/dist/esm/components/CnFlowsPage/CnFlowEditorPage.vue2.js +10 -0
  34. package/dist/esm/components/CnFlowsPage/CnFlowEditorPage.vue2.js.map +1 -1
  35. package/dist/esm/components/CnFlowsPage/CnFlowEditorPage.vue3.js +2 -1
  36. package/dist/esm/components/CnFlowsPage/CnFlowEditorPage.vue3.js.map +1 -1
  37. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +136 -7
  38. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  39. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  40. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue.js +1 -1
  41. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue2.js +26 -7
  42. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue2.js.map +1 -1
  43. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue3.js +78 -17
  44. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue3.js.map +1 -1
  45. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +1 -1
  46. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +68 -7
  47. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -1
  48. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js +256 -240
  49. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -1
  50. package/dist/esm/components/CnSettingsField/CnSettingsField.vue.js +9 -0
  51. package/dist/esm/components/CnSettingsField/CnSettingsField.vue.js.map +1 -0
  52. package/dist/esm/components/CnSettingsField/CnSettingsField.vue2.js +265 -0
  53. package/dist/esm/components/CnSettingsField/CnSettingsField.vue2.js.map +1 -0
  54. package/dist/esm/components/CnSettingsField/CnSettingsField.vue3.js +191 -0
  55. package/dist/esm/components/CnSettingsField/CnSettingsField.vue3.js.map +1 -0
  56. package/dist/esm/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue.js +9 -0
  57. package/dist/esm/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue.js.map +1 -0
  58. package/dist/esm/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue2.js +537 -0
  59. package/dist/esm/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue2.js.map +1 -0
  60. package/dist/esm/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue3.js +99 -0
  61. package/dist/esm/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue3.js.map +1 -0
  62. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue.js +1 -1
  63. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue2.js +18 -70
  64. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue2.js.map +1 -1
  65. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue3.js +6 -44
  66. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue3.js.map +1 -1
  67. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue.js +1 -1
  68. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js +16 -2
  69. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js.map +1 -1
  70. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js +40 -25
  71. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js.map +1 -1
  72. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
  73. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js.map +1 -1
  74. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue.js +1 -1
  75. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue2.js +7 -7
  76. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue2.js.map +1 -1
  77. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue3.js.map +1 -1
  78. package/dist/esm/components/CnWorkingCalendarWidget/CnWorkingCalendarWidget.vue.js +9 -0
  79. package/dist/esm/components/CnWorkingCalendarWidget/CnWorkingCalendarWidget.vue.js.map +1 -0
  80. package/dist/esm/components/CnWorkingCalendarWidget/CnWorkingCalendarWidget.vue2.js +472 -0
  81. package/dist/esm/components/CnWorkingCalendarWidget/CnWorkingCalendarWidget.vue2.js.map +1 -0
  82. package/dist/esm/components/CnWorkingCalendarWidget/CnWorkingCalendarWidget.vue3.js +231 -0
  83. package/dist/esm/components/CnWorkingCalendarWidget/CnWorkingCalendarWidget.vue3.js.map +1 -0
  84. package/dist/esm/components/CnWorkingCalendarWidget/index.js +27 -0
  85. package/dist/esm/components/CnWorkingCalendarWidget/index.js.map +1 -0
  86. package/dist/esm/composables/useFlowStore.js +2 -1
  87. package/dist/esm/composables/useFlowStore.js.map +1 -1
  88. package/dist/esm/index.js +3 -0
  89. package/dist/esm/index.js.map +1 -1
  90. package/dist/esm/l10n/en.json.js +101 -1
  91. package/dist/esm/l10n/en.json.js.map +1 -1
  92. package/dist/esm/l10n/nl.json.js +115 -1
  93. package/dist/esm/l10n/nl.json.js.map +1 -1
  94. package/dist/esm/library-style.js +1 -1
  95. package/dist/esm/nextcloud-vue.css +999 -233
  96. package/dist/esm/utils/libraryWidgetKeys.js +1 -0
  97. package/dist/esm/utils/libraryWidgetKeys.js.map +1 -1
  98. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  99. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  100. package/dist/esm/utils/workingCalendarMonth.js +85 -0
  101. package/dist/esm/utils/workingCalendarMonth.js.map +1 -0
  102. package/dist/nextcloud-vue.cjs.js +10314 -7778
  103. package/dist/nextcloud-vue.cjs.js.map +2 -2
  104. package/dist/nextcloud-vue.css +999 -233
  105. package/l10n/en.json +101 -1
  106. package/l10n/nl.json +115 -1
  107. package/package.json +1 -1
  108. package/src/components/CnActionsMenu/CnActionsMenu.vue +14 -1
  109. package/src/components/CnAdminSettingsShell/CnAdminSettingsShell.vue +7 -1
  110. package/src/components/CnBreadcrumbs/CnBreadcrumbs.vue +1 -0
  111. package/src/components/CnDetailPage/CnDetailPage.vue +29 -9
  112. package/src/components/CnDetailWidgetHost/CnDetailWidgetHost.vue +13 -4
  113. package/src/components/CnFactsWidget/CnFactsWidget.vue +65 -3
  114. package/src/components/CnFlowsPage/CnFlowBoard.vue +77 -2
  115. package/src/components/CnFlowsPage/CnFlowEditorPage.vue +11 -0
  116. package/src/components/CnIndexPage/CnIndexPage.vue +136 -7
  117. package/src/components/CnOrderedListField/CnOrderedListField.vue +81 -9
  118. package/src/components/CnRecordListWidget/CnRecordListWidget.vue +114 -7
  119. package/src/components/CnSettingsField/CnSettingsField.vue +449 -0
  120. package/src/components/CnSettingsField/index.js +4 -0
  121. package/src/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue +642 -0
  122. package/src/components/CnSettingsFieldsSection/index.js +4 -0
  123. package/src/components/CnSettingsPage/CnSettingsPage.vue +6 -86
  124. package/src/components/CnSetupWizard/CnSetupWizard.vue +37 -3
  125. package/src/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
  126. package/src/components/CnWizardDialog/CnWizardDialog.vue +7 -7
  127. package/src/components/CnWorkingCalendarWidget/CnWorkingCalendarWidget.vue +725 -0
  128. package/src/components/CnWorkingCalendarWidget/index.js +26 -0
  129. package/src/components/index.js +3 -0
  130. package/src/composables/useFlowStore.js +2 -1
  131. package/src/css/badge.css +32 -16
  132. package/src/css/detail-page.css +15 -0
  133. package/src/css/dialog.css +48 -0
  134. package/src/css/look-board-detail.css +80 -8
  135. package/src/css/look-board-index.css +52 -8
  136. package/src/css/look-board.css +301 -1
  137. package/src/index.js +3 -0
  138. package/src/schemas/app-manifest-v2.schema.json +25 -1
  139. package/src/utils/libraryWidgetKeys.js +1 -0
  140. package/src/utils/validateManifestV2.compiled.js +3 -3
  141. 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((row, index) => {
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
- * Replace `{total}` in a translated label.
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).replace(/\{total\}/g, String(this.total))
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>
@@ -0,0 +1,4 @@
1
+ import CnSettingsField from './CnSettingsField.vue'
2
+
3
+ export default CnSettingsField
4
+ export { CnSettingsField }