@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.
- package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js +19 -0
- package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js.map +1 -1
- package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js +10 -3
- package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js.map +1 -1
- package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +101 -5
- package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
- package/dist/esm/components/CnAppNav/CnAppNav.vue3.js +23 -7
- package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
- package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue2.js +13 -0
- package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue2.js.map +1 -1
- package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js +2 -1
- package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +125 -4
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +439 -381
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
- package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js +9 -3
- package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js.map +1 -1
- package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js +1 -1
- package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js.map +1 -1
- package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +4 -4
- package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
- package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +142 -26
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +195 -124
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
- package/dist/esm/components/CnFkResolveCell/CnFkResolveCell.vue2.js +3 -1
- package/dist/esm/components/CnFkResolveCell/CnFkResolveCell.vue2.js.map +1 -1
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +144 -4
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
- package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js +84 -11
- package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js +3 -2
- package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +31 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +335 -313
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
- package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue2.js +11 -0
- package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue2.js.map +1 -1
- package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue3.js +2 -1
- package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue3.js.map +1 -1
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js +5 -11
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js.map +1 -1
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js.map +1 -1
- package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +22 -3
- package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js +10 -3
- package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
- package/dist/esm/composables/useDependencyCheck.js +44 -1
- package/dist/esm/composables/useDependencyCheck.js.map +1 -1
- package/dist/esm/composables/useListView.js +43 -1
- package/dist/esm/composables/useListView.js.map +1 -1
- package/dist/esm/composables/useSetupStatus.js +26 -3
- package/dist/esm/composables/useSetupStatus.js.map +1 -1
- package/dist/esm/l10n/en.json.js +7 -1
- package/dist/esm/l10n/en.json.js.map +1 -1
- package/dist/esm/l10n/nl.json.js +7 -1
- package/dist/esm/l10n/nl.json.js.map +1 -1
- package/dist/esm/mixins/pageViews.js +400 -0
- package/dist/esm/mixins/pageViews.js.map +1 -0
- package/dist/esm/nextcloud-vue.css +202 -2
- package/dist/esm/store/plugins/liveUpdates.js +19 -9
- package/dist/esm/store/plugins/liveUpdates.js.map +1 -1
- package/dist/esm/store/useObjectStore.js +138 -42
- package/dist/esm/store/useObjectStore.js.map +1 -1
- package/dist/esm/utils/actionsDispatcher.js +25 -2
- package/dist/esm/utils/actionsDispatcher.js.map +1 -1
- package/dist/esm/utils/columnFilters.js +27 -3
- package/dist/esm/utils/columnFilters.js.map +1 -1
- package/dist/esm/utils/schema.js +40 -2
- package/dist/esm/utils/schema.js.map +1 -1
- package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
- package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
- package/dist/nextcloud-vue.cjs.js +2857 -1335
- package/dist/nextcloud-vue.cjs.js.map +2 -2
- package/dist/nextcloud-vue.css +202 -2
- package/l10n/en.json +7 -1
- package/l10n/nl.json +7 -1
- package/package.json +1 -1
- package/src/components/CnActionsBar/CnActionsBar.md +2 -1
- package/src/components/CnActionsBar/CnActionsBar.vue +25 -1
- package/src/components/CnAppNav/CnAppNav.vue +150 -9
- package/src/components/CnBreadcrumbs/CnBreadcrumbs.vue +14 -0
- package/src/components/CnDashboardPage/CnDashboardPage.vue +194 -10
- package/src/components/CnDataTable/CnColumnFilterPopover.vue +11 -5
- package/src/components/CnDataTable/CnDataTable.md +1 -1
- package/src/components/CnDataTable/CnDataTable.vue +4 -4
- package/src/components/CnDetailPage/CnDetailPage.md +5 -1
- package/src/components/CnDetailPage/CnDetailPage.vue +208 -11
- package/src/components/CnFkResolveCell/CnFkResolveCell.vue +3 -1
- package/src/components/CnFormDialog/CnFormDialog.vue +144 -4
- package/src/components/CnHeaderWidget/CnHeaderWidget.vue +100 -13
- package/src/components/CnIndexPage/CnIndexPage.md +2 -1
- package/src/components/CnIndexPage/CnIndexPage.vue +47 -1
- package/src/components/CnSegmentedControl/CnSegmentedControl.md +1 -1
- package/src/components/CnSegmentedControl/CnSegmentedControl.vue +12 -0
- package/src/components/CnSetupWizard/CnSetupWizard.vue +5 -11
- package/src/components/CnStagesWidget/CnStagesWidget.vue +9 -0
- package/src/components/CnStatWidget/CnStatWidget.vue +33 -5
- package/src/composables/useDependencyCheck.js +43 -0
- package/src/composables/useListView.js +43 -1
- package/src/composables/useSetupStatus.js +26 -3
- package/src/css/actions-bar.css +20 -2
- package/src/css/detail-page.css +27 -0
- package/src/css/kpi-card.css +47 -0
- package/src/mixins/pageViews.js +396 -0
- package/src/schemas/app-manifest-v2.schema.json +53 -4
- package/src/schemas/app-manifest.schema.json +1 -1
- package/src/store/plugins/liveUpdates.js +19 -9
- package/src/store/useObjectStore.js +137 -41
- package/src/utils/actionsDispatcher.js +25 -2
- package/src/utils/columnFilters.js +29 -2
- package/src/utils/schema.js +40 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/src/css/actions-bar.css
CHANGED
|
@@ -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
|
|
298
|
-
search field (DOM order — search first via its
|
|
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
|
}
|
package/src/css/detail-page.css
CHANGED
|
@@ -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
|
+
}
|
package/src/css/kpi-card.css
CHANGED
|
@@ -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
|
+
}
|