@conduction/nextcloud-vue 2.56.0-beta.12 → 2.56.0-beta.14

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 (119) hide show
  1. package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js +19 -0
  2. package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js.map +1 -1
  3. package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js +10 -3
  4. package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js.map +1 -1
  5. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +101 -5
  6. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
  7. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js +23 -7
  8. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
  9. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue2.js +13 -0
  10. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue2.js.map +1 -1
  11. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js +2 -1
  12. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js.map +1 -1
  13. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +125 -4
  14. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  15. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +439 -381
  16. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  17. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js +9 -3
  18. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js.map +1 -1
  19. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js +1 -1
  20. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js.map +1 -1
  21. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +4 -4
  22. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  23. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  24. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +142 -26
  25. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  26. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +195 -124
  27. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  28. package/dist/esm/components/CnFkResolveCell/CnFkResolveCell.vue2.js +3 -1
  29. package/dist/esm/components/CnFkResolveCell/CnFkResolveCell.vue2.js.map +1 -1
  30. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +144 -4
  31. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  32. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  33. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js +84 -11
  34. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js.map +1 -1
  35. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js +3 -2
  36. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js.map +1 -1
  37. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +31 -1
  38. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  39. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +335 -313
  40. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  41. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue2.js +11 -0
  42. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue2.js.map +1 -1
  43. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue3.js +2 -1
  44. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue3.js.map +1 -1
  45. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js +5 -11
  46. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js.map +1 -1
  47. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js.map +1 -1
  48. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js.map +1 -1
  49. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js.map +1 -1
  50. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +22 -3
  51. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
  52. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js +10 -3
  53. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
  54. package/dist/esm/composables/useDependencyCheck.js +44 -1
  55. package/dist/esm/composables/useDependencyCheck.js.map +1 -1
  56. package/dist/esm/composables/useListView.js +43 -1
  57. package/dist/esm/composables/useListView.js.map +1 -1
  58. package/dist/esm/composables/useSetupStatus.js +26 -3
  59. package/dist/esm/composables/useSetupStatus.js.map +1 -1
  60. package/dist/esm/l10n/en.json.js +7 -1
  61. package/dist/esm/l10n/en.json.js.map +1 -1
  62. package/dist/esm/l10n/nl.json.js +7 -1
  63. package/dist/esm/l10n/nl.json.js.map +1 -1
  64. package/dist/esm/mixins/pageViews.js +400 -0
  65. package/dist/esm/mixins/pageViews.js.map +1 -0
  66. package/dist/esm/nextcloud-vue.css +202 -2
  67. package/dist/esm/store/plugins/liveUpdates.js +19 -9
  68. package/dist/esm/store/plugins/liveUpdates.js.map +1 -1
  69. package/dist/esm/store/useObjectStore.js +138 -42
  70. package/dist/esm/store/useObjectStore.js.map +1 -1
  71. package/dist/esm/utils/actionsDispatcher.js +25 -2
  72. package/dist/esm/utils/actionsDispatcher.js.map +1 -1
  73. package/dist/esm/utils/columnFilters.js +27 -3
  74. package/dist/esm/utils/columnFilters.js.map +1 -1
  75. package/dist/esm/utils/schema.js +40 -2
  76. package/dist/esm/utils/schema.js.map +1 -1
  77. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  78. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  79. package/dist/nextcloud-vue.cjs.js +2857 -1335
  80. package/dist/nextcloud-vue.cjs.js.map +2 -2
  81. package/dist/nextcloud-vue.css +202 -2
  82. package/l10n/en.json +7 -1
  83. package/l10n/nl.json +7 -1
  84. package/package.json +1 -1
  85. package/src/components/CnActionsBar/CnActionsBar.md +2 -1
  86. package/src/components/CnActionsBar/CnActionsBar.vue +25 -1
  87. package/src/components/CnAppNav/CnAppNav.vue +150 -9
  88. package/src/components/CnBreadcrumbs/CnBreadcrumbs.vue +14 -0
  89. package/src/components/CnDashboardPage/CnDashboardPage.vue +194 -10
  90. package/src/components/CnDataTable/CnColumnFilterPopover.vue +11 -5
  91. package/src/components/CnDataTable/CnDataTable.md +1 -1
  92. package/src/components/CnDataTable/CnDataTable.vue +4 -4
  93. package/src/components/CnDetailPage/CnDetailPage.md +5 -1
  94. package/src/components/CnDetailPage/CnDetailPage.vue +208 -11
  95. package/src/components/CnFkResolveCell/CnFkResolveCell.vue +3 -1
  96. package/src/components/CnFormDialog/CnFormDialog.vue +144 -4
  97. package/src/components/CnHeaderWidget/CnHeaderWidget.vue +100 -13
  98. package/src/components/CnIndexPage/CnIndexPage.md +2 -1
  99. package/src/components/CnIndexPage/CnIndexPage.vue +47 -1
  100. package/src/components/CnSegmentedControl/CnSegmentedControl.md +1 -1
  101. package/src/components/CnSegmentedControl/CnSegmentedControl.vue +12 -0
  102. package/src/components/CnSetupWizard/CnSetupWizard.vue +5 -11
  103. package/src/components/CnStagesWidget/CnStagesWidget.vue +9 -0
  104. package/src/components/CnStatWidget/CnStatWidget.vue +33 -5
  105. package/src/composables/useDependencyCheck.js +43 -0
  106. package/src/composables/useListView.js +43 -1
  107. package/src/composables/useSetupStatus.js +26 -3
  108. package/src/css/actions-bar.css +20 -2
  109. package/src/css/detail-page.css +27 -0
  110. package/src/css/kpi-card.css +47 -0
  111. package/src/mixins/pageViews.js +396 -0
  112. package/src/schemas/app-manifest-v2.schema.json +53 -4
  113. package/src/schemas/app-manifest.schema.json +1 -1
  114. package/src/store/plugins/liveUpdates.js +19 -9
  115. package/src/store/useObjectStore.js +137 -41
  116. package/src/utils/actionsDispatcher.js +25 -2
  117. package/src/utils/columnFilters.js +29 -2
  118. package/src/utils/schema.js +40 -2
  119. package/src/utils/validateManifestV2.compiled.js +2 -2
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { computed, ref } from 'vue'
7
+ import { missingRequiredApps, readServerAppStatuses } from './useDependencyCheck.js'
7
8
 
