@conduction/nextcloud-vue 2.97.0 → 2.99.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 (160) hide show
  1. package/dist/esm/components/CnActionsMenu/CnActionsMenu.vue2.js +12 -0
  2. package/dist/esm/components/CnActionsMenu/CnActionsMenu.vue2.js.map +1 -1
  3. package/dist/esm/components/CnActionsMenu/CnActionsMenu.vue3.js +11 -2
  4. package/dist/esm/components/CnActionsMenu/CnActionsMenu.vue3.js.map +1 -1
  5. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +17 -0
  6. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
  7. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
  8. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue.js +1 -1
  9. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue2.js.map +1 -1
  10. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js +1 -1
  11. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js.map +1 -1
  12. package/dist/esm/components/CnChartWidget/CnChartWidget.vue.js +1 -1
  13. package/dist/esm/components/CnChartWidget/CnChartWidget.vue2.js +106 -2
  14. package/dist/esm/components/CnChartWidget/CnChartWidget.vue2.js.map +1 -1
  15. package/dist/esm/components/CnChartWidget/CnChartWidget.vue3.js.map +1 -1
  16. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue.js +9 -0
  17. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue.js.map +1 -0
  18. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue2.js +964 -0
  19. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue2.js.map +1 -0
  20. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue3.js +282 -0
  21. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue3.js.map +1 -0
  22. package/dist/esm/components/CnChecklistRunWidget/index.js +28 -0
  23. package/dist/esm/components/CnChecklistRunWidget/index.js.map +1 -0
  24. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
  25. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +1 -0
  26. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  27. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  28. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +26 -8
  29. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  30. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  31. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +188 -5
  32. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  33. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +2 -1
  34. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  35. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue.js +1 -1
  36. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue2.js +3 -3
  37. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue2.js.map +1 -1
  38. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue3.js.map +1 -1
  39. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue.js +1 -1
  40. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue2.js +27 -0
  41. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue2.js.map +1 -1
  42. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue3.js +16 -2
  43. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue3.js.map +1 -1
  44. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +43 -3
  45. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  46. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  47. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue.js +9 -0
  48. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue.js.map +1 -0
  49. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue2.js +239 -0
  50. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue2.js.map +1 -0
  51. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue3.js +182 -0
  52. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue3.js.map +1 -0
  53. package/dist/esm/components/CnSettingsField/CnSettingsField.vue.js +9 -0
  54. package/dist/esm/components/CnSettingsField/CnSettingsField.vue.js.map +1 -0
  55. package/dist/esm/components/CnSettingsField/CnSettingsField.vue2.js +265 -0
  56. package/dist/esm/components/CnSettingsField/CnSettingsField.vue2.js.map +1 -0
  57. package/dist/esm/components/CnSettingsField/CnSettingsField.vue3.js +191 -0
  58. package/dist/esm/components/CnSettingsField/CnSettingsField.vue3.js.map +1 -0
  59. package/dist/esm/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue.js +9 -0
  60. package/dist/esm/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue.js.map +1 -0
  61. package/dist/esm/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue2.js +508 -0
  62. package/dist/esm/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue2.js.map +1 -0
  63. package/dist/esm/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue3.js +99 -0
  64. package/dist/esm/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue3.js.map +1 -0
  65. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue.js +1 -1
  66. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue2.js +18 -70
  67. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue2.js.map +1 -1
  68. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue3.js +6 -44
  69. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue3.js.map +1 -1
  70. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue.js +1 -1
  71. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue2.js +83 -9
  72. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue2.js.map +1 -1
  73. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue3.js +80 -11
  74. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue3.js.map +1 -1
  75. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js +2 -0
  76. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js.map +1 -1
  77. package/dist/esm/components/CnWorkingCalendarWidget/CnWorkingCalendarWidget.vue.js +9 -0
  78. package/dist/esm/components/CnWorkingCalendarWidget/CnWorkingCalendarWidget.vue.js.map +1 -0
  79. package/dist/esm/components/CnWorkingCalendarWidget/CnWorkingCalendarWidget.vue2.js +472 -0
  80. package/dist/esm/components/CnWorkingCalendarWidget/CnWorkingCalendarWidget.vue2.js.map +1 -0
  81. package/dist/esm/components/CnWorkingCalendarWidget/CnWorkingCalendarWidget.vue3.js +231 -0
  82. package/dist/esm/components/CnWorkingCalendarWidget/CnWorkingCalendarWidget.vue3.js.map +1 -0
  83. package/dist/esm/components/CnWorkingCalendarWidget/index.js +27 -0
  84. package/dist/esm/components/CnWorkingCalendarWidget/index.js.map +1 -0
  85. package/dist/esm/composables/indexSources.js +238 -2
  86. package/dist/esm/composables/indexSources.js.map +1 -1
  87. package/dist/esm/composables/useFlowStore.js +2 -1
  88. package/dist/esm/composables/useFlowStore.js.map +1 -1
  89. package/dist/esm/composables/useOrganisationStore.js +115 -0
  90. package/dist/esm/composables/useOrganisationStore.js.map +1 -0
  91. package/dist/esm/index.js +5 -0
  92. package/dist/esm/index.js.map +1 -1
  93. package/dist/esm/l10n/en.json.js +109 -1
  94. package/dist/esm/l10n/en.json.js.map +1 -1
  95. package/dist/esm/l10n/nl.json.js +138 -1
  96. package/dist/esm/l10n/nl.json.js.map +1 -1
  97. package/dist/esm/library-style.js +1 -1
  98. package/dist/esm/nextcloud-vue.css +1071 -274
  99. package/dist/esm/utils/builtInFormatters.js +34 -1
  100. package/dist/esm/utils/builtInFormatters.js.map +1 -1
  101. package/dist/esm/utils/libraryWidgetKeys.js +2 -0
  102. package/dist/esm/utils/libraryWidgetKeys.js.map +1 -1
  103. package/dist/esm/utils/matrixCells.js +158 -0
  104. package/dist/esm/utils/matrixCells.js.map +1 -0
  105. package/dist/esm/utils/nextcloudDirectory.js +121 -0
  106. package/dist/esm/utils/nextcloudDirectory.js.map +1 -0
  107. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  108. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  109. package/dist/esm/utils/workingCalendarMonth.js +85 -0
  110. package/dist/esm/utils/workingCalendarMonth.js.map +1 -0
  111. package/dist/nextcloud-vue.cjs.js +14888 -10014
  112. package/dist/nextcloud-vue.cjs.js.map +2 -2
  113. package/dist/nextcloud-vue.css +1071 -274
  114. package/l10n/en.json +109 -1
  115. package/l10n/nl.json +138 -1
  116. package/package.json +2 -2
  117. package/scripts/merge-l10n.js +196 -0
  118. package/src/components/CnActionsMenu/CnActionsMenu.vue +14 -1
  119. package/src/components/CnAppRoot/CnAppRoot.md +1 -0
  120. package/src/components/CnAppRoot/CnAppRoot.vue +17 -0
  121. package/src/components/CnBreadcrumbs/CnBreadcrumbs.vue +1 -0
  122. package/src/components/CnChartWidget/CnChartWidget.md +2 -1
  123. package/src/components/CnChartWidget/CnChartWidget.vue +106 -2
  124. package/src/components/CnChecklistRunWidget/CnChecklistRunWidget.vue +1269 -0
  125. package/src/components/CnChecklistRunWidget/index.js +27 -0
  126. package/src/components/CnDashboardPage/CnDashboardPage.vue +1 -0
  127. package/src/components/CnDataTable/CnDataTable.vue +26 -8
  128. package/src/components/CnDetailPage/CnDetailPage.md +48 -0
  129. package/src/components/CnDetailPage/CnDetailPage.vue +189 -5
  130. package/src/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue +3 -3
  131. package/src/components/CnFeaturesBoard/CnFeaturesBoard.vue +38 -2
  132. package/src/components/CnIndexPage/CnIndexPage.md +28 -0
  133. package/src/components/CnIndexPage/CnIndexPage.vue +43 -3
  134. package/src/components/CnOrderedListField/CnOrderedListField.vue +458 -0
  135. package/src/components/CnOrderedListField/index.js +7 -0
  136. package/src/components/CnSettingsField/CnSettingsField.vue +449 -0
  137. package/src/components/CnSettingsField/index.js +4 -0
  138. package/src/components/CnSettingsFieldsSection/CnSettingsFieldsSection.vue +613 -0
  139. package/src/components/CnSettingsFieldsSection/index.js +4 -0
  140. package/src/components/CnSettingsPage/CnSettingsPage.vue +6 -86
  141. package/src/components/CnUserSettingsDialog/CnUserSettingsDialog.vue +139 -10
  142. package/src/components/CnWidgetGrid/registerDashboardWidgets.js +2 -0
  143. package/src/components/CnWorkingCalendarWidget/CnWorkingCalendarWidget.vue +725 -0
  144. package/src/components/CnWorkingCalendarWidget/index.js +26 -0
  145. package/src/components/index.js +5 -0
  146. package/src/composables/indexSources.js +237 -1
  147. package/src/composables/useFlowStore.js +2 -1
  148. package/src/composables/useOrganisationStore.js +111 -0
  149. package/src/css/badge.css +32 -16
  150. package/src/css/look-board-detail.css +80 -8
  151. package/src/css/look-board-index.css +52 -8
  152. package/src/css/look-board.css +219 -1
  153. package/src/index.js +5 -0
  154. package/src/schemas/app-manifest-v2.schema.json +33 -3
  155. package/src/utils/builtInFormatters.js +33 -0
  156. package/src/utils/libraryWidgetKeys.js +2 -0
  157. package/src/utils/matrixCells.js +154 -0
  158. package/src/utils/nextcloudDirectory.js +117 -0
  159. package/src/utils/validateManifestV2.compiled.js +3 -3
  160. package/src/utils/workingCalendarMonth.js +82 -0
