@conduction/nextcloud-vue 2.139.0 → 2.140.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 (101) 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/CnCellRenderer/CnCellRenderer.vue.js +1 -1
  5. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js +17 -1
  6. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js.map +1 -1
  7. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js +2 -2
  8. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js.map +1 -1
  9. package/dist/esm/components/CnChartWidget/CnChartWidget.vue.js +1 -1
  10. package/dist/esm/components/CnChartWidget/CnChartWidget.vue2.js +52 -1
  11. package/dist/esm/components/CnChartWidget/CnChartWidget.vue2.js.map +1 -1
  12. package/dist/esm/components/CnChartWidget/CnChartWidget.vue3.js +23 -1
  13. package/dist/esm/components/CnChartWidget/CnChartWidget.vue3.js.map +1 -1
  14. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue.js +1 -1
  15. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue2.js +106 -0
  16. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue2.js.map +1 -1
  17. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue3.js +30 -7
  18. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue3.js.map +1 -1
  19. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue.js +1 -1
  20. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js +59 -3
  21. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js.map +1 -1
  22. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js +44 -34
  23. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js.map +1 -1
  24. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue.js +1 -1
  25. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js +28 -1
  26. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js.map +1 -1
  27. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js.map +1 -1
  28. package/dist/esm/components/CnFilesBrowser/filesBrowser.js +7 -1
  29. package/dist/esm/components/CnFilesBrowser/filesBrowser.js.map +1 -1
  30. package/dist/esm/components/CnFilesBrowser/filesBrowserColumns.js +5 -0
  31. package/dist/esm/components/CnFilesBrowser/filesBrowserColumns.js.map +1 -1
  32. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +30 -2
  33. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  34. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  35. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue.js +1 -1
  36. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue2.js +28 -3
  37. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue2.js.map +1 -1
  38. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue3.js.map +1 -1
  39. package/dist/esm/components/CnRecordListWidget/CnRecordNoteRows.vue.js +1 -1
  40. package/dist/esm/components/CnRecordListWidget/CnRecordNoteRows.vue2.js +10 -0
  41. package/dist/esm/components/CnRecordListWidget/CnRecordNoteRows.vue2.js.map +1 -1
  42. package/dist/esm/components/CnRecordListWidget/CnRecordNoteRows.vue3.js.map +1 -1
  43. package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue.js +1 -1
  44. package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue2.js +12 -1
  45. package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue2.js.map +1 -1
  46. package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue3.js +1 -1
  47. package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue3.js.map +1 -1
  48. package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue.js +1 -1
  49. package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue2.js +23 -1
  50. package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue2.js.map +1 -1
  51. package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue3.js +43 -13
  52. package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue3.js.map +1 -1
  53. package/dist/esm/composables/indexSources.js +5 -2
  54. package/dist/esm/composables/indexSources.js.map +1 -1
  55. package/dist/esm/composables/useTaskInboxStore.js +4 -3
  56. package/dist/esm/composables/useTaskInboxStore.js.map +1 -1
  57. package/dist/esm/l10n/en.json.js +2 -1
  58. package/dist/esm/l10n/en.json.js.map +1 -1
  59. package/dist/esm/l10n/nl.json.js +5 -1
  60. package/dist/esm/l10n/nl.json.js.map +1 -1
  61. package/dist/esm/library-style.js +1 -1
  62. package/dist/esm/nextcloud-vue.css +466 -371
  63. package/dist/esm/utils/builtInFormatters.js +23 -1
  64. package/dist/esm/utils/builtInFormatters.js.map +1 -1
  65. package/dist/esm/utils/fillTemplate.js +7 -1
  66. package/dist/esm/utils/fillTemplate.js.map +1 -1
  67. package/dist/esm/utils/myTeam.js +24 -4
  68. package/dist/esm/utils/myTeam.js.map +1 -1
  69. package/dist/esm/utils/resolveFilterTokens.js +5 -4
  70. package/dist/esm/utils/resolveFilterTokens.js.map +1 -1
  71. package/dist/esm/utils/transitionInputs.js +43 -2
  72. package/dist/esm/utils/transitionInputs.js.map +1 -1
  73. package/dist/nextcloud-vue.cjs.js +771 -222
  74. package/dist/nextcloud-vue.cjs.js.map +2 -2
  75. package/dist/nextcloud-vue.css +466 -371
  76. package/l10n/en.json +2 -1
  77. package/l10n/nl.json +5 -1
  78. package/package.json +1 -1
  79. package/src/components/CnAppRoot/CnAppRoot.vue +9 -2
  80. package/src/components/CnCellRenderer/CnCellRenderer.vue +19 -3
  81. package/src/components/CnChartWidget/CnChartWidget.md +1 -0
  82. package/src/components/CnChartWidget/CnChartWidget.vue +72 -1
  83. package/src/components/CnConversationThread/CnConversationWidget.vue +126 -1
  84. package/src/components/CnFactsWidget/CnFactsWidget.vue +72 -5
  85. package/src/components/CnFilesBrowser/CnFilesBrowser.vue +28 -1
  86. package/src/components/CnFilesBrowser/filesBrowser.js +7 -1
  87. package/src/components/CnFilesBrowser/filesBrowserColumns.js +5 -0
  88. package/src/components/CnIndexPage/CnIndexPage.vue +30 -2
  89. package/src/components/CnObjectDataWidget/CnObjectDataWidget.vue +28 -3
  90. package/src/components/CnRecordListWidget/CnRecordNoteRows.vue +10 -0
  91. package/src/components/CnStackedBarWidget/CnStackedBarWidget.vue +68 -2
  92. package/src/components/CnTransitionInputFields/CnTransitionInputFields.vue +43 -1
  93. package/src/composables/indexSources.js +5 -2
  94. package/src/composables/useTaskInboxStore.js +4 -3
  95. package/src/css/look-board.css +10 -2
  96. package/src/css/table.css +13 -0
  97. package/src/utils/builtInFormatters.js +22 -0
  98. package/src/utils/fillTemplate.js +7 -1
  99. package/src/utils/myTeam.js +24 -4
  100. package/src/utils/resolveFilterTokens.js +5 -4
  101. package/src/utils/transitionInputs.js +42 -1
