@conduction/nextcloud-vue 2.96.0 → 2.98.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 (163) hide show
  1. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +17 -0
  2. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
  3. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
  4. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue.js +1 -1
  5. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js +33 -1
  6. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js.map +1 -1
  7. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js +383 -359
  8. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js.map +1 -1
  9. package/dist/esm/components/CnChartWidget/CnChartWidget.vue.js +1 -1
  10. package/dist/esm/components/CnChartWidget/CnChartWidget.vue2.js +106 -2
  11. package/dist/esm/components/CnChartWidget/CnChartWidget.vue2.js.map +1 -1
  12. package/dist/esm/components/CnChartWidget/CnChartWidget.vue3.js.map +1 -1
  13. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue.js +9 -0
  14. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue.js.map +1 -0
  15. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue2.js +964 -0
  16. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue2.js.map +1 -0
  17. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue3.js +282 -0
  18. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue3.js.map +1 -0
  19. package/dist/esm/components/CnChecklistRunWidget/index.js +28 -0
  20. package/dist/esm/components/CnChecklistRunWidget/index.js.map +1 -0
  21. package/dist/esm/components/CnConversationThread/CnConversationThread.vue.js +1 -1
  22. package/dist/esm/components/CnConversationThread/CnConversationThread.vue2.js +17 -3
  23. package/dist/esm/components/CnConversationThread/CnConversationThread.vue2.js.map +1 -1
  24. package/dist/esm/components/CnConversationThread/CnConversationThread.vue3.js +61 -43
  25. package/dist/esm/components/CnConversationThread/CnConversationThread.vue3.js.map +1 -1
  26. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue.js +1 -1
  27. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue2.js +159 -18
  28. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue2.js.map +1 -1
  29. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue3.js +9 -7
  30. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue3.js.map +1 -1
  31. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
  32. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +1 -0
  33. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  34. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  35. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +26 -8
  36. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  37. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  38. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +188 -5
  39. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  40. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  41. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue.js +1 -1
  42. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js +48 -3
  43. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js.map +1 -1
  44. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js +46 -12
  45. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js.map +1 -1
  46. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue.js +1 -1
  47. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue2.js +3 -3
  48. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue2.js.map +1 -1
  49. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue3.js.map +1 -1
  50. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue.js +1 -1
  51. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue2.js +27 -0
  52. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue2.js.map +1 -1
  53. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue3.js +16 -2
  54. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue3.js.map +1 -1
  55. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue.js +1 -1
  56. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js +144 -0
  57. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js.map +1 -1
  58. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js +488 -399
  59. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js.map +1 -1
  60. package/dist/esm/components/CnFilesBrowser/filesBrowserColumns.js +8 -2
  61. package/dist/esm/components/CnFilesBrowser/filesBrowserColumns.js.map +1 -1
  62. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +344 -21
  63. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  64. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +29 -7
  65. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  66. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue.js +1 -1
  67. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue2.js +2 -2
  68. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue2.js.map +1 -1
  69. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue3.js +2 -2
  70. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue3.js.map +1 -1
  71. package/dist/esm/components/CnIndexPage/useNamedSource.js +1 -1
  72. package/dist/esm/components/CnIndexPage/useNamedSource.js.map +1 -1
  73. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue.js +1 -1
  74. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js +13 -0
  75. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js.map +1 -1
  76. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js +5 -1
  77. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js.map +1 -1
  78. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue.js +9 -0
  79. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue.js.map +1 -0
  80. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue2.js +220 -0
  81. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue2.js.map +1 -0
  82. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue3.js +121 -0
  83. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue3.js.map +1 -0
  84. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue.js +1 -1
  85. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue2.js +83 -9
  86. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue2.js.map +1 -1
  87. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue3.js +80 -11
  88. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue3.js.map +1 -1
  89. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
  90. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js.map +1 -1
  91. package/dist/esm/composables/indexSources.js +420 -5
  92. package/dist/esm/composables/indexSources.js.map +1 -1
  93. package/dist/esm/composables/useOrganisationStore.js +115 -0
  94. package/dist/esm/composables/useOrganisationStore.js.map +1 -0
  95. package/dist/esm/index.js +2 -0
  96. package/dist/esm/index.js.map +1 -1
  97. package/dist/esm/l10n/en.json.js +21 -1
  98. package/dist/esm/l10n/en.json.js.map +1 -1
  99. package/dist/esm/l10n/nl.json.js +36 -1
  100. package/dist/esm/l10n/nl.json.js.map +1 -1
  101. package/dist/esm/library-style.js +1 -1
  102. package/dist/esm/nextcloud-vue.css +725 -383
  103. package/dist/esm/utils/builtInFormatters.js +34 -1
  104. package/dist/esm/utils/builtInFormatters.js.map +1 -1
  105. package/dist/esm/utils/libraryWidgetKeys.js +1 -0
  106. package/dist/esm/utils/libraryWidgetKeys.js.map +1 -1
  107. package/dist/esm/utils/matrixCells.js +158 -0
  108. package/dist/esm/utils/matrixCells.js.map +1 -0
  109. package/dist/esm/utils/nextcloudDirectory.js +121 -0
  110. package/dist/esm/utils/nextcloudDirectory.js.map +1 -0
  111. package/dist/esm/utils/queryPresets.js +13 -2
  112. package/dist/esm/utils/queryPresets.js.map +1 -1
  113. package/dist/esm/utils/scopeListLayout.js +37 -1
  114. package/dist/esm/utils/scopeListLayout.js.map +1 -1
  115. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  116. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  117. package/dist/nextcloud-vue.cjs.js +14443 -10519
  118. package/dist/nextcloud-vue.cjs.js.map +2 -2
  119. package/dist/nextcloud-vue.css +725 -383
  120. package/l10n/en.json +21 -1
  121. package/l10n/nl.json +36 -1
  122. package/package.json +2 -2
  123. package/scripts/merge-l10n.js +196 -0
  124. package/src/components/CnAppRoot/CnAppRoot.md +1 -0
  125. package/src/components/CnAppRoot/CnAppRoot.vue +17 -0
  126. package/src/components/CnCellRenderer/CnCellRenderer.vue +57 -1
  127. package/src/components/CnChartWidget/CnChartWidget.md +2 -1
  128. package/src/components/CnChartWidget/CnChartWidget.vue +106 -2
  129. package/src/components/CnChecklistRunWidget/CnChecklistRunWidget.vue +1269 -0
  130. package/src/components/CnChecklistRunWidget/index.js +27 -0
  131. package/src/components/CnConversationThread/CnConversationThread.md +12 -0
  132. package/src/components/CnConversationThread/CnConversationThread.vue +30 -4
  133. package/src/components/CnConversationThread/CnConversationWidget.vue +167 -24
  134. package/src/components/CnDashboardPage/CnDashboardPage.vue +1 -0
  135. package/src/components/CnDataTable/CnDataTable.vue +26 -8
  136. package/src/components/CnDetailPage/CnDetailPage.md +48 -0
  137. package/src/components/CnDetailPage/CnDetailPage.vue +188 -5
  138. package/src/components/CnFactsWidget/CnFactsWidget.vue +114 -3
  139. package/src/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue +3 -3
  140. package/src/components/CnFeaturesBoard/CnFeaturesBoard.vue +38 -2
  141. package/src/components/CnFilesBrowser/CnFilesBrowser.vue +508 -236
  142. package/src/components/CnFilesBrowser/filesBrowserColumns.js +8 -2
  143. package/src/components/CnIndexPage/CnIndexPage.md +28 -0
  144. package/src/components/CnIndexPage/CnIndexPage.vue +367 -27
  145. package/src/components/CnIndexPage/CnIndexPageWidgets.vue +13 -4
  146. package/src/components/CnIndexPage/useNamedSource.js +1 -1
  147. package/src/components/CnObjectSidebar/CnFilesTab.vue +17 -0
  148. package/src/components/CnOrderedListField/CnOrderedListField.vue +386 -0
  149. package/src/components/CnOrderedListField/index.js +7 -0
  150. package/src/components/CnUserSettingsDialog/CnUserSettingsDialog.vue +139 -10
  151. package/src/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
  152. package/src/components/index.js +2 -0
  153. package/src/composables/indexSources.js +419 -4
  154. package/src/composables/useOrganisationStore.js +111 -0
  155. package/src/index.js +2 -0
  156. package/src/schemas/app-manifest-v2.schema.json +42 -5
  157. package/src/utils/builtInFormatters.js +33 -0
  158. package/src/utils/libraryWidgetKeys.js +1 -0
  159. package/src/utils/matrixCells.js +154 -0
  160. package/src/utils/nextcloudDirectory.js +117 -0
  161. package/src/utils/queryPresets.js +13 -2
  162. package/src/utils/scopeListLayout.js +37 -1
  163. package/src/utils/validateManifestV2.compiled.js +3 -3