@@ -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.
@@ -379,6 +379,7 @@
379
379
  :showDocumentation="showDocumentation && menuShowsHelpLinks"
380
380
  :actionsMenuLabel="boardMenuLabel"
381
381
  :variant="isBoard ? 'secondary' : undefined"
382
+ :trailingChevron="isBoard"
382
383
  :hasPrimaryItems="headerMenuGroups.length > 0 || showMarkUnread || showOpenSidebar"
383
384
  :documentationUrl="documentationUrl"
384
385
  :docsAnchor="resolvedPageId"
@@ -1232,11 +1233,13 @@ import CnSegmentedControl from '../CnSegmentedControl/CnSegmentedControl.vue'
1232
1233
  import CnSummaryAggregates from '../CnSummaryAggregates/CnSummaryAggregates.vue'
1233
1234
  import CnTranslatedBadge from '../CnTranslatedBadge/CnTranslatedBadge.vue'
1234
1235
  import CnDetailHeaderChips from './CnDetailHeaderChips.vue'
1236
+ import { resolveRecordSource } from '../../composables/indexSources.js'
1235
1237
  import { useIntegrationRegistry } from '../../composables/useIntegrationRegistry.js'
1236
1238
  import { normalizeLook } from '../../composables/useLook.js'
1237
1239
  import { useObjectLock } from '../../composables/useObjectLock.js'