@@ -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 }))
@@ -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
  *
@@ -117,6 +117,13 @@ import { readPath } from '../../utils/readPath.js'
117
117
  /** The bus channel every endpoint-bound widget on the page refetches on. */
118
118
  const PAGE_REFRESH_CHANNEL = 'cn:page:refresh'
119
119
 
120
+ /**
121
+ * The document event a recorded note sends, so a row's leaf (humaniq's
122
+ * hours) can record what it holds for the same object at the same moment.
123
+ * `detail` is the row's leaf props plus `note`.
124
+ */
125
+ const NOTE_RECORDED_EVENT = 'cn-record-notes:recorded'
126
+
120
127
  /**
121
128
  * The rows of a record list drawn as notes to record (`presentation:
122
129
  * "notes"`, DqDagAfsluiten "Nog open van vandaag"): per row its pill, title,
@@ -351,6 +358,9 @@ export default {
351
358
  try {
352
359
  const { default: axios } = await import('@nextcloud/axios')
353
360
  await axios.post(prefixUrl(url), { [this.action.bodyField || 'message']: text })
361
+ if (this.leaf) {
362
+ document.dispatchEvent(new CustomEvent(NOTE_RECORDED_EVENT, { detail: { ...this.leafProps(item.row), note: text } }))
363
+ }
354
364
  const { [item.key]: done, ...rest } = this.drafts
355
365
  this.drafts = rest
356
366
  if (refresh) {
@@ -3,7 +3,7 @@
3
3
  SPDX-License-Identifier: EUPL-1.2
4
4
  -->
5
5
  <template>
6
- <div class="cn-stacked-bar" :class="{ 'cn-stacked-bar--inset': inset }" data-testid="cn-stacked-bar">
6
+ <div class="cn-stacked-bar" :class="{ 'cn-stacked-bar--inset': inset, 'cn-stacked-bar--compact': compact }" data-testid="cn-stacked-bar">
7
7
  <div v-if="isLoading" class="cn-stacked-bar__state" role="status">
8
8
  <NcLoadingIcon :size="20" />
9
9
  <span>{{ loadingLabel }}</span>
@@ -183,7 +183,7 @@ export default {
183
183
  * segments to the card (`header` event, `count`), for a chip after the
184
184
  * title or a `{count}` in it.
185
185
  *
186
- * @type {{inset?: boolean, headerCount?: boolean, source?: {register?: string, schema?: string, groupBy?: string, filter?: object}, segments?: Array<{key?: string, label: string, value: number}>, order?: Array<string>, labels?: {[key: string]: string}, emptyText?: string}}
186
+ * @type {{inset?: boolean, compact?: boolean, headerCount?: boolean, source?: {register?: string, schema?: string, groupBy?: string, filter?: object}, segments?: Array<{key?: string, label: string, value: number}>, order?: Array<string>, labels?: {[key: string]: string}, emptyText?: string}}
187
187
  */
