@conduction/nextcloud-vue 2.65.0 → 2.67.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 (103) hide show
  1. package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js +19 -0
  2. package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js.map +1 -1
  3. package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js +10 -3
  4. package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js.map +1 -1
  5. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +2 -2
  6. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
  7. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
  8. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +99 -2
  9. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  10. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +438 -383
  11. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  12. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js +9 -3
  13. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js.map +1 -1
  14. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js +1 -1
  15. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js.map +1 -1
  16. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +4 -4
  17. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  18. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  19. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +78 -21
  20. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  21. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +129 -96
  22. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  23. package/dist/esm/components/CnFkResolveCell/CnFkResolveCell.vue2.js +3 -1
  24. package/dist/esm/components/CnFkResolveCell/CnFkResolveCell.vue2.js.map +1 -1
  25. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +144 -4
  26. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  27. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  28. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js +63 -7
  29. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js.map +1 -1
  30. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js +2 -1
  31. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js.map +1 -1
  32. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +31 -1
  33. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  34. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +335 -313
  35. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  36. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue2.js +11 -0
  37. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue2.js.map +1 -1
  38. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue3.js +2 -1
  39. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue3.js.map +1 -1
  40. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js +5 -11
  41. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js.map +1 -1
  42. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js.map +1 -1
  43. package/dist/esm/composables/useDependencyCheck.js +44 -1
  44. package/dist/esm/composables/useDependencyCheck.js.map +1 -1
  45. package/dist/esm/composables/useListView.js +43 -1
  46. package/dist/esm/composables/useListView.js.map +1 -1
  47. package/dist/esm/composables/useSetupStatus.js +26 -3
  48. package/dist/esm/composables/useSetupStatus.js.map +1 -1
  49. package/dist/esm/l10n/en.json.js +7 -1
  50. package/dist/esm/l10n/en.json.js.map +1 -1
  51. package/dist/esm/l10n/nl.json.js +7 -1
  52. package/dist/esm/l10n/nl.json.js.map +1 -1
  53. package/dist/esm/mixins/pageViews.js +400 -0
  54. package/dist/esm/mixins/pageViews.js.map +1 -0
  55. package/dist/esm/nextcloud-vue.css +38 -2
  56. package/dist/esm/store/plugins/liveUpdates.js +19 -9
  57. package/dist/esm/store/plugins/liveUpdates.js.map +1 -1
  58. package/dist/esm/store/useObjectStore.js +20 -9
  59. package/dist/esm/store/useObjectStore.js.map +1 -1
  60. package/dist/esm/utils/actionsDispatcher.js +11 -2
  61. package/dist/esm/utils/actionsDispatcher.js.map +1 -1
  62. package/dist/esm/utils/columnFilters.js +27 -3
  63. package/dist/esm/utils/columnFilters.js.map +1 -1
  64. package/dist/esm/utils/schema.js +40 -2
  65. package/dist/esm/utils/schema.js.map +1 -1
  66. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  67. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  68. package/dist/nextcloud-vue.cjs.js +2377 -1247
  69. package/dist/nextcloud-vue.cjs.js.map +2 -2
  70. package/dist/nextcloud-vue.css +38 -2
  71. package/l10n/en.json +7 -1
  72. package/l10n/nl.json +7 -1
  73. package/package.json +1 -1
  74. package/src/components/CnActionsBar/CnActionsBar.md +2 -1
  75. package/src/components/CnActionsBar/CnActionsBar.vue +25 -1
  76. package/src/components/CnAppNav/CnAppNav.vue +2 -2
  77. package/src/components/CnDashboardPage/CnDashboardPage.vue +165 -8
  78. package/src/components/CnDataTable/CnColumnFilterPopover.vue +11 -5
  79. package/src/components/CnDataTable/CnDataTable.md +1 -1
  80. package/src/components/CnDataTable/CnDataTable.vue +4 -4
  81. package/src/components/CnDetailPage/CnDetailPage.vue +94 -5
  82. package/src/components/CnFkResolveCell/CnFkResolveCell.vue +3 -1
  83. package/src/components/CnFormDialog/CnFormDialog.vue +144 -4
  84. package/src/components/CnHeaderWidget/CnHeaderWidget.vue +67 -8
  85. package/src/components/CnIndexPage/CnIndexPage.md +2 -1
  86. package/src/components/CnIndexPage/CnIndexPage.vue +47 -1
  87. package/src/components/CnSegmentedControl/CnSegmentedControl.md +1 -1
  88. package/src/components/CnSegmentedControl/CnSegmentedControl.vue +12 -0
  89. package/src/components/CnSetupWizard/CnSetupWizard.vue +5 -11
  90. package/src/composables/useDependencyCheck.js +43 -0
  91. package/src/composables/useListView.js +43 -1
  92. package/src/composables/useSetupStatus.js +26 -3
  93. package/src/css/actions-bar.css +20 -2
  94. package/src/css/detail-page.css +5 -0
  95. package/src/mixins/pageViews.js +396 -0
  96. package/src/schemas/app-manifest-v2.schema.json +31 -3
  97. package/src/schemas/app-manifest.schema.json +1 -1
  98. package/src/store/plugins/liveUpdates.js +19 -9
  99. package/src/store/useObjectStore.js +19 -8
  100. package/src/utils/actionsDispatcher.js +11 -2
  101. package/src/utils/columnFilters.js +29 -2
  102. package/src/utils/schema.js +40 -2
  103. package/src/utils/validateManifestV2.compiled.js +2 -2
