@conduction/nextcloud-vue 2.64.0 → 2.66.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 (84) hide show
  1. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +99 -3
  2. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
  3. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js +23 -7
  4. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
  5. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue2.js +13 -0
  6. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue2.js.map +1 -1
  7. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js +2 -1
  8. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js.map +1 -1
  9. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +125 -4
  10. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  11. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +439 -381
  12. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  13. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js +9 -3
  14. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js.map +1 -1
  15. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js +1 -1
  16. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js.map +1 -1
  17. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +142 -26
  18. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  19. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +195 -124
  20. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  21. package/dist/esm/components/CnFkResolveCell/CnFkResolveCell.vue2.js +3 -1
  22. package/dist/esm/components/CnFkResolveCell/CnFkResolveCell.vue2.js.map +1 -1
  23. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +144 -4
  24. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  25. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  26. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js +84 -11
  27. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js.map +1 -1
  28. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js +3 -2
  29. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js.map +1 -1
  30. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue2.js +11 -0
  31. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue2.js.map +1 -1
  32. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue3.js +2 -1
  33. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue3.js.map +1 -1
  34. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js.map +1 -1
  35. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js.map +1 -1
  36. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +22 -3
  37. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
  38. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js +10 -3
  39. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
  40. package/dist/esm/l10n/en.json.js +3 -0
  41. package/dist/esm/l10n/en.json.js.map +1 -1
  42. package/dist/esm/l10n/nl.json.js +3 -0
  43. package/dist/esm/l10n/nl.json.js.map +1 -1
  44. package/dist/esm/mixins/pageViews.js +400 -0
  45. package/dist/esm/mixins/pageViews.js.map +1 -0
  46. package/dist/esm/nextcloud-vue.css +182 -0
  47. package/dist/esm/store/useObjectStore.js +118 -33
  48. package/dist/esm/store/useObjectStore.js.map +1 -1
  49. package/dist/esm/utils/actionsDispatcher.js +25 -2
  50. package/dist/esm/utils/actionsDispatcher.js.map +1 -1
  51. package/dist/esm/utils/columnFilters.js +27 -3
  52. package/dist/esm/utils/columnFilters.js.map +1 -1
  53. package/dist/esm/utils/schema.js +40 -2
  54. package/dist/esm/utils/schema.js.map +1 -1
  55. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  56. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  57. package/dist/nextcloud-vue.cjs.js +2001 -684
  58. package/dist/nextcloud-vue.cjs.js.map +2 -2
  59. package/dist/nextcloud-vue.css +182 -0
  60. package/l10n/en.json +3 -0
  61. package/l10n/nl.json +3 -0
  62. package/package.json +1 -1
  63. package/src/components/CnAppNav/CnAppNav.vue +148 -7
  64. package/src/components/CnBreadcrumbs/CnBreadcrumbs.vue +14 -0
  65. package/src/components/CnDashboardPage/CnDashboardPage.vue +194 -10
  66. package/src/components/CnDataTable/CnColumnFilterPopover.vue +11 -5
  67. package/src/components/CnDetailPage/CnDetailPage.md +5 -1
  68. package/src/components/CnDetailPage/CnDetailPage.vue +208 -11
  69. package/src/components/CnFkResolveCell/CnFkResolveCell.vue +3 -1
  70. package/src/components/CnFormDialog/CnFormDialog.vue +144 -4
  71. package/src/components/CnHeaderWidget/CnHeaderWidget.vue +100 -13
  72. package/src/components/CnSegmentedControl/CnSegmentedControl.md +1 -1
  73. package/src/components/CnSegmentedControl/CnSegmentedControl.vue +12 -0
  74. package/src/components/CnStagesWidget/CnStagesWidget.vue +9 -0
  75. package/src/components/CnStatWidget/CnStatWidget.vue +33 -5
  76. package/src/css/detail-page.css +27 -0
  77. package/src/css/kpi-card.css +47 -0
  78. package/src/mixins/pageViews.js +396 -0
  79. package/src/schemas/app-manifest-v2.schema.json +51 -2
  80. package/src/store/useObjectStore.js +118 -33
  81. package/src/utils/actionsDispatcher.js +25 -2
  82. package/src/utils/columnFilters.js +29 -2
  83. package/src/utils/schema.js +40 -2
  84. package/src/utils/validateManifestV2.compiled.js +2 -2
