@conduction/nextcloud-vue 2.67.0 → 2.69.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 (91) hide show
  1. package/dist/esm/components/CnContextMenu/CnContextMenu.vue2.js +37 -26
  2. package/dist/esm/components/CnContextMenu/CnContextMenu.vue2.js.map +1 -1
  3. package/dist/esm/components/CnContextMenu/CnContextMenu.vue3.js +3 -3
  4. package/dist/esm/components/CnContextMenu/CnContextMenu.vue3.js.map +1 -1
  5. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +21 -35
  6. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  7. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +17 -0
  8. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  9. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +40 -3
  10. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  11. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +58 -16
  12. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  13. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +87 -18
  14. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  15. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js +6 -1
  16. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  17. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +58 -48
  18. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  19. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +2 -2
  20. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  21. package/dist/esm/components/CnIndexPage/defaultActions.js +6 -7
  22. package/dist/esm/components/CnIndexPage/defaultActions.js.map +1 -1
  23. package/dist/esm/components/CnRowActions/CnRowActions.vue2.js +33 -34
  24. package/dist/esm/components/CnRowActions/CnRowActions.vue2.js.map +1 -1
  25. package/dist/esm/components/CnRowActions/CnRowActions.vue3.js +3 -3
  26. package/dist/esm/components/CnRowActions/CnRowActions.vue3.js.map +1 -1
  27. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js +8 -2
  28. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js.map +1 -1
  29. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js.map +1 -1
  30. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +2 -2
  31. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
  32. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
  33. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue2.js +70 -1
  34. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue2.js.map +1 -1
  35. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue3.js +23 -3
  36. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue3.js.map +1 -1
  37. package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue2.js +3 -2
  38. package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue2.js.map +1 -1
  39. package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue3.js.map +1 -1
  40. package/dist/esm/composables/useContextMenu.js +4 -2
  41. package/dist/esm/composables/useContextMenu.js.map +1 -1
  42. package/dist/esm/l10n/en.json.js +3 -0
  43. package/dist/esm/l10n/en.json.js.map +1 -1
  44. package/dist/esm/l10n/nl.json.js +3 -0
  45. package/dist/esm/l10n/nl.json.js.map +1 -1
  46. package/dist/esm/mixins/pageViews.js +278 -11
  47. package/dist/esm/mixins/pageViews.js.map +1 -1
  48. package/dist/esm/store/plugins/dashboardLayouts.js +35 -1
  49. package/dist/esm/store/plugins/dashboardLayouts.js.map +1 -1
  50. package/dist/esm/utils/formatMetric.js +137 -16
  51. package/dist/esm/utils/formatMetric.js.map +1 -1
  52. package/dist/esm/utils/resolveRowActions.js +130 -0
  53. package/dist/esm/utils/resolveRowActions.js.map +1 -0
  54. package/dist/esm/utils/rowActionItem.js +104 -0
  55. package/dist/esm/utils/rowActionItem.js.map +1 -0
  56. package/dist/esm/utils/timelineEvents.js +44 -1
  57. package/dist/esm/utils/timelineEvents.js.map +1 -1
  58. package/dist/esm/utils/validateManifest.js +59 -3
  59. package/dist/esm/utils/validateManifest.js.map +1 -1
  60. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  61. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  62. package/dist/nextcloud-vue.cjs.js +1251 -232
  63. package/dist/nextcloud-vue.cjs.js.map +2 -2
  64. package/l10n/en.json +3 -0
  65. package/l10n/nl.json +3 -0
  66. package/package.json +1 -1
  67. package/scripts/.jsdoc-baselines.json +2 -2
  68. package/src/components/CnContextMenu/CnContextMenu.vue +40 -29
  69. package/src/components/CnDashboardPage/CnDashboardPage.md +2 -0
  70. package/src/components/CnDashboardPage/CnDashboardPage.vue +34 -35
  71. package/src/components/CnDetailPage/CnDetailPage.md +6 -0
  72. package/src/components/CnDetailPage/CnDetailPage.vue +67 -5
  73. package/src/components/CnFormDialog/CnFormDialog.vue +92 -18
  74. package/src/components/CnIndexPage/CnIndexPage.md +21 -0
  75. package/src/components/CnIndexPage/CnIndexPage.vue +61 -51
  76. package/src/components/CnIndexPage/defaultActions.js +6 -7
  77. package/src/components/CnRowActions/CnRowActions.vue +36 -37
  78. package/src/components/CnSetupWizard/CnSetupWizard.vue +8 -2
  79. package/src/components/CnStatWidget/CnStatWidget.vue +2 -2
  80. package/src/components/CnStatsBlockWidget/CnStatsBlockWidget.vue +82 -3
  81. package/src/components/CnTimelineWidget/CnTimelineWidget.vue +3 -2
  82. package/src/composables/useContextMenu.js +4 -2
  83. package/src/mixins/pageViews.js +277 -10
  84. package/src/schemas/app-manifest-v2.schema.json +73 -9
  85. package/src/store/plugins/dashboardLayouts.js +34 -0
  86. package/src/utils/formatMetric.js +143 -15
  87. package/src/utils/resolveRowActions.js +127 -0
  88. package/src/utils/rowActionItem.js +100 -0
  89. package/src/utils/timelineEvents.js +43 -0
  90. package/src/utils/validateManifest.js +60 -3
  91. package/src/utils/validateManifestV2.compiled.js +2 -2
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: package/dist/nextcloud-vue.cjs.js.map
3
- size: 35372297 bytes
4
- sha256: 7065df25e721d338e4612aac2ee75c58c87fb5e17477c811e7bcf66cee41783d
3
+ size: 35499103 bytes
4
+ sha256: fe1cf925335eec45a51c59903eb32d313c16bdcbdaf047c4b80fad05712db22a
package/l10n/en.json CHANGED
@@ -1092,6 +1092,9 @@
1092
1092
  "An attention card needs a title": "An attention card needs a title",
