@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
@@ -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
@@ -49,3 +49,15 @@ Posting to an endpoint. The reply goes out as `{ message, ...payload }`. `title-
49
49
  </template>
50
50
  </CnConversationThread>
51
51
  ```
52
+
53
+ While the messages load, `loading` makes the thread say so (in a status region) instead of showing the empty text. A
54
+ message's `channel` is shown after its time:
55
+
56
+ ```vue
57
+ <CnConversationThread
58
+ title="Contact"
59
+ :loading="false"
60
+ :messages="[
61
+ { id: 1, author: 'Sanne de Vries', time: '2026-10-04T10:52:00', side: 'them', channel: 'Message box', text: 'Thank you.' },
62
+ ]" />
63
+ ```
@@ -17,7 +17,13 @@
17
17
  {{ title }}
18
18
  </component>
19
19
 
20
- <p v-if="rows.length === 0" class="cn-conversation-thread__empty" data-testid="cn-conversation-empty">
20
+ <p v-if="loading && rows.length === 0"
21
+ class="cn-conversation-thread__empty"
22
+ role="status"
23
+ data-testid="cn-conversation-loading">
24
+ {{ loadingText }}
25
+ </p>
26
+ <p v-else-if="rows.length === 0" class="cn-conversation-thread__empty" data-testid="cn-conversation-empty">
21
27
  {{ emptyText }}
22
28
  </p>
23
29
 
@@ -32,8 +38,10 @@
32
38
  <span class="cn-conversation-thread__avatar" aria-hidden="true">{{ row.initials }}</span>
33
39
  <div class="cn-conversation-thread__bubble">
34
40
  <span class="cn-conversation-thread__meta">
41
+ <span class="hidden-visually">{{ row.directionLabel }}</span>
35
42
  <span class="cn-conversation-thread__author">{{ row.author }}</span>
36
43
  <time v-if="row.timeLabel" :datetime="row.datetime || null">{{ row.timeLabel }}</time>
44
+ <span v-if="row.channel" class="cn-conversation-thread__channel" data-testid="cn-conversation-channel">{{ row.channel }}</span>
37
45
  </span>
38
46
  <span class="cn-conversation-thread__text">{{ row.text }}</span>
39
47
  </div>