188
188
  content: {
189
189
  type: Object,
@@ -241,6 +241,17 @@ export default {
241
241
  return Boolean(this.content && this.content.inset === true)
242
242
  },
243
243
 
244
+ /**
245
+ * Whether the widget is the compact summary box (`content.compact`):
246
+ * a 15px heading, one thin bar and the legend on one line.
247
+ *
248
+ * @spec openspec/changes/stacked-bar-compact/specs/stacked-bar-compact/spec.md#requirement-a-stacked-bar-can-be-drawn-as-the-compact-summary-box
249
+ * @return {boolean}
250
+ */
251
+ compact() {
252
+ return Boolean(this.content && this.content.compact === true)
253
+ },
254
+
244
255
  /**
245
256
  * The translate function in use.
246
257
  *
@@ -651,4 +662,59 @@ export default {
651
662
  line-height: 1.2;
652
663
  color: var(--color-main-text);
653
664
  }
665
+
666
+ /* `content.compact: true`: the summary box (DqZaakDocumenten "4 van 7
667
+ beoordeeld"): the sunken ground, a 15px heading, one 8px bar of joined
668
+ segments and the legend on one line, each count beside its label. */
669
+ .cn-stacked-bar--compact {
670
+ box-sizing: border-box;
671
+ gap: 10px;
672
+ padding: 14px 16px;
673
+ border-radius: 10px;
674
+ background: var(--cn-board-sunken-ground, var(--color-background-hover));
675
+ }
676
+
677
+ .cn-stacked-bar--compact .cn-stacked-bar__heading {
678
+ flex-wrap: wrap;
679
+ gap: 8px;
680
+ font-size: 15px;
681
+ }
682
+
683
+ .cn-stacked-bar--compact .cn-stacked-bar__bar {
684
+ gap: 0;
685
+ height: 8px;
686
+ border-radius: 4px;
687
+ overflow: hidden;
688
+ }
689
+
690
+ .cn-stacked-bar--compact .cn-stacked-bar__segment {
691
+ border-radius: 0;
692
+ }
693
+
694
+ .cn-stacked-bar--compact .cn-stacked-bar__legend {
695
+ display: flex;
696
+ flex-wrap: wrap;
697
+ gap: 8px 18px;
698
+ font-size: 14px;
699
+ }
700
+
701
+ .cn-stacked-bar--compact .cn-stacked-bar__legend-item {
702
+ flex-direction: row;
703
+ align-items: center;
704
+ gap: 8px;
705
+ }
706
+
707
+ .cn-stacked-bar--compact .cn-stacked-bar__label {
708
+ color: var(--color-main-text);
709
+ font-size: 1em;
710
+ }
711
+
712
+ .cn-stacked-bar--compact .cn-stacked-bar__dot {
713
+ border-radius: 50%;
714
+ }
715
+
716
+ .cn-stacked-bar--compact .cn-stacked-bar__count {
717
+ font-size: 1em;
718
+ line-height: inherit;
719
+ }
654
720
  </style>
@@ -162,6 +162,26 @@
162
162
  </p>
163
163
  </div>
164
164
 
165
+ <!-- A days input set as a date (`dateField`): today plus the days,
166
+ and a chosen date sets the days. -->
167
+ <div v-if="field.input.dateField"
168
+ :key="`${field.key}-date`"
169
+ class="cn-transition-input-fields__field cn-transition-input-fields__wide"
170
+ :data-testid="field.input.dateField.testid || `cn-transition-input-date-${field.key}`">
171
+ <label class="cn-transition-input-fields__label"
172
+ :for="`${inputId(field)}-date`">{{ dateFieldLabel(field) }}</label>
173
+ <NcTextField :id="`${inputId(field)}-date`"
174
+ :modelValue="field.dateValue"
175
+ type="date"
176
+ :label="dateFieldLabel(field)"
177
+ :labelOutside="true"
178
+ :disabled="disabled"
179
+ @update:modelValue="setValue(field.key, daysUntilIso($event))" />
180
+ <span v-if="field.input.dateField.note" class="cn-transition-input-fields__muted">
181
+ {{ field.input.dateField.note }}
182
+ </span>
183
+ </div>
184
+
165
185
  <!-- What the input means once filled in, such as the day a paused
166
186
  term comes back. Shown only once it can be worked out. -->
167
187
  <div v-if="field.previewText"
@@ -209,7 +229,7 @@ import { NcCheckboxRadioSwitch, NcSelect, NcTextArea, NcTextField } from '@nextc
209
229
  import CnResourceSelect from '../CnResourceSelect/CnResourceSelect.vue'
210
230
  import CnTemplateSelect from '../CnTemplateSelect/CnTemplateSelect.vue'
211
231
  import { readPath } from '../../utils/readPath.js'
212
- import { applyTemplateText, isInputFilled, resolveInputFields } from '../../utils/transitionInputs.js'
232
+ import { applyTemplateText, daysUntilIso, isInputFilled, resolveInputFields } from '../../utils/transitionInputs.js'
213
233
  import { CnIcon } from '../CnIcon/index.js'
214
234
 
215
235
  let uid = 0
@@ -505,6 +525,28 @@ export default {
505
525
  return this.markOptional && !field.required ? `${field.label} (${t('nextcloud-vue', 'optional')})` : field.label
506
526
  },
507
527
 
528
+ /**
529
+ * The label of a days input's date field (`dateField`), with the
530
+ * optional marker when asked for: the date is never required.
531
+ *
532
+ * @param {object} field The descriptor.
533
+ * @return {string}
534
+ */
535
+ dateFieldLabel(field) {
536
+ const label = String(field.input.dateField.label || '')
537
+ return this.markOptional ? `${label} (${t('nextcloud-vue', 'optional')})` : label
538
+ },
539
+
540
+ /**
541
+ * The days a chosen date stands for.
542
+ *
543
+ * @param {string} iso The date input's value.
544
+ * @return {string} The days, '' without a date.
545
+ */
546
+ daysUntilIso(iso) {
547
+ return daysUntilIso(iso)
548
+ },
549
+
508
550
  /**
509
551
  * Label of a sub-field, with the required marker.
510
552
  *
@@ -702,12 +702,15 @@ function tasksSource() {
702
702
  *
703
703
  * @param {string} key The column key.
704
704
  * @param {string} order `asc` or `desc`.
705
- * @return {{sort: string}|null} The parameter, or null for a column the inbox cannot sort by.
705
+ * Open tasks come before finished ones (`openFirst`), each in that order.
706
+ *
707
+ * @return {{sort: string, openFirst: boolean}|null} The parameters, or null for a column the inbox cannot sort by.
706
708
  * @spec openspec/changes/tasks-source-sorts-on-server/specs/index-sources/spec.md#requirement-a-named-source-can-sort-on-the-server
709
+ * @spec openspec/changes/tasks-source-open-first/specs/index-sources/spec.md#requirement-a-sorted-task-list-puts-open-tasks-first
707
710
  */
708
711
  sortParam: (key, order) => {
709
712
  const field = TASK_SORT_FIELDS[key]
710
- return field ? { sort: (order === 'desc' ? '-' : '') + field } : null
713
+ return field ? { sort: (order === 'desc' ? '-' : '') + field, openFirst: true } : null
711
714
  },
712
715
 
713
716
  // The scope tabs. Each tab's filter is merged over the page's
@@ -41,7 +41,8 @@ export const FLOW_TASKS_URL = '/apps/openregister/api/flow-tasks'
41
41
  // creating app named it, so it is the one filter that lets a Tasks page say
42
42
  // "the reminders" without the engine learning what a reminder is. It widens
43
43
  // nothing: it narrows within the inbox the session already decides.
44
- const ALLOWED_PARAMS = ['scope', 'state', 'isTerminal', 'priority', 'kind', 'overdue', 'dueAfter', 'dueBefore', 'objectUuid', 'sort', 'limit', 'offset']
44
+ // `openFirst` asks open tasks before finished ones, a sort the engine applies.
45
+ const ALLOWED_PARAMS = ['scope', 'state', 'isTerminal', 'priority', 'kind', 'overdue', 'dueAfter', 'dueBefore', 'objectUuid', 'sort', 'openFirst', 'limit', 'offset']
45
46
 
46
47
  /**
47
48
  * The flat body `POST /api/flow-tasks` takes for a task on a record.
@@ -178,7 +179,7 @@ export const useTaskInboxStore = defineStore('cnTaskInbox', {
178
179
  if (value === undefined || value === null || value === '') {
179
180
  continue
180
181
  }
181
- params[key] = (key === 'overdue' || key === 'isTerminal') ? String(value) : value
182
+ params[key] = (key === 'overdue' || key === 'isTerminal' || key === 'openFirst') ? String(value) : value
182
183
  }
183
184
  const response = await axios.get(generateUrl(FLOW_TASKS_URL), { params })
184
185
  const results = response.data?.results || []
@@ -358,7 +359,7 @@ export const useTaskInboxStore = defineStore('cnTaskInbox', {
358
359
  }
359
360
  // The endpoint reads `overdue` as a boolean-ish string; a quick
360
361
  // filter authors it as `true` for legibility.
361
- params[key] = (key === 'overdue' || key === 'isTerminal') ? String(value) : value
362
+ params[key] = (key === 'overdue' || key === 'isTerminal' || key === 'openFirst') ? String(value) : value
362
363
  }
363
364
 
364
365
  this.loading = true
@@ -3004,8 +3004,8 @@ body:has(.cn-look-board):not(:has(.cn-look-nextcloud)) .v-popper__popper.action-
3004
3004
 
3005
3005
  /* A dialog's options as the boards draw them (DqVolgendeStap, DqBesluit,
3006
3006
  DqDocumentGenereren, DqFilterBewerken): rounded at 10px, an idle option
3007
- edged in the dark border grey, and an 18px radio. The chosen and the
3008
- refused options keep their own edges. */
3007
+ edged in the dark border grey, and an 18px radio. The chosen option keeps
3008
+ its own edge. */
3009
3009
  .cn-look-board .cn-choice-cards .cn-choice-cards__row:not(.cn-look-nextcloud *),
3010
3010
  .cn-look-board .cn-choice-cards .cn-choice-cards__cell--with-details:not(.cn-look-nextcloud *) {
3011
3011
  border-radius: 10px;
@@ -3015,6 +3015,14 @@ body:has(.cn-look-board):not(:has(.cn-look-nextcloud)) .v-popper__popper.action-
3015
3015
  border-color: var(--color-border-maxcontrast);
3016
3016
  }
3017
3017
 
3018
+ /* A refused option keeps that edge and sits on the sunken ground
3019
+ (DqVolgendeStap: Hervatten, Archiveren, Heropenen). */
3020
+ .cn-look-board .cn-choice-cards .cn-choice-cards__row--disabled:not(.cn-look-nextcloud *),
3021
+ .cn-look-board .cn-choice-cards .cn-choice-cards__row--disabled:hover:not(.cn-look-nextcloud *) {
3022
+ border-color: var(--color-border-maxcontrast);
3023
+ background: var(--cn-board-sunken-ground);
3024
+ }
3025
+
3018
3026
  /* A chosen option stays white under its primary edge, as every Dq dialog board
3019
3027
  draws it (design-system #371); the Nextcloud look keeps the tint.
3020
3028
  @spec openspec/changes/board-choice-row-chosen-white/specs/board-choice-row-chosen-white/spec.md#requirement-a-chosen-board-option-is-white */
package/src/css/table.css CHANGED
@@ -289,6 +289,19 @@
289
289
  white-space: nowrap;
290
290
  }
