@conduction/nextcloud-vue 2.56.0-beta.11 → 2.56.0-beta.13

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 (138) hide show
  1. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +253 -10
  2. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
  3. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js +139 -46
  4. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
  5. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +87 -0
  6. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
  7. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js +26 -24
  8. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
  9. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue2.js +61 -16
  10. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue2.js.map +1 -1
  11. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue3.js.map +1 -1
  12. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue2.js +20 -0
  13. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue2.js.map +1 -1
  14. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue3.js +3 -2
  15. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue3.js.map +1 -1
  16. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue2.js +13 -0
  17. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue2.js.map +1 -1
  18. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js +2 -1
  19. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js.map +1 -1
  20. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js +33 -2
  21. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js.map +1 -1
  22. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js.map +1 -1
  23. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +154 -4
  24. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  25. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +555 -480
  26. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  27. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js +9 -3
  28. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js.map +1 -1
  29. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js +1 -1
  30. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js.map +1 -1
  31. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +37 -1
  32. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  33. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +12 -4
  34. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  35. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +187 -19
  36. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  37. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +215 -135
  38. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  39. package/dist/esm/components/CnFkResolveCell/CnFkResolveCell.vue2.js +3 -1
  40. package/dist/esm/components/CnFkResolveCell/CnFkResolveCell.vue2.js.map +1 -1
  41. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +144 -4
  42. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  43. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  44. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js +157 -7
  45. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js.map +1 -1
  46. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js +23 -5
  47. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js.map +1 -1
  48. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +30 -0
  49. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  50. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +2 -2
  51. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  52. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +5 -1
  53. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
  54. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js +4 -2
  55. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
  56. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue2.js +11 -0
  57. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue2.js.map +1 -1
  58. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue3.js +2 -1
  59. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue3.js.map +1 -1
  60. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js +63 -2
  61. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js.map +1 -1
  62. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js +96 -37
  63. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js.map +1 -1
  64. package/dist/esm/components/CnStagesWidget/stagesModel.js +5 -2
  65. package/dist/esm/components/CnStagesWidget/stagesModel.js.map +1 -1
  66. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +74 -5
  67. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
  68. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js +15 -8
  69. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
  70. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +14 -2
  71. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  72. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js +2 -1
  73. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  74. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js +73 -1
  75. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js.map +1 -1
  76. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js +14 -2
  77. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js.map +1 -1
  78. package/dist/esm/l10n/en.json.js +5 -1
  79. package/dist/esm/l10n/en.json.js.map +1 -1
  80. package/dist/esm/l10n/nl.json.js +5 -1
  81. package/dist/esm/l10n/nl.json.js.map +1 -1
  82. package/dist/esm/mixins/pageViews.js +400 -0
  83. package/dist/esm/mixins/pageViews.js.map +1 -0
  84. package/dist/esm/nextcloud-vue.css +513 -81
  85. package/dist/esm/store/useObjectStore.js +118 -33
  86. package/dist/esm/store/useObjectStore.js.map +1 -1
  87. package/dist/esm/utils/actionsDispatcher.js +25 -2
  88. package/dist/esm/utils/actionsDispatcher.js.map +1 -1
  89. package/dist/esm/utils/columnFilters.js +27 -3
  90. package/dist/esm/utils/columnFilters.js.map +1 -1
  91. package/dist/esm/utils/schema.js +40 -2
  92. package/dist/esm/utils/schema.js.map +1 -1
  93. package/dist/esm/utils/schemaRefSlug.js +19 -3
  94. package/dist/esm/utils/schemaRefSlug.js.map +1 -1
  95. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  96. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  97. package/dist/nextcloud-vue.cjs.js +8858 -6566
  98. package/dist/nextcloud-vue.cjs.js.map +2 -2
  99. package/dist/nextcloud-vue.css +513 -81
  100. package/l10n/en.json +5 -0
  101. package/l10n/nl.json +5 -0
  102. package/package.json +1 -1
  103. package/src/components/CnAppNav/CnAppNav.vue +441 -14
  104. package/src/components/CnAppRoot/CnAppRoot.md +4 -0
  105. package/src/components/CnAppRoot/CnAppRoot.vue +94 -1
  106. package/src/components/CnAuditTrailCard/CnAuditTrailCard.vue +61 -16
  107. package/src/components/CnAuditTrailWidget/CnAuditTrailWidget.vue +22 -1
  108. package/src/components/CnBreadcrumbs/CnBreadcrumbs.vue +14 -0
  109. package/src/components/CnCellRenderer/CnCellRenderer.vue +33 -2
  110. package/src/components/CnDashboardPage/CnDashboardPage.vue +237 -12
  111. package/src/components/CnDataTable/CnColumnFilterPopover.vue +11 -5
  112. package/src/components/CnDataTable/CnDataTable.vue +45 -1
  113. package/src/components/CnDetailPage/CnDetailPage.md +8 -0
  114. package/src/components/CnDetailPage/CnDetailPage.vue +263 -7
  115. package/src/components/CnFkResolveCell/CnFkResolveCell.vue +3 -1
  116. package/src/components/CnFormDialog/CnFormDialog.vue +144 -4
  117. package/src/components/CnHeaderWidget/CnHeaderWidget.vue +206 -9
  118. package/src/components/CnIndexPage/CnIndexPage.vue +32 -2
  119. package/src/components/CnObjectListWidget/CnObjectListWidget.vue +7 -1
  120. package/src/components/CnSegmentedControl/CnSegmentedControl.md +1 -1
  121. package/src/components/CnSegmentedControl/CnSegmentedControl.vue +12 -0
  122. package/src/components/CnStagesWidget/CnStagesWidget.vue +189 -2
  123. package/src/components/CnStagesWidget/stagesModel.js +5 -2
  124. package/src/components/CnStatWidget/CnStatWidget.vue +89 -7
  125. package/src/components/CnTabsWidget/CnTabsWidget.vue +15 -2
  126. package/src/components/CnWidgetWrapper/CnWidgetWrapper.md +4 -0
  127. package/src/components/CnWidgetWrapper/CnWidgetWrapper.vue +107 -1
  128. package/src/css/detail-page.css +37 -4
  129. package/src/css/kpi-card.css +71 -1
  130. package/src/css/table.css +18 -6
  131. package/src/mixins/pageViews.js +396 -0
  132. package/src/schemas/app-manifest-v2.schema.json +162 -3
  133. package/src/store/useObjectStore.js +118 -33
  134. package/src/utils/actionsDispatcher.js +25 -2
  135. package/src/utils/columnFilters.js +29 -2
  136. package/src/utils/schema.js +40 -2
  137. package/src/utils/schemaRefSlug.js +19 -3
  138. package/src/utils/validateManifestV2.compiled.js +2 -2
