@conduction/nextcloud-vue 2.139.0 → 2.141.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 (144) hide show
  1. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +9 -2
  2. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
  3. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
  4. package/dist/esm/components/CnAssignDialog/CnAssignDialog.vue.js +1 -1
  5. package/dist/esm/components/CnAssignDialog/CnAssignDialog.vue2.js +10 -2
  6. package/dist/esm/components/CnAssignDialog/CnAssignDialog.vue2.js.map +1 -1
  7. package/dist/esm/components/CnAssignDialog/CnAssignDialog.vue3.js.map +1 -1
  8. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue.js +1 -1
  9. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js +28 -4
  10. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js.map +1 -1
  11. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js +2 -2
  12. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js.map +1 -1
  13. package/dist/esm/components/CnChartWidget/CnChartWidget.vue.js +1 -1
  14. package/dist/esm/components/CnChartWidget/CnChartWidget.vue2.js +64 -1
  15. package/dist/esm/components/CnChartWidget/CnChartWidget.vue2.js.map +1 -1
  16. package/dist/esm/components/CnChartWidget/CnChartWidget.vue3.js +76 -37
  17. package/dist/esm/components/CnChartWidget/CnChartWidget.vue3.js.map +1 -1
  18. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue.js +1 -1
  19. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue2.js +106 -0
  20. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue2.js.map +1 -1
  21. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue3.js +30 -7
  22. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue3.js.map +1 -1
  23. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
  24. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +1 -0
  25. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  26. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  27. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +5 -3
  28. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  29. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +1 -0
  30. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  31. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue.js +1 -1
  32. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js +59 -3
  33. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js.map +1 -1
  34. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js +44 -34
  35. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js.map +1 -1
  36. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue.js +1 -1
  37. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js +28 -1
  38. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js.map +1 -1
  39. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js.map +1 -1
  40. package/dist/esm/components/CnFilesBrowser/filesBrowser.js +7 -1
  41. package/dist/esm/components/CnFilesBrowser/filesBrowser.js.map +1 -1
  42. package/dist/esm/components/CnFilesBrowser/filesBrowserColumns.js +5 -0
  43. package/dist/esm/components/CnFilesBrowser/filesBrowserColumns.js.map +1 -1
  44. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +30 -2
  45. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  46. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  47. package/dist/esm/components/CnIndexSidebar/CnIndexSidebar.vue2.js +65 -1
  48. package/dist/esm/components/CnIndexSidebar/CnIndexSidebar.vue2.js.map +1 -1
  49. package/dist/esm/components/CnIndexSidebar/CnIndexSidebar.vue3.js +120 -69
  50. package/dist/esm/components/CnIndexSidebar/CnIndexSidebar.vue3.js.map +1 -1
  51. package/dist/esm/components/CnMapWidget/CnMapWidget.vue.js +1 -1
  52. package/dist/esm/components/CnMapWidget/CnMapWidget.vue2.js +47 -9
  53. package/dist/esm/components/CnMapWidget/CnMapWidget.vue2.js.map +1 -1
  54. package/dist/esm/components/CnMapWidget/CnMapWidget.vue3.js.map +1 -1
  55. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue.js +1 -1
  56. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue2.js +28 -3
  57. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue2.js.map +1 -1
  58. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue3.js.map +1 -1
  59. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue.js +1 -1
  60. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +3 -3
  61. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
  62. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
  63. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +1 -1
  64. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +2 -0
  65. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -1
  66. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js +1 -1
  67. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -1
  68. package/dist/esm/components/CnRecordListWidget/CnRecordNoteRows.vue.js +1 -1
  69. package/dist/esm/components/CnRecordListWidget/CnRecordNoteRows.vue2.js +20 -0
  70. package/dist/esm/components/CnRecordListWidget/CnRecordNoteRows.vue2.js.map +1 -1
  71. package/dist/esm/components/CnRecordListWidget/CnRecordNoteRows.vue3.js +34 -26
  72. package/dist/esm/components/CnRecordListWidget/CnRecordNoteRows.vue3.js.map +1 -1
  73. package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue.js +1 -1
  74. package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue2.js +12 -1
  75. package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue2.js.map +1 -1
  76. package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue3.js +1 -1
  77. package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue3.js.map +1 -1
  78. package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue.js +1 -1
  79. package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue2.js +23 -1
  80. package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue2.js.map +1 -1
  81. package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue3.js +43 -13
  82. package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue3.js.map +1 -1
  83. package/dist/esm/composables/indexSources.js +5 -2
  84. package/dist/esm/composables/indexSources.js.map +1 -1
  85. package/dist/esm/composables/useTaskInboxStore.js +4 -3
  86. package/dist/esm/composables/useTaskInboxStore.js.map +1 -1
  87. package/dist/esm/l10n/en.json.js +3 -1
  88. package/dist/esm/l10n/en.json.js.map +1 -1
  89. package/dist/esm/l10n/nl.json.js +6 -1
  90. package/dist/esm/l10n/nl.json.js.map +1 -1
  91. package/dist/esm/library-style.js +1 -1
  92. package/dist/esm/nextcloud-vue.css +961 -605
  93. package/dist/esm/utils/builtInFormatters.js +23 -1
  94. package/dist/esm/utils/builtInFormatters.js.map +1 -1
  95. package/dist/esm/utils/fillTemplate.js +7 -1
  96. package/dist/esm/utils/fillTemplate.js.map +1 -1
  97. package/dist/esm/utils/groupSublines.js +120 -0
  98. package/dist/esm/utils/groupSublines.js.map +1 -0
  99. package/dist/esm/utils/myTeam.js +24 -4
  100. package/dist/esm/utils/myTeam.js.map +1 -1
  101. package/dist/esm/utils/resolveFilterTokens.js +5 -4
  102. package/dist/esm/utils/resolveFilterTokens.js.map +1 -1
  103. package/dist/esm/utils/transitionInputs.js +43 -2
  104. package/dist/esm/utils/transitionInputs.js.map +1 -1
  105. package/dist/nextcloud-vue.cjs.js +1731 -846
  106. package/dist/nextcloud-vue.cjs.js.map +2 -2
  107. package/dist/nextcloud-vue.css +961 -605
  108. package/l10n/en.json +3 -1
  109. package/l10n/nl.json +6 -1
  110. package/package.json +1 -1
  111. package/src/components/CnAppRoot/CnAppRoot.vue +9 -2
  112. package/src/components/CnAssignDialog/CnAssignDialog.vue +10 -2
  113. package/src/components/CnCellRenderer/CnCellRenderer.vue +30 -6
  114. package/src/components/CnChartWidget/CnChartWidget.md +2 -0
  115. package/src/components/CnChartWidget/CnChartWidget.vue +121 -5
  116. package/src/components/CnConversationThread/CnConversationWidget.vue +126 -1
  117. package/src/components/CnDashboardPage/CnDashboardPage.vue +1 -0
  118. package/src/components/CnDataTable/CnDataTable.vue +6 -3
  119. package/src/components/CnFactsWidget/CnFactsWidget.vue +72 -5
  120. package/src/components/CnFilesBrowser/CnFilesBrowser.vue +28 -1
  121. package/src/components/CnFilesBrowser/filesBrowser.js +7 -1
  122. package/src/components/CnFilesBrowser/filesBrowserColumns.js +5 -0
  123. package/src/components/CnIndexPage/CnIndexPage.vue +30 -2
  124. package/src/components/CnIndexSidebar/CnIndexSidebar.vue +101 -3
  125. package/src/components/CnMapWidget/CnMapWidget.vue +47 -9
  126. package/src/components/CnObjectDataWidget/CnObjectDataWidget.vue +28 -3
  127. package/src/components/CnObjectListWidget/CnObjectListWidget.vue +3 -3
  128. package/src/components/CnRecordListWidget/CnRecordListWidget.vue +20 -1
  129. package/src/components/CnRecordListWidget/CnRecordNoteRows.vue +43 -9
  130. package/src/components/CnStackedBarWidget/CnStackedBarWidget.vue +68 -2
  131. package/src/components/CnTransitionInputFields/CnTransitionInputFields.vue +43 -1
  132. package/src/composables/indexSources.js +5 -2
  133. package/src/composables/useTaskInboxStore.js +4 -3
  134. package/src/css/dialog.css +13 -1
  135. package/src/css/look-board-detail.css +12 -9
  136. package/src/css/look-board-index.css +90 -7
  137. package/src/css/look-board.css +102 -2
  138. package/src/css/table.css +25 -0
  139. package/src/utils/builtInFormatters.js +22 -0
  140. package/src/utils/fillTemplate.js +7 -1
  141. package/src/utils/groupSublines.js +125 -0
  142. package/src/utils/myTeam.js +24 -4
  143. package/src/utils/resolveFilterTokens.js +5 -4
  144. package/src/utils/transitionInputs.js +42 -1