@@ -47,9 +47,14 @@
47
47
  v-if="breadcrumbCrumbs.length > 0 && !objectNotFound"
48
48
  class="cn-detail-page__breadcrumbs"
49
49
  :crumbs="breadcrumbCrumbs"
50
+ :rootText="breadcrumbCrumbs[0].icon === undefined"
50
51
  data-testid="cn-detail-page-breadcrumbs" />
51
52
  <!-- Header -->
52
- <div v-if="!objectNotFound" class="cn-detail-page__header" data-testid="cn-detail-page-header">
53
+ <div
54
+ v-if="!objectNotFound"
55
+ class="cn-detail-page__header"
56
+ :class="{ 'cn-detail-page__header--card': headerCard, 'cn-detail-page__header--with-widget': headerWidgetDef !== null }"
57
+ data-testid="cn-detail-page-header">
53
58
  <!-- Header (left block) — overridable via #header slot. Default
54
59
  renders the icon + title + description. The right-hand
55
60
  #actions slot remains separate so headerComponent and
@@ -140,6 +145,18 @@
140
145
  </div>
141
146
  </slot>
142
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" />
143
160
  <!-- Next and previous inside the list this record was opened
144
161
  from. Rendered only when the address names that list: a
145
162
  record reached by a bare link offers neither, rather than
@@ -370,6 +387,50 @@
370
387
  </template>
371
388
  </CnActionsMenu>
372
389
  </div>
390
+ <!-- A widget inside the header (manifest `config.headerWidget`), on
391
+ a row of its own under the title and the actions: the stages
392
+ bars of the board's case card. Drawn without a card of its own,
393
+ and taken out of the body grid. Nothing renders without the
394
+ key. -->
395
+ <div
396
+ v-if="headerWidgetDef !== null"
397
+ class="cn-detail-page__header-widget"
398
+ data-testid="cn-detail-page-header-widget">
399
+ <!--
400
+ @slot `widget-${headerWidgetDef.id}`
401
+ @description The same per-widget slot the body grid and the side
402
+ column offer, for the widget placed in the header
403
+ (`headerWidget`). Same name and same bindings.
404
+ -->
405
+ <slot
406
+ :name="`widget-${headerWidgetDef.id}`"
407
+ :item="{ id: headerWidgetDef.id, widgetId: headerWidgetDef.id }"
408
+ :widget="headerWidgetDef"
409
+ :objectId="objectId"
410
+ :object="resolvedObject"
411
+ :objectData="resolvedObject"
412
+ :objectType="resolvedObjectType"
413
+ :register="register"
414
+ :schema="schema">
415
+ <CnDetailWidgetHost
416
+ :widget="headerWidgetDef"
417
+ chrome="bare"
418
+ :objectId="objectId"
419
+ :object="currentObject"
420
+ :objectType="resolvedObjectType"
421
+ :schemaObject="currentSchema"
422
+ :register="register"
423
+ :schema="schema"
424
+ :store="effectiveObjectStore"
425
+ :surface="surface"
426
+ :integrationContext="effectiveIntegrationContext"
427
+ :hideEmpty="hideEmpty"
428
+ :cnRegistry="cnRegistry"
429
+ :availableWidgets="widgets"
430
+ @geoSaved="onGeoSaved"
431
+ @openIntegration="onAutoBodyOpenIntegration" />
432
+ </slot>
433
+ </div>
373
434
  </div>
374
435
 
375
436
  <!-- The locked card, under the title, for ANY active lock — not only a