@@ -1232,11 +1232,13 @@ import CnSegmentedControl from '../CnSegmentedControl/CnSegmentedControl.vue'
1232
1232
  import CnSummaryAggregates from '../CnSummaryAggregates/CnSummaryAggregates.vue'
1233
1233
  import CnTranslatedBadge from '../CnTranslatedBadge/CnTranslatedBadge.vue'
1234
1234
  import CnDetailHeaderChips from './CnDetailHeaderChips.vue'
1235
+ import { resolveRecordSource } from '../../composables/indexSources.js'
1235
1236
  import { useIntegrationRegistry } from '../../composables/useIntegrationRegistry.js'
1236
1237
  import { normalizeLook } from '../../composables/useLook.js'
1237
1238
  import { useObjectLock } from '../../composables/useObjectLock.js'
1238
1239
  import { useObjectPresence } from '../../composables/useObjectPresence.js'
1239
1240
  import { useObjectSubscription } from '../../composables/useObjectSubscription.js'
1241
+ import { createRefLabelResolver } from '../../composables/useRefLabels.js'
1240
1242
  import { gridLayout } from '../../mixins/gridLayout.js'
1241
1243
  import { pageViews } from '../../mixins/pageViews.js'
1242
1244
  import { useObjectStore } from '../../store/index.js'