@@ -66,19 +66,25 @@
66
66
  v-else-if="row.to"
67
67
  :to="row.to"
68
68
  class="cn-facts-widget__value-link">
69
- {{ row.masked && !revealed[row.key] ? row.masked : row.value }}
69
+ {{ shownValue(row) }}
70
70
  </router-link>
71
- <span v-else>{{ row.masked && !revealed[row.key] ? row.masked : row.value }}</span>
71
+ <span v-else>{{ shownValue(row) }}</span>
72
72
  <button
73
73
  v-if="row.reveal && !revealed[row.key]"
74
74
  type="button"
75
75
  class="cn-facts-widget__inline-button"
76
76
  :aria-label="revealLabel(row)"
77
77
  :data-testid="`cn-fact-reveal-${row.key}`"
78
+ :disabled="revealing === row.key"
78
79
  @click="reveal(row)">
79
80
  <EyeOutline :size="16" aria-hidden="true" />
80
81
  {{ showLabel }}
81
82
  </button>
83
+ <span
84
+ v-if="revealErrors[row.key]"
85
+ class="cn-facts-widget__reveal-error"
86
+ role="alert"
87
+ :data-testid="`cn-fact-reveal-error-${row.key}`">{{ revealErrors[row.key] }}</span>
82
88
  <button
83
89
  v-if="row.copy"
84
90
  type="button"
