@conduction/nextcloud-vue 2.67.0 → 2.69.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/components/CnContextMenu/CnContextMenu.vue2.js +37 -26
- package/dist/esm/components/CnContextMenu/CnContextMenu.vue2.js.map +1 -1
- package/dist/esm/components/CnContextMenu/CnContextMenu.vue3.js +3 -3
- package/dist/esm/components/CnContextMenu/CnContextMenu.vue3.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +21 -35
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +17 -0
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +40 -3
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +58 -16
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +87 -18
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js +6 -1
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +58 -48
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +2 -2
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
- package/dist/esm/components/CnIndexPage/defaultActions.js +6 -7
- package/dist/esm/components/CnIndexPage/defaultActions.js.map +1 -1
- package/dist/esm/components/CnRowActions/CnRowActions.vue2.js +33 -34
- package/dist/esm/components/CnRowActions/CnRowActions.vue2.js.map +1 -1
- package/dist/esm/components/CnRowActions/CnRowActions.vue3.js +3 -3
- package/dist/esm/components/CnRowActions/CnRowActions.vue3.js.map +1 -1
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js +8 -2
- 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/CnStatWidget/CnStatWidget.vue2.js +2 -2
- package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue2.js +70 -1
- package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue3.js +23 -3
- package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue2.js +3 -2
- package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue3.js.map +1 -1
- package/dist/esm/composables/useContextMenu.js +4 -2
- package/dist/esm/composables/useContextMenu.js.map +1 -1
- package/dist/esm/l10n/en.json.js +3 -0
- package/dist/esm/l10n/en.json.js.map +1 -1
- package/dist/esm/l10n/nl.json.js +3 -0
- package/dist/esm/l10n/nl.json.js.map +1 -1
- package/dist/esm/mixins/pageViews.js +278 -11
- package/dist/esm/mixins/pageViews.js.map +1 -1
- package/dist/esm/store/plugins/dashboardLayouts.js +35 -1
- package/dist/esm/store/plugins/dashboardLayouts.js.map +1 -1
- package/dist/esm/utils/formatMetric.js +137 -16
- package/dist/esm/utils/formatMetric.js.map +1 -1
- package/dist/esm/utils/resolveRowActions.js +130 -0
- package/dist/esm/utils/resolveRowActions.js.map +1 -0
- package/dist/esm/utils/rowActionItem.js +104 -0
- package/dist/esm/utils/rowActionItem.js.map +1 -0
- package/dist/esm/utils/timelineEvents.js +44 -1
- package/dist/esm/utils/timelineEvents.js.map +1 -1
- package/dist/esm/utils/validateManifest.js +59 -3
- package/dist/esm/utils/validateManifest.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 +1251 -232
- package/dist/nextcloud-vue.cjs.js.map +2 -2
- package/l10n/en.json +3 -0
- package/l10n/nl.json +3 -0
- package/package.json +1 -1
- package/scripts/.jsdoc-baselines.json +2 -2
- package/src/components/CnContextMenu/CnContextMenu.vue +40 -29
- package/src/components/CnDashboardPage/CnDashboardPage.md +2 -0
- package/src/components/CnDashboardPage/CnDashboardPage.vue +34 -35
- package/src/components/CnDetailPage/CnDetailPage.md +6 -0
- package/src/components/CnDetailPage/CnDetailPage.vue +67 -5
- package/src/components/CnFormDialog/CnFormDialog.vue +92 -18
- package/src/components/CnIndexPage/CnIndexPage.md +21 -0
- package/src/components/CnIndexPage/CnIndexPage.vue +61 -51
- package/src/components/CnIndexPage/defaultActions.js +6 -7
- package/src/components/CnRowActions/CnRowActions.vue +36 -37
- package/src/components/CnSetupWizard/CnSetupWizard.vue +8 -2
- package/src/components/CnStatWidget/CnStatWidget.vue +2 -2
- package/src/components/CnStatsBlockWidget/CnStatsBlockWidget.vue +82 -3
- package/src/components/CnTimelineWidget/CnTimelineWidget.vue +3 -2
- package/src/composables/useContextMenu.js +4 -2
- package/src/mixins/pageViews.js +277 -10
- package/src/schemas/app-manifest-v2.schema.json +73 -9
- package/src/store/plugins/dashboardLayouts.js +34 -0
- package/src/utils/formatMetric.js +143 -15
- package/src/utils/resolveRowActions.js +127 -0
- package/src/utils/rowActionItem.js +100 -0
- package/src/utils/timelineEvents.js +43 -0
- package/src/utils/validateManifest.js +60 -3
- package/src/utils/validateManifestV2.compiled.js +2 -2
|
@@ -34,7 +34,13 @@
|
|
|
34
34
|
:vertical="vertical"
|
|
35
35
|
:filled="filled"
|
|
36
36
|
:clickable="!!view.route"
|
|
37
|
-
:route="view.route || null"
|
|
37
|
+
:route="view.route || null">
|
|
38
|
+
<!-- An entry with a `format` (or `currency` / `currencyField`)
|
|
39
|
+
shows its value formatted, money in its own currency. -->
|
|
40
|
+
<template v-if="view.formatted !== null" #value>
|
|
41
|
+
{{ view.formatted }}
|
|
42
|
+
</template>
|
|
43
|
+
</CnStatsBlock>
|
|
38
44
|
</template>
|
|
39
45
|
|
|
40
46
|
<!-- Single-source mode (pre-existing interface, unchanged). -->
|
|
@@ -50,7 +56,11 @@
|
|
|
50
56
|
:vertical="vertical"
|
|
51
57
|
:filled="filled"
|
|
52
58
|
:clickable="!!route"
|
|
53
|
-
:route="route"
|
|
59
|
+
:route="route">
|
|
60
|
+
<template v-if="formattedSingle !== null" #value>
|
|
61
|
+
{{ formattedSingle }}
|
|
62
|
+
</template>
|
|
63
|
+
</CnStatsBlock>
|
|
54
64
|
</div>
|
|
55
65
|
</template>
|
|
56
66
|
|
|
@@ -58,6 +68,7 @@
|
|
|
58
68
|
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
|
|
59
69
|
import CnStatsBlock from '../CnStatsBlock/CnStatsBlock.vue'
|
|
60
70
|
import { useDataSource } from '../../composables/useDataSource.js'
|
|
71
|
+
import { formatMetricValue, normalizeMetricFormat, unwrapAppConfig } from '../../utils/formatMetric.js'
|
|
61
72
|
import { dropOptionalUnresolved, hasUnresolvedTokens, resolveFilterTokens } from '../../utils/resolveFilterTokens.js'
|
|
62
73
|
|
|
63
74
|
// The canonical KPI scale (`--cn-kpi-*`) lives in one stylesheet. Imported
|
|
@@ -85,6 +96,15 @@ const PAGE_REFRESH_BUS_CHANNEL = 'cn:page:refresh'
|
|
|
85
96
|
* `field`, token-resolved `filter`) plus per-entry presentation
|
|
86
97
|
* (`title`, `variant`, `countLabel`), an optional `route` deep link, and
|
|
87
98
|
* `hideWhenZero` (the entry is omitted when its resolved count is 0).
|
|
99
|
+
*
|
|
100
|
+
* An entry formats its value with `format` (a style name such as
|
|
101
|
+
* `"currency"`, or `{ style, currency, currencyField, decimals, prefix,
|
|
102
|
+
* suffix }`), as a `type:"stat"` widget's `content.format` does. A money
|
|
103
|
+
* value takes the currency from `currencyField` (a field of the detail
|
|
104
|
+
* page's object, e.g. a contract's own `currency`), then `currency` (a code
|
|
105
|
+
* or `@config.currency`), then the app's reporting currency, then EUR, and
|
|
106
|
+
* is formatted in the user's locale. `currency` / `currencyField` may sit on
|
|
107
|
+
* the entry itself and imply `format: "currency"`.
|
|
88
108
|
* Exactly one of `dataSource` / `entries` must be provided — when
|
|
89
109
|
* `entries` is absent, the single-`dataSource` path renders exactly as
|
|
90
110
|
* before.
|
|
@@ -140,6 +160,11 @@ export default {
|
|
|
140
160
|
* `filter`. Null on pages that don't provide it.
|
|
141
161
|
*/
|
|
142
162
|
cnWorkspaceContext: { default: null },
|
|
163
|
+
/**
|
|
164
|
+
* Page-level app config provided by the page — the reporting
|
|
165
|
+
* currency (`currency`) and `@config.<key>` tokens in `format`.
|
|
166
|
+
*/
|
|
167
|
+
cnAppConfig: { default: () => ({}) },
|
|
143
168
|
},
|
|
144
169
|
|
|
145
170
|
props: {
|
|
@@ -179,6 +204,20 @@ export default {
|
|
|
179
204
|
default: () => [],
|
|
180
205
|
},
|
|
181
206
|
|
|
207
|
+
/**
|
|
208
|
+
* How the single-source value is shown: a style name (`'currency'`,
|
|
209
|
+
* `'number'`, `'percent'`, `'decimal'`, `'duration-hours'`) or
|
|
210
|
+
* `{ style, currency, currencyField, decimals, prefix, suffix }`.
|
|
211
|
+
* Empty (the default) shows the plain localized count. Entries carry
|
|
212
|
+
* their own `format`.
|
|
213
|
+
*
|
|
214
|
+
* @type {string|object|null}
|
|
215
|
+
*/
|
|
216
|
+
format: {
|
|
217
|
+
type: [String, Object],
|
|
218
|
+
default: null,
|
|
219
|
+
},
|
|
220
|
+
|
|
182
221
|
/** Block title (manifest `widgetDef.title`). */
|
|
183
222
|
title: {
|
|
184
223
|
type: String,
|
|
@@ -329,6 +368,41 @@ export default {
|
|
|
329
368
|
return (typeof c === 'object' && 'value' in c) ? c.value : c
|
|
330
369
|
},
|
|
331
370
|
|
|
371
|
+
/**
|
|
372
|
+
* The page-level app config map (reporting currency, `@config` keys).
|
|
373
|
+
*
|
|
374
|
+
* @return {object}
|
|
375
|
+
*/
|
|
376
|
+
configCtx() {
|
|
377
|
+
return unwrapAppConfig(this.cnAppConfig)
|
|
378
|
+
},
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* The detail page's object, or null on a dashboard. Its fields feed
|
|
382
|
+
* an entry's `currencyField`.
|
|
383
|
+
*
|
|
384
|
+
* @return {object|null}
|
|
385
|
+
*/
|
|
386
|
+
pageObject() {
|
|
387
|
+
const object = this.objectCtx && this.objectCtx.object
|
|
388
|
+
return object && typeof object === 'object' ? object : null
|
|
389
|
+
},
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* The single-source value formatted per `format`, or null to keep
|
|
393
|
+
* the plain count.
|
|
394
|
+
*
|
|
395
|
+
* @return {string|null}
|
|
396
|
+
* @spec openspec/changes/stat-currency-from-object/specs/dashboard-page/spec.md#requirement-a-stats-block-entry-formats-its-value
|
|
397
|
+
*/
|
|
398
|
+
formattedSingle() {
|
|
399
|
+
const fmt = normalizeMetricFormat({ format: this.format })
|
|
400
|
+
if (!fmt || this.loading) {
|
|
401
|
+
return null
|
|
402
|
+
}
|
|
403
|
+
return formatMetricValue(this.resolvedCount, fmt, this.configCtx, this.pageObject)
|
|
404
|
+
},
|
|
405
|
+
|
|
332
406
|
/**
|
|
333
407
|
* Token-resolution context for entry filters, merged from the
|
|
334
408
|
* detail-page object context and the page-level workspace bag.
|
|
@@ -346,14 +420,19 @@ export default {
|
|
|
346
420
|
* its fetched count and loading flag, with `hideWhenZero` entries
|
|
347
421
|
* whose resolved count is 0 omitted.
|
|
348
422
|
*
|
|
349
|
-
* @return {Array<{entry: object, key: string, count: number, loading: boolean}>}
|
|
423
|
+
* @return {Array<{entry: object, key: string, count: number, loading: boolean, formatted: (string|null)}>}
|
|
424
|
+
* @spec openspec/changes/stat-currency-from-object/specs/dashboard-page/spec.md#requirement-a-stats-block-entry-formats-its-value
|
|
350
425
|
*/
|
|
351
426
|
entryViews() {
|
|
352
427
|
return (this.entries || [])
|
|
353
428
|
.map((entry, i) => {
|
|
354
429
|
const raw = this.entryCounts[i]
|
|
355
430
|
const resolved = typeof raw === 'number'
|
|
431
|
+
const fmt = normalizeMetricFormat(entry)
|
|
356
432
|
return {
|
|
433
|
+
// The value as the entry's `format` says, money in the
|
|
434
|
+
// currency the entry names; null keeps the plain count.
|
|
435
|
+
formatted: fmt && resolved ? formatMetricValue(raw, fmt, this.configCtx, this.pageObject) : null,
|
|
357
436
|
entry: entry || {},
|
|
358
437
|
key: `${(entry && entry.register) || ''}/${(entry && entry.schema) || ''}/${i}`,
|
|
359
438
|
count: resolved ? raw : 0,
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
<script>
|
|
54
54
|
import { getLanguage, translate as t } from '@nextcloud/l10n'
|
|
55
55
|
import { NcLoadingIcon, NcNoteCard } from '@nextcloud/vue'
|
|
56
|
-
import { auditEvents, fieldEvents, relatedEvents, sortEvents, timelineEntryEvents } from '../../utils/timelineEvents.js'
|
|
56
|
+
import { auditEvents, fieldEvents, listEvents, relatedEvents, sortEvents, timelineEntryEvents } from '../../utils/timelineEvents.js'
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
59
|
* CnTimelineWidget — an object's dated events in time order (`timeline`
|
|
@@ -193,6 +193,7 @@ export default {
|
|
|
193
193
|
const now = new Date()
|
|
194
194
|
const all = [
|
|
195
195
|
...fieldEvents(this.resolvedObject || {}, this.fieldsConfig, now),
|
|
196
|
+
...listEvents(this.resolvedObject || {}, this.content.lists, now),
|
|
196
197
|
...this.relatedConfig.flatMap((cfg, i) => relatedEvents(this.relatedRows[i] || [], cfg, now)),
|
|
197
198
|
...auditEvents(this.auditEntries, this.describeAudit, now),
|
|
198
199
|
...timelineEntryEvents(this.timelineEntries, t('nextcloud-vue', 'Note'), now),
|
|
@@ -347,7 +348,7 @@ export default {
|
|
|
347
348
|
})
|
|
348
349
|
|
|
349
350
|
if (this.content.auditTrail === true) {
|
|
350
|
-
jobs.push(axios.get(generateUrl(`${base}/audit-
|
|
351
|
+
jobs.push(axios.get(generateUrl(`${base}/audit-trails`), { params: { limit: this.content.auditLimit || 50 } })
|
|
351
352
|
.then((r) => {
|
|
352
353
|
const data = r && r.data
|
|
353
354
|
this.auditEntries = (data && (data.results || data)) || []
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { onBeforeUnmount, ref } from 'vue'
|
|
2
|
+
import { rowActionPayload } from '../utils/rowActionItem.js'
|
|
2
3
|
|
|
3
4
|
export const CTX_MENU_CSS_VAR_X = '--cn-ctx-menu-x'
|
|
4
5
|
export const CTX_MENU_CSS_VAR_Y = '--cn-ctx-menu-y'
|
|
@@ -135,15 +136,16 @@ export function useContextMenu() {
|
|
|
135
136
|
*
|
|
136
137
|
* Calls `action.handler(targetItem)` if a handler exists, then returns a
|
|
137
138
|
* payload object the caller can pass to `$emit('action', payload)`.
|
|
139
|
+
* The payload is the one CnRowActions and CnContextMenu emit: the label as `action`, plus `id` and `builtin: true` when the action carries them.
|
|
138
140
|
*
|
|
139
141
|
* @param {object} action Action definition with `label` and optional `handler`
|
|
140
|
-
* @return {{ action: string, row: object }}
|
|
142
|
+
* @return {{ action: string, row: object, id?: string, builtin?: boolean }}
|
|
141
143
|
*/
|
|
142
144
|
function triggerAction(action) {
|
|
143
145
|
if (action.handler && typeof action.handler === 'function') {
|
|
144
146
|
action.handler(targetItem.value)
|
|
145
147
|
}
|
|
146
|
-
return
|
|
148
|
+
return rowActionPayload(action, targetItem.value)
|
|
147
149
|
}
|
|
148
150
|
|
|
149
151
|
// Clean up DOM if the consumer unmounts while the menu is open — nothing
|
package/src/mixins/pageViews.js
CHANGED
|
@@ -18,11 +18,19 @@
|
|
|
18
18
|
* `layout` with its own grid, so a view looks exactly like the page's own
|
|
19
19
|
* grid.
|
|
20
20
|
*
|
|
21
|
+
* With `userLayout` on, a user arranges each view's grid for themselves, as
|
|
22
|
+
* they arrange the dashboard's own grid. The arrangement is stored per view,
|
|
23
|
+
* per page and per user, through the same user-preference record the
|
|
24
|
+
* dashboard uses (the server, mirrored in the browser). The manifest stays
|
|
25
|
+
* the base: it decides which widgets a view has, the user decides where they
|
|
26
|
+
* sit, and a reset returns the view to the manifest.
|
|
27
|
+
*
|
|
21
28
|
* @mixin pageViews
|
|
22
29
|
* @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-page-declares-views-that-each-hold-a-widget-grid
|
|
23
30
|
*/
|
|
24
31
|
import { translate as t } from '@nextcloud/l10n'
|
|
25
32
|
import { readUserPreference, USER_PREFERENCE_STORAGE_PREFIX, writeUserPreference } from '../composables/useUserPreferences.js'
|
|
33
|
+
import { dashboardLayoutKey, LAYOUT_GEOMETRY_FIELDS, mergeUserLayout, resolveUserLayoutApi } from '../store/plugins/dashboardLayouts.js'
|
|
26
34
|
|
|
27
35
|
/** The query parameter that carries the chosen view. */
|
|
28
36
|
export const PAGE_VIEW_QUERY_KEY = 'view'
|
|
@@ -30,18 +38,32 @@ export const PAGE_VIEW_QUERY_KEY = 'view'
|
|
|
30
38
|
/** Prefix of the user-preference key; the page id follows it. */
|
|
31
39
|
export const PAGE_VIEW_PREFERENCE_PREFIX = 'cn_page_view:'
|
|
32
40
|
|
|
41
|
+
/**
|
|
42
|
+
* The page id one view's user layout is stored under: the page's own id,
|
|
43
|
+
* then `.view.`, then the view id. Spelled once, because a read and a write
|
|
44
|
+
* that disagree about the key read as an arrangement that never persists.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} pageKey The page's id.
|
|
47
|
+
* @param {string} viewId The view id.
|
|
48
|
+
* @return {string} The page id handed to the layout store.
|
|
49
|
+
* @spec openspec/changes/page-view-user-layouts/specs/view-switch-containers/spec.md#requirement-a-user-arranges-each-view-for-themselves
|
|
50
|
+
*/
|
|
51
|
+
export function pageViewLayoutId(pageKey, viewId) {
|
|
52
|
+
return `${String(pageKey || 'page')}.view.${String(viewId)}`
|
|
53
|
+
}
|
|
54
|
+
|
|
33
55
|
let regionSeq = 0
|
|
34
56
|
|
|
35
57
|
/**
|
|
36
|
-
* Read the browser mirror of a user preference synchronously, so
|
|
37
|
-
*
|
|
58
|
+
* Read the browser mirror of a user preference synchronously, so a stored
|
|
59
|
+
* value is on screen from the first frame. Errors read as nothing stored.
|
|
38
60
|
*
|
|
39
61
|
* @param {string} appId The app id.
|
|
40
62
|
* @param {string} key The preference key.
|
|
41
|
-
* @return {string|null} The stored
|
|
42
|
-
* @spec openspec/changes/view-
|
|
63
|
+
* @return {object|string|number|boolean|Array|null} The stored value, or null.
|
|
64
|
+
* @spec openspec/changes/page-view-user-layouts/specs/view-switch-containers/spec.md#requirement-a-user-arranges-each-view-for-themselves
|
|
43
65
|
*/
|
|
44
|
-
function
|
|
66
|
+
function readPreferenceMirror(appId, key) {
|
|
45
67
|
try {
|
|
46
68
|
if (typeof localStorage === 'undefined') {
|
|
47
69
|
return null
|
|
@@ -50,19 +72,45 @@ function readStoredViewMirror(appId, key) {
|
|
|
50
72
|
if (raw === null || raw === undefined) {
|
|
51
73
|
return null
|
|
52
74
|
}
|
|
53
|
-
|
|
54
|
-
return typeof value === 'string' && value !== '' ? value : null
|
|
75
|
+
return JSON.parse(raw)
|
|
55
76
|
} catch {
|
|
56
77
|
return null
|
|
57
78
|
}
|
|
58
79
|
}
|
|
59
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Read the browser mirror of the stored view synchronously, so the stored
|
|
83
|
+
* view is on screen from the first frame.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} appId The app id.
|
|
86
|
+
* @param {string} key The preference key.
|
|
87
|
+
* @return {string|null} The stored id, or null.
|
|
88
|
+
* @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-the-chosen-view-is-linkable-and-remembered
|
|
89
|
+
*/
|
|
90
|
+
function readStoredViewMirror(appId, key) {
|
|
91
|
+
const value = readPreferenceMirror(appId, key)
|
|
92
|
+
return typeof value === 'string' && value !== '' ? value : null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Whether a stored layout record holds an arrangement.
|
|
97
|
+
*
|
|
98
|
+
* @param {object|Array<object>|null} record The record, or its items.
|
|
99
|
+
* @return {boolean}
|
|
100
|
+
*/
|
|
101
|
+
function holdsArrangement(record) {
|
|
102
|
+
const items = Array.isArray(record) ? record : (record && Array.isArray(record.items) ? record.items : [])
|
|
103
|
+
return items.length > 0
|
|
104
|
+
}
|
|
105
|
+
|
|
60
106
|
export const pageViews = {
|
|
61
107
|
inject: {
|
|
62
108
|
/** The host app id, provided by CnAppRoot; keys the stored view. */
|
|
63
109
|
cnPageViewsAppId: { from: 'cnAppId', default: '' },
|
|
64
110
|
},
|
|
65
111
|
|
|
112
|
+
emits: ['view-layout-change', 'view-layout-reset'],
|
|
113
|
+
|
|
66
114
|
props: {
|
|
67
115
|
/**
|
|
68
116
|
* Views of this page, each its own widget grid behind one segmented
|
|
@@ -110,6 +158,17 @@ export const pageViews = {
|
|
|
110
158
|
viewChosen: false,
|
|
111
159
|
/** Id of the view region, the target of the options' `aria-controls`. */
|
|
112
160
|
viewRegionId: `cn-page-view-region-${regionSeq}`,
|
|
161
|
+
/**
|
|
162
|
+
* This user's arrangement of each view, by view id, when the page
|
|
163
|
+
* keeps a user layout. A separate copy, never the view's `layout`:
|
|
164
|
+
* that array is the manifest, and writing a user's drag into it
|
|
165
|
+
* would rearrange the view for everybody.
|
|
166
|
+
*
|
|
167
|
+
* @type {Record<string, Array<object>>}
|
|
168
|
+
*/
|
|
169
|
+
viewUserLayouts: {},
|
|
170
|
+
/** The views whose arrangement changed since the last save, by id. */
|
|
171
|
+
viewUserLayoutsDirty: {},
|
|
113
172
|
}
|
|
114
173
|
},
|
|
115
174
|
|
|
@@ -207,6 +266,32 @@ export const pageViews = {
|
|
|
207
266
|
return this.normalizedViews.find((view) => view.id === this.activeViewId) || null
|
|
208
267
|
},
|
|
209
268
|
|
|
269
|
+
/**
|
|
270
|
+
* Whether a user arranges the views for themselves (`userLayout`).
|
|
271
|
+
*
|
|
272
|
+
* @return {boolean}
|
|
273
|
+
* @spec openspec/changes/page-view-user-layouts/specs/view-switch-containers/spec.md#requirement-a-user-arranges-each-view-for-themselves
|
|
274
|
+
*/
|
|
275
|
+
viewUserLayoutOn() {
|
|
276
|
+
return this.userLayout === true && this.hasViews
|
|
277
|
+
},
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The chosen view's layout: this user's arrangement when they have
|
|
281
|
+
* one, the manifest's otherwise.
|
|
282
|
+
*
|
|
283
|
+
* @return {Array<object>}
|
|
284
|
+
* @spec openspec/changes/page-view-user-layouts/specs/view-switch-containers/spec.md#requirement-a-user-arranges-each-view-for-themselves
|
|
285
|
+
*/
|
|
286
|
+
activeViewLayout() {
|
|
287
|
+
const view = this.activeView
|
|
288
|
+
if (!view) {
|
|
289
|
+
return []
|
|
290
|
+
}
|
|
291
|
+
const arranged = this.viewUserLayoutOn ? this.viewUserLayouts[view.id] : null
|
|
292
|
+
return Array.isArray(arranged) ? arranged : view.layout
|
|
293
|
+
},
|
|
294
|
+
|
|
210
295
|
/**
|
|
211
296
|
* The switch's options, for CnSegmentedControl.
|
|
212
297
|
*
|
|
@@ -288,8 +373,21 @@ export const pageViews = {
|
|
|
288
373
|
},
|
|
289
374
|
},
|
|
290
375
|
|
|
376
|
+
watch: {
|
|
377
|
+
/**
|
|
378
|
+
* Read the user's arrangement of a view the first time it is shown.
|
|
379
|
+
*
|
|
380
|
+
* @param {string|null} id The chosen view id.
|
|
381
|
+
* @return {void}
|
|
382
|
+
*/
|
|
383
|
+
activeViewId(id) {
|
|
384
|
+
this.loadViewUserLayout(id)
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
|
|
291
388
|
created() {
|
|
292
389
|
this.loadStoredView()
|
|
390
|
+
this.loadViewUserLayout(this.activeViewId)
|
|
293
391
|
},
|
|
294
392
|
|
|
295
393
|
methods: {
|
|
@@ -361,18 +459,187 @@ export const pageViews = {
|
|
|
361
459
|
},
|
|
362
460
|
|
|
363
461
|
/**
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
462
|
+
* The page id a view's arrangement is stored under.
|
|
463
|
+
*
|
|
464
|
+
* @param {string} viewId The view id.
|
|
465
|
+
* @return {string}
|
|
466
|
+
* @spec openspec/changes/page-view-user-layouts/specs/view-switch-containers/spec.md#requirement-a-user-arranges-each-view-for-themselves
|
|
467
|
+
*/
|
|
468
|
+
viewLayoutPageId(viewId) {
|
|
469
|
+
const pageKey = this.viewPreferenceKey.slice(PAGE_VIEW_PREFERENCE_PREFIX.length)
|
|
470
|
+
return pageViewLayoutId(pageKey, viewId)
|
|
471
|
+
},
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* The layout store calls: the page's own (`userLayoutApi`, so an
|
|
475
|
+
* injected `userLayoutStore` covers the views too), else the user
|
|
476
|
+
* preferences.
|
|
477
|
+
*
|
|
478
|
+
* @return {{load: (appId: string, pageId: string) => Promise<object|null>, save: (appId: string, pageId: string, layout: Array<object>) => Promise<boolean>, reset: (appId: string, pageId: string) => Promise<boolean>}}
|
|
479
|
+
*/
|
|
480
|
+
viewLayoutApi() {
|
|
481
|
+
if (typeof this.userLayoutApi === 'function') {
|
|
482
|
+
return this.userLayoutApi()
|
|
483
|
+
}
|
|
484
|
+
return resolveUserLayoutApi(null)
|
|
485
|
+
},
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Read this user's arrangement of one view, once: the browser mirror
|
|
489
|
+
* at once, then the server, which wins unless the user already moved
|
|
490
|
+
* something in that view. A page without `userLayout` reads nothing.
|
|
491
|
+
*
|
|
492
|
+
* @param {string|null} viewId The view id.
|
|
493
|
+
* @return {Promise<void>}
|
|
494
|
+
* @spec openspec/changes/page-view-user-layouts/specs/view-switch-containers/spec.md#requirement-a-user-arranges-each-view-for-themselves
|
|
495
|
+
*/
|
|
496
|
+
async loadViewUserLayout(viewId) {
|
|
497
|
+
if (!this.viewUserLayoutOn || viewId === null || viewId === undefined) {
|
|
498
|
+
return
|
|
499
|
+
}
|
|
500
|
+
const view = this.normalizedViews.find((candidate) => candidate.id === String(viewId))
|
|
501
|
+
if (!view || Array.isArray(this.viewUserLayouts[view.id])) {
|
|
502
|
+
return
|
|
503
|
+
}
|
|
504
|
+
const appId = this.viewPreferenceAppId
|
|
505
|
+
const pageId = this.viewLayoutPageId(view.id)
|
|
506
|
+
const mirror = readPreferenceMirror(appId, dashboardLayoutKey(pageId))
|
|
507
|
+
this.viewUserLayouts = { ...this.viewUserLayouts, [view.id]: mergeUserLayout(view.layout, holdsArrangement(mirror) ? mirror : null) }
|
|
508
|
+
if (!appId) {
|
|
509
|
+
return
|
|
510
|
+
}
|
|
511
|
+
let record
|
|
512
|
+
try {
|
|
513
|
+
record = await this.viewLayoutApi().load(appId, pageId)
|
|
514
|
+
} catch {
|
|
515
|
+
// The mirror, or the manifest, stands: a store that cannot
|
|
516
|
+
// answer leaves the view as the user last saw it.
|
|
517
|
+
return
|
|
518
|
+
}
|
|
519
|
+
if (this.viewUserLayoutsDirty[view.id] || !holdsArrangement(record)) {
|
|
520
|
+
return
|
|
521
|
+
}
|
|
522
|
+
this.viewUserLayouts = { ...this.viewUserLayouts, [view.id]: mergeUserLayout(view.layout, record) }
|
|
523
|
+
},
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Store the arrangement of every view the user changed since the last
|
|
527
|
+
* save. The page calls it when the user leaves edit mode, so a drag
|
|
528
|
+
* is not a write per pixel gesture.
|
|
529
|
+
*
|
|
530
|
+
* @return {Promise<void>}
|
|
531
|
+
* @spec openspec/changes/page-view-user-layouts/specs/view-switch-containers/spec.md#requirement-a-user-arranges-each-view-for-themselves
|
|
532
|
+
*/
|
|
533
|
+
async saveViewUserLayouts() {
|
|
534
|
+
if (!this.viewUserLayoutOn) {
|
|
535
|
+
return
|
|
536
|
+
}
|
|
537
|
+
const ids = Object.keys(this.viewUserLayoutsDirty).filter((id) => this.viewUserLayoutsDirty[id])
|
|
538
|
+
this.viewUserLayoutsDirty = {}
|
|
539
|
+
const appId = this.viewPreferenceAppId
|
|
540
|
+
for (const id of ids) {
|
|
541
|
+
const pageId = this.viewLayoutPageId(id)
|
|
542
|
+
const items = this.viewUserLayouts[id] || []
|
|
543
|
+
try {
|
|
544
|
+
if (appId) {
|
|
545
|
+
await this.viewLayoutApi().save(appId, pageId, items)
|
|
546
|
+
} else {
|
|
547
|
+
// No app to address: keep it in this browser only, as
|
|
548
|
+
// the chosen view is kept.
|
|
549
|
+
await writeUserPreference('', dashboardLayoutKey(pageId), { items: items.map((item) => this.viewGeometry(item, true)) })
|
|
550
|
+
}
|
|
551
|
+
} catch {
|
|
552
|
+
// The arrangement stands for this session. A toast on a
|
|
553
|
+
// failed layout write is noise on an instance without the
|
|
554
|
+
// preference route.
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
},
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Drop this user's arrangement of one view and return it to the
|
|
561
|
+
* manifest layout. Other views keep theirs.
|
|
562
|
+
*
|
|
563
|
+
* @param {string} [viewId] The view id; the chosen view when left out.
|
|
564
|
+
* @return {Promise<void>}
|
|
565
|
+
* @spec openspec/changes/page-view-user-layouts/specs/view-switch-containers/spec.md#requirement-resetting-a-view-restores-its-manifest-layout
|
|
566
|
+
*/
|
|
567
|
+
async resetViewUserLayout(viewId) {
|
|
568
|
+
const id = viewId === undefined || viewId === null ? this.activeViewId : String(viewId)
|
|
569
|
+
const view = this.normalizedViews.find((candidate) => candidate.id === id)
|
|
570
|
+
if (!this.viewUserLayoutOn || !view) {
|
|
571
|
+
return
|
|
572
|
+
}
|
|
573
|
+
const appId = this.viewPreferenceAppId
|
|
574
|
+
const pageId = this.viewLayoutPageId(view.id)
|
|
575
|
+
try {
|
|
576
|
+
if (appId) {
|
|
577
|
+
await this.viewLayoutApi().reset(appId, pageId)
|
|
578
|
+
} else {
|
|
579
|
+
await writeUserPreference('', dashboardLayoutKey(pageId), { items: [] })
|
|
580
|
+
}
|
|
581
|
+
} catch {
|
|
582
|
+
// The view returns to the manifest either way, which is what
|
|
583
|
+
// the user asked for.
|
|
584
|
+
}
|
|
585
|
+
const dirty = { ...this.viewUserLayoutsDirty }
|
|
586
|
+
delete dirty[view.id]
|
|
587
|
+
this.viewUserLayoutsDirty = dirty
|
|
588
|
+
this.viewUserLayouts = { ...this.viewUserLayouts, [view.id]: mergeUserLayout(view.layout, null) }
|
|
589
|
+
/**
|
|
590
|
+
* Emitted when the user drops their arrangement of a view and it
|
|
591
|
+
* returns to the manifest layout.
|
|
592
|
+
*
|
|
593
|
+
* @event view-layout-reset
|
|
594
|
+
* @type {{view: string}}
|
|
595
|
+
*/
|
|
596
|
+
this.$emit('view-layout-reset', { view: view.id })
|
|
597
|
+
},
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* The geometry fields of one layout item, and nothing else.
|
|
601
|
+
*
|
|
602
|
+
* @param {object} item A layout item, or a grid update.
|
|
603
|
+
* @param {boolean} [withWidgetId] Whether to keep the widget id too.
|
|
604
|
+
* @return {object}
|
|
605
|
+
*/
|
|
606
|
+
viewGeometry(item, withWidgetId = false) {
|
|
607
|
+
const geometry = withWidgetId ? { widgetId: String(item?.widgetId ?? '') } : {}
|
|
608
|
+
for (const field of LAYOUT_GEOMETRY_FIELDS) {
|
|
609
|
+
if (item?.[field] !== undefined) {
|
|
610
|
+
geometry[field] = item[field]
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return geometry
|
|
614
|
+
},
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* A drag or resize in the chosen view's grid. With `userLayout` it
|
|
618
|
+
* moves this user's copy of the view and marks it for saving; the
|
|
619
|
+
* view's manifest `layout` is not touched. Without it the geometry is
|
|
620
|
+
* written back onto the layout items in place, as the page does for
|
|
621
|
+
* its own layout, so the in-app manifest editor sees it.
|
|
367
622
|
*
|
|
368
623
|
* @param {Array<object>} updated The layout from the grid.
|
|
369
624
|
* @return {void}
|
|
625
|
+
* @spec openspec/changes/page-view-user-layouts/specs/view-switch-containers/spec.md#requirement-a-user-arranges-each-view-for-themselves
|
|
370
626
|
*/
|
|
371
627
|
onViewLayoutChange(updated) {
|
|
372
628
|
const view = this.activeView
|
|
373
629
|
if (!view || !Array.isArray(updated)) {
|
|
374
630
|
return
|
|
375
631
|
}
|
|
632
|
+
if (this.viewUserLayoutOn) {
|
|
633
|
+
const current = Array.isArray(this.viewUserLayouts[view.id]) ? this.viewUserLayouts[view.id] : mergeUserLayout(view.layout, null)
|
|
634
|
+
const arranged = current.map((item) => {
|
|
635
|
+
const u = updated.find((candidate) => String(candidate.id) === String(item.id))
|
|
636
|
+
return u ? { ...item, ...this.viewGeometry(u) } : item
|
|
637
|
+
})
|
|
638
|
+
this.viewUserLayouts = { ...this.viewUserLayouts, [view.id]: arranged }
|
|
639
|
+
this.viewUserLayoutsDirty = { ...this.viewUserLayoutsDirty, [view.id]: true }
|
|
640
|
+
this.$emit('view-layout-change', { view: view.id, layout: arranged })
|
|
641
|
+
return
|
|
642
|
+
}
|
|
376
643
|
for (const u of updated) {
|
|
377
644
|
const item = view.layout.find((l) => String(l.id) === String(u.id))
|
|
378
645
|
if (!item) {
|