1093
1093
  "Good morning, {name}": "Good morning, {name}",
1094
1094
  "This view has no widgets yet.": "This view has no widgets yet.",
1095
+ "Arrange view": "Arrange view",
1096
+ "Reset view": "Reset view",
1097
+ "Reset layout": "Reset layout",
1095
1098
  "Good morning": "Good morning",
1096
1099
  "Good afternoon, {name}": "Good afternoon, {name}",
1097
1100
  "Good afternoon": "Good afternoon",
package/l10n/nl.json CHANGED
@@ -1136,6 +1136,9 @@
1136
1136
  "An attention card needs a title": "Een aandachtskaart heeft een titel nodig",
1137
1137
  "Good morning, {name}": "Goedemorgen, {name}",
1138
1138
  "This view has no widgets yet.": "Deze weergave heeft nog geen widgets.",
1139
+ "Arrange view": "Weergave indelen",
1140
+ "Reset view": "Weergave herstellen",
1141
+ "Reset layout": "Indeling herstellen",
1139
1142
  "Good morning": "Goedemorgen",
1140
1143
  "Good afternoon, {name}": "Goedemiddag, {name}",
1141
1144
  "Good afternoon": "Goedemiddag",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/nextcloud-vue",
3
- "version": "2.67.0",
3
+ "version": "2.69.0",
4
4
  "description": "Shared Vue component library for Conduction Nextcloud apps — complements @nextcloud/vue with higher-level components, OpenRegister integration, and NL Design System support",
5
5
  "homepage": "https://github.com/ConductionNL/nextcloud-vue#readme",