8
9
  /**
9
10
  * Per-`appId` cache of setup-status results. The Vue refs are stored so all
@@ -27,6 +28,14 @@ const cache = new Map()
27
28
  * every non-admin user of an app with a `setup` block met a wizard they had no
28
29
  * permission to complete instead of the app itself.
29
30
  *
31
+ * A step that declares `requires: [appId, ...]` is **not applicable** while any
32
+ * of those apps is not installed and enabled: it is neither met nor unmet, so
33
+ * it never lands in `requiredUnmet` or `optionalUnmet`. `CnSetupWizard` skips
34
+ * such a step, and counting it as unmet kept the wizard reopening (optional
35
+ * step) or gating the app (required step) for a step it would never show. App
36
+ * status is resolved exactly as the wizard does it: the `dependency_statuses`
37
+ * initial state first, then `useAppStatus`.
38
+ *
30
39
  * @param {string} appId Nextcloud app id (e.g. `"procest"`).
31
40
  * @param {object} manifest The app manifest (reads `manifest.setup`).
32
41
  * @return {{
@@ -35,6 +44,7 @@ const cache = new Map()
35
44
  * requiredUnmet: import('vue').ComputedRef<Array<object>>,
36
45
  * optionalUnmet: import('vue').ComputedRef<Array<object>>,
37
46
  * optionalUnmetReported: import('vue').ComputedRef<Array<object>>,
47
+ * notApplicable: import('vue').ComputedRef<Array<object>>,
38
48
  * completed: import('vue').ComputedRef<boolean>,
39
49
  * enabled: boolean,
40
50
  * loading: import('vue').Ref<boolean>,
@@ -45,6 +55,7 @@ const cache = new Map()
45
55
  *
46
56
  * @example
47
57
  * const { requiredUnmet, completed, loading } = useSetupStatus('procest', manifest)
58
+ * @spec openspec/changes/optional-step-requires/specs/cn-setup-wizard/spec.md#requirement-a-step-whose-required-apps-are-absent-is-not-applicable
48
59
  */
