@conduction/nextcloud-vue 2.143.0 → 2.144.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/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.map +1 -1
  5. package/dist/esm/components/CnConversationThread/CnConversationThread.vue.js +1 -1
  6. package/dist/esm/components/CnConversationThread/CnConversationThread.vue2.js.map +1 -1
  7. package/dist/esm/components/CnConversationThread/CnConversationThread.vue3.js +1 -1
  8. package/dist/esm/components/CnConversationThread/CnConversationThread.vue3.js.map +1 -1
  9. package/dist/esm/components/CnEventsCard/CnEventsCard.vue.js +1 -1
  10. package/dist/esm/components/CnEventsCard/CnEventsCard.vue2.js +9 -0
  11. package/dist/esm/components/CnEventsCard/CnEventsCard.vue2.js.map +1 -1
  12. package/dist/esm/components/CnEventsCard/CnEventsCard.vue3.js +24 -13
  13. package/dist/esm/components/CnEventsCard/CnEventsCard.vue3.js.map +1 -1
  14. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue.js +1 -1
  15. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js +2 -1
  16. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js.map +1 -1
  17. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js.map +1 -1
  18. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +3 -1
  19. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  20. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  21. package/dist/esm/components/CnNotesCard/CnNotesCard.vue.js +1 -1
  22. package/dist/esm/components/CnNotesCard/CnNotesCard.vue2.js +3 -0
  23. package/dist/esm/components/CnNotesCard/CnNotesCard.vue2.js.map +1 -1
  24. package/dist/esm/components/CnNotesCard/CnNotesCard.vue3.js +19 -14
  25. package/dist/esm/components/CnNotesCard/CnNotesCard.vue3.js.map +1 -1
  26. package/dist/esm/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue.js +1 -1
  27. package/dist/esm/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue2.js +30 -0
  28. package/dist/esm/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue2.js.map +1 -1
  29. package/dist/esm/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue3.js +158 -102
  30. package/dist/esm/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue3.js.map +1 -1
  31. package/dist/esm/components/CnRetentionWidget/CnRetentionWidget.vue.js +1 -1
  32. package/dist/esm/components/CnRetentionWidget/CnRetentionWidget.vue2.js.map +1 -1
  33. package/dist/esm/components/CnRetentionWidget/CnRetentionWidget.vue3.js +12 -0
  34. package/dist/esm/components/CnRetentionWidget/CnRetentionWidget.vue3.js.map +1 -1
  35. package/dist/esm/components/CnTasksWidget/CnTasksWidget.vue.js +1 -1
  36. package/dist/esm/components/CnTasksWidget/CnTasksWidget.vue2.js +110 -2
  37. package/dist/esm/components/CnTasksWidget/CnTasksWidget.vue2.js.map +1 -1
  38. package/dist/esm/components/CnTasksWidget/CnTasksWidget.vue3.js +146 -80
  39. package/dist/esm/components/CnTasksWidget/CnTasksWidget.vue3.js.map +1 -1
  40. package/dist/esm/integrations/builtin/calendar/CnCalendarCard.vue.js +1 -1
  41. package/dist/esm/integrations/builtin/calendar/CnCalendarCard.vue2.js +45 -0
  42. package/dist/esm/integrations/builtin/calendar/CnCalendarCard.vue2.js.map +1 -1
  43. package/dist/esm/integrations/builtin/calendar/CnCalendarCard.vue3.js +67 -34
  44. package/dist/esm/integrations/builtin/calendar/CnCalendarCard.vue3.js.map +1 -1
  45. package/dist/esm/integrations/builtin/calendar/CnCalendarTab.vue.js +1 -1
  46. package/dist/esm/integrations/builtin/calendar/CnCalendarTab.vue2.js +3 -22
  47. package/dist/esm/integrations/builtin/calendar/CnCalendarTab.vue2.js.map +1 -1
  48. package/dist/esm/integrations/builtin/calendar/CnCalendarTab.vue3.js.map +1 -1
  49. package/dist/esm/integrations/builtin/calendar/eventStatus.js +67 -0
  50. package/dist/esm/integrations/builtin/calendar/eventStatus.js.map +1 -0
  51. package/dist/esm/integrations/builtin/deck/CnDeckCard.vue.js +1 -1
  52. package/dist/esm/integrations/builtin/deck/CnDeckCard.vue2.js +39 -0
  53. package/dist/esm/integrations/builtin/deck/CnDeckCard.vue2.js.map +1 -1
  54. package/dist/esm/integrations/builtin/deck/CnDeckCard.vue3.js +81 -32
  55. package/dist/esm/integrations/builtin/deck/CnDeckCard.vue3.js.map +1 -1
  56. package/dist/esm/integrations/builtin/deck/CnDeckTab.vue.js +1 -1
  57. package/dist/esm/integrations/builtin/deck/CnDeckTab.vue2.js +5 -6
  58. package/dist/esm/integrations/builtin/deck/CnDeckTab.vue2.js.map +1 -1
  59. package/dist/esm/integrations/builtin/deck/CnDeckTab.vue3.js.map +1 -1
  60. package/dist/esm/integrations/builtin/deck/deckCardParts.js +59 -0
  61. package/dist/esm/integrations/builtin/deck/deckCardParts.js.map +1 -0
  62. package/dist/esm/l10n/en.json.js +6 -0
  63. package/dist/esm/l10n/en.json.js.map +1 -1
  64. package/dist/esm/l10n/nl.json.js +6 -0
  65. package/dist/esm/l10n/nl.json.js.map +1 -1
  66. package/dist/esm/library-style.js +1 -1
  67. package/dist/esm/nextcloud-vue.css +815 -363
  68. package/dist/esm/utils/fetchFilterCounts.js +5 -2
  69. package/dist/esm/utils/fetchFilterCounts.js.map +1 -1
  70. package/dist/esm/utils/nextcloudDirectory.js +19 -6
  71. package/dist/esm/utils/nextcloudDirectory.js.map +1 -1
  72. package/dist/nextcloud-vue.cjs.js +1347 -758
  73. package/dist/nextcloud-vue.cjs.js.map +2 -2
  74. package/dist/nextcloud-vue.css +815 -363
  75. package/l10n/en.json +6 -0
  76. package/l10n/nl.json +6 -0
  77. package/package.json +1 -1
  78. package/src/components/CnActionDialog/CnActionDialog.vue +28 -3
  79. package/src/components/CnConversationThread/CnConversationThread.vue +5 -2
  80. package/src/components/CnEventsCard/CnEventsCard.vue +27 -5
  81. package/src/components/CnFilesBrowser/CnFilesBrowser.vue +15 -1
  82. package/src/components/CnIndexPage/CnIndexPage.vue +3 -1
  83. package/src/components/CnNotesCard/CnNotesCard.vue +8 -0
  84. package/src/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue +114 -46
  85. package/src/components/CnRetentionWidget/CnRetentionWidget.vue +26 -1
  86. package/src/components/CnTasksWidget/CnTasksWidget.vue +170 -13
  87. package/src/css/dialog.css +7 -1
  88. package/src/css/kpi-card.css +3 -0
  89. package/src/css/look-board-detail.css +317 -0
  90. package/src/css/look-board-index.css +14 -0
  91. package/src/css/look-board.css +25 -0
  92. package/src/integrations/builtin/calendar/CnCalendarCard.vue +76 -11
  93. package/src/integrations/builtin/calendar/CnCalendarTab.vue +3 -22
  94. package/src/integrations/builtin/calendar/__tests__/CnCalendarCardBoardRows.spec.js +52 -0
  95. package/src/integrations/builtin/calendar/eventStatus.js +63 -0
  96. package/src/integrations/builtin/deck/CnDeckCard.vue +66 -0
  97. package/src/integrations/builtin/deck/CnDeckTab.vue +5 -6
  98. package/src/integrations/builtin/deck/__tests__/CnDeckCardBoardRows.spec.js +49 -0
  99. package/src/integrations/builtin/deck/deckCardParts.js +56 -0
  100. package/src/utils/fetchFilterCounts.js +5 -2
  101. package/src/utils/nextcloudDirectory.js +19 -6