@@ -1909,6 +1911,23 @@ export default {
1909
1911
  default: null,
1910
1912
  },
1911
1913
 
1914
+ /**
1915
+ * Name of a registered entity source that reads ONE record (manifest
1916
+ * `config.entitySource`), for a record that is not a register/schema
1917
+ * object. The same registry and the same name as `CnIndexPage`'s
1918
+ * `entitySource`: `organisations` shows the OpenRegister organisation
1919
+ * whose uuid is `objectId`. The record feeds the header, pills, facts,
1920
+ * widgets and the `@objectId` / `@object.*` tokens exactly as a fetched
1921
+ * object does. Wins over `register` + `schema`. Empty (the default)
1922
+ * leaves the page on its object store.
1923
+ *
1924
+ * @type {string}
1925
+ */
1926
+ entitySource: {
1927
+ type: String,
1928
+ default: '',
1929
+ },
1930
+
1912
1931
  /**
1913
1932
  * OpenRegister register slug. Pair with `schema` to opt into the
1914
1933
  * schema-driven mode: the page fuses the two into an internal
@@ -2863,6 +2882,10 @@ export default {
2863
2882
  pillCounts: {},
2864
2883
  /** Sequence of the last pill count round, so a late answer does not overwrite a newer one. */
2865
2884
  pillCountSeq: 0,
2885
+ /** The labels of the records pills with `resolve` point at, by pill key. */
2886
+ pillRefLabels: {},
2887
+ /** Sequence of the last pill label lookup, so a late answer for another record is dropped. */
2888
+ pillRefSeq: 0,
2866
2889
  /** `register/schema/id` the read-state call was last made for, so it is sent once per load. */
2867
2890
  readStateSentFor: '',
2868
2891
  /** Whether the user is arranging the chosen view (`userLayout`). */
@@ -3384,7 +3407,16 @@ export default {
3384
3407
  const pill = pillCountSource(config)
3385
3408
  ? resolveCountPill(config, this.pillCounts[key], (text) => this.effectiveTranslate(text))
3386
3409
  : resolvePill(config, this.resolvedObject)
3387
- return pill ? { ...pill, key, label: this.effectiveTranslate(pill.label) } : null
3410
+ if (!pill) {
3411
+ return null
3412
+ }
3413
+ // `resolve`: the referenced record's label replaces the id, and
3414
+ // keys the colour too ("Woo-verzoek", not a uuid).
3415
+ const resolved = this.pillRefLabels[key]
3416
+ if (typeof resolved === 'string' && resolved !== '') {
3417
+ return { ...pill, key, label: this.effectiveTranslate(resolved), colorKey: resolved }
3418
+ }
3419
+ return { ...pill, key, label: this.effectiveTranslate(pill.label) }
3388
3420
  })
3389
3421
  .filter(Boolean)
3390
3422
  },