291
291
 
292
+ /* A growing column that opts into wrapping (`cn-cell--wrap`, DqDoorlooptijd
293
+ "Zaken met risico" titles) wraps its text over lines instead of cutting
294
+ it, and keeps a readable width: with `max-width: 0` the wide one-line
295
+ cells beside it squeezed it to its padding (24px).
296
+ @spec openspec/changes/fit-table-wrap-column-keeps-width/specs/fit-table-wrap-column-keeps-width/spec.md#requirement-a-wrapping-growing-column-keeps-a-readable-width */
297
+ .cn-data-table--fit th.cn-table-col--grow.cn-cell--wrap,
298
+ .cn-data-table--fit td.cn-table-col--grow.cn-cell--wrap {
299
+ min-width: 8em;
300
+ max-width: none;
301
+ overflow-wrap: anywhere;
302
+ white-space: normal;
303
+ }
304
+
292
305
  /* The secondary line of the growing column ("2026-0004" under a case title)
293
306
  is a line of its own with its own ellipsis. The cell does not wrap, so in
294
307
  the board look, where the secondary line is an inline block (the title
@@ -470,6 +470,27 @@ export function formatMasked(value, _row, _property, options) {
470
470
  return '*'.repeat(Math.min(text.length - visible, 5)) + (visible > 0 ? text.slice(-visible) : '')
471
471
  }
472
472
 
473
+ /**
474
+ * A stored value through a display map (`valueMap` registry key): the label
475
+ * `options.map` gives the value, else the value as stored. "Besloten
476
+ * Vennootschap" reads "BV" on DqOrganisaties.
477
+ *
478
+ * @param {unknown} value The stored value.
479
+ * @param {object} [_row] The full row (unused).
480
+ * @param {object} [_property] The schema property (unused).
481
+ * @param {{map?: Object<string, string>}} [options] The column's `formatterOptions`.
482
+ * @return {string} The label, or the value.
483
+ * @spec openspec/changes/value-map-formatter/specs/index-page/spec.md#requirement-a-column-can-show-stored-values-through-a-display-map
484
+ */
485
+ export function formatValueMap(value, _row, _property, options) {
486
+ if (value === null || value === undefined || value === '') {
487
+ return ''
488
+ }
489
+ const map = options && options.map && typeof options.map === 'object' ? options.map : {}
490
+ const label = Object.hasOwn(map, String(value)) ? map[String(value)] : undefined
491
+ return typeof label === 'string' && label !== '' ? label : String(value)
492
+ }
493
+
473
494
  /**
474
495
  * Built-in formatter registry merged under any consumer-registered
475
496
  * `formatters` in `CnAppRoot`'s `cnFormatters` provide.
@@ -516,4 +537,5 @@ export const BUILT_IN_FORMATTERS = {
516
537
  connectionSettingsLabel: formatConnectionSettingsLabel,
517
538
  masked: formatMasked,
518
539
  isoDuration: formatIsoDuration,
540
+ valueMap: formatValueMap,
519
541
  }
@@ -64,7 +64,7 @@ export function formatLongDate(value, now = new Date(), withYear = false) {
64
64
  * Format one token value.
65
65
  *
66
66
  * @param {unknown} value The raw value.
67
- * @param {string} format `date`, `datetime`, `weekday`, `longdate`, `time`, `user`, `currency`, `relative`, `relativetime`, `ago`, `week`, `days`, `lower` or '' (as is).
67
+ * @param {string} format `date`, `datetime`, `weekday`, `longdate`, `time`, `user`, `currency`, `relative`, `relativetime`, `ago`, `week`, `days`, `lower`, `mailbox` or '' (as is).
68
68
  * @param {Date} now The reference moment.
69
69
  * @param {string} [arg] The format's argument (`currency:EUR`, or a row path holding the code).
70
70
  * @param {object|null} [object] The row, for a currency read from a field.
@@ -125,6 +125,12 @@ function formatValue(value, format, now, arg = '', object = null, config = null,
125
125
  if (format === 'ago') {
126
126
  return formatAgo(value, now)
127
127
  }
128
+ if (format === 'mailbox') {
129
+ // An address up to and with its @ ("gemeente@" from gemeente@zuiddrecht.nl).
130
+ const text = String(value)
131
+ const at = text.indexOf('@')
132
+ return at > 0 ? text.slice(0, at + 1) : text
133
+ }
128
134
  if (format === 'lower') {
129
135
  return String(value).toLocaleLowerCase()
130
136
  }
@@ -30,12 +30,32 @@ let _source = null
30
30
  /** The read in flight, shared by every caller. */