@@ -83,22 +83,42 @@
83
83
  v-for="ev in displayedEvents"
84
84
  :key="rowKey(ev)"
85
85
  class="cn-calendar-card__row">
86
- <div class="cn-calendar-card__row-icon">
87
- <CalendarClock v-if="isUpcoming(ev)" :size="20" class="cn-calendar-card__icon--upcoming" />
88
- <CalendarCheck v-else :size="20" class="cn-calendar-card__icon--past" />
89
- </div>
90
- <div class="cn-calendar-card__row-main">
91
- <span class="cn-calendar-card__title">{{ ev.summary || untitledLabel }}</span>
92
- <div class="cn-calendar-card__meta">
93
- <span v-if="ev.dtstart" class="cn-calendar-card__when">{{ formatShort(ev.dtstart) }}</span>
94
- <span v-if="ev.location" class="cn-calendar-card__location">· {{ ev.location }}</span>
86
+ <!-- The board look draws a meeting as a ruled row: its title, one
87
+ meta line (when, where, who) and its status at the end. -->
88
+ <template v-if="boardRows">
89
+ <div class="cn-calendar-card__row-main">
90
+ <span class="cn-calendar-card__title">{{ ev.summary || untitledLabel }}</span>
91
+ <span class="cn-calendar-card__meta" data-testid="cn-calendar-card-meta">{{ boardMetaOf(ev) }}</span>
95
92
  </div>
