@conduction/nextcloud-vue 2.106.0 → 2.107.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 (135) hide show
  1. package/dist/esm/components/CnActionDialog/CnActionDialog.vue.js +9 -0
  2. package/dist/esm/components/CnActionDialog/CnActionDialog.vue.js.map +1 -0
  3. package/dist/esm/components/CnActionDialog/CnActionDialog.vue2.js +338 -0
  4. package/dist/esm/components/CnActionDialog/CnActionDialog.vue2.js.map +1 -0
  5. package/dist/esm/components/CnActionDialog/CnActionDialog.vue3.js +249 -0
  6. package/dist/esm/components/CnActionDialog/CnActionDialog.vue3.js.map +1 -0
  7. package/dist/esm/components/CnAdvancedFormDialog/CnPropertiesTab.vue2.js +2 -2
  8. package/dist/esm/components/CnAdvancedFormDialog/CnPropertiesTab.vue2.js.map +1 -1
  9. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
  10. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js +145 -138
  11. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
  12. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +102 -2
  13. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
  14. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
  15. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
  16. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +83 -6
  17. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  18. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +4 -2
  19. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  20. package/dist/esm/components/CnIcon/semanticIcons.js +2 -2
  21. package/dist/esm/components/CnIcon/semanticIcons.js.map +1 -1
  22. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +389 -20
  23. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  24. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +68 -35
  25. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  26. package/dist/esm/components/CnIndexPage/useSelfFetchList.js +22 -2
  27. package/dist/esm/components/CnIndexPage/useSelfFetchList.js.map +1 -1
  28. package/dist/esm/components/CnIndexSidebar/CnIndexSidebar.vue2.js +69 -5
  29. package/dist/esm/components/CnIndexSidebar/CnIndexSidebar.vue2.js.map +1 -1
  30. package/dist/esm/components/CnIndexSidebar/CnIndexSidebar.vue3.js +38 -5
  31. package/dist/esm/components/CnIndexSidebar/CnIndexSidebar.vue3.js.map +1 -1
  32. package/dist/esm/components/CnMapWidget/CnMapWidget.vue.js +1 -1
  33. package/dist/esm/components/CnMapWidget/CnMapWidget.vue2.js +7 -1
  34. package/dist/esm/components/CnMapWidget/CnMapWidget.vue2.js.map +1 -1
  35. package/dist/esm/components/CnMapWidget/CnMapWidget.vue3.js.map +1 -1
  36. package/dist/esm/components/CnPartyPicker/CnPartyPicker.vue.js +8 -0
  37. package/dist/esm/components/CnPartyPicker/CnPartyPicker.vue.js.map +1 -0
  38. package/dist/esm/components/CnPartyPicker/CnPartyPicker.vue2.js +297 -0
  39. package/dist/esm/components/CnPartyPicker/CnPartyPicker.vue2.js.map +1 -0
  40. package/dist/esm/components/CnPartyPicker/CnPartyPicker.vue3.js +24 -0
  41. package/dist/esm/components/CnPartyPicker/CnPartyPicker.vue3.js.map +1 -0
  42. package/dist/esm/components/CnSavedViewEditDialog/CnSavedViewEditDialog.vue.js +9 -0
  43. package/dist/esm/components/CnSavedViewEditDialog/CnSavedViewEditDialog.vue.js.map +1 -0
  44. package/dist/esm/components/CnSavedViewEditDialog/CnSavedViewEditDialog.vue2.js +95 -0
  45. package/dist/esm/components/CnSavedViewEditDialog/CnSavedViewEditDialog.vue2.js.map +1 -0
  46. package/dist/esm/components/CnSavedViewEditDialog/CnSavedViewEditDialog.vue3.js +123 -0
  47. package/dist/esm/components/CnSavedViewEditDialog/CnSavedViewEditDialog.vue3.js.map +1 -0
  48. package/dist/esm/components/CnSavedViewShareDialog/CnSavedViewShareDialog.vue.js +1 -1
  49. package/dist/esm/components/CnSavedViewShareDialog/CnSavedViewShareDialog.vue2.js +13 -5
  50. package/dist/esm/components/CnSavedViewShareDialog/CnSavedViewShareDialog.vue2.js.map +1 -1
  51. package/dist/esm/components/CnSavedViewShareDialog/CnSavedViewShareDialog.vue3.js +3 -1
  52. package/dist/esm/components/CnSavedViewShareDialog/CnSavedViewShareDialog.vue3.js.map +1 -1
  53. package/dist/esm/components/CnSavedViewShareFields/CnSavedViewShareFields.vue.js +1 -1
  54. package/dist/esm/components/CnSavedViewShareFields/CnSavedViewShareFields.vue2.js +49 -23
  55. package/dist/esm/components/CnSavedViewShareFields/CnSavedViewShareFields.vue2.js.map +1 -1
  56. package/dist/esm/components/CnSavedViewShareFields/CnSavedViewShareFields.vue3.js +74 -27
  57. package/dist/esm/components/CnSavedViewShareFields/CnSavedViewShareFields.vue3.js.map +1 -1
  58. package/dist/esm/components/CnSavedViewsControl/CnSavedViewsControl.vue.js +0 -1
  59. package/dist/esm/components/CnSavedViewsControl/CnSavedViewsControl.vue.js.map +1 -1
  60. package/dist/esm/components/CnSavedViewsControl/CnSavedViewsControl.vue2.js +18 -413
  61. package/dist/esm/components/CnSavedViewsControl/CnSavedViewsControl.vue2.js.map +1 -1
  62. package/dist/esm/components/CnSavedViewsControl/CnSavedViewsControl.vue3.js +27 -214
  63. package/dist/esm/components/CnSavedViewsControl/CnSavedViewsControl.vue3.js.map +1 -1
  64. package/dist/esm/components/CnSavedViewsPanel/CnSavedViewsPanel.vue.js +9 -0
  65. package/dist/esm/components/CnSavedViewsPanel/CnSavedViewsPanel.vue.js.map +1 -0
  66. package/dist/esm/components/CnSavedViewsPanel/CnSavedViewsPanel.vue2.js +190 -0
  67. package/dist/esm/components/CnSavedViewsPanel/CnSavedViewsPanel.vue2.js.map +1 -0
  68. package/dist/esm/components/CnSavedViewsPanel/CnSavedViewsPanel.vue3.js +270 -0
  69. package/dist/esm/components/CnSavedViewsPanel/CnSavedViewsPanel.vue3.js.map +1 -0
  70. package/dist/esm/index.js +4 -0
  71. package/dist/esm/index.js.map +1 -1
  72. package/dist/esm/l10n/en.json.js +56 -1
  73. package/dist/esm/l10n/en.json.js.map +1 -1
  74. package/dist/esm/l10n/nl.json.js +56 -1
  75. package/dist/esm/l10n/nl.json.js.map +1 -1
  76. package/dist/esm/library-style.js +1 -1
  77. package/dist/esm/mixins/savedViewsList.js +235 -0
  78. package/dist/esm/mixins/savedViewsList.js.map +1 -0
  79. package/dist/esm/nextcloud-vue.css +348 -108
  80. package/dist/esm/utils/actionsDispatcher.js +2 -2
  81. package/dist/esm/utils/actionsDispatcher.js.map +1 -1
  82. package/dist/esm/utils/builtInModals.js +39 -0
  83. package/dist/esm/utils/builtInModals.js.map +1 -0
  84. package/dist/esm/utils/mapMarkerColor.js +121 -0
  85. package/dist/esm/utils/mapMarkerColor.js.map +1 -0
  86. package/dist/esm/utils/mergeManifestDelta.js +4 -0
  87. package/dist/esm/utils/mergeManifestDelta.js.map +1 -1
  88. package/dist/esm/utils/savedViewHelpers.js +59 -7
  89. package/dist/esm/utils/savedViewHelpers.js.map +1 -1
  90. package/dist/esm/utils/savedViewMenu.js +154 -0
  91. package/dist/esm/utils/savedViewMenu.js.map +1 -0
  92. package/dist/esm/utils/searchGroupSharees.js +50 -15
  93. package/dist/esm/utils/searchGroupSharees.js.map +1 -1
  94. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  95. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  96. package/dist/nextcloud-vue.cjs.js +13351 -10855
  97. package/dist/nextcloud-vue.cjs.js.map +2 -2
  98. package/dist/nextcloud-vue.css +348 -108
  99. package/l10n/en.json +56 -1
  100. package/l10n/nl.json +56 -1
  101. package/package.json +1 -1
  102. package/src/components/CnActionDialog/CnActionDialog.vue +545 -0
  103. package/src/components/CnActionDialog/index.js +12 -0
  104. package/src/components/CnAppNav/CnAppNav.vue +17 -0
  105. package/src/components/CnAppRoot/CnAppRoot.vue +102 -2
  106. package/src/components/CnDashboardPage/CnDashboardPage.vue +86 -7
  107. package/src/components/CnIndexPage/CnIndexPage.vue +409 -23
  108. package/src/components/CnIndexPage/useSelfFetchList.js +21 -1
  109. package/src/components/CnIndexSidebar/CnIndexSidebar.md +2 -0
  110. package/src/components/CnIndexSidebar/CnIndexSidebar.vue +91 -1
  111. package/src/components/CnMapWidget/CnMapWidget.vue +16 -1
  112. package/src/components/CnPartyPicker/CnPartyPicker.vue +317 -0
  113. package/src/components/CnPartyPicker/index.js +11 -0
  114. package/src/components/CnSavedViewEditDialog/CnSavedViewEditDialog.vue +192 -0
  115. package/src/components/CnSavedViewEditDialog/index.js +11 -0
  116. package/src/components/CnSavedViewShareDialog/CnSavedViewShareDialog.vue +21 -6
  117. package/src/components/CnSavedViewShareFields/CnSavedViewShareFields.vue +130 -37
  118. package/src/components/CnSavedViewsControl/CnSavedViewsControl.vue +36 -599
  119. package/src/components/CnSavedViewsPanel/CnSavedViewsPanel.vue +459 -0
  120. package/src/components/CnSavedViewsPanel/index.js +11 -0
  121. package/src/components/index.js +4 -0
  122. package/src/css/index-page.css +16 -0
  123. package/src/css/look-board-index.css +5 -3
  124. package/src/css/look-board.css +18 -3
  125. package/src/index.js +4 -0
  126. package/src/mixins/savedViewsList.js +231 -0
  127. package/src/schemas/app-manifest-v2.schema.json +98 -3
  128. package/src/utils/actionsDispatcher.js +2 -2
  129. package/src/utils/builtInModals.js +35 -0
  130. package/src/utils/mapMarkerColor.js +117 -0
  131. package/src/utils/mergeManifestDelta.js +4 -0
  132. package/src/utils/savedViewHelpers.js +58 -6
  133. package/src/utils/savedViewMenu.js +150 -0
  134. package/src/utils/searchGroupSharees.js +49 -14
  135. package/src/utils/validateManifestV2.compiled.js +3 -3