1238
1240
  import { useObjectPresence } from '../../composables/useObjectPresence.js'
1239
1241
  import { useObjectSubscription } from '../../composables/useObjectSubscription.js'
1242
+ import { createRefLabelResolver } from '../../composables/useRefLabels.js'
1240
1243
  import { gridLayout } from '../../mixins/gridLayout.js'
1241
1244
  import { pageViews } from '../../mixins/pageViews.js'
1242
1245
  import { useObjectStore } from '../../store/index.js'
@@ -1909,6 +1912,23 @@ export default {
1909
1912
  default: null,
1910
1913
  },
1911
1914
 
1915
+ /**
1916
+ * Name of a registered entity source that reads ONE record (manifest
1917
+ * `config.entitySource`), for a record that is not a register/schema
1918
+ * object. The same registry and the same name as `CnIndexPage`'s
1919
+ * `entitySource`: `organisations` shows the OpenRegister organisation
1920
+ * whose uuid is `objectId`. The record feeds the header, pills, facts,
1921
+ * widgets and the `@objectId` / `@object.*` tokens exactly as a fetched
1922
+ * object does. Wins over `register` + `schema`. Empty (the default)
1923
+ * leaves the page on its object store.
1924
+ *
1925
+ * @type {string}
1926
+ */
1927
+ entitySource: {
1928
+ type: String,
1929
+ default: '',
1930
+ },
1931
+
1912
1932
  /**
1913
1933
  * OpenRegister register slug. Pair with `schema` to opt into the
1914
1934
  * schema-driven mode: the page fuses the two into an internal
@@ -2863,6 +2883,10 @@ export default {
2863
2883
  pillCounts: {},
2864
2884
  /** Sequence of the last pill count round, so a late answer does not overwrite a newer one. */
