@conduction/nextcloud-vue 2.65.0 → 2.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +99 -2
  2. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  3. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +438 -383
  4. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  5. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js +9 -3
  6. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js.map +1 -1
  7. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js +1 -1
  8. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js.map +1 -1
  9. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +78 -21
  10. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  11. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +129 -96
  12. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  13. package/dist/esm/components/CnFkResolveCell/CnFkResolveCell.vue2.js +3 -1
  14. package/dist/esm/components/CnFkResolveCell/CnFkResolveCell.vue2.js.map +1 -1
  15. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +144 -4
  16. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  17. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  18. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js +63 -7
  19. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js.map +1 -1
  20. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js +2 -1
  21. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js.map +1 -1
  22. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue2.js +11 -0
  23. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue2.js.map +1 -1
  24. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue3.js +2 -1
  25. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue3.js.map +1 -1
  26. package/dist/esm/l10n/en.json.js +3 -0
  27. package/dist/esm/l10n/en.json.js.map +1 -1
  28. package/dist/esm/l10n/nl.json.js +3 -0
  29. package/dist/esm/l10n/nl.json.js.map +1 -1
  30. package/dist/esm/mixins/pageViews.js +400 -0
  31. package/dist/esm/mixins/pageViews.js.map +1 -0
  32. package/dist/esm/nextcloud-vue.css +18 -0
  33. package/dist/esm/utils/actionsDispatcher.js +11 -2
  34. package/dist/esm/utils/actionsDispatcher.js.map +1 -1
  35. package/dist/esm/utils/columnFilters.js +27 -3
  36. package/dist/esm/utils/columnFilters.js.map +1 -1
  37. package/dist/esm/utils/schema.js +40 -2
  38. package/dist/esm/utils/schema.js.map +1 -1
  39. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  40. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  41. package/dist/nextcloud-vue.cjs.js +1521 -596
  42. package/dist/nextcloud-vue.cjs.js.map +2 -2
  43. package/dist/nextcloud-vue.css +18 -0
  44. package/l10n/en.json +3 -0
  45. package/l10n/nl.json +3 -0
  46. package/package.json +1 -1
  47. package/src/components/CnDashboardPage/CnDashboardPage.vue +165 -8
  48. package/src/components/CnDataTable/CnColumnFilterPopover.vue +11 -5
  49. package/src/components/CnDetailPage/CnDetailPage.vue +94 -5
  50. package/src/components/CnFkResolveCell/CnFkResolveCell.vue +3 -1
  51. package/src/components/CnFormDialog/CnFormDialog.vue +144 -4
  52. package/src/components/CnHeaderWidget/CnHeaderWidget.vue +67 -8
  53. package/src/components/CnSegmentedControl/CnSegmentedControl.md +1 -1
  54. package/src/components/CnSegmentedControl/CnSegmentedControl.vue +12 -0
  55. package/src/css/detail-page.css +5 -0
  56. package/src/mixins/pageViews.js +396 -0
  57. package/src/schemas/app-manifest-v2.schema.json +29 -1
  58. package/src/utils/actionsDispatcher.js +11 -2
  59. package/src/utils/columnFilters.js +29 -2
  60. package/src/utils/schema.js +40 -2
  61. 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: 35252015 bytes
4
- sha256: fc6a53315288d3796fbfb1aabafee9aa7c5145a0eadbfa2e240269fa544b9764
3
+ size: 35355767 bytes
4
+ sha256: e048d3627c1eba03f90228c9f300e9341baf3086530119fe40b4af2106f5b7b2
@@ -3279,6 +3279,11 @@ a.cn-kpi-card--stacked:focus-visible {
3279
3279
  font-size: 0.9em;
3280
3280
  white-space: nowrap;
3281
3281
  }
3282
+
3283
+ /* A page view the switch shows with nothing to draw (view-switch-containers). */
3284
+ .cn-detail-page .cn-page-view-region--empty {
3285
+ padding: 40px 20px;
3286
+ }
3282
3287
  /* CnReferencePreview — a reference that shows what it points at. */
3283
3288
 