96
- </div>
93
+ <span v-if="statusOf(ev)"
94
+ class="cn-status-badge"
95
+ :class="`cn-status-badge--${statusVariantOf(ev)}`"
96
+ data-testid="cn-calendar-card-status">{{ statusOf(ev) }}</span>
97
+ </template>
98
+ <template v-else>
99
+ <div class="cn-calendar-card__row-icon">
100
+ <CalendarClock v-if="isUpcoming(ev)" :size="20" class="cn-calendar-card__icon--upcoming" />
101
+ <CalendarCheck v-else :size="20" class="cn-calendar-card__icon--past" />
102
+ </div>
103
+ <div class="cn-calendar-card__row-main">
104
+ <span class="cn-calendar-card__title">{{ ev.summary || untitledLabel }}</span>
105
+ <div class="cn-calendar-card__meta">
106
+ <span v-if="ev.dtstart" class="cn-calendar-card__when">{{ formatShort(ev.dtstart) }}</span>
107
+ <span v-if="ev.location" class="cn-calendar-card__location">· {{ ev.location }}</span>
108
+ </div>
109
+ </div>
110
+ </template>
97
111
  </div>
112
+ <a v-if="boardRows"
113
+ class="cn-calendar-card__open"
114
+ :href="calendarAppUrl"
115
+ target="_blank"
116
+ rel="noopener"
117
+ data-testid="cn-calendar-card-open">{{ openInCalendarLabel }}</a>
98
118
  </div>
99
119
 
100
120
  <!-- Footer: detail surface gets "Open in NC Calendar" CTA -->
101
- <template v-if="surface === 'detail-page'" #footer>
121
+ <template v-if="surface === 'detail-page' && !isBoard" #footer>
102
122
  <a
103
123
  class="cn-calendar-card__cta"
104
124
  :href="calendarAppUrl"
@@ -144,9 +164,12 @@ import CnCalendarEventCreate from '../../../components/CnCalendarEventCreate/CnC
144
164
  import CnCalendarEventPicker from '../../../components/CnCalendarEventPicker/CnCalendarEventPicker.vue'
145
165
  import CnDetailCard from '../../../components/CnDetailCard/CnDetailCard.vue'
146
166
  import CnIcon from '../../../components/CnIcon/CnIcon.vue'
167
+ import { normalizeLook } from '../../../composables/useLook.js'
168
+ import { formatBoardDate, formatTimeOfDay } from '../../../utils/boardDate.js'
147
169
  import { buildHeaders, prefixUrl } from '../../../utils/index.js'
148
170
  import { userLocale } from '../../../utils/userLocale.js'
149
171
  import { linkCalendarEvent } from './calendarEventLink.js'
172
+ import { eventAttendeeNames, eventStatusLabel, eventStatusVariant } from './eventStatus.js'
150
173
 
151
174
  /**
152
175
  * CnCalendarCard — surface-aware compact widget for the calendar
@@ -158,6 +181,10 @@ export default {
158
181
 
159
182
  components: { CnCalendarEventCreate, CnCalendarEventPicker, CnDetailCard, CnIcon, LinkVariant, NcButton, NcLoadingIcon, Plus, CalendarClock, CalendarCheck },
160
183
 
184
+ inject: {
185
+ cnLook: { default: 'nextcloud' },
186
+ },
187
+
161
188
  props: {
162
189
  /** OpenRegister register id (slug or uuid). Optional for user-dashboard. */
163
190
  register: { type: String, default: '' },
