@conduction/nextcloud-vue 2.141.0 → 2.142.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.
- package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue.js +1 -1
- package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js +13 -0
- package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js.map +1 -1
- package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js +3 -3
- package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js.map +1 -1
- package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +21 -0
- package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
- package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +1 -1
- package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
- package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue.js +1 -1
- package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js +34 -3
- package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue.js +1 -1
- package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js +43 -3
- package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js.map +1 -1
- package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js +1 -1
- package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +21 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +2 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
- package/dist/esm/components/CnIndexPage/useSelfFetchList.js +14 -0
- package/dist/esm/components/CnIndexPage/useSelfFetchList.js.map +1 -1
- package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue.js +1 -1
- package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js +3 -1
- package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js.map +1 -1
- package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js +2 -1
- package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js.map +1 -1
- package/dist/esm/components/CnPartyPicker/CnPartyPicker.vue.js +1 -1
- package/dist/esm/components/CnPartyPicker/CnPartyPicker.vue2.js +31 -1
- package/dist/esm/components/CnPartyPicker/CnPartyPicker.vue2.js.map +1 -1
- package/dist/esm/components/CnPartyPicker/CnPartyPicker.vue3.js.map +1 -1
- package/dist/esm/integrations/builtin/contacts/CnContactsTab.vue.js +1 -1
- package/dist/esm/integrations/builtin/contacts/CnContactsTab.vue2.js +52 -1
- package/dist/esm/integrations/builtin/contacts/CnContactsTab.vue2.js.map +1 -1
- package/dist/esm/integrations/builtin/contacts/CnContactsTab.vue3.js +119 -65
- package/dist/esm/integrations/builtin/contacts/CnContactsTab.vue3.js.map +1 -1
- package/dist/esm/integrations/builtin/deck/CnDeckCard.vue.js +1 -1
- package/dist/esm/integrations/builtin/deck/CnDeckCard.vue2.js +86 -4
- package/dist/esm/integrations/builtin/deck/CnDeckCard.vue2.js.map +1 -1
- package/dist/esm/integrations/builtin/deck/CnDeckCard.vue3.js +67 -5
- package/dist/esm/integrations/builtin/deck/CnDeckCard.vue3.js.map +1 -1
- package/dist/esm/l10n/en.json.js +10 -1
- package/dist/esm/l10n/en.json.js.map +1 -1
- package/dist/esm/l10n/nl.json.js +11 -2
- package/dist/esm/l10n/nl.json.js.map +1 -1
- package/dist/esm/library-style.js +1 -1
- package/dist/esm/nextcloud-vue.css +527 -358
- package/dist/esm/utils/buildViewTree.js +23 -8
- package/dist/esm/utils/buildViewTree.js.map +1 -1
- package/dist/nextcloud-vue.cjs.js +1064 -616
- package/dist/nextcloud-vue.cjs.js.map +2 -2
- package/dist/nextcloud-vue.css +527 -358
- package/l10n/en.json +10 -1
- package/l10n/nl.json +11 -2
- package/package.json +1 -1
- package/src/components/CnCellRenderer/CnCellRenderer.vue +15 -1
- package/src/components/CnDataTable/CnDataTable.md +1 -0
- package/src/components/CnDataTable/CnDataTable.vue +22 -1
- package/src/components/CnFactsWidget/CnFactsWidget.vue +34 -3
- package/src/components/CnFilesBrowser/CnFilesBrowser.vue +44 -4
- package/src/components/CnIndexPage/CnIndexPage.vue +22 -1
- package/src/components/CnIndexPage/useSelfFetchList.js +14 -0
- package/src/components/CnObjectSidebar/CnFilesTab.vue +4 -1
- package/src/components/CnPartyPicker/CnPartyPicker.vue +31 -1
- package/src/css/look-board-detail.css +95 -0
- package/src/css/look-board-index.css +53 -0
- package/src/css/look-board.css +17 -0
- package/src/integrations/builtin/contacts/CnContactsTab.vue +101 -26
- package/src/integrations/builtin/contacts/__tests__/CnContactsTabBoardRows.spec.js +67 -0
- package/src/integrations/builtin/deck/CnDeckCard.vue +121 -2
- package/src/integrations/builtin/deck/__tests__/CnDeckCardHeaderActions.spec.js +68 -0
- package/src/utils/buildViewTree.js +23 -8
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
class="cn-contacts-tab__group">
|
|
88
88
|
<h3 class="cn-contacts-tab__group-title">
|
|
89
89
|
{{ group.label }}
|
|
90
|
-
<span class="cn-contacts-tab__group-count">{{ group.items.length }}</span>
|
|
90
|
+
<span v-if="!isBoard" class="cn-contacts-tab__group-count">{{ group.items.length }}</span>
|
|
91
91
|
</h3>
|
|
92
92
|
<ul class="cn-contacts-tab__list">
|
|
93
93
|
<li
|
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
@click="openReverseLookup(contact)">
|
|
98
98
|
<div class="cn-contacts-tab__avatar">
|
|
99
99
|
<NcAvatar
|
|
100
|
-
:size="40"
|
|
100
|
+
:size="isBoard ? 32 : 40"
|
|
101
101
|
:displayName="contact.displayName || unknownLabel"
|
|
102
102
|
:user="avatarSeed(contact)"
|
|
103
103
|
:url="contact.avatarUrl || undefined"
|
|
@@ -112,7 +112,7 @@
|
|
|
112
112
|
{{ contact.displayName || unknownLabel }}
|
|
113
113
|
</span>
|
|
114
114
|
<CnStatusBadge
|
|
115
|
-
v-if="contact.role"
|
|
115
|
+
v-if="contact.role && !isBoard"
|
|
116
116
|
:label="roleLabel(contact)"
|
|
117
117
|
:variant="roleVariant(contact)"
|
|
118
118
|
size="small" />
|
|
@@ -123,30 +123,54 @@
|
|
|
123
123
|
size="small"
|
|
124
124
|
data-testid="cn-contacts-tab-inactive" />
|
|
125
125
|
</div>
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
<
|
|
131
|
-
<
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
<
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
v-if="
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
<
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
126
|
+
<!-- The board look sets the details on one line (DqZaakBetrokkenen). -->
|
|
127
|
+
<span v-if="isBoard && boardMetaOf(contact)"
|
|
128
|
+
class="cn-contacts-tab__meta"
|
|
129
|
+
data-testid="cn-contacts-tab-meta">{{ boardMetaOf(contact) }}</span>
|
|
130
|
+
<template v-if="!isBoard">
|
|
131
|
+
<div v-if="contact.email" class="cn-contacts-tab__sub">
|
|
132
|
+
<Email :size="14" class="cn-contacts-tab__sub-icon" />
|
|
133
|
+
<span class="cn-contacts-tab__sub-text">{{ contact.email }}</span>
|
|
134
|
+
</div>
|
|
135
|
+
<div v-if="contact.phone" class="cn-contacts-tab__sub">
|
|
136
|
+
<Phone :size="14" class="cn-contacts-tab__sub-icon" />
|
|
137
|
+
<span class="cn-contacts-tab__sub-text">{{ contact.phone }}</span>
|
|
138
|
+
</div>
|
|
139
|
+
<div v-if="contact.org" class="cn-contacts-tab__sub">
|
|
140
|
+
<OfficeBuildingOutline :size="14" class="cn-contacts-tab__sub-icon" />
|
|
141
|
+
<span class="cn-contacts-tab__sub-text">{{ contact.org }}</span>
|
|
142
|
+
</div>
|
|
143
|
+
<div
|
|
144
|
+
v-if="periodOf(contact)"
|
|
145
|
+
class="cn-contacts-tab__sub"
|
|
146
|
+
data-testid="cn-contacts-tab-period">
|
|
147
|
+
<CalendarRange :size="14" class="cn-contacts-tab__sub-icon" />
|
|
148
|
+
<span class="cn-contacts-tab__sub-text">{{ periodOf(contact) }}</span>
|
|
149
|
+
</div>
|
|
150
|
+
<div v-if="contact.note" class="cn-contacts-tab__sub">
|
|
151
|
+
<span class="cn-contacts-tab__sub-text">{{ contact.note }}</span>
|
|
152
|
+
</div>
|
|
153
|
+
</template>
|
|
148
154
|
</div>
|
|
155
|
+
<template v-if="isBoard">
|
|
156
|
+
<span v-if="boardPeriodOf(contact)"
|
|
157
|
+
class="cn-contacts-tab__period"
|
|
158
|
+
data-testid="cn-contacts-tab-board-period">{{ boardPeriodOf(contact) }}</span>
|
|
159
|
+
<NcActions class="cn-contacts-tab__menu"
|
|
160
|
+
variant="secondary"
|
|
161
|
+
:aria-label="moreActionsLabel(contact)"
|
|
162
|
+
data-testid="cn-contacts-tab-menu"
|
|
163
|
+
@click.stop>
|
|
164
|
+
<NcActionButton :closeAfterClick="true" @click="unlink(contact)">
|
|
165
|
+
<template #icon>
|
|
166
|
+
<Close :size="20" />
|
|
167
|
+
</template>
|
|
168
|
+
{{ unlinkLabel }}
|
|
169
|
+
</NcActionButton>
|
|
170
|
+
</NcActions>
|
|
171
|
+
</template>
|
|
149
172
|
<button
|
|
173
|
+
v-else
|
|
150
174
|
type="button"
|
|
151
175
|
class="cn-contacts-tab__unlink"
|
|
152
176
|
:aria-label="unlinkLabel"
|
|
@@ -178,7 +202,7 @@
|
|
|
178
202
|
|
|
179
203
|
<script>
|
|
180
204
|
import { translate as t } from '@nextcloud/l10n'
|
|
181
|
-
import { NcAvatar, NcButton, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
|
|
205
|
+
import { NcActionButton, NcActions, NcAvatar, NcButton, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
|
|
182
206
|
import AccountMultipleOutline from 'vue-material-design-icons/AccountMultipleOutline.vue'
|
|
183
207
|
import AccountPlus from 'vue-material-design-icons/AccountPlus.vue'
|
|
184
208
|
import AlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue'
|
|
@@ -193,6 +217,7 @@ import CnContactPicker from '../../../components/CnContactPicker/CnContactPicker
|
|
|
193
217
|
import CnStatusBadge from '../../../components/CnStatusBadge/CnStatusBadge.vue'
|
|
194
218
|
import CnWidgetHeaderActionButtons from '../../../components/CnWidgetWrapper/CnWidgetHeaderActionButtons.vue'
|
|
195
219
|
import { normalizeLook } from '../../../composables/useLook.js'
|
|
220
|
+
import { formatBoardDate } from '../../../utils/boardDate.js'
|
|
196
221
|
import { buildHeaders, prefixUrl } from '../../../utils/index.js'
|
|
197
222
|
import { WIDGET_BODY_ACTIONS } from '../../../utils/widgetHeaderActions.js'
|
|
198
223
|
|
|
@@ -223,6 +248,8 @@ export default {
|
|
|
223
248
|
name: 'CnContactsTab',
|
|
224
249
|
|
|
225
250
|
components: {
|
|
251
|
+
NcActionButton,
|
|
252
|
+
NcActions,
|
|
226
253
|
NcAvatar,
|
|
227
254
|
NcButton,
|
|
228
255
|
NcEmptyContent,
|
|
@@ -313,6 +340,11 @@ export default {
|
|
|
313
340
|
},
|
|
314
341
|
|
|
315
342
|
computed: {
|
|
343
|
+
/** @return {boolean} The board look draws the rows as DqZaakBetrokkenen does. */
|
|
344
|
+
isBoard() {
|
|
345
|
+
return normalizeLook(this.cnLook) === 'board'
|
|
346
|
+
},
|
|
347
|
+
|
|
316
348
|
/**
|
|
317
349
|
* The two header buttons in the order and weight of the look: the
|
|
318
350
|
* board look draws Link contact then New contact, both outlined; the
|
|
@@ -605,6 +637,49 @@ export default {
|
|
|
605
637
|
*
|
|
606
638
|
* @return {string} The window.
|
|
607
639
|
*/
|
|
640
|
+
/**
|
|
641
|
+
* A person's details on one line for the board look: e-mail, phone,
|
|
642
|
+
* organisation and note, separated by middots.
|
|
643
|
+
*
|
|
644
|
+
* @param {object} contact The contact link.
|
|
645
|
+
* @return {string}
|
|
646
|
+
*/
|
|
647
|
+
boardMetaOf(contact) {
|
|
648
|
+
return [contact?.email, contact?.phone, contact?.org, contact?.note].filter((part) => typeof part === 'string' && part !== '').join(' · ')
|
|
649
|
+
},
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* The validity window in the board's short dates: "Vanaf 3 okt",
|
|
653
|
+
* "Tot 31 okt" or "3 okt – 31 okt".
|
|
654
|
+
*
|
|
655
|
+
* @param {object} contact The contact link.
|
|
656
|
+
* @return {string}
|
|
657
|
+
*/
|
|
658
|
+
boardPeriodOf(contact) {
|
|
659
|
+
const from = formatBoardDate(contact?.validFrom || '')
|
|
660
|
+
const until = formatBoardDate(contact?.validUntil || '')
|
|
661
|
+
if (from === '' && until === '') {
|
|
662
|
+
return ''
|
|
663
|
+
}
|
|
664
|
+
if (until === '') {
|
|
665
|
+
return t('nextcloud-vue', 'From {date}', { date: from })
|
|
666
|
+
}
|
|
667
|
+
if (from === '') {
|
|
668
|
+
return t('nextcloud-vue', 'Until {date}', { date: until })
|
|
669
|
+
}
|
|
670
|
+
return `${from} – ${until}`
|
|
671
|
+
},
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* The label of a person's actions menu.
|
|
675
|
+
*
|
|
676
|
+
* @param {object} contact The contact link.
|
|
677
|
+
* @return {string}
|
|
678
|
+
*/
|
|
679
|
+
moreActionsLabel(contact) {
|
|
680
|
+
return t('nextcloud-vue', 'More actions for {name}', { name: contact?.displayName || this.unknownLabel })
|
|
681
|
+
},
|
|
682
|
+
|
|
608
683
|
periodOf(contact) {
|
|
609
684
|
const from = contact?.validFrom || ''
|
|
610
685
|
const until = contact?.validUntil || ''
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPDX-License-Identifier: EUPL-1.2
|
|
3
|
+
* SPDX-FileCopyrightText: 2026 Conduction B.V.
|
|
4
|
+
*
|
|
5
|
+
* CnContactsTab in the board look draws a person as DqZaakBetrokkenen
|
|
6
|
+
* "Partijen" does: no role badge under a group that already names the role
|
|
7
|
+
* (r26a showed a role id as a badge), no count beside the group heading, the
|
|
8
|
+
* contact details on one line, the period at the right in the board's short
|
|
9
|
+
* date ("Vanaf 3 okt"), and the remove action in a "…" menu.
|
|
10
|
+
*
|
|
11
|
+
* @spec openspec/changes/board-contacts-rows/specs/contacts/spec.md
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { flushPromises, mount } = require('@vue/test-utils')
|
|
15
|
+
const CnContactsTab = require('../CnContactsTab.vue').default
|
|
16
|
+
const { formatBoardDate } = require('../../../../utils/boardDate.js')
|
|
17
|
+
|
|
18
|
+
const ROWS = [{
|
|
19
|
+
id: 1,
|
|
20
|
+
contactUid: 'c1',
|
|
21
|
+
displayName: 'Sanne de Vries',
|
|
22
|
+
role: '3c0f5a00-0000-4000-a000-0000000c0001',
|
|
23
|
+
email: 'sanne@example.nl',
|
|
24
|
+
note: 'ingelogd met DigiD',
|
|
25
|
+
validFrom: '2026-10-03',
|
|
26
|
+
}]
|
|
27
|
+
|
|
28
|
+
function mountTab(look) {
|
|
29
|
+
return mount(CnContactsTab, {
|
|
30
|
+
propsData: { objectId: 'o1', register: 'r1', schema: 's1' },
|
|
31
|
+
global: { provide: { cnLook: look } },
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('CnContactsTab board rows', () => {
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
global.fetch = jest.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ results: ROWS, total: 1 }) })
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
delete global.fetch
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('draws the board row in the board look', async () => {
|
|
45
|
+
const w = mountTab('board')
|
|
46
|
+
await flushPromises()
|
|
47
|
+
const item = w.find('.cn-contacts-tab__item')
|
|
48
|
+
expect(item.find('.cn-contacts-tab__group-count').exists()).toBe(false)
|
|
49
|
+
expect(w.find('.cn-contacts-tab__group-count').exists()).toBe(false)
|
|
50
|
+
expect(item.text()).not.toContain('3c0f5a00')
|
|
51
|
+
expect(item.find('[data-testid="cn-contacts-tab-meta"]').text()).toBe('sanne@example.nl · ingelogd met DigiD')
|
|
52
|
+
expect(item.find('[data-testid="cn-contacts-tab-board-period"]').text()).toBe(`From ${formatBoardDate('2026-10-03')}`)
|
|
53
|
+
expect(item.find('.cn-contacts-tab__unlink').exists()).toBe(false)
|
|
54
|
+
expect(item.find('[data-testid="cn-contacts-tab-menu"]').exists()).toBe(true)
|
|
55
|
+
w.unmount()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('keeps the Nextcloud row in the Nextcloud look', async () => {
|
|
59
|
+
const w = mountTab('nextcloud')
|
|
60
|
+
await flushPromises()
|
|
61
|
+
const item = w.find('.cn-contacts-tab__item')
|
|
62
|
+
expect(w.find('.cn-contacts-tab__group-count').exists()).toBe(true)
|
|
63
|
+
expect(item.find('.cn-contacts-tab__unlink').exists()).toBe(true)
|
|
64
|
+
expect(item.find('[data-testid="cn-contacts-tab-meta"]').exists()).toBe(false)
|
|
65
|
+
w.unmount()
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -21,6 +21,22 @@
|
|
|
21
21
|
-->
|
|
22
22
|
<template>
|
|
23
23
|
<CnDetailCard :title="cardHeaderTitle" :icon="cardIcon" :collapsible="collapsible">
|
|
24
|
+
<!-- `headerActions`: the Deck tab's link and create flows in the title row (detail page). -->
|
|
25
|
+
<template v-if="shownHeaderActions.length > 0" #actions>
|
|
26
|
+
<NcButton
|
|
27
|
+
v-for="action in shownHeaderActions"
|
|
28
|
+
:key="action"
|
|
29
|
+
variant="secondary"
|
|
30
|
+
:data-testid="`cn-deck-card-${action}`"
|
|
31
|
+
@click.stop="action === 'create' ? (createOpen = true) : (pickerOpen = true)">
|
|
32
|
+
<template #icon>
|
|
33
|
+
<Plus v-if="action === 'create'" :size="20" />
|
|
34
|
+
<LinkVariant v-else :size="20" />
|
|
35
|
+
</template>
|
|
36
|
+
{{ action === 'create' ? createLabel : linkLabel }}
|
|
37
|
+
</NcButton>
|
|
38
|
+
</template>
|
|
39
|
+
|
|
24
40
|
<NcLoadingIcon v-if="loading" />
|
|
25
41
|
|
|
26
42
|
<!-- single-entity surface: chip -->
|
|
@@ -102,13 +118,33 @@
|
|
|
102
118
|
</section>
|
|
103
119
|
</div>
|
|
104
120
|
</template>
|
|
121
|
+
|
|
122
|
+
<p v-if="actionError" class="cn-deck-card__action-error" role="alert">
|
|
123
|
+
{{ actionError }}
|
|
124
|
+
</p>
|
|
125
|
+
|
|
126
|
+
<CnDeckCardPicker
|
|
127
|
+
v-if="pickerOpen"
|
|
128
|
+
:apiBase="apiBase"
|
|
129
|
+
@close="pickerOpen = false"
|
|
130
|
+
@link="onLink" />
|
|
131
|
+
|
|
132
|
+
<CnDeckCardCreate
|
|
133
|
+
v-if="createOpen"
|
|
134
|
+
:apiBase="apiBase"
|
|
135
|
+
@close="createOpen = false"
|
|
136
|
+
@create="onCreate" />
|
|
105
137
|
</CnDetailCard>
|
|
106
138
|
</template>
|
|
107
139
|
|
|
108
140
|
<script>
|
|
109
141
|
import { translatePlural as n, translate as t } from '@nextcloud/l10n'
|
|
110
|
-
import { NcLoadingIcon } from '@nextcloud/vue'
|
|
142
|
+
import { NcButton, NcLoadingIcon } from '@nextcloud/vue'
|
|
143
|
+
import LinkVariant from 'vue-material-design-icons/LinkVariant.vue'
|
|
144
|
+
import Plus from 'vue-material-design-icons/Plus.vue'
|
|
111
145
|
import ViewColumnOutline from 'vue-material-design-icons/ViewColumnOutline.vue'
|
|
146
|
+
import CnDeckCardCreate from '../../../components/CnDeckCardCreate/CnDeckCardCreate.vue'
|
|
147
|
+
import CnDeckCardPicker from '../../../components/CnDeckCardPicker/CnDeckCardPicker.vue'
|
|
112
148
|
import CnDetailCard from '../../../components/CnDetailCard/CnDetailCard.vue'
|
|
113
149
|
import { buildHeaders, prefixUrl, safeHref } from '../../../utils/index.js'
|
|
114
150
|
|
|
@@ -123,7 +159,7 @@ const VALID_SURFACES = ['user-dashboard', 'app-dashboard', 'detail-page', 'singl
|
|
|
123
159
|
export default {
|
|
124
160
|
name: 'CnDeckCard',
|
|
125
161
|
|
|
126
|
-
components: { CnDetailCard, NcLoadingIcon, ViewColumnOutline },
|
|
162
|
+
components: { CnDeckCardCreate, CnDeckCardPicker, CnDetailCard, LinkVariant, NcButton, NcLoadingIcon, Plus, ViewColumnOutline },
|
|
127
163
|
|
|
128
164
|
props: {
|
|
129
165
|
/** Stable integration id (forwarded from the registry — always `'deck'`). */
|
|
@@ -157,6 +193,16 @@ export default {
|
|
|
157
193
|
unavailableLabel: { type: String, default: () => t('nextcloud-vue', 'NC Deck is currently unavailable.') },
|
|
158
194
|
/** URL of the NC Deck app entry. */
|
|
159
195
|
deckAppUrl: { type: String, default: '/index.php/apps/deck' },
|
|
196
|
+
/**
|
|
197
|
+
* Buttons in the card's title row on a detail page: `link` links an
|
|
198
|
+
* existing Deck card, `create` makes a new one linked to the object.
|
|
199
|
+
* In the order given; none by default.
|
|
200
|
+
*/
|
|
201
|
+
headerActions: { type: Array, default: () => [] },
|
|
202
|
+
/** Label of the `link` button. */
|
|
203
|
+
linkLabel: { type: String, default: () => t('nextcloud-vue', 'Link an existing card') },
|
|
204
|
+
/** Label of the `create` button. */
|
|
205
|
+
createLabel: { type: String, default: () => t('nextcloud-vue', 'New card') },
|
|
160
206
|
},
|
|
161
207
|
|
|
162
208
|
data() {
|
|
@@ -165,10 +211,26 @@ export default {
|
|
|
165
211
|
entity: null,
|
|
166
212
|
loading: false,
|
|
167
213
|
degraded: '',
|
|
214
|
+
pickerOpen: false,
|
|
215
|
+
createOpen: false,
|
|
216
|
+
actionError: '',
|
|
168
217
|
}
|
|
169
218
|
},
|
|
170
219
|
|
|
171
220
|
computed: {
|
|
221
|
+
/**
|
|
222
|
+
* The title-row buttons to draw: only on a detail page with an
|
|
223
|
+
* object, only the known ones, in the order given.
|
|
224
|
+
*
|
|
225
|
+
* @return {Array<string>}
|
|
226
|
+
*/
|
|
227
|
+
shownHeaderActions() {
|
|
228
|
+
if (this.surface !== 'detail-page' || !this.objectId || !Array.isArray(this.headerActions)) {
|
|
229
|
+
return []
|
|
230
|
+
}
|
|
231
|
+
return this.headerActions.filter((action, index, all) => ['create', 'link'].includes(action) && all.indexOf(action) === index)
|
|
232
|
+
},
|
|
233
|
+
|
|
172
234
|
cardHeaderTitle() {
|
|
173
235
|
return this.title || this.integrationId
|
|
174
236
|
},
|
|
@@ -263,6 +325,58 @@ export default {
|
|
|
263
325
|
},
|
|
264
326
|
|
|
265
327
|
methods: {
|
|
328
|
+
/** @return {string} The object's Deck endpoint the link and create flows post to. */
|
|
329
|
+
deckEndpoint() {
|
|
330
|
+
return `${this.apiBase}/objects/${this.register}/${this.schema}/${this.objectId}/deck`
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Link the picked card, then read the list again.
|
|
335
|
+
*
|
|
336
|
+
* @param {object} payload `{ cardId }` from the picker.
|
|
337
|
+
*/
|
|
338
|
+
async onLink(payload) {
|
|
339
|
+
this.pickerOpen = false
|
|
340
|
+
await this.post(this.deckEndpoint(), payload, t('nextcloud-vue', 'Could not link card.'))
|
|
341
|
+
},
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Create the card linked to the object, then read the list again.
|
|
345
|
+
*
|
|
346
|
+
* @param {object} payload `{ boardId, stackId, title, ... }` from the form.
|
|
347
|
+
*/
|
|
348
|
+
async onCreate(payload) {
|
|
349
|
+
this.createOpen = false
|
|
350
|
+
await this.post(`${this.deckEndpoint()}/new`, payload, t('nextcloud-vue', 'Could not create card.'))
|
|
351
|
+
},
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* POST one flow's payload and reload on success.
|
|
355
|
+
*
|
|
356
|
+
* @param {string} url The endpoint.
|
|
357
|
+
* @param {object} payload The body.
|
|
358
|
+
* @param {string} failure The sentence shown when it fails.
|
|
359
|
+
*/
|
|
360
|
+
async post(url, payload, failure) {
|
|
361
|
+
this.actionError = ''
|
|
362
|
+
try {
|
|
363
|
+
const response = await fetch(prefixUrl(url), {
|
|
364
|
+
method: 'POST',
|
|
365
|
+
headers: { ...buildHeaders(), 'Content-Type': 'application/json' },
|
|
366
|
+
body: JSON.stringify(payload),
|
|
367
|
+
})
|
|
368
|
+
if (response.ok) {
|
|
369
|
+
await this.fetchList()
|
|
370
|
+
} else if (response.status === 409) {
|
|
371
|
+
this.actionError = t('nextcloud-vue', 'This card is already linked.')
|
|
372
|
+
} else {
|
|
373
|
+
this.actionError = failure
|
|
374
|
+
}
|
|
375
|
+
} catch {
|
|
376
|
+
this.actionError = failure
|
|
377
|
+
}
|
|
378
|
+
},
|
|
379
|
+
|
|
266
380
|
baseUrl() {
|
|
267
381
|
return `${this.apiBase}/objects/${this.register}/${this.schema}/${this.objectId}/integrations/${this.integrationId}`
|
|
268
382
|
},
|
|
@@ -366,6 +480,11 @@ export default {
|
|
|
366
480
|
</script>
|
|
367
481
|
|
|
368
482
|
<style scoped>
|
|
483
|
+
.cn-deck-card__action-error {
|
|
484
|
+
margin: 8px 0 0;
|
|
485
|
+
color: var(--color-error-text, var(--color-error));
|
|
486
|
+
}
|
|
487
|
+
|
|
369
488
|
.cn-deck-card__empty {
|
|
370
489
|
color: var(--color-text-maxcontrast);
|
|
371
490
|
font-size: 0.9em;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CnDeckCard `headerActions` (DqZaakTaken "Kaarten": "Bestaande kaart
|
|
3
|
+
* koppelen" and "Nieuwe kaart" in the title row): on a detail page with an
|
|
4
|
+
* object, the card offers the Deck tab's link and create flows and reloads
|
|
5
|
+
* after either.
|
|
6
|
+
*
|
|
7
|
+
* @spec openspec/changes/deck-card-header-actions/specs/deck-card/spec.md
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { flushPromises, mount } = require('@vue/test-utils')
|
|
11
|
+
const CnDeckCard = require('../CnDeckCard.vue').default
|
|
12
|
+
|
|
13
|
+
const PROPS = { register: 'reg', schema: 'case', objectId: 'obj-1', surface: 'detail-page' }
|
|
14
|
+
|
|
15
|
+
function ok(body = { results: [] }) {
|
|
16
|
+
return { ok: true, status: 200, json: () => Promise.resolve(body) }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('CnDeckCard headerActions', () => {
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
global.fetch = jest.fn().mockResolvedValue(ok())
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
delete global.fetch
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('draws no buttons by default', async () => {
|
|
29
|
+
const w = mount(CnDeckCard, { props: PROPS })
|
|
30
|
+
await flushPromises()
|
|
31
|
+
expect(w.find('[data-testid="cn-deck-card-link"]').exists()).toBe(false)
|
|
32
|
+
expect(w.find('[data-testid="cn-deck-card-create"]').exists()).toBe(false)
|
|
33
|
+
w.unmount()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('draws link and create in the order given, only on a detail page', async () => {
|
|
37
|
+
const w = mount(CnDeckCard, { props: { ...PROPS, headerActions: ['link', 'create'] } })
|
|
38
|
+
await flushPromises()
|
|
39
|
+
const buttons = w.findAll('[data-testid^="cn-deck-card-"]').filter((b) => ['cn-deck-card-link', 'cn-deck-card-create'].includes(b.attributes('data-testid')))
|
|
40
|
+
expect(buttons.map((b) => b.attributes('data-testid'))).toEqual(['cn-deck-card-link', 'cn-deck-card-create'])
|
|
41
|
+
await w.setProps({ surface: 'user-dashboard' })
|
|
42
|
+
expect(w.find('[data-testid="cn-deck-card-link"]').exists()).toBe(false)
|
|
43
|
+
w.unmount()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('links a picked card through the deck endpoint and reloads', async () => {
|
|
47
|
+
const w = mount(CnDeckCard, { props: { ...PROPS, headerActions: ['link'] } })
|
|
48
|
+
await flushPromises()
|
|
49
|
+
global.fetch.mockClear()
|
|
50
|
+
await w.vm.onLink({ cardId: 7 })
|
|
51
|
+
await flushPromises()
|
|
52
|
+
expect(global.fetch.mock.calls[0][0]).toContain('/objects/reg/case/obj-1/deck')
|
|
53
|
+
expect(global.fetch.mock.calls[0][1].method).toBe('POST')
|
|
54
|
+
expect(global.fetch.mock.calls[1][0]).toContain('/integrations/deck')
|
|
55
|
+
w.unmount()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('creates a card through the deck endpoint and reloads', async () => {
|
|
59
|
+
const w = mount(CnDeckCard, { props: { ...PROPS, headerActions: ['create'] } })
|
|
60
|
+
await flushPromises()
|
|
61
|
+
global.fetch.mockClear()
|
|
62
|
+
await w.vm.onCreate({ boardId: 1, stackId: 2, title: 'Offerte' })
|
|
63
|
+
await flushPromises()
|
|
64
|
+
expect(global.fetch.mock.calls[0][0]).toContain('/objects/reg/case/obj-1/deck/new')
|
|
65
|
+
expect(global.fetch.mock.calls[1][0]).toContain('/integrations/deck')
|
|
66
|
+
w.unmount()
|
|
67
|
+
})
|
|
68
|
+
})
|
|
@@ -164,15 +164,15 @@ export function buildViewTree({ views = [], labelFilter = '', maxDepth = 3 } = {
|
|
|
164
164
|
}
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
// Seeded roots first,
|
|
168
|
-
//
|
|
169
|
-
// can build a habit with.
|
|
167
|
+
// Seeded roots first, in the order the app declares them, then the
|
|
168
|
+
// reader's own in the order they were saved (DqFilterBeheren). Both are
|
|
169
|
+
// stable between two openings, so a person can build a habit with them.
|
|
170
170
|
const seededRoots = roots.filter(({ view }) => view?.seeded === true)
|
|
171
171
|
const ownRoots = roots.filter(({ view }) => view?.seeded !== true)
|
|
172
|
-
for (const { view, orphaned } of
|
|
172
|
+
for (const { view, orphaned } of seededRoots) {
|
|
173
173
|
emit(view, 0, orphaned)
|
|
174
174
|
}
|
|
175
|
-
for (const { view, orphaned } of
|
|
175
|
+
for (const { view, orphaned } of sortRootsBySaved(ownRoots)) {
|
|
176
176
|
emit(view, 0, orphaned)
|
|
177
177
|
}
|
|
178
178
|
|
|
@@ -228,11 +228,26 @@ function sortByName(views) {
|
|
|
228
228
|
}
|
|
229
229
|
|
|
230
230
|
/**
|
|
231
|
-
* Root entries
|
|
231
|
+
* Root entries in the order their views were saved (`created`, oldest
|
|
232
|
+
* first); views without a saved time follow, by name.
|
|
232
233
|
*
|
|
233
234
|
* @param {Array<object>} roots - The root entries.
|
|
234
235
|
* @return {Array<object>} The same entries, sorted.
|
|
235
236
|
*/
|
|
236
|
-
function
|
|
237
|
-
|
|
237
|
+
function sortRootsBySaved(roots) {
|
|
238
|
+
const savedAt = (entry) => (typeof entry.view?.created === 'string' ? entry.view.created : '')
|
|
239
|
+
return [...roots].sort((left, right) => {
|
|
240
|
+
const a = savedAt(left)
|
|
241
|
+
const b = savedAt(right)
|
|
242
|
+
if (a !== b) {
|
|
243
|
+
if (a === '') {
|
|
244
|
+
return 1
|
|
245
|
+
}
|
|
246
|
+
if (b === '') {
|
|
247
|
+
return -1
|
|
248
|
+
}
|
|
249
|
+
return a < b ? -1 : 1
|
|
250
|
+
}
|
|
251
|
+
return String(left.view?.name ?? '').localeCompare(String(right.view?.name ?? ''))
|
|
252
|
+
})
|
|
238
253
|
}
|