@conduction/nextcloud-vue 2.68.0 → 2.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +21 -35
  2. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  3. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +17 -0
  4. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  5. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +40 -3
  6. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  7. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +58 -16
  8. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  9. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +2 -2
  10. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
  11. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
  12. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue2.js +70 -1
  13. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue2.js.map +1 -1
  14. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue3.js +23 -3
  15. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue3.js.map +1 -1
  16. package/dist/esm/l10n/en.json.js +3 -0
  17. package/dist/esm/l10n/en.json.js.map +1 -1
  18. package/dist/esm/l10n/nl.json.js +3 -0
  19. package/dist/esm/l10n/nl.json.js.map +1 -1
  20. package/dist/esm/mixins/pageViews.js +278 -11
  21. package/dist/esm/mixins/pageViews.js.map +1 -1
  22. package/dist/esm/store/plugins/dashboardLayouts.js +35 -1
  23. package/dist/esm/store/plugins/dashboardLayouts.js.map +1 -1
  24. package/dist/esm/utils/formatMetric.js +137 -16
  25. package/dist/esm/utils/formatMetric.js.map +1 -1
  26. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  27. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  28. package/dist/nextcloud-vue.cjs.js +678 -83
  29. package/dist/nextcloud-vue.cjs.js.map +2 -2
  30. package/l10n/en.json +3 -0
  31. package/l10n/nl.json +3 -0
  32. package/package.json +1 -1
  33. package/src/components/CnDashboardPage/CnDashboardPage.md +2 -0
  34. package/src/components/CnDashboardPage/CnDashboardPage.vue +34 -35
  35. package/src/components/CnDetailPage/CnDetailPage.md +6 -0
  36. package/src/components/CnDetailPage/CnDetailPage.vue +67 -5
  37. package/src/components/CnStatWidget/CnStatWidget.vue +2 -2
  38. package/src/components/CnStatsBlockWidget/CnStatsBlockWidget.vue +82 -3
  39. package/src/mixins/pageViews.js +277 -10
  40. package/src/schemas/app-manifest-v2.schema.json +28 -2
  41. package/src/store/plugins/dashboardLayouts.js +34 -0
  42. package/src/utils/formatMetric.js +143 -15
  43. package/src/utils/validateManifestV2.compiled.js +2 -2
