@conduction/nextcloud-vue 2.97.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 (107) 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/CnChartWidget/CnChartWidget.vue.js +1 -1
  5. package/dist/esm/components/CnChartWidget/CnChartWidget.vue2.js +106 -2
  6. package/dist/esm/components/CnChartWidget/CnChartWidget.vue2.js.map +1 -1
  7. package/dist/esm/components/CnChartWidget/CnChartWidget.vue3.js.map +1 -1
  8. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue.js +9 -0
  9. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue.js.map +1 -0
  10. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue2.js +964 -0
  11. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue2.js.map +1 -0
  12. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue3.js +282 -0
  13. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue3.js.map +1 -0
  14. package/dist/esm/components/CnChecklistRunWidget/index.js +28 -0
  15. package/dist/esm/components/CnChecklistRunWidget/index.js.map +1 -0
  16. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
  17. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +1 -0
  18. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  19. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  20. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +26 -8
  21. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  22. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  23. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +188 -5
  24. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  25. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  26. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue.js +1 -1
  27. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue2.js +3 -3
  28. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue2.js.map +1 -1
  29. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue3.js.map +1 -1
  30. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue.js +1 -1
  31. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue2.js +27 -0
  32. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue2.js.map +1 -1
  33. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue3.js +16 -2
  34. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue3.js.map +1 -1
  35. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +43 -3
  36. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  37. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  38. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue.js +9 -0
  39. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue.js.map +1 -0
  40. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue2.js +220 -0
  41. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue2.js.map +1 -0
  42. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue3.js +121 -0
  43. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue3.js.map +1 -0
  44. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue.js +1 -1
  45. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue2.js +83 -9
  46. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue2.js.map +1 -1
  47. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue3.js +80 -11
  48. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue3.js.map +1 -1
  49. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
  50. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js.map +1 -1
  51. package/dist/esm/composables/indexSources.js +238 -2
  52. package/dist/esm/composables/indexSources.js.map +1 -1
  53. package/dist/esm/composables/useOrganisationStore.js +115 -0
  54. package/dist/esm/composables/useOrganisationStore.js.map +1 -0
  55. package/dist/esm/index.js +2 -0
  56. package/dist/esm/index.js.map +1 -1
  57. package/dist/esm/l10n/en.json.js +15 -1
  58. package/dist/esm/l10n/en.json.js.map +1 -1
  59. package/dist/esm/l10n/nl.json.js +30 -1
  60. package/dist/esm/l10n/nl.json.js.map +1 -1
  61. package/dist/esm/library-style.js +1 -1
  62. package/dist/esm/nextcloud-vue.css +424 -206
  63. package/dist/esm/utils/builtInFormatters.js +34 -1
  64. package/dist/esm/utils/builtInFormatters.js.map +1 -1
  65. package/dist/esm/utils/libraryWidgetKeys.js +1 -0
  66. package/dist/esm/utils/libraryWidgetKeys.js.map +1 -1
  67. package/dist/esm/utils/matrixCells.js +158 -0
  68. package/dist/esm/utils/matrixCells.js.map +1 -0
  69. package/dist/esm/utils/nextcloudDirectory.js +121 -0
  70. package/dist/esm/utils/nextcloudDirectory.js.map +1 -0
  71. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  72. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  73. package/dist/nextcloud-vue.cjs.js +12538 -9716
  74. package/dist/nextcloud-vue.cjs.js.map +2 -2
  75. package/dist/nextcloud-vue.css +424 -206
  76. package/l10n/en.json +15 -1
  77. package/l10n/nl.json +30 -1
  78. package/package.json +2 -2
  79. package/scripts/merge-l10n.js +196 -0
  80. package/src/components/CnAppRoot/CnAppRoot.md +1 -0
  81. package/src/components/CnAppRoot/CnAppRoot.vue +17 -0
  82. package/src/components/CnChartWidget/CnChartWidget.md +2 -1
  83. package/src/components/CnChartWidget/CnChartWidget.vue +106 -2
  84. package/src/components/CnChecklistRunWidget/CnChecklistRunWidget.vue +1269 -0
  85. package/src/components/CnChecklistRunWidget/index.js +27 -0
  86. package/src/components/CnDashboardPage/CnDashboardPage.vue +1 -0
  87. package/src/components/CnDataTable/CnDataTable.vue +26 -8
  88. package/src/components/CnDetailPage/CnDetailPage.md +48 -0
  89. package/src/components/CnDetailPage/CnDetailPage.vue +188 -5
  90. package/src/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue +3 -3
  91. package/src/components/CnFeaturesBoard/CnFeaturesBoard.vue +38 -2
  92. package/src/components/CnIndexPage/CnIndexPage.md +28 -0
  93. package/src/components/CnIndexPage/CnIndexPage.vue +43 -3
  94. package/src/components/CnOrderedListField/CnOrderedListField.vue +386 -0
  95. package/src/components/CnOrderedListField/index.js +7 -0
  96. package/src/components/CnUserSettingsDialog/CnUserSettingsDialog.vue +139 -10
  97. package/src/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
  98. package/src/components/index.js +2 -0
  99. package/src/composables/indexSources.js +237 -1
  100. package/src/composables/useOrganisationStore.js +111 -0
  101. package/src/index.js +2 -0
  102. package/src/schemas/app-manifest-v2.schema.json +33 -3
  103. package/src/utils/builtInFormatters.js +33 -0
  104. package/src/utils/libraryWidgetKeys.js +1 -0
  105. package/src/utils/matrixCells.js +154 -0
  106. package/src/utils/nextcloudDirectory.js +117 -0
  107. package/src/utils/validateManifestV2.compiled.js +3 -3