@@ -3396,6 +3428,34 @@ export default {
3396
3428
  * @return {string} The key.
3397
3429
  * @spec openspec/changes/screens-relation-pages/specs/relation-pages/spec.md#requirement-a-header-pill-can-be-a-constant-or-a-count
3398
3430
  */
3431
+ /**
3432
+ * What the resolved pills depend on, as one key: a change looks again.
3433
+ *
3434
+ * @return {string}
3435
+ * @spec openspec/changes/detail-pill-resolve/specs/detail-page/spec.md#requirement-a-header-pill-can-show-a-referenced-record-s-label
3436
+ */
3437
+ pillResolveKey() {
3438
+ return JSON.stringify(this.pillResolveTargets)
3439
+ },
3440
+
3441
+ /**
3442
+ * The pills with `resolve: { register, schema, labelField? }` and the
3443
+ * id each one shows: `[key, register, schema, labelField, id]`. Empty
3444
+ * while the field is empty.
3445
+ *
3446
+ * @return {Array<Array<string>>}
3447
+ * @spec openspec/changes/detail-pill-resolve/specs/detail-page/spec.md#requirement-a-header-pill-can-show-a-referenced-record-s-label
3448
+ */
3449
+ pillResolveTargets() {
3450
+ return [['type', this.typePill], ['status', this.statusPill]]
3451
+ .filter(([, config]) => config && config.resolve && config.resolve.register && config.resolve.schema && typeof config.field === 'string')
3452
+ .map(([key, config]) => {
3453
+ const pill = resolvePill({ field: config.field }, this.resolvedObject)
3454
+ return pill ? [key, config.resolve.register, config.resolve.schema, config.resolve.labelField || '', pill.colorKey] : null
3455
+ })
3456
+ .filter(Boolean)
3457
+ },
3458
+
3399
3459
  pillCountKey() {
3400
3460
  return JSON.stringify([this.objectId, pillCountSource(this.typePill), pillCountSource(this.statusPill)])
3401
3461
  },
@@ -3598,6 +3658,9 @@ export default {
3598
3658
  * @return {boolean}
3599
3659
  */
3600
3660
  awaitingObject() {
3661
+ if (this.hasRecordSourceFetch) {
3662
+ return this.objectFetchPending && !this.currentObject
3663
+ }
3601
3664
  return this.hasSchemaDrivenFetch
3602
3665
  && this.objectFetchPending
3603
3666
  && typeof this.effectiveObjectStore?.fetchObject === 'function'
@@ -3671,7 +3734,45 @@ export default {
3671
3734
  * the fetch path entirely.
3672
3735
  */
3673
3736
  hasSchemaDrivenFetch() {
3674
- return Boolean(this.register && this.schema && this.objectId)
3737
+ return !this.entitySource && Boolean(this.register && this.schema && this.objectId)
3738
+ },
3739
+
3740
+ /**
3741
+ * The record source named by `entitySource`, or null. An unknown name,
3742
+ * or a source that cannot read one record, is null with a warning.
3743
+ *
3744
+ * @return {object|null}
3745
+ */
3746
+ recordSource() {
3747
+ if (!this.entitySource) {
3748
+ return null
3749
+ }
3750
+ try {
3751
+ return resolveRecordSource(this.entitySource)
3752
+ } catch (err) {
3753
+ // eslint-disable-next-line no-console -- the console is the only channel a host app can read the detail on
3754
+ console.warn('[CnDetailPage] the record source could not start; no record is shown.', err)
3755
+ return null
3756
+ }
3757
+ },
3758
+
3759
+ /**
3760
+ * Whether the page reads its record through a record source.
3761
+ *
3762
+ * @return {boolean}
3763
+ */
3764
+ hasRecordSourceFetch() {
3765
+ return Boolean(this.recordSource && this.objectId)
3766
+ },
3767
+
3768
+ /**
3769
+ * Whether the page reads its own record, from the object store or a
3770
+ * record source, so Refresh and `cn:page:refresh` have something to do.
3771
+ *
3772
+ * @return {boolean}
3773
+ */
3774
+ selfFetchesRecord() {
3775
+ return this.hasSchemaDrivenFetch || this.hasRecordSourceFetch
3675
3776
  },
3676
3777
 
3677
3778
  /**
@@ -3886,7 +3987,7 @@ export default {
3886
3987
  }
3887
3988
  // `$.vnode.props`, not `$attrs`: `refresh` is a declared emit, and
3888
3989
  // Vue keeps declared emits out of `$attrs`.
3889
- return Boolean(this.$.vnode.props?.onRefresh) || this.hasSchemaDrivenFetch
3990
+ return Boolean(this.$.vnode.props?.onRefresh) || this.selfFetchesRecord
3890
3991
  },
3891
3992
 
3892
3993
  /**
@@ -3930,6 +4031,9 @@ export default {
3930
4031
  * @return {object|null}
3931
4032
  */
3932
4033
  currentObject() {
4034
+ if (this.recordSource) {
4035
+ return this.objectId ? (this.recordSource.record(this.objectId) ?? null) : null
4036
+ }
3933
4037
  const store = this.effectiveObjectStore
3934
4038
  if (!store) {
3935
4039
  return null
@@ -4605,6 +4709,14 @@ export default {
4605
4709
  },
4606
4710
  },
4607
4711
 
4712
+ // A pill with `resolve` looks its record up again for another value.
4713
+ pillResolveKey: {
4714
+ immediate: true,
4715
+ handler() {
4716
+ this.refreshPillRefLabels()
4717
+ },
4718
+ },
4719
+
4608
4720
  // The header's count pills count again for another record or other lists.
4609
4721
  pillCountKey: {
4610
4722
  immediate: true,
@@ -5028,7 +5140,7 @@ export default {
5028
5140
  // Schema-driven (manifest) detail pages self-fetch — without this
5029
5141
  // the Refresh action has no host listener to act on and does
5030
5142
  // nothing. Re-fetch the object + schema and spin the action.
5031
- if (!this.hasSchemaDrivenFetch) {
5143
+ if (!this.selfFetchesRecord) {
5032
5144
  return
5033
5145
  }
5034
5146
  this.internalRefreshing = true
@@ -5041,6 +5153,42 @@ export default {
5041
5153
  }
5042
5154
  },
5043
5155
 
5156
+ /**
5157
+ * Look up the records the `resolve` pills point at, through the same
5158
+ * reference-label resolver the index tables use. A failed lookup keeps
5159
+ * the raw value.
5160
+ *
5161
+ * @return {Promise<void>}
5162
+ * @spec openspec/changes/detail-pill-resolve/specs/detail-page/spec.md#requirement-a-header-pill-can-show-a-referenced-record-s-label
5163
+ */
5164
+ async refreshPillRefLabels() {
5165
+ const targets = this.pillResolveTargets
5166
+ const seq = ++this.pillRefSeq
5167
+ if (targets.length === 0) {
5168
+ this.pillRefLabels = {}
5169
+ return
5170
+ }
5171
+ if (!this._pillRefResolver) {
5172
+ this._pillRefResolver = createRefLabelResolver(() => {
5173
+ try {
5174
+ return this.objectStore || useObjectStore()
5175
+ } catch {
5176
+ return null
5177
+ }
5178
+ })
5179
+ }
5180
+ const labels = {}
5181
+ await Promise.all(targets.map(async ([key, register, schema, labelField, id]) => {
5182
+ const found = await this._pillRefResolver.resolve(register, schema, [id], labelField).catch(() => ({}))
5183
+ if (found && typeof found[id] === 'string') {
5184
+ labels[key] = found[id]
5185
+ }
5186
+ }))
5187
+ if (seq === this.pillRefSeq) {
5188
+ this.pillRefLabels = labels
5189
+ }
5190
+ },
5191
+
5044
5192
  /**
5045
5193
  * Count the lists behind the header's count pills (`countFrom`): one
5046
5194
  * list request each (`_limit=1`, the `total`). A failed count, or a
@@ -5083,7 +5231,7 @@ export default {
5083
5231
  */
5084
5232
  onPageRefreshBus(payload) {
5085
5233
  this.refreshPillCounts()
5086
- if (!this.hasSchemaDrivenFetch) {
5234
+ if (!this.selfFetchesRecord) {
5087
5235
  return
5088
5236
  }
5089
5237
  if (this.internalRefreshing) {
@@ -5308,6 +5456,10 @@ export default {
5308
5456
  },
5309
5457
 
5310
5458
  async fetchObjectIfNeeded() {
5459
+ if (this.recordSource) {
5460
+ await this.fetchSourceRecord()
5461
+ return
5462
+ }
5311
5463
  // Create archetype: no object to fetch, but the create form needs
5312
5464
  // the schema — register the type and fetch its schema, then stop.
5313
5465
  if (!this.hasSchemaDrivenFetch) {
@@ -5371,6 +5523,37 @@ export default {
5371
5523
  }
5372
5524
  },
5373
5525
 
5526
+ /**
5527
+ * Read the record through the record source. A refusal (403) or a
5528
+ * missing record (404) shows the not-found state; which records the
5529
+ * viewer may read is the server's decision.
5530
+ *
5531
+ * @return {Promise<void>}
5532
+ */
5533
+ async fetchSourceRecord() {
5534
+ const source = this.recordSource
5535
+ const objectId = this.objectId
5536
+ if (!objectId) {
5537
+ this.objectFetchPending = false
5538
+ return
5539
+ }
5540
+ this.objectFetchPending = true
5541
+ this.objectNotFound = false
5542
+ try {
5543
+ await source.loadRecord(String(objectId))
5544
+ } catch (err) {
5545
+ // eslint-disable-next-line no-console -- the failure is already handled; the console is the only channel a host app can read the detail on
5546
+ console.error('[CnDetailPage] record source fetch failed:', err)
5547
+ } finally {
5548
+ // A newer fetch for another id owns the flags now.
5549
+ if (objectId === this.objectId) {
5550
+ const status = typeof source.recordError === 'function' ? source.recordError(String(objectId)) : null
5551
+ this.objectNotFound = !this.currentObject && (status === 403 || status === 404)
5552
+ this.objectFetchPending = false
5553
+ }
5554
+ }
5555
+ },
5556
+
5374
5557
  /**
5375
5558
  * Whether a grid-layout item resolves to an integration-typed
5376
5559
  * widget — `def.type === 'integration'` with a string
@@ -101,6 +101,28 @@
101
101
  <span v-if="block.subtitle" class="cn-facts-widget__lead-subtitle">{{ block.subtitle }}</span>
102
102
  </span>
103
103
  </div>
104
+ <div
105
+ v-else-if="block.kind === 'meter'"
106
+ class="cn-facts-widget__meter"
107
+ :class="{ 'cn-facts-widget__meter--over': block.over }"
108
+ :data-testid="`cn-fact-${block.key}`">
109
+ <div class="cn-facts-widget__meter-head">
110
+ <span class="cn-facts-widget__meter-label">{{ block.label }}</span>
111
+ <span class="cn-facts-widget__meter-value">{{ block.value }}</span>
112
+ </div>
113
+ <div
114
+ v-if="block.limit !== null"
115
+ class="cn-facts-widget__meter-track"
116
+ role="meter"
117
+ :aria-label="block.label"
118
+ :aria-valuenow="block.amount"
119
+ aria-valuemin="0"
120
+ :aria-valuemax="block.limit"
121
+ :aria-valuetext="block.value"
122
+ :data-testid="`cn-fact-${block.key}-bar`">
123
+ <span class="cn-facts-widget__meter-bar" :style="{ width: `${block.percent}%` }" />
124
+ </div>
125
+ </div>
104
126
  <div v-else-if="block.kind === 'pill'">
105
127
  <CnStatusBadge
106
128
  :label="block.value"
@@ -133,6 +155,7 @@
133
155
 
134
156
  <script>
135
157
  import { emit } from '@nextcloud/event-bus'
158
+ import { formatFileSize } from '@nextcloud/files'
136
159
  import { translate as t } from '@nextcloud/l10n'
137
160
  import { NcLoadingIcon } from '@nextcloud/vue'
138
161
  import Check from 'vue-material-design-icons/Check.vue'
@@ -152,7 +175,7 @@ import { hasUnresolvedDeepTokens, resolveDeepTokens } from '../../utils/resolveF
152
175
  import { userDisplayName } from '../../utils/userDisplayName.js'
153
176
 
154
177
  /** Kinds of entry a `fields[]` item can be. A labelled item without `as` is a row. */
155
- const KINDS = Object.freeze(['row', 'title', 'line', 'pill', 'lead', 'text'])
178
+ const KINDS = Object.freeze(['row', 'title', 'line', 'pill', 'lead', 'text', 'meter'])
156
179
 
157
180
  /**
158
181
  * The label resolver every facts widget on the page shares, made on first use
@@ -260,13 +283,14 @@ export default {
260
283
  * The widget configuration.
261
284
  *
262
285
  * - `fields` — the entries, in order. Each is `{ path, label?, as?, from?, format?, map?, itemPath?, parts?, separator?, route?, variant?, variantMap?, colorMap?, icon?, copy?, reveal?, text?, title?, subtitle?, initialsPath?, tone? }`.
263
- * `as` is `row` (default when `label` or `icon` is set), `title`, `line`, `text` (a line in the normal ink), `lead` (an icon tile or initials beside `title` and `subtitle`) or `pill`.
286
+ * `as` is `row` (default when `label` or `icon` is set), `title`, `line`, `text` (a line in the normal ink), `lead` (an icon tile or initials beside `title` and `subtitle`), `pill` or `meter`.
287
+ * A `meter` shows the amount at `path` against the limit at `limitPath` (or a constant `limit`) as "12 of 50" with a bar; past the limit the bar turns red and the text says "over the limit"; without a positive limit it shows the amount and no bar.
264
288
  * A row's `format` may also be `email`, `phone` or `url` (a link) or `masked` (the last four characters; `reveal: true` adds Show). `copy: true` adds a copy button; `icon` replaces the label.
265
289
  * A part may be `{ text }`, a constant; a `line` or `text` entry without `path` or `parts` shows its `text`.
266
290
  * `resolve: { register, schema, labelField? }` on an entry or a part turns a value that is a reference (an id, or a list of ids) into the label of the referenced object (`labelField`, else `title`, then `name`): the case type or status of a task shows its name, not its uuid. The ids are fetched once per register and schema through the object store and shared by every facts widget on the page; the entry stays empty until its label is known, and when no label can be read. A resolved value works as a pill too: `map` is applied to the resolved label, and `variantMap` / `colorMap` look the tone up by the value of `resolve.toneField` on the referenced object when that is set (a status named by its `name`, coloured by its `state`), then by the resolved label, then by the stored value.
267
291
  * `format: 'user'` shows a Nextcloud user id as the user's display name, read once per id; the entry stays empty until the name is known.
268
292
  * - `action` — an action drawn as a button under the facts; `headerAction` — one drawn in the card header (reported through the `header` event).
269
- * `format` is `text` (default), `date` (board short date), `date-long` (always with the year), `list` (an array joined by ", ", each item read at `itemPath`) or `currency` (a money amount in the reader's language, "€ 1.234,50": the code in `currency`, else the record's `currencyField`, else the app's reporting currency, else EUR; `decimals` defaults to 2).
293
+ * `format` is `text` (default), `date` (board short date), `date-long` (always with the year), `list` (an array joined by ", ", each item read at `itemPath`), `bytes` (a size, "1.5 MB") or `currency` (a money amount in the reader's language, "€ 1.234,50": the code in `currency`, else the record's `currencyField`, else the app's reporting currency, else EUR; `decimals` defaults to 2).
270
294
  * `map` turns a raw value into a label (translated). `parts` (on a `line`) lists paths, or `{ path, label?, format? }`, joined by `separator` (default " · "); empty parts are dropped.
271
295
  * - `source` — `{ register, schema, id }` or `{ url }`; omitted = the current object. An entry with `from: "object"` reads the current object even then (a client card that also shows the ticket's channel).
272
296
  * - `link` — `{ label, route? | href? }` at the foot.
@@ -420,6 +444,9 @@ export default {
420
444
  block.initials = block.icon ? '' : this.initialsOf(readPath(record, field.initialsPath))
421
445
  block.tone = ['success', 'info', 'warning', 'error'].includes(field.tone) ? field.tone : 'neutral'
422
446
  }
447
+ if (kind === 'meter') {
448
+ Object.assign(block, this.meterOf(record, field))
449
+ }
423
450
  if (kind === 'title' && field.route) {
424
451
  block.to = this.resolveRoute(field.route, record)
425
452
  }
@@ -546,6 +573,42 @@ export default {
546
573
  return field.label || field.icon ? 'row' : 'line'
547
574
  },
548
575
 
576
+ /**
577
+ * A meter entry's parts: the amount at `path` against the limit at
578
+ * `limitPath` (or a constant `limit`), both formatted with the entry's
579
+ * `format`. Without a positive numeric limit there is no bar, only the
580
+ * amount. Past the limit the text says so, so the state never rests on
581
+ * the bar's colour alone.
582
+ *
583
+ * @param {object} record The object.
584
+ * @param {object} field The entry.
585
+ * @return {{label: string, value: string, amount: number|null, limit: number|null, percent: number, over: boolean}}
586
+ * @spec openspec/changes/facts-meter-row/specs/facts-meter-row/spec.md#requirement-a-facts-entry-can-be-a-meter
587
+ */
588
+ meterOf(record, field) {
589
+ const amount = Number(readPath(record, field.path))
590
+ const rawLimit = typeof field.limitPath === 'string' && field.limitPath !== '' ? readPath(record, field.limitPath) : field.limit
591
+ const limit = rawLimit === null || rawLimit === undefined || rawLimit === '' ? NaN : Number(rawLimit)
592
+ const label = this.tr(field.label || '')
593
+ const value = this.valueOf(record, field)
594
+ if (!Number.isFinite(amount) || !Number.isFinite(limit) || limit <= 0) {
595
+ return { label, value, amount: Number.isFinite(amount) ? amount : null, limit: null, percent: 0, over: false }
596
+ }
597
+ const over = amount > limit
598
+ const limitText = this.format(limit, field, record)
599
+ return {
600
+ label,
601
+ value: over
602
+ ? t('nextcloud-vue', '{value} of {limit}, over the limit', { value, limit: limitText })
603
+ : t('nextcloud-vue', '{value} of {limit}', { value, limit: limitText }),
604
+
605
+ amount,
606
+ limit,
607
+ percent: Math.max(0, Math.min(100, (amount / limit) * 100)),
608
+ over,
609
+ }
610
+ },
611
+
549
612
  /**
550
613
  * The display text of an entry of any kind: a lead's title, the parts of
551
614
  * a line or text, a constant `text`, else the value at `path`.
@@ -988,6 +1051,10 @@ export default {
988
1051
  ? formatBoardDate(date, new Date(date.getFullYear() + 1, 0, 1))
989
1052
  : formatBoardDate(date)
990
1053
  }
1054
+ if (format === 'bytes') {
1055
+ const bytes = Number(raw)
1056
+ return Number.isFinite(bytes) && bytes >= 0 ? formatFileSize(bytes) : ''
1057
+ }
991
1058
  if (format === 'user') {
992
1059
  return userDisplayName(raw)
993
1060
  }
@@ -1125,6 +1192,50 @@ export default {
1125
1192
  color: var(--color-text-maxcontrast);
1126
1193
  }
1127
1194
 
1195
+ .cn-facts-widget__meter {
1196
+ display: flex;
1197
+ flex-direction: column;
1198
+ gap: 4px;
1199
+ font-size: 14px;
1200
+ }
1201
+
1202
+ .cn-facts-widget__meter-head {
1203
+ display: flex;
1204
+ justify-content: space-between;
1205
+ gap: 16px;
1206
+ }
1207
+
1208
+ .cn-facts-widget__meter-label {
1209
+ color: var(--color-text-maxcontrast);
1210
+ }
1211
+
1212
+ .cn-facts-widget__meter-value {
1213
+ font-weight: 500;
1214
+ text-align: end;
1215
+ }
1216
+
1217
+ .cn-facts-widget__meter-track {
1218
+ height: 6px;
1219
+ border-radius: 3px;
1220
+ background: var(--color-background-darker);
1221
+ overflow: hidden;
1222
+ }
1223
+
1224
+ .cn-facts-widget__meter-bar {
1225
+ display: block;
1226
+ height: 100%;
1227
+ border-radius: inherit;
1228
+ background: var(--color-primary-element);
1229
+ }
1230
+
1231
+ .cn-facts-widget__meter--over .cn-facts-widget__meter-value {
1232
+ color: var(--color-error-text);
1233
+ }
1234
+
1235
+ .cn-facts-widget__meter--over .cn-facts-widget__meter-bar {
1236
+ background: var(--color-error);
1237
+ }
1238
+
1128
1239
  .cn-facts-widget__muted {
1129
1240
  margin: 0;
1130
1241
  color: var(--color-text-maxcontrast);
@@ -223,8 +223,8 @@ export default {
223
223
  /**
224
224
  * Board look options, passed to CnFeaturesBoard: `layout` (`cards` or
225
225
  * `rows`), `tabs`, `filter` (`chips` or `segmented`), `statusLabels`,
226
- * `next`, `issueLabels`, `subtitle`, `notice`, `documentationButton`
227
- * and `suggestButton`. Ignored in the default look.
226
+ * `next`, `issueLabels`, `subtitle`, `notice`, `noticeLink`,
227
+ * `summaries`, `documentationButton` and `suggestButton`. Ignored in the default look.
228
228
  *
229
229
  * @type {object}
230
230
  */
@@ -460,7 +460,7 @@ export default {
460
460
  * @return {object}
461
461
  */
462
462
  boardOptions() {
463
- const keys = ['layout', 'tabs', 'filter', 'statusLabels', 'next', 'issueLabels', 'subtitle', 'notice', 'documentationButton', 'suggestButton']
463
+ const keys = ['layout', 'tabs', 'filter', 'statusLabels', 'next', 'issueLabels', 'subtitle', 'notice', 'noticeLink', 'summaries', 'documentationButton', 'suggestButton']
464
464
  const options = {}
465
465
  for (const key of keys) {
466
466
  if (this.board && this.board[key] !== undefined && this.board[key] !== null) {
@@ -195,7 +195,7 @@
195
195
  data-testid="cn-features-board-feature">
196
196
  <span class="cn-features-board__row-text">
197
197
  <span class="cn-features-board__row-title">{{ feature.title }}</span>
198
- <span v-if="feature.summary" class="cn-features-board__row-summary">{{ feature.summary }}</span>
198
+ <span v-if="summaries && feature.summary" class="cn-features-board__row-summary">{{ feature.summary }}</span>
199
199
  </span>
200
200
  <CnStatusBadge
201
201
  :label="statusText(feature)"
@@ -271,7 +271,16 @@
271
271
  type="info"
272
272
  class="cn-features-board__notice"
273
273
  data-testid="cn-features-board-notice">
274
- {{ tr(notice) }}
274
+ <template v-if="noticeParts.link">
275
+ {{ noticeParts.before }}<a
276
+ :href="noticeParts.link.href"
277
+ target="_blank"
278
+ rel="noopener noreferrer"
279
+ data-testid="cn-features-board-notice-link">{{ noticeParts.link.text }}</a>{{ noticeParts.after }}
280
+ </template>
281
+ <template v-else>
282
+ {{ noticeParts.before }}
283
+ </template>
275
284
  </NcNoteCard>
276
285
  </template>
277
286
  </div>
@@ -397,6 +406,10 @@ export default {
397
406
  suggestUrl: { type: String, default: '' },
398
407
  /** Show the feature-request form as the header's primary button; without it, it is an entry in the Actions menu. */
399
408
  suggestButton: { type: Boolean, default: true },
409
+ /** In the `rows` layout, draw each feature's summary under its title; false shows the title and status only. */
410
+ summaries: { type: Boolean, default: true },
411
+ /** A link inside the notice: `{ text, href }`; the first occurrence of `text` in the translated notice opens `href` in a new tab. Only http(s) and site-relative hrefs link. */
412
+ noticeLink: { type: Object, default: null },
400
413
  /** Target of the OpenBuilt entry in the Actions menu. */
401
414
  openbuiltUrl: { type: String, default: '' },
402
415
  /** Target of the AI skills entry in the Actions menu. */
@@ -421,8 +434,10 @@ export default {
421
434
  return readerLanguage()
422
435
  },
423
436
 
437
+ /** The groups, their captions through the app's translate function. */
424
438
  groups() {
425
439
  return groupFeatures(this.features, this.categories, this.language)
440
+ .map((group) => ({ ...group, label: group.label ? this.tr(group.label) : '' }))
426
441
  },
427
442
 
428
443
  allFeatures() {
@@ -466,6 +481,27 @@ export default {
466
481
  return this.title ? this.tr(this.title) : t('nextcloud-vue', 'Features and roadmap')
467
482
  },
468
483
 
484
+ /**
485
+ * The notice split around its link: the text before, the link and the
486
+ * text after. Without a usable `noticeLink` (no match in the
487
+ * translated notice, or an href that is not http(s) or site-relative)
488
+ * `before` is the whole notice.
489
+ *
490
+ * @return {{before: string, link: ({text: string, href: string}|null), after: string}} The parts.
491
+ */
492
+ noticeParts() {
493
+ const text = this.notice ? this.tr(this.notice) : ''
494
+ const link = this.noticeLink
495
+ const label = link && typeof link.text === 'string' ? this.tr(link.text) : ''
496
+ const href = link && typeof link.href === 'string' ? link.href.trim() : ''
497
+ const index = label ? text.indexOf(label) : -1
498
+ const allowed = (/^https?:\/\//i.test(href) || href.startsWith('/')) && safeHref(href) === href
499
+ if (index === -1 || !allowed) {
500
+ return { before: text, link: null, after: '' }
501
+ }
502
+ return { before: text.slice(0, index), link: { text: label, href }, after: text.slice(index + label.length) }
503
+ },
504
+
469
505
  resolvedSubtitle() {
470
506
  if (!this.subtitle) {
471
507
  return ''