@@ -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 the stored
37
- * view is on screen from the first frame. Errors read as nothing stored.
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 id, or null.
42
- * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-the-chosen-view-is-linkable-and-remembered
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 readStoredViewMirror(appId, key) {
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
- const value = JSON.parse(raw)
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
- * 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.
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) {
@@ -3,7 +3,7 @@
3
3
  "$id": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest-v2.schema.json",
4
4
  "title": "Conduction App Manifest v2",
5
5
  "description": "v2 schema for the JSON-driven page and navigation manifest consumed by @conduction/nextcloud-vue. Introduces a uniform widgets[] array on every page type with a per-slot grid coordinate system, a typed actions[] discriminator, and the required $schema field for version detection. v1 manifests continue to validate against app-manifest.schema.json (unchanged).",
6
- "version": "2.50.0",
6
+ "version": "2.51.0",
7
7
  "type": "object",
8
8
  "required": ["$schema", "version"],
9
9
  "allOf": [
@@ -2903,7 +2903,7 @@
2903
2903
  "type": "object",
2904
2904
  "required": ["register", "schema"],
2905
2905
  "additionalProperties": false,
2906
- "description": "One KPI entry inside a multi-entry stats-block widget (CnStatsBlockWidget `entries[]`). Carries the same source contract as a type:'stat' widget: register + schema + metric + token-resolved filter (@today / @me / @workspace.* and ?-optional clauses), plus per-entry presentation (title, variant, countLabel), an optional route deep link, and hideWhenZero.",
2906
+ "description": "One KPI entry inside a multi-entry stats-block widget (CnStatsBlockWidget `entries[]`). Carries the same source contract as a type:'stat' widget: register + schema + metric + token-resolved filter (@today / @me / @workspace.* and ?-optional clauses), plus per-entry presentation (title, variant, countLabel, format), an optional route deep link, and hideWhenZero. A money value names its currency with currencyField (the page object's field) or currency.",
2907
2907
  "properties": {
2908
2908
  "title": {
2909
2909
  "type": "string",
@@ -2952,6 +2952,32 @@
2952
2952
  "type": "boolean",
2953
2953
  "default": false,
2954
2954
  "description": "When true, the entry is omitted from the card when its resolved count is 0."
2955
+ },
2956
+ "format": {
2957
+ "description": "How the value is shown, as a type:'stat' widget's content.format: a style name, or { style, currency, currencyField, decimals, prefix, suffix }. Omitted, the plain localized count is shown. Numbers are formatted with Intl in the user's locale.",
2958
+ "oneOf": [
2959
+ { "type": "string", "enum": ["number", "currency", "percent", "decimal", "duration-hours"] },
2960
+ {
2961
+ "type": "object",
2962
+ "additionalProperties": false,
2963
+ "properties": {
2964
+ "style": { "type": "string", "enum": ["number", "currency", "percent", "decimal", "duration-hours"] },
2965
+ "currency": { "type": "string", "description": "ISO-4217 code, or a token such as @config.currency or @object.currency." },
2966
+ "currencyField": { "type": "string", "description": "Field of the detail page's object that holds the currency (dot-path allowed)." },
2967
+ "decimals": { "type": "integer", "minimum": 0, "description": "Fraction digits." },
2968
+ "prefix": { "type": "string" },
2969
+ "suffix": { "type": "string" }
2970
+ }
2971
+ }
2972
+ ]
2973
+ },
2974
+ "currency": {
2975
+ "type": "string",
2976
+ "description": "Shorthand for format.currency: an ISO-4217 code, or a token such as @config.currency. Implies format 'currency'."
2977
+ },
2978
+ "currencyField": {
2979
+ "type": "string",
2980
+ "description": "Shorthand for format.currencyField: the field of the detail page's object that holds the currency, e.g. 'currency'. Implies format 'currency'. The currency is taken from the object, then currency, then the app's reporting currency (config currency), then EUR."
2955
2981
  }
2956
2982
  }
2957
2983
  },
@@ -156,6 +156,40 @@ export function mergeUserLayout(manifestLayout, record) {
156
156
  return [...arranged, ...appended]
157
157
  }
158
158
 
159
+ /**
160
+ * The three calls a user layout needs: from a store that carries the
161
+ * `dashboardLayoutsPlugin` actions, or straight over user preferences.
162
+ *
163
+ * Both paths write through `writeUserPreference`, so a record lands on the
164
+ * server and in the browser mirror whichever one a page uses. CnDashboardPage
165
+ * reads its own grid's record through this, and the page views mixin reads
166
+ * each view's record through it, so the two cannot disagree about where a
167
+ * user's arrangement lives.
168
+ *
169
+ * @param {object|null} [store] A store with `loadDashboardLayout`, `saveDashboardLayout` and `resetDashboardLayout`, or null.
170
+ * @return {{ load: (appId: string, pageId: string) => Promise<object|null>, save: (appId: string, pageId: string, layout: Array<object>) => Promise<boolean>, reset: (appId: string, pageId: string) => Promise<boolean> }} The api.
171
+ * @spec openspec/changes/page-view-user-layouts/specs/view-switch-containers/spec.md#requirement-a-user-arranges-each-view-for-themselves
172
+ */
173
+ export function resolveUserLayoutApi(store = null) {
174
+ if (store && typeof store.loadDashboardLayout === 'function') {
175
+ return {
176
+ load: (...a) => store.loadDashboardLayout(...a),
177
+ save: (...a) => store.saveDashboardLayout(...a),
178
+ reset: (...a) => store.resetDashboardLayout(...a),
179
+ }
180
+ }
181
+
182
+ return {
183
+ load: (appId, pageId) => readUserPreference(appId, dashboardLayoutKey(pageId), null),
184
+ save: (appId, pageId, layout) => writeUserPreference(
185
+ appId,
186
+ dashboardLayoutKey(pageId),
187
+ { items: (Array.isArray(layout) ? layout : []).map(geometryOf).filter((entry) => entry.widgetId !== '') },
188
+ ),
189
+ reset: (appId, pageId) => writeUserPreference(appId, dashboardLayoutKey(pageId), { items: [] }),
190
+ }
191
+ }
192
+
159
193
  /**
160
194
  * Plugin definition.
161
195
  *