@conduction/nextcloud-vue 2.68.0 → 2.70.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 (137) hide show
  1. package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js +25 -0
  2. package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js.map +1 -1
  3. package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js +155 -152
  4. package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js.map +1 -1
  5. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +77 -0
  6. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
  7. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js +8 -8
  8. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
  9. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue.js +1 -0
  10. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue.js.map +1 -1
  11. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue2.js +28 -0
  12. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue2.js.map +1 -1
  13. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js +4 -2
  14. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js.map +1 -1
  15. package/dist/esm/components/CnCalendarWidget/CnCalendarWidget.vue2.js +25 -1
  16. package/dist/esm/components/CnCalendarWidget/CnCalendarWidget.vue2.js.map +1 -1
  17. package/dist/esm/components/CnCalendarWidget/CnCalendarWidget.vue3.js +32 -17
  18. package/dist/esm/components/CnCalendarWidget/CnCalendarWidget.vue3.js.map +1 -1
  19. package/dist/esm/components/CnCapabilityTable/CnCapabilityTable.vue2.js +14 -1
  20. package/dist/esm/components/CnCapabilityTable/CnCapabilityTable.vue2.js.map +1 -1
  21. package/dist/esm/components/CnCapabilityTable/CnCapabilityTable.vue3.js.map +1 -1
  22. package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue2.js +43 -2
  23. package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue2.js.map +1 -1
  24. package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue3.js +3 -3
  25. package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue3.js.map +1 -1
  26. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +45 -35
  27. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  28. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +19 -1
  29. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  30. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +35 -1
  31. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  32. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  33. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +94 -6
  34. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  35. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +64 -19
  36. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  37. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js +28 -0
  38. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js.map +1 -1
  39. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js +3 -2
  40. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js.map +1 -1
  41. package/dist/esm/components/CnFeaturesAndRoadmapPage/CnFeaturesAndRoadmapPage.vue2.js +12 -0
  42. package/dist/esm/components/CnFeaturesAndRoadmapPage/CnFeaturesAndRoadmapPage.vue2.js.map +1 -1
  43. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue2.js +9 -0
  44. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue2.js.map +1 -1
  45. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue3.js.map +1 -1
  46. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js +54 -7
  47. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js.map +1 -1
  48. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js +26 -8
  49. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js.map +1 -1
  50. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +143 -0
  51. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  52. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +68 -25
  53. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  54. package/dist/esm/components/CnNextStepCard/CnNextStepCard.vue2.js.map +1 -1
  55. package/dist/esm/components/CnNextStepCard/CnNextStepCard.vue3.js.map +1 -1
  56. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +4 -1
  57. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
  58. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
  59. package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue2.js +17 -3
  60. package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue2.js.map +1 -1
  61. package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue3.js +25 -24
  62. package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue3.js.map +1 -1
  63. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js.map +1 -1
  64. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js.map +1 -1
  65. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +29 -2
  66. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
  67. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js +7 -0
  68. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
  69. package/dist/esm/components/CnStatsBlock/CnStatsBlock.vue2.js +24 -1
  70. package/dist/esm/components/CnStatsBlock/CnStatsBlock.vue2.js.map +1 -1
  71. package/dist/esm/components/CnStatsBlock/CnStatsBlock.vue3.js +1 -1
  72. package/dist/esm/components/CnStatsBlock/CnStatsBlock.vue3.js.map +1 -1
  73. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue2.js +81 -1
  74. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue2.js.map +1 -1
  75. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue3.js +25 -3
  76. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue3.js.map +1 -1
  77. package/dist/esm/components/CnWeekStripWidget/CnWeekStripWidget.vue2.js +15 -2
  78. package/dist/esm/components/CnWeekStripWidget/CnWeekStripWidget.vue2.js.map +1 -1
  79. package/dist/esm/components/CnWeekStripWidget/CnWeekStripWidget.vue3.js +31 -32
  80. package/dist/esm/components/CnWeekStripWidget/CnWeekStripWidget.vue3.js.map +1 -1
  81. package/dist/esm/l10n/en.json.js +3 -0
  82. package/dist/esm/l10n/en.json.js.map +1 -1
  83. package/dist/esm/l10n/nl.json.js +3 -0
  84. package/dist/esm/l10n/nl.json.js.map +1 -1
  85. package/dist/esm/mixins/pageViews.js +278 -11
  86. package/dist/esm/mixins/pageViews.js.map +1 -1
  87. package/dist/esm/nextcloud-vue.css +145 -0
  88. package/dist/esm/store/plugins/dashboardLayouts.js +35 -1
  89. package/dist/esm/store/plugins/dashboardLayouts.js.map +1 -1
  90. package/dist/esm/utils/capabilityComparison.js +29 -1
  91. package/dist/esm/utils/capabilityComparison.js.map +1 -1
  92. package/dist/esm/utils/formatMetric.js +137 -16
  93. package/dist/esm/utils/formatMetric.js.map +1 -1
  94. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  95. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  96. package/dist/nextcloud-vue.cjs.js +2840 -1477
  97. package/dist/nextcloud-vue.cjs.js.map +2 -2
  98. package/dist/nextcloud-vue.css +145 -0
  99. package/l10n/en.json +3 -0
  100. package/l10n/nl.json +3 -0
  101. package/package.json +1 -1
  102. package/src/components/CnActionsBar/CnActionsBar.md +2 -0
  103. package/src/components/CnActionsBar/CnActionsBar.vue +28 -2
  104. package/src/components/CnAppNav/CnAppNav.vue +85 -8
  105. package/src/components/CnBreadcrumbs/CnBreadcrumbs.vue +45 -0
  106. package/src/components/CnCalendarWidget/CnCalendarWidget.vue +32 -4
  107. package/src/components/CnCapabilityTable/CnCapabilityTable.vue +14 -0
  108. package/src/components/CnDashboardGrid/CnDashboardGrid.md +2 -0
  109. package/src/components/CnDashboardGrid/CnDashboardGrid.vue +54 -2
  110. package/src/components/CnDashboardPage/CnDashboardPage.md +2 -0
  111. package/src/components/CnDashboardPage/CnDashboardPage.vue +59 -35
  112. package/src/components/CnDataTable/CnDataTable.vue +35 -1
  113. package/src/components/CnDetailPage/CnDetailPage.md +8 -0
  114. package/src/components/CnDetailPage/CnDetailPage.vue +124 -8
  115. package/src/components/CnDetailWidgetHost/CnDetailWidgetHost.vue +30 -1
  116. package/src/components/CnFeaturesAndRoadmapPage/CnFeaturesAndRoadmapPage.vue +12 -0
  117. package/src/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue +9 -0
  118. package/src/components/CnHeaderWidget/CnHeaderWidget.vue +95 -8
  119. package/src/components/CnIndexPage/CnIndexPage.vue +171 -6
  120. package/src/components/CnNextStepCard/CnNextStepCard.vue +11 -0
  121. package/src/components/CnObjectListWidget/CnObjectListWidget.vue +4 -1
  122. package/src/components/CnStackedBarWidget/CnStackedBarWidget.vue +34 -7
  123. package/src/components/CnStagesWidget/CnStagesWidget.vue +19 -0
  124. package/src/components/CnStatWidget/CnStatWidget.vue +38 -2
  125. package/src/components/CnStatsBlock/CnStatsBlock.md +2 -0
  126. package/src/components/CnStatsBlock/CnStatsBlock.vue +25 -2
  127. package/src/components/CnStatsBlockWidget/CnStatsBlockWidget.vue +95 -3
  128. package/src/components/CnWeekStripWidget/CnWeekStripWidget.vue +24 -3
  129. package/src/css/detail-page.css +10 -0
  130. package/src/css/index-page.css +12 -0
  131. package/src/css/kpi-card.css +15 -0
  132. package/src/mixins/pageViews.js +277 -10
  133. package/src/schemas/app-manifest-v2.schema.json +73 -6
  134. package/src/store/plugins/dashboardLayouts.js +34 -0
  135. package/src/utils/capabilityComparison.js +28 -0
  136. package/src/utils/formatMetric.js +143 -15
  137. package/src/utils/validateManifestV2.compiled.js +2 -2