@@ -0,0 +1,27 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * CnChecklistRunWidget — self-registers the `checklist-run` widget type (a
6
+ * checklist answered a page of questions at a time, with a note and photos)
7
+ * into the shared dashboardWidgetRegistry at module load. Renderer only: the
8
+ * questions and where the answers go are declared in the manifest.
9
+ */
10
+
11
+ import CnChecklistRunWidget from './CnChecklistRunWidget.vue'
12
+ import { registerDashboardWidget } from '../CnWidgetGrid/dashboardWidgetRegistry.js'
13
+
14
+ registerDashboardWidget('checklist-run', {
15
+ renderer: CnChecklistRunWidget,
16
+ form: null,
17
+ defaultContent: {
18
+ questions: { register: '', schema: '', filter: {} },
19
+ pageSize: 1,
20
+ },
21
+ displayName: 'Checklist run',
22
+ icon: 'ClipboardCheckOutline',
23
+ surfaces: ['detail-page', 'app-dashboard'],
24
+ })
25
+
26
+ export { CnChecklistRunWidget }
27
+ export default CnChecklistRunWidget
@@ -1079,6 +1079,7 @@ const CHART_PROP_KEYS = [
1079
1079
  'barMax',
1080
1080
  'footnote',
1081
1081
  'highlight',
1082
+ 'cellSource',
1082
1083
  ]
1083
1084
 
