@conduction/nextcloud-vue 2.99.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 (79) hide show
  1. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue.js +1 -1
  2. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue2.js +6 -1
  3. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue2.js.map +1 -1
  4. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue3.js +2 -1
  5. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue3.js.map +1 -1
  6. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +25 -8
  7. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  8. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +5 -3
  9. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  10. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue.js +1 -1
  11. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js +11 -8
  12. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js.map +1 -1
  13. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js +13 -4
  14. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js.map +1 -1
  15. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue.js +1 -1
  16. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js +54 -3
  17. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js.map +1 -1
  18. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js +16 -2
  19. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js.map +1 -1
  20. package/dist/esm/components/CnFlowsPage/CnFlowBoard.vue.js +1 -1
  21. package/dist/esm/components/CnFlowsPage/CnFlowBoard.vue2.js +42 -7
  22. package/dist/esm/components/CnFlowsPage/CnFlowBoard.vue2.js.map +1 -1
  23. package/dist/esm/components/CnFlowsPage/CnFlowBoard.vue3.js +121 -57
  24. package/dist/esm/components/CnFlowsPage/CnFlowBoard.vue3.js.map +1 -1
  25. package/dist/esm/components/CnFlowsPage/CnFlowEditorPage.vue2.js +10 -0
  26. package/dist/esm/components/CnFlowsPage/CnFlowEditorPage.vue2.js.map +1 -1
  27. package/dist/esm/components/CnFlowsPage/CnFlowEditorPage.vue3.js +2 -1
  28. package/dist/esm/components/CnFlowsPage/CnFlowEditorPage.vue3.js.map +1 -1
  29. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +136 -7
  30. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  31. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  32. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +1 -1
  33. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +68 -7
  34. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -1
  35. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js +256 -240
  36. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -1
  37. package/dist/esm/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue.js +1 -1
  38. package/dist/esm/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue2.js +38 -9
  39. package/dist/esm/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue2.js.map +1 -1
  40. package/dist/esm/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue3.js.map +1 -1
  41. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue.js +1 -1
  42. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js +16 -2
  43. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js.map +1 -1
  44. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js +40 -25
  45. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js.map +1 -1
  46. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue.js +1 -1
  47. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue2.js +7 -7
  48. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue2.js.map +1 -1
  49. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue3.js.map +1 -1
  50. package/dist/esm/l10n/en.json.js +7 -1
  51. package/dist/esm/l10n/en.json.js.map +1 -1
  52. package/dist/esm/l10n/nl.json.js +7 -1
  53. package/dist/esm/l10n/nl.json.js.map +1 -1
  54. package/dist/esm/library-style.js +1 -1
  55. package/dist/esm/nextcloud-vue.css +348 -161
  56. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  57. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  58. package/dist/nextcloud-vue.cjs.js +1482 -998
  59. package/dist/nextcloud-vue.cjs.js.map +2 -2
  60. package/dist/nextcloud-vue.css +348 -161
  61. package/l10n/en.json +7 -1
  62. package/l10n/nl.json +7 -1
  63. package/package.json +1 -1
  64. package/src/components/CnAdminSettingsShell/CnAdminSettingsShell.vue +7 -1
  65. package/src/components/CnDetailPage/CnDetailPage.vue +28 -9
  66. package/src/components/CnDetailWidgetHost/CnDetailWidgetHost.vue +13 -4
  67. package/src/components/CnFactsWidget/CnFactsWidget.vue +65 -3
  68. package/src/components/CnFlowsPage/CnFlowBoard.vue +77 -2
  69. package/src/components/CnFlowsPage/CnFlowEditorPage.vue +11 -0
  70. package/src/components/CnIndexPage/CnIndexPage.vue +136 -7
  71. package/src/components/CnRecordListWidget/CnRecordListWidget.vue +114 -7
  72. package/src/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue +38 -9
  73. package/src/components/CnSetupWizard/CnSetupWizard.vue +37 -3
  74. package/src/components/CnWizardDialog/CnWizardDialog.vue +7 -7
  75. package/src/css/detail-page.css +15 -0
  76. package/src/css/dialog.css +48 -0
  77. package/src/css/look-board.css +83 -1
  78. package/src/schemas/app-manifest-v2.schema.json +25 -1
  79. package/src/utils/validateManifestV2.compiled.js +3 -3