2865
2885
  pillCountSeq: 0,
2886
+ /** The labels of the records pills with `resolve` point at, by pill key. */
2887
+ pillRefLabels: {},
2888
+ /** Sequence of the last pill label lookup, so a late answer for another record is dropped. */
2889
+ pillRefSeq: 0,
2866
2890
  /** `register/schema/id` the read-state call was last made for, so it is sent once per load. */
2867
2891
  readStateSentFor: '',
2868
2892
  /** Whether the user is arranging the chosen view (`userLayout`). */
@@ -3384,7 +3408,16 @@ export default {
3384
3408
  const pill = pillCountSource(config)
3385
3409
  ? resolveCountPill(config, this.pillCounts[key], (text) => this.effectiveTranslate(text))
3386
3410
  : resolvePill(config, this.resolvedObject)
3387
- return pill ? { ...pill, key, label: this.effectiveTranslate(pill.label) } : null
3411
+ if (!pill) {
3412
+ return null
3413
+ }
3414
+ // `resolve`: the referenced record's label replaces the id, and
3415
+ // keys the colour too ("Woo-verzoek", not a uuid).
3416
+ const resolved = this.pillRefLabels[key]
3417
+ if (typeof resolved === 'string' && resolved !== '') {
3418
+ return { ...pill, key, label: this.effectiveTranslate(resolved), colorKey: resolved }
3419
+ }
3420
+ return { ...pill, key, label: this.effectiveTranslate(pill.label) }
3388
3421
  })
3389
3422
  .filter(Boolean)
3390
3423
  },
