@conduction/nextcloud-vue 2.102.0 → 2.103.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/CnConversationThread/CnConversationThread.vue.js +1 -1
- package/dist/esm/components/CnConversationThread/CnConversationThread.vue2.js +73 -0
- package/dist/esm/components/CnConversationThread/CnConversationThread.vue2.js.map +1 -1
- package/dist/esm/components/CnConversationThread/CnConversationThread.vue3.js +99 -64
- package/dist/esm/components/CnConversationThread/CnConversationThread.vue3.js.map +1 -1
- package/dist/esm/components/CnConversationThread/CnConversationWidget.vue.js +1 -1
- package/dist/esm/components/CnConversationThread/CnConversationWidget.vue2.js +46 -0
- package/dist/esm/components/CnConversationThread/CnConversationWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnConversationThread/CnConversationWidget.vue3.js +37 -2
- package/dist/esm/components/CnConversationThread/CnConversationWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +29 -6
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue.js +1 -1
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +113 -20
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js +387 -359
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +14 -0
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +2 -2
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
- package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue.js +1 -1
- package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +10 -1
- package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +1 -1
- package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +97 -3
- package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js +46 -2
- package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnSearchSelect/CnSearchSelect.vue.js +9 -0
- package/dist/esm/components/CnSearchSelect/CnSearchSelect.vue.js.map +1 -0
- package/dist/esm/components/CnSearchSelect/CnSearchSelect.vue2.js +165 -0
- package/dist/esm/components/CnSearchSelect/CnSearchSelect.vue2.js.map +1 -0
- package/dist/esm/components/CnSearchSelect/CnSearchSelect.vue3.js +85 -0
- package/dist/esm/components/CnSearchSelect/CnSearchSelect.vue3.js.map +1 -0
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue.js +1 -1
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js +54 -6
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js.map +1 -1
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js +3 -1
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js.map +1 -1
- package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue.js +1 -1
- package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +8 -1
- package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue.js +1 -1
- package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue2.js +19 -0
- package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue2.js.map +1 -1
- package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue3.js +1 -1
- package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue3.js.map +1 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/library-style.js +1 -1
- package/dist/esm/mixins/pageViews.js +8 -1
- package/dist/esm/mixins/pageViews.js.map +1 -1
- package/dist/esm/nextcloud-vue.css +472 -383
- package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
- package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
- package/dist/esm/utils/visibleWhen.js +13 -2
- package/dist/esm/utils/visibleWhen.js.map +1 -1
- package/dist/nextcloud-vue.cjs.js +10707 -9883
- package/dist/nextcloud-vue.cjs.js.map +2 -2
- package/dist/nextcloud-vue.css +472 -383
- package/package.json +1 -1
- package/src/components/CnConversationThread/CnConversationThread.md +17 -0
- package/src/components/CnConversationThread/CnConversationThread.vue +113 -8
- package/src/components/CnConversationThread/CnConversationWidget.vue +60 -1
- package/src/components/CnDashboardPage/CnDashboardPage.md +4 -1
- package/src/components/CnDashboardPage/CnDashboardPage.vue +29 -6
- package/src/components/CnDetailPage/CnDetailPage.md +1 -1
- package/src/components/CnFormDialog/CnFormDialog.md +1 -1
- package/src/components/CnFormDialog/CnFormDialog.vue +127 -0
- package/src/components/CnIndexPage/CnIndexPage.md +10 -0
- package/src/components/CnIndexPage/CnIndexPage.vue +18 -3
- package/src/components/CnObjectListWidget/CnObjectListWidget.vue +10 -1
- package/src/components/CnRecordListWidget/CnRecordListWidget.vue +134 -2
- package/src/components/CnSearchSelect/CnSearchSelect.vue +283 -0
- package/src/components/CnSearchSelect/index.js +12 -0
- package/src/components/CnSetupWizard/CnSetupWizard.vue +56 -6
- package/src/components/CnTabsWidget/CnTabsWidget.vue +8 -1
- package/src/components/CnWizardDialog/CnWizardDialog.vue +20 -1
- package/src/components/index.js +1 -0
- package/src/css/dialog.css +5 -3
- package/src/index.js +1 -0
- package/src/mixins/pageViews.js +8 -1
- package/src/schemas/app-manifest-v2.schema.json +53 -6
- package/src/utils/validateManifestV2.compiled.js +3 -3
- package/src/utils/visibleWhen.js +13 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@conduction/nextcloud-vue",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.103.0",
|
|
4
4
|
"description": "Shared Vue component library for Conduction Nextcloud apps — complements @nextcloud/vue with higher-level components, OpenRegister integration, and NL Design System support",
|
|
5
5
|
"homepage": "https://github.com/ConductionNL/nextcloud-vue#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -23,6 +23,23 @@ Read-only, for a closed record:
|
|
|
23
23
|
]" />
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
+
Channel chips and a header action. `channel-filter` draws All plus a chip per channel with its count, `channels` lists labels that always get a chip, and the `header-actions` slot puts buttons beside the title:
|
|
27
|
+
|
|
28
|
+
```vue
|
|
29
|
+
<CnConversationThread
|
|
30
|
+
title="Contact"
|
|
31
|
+
channel-filter
|
|
32
|
+
:channels="['E-mail', 'Letter']"
|
|
33
|
+
:messages="[
|
|
34
|
+
{ id: 1, author: 'Sanne de Vries', side: 'them', channel: 'E-mail', text: 'Could I see the documents?' },
|
|
35
|
+
{ id: 2, side: 'us', channel: 'E-mail', text: 'They are on their way.' },
|
|
36
|
+
]">
|
|
37
|
+
<template #header-actions>
|
|
38
|
+
<NcButton>Log contact</NcButton>
|
|
39
|
+
</template>
|
|
40
|
+
</CnConversationThread>
|
|
41
|
+
```
|
|
42
|
+
|
|
26
43
|
No messages yet:
|
|
27
44
|
|
|
28
45
|
```vue
|
|
@@ -9,13 +9,27 @@
|
|
|
9
9
|
data-testid="cn-conversation-thread"
|
|
10
10
|
:aria-labelledby="title ? titleId : null"
|
|
11
11
|
:aria-label="title ? null : fallbackLabel">
|
|
12
|
-
<
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
12
|
+
<div v-if="title || $slots['header-actions']" class="cn-conversation-thread__header">
|
|
13
|
+
<component
|
|
14
|
+
:is="titleTag"
|
|
15
|
+
v-if="title"
|
|
16
|
+
:id="titleId"
|
|
17
|
+
class="cn-conversation-thread__title">
|
|
18
|
+
{{ title }}
|
|
19
|
+
</component>
|
|
20
|
+
<div v-if="$slots['header-actions']" class="cn-conversation-thread__header-actions">
|
|
21
|
+
<!-- @slot header-actions Buttons beside the title, for example "Log contact". -->
|
|
22
|
+
<slot name="header-actions" />
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<CnQuickFilterBar
|
|
27
|
+
v-if="channelChips.length > 0"
|
|
28
|
+
inline
|
|
29
|
+
class="cn-conversation-thread__channels"
|
|
30
|
+
:tabs="channelChips"
|
|
31
|
+
:activeIndex="activeChannelIndex"
|
|
32
|
+
@update:activeIndex="onChannelChip" />
|
|
19
33
|
|
|
20
34
|
<p v-if="loading && rows.length === 0"
|
|
21
35
|
class="cn-conversation-thread__empty"
|
|
@@ -27,9 +41,13 @@
|
|
|
27
41
|
{{ emptyText }}
|
|
28
42
|
</p>
|
|
29
43
|
|
|
44
|
+
<p v-else-if="shownRows.length === 0" class="cn-conversation-thread__empty" data-testid="cn-conversation-channel-empty">
|
|
45
|
+
{{ emptyText }}
|
|
46
|
+
</p>
|
|
47
|
+
|
|
30
48
|
<ol v-else class="cn-conversation-thread__messages">
|
|
31
49
|
<li
|
|
32
|
-
v-for="row in
|
|
50
|
+
v-for="row in shownRows"
|
|
33
51
|
:key="row.key"
|
|
34
52
|
class="cn-conversation-thread__message"
|
|
35
53
|
:class="`cn-conversation-thread__message--${row.side}`"
|
|
@@ -93,6 +111,7 @@
|
|
|
93
111
|
import axios from '@nextcloud/axios'
|
|
94
112
|
import { translate as t } from '@nextcloud/l10n'
|
|
95
113
|
import { NcButton } from '@nextcloud/vue'
|
|
114
|
+
import CnQuickFilterBar from '../CnQuickFilterBar/CnQuickFilterBar.vue'
|
|
96
115
|
import { prefixUrl } from '../../utils/headers.js'
|
|
97
116
|
import { userLocale } from '../../utils/userLocale.js'
|
|
98
117
|
|
|
@@ -126,6 +145,7 @@ export default {
|
|
|
126
145
|
name: 'CnConversationThread',
|
|
127
146
|
|
|
128
147
|
components: {
|
|
148
|
+
CnQuickFilterBar,
|
|
129
149
|
NcButton,
|
|
130
150
|
},
|
|
131
151
|
|
|
@@ -204,6 +224,24 @@ export default {
|
|
|
204
224
|
default: false,
|
|
205
225
|
},
|
|
206
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Draw channel chips above the messages: All, then one per channel
|
|
229
|
+
* with its count. Choosing one shows only that channel's messages.
|
|
230
|
+
*/
|
|
231
|
+
channelFilter: {
|
|
232
|
+
type: Boolean,
|
|
233
|
+
default: false,
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Channel labels that always get a chip, in this order, also when no
|
|
238
|
+
* message uses them yet. Channels the messages use follow.
|
|
239
|
+
*/
|
|
240
|
+
channels: {
|
|
241
|
+
type: Array,
|
|
242
|
+
default: () => [],
|
|
243
|
+
},
|
|
244
|
+
|
|
207
245
|
/** Shown when there are no messages yet. */
|
|
208
246
|
emptyText: {
|
|
209
247
|
type: String,
|
|
@@ -224,6 +262,8 @@ export default {
|
|
|
224
262
|
conversationThreadUid += 1
|
|
225
263
|
return {
|
|
226
264
|
draft: '',
|
|
265
|
+
/** The channel the chips narrow the thread to; '' is All. */
|
|
266
|
+
activeChannel: '',
|
|
227
267
|
sending: false,
|
|
228
268
|
errorMessage: '',
|
|
229
269
|
titleId: `cn-conversation-title-${conversationThreadUid}`,
|
|
@@ -262,6 +302,47 @@ export default {
|
|
|
262
302
|
})
|
|
263
303
|
},
|
|
264
304
|
|
|
305
|
+
/**
|
|
306
|
+
* The channel labels with a chip: the declared ones, then the ones
|
|
307
|
+
* the messages use.
|
|
308
|
+
*
|
|
309
|
+
* @return {Array<string>} The labels, each once.
|
|
310
|
+
*/
|
|
311
|
+
channelLabels() {
|
|
312
|
+
const declared = (Array.isArray(this.channels) ? this.channels : []).filter((label) => typeof label === 'string' && label !== '')
|
|
313
|
+
return [...new Set([...declared, ...this.rows.map((row) => row.channel).filter(Boolean)])]
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The chips: All, then each channel, each with its count. Empty
|
|
318
|
+
* without `channelFilter` or without channels.
|
|
319
|
+
*
|
|
320
|
+
* @return {Array<{label: string, count: number}>} The chips.
|
|
321
|
+
*/
|
|
322
|
+
channelChips() {
|
|
323
|
+
if (!this.channelFilter || this.channelLabels.length === 0) {
|
|
324
|
+
return []
|
|
325
|
+
}
|
|
326
|
+
return [
|
|
327
|
+
{ label: t('nextcloud-vue', 'All'), count: this.rows.length },
|
|
328
|
+
...this.channelLabels.map((label) => ({ label, count: this.rows.filter((row) => row.channel === label).length })),
|
|
329
|
+
]
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
/** @return {number} The pressed chip: 0 for All. */
|
|
333
|
+
activeChannelIndex() {
|
|
334
|
+
const index = this.channelLabels.indexOf(this.activeChannel)
|
|
335
|
+
return index < 0 ? 0 : index + 1
|
|
336
|
+
},
|
|
337
|
+
|
|
338
|
+
/** @return {Array<object>} The rows on the chosen channel, or all of them. */
|
|
339
|
+
shownRows() {
|
|
340
|
+
if (this.channelChips.length === 0 || this.activeChannelIndex === 0) {
|
|
341
|
+
return this.rows
|
|
342
|
+
}
|
|
343
|
+
return this.rows.filter((row) => row.channel === this.activeChannel)
|
|
344
|
+
},
|
|
345
|
+
|
|
265
346
|
/**
|
|
266
347
|
* Whether there is something to send and nothing in flight.
|
|
267
348
|
*
|
|
@@ -289,6 +370,16 @@ export default {
|
|
|
289
370
|
},
|
|
290
371
|
|
|
291
372
|
methods: {
|
|
373
|
+
/**
|
|
374
|
+
* Narrow the thread to the chip's channel; the All chip (0) clears it.
|
|
375
|
+
*
|
|
376
|
+
* @param {number|null} index The chip index.
|
|
377
|
+
* @return {void}
|
|
378
|
+
*/
|
|
379
|
+
onChannelChip(index) {
|
|
380
|
+
this.activeChannel = index > 0 ? (this.channelLabels[index - 1] || '') : ''
|
|
381
|
+
},
|
|
382
|
+
|
|
292
383
|
/**
|
|
293
384
|
* Up to two initials of a name, for the avatar.
|
|
294
385
|
*
|
|
@@ -370,6 +461,20 @@ export default {
|
|
|
370
461
|
gap: calc(3.5 * var(--default-grid-baseline));
|
|
371
462
|
}
|
|
372
463
|
|
|
464
|
+
.cn-conversation-thread__header {
|
|
465
|
+
align-items: flex-start;
|
|
466
|
+
display: flex;
|
|
467
|
+
flex-wrap: wrap;
|
|
468
|
+
gap: calc(3 * var(--default-grid-baseline));
|
|
469
|
+
justify-content: space-between;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
.cn-conversation-thread__header-actions {
|
|
473
|
+
display: flex;
|
|
474
|
+
flex-wrap: wrap;
|
|
475
|
+
gap: calc(2 * var(--default-grid-baseline));
|
|
476
|
+
}
|
|
477
|
+
|
|
373
478
|
.cn-conversation-thread__title {
|
|
374
479
|
font-size: 1.1em;
|
|
375
480
|
font-weight: 700;
|
|
@@ -15,12 +15,27 @@
|
|
|
15
15
|
:endpoint="replyAction ? '' : endpoint"
|
|
16
16
|
:loading="loading"
|
|
17
17
|
:emptyText="content.emptyText ? tr(content.emptyText) : undefined"
|
|
18
|
+
:channelFilter="content.channelChips === true"
|
|
19
|
+
:channels="channelLabels"
|
|
18
20
|
@send="onSend"
|
|
19
|
-
@sent="onSent"
|
|
21
|
+
@sent="onSent">
|
|
22
|
+
<template v-if="headerAction" #header-actions>
|
|
23
|
+
<NcButton
|
|
24
|
+
data-testid="cn-conversation-header-action"
|
|
25
|
+
@click="onHeaderAction">
|
|
26
|
+
<template v-if="headerAction.icon" #icon>
|
|
27
|
+
<CnIcon :name="headerAction.icon" :size="20" />
|
|
28
|
+
</template>
|
|
29
|
+
{{ tr(headerAction.label) }}
|
|
30
|
+
</NcButton>
|
|
31
|
+
</template>
|
|
32
|
+
</CnConversationThread>
|
|
20
33
|
</div>
|
|
21
34
|
</template>
|
|
22
35
|
|
|
23
36
|
<script>
|
|
37
|
+
import { NcButton } from '@nextcloud/vue'
|
|
38
|
+
import CnIcon from '../CnIcon/CnIcon.vue'
|
|
24
39
|
import CnConversationThread from './CnConversationThread.vue'
|
|
25
40
|
import { dispatchAction } from '../../utils/actionsDispatcher.js'
|
|
26
41
|
import { fetchReferenceObjects } from '../../utils/boardReferenceColumns.js'
|
|
@@ -45,12 +60,20 @@ import { hasUnresolvedDeepTokens, resolveDeepTokens } from '../../utils/resolveF
|
|
|
45
60
|
* `replyAction`, an action descriptor the app decides the meaning of: it is
|
|
46
61
|
* dispatched with `args: [{ text, objectId }]` and `props.text`. After a send
|
|
47
62
|
* the source is read again.
|
|
63
|
+
*
|
|
64
|
+
* `channelChips: true` draws a chip per channel above the thread (every
|
|
65
|
+
* `channelMap` label, in map order, then any other channel a message uses).
|
|
66
|
+
* `headerAction` is a button beside the title (`{ label, icon?, ...action }`,
|
|
67
|
+
* DqZaakContact's "Contact vastleggen"), dispatched like `replyAction` with
|
|
68
|
+
* `args: [{ objectId }]`; after it the source is read again.
|
|
48
69
|
*/
|
|
49
70
|
export default {
|
|
50
71
|
name: 'CnConversationWidget',
|
|
51
72
|
|
|
52
73
|
components: {
|
|
53
74
|
CnConversationThread,
|
|
75
|
+
CnIcon,
|
|
76
|
+
NcButton,
|
|
54
77
|
},
|
|
55
78
|
|
|
56
79
|
inject: {
|
|
@@ -102,6 +125,18 @@ export default {
|
|
|
102
125
|
return source && typeof source === 'object' && source.register && source.schema ? source : null
|
|
103
126
|
},
|
|
104
127
|
|
|
128
|
+
/** @return {object|null} The header action, or null without a label. */
|
|
129
|
+
headerAction() {
|
|
130
|
+
const action = this.content?.headerAction
|
|
131
|
+
return action && typeof action === 'object' && typeof action.label === 'string' && action.label !== '' ? action : null
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
/** @return {Array<string>} The `channelMap` labels, translated, each once. */
|
|
135
|
+
channelLabels() {
|
|
136
|
+
const map = this.content?.channelMap && typeof this.content.channelMap === 'object' ? this.content.channelMap : {}
|
|
137
|
+
return [...new Set(Object.values(map).filter((label) => typeof label === 'string' && label !== '').map((label) => this.tr(label)))]
|
|
138
|
+
},
|
|
139
|
+
|
|
105
140
|
/** @return {object|null} The reply action, or null. */
|
|
106
141
|
replyAction() {
|
|
107
142
|
const action = this.content?.replyAction
|
|
@@ -254,6 +289,30 @@ export default {
|
|
|
254
289
|
this.onSent({ text })
|
|
255
290
|
},
|
|
256
291
|
|
|
292
|
+
/**
|
|
293
|
+
* Run `headerAction` with the record id, then read the source again.
|
|
294
|
+
*
|
|
295
|
+
* @return {Promise<void>}
|
|
296
|
+
*/
|
|
297
|
+
async onHeaderAction() {
|
|
298
|
+
if (!this.headerAction) {
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
const { label, icon, ...rest } = resolveDeepTokens(this.headerAction, this.tokenCtx)
|
|
302
|
+
const action = { ...rest, args: [{ objectId: this.objectId }] }
|
|
303
|
+
try {
|
|
304
|
+
const outcome = await (typeof this.cnDispatchAction === 'function'
|
|
305
|
+
? this.cnDispatchAction(action)
|
|
306
|
+
: dispatchAction(action, { router: this.$router || null }))
|
|
307
|
+
if (outcome === false) {
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
} catch {
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
await this.loadSource()
|
|
314
|
+
},
|
|
315
|
+
|
|
257
316
|
/**
|
|
258
317
|
* Show an accepted reply at once, without waiting for the record.
|
|
259
318
|
*
|
|
@@ -126,12 +126,15 @@ predicate's field value into their text wherever it says `{value}` (e.g.
|
|
|
126
126
|
| `gridMargin` | Number | `12` | Grid margin (gutter) in pixels between widgets |
|
|
127
127
|
| `emptyLabel` | String | `'No widgets configured'` | Text shown in the empty state when `layout` is empty |
|
|
128
128
|
| `unavailableLabel` | String | `'Widget not available'` | Text shown for unknown or unavailable widgets |
|
|
129
|
-
| `userLayout` | Boolean | `false` | Keep a per-user arrangement of this page. Off by default: a page without it makes **no** layout request and renders exactly as before. |
|
|
129
|
+
| `userLayout` | Boolean | `false` | **Deprecated** (layout belongs to Buildiq; removed after this release). Keep a per-user arrangement of this page. Off by default: a page without it makes **no** layout request and renders exactly as before. |
|
|
130
130
|
| `appId` | String | `''` | The Nextcloud app the user layout is stored under. Required for `userLayout` to do anything. |
|
|
131
131
|
| `userLayoutStore` | Object | `null` | A store exposing `loadDashboardLayout` / `saveDashboardLayout` / `resetDashboardLayout`, usually one carrying [`dashboardLayoutsPlugin`](../../../docs/store/plugins/dashboard-layouts.md). Left unset, the page reads and writes user preferences directly. |
|
|
132
132
|
|
|
133
133
|
## Per-user layout
|
|
134
134
|
|
|
135
|
+
> **Deprecated: layout belongs to Buildiq.** Arranging a page is Buildiq's job (Ruben, 18 Oct 2026): its **Indeling aanpassen** edits the layout everybody sees. `userLayout` still works for this release and will be removed after it. While Buildiq edits the page (`cnEditingBody`), the grid shows the manifest layout, and a drag or resize goes into the manifest Buildiq saves, also on a page with `userLayout`. A user arrangement made outside an in-page edit session is saved at once, because no **Done** follows to save it.
|
|
136
|
+
|
|
137
|
+
|
|
135
138
|
With `userLayout: true` and an `appId`, this page loads the current user's arrangement before the first grid render and merges it over the manifest layout. The rules are [`mergeUserLayout`](../../../docs/utilities/merge-user-layout.md)'s: **the manifest decides which widgets exist, the user decides where they sit.**
|
|
136
139
|
|
|
137
140
|
```vue
|
|
@@ -1839,6 +1839,9 @@ export default {
|
|
|
1839
1839
|
* and renders exactly as it did before this existed, which is the
|
|
1840
1840
|
* property that makes shipping it safe on every dashboard at once.
|
|
1841
1841
|
*
|
|
1842
|
+
* Deprecated: layout belongs to Buildiq. Kept working for one release.
|
|
1843
|
+
*
|
|
1844
|
+
* @deprecated
|
|
1842
1845
|
* @type {boolean}
|
|
1843
1846
|
*/
|
|
1844
1847
|
userLayout: {
|
|
@@ -2216,9 +2219,25 @@ export default {
|
|
|
2216
2219
|
* @return {boolean}
|
|
2217
2220
|
*/
|
|
2218
2221
|
gridEditable() {
|
|
2222
|
+
return this.isEditing || this.editingBody
|
|
2223
|
+
},
|
|
2224
|
+
|
|
2225
|
+
/**
|
|
2226
|
+
* Whether Buildiq is editing the page (the injected `cnEditingBody`
|
|
2227
|
+
* ref, or a plain boolean). Buildiq is the layout editor: while it
|
|
2228
|
+
* edits, the grid shows and changes the manifest layout, also on a
|
|
2229
|
+
* page with `userLayout`.
|
|
2230
|
+
*
|
|
2231
|
+
* @return {boolean}
|
|
2232
|
+
*/
|
|
2233
|
+
editingBody() {
|
|
2219
2234
|
const e = this.cnEditingBody
|
|
2220
|
-
|
|
2221
|
-
|
|
2235
|
+
return Boolean(e && typeof e === 'object' && 'value' in e ? e.value : e)
|
|
2236
|
+
},
|
|
2237
|
+
|
|
2238
|
+
/** @return {boolean} Whether the grid shows this user's arrangement. */
|
|
2239
|
+
showsUserLayout() {
|
|
2240
|
+
return this.userLayout && !this.editingBody && Array.isArray(this.userLayoutItems)
|
|
2222
2241
|
},
|
|
2223
2242
|
|
|
2224
2243
|
/**
|
|
@@ -2279,9 +2298,7 @@ export default {
|
|
|
2279
2298
|
* @return {Array<object>} The layout.
|
|
2280
2299
|
*/
|
|
2281
2300
|
renderedLayout() {
|
|
2282
|
-
const base =
|
|
2283
|
-
? this.userLayoutItems
|
|
2284
|
-
: this.layout
|
|
2301
|
+
const base = this.showsUserLayout ? this.userLayoutItems : this.layout
|
|
2285
2302
|
// The KPI row is not part of the grid.
|
|
2286
2303
|
if (Array.isArray(this.kpiRow) && this.kpiRow.length > 0 && Array.isArray(base)) {
|
|
2287
2304
|
return base.filter((item) => !this.kpiRow.includes(item.widgetId))
|
|
@@ -3577,7 +3594,8 @@ export default {
|
|
|
3577
3594
|
// write below is for the admin editor, which edits the page for
|
|
3578
3595
|
// everybody; doing it for a user rearranging their own dashboard
|
|
3579
3596
|
// would rewrite the page for everyone who opens it after them.
|
|
3580
|
-
|
|
3597
|
+
// While Buildiq edits, the drag is the admin's and goes below.
|
|
3598
|
+
if (this.showsUserLayout) {
|
|
3581
3599
|
this.userLayoutDirty = true
|
|
3582
3600
|
this.userLayoutItems = this.userLayoutItems.map((item) => {
|
|
3583
3601
|
const u = (Array.isArray(updated) ? updated : []).find((candidate) => String(candidate.id) === String(item.id)
|
|
@@ -3586,6 +3604,11 @@ export default {
|
|
|
3586
3604
|
return (u ? { ...item, ...this.geometryFrom(u) } : item)
|
|
3587
3605
|
})
|
|
3588
3606
|
this.$emit('layout-change', this.userLayoutItems)
|
|
3607
|
+
// Outside an in-page edit session no Done will come to save
|
|
3608
|
+
// the arrangement, so it is saved now.
|
|
3609
|
+
if (!this.isEditing) {
|
|
3610
|
+
this.saveUserLayout()
|
|
3611
|
+
}
|
|
3589
3612
|
|
|
3590
3613
|
return
|
|
3591
3614
|
}
|
|
@@ -215,7 +215,7 @@ export default {
|
|
|
215
215
|
|
|
216
216
|
| Prop | Type | Default | Description |
|
|
217
217
|
|------|------|---------|-------------|
|
|
218
|
-
| `userLayout` | Boolean | `false` | Let the user arrange each of the page's `views` for themselves. The header shows **Arrange view** next to the view switch; **Done** stores the arrangement per view and user (a user preference, mirrored in the browser), **Reset view** returns the chosen view to its manifest layout. The manifest `layout` is never changed. See [the guide](../../../docs/components/cn-detail-page.md#each-view-arranged-per-user-userlayout). |
|
|
218
|
+
| `userLayout` | Boolean | `false` | **Deprecated** (layout belongs to Buildiq; removed after this release). Let the user arrange each of the page's `views` for themselves. The header shows **Arrange view** next to the view switch; **Done** stores the arrangement per view and user (a user preference, mirrored in the browser), **Reset view** returns the chosen view to its manifest layout. The manifest `layout` is never changed. See [the guide](../../../docs/components/cn-detail-page.md#each-view-arranged-per-user-userlayout). |
|
|
219
219
|
|
|
220
220
|
## Slots
|
|
221
221
|
|
|
@@ -100,7 +100,7 @@ export default {
|
|
|
100
100
|
| `lockedFields` | `[]` | Field keys rendered read-only (disabled) and immutable — typically the parent reference seeded via `initialData` so the user can't repoint a child away from the record it was created under. |
|
|
101
101
|
| `excludeFields` | `[]` | Array of field keys to exclude from the auto-generated form. |
|
|
102
102
|
| `includeFields` | `null` | Array of field keys to include (whitelist mode). Null means all fields. |
|
|
103
|
-
| `fieldOverrides` | `{}` | Per-field override objects passed to `fieldsFromSchema`. |
|
|
103
|
+
| `fieldOverrides` | `{}` | Per-field override objects passed to `fieldsFromSchema`. A `widget` that names a registered component (v2 registry, then `customComponents`) mounts it as the field's control, with `value`/`modelValue`, `field`, `error` and `disabled`, writing back on `input` or `update:modelValue`. |
|
|
104
104
|
| `nameField` | `'title'` | Which field is the "name" of the item (used in result messages). |
|
|
105
105
|
| `size` | `'normal'` | NcDialog size — `'small'`, `'normal'`, or `'large'`. |
|
|
106
106
|
| `dynamicLoadingLabel` | `'Loading the fields this choice adds.'` | Text shown while the fields a chosen value brings with it are being fetched. See *Fields the data decides* below. |
|
|
@@ -131,6 +131,22 @@
|
|
|
131
131
|
:error="errors[field.key]"
|
|
132
132
|
:updateField="updateField" />
|
|
133
133
|
|
|
134
|
+
<!-- A `widget` naming a registered component (v2 registry or
|
|
135
|
+
customComponents) mounts that component as the control.
|
|
136
|
+
It gets `value`/`modelValue`, `field`, `error` and
|
|
137
|
+
`disabled`, and writes back with `input` or
|
|
138
|
+
`update:modelValue`. -->
|
|
139
|
+
<component
|
|
140
|
+
:is="resolveFieldComponent(field)"
|
|
141
|
+
v-else-if="resolveFieldComponent(field)"
|
|
142
|
+
:value="formData[field.key]"
|
|
143
|
+
:modelValue="formData[field.key]"
|
|
144
|
+
:field="field"
|
|
145
|
+
:error="errors[field.key] || ''"
|
|
146
|
+
:disabled="!!field.readOnly"
|
|
147
|
+
@input="value => updateField(field.key, value)"
|
|
148
|
+
@update:modelValue="value => updateField(field.key, value)" />
|
|
149
|
+
|
|
134
150
|
<!-- referenceType (AD-18): render the integration's
|
|
135
151
|
single-entity widget instead of a plain input. -->
|
|
136
152
|
<component
|
|
@@ -314,8 +330,26 @@
|
|
|
314
330
|
plain select. NcSelectUsers wraps NcSelect and merges the
|
|
315
331
|
rest of these bindings through, so the async load, search
|
|
316
332
|
and select machinery is unchanged either way. -->
|
|
333
|
+
<!-- `look: "search"` (a `fieldOverrides` key): a search field
|
|
334
|
+
with rows of icon or initials, name, a secondary line from
|
|
335
|
+
`optionSecondary` and a check on the chosen one
|
|
336
|
+
(DqOverdragen "Nieuw team"). -->
|
|
337
|
+
<CnSearchSelect
|
|
338
|
+
v-if="field.look === 'search' && !isUserField(field)"
|
|
339
|
+
:inputId="'cn-form-' + field.key"
|
|
340
|
+
:inputLabel="fieldLabelText(field)"
|
|
341
|
+
:labelOutside="isBoardLook"
|
|
342
|
+
:options="searchSelectOptions(field)"
|
|
343
|
+
:modelValue="getEffectiveSelectedOption(field)"
|
|
344
|
+
:clearable="!field.required"
|
|
345
|
+
:disabled="field.readOnly"
|
|
346
|
+
:loading="isFieldLoading(field)"
|
|
347
|
+
:filterable="!isAsyncEnum(field)"
|
|
348
|
+
@update:modelValue="onSearchSelectChange(field, $event)"
|
|
349
|
+
@search="isAsyncEnum(field) ? onAsyncSearch(field, $event) : undefined" />
|
|
317
350
|
<component
|
|
318
351
|
:is="isUserField(field) ? 'NcSelectUsers' : 'NcSelect'"
|
|
352
|
+
v-else
|
|
319
353
|
v-cn-select-aria="fieldAria(field)"
|
|
320
354
|
:inputId="'cn-form-' + field.key"
|
|
321
355
|
:inputLabel="fieldLabelText(field)"
|
|
@@ -799,6 +833,7 @@ import CnIconBrowser from '../CnIconBrowser/CnIconBrowser.vue'
|
|
|
799
833
|
import CnJsonViewer from '../CnJsonViewer/CnJsonViewer.vue'
|
|
800
834
|
import CnPropertySourceField from '../CnPropertySourceField/CnPropertySourceField.vue'
|
|
801
835
|
import CnResourceSelect from '../CnResourceSelect/CnResourceSelect.vue'
|
|
836
|
+
import CnSearchSelect from '../CnSearchSelect/CnSearchSelect.vue'
|
|
802
837
|
import CnSubObjectsField from '../CnSubObjectsField/CnSubObjectsField.vue'
|
|
803
838
|
import { describeRowProblem, useChildRecords } from '../../composables/useChildRecords.js'
|
|
804
839
|
import { clearAnonymousDrafts, draftIndicatorText, draftKey, formDraftMixin, hasDraftChanges, readDraft, resolveDraftUserId } from '../../composables/useFormDraft.js'
|
|
@@ -821,6 +856,7 @@ import {
|
|
|
821
856
|
splitDynamicFormData,
|
|
822
857
|
} from '../../utils/dynamicProperties.js'
|
|
823
858
|
import { shouldShow } from '../../utils/fieldCondition.js'
|
|
859
|
+
import { fillTemplate } from '../../utils/fillTemplate.js'
|
|
824
860
|
import { computeAssignments } from '../../utils/formAssign.js'
|
|
825
861
|
import { resolveNextcloudGroup, searchNextcloudGroups } from '../../utils/groupAutocomplete.js'
|
|
826
862
|
import { durationSeconds } from '../../utils/isoDuration.js'
|
|
@@ -844,6 +880,36 @@ import { resolveNextcloudUser, searchNextcloudUsers } from '../../utils/userAuto
|
|
|
844
880
|
*/
|
|
845
881
|
const WIDE_WIDGETS = ['textarea', 'json', 'code']
|
|
846
882
|
|
|
883
|
+
// The widget names the form draws itself; a registered component cannot take one over.
|
|
884
|
+
const BUILT_IN_WIDGETS = new Set([
|
|
885
|
+
'text',
|
|
886
|
+
'email',
|
|
887
|
+
'url',
|
|
888
|
+
'number',
|
|
889
|
+
'textarea',
|
|
890
|
+
'switch',
|
|
891
|
+
'select',
|
|
892
|
+
'user',
|
|
893
|
+
'group',
|
|
894
|
+
'multiselect',
|
|
895
|
+
'user-multiselect',
|
|
896
|
+
'group-multiselect',
|
|
897
|
+
'tags',
|
|
898
|
+
'checkbox',
|
|
899
|
+
'date',
|
|
900
|
+
'datetime',
|
|
901
|
+
'json',
|
|
902
|
+
'code',
|
|
903
|
+
'file',
|
|
904
|
+
'duration',
|
|
905
|
+
'child-records',
|
|
906
|
+
'sub-objects',
|
|
907
|
+
'property-source',
|
|
908
|
+
'icon',
|
|
909
|
+
'language',
|
|
910
|
+
'timezone',
|
|
911
|
+
])
|
|
912
|
+
|
|
847
913
|
/** Largest file carried inline in the saved payload; bigger ones upload after save. */
|
|
848
914
|
const INLINE_FILE_CAP = 1024 * 1024
|
|
849
915
|
|
|
@@ -1033,6 +1099,7 @@ export default {
|
|
|
1033
1099
|
CnFileField,
|
|
1034
1100
|
CnDurationField,
|
|
1035
1101
|
CnPropertySourceField,
|
|
1102
|
+
CnSearchSelect,
|
|
1036
1103
|
CnReplaceValuesDialog,
|
|
1037
1104
|
CnSubObjectsField,
|
|
1038
1105
|
CnChildRecordsField,
|
|
@@ -2029,6 +2096,28 @@ export default {
|
|
|
2029
2096
|
},
|
|
2030
2097
|
|
|
2031
2098
|
methods: {
|
|
2099
|
+
/**
|
|
2100
|
+
* Resolve the registered component a field's `widget` names: the v2
|
|
2101
|
+
* registry (any entry with a `component`) first, then the legacy
|
|
2102
|
+
* customComponents map. Built-in widget names never resolve here.
|
|
2103
|
+
*
|
|
2104
|
+
* @param {object} field A resolved field descriptor.
|
|
2105
|
+
* @return {object|null} Vue component, or null.
|
|
2106
|
+
*/
|
|
2107
|
+
resolveFieldComponent(field) {
|
|
2108
|
+
const name = field && field.widget
|
|
2109
|
+
if (typeof name !== 'string' || name === '' || BUILT_IN_WIDGETS.has(name)) {
|
|
2110
|
+
return null
|
|
2111
|
+
}
|
|
2112
|
+
const entry = this.cnRegistry && this.cnRegistry[name]
|
|
2113
|
+
if (entry && entry.component) {
|
|
2114
|
+
return entry.component
|
|
2115
|
+
}
|
|
2116
|
+
const legacy = this.cnCustomComponents && this.cnCustomComponents[name]
|
|
2117
|
+
// A bare function in the legacy map is a handler, not a component.
|
|
2118
|
+
return legacy && typeof legacy === 'object' ? legacy : null
|
|
2119
|
+
},
|
|
2120
|
+
|
|
2032
2121
|
/**
|
|
2033
2122
|
* Resolve a field's reference integration widget, if any.
|
|
2034
2123
|
* Returns the integration's single-entity widget component
|
|
@@ -4337,6 +4426,44 @@ export default {
|
|
|
4337
4426
|
* @param {object} field The field definition
|
|
4338
4427
|
* @return {Array}
|
|
4339
4428
|
*/
|
|
4429
|
+
/**
|
|
4430
|
+
* The options of a `look: "search"` field as CnSearchSelect rows: each
|
|
4431
|
+
* option's own `secondary` / `icon` / `avatar`, else the field's
|
|
4432
|
+
* `optionSecondary` template (filled from the option), `optionIcon` and
|
|
4433
|
+
* `optionAvatar`.
|
|
4434
|
+
*
|
|
4435
|
+
* @spec openspec/changes/search-select-look/specs/search-select/spec.md#requirement-a-single-picker-can-take-the-search-look
|
|
4436
|
+
* @param {object} field The field definition.
|
|
4437
|
+
* @return {Array<object>}
|
|
4438
|
+
*/
|
|
4439
|
+
searchSelectOptions(field) {
|
|
4440
|
+
const template = typeof field.optionSecondary === 'string' ? field.optionSecondary : ''
|
|
4441
|
+
return this.getEffectiveOptions(field).map((option) => ({
|
|
4442
|
+
...option,
|
|
4443
|
+
secondary: option.secondary || (template ? fillTemplate(template, option).trim() : ''),
|
|
4444
|
+
icon: option.icon || field.optionIcon || '',
|
|
4445
|
+
avatar: option.avatar ?? (field.optionAvatar === true),
|
|
4446
|
+
}))
|
|
4447
|
+
},
|
|
4448
|
+
|
|
4449
|
+
/**
|
|
4450
|
+
* Pick the option a CnSearchSelect field emitted by its id, and hand
|
|
4451
|
+
* it to the select change handler.
|
|
4452
|
+
*
|
|
4453
|
+
* @spec openspec/changes/search-select-look/specs/search-select/spec.md#requirement-a-single-picker-can-take-the-search-look
|
|
4454
|
+
* @param {object} field The field definition.
|
|
4455
|
+
* @param {string|number|null} id The chosen option's id.
|
|
4456
|
+
*/
|
|
4457
|
+
onSearchSelectChange(field, id) {
|
|
4458
|
+
if (id === null || id === undefined) {
|
|
4459
|
+
this.onEffectiveSelectChange(field, null)
|
|
4460
|
+
return
|
|
4461
|
+
}
|
|
4462
|
+
const option = this.searchSelectOptions(field).find((o) => String(o.id ?? o.value) === String(id)) || { id, label: String(id) }
|
|
4463
|
+
const { secondary, icon, avatar, ...plain } = option
|
|
4464
|
+
this.onEffectiveSelectChange(field, plain)
|
|
4465
|
+
},
|
|
4466
|
+
|
|
4340
4467
|
getEffectiveOptions(field) {
|
|
4341
4468
|
if (this.isAsyncEnum(field)) {
|
|
4342
4469
|
// TODO: restore to `this.asyncState[field.key]?.options || []` once on Vue 3 (buble doesn't support optional chaining)
|
|
@@ -1094,6 +1094,16 @@ Sources: `register` (fetch the folder list from an OpenRegister `register`/`sche
|
|
|
1094
1094
|
}
|
|
1095
1095
|
```
|
|
1096
1096
|
|
|
1097
|
+
`pane: false` draws no pane. The folder still comes from the deep-link filter (`/cases?caseType=<uuid>`) and its scope look (title, count line, columns, quick filters, `afterList` from the folder or its row's `x-index`) still applies, so a list narrowed to one case type looks like that case type's list without a folder column (boards DqWooVerzoeken, DqKlachten):
|
|
1098
|
+
|
|
1099
|
+
```json
|
|
1100
|
+
"folderSidebar": {
|
|
1101
|
+
"source": "register", "register": "dossiq", "schema": "caseType",
|
|
1102
|
+
"idField": "@self.uuid", "nameField": "title",
|
|
1103
|
+
"filterField": "caseType", "pane": false
|
|
1104
|
+
}
|
|
1105
|
+
```
|
|
1106
|
+
|
|
1097
1107
|
### A column set per folder
|
|
1098
1108
|
|
|
1099
1109
|
A folder is also a scope. Give it `columns`, `defaultSort` or `searchFields`
|
|
@@ -660,11 +660,12 @@
|
|
|
660
660
|
</slot>
|
|
661
661
|
|
|
662
662
|
<!-- Body -->
|
|
663
|
-
<div class="cn-index-page__body" :class="{ 'cn-index-page__body--with-folders':
|
|
663
|
+
<div class="cn-index-page__body" :class="{ 'cn-index-page__body--with-folders': showsFolderPane }">
|
|
664
664
|
<!-- Optional folder navigation pane (opt-in via the `folderSidebar`
|
|
665
665
|
config). Selecting a folder filters the list by the config's
|
|
666
|
-
`filterField`; "All" clears it.
|
|
667
|
-
|
|
666
|
+
`filterField`; "All" clears it. `pane: false` keeps the scope
|
|
667
|
+
from the deep link and draws no pane. -->
|
|
668
|
+
<div v-if="showsFolderPane" class="cn-index-page__folder-pane">
|
|
668
669
|
<CnFolderSidebar
|
|
669
670
|
:source="folderSidebarSource"
|
|
670
671
|
:folders="folderSidebarFolders"
|
|
@@ -3200,6 +3201,9 @@ export default {
|
|
|
3200
3201
|
* defaults to `field`).
|
|
3201
3202
|
* - `folders` — explicit folder list for `source:'custom'`.
|
|
3202
3203
|
* - `allLabel` / `title` / `allowCreate` — passed to CnFolderSidebar.
|
|
3204
|
+
* - `pane` — `false` draws no pane: the scope still comes from the
|
|
3205
|
+
* deep-link filter (`?caseType=<id>`) and still applies its look, as
|
|
3206
|
+
* a list narrowed to one case type does without a folder column.
|
|
3203
3207
|
*
|
|
3204
3208
|
* A folder entry is also a SCOPE: it may carry `columns`, `defaultSort`
|
|
3205
3209
|
* and `searchFields`, and while it is selected the list is shown that
|
|
@@ -4833,6 +4837,17 @@ export default {
|
|
|
4833
4837
|
return { shown, total: Math.max(shown, Number(this.effectivePagination?.total || 0)) }
|
|
4834
4838
|
},
|
|
4835
4839
|
|
|
4840
|
+
/**
|
|
4841
|
+
* Whether the folder pane is drawn: a `folderSidebar` without
|
|
4842
|
+
* `pane: false`.
|
|
4843
|
+
*
|
|
4844
|
+
* @spec openspec/changes/folder-sidebar-pane-off/specs/index-page/spec.md#requirement-a-folder-scope-without-a-pane
|
|
4845
|
+
* @return {boolean}
|
|
4846
|
+
*/
|
|
4847
|
+
showsFolderPane() {
|
|
4848
|
+
return Boolean(this.folderSidebar) && this.folderSidebar.pane !== false
|
|
4849
|
+
},
|
|
4850
|
+
|
|
4836
4851
|
/**
|
|
4837
4852
|
* The CnFolderSidebar source. A `register` source is fetched by this
|
|
4838
4853
|
* component and handed to CnFolderSidebar as a `custom` folder list.
|