31
31
  let _pending = null
32
32
 
33
+ /**
34
+ * The team the app answered, as kept: one group id, a list of two or more,
35
+ * or `''` for none. Blank entries are dropped and a list of one reads as that
36
+ * one id.
37
+ *
38
+ * @spec openspec/changes/my-team-source-may-answer-several-teams/specs/schema-utilities/spec.md#requirement-myteam-may-resolve-to-several-teams
39
+ * @param {unknown} team What the app's loader answered.
40
+ * @return {string|string[]} The kept team.
41
+ */
42
+ function normalizeTeam(team) {
43
+ if (Array.isArray(team)) {
44
+ const ids = team.filter((id) => typeof id === 'string').map((id) => id.trim()).filter((id) => id !== '')
45
+ if (ids.length === 0) {
46
+ return ''
47
+ }
48
+ return ids.length === 1 ? ids[0] : ids
49
+ }
50
+ return typeof team === 'string' ? team.trim() : ''
51
+ }
52
+
33
53
  /**
34
54
  * Name where the current user's primary team is read: a function answering
35
55
  * the team's Nextcloud group id, or null/'' for a user in no team.
36
56
  *
37
57
  * @spec openspec/changes/saved-view-tokens-my-team/specs/schema-utilities/spec.md#requirement-myteam-resolves-to-the-current-users-primary-team
38
- * @param {(() => Promise<string|null>)|null} loader The app's loader, or null to unregister.
58
+ * @param {(() => Promise<string|string[]|null>)|null} loader The app's loader (a group id, or the ids of several teams), or null to unregister.
39
59
  * @return {void}
40
60
  */