6
6
  "bugs": {
@@ -111,7 +111,7 @@
111
111
  "CnIconPicker": 1,
112
112
  "CnImageWidget": 1,
113
113
  "CnImageWidgetForm": 1,
114
- "CnIndexPage": 0.74,
114
+ "CnIndexPage": 0.79,
115
115
  "CnIndexSidebar": 0.69,
116
116
  "CnInfoWidget": 1,
117
117
  "CnIntegrationCard": 1,
@@ -202,7 +202,7 @@
202
202
  "CnRichSubmitDialog": 1,
203
203
  "CnRoadmapItem": 1,
204
204
  "CnRoadmapTab": 1,
205
- "CnRowActions": 0.8,
205
+ "CnRowActions": 1,
206
206
  "CnSavedViewsControl": 1,
207
207
  "CnSaveViewDialog": 1,
208
208
  "CnSchemaFormDialog": 0.79,
@@ -12,7 +12,7 @@
12
12
  @close="onClose"
13
13
  @closed="onClosed">
14
14
  <!-- Dynamic actions from array prop -->
15
- <template v-for="{ action, link } in renderedActions" :key="action.label">
15
+ <template v-for="{ action, link } in renderedActions" :key="actionKey(action)">
16
16
  <!-- A navigate-only action is a real link, so it can be middle-clicked, opened in a new tab or copied. -->
17
17
  <NcActionLink
18
18
  v-if="link"
@@ -20,7 +20,7 @@
20
20
  :target="link.target"
21
21
  :title="resolveTitle(action)"
22
22
  :class="{ 'cn-row-action--destructive': action.destructive }"
23
- :data-testid="`cn-action-item-${slugifyLabel(action.label)}`"
23
+ :data-testid="actionTestId(action)"
24
24
  closeAfterClick
25
25
  @click="onLinkAction(action, link, $event)">
26
26
  <template v-if="action.icon" #icon>
@@ -34,7 +34,7 @@
34
34
  :title="resolveTitle(action)"
35
35
  :disabled="resolveDisabled(action)"
36
36
  :class="{ 'cn-row-action--destructive': action.destructive }"
37
- :data-testid="`cn-action-item-${slugifyLabel(action.label)}`"
37
+ :data-testid="actionTestId(action)"
38
38
  closeAfterClick
39
39
  @click="onAction(action)">
40
40
  <template v-if="action.icon" #icon>
@@ -95,6 +95,7 @@ import { NcActionButton, NcActionLink, NcActions } from '@nextcloud/vue'
95
95
  import { CTX_MENU_DATA_ATTR, CTX_MENU_POPPER_ATTR } from '../../composables/useContextMenu.js'
96
96
  import { followItemActionLink, resolveItemActionLink } from '../../utils/actionLink.js'
97
97
  import { isModifiedClick } from '../../utils/linkNavigation.js'
98
+ import { isRowActionVisible, rowActionKey, rowActionPayload, rowActionTestId, slugifyActionLabel } from '../../utils/rowActionItem.js'
98
99
  import { CnIcon } from '../CnIcon/index.js'
99
100
 
100
101
  /**
@@ -109,12 +110,13 @@ import { CnIcon } from '../CnIcon/index.js'
109
110
  * cursor positioning). The composable handles the DOM attributes; this component
110
111
  * handles the NcActions template boilerplate.
111
112
  *
112
- * Dynamic actions (CnIndexPage pattern)
113
+ * Dynamic actions (CnIndexPage pattern).
114
+ * CnIndexPage passes the same per-row list its row actions menu renders, so the two menus always match.
113
115
  * ```vue
114
116
  * <CnContextMenu
115
117
  * v-model:open="contextMenuOpen"
116
- * :actions="mergedActions"
117
- * :target-item="contextMenuRow"
118
+ * :actions="rowActionsFor(contextMenuShownRow)"
119
+ * :target-item="contextMenuShownRow"
118
120
  * @action="$emit('action', $event)"
119
121
  * @close="closeContextMenu" />
120
122
  * ```
@@ -161,13 +163,15 @@ export default {
161
163
  * `visible` (boolean | (targetItem) => boolean) hides the entry when falsy
162
164
  * (default: shown). `title` (string | (targetItem) => string) renders as
163
165
  * a native tooltip — useful for explaining why an entry is disabled.
166
+ * A locally decidable `visibleWhen` that is false for the target hides the entry too, as in CnRowActions.
167
+ * `id` is passed in the `action` payload; `builtin` marks a CnIndexPage built-in, whose testid is `cn-action-item-<id>` in every locale.
164
168
  * `href` (URL) or `to` (vue-router location), each a value or a
165
169
  * `(targetItem) => …` function, render the entry as a real link, with
166
170
  * `linkTarget` as its `target`; a link emits `action` but does not call
167
171
  * `handler`. When the entire array is empty (or all entries are filtered
168
172
  * out), only the default slot content is rendered.
169
173
  *
170
- * @type {Array<{label: string, icon: object | string, handler: (targetItem: object) => void, disabled: boolean | ((targetItem: object) => boolean), visible: boolean | ((targetItem: object) => boolean), title: string | ((targetItem: object) => string), destructive: boolean, href: string | ((targetItem: object) => string), to: string | object | ((targetItem: object) => string | object), linkTarget: string}>}
174
+ * @type {Array<{label: string, id: string, builtin: boolean, icon: object | string, handler: (targetItem: object) => void, disabled: boolean | ((targetItem: object) => boolean), visible: boolean | ((targetItem: object) => boolean), visibleWhen: object, title: string | ((targetItem: object) => string), destructive: boolean, href: string | ((targetItem: object) => string), to: string | object | ((targetItem: object) => string | object), linkTarget: string}>}
171
175
  */
172
176
  actions: {
173
177
  type: Array,
@@ -209,21 +213,13 @@ export default {
209
213
 
210
214
  computed: {
211
215
  /**
212
- * Filter actions by their `visible` predicate. Entries without
213
- * `visible` are always shown (backwards compatible).
216
+ * Filter actions by their `visibleWhen` and `visible` gates, with the same rule CnRowActions uses.
217
+ * Entries without either always show.
214
218
  *
215
219
  * @return {Array} Visible actions for the current targetItem.
216
220
  */
217
221
  visibleActions() {
218
- return this.actions.filter((action) => {
219
- if (action.visible === undefined) {
220
- return true
221
- }
222
- if (typeof action.visible === 'function') {
223
- return !!action.visible(this.targetItem)
224
- }
225
- return !!action.visible
226
- })
222
+ return this.actions.filter((action) => isRowActionVisible(action, this.targetItem))
227
223
  },
228
224
 
229
225
  /**
@@ -466,18 +462,33 @@ export default {
466
462
 
467
463
  /**
468
464
  * Slugify an action label for use in stable `data-testid` selectors.
469
- * Lowercase, kebab-case, strip non-alphanumeric. Used solely by the
470
- * `:data-testid` binding on NcActionButton — does not affect runtime
471
- * behaviour or rendered text.
465
+ * Lowercase, kebab-case, strip non-alphanumeric.
472
466
  *
473
467
  * @param {string} label The action's display label.
474
468
  * @return {string} kebab-case slug suitable for a testid suffix.
475
469
  */
476
470
  slugifyLabel(label) {
477
- return String(label || '')
478
- .toLowerCase()
479
- .replace(/[^a-z0-9]+/g, '-')
480
- .replace(/^-+|-+$/g, '')
471
+ return slugifyActionLabel(label)
472
+ },
473
+
474
+ /**
475
+ * The entry's `data-testid`: `cn-action-item-<id>` for a built-in, the label slug otherwise.
476
+ *
477
+ * @param {object} action The action descriptor.
478
+ * @return {string} The testid.
479
+ */
480
+ actionTestId(action) {
481
+ return rowActionTestId(action)
482
+ },
483
+
484
+ /**
485
+ * The entry's render key: `builtin:<id>` for a built-in, the label otherwise.
486
+ *
487
+ * @param {object} action The action descriptor.
488
+ * @return {string} The key.
489
+ */
490
+ actionKey(action) {
491
+ return rowActionKey(action)
481
492
  },
482
493
 
483
494
  onAction(action) {
@@ -485,10 +496,10 @@ export default {
485
496
  action.handler(this.targetItem)
486
497
  }
487
498
  /**
488
- * @event action User picked an entry from the menu. The action's own `handler(targetItem)` (when present) ran synchronously before this event fires; the event lets parents observe / log the choice.
489
- * @type {{ action: string, row: object|null }}
499
+ * @event action User picked an entry from the menu. The action's own `handler(targetItem)` (when present) ran synchronously before this event fires; the event lets parents observe / log the choice. Payload: `action` (the label), the target as `row`, the action's `id` when it has one, and `builtin: true` for a CnIndexPage built-in.
500
+ * @type {{ action: string, row: object|null, id?: string, builtin?: boolean }}
490
501
  */
491
- this.$emit('action', { action: action.label, row: this.targetItem })
502
+ this.$emit('action', rowActionPayload(action, this.targetItem))
492
503
  },
493
504
 
494
505
  /**
@@ -506,7 +517,7 @@ export default {
506
517
  return
507
518
  }
508
519
  followItemActionLink(event, link, this.$router)
509
- this.$emit('action', { action: action.label, row: this.targetItem })
520
+ this.$emit('action', rowActionPayload(action, this.targetItem))
510
521
  },
511
522
 
512
523
  onClose() {
@@ -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. -->
@@ -754,6 +767,7 @@ import Check from 'vue-material-design-icons/Check.vue'
754
767
  import Cog from 'vue-material-design-icons/Cog.vue'
755
768
  import Download from 'vue-material-design-icons/Download.vue'
756
769
  import Pencil from 'vue-material-design-icons/Pencil.vue'
770
+ import Restore from 'vue-material-design-icons/Restore.vue'
757
771
  import ViewDashboardOutline from 'vue-material-design-icons/ViewDashboardOutline.vue'
758
772
  import CnWidgetStyleEditorModal from '../../dialogs/CnWidgetStyleEditorModal.vue'
759
773
  import CnBodySections from '../CnBodySections/CnBodySections.vue'
@@ -768,9 +782,8 @@ import CnWidgetRefItem from '../CnWidgetRefItem/CnWidgetRefItem.vue'
768
782
  import CnWidgetRenderer from '../CnWidgetRenderer/CnWidgetRenderer.vue'
769
783
  import CnWidgetWrapper from '../CnWidgetWrapper/CnWidgetWrapper.vue'
770
784
  import { useIntegrationRegistry } from '../../composables/useIntegrationRegistry.js'
771
- import { readUserPreference, writeUserPreference } from '../../composables/useUserPreferences.js'
772
785
  import { pageViews } from '../../mixins/pageViews.js'
773
- import { dashboardLayoutKey, mergeUserLayout } from '../../store/plugins/dashboardLayouts.js'
786
+ import { mergeUserLayout, resolveUserLayoutApi } from '../../store/plugins/dashboardLayouts.js'
774
787
  import { isAppInstalled } from '../../utils/appInstalled.js'
775
788
  import { compareVisibleWhen, readVisibleWhenValue } from '../../utils/visibleWhen.js'
776
789
  import { canonicalWidgetType } from '../../utils/widgetTypeAliases.js'
@@ -905,6 +918,7 @@ export default {
905
918
  NcLoadingIcon,
906
919
  NcSelect,
907
920
  Pencil,
921
+ Restore,
908
922
  Check,
909
923
  Cog,
910
924
  CalendarRange,
@@ -1958,7 +1972,7 @@ export default {
1958
1972
  * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-view-with-nothing-to-draw-says-so
1959
1973
  */
1960
1974
  viewDisplayLayout() {
1961
- const items = this.activeView ? this.activeView.layout : []
1975
+ const items = this.activeViewLayout
1962
1976
  if (this.gridEditable) {
1963
1977
  return items
1964
1978
  }
@@ -2000,8 +2014,9 @@ export default {
2000
2014
  key: `view-${this.activeViewId}`,
2001
2015
  view: true,
2002
2016
  layout: this.viewDisplayLayout,
2003
- // A user arrangement covers the page's own layout only.
2004
- editable: this.gridEditable && !this.userLayout,
2017
+ // With `userLayout` the user arranges the view too; the
2018
+ // arrangement is kept per view (pageViews mixin).
2019
+ editable: this.gridEditable,
2005
2020
  attrs: {
2006
2021
  id: this.viewRegionId,
2007
2022
  role: 'region',
@@ -2721,6 +2736,7 @@ export default {
2721
2736
  // the user's arrangement is finished when they say it is.
2722
2737
  if (leaving) {
2723
2738
  this.saveUserLayout()
2739
+ this.saveViewUserLayouts()
2724
2740
  }
2725
2741
 
2726
2742
  /**
@@ -2780,6 +2796,17 @@ export default {
2780
2796
  }
2781
2797
  },
2782
2798
 
2799
+ /**
2800
+ * The edit-mode reset: the page's own grid and the chosen view return
2801
+ * to the manifest layout. Other views keep this user's arrangement.
2802
+ *
2803
+ * @return {Promise<void>}
2804
+ * @spec openspec/changes/page-view-user-layouts/specs/view-switch-containers/spec.md#requirement-resetting-a-view-restores-its-manifest-layout
2805
+ */
2806
+ async resetLayout() {
2807
+ await Promise.all([this.resetUserLayout(), this.resetViewUserLayout()])
2808
+ },
2809
+
2783
2810
  /**
2784
2811
  * Drop this user's arrangement and return to the manifest.
2785
2812
  *
@@ -2832,35 +2859,7 @@ export default {
2832
2859
  * @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
2860
  */
2834
2861
  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
- }
2862
+ return resolveUserLayoutApi(this.userLayoutStore)
2864
2863
  },
2865
2864
 
2866
2865
  onLayoutChange(updated) {
@@ -3906,7 +3905,7 @@ export default {
3906
3905
  // library's own defaults, so a manifest need not set them — but a
3907
3906
  // manifest that DOES set them must reach the component, or the
3908
3907
  // declaration is a silent no-op that reads like configuration.
3909
- for (const key of ['countLabel', 'variant', 'showZeroCount', 'horizontal', 'vertical', 'filled', 'route', 'iconClass']) {
3908
+ for (const key of ['countLabel', 'variant', 'showZeroCount', 'horizontal', 'vertical', 'filled', 'route', 'iconClass', 'format']) {
3910
3909
  if (props[key] !== undefined) {
3911
3910
  out[key] = props[key]
3912
3911
  }
@@ -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 |
@@ -157,6 +157,31 @@
157
157
  :controls="viewRegionId"
158
158
  data-testid="cn-detail-page-view-switch"
159
159
  @update:modelValue="selectView" />
160
+ <!-- With `userLayout`, the user arranges the chosen view for
161
+ themselves: Arrange view makes its grid draggable, Done
162
+ stores the arrangement, Reset view returns the view to the
163
+ layout the app ships. -->
164
+ <NcButton
165
+ v-if="userLayout && viewUserLayoutOn && !viewIsEmpty"
166
+ :variant="arrangingView ? 'primary' : 'secondary'"
167
+ data-testid="cn-detail-page-arrange-view"
168
+ @click="toggleArrangeView">
169
+ <template #icon>
170
+ <Check v-if="arrangingView" :size="20" />
171
+ <Pencil v-else :size="20" />
172
+ </template>
173
+ {{ arrangingView ? t('nextcloud-vue', 'Done') : t('nextcloud-vue', 'Arrange view') }}
174
+ </NcButton>
175
+ <NcButton
176
+ v-if="viewUserLayoutOn && arrangingView"
177
+ variant="tertiary"
178
+ data-testid="cn-detail-page-reset-view"
179
+ @click="resetViewUserLayout()">
180
+ <template #icon>
181
+ <Restore :size="20" />
182
+ </template>
183
+ {{ t('nextcloud-vue', 'Reset view') }}
184
+ </NcButton>
160
185
  <!-- Next and previous inside the list this record was opened
161
186
  from. Rendered only when the address names that list: a
162
187
  record reached by a bare link offers neither, rather than
@@ -626,10 +651,10 @@
626
651
  keyed on its view, so switching mounts the other view's grid. -->
627
652
  <CnDashboardGrid
628
653
  v-for="section in bodyGridSections"
629
- :key="`cn-detail-grid-${section.key}-${editingBody ? 'editing' : 'live'}`"
654
+ :key="`cn-detail-grid-${section.key}-${section.editable ? 'editing' : 'live'}`"
630
655
  v-bind="section.attrs"
631
656
  :layout="section.layout"
632
- :editable="editingBody"
657
+ :editable="section.editable"
633
658
  :columns="12"
634
659
  :columnOpts="columnOpts"
635
660
  class="cn-detail-page__grid"
@@ -1060,6 +1085,7 @@ import { NcActionButton, NcActionCaption, NcActionLink, NcActionSeparator, NcBut
1060
1085
  import { provide, ref, watch } from 'vue'
1061
1086
  import AlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue'
1062
1087
  import ArrowLeft from 'vue-material-design-icons/ArrowLeft.vue'
1088
+ import Check from 'vue-material-design-icons/Check.vue'
1063
1089
  import ChevronLeft from 'vue-material-design-icons/ChevronLeft.vue'
1064
1090
  import ChevronRight from 'vue-material-design-icons/ChevronRight.vue'
1065
1091
  import Cog from 'vue-material-design-icons/Cog.vue'
@@ -1068,6 +1094,7 @@ import InformationOutline from 'vue-material-design-icons/InformationOutline.vue
1068
1094
  import Pencil from 'vue-material-design-icons/Pencil.vue'
1069
1095
  import Plus from 'vue-material-design-icons/Plus.vue'
1070
1096
  import Refresh from 'vue-material-design-icons/Refresh.vue'
1097
+ import Restore from 'vue-material-design-icons/Restore.vue'
1071
1098
  import ViewDashboardOutline from 'vue-material-design-icons/ViewDashboardOutline.vue'
1072
1099
  import CnRelationLinkModal from '../../dialogs/CnRelationLinkModal.vue'
1073
1100
  import CnWidgetStyleEditorModal from '../../dialogs/CnWidgetStyleEditorModal.vue'
@@ -1274,9 +1301,11 @@ export default {
1274
1301
  CnTranslatedBadge,
1275
1302
  CnWidgetStyleEditorModal,
1276
1303
  CnRelationLinkModal,
1304
+ Check,
1277
1305
  Cog,
1278
1306
  Plus,
1279
1307
  Pencil,
1308
+ Restore,
1280
1309
  },
1281
1310
 
1282
1311
  mixins: [gridLayout, pageViews],
@@ -1769,6 +1798,20 @@ export default {
1769
1798
  default: '',
1770
1799
  },
1771
1800
 
1801
+ /**
1802
+ * Whether the user arranges each view (`views`) for themselves. On,
1803
+ * the header offers Arrange view next to the view switch; the
1804
+ * arrangement is stored per view and per user (the server, mirrored
1805
+ * in the browser) and a reset returns the view to its manifest
1806
+ * layout. Off (the default), a view renders as the manifest says.
1807
+ *
1808
+ * @type {boolean}
1809
+ */
1810
+ userLayout: {
1811
+ type: Boolean,
1812
+ default: false,
1813
+ },
1814
+
1772
1815
  /**
1773
1816
  * Whether the Refresh entry renders in the page-header menu.
1774
1817
  * Tri-state:
@@ -2419,6 +2462,8 @@ export default {
2419
2462
 
2420
2463
  data() {
2421
2464
  return {
2465
+ /** Whether the user is arranging the chosen view (`userLayout`). */
2466
+ arrangingView: false,
2422
2467
  /** Whether the record edit form is open. */
2423
2468
  editFormOpen: false,
2424
2469
  /**
@@ -3589,7 +3634,7 @@ export default {
3589
3634
  viewDisplayLayout() {
3590
3635
  // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- reading the flag IS the effect, as in bodyGridLayout
3591
3636
  this.editingBody
3592
- return this.activeView ? this.closeDroppedRows(this.activeView.layout) : []
3637
+ return this.activeView ? this.closeDroppedRows(this.activeViewLayout) : []
3593
3638
  },
3594
3639
 
3595
3640
  /**
@@ -3607,16 +3652,18 @@ export default {
3607
3652
  * chosen view's grid in the region the view switch controls (left out
3608
3653
  * when the view is empty; the empty state takes the region then).
3609
3654
  *
3610
- * @return {Array<{key: string, view: boolean, layout: Array<object>, attrs: object}>}
3655
+ * @return {Array<{key: string, view: boolean, layout: Array<object>, editable: boolean, attrs: object}>}
3611
3656
  * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-page-declares-views-that-each-hold-a-widget-grid
3612
3657
  */
3613
3658
  bodyGridSections() {
3614
- const sections = this.hasBodyGrid ? [{ key: 'body', view: false, layout: this.bodyGridLayout, attrs: {} }] : []
3659
+ const sections = this.hasBodyGrid ? [{ key: 'body', view: false, layout: this.bodyGridLayout, editable: this.editingBody, attrs: {} }] : []
3615
3660
  if (this.hasViews && !this.viewIsEmpty) {
3616
3661
  sections.push({
3617
3662
  key: `view-${this.activeViewId}`,
3618
3663
  view: true,
3619
3664
  layout: this.viewDisplayLayout,
3665
+ // The user arranges the view while Arrange view is on.
3666
+ editable: this.editingBody || this.arrangingView,
3620
3667
  attrs: {
3621
3668
  id: this.viewRegionId,
3622
3669
  role: 'region',
@@ -3941,6 +3988,21 @@ export default {
3941
3988
  },
3942
3989
 
3943
3990
  methods: {
3991
+ /**
3992
+ * Start or finish arranging the chosen view. Finishing stores the
3993
+ * arrangement of every view the user moved, once.
3994
+ *
3995
+ * @return {void}
3996
+ * @spec openspec/changes/page-view-user-layouts/specs/view-switch-containers/spec.md#requirement-a-user-arranges-each-view-for-themselves
3997
+ */
3998
+ toggleArrangeView() {
3999
+ const leaving = this.arrangingView
4000
+ this.arrangingView = !this.arrangingView
4001
+ if (leaving) {
4002
+ this.saveViewUserLayouts()
4003
+ }
4004
+ },
4005
+
3944
4006
  // Expose the shared grid helpers to the template (grid mode + auto-body).
3945
4007
  cnGridCellStyle,
3946
4008
  hasGridRow,