@@ -1578,6 +1578,8 @@ export default {
1578
1578
  */
1579
1579
  inject: {
1580
1580
  cnCustomComponents: { default: () => ({}) },
1581
+ /** The permissions the user holds, provided by CnAppRoot; for gated page tabs. */
1582
+ cnPermissions: { default: () => [] },
1581
1583
  /** The app id CnAppRoot provides, for the plural form of `countSubtitle`. */
1582
1584
  cnAppId: { default: '' },
1583
1585
  /**
@@ -1668,7 +1670,9 @@ export default {
1668
1670
 
1669
1671
  /**
1670
1672
  * A note after the breadcrumb and a middle dot (manifest `config.headerMeta`),
1671
- * such as "{total} open": `{total}` and `{shown}` are the list's counts.
1673
+ * such as "{total} open": `{total}` and `{shown}` are the list's counts,
1674
+ * and `{count:<name>}` the count of a list named in `headerMetaCounts`
1675
+ * ("{total} contracten bij {count:suppliers} leveranciers").
1672
1676
  *
1673
1677
  * @spec openspec/changes/page-header-breadcrumb/specs/page-header/spec.md#requirement-a-list-dashboard-or-settings-page-can-show-a-breadcrumb-and-a-meta-note
1674
1678
  */
@@ -1677,12 +1681,29 @@ export default {
1677
1681
  default: '',
1678
1682
  },
1679
1683
 
1684
+ /**
1685
+ * The other lists `headerMeta` counts (manifest `config.headerMetaCounts`):
1686
+ * `{ <name>: { register, schema, filter? } }`, each read as one list
1687
+ * total and filled in for `{count:<name>}`. While a count loads, or when
1688
+ * it fails, the note is left out rather than shown with a gap.
1689
+ *
1690
+ * @type {Record<string, {register: string, schema: string, filter?: object}>|null}
1691
+ */
1692
+ headerMetaCounts: {
1693
+ type: Object,
1694
+ default: null,
1695
+ },
1696
+
1680
1697
  /**
1681
1698
  * Tabs to sibling pages above the toolbar (manifest `config.pageTabs`),
1682
1699
  * each `{ id?, label, route?, params?, query?, href?, countFrom?: {
1683
- * register, schema, filter? } }`: DqPrivacy's "AVG-verzoeken |
1684
- * Datalekken". The current page's tab is marked; `countFrom` adds the
1685
- * count of that list. Wins over a named source's own page tabs.
1700
+ * register, schema, filter? }, adminOnly?, permission? }`: DqPrivacy's
1701
+ * "AVG-verzoeken | Datalekken". The current page's tab is marked;
1702
+ * `countFrom` adds the count of that list. A tab with `adminOnly` shows
1703
+ * only to an instance admin, one with `permission` only to a user who
1704
+ * holds it. That hides the tab and nothing more: the page it points to
1705
+ * must still refuse the user on the server. Wins over a named source's
1706
+ * own page tabs.
1686
1707
  *
1687
1708
  * @type {Array<object>|null}
1688
1709
  * @spec openspec/changes/index-page-tabs-notice/specs/index-page/spec.md#requirement-an-index-page-can-link-its-sibling-pages-as-tabs
@@ -3722,6 +3743,8 @@ export default {
3722
3743
  filterStripCounts: null,
3723
3744
  /** The `countFrom` counts of the manifest page tabs, by index. */
3724
3745
  pageTabCounts: {},
3746
+ /** The `headerMetaCounts` totals, by name; a failed one is null. */
3747
+ headerMetaCountValues: {},
3725
3748
  /** The tabs `quickFiltersFrom` produced, or null before they load. */
3726
3749
  generatedQuickFilters: null,
3727
3750
  /** Count per saved-view id, for the views control; null = none. */
@@ -6202,9 +6225,13 @@ export default {
6202
6225
  if (!Array.isArray(this.pageTabs) || this.pageTabs.length === 0) {
6203
6226
  return this.sourcePageTabs
6204
6227
  }
6228
+ // The index is the tab's place in `pageTabs`, which is how its count
6229
+ // is keyed, so the tabs are numbered before any is left out.
6205
6230
  return this.pageTabs
6206
- .filter((tab) => tab && typeof tab.label === 'string' && tab.label !== '' && (tab.route || tab.to || tab.href))
6207
- .map((tab, index) => {
6231
+ .map((tab, index) => ({ tab, index }))
6232
+ .filter(({ tab }) => tab && typeof tab.label === 'string' && tab.label !== '' && (tab.route || tab.to || tab.href))
6233
+ .filter(({ tab }) => this.pageTabVisible(tab))
6234
+ .map(({ tab, index }) => {
6208
6235
  const to = tab.route
6209
6236
  ? { name: tab.route, ...(tab.params ? { params: tab.params } : {}), ...(tab.query ? { query: tab.query } : {}) }
6210
6237
  : (tab.to || tab.href)
@@ -6646,9 +6673,41 @@ export default {
6646
6673
  return ''
6647
6674
  }
6648
6675
  const total = this.effectivePagination?.total
6649
- return this.cnTranslate(headerMeta)
6676
+ let missing = false
6677
+ const text = this.cnTranslate(headerMeta)
6650
6678
  .replace('{shown}', String(this.effectiveObjects.length))
6651
6679
  .replace('{total}', typeof total === 'number' ? String(total) : '')
6680
+ .replace(/\{count:([\w-]+)\}/g, (match, name) => {
6681
+ const value = this.headerMetaCountValues[name]
6682
+ if (typeof value !== 'number') {
6683
+ missing = true
6684
+ return ''
6685
+ }
6686
+ return String(value)
6687
+ })
6688
+ return missing ? '' : text
6689
+ },
6690
+
6691
+ /**
6692
+ * Whether the header note carries a count, so the board leaves out its
6693
+ * own "{shown} of {total}" line (DqPrivacy, DqContracten).
6694
+ *
6695
+ * @return {boolean}
6696
+ */
6697
+ headerMetaCarriesCount() {
6698
+ const headerMeta = this.presented('headerMeta')
6699
+ return typeof headerMeta === 'string' && /\{(shown|total|count:[\w-]+)\}/.test(headerMeta)
6700
+ },
6701
+
6702
+ /**
6703
+ * What the header note's other counts depend on, as one key.
6704
+ *
6705
+ * @return {string}
6706
+ */
6707
+ headerMetaCountKey() {
6708
+ const sources = this.headerMetaCounts && typeof this.headerMetaCounts === 'object' ? this.headerMetaCounts : {}
6709
+ const valid = Object.entries(sources).filter(([, from]) => from && from.register && from.schema)
6710
+ return valid.length > 0 ? JSON.stringify(valid) : ''
6652
6711
  },
6653
6712
 
6654
6713
  headerDescription() {
@@ -6661,6 +6720,10 @@ export default {
6661
6720
  // reads "14 van 14" in Dutch; a page's own template goes
6662
6721
  // through the app's label lookup.
6663
6722
  const countText = this.presented('countText')
6723
+ // The note after the breadcrumb already says how many there are.
6724
+ if (!countText && !this.countSubtitle && this.headerMetaCarriesCount) {
6725
+ return this.description
6726
+ }
6664
6727
  if (!countText && !this.countSubtitle) {
6665
6728
  return t('nextcloud-vue', '{shown} of {total}', { shown, total })
6666
6729
  }
@@ -6907,6 +6970,13 @@ export default {
6907
6970
  },
6908
6971
  },
6909
6972
 
6973
+ headerMetaCountKey: {
6974
+ immediate: true,
6975
+ handler() {
6976
+ this.loadHeaderMetaCounts()
6977
+ },
6978
+ },
6979
+
6910
6980
  pageTabCountKey: {
6911
6981
  immediate: true,
6912
6982
  /** @spec openspec/changes/index-page-tabs-notice/specs/index-page/spec.md#requirement-an-index-page-can-link-its-sibling-pages-as-tabs */
@@ -7326,6 +7396,65 @@ export default {
7326
7396
  ]
7327
7397
  },
7328
7398
 
7399
+ /**
7400
+ * Whether Nextcloud says the user administers this instance; false
7401
+ * when the user cannot be read.
7402
+ *
7403
+ * @return {boolean}
7404
+ */
7405
+ viewerIsAdmin() {
7406
+ try {
7407
+ return getCurrentUser()?.isAdmin === true
7408
+ } catch {
7409
+ return false
7410
+ }
7411
+ },
7412
+
7413
+ /**
7414
+ * Whether a manifest page tab shows to this user: an `adminOnly` tab to
7415
+ * an instance admin, a tab with a `permission` to a user who holds it
7416
+ * (as a menu entry: with no permissions known, it shows). This only
7417
+ * decides whether the tab is drawn. It is not access control; the
7418
+ * page behind the tab must enforce its own access on the server.
7419
+ *
7420
+ * @param {object} tab The tab declaration.
7421
+ * @return {boolean} True when the tab is drawn.
7422
+ */
7423
+ pageTabVisible(tab) {
7424
+ if (tab.adminOnly === true && !this.viewerIsAdmin()) {
7425
+ return false
7426
+ }
7427
+ if (tab.permission) {
7428
+ const held = Array.isArray(this.cnPermissions) ? this.cnPermissions : []
7429
+ if (held.length > 0 && !held.includes(tab.permission)) {
7430
+ return false
7431
+ }
7432
+ }
7433
+ return true
7434
+ },
7435
+
7436
+ /**
7437
+ * Read the totals of the lists `headerMetaCounts` names, one list
7438
+ * request each. A failed count is null and leaves the note out.
7439
+ *
7440
+ * @return {Promise<void>}
7441
+ */
7442
+ async loadHeaderMetaCounts() {
7443
+ const key = this.headerMetaCountKey
7444
+ if (key === '') {
7445
+ this.headerMetaCountValues = {}
7446
+ return
7447
+ }
7448
+ const ctx = typeof this.selfFetchTokenCtx === 'function' ? this.selfFetchTokenCtx() : {}
7449
+ const values = {}
7450
+ await Promise.all(JSON.parse(key).map(async ([name, from]) => {
7451
+ values[name] = await fetchListTotal(from.register, from.schema, from.filter || {}, ctx).catch(() => null)
7452
+ }))
7453
+ if (key === this.headerMetaCountKey) {
7454
+ this.headerMetaCountValues = values
7455
+ }
7456
+ },
7457
+
7329
7458
  /**
7330
7459
  * Count the lists behind the manifest page tabs (`countFrom`), one list
7331
7460
  * request each. A failed count shows no number.
@@ -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;
@@ -68,6 +68,32 @@ import { serverErrorMessage } from '../../utils/serverErrorMessage.js'
68
68
  import { CnSettingsField } from '../CnSettingsField/index.js'
69
69
  import { CnSettingsSection } from '../CnSettingsSection/index.js'
70
70
 
71
+ /**
72
+ * A value by key: the key as written when the object holds it (a flat map
73
+ * with dotted keys such as `integration.brk.mode`), else as a dotted path.
74
+ *
75
+ * @param {object} data The object.
76
+ * @param {string} key The key or dotted path.
77
+ * @return {unknown}
78
+ */
79
+ function literalFirst(data, key) {
80
+ if (data && typeof data === 'object' && Object.hasOwn(data, key)) {
81
+ return data[key]
82
+ }
83
+ return readPath(data, key)
84
+ }
85
+
86
+ /**
87
+ * The request options for a target: an OCS endpoint (`/ocs/...`) needs the
88
+ * `OCS-APIRequest` header; `prefixUrl` already serves it outside index.php.
89
+ *
90
+ * @param {string} url The target.
91
+ * @return {object}
92
+ */
93
+ function ocsConfig(url) {
94
+ return /^\/ocs\//.test(String(url)) ? { headers: { 'OCS-APIRequest': 'true' } } : {}
95
+ }
96
+
71
97
  /**
72
98
  * Up to two initials of a name, for an image field without an image.
73
99
  *
@@ -146,15 +172,16 @@ export default {
146
172
  * The fields: `[{ key, label, type?, hint?, placeholder?, required?,
147
173
  * pattern?, patternMessage?, min?, max?, rows?, options?, optionsUrl?,
148
174
  * optionsPath?, optionLabel?, optionValue?, default?, readOnly?, wide?,
149
- * url?, valuePath?, saveUrl?, saveMethod?, saveKey?, uploadUrl?,
175
+ * url?, valuePath?, saveUrl?, saveMethod?, saveKey?, saveParams?, uploadUrl?,
150
176
  * uploadField?, uploadParams?, uploadValuePath?, initials?, initialsFrom? }]`.
151
177
  * `type` is `text` (default), `textarea`, `number`, `switch`, `select`,
152
178
  * `secret` or `image`. `key` names the setting; a dotted key reads a
153
179
  * nested value and is written as written. A field with its own `url`
154
180
  * is read there (at `valuePath`, default its key) and saved to its
155
181
  * `saveUrl` (default that `url`) with `saveMethod` (default POST) under
156
- * `saveKey` (default its key), so one section can hold the settings of
157
- * several apps. An `image` uploads at once to `uploadUrl`. Labels,
182
+ * `saveKey` (default its key), with `saveParams` merged into the body,
183
+ * so one section can hold the settings of several apps. A target under
184
+ * `/ocs/` is sent the `OCS-APIRequest` header. An `image` uploads at once to `uploadUrl`. Labels,
158
185
  * hints, placeholders and messages go through the app's translate
159
186
  * function.
160
187
  *
@@ -343,7 +370,7 @@ export default {
343
370
  const failures = {}
344
371
  await Promise.all(targets.map(async (target) => {
345
372
  try {
346
- const response = await axios.get(prefixUrl(target))
373
+ const response = await axios.get(prefixUrl(target), ocsConfig(target))
347
374
  answers[target] = response?.data ?? {}
348
375
  } catch (error) {
349
376
  failures[target] = error
@@ -396,7 +423,8 @@ export default {
396
423
 
397
424
  /**
398
425
  * A field's value in its read answer: at its `valuePath` (a field with its
399
- * own `url`), else at the section's `valuesPath`, then its `key`.
426
+ * own `url`), else at the section's `valuesPath`, then its `key`. A key
427
+ * the answer holds as written wins over reading it as a dotted path.
400
428
  *
401
429
  * @param {object} field The field.
402
430
  * @param {object|null} data The answer of the field's read target.
@@ -407,10 +435,10 @@ export default {
407
435
  return undefined
408
436
  }
409
437
  if (field.url) {
410
- return readPath(data, field.valuePath || field.key)
438
+ return literalFirst(data, field.valuePath || field.key)
411
439
  }
412
- const values = (this.valuesPath ? readPath(data, this.valuesPath) : data) || {}
413
- return Object.hasOwn(values, field.key) ? values[field.key] : readPath(values, field.key)
440
+ const values = (this.valuesPath ? literalFirst(data, this.valuesPath) : data) || {}
441
+ return literalFirst(values, field.key)
414
442
  },
415
443
 
416
444
  /**
@@ -494,6 +522,7 @@ export default {
494
522
  }
495
523
  const group = groups.get(id)
496
524
  group.fields.push(field)
525
+ Object.assign(group.data, field.saveParams || {})
497
526
  group.data[field.saveKey || field.key] = value
498
527
  }
499
528
  this.saving = true
@@ -502,7 +531,7 @@ export default {
502
531
  const sent = {}
503
532
  for (const group of groups.values()) {
504
533
  try {
505
- await axios.request({ url: prefixUrl(group.url), method: group.method, data: group.data })
534
+ await axios.request({ url: prefixUrl(group.url), method: group.method, data: group.data, ...ocsConfig(group.url) })
506
535
  Object.assign(sent, group.data)
507
536
  this.acceptSaved(group.fields)
508
537
  } catch (error) {
@@ -69,7 +69,12 @@
69
69
 
70
70
  <!-- choice -->
71
71
  <template v-else-if="step.type === 'choice'">
72
- <NcNoteCard v-if="step.body" type="info">
72
+ <!-- The board sets the intro as a plain paragraph above the
73
+ cards (DqInstellen); the Nextcloud look keeps the note. -->
74
+ <p v-if="step.body && isBoard" class="cn-setup-step__intro" data-testid="cn-setup-step-intro">
75
+ {{ stepBody(step) }}
76
+ </p>
77
+ <NcNoteCard v-else-if="step.body" type="info">
73
78
  {{ stepBody(step) }}
74
79
  </NcNoteCard>
75
80
  <CnChoiceCards
@@ -119,6 +124,9 @@
119
124
  <NcNoteCard v-if="isChoiceDisabled(step)" type="warning">
120
125
  {{ dependsOnHint(step) }}
121
126
  </NcNoteCard>
127
+ <p v-if="stepNote(step)" class="cn-setup-step__note" data-testid="cn-setup-step-note">
128
+ {{ stepNote(step) }}
129
+ </p>
122
130
  </template>
123
131
 
124
132
  <!-- config-fields -->
@@ -212,6 +220,7 @@ import CnChoiceCards from '../CnChoiceCards/CnChoiceCards.vue'
212
220
  import CnLeafDependencySettings from '../CnLeafDependencySettings/CnLeafDependencySettings.vue'
213
221
  import CnWizardDialog from '../CnWizardDialog/CnWizardDialog.vue'
214
222
  import { checkDependencies, isDependencyResolved, missingRequiredApps, readServerAppStatuses } from '../../composables/useDependencyCheck.js'
223
+ import { useLook } from '../../composables/useLook.js'
215
224
  import { useSetupStatus } from '../../composables/useSetupStatus.js'
216
225
  import { fieldsFromSchema } from '../../utils/schema.js'
217
226
 
@@ -428,12 +437,13 @@ export default {
428
437
  * @return {object} `setupStatus` / `setupStatusLoading` for the option resolver.
429
438
  */
430
439
  setup(props) {
440
+ const { isBoard } = useLook()
431
441
  const wantsSource = (props.steps || []).some((s) => s && s.optionsSource)
432
442
  if (!wantsSource) {
433
- return {}
443
+ return { isBoard }
434
444
  }
435
445
  const { status, loading } = useSetupStatus(props.appId, { setup: { steps: props.steps } })
436
- return { setupStatus: status, setupStatusLoading: loading }
446
+ return { isBoard, setupStatus: status, setupStatusLoading: loading }
437
447
  },
438
448
 
439
449
  data() {
@@ -793,6 +803,18 @@ export default {
793
803
  return raw === '' ? '' : this.cnTranslate(raw)
794
804
  },
795
805
 
806
+ /**
807
+ * The footnote under a choice step's options, from the step's `note`
808
+ * (or `footnote`), translated like the body.
809
+ *
810
+ * @param {object} step The step declaration.
811
+ * @return {string} The translated footnote, or ''.
812
+ */
813
+ stepNote(step) {
814
+ const raw = (step && (step.note || step.footnote)) || ''
815
+ return raw === '' ? '' : this.cnTranslate(raw)
816
+ },
817
+
796
818
  stepSlot(step) {
797
819
  return 'step-' + step.id
798
820
  },
@@ -1338,6 +1360,18 @@ export default {
1338
1360
  gap: 12px;
1339
1361
  }
1340
1362
 
1363
+ .cn-setup-step__intro {
1364
+ margin: 0;
1365
+ font-size: 15px;
1366
+ line-height: 1.5;
1367
+ }
1368
+
1369
+ .cn-setup-step__note {
1370
+ margin: 0;
1371
+ font-size: 13px;
1372
+ color: var(--color-text-maxcontrast);
1373
+ }
1374
+
1341
1375
  .cn-setup-step__nav {
1342
1376
  display: flex;
1343
1377
  justify-content: flex-end;
@@ -208,7 +208,7 @@ export default {
208
208
  */
209
209
  dialogTitle: {
210
210
  type: String,
211
- default: 'Wizard',
211
+ default: () => t('nextcloud-vue', 'Wizard'),
212
212
  },
213
213
 
214
214
  /**
@@ -279,17 +279,17 @@ export default {
279
279
  submitIcon: { type: [Object, Function], default: null },
280
280
 
281
281
  /** Cancel-button label (wizard phase). */
282
- cancelLabel: { type: String, default: 'Cancel' },
282
+ cancelLabel: { type: String, default: () => t('nextcloud-vue', 'Cancel') },
283
283
  /** Back-button label. */
284
- backLabel: { type: String, default: 'Back' },
284
+ backLabel: { type: String, default: () => t('nextcloud-vue', 'Back') },
285
285
  /** Next-button label (intermediate steps). */
286
- nextLabel: { type: String, default: 'Next' },
286
+ nextLabel: { type: String, default: () => t('nextcloud-vue', 'Next') },
287
287
  /** Submit-button label (last step). */
288
- submitLabel: { type: String, default: 'Submit' },
288
+ submitLabel: { type: String, default: () => t('nextcloud-vue', 'Submit') },
289
289
  /** Close-button label (result phase). */
290
- closeLabel: { type: String, default: 'Close' },
290
+ closeLabel: { type: String, default: () => t('nextcloud-vue', 'Close') },
291
291
  /** Success banner default text when result.message is empty. */
292
- successText: { type: String, default: 'Done.' },
292
+ successText: { type: String, default: () => t('nextcloud-vue', 'Done.') },
293
293
  /**
294
294
  * Whether the dialog can be dismissed (Cancel button, ESC, backdrop
295
295
  * click) before reaching the result phase. `false` hides Cancel and
@@ -525,3 +525,18 @@
525
525
  align-items: center;
526
526
  gap: 4px;
527
527
  }
528
+
529
+ /* The tag after an admin-only entry in a board detail page's Actions menu
530
+ ("beheerder", DqZaakMeer): 12px muted text at the end of the row. The menu
531
+ opens outside the page, so the rule keys on the attribute CnDetailPage sets
532
+ only in the board look. */
533
+ .action-button[data-cn-admin-tag]::after,
534
+ .action-link[data-cn-admin-tag]::after {
535
+ content: attr(data-cn-admin-tag);
536
+ margin-inline-start: auto;
537
+ padding-inline: 12px 4px;
538
+ color: var(--color-text-maxcontrast);
539
+ font-size: 12px;
540
+ font-weight: 400;
541
+ white-space: nowrap;
542
+ }