@@ -123,9 +131,10 @@ export default {
123
131
 
124
132
  props: {
125
133
  /**
126
- * The messages, oldest first: `{ id?, author?, time?, text, side? }`.
134
+ * The messages, oldest first: `{ id?, author?, time?, text, side?, channel? }`.
127
135
  * `side` is `us` or `them` (default `them`). `time` is an ISO
128
- * date, or any text to show as is.
136
+ * date, or any text to show as is. `channel` is shown after the time
137
+ * ("E-mail", "Phone note").
129
138
  */
130
139
  messages: {
131
140
  type: Array,
@@ -189,6 +198,12 @@ export default {
189
198
  default: () => ({}),
190
199
  },
191
200
 
201
+ /** Whether the messages are still loading; with none yet the thread says so. */
202
+ loading: {
203
+ type: Boolean,
204
+ default: false,
205
+ },
206
+
192
207
  /** Shown when there are no messages yet. */
193
208
  emptyText: {
194
209
  type: String,
@@ -221,7 +236,7 @@ export default {
221
236
  /**
222
237
  * The messages, with author, side, time and initials resolved.
223
238
  *
224
- * @return {Array<{key: string, author: string, initials: string, side: string, text: string, timeLabel: string, datetime: string}>} The rows.
239
+ * @return {Array<{key: string, author: string, initials: string, side: string, text: string, timeLabel: string, datetime: string, channel: string, directionLabel: string}>} The rows.
225
240
  * @spec openspec/changes/detail-action-model-and-case-surfaces/specs/detail-action-model/spec.md#requirement-conversation-thread
226
241
  */
227
242
  rows() {
@@ -241,6 +256,8 @@ export default {
241
256
  text: message.text === undefined || message.text === null ? '' : String(message.text),
242
257
  timeLabel: time.label,
243
258
  datetime: time.datetime,
259
+ channel: typeof message.channel === 'string' ? message.channel : '',
260
+ directionLabel: side === 'us' ? t('nextcloud-vue', 'Sent message') : t('nextcloud-vue', 'Received message'),
244
261
  }
245
262
  })
246
263
  },
@@ -264,6 +281,11 @@ export default {
264
281
  fallbackLabel() {
265
282
  return t('nextcloud-vue', 'Conversation')
266
283
  },
284
+
285
+ /** @return {string} What the thread says while its messages load. */
286
+ loadingText() {
287
+ return t('nextcloud-vue', 'Loading messages…')
288
+ },
267
289
  },
268
290
 
269
291
  methods: {
@@ -455,4 +477,8 @@ export default {
455
477
  gap: calc(2.5 * var(--default-grid-baseline));
456
478
  justify-content: flex-end;
457
479
  }
480
+
481
+ .cn-conversation-thread__channel {
482
+ color: var(--color-text-maxcontrast);
483
+ }
458
484
  </style>
@@ -7,29 +7,44 @@
7
7
  <div class="cn-case-widget-card">
8
8
  <CnConversationThread
9
9
  :messages="messages"
10
- :title="content.title || ''"
10
+ :title="tr(content.title || '')"
11
11
  :allowReply="content.allowReply !== false"
12
- :replyLabel="content.replyLabel || undefined"
13
- :placeholder="content.placeholder || ''"
14
- :sendLabel="content.sendLabel || undefined"
15
- :endpoint="endpoint"
16
- :emptyText="content.emptyText || undefined"
12
+ :replyLabel="content.replyLabel ? tr(content.replyLabel) : undefined"
13
+ :placeholder="tr(content.placeholder || '')"
14
+ :sendLabel="content.sendLabel ? tr(content.sendLabel) : undefined"
15
+ :endpoint="replyAction ? '' : endpoint"
16
+ :loading="loading"
17
+ :emptyText="content.emptyText ? tr(content.emptyText) : undefined"
18
+ @send="onSend"
17
19
  @sent="onSent" />
18
20
  </div>
19
21
  </template>
20
22
 
21
23
  <script>
22
24
  import CnConversationThread from './CnConversationThread.vue'
25
+ import { dispatchAction } from '../../utils/actionsDispatcher.js'
26
+ import { fetchReferenceObjects } from '../../utils/boardReferenceColumns.js'
23
27
  import { readPath } from '../../utils/readPath.js'
28
+ import { hasUnresolvedDeepTokens, resolveDeepTokens } from '../../utils/resolveFilterTokens.js'
24
29
 
25
30
  /**
26
31
  * CnConversationWidget: the `conversation` detail widget type.
27
32
  *
28
- * Renders CnConversationThread from a list on the bound record:
29
- * `content: { field, endpoint?, authorField?, timeField?, textField?, sideField?, usValue? }`.
30
- * `@objectId` in `endpoint` is replaced by the record's id. A reply the
31
- * endpoint accepts is shown at once, until the record is read again.
32
- * Resolved by its registry key, and exported for an app that renders it itself.
33
+ * Renders CnConversationThread from the messages of a record, oldest first.
34
+ * The messages are a list on the bound record (`field`), or the objects of a
35
+ * register list (`source: { register, schema, filter?, limit? }`, the filter
36
+ * token-resolved like a record-list's: `{ "case": "@objectId" }`).
37
+ *
38
+ * Mapping keys name where each message keeps its parts: `textField` (alias
39
+ * `bodyField`), `timeField` (alias `timestampField`), `authorField` (alias
40
+ * `senderField`), `sideField` (alias `directionField`) with `usValue` (alias
41
+ * `outValue`) for the messages the organisation sent, and `channelField` with
42
+ * an optional `channelMap` from stored value to label ("email" to "E-mail").
43
+ *
44
+ * The reply box posts to `endpoint` (`@objectId` filled in), or runs
45
+ * `replyAction`, an action descriptor the app decides the meaning of: it is
46
+ * dispatched with `args: [{ text, objectId }]` and `props.text`. After a send
47
+ * the source is read again.
33
48
  */
34
49
  export default {
35
50
  name: 'CnConversationWidget',
@@ -38,8 +53,19 @@ export default {
38
53
  CnConversationThread,
39
54
  },
40
55
 
56
+ inject: {
57
+ cnTranslate: { default: () => (key) => key },
58
+ /** Pre-bound action dispatcher of the page, for `replyAction`. */
59
+ cnDispatchAction: { default: null },
60
+ },
61
+
41
62
  props: {
42
- /** The widget's content. `field` is the dot-path to the messages on the record. */
63
+ /**
64
+ * The widget's content: `field` (the dot-path to the messages on the
65
+ * record) or `source`, the mapping keys, `endpoint` or `replyAction`,
66
+ * and the texts (`title`, `replyLabel`, `placeholder`, `sendLabel`,
67
+ * `emptyText`, translated).
68
+ */
43
69
  content: {
44
70
  type: Object,
45
71
  default: () => ({}),
@@ -62,29 +88,63 @@ export default {
62
88
  return {
63
89
  /** Replies sent from this widget that the record does not hold yet. */
64
90
  sentHere: [],
91
+ /** The messages read from `source`. */
92
+ listed: [],
93
+ /** Whether `source` is being read. */
94
+ loading: false,
65
95
  }
66
96
  },
67
97
 
68
98
  computed: {
99
+ /** @return {object|null} The register list the messages come from, or null. */
100
+ source() {
101
+ const source = this.content?.source
102
+ return source && typeof source === 'object' && source.register && source.schema ? source : null
103
+ },
104
+
105
+ /** @return {object|null} The reply action, or null. */
106
+ replyAction() {
107
+ const action = this.content?.replyAction
108
+ return action && typeof action === 'object' ? action : null
109
+ },
110
+
69
111
  /**
70
- * The record's messages in the thread's shape, plus the replies sent here.
112
+ * The messages in the thread's shape, oldest first, plus the replies
113
+ * sent here.
71
114
  *
72
115
  * @return {Array<object>} The messages.
73
116
  * @spec openspec/changes/detail-action-model-and-case-surfaces/specs/detail-action-model/spec.md#requirement-conversation-thread
117
+ * @spec openspec/changes/conversation-source/specs/conversation-source/spec.md#requirement-a-conversation-reads-its-messages-from-a-list-and-maps-their-parts
74
118
  */
75
119
  messages() {
76
- const c = this.content
77
- const value = readPath(this.objectData || {}, c.field || 'messages')
78
- const usValue = String(c.usValue === undefined ? 'us' : c.usValue)
79
- const stored = (Array.isArray(value) ? value : [])
120
+ const c = this.content || {}
121
+ const raw = this.source ? this.listed : readPath(this.objectData || {}, c.field || 'messages')
122
+ const usValue = String(c.outValue ?? c.usValue ?? 'us')
123
+ const channelMap = c.channelMap && typeof c.channelMap === 'object' ? c.channelMap : {}
124
+ const stored = (Array.isArray(raw) ? raw : [])
80
125
  .filter((message) => message && typeof message === 'object')
81
- .map((message) => ({
82
- id: message.id,
83
- author: readPath(message, c.authorField || 'author'),
84
- time: readPath(message, c.timeField || 'time'),
85
- text: readPath(message, c.textField || 'text'),
86
- side: String(readPath(message, c.sideField || 'side')) === usValue ? 'us' : 'them',
87
- }))
126
+ .map((message, index) => {
127
+ const channel = c.channelField ? readPath(message, c.channelField) : undefined
128
+ const channelKey = channel === undefined || channel === null ? '' : String(channel)
129
+ return {
130
+ id: message.id ?? `message-${index}`,
131
+ author: readPath(message, c.senderField || c.authorField || 'author'),
132
+ time: readPath(message, c.timestampField || c.timeField || 'time'),
133
+ text: readPath(message, c.bodyField || c.textField || 'text'),
134
+ side: String(readPath(message, c.directionField || c.sideField || 'side')) === usValue ? 'us' : 'them',
135
+ channel: channelKey === '' ? '' : this.tr(Object.hasOwn(channelMap, channelKey) ? channelMap[channelKey] : channelKey),
136
+ order: index,
137
+ }
138
+ })
139
+ const at = (message) => {
140
+ const ms = Date.parse(message.time)
141
+ return Number.isNaN(ms) ? null : ms
142
+ }
143
+ stored.sort((a, b) => {
144
+ const ta = at(a)
145
+ const tb = at(b)
146
+ return (ta === null || tb === null || ta === tb) ? a.order - b.order : ta - tb
147
+ })
88
148
  return [...stored, ...this.sentHere]
89
149
  },
90
150
 
@@ -101,6 +161,11 @@ export default {
101
161
  }
102
162
  return url.replace(/@objectId/g, encodeURIComponent(String(this.objectId ?? '')))
103
163
  },
164
+
165
+ /** @return {object} The token context for `source.filter` and `replyAction`. */
166
+ tokenCtx() {
167
+ return { objectId: this.objectId, object: this.objectData || {} }
168
+ },
104
169
  },
105
170
 
106
171
  watch: {
@@ -108,9 +173,87 @@ export default {
108
173
  objectData() {
109
174
  this.sentHere = []
110
175
  },
176
+
177
+ objectId: {
178
+ immediate: true,
179
+ handler() {
180
+ this.loadSource()
181
+ },
182
+ },
111
183
  },
112
184
 
113
185
  methods: {
186
+ /**
187
+ * Translate through the app's translate.
188
+ *
189
+ * @param {string} key The source string.
190
+ * @return {string}
191
+ */
192
+ tr(key) {
193
+ return key && typeof this.cnTranslate === 'function' ? this.cnTranslate(key) : (key || '')
194
+ },
195
+
196
+ /**
197
+ * Read the messages from `source`. A filter whose token does not
198
+ * resolve sends no request; a failed read leaves the thread empty.
199
+ *
200
+ * @return {Promise<void>}
201
+ * @spec openspec/changes/conversation-source/specs/conversation-source/spec.md#requirement-a-conversation-reads-its-messages-from-a-list-and-maps-their-parts
202
+ */
203
+ async loadSource() {
204
+ if (!this.source) {
205
+ return
206
+ }
207
+ const raw = this.source.filter || {}
208
+ const filter = resolveDeepTokens(raw, this.tokenCtx)
209
+ const emptied = Object.keys(raw).some((key) => typeof raw[key] === 'string' && raw[key].startsWith('@') && (filter[key] === '' || filter[key] === null || filter[key] === undefined))
210
+ if (emptied || hasUnresolvedDeepTokens(filter)) {
211
+ this.listed = []
212
+ return
213
+ }
214
+ this.loading = true
215
+ try {
216
+ this.listed = await fetchReferenceObjects({ register: this.source.register, schema: this.source.schema, filter, limit: this.source.limit || 200 })
217
+ this.sentHere = []
218
+ } catch {
219
+ this.listed = []
220
+ } finally {
221
+ this.loading = false
222
+ }
223
+ },
224
+
225
+ /**
226
+ * Run `replyAction` with the reply, when there is one; the endpoint
227
+ * path is the thread's own.
228
+ *
229
+ * @param {string} text The reply.
230
+ * @return {Promise<void>}
231
+ * @spec openspec/changes/conversation-source/specs/conversation-source/spec.md#requirement-a-reply-can-run-an-app-action
232
+ */
233
+ async onSend(text) {
234
+ if (!this.replyAction) {
235
+ return
236
+ }
237
+ const resolved = resolveDeepTokens(this.replyAction, this.tokenCtx)
238
+ const action = { ...resolved, args: [{ text, objectId: this.objectId }], props: { ...(resolved.props || {}), text } }
239
+ const result = typeof this.cnDispatchAction === 'function'
240
+ ? this.cnDispatchAction(action)
241
+ : dispatchAction(action, { router: this.$router || null })
242
+ try {
243
+ const outcome = await result
244
+ if (outcome === false) {
245
+ return
246
+ }
247
+ } catch {
248
+ return
249
+ }
250
+ if (this.source) {
251
+ await this.loadSource()
252
+ return
253
+ }
254
+ this.onSent({ text })
255
+ },
256
+
114
257
  /**
115
258
  * Show an accepted reply at once, without waiting for the record.
116
259
  *
@@ -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.