@@ -560,15 +621,19 @@
560
621
  subscribed to the layout/widgets arrays — in-place pushes
561
622
  (Add widget) keep the same array identity and can't re-render
562
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. -->
563
627
  <CnDashboardGrid
564
- v-if="hasBodyGrid"
565
- :key="`cn-detail-grid-${editingBody ? 'editing' : 'live'}`"
566
- :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"
567
632
  :editable="editingBody"
568
633
  :columns="12"
569
634
  :columnOpts="columnOpts"
570
635
  class="cn-detail-page__grid"
571
- @layoutChange="onBodyLayoutChange">
636
+ @layoutChange="section.view ? onViewLayoutChange($event) : onBodyLayoutChange($event)">
572
637
  <template #widget="{ item }">
573
638
  <div
574
639
  class="cn-detail-page__grid-item"
@@ -678,6 +743,22 @@
678
743
  </template>
679
744
  </CnDashboardGrid>
680
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
+
681
762
  <!-- Statistics table -->
682
763
  <div v-if="hasStats" class="cn-detail-page__stats">
683
764
  <!--
@@ -987,6 +1068,7 @@ import InformationOutline from 'vue-material-design-icons/InformationOutline.vue
987
1068
  import Pencil from 'vue-material-design-icons/Pencil.vue'
988
1069
  import Plus from 'vue-material-design-icons/Plus.vue'
989
1070
  import Refresh from 'vue-material-design-icons/Refresh.vue'
1071
+ import ViewDashboardOutline from 'vue-material-design-icons/ViewDashboardOutline.vue'
990
1072
  import CnRelationLinkModal from '../../dialogs/CnRelationLinkModal.vue'
991
1073
  import CnWidgetStyleEditorModal from '../../dialogs/CnWidgetStyleEditorModal.vue'
992
1074
  import CnActionsMenu from '../CnActionsMenu/CnActionsMenu.vue'
@@ -999,6 +1081,7 @@ import CnLifecycleActions from '../CnLifecycleActions/CnLifecycleActions.vue'
999
1081
  import CnLockedBanner from '../CnLockedBanner/CnLockedBanner.vue'
1000
1082
  import CnNextStepCard from '../CnNextStepCard/CnNextStepCard.vue'
1001
1083
  import CnRelatedCollections from '../CnRelatedCollections/CnRelatedCollections.vue'
1084
+ import CnSegmentedControl from '../CnSegmentedControl/CnSegmentedControl.vue'
1002
1085
  import CnSummaryAggregates from '../CnSummaryAggregates/CnSummaryAggregates.vue'
1003
1086
  import CnTranslatedBadge from '../CnTranslatedBadge/CnTranslatedBadge.vue'
1004
1087
  import { useIntegrationRegistry } from '../../composables/useIntegrationRegistry.js'
@@ -1006,6 +1089,7 @@ import { useObjectLock } from '../../composables/useObjectLock.js'
1006
1089
  import { useObjectPresence } from '../../composables/useObjectPresence.js'
1007
1090
  import { useObjectSubscription } from '../../composables/useObjectSubscription.js'
1008
1091
  import { gridLayout } from '../../mixins/gridLayout.js'
1092
+ import { pageViews } from '../../mixins/pageViews.js'
1009
1093
  import { useObjectStore } from '../../store/index.js'
1010
1094
  import { isAppInstalled } from '../../utils/appInstalled.js'
1011
1095
  import { compactLayoutRows } from '../../utils/dashboardPlacement.js'