3284
3289
  .cn-reference-preview {
@@ -30567,6 +30572,19 @@ to {
30567
30572
  .cn-dashboard-page__empty[data-v-824052fe] {
30568
30573
  padding: 60px 20px;
30569
30574
  }
30575
+
30576
+ /* The page's view switch on a page without a header row: at the end of its
30577
+ own row, above the grids, where the header would have put it. */
30578
+ .cn-dashboard-page__view-switch[data-v-824052fe] {
30579
+ display: flex;
30580
+ justify-content: flex-end;
30581
+ margin-bottom: 12px;
30582
+ }
30583
+
30584
+ /* A view the switch shows with nothing to draw. */
30585
+ .cn-page-view-region--empty[data-v-824052fe] {
30586
+ padding: 40px 20px;
30587
+ }
30570
30588
  .cn-dashboard-page__page-filters[data-v-824052fe] {
30571
30589
  display: flex;
30572
30590
  flex-wrap: wrap;
package/l10n/en.json CHANGED
@@ -49,6 +49,7 @@
49
49
  "This integration is not available.": "This integration is not available.",
50
50
  "Integrations": "Integrations",
51
51
  "View": "View",
52
+ "Views": "Views",
52
53
  "current step": "current step",
53
54
  "{app} not available": "{app} not available",
54
55
  "{app} not configured": "{app} not configured",
@@ -408,6 +409,7 @@
408
409
  "Any": "Any",
409
410
  "To": "To",
410
411
  "Equals": "Equals",
412
+ "Contains": "Contains",
411
413
  "Clear": "Clear",
412
414
  "Apply": "Apply",
413
415
  "This column cannot list its objects.": "This column cannot list its objects.",
@@ -1088,6 +1090,7 @@
1088
1090
  "The actions of an attention card are declared in the manifest.": "The actions of an attention card are declared in the manifest.",
1089
1091
  "An attention card needs a title": "An attention card needs a title",
1090
1092
  "Good morning, {name}": "Good morning, {name}",
1093
+ "This view has no widgets yet.": "This view has no widgets yet.",
1091
1094
  "Good morning": "Good morning",
1092
1095
  "Good afternoon, {name}": "Good afternoon, {name}",
1093
1096
  "Good afternoon": "Good afternoon",
package/l10n/nl.json CHANGED
@@ -49,6 +49,7 @@
49
49
  "This integration is not available.": "Deze integratie is niet beschikbaar.",
50
50
  "Integrations": "Integraties",
51
51
  "View": "Weergave",
52
+ "Views": "Weergaven",
52
53
  "current step": "huidige stap",
53
54
  "{app} not available": "{app} niet beschikbaar",
54
55
  "{app} not configured": "{app} niet geconfigureerd",
@@ -408,6 +409,7 @@
408
409
  "Any": "Alle",
409
410
  "To": "Tot",
410
411
  "Equals": "Is gelijk aan",
412
+ "Contains": "Bevat",
411
413
  "Clear": "Wissen",
412
414
  "Apply": "Toepassen",
413
415
  "This column cannot list its objects.": "Deze kolom kan zijn objecten niet tonen.",
@@ -1132,6 +1134,7 @@
1132
1134
  "The actions of an attention card are declared in the manifest.": "De acties van een aandachtskaart staan in het manifest.",
1133
1135
  "An attention card needs a title": "Een aandachtskaart heeft een titel nodig",
1134
1136
  "Good morning, {name}": "Goedemorgen, {name}",
1137
+ "This view has no widgets yet.": "Deze weergave heeft nog geen widgets.",
1135
1138
  "Good morning": "Goedemorgen",
1136
1139
  "Good afternoon, {name}": "Goedemiddag, {name}",
1137
1140
  "Good afternoon": "Goedemiddag",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/nextcloud-vue",
3
- "version": "2.65.0",
3
+ "version": "2.66.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": {
@@ -32,6 +32,18 @@
32
32
  </p>
33
33
  </div>
34
34
  <div class="cn-dashboard-page__header-actions">
35
+ <!-- The page's view switch (`views`), unless a greeting header
36
+ widget on the page draws it. Its options control the view
37
+ region below the grid. -->
38
+ <CnSegmentedControl
39
+ v-if="showsPageViewSwitch"
40
+ class="cn-page-view-switch"
41
+ :options="viewSwitchOptions"
42
+ :modelValue="activeViewId"
43
+ :ariaLabel="viewSwitchLabel"
44
+ :controls="viewRegionId"
45
+ data-testid="cn-dashboard-page-view-switch"
46
+ @update:modelValue="selectView" />
35
47
  <!-- Declarative header actions (#91 Wave 3): a manifest
36
48
  `headerActions[]` renders as buttons (open-form / api-call /
37
49
  toggle / navigate / refresh) with visibleWhen gating,
@@ -218,7 +230,7 @@
218
230
  sections (before-grid / after-grid / end): a page can legitimately
219
231
  have no grid widgets yet still show bodyWidget content, so
220
232
  "No widgets configured" would be a false negative. -->
221
- <div v-else-if="!hasWidgets && !hasBodyWidgets" class="cn-dashboard-page__empty">
233
+ <div v-else-if="!hasWidgets && !hasBodyWidgets && !hasViews" class="cn-dashboard-page__empty">
222
234
  <!-- @slot empty Replaces the default empty state shown when the dashboard has no widgets. Defaults to an `NcEmptyContent` block. -->
223
235
  <slot name="empty">
224
236
  <NcEmptyContent :description="emptyLabel">
@@ -253,15 +265,34 @@
253
265
  to the layout/widgets arrays — in-place pushes (Add widget) keep
254
266
  the same array identity and can't re-render it. One remount per
255
267
  edit flip re-subscribes against the now-reactive graph. -->
268
+ <!-- The view switch for a page without a header row, when no greeting
269
+ header widget draws it. -->
270
+ <div
271
+ v-if="gridSectionsShown && !showHeader && showsPageViewSwitch"
272
+ class="cn-dashboard-page__view-switch">
273
+ <CnSegmentedControl
274
+ class="cn-page-view-switch"
275
+ :options="viewSwitchOptions"
276
+ :modelValue="activeViewId"
277
+ :ariaLabel="viewSwitchLabel"
278
+ :controls="viewRegionId"
279
+ data-testid="cn-dashboard-page-view-switch"
280
+ @update:modelValue="selectView" />
281
+ </div>
282
+ <!-- One grid per section: the page's own layout, then the chosen
283
+ view (`views`) in a region the view switch controls. Both are the
284
+ same grid with the same widget rendering below. A view grid is
285
+ keyed on its view, so switching mounts the other view's grid. -->
256
286
  <CnDashboardGrid
257
- v-else
258
- :key="`cn-dashboard-grid-${gridEditable ? 'editing' : 'live'}`"
259
- :layout="displayLayout"
260
- :editable="gridEditable"
287
+ v-for="section in gridSections"
288
+ :key="`cn-dashboard-grid-${section.key}-${gridEditable ? 'editing' : 'live'}`"
289
+ v-bind="section.attrs"
290
+ :layout="section.layout"
291
+ :editable="section.editable"
261
292
  :columns="columns"
262
293
  :cellHeight="cellHeight"
263
294
  :margin="gridMargin"
264
- @layoutChange="onLayoutChange">
295
+ @layoutChange="section.view ? onViewLayoutChange($event) : onLayoutChange($event)">
265
296
  <template #widget="{ item }">
266
297
  <!-- In-app edit overlay (ADR-041): a single launchpad-style
267
298
  configure cog opens the per-widget style/config editor,
@@ -655,6 +686,22 @@
655
686
  </template>
656
687
  </CnDashboardGrid>
657
688
 
689
+ <!-- The chosen view has nothing to draw: say so in the region, never
690
+ leave a blank area under the switch. -->
691
+ <div
692
+ v-if="gridSectionsShown && viewIsEmpty"
693
+ :id="viewRegionId"
694
+ role="region"
695
+ :aria-label="activeView ? activeView.label : null"
696
+ class="cn-page-view-region cn-page-view-region--empty"
697
+ data-testid="cn-dashboard-page-view-empty">
698
+ <NcEmptyContent :description="viewEmptyText">
699
+ <template #icon>
700
+ <ViewDashboardOutline :size="48" />
701
+ </template>
702
+ </NcEmptyContent>
703
+ </div>
704
+
658
705
  <!-- Declarative in-body sections, `placement: "after-grid"` — host-app
659
706
  section components rendered BELOW the widget grid. -->
660
707
  <CnBodySections
@@ -714,6 +761,7 @@ import CnBuildiqEditButton from '../CnBuildiqEditButton/CnBuildiqEditButton.vue'
714
761
  import CnChartWidget from '../CnChartWidget/CnChartWidget.vue'
715
762
  import CnDashboardGrid from '../CnDashboardGrid/CnDashboardGrid.vue'
716
763
  import CnDateRangePicker, { DEFAULT_DATE_RANGE_PRESETS, resolvePresetWindow } from '../CnDateRangePicker/CnDateRangePicker.vue'
764
+ import CnSegmentedControl from '../CnSegmentedControl/CnSegmentedControl.vue'
717
765
  import CnStatsBlockWidget from '../CnStatsBlockWidget/CnStatsBlockWidget.vue'
718
766
  import CnTileWidget from '../CnTileWidget/CnTileWidget.vue'
719
767
  import CnWidgetRefItem from '../CnWidgetRefItem/CnWidgetRefItem.vue'
@@ -721,6 +769,7 @@ import CnWidgetRenderer from '../CnWidgetRenderer/CnWidgetRenderer.vue'
721
769
  import CnWidgetWrapper from '../CnWidgetWrapper/CnWidgetWrapper.vue'
722
770
  import { useIntegrationRegistry } from '../../composables/useIntegrationRegistry.js'
723
771
  import { readUserPreference, writeUserPreference } from '../../composables/useUserPreferences.js'
772
+ import { pageViews } from '../../mixins/pageViews.js'
724
773
  import { dashboardLayoutKey, mergeUserLayout } from '../../store/plugins/dashboardLayouts.js'
725
774
  import { isAppInstalled } from '../../utils/appInstalled.js'
726
775
  import { compareVisibleWhen, readVisibleWhenValue } from '../../utils/visibleWhen.js'
@@ -875,8 +924,11 @@ export default {
875
924
  CnBuildiqEditButton,
876
925
  CnWidgetStyleEditorModal,
877
926
  CnLeafMountHost,
927
+ CnSegmentedControl,
878
928
  },
879
929
 
930
+ mixins: [pageViews],
931
+
880
932
  inject: {
881
933
  /**
882
934
  * Buildiq in-app edit state (ADR-041), provided by CnAppRoot as a ref.
@@ -1879,6 +1931,89 @@ export default {
1879
1931
  return this.layout.length > 0 || this.widgetRefItems.length > 0
1880
1932
  },
1881
1933
 
1934
+ /**
1935
+ * Whether the grid sections render at all: not while loading, not
1936
+ * while the widget predicates settle, not on the empty page and not
1937
+ * on a widget-ref content page.
1938
+ *
1939
+ * @return {boolean}
1940
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-page-declares-views-that-each-hold-a-widget-grid
1941
+ */
1942
+ gridSectionsShown() {
1943
+ if (this.loading || (!this.widgetConditionsSettled && !this.gridEditable)) {
1944
+ return false
1945
+ }
1946
+ if (!this.hasWidgets && !this.hasBodyWidgets && !this.hasViews) {
1947
+ return false
1948
+ }
1949
+ return this.widgetRefItems.length === 0
1950
+ },
1951
+
1952
+ /**
1953
+ * The chosen view's layout as the grid draws it: in live mode the
1954
+ * widgets that would draw nothing leave and the rest close up, as
1955
+ * for the page's own layout.
1956
+ *
1957
+ * @return {Array<object>}
1958
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-view-with-nothing-to-draw-says-so
1959
+ */
1960
+ viewDisplayLayout() {
1961
+ const items = this.activeView ? this.activeView.layout : []
1962
+ if (this.gridEditable) {
1963
+ return items
1964
+ }
1965
+ const visible = items.filter((item) => !this.isCollapsedWidget(item))
1966
+ return visible.length === items.length ? items : this.compactDisplayLayout(visible)
1967
+ },
1968
+
1969
+ /**
1970
+ * Whether the chosen view has nothing to draw.
1971
+ *
1972
+ * @return {boolean}
1973
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-view-with-nothing-to-draw-says-so
1974
+ */
1975
+ viewIsEmpty() {
1976
+ return this.hasViews && this.viewDisplayLayout.length === 0
1977
+ },
1978
+
1979
+ /**
1980
+ * The grids this page draws. Without views: the page's own layout,
1981
+ * as it always was. With views: the page's own layout when it has
1982
+ * items, then the chosen view's layout in the region the switch
1983
+ * controls (left out when the view is empty; the empty state takes
1984
+ * the region then).
1985
+ *
1986
+ * @return {Array<{key: string, view: boolean, layout: Array<object>, editable: boolean, attrs: object}>}
1987
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-page-declares-views-that-each-hold-a-widget-grid
1988
+ */
1989
+ gridSections() {
1990
+ if (!this.gridSectionsShown) {
1991
+ return []
1992
+ }
1993
+ const page = { key: 'page', view: false, layout: this.displayLayout, editable: this.gridEditable, attrs: {} }
1994
+ if (!this.hasViews) {
1995
+ return [page]
1996
+ }
1997
+ const sections = this.displayLayout.length > 0 ? [page] : []
1998
+ if (!this.viewIsEmpty) {
1999
+ sections.push({
2000
+ key: `view-${this.activeViewId}`,
2001
+ view: true,
2002
+ layout: this.viewDisplayLayout,
2003
+ // A user arrangement covers the page's own layout only.
2004
+ editable: this.gridEditable && !this.userLayout,
2005
+ attrs: {
2006
+ id: this.viewRegionId,
2007
+ role: 'region',
2008
+ 'aria-label': this.activeView.label,
2009
+ class: 'cn-page-view-region',
2010
+ 'data-testid': 'cn-dashboard-page-view-region',
2011
+ },
2012
+ })
2013
+ }
2014
+ return sections
2015
+ },
2016
+
1882
2017
  /**
1883
2018
  * The widget settings object handed to CnWidgetStyleEditorModal,
1884
2019
  * built from the widget definition currently being configured
@@ -2009,6 +2144,14 @@ export default {
2009
2144
  this.evaluateWidgetConditions()
2010
2145
  },
2011
2146
  },
2147
+
2148
+ /** A view's widgets carry predicates too. */
2149
+ views: {
2150
+ deep: true,
2151
+ handler() {
2152
+ this.evaluateWidgetConditions()
2153
+ },
2154
+ },
2012
2155
  },
2013
2156
 
2014
2157
  created() {
@@ -2873,7 +3016,8 @@ export default {
2873
3016
  * @spec openspec/changes/live-check-follow-ups/specs/live-check-follow-ups/spec.md#requirement-an-attention-card-says-when-it-could-not-check
2874
3017
  */
2875
3018
  async evaluateWidgetConditions() {
2876
- const conditional = (this.widgets || []).filter((def) => def && def.id && def.type
3019
+ const defs = [...(this.widgets || []), ...this.normalizedViews.flatMap((view) => view.widgets)]
3020
+ const conditional = defs.filter((def) => def && def.id && def.type
2877
3021
  && this.widgetDisplayConfig(def).visibleWhen)
2878
3022
  // Skip when the CONDITIONAL SET is unchanged: the deep `widgets`
2879
3023
  // watch fires on any def edit, but only an added/removed/edited
@@ -3007,7 +3151,7 @@ export default {
3007
3151
  */
3008
3152
  getWidgetDef(widgetId) {
3009
3153
  const list = Array.isArray(this.widgets) ? this.widgets : []
3010
- return list.find((w) => w && w.id === widgetId) || null
3154
+ return list.find((w) => w && w.id === widgetId) || this.findViewWidget(widgetId)
3011
3155
  },
3012
3156
 
3013
3157
  /**
@@ -4021,6 +4165,19 @@ export default {
4021
4165
  padding: 60px 20px;
4022
4166
  }
4023
4167
 
4168
+ /* The page's view switch on a page without a header row: at the end of its
4169
+ own row, above the grids, where the header would have put it. */
4170
+ .cn-dashboard-page__view-switch {
4171
+ display: flex;
4172
+ justify-content: flex-end;
4173
+ margin-bottom: 12px;
4174
+ }
4175
+
4176
+ /* A view the switch shows with nothing to draw. */
4177
+ .cn-page-view-region--empty {
4178
+ padding: 40px 20px;
4179
+ }
4180
+
4024
4181
  .cn-dashboard-page__page-filters {
4025
4182
  display: flex;
4026
4183
  flex-wrap: wrap;
@@ -91,9 +91,9 @@
91
91
  </label>
92
92
  </div>
93
93
 
94
- <!-- string: equals -->
94
+ <!-- string: contains (OpenRegister `[like]`, case-insensitive) -->
95
95
  <label v-else class="cn-column-filter__field">
96
- <span class="cn-column-filter__label">{{ equalsLabel }}</span>
96
+ <span class="cn-column-filter__label">{{ containsLabel }}</span>
97
97
  <input
98
98
  v-model="draft.value"
99
99
  class="cn-column-filter__input"
@@ -131,7 +131,7 @@ let popoverSeq = 0
131
131
  *
132
132
  * Renders the control that fits the column: a checkbox list for an enum, yes,
133
133
  * no or any for a boolean, from and to for a number or a date, a searchable
134
- * list of referenced objects for a reference, and an equals box for text.
134
+ * list of referenced objects for a reference, and a contains box for text.
135
135
  * Real inputs throughout, so every control works from the keyboard. Escape
136
136
  * closes it and the host returns focus to the filter button.
137
137
  *
@@ -265,8 +265,14 @@ export default {
265
265
  return t('nextcloud-vue', 'To')
266
266
  },
267
267
 
268
- equalsLabel() {
269
- return t('nextcloud-vue', 'Equals')
268
+ /**
269
+ * Label of the text box: the text filter matches on contains.
270
+ *
271
+ * @return {string}
272
+ * @spec openspec/changes/header-filter-contains/specs/cn-data-table/spec.md#requirement-a-text-header-filter-matches-on-contains
273
+ */
274
+ containsLabel() {
275
+ return t('nextcloud-vue', 'Contains')
270
276
  },
271
277
 
272
278
  clearLabel() {
@@ -145,6 +145,18 @@
145
145
  </div>
146
146
  </slot>
147
147
  <div class="cn-detail-page__header-actions">
148
+ <!-- The page's view switch (`views`), unless a greeting header
149
+ widget on the page draws it. Its options control the view
150
+ region in the body. -->
151
+ <CnSegmentedControl
152
+ v-if="showsPageViewSwitch"
153
+ class="cn-page-view-switch"
154
+ :options="viewSwitchOptions"
155
+ :modelValue="activeViewId"
156
+ :ariaLabel="viewSwitchLabel"
157
+ :controls="viewRegionId"
158
+ data-testid="cn-detail-page-view-switch"
159
+ @update:modelValue="selectView" />
148
160
  <!-- Next and previous inside the list this record was opened
149
161
  from. Rendered only when the address names that list: a
150
162
  record reached by a bare link offers neither, rather than
@@ -609,15 +621,19 @@
609
621
  subscribed to the layout/widgets arrays — in-place pushes
610
622
  (Add widget) keep the same array identity and can't re-render
611
623
  it. One remount per edit flip re-subscribes it. -->
624
+ <!-- One grid per section: the body grid, then the chosen view
625
+ (`views`) in a region the view switch controls. A view grid is
626
+ keyed on its view, so switching mounts the other view's grid. -->
612
627
  <CnDashboardGrid
613
- v-if="hasBodyGrid"
614
- :key="`cn-detail-grid-${editingBody ? 'editing' : 'live'}`"
615
- :layout="bodyGridLayout"
628
+ v-for="section in bodyGridSections"
629
+ :key="`cn-detail-grid-${section.key}-${editingBody ? 'editing' : 'live'}`"
630
+ v-bind="section.attrs"
631
+ :layout="section.layout"
616
632
  :editable="editingBody"
617
633
  :columns="12"
618
634
  :columnOpts="columnOpts"
619
635
  class="cn-detail-page__grid"
620
- @layoutChange="onBodyLayoutChange">
636
+ @layoutChange="section.view ? onViewLayoutChange($event) : onBodyLayoutChange($event)">
621
637
  <template #widget="{ item }">
622
638
  <div
623
639
  class="cn-detail-page__grid-item"
@@ -727,6 +743,22 @@
727
743
  </template>
728
744
  </CnDashboardGrid>
729
745
 
746
+ <!-- The chosen view has nothing to draw: say so in the region,
747
+ never leave a blank area under the switch. -->
748
+ <div
749
+ v-if="viewIsEmpty"
750
+ :id="viewRegionId"
751
+ role="region"
752
+ :aria-label="activeView ? activeView.label : null"
753
+ class="cn-page-view-region cn-page-view-region--empty"
754
+ data-testid="cn-detail-page-view-empty">
755
+ <NcEmptyContent :description="viewEmptyText">
756
+ <template #icon>
757
+ <ViewDashboardOutline :size="48" />
758
+ </template>
759
+ </NcEmptyContent>
760
+ </div>
761
+
730
762
  <!-- Statistics table -->
731
763
  <div v-if="hasStats" class="cn-detail-page__stats">
732
764
  <!--
@@ -1036,6 +1068,7 @@ import InformationOutline from 'vue-material-design-icons/InformationOutline.vue
1036
1068
  import Pencil from 'vue-material-design-icons/Pencil.vue'
1037
1069
  import Plus from 'vue-material-design-icons/Plus.vue'
1038
1070
  import Refresh from 'vue-material-design-icons/Refresh.vue'
1071
+ import ViewDashboardOutline from 'vue-material-design-icons/ViewDashboardOutline.vue'
1039
1072
  import CnRelationLinkModal from '../../dialogs/CnRelationLinkModal.vue'
1040
1073
  import CnWidgetStyleEditorModal from '../../dialogs/CnWidgetStyleEditorModal.vue'
1041
1074
  import CnActionsMenu from '../CnActionsMenu/CnActionsMenu.vue'
@@ -1048,6 +1081,7 @@ import CnLifecycleActions from '../CnLifecycleActions/CnLifecycleActions.vue'
1048
1081
  import CnLockedBanner from '../CnLockedBanner/CnLockedBanner.vue'
1049
1082
  import CnNextStepCard from '../CnNextStepCard/CnNextStepCard.vue'
1050
1083
  import CnRelatedCollections from '../CnRelatedCollections/CnRelatedCollections.vue'
1084
+ import CnSegmentedControl from '../CnSegmentedControl/CnSegmentedControl.vue'
1051
1085
  import CnSummaryAggregates from '../CnSummaryAggregates/CnSummaryAggregates.vue'
1052
1086
  import CnTranslatedBadge from '../CnTranslatedBadge/CnTranslatedBadge.vue'
1053
1087
  import { useIntegrationRegistry } from '../../composables/useIntegrationRegistry.js'
@@ -1055,6 +1089,7 @@ import { useObjectLock } from '../../composables/useObjectLock.js'
1055
1089
  import { useObjectPresence } from '../../composables/useObjectPresence.js'
1056
1090
  import { useObjectSubscription } from '../../composables/useObjectSubscription.js'
1057
1091
  import { gridLayout } from '../../mixins/gridLayout.js'
1092
+ import { pageViews } from '../../mixins/pageViews.js'
1058
1093
  import { useObjectStore } from '../../store/index.js'
1059
1094
  import { isAppInstalled } from '../../utils/appInstalled.js'
1060
1095
  import { compactLayoutRows } from '../../utils/dashboardPlacement.js'
@@ -1227,6 +1262,8 @@ export default {
1227
1262
  CnDetailWidgetHost,
1228
1263
  CnFormDialog,
1229
1264
  CnDashboardGrid,
1265
+ CnSegmentedControl,
1266
+ ViewDashboardOutline,
1230
1267
  CnLifecycleActions,
1231
1268
  CnActionButtons,
1232
1269
  CnBreadcrumbs,
@@ -1242,7 +1279,7 @@ export default {
1242
1279
  Pencil,
1243
1280
  },
1244
1281
 
1245
- mixins: [gridLayout],
1282
+ mixins: [gridLayout, pageViews],
1246
1283
 
1247
1284
  inject: {
1248
1285
  /**
@@ -3462,6 +3499,7 @@ export default {
3462
3499
  && this.currentObject
3463
3500
  && !this.hasDefaultSlotContent
3464
3501
  && !this.hasGridLayout
3502
+ && !this.hasViews
3465
3503
  },
3466
3504
 
3467
3505
  /**
@@ -3541,6 +3579,55 @@ export default {
3541
3579
  return this.bodyGridLayout.length > 0
3542
3580
  },
3543
3581
 
3582
+ /**
3583
+ * The chosen view's layout as the grid draws it: items that draw
3584
+ * nothing leave and their rows close, as for the body grid.
3585
+ *
3586
+ * @return {Array<object>}
3587
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-view-with-nothing-to-draw-says-so
3588
+ */
3589
+ viewDisplayLayout() {
3590
+ // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- reading the flag IS the effect, as in bodyGridLayout
3591
+ this.editingBody
3592
+ return this.activeView ? this.closeDroppedRows(this.activeView.layout) : []
3593
+ },
3594
+
3595
+ /**
3596
+ * Whether the chosen view has nothing to draw.
3597
+ *
3598
+ * @return {boolean}
3599
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-view-with-nothing-to-draw-says-so
3600
+ */
3601
+ viewIsEmpty() {
3602
+ return this.hasViews && this.viewDisplayLayout.length === 0
3603
+ },
3604
+
3605
+ /**
3606
+ * The grids the body draws: the body grid when it has items, then the
3607
+ * chosen view's grid in the region the view switch controls (left out
3608
+ * when the view is empty; the empty state takes the region then).
3609
+ *
3610
+ * @return {Array<{key: string, view: boolean, layout: Array<object>, attrs: object}>}
3611
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-page-declares-views-that-each-hold-a-widget-grid
3612
+ */
3613
+ bodyGridSections() {
3614
+ const sections = this.hasBodyGrid ? [{ key: 'body', view: false, layout: this.bodyGridLayout, attrs: {} }] : []
3615
+ if (this.hasViews && !this.viewIsEmpty) {
3616
+ sections.push({
3617
+ key: `view-${this.activeViewId}`,
3618
+ view: true,
3619
+ layout: this.viewDisplayLayout,
3620
+ attrs: {
3621
+ id: this.viewRegionId,
3622
+ role: 'region',
3623
+ 'aria-label': this.activeView.label,
3624
+ 'data-testid': 'cn-detail-page-view-region',
3625
+ },
3626
+ })
3627
+ }
3628
+ return sections
3629
+ },
3630
+
3544
3631
  /**
3545
3632
  * Whether the sidebar is rendered externally (via objectSidebarState inject)
3546
3633
  * rather than inline. When external, CnDetailPage only manages state —
@@ -4762,6 +4849,8 @@ export default {
4762
4849
  */
4763
4850
  findWidget(item) {
4764
4851
  return this.bodyGridWidgets.find((w) => w.id === item.widgetId)
4852
+ || this.findViewWidget(item.widgetId)
4853
+ || undefined
4765
4854
  },
4766
4855
 
4767
4856
  /**
@@ -135,7 +135,9 @@ export default {
135
135
  if (!store) {
136
136
  return
137
137
  }
138
- const type = resolveObjectOpType(store, { register: this.register, schema: this.schema })
138
+ // `schema` is a slug the manifest author wrote, not a `$ref` title:
139
+ // use it exactly as given (a camelCase slug must not be kebab-cased).
140
+ const type = resolveObjectOpType(store, { register: this.register, schema: this.schema }, { exactSchema: true })
139
141
  await Promise.all(this.ids.map(async (id) => {
140
142
  if (this.labels[id]) {
141
143
  return