@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
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: package/dist/nextcloud-vue.cjs.js.map
3
- size: 35437389 bytes
4
- sha256: a49cebe981b616ba6555ce135455260b6b111ebcea9afc9c34da210cbc38f32b
3
+ size: 35499103 bytes
4
+ sha256: fe1cf925335eec45a51c59903eb32d313c16bdcbdaf047c4b80fad05712db22a
package/l10n/en.json CHANGED
@@ -1092,6 +1092,9 @@
1092
1092
  "An attention card needs a title": "An attention card needs a title",
1093
1093
  "Good morning, {name}": "Good morning, {name}",
1094
1094
  "This view has no widgets yet.": "This view has no widgets yet.",
1095
+ "Arrange view": "Arrange view",
1096
+ "Reset view": "Reset view",
1097
+ "Reset layout": "Reset layout",
1095
1098
  "Good morning": "Good morning",
1096
1099
  "Good afternoon, {name}": "Good afternoon, {name}",
1097
1100
  "Good afternoon": "Good afternoon",
package/l10n/nl.json CHANGED
@@ -1136,6 +1136,9 @@
1136
1136
  "An attention card needs a title": "Een aandachtskaart heeft een titel nodig",
1137
1137
  "Good morning, {name}": "Goedemorgen, {name}",
1138
1138
  "This view has no widgets yet.": "Deze weergave heeft nog geen widgets.",
1139
+ "Arrange view": "Weergave indelen",
1140
+ "Reset view": "Weergave herstellen",
1141
+ "Reset layout": "Indeling herstellen",
1139
1142
  "Good morning": "Goedemorgen",
1140
1143
  "Good afternoon, {name}": "Goedemiddag, {name}",
1141
1144
  "Good afternoon": "Goedemiddag",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/nextcloud-vue",
3
- "version": "2.68.0",
3
+ "version": "2.69.0",
4
4
  "description": "Shared Vue component library for Conduction Nextcloud apps — complements @nextcloud/vue with higher-level components, OpenRegister integration, and NL Design System support",
5
5
  "homepage": "https://github.com/ConductionNL/nextcloud-vue#readme",