@@ -3396,6 +3429,34 @@ export default {
3396
3429
  * @return {string} The key.
3397
3430
  * @spec openspec/changes/screens-relation-pages/specs/relation-pages/spec.md#requirement-a-header-pill-can-be-a-constant-or-a-count
3398
3431
  */
3432
+ /**
3433
+ * What the resolved pills depend on, as one key: a change looks again.
3434
+ *
3435
+ * @return {string}
3436
+ * @spec openspec/changes/detail-pill-resolve/specs/detail-page/spec.md#requirement-a-header-pill-can-show-a-referenced-record-s-label
3437
+ */
3438
+ pillResolveKey() {
3439
+ return JSON.stringify(this.pillResolveTargets)
3440
+ },
3441
+
3442
+ /**
3443
+ * The pills with `resolve: { register, schema, labelField? }` and the
3444
+ * id each one shows: `[key, register, schema, labelField, id]`. Empty
3445
+ * while the field is empty.
3446
+ *
3447
+ * @return {Array<Array<string>>}
3448
+ * @spec openspec/changes/detail-pill-resolve/specs/detail-page/spec.md#requirement-a-header-pill-can-show-a-referenced-record-s-label
3449
+ */
3450
+ pillResolveTargets() {
3451
+ return [['type', this.typePill], ['status', this.statusPill]]
3452
+ .filter(([, config]) => config && config.resolve && config.resolve.register && config.resolve.schema && typeof config.field === 'string')
3453
+ .map(([key, config]) => {
3454
+ const pill = resolvePill({ field: config.field }, this.resolvedObject)
3455
+ return pill ? [key, config.resolve.register, config.resolve.schema, config.resolve.labelField || '', pill.colorKey] : null
3456
+ })
3457
+ .filter(Boolean)
3458
+ },
3459
+
3399
3460
  pillCountKey() {
3400
3461
  return JSON.stringify([this.objectId, pillCountSource(this.typePill), pillCountSource(this.statusPill)])
3401
3462
  },
@@ -3598,6 +3659,9 @@ export default {
3598
3659
  * @return {boolean}
3599
3660
  */
3600
3661
  awaitingObject() {
3662
+ if (this.hasRecordSourceFetch) {
3663
+ return this.objectFetchPending && !this.currentObject
3664
+ }
3601
3665
  return this.hasSchemaDrivenFetch
3602
3666
  && this.objectFetchPending
3603
3667
  && typeof this.effectiveObjectStore?.fetchObject === 'function'
@@ -3671,7 +3735,45 @@ export default {
3671
3735
  * the fetch path entirely.
3672
3736
  */
3673
3737
  hasSchemaDrivenFetch() {
3674
- return Boolean(this.register && this.schema && this.objectId)
3738
+ return !this.entitySource && Boolean(this.register && this.schema && this.objectId)
3739
+ },
3740
+
3741
+ /**
3742
+ * The record source named by `entitySource`, or null. An unknown name,
3743
+ * or a source that cannot read one record, is null with a warning.
3744
+ *
3745
+ * @return {object|null}
3746
+ */
3747
+ recordSource() {
3748
+ if (!this.entitySource) {
3749
+ return null
3750
+ }
3751
+ try {
3752
+ return resolveRecordSource(this.entitySource)
3753
+ } catch (err) {
3754
+ // eslint-disable-next-line no-console -- the console is the only channel a host app can read the detail on
3755
+ console.warn('[CnDetailPage] the record source could not start; no record is shown.', err)
3756
+ return null
3757
+ }
3758
+ },
3759
+
3760
+ /**
3761
+ * Whether the page reads its record through a record source.
3762
+ *
3763
+ * @return {boolean}
3764
+ */
3765
+ hasRecordSourceFetch() {
3766
+ return Boolean(this.recordSource && this.objectId)
3767
+ },
3768
+
3769
+ /**
3770
+ * Whether the page reads its own record, from the object store or a
3771
+ * record source, so Refresh and `cn:page:refresh` have something to do.
3772
+ *
3773
+ * @return {boolean}
3774
+ */
3775
+ selfFetchesRecord() {
3776
+ return this.hasSchemaDrivenFetch || this.hasRecordSourceFetch
3675
3777
  },
3676
3778
 
3677
3779
  /**
@@ -3886,7 +3988,7 @@ export default {
3886
3988
  }
3887
3989
  // `$.vnode.props`, not `$attrs`: `refresh` is a declared emit, and
3888
3990
  // Vue keeps declared emits out of `$attrs`.
3889
- return Boolean(this.$.vnode.props?.onRefresh) || this.hasSchemaDrivenFetch
3991
+ return Boolean(this.$.vnode.props?.onRefresh) || this.selfFetchesRecord
3890
3992
  },
3891
3993
 
3892
3994
  /**
@@ -3930,6 +4032,9 @@ export default {
3930
4032
  * @return {object|null}
3931
4033
  */
3932
4034
  currentObject() {
4035
+ if (this.recordSource) {
4036
+ return this.objectId ? (this.recordSource.record(this.objectId) ?? null) : null
4037
+ }
3933
4038
  const store = this.effectiveObjectStore
3934
4039
  if (!store) {
3935
4040
  return null
@@ -4605,6 +4710,14 @@ export default {
4605
4710
  },
4606
4711
  },
4607
4712
 
4713
+ // A pill with `resolve` looks its record up again for another value.
4714
+ pillResolveKey: {
4715
+ immediate: true,
4716
+ handler() {
4717
+ this.refreshPillRefLabels()
4718
+ },
4719
+ },
4720
+
4608
4721
  // The header's count pills count again for another record or other lists.
4609
4722
  pillCountKey: {
4610
4723
  immediate: true,
@@ -5028,7 +5141,7 @@ export default {
5028
5141
  // Schema-driven (manifest) detail pages self-fetch — without this
5029
5142
  // the Refresh action has no host listener to act on and does
5030
5143
  // nothing. Re-fetch the object + schema and spin the action.
5031
- if (!this.hasSchemaDrivenFetch) {
5144
+ if (!this.selfFetchesRecord) {
5032
5145
  return
5033
5146
  }
5034
5147
  this.internalRefreshing = true
@@ -5041,6 +5154,42 @@ export default {
5041
5154
  }
5042
5155
  },
5043
5156
 
5157
+ /**
5158
+ * Look up the records the `resolve` pills point at, through the same
5159
+ * reference-label resolver the index tables use. A failed lookup keeps
5160
+ * the raw value.
5161
+ *
5162
+ * @return {Promise<void>}
5163
+ * @spec openspec/changes/detail-pill-resolve/specs/detail-page/spec.md#requirement-a-header-pill-can-show-a-referenced-record-s-label
5164
+ */
5165
+ async refreshPillRefLabels() {
5166
+ const targets = this.pillResolveTargets
5167
+ const seq = ++this.pillRefSeq
5168
+ if (targets.length === 0) {
5169
+ this.pillRefLabels = {}
5170
+ return
5171
+ }
5172
+ if (!this._pillRefResolver) {
5173
+ this._pillRefResolver = createRefLabelResolver(() => {
5174
+ try {
5175
+ return this.objectStore || useObjectStore()
5176
+ } catch {
5177
+ return null
5178
+ }
5179
+ })
5180
+ }
5181
+ const labels = {}
5182
+ await Promise.all(targets.map(async ([key, register, schema, labelField, id]) => {
5183
+ const found = await this._pillRefResolver.resolve(register, schema, [id], labelField).catch(() => ({}))
5184
+ if (found && typeof found[id] === 'string') {
5185
+ labels[key] = found[id]
5186
+ }
5187
+ }))
5188
+ if (seq === this.pillRefSeq) {
5189
+ this.pillRefLabels = labels
5190
+ }
5191
+ },
5192
+
5044
5193
  /**
5045
5194
  * Count the lists behind the header's count pills (`countFrom`): one
5046
5195
  * list request each (`_limit=1`, the `total`). A failed count, or a
@@ -5083,7 +5232,7 @@ export default {
5083
5232
  */
5084
5233
  onPageRefreshBus(payload) {
5085
5234
  this.refreshPillCounts()
5086
- if (!this.hasSchemaDrivenFetch) {
5235
+ if (!this.selfFetchesRecord) {
5087
5236
  return
5088
5237
  }
5089
5238
  if (this.internalRefreshing) {
@@ -5308,6 +5457,10 @@ export default {
5308
5457
  },
5309
5458
 
5310
5459
  async fetchObjectIfNeeded() {
5460
+ if (this.recordSource) {
5461
+ await this.fetchSourceRecord()
5462
+ return
5463
+ }
5311
5464
  // Create archetype: no object to fetch, but the create form needs
5312
5465
  // the schema — register the type and fetch its schema, then stop.
5313
5466
  if (!this.hasSchemaDrivenFetch) {
@@ -5371,6 +5524,37 @@ export default {
5371
5524
  }
5372
5525
  },
5373
5526
 
5527
+ /**
5528
+ * Read the record through the record source. A refusal (403) or a
5529
+ * missing record (404) shows the not-found state; which records the
5530
+ * viewer may read is the server's decision.
5531
+ *
5532
+ * @return {Promise<void>}
5533
+ */
5534
+ async fetchSourceRecord() {
5535
+ const source = this.recordSource
5536
+ const objectId = this.objectId
5537
+ if (!objectId) {
5538
+ this.objectFetchPending = false
5539
+ return
5540
+ }
5541
+ this.objectFetchPending = true
5542
+ this.objectNotFound = false
5543
+ try {
5544
+ await source.loadRecord(String(objectId))
5545
+ } catch (err) {
5546
+ // 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
5547
+ console.error('[CnDetailPage] record source fetch failed:', err)
5548
+ } finally {
5549
+ // A newer fetch for another id owns the flags now.
5550
+ if (objectId === this.objectId) {
5551
+ const status = typeof source.recordError === 'function' ? source.recordError(String(objectId)) : null
5552
+ this.objectNotFound = !this.currentObject && (status === 403 || status === 404)
5553
+ this.objectFetchPending = false
5554
+ }
5555
+ }
5556
+ },
5557
+
5374
5558
  /**
5375
5559
  * Whether a grid-layout item resolves to an integration-typed
5376
5560
  * 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.