@@ -41,8 +41,20 @@
41
41
  {{ skipLinkLabel }}
42
42
  </a>
43
43
 
44
+ <!-- Breadcrumb line (manifest `config.breadcrumb`): the list the record
45
+ belongs to, then the record itself as the current crumb. -->
46
+ <CnBreadcrumbs
47
+ v-if="breadcrumbCrumbs.length > 0 && !objectNotFound"
48
+ class="cn-detail-page__breadcrumbs"
49
+ :crumbs="breadcrumbCrumbs"
50
+ :rootText="breadcrumbCrumbs[0].icon === undefined"
51
+ data-testid="cn-detail-page-breadcrumbs" />
44
52
  <!-- Header -->
45
- <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">
46
58
  <!-- Header (left block) — overridable via #header slot. Default
47
59
  renders the icon + title + description. The right-hand
48
60
  #actions slot remains separate so headerComponent and
@@ -98,7 +110,7 @@
98
110
  (ADR-062). Only shown once the object resolves to a
99
111
  display name that differs from the type label. -->
100
112
  <p
101
- v-if="typeEyebrow"
113
+ v-if="typeEyebrow && showTypeEyebrow"
102
114
  class="cn-detail-page__type-eyebrow"
103
115
  data-testid="cn-detail-page-type-eyebrow">
104
116
  {{ typeEyebrow }}
@@ -133,6 +145,18 @@
133
145
  </div>
134
146
  </slot>
135
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" />
136
160
  <!-- Next and previous inside the list this record was opened
137
161
  from. Rendered only when the address names that list: a
138
162
  record reached by a bare link offers neither, rather than
@@ -363,6 +387,50 @@
363
387
  </template>
364
388
  </CnActionsMenu>
365
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>
366
434
  </div>
367
435
 
368
436
  <!-- The locked card, under the title, for ANY active lock — not only a
@@ -553,15 +621,19 @@
553
621
  subscribed to the layout/widgets arrays — in-place pushes
554
622
  (Add widget) keep the same array identity and can't re-render
555
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. -->
556
627
  <CnDashboardGrid
557
- v-if="hasBodyGrid"
558
- :key="`cn-detail-grid-${editingBody ? 'editing' : 'live'}`"
559
- :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"
560
632
  :editable="editingBody"
561
633
  :columns="12"
562
634
  :columnOpts="columnOpts"
563
635
  class="cn-detail-page__grid"
564
- @layoutChange="onBodyLayoutChange">
636
+ @layoutChange="section.view ? onViewLayoutChange($event) : onBodyLayoutChange($event)">
565
637
  <template #widget="{ item }">
