@conduction/nextcloud-vue 2.143.0 → 2.145.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 (165) hide show
  1. package/dist/esm/components/CnActionDialog/CnActionDialog.vue.js +1 -1
  2. package/dist/esm/components/CnActionDialog/CnActionDialog.vue2.js +7 -2
  3. package/dist/esm/components/CnActionDialog/CnActionDialog.vue2.js.map +1 -1
  4. package/dist/esm/components/CnActionDialog/CnActionDialog.vue3.js +1 -0
  5. package/dist/esm/components/CnActionDialog/CnActionDialog.vue3.js.map +1 -1
  6. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue.js +1 -1
  7. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue2.js +55 -2
  8. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue2.js.map +1 -1
  9. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue3.js.map +1 -1
  10. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue.js +1 -1
  11. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue2.js +16 -0
  12. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue2.js.map +1 -1
  13. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue3.js +3 -1
  14. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue3.js.map +1 -1
  15. package/dist/esm/components/CnConversationThread/CnConversationThread.vue.js +1 -1
  16. package/dist/esm/components/CnConversationThread/CnConversationThread.vue2.js.map +1 -1
  17. package/dist/esm/components/CnConversationThread/CnConversationThread.vue3.js +1 -1
  18. package/dist/esm/components/CnConversationThread/CnConversationThread.vue3.js.map +1 -1
  19. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHeaderItems.vue.js +1 -1
  20. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHeaderItems.vue2.js +1 -1
  21. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHeaderItems.vue2.js.map +1 -1
  22. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHeaderItems.vue3.js +3 -3
  23. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHeaderItems.vue3.js.map +1 -1
  24. package/dist/esm/components/CnEventsCard/CnEventsCard.vue.js +1 -1
  25. package/dist/esm/components/CnEventsCard/CnEventsCard.vue2.js +9 -0
  26. package/dist/esm/components/CnEventsCard/CnEventsCard.vue2.js.map +1 -1
  27. package/dist/esm/components/CnEventsCard/CnEventsCard.vue3.js +24 -13
  28. package/dist/esm/components/CnEventsCard/CnEventsCard.vue3.js.map +1 -1
  29. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue.js +1 -1
  30. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue2.js +2 -2
  31. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue2.js.map +1 -1
  32. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue3.js.map +1 -1
  33. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue.js +1 -1
  34. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js +2 -1
  35. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js.map +1 -1
  36. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js.map +1 -1
  37. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +9 -4
  38. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  39. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  40. package/dist/esm/components/CnMailTab/CnMailTab.vue.js +1 -1
  41. package/dist/esm/components/CnMailTab/CnMailTab.vue2.js.map +1 -1
  42. package/dist/esm/components/CnMailTab/CnMailTab.vue3.js +2 -2
  43. package/dist/esm/components/CnMailTab/CnMailTab.vue3.js.map +1 -1
  44. package/dist/esm/components/CnNotesCard/CnNotesCard.vue.js +1 -1
  45. package/dist/esm/components/CnNotesCard/CnNotesCard.vue2.js +3 -0
  46. package/dist/esm/components/CnNotesCard/CnNotesCard.vue2.js.map +1 -1
  47. package/dist/esm/components/CnNotesCard/CnNotesCard.vue3.js +19 -14
  48. package/dist/esm/components/CnNotesCard/CnNotesCard.vue3.js.map +1 -1
  49. package/dist/esm/components/CnNotificationMatrix/CnNotificationMatrix.vue.js +1 -1
  50. package/dist/esm/components/CnNotificationMatrix/CnNotificationMatrix.vue2.js.map +1 -1
  51. package/dist/esm/components/CnNotificationMatrix/CnNotificationMatrix.vue3.js.map +1 -1
  52. package/dist/esm/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue.js +1 -1
  53. package/dist/esm/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue2.js +30 -0
  54. package/dist/esm/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue2.js.map +1 -1
  55. package/dist/esm/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue3.js +158 -102
  56. package/dist/esm/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue3.js.map +1 -1
  57. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +1 -1
  58. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +73 -12
  59. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -1
  60. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js +165 -126
  61. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -1
  62. package/dist/esm/components/CnRetentionWidget/CnRetentionWidget.vue.js +1 -1
  63. package/dist/esm/components/CnRetentionWidget/CnRetentionWidget.vue2.js.map +1 -1
  64. package/dist/esm/components/CnRetentionWidget/CnRetentionWidget.vue3.js +12 -0
  65. package/dist/esm/components/CnRetentionWidget/CnRetentionWidget.vue3.js.map +1 -1
  66. package/dist/esm/components/CnSettingsField/CnSettingsField.vue.js +1 -1
  67. package/dist/esm/components/CnSettingsField/CnSettingsField.vue2.js +14 -0
  68. package/dist/esm/components/CnSettingsField/CnSettingsField.vue2.js.map +1 -1
  69. package/dist/esm/components/CnSettingsField/CnSettingsField.vue3.js +2 -1
  70. package/dist/esm/components/CnSettingsField/CnSettingsField.vue3.js.map +1 -1
  71. package/dist/esm/components/CnTasksWidget/CnTasksWidget.vue.js +1 -1
  72. package/dist/esm/components/CnTasksWidget/CnTasksWidget.vue2.js +110 -2
  73. package/dist/esm/components/CnTasksWidget/CnTasksWidget.vue2.js.map +1 -1
  74. package/dist/esm/components/CnTasksWidget/CnTasksWidget.vue3.js +146 -80
  75. package/dist/esm/components/CnTasksWidget/CnTasksWidget.vue3.js.map +1 -1
  76. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue.js +1 -1
  77. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue2.js +13 -8
  78. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue2.js.map +1 -1
  79. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue3.js.map +1 -1
  80. package/dist/esm/components/CnWorkspaceFilterWidget/CnWorkspaceFilterWidget.vue.js +1 -1
  81. package/dist/esm/components/CnWorkspaceFilterWidget/CnWorkspaceFilterWidget.vue2.js +10 -2
  82. package/dist/esm/components/CnWorkspaceFilterWidget/CnWorkspaceFilterWidget.vue2.js.map +1 -1
  83. package/dist/esm/components/CnWorkspaceFilterWidget/CnWorkspaceFilterWidget.vue3.js.map +1 -1
  84. package/dist/esm/composables/indexSources.js +35 -9
  85. package/dist/esm/composables/indexSources.js.map +1 -1
  86. package/dist/esm/integrations/builtin/calendar/CnCalendarCard.vue.js +1 -1
  87. package/dist/esm/integrations/builtin/calendar/CnCalendarCard.vue2.js +45 -0
  88. package/dist/esm/integrations/builtin/calendar/CnCalendarCard.vue2.js.map +1 -1
  89. package/dist/esm/integrations/builtin/calendar/CnCalendarCard.vue3.js +67 -34
  90. package/dist/esm/integrations/builtin/calendar/CnCalendarCard.vue3.js.map +1 -1
  91. package/dist/esm/integrations/builtin/calendar/CnCalendarTab.vue.js +1 -1
  92. package/dist/esm/integrations/builtin/calendar/CnCalendarTab.vue2.js +3 -22
  93. package/dist/esm/integrations/builtin/calendar/CnCalendarTab.vue2.js.map +1 -1
  94. package/dist/esm/integrations/builtin/calendar/CnCalendarTab.vue3.js.map +1 -1
  95. package/dist/esm/integrations/builtin/calendar/eventStatus.js +67 -0
  96. package/dist/esm/integrations/builtin/calendar/eventStatus.js.map +1 -0
  97. package/dist/esm/integrations/builtin/deck/CnDeckCard.vue.js +1 -1
  98. package/dist/esm/integrations/builtin/deck/CnDeckCard.vue2.js +39 -0
  99. package/dist/esm/integrations/builtin/deck/CnDeckCard.vue2.js.map +1 -1
  100. package/dist/esm/integrations/builtin/deck/CnDeckCard.vue3.js +81 -32
  101. package/dist/esm/integrations/builtin/deck/CnDeckCard.vue3.js.map +1 -1
  102. package/dist/esm/integrations/builtin/deck/CnDeckTab.vue.js +1 -1
  103. package/dist/esm/integrations/builtin/deck/CnDeckTab.vue2.js +5 -6
  104. package/dist/esm/integrations/builtin/deck/CnDeckTab.vue2.js.map +1 -1
  105. package/dist/esm/integrations/builtin/deck/CnDeckTab.vue3.js.map +1 -1
  106. package/dist/esm/integrations/builtin/deck/deckCardParts.js +59 -0
  107. package/dist/esm/integrations/builtin/deck/deckCardParts.js.map +1 -0
  108. package/dist/esm/l10n/en.json.js +6 -0
  109. package/dist/esm/l10n/en.json.js.map +1 -1
  110. package/dist/esm/l10n/nl.json.js +8 -0
  111. package/dist/esm/l10n/nl.json.js.map +1 -1
  112. package/dist/esm/library-style.js +1 -1
  113. package/dist/esm/nextcloud-vue.css +1505 -637
  114. package/dist/esm/utils/builtInFormatters.js +18 -1
  115. package/dist/esm/utils/builtInFormatters.js.map +1 -1
  116. package/dist/esm/utils/fetchFilterCounts.js +5 -2
  117. package/dist/esm/utils/fetchFilterCounts.js.map +1 -1
  118. package/dist/esm/utils/localHeaderRows.js +95 -0
  119. package/dist/esm/utils/localHeaderRows.js.map +1 -0
  120. package/dist/esm/utils/nextcloudDirectory.js +19 -6
  121. package/dist/esm/utils/nextcloudDirectory.js.map +1 -1
  122. package/dist/nextcloud-vue.cjs.js +1848 -924
  123. package/dist/nextcloud-vue.cjs.js.map +2 -2
  124. package/dist/nextcloud-vue.css +1505 -637
  125. package/l10n/en.json +6 -0
  126. package/l10n/nl.json +8 -0
  127. package/package.json +1 -1
  128. package/src/components/CnActionDialog/CnActionDialog.vue +36 -4
  129. package/src/components/CnAuditTrailCard/CnAuditTrailCard.vue +55 -2
  130. package/src/components/CnAuditTrailWidget/CnAuditTrailWidget.vue +18 -0
  131. package/src/components/CnConversationThread/CnConversationThread.vue +5 -2
  132. package/src/components/CnDetailWidgetHost/CnDetailWidgetHeaderItems.vue +2 -1
  133. package/src/components/CnEventsCard/CnEventsCard.vue +27 -5
  134. package/src/components/CnFieldListWidget/CnFieldListWidget.vue +2 -2
  135. package/src/components/CnFilesBrowser/CnFilesBrowser.vue +15 -1
  136. package/src/components/CnIndexPage/CnIndexPage.vue +9 -4
  137. package/src/components/CnMailTab/CnMailTab.vue +6 -4
  138. package/src/components/CnNotesCard/CnNotesCard.vue +8 -0
  139. package/src/components/CnNotificationMatrix/CnNotificationMatrix.vue +4 -1
  140. package/src/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue +114 -46
  141. package/src/components/CnRecordListWidget/CnRecordListWidget.vue +131 -20
  142. package/src/components/CnRetentionWidget/CnRetentionWidget.vue +26 -1
  143. package/src/components/CnSettingsField/CnSettingsField.vue +15 -0
  144. package/src/components/CnTasksWidget/CnTasksWidget.vue +170 -13
  145. package/src/components/CnWidgetObjectTable/CnWidgetObjectTable.vue +13 -8
  146. package/src/components/CnWorkspaceFilterWidget/CnWorkspaceFilterWidget.vue +10 -2
  147. package/src/composables/indexSources.js +35 -9
  148. package/src/css/dialog.css +25 -10
  149. package/src/css/kpi-card.css +3 -0
  150. package/src/css/look-board-detail.css +513 -0
  151. package/src/css/look-board-index.css +52 -4
  152. package/src/css/look-board.css +163 -8
  153. package/src/css/table.css +8 -1
  154. package/src/integrations/builtin/calendar/CnCalendarCard.vue +76 -11
  155. package/src/integrations/builtin/calendar/CnCalendarTab.vue +3 -22
  156. package/src/integrations/builtin/calendar/__tests__/CnCalendarCardBoardRows.spec.js +52 -0
  157. package/src/integrations/builtin/calendar/eventStatus.js +63 -0
  158. package/src/integrations/builtin/deck/CnDeckCard.vue +66 -0
  159. package/src/integrations/builtin/deck/CnDeckTab.vue +5 -6
  160. package/src/integrations/builtin/deck/__tests__/CnDeckCardBoardRows.spec.js +49 -0
  161. package/src/integrations/builtin/deck/deckCardParts.js +56 -0
  162. package/src/utils/builtInFormatters.js +17 -0
  163. package/src/utils/fetchFilterCounts.js +5 -2
  164. package/src/utils/localHeaderRows.js +91 -0
  165. package/src/utils/nextcloudDirectory.js +19 -6