6
6
  "bugs": {
@@ -153,6 +153,8 @@ Three behaviours are worth knowing because each is load-bearing:
153
153
 
154
154
  Call `resetUserLayout()` to drop the arrangement; the page emits `user-layout-reset` and returns to the manifest.
155
155
 
156
+ The same `userLayout` covers the page's [views](../../../docs/components/cn-dashboard-page.md#each-view-arranged-per-user-userlayout): each view's grid is draggable in edit mode and is stored per view, under the page id with `.view.<view id>` appended. Edit mode shows **Reset layout**, which returns the page's own grid and the chosen view to the manifest (`user-layout-reset`, `view-layout-reset`). `resetViewUserLayout(viewId)` resets one view.
157
+
156
158
  ## Slots
157
159
 
158
160
  | Slot | Scope | Description |
@@ -67,6 +67,19 @@
67
67
  </template>
68
68
  {{ isEditing ? doneLabel : editLabel }}
69
69
  </NcButton>
70
+ <!-- With `userLayout`, edit mode offers a way back to the
71
+ manifest: the page's own grid and the chosen view return
72
+ to the layout the app ships. Other views keep theirs. -->
73
+ <NcButton
74
+ v-if="userLayout && isEditing"
75
+ variant="tertiary"
76
+ data-testid="cn-dashboard-page-reset-layout"
77
+ @click="resetLayout">
78
+ <template #icon>
79
+ <Restore :size="20" />
80
+ </template>
81
+ {{ t('nextcloud-vue', 'Reset layout') }}
82
+ </NcButton>
70
83
  <!-- In-app edit button (ADR-041). Renders only when Buildiq is
71
84
  reachable; self-wires from the cnManifestEditor / cnOpenBuildAvailable
72
85
  provided by CnAppRoot. Sits inline with the page's action buttons. -->
@@ -754,6 +767,7 @@ import Check from 'vue-material-design-icons/Check.vue'
754
767
  import Cog from 'vue-material-design-icons/Cog.vue'
755
768
  import Download from 'vue-material-design-icons/Download.vue'
756
769
  import Pencil from 'vue-material-design-icons/Pencil.vue'
770
+ import Restore from 'vue-material-design-icons/Restore.vue'
757
771
  import ViewDashboardOutline from 'vue-material-design-icons/ViewDashboardOutline.vue'
758
772
  import CnWidgetStyleEditorModal from '../../dialogs/CnWidgetStyleEditorModal.vue'
759
773
  import CnBodySections from '../CnBodySections/CnBodySections.vue'
@@ -768,9 +782,8 @@ import CnWidgetRefItem from '../CnWidgetRefItem/CnWidgetRefItem.vue'
768
782
  import CnWidgetRenderer from '../CnWidgetRenderer/CnWidgetRenderer.vue'
769
783
  import CnWidgetWrapper from '../CnWidgetWrapper/CnWidgetWrapper.vue'
770
784
  import { useIntegrationRegistry } from '../../composables/useIntegrationRegistry.js'
771
- import { readUserPreference, writeUserPreference } from '../../composables/useUserPreferences.js'
772
785
  import { pageViews } from '../../mixins/pageViews.js'
773
- import { dashboardLayoutKey, mergeUserLayout } from '../../store/plugins/dashboardLayouts.js'
786
+ import { mergeUserLayout, resolveUserLayoutApi } from '../../store/plugins/dashboardLayouts.js'
774
787
  import { isAppInstalled } from '../../utils/appInstalled.js'
775
788
  import { compareVisibleWhen, readVisibleWhenValue } from '../../utils/visibleWhen.js'
776
789
  import { canonicalWidgetType } from '../../utils/widgetTypeAliases.js'
@@ -905,6 +918,7 @@ export default {
905
918
  NcLoadingIcon,
906
919
  NcSelect,
907
920
  Pencil,
921
+ Restore,
908
922
  Check,
909
923
  Cog,
910
924
  CalendarRange,
@@ -1958,7 +1972,7 @@ export default {
1958
1972
  * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-view-with-nothing-to-draw-says-so
1959
1973
  */
1960
1974
  viewDisplayLayout() {
1961
- const items = this.activeView ? this.activeView.layout : []
1975
+ const items = this.activeViewLayout
1962
1976
  if (this.gridEditable) {
1963
1977
  return items
1964
1978
  }
@@ -2000,8 +2014,9 @@ export default {
2000
2014
  key: `view-${this.activeViewId}`,
2001
2015
  view: true,
2002
2016
  layout: this.viewDisplayLayout,
2003
- // A user arrangement covers the page's own layout only.
2004
- editable: this.gridEditable && !this.userLayout,
2017
+ // With `userLayout` the user arranges the view too; the
2018
+ // arrangement is kept per view (pageViews mixin).
2019
+ editable: this.gridEditable,
2005
2020
  attrs: {
2006
2021
  id: this.viewRegionId,
2007
2022
  role: 'region',
@@ -2721,6 +2736,7 @@ export default {
2721
2736
  // the user's arrangement is finished when they say it is.
2722
2737
  if (leaving) {
2723
2738
  this.saveUserLayout()
2739
+ this.saveViewUserLayouts()
2724
2740
  }
2725
2741
 
2726
2742
  /**
@@ -2780,6 +2796,17 @@ export default {
2780
2796
  }
2781
2797
  },
2782
2798
 
2799
+ /**
2800
+ * The edit-mode reset: the page's own grid and the chosen view return
2801
+ * to the manifest layout. Other views keep this user's arrangement.
2802
+ *
2803
+ * @return {Promise<void>}
2804
+ * @spec openspec/changes/page-view-user-layouts/specs/view-switch-containers/spec.md#requirement-resetting-a-view-restores-its-manifest-layout
2805
+ */
2806
+ async resetLayout() {
2807
+ await Promise.all([this.resetUserLayout(), this.resetViewUserLayout()])
2808
+ },
2809
+
2783
2810
  /**
2784
2811
  * Drop this user's arrangement and return to the manifest.
2785
2812
  *
@@ -2832,35 +2859,7 @@ export default {
2832
2859
  * @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.
2833
2860
  */
2834
2861
  userLayoutApi() {
2835
- const store = this.userLayoutStore
2836
- if (store && typeof store.loadDashboardLayout === 'function') {
2837
- return {
2838
- load: (...a) => store.loadDashboardLayout(...a),
2839
- save: (...a) => store.saveDashboardLayout(...a),
2840
- reset: (...a) => store.resetDashboardLayout(...a),
2841
- }
2842
- }
2843
-
2844
- return {
2845
- load: (appId, pageId) => readUserPreference(appId, dashboardLayoutKey(pageId), null),
2846
- save: (appId, pageId, layout) => writeUserPreference(
2847
- appId,
2848
- dashboardLayoutKey(pageId),
2849
- { items: (layout || []).map((i) => ({
2850
- widgetId: i.widgetId,
2851
- gridX: i.gridX,
2852
- gridY: i.gridY,
2853
- gridWidth: i.gridWidth,
2854
- gridHeight: i.gridHeight,
2855
- })) },
2856
- ),
2857
-
2858
- reset: (appId, pageId) => writeUserPreference(
2859
- appId,
2860
- dashboardLayoutKey(pageId),
2861
- { items: [] },
2862
- ),
2863
- }
2862
+ return resolveUserLayoutApi(this.userLayoutStore)
2864
2863
  },
2865
2864
 
2866
2865
  onLayoutChange(updated) {
@@ -3906,7 +3905,7 @@ export default {
3906
3905
  // library's own defaults, so a manifest need not set them — but a
3907
3906
  // manifest that DOES set them must reach the component, or the
3908
3907
  // declaration is a silent no-op that reads like configuration.
3909
- for (const key of ['countLabel', 'variant', 'showZeroCount', 'horizontal', 'vertical', 'filled', 'route', 'iconClass']) {
3908
+ for (const key of ['countLabel', 'variant', 'showZeroCount', 'horizontal', 'vertical', 'filled', 'route', 'iconClass', 'format']) {
3910
3909
  if (props[key] !== undefined) {
3911
3910
  out[key] = props[key]
3912
3911
  }
@@ -211,6 +211,12 @@ export default {
211
211
  | `excludeFields` (`exclude-fields`) | Array | `[]` | Fields the create and edit forms never ask for. Applied after `includeFields`. |
212
212
  | `fieldOverrides` (`field-overrides`) | Object | `{}` | Per-field widget / label overrides for the create and edit forms, keyed by field name. Forwarded to `CnFormDialog`. |
213
213
 
214
+ ## Each view arranged per user
215
+
216
+ | Prop | Type | Default | Description |
217
+ |------|------|---------|-------------|
218
+ | `userLayout` | Boolean | `false` | Let the user arrange each of the page's `views` for themselves. The header shows **Arrange view** next to the view switch; **Done** stores the arrangement per view and user (a user preference, mirrored in the browser), **Reset view** returns the chosen view to its manifest layout. The manifest `layout` is never changed. See [the guide](../../../docs/components/cn-detail-page.md#each-view-arranged-per-user-userlayout). |
219
+
214
220
  ## Slots
215
221
 
216
222
  | Slot | Description |
@@ -157,6 +157,31 @@
157
157
  :controls="viewRegionId"
158
158
  data-testid="cn-detail-page-view-switch"
159
159
  @update:modelValue="selectView" />
160
+ <!-- With `userLayout`, the user arranges the chosen view for
161
+ themselves: Arrange view makes its grid draggable, Done
162
+ stores the arrangement, Reset view returns the view to the
163
+ layout the app ships. -->
164
+ <NcButton
165
+ v-if="userLayout && viewUserLayoutOn && !viewIsEmpty"
166
+ :variant="arrangingView ? 'primary' : 'secondary'"
167
+ data-testid="cn-detail-page-arrange-view"
168
+ @click="toggleArrangeView">
169
+ <template #icon>
170
+ <Check v-if="arrangingView" :size="20" />
171
+ <Pencil v-else :size="20" />
172
+ </template>
173
+ {{ arrangingView ? t('nextcloud-vue', 'Done') : t('nextcloud-vue', 'Arrange view') }}
174
+ </NcButton>
175
+ <NcButton
176
+ v-if="viewUserLayoutOn && arrangingView"
177
+ variant="tertiary"
178
+ data-testid="cn-detail-page-reset-view"
179
+ @click="resetViewUserLayout()">
180
+ <template #icon>
181
+ <Restore :size="20" />
182
+ </template>
183
+ {{ t('nextcloud-vue', 'Reset view') }}
184
+ </NcButton>
160
185
  <!-- Next and previous inside the list this record was opened
161
186
  from. Rendered only when the address names that list: a
162
187
  record reached by a bare link offers neither, rather than
@@ -626,10 +651,10 @@
626
651
  keyed on its view, so switching mounts the other view's grid. -->
627
652
  <CnDashboardGrid
628
653
  v-for="section in bodyGridSections"
629
- :key="`cn-detail-grid-${section.key}-${editingBody ? 'editing' : 'live'}`"
654
+ :key="`cn-detail-grid-${section.key}-${section.editable ? 'editing' : 'live'}`"
630
655
  v-bind="section.attrs"
631
656
  :layout="section.layout"
632
- :editable="editingBody"
657
+ :editable="section.editable"
633
658
  :columns="12"
634
659
  :columnOpts="columnOpts"
635
660
  class="cn-detail-page__grid"
@@ -1060,6 +1085,7 @@ import { NcActionButton, NcActionCaption, NcActionLink, NcActionSeparator, NcBut
1060
1085
  import { provide, ref, watch } from 'vue'
1061
1086
  import AlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue'
1062
1087
  import ArrowLeft from 'vue-material-design-icons/ArrowLeft.vue'
1088
+ import Check from 'vue-material-design-icons/Check.vue'
1063
1089
  import ChevronLeft from 'vue-material-design-icons/ChevronLeft.vue'
1064
1090
  import ChevronRight from 'vue-material-design-icons/ChevronRight.vue'
1065
1091
  import Cog from 'vue-material-design-icons/Cog.vue'
@@ -1068,6 +1094,7 @@ import InformationOutline from 'vue-material-design-icons/InformationOutline.vue
1068
1094
  import Pencil from 'vue-material-design-icons/Pencil.vue'
1069
1095
  import Plus from 'vue-material-design-icons/Plus.vue'
1070
1096
  import Refresh from 'vue-material-design-icons/Refresh.vue'
1097
+ import Restore from 'vue-material-design-icons/Restore.vue'
1071
1098
  import ViewDashboardOutline from 'vue-material-design-icons/ViewDashboardOutline.vue'
1072
1099
  import CnRelationLinkModal from '../../dialogs/CnRelationLinkModal.vue'
1073
1100
  import CnWidgetStyleEditorModal from '../../dialogs/CnWidgetStyleEditorModal.vue'
@@ -1274,9 +1301,11 @@ export default {
1274
1301
  CnTranslatedBadge,
1275
1302
  CnWidgetStyleEditorModal,
1276
1303
  CnRelationLinkModal,
1304
+ Check,
1277
1305
  Cog,
1278
1306
  Plus,
1279
1307
  Pencil,
1308
+ Restore,
1280
1309
  },
1281
1310
 
1282
1311
  mixins: [gridLayout, pageViews],
@@ -1769,6 +1798,20 @@ export default {
1769
1798
  default: '',
1770
1799
  },
1771
1800
 
1801
+ /**
1802
+ * Whether the user arranges each view (`views`) for themselves. On,
1803
+ * the header offers Arrange view next to the view switch; the
1804
+ * arrangement is stored per view and per user (the server, mirrored
1805
+ * in the browser) and a reset returns the view to its manifest
1806
+ * layout. Off (the default), a view renders as the manifest says.
1807
+ *
1808
+ * @type {boolean}
1809
+ */
1810
+ userLayout: {
1811
+ type: Boolean,
1812
+ default: false,
1813
+ },
1814
+
1772
1815
  /**
1773
1816
  * Whether the Refresh entry renders in the page-header menu.
1774
1817
  * Tri-state:
@@ -2419,6 +2462,8 @@ export default {
2419
2462
 
2420
2463
  data() {
2421
2464
  return {
2465
+ /** Whether the user is arranging the chosen view (`userLayout`). */
2466
+ arrangingView: false,
2422
2467
  /** Whether the record edit form is open. */
2423
2468
  editFormOpen: false,
2424
2469
  /**
@@ -3589,7 +3634,7 @@ export default {
3589
3634
  viewDisplayLayout() {
3590
3635
  // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- reading the flag IS the effect, as in bodyGridLayout
3591
3636
  this.editingBody
3592
- return this.activeView ? this.closeDroppedRows(this.activeView.layout) : []
3637
+ return this.activeView ? this.closeDroppedRows(this.activeViewLayout) : []
3593
3638
  },
3594
3639
 
3595
3640
  /**
@@ -3607,16 +3652,18 @@ export default {
3607
3652
  * chosen view's grid in the region the view switch controls (left out
3608
3653
  * when the view is empty; the empty state takes the region then).
3609
3654
  *
3610
- * @return {Array<{key: string, view: boolean, layout: Array<object>, attrs: object}>}
3655
+ * @return {Array<{key: string, view: boolean, layout: Array<object>, editable: boolean, attrs: object}>}
3611
3656
  * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-page-declares-views-that-each-hold-a-widget-grid
3612
3657
  */
3613
3658
  bodyGridSections() {
3614
- const sections = this.hasBodyGrid ? [{ key: 'body', view: false, layout: this.bodyGridLayout, attrs: {} }] : []
3659
+ const sections = this.hasBodyGrid ? [{ key: 'body', view: false, layout: this.bodyGridLayout, editable: this.editingBody, attrs: {} }] : []
3615
3660
  if (this.hasViews && !this.viewIsEmpty) {
3616
3661
  sections.push({
3617
3662
  key: `view-${this.activeViewId}`,
3618
3663
  view: true,
3619
3664
  layout: this.viewDisplayLayout,
3665
+ // The user arranges the view while Arrange view is on.
3666
+ editable: this.editingBody || this.arrangingView,
3620
3667
  attrs: {
3621
3668
  id: this.viewRegionId,
3622
3669
  role: 'region',
@@ -3941,6 +3988,21 @@ export default {
3941
3988
  },
3942
3989
 
3943
3990
  methods: {
3991
+ /**
3992
+ * Start or finish arranging the chosen view. Finishing stores the
3993
+ * arrangement of every view the user moved, once.
3994
+ *
3995
+ * @return {void}
3996
+ * @spec openspec/changes/page-view-user-layouts/specs/view-switch-containers/spec.md#requirement-a-user-arranges-each-view-for-themselves
3997
+ */
3998
+ toggleArrangeView() {
3999
+ const leaving = this.arrangingView
4000
+ this.arrangingView = !this.arrangingView
4001
+ if (leaving) {
4002
+ this.saveViewUserLayouts()
4003
+ }
4004
+ },
4005
+
3944
4006
  // Expose the shared grid helpers to the template (grid mode + auto-body).
3945
4007
  cnGridCellStyle,
3946
4008
  hasGridRow,
@@ -1312,7 +1312,7 @@ export default {
1312
1312
  * @return {string}
1313
1313
  */
1314
1314
  formattedValue() {
1315
- return formatMetricValue(this.displayValue, this.content.format, this.configCtx)
1315
+ return formatMetricValue(this.displayValue, this.content.format, this.configCtx, this.objectCtx?.object || null)
1316
1316
  },
1317
1317
 
1318
1318
  /**
@@ -1346,7 +1346,7 @@ export default {
1346
1346
  return ''
1347
1347
  }
1348
1348
  const { prefix, suffix, ...rest } = (this.content.format || {})
1349
- return formatMetricValue(this.limitValue, rest, this.configCtx)
1349
+ return formatMetricValue(this.limitValue, rest, this.configCtx, this.objectCtx?.object || null)
1350
1350
  },
1351
1351
 
1352
1352
  /**
@@ -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,