1084
1085
  /**
@@ -408,6 +408,7 @@ import { fillTemplate, joinTemplateParts } from '../../utils/fillTemplate.js'
408
408
  import { lensUnavailableText, readLensReports } from '../../utils/lensAvailability.js'
409
409
  import { followLinkClick, openRowTarget, resolveHref } from '../../utils/linkNavigation.js'
410
410
  import { nextSortState } from '../../utils/multiColumnSort.js'
411
+ import { readPath } from '../../utils/readPath.js'
411
412
  import { isNewTabClick, isNewTabHandled, isRowMiddleClick, markNewTabHandled, preventMiddleClickAutoscroll } from '../../utils/rowAuxClick.js'
412
413
  import { DEFAULT_ROW_INDICATOR_CAP, resolveRowIndicators } from '../../utils/rowIndicators.js'
413
414
  import { columnsFromSchema } from '../../utils/schema.js'
@@ -1841,8 +1842,11 @@ export default {
1841
1842
  if (v === undefined) {
1842
1843
  return this.aggregateLoading ? '…' : '—'
1843
1844
  }
1845
+ if (v === null && !(typeof col.aggregate.zeroLabel === 'string' && col.aggregate.zeroLabel !== '')) {
1846
+ return '—'
1847
+ }
1844
1848
  const zeroLabel = col.aggregate.zeroLabel
1845
- if ((v === 0 || v === '0') && typeof zeroLabel === 'string' && zeroLabel !== '') {
1849
+ if ((v === 0 || v === '0' || v === null) && typeof zeroLabel === 'string' && zeroLabel !== '') {
1846
1850
  return typeof this.cnTranslate === 'function' ? this.cnTranslate(zeroLabel) : zeroLabel
1847
1851
  }
1848
1852
  return v
@@ -1872,17 +1876,21 @@ export default {
1872
1876
  },
1873
1877
 
1874
1878
  /**
1875
- * For every column that declares `aggregate` (currently `op: "count"`),
1876
- * issue one `_limit=0` count request per visible row against the related
1877
- * OpenRegister collection and cache the totals in `aggregateValues`.
1879
+ * For every column that declares `aggregate`, issue one request per
1880
+ * visible row against the related OpenRegister collection and cache the
1881
+ * answer in `aggregateValues`: `op: "count"` asks `_limit=0` for the
1882
+ * total, `op: "max"` asks for the one object with the highest `field`
1883
+ * (`_limit=1`, sorted descending) and keeps that value, null when there
1884
+ * is none (shown as `zeroLabel`, else a dash).
1878
1885
  * Batched with `Promise.all`; a per-request failure degrades that one
1879
1886
  * cell to `'—'` (logged), never the page. A monotonic request id
1880
1887
  * discards a stale batch when `rows` / columns change mid-flight.
1881
1888
  *
1882
1889
  * @return {Promise<void>}
1890
+ * @spec openspec/changes/masked-formatter-max-aggregate/specs/index-page/spec.md#requirement-a-column-aggregate-can-take-the-highest-value
1883
1891
  */