@@ -145,6 +145,18 @@
145
145
  </div>
146
146
  </slot>
147
147
  <div class="cn-detail-page__header-actions">
148
+ <!-- The page's view switch (`views`), unless a greeting header
149
+ widget on the page draws it. Its options control the view
150
+ region in the body. -->
151
+ <CnSegmentedControl
152
+ v-if="showsPageViewSwitch"
153
+ class="cn-page-view-switch"
154
+ :options="viewSwitchOptions"
155
+ :modelValue="activeViewId"
156
+ :ariaLabel="viewSwitchLabel"
157
+ :controls="viewRegionId"
158
+ data-testid="cn-detail-page-view-switch"
159
+ @update:modelValue="selectView" />
148
160
  <!-- Next and previous inside the list this record was opened
149
161
  from. Rendered only when the address names that list: a
150
162
  record reached by a bare link offers neither, rather than
@@ -609,15 +621,19 @@
609
621
  subscribed to the layout/widgets arrays — in-place pushes
610
622
  (Add widget) keep the same array identity and can't re-render
611
623
  it. One remount per edit flip re-subscribes it. -->
624
+ <!-- One grid per section: the body grid, then the chosen view
625
+ (`views`) in a region the view switch controls. A view grid is
626
+ keyed on its view, so switching mounts the other view's grid. -->
612
627
  <CnDashboardGrid
613
- v-if="hasBodyGrid"
614
- :key="`cn-detail-grid-${editingBody ? 'editing' : 'live'}`"
615
- :layout="bodyGridLayout"
628
+ v-for="section in bodyGridSections"
629
+ :key="`cn-detail-grid-${section.key}-${editingBody ? 'editing' : 'live'}`"
630
+ v-bind="section.attrs"
631
+ :layout="section.layout"
616
632
  :editable="editingBody"
617
633
  :columns="12"
618
634
  :columnOpts="columnOpts"
619
635
  class="cn-detail-page__grid"
620
- @layoutChange="onBodyLayoutChange">
636
+ @layoutChange="section.view ? onViewLayoutChange($event) : onBodyLayoutChange($event)">
621
637
  <template #widget="{ item }">
622
638
  <div
623
639
  class="cn-detail-page__grid-item"
@@ -727,6 +743,22 @@
727
743
  </template>
728
744
  </CnDashboardGrid>
729
745
 
746
+ <!-- The chosen view has nothing to draw: say so in the region,
747
+ never leave a blank area under the switch. -->
748
+ <div
749
+ v-if="viewIsEmpty"
750
+ :id="viewRegionId"
751
+ role="region"
752
+ :aria-label="activeView ? activeView.label : null"
753
+ class="cn-page-view-region cn-page-view-region--empty"
754
+ data-testid="cn-detail-page-view-empty">
755
+ <NcEmptyContent :description="viewEmptyText">
756
+ <template #icon>
757
+ <ViewDashboardOutline :size="48" />
758
+ </template>
759
+ </NcEmptyContent>
760
+ </div>
761
+
730
762
  <!-- Statistics table -->
731
763
  <div v-if="hasStats" class="cn-detail-page__stats">
732
764
  <!--
@@ -1036,6 +1068,7 @@ import InformationOutline from 'vue-material-design-icons/InformationOutline.vue
1036
1068
  import Pencil from 'vue-material-design-icons/Pencil.vue'
1037
1069
  import Plus from 'vue-material-design-icons/Plus.vue'
1038
1070
  import Refresh from 'vue-material-design-icons/Refresh.vue'
1071
+ import ViewDashboardOutline from 'vue-material-design-icons/ViewDashboardOutline.vue'
1039
1072
  import CnRelationLinkModal from '../../dialogs/CnRelationLinkModal.vue'
1040
1073
  import CnWidgetStyleEditorModal from '../../dialogs/CnWidgetStyleEditorModal.vue'
1041
1074
  import CnActionsMenu from '../CnActionsMenu/CnActionsMenu.vue'
@@ -1048,6 +1081,7 @@ import CnLifecycleActions from '../CnLifecycleActions/CnLifecycleActions.vue'
1048
1081
  import CnLockedBanner from '../CnLockedBanner/CnLockedBanner.vue'
1049
1082
  import CnNextStepCard from '../CnNextStepCard/CnNextStepCard.vue'
1050
1083
  import CnRelatedCollections from '../CnRelatedCollections/CnRelatedCollections.vue'