566
638
  <div
567
639
  class="cn-detail-page__grid-item"
@@ -671,6 +743,22 @@
671
743
  </template>
672
744
  </CnDashboardGrid>
673
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
+
674
762
  <!-- Statistics table -->
675
763
  <div v-if="hasStats" class="cn-detail-page__stats">
676
764
  <!--
@@ -980,6 +1068,7 @@ import InformationOutline from 'vue-material-design-icons/InformationOutline.vue
980
1068
  import Pencil from 'vue-material-design-icons/Pencil.vue'
981
1069
  import Plus from 'vue-material-design-icons/Plus.vue'
982
1070
  import Refresh from 'vue-material-design-icons/Refresh.vue'
1071
+ import ViewDashboardOutline from 'vue-material-design-icons/ViewDashboardOutline.vue'
983
1072
  import CnRelationLinkModal from '../../dialogs/CnRelationLinkModal.vue'
984
1073
  import CnWidgetStyleEditorModal from '../../dialogs/CnWidgetStyleEditorModal.vue'
985
1074
  import CnActionsMenu from '../CnActionsMenu/CnActionsMenu.vue'
@@ -992,6 +1081,7 @@ import CnLifecycleActions from '../CnLifecycleActions/CnLifecycleActions.vue'
992
1081
  import CnLockedBanner from '../CnLockedBanner/CnLockedBanner.vue'
993
1082
  import CnNextStepCard from '../CnNextStepCard/CnNextStepCard.vue'
994
1083
  import CnRelatedCollections from '../CnRelatedCollections/CnRelatedCollections.vue'
1084
+ import CnSegmentedControl from '../CnSegmentedControl/CnSegmentedControl.vue'
995
1085
  import CnSummaryAggregates from '../CnSummaryAggregates/CnSummaryAggregates.vue'
996
1086
  import CnTranslatedBadge from '../CnTranslatedBadge/CnTranslatedBadge.vue'
997
1087
  import { useIntegrationRegistry } from '../../composables/useIntegrationRegistry.js'
@@ -999,6 +1089,7 @@ import { useObjectLock } from '../../composables/useObjectLock.js'
999
1089
  import { useObjectPresence } from '../../composables/useObjectPresence.js'
1000
1090
  import { useObjectSubscription } from '../../composables/useObjectSubscription.js'
1001
1091
  import { gridLayout } from '../../mixins/gridLayout.js'
1092
+ import { pageViews } from '../../mixins/pageViews.js'
1002
1093
  import { useObjectStore } from '../../store/index.js'
1003
1094
  import { isAppInstalled } from '../../utils/appInstalled.js'
1004
1095
  import { compactLayoutRows } from '../../utils/dashboardPlacement.js'
@@ -1025,6 +1116,7 @@ import {
1025
1116
  widgetTitleOf,
1026
1117
  } from '../../utils/widgetDispatch.js'
1027
1118
  import { CnActionButtons } from '../CnActionButtons/index.js'
1119
+ import { CnBreadcrumbs } from '../CnBreadcrumbs/index.js'
1028
1120
  import { CnIcon } from '../CnIcon/index.js'
1029
1121
  import { CnStatusBadge } from '../CnStatusBadge/index.js'
1030
1122
  import { getWidgetTypeEntry } from '../CnWidgetGrid/dashboardWidgetRegistry.js'