@@ -93,6 +93,33 @@
93
93
  <div v-else-if="cards.length === 0" class="cn-deck-card__empty">
94
94
  {{ emptyLabel }}
95
95
  </div>
96
+ <!-- The board look draws each linked card as a ruled row: its title,
97
+ one meta line (board, list, assignees) and its first label at
98
+ the end, then a link to Deck. -->
99
+ <div v-else-if="isBoard" class="cn-deck-card__rows">
100
+ <ul class="cn-deck-card__row-list">
101
+ <li v-for="card in cards"
102
+ :key="cardKey(card)"
103
+ class="cn-deck-card__row"
104
+ data-testid="cn-deck-card-row">
105
+ <span class="cn-deck-card__row-main">
106
+ <a class="cn-deck-card__row-title"
107
+ :href="cardUrl(card)"
108
+ target="_blank"
109
+ rel="noopener">{{ cardTitle(card) }}</a>
110
+ <span class="cn-deck-card__row-meta" data-testid="cn-deck-card-meta">{{ boardMetaOf(card) }}</span>
111
+ </span>
112
+ <span v-if="firstLabel(card)"
113
+ class="cn-status-badge cn-status-badge--purple"
114
+ data-testid="cn-deck-card-label">{{ firstLabel(card) }}</span>
115
+ </li>
116
+ </ul>
117
+ <a class="cn-deck-card__open"
118
+ :href="deckAppUrl"
119
+ target="_blank"
120
+ rel="noopener"
121
+ data-testid="cn-deck-card-open">{{ openDeckLabel }}</a>
122
+ </div>
96
123
  <div v-else class="cn-deck-card__kanban">