1084
+ import CnSegmentedControl from '../CnSegmentedControl/CnSegmentedControl.vue'
1051
1085
  import CnSummaryAggregates from '../CnSummaryAggregates/CnSummaryAggregates.vue'
1052
1086
  import CnTranslatedBadge from '../CnTranslatedBadge/CnTranslatedBadge.vue'
1053
1087
  import { useIntegrationRegistry } from '../../composables/useIntegrationRegistry.js'
@@ -1055,6 +1089,7 @@ import { useObjectLock } from '../../composables/useObjectLock.js'
1055
1089
  import { useObjectPresence } from '../../composables/useObjectPresence.js'
1056
1090
  import { useObjectSubscription } from '../../composables/useObjectSubscription.js'
1057
1091
  import { gridLayout } from '../../mixins/gridLayout.js'
1092
+ import { pageViews } from '../../mixins/pageViews.js'
1058
1093
  import { useObjectStore } from '../../store/index.js'
1059
1094
  import { isAppInstalled } from '../../utils/appInstalled.js'
1060
1095
  import { compactLayoutRows } from '../../utils/dashboardPlacement.js'
@@ -1227,6 +1262,8 @@ export default {
1227
1262
  CnDetailWidgetHost,
1228
1263
  CnFormDialog,
1229
1264
  CnDashboardGrid,
1265
+ CnSegmentedControl,
1266
+ ViewDashboardOutline,
1230
1267
  CnLifecycleActions,
1231
1268
  CnActionButtons,
1232
1269
  CnBreadcrumbs,
@@ -1242,7 +1279,7 @@ export default {
1242
1279
  Pencil,
1243
1280
  },
1244
1281
 
1245
- mixins: [gridLayout],
1282
+ mixins: [gridLayout, pageViews],
1246
1283
 
1247
1284
  inject: {
1248
1285
  /**
@@ -3462,6 +3499,7 @@ export default {
3462
3499
  && this.currentObject
3463
3500
  && !this.hasDefaultSlotContent
3464
3501
  && !this.hasGridLayout
3502
+ && !this.hasViews
3465
3503
  },
3466
3504
 
3467
3505
  /**
@@ -3541,6 +3579,55 @@ export default {
3541
3579
  return this.bodyGridLayout.length > 0
3542
3580
  },
3543
3581
 
3582
+ /**
3583
+ * The chosen view's layout as the grid draws it: items that draw
3584
+ * nothing leave and their rows close, as for the body grid.
3585
+ *
3586
+ * @return {Array<object>}
3587
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-view-with-nothing-to-draw-says-so
3588
+ */
3589
+ viewDisplayLayout() {
3590
+ // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- reading the flag IS the effect, as in bodyGridLayout
3591
+ this.editingBody
3592
+ return this.activeView ? this.closeDroppedRows(this.activeView.layout) : []
3593
+ },
3594
+
3595
+ /**
3596
+ * Whether the chosen view has nothing to draw.
3597
+ *
3598
+ * @return {boolean}
3599
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-view-with-nothing-to-draw-says-so
3600
+ */
3601
+ viewIsEmpty() {
3602
+ return this.hasViews && this.viewDisplayLayout.length === 0
3603
+ },
3604
+
3605
+ /**
3606
+ * The grids the body draws: the body grid when it has items, then the
3607
+ * chosen view's grid in the region the view switch controls (left out
3608
+ * when the view is empty; the empty state takes the region then).
3609
+ *
3610
+ * @return {Array<{key: string, view: boolean, layout: Array<object>, attrs: object}>}
3611
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-page-declares-views-that-each-hold-a-widget-grid
3612
+ */
3613
+ bodyGridSections() {
3614
+ const sections = this.hasBodyGrid ? [{ key: 'body', view: false, layout: this.bodyGridLayout, attrs: {} }] : []
3615
+ if (this.hasViews && !this.viewIsEmpty) {
3616
+ sections.push({
3617
+ key: `view-${this.activeViewId}`,
3618
+ view: true,
3619
+ layout: this.viewDisplayLayout,
3620
+ attrs: {
3621
+ id: this.viewRegionId,
3622
+ role: 'region',
3623
+ 'aria-label': this.activeView.label,
3624
+ 'data-testid': 'cn-detail-page-view-region',
3625
+ },
3626
+ })
3627
+ }
3628
+ return sections
3629
+ },
3630
+
3544
3631
  /**
3545
3632
  * Whether the sidebar is rendered externally (via objectSidebarState inject)
3546
3633
  * rather than inline. When external, CnDetailPage only manages state —
@@ -4762,6 +4849,8 @@ export default {
4762
4849
  */
4763
4850
  findWidget(item) {
4764
4851
  return this.bodyGridWidgets.find((w) => w.id === item.widgetId)
4852
+ || this.findViewWidget(item.widgetId)
4853
+ || undefined
4765
4854
  },
4766
4855
 
4767
4856
  /**
@@ -135,7 +135,9 @@ export default {
135
135
  if (!store) {
136
136
  return
137
137
  }
138
- const type = resolveObjectOpType(store, { register: this.register, schema: this.schema })
138
+ // `schema` is a slug the manifest author wrote, not a `$ref` title:
139
+ // use it exactly as given (a camelCase slug must not be kebab-cased).
140
+ const type = resolveObjectOpType(store, { register: this.register, schema: this.schema }, { exactSchema: true })
139
141
  await Promise.all(this.ids.map(async (id) => {
140
142
  if (this.labels[id]) {
141
143
  return
@@ -513,6 +513,7 @@ import { draftKey, formDraftMixin, readDraft } from '../../composables/useFormDr
513
513
  import { useIntegrationRegistry } from '../../composables/useIntegrationRegistry.js'
514
514
  import { TENANT_CONTEXT_KEY } from '../../composables/useTenantContext.js'
515
515
  import { useObjectStore } from '../../store/useObjectStore.js'
516
+ import { resolveCreateOverrideHandler } from '../../utils/actionsDispatcher.js'
516
517
  import {
517
518
  definitionQueryParams,
518
519
  DYNAMIC_KEY_PREFIX,
@@ -757,6 +758,18 @@ export default {
757
758
  * identity when used standalone (no CnAppRoot ancestor).
758
759
  */
759
760
  cnTranslate: { default: () => (key) => key },
761
+
762
+ /**
763
+ * The app manifest, the v2 registry, the legacy customComponents map and
764
+ * the registry-modal opener, all provided by CnAppRoot. A select-or-create
765
+ * picker reads them to create an object the way the app's own index page
766
+ * does (`config.createOverride` / `config.createModal`). Standalone (no
767
+ * CnAppRoot) they are absent and the generic nested form is used.
768
+ */
769
+ cnManifest: { default: null },
770
+ cnRegistry: { default: null },
771
+ cnCustomComponents: { default: null },
772
+ cnOpenModal: { default: null },
760
773
  },
761
774
 
762
775
  props: {
@@ -2649,6 +2662,16 @@ export default {
2649
2662
  store.registerObjectType(slug, schemaRef, register)
2650
2663
  }
2651
2664
  const labelField = this.referenceLabelField(field)
2665
+ // The app may own how this schema is created (pipelinq's client and
2666
+ // contact need a contact-first endpoint; a plain save 400s). A
2667
+ // create override keeps this form and saves through the app's
2668
+ // handler; a create modal (only when no override exists) opens the
2669
+ // app's own dialog instead of the generic form.
2670
+ const appCreate = this.appCreateFor(register, schemaRef)
2671
+ if (!appCreate.override && appCreate.modal) {
2672
+ return this.openAppCreateModal(appCreate.modal, store, slug, labelField, term)
2673
+ }
2674
+ const override = appCreate.override
2652
2675
  let schema = null
2653
2676
  try {
2654
2677
  schema = await store.fetchSchema(slug)
@@ -2656,14 +2679,113 @@ export default {
2656
2679
  schema = null
2657
2680
  }
2658
2681
  if (!schema || !schema.properties) {
2659
- return store.saveObject(slug, { [labelField]: term })
2682
+ const payload = { [labelField]: term }
2683
+ return override
2684
+ ? override(payload, { register, schema: schemaRef, objectType: slug, effectiveSchema: null })
2685
+ : store.saveObject(slug, payload)
2660
2686
  }
2661
2687
  const initialData = {}
2662
2688
  if (term && schema.properties[labelField]) {
2663
2689
  initialData[labelField] = term
2664
2690
  }
2665
2691
  return new Promise((resolve) => {
2666
- this.nestedCreate = { field, schema, register, slug, initialData, resolve }
2692
+ this.nestedCreate = { field, schema, register, slug, schemaRef, initialData, override, resolve }
2693
+ })
2694
+ },
2695
+
2696
+ /**
2697
+ * How the app creates objects of a schema, read off the manifest page
2698
+ * that lists them: the index page whose `config.schema` (and, when it
2699
+ * names one, `config.register`) matches. Its `config.createOverride` is
2700
+ * resolved to the registered handler; its `config.createModal` is the
2701
+ * registry key of a `kind: 'modal'` dialog.
2702
+ *
2703
+ * @spec openspec/changes/review-round-two/specs/schema-utilities/spec.md
2704
+ * @param {string} register The reference's register.
2705
+ * @param {string} schemaRef The reference's schema slug or id.
2706
+ * @return {{override: (((formData: object, ctx: object) => Promise<object>)|null), modal: (string|null)}} What the app declared.
2707
+ */
2708
+ appCreateFor(register, schemaRef) {
2709
+ const none = { override: null, modal: null }
2710
+ const pages = this.cnManifest && Array.isArray(this.cnManifest.pages) ? this.cnManifest.pages : []
2711
+ const wanted = String(schemaRef).toLowerCase()
2712
+ const page = pages.find((p) => {
2713
+ const config = p && p.config
2714
+ if (!config || config.schema === undefined || config.schema === null) {
2715
+ return false
2716
+ }
2717
+ if (String(config.schema).toLowerCase() !== wanted) {
2718
+ return false
2719
+ }
2720
+ return !config.register || String(config.register) === String(register)
2721
+ })
2722
+ if (!page) {
2723
+ return none
2724
+ }
2725
+ const config = page.config
2726
+ const override = typeof config.createOverride === 'function'
2727
+ ? config.createOverride
2728
+ : resolveCreateOverrideHandler(config.createOverride, this.cnRegistry, this.cnCustomComponents)
2729
+ const modalEntry = typeof config.createModal === 'string' && config.createModal !== ''
2730
+ ? (this.cnRegistry || {})[config.createModal]
2731
+ : null
2732
+ const modal = modalEntry && modalEntry.kind === 'modal' && typeof this.cnOpenModal === 'function'
2733
+ ? config.createModal
2734
+ : null
2735
+ return { override: override || null, modal }
2736
+ },
2737
+
2738
+ /**
2739
+ * Open the app's own create dialog (a registry modal) for a picker and
2740
+ * wait for it. The dialog reports the new object (or its id) with a
2741
+ * `created` event; `close` without one resolves null so the field stays
2742
+ * as it was. A reported id is fetched so the picker can label it.
2743
+ *
2744
+ * @spec openspec/changes/review-round-two/specs/schema-utilities/spec.md
2745
+ * @param {string} key The registry key of the modal.
2746
+ * @param {object} store The object store.
2747
+ * @param {string} slug The object type slug.
2748
+ * @param {string} labelField The property the typed term belongs in.
2749
+ * @param {string} term The typed search term.
2750
+ * @return {Promise<object|null>} The created object, or null.
2751
+ */
2752
+ openAppCreateModal(key, store, slug, labelField, term) {
2753
+ return new Promise((resolve) => {
2754
+ let settled = false
2755
+ let reported = false
2756
+ const settle = (value) => {
2757
+ if (!settled) {
2758
+ settled = true
2759
+ resolve(value)
2760
+ }
2761
+ }
2762
+ this.cnOpenModal(key, {
2763
+ initialData: term ? { [labelField]: term } : {},
2764
+ onCreated: async (created) => {
2765
+ if (created && typeof created === 'object') {
2766
+ settle(created)
2767
+ return
2768
+ }
2769
+ if (typeof created !== 'string' && typeof created !== 'number') {
2770
+ return
2771
+ }
2772
+ reported = true
2773
+ let obj
2774
+ try {
2775
+ obj = await store.fetchObject(slug, String(created))
2776
+ } catch {
2777
+ obj = null
2778
+ }
2779
+ settle(obj || { id: String(created), [labelField]: term })
2780
+ },
2781
+ onClose: () => {
2782
+ // A dialog often emits `close` right after `created`,
2783
+ // while the id is still being fetched: that fetch settles.
2784
+ if (!reported) {
2785
+ settle(null)
2786
+ }
2787
+ },
2788
+ })
2667
2789
  })
2668
2790
  },
2669
2791
 
@@ -2682,10 +2804,28 @@ export default {
2682
2804
  return
2683
2805
  }
2684
2806
  const store = this.getObjectStore()
2685
- const created = store ? await store.saveObject(pending.slug, formData) : null
2807
+ let created = null
2808
+ let thrown = null
2809
+ if (pending.override) {
2810
+ // The app's create override owns persistence (same contract as
2811
+ // CnIndexPage's `createOverride`).
2812
+ try {
2813
+ created = await pending.override(formData, {
2814
+ register: pending.register,
2815
+ schema: pending.schemaRef,
2816
+ objectType: pending.slug,
2817
+ effectiveSchema: pending.schema,
2818
+ })
2819
+ } catch (err) {
2820
+ thrown = (err && err.response && err.response.data && err.response.data.error) || (err && err.message) || null
2821
+ }
2822
+ } else {
2823
+ created = store ? await store.saveObject(pending.slug, formData) : null
2824
+ }
2686
2825
  if (!created) {
2687
2826
  const error = store && store.errors ? store.errors[pending.slug] : null
2688
- const message = (error && error.message)
2827
+ const message = thrown
2828
+ || (!pending.override && error && error.message)
2689
2829
  || t('nextcloud-vue', 'The item could not be created.')
2690
2830
  const dialog = this.$refs.nestedCreateDialog
2691
2831
  if (dialog && typeof dialog.setValidationErrors === 'function') {
@@ -35,12 +35,15 @@
35
35
  </a>
36
36
  </div>
37
37
  <!-- A view switch at the right of the heading (`content.views`): "My
38
- work / My team" as a segmented control whose options are routes. -->
38
+ work / My team" as a segmented control whose options are routes,
39
+ or views of the page the widget sits on (`option.view`); a view
40
+ option controls the page's view region. -->
39
41
  <div v-if="viewOptions.length > 0" class="cn-header-widget__views">
40
42
  <CnSegmentedControl
41
43
  :options="viewOptions"
42
44
  :modelValue="activeView"
43
45
  :ariaLabel="viewsLabel"
46
+ :controls="viewsRegionId"
44
47
  data-testid="cn-header-widget-views"
45
48
  @update:modelValue="onViewChange" />
46
49
  </div>
@@ -66,6 +69,9 @@ const ALLOWED_TEXT_ALIGN = ['left', 'center', 'right']
66
69
  const ALLOWED_VERTICAL_ALIGN = ['top', 'middle', 'bottom']
67
70
  const ALLOWED_CTA_STYLES = ['primary', 'secondary', 'ghost']
68
71
 
72
+ /** Marks a switch option that selects a page view rather than a route. */
73
+ const VIEW_OPTION_PREFIX = 'view:'
74
+
69
75
  const HEIGHT_PIXELS = Object.freeze({
70
76
  small: 120,
71
77
  medium: 200,
@@ -121,6 +127,12 @@ export default {
121
127
  * untranslated key renders as itself.
122
128
  */
123
129
  cnTranslate: { default: () => (key) => key },
130
+ /**
131
+ * The views of the page this widget sits on, provided by
132
+ * CnDashboardPage / CnDetailPage (`views`). An option with a `view`
133
+ * selects one of them. Null outside such a page.
134
+ */
135
+ cnPageViews: { default: null },
124
136
  },
125
137
 
126
138
  props: {
@@ -131,9 +143,12 @@ export default {
131
143
  * greeting, showDate, plain, ground, views}`. All fields are optional except
132
144
  * `title` (or `greeting`); unknown enum values collapse to documented
133
145
  * defaults and the renderer never throws. `views` is
134
- * `{ ariaLabel?, options: [{ label, route, params? }] }`: a segmented
135
- * control at the right of the heading whose checked option is the
136
- * current route; choosing another pushes its route. `ground: true`
146
+ * `{ ariaLabel?, options: [{ label, route, params? } | { label, view }] }`:
147
+ * a segmented control at the right of the heading whose checked option
148
+ * is the current route; choosing another pushes its route. An option
149
+ * with `view` selects that view of the page the widget sits on (the
150
+ * page's `views`) instead, and the page then draws no switch of its
151
+ * own. `ground: true`
137
152
  * draws the greeting on the page ground: the plain look with no
138
153
  * padding, a 32px heading and the date line 6px above it (a dashboard
139
154
  * also drops the widget's card for it).
@@ -229,12 +244,45 @@ export default {
229
244
  */
230
245
  viewOptions() {
231
246
  const raw = this.content && this.content.views && this.content.views.options
232
- if (!Array.isArray(raw) || !this.$router) {
247
+ if (!Array.isArray(raw)) {
233
248
  return []
234
249
  }
250
+ const pageViewIds = this.pageViewIds
235
251
  return raw
236
- .filter((o) => o && typeof o.label === 'string' && o.label !== '' && typeof o.route === 'string' && o.route !== '')
237
- .map((o) => ({ value: o.route, label: this.cnTranslate(o.label) }))
252
+ .filter((o) => o && typeof o.label === 'string' && o.label !== '')
253
+ .map((o) => {
254
+ if (typeof o.view === 'string' && o.view !== '') {
255
+ return pageViewIds.includes(o.view) ? { value: VIEW_OPTION_PREFIX + o.view, label: this.cnTranslate(o.label) } : null
256
+ }
257
+ if (typeof o.route === 'string' && o.route !== '' && this.$router) {
258
+ return { value: o.route, label: this.cnTranslate(o.label) }
259
+ }
260
+ return null
261
+ })
262
+ .filter(Boolean)
263
+ },
264
+
265
+ /**
266
+ * The ids of the page's views, empty outside a page with views.
267
+ *
268
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-greeting-header-can-switch-the-pages-views
269
+ * @return {string[]}
270
+ */
271
+ pageViewIds() {
272
+ const views = this.cnPageViews && Array.isArray(this.cnPageViews.views) ? this.cnPageViews.views : []
273
+ return views.map((view) => view.id)
274
+ },
275
+
276
+ /**
277
+ * The id of the page's view region when an option selects a view,
278
+ * for the options' `aria-controls`; '' otherwise.
279
+ *
280
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-the-view-switch-is-accessible
281
+ * @return {string}
282
+ */
283
+ viewsRegionId() {
284
+ const hasViewOption = this.viewOptions.some((o) => o.value.startsWith(VIEW_OPTION_PREFIX))
285
+ return hasViewOption && this.cnPageViews ? (this.cnPageViews.regionId || '') : ''
238
286
  },
239
287
 
240
288
  /**
@@ -249,6 +297,11 @@ export default {
249
297
  if (options.length === 0) {
250
298
  return null
251
299
  }
300
+ const activeView = this.cnPageViews ? this.cnPageViews.activeId : null
301
+ const viewMatch = activeView ? options.find((o) => o.value === VIEW_OPTION_PREFIX + activeView) : null
302
+ if (viewMatch) {
303
+ return viewMatch.value
304
+ }
252
305
  const current = this.$route && this.$route.name
253
306
  const match = options.find((o) => o.value === current)
254
307
  return match ? match.value : options[0].value
@@ -263,7 +316,7 @@ export default {
263
316
  */
264
317
  viewsLabel() {
265
318
  const label = this.content && this.content.views && this.content.views.ariaLabel
266
- return label ? this.cnTranslate(label) : t('nextcloud-vue', 'View')
319
+ return label ? this.cnTranslate(label) : t('nextcloud-vue', 'Views')
267
320
  },
268
321
 
269
322
  /**
@@ -697,6 +750,12 @@ export default {
697
750
  * @return {void}
698
751
  */
699
752
  onViewChange(route) {
753
+ if (typeof route === 'string' && route.startsWith(VIEW_OPTION_PREFIX)) {
754
+ if (this.cnPageViews && typeof this.cnPageViews.select === 'function') {
755
+ this.cnPageViews.select(route.slice(VIEW_OPTION_PREFIX.length))
756
+ }
757
+ return
758
+ }
700
759
  if (!this.$router || !route || route === (this.$route && this.$route.name)) {
701
760
  return
702
761
  }
@@ -386,7 +386,8 @@ export default {
386
386
  | `fieldOverrides` | Object | `{}` | Per-field config overrides passed to `CnFormDialog` |
387
387
  | `customComponents` | Object | `null` | Custom-component / handler registry. When set, takes precedence over the injected `cnCustomComponents` from CnAppRoot. Used to resolve `actions[].handler` registry names declared in the manifest (manifest-actions-dispatch). Named handlers resolve out of the v2 `registry` (a `kind: "handler"` entry) FIRST and fall back to this map. |
388
388
  | `showViewToggle` | Boolean | `true` | Whether to show the Cards/Table view toggle |
389
- | `inlineSearch` | Boolean | `false` | Show an inline search field in the actions bar (in addition to / instead of the sidebar search). Fed from the manifest as `pages[].config.inlineSearch`. |
389
+ | `inlineSearch` | Boolean | `false` | Show an inline search field in the actions bar (in addition to / instead of the sidebar search). Fed from the manifest as `pages[].config.inlineSearch`. The field takes the place of the "Showing X of Y" counter unless `showCountWithSearch` is set. |
390
+ | `showCountWithSearch` | Boolean | `false` | Keep the "Showing X of Y" counter visible beside the inline search field, after the search and any `#after-search` controls (only relevant with `inlineSearch`, which otherwise takes the counter's place). Fed from the manifest as `pages[].config.showCountWithSearch`. |
390
391
  | `searchPlaceholder` | String | `''` | Placeholder for the inline search field (manifest `config.searchPlaceholder`). |
391
392
  | `cardsLabel` | String | `''` | Label for the cards view-toggle option (manifest `config.cardsLabel`, e.g. "Tiles"). |
392
393
  | `tableLabel` | String | `''` | Label for the table view-toggle option (manifest `config.tableLabel`, e.g. "List"). |
@@ -59,6 +59,7 @@
59
59
  :showSearch="inlineSearch"
60
60
  :searchValue="effectiveSearchValue"
61
61
  :searchPlaceholder="searchPlaceholder"
62
+ :showCountWithSearch="showCountWithSearch"
62
63
  :refreshing="effectiveRefreshing"
63
64
  :refreshDisabled="refreshDisabled"
64
65
  :addDisabled="addDisabled"
@@ -417,6 +418,21 @@
417
418
  <NcLoadingIcon :size="32" :name="loadingText" />
418
419
  </div>
419
420
 
421
+ <!-- Error state: the latest self-fetch failed (e.g. a search term the server rejects) -->
422
+ <div v-else-if="selfFetchFailed"
423
+ class="cn-index-page__empty"
424
+ role="status"
425
+ data-testid="cn-index-page-fetch-error">
426
+ <NcEmptyContent :name="t('nextcloud-vue', 'An error occurred')"
427
+ :description="effectiveSearchValue
428
+ ? t('nextcloud-vue', 'Change the search or try again.')
429
+ : t('nextcloud-vue', 'Try again later.')">
430
+ <template #icon>
431
+ <AlertCircleOutline :size="64" />
432
+ </template>
433
+ </NcEmptyContent>
434
+ </div>
435
+
420
436
  <!-- Empty state -->
421
437
  <div v-else-if="effectiveObjects.length === 0" class="cn-index-page__empty">
422
438
  <slot name="empty">
@@ -774,6 +790,7 @@ import { getCurrentUser } from '@nextcloud/auth'
774
790
  import { translate as t } from '@nextcloud/l10n'
775
791
  import { NcActionButton, NcActionCaption, NcActionCheckbox, NcActions, NcButton, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
776
792
  import { getCurrentInstance, inject, markRaw, ref } from 'vue'
793
+ import AlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue'
777
794
  import Close from 'vue-material-design-icons/Close.vue'
778
795
  import Cog from 'vue-material-design-icons/Cog.vue'
779
796
  import DatabaseSearch from 'vue-material-design-icons/DatabaseSearch.vue'
@@ -1054,6 +1071,7 @@ export default {
1054
1071
  NcActionCaption,
1055
1072
  NcActionCheckbox,
1056
1073
  NcButton,
1074
+ AlertCircleOutline,
1057
1075
  Close,
1058
1076
  Cog,
1059
1077
  DatabaseSearch,
@@ -2034,6 +2052,17 @@ export default {
2034
2052
  default: false,
2035
2053
  },
2036
2054
 
2055
+ /**
2056
+ * Keep the "Showing X of Y" counter visible beside the inline search field,
2057
+ * after any `#after-search` controls (only relevant with `inlineSearch`,
2058
+ * which otherwise takes the counter's place). Fed from the manifest as
2059
+ * `pages[].config.showCountWithSearch`.
2060
+ */
2061
+ showCountWithSearch: {
2062
+ type: Boolean,
2063
+ default: false,
2064
+ },
2065
+
2037
2066
  /** Placeholder for the inline search field (manifest `config.searchPlaceholder`) */
2038
2067
  searchPlaceholder: {
2039
2068
  type: String,
@@ -2934,11 +2963,23 @@ export default {
2934
2963
  return this.isSelfFetch && !!this.list
2935
2964
  },
2936
2965
 
2966
+ /**
2967
+ * Whether the latest self-fetch failed. The store keeps the previous
2968
+ * rows and pagination on a failed fetch, so while this holds the page
2969
+ * shows its error state instead of results for an earlier query.
2970
+ */
2971
+ selfFetchFailed() {
2972
+ return this.isSelfFetchMode && !!this.list.error?.value
2973
+ },
2974
+
2937
2975
  /** Rows: store collection in self-fetch mode, else the `objects` prop. */
2938
2976
  effectiveObjects() {
2939
2977
  if (this.isNamedSource) {
2940
2978
  return this.namedRows
2941
2979
  }
2980
+ if (this.selfFetchFailed) {
2981
+ return []
2982
+ }
2942
2983
  return this.isSelfFetchMode ? (this.list.objects.value || []) : this.objects
2943
2984
  },
2944
2985
 
@@ -3342,7 +3383,9 @@ export default {
3342
3383
  s: this.exportSchemaSlug,
3343
3384
  f: this.filter || null,
3344
3385
  e: this.countEntries,
3345
- t: Number(this.effectivePagination?.total ?? 0),
3386
+ // The store's total, not the error override in effectivePagination,
3387
+ // so a failed fetch and its recovery do not refetch the counts.
3388
+ t: Number((this.isSelfFetchMode ? this.list.pagination.value : this.effectivePagination)?.total ?? 0),
3346
3389
  })
3347
3390
  },
3348
3391
 
@@ -3748,6 +3791,9 @@ export default {
3748
3791
 
3749
3792
  /** Pagination: store pagination in self-fetch mode, else the `pagination` prop. */
3750
3793
  effectivePagination() {
3794
+ if (this.selfFetchFailed) {
3795
+ return { ...this.list.pagination.value, total: 0, page: 1, pages: 1 }
3796
+ }
3751
3797
  return this.isSelfFetchMode ? this.list.pagination.value : this.pagination
3752
3798
  },
3753
3799
 
@@ -43,4 +43,4 @@ export default {
43
43
  </script>
44
44
  ```
45
45
 
46
- Props: `options` lists the choices, `modelValue` is the chosen value (use `v-model`), `aria-label` names the group (or point `aria-labelledby` at a visible heading), and `stretch` fills the width.
46
+ Props: `options` lists the choices, `modelValue` is the chosen value (use `v-model`), `aria-label` names the group (or point `aria-labelledby` at a visible heading), `controls` names the id of the element the options switch (set as `aria-controls` on every option), and `stretch` fills the width.
@@ -20,6 +20,7 @@
20
20
  :aria-checked="isChecked(option) ? 'true' : 'false'"
21
21
  :tabindex="index === focusableIndex ? 0 : -1"
22
22
  :disabled="option.disabled || null"
23
+ :aria-controls="controls || null"
23
24
  :data-value="option.key"
24
25
  @click="select(option)">
25
26
  <!-- @slot option Replaces the content of one option. -->
@@ -95,6 +96,17 @@ export default {
95
96
  default: '',
96
97
  },
97
98
 
99
+ /**
100
+ * Id of the element the options switch, such as the region holding a
101
+ * page view's widgets. Set as `aria-controls` on every option.
102
+ *
103
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-the-view-switch-is-accessible
104
+ */
105
+ controls: {
106
+ type: String,
107
+ default: '',
108
+ },
109
+
98
110
  /** Stretch the control to the full width, with options of equal width. */
99
111
  stretch: {
100
112
  type: Boolean,