@@ -1170,8 +1262,11 @@ export default {
1170
1262
  CnDetailWidgetHost,
1171
1263
  CnFormDialog,
1172
1264
  CnDashboardGrid,
1265
+ CnSegmentedControl,
1266
+ ViewDashboardOutline,
1173
1267
  CnLifecycleActions,
1174
1268
  CnActionButtons,
1269
+ CnBreadcrumbs,
1175
1270
  CnStatusBadge,
1176
1271
  CnSummaryAggregates,
1177
1272
  CnRelatedCollections,
@@ -1184,7 +1279,7 @@ export default {
1184
1279
  Pencil,
1185
1280
  },
1186
1281
 
1187
- mixins: [gridLayout],
1282
+ mixins: [gridLayout, pageViews],
1188
1283
 
1189
1284
  inject: {
1190
1285
  /**
@@ -2078,6 +2173,37 @@ export default {
2078
2173
  default: null,
2079
2174
  },
2080
2175
 
2176
+ /**
2177
+ * Whether the type eyebrow (the type label above the record name)
2178
+ * renders once the record resolves. `false` (manifest
2179
+ * `config.showTypeEyebrow: false`) drops it for a header that says
2180
+ * the type in a pill instead.
2181
+ *
2182
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-detail-page-drops-its-eyebrow-and-shows-a-breadcrumb
2183
+ */
2184
+ showTypeEyebrow: {
2185
+ type: Boolean,
2186
+ default: true,
2187
+ },
2188
+
2189
+ /**
2190
+ * A breadcrumb line above the header (manifest `config.breadcrumb`):
2191
+ * `{ label, route?, params?, href? }` names the list the record
2192
+ * belongs to; the record's display name follows as the current
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.
2197
+ *
2198
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-detail-page-drops-its-eyebrow-and-shows-a-breadcrumb
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}
2201
+ */
2202
+ breadcrumb: {
2203
+ type: Object,
2204
+ default: null,
2205
+ },
2206
+
2081
2207
  /**
2082
2208
  * A column of cards beside the body (manifest `config.sideColumn`),
2083
2209
  * for the record's facts: deadline, requester, handler, links. Each
@@ -2093,6 +2219,33 @@ export default {
2093
2219
  default: () => [],
2094
2220
  },
2095
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
+
2096
2249
  /**
2097
2250
  * Whether the current user administers this instance. Shows the
2098
2251
  * `adminOnly` header actions. `null` (the default) reads it from
@@ -2632,6 +2785,21 @@ export default {
2632
2785
  .filter(Boolean)
2633
2786
  },
2634
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
+
2635
2803
  /**
2636
2804
  * The side column's widget definitions. A string names a widget
2637
2805
  * declared in `widgets`; an object is a definition of its own.
@@ -3201,6 +3369,33 @@ export default {
3201
3369
  return this.objectDisplayName || this.resolvedTitle
3202
3370
  },
3203
3371
 
3372
+ /**
3373
+ * The breadcrumb trail: the declared crumb (a router target from
3374
+ * `route` + `params`, or an `href`), then the record's display name
3375
+ * as the current crumb. Empty without a `breadcrumb` label. The first
3376
+ * crumb carries `icon` only when the manifest declares one.
3377
+ *
3378
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-detail-page-drops-its-eyebrow-and-shows-a-breadcrumb
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}>}
3381
+ */
3382
+ breadcrumbCrumbs() {
3383
+ const crumb = this.breadcrumb
3384
+ if (!crumb || typeof crumb.label !== 'string' || crumb.label === '') {
3385
+ return []
3386
+ }
3387
+ const first = { label: this.effectiveTranslate(crumb.label) }
3388
+ if (typeof crumb.icon === 'string' && crumb.icon !== '') {
3389
+ first.icon = crumb.icon
3390
+ }
3391
+ if (crumb.route) {
3392
+ first.to = { name: crumb.route, ...(crumb.params ? { params: crumb.params } : {}) }
3393
+ } else if (crumb.href) {
3394
+ first.href = crumb.href
3395
+ }
3396
+ return [first, { label: this.displayTitle }]
3397
+ },
3398
+
3204
3399
  /**
3205
3400
  * Effective translate function: the injected `cnTranslate` (the host
3206
3401
  * app's bound `t()`), identity by default.
@@ -3304,6 +3499,7 @@ export default {
3304
3499
  && this.currentObject
3305
3500
  && !this.hasDefaultSlotContent
3306
3501
  && !this.hasGridLayout
3502
+ && !this.hasViews
3307
3503
  },
3308
3504
 
3309
3505
  /**
@@ -3383,6 +3579,55 @@ export default {
3383
3579
  return this.bodyGridLayout.length > 0
3384
3580
  },
3385
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
+
3386
3631
  /**
3387
3632
  * Whether the sidebar is rendered externally (via objectSidebarState inject)
3388
3633
  * rather than inline. When external, CnDetailPage only manages state —
@@ -4604,6 +4849,8 @@ export default {
4604
4849
  */
4605
4850
  findWidget(item) {
4606
4851
  return this.bodyGridWidgets.find((w) => w.id === item.widgetId)
4852
+ || this.findViewWidget(item.widgetId)
4853
+ || undefined
4607
4854
  },
4608
4855
 
4609
4856
  /**
@@ -4652,6 +4899,10 @@ export default {
4652
4899
  * it — CnPageRenderer's split pane forwards no slots, which is exactly
4653
4900
  * that fault and wants fixing, not concealing.
4654
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
4655
4906
  * @param {object} item A layout item.
4656
4907
  * @return {boolean} False only when the item provably draws nothing.
4657
4908
  */
@@ -4662,6 +4913,11 @@ export default {
4662
4913
  if (this.editingBody) {
4663
4914
  return true
4664
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
+ }
4665
4921
  const widget = this.findWidget(item)
4666
4922
  if (!widget) {
4667
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') {