@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.
Files changed (90) hide show
  1. package/dist/esm/components/CnConversationThread/CnConversationThread.vue.js +1 -1
  2. package/dist/esm/components/CnConversationThread/CnConversationThread.vue2.js +73 -0
  3. package/dist/esm/components/CnConversationThread/CnConversationThread.vue2.js.map +1 -1
  4. package/dist/esm/components/CnConversationThread/CnConversationThread.vue3.js +99 -64
  5. package/dist/esm/components/CnConversationThread/CnConversationThread.vue3.js.map +1 -1
  6. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue.js +1 -1
  7. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue2.js +46 -0
  8. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue2.js.map +1 -1
  9. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue3.js +37 -2
  10. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue3.js.map +1 -1
  11. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
  12. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +29 -6
  13. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  14. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  15. package/dist/esm/components/CnFormDialog/CnFormDialog.vue.js +1 -1
  16. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +113 -20
  17. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  18. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js +387 -359
  19. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  20. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +14 -0
  21. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  22. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +2 -2
  23. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  24. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue.js +1 -1
  25. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +10 -1
  26. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
  27. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
  28. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +1 -1
  29. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +97 -3
  30. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -1
  31. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js +46 -2
  32. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -1
  33. package/dist/esm/components/CnSearchSelect/CnSearchSelect.vue.js +9 -0
  34. package/dist/esm/components/CnSearchSelect/CnSearchSelect.vue.js.map +1 -0
  35. package/dist/esm/components/CnSearchSelect/CnSearchSelect.vue2.js +165 -0
  36. package/dist/esm/components/CnSearchSelect/CnSearchSelect.vue2.js.map +1 -0
  37. package/dist/esm/components/CnSearchSelect/CnSearchSelect.vue3.js +85 -0
  38. package/dist/esm/components/CnSearchSelect/CnSearchSelect.vue3.js.map +1 -0
  39. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue.js +1 -1
  40. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js +54 -6
  41. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js.map +1 -1
  42. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js +3 -1
  43. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js.map +1 -1
  44. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue.js +1 -1
  45. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +8 -1
  46. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  47. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  48. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue.js +1 -1
  49. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue2.js +19 -0
  50. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue2.js.map +1 -1
  51. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue3.js +1 -1
  52. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue3.js.map +1 -1
  53. package/dist/esm/index.js +1 -0
  54. package/dist/esm/index.js.map +1 -1
  55. package/dist/esm/library-style.js +1 -1
  56. package/dist/esm/mixins/pageViews.js +8 -1
  57. package/dist/esm/mixins/pageViews.js.map +1 -1
  58. package/dist/esm/nextcloud-vue.css +472 -383
  59. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  60. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  61. package/dist/esm/utils/visibleWhen.js +13 -2
  62. package/dist/esm/utils/visibleWhen.js.map +1 -1
  63. package/dist/nextcloud-vue.cjs.js +10707 -9883
  64. package/dist/nextcloud-vue.cjs.js.map +2 -2
  65. package/dist/nextcloud-vue.css +472 -383
  66. package/package.json +1 -1
  67. package/src/components/CnConversationThread/CnConversationThread.md +17 -0
  68. package/src/components/CnConversationThread/CnConversationThread.vue +113 -8
  69. package/src/components/CnConversationThread/CnConversationWidget.vue +60 -1
  70. package/src/components/CnDashboardPage/CnDashboardPage.md +4 -1
  71. package/src/components/CnDashboardPage/CnDashboardPage.vue +29 -6
  72. package/src/components/CnDetailPage/CnDetailPage.md +1 -1
  73. package/src/components/CnFormDialog/CnFormDialog.md +1 -1
  74. package/src/components/CnFormDialog/CnFormDialog.vue +127 -0
  75. package/src/components/CnIndexPage/CnIndexPage.md +10 -0
  76. package/src/components/CnIndexPage/CnIndexPage.vue +18 -3
  77. package/src/components/CnObjectListWidget/CnObjectListWidget.vue +10 -1
  78. package/src/components/CnRecordListWidget/CnRecordListWidget.vue +134 -2
  79. package/src/components/CnSearchSelect/CnSearchSelect.vue +283 -0
  80. package/src/components/CnSearchSelect/index.js +12 -0
  81. package/src/components/CnSetupWizard/CnSetupWizard.vue +56 -6
  82. package/src/components/CnTabsWidget/CnTabsWidget.vue +8 -1
  83. package/src/components/CnWizardDialog/CnWizardDialog.vue +20 -1
  84. package/src/components/index.js +1 -0
  85. package/src/css/dialog.css +5 -3
  86. package/src/index.js +1 -0
  87. package/src/mixins/pageViews.js +8 -1
  88. package/src/schemas/app-manifest-v2.schema.json +53 -6
  89. package/src/utils/validateManifestV2.compiled.js +3 -3
  90. 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.102.0",
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
- <component
13
- :is="titleTag"
14
- v-if="title"
15
- :id="titleId"
16
- class="cn-conversation-thread__title">
17
- {{ title }}
18
- </component>
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 rows"
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
- const buildiqEditing = Boolean(e && typeof e === 'object' && 'value' in e ? e.value : e)
2221
- return this.isEditing || buildiqEditing
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 = (this.userLayout && Array.isArray(this.userLayoutItems))
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
- if (this.userLayout && Array.isArray(this.userLayoutItems)) {
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': folderSidebar }">
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
- <div v-if="folderSidebar" class="cn-index-page__folder-pane">
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.