@conduction/nextcloud-vue 2.73.0 → 2.73.1

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 (51) hide show
  1. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +29 -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/CnDetailPage/CnDetailPage.vue2.js +18 -0
  5. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  6. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +9 -6
  7. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  8. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js +16 -1
  9. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js.map +1 -1
  10. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js +5 -4
  11. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js.map +1 -1
  12. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +16 -1
  13. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  14. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +6 -6
  15. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  16. package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue2.js +53 -5
  17. package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue2.js.map +1 -1
  18. package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue3.js +1 -1
  19. package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue3.js.map +1 -1
  20. package/dist/esm/components/CnPageRenderer/CnPageRenderer.vue2.js +133 -0
  21. package/dist/esm/components/CnPageRenderer/CnPageRenderer.vue2.js.map +1 -1
  22. package/dist/esm/components/CnPageRenderer/CnPageRenderer.vue3.js +22 -14
  23. package/dist/esm/components/CnPageRenderer/CnPageRenderer.vue3.js.map +1 -1
  24. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +15 -1
  25. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  26. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js +3 -2
  27. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  28. package/dist/esm/components/CnWidgetGrid/CnWidgetGrid.vue2.js +15 -1
  29. package/dist/esm/components/CnWidgetGrid/CnWidgetGrid.vue2.js.map +1 -1
  30. package/dist/esm/components/CnWidgetGrid/CnWidgetGrid.vue3.js +2 -2
  31. package/dist/esm/components/CnWidgetGrid/CnWidgetGrid.vue3.js.map +1 -1
  32. package/dist/esm/index.js +1 -0
  33. package/dist/esm/index.js.map +1 -1
  34. package/dist/esm/utils/notificationRuleLabel.js +132 -0
  35. package/dist/esm/utils/notificationRuleLabel.js.map +1 -0
  36. package/dist/nextcloud-vue.cjs.js +473 -43
  37. package/dist/nextcloud-vue.cjs.js.map +2 -2
  38. package/package.json +1 -1
  39. package/src/components/CnAppRoot/CnAppRoot.md +1 -0
  40. package/src/components/CnAppRoot/CnAppRoot.vue +29 -0
  41. package/src/components/CnDetailPage/CnDetailPage.vue +24 -3
  42. package/src/components/CnDetailWidgetHost/CnDetailWidgetHost.vue +22 -4
  43. package/src/components/CnIndexPage/CnIndexPage.vue +17 -2
  44. package/src/components/CnNotificationPreferences/CnNotificationPreferences.vue +54 -6
  45. package/src/components/CnPageRenderer/CnPageRenderer.vue +147 -6
  46. package/src/components/CnTabsWidget/CnTabsWidget.vue +17 -2
  47. package/src/components/CnWidgetGrid/CnWidgetGrid.vue +19 -3
  48. package/src/index.js +2 -0
  49. package/src/utils/notificationRuleLabel.js +129 -0
  50. package/webpack/index.d.ts +16 -0
  51. package/webpack/index.js +70 -0
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: package/dist/nextcloud-vue.cjs.js.map
3
- size: 35692438 bytes
4
- sha256: 9054c705b23062b8dca68969cf561ce87cf95a1f2c6dea28e8e5cb78a2ca437c
3
+ size: 35731122 bytes
4
+ sha256: 2f00e1767fdb1c72a109ba42b159ca43fb36fbd6a93a33087f4c8529da77b80b
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/nextcloud-vue",
3
- "version": "2.73.0",
3
+ "version": "2.73.1",
4
4
  "description": "Shared Vue component library for Conduction Nextcloud apps — complements @nextcloud/vue with higher-level components, OpenRegister integration, and NL Design System support",
5
5
  "homepage": "https://github.com/ConductionNL/nextcloud-vue#readme",