@@ -36,6 +36,24 @@ export function resolveInitialQuickFilterIndex(quickFilters) {
36
36
  */
37
37
  export const BOARD_PAGE_SIZE = 500
38
38
 
39
+ /** How many rows `mapConfig.plotAll: true` plots at most. */
40
+ export const MAP_PLOT_ALL_LIMIT = 500
41
+
42
+ /**
43
+ * How many rows a map that plots every matching row loads (`mapConfig.plotAll`:
44
+ * `true` for {@link MAP_PLOT_ALL_LIMIT}, or a number), 0 when it plots the page.
45
+ *
46
+ * @param {object} [mapConfig] The page's map config.
47
+ * @return {number} The limit.
48
+ */
49
+ export function mapPlotAllLimitOf(mapConfig) {
50
+ const plotAll = mapConfig && mapConfig.plotAll
51
+ if (plotAll === true) {
52
+ return MAP_PLOT_ALL_LIMIT
53
+ }
54
+ return typeof plotAll === 'number' && Number.isFinite(plotAll) && plotAll > 0 ? Math.floor(plotAll) : 0
55
+ }
56
+
39
57
  /**
40
58
  * The sort a quick filter sets while it is active (`sort: { key, order }`, or a
41
59
  * list of those for a multi-column order), as sort keys, or null when it sets
@@ -267,7 +285,9 @@ export function useSelfFetchList(props, instance, inject, extras = {}) {
267
285
  defaultSortKeys: initialSortKeys,
268
286
  defaultActiveFilters: initialActiveFilters,
269
287
  defaultSearchTerm: initialSearchTerm,
270
- defaultPageSize: props.viewMode === 'board' ? BOARD_PAGE_SIZE : ((props.pagination && props.pagination.limit) || undefined),
288
+ defaultPageSize: props.viewMode === 'board'
289
+ ? BOARD_PAGE_SIZE
290
+ : ((props.viewMode === 'map' && mapPlotAllLimitOf(props.mapConfig)) || (props.pagination && props.pagination.limit) || undefined),
271
291
  defaultVisibleColumns: configuredColumnKeys.length ? configuredColumnKeys : null,
272
292
  // `pages[].config.extend` → OpenRegister's repeated `_extend[]`. A
273
293
  // getter so a reactive change re-scopes the next fetch, matching how
@@ -214,6 +214,8 @@ With admin-only filter control:
214
214
  | `columnsDescription` | String | `'Select which columns to display in the table'` | Description below the column visibility heading |
215
215
  | `propertiesGroupLabel` | String | `''` | Override label for the schema properties group. Falls back to `schema.title` |
216
216
  | `defaultTab` | String | `'search-tab'` | Tab id that is active when the sidebar opens. Built-in ids: `'search-tab'`, `'columns-tab'` |
217
+ | `activeTab` | String | `''` | Switch to a tab while mounted, e.g. `'filters-tab'` |
218
+ | `savedViews` | Object | `null` | The page's saved views, shown in a third tab, Filters (CnSavedViewsPanel); every intent comes back as one `saved-view` event `{ action, view }` |
217
219
  | `userIsAdmin` | Boolean | `true` | When `false`, schema properties with `adminOnly: true` are hidden from filters |
218
220
  | `filterFields` | Object | `null` | Filter declarations for a page with no schema of its own. Wins over `schema`, and feeds the Search tab only |
219
221
 
@@ -287,6 +287,32 @@
287
287
  </div>
288
288
  </NcAppSidebarTab>
289
289
 
290
+ <!-- Filters Tab: the page's saved views (save, list, manage, add to menu). -->
291
+ <NcAppSidebarTab
292
+ v-if="savedViews"
293
+ id="filters-tab"
294
+ :name="t('nextcloud-vue', 'Filters')"
295
+ :order="3">
296
+ <template #icon>
297
+ <BookmarkOutline :size="20" />
298
+ </template>
299
+
300
+ <div class="cn-index-sidebar__tab-content">
301
+ <div class="cn-index-sidebar__section">
302
+ <CnSavedViewsPanel
303
+ v-bind="savedViewsPanelProps"
304
+ @apply="onSavedView('apply', $event)"
305
+ @saveRequest="onSavedView('save', null)"
306
+ @editRequest="onSavedView('edit', $event)"
307
+ @shareRequest="onSavedView('share', $event)"
308
+ @updateRequest="onSavedView('update', $event)"
309
+ @copyRequest="onSavedView('copy', $event)"
310
+ @menuToggle="onSavedView('menu', $event)"
311
+ @deleteRequest="onSavedView('delete', $event)" />
312
+ </div>
313
+ </div>
314
+ </NcAppSidebarTab>
315
+
290
316
  <!-- Extra tabs injected by the consumer -->
291
317
  <slot name="tabs" />
292
318
  </NcAppSidebar>
@@ -295,6 +321,7 @@
295
321
  <script>
296
322
  import { translate as t } from '@nextcloud/l10n'
297
323
  import { NcAppSidebar, NcAppSidebarTab, NcButton, NcCheckboxRadioSwitch, NcPopover, NcSelect, NcTextField } from '@nextcloud/vue'
324
+ import BookmarkOutline from 'vue-material-design-icons/BookmarkOutline.vue'
298
325
  import ChevronDown from 'vue-material-design-icons/ChevronDown.vue'
299
326
  import ChevronRight from 'vue-material-design-icons/ChevronRight.vue'
300
327
  import ChevronUp from 'vue-material-design-icons/ChevronUp.vue'
@@ -305,6 +332,7 @@ import InformationOutline from 'vue-material-design-icons/InformationOutline.vue
305
332
  import Pin from 'vue-material-design-icons/Pin.vue'
306
333
  import PinOutline from 'vue-material-design-icons/PinOutline.vue'
307
334
  import ViewColumnOutline from 'vue-material-design-icons/ViewColumnOutline.vue'
335
+ import CnSavedViewsPanel from '../CnSavedViewsPanel/CnSavedViewsPanel.vue'
308
336
  import { METADATA_COLUMNS } from '../../constants/metadata.js'
309
337
  import { facetOptions } from '../../utils/facets.js'
310
338
  import { labelLang } from '../../utils/manifestTranslate.js'
@@ -332,6 +360,8 @@ export default {
332
360
  components: {
333
361
  NcAppSidebar,
334
362
  NcAppSidebarTab,
363
+ CnSavedViewsPanel,
364
+ BookmarkOutline,
335
365
  NcTextField,
336
366
  NcSelect,
337
367
  CnDateRangePicker,
@@ -551,6 +581,26 @@ export default {
551
581
  default: 'search-tab',
552
582
  },
553
583
 
584
+ /**
585
+ * Switch to a tab while the sidebar is mounted, e.g. `'filters-tab'`
586
+ * after a view was saved from the toolbar. Empty leaves the tab alone.
587
+ */
588
+ activeTab: {
589
+ type: String,
590
+ default: '',
591
+ },
592
+
593
+ /**
594
+ * The page's saved views, shown in the Filters tab:
595
+ * `{ views, loading, currentUserId, counts, selectedViewId, maxDepth, menuIds }`
596
+ * as `CnSavedViewsPanel` takes them. `null` shows no saved views.
597
+ * Every intent comes back as one `saved-view` event.
598
+ */
599
+ savedViews: {
600
+ type: Object,
601
+ default: null,
602
+ },
603
+
554
604
  /**
555
605
  * Whether the current user is an admin.
556
606
  * When false, schema properties with `adminOnly: true` are hidden from filters.
@@ -561,7 +611,7 @@ export default {
561
611
  },
562
612
  },
563
613
 
564
- emits: ['clear-filters', 'columns-change', 'columns-reorder', 'columns-reset', 'pin-change', 'filter-change', 'search', 'sort-change', 'tab-change', 'update:open'],
614
+ emits: ['clear-filters', 'columns-change', 'columns-reorder', 'columns-reset', 'pin-change', 'filter-change', 'saved-view', 'search', 'sort-change', 'tab-change', 'update:open'],
565
615
 
566
616
  data() {
567
617
  return {
@@ -638,6 +688,25 @@ export default {
638
688
  return t('nextcloud-vue', 'Current order: {order}', { order })
639
689
  },
640
690
 
691
+ /**
692
+ * The panel's props, from `savedViews`.
693
+ *
694
+ * @return {object}
695
+ * @spec openspec/changes/saved-views-manage-and-defaults/tasks.md#task-6
696
+ */
697
+ savedViewsPanelProps() {
698
+ const f = this.savedViews || {}
699
+ return {
700
+ views: Array.isArray(f.views) ? f.views : [],
701
+ loading: f.loading === true,
702
+ currentUserId: f.currentUserId || '',
703
+ counts: f.counts || null,
704
+ selectedViewId: f.selectedViewId || '',
705
+ maxDepth: typeof f.maxDepth === 'number' ? f.maxDepth : 3,
706
+ menuIds: Array.isArray(f.menuIds) ? f.menuIds : null,
707
+ }
708
+ },
709
+
641
710
  /** Resolved icon — explicit prop overrides schema.icon */
642
711
  resolvedIcon() {
643
712
  return this.icon || this.schema?.icon || ''
@@ -760,6 +829,12 @@ export default {
760
829
  },
761
830
 
762
831
  watch: {
832
+ activeTab(tab) {
833
+ if (tab) {
834
+ this.internalActiveTab = tab
835
+ }
836
+ },
837
+
763
838
  open(val) {
764
839
  this.internalOpen = val
765
840
  },
@@ -840,6 +915,21 @@ export default {
840
915
  this.$emit('sort-change', direction === 'desc' ? reverseSortKeys(keys) : keys.map((k) => ({ key: k.key, order: k.order || 'asc' })))
841
916
  },
842
917
 
918
+ /**
919
+ * Hand a saved-view intent to the page.
920
+ *
921
+ * @param {'apply'|'save'|'edit'|'share'|'presentation'|'update'|'copy'|'menu'|'delete'} action The intent.
922
+ * @param {object|null} view The view, or null for save.
923
+ * @spec openspec/changes/saved-views-manage-and-defaults/tasks.md#task-6
924
+ */
925
+ onSavedView(action, view) {
926
+ /**
927
+ * @event saved-view A saved-view intent from the Filters tab. Payload: `{ action, view }`.
928
+ * @type {{action: string, view: (object|null)}}
929
+ */
930
+ this.$emit('saved-view', { action, view })
931
+ },
932
+
843
933
  /**
844
934
  * A manifest label through the injected lookup, or as written.
845
935
  *
@@ -871,10 +871,16 @@ export default {
871
871
  const number = feature.properties && Number(feature.properties.markerNumber)
872
872
  if (Number.isInteger(number) && number > 0) {
873
873
  const active = feature.properties.markerActive === true
874
+ // A colour of the row's own (`markerColor`) fills the pin, except
875
+ // the active one, which keeps the active colour.
876
+ const fill = feature.properties.markerColor
877
+ const style = !active && typeof fill === 'string' && /^[#a-zA-Z0-9(),.%\s_-]+$/.test(fill)
878
+ ? ` class="cn-map-widget__pin-fill" style="background:${fill}"`
879
+ : ''
874
880
  return L.marker(latlng, {
875
881
  icon: L.divIcon({
876
882
  className: active ? 'cn-map-widget__pin cn-map-widget__pin--active' : 'cn-map-widget__pin',
877
- html: `<span>${number}</span>`,
883
+ html: `<span${style}>${number}</span>`,
878
884
  iconSize: [26, 26],
879
885
  iconAnchor: [13, 13],
880
886
  }),
@@ -1512,6 +1518,15 @@ export default {
1512
1518
  box-sizing: border-box;
1513
1519
  }
1514
1520
 
1521
+ .cn-map-widget__pin-fill {
1522
+ display: flex;
1523
+ align-items: center;
1524
+ justify-content: center;
1525
+ width: 100%;
1526
+ height: 100%;
1527
+ border-radius: 50%;
1528
+ }
1529
+
1515
1530
  .cn-map-widget__pin--active {
1516
1531
  background: var(--color-primary-element-light-text);
1517
1532
  }
@@ -0,0 +1,317 @@
1
+ <!--
2
+ - SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ - SPDX-License-Identifier: EUPL-1.2
4
+ -->
5
+ <template>
6
+ <CnSearchSelect
7
+ class="cn-party-picker"
8
+ :modelValue="selectedOption"
9
+ :options="options"
10
+ :inputLabel="inputLabel"
11
+ :labelOutside="labelOutside"
12
+ :placeholder="placeholder ? cnTranslate(placeholder) : ''"
13
+ :loading="searching"
14
+ :filterable="false"
15
+ :clearable="clearable"
16
+ :disabled="disabled"
17
+ data-testid="cn-party-picker"
18
+ @search="onSearch"
19
+ @update:modelValue="onPick" />
20
+ </template>
21
+
22
+ <script>
23
+ import axios from '@nextcloud/axios'
24
+ import { generateUrl } from '@nextcloud/router'
25
+ import { fillTemplate } from '../../utils/fillTemplate.js'
26
+ import { CnSearchSelect } from '../CnSearchSelect/index.js'
27
+
28
+ /** How long typing rests before a search runs, in ms. */
29
+ const SEARCH_DELAY = 250
30
+
31
+ /**
32
+ * CnPartyPicker — one search field over several kinds of party: people and
33
+ * organisations from OpenRegister register sets (BRP persons, KvK companies,
34
+ * an app's own clients) and the reader's Nextcloud contacts, as rows with
35
+ * initials, the name and a second line, in one list (board DqNieuweZaak
36
+ * "Aanvrager": "Henk Visser · Inwoner · Beukenlaan 3", "Visser Bouw B.V. ·
37
+ * Organisatie · KvK …"). Built on CnSearchSelect.
38
+ *
39
+ * `sources`: each `{ id, register, schema, label?, title?, secondary?,
40
+ * limit?, icon? }` searches OpenRegister with `_search`; `title` and
41
+ * `secondary` are templates filled from the row (default `{name}` / none),
42
+ * `label` names the kind ("Resident") and starts the second line. A source
43
+ * `{ id, type: "contacts", label? }` searches the reader's Nextcloud contacts.
44
+ *
45
+ * `update:modelValue` emits the chosen row's id (a contact has none and
46
+ * emits its contact id); `select` emits the whole choice: `{ source, id,
47
+ * label, secondary, row }`, so a host can store more than the id (a case's
48
+ * initiator type, source id and display name). A `modelValue` the list does
49
+ * not hold is shown by its `selectedLabel`, else looked up by id in the
50
+ * OpenRegister sources.
51
+ *
52
+ * ```vue
53
+ * <CnPartyPicker
54
+ * v-model="case.requester"
55
+ * input-label="Requester"
56
+ * :sources="[
57
+ * { id: 'person', register: 'dossiq', schema: 'brpPerson', label: 'Resident', title: '{firstName} {lastName}', secondary: '{residence.street} {residence.houseNumber}' },
58
+ * { id: 'company', register: 'dossiq', schema: 'kvkCompany', label: 'Organisation', title: '{tradeName}', secondary: 'KvK {sourceId}' },
59
+ * { id: 'contact', type: 'contacts', label: 'Contact' },
60
+ * ]"
61
+ * @select="onRequester" />
62
+ * ```
63
+ */
64
+ export default {
65
+ name: 'CnPartyPicker',
66
+
67
+ components: { CnSearchSelect },
68
+
69
+ inject: {
70
+ /** The host app's translate (CnAppRoot `cnTranslate`). */
71
+ cnTranslate: { default: () => (key) => key },
72
+ },
73
+
74
+ props: {
75
+ /** The chosen party's id. */
76
+ modelValue: {
77
+ type: [String, Number],
78
+ default: null,
79
+ },
80
+
81
+ /**
82
+ * Where to search: OpenRegister sets `{ id, register, schema, label?,
83
+ * title?, secondary?, limit?, icon? }`, or `{ id, type: "contacts",
84
+ * label? }` for the reader's Nextcloud contacts.
85
+ *
86
+ * @type {Array<object>}
87
+ */
88
+ sources: {
89
+ type: Array,
90
+ default: () => [],
91
+ },
92
+
93
+ /** The field's label. */
94
+ inputLabel: {
95
+ type: String,
96
+ default: '',
97
+ },
98
+
99
+ /** Draw the label above the field. */
100
+ labelOutside: {
101
+ type: Boolean,
102
+ default: false,
103
+ },
104
+
105
+ /** Placeholder ("Name, BSN, trade name or KvK number"), through the host translate. */
106
+ placeholder: {
107
+ type: String,
108
+ default: '',
109
+ },
110
+
111
+ /** The chosen party's name, shown before the list holds it. */
112
+ selectedLabel: {
113
+ type: String,
114
+ default: '',
115
+ },
116
+
117
+ /** Offer a clear button. */
118
+ clearable: {
119
+ type: Boolean,
120
+ default: true,
121
+ },
122
+
123
+ /** Disable the field. */
124
+ disabled: {
125
+ type: Boolean,
126
+ default: false,
127
+ },
128
+
129
+ /** The fewest characters a search needs. */
130
+ minLength: {
131
+ type: Number,
132
+ default: 2,
133
+ },
134
+ },
135
+
136
+ emits: ['update:modelValue', 'select'],
137
+
138
+ data() {
139
+ return {
140
+ options: [],
141
+ searching: false,
142
+ known: null,
143
+ timer: null,
144
+ searchSeq: 0,
145
+ }
146
+ },
147
+
148
+ computed: {
149
+ /**
150
+ * The option for `modelValue`: in the list, else the known choice,
151
+ * else one named by `selectedLabel`.
152
+ *
153
+ * @spec openspec/changes/party-picker/specs/party-picker/spec.md#requirement-one-search-field-over-several-kinds-of-party
154
+ * @return {object|null}
155
+ */
156
+ selectedOption() {
157
+ const value = this.modelValue
158
+ if (value === null || value === undefined || value === '') {
159
+ return null
160
+ }
161
+ const found = this.options.find((o) => String(o.id) === String(value))
162
+ if (found) {
163
+ return found
164
+ }
165
+ if (this.known && String(this.known.id) === String(value)) {
166
+ return this.known
167
+ }
168
+ return { id: value, label: this.selectedLabel || String(value), avatar: true }
169
+ },
170
+ },
171
+
172
+ watch: {
173
+ modelValue: {
174
+ immediate: true,
175
+ handler(value) {
176
+ if (value && !this.selectedLabel && !this.options.some((o) => String(o.id) === String(value))) {
177
+ this.lookUp(value)
178
+ }
179
+ },
180
+ },
181
+ },
182
+
183
+ beforeUnmount() {
184
+ clearTimeout(this.timer)
185
+ },
186
+
187
+ methods: {
188
+ /**
189
+ * A source's row as a picker option.
190
+ *
191
+ * @param {object} source The source.
192
+ * @param {object} row The row.
193
+ * @return {object|null} The option, or null without an id.
194
+ */
195
+ optionOf(source, row) {
196
+ const id = row && (row.id ?? row['@self']?.id ?? row.uuid)
197
+ if (id === undefined || id === null || id === '') {
198
+ return null
199
+ }
200
+ const title = (source.title ? fillTemplate(source.title, row) : (row.name || row.title || row.displayName || '')).trim() || String(id)
201
+ const detail = source.secondary ? fillTemplate(source.secondary, row).trim() : ''
202
+ const kind = source.label ? this.cnTranslate(source.label) : ''
203
+ return {
204
+ id,
205
+ label: title,
206
+ secondary: [kind, detail].filter(Boolean).join(' · '),
207
+ avatar: !source.icon,
208
+ icon: source.icon || '',
209
+ source: source.id,
210
+ row,
211
+ }
212
+ },
213
+
214
+ /**
215
+ * A Nextcloud contact as a picker option.
216
+ *
217
+ * @param {object} source The contacts source.
218
+ * @param {object} contact The contactsmenu entry.
219
+ * @return {object|null}
220
+ */
221
+ contactOptionOf(source, contact) {
222
+ const id = contact && (contact.id || contact.uid)
223
+ if (!id) {
224
+ return null
225
+ }
226
+ const mail = Array.isArray(contact.emailAddresses) ? contact.emailAddresses[0] : ''
227
+ const kind = source.label ? this.cnTranslate(source.label) : ''
228
+ return { id, label: contact.fullName || String(id), secondary: [kind, mail].filter(Boolean).join(' · '), avatar: true, source: source.id, row: contact }
229
+ },
230
+
231
+ /**
232
+ * Search after typing rests.
233
+ *
234
+ * @param {string} query The typed text.
235
+ */
236
+ onSearch(query) {
237
+ clearTimeout(this.timer)
238
+ const term = String(query || '').trim()
239
+ if (term.length < this.minLength) {
240
+ this.options = this.selectedOption ? [this.selectedOption] : []
241
+ return
242
+ }
243
+ this.timer = setTimeout(() => this.search(term), SEARCH_DELAY)
244
+ },
245
+
246
+ /**
247
+ * Search every source at once; a source that fails adds nothing.
248
+ *
249
+ * @spec openspec/changes/party-picker/specs/party-picker/spec.md#requirement-one-search-field-over-several-kinds-of-party
250
+ * @param {string} term The search term.
251
+ * @return {Promise<void>}
252
+ */
253
+ async search(term) {
254
+ const seq = ++this.searchSeq
255
+ this.searching = true
256
+ const sets = await Promise.all((this.sources || []).map(async (source) => {
257
+ try {
258
+ if (source.type === 'contacts') {
259
+ const { data } = await axios.post(generateUrl('/contactsmenu/contacts'), { filter: term })
260
+ return (data && Array.isArray(data.contacts) ? data.contacts : []).map((c) => this.contactOptionOf(source, c))
261
+ }
262
+ if (!source.register || !source.schema) {
263
+ return []
264
+ }
265
+ const { data } = await axios.get(generateUrl(`/apps/openregister/api/objects/${source.register}/${source.schema}`), { params: { _search: term, _limit: source.limit || 8 } })
266
+ return (data && Array.isArray(data.results) ? data.results : []).map((row) => this.optionOf(source, row))
267
+ } catch {
268
+ return []
269
+ }
270
+ }))
271
+ if (seq !== this.searchSeq) {
272
+ return
273
+ }
274
+ this.options = sets.flat().filter(Boolean)
275
+ this.searching = false
276
+ },
277
+
278
+ /**
279
+ * Name a value the list does not hold: the first OpenRegister source
280
+ * that has an object with that id.
281
+ *
282
+ * @param {string|number} id The id.
283
+ * @return {Promise<void>}
284
+ */
285
+ async lookUp(id) {
286
+ for (const source of (this.sources || []).filter((s) => s.register && s.schema && s.type !== 'contacts')) {
287
+ try {
288
+ const { data } = await axios.get(generateUrl(`/apps/openregister/api/objects/${source.register}/${source.schema}/${encodeURIComponent(id)}`))
289
+ const option = data ? this.optionOf(source, data) : null
290
+ if (option && String(this.modelValue) === String(id)) {
291
+ this.known = option
292
+ return
293
+ }
294
+ } catch {
295
+ // Not in this source; try the next.
296
+ }
297
+ }
298
+ },
299
+
300
+ /**
301
+ * Take the picked option: emit its id and the whole choice.
302
+ *
303
+ * @param {string|number|null} id The picked id.
304
+ */
305
+ onPick(id) {
306
+ const option = id === null ? null : this.options.find((o) => String(o.id) === String(id)) || null
307
+ if (option) {
308
+ this.known = option
309
+ }
310
+ /** @event update:modelValue The chosen party's id, or null when cleared. */
311
+ this.$emit('update:modelValue', id)
312
+ /** @event select The whole choice: `{ source, id, label, secondary, row }`, or null when cleared. */
313
+ this.$emit('select', option ? { source: option.source, id: option.id, label: option.label, secondary: option.secondary, row: option.row } : null)
314
+ },
315
+ },
316
+ }
317
+ </script>
@@ -0,0 +1,11 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * CnPartyPicker — one search field over people, organisations and contacts.
6
+ */
7
+
8
+ import CnPartyPicker from './CnPartyPicker.vue'
9
+
10
+ export { CnPartyPicker }
11
+ export default CnPartyPicker