49
60
  export function useSetupStatus(appId, manifest) {
50
61
  const setup = (manifest && manifest.setup) || {}
@@ -68,6 +79,9 @@ export function useSetupStatus(appId, manifest) {
68
79
  cache.set(appId, entry)
69
80
  }
70
81
  const { status, loading, error, forbidden } = entry
82
+ // Same source the wizard's dependency gate reads. Initial state is fixed
83
+ // for the page lifetime, so one read per call is enough.
84
+ const serverAppStatuses = readServerAppStatuses(appId)
71
85
 
72
86
  const steps = computed(() => stepDefs.map((s) => {
73
87
  const bag = status.value.steps || {}
@@ -81,7 +95,12 @@ export function useSetupStatus(appId, manifest) {
81
95
  // that act on an unmet step — CnAppRoot auto-opening the non-gating
82
96
  // wizard — must act only on steps the server actually reported, or an
83
97
  // unknowable step parks the wizard over the app for ever.
84
- return { ...s, done: st.done === true, reported, detail: st.detail }
98
+ //
99
+ // `applicable` is false when the step `requires` an app that is not
100
+ // installed and enabled. The wizard skips such a step, so it can never
101
+ // be done here; it is not applicable rather than unmet.
102
+ const missingApps = missingRequiredApps(s.requires, serverAppStatuses).map((row) => row.id)
103
+ return { ...s, done: st.done === true, reported, detail: st.detail, applicable: missingApps.length === 0, missingApps }
85
104
  }))
86
105
  // Presentational step types carry no work, so the server never reports a
87
106
  // `done` flag for them. Counting them as "unmet" made `optionalUnmet`
@@ -89,7 +108,9 @@ export function useSetupStatus(appId, manifest) {
89
108
  // which kept CnAppRoot's non-gating setup wizard auto-opening over the
90
109
  // app on every fresh browser profile no matter how complete setup was.
91
110
  // Only actionable steps can be unmet.
92
- const isActionable = (s) => s.type !== 'info' && s.type !== 'summary'
111
+ // A step whose required apps are absent is skipped by the wizard, so it
112
+ // is neither met nor unmet (see `applicable` above).
113
+ const isActionable = (s) => s.type !== 'info' && s.type !== 'summary' && s.applicable !== false
93
114
  // `forbidden` empties BOTH lists, and that is what actually suppresses the
94
115
  // wizard: CnAppRoot gates on `requiredUnmet.length > 0` (blocking) and on
95
116
  // `requiredUnmet.length === 0 && optionalUnmet.length > 0` (auto-open) —
@@ -110,6 +131,8 @@ export function useSetupStatus(appId, manifest) {
110
131
  // suppressing it is what left dossiq's `seed` step (and with it the app's
111
132
  // only demo-data affordance) permanently unreachable.
112
133
  const optionalUnmetReported = computed(() => optionalUnmet.value.filter((s) => s.reported === true))
134
+ // Steps the wizard skips because an app in their `requires` is absent.
135
+ const notApplicable = computed(() => steps.value.filter((s) => s.applicable === false))
113
136
  const completed = computed(() => {
114
137
  if (!enabled) {
115
138
  return true
@@ -170,7 +193,7 @@ export function useSetupStatus(appId, manifest) {
170
193
  refresh()
171
194
  }
172
195
 
173
- return { steps, status, requiredUnmet, optionalUnmet, optionalUnmetReported, completed, enabled, loading, error, forbidden, refresh }
196
+ return { steps, status, requiredUnmet, optionalUnmet, optionalUnmetReported, notApplicable, completed, enabled, loading, error, forbidden, refresh }
174
197
  }
175
198
 
176
199
  /**
@@ -35,6 +35,23 @@
35
35
  color: var(--color-text-maxcontrast);
36
36
  }
37
37
 
38
+ /* Beside the inline search (`showCountWithSearch`): the search field shrinks
39
+ first and the counter stays on one line. */
40
+ .cn-actions-bar__count--beside-search {
41
+ flex-shrink: 0;
42
+ white-space: nowrap;
43
+ }
44
+
45
+ /* Taken out of the flex row so it adds no gap, but kept in the accessibility
46
+ tree so the live region exists before a total appears. */
47
+ .cn-actions-bar__count--empty {
48
+ position: absolute;
49
+ width: 1px;
50
+ height: 1px;
51
+ overflow: hidden;
52
+ clip-path: inset(50%);
53
+ }
54
+
38
55
  /* Inline search field */
39
56
  .cn-actions-bar__search {
40
57
  position: relative;
@@ -294,8 +311,9 @@
294
311
  display: contents;
295
312
  }
296
313
 
297
- /* #after-search filter buttons + the view toggle ride beside the
298
- search field (DOM order — search first via its own order). */
314
+ /* #after-search filter buttons, the beside-search counter and the view
315
+ toggle ride beside the search field (DOM order — search first via its
316
+ own order). */
299
317
  .cn-actions-bar__info > * {
300
318
  order: 1;
301
319
  }
@@ -39,6 +39,28 @@
39
39
  min-width: 0;
40
40
  }
41
41
 
42
+ /* A widget in the header (`config.headerWidget`) takes a row of its own under
43
+ the title and the actions. */
44
+ .cn-detail-page__header--with-widget {
45
+ flex-wrap: wrap;
46
+ row-gap: var(--cn-detail-header-row-gap, 22px);
47
+ }
48
+
49
+ .cn-detail-page__header-widget {
50
+ flex: 1 0 100%;
51
+ min-width: 0;
52
+ }
53
+
54
+ /* The header as a card (`config.headerCard`): the board's case header, a
55
+ bordered box on the surface colour. The 56px inset that clears the
56
+ navigation toggle is not needed inside a card that starts below it. */
57
+ .cn-detail-page__header--card {
58
+ padding: var(--cn-detail-header-card-padding, 22px 24px);
59
+ border: 1px solid var(--color-border);
60
+ border-radius: var(--cn-detail-header-card-radius, 12px);
61
+ background: var(--color-main-background);
62
+ }
63
+
42
64
  .cn-detail-page__header-left {
43
65
  display: flex;
44
66
  align-items: center;
@@ -459,3 +481,8 @@
459
481
  font-size: 0.9em;
460
482
  white-space: nowrap;
461
483
  }
484
+
485
+ /* A page view the switch shows with nothing to draw (view-switch-containers). */
486
+ .cn-detail-page .cn-page-view-region--empty {
487
+ padding: 40px 20px;
488
+ }
@@ -367,6 +367,53 @@
367
367
  font-size: var(--cn-kpi-value-size-stacked, 2rem);
368
368
  }
369
369
 
370
+ /* Stacked: the board's tile (`content.layout: 'stacked'`). A plain muted
371
+ label, the number at 34px/700 in the text colour, and the caption on a line
372
+ of its own; CnStatWidget draws no icon circle in this layout. The padding
373
+ adds up with a dashboard card-fit cell's 8px 14px to the board's 18px 20px.
374
+ Every value is a token so a theme can move it. */
375
+ .cn-kpi-card--stacked {
376
+ flex-direction: column;
377
+ align-items: stretch;
378
+ justify-content: center;
379
+ gap: 0;
380
+ padding: var(--cn-kpi-stacked-padding, 10px 6px);
381
+ }
382
+
383
+ .cn-kpi-card--stacked .cn-kpi-card__body {
384
+ gap: var(--cn-kpi-stacked-gap, 6px);
385
+ }
386
+
387
+ .cn-kpi-card--stacked .cn-kpi-card__title {
388
+ font-size: var(--cn-kpi-stacked-title-size, 14px);
389
+ font-weight: var(--cn-kpi-stacked-title-weight, 400);
390
+ color: var(--color-text-maxcontrast);
391
+ }
392
+
393
+ .cn-kpi-card--stacked .cn-kpi-card__value {
394
+ font-size: var(--cn-kpi-stacked-value-size, 34px);
395
+ font-weight: var(--cn-kpi-value-weight, 700);
396
+ line-height: 1;
397
+ letter-spacing: -0.01em;
398
+ color: var(--cn-kpi-stacked-value-color, var(--color-main-text));
399
+ }
400
+
401
+ .cn-kpi-card--stacked .cn-kpi-card__caption-line {
402
+ flex: 0 0 auto;
403
+ max-width: 100%;
404
+ font-size: var(--cn-kpi-label-size, 13px);
405
+ font-weight: 500;
406
+ }
407
+
408
+ /* A linked stacked tile reads as a tile, not as underlined link text. The
409
+ NL Design theme underlines every `a` with `!important`, so the opt-out
410
+ needs it too; it only applies to this opt-in layout. */
411
+ a.cn-kpi-card--stacked,
412
+ a.cn-kpi-card--stacked:hover,
413
+ a.cn-kpi-card--stacked:focus-visible {
414
+ text-decoration: none !important;
415
+ }
416
+
370
417
  /* Filled: the card draws its own grey box, for a KPI that is NOT already
371
418
  inside a wrapper that draws one. Opt-in via `content.flat: false`. */
372
419
  .cn-kpi-card--filled {
@@ -0,0 +1,396 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * Page views: a few views of one page, each its own widget grid, behind one
6
+ * segmented control ("My work | My team").
7
+ *
8
+ * A page that mixes this in accepts `views` (each `{ id, label, icon?,
9
+ * widgets, layout, emptyText? }`), `defaultView` and `viewsLabel`. It keeps
10
+ * the chosen view, writes it to the address (`?view=<id>`) and to the
11
+ * user's preferences (the server, mirrored in the browser), and provides `cnPageViews` so a greeting header widget on
12
+ * the page can draw the switch instead of the page.
13
+ *
14
+ * Which view opens: the address, then the stored view, then `defaultView`,
15
+ * then the first view. An id that names no view is ignored at every step.
16
+ *
17
+ * Used by CnDashboardPage and CnDetailPage. Each renders the chosen view's
18
+ * `layout` with its own grid, so a view looks exactly like the page's own
19
+ * grid.
20
+ *
21
+ * @mixin pageViews
22
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-page-declares-views-that-each-hold-a-widget-grid
23
+ */
24
+ import { translate as t } from '@nextcloud/l10n'
25
+ import { readUserPreference, USER_PREFERENCE_STORAGE_PREFIX, writeUserPreference } from '../composables/useUserPreferences.js'
26
+
27
+ /** The query parameter that carries the chosen view. */
28
+ export const PAGE_VIEW_QUERY_KEY = 'view'
29
+
30
+ /** Prefix of the user-preference key; the page id follows it. */
31
+ export const PAGE_VIEW_PREFERENCE_PREFIX = 'cn_page_view:'
32
+
33
+ let regionSeq = 0
34
+
35
+ /**
36
+ * Read the browser mirror of a user preference synchronously, so the stored
37
+ * view is on screen from the first frame. Errors read as nothing stored.
38
+ *
39
+ * @param {string} appId The app id.
40
+ * @param {string} key The preference key.
41
+ * @return {string|null} The stored id, or null.
42
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-the-chosen-view-is-linkable-and-remembered
43
+ */
44
+ function readStoredViewMirror(appId, key) {
45
+ try {
46
+ if (typeof localStorage === 'undefined') {
47
+ return null
48
+ }
49
+ const raw = localStorage.getItem(`${USER_PREFERENCE_STORAGE_PREFIX}${appId}:${key}`)
50
+ if (raw === null || raw === undefined) {
51
+ return null
52
+ }
53
+ const value = JSON.parse(raw)
54
+ return typeof value === 'string' && value !== '' ? value : null
55
+ } catch {
56
+ return null
57
+ }
58
+ }
59
+
60
+ export const pageViews = {
61
+ inject: {
62
+ /** The host app id, provided by CnAppRoot; keys the stored view. */
63
+ cnPageViewsAppId: { from: 'cnAppId', default: '' },
64
+ },
65
+
66
+ props: {
67
+ /**
68
+ * Views of this page, each its own widget grid behind one segmented
69
+ * control. Each is `{ id, label, icon?, widgets, layout, emptyText? }`:
70
+ * `widgets` and `layout` have the same shape as the page's own. The
71
+ * chosen view renders in a region below the page's own grid. Empty
72
+ * (the default) draws no switch and no region.
73
+ *
74
+ * @type {Array<{id: string, label: string, icon?: string, widgets?: Array<object>, layout?: Array<object>, emptyText?: string}>}
75
+ */
76
+ views: {
77
+ type: Array,
78
+ default: () => [],
79
+ },
80
+
81
+ /**
82
+ * The id of the view chosen when neither the address nor the browser
83
+ * store names one. Empty chooses the first view.
84
+ *
85
+ * @type {string}
86
+ */
87
+ defaultView: {
88
+ type: String,
89
+ default: '',
90
+ },
91
+
92
+ /**
93
+ * Accessible name of the view switch (through the host translate
94
+ * function). Empty reads "View".
95
+ *
96
+ * @type {string}
97
+ */
98
+ viewsLabel: {
99
+ type: String,
100
+ default: '',
101
+ },
102
+ },
103
+
104
+ data() {
105
+ regionSeq += 1
106
+ return {
107
+ /** The view the user chose in this session, or the stored one. */
108
+ selectedViewId: null,
109
+ /** Whether the user chose a view in this session (a late server read then leaves it alone). */
110
+ viewChosen: false,
111
+ /** Id of the view region, the target of the options' `aria-controls`. */
112
+ viewRegionId: `cn-page-view-region-${regionSeq}`,
113
+ }
114
+ },
115
+
116
+ /**
117
+ * Provide the views to descendants, so a greeting header widget in the
118
+ * page's grid can draw the switch. Getters keep it reactive.
119
+ *
120
+ * @return {object} The provided `cnPageViews`.
121
+ */
122
+ provide() {
123
+ const vm = this
124
+ return {
125
+ cnPageViews: {
126
+ get views() { return vm.normalizedViews },
127
+ get activeId() { return vm.activeViewId },
128
+ get regionId() { return vm.viewRegionId },
129
+ select: (id) => vm.selectView(id),
130
+ },
131
+ }
132
+ },
133
+
134
+ computed: {
135
+ /**
136
+ * The declared views in one shape, labels translated. Entries without
137
+ * an id or a label are dropped, as are repeated ids.
138
+ *
139
+ * @return {Array<{id: string, label: string, icon: string, widgets: Array<object>, layout: Array<object>, emptyText: string}>}
140
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-page-declares-views-that-each-hold-a-widget-grid
141
+ */
142
+ normalizedViews() {
143
+ const translate = typeof this.cnTranslate === 'function' ? this.cnTranslate : (key) => key
144
+ const seen = new Set()
145
+ return (Array.isArray(this.views) ? this.views : [])
146
+ .filter((view) => {
147
+ if (!view || typeof view !== 'object' || view.id === undefined || view.id === null || view.id === '') {
148
+ return false
149
+ }
150
+ if (typeof view.label !== 'string' || view.label === '' || seen.has(String(view.id))) {
151
+ return false
152
+ }
153
+ seen.add(String(view.id))
154
+ return true
155
+ })
156
+ .map((view) => ({
157
+ id: String(view.id),
158
+ label: translate(view.label),
159
+ icon: typeof view.icon === 'string' ? view.icon : '',
160
+ widgets: Array.isArray(view.widgets) ? view.widgets : [],
161
+ layout: Array.isArray(view.layout) ? view.layout : [],
162
+ emptyText: typeof view.emptyText === 'string' && view.emptyText !== '' ? translate(view.emptyText) : '',
163
+ }))
164
+ },
165
+
166
+ /**
167
+ * Whether this page has views.
168
+ *
169
+ * @return {boolean}
170
+ */
171
+ hasViews() {
172
+ return this.normalizedViews.length > 0
173
+ },
174
+
175
+ /**
176
+ * The chosen view's id: the address, then this session's or the
177
+ * stored choice, then `defaultView`, then the first view.
178
+ *
179
+ * @return {string|null} The id, or null without views.
180
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-the-chosen-view-is-linkable-and-remembered
181
+ */
182
+ activeViewId() {
183
+ const views = this.normalizedViews
184
+ if (views.length === 0) {
185
+ return null
186
+ }
187
+ const known = (id) => id !== null && id !== undefined && views.some((view) => view.id === String(id))
188
+ const fromAddress = this.$route && this.$route.query ? this.$route.query[PAGE_VIEW_QUERY_KEY] : undefined
189
+ if (known(fromAddress)) {
190
+ return String(fromAddress)
191
+ }
192
+ if (known(this.selectedViewId)) {
193
+ return String(this.selectedViewId)
194
+ }
195
+ if (known(this.defaultView)) {
196
+ return this.defaultView
197
+ }
198
+ return views[0].id
199
+ },
200
+
201
+ /**
202
+ * The chosen view, or null without views.
203
+ *
204
+ * @return {object|null}
205
+ */
206
+ activeView() {
207
+ return this.normalizedViews.find((view) => view.id === this.activeViewId) || null
208
+ },
209
+
210
+ /**
211
+ * The switch's options, for CnSegmentedControl.
212
+ *
213
+ * @return {Array<{value: string, label: string}>}
214
+ */
215
+ viewSwitchOptions() {
216
+ return this.normalizedViews.map((view) => ({ value: view.id, label: view.label }))
217
+ },
218
+
219
+ /**
220
+ * The switch's accessible name: `viewsLabel` translated, else "View".
221
+ *
222
+ * @return {string}
223
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-the-view-switch-is-accessible
224
+ */
225
+ viewSwitchLabel() {
226
+ const translate = typeof this.cnTranslate === 'function' ? this.cnTranslate : (key) => key
227
+ return this.viewsLabel ? translate(this.viewsLabel) : t('nextcloud-vue', 'Views')
228
+ },
229
+
230
+ /**
231
+ * Whether a greeting header in the page's own widgets draws the
232
+ * switch (an option with a `view`), so the page draws none.
233
+ *
234
+ * @return {boolean}
235
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-greeting-header-can-switch-the-pages-views
236
+ */
237
+ viewSwitchInHeaderWidget() {
238
+ const widgets = Array.isArray(this.widgets) ? this.widgets : []
239
+ return widgets.some((def) => {
240
+ const options = def && def.content && def.content.views && def.content.views.options
241
+ return Array.isArray(options) && options.some((option) => option && typeof option.view === 'string' && option.view !== '')
242
+ })
243
+ },
244
+
245
+ /**
246
+ * Whether the page draws the switch itself.
247
+ *
248
+ * @return {boolean}
249
+ */
250
+ showsPageViewSwitch() {
251
+ return this.hasViews && !this.viewSwitchInHeaderWidget
252
+ },
253
+
254
+ /**
255
+ * The user-preference key for this page's chosen view.
256
+ *
257
+ * @return {string}
258
+ */
259
+ viewPreferenceKey() {
260
+ // The page id, else the route name: a detail page's fallback id is
261
+ // its record's title, which would remember a view per record.
262
+ const id = this.pageId || (this.$route && this.$route.name) || this.resolvedPageId || 'page'
263
+ return `${PAGE_VIEW_PREFERENCE_PREFIX}${id}`
264
+ },
265
+
266
+ /**
267
+ * The app id the stored view is kept under: the page's `appId` prop
268
+ * when it has one, else the app root's.
269
+ *
270
+ * @return {string}
271
+ */
272
+ viewPreferenceAppId() {
273
+ const own = typeof this.appId === 'string' ? this.appId : ''
274
+ return own || (typeof this.cnPageViewsAppId === 'string' ? this.cnPageViewsAppId : '')
275
+ },
276
+
277
+ /**
278
+ * The message of the chosen view's empty state.
279
+ *
280
+ * @return {string}
281
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-view-with-nothing-to-draw-says-so
282
+ */
283
+ viewEmptyText() {
284
+ if (this.activeView && this.activeView.emptyText) {
285
+ return this.activeView.emptyText
286
+ }
287
+ return t('nextcloud-vue', 'This view has no widgets yet.')
288
+ },
289
+ },
290
+
291
+ created() {
292
+ this.loadStoredView()
293
+ },
294
+
295
+ methods: {
296
+ /**
297
+ * Read the stored view: the browser mirror at once, then the user
298
+ * preference on the server, which wins unless the user already chose.
299
+ *
300
+ * @return {Promise<void>}
301
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-the-chosen-view-is-linkable-and-remembered
302
+ */
303
+ async loadStoredView() {
304
+ if (!this.hasViews) {
305
+ return
306
+ }
307
+ const appId = this.viewPreferenceAppId
308
+ const key = this.viewPreferenceKey
309
+ this.selectedViewId = readStoredViewMirror(appId, key)
310
+ if (!appId) {
311
+ return
312
+ }
313
+ const stored = await readUserPreference(appId, key, null)
314
+ if (!this.viewChosen && typeof stored === 'string' && stored !== '') {
315
+ this.selectedViewId = stored
316
+ }
317
+ },
318
+
319
+ /**
320
+ * Choose a view: keep it, store it, and write it to the address
321
+ * (replacing the history entry, keeping the other query keys).
322
+ *
323
+ * @param {string} id The view id.
324
+ * @return {void}
325
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-the-chosen-view-is-linkable-and-remembered
326
+ */
327
+ selectView(id) {
328
+ const view = this.normalizedViews.find((candidate) => candidate.id === String(id))
329
+ if (!view) {
330
+ return
331
+ }
332
+ this.selectedViewId = view.id
333
+ this.viewChosen = true
334
+ writeUserPreference(this.viewPreferenceAppId, this.viewPreferenceKey, view.id)
335
+ if (!this.$router || !this.$route) {
336
+ return
337
+ }
338
+ const query = this.$route.query || {}
339
+ if (query[PAGE_VIEW_QUERY_KEY] === view.id) {
340
+ return
341
+ }
342
+ const result = this.$router.replace({ query: { ...query, [PAGE_VIEW_QUERY_KEY]: view.id } })
343
+ if (result && typeof result.catch === 'function') {
344
+ result.catch(() => {})
345
+ }
346
+ },
347
+
348
+ /**
349
+ * Look a widget definition up in the views, for a layout item of the
350
+ * chosen view.
351
+ *
352
+ * @param {string} widgetId The widget id.
353
+ * @return {object|null} The definition, or null.
354
+ */
355
+ findViewWidget(widgetId) {
356
+ const view = this.activeView
357
+ if (!view) {
358
+ return null
359
+ }
360
+ return view.widgets.find((def) => def && def.id === widgetId) || null
361
+ },
362
+
363
+ /**
364
+ * Write a drag or resize in the chosen view's grid back onto its
365
+ * layout items in place, as the page does for its own layout, so the
366
+ * in-app manifest editor sees it.
367
+ *
368
+ * @param {Array<object>} updated The layout from the grid.
369
+ * @return {void}
370
+ */
371
+ onViewLayoutChange(updated) {
372
+ const view = this.activeView
373
+ if (!view || !Array.isArray(updated)) {
374
+ return
375
+ }
376
+ for (const u of updated) {
377
+ const item = view.layout.find((l) => String(l.id) === String(u.id))
378
+ if (!item) {
379
+ continue
380
+ }
381
+ for (const key of ['gridX', 'gridY', 'gridWidth', 'gridHeight']) {
382
+ if (u[key] !== undefined) {
383
+ item[key] = u[key]
384
+ }
385
+ }
386
+ }
387
+ /**
388
+ * Emitted when a widget in a view's grid is dragged or resized.
389
+ *
390
+ * @event view-layout-change
391
+ * @type {{view: string, layout: Array<object>}}
392
+ */
393
+ this.$emit('view-layout-change', { view: view.id, layout: view.layout })
394
+ },
395
+ },
396
+ }