6
6
  "bugs": {
@@ -78,6 +78,7 @@ With loading state, custom components, translate, permissions, and custom page t
78
78
  | `cellWidgets` | Object | No | `{}` | Cell-widget registry — map of widget-id → Vue component, rendered for a column that declares `pages[].config.columns[].widget`. The component receives `{ value, row, property, formatted, ...widgetProps }`. The library ships one built-in id, `"badge"` (renders `CnStatusBadge`); consumer entries cover everything else (status pills with custom colour maps, inline toggles, link cells, …). Provided to descendant `CnDataTable` / `CnCellRenderer` as `cnCellWidgets`. See `docs/migrating-to-manifest.md` → "Column widgets". |
79
79
  | `kbSearchProviders` | Object | No | `{}` | Pluggable knowledge-base search providers (#91 Wave 3) — map of provider-key → `{ search(query, opts), externalOpen? }`, merged OVER the library built-in `default` (endpoint) provider and provided to descendant `CnKbSearchWidget` as `cnKbSearchProviders`. A `kb-search` widget selects its provider via `content.provider`; an app talking to a bespoke KB backend (the xwiki proxy) registers its client here — the library ships only the seam. |
80
80
  | `translate` | Function | No | `key => key` | Translate function from the consuming app, typically `(key) => t(appId, key)`. Provided to descendants as `cnTranslate` |
81
+ | `notificationLabels` | Object | No | `{}` | Labels for the app's notification rules in the user-settings notification pane, keyed `<schema>.<key>` or `<key>`; values are translated strings or `{ nl, en }` maps. A rule without a label reads as its key split into words ("Case assigned"), never as the raw key. Provided to descendants as `cnNotificationLabels` |
81
82
  | `permissions` | Array | No | `[]` | Permission strings for the current user; forwarded to `CnAppNav` for menu filtering |
82
83
  | `pageTypes` | Object | No | `null` | Page-type registry map (`type → component`). Falls back to the library's `defaultPageTypes`. Extend with `{ ...defaultPageTypes, report: MyReportPage }` |
83
84
  | `userSettingsTitle` | String | No | `''` | Title shown at the top of the hosted `NcAppSettingsDialog`. Empty (the default) resolves to `translate('User settings')`. Override per app to brand the modal (e.g. `'Decidiq preferences'`). |
@@ -996,6 +996,18 @@ export default {
996
996
  * wiring.
997
997
  */
998
998
  cnAppId: this.appId,
999
+ /**
1000
+ * The app's labels for its notification rules, read by the
1001
+ * notification-preferences pane in the user-settings modal. A
1002
+ * getter so a late prop update is still seen by a pane that
1003
+ * mounts after it.
1004
+ *
1005
+ * @spec openspec/changes/notification-rule-labels-and-runtime-version/specs/notification-preferences/spec.md
1006
+ */
1007
+ get cnNotificationLabels() {
1008
+ return self.notificationLabels || {}
1009
+ },
1010
+
999
1011
  /**
1000
1012
  * Target repo slug for the in-product feature-request deep
1001
1013
  * link (e.g. `ConductionNL/pipelinq`). Read from the
@@ -1411,6 +1423,23 @@ export default {
1411
1423
  default: (key) => key,
1412
1424
  },
1413
1425
 
1426
+ /**
1427
+ * Labels for the app's notification rules, shown in the
1428
+ * user-settings notification pane instead of the rule keys. Keyed
1429
+ * `<schema>.<key>` (or just `<key>`); values are already translated
1430
+ * strings, e.g. `{ 'case.caseAssigned': t('dossiq', 'A case is
1431
+ * assigned to me') }`, or per-locale maps `{ nl, en }`. A rule's own
1432
+ * `label` from OpenRegister wins; a rule with no label anywhere reads
1433
+ * as its key split into words, never as the raw key.
1434
+ *
1435
+ * @type {{[key: string]: (string|object)}}
1436
+ * @spec openspec/changes/notification-rule-labels-and-runtime-version/specs/notification-preferences/spec.md
1437
+ */
1438
+ notificationLabels: {
1439
+ type: Object,
1440
+ default: () => ({}),
1441
+ },
1442
+
1414
1443
  /**
1415
1444
  * List of permission strings the current user holds. Forwarded
1416
1445
  * to CnAppNav's permission filter.
@@ -454,7 +454,8 @@
454
454
  :cnRegistry="cnRegistry"
455
455
  :availableWidgets="widgets"
456
456
  @geoSaved="onGeoSaved"
457
- @openIntegration="onAutoBodyOpenIntegration" />
457
+ @openIntegration="onAutoBodyOpenIntegration"
458
+ @selectObject="onRelatedObjectSelect" />
458
459
  </slot>
459
460
  </div>
460
461
  </div>
@@ -764,7 +765,8 @@
764
765
  :cnRegistry="cnRegistry"
765
766
  :availableWidgets="bodyGridWidgets"
766
767
  @geoSaved="onGeoSaved"
767
- @openIntegration="onAutoBodyOpenIntegration" />
768
+ @openIntegration="onAutoBodyOpenIntegration"
769
+ @selectObject="onRelatedObjectSelect" />
768
770
  </slot>
769
771
  </div>
770
772
  </template>
@@ -975,7 +977,8 @@
975
977
  :cnRegistry="cnRegistry"
976
978
  :availableWidgets="widgets"
977
979
  @geoSaved="onGeoSaved"
978
- @openIntegration="onAutoBodyOpenIntegration" />
980
+ @openIntegration="onAutoBodyOpenIntegration"
981
+ @selectObject="onRelatedObjectSelect" />
979
982
  </slot>
980
983
  </div>
981
984
  </aside>
@@ -2334,6 +2337,7 @@ export default {
2334
2337
  'layout-change',
2335
2338
  'open-integration',
2336
2339
  'refresh',
2340
+ 'related-object-click',
2337
2341
  'related-row-click',
2338
2342
  'relation-linked',
2339
2343
  'request-feature',
@@ -4315,6 +4319,23 @@ export default {
4315
4319
  this.$emit('open-integration', integrationId)
4316
4320
  },
4317
4321
 
4322
+ /**
4323
+ * Re-emit a click on an object in the Related widget. The page does not
4324
+ * know the routes; the host (CnPageRenderer on a manifest page) opens
4325
+ * the object's own detail page.
4326
+ *
4327
+ * @param {object} raw The clicked object; `@self` names its register and schema.
4328
+ */
4329
+ onRelatedObjectSelect(raw) {
4330
+ /**
4331
+ * @event related-object-click An object in the Related widget was
4332
+ * clicked. Payload is the raw object; `@self` carries its register,
4333
+ * schema and id.
4334
+ * @type {object}
4335
+ */
4336
+ this.$emit('related-object-click', raw)
4337
+ },
4338
+
4318
4339
  /**
4319
4340
  * Re-fetch the object after the geo widget persists `@self.geo` (so the
4320
4341
  * rest of the page — metadata, other widgets — reflects the new value),
@@ -114,7 +114,8 @@
114
114
  :includeGroups="content.groups || []"
115
115
  :hideSingleTabTitle="content.hideSingleTabTitle !== false"
116
116
  :showTotalCount="content.showTotalCount !== false"
117
- @openIntegration="onOpenIntegration" />
117
+ @openIntegration="onOpenIntegration"
118
+ @selectObject="onSelectObject" />
118
119
 
119
120
  <!-- `type: 'object-geo'` — view/edit the object's `@self.geo` on a map. -->
120
121
  <CnObjectGeoWidget
@@ -183,7 +184,8 @@
183
184
  <component
184
185
  :is="renderer"
185
186
  ref="renderer"
186
- v-bind="rendererProps" />
187
+ v-bind="rendererProps"
188
+ @selectObject="onSelectObject" />
187
189
  </CnWidgetWrapper>
188
190
 
189
191
  <!-- Registry "card" widgets (stat / gauge / delta) render bare tile
@@ -218,7 +220,8 @@
218
220
  :is="renderer"
219
221
  v-else-if="renderer"
220
222
  ref="renderer"
221
- v-bind="rendererProps" />
223
+ v-bind="rendererProps"
224
+ @selectObject="onSelectObject" />
222
225
 
223
226
  <!-- No renderer resolves: render nothing, which is what CnDetailPage has
224
227
  always done here. Surfacing an "unknown widget" box instead would be
@@ -501,7 +504,7 @@ export default {
501
504
  },
502
505
  },
503
506
 
504
- emits: ['geo-saved', 'open-integration'],
507
+ emits: ['geo-saved', 'open-integration', 'select-object'],
505
508
 
506
509
  setup() {
507
510
  // Every integration component this host renders goes through the
@@ -1006,6 +1009,21 @@ export default {
1006
1009
  this.$emit('open-integration', integrationId)
1007
1010
  },
1008
1011
 
1012
+ /**
1013
+ * Pass a related-object click up; the page decides where it opens. Heard
1014
+ * from the Related widget itself and from a registry widget that nests a
1015
+ * host of its own (a Tabs widget with a Related tab).
1016
+ *
1017
+ * @param {object} raw The clicked object as the related widget holds it.
1018
+ */
1019
+ onSelectObject(raw) {
1020
+ /**
1021
+ * @event select-object A related object was clicked in the related widget. Payload is the raw object; its `@self` names the register and schema.
1022
+ * @type {object}
1023
+ */
1024
+ this.$emit('select-object', raw)
1025
+ },
1026
+
1009
1027
  /**
1010
1028
  * Delegate to the rendered list widget's own create affordance, so the
1011
1029
  * card's Add action and the widget's footer button open the same form.
@@ -731,7 +731,7 @@
731
731
  :currentPageSize="effectivePagination.limit || 20"
732
732
  class="cn-index-page__pagination"
733
733
  @pageChanged="onPageEvent"
734
- @pageSizeChanged="$emit('page-size-changed', $event)" />
734
+ @pageSizeChanged="onPageSizeEvent" />
735
735
  </div>
736
736
 
737
737
  <!-- The open record, beside the list on a wide screen and instead
@@ -1057,7 +1057,7 @@ function namedFilterToQuery(values) {
1057
1057
  * @event {object} row-aux-click — Table row or card middle-clicked. Payload: (row, event) — the row object and the native auxclick event
1058
1058
  * @event {{ key: string, order: string }} sort — Column sort changed
1059
1059
  * @event {number} page-changed — Pagination page changed
1060
- * @event {number} page-size-changed — Pagination page size changed
1060
+ * @event {number} page-size-changed — Pagination page size changed. In self-fetch mode the list refetches page 1 at the new size first.
1061
1061
  * @event {string[]} select — Selection changed. Payload: array of selected IDs
1062
1062
  * @event {object} action — Row action triggered. Payload: { action, row }
1063
1063
  * @event {{ action: string, id: string, selectedIds: Array, count: number }} bulk-action — A declarative bulk action was triggered from the selection strip. The SELECTION travels with it: an action that has to go and find out what was selected is one re-render away from acting on a different set than the user saw highlighted.
@@ -5482,6 +5482,21 @@ export default {
5482
5482
  this.$emit('page-changed', page)
5483
5483
  },
5484
5484
 
5485
+ /**
5486
+ * Page-size pick from CnPagination. In self-fetch mode the list refetches
5487
+ * page 1 at the new size — the event alone left the select changing and
5488
+ * the table not, since a manifest page has no host listening.
5489
+ *
5490
+ * @param {number} size Requested page size.
5491
+ * @return {void}
5492
+ */
5493
+ onPageSizeEvent(size) {
5494
+ if (this.isSelfFetchMode && typeof this.list.onPageSizeChange === 'function') {
5495
+ this.list.onPageSizeChange(size)
5496
+ }
5497
+ this.$emit('page-size-changed', size)
5498
+ },
5499
+
5485
5500
  /**
5486
5501
  * @param {{key: string, values: Array}} payload Facet-filter change from the sidebar.
5487
5502
  * @return {void}
@@ -32,7 +32,7 @@
32
32
  :modelValue="entry.enabled"
33
33
  :disabled="entry.saving"
34
34
  @update:modelValue="onToggle(entry, $event)">
35
- {{ notificationLabel(entry.notification) }}
35
+ {{ notificationLabel(entry) }}
36
36
  </NcCheckboxRadioSwitch>
37
37
  <NcButton v-if="entry.source === 'user-override'"
38
38
  variant="tertiary"
@@ -49,7 +49,7 @@
49
49
 
50
50
  <script>
51
51
  import axios from '@nextcloud/axios'
52
- import { translate as t } from '@nextcloud/l10n'
52
+ import { getLanguage, translate as t } from '@nextcloud/l10n'
53
53
  import { generateUrl } from '@nextcloud/router'
54
54
  /**
55
55
  * CnNotificationPreferences
@@ -82,6 +82,7 @@ import { generateUrl } from '@nextcloud/router'
82
82
  import { NcAppSettingsSection, NcButton, NcCheckboxRadioSwitch, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
83
83
  import BellOffOutline from 'vue-material-design-icons/BellOffOutline.vue'
84
84
  import BellOutline from 'vue-material-design-icons/BellOutline.vue'
85
+ import { notificationRuleLabel } from '../../utils/notificationRuleLabel.js'
85
86
 
86
87
  const PREFS_PATH = '/apps/openregister/api/notification-preferences'
87
88
 
@@ -106,6 +107,28 @@ export default {
106
107
  * than an always-empty pane.
107
108
  */
108
109
  cnAppId: { default: () => '' },
110
+ /**
111
+ * App-supplied labels for its notification rules, provided by the
112
+ * CnAppRoot ancestor from its `notificationLabels` prop. Keyed
113
+ * `<schema>.<key>` or `<key>`.
114
+ */
115
+ cnNotificationLabels: { default: () => ({}) },
116
+ },
117
+
118
+ props: {
119
+ /**
120
+ * Labels for the notification rules, keyed `<schema>.<key>` or
121
+ * `<key>`; values are strings or per-locale maps (`{ nl, en }`).
122
+ * Optional: inside CnAppRoot the app's `notificationLabels` arrive by
123
+ * inject, and a rule without a label still reads as words, never as
124
+ * its raw key. A prop given here wins over the injected map.
125
+ *
126
+ * @spec openspec/changes/notification-rule-labels-and-runtime-version/specs/notification-preferences/spec.md
127
+ */
128
+ labels: {
129
+ type: Object,
130
+ default: null,
131
+ },
109
132
  },
110
133
 
111
134
  data() {
@@ -231,18 +254,43 @@ export default {
231
254
  },
232
255
 
233
256
  /**
234
- * Human-readable label for a notification key, falling back to the key.
257
+ * Human-readable label for a notification rule. Never the raw key: the
258
+ * rule's own label, then the app's, then the library's wording for the
259
+ * generic keys, then the key split into words.
235
260
  *
236
- * @param {string} key The notification annotation key.
261
+ * @param {object} entry The preference row.
237
262
  * @return {string} A display label.
263
+ *
264
+ * @spec openspec/changes/notification-rule-labels-and-runtime-version/specs/notification-preferences/spec.md
238
265
  */
239
- notificationLabel(key) {
266
+ notificationLabel(entry) {
240
267
  const known = {
241
268
  object_created: t('nextcloud-vue', 'When an item is created'),
242
269
  object_updated: t('nextcloud-vue', 'When an item is updated'),
243
270
  object_transitioned: t('nextcloud-vue', 'When an item is assigned or changes status'),
244
271
  }
245
- return known[key] || key
272
+ const language = this.currentLanguage()
273
+ const injected = typeof this.cnNotificationLabels === 'function'
274
+ ? this.cnNotificationLabels()
275
+ : this.cnNotificationLabels
276
+ return notificationRuleLabel(entry, {
277
+ labels: { ...(injected || {}), ...(this.labels || {}) },
278
+ known,
279
+ language,
280
+ })
281
+ },
282
+
283
+ /**
284
+ * The person's language, `en` when it cannot be read.
285
+ *
286
+ * @return {string} The language code.
287
+ */
288
+ currentLanguage() {
289
+ try {
290
+ return (typeof getLanguage === 'function' && getLanguage()) || 'en'
291
+ } catch {
292
+ return 'en'
293
+ }
246
294
  },
247
295
 
248
296
  /**
@@ -120,7 +120,8 @@
120
120
  v-if="widgetsBySlot.has('body')"
121
121
  :widgets="widgetsBySlot.get('body')"
122
122
  :editable="bodyEditable"
123
- slotName="body" />
123
+ slotName="body"
124
+ @selectObject="onRelatedObjectOpen" />
124
125
  <component
125
126
  :is="resolvedComponent"
126
127
  v-else-if="resolvedComponent"
@@ -130,6 +131,7 @@
130
131
  @rowClick="onRowOpen"
131
132
  @rowAuxClick="onRowOpen"
132
133
  @editOpen="onRowOpen"
134
+ @relatedObjectClick="onRelatedObjectOpen"
133
135
  @configure="showConfigModal = true">
134
136
  <!-- This `<template v-for>` defines dynamic SLOTS, not a
135
137
  rendered list, so the rule's advice is inverted here:
@@ -173,27 +175,32 @@
173
175
  <CnWidgetGrid
174
176
  v-if="widgetsBySlot.has('header-actions')"
175
177
  :widgets="widgetsBySlot.get('header-actions')"
176
- slotName="header-actions" />
178
+ slotName="header-actions"
179
+ @selectObject="onRelatedObjectOpen" />
177
180
  <!-- footer slot -->
178
181
  <CnWidgetGrid
179
182
  v-if="widgetsBySlot.has('footer')"
180
183
  :widgets="widgetsBySlot.get('footer')"
181
- slotName="footer" />
184
+ slotName="footer"
185
+ @selectObject="onRelatedObjectOpen" />
182
186
  <!-- modal slot -->
183
187
  <CnWidgetGrid
184
188
  v-if="widgetsBySlot.has('modal')"
185
189
  :widgets="widgetsBySlot.get('modal')"
186
- slotName="modal" />
190
+ slotName="modal"
191
+ @selectObject="onRelatedObjectOpen" />
187
192
  <!-- sidebar slot (gated by cnPageSidebarVisible) -->
188
193
  <CnWidgetGrid
189
194
  v-if="widgetsBySlot.has('sidebar') && pageSidebarVisibleValue"
190
195
  :widgets="widgetsBySlot.get('sidebar')"
191
- slotName="sidebar" />
196
+ slotName="sidebar"
197
+ @selectObject="onRelatedObjectOpen" />
192
198
  <!-- dynamic tab:* and section:* slots -->
193
199
  <template v-for="dynamicSlot in dynamicSlotKeys" :key="dynamicSlot">
194
200
  <CnWidgetGrid
195
201
  :widgets="widgetsBySlot.get(dynamicSlot)"
196
- :slotName="dynamicSlot" />
202
+ :slotName="dynamicSlot"
203
+ @selectObject="onRelatedObjectOpen" />
197
204
  </template>
198
205
  </template>
199
206
 
@@ -206,6 +213,7 @@
206
213
  @rowClick="onRowOpen"
207
214
  @rowAuxClick="onRowOpen"
208
215
  @editOpen="onRowOpen"
216
+ @relatedObjectClick="onRelatedObjectOpen"
209
217
  @configure="showConfigModal = true">
210
218
  <!-- Dynamic slot definition, not a rendered list — see the note on
211
219
  the identical block above. -->
@@ -300,6 +308,53 @@ import { resolveRouteSentinels } from '../../utils/resolveRouteSentinels.js'
300
308
  import { parseSortKeys } from '../../utils/routeFilters.js'
301
309
  import { buildRouteParams, routePathFor } from '../../utils/routeParams.js'
302
310
  import { isNewTabHandled } from '../../utils/rowAuxClick.js'
311
+
312
+ /**
313
+ * OpenRegister registers and schemas already described by id, so a click on
314
+ * a related object costs at most one request per register or schema for as
315
+ * long as this module stays loaded (the SPA's lifetime, across page
316
+ * changes). Keyed by `${kind}:${id}`; a failed lookup is dropped again so the
317
+ * next click retries it.
318
+ *
319
+ * @type {Map<string, Promise<{ id: string, slug: string }>>}
320
+ */
321
+ const entityDescriptorCache = new Map()
322
+
323
+ /**
324
+ * Describe an OpenRegister register or schema (`{ id, slug }`) by its id or
325
+ * slug, or null when it cannot be fetched. An object's `@self` names its
326
+ * register and schema by id while a manifest may name them by slug; this is
327
+ * the bridge. Lazy-imports axios and the router helper like the widgets do.
328
+ *
329
+ * @param {'registers'|'schemas'} kind The collection to ask.
330
+ * @param {string|number} idOrSlug The id (or slug) to describe.
331
+ * @return {Promise<{ id: string, slug: string }|null>} The descriptor.
332
+ */
333
+ function describeOpenRegisterEntity(kind, idOrSlug) {
334
+ const key = `${kind}:${idOrSlug}`
335
+ if (!entityDescriptorCache.has(key)) {
336
+ entityDescriptorCache.set(key, (async () => {
337
+ try {
338
+ const [{ default: axios }, { generateUrl }] = await Promise.all([
339
+ import('@nextcloud/axios'),
340
+ import('@nextcloud/router'),
341
+ ])
342
+ const url = generateUrl('/apps/openregister/api/{kind}/{id}', { kind, id: String(idOrSlug) })
343
+ const res = await axios.get(url)
344
+ const data = res && res.data
345
+ if (!data || typeof data !== 'object') {
346
+ throw new Error(`no descriptor for ${key}`)
347
+ }
348
+ return { id: String(data.id ?? idOrSlug), slug: String(data.slug ?? '') }
349
+ } catch (error) {
350
+ // One failed request must not pin every later click to nowhere.
351
+ entityDescriptorCache.delete(key)
352
+ throw error
353
+ }
354
+ })())
355
+ }
356
+ return entityDescriptorCache.get(key).catch(() => null)
357
+ }
303
358
  import { CnMassExportDialog } from '../CnMassExportDialog/index.js'
304
359
  import { defaultPageTypes } from './pageTypes.js'
305
360
 
@@ -1830,6 +1885,92 @@ export default {
1830
1885
  })
1831
1886
  },
1832
1887
 
1888
+ /**
1889
+ * Open the detail page of an object clicked in a Related widget —
1890
+ * reaching here as `CnDetailPage`'s `related-object-click`, or as
1891
+ * `CnWidgetGrid`'s `select-object` on a v2 widget-grid page. Nothing
1892
+ * listened to either, so the rows looked like links and went nowhere.
1893
+ *
1894
+ * @param {object} raw The clicked object as the Related widget holds it.
1895
+ * @return {Promise<void>}
1896
+ */
1897
+ async onRelatedObjectOpen(raw) {
1898
+ const target = await this.relatedObjectTarget(raw)
1899
+ if (target) {
1900
+ this.$router.push(target).catch(() => {})
1901
+ }
1902
+ },
1903
+
1904
+ /**
1905
+ * Where a related object opens: the first `type: 'detail'` page bound
1906
+ * to the object's register and schema, with the id in whichever param
1907
+ * that page's route declares. The object's `@self` names register and
1908
+ * schema by id and a manifest may name them by slug, so a miss on the
1909
+ * literal pair describes both (one request each, cached) and matches
1910
+ * again. Null, silently, when there is no router or the object carries
1911
+ * no register, schema or id (nothing to open); null with a warning,
1912
+ * like a dead row click, when no detail page matches or the router
1913
+ * lacks the route.
1914
+ *
1915
+ * @param {object} raw The clicked object.
1916
+ * @return {Promise<object|null>} The router location, or null.
1917
+ */
1918
+ async relatedObjectTarget(raw) {
1919
+ const router = this.$router
1920
+ if (!router || !raw || typeof raw !== 'object') {
1921
+ return null
1922
+ }
1923
+ const self = raw['@self'] || {}
1924
+ const id = raw.id ?? self.id ?? self.uuid ?? raw.uuid
1925
+ const register = self.register ?? raw.register
1926
+ const schema = self.schema ?? raw.schema
1927
+ const missing = (v) => v === undefined || v === null || v === ''
1928
+ if (missing(id) || missing(register) || missing(schema)) {
1929
+ return null
1930
+ }
1931
+ let page = this.detailPageFor([String(register)], [String(schema)])
1932
+ if (!page) {
1933
+ const [reg, sch] = await Promise.all([
1934
+ describeOpenRegisterEntity('registers', register),
1935
+ describeOpenRegisterEntity('schemas', schema),
1936
+ ])
1937
+ page = this.detailPageFor(
1938
+ [String(register), reg?.id, reg?.slug].filter(Boolean),
1939
+ [String(schema), sch?.id, sch?.slug].filter(Boolean),
1940
+ )
1941
+ }
1942
+ if (!page) {
1943
+ // eslint-disable-next-line no-console
1944
+ console.warn(`[CnPageRenderer] No detail page is bound to register "${register}" and schema "${schema}", so related object "${id}" opens nowhere.`)
1945
+ return null
1946
+ }
1947
+ if (this.routeNameIsKnown(page.id) === false) {
1948
+ // eslint-disable-next-line no-console
1949
+ console.warn(`[CnPageRenderer] Detail page "${page.id}" is not a route the router has, so related object "${id}" opens nowhere.`)
1950
+ return null
1951
+ }
1952
+ const path = page.route ?? routePathFor(router, page.id)
1953
+ return { name: page.id, params: buildRouteParams(path, id, this.$route?.params) }
1954
+ },
1955
+
1956
+ /**
1957
+ * The first `type: 'detail'` page whose register and schema are both
1958
+ * among the given names (ids or slugs, compared as strings).
1959
+ *
1960
+ * @param {Array<string>} registerNames Names the register goes by.
1961
+ * @param {Array<string>} schemaNames Names the schema goes by.
1962
+ * @return {object|null} The page, or null.
1963
+ */
1964
+ detailPageFor(registerNames, schemaNames) {
1965
+ for (const page of this.detailPageByRegisterSchema.values()) {
1966
+ const cfg = page.config || {}
1967
+ if (registerNames.includes(String(cfg.register)) && schemaNames.includes(String(cfg.schema))) {
1968
+ return page
1969
+ }
1970
+ }
1971
+ return null
1972
+ },
1973
+
1833
1974
  /**
1834
1975
  * Whether the router has a route by this name.
1835
1976
  *
@@ -88,7 +88,8 @@
88
88
  :integrationContext="integrationContext"
89
89
  :cnRegistry="cnRegistry"
90
90
  @geoSaved="onGeoSaved"
91
- @openIntegration="onOpenIntegration" />
91
+ @openIntegration="onOpenIntegration"
92
+ @selectObject="onSelectObject" />
92
93
  <NcEmptyContent v-else :name="missingLabel(entry)" />
93
94
  </CnTab>
94
95
  </CnTabs>
@@ -357,7 +358,7 @@ export default {
357
358
  },
358
359
  },
359
360
 
360
- emits: ['geo-saved', 'open-integration'],
361
+ emits: ['geo-saved', 'open-integration', 'select-object'],
361
362
 
362
363
  data() {
363
364
  return {
@@ -530,6 +531,20 @@ export default {
530
531
  this.$emit('open-integration', integrationId)
531
532
  },
532
533
 
534
+ /**
535
+ * Re-emit a related child's object click; the page opens the object.
536
+ *
537
+ * @param {object} raw The clicked object.
538
+ * @return {void}
539
+ */
540
+ onSelectObject(raw) {
541
+ /**
542
+ * @event select-object Re-emitted from a related child whose object row was clicked. Payload is the raw object.
543
+ * @type {object}
544
+ */
545
+ this.$emit('select-object', raw)
546
+ },
547
+
533
548
  /**
534
549
  * Follow the strip's own selection, so keyboard navigation moves the
535
550
  * hoisted Actions menu too.
@@ -39,7 +39,8 @@
39
39
  <div class="grid-stack-item-content">
40
40
  <component
41
41
  :is="widget.component"
42
- v-bind="widget.props" />
42
+ v-bind="widget.props"
43
+ @selectObject="onSelectObject" />
43
44
  </div>
44
45
  </div>
45
46
  </div>
@@ -58,7 +59,8 @@
58
59
  :style="cnGridCellStyle(widget, gridColumns)">
59
60
  <component
60
61
  :is="widget.component"
61
- v-bind="widget.props" />
62
+ v-bind="widget.props"
63
+ @selectObject="onSelectObject" />
62
64
  </div>
63
65
  </template>
64
66
  </div>
@@ -161,7 +163,7 @@ export default {
161
163
  },
162
164
  },
163
165
 
164
- emits: ['layout-change'],
166
+ emits: ['layout-change', 'select-object'],
165
167
 
166
168
  data() {
167
169
  return {
@@ -371,6 +373,20 @@ export default {
371
373
  },
372
374
 
373
375
  methods: {
376
+ /**
377
+ * Pass a related widget's object click up; the page opens the object.
378
+ *
379
+ * @param {object} raw The clicked object.
380
+ * @return {void}
381
+ */
382
+ onSelectObject(raw) {
383
+ /**
384
+ * @event select-object An object row in a related widget of this grid was clicked. Payload is the raw object.
385
+ * @type {object}
386
+ */
387
+ this.$emit('select-object', raw)
388
+ },
389
+
374
390
  // Expose the shared grid helpers to the template.
375
391
  cnGridCellStyle,
376
392
  hasGridRow,
package/src/index.js CHANGED
@@ -476,6 +476,8 @@ export {
476
476
  } from './utils/folderCustomization.js'
477
477
  export { currentTheme, useCurrentTheme } from './composables/useCurrentTheme.js'
478
478
  export { ROADMAP_LABEL_BLOCKLIST } from './utils/roadmapLabelBlocklist.js'
479
+ // The words a person reads for a notification rule key (never the raw key).
480
+ export { humaniseRuleKey, notificationRuleLabel } from './utils/notificationRuleLabel.js'
479
481
 
480
482
  // Shared field validators. These are UX affordances that run in the browser —
481
483
  // they do NOT replace server-side validation, which stays authoritative at the