@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
@@ -0,0 +1,12 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * CnActionDialog — the generic "summary, checklist, reason, confirm" dialog
6
+ * for an act on a record, configured from the manifest.
7
+ */
8
+
9
+ import CnActionDialog from './CnActionDialog.vue'
10
+
11
+ export { CnActionDialog }
12
+ export default CnActionDialog
@@ -196,6 +196,15 @@
196
196
  </NcActionLink>
197
197
  </template>
198
198
  </NcAppNavigationCaption>
199
+ <!-- `type: "hint"`: a muted line, no link, as the empty "My
200
+ filters" group says how to add a filter. -->
201
+ <li
202
+ v-else-if="item.type === 'hint'"
203
+ :key="item.id"
204
+ class="cn-app-nav__hint"
205
+ :data-testid="`cn-nav-hint-${item.id}`">
206
+ {{ resolveLabel(item) }}
207
+ </li>
199
208
  <NcAppNavigationItem
200
209
  v-else
201
210
  :key="item.id"
@@ -2474,6 +2483,14 @@ export default {
2474
2483
  </script>
2475
2484
 
2476
2485
  <style>
2486
+ .cn-app-nav__hint {
2487
+ list-style: none;
2488
+ padding: 4px 12px 8px 16px;
2489
+ font-size: 13px;
2490
+ line-height: 1.4;
2491
+ color: var(--color-text-maxcontrast);
2492
+ }
2493
+
2477
2494
  /* The emblem beside the app name (`nav.brand.emblem`). A theme sets
2478
2495
  --nldesign-emblem-url for `emblem: true`, and --cn-nav-emblem-size for both. */
2479
2496
  .cn-app-nav__brand-emblem {
@@ -749,6 +749,7 @@ import { useObjectStore } from '../../store/index.js'
749
749
  import { isAppInstalled } from '../../utils/appInstalled.js'
750
750
  import { appShownName } from '../../utils/appShownName.js'
751
751
  import { BUILT_IN_FORMATTERS } from '../../utils/builtInFormatters.js'
752
+ import { resolveModalEntry } from '../../utils/builtInModals.js'
752
753
  import { addDiagnosticsListener, clearReportedDiagnostics, reportDiagnostic } from '../../utils/diagnostics.js'
753
754
  import { DEFAULT_FORGE, resolveForge } from '../../utils/forge.js'
754
755
  import { BUILT_IN_KB_PROVIDERS } from '../../utils/kbSearchProviders.js'
@@ -757,6 +758,7 @@ import { installModalStack, uninstallModalStack } from '../../utils/modalStack.j
757
758
  import { OBJECTS_CHANGED_EVENT } from '../../utils/objectSignals.js'
758
759
  import { resolveBrand } from '../../utils/resolveBrand.js'
759
760
  import { resolveFilterTokens } from '../../utils/resolveFilterTokens.js'
761
+ import { normalizeSavedViewMenu, SAVED_VIEW_MENU_KEY, syncSavedViewMenu, toggleSavedViewMenuEntry, withSavedViewMenu } from '../../utils/savedViewMenu.js'
760
762
  import { passesContextPredicates } from '../../utils/visibleIfContext.js'
761
763
 
762
764
  /**
@@ -892,6 +894,10 @@ export default {
892
894
  return self.userPreferences
893
895
  },
894
896
 
897
+ // The saved views this user added to the menu, and the way to
898
+ // change them, for an index page's sidebar.
899
+ cnSavedViewMenu: self.savedViewMenu,
900
+
895
901
  // The same manifest as a REF. The getter above is NOT enough on its
896
902
  // own: Options-API `inject` resolves a provided value once, at the
897
903
  // descendant's creation, so a renderer created before edit mode gets
@@ -968,7 +974,7 @@ export default {
968
974
  * @param {object} props Props forwarded to the modal component.
969
975
  */
970
976
  cnOpenModal: (key, props = {}) => {
971
- const entry = this.registry[key]
977
+ const entry = resolveModalEntry(this.registry, key)
972
978
  if (!entry || entry.kind !== 'modal') {
973
979
  // eslint-disable-next-line no-console
974
980
  console.warn(`[CnAppRoot] cnOpenModal: "${key}" is not a registered modal (kind must be "modal").`)
@@ -1910,6 +1916,16 @@ export default {
1910
1916
  data() {
1911
1917
  const willCheck = Array.isArray(this.requiresApps) && this.requiresApps.length > 0
1912
1918
  return {
1919
+ // Saved views this user added to the menu (per-user preference).
1920
+ // One object for the whole session, so the provide hands out a
1921
+ // live reference: `entries` changes, the holder does not.
1922
+ savedViewMenuLoaded: false,
1923
+ savedViewMenu: {
1924
+ entries: [],
1925
+ toggle: (entry) => this.toggleSavedViewMenu(entry),
1926
+ sync: (page, views) => this.syncSavedViewMenu(page, views),
1927
+ },
1928
+
1913
1929
  // Nextcloud's own header, present on a server-rendered page; the
1914
1930
  // brand block goes into it (CnBrandBar). Read once at creation so
1915
1931
  // the navigation never draws a block that then moves.
@@ -2377,6 +2393,16 @@ export default {
2377
2393
  return groups.some((g) => owners.includes(g))
2378
2394
  },
2379
2395
 
2396
+ /**
2397
+ * Whether any page of the manifest offers saved views.
2398
+ *
2399
+ * @return {boolean}
2400
+ */
2401
+ offersSavedViews() {
2402
+ const pages = this.manifest && Array.isArray(this.manifest.pages) ? this.manifest.pages : []
2403
+ return pages.some((page) => page && page.config && page.config.allowSavedViews === true)
2404
+ },
2405
+
2380
2406
  /**
2381
2407
  * The manifest the default `<CnAppNav>` renders — the editor's working
2382
2408
  * `source` while in-app editing, else the live `manifest` prop. Passed to
@@ -2408,6 +2434,12 @@ export default {
2408
2434
  if (this.manifestEditor && this.manifestEditor.editing.value && m && typeof m === 'object') {
2409
2435
  return { ...m }
2410
2436
  }
2437
+ // An app that offers saved views ends its menu with the "My views"
2438
+ // group: the user's pinned views, or one line saying how to add one.
2439
+ // Any other app gets its live manifest by identity.
2440
+ if (m && typeof m === 'object' && Array.isArray(m.menu) && (this.savedViewMenu.entries.length > 0 || this.offersSavedViews)) {
2441
+ return { ...m, menu: withSavedViewMenu(m.menu, this.savedViewMenu.entries, { showEmpty: this.offersSavedViews }) }
2442
+ }
2411
2443
  return m
2412
2444
  },
2413
2445
 
@@ -3245,12 +3277,24 @@ export default {
3245
3277
  if (!this.activeModalKey) {
3246
3278
  return null
3247
3279
  }
3248
- const entry = (this.registry || {})[this.activeModalKey]
3280
+ const entry = resolveModalEntry(this.registry, this.activeModalKey)
3249
3281
  return (entry && entry.component) ? entry.component : null
3250
3282
  },
3251
3283
  },
3252
3284
 
3253
3285
  watch: {
3286
+ // Only an app with a page that offers saved views reads the list,
3287
+ // once, when such a page first appears in the manifest.
3288
+ offersSavedViews: {
3289
+ immediate: true,
3290
+ handler(offers) {
3291
+ if (offers && !this.savedViewMenuLoaded) {
3292
+ this.savedViewMenuLoaded = true
3293
+ this.loadSavedViewMenu()
3294
+ }
3295
+ },
3296
+ },
3297
+
3254
3298
  /**
3255
3299
  * Close a registry modal when the route changes.
3256
3300
  *
@@ -3392,6 +3436,62 @@ export default {
3392
3436
  },
3393
3437
 
3394
3438
  methods: {
3439
+ /**
3440
+ * Read the saved views this user added to the menu.
3441
+ *
3442
+ * @return {Promise<void>}
3443
+ * @spec openspec/changes/saved-views-manage-and-defaults/tasks.md#task-7
3444
+ */
3445
+ async loadSavedViewMenu() {
3446
+ const read = this.userPreferences && this.userPreferences.read
3447
+ if (typeof read !== 'function') {
3448
+ return
3449
+ }
3450
+ try {
3451
+ this.savedViewMenu.entries = normalizeSavedViewMenu(await read(SAVED_VIEW_MENU_KEY, []))
3452
+ } catch {
3453
+ // No menu entries; the views themselves are unaffected.
3454
+ }
3455
+ },
3456
+
3457
+ /**
3458
+ * Add a saved view to the menu, or remove it, and store the list.
3459
+ *
3460
+ * @param {{id: string, page: string, name: string}} entry The view.
3461
+ * @return {Promise<void>}
3462
+ * @spec openspec/changes/saved-views-manage-and-defaults/tasks.md#task-7
3463
+ */
3464
+ async toggleSavedViewMenu(entry) {
3465
+ this.savedViewMenu.entries = toggleSavedViewMenuEntry(this.savedViewMenu.entries, entry)
3466
+ await this.writeSavedViewMenu()
3467
+ },
3468
+
3469
+ /**
3470
+ * Bring one page's menu entries in line with the views it lists, and
3471
+ * store the list only when that changed something.
3472
+ *
3473
+ * @param {string} page The page's route name.
3474
+ * @param {Array<object>} views The views the page lists.
3475
+ * @return {Promise<void>}
3476
+ * @spec openspec/changes/saved-views-manage-and-defaults/tasks.md#task-7
3477
+ */
3478
+ async syncSavedViewMenu(page, views) {
3479
+ const next = syncSavedViewMenu(this.savedViewMenu.entries, page, views)
3480
+ if (JSON.stringify(next) === JSON.stringify(this.savedViewMenu.entries)) {
3481
+ return
3482
+ }
3483
+ this.savedViewMenu.entries = next
3484
+ await this.writeSavedViewMenu()
3485
+ },
3486
+
3487
+ /** @return {Promise<void>} Store the list in the user's preferences. */
3488
+ async writeSavedViewMenu() {
3489
+ const write = this.userPreferences && this.userPreferences.write
3490
+ if (typeof write === 'function') {
3491
+ await write(SAVED_VIEW_MENU_KEY, this.savedViewMenu.entries)
3492
+ }
3493
+ },
3494
+
3395
3495
  /** Listen for the host's `diagnostics` function while it is set; adds this root's page id. */
3396
3496
  syncDiagnostics() {
3397
3497
  if (this.removeDiagnostics) {
@@ -351,7 +351,9 @@
351
351
  <NcSelect
352
352
  :modelValue="selectedPageFilterOption(pf)"
353
353
  :options="pageFilterSegments(pf)"
354
- :clearable="false"
354
+ :clearable="pf.clearable === true"
355
+ :placeholder="pf.placeholder ? effectiveTranslate(pf.placeholder) : undefined"
356
+ :loading="pageFilterLoading[pf.key] === true"
355
357
  :inputLabel="pf.label ? effectiveTranslate(pf.label) : pf.key"
356
358
  label="label"
357
359
  :data-testid="'cn-page-filter-' + pf.key"
@@ -1036,6 +1038,7 @@ import CnWidgetIcon from '../CnWidgetGrid/CnWidgetIcon.vue'
1036
1038
  import CnWidgetRefItem from '../CnWidgetRefItem/CnWidgetRefItem.vue'
1037
1039
  import CnWidgetRenderer from '../CnWidgetRenderer/CnWidgetRenderer.vue'
1038
1040
  import CnWidgetWrapper from '../CnWidgetWrapper/CnWidgetWrapper.vue'
1041
+ import { fetchEndpointSource } from '../../composables/useEndpointSource.js'
1039
1042
  import { useIntegrationRegistry } from '../../composables/useIntegrationRegistry.js'
1040
1043
  import { normalizeLook } from '../../composables/useLook.js'
1041
1044
  import { pageViews } from '../../mixins/pageViews.js'
@@ -1043,6 +1046,7 @@ import { mergeUserLayout, resolveUserLayoutApi } from '../../store/plugins/dashb
1043
1046
  import { isAppInstalled } from '../../utils/appInstalled.js'
1044
1047
  import { contentHeaderLink } from '../../utils/contentHeaderLink.js'
1045
1048
  import { pickDeclaredProps } from '../../utils/declaredProps.js'
1049
+ import { readPath } from '../../utils/readPath.js'
1046
1050
  import { userLocale } from '../../utils/userLocale.js'
1047
1051
  import { compareVisibleWhen, evaluateWorkspaceCondition, isWorkspaceCondition, readVisibleWhenValue } from '../../utils/visibleWhen.js'
1048
1052
  import { canonicalWidgetType } from '../../utils/widgetTypeAliases.js'
@@ -1177,6 +1181,32 @@ const CHART_PROP_KEYS = [
1177
1181
  * }]
1178
1182
  * ```
1179
1183
  */
1184
+
1185
+ /**
1186
+ * One row of a page filter's `optionsFrom` as a `{ value, label }` option:
1187
+ * `valueField` (default `id`, else the OpenRegister uuid) and `labelField`
1188
+ * (default `title`, else `name`, else the value). Null for a row without a
1189
+ * value.
1190
+ *
1191
+ * @param {unknown} row The row.
1192
+ * @param {{valueField?: string, labelField?: string}} from The `optionsFrom` block.
1193
+ * @return {{value: (string|number), label: string}|null}
1194
+ */
1195
+ function pageFilterOptionOf(row, from) {
1196
+ if (row === null || row === undefined) {
1197
+ return null
1198
+ }
1199
+ if (typeof row !== 'object') {
1200
+ return { value: row, label: String(row) }
1201
+ }
1202
+ const value = from.valueField ? readPath(row, from.valueField) : (row.id ?? row['@self']?.id ?? row.uuid)
1203
+ if (value === null || value === undefined || value === '') {
1204
+ return null
1205
+ }
1206
+ const label = from.labelField ? readPath(row, from.labelField) : (row.title || row.name)
1207
+ return { value, label: label === null || label === undefined || label === '' ? String(value) : String(label) }
1208
+ }
1209
+
1180
1210
  export default {
1181
1211
  name: 'CnDashboardPage',
1182
1212
 
@@ -1715,10 +1745,16 @@ export default {
1715
1745
  * group on the header's description line, after the period group
1716
1746
  * (DqProcesanalyse "Gemiddeld | Mediaan"). An endpoint widget can read
1717
1747
  * the value in its `responsePath` too (`phaseDays.@workspace.measure`).
1718
- * Empty (the default) renders no controls and leaves the context
1719
- * untouched.
1748
+ * `optionsFrom` reads the options instead of listing them: `{ url,
1749
+ * params?, responsePath?, valueField?, labelField? }` from an app
1750
+ * endpoint, or `{ register, schema, filter?, valueField?, labelField? }`
1751
+ * from OpenRegister objects (value `id`, label `title` or `name` by
1752
+ * default; tokens resolve). `clearable: true` lets the reader clear the
1753
+ * select back to "all" (`placeholder`, e.g. "All case types"), which
1754
+ * writes null and seeds nothing. Empty (the default) renders no
1755
+ * controls and leaves the context untouched.
1720
1756
  *
1721
- * @type {Array<{key: string, label?: string, type?: string, control?: ('select'|'segmented'), placement?: ('row'|'description'), options: Array<{value: (string|number), label: string}>, default?: (string|number)}>}
1757
+ * @type {Array<{key: string, label?: string, type?: string, control?: ('select'|'segmented'), placement?: ('row'|'description'), options?: Array<{value: (string|number), label: string}>, optionsFrom?: object, clearable?: boolean, placeholder?: string, default?: (string|number)}>}
1722
1758
  */
1723
1759
  pageFilters: {
1724
1760
  type: Array,
@@ -1981,6 +2017,10 @@ export default {
1981
2017
 
1982
2018
  data() {
1983
2019
  return {
2020
+ /** Options read through a page filter's `optionsFrom`, by filter key. */
2021
+ pageFilterFetched: {},
2022
+ /** Whether a page filter's `optionsFrom` read is in flight, by filter key. */
2023
+ pageFilterLoading: {},
1984
2024
  isEditing: false,
1985
2025
  /**
1986
2026
  * This user's arranged layout, when `userLayout` is on.
@@ -2840,6 +2880,7 @@ export default {
2840
2880
  this.pushAiContext()
2841
2881
  this.initDateRange()
2842
2882
  this.initPageFilters()
2883
+ this.loadPageFilterOptions()
2843
2884
  this.evaluateWidgetConditions()
2844
2885
  this.loadUserLayout()
2845
2886
  },
@@ -2869,7 +2910,7 @@ export default {
2869
2910
  if (this.workspaceContext[pf.key] !== undefined) {
2870
2911
  continue
2871
2912
  }
2872
- const fallback = (pf.options && pf.options.length) ? pf.options[0].value : undefined
2913
+ const fallback = (pf.options && pf.options.length && pf.clearable !== true) ? pf.options[0].value : undefined
2873
2914
  const value = pf.default !== undefined ? pf.default : fallback
2874
2915
  if (value !== undefined) {
2875
2916
  this.workspaceContext[pf.key] = value
@@ -2901,7 +2942,45 @@ export default {
2901
2942
  * @return {Array<{value: (string|number), label: string}>}
2902
2943
  */
2903
2944
  pageFilterSegments(pf) {
2904
- return (pf.options || []).map((o) => ({ value: o.value, label: o.label ? this.effectiveTranslate(o.label) : String(o.value) }))
2945
+ const options = Array.isArray(pf.options) ? pf.options : (this.pageFilterFetched[pf.key] || [])
2946
+ return options.map((o) => ({ value: o.value, label: o.label ? this.effectiveTranslate(o.label) : String(o.value) }))
2947
+ },
2948
+
2949
+ /**
2950
+ * Read the options of every page filter with `optionsFrom`, then seed
2951
+ * a filter that is not clearable with its first option when it has no
2952
+ * value yet.
2953
+ *
2954
+ * @spec openspec/changes/page-filter-options-from/specs/dashboard-page/spec.md#requirement-a-page-filter-can-read-its-options
2955
+ * @return {Promise<void>}
2956
+ */
2957
+ async loadPageFilterOptions() {
2958
+ const ctx = { workspace: this.workspaceContext || {} }
2959
+ await Promise.all((this.pageFilters || [])
2960
+ .filter((pf) => pf && pf.key && !Array.isArray(pf.options) && pf.optionsFrom && typeof pf.optionsFrom === 'object')
2961
+ .map(async (pf) => {
2962
+ const from = pf.optionsFrom
2963
+ const source = from.register && from.schema
2964
+ ? { url: `/apps/openregister/api/objects/${from.register}/${from.schema}`, params: { _limit: 200, ...(from.filter || {}) }, responsePath: 'results' }
2965
+ : { url: from.url, params: from.params, responsePath: from.responsePath }
2966
+ if (!source.url) {
2967
+ return
2968
+ }
2969
+ this.pageFilterLoading = { ...this.pageFilterLoading, [pf.key]: true }
2970
+ let rows
2971
+ try {
2972
+ const data = await fetchEndpointSource(source, ctx, { fresh: true })
2973
+ rows = Array.isArray(data) ? data : []
2974
+ } catch {
2975
+ rows = []
2976
+ }
2977
+ const options = rows.map((row) => pageFilterOptionOf(row, from)).filter(Boolean)
2978
+ this.pageFilterFetched = { ...this.pageFilterFetched, [pf.key]: options }
2979
+ this.pageFilterLoading = { ...this.pageFilterLoading, [pf.key]: false }
2980
+ if (pf.clearable !== true && pf.default === undefined && this.workspaceContext[pf.key] === undefined && options.length > 0) {
2981
+ this.workspaceContext[pf.key] = options[0].value
2982
+ }
2983
+ }))
2905
2984
  },
2906
2985
 
2907
2986
  /**
@@ -2916,7 +2995,7 @@ export default {
2916
2995
  if (!pf || !pf.key) {
2917
2996
  return
2918
2997
  }
2919
- const value = option && typeof option === 'object' ? option.value : option
2998
+ const value = option && typeof option === 'object' ? option.value : (option ?? null)
2920
2999
  this.workspaceContext[pf.key] = value
2921
3000
  /**
2922
3001
  * @event page-filter-change Emitted when a page-level filter selection changes.