@@ -31,6 +31,7 @@
31
31
  v-for="(item, index) in layout"
32
32
  :key="resolveItemKey(item)"
33
33
  class="grid-stack-item"
34
+ :class="{ 'cn-dashboard-grid__item--fit': sizeToContentAttr(item) !== undefined }"
34
35
  role="group"
35
36
  :aria-label="resolveItemLabel(item, index)"
36
37
  :aria-describedby="itemDescribedBy"
@@ -255,6 +256,19 @@ export default {
255
256
  type: Boolean,
256
257
  default: true,
257
258
  },
259
+
260
+ /**
261
+ * GridStack's `float`. True (the default) keeps every item on the row
262
+ * it was placed on, with empty rows above it if need be. False packs
263
+ * items upward, so a size-to-content cell that shrinks, or a widget
264
+ * that is not shown, closes its gap instead of leaving a band.
265
+ *
266
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-3/specs/zuiddrecht-pixel-gaps-3/spec.md#requirement-a-dashboard-closes-the-gap-a-shrinking-or-hidden-widget-leaves
267
+ */
268
+ float: {
269
+ type: Boolean,
270
+ default: true,
271
+ },
258
272
  },
259
273
 
260
274
  emits: [
@@ -278,6 +292,8 @@ export default {
278
292
  return {
279
293
  grid: null,
280
294
  contentObserver: null,
295
+ /** True while the grid moves an item to where the layout put it. */
296
+ applyingLayout: false,
281
297
  domId: `cn-dashboard-grid-${++domIdCounter}`,
282
298
  /** Text currently held in the polite live region. */
283
299
  announcement: '',
@@ -485,7 +501,7 @@ export default {
485
501
  column: this.columns,
486
502
  cellHeight: this.cellHeight,
487
503
  margin: this.margin,
488
- float: true,
504
+ float: this.float,
489
505
  animate: true,
490
506
  disableDrag: !this.editable,
491
507
  disableResize: !this.editable,
@@ -558,6 +574,10 @@ export default {
558
574
  // switched off as the grid narrows, and back on as it widens.
559
575
  this.syncInteractivity()
560
576
 
577
+ if (this.applyingLayout) {
578
+ return
579
+ }
580
+
561
581
  // GridStack fires `change` for its own responsive rescale too, and at a
562
582
  // 12 → 1 breakpoint every item reads `gridX: 0, gridWidth: 1`. Emitting
563
583
  // that has the host persist the reflow AS the authored layout, which
@@ -957,7 +977,29 @@ export default {
957
977
  const node = this.grid.engine.nodes.find((n) => String(n.id) === String(item.id))
958
978
  if (!node) {
959
979
  this.grid.makeWidget(el)
960
- } else if (node.el !== el) {
980
+ } else if (node.el === el) {
981
+ // The layout MOVED an item the grid already tracks: the
982
+ // page re-compacts its display layout when a widget's
983
+ // condition hides it, and the item below was given a new
984
+ // gridY that never reached GridStack, so a band stayed
985
+ // where the hidden widget had been. A drag reports the
986
+ // grid's own position back, which matches and does
987
+ // nothing here.
988
+ const x = item.gridX ?? node.x
989
+ const y = item.gridY ?? node.y
990
+ if (x !== node.x || y !== node.y) {
991
+ // The layout said where it goes; GridStack's `change`
992
+ // for it is not a user's arrangement and is not
993
+ // reported back (it would write the display-only
994
+ // compaction into the authored layout).
995
+ this.applyingLayout = true
996
+ try {
997
+ this.grid.update(el, { x, y })
998
+ } finally {
999
+ this.applyingLayout = false
1000
+ }
1001
+ }
1002
+ } else {
961
1003
  // Drop the stale node without touching its detached DOM,
962
1004
  // then adopt the new element so GridStack sizes/places it.
963
1005
  this.grid.removeWidget(node.el, false, false)
@@ -1016,6 +1058,16 @@ export default {
1016
1058
  border-radius: var(--border-radius-container-large, 16px);
1017
1059
  }
1018
1060
 
1061
+ /* A size-to-content cell (`sizeToContent`) is measured by GridStack from the
1062
+ content's first child, and a widget wrapper there filled the cell
1063
+ (`height: 100%`), so the measure returned the cell's own height and the
1064
+ cell never shrank: "Waiting for me" stayed a tall card around one row. In
1065
+ such a cell the child takes its own height. A cell without the key keeps
1066
+ the filling child. */
1067
+ .cn-dashboard-grid__item--fit > :deep(.grid-stack-item-content > *) {
1068
+ height: auto;
1069
+ }
1070
+
1019
1071
  :deep(.grid-stack-item-content) {
1020
1072
  background: var(--color-main-background);
1021
1073
  border: none;
@@ -153,6 +153,8 @@ Three behaviours are worth knowing because each is load-bearing:
153
153
 
154
154
  Call `resetUserLayout()` to drop the arrangement; the page emits `user-layout-reset` and returns to the manifest.
155
155
 
156
+ The same `userLayout` covers the page's [views](../../../docs/components/cn-dashboard-page.md#each-view-arranged-per-user-userlayout): each view's grid is draggable in edit mode and is stored per view, under the page id with `.view.<view id>` appended. Edit mode shows **Reset layout**, which returns the page's own grid and the chosen view to the manifest (`user-layout-reset`, `view-layout-reset`). `resetViewUserLayout(viewId)` resets one view.
157
+
156
158
  ## Slots
157
159
 
158
160
  | Slot | Scope | Description |
@@ -67,6 +67,19 @@
67
67
  </template>
68
68
  {{ isEditing ? doneLabel : editLabel }}
69
69
  </NcButton>
70
+ <!-- With `userLayout`, edit mode offers a way back to the
71
+ manifest: the page's own grid and the chosen view return
72
+ to the layout the app ships. Other views keep theirs. -->
73
+ <NcButton
74
+ v-if="userLayout && isEditing"
75
+ variant="tertiary"
76
+ data-testid="cn-dashboard-page-reset-layout"
77
+ @click="resetLayout">
78
+ <template #icon>
79
+ <Restore :size="20" />
80
+ </template>
81
+ {{ t('nextcloud-vue', 'Reset layout') }}
82
+ </NcButton>
70
83
  <!-- In-app edit button (ADR-041). Renders only when Buildiq is
71
84
  reachable; self-wires from the cnManifestEditor / cnOpenBuildAvailable
72
85
  provided by CnAppRoot. Sits inline with the page's action buttons. -->
@@ -292,6 +305,7 @@
292
305
  :columns="columns"
293
306
  :cellHeight="cellHeight"
294
307
  :margin="gridMargin"
308
+ :float="gridFloat"
295
309
  @layoutChange="section.view ? onViewLayoutChange($event) : onLayoutChange($event)">
296
310
  <template #widget="{ item }">
297
311
  <!-- In-app edit overlay (ADR-041): a single launchpad-style
@@ -754,6 +768,7 @@ import Check from 'vue-material-design-icons/Check.vue'
754
768
  import Cog from 'vue-material-design-icons/Cog.vue'
755
769
  import Download from 'vue-material-design-icons/Download.vue'
756
770
  import Pencil from 'vue-material-design-icons/Pencil.vue'
771
+ import Restore from 'vue-material-design-icons/Restore.vue'
757
772
  import ViewDashboardOutline from 'vue-material-design-icons/ViewDashboardOutline.vue'
758
773
  import CnWidgetStyleEditorModal from '../../dialogs/CnWidgetStyleEditorModal.vue'
759
774
  import CnBodySections from '../CnBodySections/CnBodySections.vue'
@@ -768,9 +783,8 @@ import CnWidgetRefItem from '../CnWidgetRefItem/CnWidgetRefItem.vue'
768
783
  import CnWidgetRenderer from '../CnWidgetRenderer/CnWidgetRenderer.vue'
769
784
  import CnWidgetWrapper from '../CnWidgetWrapper/CnWidgetWrapper.vue'
770
785
  import { useIntegrationRegistry } from '../../composables/useIntegrationRegistry.js'
771
- import { readUserPreference, writeUserPreference } from '../../composables/useUserPreferences.js'
772
786
  import { pageViews } from '../../mixins/pageViews.js'
773
- import { dashboardLayoutKey, mergeUserLayout } from '../../store/plugins/dashboardLayouts.js'
787
+ import { mergeUserLayout, resolveUserLayoutApi } from '../../store/plugins/dashboardLayouts.js'
774
788
  import { isAppInstalled } from '../../utils/appInstalled.js'
775
789
  import { compareVisibleWhen, readVisibleWhenValue } from '../../utils/visibleWhen.js'
776
790
  import { canonicalWidgetType } from '../../utils/widgetTypeAliases.js'
@@ -905,6 +919,7 @@ export default {
905
919
  NcLoadingIcon,
906
920
  NcSelect,
907
921
  Pencil,
922
+ Restore,
908
923
  Check,
909
924
  Cog,
910
925
  CalendarRange,
@@ -1016,6 +1031,19 @@ export default {
1016
1031
  default: true,
1017
1032
  },
1018
1033
 
1034
+ /**
1035
+ * GridStack's `float` for this dashboard (manifest
1036
+ * `config.gridFloat`). True (the default) keeps every widget on its
1037
+ * row. False packs widgets upward, so a size-to-content widget that
1038
+ * shrinks closes the gap under it instead of leaving an empty band.
1039
+ *
1040
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-3/specs/zuiddrecht-pixel-gaps-3/spec.md#requirement-a-dashboard-closes-the-gap-a-shrinking-or-hidden-widget-leaves
1041
+ */
1042
+ gridFloat: {
1043
+ type: Boolean,
1044
+ default: true,
1045
+ },
1046
+
1019
1047
  /**
1020
1048
  * Widget definitions array. Each widget defines metadata for rendering.
1021
1049
  *
@@ -1958,7 +1986,7 @@ export default {
1958
1986
  * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-view-with-nothing-to-draw-says-so
1959
1987
  */
1960
1988
  viewDisplayLayout() {
1961
- const items = this.activeView ? this.activeView.layout : []
1989
+ const items = this.activeViewLayout
1962
1990
  if (this.gridEditable) {
1963
1991
  return items
1964
1992
  }
@@ -2000,8 +2028,9 @@ export default {
2000
2028
  key: `view-${this.activeViewId}`,
2001
2029
  view: true,
2002
2030
  layout: this.viewDisplayLayout,
2003
- // A user arrangement covers the page's own layout only.
2004
- editable: this.gridEditable && !this.userLayout,
2031
+ // With `userLayout` the user arranges the view too; the
2032
+ // arrangement is kept per view (pageViews mixin).
2033
+ editable: this.gridEditable,
2005
2034
  attrs: {
2006
2035
  id: this.viewRegionId,
2007
2036
  role: 'region',
@@ -2721,6 +2750,7 @@ export default {
2721
2750
  // the user's arrangement is finished when they say it is.
2722
2751
  if (leaving) {
2723
2752
  this.saveUserLayout()
2753
+ this.saveViewUserLayouts()
2724
2754
  }
2725
2755
 
2726
2756
  /**
@@ -2780,6 +2810,17 @@ export default {
2780
2810
  }
2781
2811
  },
2782
2812
 
2813
+ /**
2814
+ * The edit-mode reset: the page's own grid and the chosen view return
2815
+ * to the manifest layout. Other views keep this user's arrangement.
2816
+ *
2817
+ * @return {Promise<void>}
2818
+ * @spec openspec/changes/page-view-user-layouts/specs/view-switch-containers/spec.md#requirement-resetting-a-view-restores-its-manifest-layout
2819
+ */
2820
+ async resetLayout() {
2821
+ await Promise.all([this.resetUserLayout(), this.resetViewUserLayout()])
2822
+ },
2823
+
2783
2824
  /**
2784
2825
  * Drop this user's arrangement and return to the manifest.
2785
2826
  *
@@ -2832,35 +2873,7 @@ export default {
2832
2873
  * @return {{ load: (appId: string, pageId: string) => Promise<object|null>, save: (appId: string, pageId: string, layout: Array<object>) => Promise<boolean>, reset: (appId: string, pageId: string) => Promise<boolean> }} The api.
2833
2874
  */
2834
2875
  userLayoutApi() {
2835
- const store = this.userLayoutStore
2836
- if (store && typeof store.loadDashboardLayout === 'function') {
2837
- return {
2838
- load: (...a) => store.loadDashboardLayout(...a),
2839
- save: (...a) => store.saveDashboardLayout(...a),
2840
- reset: (...a) => store.resetDashboardLayout(...a),
2841
- }
2842
- }
2843
-
2844
- return {
2845
- load: (appId, pageId) => readUserPreference(appId, dashboardLayoutKey(pageId), null),
2846
- save: (appId, pageId, layout) => writeUserPreference(
2847
- appId,
2848
- dashboardLayoutKey(pageId),
2849
- { items: (layout || []).map((i) => ({
2850
- widgetId: i.widgetId,
2851
- gridX: i.gridX,
2852
- gridY: i.gridY,
2853
- gridWidth: i.gridWidth,
2854
- gridHeight: i.gridHeight,
2855
- })) },
2856
- ),
2857
-
2858
- reset: (appId, pageId) => writeUserPreference(
2859
- appId,
2860
- dashboardLayoutKey(pageId),
2861
- { items: [] },
2862
- ),
2863
- }
2876
+ return resolveUserLayoutApi(this.userLayoutStore)
2864
2877
  },
2865
2878
 
2866
2879
  onLayoutChange(updated) {
@@ -3603,6 +3616,12 @@ export default {
3603
3616
 
3604
3617
  getWidgetButtons(item) {
3605
3618
  const def = this.getWidgetDef(item.widgetId)
3619
+ // `showButtons: false` on the placement or the definition drops
3620
+ // the footer links ("More events"), for a placement whose card
3621
+ // says enough without them (zuiddrecht-pixel-gaps-3).
3622
+ if (item?.showButtons === false || def?.showButtons === false) {
3623
+ return []
3624
+ }
3606
3625
  return def?.buttons || []
3607
3626
  },
3608
3627
 
@@ -3906,11 +3925,16 @@ export default {
3906
3925
  // library's own defaults, so a manifest need not set them — but a
3907
3926
  // manifest that DOES set them must reach the component, or the
3908
3927
  // declaration is a silent no-op that reads like configuration.
3909
- for (const key of ['countLabel', 'variant', 'showZeroCount', 'horizontal', 'vertical', 'filled', 'route', 'iconClass']) {
3928
+ for (const key of ['countLabel', 'variant', 'showZeroCount', 'horizontal', 'vertical', 'filled', 'route', 'iconClass', 'format', 'layout']) {
3910
3929
  if (props[key] !== undefined) {
3911
3930
  out[key] = props[key]
3912
3931
  }
3913
3932
  }
3933
+ // `content.layout: "stacked"`, the key a stat tile takes, works on
3934
+ // a stats block too (zuiddrecht-pixel-gaps-3).
3935
+ if (out.layout === undefined && typeof content.layout === 'string') {
3936
+ out.layout = content.layout
3937
+ }
3914
3938
  // `countLabel` is the unit beside the number ("0 cases", "0 tasks").
3915
3939
  // It is manifest-authored prose and was being forwarded raw, so a
3916
3940
  // Dutch account read "0 cases" beside a translated widget title.
@@ -361,6 +361,40 @@ import { CnLockIndicator } from '../CnLockIndicator/index.js'
361
361
  // consuming app does not pull in the library's global css/index.css.
362
362
  import '../../css/table.css'
363
363
 
364
+ /**
365
+ * Fill a `secondary` template such as `"{identifier} · {requester}"`.
366
+ *
367
+ * A field without a value takes its separator with it: the text between two
368
+ * fields belongs to the field after it, so an empty requester leaves
369
+ * "2026-0002", not "2026-0002 ·", and an empty first field does not leave a
370
+ * leading separator either. Text before the first field and after the last
371
+ * one is kept as written. A template whose fields all have values fills
372
+ * exactly as before.
373
+ *
374
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-3/specs/zuiddrecht-pixel-gaps-3/spec.md#requirement-an-empty-field-takes-its-separator-with-it
375
+ * @param {string} template The template, with `{field}` placeholders.
376
+ * @param {(key: string) => string} valueOf The text of a field ('' when empty).
377
+ * @return {string} The filled template.
378
+ */
379
+ function fillSecondaryTemplate(template, valueOf) {
380
+ // split() with a capture group alternates literal, field, literal, …
381
+ const parts = template.split(/\{([^}]+)\}/)
382
+ const lead = parts[0]
383
+ const tail = parts[parts.length - 1]
384
+ let body = ''
385
+ for (let i = 1; i < parts.length; i += 2) {
386
+ const value = valueOf(parts[i].trim())
387
+ if (value === '') {
388
+ continue
389
+ }
390
+ // The literal before this field: the separator, unless nothing has
391
+ // been written yet (then there is nothing to separate from).
392
+ const separator = i === 1 ? '' : parts[i - 1]
393
+ body += (body === '' ? '' : separator) + value
394
+ }
395
+ return lead + body + tail
396
+ }
397
+
364
398
  /**
365
399
  * CnDataTable — Generic sortable data table for list views.
366
400
  *
@@ -1451,7 +1485,7 @@ export default {
1451
1485
  }
1452
1486
  const asText = (v) => (v === null || v === undefined ? '' : String(v))
1453
1487
  if (spec.includes('{')) {
1454
- const filled = spec.replace(/\{([^}]+)\}/g, (_, key) => asText(this.getCellValue(row, key.trim())))
1488
+ const filled = fillSecondaryTemplate(spec, (key) => asText(this.getCellValue(row, key)))
1455
1489
  // A template whose every field is empty leaves only separators:
1456
1490
  // nothing worth a line.
1457
1491
  return /[\p{L}\p{N}]/u.test(filled) ? filled.trim() : ''
@@ -211,6 +211,12 @@ export default {
211
211
  | `excludeFields` (`exclude-fields`) | Array | `[]` | Fields the create and edit forms never ask for. Applied after `includeFields`. |
212
212
  | `fieldOverrides` (`field-overrides`) | Object | `{}` | Per-field widget / label overrides for the create and edit forms, keyed by field name. Forwarded to `CnFormDialog`. |
213
213
 
214
+ ## Each view arranged per user
215
+
216
+ | Prop | Type | Default | Description |
217
+ |------|------|---------|-------------|
218
+ | `userLayout` | Boolean | `false` | Let the user arrange each of the page's `views` for themselves. The header shows **Arrange view** next to the view switch; **Done** stores the arrangement per view and user (a user preference, mirrored in the browser), **Reset view** returns the chosen view to its manifest layout. The manifest `layout` is never changed. See [the guide](../../../docs/components/cn-detail-page.md#each-view-arranged-per-user-userlayout). |
219
+
214
220
  ## Slots
215
221
 
216
222
  | Slot | Description |
@@ -438,3 +444,5 @@ When the schema-driven fetch answers 404 the page shows a not-found state whose
438
444
  ### Header card and header widget
439
445
 
440
446
  `headerCard` (default `false`) draws the header as a bordered card. `headerWidget` (default `''`) names a widget in `widgets` to render inside the header under the title, without a card of its own and out of the body grid: the stages bars of the board's case card.
447
+
448
+ `showWidgetActions` (Boolean, default `true`): `false` (manifest `config.showWidgetActions: false`) drops the overflow Actions menu from the cards of the body grid and the side column, unless a widget definition sets `showActions: true`.
@@ -48,6 +48,7 @@
48
48
  class="cn-detail-page__breadcrumbs"
49
49
  :crumbs="breadcrumbCrumbs"
50
50
  :rootText="breadcrumbCrumbs[0].icon === undefined"
51
+ :separator="breadcrumbSeparator"
51
52
  data-testid="cn-detail-page-breadcrumbs" />
52
53
  <!-- Header -->
53
54
  <div
@@ -157,6 +158,31 @@
157
158
  :controls="viewRegionId"
158
159
  data-testid="cn-detail-page-view-switch"
159
160
  @update:modelValue="selectView" />
161
+ <!-- With `userLayout`, the user arranges the chosen view for
162
+ themselves: Arrange view makes its grid draggable, Done
163
+ stores the arrangement, Reset view returns the view to the
164
+ layout the app ships. -->
165
+ <NcButton
166
+ v-if="userLayout && viewUserLayoutOn && !viewIsEmpty"
167
+ :variant="arrangingView ? 'primary' : 'secondary'"
168
+ data-testid="cn-detail-page-arrange-view"
169
+ @click="toggleArrangeView">
170
+ <template #icon>
171
+ <Check v-if="arrangingView" :size="20" />
172
+ <Pencil v-else :size="20" />
173
+ </template>
174
+ {{ arrangingView ? t('nextcloud-vue', 'Done') : t('nextcloud-vue', 'Arrange view') }}
175
+ </NcButton>
176
+ <NcButton
177
+ v-if="viewUserLayoutOn && arrangingView"
178
+ variant="tertiary"
179
+ data-testid="cn-detail-page-reset-view"
180
+ @click="resetViewUserLayout()">
181
+ <template #icon>
182
+ <Restore :size="20" />
183
+ </template>
184
+ {{ t('nextcloud-vue', 'Reset view') }}
185
+ </NcButton>
160
186
  <!-- Next and previous inside the list this record was opened
161
187
  from. Rendered only when the address names that list: a
162
188
  record reached by a bare link offers neither, rather than
@@ -626,10 +652,10 @@
626
652
  keyed on its view, so switching mounts the other view's grid. -->
627
653
  <CnDashboardGrid
628
654
  v-for="section in bodyGridSections"
629
- :key="`cn-detail-grid-${section.key}-${editingBody ? 'editing' : 'live'}`"
655
+ :key="`cn-detail-grid-${section.key}-${section.editable ? 'editing' : 'live'}`"
630
656
  v-bind="section.attrs"
631
657
  :layout="section.layout"
632
- :editable="editingBody"
658
+ :editable="section.editable"
633
659
  :columns="12"
634
660
  :columnOpts="columnOpts"
635
661
  class="cn-detail-page__grid"
@@ -724,6 +750,7 @@
724
750
  :widget="findWidget(item)"
725
751
  chrome="card"
726
752
  :showCardTitle="showCardTitle(item)"
753
+ :showActions="showWidgetActions"
727
754
  :objectId="objectId"
728
755
  :object="currentObject"
729
756
  :objectType="resolvedObjectType"
@@ -934,6 +961,7 @@
934
961
  :widget="widget"
935
962
  chrome="card"
936
963
  :showCardTitle="true"
964
+ :showActions="showWidgetActions"
937
965
  :objectId="objectId"
938
966
  :object="currentObject"
939
967
  :objectType="resolvedObjectType"
@@ -1060,6 +1088,7 @@ import { NcActionButton, NcActionCaption, NcActionLink, NcActionSeparator, NcBut
1060
1088
  import { provide, ref, watch } from 'vue'
1061
1089
  import AlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue'
1062
1090
  import ArrowLeft from 'vue-material-design-icons/ArrowLeft.vue'
1091
+ import Check from 'vue-material-design-icons/Check.vue'
1063
1092
  import ChevronLeft from 'vue-material-design-icons/ChevronLeft.vue'
1064
1093
  import ChevronRight from 'vue-material-design-icons/ChevronRight.vue'
1065
1094
  import Cog from 'vue-material-design-icons/Cog.vue'
@@ -1068,6 +1097,7 @@ import InformationOutline from 'vue-material-design-icons/InformationOutline.vue
1068
1097
  import Pencil from 'vue-material-design-icons/Pencil.vue'
1069
1098
  import Plus from 'vue-material-design-icons/Plus.vue'
1070
1099
  import Refresh from 'vue-material-design-icons/Refresh.vue'
1100
+ import Restore from 'vue-material-design-icons/Restore.vue'
1071
1101
  import ViewDashboardOutline from 'vue-material-design-icons/ViewDashboardOutline.vue'
1072
1102
  import CnRelationLinkModal from '../../dialogs/CnRelationLinkModal.vue'
1073
1103
  import CnWidgetStyleEditorModal from '../../dialogs/CnWidgetStyleEditorModal.vue'
@@ -1274,9 +1304,11 @@ export default {
1274
1304
  CnTranslatedBadge,
1275
1305
  CnWidgetStyleEditorModal,
1276
1306
  CnRelationLinkModal,
1307
+ Check,
1277
1308
  Cog,
1278
1309
  Plus,
1279
1310
  Pencil,
1311
+ Restore,
1280
1312
  },
1281
1313
 
1282
1314
  mixins: [gridLayout, pageViews],
@@ -1769,6 +1801,20 @@ export default {
1769
1801
  default: '',
1770
1802
  },
1771
1803
 
1804
+ /**
1805
+ * Whether the user arranges each view (`views`) for themselves. On,
1806
+ * the header offers Arrange view next to the view switch; the
1807
+ * arrangement is stored per view and per user (the server, mirrored
1808
+ * in the browser) and a reset returns the view to its manifest
1809
+ * layout. Off (the default), a view renders as the manifest says.
1810
+ *
1811
+ * @type {boolean}
1812
+ */
1813
+ userLayout: {
1814
+ type: Boolean,
1815
+ default: false,
1816
+ },
1817
+
1772
1818
  /**
1773
1819
  * Whether the Refresh entry renders in the page-header menu.
1774
1820
  * Tri-state:
@@ -2192,18 +2238,38 @@ export default {
2192
2238
  * belongs to; the record's display name follows as the current
2193
2239
  * crumb. `label` goes through the host translate function and shows
2194
2240
  * 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
2241
+ * with the label as its accessible name. `currentField` names a field
2242
+ * of the record (a dotted path, e.g. `identifier`) whose value is the
2243
+ * current crumb instead of the display name; an empty value falls back
2244
+ * to the display name. `separator` draws that text (e.g. `/`) between
2245
+ * the crumbs instead of the chevron. Null (the default) draws no
2196
2246
  * trail.
2197
2247
  *
2198
2248
  * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-detail-page-drops-its-eyebrow-and-shows-a-breadcrumb
2199
2249
  * @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}
2250
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-3/specs/zuiddrecht-pixel-gaps-3/spec.md#requirement-a-breadcrumb-can-name-the-record-by-a-field-and-use-a-text-separator
2251
+ * @type {{label: string, route?: string, params?: object, href?: string, icon?: string, currentField?: string, separator?: string}|null}
2201
2252
  */
2202
2253
  breadcrumb: {
2203
2254
  type: Object,
2204
2255
  default: null,
2205
2256
  },
2206
2257
 
2258
+ /**
2259
+ * Whether the record's widget cards (the body grid and the side
2260
+ * column) keep their overflow Actions menu when their definition does
2261
+ * not say (`showActions`). `false` (manifest
2262
+ * `config.showWidgetActions: false`) drops it, as on a dashboard; a
2263
+ * widget with `showActions: true` keeps its menu. A card that offers
2264
+ * an Add action keeps the menu that holds it. True by default.
2265
+ *
2266
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-3/specs/zuiddrecht-pixel-gaps-3/spec.md#requirement-a-detail-page-can-drop-the-widget-actions-menu
2267
+ */
2268
+ showWidgetActions: {
2269
+ type: Boolean,
2270
+ default: true,
2271
+ },
2272
+
2207
2273
  /**
2208
2274
  * A column of cards beside the body (manifest `config.sideColumn`),
2209
2275
  * for the record's facts: deadline, requester, handler, links. Each
@@ -2419,6 +2485,8 @@ export default {
2419
2485
 
2420
2486
  data() {
2421
2487
  return {
2488
+ /** Whether the user is arranging the chosen view (`userLayout`). */
2489
+ arrangingView: false,
2422
2490
  /** Whether the record edit form is open. */
2423
2491
  editFormOpen: false,
2424
2492
  /**
@@ -3393,7 +3461,38 @@ export default {
3393
3461
  } else if (crumb.href) {
3394
3462
  first.href = crumb.href
3395
3463
  }
3396
- return [first, { label: this.displayTitle }]
3464
+ return [first, { label: this.breadcrumbCurrentLabel }]
3465
+ },
3466
+
3467
+ /**
3468
+ * The current crumb: the record's `breadcrumb.currentField` value
3469
+ * (the case number on a case page) when declared and filled, else the
3470
+ * display name.
3471
+ *
3472
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-3/specs/zuiddrecht-pixel-gaps-3/spec.md#requirement-a-breadcrumb-can-name-the-record-by-a-field-and-use-a-text-separator
3473
+ * @return {string}
3474
+ */
3475
+ breadcrumbCurrentLabel() {
3476
+ const field = this.breadcrumb && this.breadcrumb.currentField
3477
+ if (typeof field === 'string' && field !== '') {
3478
+ const value = stageOf(this.resolvedObject, field)
3479
+ if (value !== '') {
3480
+ return value
3481
+ }
3482
+ }
3483
+ return this.displayTitle
3484
+ },
3485
+
3486
+ /**
3487
+ * The text drawn between the crumbs (`breadcrumb.separator`), or ''
3488
+ * for the chevron.
3489
+ *
3490
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-3/specs/zuiddrecht-pixel-gaps-3/spec.md#requirement-a-breadcrumb-can-name-the-record-by-a-field-and-use-a-text-separator
3491
+ * @return {string}
3492
+ */
3493
+ breadcrumbSeparator() {
3494
+ const separator = this.breadcrumb && this.breadcrumb.separator
3495
+ return typeof separator === 'string' ? separator : ''
3397
3496
  },
3398
3497
 
3399
3498
  /**
@@ -3589,7 +3688,7 @@ export default {
3589
3688
  viewDisplayLayout() {
3590
3689
  // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- reading the flag IS the effect, as in bodyGridLayout
3591
3690
  this.editingBody
3592
- return this.activeView ? this.closeDroppedRows(this.activeView.layout) : []
3691
+ return this.activeView ? this.closeDroppedRows(this.activeViewLayout) : []
3593
3692
  },
3594
3693
 
3595
3694
  /**
@@ -3607,16 +3706,18 @@ export default {
3607
3706
  * chosen view's grid in the region the view switch controls (left out
3608
3707
  * when the view is empty; the empty state takes the region then).
3609
3708
  *
3610
- * @return {Array<{key: string, view: boolean, layout: Array<object>, attrs: object}>}
3709
+ * @return {Array<{key: string, view: boolean, layout: Array<object>, editable: boolean, attrs: object}>}
3611
3710
  * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-page-declares-views-that-each-hold-a-widget-grid
3612
3711
  */
3613
3712
  bodyGridSections() {
3614
- const sections = this.hasBodyGrid ? [{ key: 'body', view: false, layout: this.bodyGridLayout, attrs: {} }] : []
3713
+ const sections = this.hasBodyGrid ? [{ key: 'body', view: false, layout: this.bodyGridLayout, editable: this.editingBody, attrs: {} }] : []
3615
3714
  if (this.hasViews && !this.viewIsEmpty) {
3616
3715
  sections.push({
3617
3716
  key: `view-${this.activeViewId}`,
3618
3717
  view: true,
3619
3718
  layout: this.viewDisplayLayout,
3719
+ // The user arranges the view while Arrange view is on.
3720
+ editable: this.editingBody || this.arrangingView,
3620
3721
  attrs: {
3621
3722
  id: this.viewRegionId,
3622
3723
  role: 'region',
@@ -3941,6 +4042,21 @@ export default {
3941
4042
  },
3942
4043
 
3943
4044
  methods: {
4045
+ /**
4046
+ * Start or finish arranging the chosen view. Finishing stores the
4047
+ * arrangement of every view the user moved, once.
4048
+ *
4049
+ * @return {void}
4050
+ * @spec openspec/changes/page-view-user-layouts/specs/view-switch-containers/spec.md#requirement-a-user-arranges-each-view-for-themselves
4051
+ */
4052
+ toggleArrangeView() {
4053
+ const leaving = this.arrangingView
4054
+ this.arrangingView = !this.arrangingView
4055
+ if (leaving) {
4056
+ this.saveViewUserLayouts()
4057
+ }
4058
+ },
4059
+
3944
4060
  // Expose the shared grid helpers to the template (grid mode + auto-body).
3945
4061
  cnGridCellStyle,
3946
4062
  hasGridRow,