97
124
  <section
98
125
  v-for="stack in miniColumns"
@@ -146,7 +173,9 @@ import ViewColumnOutline from 'vue-material-design-icons/ViewColumnOutline.vue'
146
173
  import CnDeckCardCreate from '../../../components/CnDeckCardCreate/CnDeckCardCreate.vue'
147
174
  import CnDeckCardPicker from '../../../components/CnDeckCardPicker/CnDeckCardPicker.vue'
148
175
  import CnDetailCard from '../../../components/CnDetailCard/CnDetailCard.vue'
176
+ import { normalizeLook } from '../../../composables/useLook.js'
149
177
  import { buildHeaders, prefixUrl, safeHref } from '../../../utils/index.js'
178
+ import { assigneeDisplayName, cardAssignees, cardBoardTitle, cardLabels, cardStackTitle } from './deckCardParts.js'
150
179
 
151
180
  const VALID_SURFACES = ['user-dashboard', 'app-dashboard', 'detail-page', 'single-entity']
152
181
 
@@ -161,6 +190,10 @@ export default {
161
190
 
162
191
  components: { CnDeckCardCreate, CnDeckCardPicker, CnDetailCard, LinkVariant, NcButton, NcLoadingIcon, Plus, ViewColumnOutline },
163
192
 
193
+ inject: {
194
+ cnLook: { default: 'nextcloud' },
195
+ },
196
+
164
197
  props: {
165
198
  /** Stable integration id (forwarded from the registry — always `'deck'`). */
166
199
  integrationId: { type: String, default: 'deck' },
@@ -203,6 +236,8 @@ export default {
203
236
  linkLabel: { type: String, default: () => t('nextcloud-vue', 'Link an existing card') },
204
237
  /** Label of the `create` button. */
205
238
  createLabel: { type: String, default: () => t('nextcloud-vue', 'New card') },
239
+ /** Label of the link to Deck under the rows (board look). */
240
+ openDeckLabel: { type: String, default: () => t('nextcloud-vue', 'Open Deck') },
206
241
  },
207
242
 
208
243
  data() {
@@ -231,6 +266,11 @@ export default {
231
266
  return this.headerActions.filter((action, index, all) => ['create', 'link'].includes(action) && all.indexOf(action) === index)
232
267
  },
233
268
 
269
+ /** @return {boolean} The board look draws ruled rows instead of the mini-kanban. */
270
+ isBoard() {
271
+ return normalizeLook(this.cnLook) === 'board'
272
+ },
273
+
234
274
  cardHeaderTitle() {
235
275
  return this.title || this.integrationId
236
276
  },
@@ -325,6 +365,32 @@ export default {
325
365
  },
326
366
 
327
367
  methods: {
368
+ /**
369
+ * A card's meta line in the board look: its board, its list and its
370
+ * assignees, separated by middots.
371
+ *
372
+ * @param {object} card The card row.
373
+ * @return {string}
374
+ */
375
+ boardMetaOf(card) {
376
+ const board = cardBoardTitle(card)
377
+ const stack = cardStackTitle(card)
378
+ return [
379
+ board ? t('nextcloud-vue', 'Board {name}', { name: board }) : '',
380
+ stack ? t('nextcloud-vue', 'list {name}', { name: stack }) : '',
381
+ ...cardAssignees(card).map(assigneeDisplayName),
382
+ ].filter(Boolean).join(' · ')
383
+ },
384
+
385
+ /**
386
+ * @param {object} card The card row.
387
+ * @return {string} Its first label's title, or ''.
388
+ */
389
+ firstLabel(card) {
390
+ const label = cardLabels(card)[0]
391
+ return label ? String(label.title ?? label.name ?? '') : ''
392
+ },
393
+
328
394
  /** @return {string} The object's Deck endpoint the link and create flows post to. */
329
395
  deckEndpoint() {
330
396
  return `${this.apiBase}/objects/${this.register}/${this.schema}/${this.objectId}/deck`
@@ -169,6 +169,7 @@ import CnDeckCardPicker from '../../../components/CnDeckCardPicker/CnDeckCardPic
169
169
  import CnStatusBadge from '../../../components/CnStatusBadge/CnStatusBadge.vue'
170
170
  import { buildHeaders, prefixUrl, safeHref } from '../../../utils/index.js'
171
171
  import { userLocale } from '../../../utils/userLocale.js'
172
+ import { cardAssignees, cardBoardTitle, cardLabels, cardStackTitle } from './deckCardParts.js'
172
173
 
173
174
  /** Maximum assignee avatars shown before collapsing the rest into a +N overflow badge. */
174
175
  const MAX_VISIBLE_ASSIGNEES = 3
@@ -365,8 +366,7 @@ export default {
365
366
  },
366
367
 
367
368
  cardLabels(card) {
368
- const labels = card.labels ?? card.tags ?? []
369
- return Array.isArray(labels) ? labels : []
369
+ return cardLabels(card)
370
370
  },
371
371
 
372
372
  chipText(label) {
@@ -451,8 +451,8 @@ export default {
451
451
  * @return {string} board (or board · stack) label, or '' when unknown
452
452
  */
453
453
  boardBadge(card) {
454
- const board = card.boardTitle ?? card.board?.title ?? ''
455
- const stack = card.stackTitle ?? card.stack?.title ?? ''
454
+ const board = cardBoardTitle(card)
455
+ const stack = cardStackTitle(card)
456
456
  if (board && stack) {
457
457
  return `${board} · ${stack}`
458
458
  }
@@ -468,8 +468,7 @@ export default {
468
468
  * @return {Array} assignee entries
469
469
  */
470
470
  cardAssignees(card) {
471
- const raw = card.assignedUsers ?? card.assignees ?? card.users ?? []
472
- return Array.isArray(raw) ? raw : []
471
+ return cardAssignees(card)
473
472
  },
474
473
 
475
474
  /**
@@ -0,0 +1,49 @@
1
+ /**
2
+ * CnDeckCard in the board look on a detail page (DqZaakTaken "Kaarten"):
3
+ * each linked card is a ruled row with its title, one meta line (board,
4
+ * list, assignees) and its first label at the end, with "Open Deck" under
5
+ * the rows, instead of the mini-kanban.
6
+ *
7
+ * @spec openspec/changes/board-deck-calendar-rows/specs/integrations/spec.md
8
+ */
9
+
10
+ const { flushPromises, mount } = require('@vue/test-utils')
11
+ const CnDeckCard = require('../CnDeckCard.vue').default
12
+
13
+ const CARD = { cardId: 7, boardId: 2, cardTitle: 'Lakken offerte lantaarnpalen', boardTitle: 'Woo en informatie', stackTitle: 'Deze week', assignedUsers: [{ participant: { uid: 'lars', displayname: 'Lars de Koning' } }], labels: [{ id: 1, title: 'Bezig' }] }
14
+
15
+ function mountCard(look) {
16
+ global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve({ results: [CARD] }) })
17
+ return mount(CnDeckCard, {
18
+ props: { register: 'reg', schema: 'case', objectId: 'c-1', surface: 'detail-page' },
19
+ global: { provide: { cnLook: look } },
20
+ })
21
+ }
22
+
23
+ describe('CnDeckCard board rows', () => {
24
+ afterEach(() => {
25
+ delete global.fetch
26
+ })
27
+
28
+ it('draws the linked card as a row with board, list and assignee, its label at the end, and Open Deck', async () => {
29
+ const w = mountCard('board')
30
+ await flushPromises()
31
+ expect(w.find('.cn-deck-card__kanban').exists()).toBe(false)
32
+ const row = w.find('[data-testid="cn-deck-card-row"]')
33
+ expect(row.text()).toContain('Lakken offerte lantaarnpalen')
34
+ const meta = row.find('[data-testid="cn-deck-card-meta"]').text()
35
+ expect(meta).toContain('Woo en informatie')
36
+ expect(meta).toContain('Deze week')
37
+ expect(meta).toContain('Lars de Koning')
38
+ expect(row.find('[data-testid="cn-deck-card-label"]').text()).toBe('Bezig')
39
+ expect(w.find('[data-testid="cn-deck-card-open"]').attributes('href')).toBe('/index.php/apps/deck')
40
+ w.unmount()
41
+ })
42
+
43
+ it('keeps the mini-kanban in the Nextcloud look', async () => {
44
+ const w = mountCard('nextcloud')
45
+ await flushPromises()
46
+ expect(w.find('.cn-deck-card__kanban').exists()).toBe(true)
47
+ w.unmount()
48
+ })
49
+ })
@@ -0,0 +1,56 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * The parts of a linked Deck card a row shows, shared by the Deck tab and
6
+ * the Deck card: its board, its stack, its assignees and its labels.
7
+ */
8
+
9
+ /**
10
+ * @param {object} card The card row.
11
+ * @return {string} The board's title, or ''.
12
+ */
13
+ export function cardBoardTitle(card) {
14
+ return card?.boardTitle ?? card?.board?.title ?? ''
15
+ }
16
+
17
+ /**
18
+ * @param {object} card The card row.
19
+ * @return {string} The stack's title, or ''.
20
+ */
21
+ export function cardStackTitle(card) {
22
+ return card?.stackTitle ?? card?.stack?.title ?? ''
23
+ }
24
+
25
+ /**
26
+ * Deck's `assignedUsers` (each with a nested `participant`), or a plain
27
+ * `assignees` / `users` list of strings or objects.
28
+ *
29
+ * @param {object} card The card row.
30
+ * @return {Array} The assignee entries.
31
+ */
32
+ export function cardAssignees(card) {
33
+ const raw = card?.assignedUsers ?? card?.assignees ?? card?.users ?? []
34
+ return Array.isArray(raw) ? raw : []
35
+ }
36
+
37
+ /**
38
+ * @param {object|string} assignee An assignee entry.
39
+ * @return {string} Its display name, or ''.
40
+ */
41
+ export function assigneeDisplayName(assignee) {
42
+ if (typeof assignee === 'string') {
43
+ return assignee
44
+ }
45
+ const p = assignee?.participant ?? assignee ?? {}
46
+ return p.displayname ?? p.displayName ?? p.uid ?? p.id ?? ''
47
+ }
48
+
49
+ /**
50
+ * @param {object} card The card row.
51
+ * @return {Array<object>} Its labels.
52
+ */
53
+ export function cardLabels(card) {
54
+ const labels = card?.labels ?? card?.tags ?? []
55
+ return Array.isArray(labels) ? labels : []
56
+ }
@@ -2,6 +2,7 @@ import { translatePlural as n, translate as t } from '@nextcloud/l10n'
2
2
  import { formatRelativeDate } from './boardDate.js'
3
3
  import { formatMoney } from './formatMetric.js'
4
4
  import { parseDuration } from './isoDuration.js'
5
+ import { userDisplayName } from './userDisplayName.js'
5
6
 
6
7
  /**
7
8
  * @file Built-in formatters merged into the `cnFormatters` registry
@@ -523,6 +524,21 @@ export function formatIsoDuration(value) {
523
524
  return words[period.unit] ? words[period.unit]() : String(value)
524
525
  }
525
526
 
527
+ /**
528
+ * A Nextcloud user id as its display name (`userName` registry key): the id
529
+ * while the name is looked up, and for a user nobody answers for.
530
+ *
531
+ * @spec openspec/changes/user-name-formatter/specs/user-name-formatter/spec.md#requirement-a-user-id-can-read-as-its-display-name
532
+ * @param {unknown} value The user id.
533
+ * @return {string} The display name, the id, or ''.
534
+ */
535
+ export function formatUserName(value) {
536
+ if (value === null || value === undefined || value === '' || typeof value === 'object') {
537
+ return ''
538
+ }
539
+ return userDisplayName(value) || String(value)
540
+ }
541
+
526
542
  export const BUILT_IN_FORMATTERS = {
527
543
  date: formatDate,
528
544
  datetime: formatDateTime,
@@ -538,4 +554,5 @@ export const BUILT_IN_FORMATTERS = {
538
554
  masked: formatMasked,
539
555
  isoDuration: formatIsoDuration,
540
556
  valueMap: formatValueMap,
557
+ userName: formatUserName,
541
558
  }
@@ -176,10 +176,13 @@ export function singleEqualityOf(filter) {
176
176
  * @param {Array<{key: (string|number), filter: object}>} options.entries The filters to count, each under its own key.
177
177
  * @param {object} [options.baseFilter] A filter every count is narrowed by (the page's fixed filter).
178
178
  * @param {object} [options.ctx] Token-resolution context for the filters.
179
+ * @param {string} [options.via] `'list'` counts every entry through the list endpoint, the request a list sends, so a
180
+ * count beside a list cannot disagree with it; else the aggregation endpoints where they apply.
179
181
  * @return {Promise<{[key: string]: number}>} The count per entry key. An entry whose count failed is left out.
180
182
  * @spec openspec/changes/workplace-dashboard-primitives/specs/workplace-dashboard-primitives/spec.md#requirement-counts-on-filters-and-views
183
+ * @spec openspec/changes/index-counts-read-the-list-endpoint/specs/index-page/spec.md#requirement-an-index-pages-counts-come-from-the-list-request
181
184
  */
182
- export async function fetchFilterCounts({ register, schema, entries, baseFilter, ctx } = {}) {
185
+ export async function fetchFilterCounts({ register, schema, entries, baseFilter, ctx, via = '' } = {}) {
183
186
  const counts = {}
184
187
  if (!register || !schema || !Array.isArray(entries) || entries.length === 0) {
185
188
  return counts
@@ -191,7 +194,7 @@ export async function fetchFilterCounts({ register, schema, entries, baseFilter,
191
194
  // and `/value` returns exactly its number.
192
195
  // A list of values anywhere in a request rules the aggregation endpoints
193
196
  // out for it. A base filter with one rules them out for every entry.
194
- const baseHasList = needsListEndpoint(base)
197
+ const baseHasList = via === 'list' || needsListEndpoint(base)
195
198
  const byField = {}
196
199
  const single = []
197
200
  const viaList = []
@@ -0,0 +1,91 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * Header sort and filter applied to rows the table already holds (an
6
+ * `endpointSource` table): the same `{ field, dir }` sort and
7
+ * `{ paramKey: values[] }` filter map a self-fetching table sends to the
8
+ * server, read here in the browser.
9
+ *
10
+ * @module utils/localHeaderRows
11
+ */
12
+
13
+ import { CONTAINS_OPERATOR } from './columnFilters.js'
14
+
15
+ /**
16
+ * A row value as one comparable scalar: an array counts as its joined items.
17
+ *
18
+ * @param {unknown} value The raw value.
19
+ * @return {string|number|null} The value to compare.
20
+ */
21
+ function scalar(value) {
22
+ if (value === null || value === undefined) {
23
+ return null
24
+ }
25
+ if (Array.isArray(value)) {
26
+ return value.map((item) => (item && typeof item === 'object' ? (item.title || item.name || '') : String(item))).join(', ')
27
+ }
28
+ if (typeof value === 'number' || typeof value === 'boolean') {
29
+ return typeof value === 'boolean' ? String(value) : value
30
+ }
31
+ return typeof value === 'object' ? (value.title || value.name || '') : String(value)
32
+ }
33
+
34
+ /**
35
+ * Whether a row holds a value the filter entry asks for.
36
+ *
37
+ * @param {object} row The row.
38
+ * @param {string} param The filter key (`field`, `field[like]`, `field[gte]`, `field[lte]`).
39
+ * @param {Array<string>} values The chosen values.
40
+ * @return {boolean} True when the row passes.
41
+ */
42
+ function passes(row, param, values) {
43
+ const match = /^(.+)\[(\w+)\]$/.exec(param)
44
+ const field = match ? match[1] : param
45
+ const op = match ? match[2] : ''
46
+ const value = scalar(row[field])
47
+ if (op === CONTAINS_OPERATOR) {
48
+ const term = String(values[0]).toLowerCase()
49
+ return value !== null && String(value).toLowerCase().includes(term)
50
+ }
51
+ if (op === 'gte' || op === 'lte') {
52
+ if (value === null) {
53
+ return false
54
+ }
55
+ const left = typeof value === 'number' ? value : String(value)
56
+ const right = typeof value === 'number' ? Number(values[0]) : String(values[0])
57
+ return op === 'gte' ? left >= right : left <= right
58
+ }
59
+ return value !== null && values.map(String).includes(String(value))
60
+ }
61
+
62
+ /**
63
+ * The rows that pass every active header filter, in the header sort's order.
64
+ *
65
+ * @param {Array<object>} rows The rows.
66
+ * @param {{field: string, dir: string}|null} sort The header sort.
67
+ * @param {object} filters The header filters, `{ paramKey: values[] }`.
68
+ * @return {Array<object>} The rows to show (the same array when nothing applies).
69
+ */
70
+ export function localHeaderRows(rows, sort, filters) {
71
+ if (!Array.isArray(rows)) {
72
+ return []
73
+ }
74
+ const active = Object.entries(filters || {}).filter(([, values]) => Array.isArray(values) && values.length > 0)
75
+ let out = active.length > 0 ? rows.filter((row) => row && active.every(([param, values]) => passes(row, param, values))) : rows
76
+ if (sort && sort.field) {
77
+ const sign = sort.dir === 'desc' ? -1 : 1
78
+ out = [...out].sort((a, b) => {
79
+ const left = scalar(a && a[sort.field])
80
+ const right = scalar(b && b[sort.field])
81
+ if (left === null || right === null) {
82
+ return left === right ? 0 : (left === null ? 1 : -1)
83
+ }
84
+ if (typeof left === 'number' && typeof right === 'number') {
85
+ return sign * (left - right)
86
+ }
87
+ return sign * String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: 'base' })
88
+ })
89
+ }
90
+ return out
91
+ }
@@ -26,20 +26,33 @@ function ocsData(res) {
26
26
  return (res && res.data && res.data.ocs && res.data.ocs.data) || null
27
27
  }
28
28
 
29
+ /** The most groups read, so a server that ignores the offset cannot loop forever. */
30
+ const MAX_GROUPS = 5000
31
+
29
32
  /**
30
33
  * The groups, with their member counts.
31
34
  *
32
35
  * @param {object} axios The axios instance.
33
- * @param {{search?: string, limit?: number}} [options] Narrowing.
36
+ * @param {{search?: string, limit?: number}} [options] Narrowing; `limit` is the page size.
34
37
  * @return {Promise<Array<{id: string, name: string, count: number}>>}
35
38
  * @spec openspec/changes/index-groups-users-source/specs/index-page/spec.md#requirement-an-index-page-can-list-nextcloud-groups-and-their-members
36
39
  */
37
40
  export async function fetchGroups(axios, options = {}) {
38
- const res = await axios.get(generateOcsUrl('cloud/groups/details'), {
39
- params: { search: options.search || '', limit: options.limit || 200, format: 'json' },
40
- headers: OCS_HEADERS,
41
- })
42
- const groups = (ocsData(res) && ocsData(res).groups) || []
41
+ const limit = options.limit || 200
42
+ // Every page, while a page comes back full: one page cut an instance with
43
+ // more groups than the limit off before its last ones (DqAfdelingen).
44
+ const groups = []
45
+ for (let offset = 0; offset < MAX_GROUPS; offset += limit) {
46
+ const res = await axios.get(generateOcsUrl('cloud/groups/details'), {
47
+ params: { search: options.search || '', limit, offset, format: 'json' },
48
+ headers: OCS_HEADERS,
49
+ })
50
+ const batch = (ocsData(res) && ocsData(res).groups) || []
51
+ groups.push(...batch)
52
+ if (batch.length < limit) {
53
+ break
54
+ }
55
+ }
43
56
  return groups.map((group) => ({
44
57
  id: String(group.id),
45
58
  name: group.displayname || String(group.id),