@@ -233,6 +260,16 @@ export default {
233
260
  *
234
261
  * @return {Array<string>}
235
262
  */
263
+ /** @return {boolean} The board look draws ruled rows. */
264
+ isBoard() {
265
+ return normalizeLook(this.cnLook) === 'board'
266
+ },
267
+
268
+ /** @return {boolean} The meetings are ruled rows: the board look on a detail page. */
269
+ boardRows() {
270
+ return this.isBoard && this.surface === 'detail-page'
271
+ },
272
+
236
273
  shownHeaderActions() {
237
274
  if (this.surface !== 'detail-page' || !this.objectId || !Array.isArray(this.headerActions)) {
238
275
  return []
@@ -292,6 +329,34 @@ export default {
292
329
  },
293
330
 
294
331
  methods: {
332
+ /**
333
+ * A meeting's meta line in the board look: "14 okt, 10:00 tot 10:30",
334
+ * its place and its attendees, separated by middots.
335
+ *
336
+ * @param {object} ev The event.
337
+ * @return {string}
338
+ */
339
+ boardMetaOf(ev) {
340
+ const start = ev.dtstart ? new Date(ev.dtstart) : null
341
+ const end = ev.dtend ? new Date(ev.dtend) : null
342
+ let when = ''
343
+ if (start && !Number.isNaN(start.getTime())) {
344
+ when = `${formatBoardDate(start)}, ${formatTimeOfDay(start)}`
345
+ if (end && !Number.isNaN(end.getTime())) {
346
+ when = t('nextcloud-vue', '{start} until {end}', { start: when, end: formatTimeOfDay(end) })
347
+ }
348
+ }
349
+ return [when, ev.location, ...eventAttendeeNames(ev)].filter(Boolean).join(' · ')
350
+ },
351
+
352
+ statusOf(ev) {
353
+ return eventStatusLabel(ev)
354
+ },
355
+
356
+ statusVariantOf(ev) {
357
+ return eventStatusVariant(ev)
358
+ },
359
+
295
360
  /**
296
361
  * A meeting was made on this object: close the form, read the list again.
297
362
  *
@@ -242,6 +242,7 @@ import CnStatusBadge from '../../../components/CnStatusBadge/CnStatusBadge.vue'
242
242
  import { buildHeaders, prefixUrl } from '../../../utils/index.js'
243
243
  import { userLocale } from '../../../utils/userLocale.js'
244
244
  import { linkCalendarEvent } from './calendarEventLink.js'
245
+ import { eventStatusLabel, eventStatusVariant } from './eventStatus.js'
245
246
 
246
247
  const MAX_AVATARS = 3
247
248
 
@@ -542,31 +543,11 @@ export default {
542
543
  },
543
544
 
544
545
  statusLabel(ev) {
545
- const status = (ev.status || '').toString().toLowerCase()
546
- if (status === 'confirmed') {
547
- return t('nextcloud-vue', 'Confirmed')
548
- }
549
- if (status === 'tentative') {
550
- return t('nextcloud-vue', 'Tentative')
551
- }
552
- if (status === 'cancelled') {
553
- return t('nextcloud-vue', 'Cancelled')
554
- }
555
- return ''
546
+ return eventStatusLabel(ev)
556
547
  },
557
548
 
558
549
  statusVariant(ev) {
559
- const status = (ev.status || '').toString().toLowerCase()
560
- if (status === 'confirmed') {
561
- return 'success'
562
- }
563
- if (status === 'tentative') {
564
- return 'warning'
565
- }
566
- if (status === 'cancelled') {
567
- return 'error'
568
- }
569
- return 'default'
550
+ return eventStatusVariant(ev)
570
551
  },
571
552
 
572
553
  attendeeList(ev) {
@@ -0,0 +1,52 @@
1
+ /**
2
+ * CnCalendarCard in the board look on a detail page (DqZaakTaken
3
+ * "Afspraken"): each meeting is a ruled row with its title, one meta line
4
+ * (when, where, who) and its status at the end, and "Open in Calendar" is a
5
+ * link under the rows rather than a card footer.
6
+ *
7
+ * @spec openspec/changes/board-deck-calendar-rows/specs/integrations/spec.md
8
+ */
9
+
10
+ const { flushPromises, mount } = require('@vue/test-utils')
11
+
12
+ jest.mock('@nextcloud/router', () => ({ generateUrl: (path) => path }))
13
+
14
+ const CnCalendarCard = require('../CnCalendarCard.vue').default
15
+
16
+ const EVENT = { id: 'e1', summary: 'Toelichting stukken', dtstart: '2026-10-14T10:00:00', dtend: '2026-10-14T10:30:00', location: 'stadskantoor, balie 3', attendees: [{ name: 'Sanne de Vries' }], status: 'TENTATIVE' }
17
+
18
+ function mountCard(look) {
19
+ global.fetch = jest.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ results: [EVENT] }) })
20
+ return mount(CnCalendarCard, {
21
+ props: { register: 'reg', schema: 'case', objectId: 'c-1', surface: 'detail-page' },
22
+ global: { provide: { cnLook: look }, stubs: { NcLoadingIcon: true } },
23
+ })
24
+ }
25
+
26
+ describe('CnCalendarCard board rows', () => {
27
+ afterEach(() => {
28
+ delete global.fetch
29
+ })
30
+
31
+ it('draws the meeting as a row with its meta line and status, and the link under the rows', async () => {
32
+ const w = mountCard('board')
33
+ await flushPromises()
34
+ const row = w.find('.cn-calendar-card__row')
35
+ expect(row.find('.cn-calendar-card__row-icon').exists()).toBe(false)
36
+ const meta = row.find('[data-testid="cn-calendar-card-meta"]').text()
37
+ expect(meta).toContain('stadskantoor, balie 3')
38
+ expect(meta).toContain('Sanne de Vries')
39
+ expect(row.find('[data-testid="cn-calendar-card-status"]').text()).toBe('Tentative')
40
+ expect(w.find('[data-testid="cn-calendar-card-open"]').exists()).toBe(true)
41
+ expect(w.find('.cn-detail-card__footer').exists()).toBe(false)
42
+ w.unmount()
43
+ })
44
+
45
+ it('keeps the icon rows and the footer in the Nextcloud look', async () => {
46
+ const w = mountCard('nextcloud')
47
+ await flushPromises()
48
+ expect(w.find('.cn-calendar-card__row-icon').exists()).toBe(true)
49
+ expect(w.find('.cn-detail-card__footer').exists()).toBe(true)
50
+ w.unmount()
51
+ })
52
+ })
@@ -0,0 +1,63 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * A calendar event's status as a label and a badge tone, shared by the
6
+ * calendar tab and the calendar card.
7
+ */
8
+
9
+ import { translate as t } from '@nextcloud/l10n'
10
+
11
+ /**
12
+ * The event's status in words, or '' when it has none the reader needs.
13
+ *
14
+ * @param {object} ev The event.
15
+ * @return {string}
16
+ */
17
+ export function eventStatusLabel(ev) {
18
+ const status = String(ev?.status || '').toLowerCase()
19
+ if (status === 'confirmed') {
20
+ return t('nextcloud-vue', 'Confirmed')
21
+ }
22
+ if (status === 'tentative') {
23
+ return t('nextcloud-vue', 'Tentative')
24
+ }
25
+ if (status === 'cancelled') {
26
+ return t('nextcloud-vue', 'Cancelled')
27
+ }
28
+ return ''
29
+ }
30
+
31
+ /**
32
+ * The badge tone of the event's status.
33
+ *
34
+ * @param {object} ev The event.
35
+ * @return {string} `success`, `warning`, `error` or `default`.
36
+ */
37
+ export function eventStatusVariant(ev) {
38
+ const status = String(ev?.status || '').toLowerCase()
39
+ if (status === 'confirmed') {
40
+ return 'success'
41
+ }
42
+ if (status === 'tentative') {
43
+ return 'warning'
44
+ }
45
+ if (status === 'cancelled') {
46
+ return 'error'
47
+ }
48
+ return 'default'
49
+ }
50
+
51
+ /**
52
+ * The names of the event's attendees.
53
+ *
54
+ * @param {object} ev The event.
55
+ * @return {Array<string>}
56
+ */
57
+ export function eventAttendeeNames(ev) {
58
+ const raw = ev?.attendees || ev?.participants || []
59
+ if (!Array.isArray(raw)) {
60
+ return []
61
+ }
62
+ return raw.map((att) => (typeof att === 'string' ? att : (att.name || att.displayName || att.cn || att.email || ''))).filter(Boolean)
63
+ }
@@ -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
+ }
@@ -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 = []
@@ -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),