@@ -420,7 +426,7 @@ export default {
420
426
  * `as` is `row` (default when `label` or `icon` is set), `title`, `line`, `text` (a line in the normal ink), `lead` (an icon tile or initials beside `title` and `subtitle`), `pill` or `meter`.
421
427
  * `countFrom` (`{ register, schema, filter? }`, `@objectId` / `@object.<field>` in the filter) shows how many records that list holds ("Lopende zaken 43"); the row is left out while the count cannot be read.
422
428
  * A `meter` shows the amount at `path` against the limit at `limitPath` (or a constant `limit`) as "12 of 50" with a bar; past the limit the bar turns red and the text says "over the limit"; without a positive limit it shows the amount and no bar.
423
- * A row's `format` may also be `email`, `phone` or `url` (a link) or `masked` (the last four characters; `reveal: true` adds Show). `copy: true` adds a copy button; `icon` replaces the label.
429
+ * A row's `format` may also be `email`, `phone` or `url` (a link) or `masked` (the last four characters; `reveal: true` adds Show, which unmasks the record's value; `reveal: { url, path? }` makes Show ask the server for the value at `url` (tokens resolved), read at `path` (else the entry's path), so a server that answers the mask can check and record the reveal). `copy: true` adds a copy button; `icon` replaces the label.
424
430
  * A part may be `{ text }`, a constant; a `line` or `text` entry without `path` or `parts` shows its `text`.
425
431
  * `resolve: { register, schema, labelField? }` on an entry or a part turns a value that is a reference (an id, or a list of ids) into the label of the referenced object (`labelField`, else `title`, then `name`): the case type or status of a task shows its name, not its uuid. The ids are fetched once per register and schema through the object store and shared by every facts widget on the page; the entry stays empty until its label is known, and when no label can be read. A resolved value works as a pill too: `map` is applied to the resolved label, and `variantMap` / `colorMap` look the tone up by the value of `resolve.toneField` on the referenced object when that is set (a status named by its `name`, coloured by its `state`), then by the resolved label, then by the stored value.
426
432
  * `route` (`{ name, params?, query? }`, `@source.<path>` in a param) on a `title` or a `row` links its value to that route; a param that does not fill leaves it plain.
@@ -474,6 +480,9 @@ export default {
474
480
  error: false,
475
481
  /** Keys of the masked rows the reader chose to show. */
476
482
  revealed: {},
483
+ revealedValues: {},
484
+ revealErrors: {},
485
+ revealing: '',
477
486
  /** Key of the row whose value was just copied. */
478
487
  copied: '',
479
488
  /** Labels of the referenced objects read so far, by `refKey`. */
@@ -1321,7 +1330,12 @@ export default {
1321
1330
  extras.external = true
1322
1331
  } else if (field.format === 'masked') {
1323
1332
  extras.masked = value.length > 4 ? '*'.repeat(Math.min(value.length - 4, 5)) + value.slice(-4) : value
1324
- extras.reveal = field.reveal === true && value.length > 4
1333
+ const server = field.reveal && typeof field.reveal === 'object' && typeof field.reveal.url === 'string' && field.reveal.url !== ''
1334
+ extras.reveal = (field.reveal === true || server) && value.length > 4
1335
+ if (server) {
1336
+ extras.revealUrl = field.reveal.url
1337
+ extras.revealPath = typeof field.reveal.path === 'string' && field.reveal.path !== '' ? field.reveal.path : (field.path || '')
1338
+ }
1325
1339
  }
1326
1340
  return extras
1327
1341
  },
@@ -1333,7 +1347,14 @@ export default {
1333
1347
  * @param {object} row The row.
1334
1348
  * @spec openspec/changes/screens-relation-pages/specs/relation-pages/spec.md#requirement-the-facts-card-draws-contact-details-identity-and-a-lead
1335
1349
  */
1336
- reveal(row) {
1350
+ async reveal(row) {
1351
+ if (row.revealUrl) {
1352
+ const value = await this.fetchRevealed(row)
1353
+ if (value === null) {
1354
+ return
1355
+ }
1356
+ this.revealedValues = { ...this.revealedValues, [row.key]: value }
1357
+ }
1337
1358
  this.revealed = { ...this.revealed, [row.key]: true }
1338
1359
  const detail = { path: row.path }
1339
1360
  /**
@@ -1344,6 +1365,47 @@ export default {
1344
1365
  emit(FACTS_REVEAL_CHANNEL, { ...detail, objectId: this.tokenCtx.objectId, register: this.tokenCtx.register || null, schema: this.tokenCtx.schema || null })
1345
1366
  },
1346
1367
 
1368
+ /**
1369
+ * The text a row shows: its mask until revealed, then the value the
1370
+ * server answered (a server reveal) or the value the record holds.
1371
+ *
1372
+ * @param {object} row The row.
1373
+ * @return {string}
1374
+ * @spec openspec/changes/facts-reveal-from-server/specs/facts-widget/spec.md#requirement-a-masked-fact-can-be-revealed-by-the-server
1375
+ */
1376
+ shownValue(row) {
1377
+ if (!row.masked || !this.revealed[row.key]) {
1378
+ return row.masked || row.value
1379
+ }
1380
+ return Object.hasOwn(this.revealedValues, row.key) ? this.revealedValues[row.key] : row.value
1381
+ },
1382
+
1383
+ /**
1384
+ * Ask the server for a masked value (`reveal.url`, tokens resolved,
1385
+ * read at `reveal.path`), never from a cache: each show is a request
1386
+ * the server can check and record.
1387
+ *
1388
+ * @param {object} row The row.
1389
+ * @return {Promise<string|null>} The value, or null when it could not be read.
1390
+ * @spec openspec/changes/facts-reveal-from-server/specs/facts-widget/spec.md#requirement-a-masked-fact-can-be-revealed-by-the-server
1391
+ */
1392
+ async fetchRevealed(row) {
1393
+ this.revealing = row.key
1394
+ this.revealErrors = { ...this.revealErrors, [row.key]: '' }
1395
+ try {
1396
+ const payload = await fetchEndpointSource({ url: row.revealUrl, responsePath: row.revealPath }, this.tokenCtx, { force: true })
1397
+ if (payload === null || payload === undefined || payload === '' || typeof payload === 'object') {
1398
+ throw new Error('no value')
1399
+ }
1400
+ return String(payload)
1401
+ } catch {
1402
+ this.revealErrors = { ...this.revealErrors, [row.key]: t('nextcloud-vue', 'The value could not be shown.') }
1403
+ return null
1404
+ } finally {
1405
+ this.revealing = ''
1406
+ }
1407
+ },
1408
+
1347
1409
  /**
1348
1410
  * Copy a row's value to the clipboard.
1349
1411
  *
@@ -1955,6 +2017,11 @@ export default {
1955
2017
  font: inherit;
1956
2018
  }
1957
2019
 
2020
+ .cn-facts-widget__reveal-error {
2021
+ font-size: 13px;
2022
+ color: var(--color-text-error, var(--color-error));
2023
+ }
2024
+
1958
2025
  .cn-facts-widget__inline-button {
1959
2026
  background: var(--color-main-background);
1960
2027
  border: 1px solid var(--color-border-dark);
@@ -775,6 +775,7 @@ import CnIcon from '../CnIcon/CnIcon.vue'
775
775
  import { readUserPreference, writeUserPreference } from '../../composables/useUserPreferences.js'
776
776
  import { dispatchAction } from '../../utils/actionsDispatcher.js'
777
777
  import { fillTemplate } from '../../utils/fillTemplate.js'
778
+ import { readPath } from '../../utils/readPath.js'
778
779
  import { isNewTabClick, isRowMiddleClick, preventMiddleClickAutoscroll } from '../../utils/rowAuxClick.js'
779
780
  import { ACTIONS_NEEDING_THE_FILES_PAGE, crumbsFor, hostActionApplies, joinPath } from './filesBrowser.js'
780
781
  import {
@@ -3049,7 +3050,7 @@ export default {
3049
3050
  ? { path: this.currentPath }
3050
3051
  : { fileId: node.fileid, fileName: node.basename, path: node.path }
3051
3052
  if (type === 'open-modal') {
3052
- wrapped = { ...action, props: { ...(action.props || {}), ...context } }
3053
+ wrapped = { ...action, props: { ...(action.props || {}), ...context, ...this.rowPropsOf(action, node) } }
3053
3054
  } else if (type === 'handler') {
3054
3055
  wrapped = { ...action, args: [...(action.args || []), node ?? this.folder] }
3055
3056
  }
@@ -3060,6 +3061,32 @@ export default {
3060
3061
  dispatchAction(wrapped, { router: this.$router || null })
3061
3062
  },
3062
3063
 
3064
+ /**
3065
+ * The clicked file's row-data fields an action asks for as modal props
3066
+ * (`rowProps: { prop: field }`), or none.
3067
+ *
3068
+ * @param {object} action The declared action.
3069
+ * @param {object|null} node The file, or null for a New-menu entry.
3070
+ * @return {object} The props.
3071
+ * @spec openspec/changes/files-review-row-when/specs/files-review-row-when/spec.md#requirement-a-modal-action-can-carry-fields-of-the-clicked-files-row-data
3072
+ */
3073
+ rowPropsOf(action, node) {
3074
+ const wanted = action && action.rowProps
3075
+ if (node === null || !wanted || typeof wanted !== 'object') {
3076
+ return {}
3077
+ }
3078
+ const byId = this.resolvedRowData && typeof this.resolvedRowData === 'object' ? this.resolvedRowData : {}
3079
+ const entry = byId[node.fileid] ?? byId[String(node.fileid)] ?? {}
3080
+ const out = {}
3081
+ for (const [prop, field] of Object.entries(wanted)) {
3082
+ const value = readPath(entry, String(field))
3083
+ if (value !== undefined && value !== null && value !== '') {
3084
+ out[prop] = value
3085
+ }
3086
+ }
3087
+ return out
3088
+ },
3089
+
3063
3090
  /**
3064
3091
  * A click on a linked row opens the file where it lives.
3065
3092
  *
@@ -285,13 +285,19 @@ export function indexRowData(data, { path = '', key = 'fileId' } = {}) {
285
285
  * `visibleIf.extension` lists file extensions (no case, a leading dot is
286
286
  * allowed); `visibleIf.mime` lists mime types, where one ending in `/` or
287
287
  * `/*` names the whole family. Every condition named must hold. An action
288
- * without `visibleIf` is offered on every file, as before.
288
+ * without `visibleIf` is offered on every file, as before. An action with
289
+ * `when` is offered only while `when` is `true`.
289
290
  *
290
291
  * @param {object} action The declared action.
291
292
  * @param {object} node The file (`basename`, `mime`).
292
293
  * @return {boolean} True when the action belongs on this file.
293
294
  */
294
295
  export function hostActionApplies(action, node) {
296
+ // `when`: the action applies only while it is true (a case type flag).
297
+ // @spec openspec/changes/files-review-row-when/specs/files-review-row-when/spec.md#requirement-a-files-browser-entry-can-apply-only-when-a-condition-holds
298
+ if (action && typeof action === 'object' && 'when' in action && action.when !== true) {
299
+ return false
300
+ }
295
301
  const condition = action && action.visibleIf
296
302
  if (!condition || typeof condition !== 'object') {
297
303
  return true
@@ -78,6 +78,11 @@ export function normaliseFileColumns(columns) {
78
78
  if (!entry || typeof entry !== 'object' || typeof entry.key !== 'string' || entry.key === '') {
79
79
  return
80
80
  }
81
+ // `when`: the column applies only while it is true (a case type flag).
82
+ // @spec openspec/changes/files-review-row-when/specs/files-review-row-when/spec.md#requirement-a-files-browser-entry-can-apply-only-when-a-condition-holds
83
+ if ('when' in entry && entry.when !== true) {
84
+ return
85
+ }
81
86
  if (isBuiltInFileColumn(entry.key) && entry.source === undefined) {
82
87
  out.push({ ...BUILT_IN_FILE_COLUMNS[entry.key], ...entry, builtIn: true })
83
88
  return
@@ -4670,6 +4670,8 @@ export default {
4670
4670
  savedViews: [],
4671
4671
  savedViewsLoading: false,
4672
4672
  showSaveViewDialog: false,
4673
+ /** Whether the reader changed the view since it was opened or saved. */
4674
+ viewChanged: false,
4673
4675
  viewPendingDelete: null,
4674
4676
  /** An `api-call` header action waiting on its confirmation, or null. */
4675
4677
  headerApiCallPending: null,
@@ -8706,12 +8708,20 @@ export default {
8706
8708
 
8707
8709
  /**
8708
8710
  * Whether the toolbar draws Save view: not when the page switches it
8709
- * off (`savedViewSave: false`) or its header carries one.
8711
+ * off (`savedViewSave: false`) or its header carries one, and in the
8712
+ * board look only once the view has changed.
8710
8713
  *
8714
+ * @spec openspec/changes/board-save-view-only-when-changed/specs/saved-views/spec.md#requirement-the-board-look-offers-save-view-once-the-view-has-changed
8711
8715
  * @return {boolean}
8712
8716
  */
8713
8717
  toolbarSavesView() {
8714
- return this.savedViewSave !== false && !this.headerSavesView
8718
+ if (this.savedViewSave === false || this.headerSavesView) {
8719
+ return false
8720
+ }
8721
+ // The board look offers it once the view differs from the one
8722
+ // opened: changed by the reader, or, with no saved view open,
8723
+ // filtered by the address.
8724
+ return !this.isBoardLook || this.viewChanged || (!this.appliedSavedViewId && this.activeFilterChips.length > 0)
8715
8725
  },
8716
8726
 
8717
8727
  /**
@@ -10849,6 +10859,7 @@ export default {
10849
10859
  * @return {void}
10850
10860
  */
10851
10861
  onSearchEvent(value) {
10862
+ this.viewChanged = true
10852
10863
  if (this.namedSourceSearches) {
10853
10864
  this.namedSearchTerm = value || ''
10854
10865
  }
@@ -10868,6 +10879,7 @@ export default {
10868
10879
  * @return {void}
10869
10880
  */
10870
10881
  onQuickFilterChange(index) {
10882
+ this.viewChanged = true
10871
10883
  // `activeQuickFilterIndex` is a setup-returned ref; the
10872
10884
  // Vue 2 ref-unwrap proxy makes plain assignment work.
10873
10885
  this.activeQuickFilterIndex = index
@@ -10884,6 +10896,7 @@ export default {
10884
10896
  * @return {void}
10885
10897
  */
10886
10898
  onQuickFilterMultiChange(indices) {
10899
+ this.viewChanged = true
10887
10900
  this.selectedQuickFilterIndices = Array.isArray(indices) ? indices : []
10888
10901
  this.$emit('quick-filter-change', this.selectedQuickFilterIndices)
10889
10902
  },
@@ -10899,6 +10912,7 @@ export default {
10899
10912
  * @return {void}
10900
10913
  */
10901
10914
  onSidebarSortChange(keys, value) {
10915
+ this.viewChanged = true
10902
10916
  if (value !== undefined && this.selectSortOption(value)) {
10903
10917
  return
10904
10918
  }
@@ -10978,6 +10992,7 @@ export default {
10978
10992
  * @return {void}
10979
10993
  */
10980
10994
  onSortEvent(payload) {
10995
+ this.viewChanged = true
10981
10996
  // An order by keys (a column header, a field in the Sort section)
10982
10997
  // leaves an endpoint-ranked order.
10983
10998
  if (this.sortSource.value) {
@@ -11181,6 +11196,7 @@ export default {
11181
11196
  * @return {void}
11182
11197
  */
11183
11198
  onClearFilters() {
11199
+ this.viewChanged = true
11184
11200
  // "Clear all" on the Active line also undoes the quick filters it
11185
11201
  // listed (a tab without an `activeLabel` is not a filter there).
11186
11202
  for (const chip of this.quickFilterActiveChips) {
@@ -11289,6 +11305,7 @@ export default {
11289
11305
  * @spec openspec/changes/screens-board-reference-columns/specs/board-view/spec.md#requirement-a-board-can-switch-the-field-its-columns-come-from
11290
11306
  */
11291
11307
  onBoardColumnFieldChange(field) {
11308
+ this.viewChanged = true
11292
11309
  this.boardColumnFieldChoice = field
11293
11310
  /**
11294
11311
  * @event board-column-field-change The board's column switch picked another field. Payload: the field name.
@@ -11304,6 +11321,7 @@ export default {
11304
11321
  * @return {void}
11305
11322
  */
11306
11323
  onBoardNarrowChange(value) {
11324
+ this.viewChanged = true
11307
11325
  this.onFilterEvent({ key: this.boardNarrow.field, values: value === '' ? [] : [value] })
11308
11326
  },
11309
11327
 
@@ -11366,6 +11384,7 @@ export default {
11366
11384
  * @return {void}
11367
11385
  */
11368
11386
  onFilterEvent(payload) {
11387
+ this.viewChanged = true
11369
11388
  if (this.isSelfFetchMode && typeof this.list.onFilterChange === 'function') {
11370
11389
  this.list.onFilterChange(payload.key, payload.values)
11371
11390
  this.persistViewStateToRoute(this.currentViewState())
@@ -11388,6 +11407,7 @@ export default {
11388
11407
  * @spec openspec/changes/screens-index-list-parity/specs/index-list-board-look/spec.md#requirement-the-toolbar-sits-on-the-ground-in-two-rows
11389
11408
  */
11390
11409
  onRemoveActiveFilter(chip) {
11410
+ this.viewChanged = true
11391
11411
  if (chip && chip.searchTerm === true) {
11392
11412
  this.onSearchEvent('')
11393
11413
  return
@@ -12356,6 +12376,7 @@ export default {
12356
12376
  * @return {void}
12357
12377
  */
12358
12378
  onColumnsEvent(columns) {
12379
+ this.viewChanged = true
12359
12380
  if (this.isSelfFetchMode && this.list.visibleColumns) {
12360
12381
  this.list.visibleColumns.value = columns
12361
12382
  }
@@ -12403,6 +12424,7 @@ export default {
12403
12424
  * @return {void}
12404
12425
  */
12405
12426
  onColumnsReorder(columns) {
12427
+ this.viewChanged = true
12406
12428
  this.onColumnsEvent(columns)
12407
12429
  },
12408
12430
 
@@ -12413,6 +12435,7 @@ export default {
12413
12435
  * @return {void}
12414
12436
  */
12415
12437
  onPinChange(count) {
12438
+ this.viewChanged = true
12416
12439
  if (!this.personalColumns || Array.isArray(this.appliedViewColumns)) {
12417
12440
  return
12418
12441
  }
@@ -13316,6 +13339,10 @@ export default {
13316
13339
  loadMyTeam().then(() => this.onApplySavedView(view, { keepSavedViewKey }))
13317
13340
  return
13318
13341
  }
13342
+ // An opened view is unchanged until the reader changes it.
13343
+ this.$nextTick(() => {
13344
+ this.viewChanged = false
13345
+ })
13319
13346
  const ctx = typeof this.selfFetchTokenCtx === 'function' ? this.selfFetchTokenCtx() : {}
13320
13347
  state.filters = dropOptionalUnresolved(resolveFilterTokens(state.filters, ctx))
13321
13348
  this.appliedViewFiltersKey = this.viewFiltersKey(state.filters)
@@ -13441,6 +13468,7 @@ export default {
13441
13468
  }
13442
13469
  this.savedViews = [...this.savedViews, view]
13443
13470
  this.showSaveViewDialog = false
13471
+ this.viewChanged = false
13444
13472
  this.sidebarActiveTab = 'filters-tab'
13445
13473
  this.sidebarOpen = true
13446
13474
  this.toastSavedView('success', t('nextcloud-vue', 'View "{name}" saved', { name }))
@@ -103,7 +103,7 @@
103
103
  </p>
104
104
  </div>
105
105
 
106
- <div v-if="hasActiveFilters" class="cn-index-sidebar__section">
106
+ <div v-if="hasActiveFilters && !isBoardLook" class="cn-index-sidebar__section">
107
107
  <NcButton variant="tertiary" @click="onClearAll">
108
108
  <template #icon>
109
109
  <FilterRemoveOutline :size="20" />
@@ -113,7 +113,20 @@
113
113
  </div>
114
114
 
115
115
  <div v-if="schemaFilters.length > 0" class="cn-index-sidebar__section">
116
- <h3>{{ filtersLabel }}</h3>
116
+ <!-- The board look: "Alles wissen" beside the heading (DqZakenSorteren). -->
117
+ <div v-if="isBoardLook" class="cn-index-sidebar__filters-head" data-testid="cn-index-sidebar-filters-head">
118
+ <h3>{{ filtersLabel }}</h3>
119
+ <button
120
+ v-if="hasActiveFilters"
121
+ type="button"
122
+ class="cn-index-sidebar__filters-clear"
123
+ @click="onClearAll">
124
+ {{ clearAllLabel }}
125
+ </button>
126
+ </div>
127
+ <h3 v-else>
128
+ {{ filtersLabel }}
129
+ </h3>
117
130
  <div
118
131
  v-for="filter in schemaFilters"
119
132
  :key="filter.key"
@@ -155,6 +168,27 @@
155
168
  :label="filter.label"
156
169
  :placeholder="filter.label"
157
170
  @update:modelValue="onTextChange(filter.key, $event)" />
171
+ <!-- The board look: a short field as checkbox rows with counts. -->
172
+ <fieldset
173
+ v-else-if="checklistOptions(filter)"
174
+ class="cn-index-sidebar__checklist"
175
+ :data-testid="`cn-index-sidebar-checklist-${filter.key}`">
176
+ <legend class="hidden-visually">
177
+ {{ filter.label }}
178
+ </legend>
179
+ <label
180
+ v-for="option in checklistOptions(filter)"
181
+ :key="`${filter.key}-${option.id}`"
182
+ class="cn-index-sidebar__checklist-row"
183
+ data-testid="cn-index-sidebar-checklist-row">
184
+ <input
185
+ type="checkbox"
186
+ :checked="isChecked(filter, option.id)"
187
+ @change="onChecklistToggle(filter, option.id, $event.target.checked)">
188
+ <span class="cn-index-sidebar__checklist-label">{{ option.label }}</span>
189
+ <span v-if="option.count !== null" class="cn-index-sidebar__checklist-count">{{ option.count }}</span>
190
+ </label>
191
+ </fieldset>
158
192
  <NcSelect
159
193
  v-else
160
194
  class="cn-index-sidebar__select"
@@ -369,7 +403,7 @@ import CnSavedViewsPanel from '../CnSavedViewsPanel/CnSavedViewsPanel.vue'
369
403
  import CnSegmentedControl from '../CnSegmentedControl/CnSegmentedControl.vue'
370
404
  import { normalizeLook } from '../../composables/useLook.js'
371
405
  import { METADATA_COLUMNS } from '../../constants/metadata.js'
372
- import { facetOptions } from '../../utils/facets.js'
406
+ import { facetOptions, MISSING_VALUE } from '../../utils/facets.js'
373
407
  import { labelLang } from '../../utils/manifestTranslate.js'
374
408
  import { columnsFromSchema, filtersFromSchema } from '../../utils/schema.js'
375
409
  /**
@@ -389,6 +423,9 @@ import { labelSidePanelClose } from '../../utils/sidePanelCloseLabel.js'
389
423
  import { reverseSortKeys, sameSortKeys } from '../../utils/sortOrder.js'
390
424
  import { CnDateRangePicker, DEFAULT_DATE_RANGE_PRESETS } from '../CnDateRangePicker/index.js'
391
425
  import { CnIcon } from '../CnIcon/index.js'
426
+ /** The most values a filter shows as checkbox rows in the board look; a longer field keeps its picker. */
427
+ const CHECKLIST_MAX = 12
428
+
392
429
  export default {
393
430
  name: 'CnIndexSidebar',
394
431
 
@@ -1411,6 +1448,67 @@ export default {
1411
1448
  return values.map((v) => options.find((o) => o.id === v) || { id: v, label: String(v) })
1412
1449
  },
1413
1450
 
1451
+ /**
1452
+ * A field's values as checkbox rows, in the board look and for a short
1453
+ * list only: label and count apart, from the facet when there is one,
1454
+ * else the field's own options. Null where the picker stays (the
1455
+ * Nextcloud look, a single choice, a reference, more than
1456
+ * CHECKLIST_MAX values).
1457
+ *
1458
+ * @spec openspec/changes/board-sidebar-filters-as-checkbox-lists/specs/index-sidebar/spec.md#requirement-the-board-look-draws-a-short-filter-as-a-checkbox-list-with-counts
1459
+ * @param {object} filter The filter definition.
1460
+ * @return {Array<{id: (string|number|boolean), label: string, count: (number|null)}>|null} The rows, or null.
1461
+ */
1462
+ checklistOptions(filter) {
1463
+ if (!this.isBoardLook || filter.multiple === false || filter.type === 'reference') {
1464
+ return null
1465
+ }
1466
+ const facet = this.facetData[filter.key]
1467
+ let rows
1468
+ if (facet && Array.isArray(facet.values) && facet.values.length > 0) {
1469
+ rows = facet.values.map((bucket) => ({
1470
+ id: bucket.value,
1471
+ label: String(bucket.label !== undefined && bucket.label !== null ? bucket.label : bucket.value),
1472
+ count: typeof bucket.count === 'number' ? bucket.count : null,
1473
+ }))
1474
+ if (typeof facet.missing === 'number' && facet.missing > 0) {
1475
+ rows.push({ id: MISSING_VALUE, label: t('nextcloud-vue', 'No value'), count: facet.missing })
1476
+ }
1477
+ } else {
1478
+ rows = (filter.options || []).map((option) => ({ id: option.id, label: String(option.label), count: null }))
1479
+ }
1480
+ return rows.length > 0 && rows.length <= CHECKLIST_MAX ? rows : null
1481
+ },
1482
+
1483
+ /**
1484
+ * Whether a value of a field is among its active filter values.
1485
+ *
1486
+ * @param {object} filter The filter definition.
1487
+ * @param {string|number|boolean} id The value.
1488
+ * @return {boolean}
1489
+ */
1490
+ isChecked(filter, id) {
1491
+ const value = this.activeFilters[filter.key]
1492
+ const values = Array.isArray(value) ? value : (value === undefined || value === null || value === '' ? [] : [value])
1493
+ return values.map(String).includes(String(id))
1494
+ },
1495
+
1496
+ /**
1497
+ * A checkbox row was toggled: the field's values with it added or taken out.
1498
+ *
1499
+ * @spec openspec/changes/board-sidebar-filters-as-checkbox-lists/specs/index-sidebar/spec.md#requirement-the-board-look-draws-a-short-filter-as-a-checkbox-list-with-counts
1500
+ * @param {object} filter The filter definition.
1501
+ * @param {string|number|boolean} id The value.
1502
+ * @param {boolean} checked Whether it is now ticked.
1503
+ * @return {void}
1504
+ */
1505
+ onChecklistToggle(filter, id, checked) {
1506
+ const value = this.activeFilters[filter.key]
1507
+ const current = Array.isArray(value) ? [...value] : (value === undefined || value === null || value === '' ? [] : [value])
1508
+ const rest = current.filter((v) => String(v) !== String(id))
1509
+ this.$emit('filter-change', { key: filter.key, values: checked ? [...rest, id] : rest })
1510
+ },
1511
+
1414
1512
  /**
1415
1513
  * Handle filter select change
1416
1514
  *
@@ -226,6 +226,25 @@ function controlIcon(path) {
226
226
  * @slot popup Per-marker popup body override. Scoped props: `{ feature, properties }`. When omitted, the widget renders the value of `markers.popupField` (or no popup at all when unset).
227
227
  * @slot fallback Replaces the default "map unavailable" message when Leaflet fails to load (test environments, CSP-blocked builds, offline dev). No scoped props.
228
228
  */
229
+ /**
230
+ * The count line a header spec gives (`{ one, other }` or one string, with
231
+ * `{total}`), once the marker source answered with markers.
232
+ *
233
+ * @param {object|string|undefined} spec The declared line.
234
+ * @param {object} vm The map: `markersLoaded`, `markersFailed`, `markerCount`, `cnTranslate`.
235
+ * @return {string} The line, or ''.
236
+ */
237
+ function countLine(spec, vm) {
238
+ if (!spec || !vm.markersLoaded || vm.markersFailed || vm.markerCount === 0) {
239
+ return ''
240
+ }
241
+ const text = typeof spec === 'string' ? spec : (vm.markerCount === 1 ? spec.one : spec.other)
242
+ if (typeof text !== 'string' || text === '') {
243
+ return ''
244
+ }
245
+ return vm.cnTranslate(text).replace(/\{total\}/g, String(vm.markerCount))
246
+ }
247
+
229
248
  export default {
230
249
  name: 'CnMapWidget',
231
250
 
@@ -577,15 +596,19 @@ export default {
577
596
  * @spec openspec/changes/map-widget-header-count/specs/map-widget-header-count/spec.md#requirement-a-map-card-can-say-how-many-places-it-shows
578
597
  */
579
598
  headerSubtitleText() {
580
- const spec = this.content?.headerSubtitle
581
- if (!spec || !this.markersLoaded || this.markersFailed || this.markerCount === 0) {
582
- return ''
583
- }
584
- const text = typeof spec === 'string' ? spec : (this.markerCount === 1 ? spec.one : spec.other)
585
- if (typeof text !== 'string' || text === '') {
586
- return ''
587
- }
588
- return this.cnTranslate(text).replace(/\{total\}/g, String(this.markerCount))
599
+ return countLine(this.content?.headerSubtitle, this)
600
+ },
601
+
602
+ /**
603
+ * The same count as a note at the right of the card's title row
604
+ * (`content.headerNote`, the same shape as `headerSubtitle`), as the
605
+ * case boards draw "Locaties ... 1 locatie".
606
+ *
607
+ * @return {string}
608
+ * @spec openspec/changes/map-count-note/specs/map-widget/spec.md
609
+ */
610
+ headerNoteText() {
611
+ return countLine(this.content?.headerNote, this)
589
612
  },
590
613
 
591
614
  markerStatusText() {
@@ -672,6 +695,21 @@ export default {
672
695
  }
673
696
  },
674
697
 
698
+ /**
699
+ * Report the count note to the card around the map.
700
+ *
701
+ * @param {string} note The note, or ''.
702
+ */
703
+ headerNoteText(note) {
704
+ if (this.content?.headerNote) {
705
+ /**
706
+ * @event header The card header's note at the right of the title.
707
+ * @type {{note: string}}
708
+ */
709
+ this.$emit('header', { note })
710
+ }
711
+ },
712
+
675
713
  // Keyed, never `deep: true` — see `layersKey`.
676
714
  layersKey() {
677
715
  if (this.map) {
@@ -720,6 +720,7 @@ export default {
720
720
  * - `hidden` (boolean) — Hide this property
721
721
  * - `editable` (boolean) — Override editability (default: based on schema readOnly)
722
722
  * - `label` (string) — Override the display label
723
+ * - `emptyValue` (string) — Words shown, translated, for an empty or false value instead of a dash
723
724
  * - `widget` (string) — Override the widget type for editing
724
725
  * - `formatter` (string) — Display the value through the formatter
725
726
  * registered under this id on CnAppRoot (`cnFormatters`), called as
@@ -739,7 +740,8 @@ export default {
739
740
  },
740
741
 
741
742
  /**
742
- * Number of grid columns.
743
+ * Number of grid columns. The board look fits 200px columns instead,
744
+ * except for one column, which it keeps.
743
745
  */
744
746
  columns: {
745
747
  type: Number,
@@ -1100,7 +1102,8 @@ export default {
1100
1102
  // still showing the stored code — `submitted` under a Dutch
1101
1103
  // label, beside a list that already read "Ingediend".
1102
1104
  const enumLabel = this.enumDisplayLabel(field, raw)
1103
- values[field.key] = enumLabel !== null ? enumLabel : formatValue(raw, prop || {})
1105
+ const empty = this.emptyValueFor(field, raw)
1106
+ values[field.key] = empty !== null ? empty : (enumLabel !== null ? enumLabel : formatValue(raw, prop || {}))
1104
1107
  }
1105
1108
  return values
1106
1109
  },
@@ -1116,8 +1119,12 @@ export default {
1116
1119
  * CSS grid template for the container.
1117
1120
  */
1118
1121
  gridStyle() {
1119
- // The board look: as many 200px columns as the card has room for.
1122
+ // The board look: as many 200px columns as the card has room for,
1123
+ // unless the config asks for one column (a stacked list of pairs).
1120
1124
  if (normalizeLook(this.cnLook) === 'board') {
1125
+ if (this.columns === 1) {
1126
+ return { 'grid-template-columns': 'minmax(0, 1fr)' }
1127
+ }
1121
1128
  return {
1122
1129
  'grid-template-columns': 'repeat(auto-fit, minmax(200px, 1fr))',
1123
1130
  }
@@ -1842,6 +1849,24 @@ export default {
1842
1849
  return this.isPlainObject(raw)
1843
1850
  },
1844
1851
 
1852
+ /**
1853
+ * The words an override gives an empty or false value
1854
+ * (`overrides[key].emptyValue`, translated), or null to format it as usual.
1855
+ *
1856
+ * @param {object} field - Resolved field definition from resolvedFields
1857
+ * @param {*} raw - The field's value
1858
+ * @return {string|null}
1859
+ * @spec openspec/changes/data-widget-empty-value-one-column/specs/data-widget/spec.md
1860
+ */
1861
+ emptyValueFor(field, raw) {
1862
+ const override = this.resolvedOverrides[field.key]
1863
+ const words = override && typeof override.emptyValue === 'string' ? override.emptyValue : ''
1864
+ if (words === '' || !(raw === null || raw === undefined || raw === '' || raw === false)) {
1865
+ return null
1866
+ }
1867
+ return this.cnTranslate(words)
1868
+ },
1869
+
1845
1870
  /**
1846
1871
  * Check if a field is editable.
1847
1872
  *