1884
1892
  async loadAggregates() {
1885
- const aggCols = this.effectiveColumns.filter((c) => c && c.aggregate && c.aggregate.op === 'count')
1893
+ const aggCols = this.effectiveColumns.filter((c) => c && c.aggregate && (c.aggregate.op === 'count' || (c.aggregate.op === 'max' && c.aggregate.field)))
1886
1894
  if (aggCols.length === 0) {
1887
1895
  if (Object.keys(this.aggregateValues).length > 0) {
1888
1896
  this.aggregateValues = {}
@@ -1903,11 +1911,21 @@ export default {
1903
1911
  continue
1904
1912
  }
1905
1913
  const where = this.resolveAggregateWhere(agg.where, row)
1906
- jobs.push(axios.get(generateUrl(`/apps/openregister/api/objects/${agg.register}/${agg.schema}`), {
1907
- params: { ...where, _limit: 0 },
1908
- })
1914
+ // `max`: the highest `field` among the related objects, as the
1915
+ // first of them sorted on it descending ("Laatste contact").
1916
+ const isMax = agg.op === 'max'
1917
+ const params = isMax
1918
+ ? { ...where, _limit: 1, [`_order[${agg.field}]`]: 'desc' }
1919
+ : { ...where, _limit: 0 }
1920
+ jobs.push(axios.get(generateUrl(`/apps/openregister/api/objects/${agg.register}/${agg.schema}`), { params })
1909
1921
  .then((res) => {
1910
1922
  const d = res && res.data
1923
+ if (isMax) {
1924
+ const first = d && Array.isArray(d.results) ? d.results[0] : undefined
1925
+ const top = first === undefined ? undefined : readPath(first, agg.field)
1926
+ next[rowKey][col.key] = top === undefined || top === '' ? null : top
1927
+ return
1928
+ }
1911
1929
  next[rowKey][col.key] = (d && (d.total ?? (Array.isArray(d.results) ? d.results.length : undefined))) ?? 0
1912
1930
  })
1913
1931
  .catch((e) => {
@@ -434,6 +434,54 @@ Four levels of action and the case surfaces around them. All opt in. See [the re
434
434
 
435
435
  `previousTo` and `nextTo` (default `null`) take `useListNavigation`'s `previousRoute` / `nextRoute` and turn the previous and next controls into real links, so they can be opened in a new tab. The link navigates by itself; `previous-record` / `next-record` are still emitted, with `{ event, to }`, and `goPrevious` / `goNext` do not push again when they receive that payload.
436
436
 
437
+ ## Records that are not objects (`config.entitySource`)
438
+
439
+ A detail page reads its record from the object store through `register` +
440
+ `schema`. A record that is not a register/schema object names a registered
441
+ entity source instead, the same registry and the same name `CnIndexPage`
442
+ lists it with. `organisations` reads the OpenRegister organisation whose uuid
443
+ is the route's id (`GET /apps/openregister/api/organisations/{uuid}`):
444
+
445
+ ```json
446
+ {
447
+ "id": "organisation-detail",
448
+ "route": "/organisations/:id",
449
+ "type": "detail",
450
+ "config": {
451
+ "entitySource": "organisations",
452
+ "statusPill": { "field": "status" },
453
+ "typePill": { "field": "type" },
454
+ "headerFields": [{ "key": "slug" }],
455
+ "layout": [{ "id": "1", "widgetId": "facts", "gridX": 0, "gridY": 0, "gridWidth": 6 }],
456
+ "widgets": [{
457
+ "id": "facts",
458
+ "type": "facts",
459
+ "title": "Details",
460
+ "content": { "fields": [
461
+ { "path": "slug", "label": "Slug" },
462
+ { "path": "created", "label": "Created", "format": "date" },
463
+ { "path": "contactEmail", "label": "Contact", "format": "email" },
464
+ { "path": "kvk", "label": "KvK" }
465
+ ] }
466
+ }]
467
+ }
468
+ }
469
+ ```
470
+
471
+ The record feeds everything a fetched object feeds: the title, the pills, the
472
+ header fields, the widgets, and the `@objectId` / `@object.*` tokens. A tab
473
+ that lists register objects belonging to the organisation filters on
474
+ `@objectId`, e.g. `{ "type": "object-list", "content": { "register": "...",
475
+ "schema": "...", "filter": { "tenantRef": "@objectId" } } }`. The record's
476
+ `id` is its uuid, as an object's is.
477
+
478
+ `entitySource` wins over `register` + `schema`. A name that is unknown, or a
479
+ source that cannot read one record, shows no record and warns in the console.
480
+ A refusal (403) or a missing record (404) shows the not-found state; which
481
+ records the viewer may read is OpenRegister's decision. Refresh and
482
+ `cn:page:refresh` re-read the record. There is no Edit: a record source has
483
+ no schema form.
484
+
437
485
  ## Not found
438
486
 
439
487
  When the schema-driven fetch answers 404 the page shows a not-found state whose back button is a router link to `notFoundRoute` (default `null`, which means the app root `/`). `notFoundRouteLabel` names that page in the button's "Back to …" label.
@@ -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
@@ -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 ''
@@ -535,6 +535,34 @@ What the source brings, all overridable by the manifest:
535
535
  - Scope stays the endpoint's session decision. The loader drops any
536
536
  user-naming key, so a manifest cannot ask for someone else's inbox.
537
537
 
538
+ ### The `organisations` source: OpenRegister organisations
539
+
540
+ `entitySource: "organisations"` lists the OpenRegister organisations the
541
+ viewer belongs to, from `GET /apps/openregister/api/organisations`. An
542
+ organisation is not a register/schema object, so self-fetch cannot reach it.
543
+
544
+ ```json
545
+ {
546
+ "id": "organisations",
547
+ "route": "/organisations",
548
+ "type": "index",
549
+ "config": { "entitySource": "organisations", "rowRoute": "organisation-detail" }
550
+ }
551
+ ```
552
+
553
+ - Columns: name, status (a pill with the lifecycle state in words), type and
554
+ created. A manifest `columns` list still wins; every field of the
555
+ organisation can be a column (`slug`, `contactEmail`, `kvk`, ...).
556
+ - A row's `id` is the organisation's uuid, so `rowRoute` pushes
557
+ `{ name: rowRoute, params: { id: <uuid> } }`.
558
+ - No Add, Copy or Delete: creating an organisation is not this page's object
559
+ form, and OpenRegister has no delete for one.
560
+ - Which organisations are listed is OpenRegister's decision from the
561
+ session. The endpoint takes no parameters, so no config key reaches it.
562
+
563
+ The same name works on a `type: "detail"` page, which then shows ONE
564
+ organisation (see CnDetailPage, "Records that are not objects").
565
+
538
566
  ### Search fields on a named source
539
567
 
540
568
  Available from the release that follows 3.2.0. An app on 3.2.0 or older ignores `sidebar.fields`, so bump the dependency before declaring them.