@@ -1178,6 +1262,8 @@ export default {
1178
1262
  CnDetailWidgetHost,
1179
1263
  CnFormDialog,
1180
1264
  CnDashboardGrid,
1265
+ CnSegmentedControl,
1266
+ ViewDashboardOutline,
1181
1267
  CnLifecycleActions,
1182
1268
  CnActionButtons,
1183
1269
  CnBreadcrumbs,
@@ -1193,7 +1279,7 @@ export default {
1193
1279
  Pencil,
1194
1280
  },
1195
1281
 
1196
- mixins: [gridLayout],
1282
+ mixins: [gridLayout, pageViews],
1197
1283
 
1198
1284
  inject: {
1199
1285
  /**
@@ -2104,11 +2190,14 @@ export default {
2104
2190
  * A breadcrumb line above the header (manifest `config.breadcrumb`):
2105
2191
  * `{ label, route?, params?, href? }` names the list the record
2106
2192
  * belongs to; the record's display name follows as the current
2107
- * crumb. `label` goes through the host translate function. Null (the
2108
- * default) draws no trail.
2193
+ * crumb. `label` goes through the host translate function and shows
2194
+ * as text; `icon` (an MDI name, e.g. `Home`) draws that icon instead,
2195
+ * with the label as its accessible name. Null (the default) draws no
2196
+ * trail.
2109
2197
  *
2110
2198
  * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-detail-page-drops-its-eyebrow-and-shows-a-breadcrumb
2111
- * @type {{label: string, route?: string, params?: object, href?: string}|null}
2199
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-2/specs/zuiddrecht-pixel-gaps-2/spec.md#requirement-a-declared-breadcrumb-label-shows-as-text
2200
+ * @type {{label: string, route?: string, params?: object, href?: string, icon?: string}|null}
2112
2201
  */
2113
2202
  breadcrumb: {
2114
2203
  type: Object,
@@ -2130,6 +2219,33 @@ export default {
2130
2219
  default: () => [],
2131
2220
  },
2132
2221
 
2222
+ /**
2223
+ * Draw the header as a card (manifest `config.headerCard: true`): the
2224
+ * pills, the title and the actions on the page's surface colour inside
2225
+ * a bordered, rounded box, as the board's case header. Off by default,
2226
+ * which keeps the header on the page ground.
2227
+ *
2228
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-2/specs/zuiddrecht-pixel-gaps-2/spec.md#requirement-a-detail-header-can-be-a-card-that-holds-a-widget
2229
+ */
2230
+ headerCard: {
2231
+ type: Boolean,
2232
+ default: false,
2233
+ },
2234
+
2235
+ /**
2236
+ * The id of a widget declared in `widgets` to render inside the header,
2237
+ * on its own row under the title and the actions (manifest
2238
+ * `config.headerWidget`), e.g. the stages bars. It renders without a
2239
+ * card of its own and leaves the body grid. Empty (the default), or an
2240
+ * id that names no widget, renders nothing extra.
2241
+ *
2242
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-2/specs/zuiddrecht-pixel-gaps-2/spec.md#requirement-a-detail-header-can-be-a-card-that-holds-a-widget
2243
+ */
2244
+ headerWidget: {
2245
+ type: String,
2246
+ default: '',
2247
+ },
2248
+
2133
2249
  /**
2134
2250
  * Whether the current user administers this instance. Shows the
2135
2251
  * `adminOnly` header actions. `null` (the default) reads it from
@@ -2669,6 +2785,21 @@ export default {
2669
2785
  .filter(Boolean)
2670
2786
  },
2671
2787
 
2788
+ /**
2789
+ * The widget the header holds (`headerWidget`): the definition in
2790
+ * `widgets` with that id, or null without the key or a match.
2791
+ *
2792
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-2/specs/zuiddrecht-pixel-gaps-2/spec.md#requirement-a-detail-header-can-be-a-card-that-holds-a-widget
2793
+ * @return {object|null} The widget definition, or null.
2794
+ */
2795
+ headerWidgetDef() {
2796
+ if (!this.headerWidget) {
2797
+ return null
2798
+ }
2799
+ const declared = Array.isArray(this.widgets) ? this.widgets : []
2800
+ return declared.find((widget) => widget && widget.id === this.headerWidget) || null
2801
+ },
2802
+
2672
2803
  /**
2673
2804
  * The side column's widget definitions. A string names a widget
2674
2805
  * declared in `widgets`; an object is a definition of its own.
@@ -3241,10 +3372,12 @@ export default {
3241
3372
  /**
3242
3373
  * The breadcrumb trail: the declared crumb (a router target from
3243
3374
  * `route` + `params`, or an `href`), then the record's display name
3244
- * as the current crumb. Empty without a `breadcrumb` label.
3375
+ * as the current crumb. Empty without a `breadcrumb` label. The first
3376
+ * crumb carries `icon` only when the manifest declares one.
3245
3377
  *
3246
3378
  * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-detail-page-drops-its-eyebrow-and-shows-a-breadcrumb
3247
- * @return {Array<{label: string, to?: object, href?: string}>}
3379
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-2/specs/zuiddrecht-pixel-gaps-2/spec.md#requirement-a-declared-breadcrumb-label-shows-as-text
3380
+ * @return {Array<{label: string, to?: object, href?: string, icon?: string}>}
3248
3381
  */
3249
3382
  breadcrumbCrumbs() {
3250
3383
  const crumb = this.breadcrumb
@@ -3252,6 +3385,9 @@ export default {
3252
3385
  return []
3253
3386
  }
3254
3387
  const first = { label: this.effectiveTranslate(crumb.label) }
3388
+ if (typeof crumb.icon === 'string' && crumb.icon !== '') {
3389
+ first.icon = crumb.icon
3390
+ }
3255
3391
  if (crumb.route) {
3256
3392
  first.to = { name: crumb.route, ...(crumb.params ? { params: crumb.params } : {}) }
3257
3393
  } else if (crumb.href) {
@@ -3363,6 +3499,7 @@ export default {
3363
3499
  && this.currentObject
3364
3500
  && !this.hasDefaultSlotContent
3365
3501
  && !this.hasGridLayout
3502
+ && !this.hasViews
3366
3503
  },
3367
3504
 
3368
3505
  /**
@@ -3442,6 +3579,55 @@ export default {
3442
3579
  return this.bodyGridLayout.length > 0
3443
3580
  },
3444
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
+
3445
3631
  /**
3446
3632
  * Whether the sidebar is rendered externally (via objectSidebarState inject)
3447
3633
  * rather than inline. When external, CnDetailPage only manages state —
@@ -4663,6 +4849,8 @@ export default {
4663
4849
  */
4664
4850
  findWidget(item) {
4665
4851
  return this.bodyGridWidgets.find((w) => w.id === item.widgetId)
4852
+ || this.findViewWidget(item.widgetId)
4853
+ || undefined
4666
4854
  },
4667
4855
 
4668
4856
  /**
@@ -4711,6 +4899,10 @@ export default {
4711
4899
  * it — CnPageRenderer's split pane forwards no slots, which is exactly
4712
4900
  * that fault and wants fixing, not concealing.
4713
4901
  *
4902
+ * The widget the header holds (`headerWidget`) draws nothing here
4903
+ * either: it is already drawn in the header.
4904
+ *
4905
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-2/specs/zuiddrecht-pixel-gaps-2/spec.md#requirement-a-detail-header-can-be-a-card-that-holds-a-widget
4714
4906
  * @param {object} item A layout item.
4715
4907
  * @return {boolean} False only when the item provably draws nothing.
4716
4908
  */
@@ -4721,6 +4913,11 @@ export default {
4721
4913
  if (this.editingBody) {
4722
4914
  return true
4723
4915
  }
4916
+ // The widget the header holds (`headerWidget`) is drawn there, so
4917
+ // its grid cell would be the same widget twice.
4918
+ if (this.headerWidgetDef !== null && item.widgetId === this.headerWidgetDef.id) {
4919
+ return false
4920
+ }
4724
4921
  const widget = this.findWidget(item)
4725
4922
  if (!widget) {
4726
4923
  return true
@@ -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') {