41
61
  export function registerMyTeamSource(loader) {
@@ -49,7 +69,7 @@ export function registerMyTeamSource(loader) {
49
69
  * when no source is registered, or when the read fails.
50
70
  *
51
71
  * @spec openspec/changes/saved-view-tokens-my-team/specs/schema-utilities/spec.md#requirement-myteam-resolves-to-the-current-users-primary-team
52
- * @return {Promise<string>} The team's group id, or ''.
72
+ * @return {Promise<string|string[]>} The team's group id (or ids), or ''.
53
73
  */
54
74
  export function loadMyTeam() {
55
75
  if (_state.team !== null) {
@@ -64,7 +84,7 @@ export function loadMyTeam() {
64
84
  }
65
85
  _pending = Promise.resolve()
66
86
  .then(() => _source())
67
- .then((team) => (typeof team === 'string' ? team.trim() : ''))
87
+ .then((team) => normalizeTeam(team))
68
88
  .catch(() => '')
69
89
  .then((team) => {
70
90
  _state.team = team
@@ -79,7 +99,7 @@ export function loadMyTeam() {
79
99
  * it is being read (and starts the read on first use).
80
100
  *
81
101
  * @spec openspec/changes/saved-view-tokens-my-team/specs/schema-utilities/spec.md#requirement-myteam-resolves-to-the-current-users-primary-team
82
- * @return {string|null} The team, '' or null.
102
+ * @return {string|string[]|null} The team (or teams), '' or null.
83
103
  */
84
104
  export function peekMyTeam() {
85
105
  if (_state.team === null) {
@@ -89,15 +89,16 @@ export const MY_GROUPS_TOKEN = '@myGroups'
89
89
  export const MY_TEAM_TOKEN = '@myTeam'
90
90
 
91
91
  /**
92
- * Resolve `@myTeam`: the team's group id; `@myTeam` while it is being read (the
92
+ * Resolve `@myTeam`: the team's group id, or the ids of several teams when the
93
+ * app answers a list (an IN filter); `@myTeam` while it is being read (the
93
94
  * caller waits); `@myTeam?` for a user in no team (the caller drops the key).
94
95
  *
95
96
  * @spec openspec/changes/saved-view-tokens-my-team/specs/schema-utilities/spec.md#requirement-myteam-resolves-to-the-current-users-primary-team
96
- * @param {{myTeam?: string}} [ctx] Optional context; `ctx.myTeam` wins.
97
- * @return {string} The group id or the token.
97
+ * @param {{myTeam?: (string|string[])}} [ctx] Optional context; `ctx.myTeam` wins.
98
+ * @return {string|string[]} The group id(s) or the token.
98
99
  */
99
100
  function resolveMyTeam(ctx) {
100
- const team = ctx && typeof ctx.myTeam === 'string' ? ctx.myTeam : peekMyTeam()
101
+ const team = ctx && (typeof ctx.myTeam === 'string' || Array.isArray(ctx.myTeam)) ? ctx.myTeam : peekMyTeam()
101
102
  if (team === null) {
102
103
  return MY_TEAM_TOKEN
103
104
  }