@conduction/nextcloud-vue 2.74.0 → 2.76.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 (126) hide show
  1. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +19 -14
  2. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
  3. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js +2 -2
  4. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
  5. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +77 -26
  6. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
  7. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js +22 -14
  8. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
  9. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue2.js +23 -4
  10. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue2.js.map +1 -1
  11. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue3.js.map +1 -1
  12. package/dist/esm/components/CnBrandBar/CnBrandBar.vue.js +9 -0
  13. package/dist/esm/components/CnBrandBar/CnBrandBar.vue.js.map +1 -0
  14. package/dist/esm/components/CnBrandBar/CnBrandBar.vue2.js +107 -0
  15. package/dist/esm/components/CnBrandBar/CnBrandBar.vue2.js.map +1 -0
  16. package/dist/esm/components/CnBrandBar/CnBrandBar.vue3.js +79 -0
  17. package/dist/esm/components/CnBrandBar/CnBrandBar.vue3.js.map +1 -0
  18. package/dist/esm/components/CnCardGrid/CnCardGrid.vue2.js +6 -0
  19. package/dist/esm/components/CnCardGrid/CnCardGrid.vue2.js.map +1 -1
  20. package/dist/esm/components/CnCardGrid/CnCardGrid.vue3.js +3 -2
  21. package/dist/esm/components/CnCardGrid/CnCardGrid.vue3.js.map +1 -1
  22. package/dist/esm/components/CnCredentials/CnCredentials.vue2.js +11 -10
  23. package/dist/esm/components/CnCredentials/CnCredentials.vue2.js.map +1 -1
  24. package/dist/esm/components/CnCredentials/CnCredentials.vue3.js.map +1 -1
  25. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +8 -5
  26. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  27. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  28. package/dist/esm/components/CnEnvironmentBanner/CnEnvironmentBanner.vue2.js +4 -1
  29. package/dist/esm/components/CnEnvironmentBanner/CnEnvironmentBanner.vue2.js.map +1 -1
  30. package/dist/esm/components/CnEnvironmentBanner/CnEnvironmentBanner.vue3.js.map +1 -1
  31. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +7 -0
  32. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  33. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js +14 -7
  34. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  35. package/dist/esm/components/CnFormPage/CnFormPage.vue2.js +32 -0
  36. package/dist/esm/components/CnFormPage/CnFormPage.vue2.js.map +1 -1
  37. package/dist/esm/components/CnFormPage/CnFormPage.vue3.js +6 -3
  38. package/dist/esm/components/CnFormPage/CnFormPage.vue3.js.map +1 -1
  39. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +144 -6
  40. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  41. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +14 -13
  42. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  43. package/dist/esm/components/CnIndexPage/selfModeActions.js +90 -14
  44. package/dist/esm/components/CnIndexPage/selfModeActions.js.map +1 -1
  45. package/dist/esm/components/CnIndexPage/useSelfFetchList.js +15 -3
  46. package/dist/esm/components/CnIndexPage/useSelfFetchList.js.map +1 -1
  47. package/dist/esm/components/CnObjectCard/CnObjectCard.vue2.js +26 -0
  48. package/dist/esm/components/CnObjectCard/CnObjectCard.vue2.js.map +1 -1
  49. package/dist/esm/components/CnObjectCard/CnObjectCard.vue3.js +26 -9
  50. package/dist/esm/components/CnObjectCard/CnObjectCard.vue3.js.map +1 -1
  51. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +19 -3
  52. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
  53. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
  54. package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue2.js +18 -2
  55. package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue2.js.map +1 -1
  56. package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue3.js +2 -2
  57. package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue3.js.map +1 -1
  58. package/dist/esm/components/CnRelatedObjectsWidget/CnRelatedObjectsWidget.vue2.js +153 -38
  59. package/dist/esm/components/CnRelatedObjectsWidget/CnRelatedObjectsWidget.vue2.js.map +1 -1
  60. package/dist/esm/components/CnRelatedObjectsWidget/CnRelatedObjectsWidget.vue3.js +74 -56
  61. package/dist/esm/components/CnRelatedObjectsWidget/CnRelatedObjectsWidget.vue3.js.map +1 -1
  62. package/dist/esm/components/CnStatsBlock/CnStatsBlock.vue2.js +16 -0
  63. package/dist/esm/components/CnStatsBlock/CnStatsBlock.vue2.js.map +1 -1
  64. package/dist/esm/components/CnStatsBlock/CnStatsBlock.vue3.js +2 -2
  65. package/dist/esm/components/CnStatsBlock/CnStatsBlock.vue3.js.map +1 -1
  66. package/dist/esm/composables/useObjectLock.js +41 -10
  67. package/dist/esm/composables/useObjectLock.js.map +1 -1
  68. package/dist/esm/directives/cnSelectAria.js +55 -0
  69. package/dist/esm/directives/cnSelectAria.js.map +1 -0
  70. package/dist/esm/index.js +1 -0
  71. package/dist/esm/index.js.map +1 -1
  72. package/dist/esm/l10n/en.json.js +7 -1
  73. package/dist/esm/l10n/en.json.js.map +1 -1
  74. package/dist/esm/l10n/nl.json.js +8 -2
  75. package/dist/esm/l10n/nl.json.js.map +1 -1
  76. package/dist/esm/nextcloud-vue.css +154 -2
  77. package/dist/esm/store/useObjectStore.js +37 -9
  78. package/dist/esm/store/useObjectStore.js.map +1 -1
  79. package/dist/esm/utils/cnFetch.js +17 -2
  80. package/dist/esm/utils/cnFetch.js.map +1 -1
  81. package/dist/esm/utils/objectSignals.js +44 -0
  82. package/dist/esm/utils/objectSignals.js.map +1 -0
  83. package/dist/esm/utils/resolveBrand.js +38 -0
  84. package/dist/esm/utils/resolveBrand.js.map +1 -0
  85. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  86. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  87. package/dist/nextcloud-vue.cjs.js +9640 -8649
  88. package/dist/nextcloud-vue.cjs.js.map +2 -2
  89. package/dist/nextcloud-vue.css +154 -2
  90. package/l10n/en.json +7 -1
  91. package/l10n/nl.json +8 -2
  92. package/package.json +1 -1
  93. package/src/components/CnAppNav/CnAppNav.vue +21 -16
  94. package/src/components/CnAppRoot/CnAppRoot.vue +53 -0
  95. package/src/components/CnBannerWidget/CnBannerWidget.vue +23 -4
  96. package/src/components/CnBrandBar/CnBrandBar.vue +230 -0
  97. package/src/components/CnCardGrid/CnCardGrid.md +1 -0
  98. package/src/components/CnCardGrid/CnCardGrid.vue +7 -0
  99. package/src/components/CnCredentials/CnCredentials.vue +17 -16
  100. package/src/components/CnDetailPage/CnDetailPage.vue +8 -5
  101. package/src/components/CnEnvironmentBanner/CnEnvironmentBanner.vue +4 -1
  102. package/src/components/CnFormDialog/CnFormDialog.vue +10 -0
  103. package/src/components/CnFormPage/CnFormPage.vue +33 -0
  104. package/src/components/CnIndexPage/CnIndexPage.md +15 -0
  105. package/src/components/CnIndexPage/CnIndexPage.vue +157 -18
  106. package/src/components/CnIndexPage/selfModeActions.js +89 -13
  107. package/src/components/CnIndexPage/useSelfFetchList.js +15 -3
  108. package/src/components/CnObjectCard/CnObjectCard.md +1 -0
  109. package/src/components/CnObjectCard/CnObjectCard.vue +89 -1
  110. package/src/components/CnObjectListWidget/CnObjectListWidget.vue +19 -3
  111. package/src/components/CnQuickFilterBar/CnQuickFilterBar.vue +20 -4
  112. package/src/components/CnRelatedObjectsWidget/CnRelatedObjectsWidget.vue +181 -38
  113. package/src/components/CnStatsBlock/CnStatsBlock.vue +18 -2
  114. package/src/composables/__tests__/useObjectLock.endpoints.spec.js +83 -0
  115. package/src/composables/useObjectLock.js +41 -10
  116. package/src/css/form-field.css +1 -1
  117. package/src/css/look-board-index.css +35 -1
  118. package/src/directives/cnSelectAria.js +54 -0
  119. package/src/index.js +1 -0
  120. package/src/schemas/app-manifest-v2.schema.json +6 -1
  121. package/src/store/useObjectStore.js +37 -9
  122. package/src/utils/cnFetch.js +16 -1
  123. package/src/utils/index.js +1 -0
  124. package/src/utils/objectSignals.js +41 -0
  125. package/src/utils/resolveBrand.js +37 -0
  126. package/src/utils/validateManifestV2.compiled.js +2 -2
@@ -357,7 +357,8 @@ export default {
357
357
  * Host translate function provided by CnAppRoot as
358
358
  * `cnTranslate: this.translate` (bound to the host app's id). The
359
359
  * manifest-authored `content.emptyText` is run through it for this
360
- * component's OWN empty state, and so is `content.prompt`. Defaults to an identity function so
360
+ * component's OWN empty state, and so are `content.prompt` and
361
+ * `content.addLabel`. Defaults to an identity function so
361
362
  * an untranslated key renders as itself.
362
363
  */
363
364
  cnTranslate: { default: () => (key) => key },
@@ -1092,9 +1093,24 @@ export default {
1092
1093
  return (c.formFieldOverrides && typeof c.formFieldOverrides === 'object') ? c.formFieldOverrides : {}
1093
1094
  },
1094
1095
 
1095
- /** Pre-translated Add label (overridable via `content.addLabel`). */
1096
+ /**
1097
+ * The Add label: `content.addLabel` through the app's translate
1098
+ * function, else the library's "Add".
1099
+ *
1100
+ * @spec openspec/changes/r4-object-lock-url-and-credentials-copy/specs/object-list-add-label/spec.md
1101
+ * @return {string}
1102
+ */
1096
1103
  addLabel() {
1097
- return this.content.addLabel || t('nextcloud-vue', 'Add')
1104
+ // `content.addLabel` is manifest copy in the APP's catalogue, so it
1105
+ // goes through the host translate function like `emptyText` and
1106
+ // `prompt`. It used to print raw, so a Dutch user read the English
1107
+ // manifest string. A label an app already translated has no key
1108
+ // of its own in the catalogue and comes back unchanged.
1109
+ if (this.content.addLabel) {
1110
+ const fn = typeof this.cnTranslate === 'function' ? this.cnTranslate : (k) => k
1111
+ return fn(this.content.addLabel)
1112
+ }
1113
+ return t('nextcloud-vue', 'Add')
1098
1114
  },
1099
1115
 
1100
1116
  /** Stable signature of the query so the watcher only refetches on real change. */
@@ -46,7 +46,7 @@
46
46
  :name="entry.tab.icon"
47
47
  :size="16"
48
48
  class="cn-quick-filter-bar__icon" />
49
- <span class="cn-quick-filter-bar__label">{{ entry.tab.label }}</span>
49
+ <span class="cn-quick-filter-bar__label">{{ tl(entry.tab.label) }}</span>
50
50
  <span
51
51
  v-if="countOf(entry) !== null"
52
52
  class="cn-quick-filter-bar__count"
@@ -102,7 +102,7 @@
102
102
  :name="entry.tab.icon"
103
103
  :size="16"
104
104
  class="cn-quick-filter-bar__icon" />
105
- <span class="cn-quick-filter-bar__label">{{ entry.tab.label }}</span>
105
+ <span class="cn-quick-filter-bar__label">{{ tl(entry.tab.label) }}</span>
106
106
  <span
107
107
  v-if="countOf(entry) !== null"
108
108
  class="cn-quick-filter-bar__count"
@@ -152,6 +152,11 @@ export default {
152
152
  inject: {
153
153
  /** The look CnAppRoot provides; `board` draws the tabs as pressed chips. */
154
154
  cnLook: { default: 'nextcloud' },
155
+ /**
156
+ * The host's label lookup (CnAppRoot provides it); identity without one, so a
157
+ * label that is not a key renders as written.
158
+ */
159
+ cnTranslate: { default: () => (key) => key },
155
160
  },
156
161
 
157
162
  // NO `model: { prop, event }` OPTION.
@@ -336,7 +341,7 @@ export default {
336
341
  overflowLabel() {
337
342
  const active = this.activeOverflowEntries
338
343
  if (active.length === 1) {
339
- return active[0].tab.label
344
+ return this.tl(active[0].tab.label)
340
345
  }
341
346
  if (active.length > 1) {
342
347
  return t('nextcloud-vue', '{count} filters', { count: active.length })
@@ -370,7 +375,7 @@ export default {
370
375
  .map((tab, index) => {
371
376
  const count = this.countOf({ tab, index })
372
377
  return {
373
- label: count === null ? tab.label : `${tab.label} (${count})`,
378
+ label: count === null ? this.tl(tab.label) : `${this.tl(tab.label)} (${count})`,
374
379
  icon: tab.icon,
375
380
  index,
376
381
  _empty: this.isEmptyFilter(tab),
@@ -394,6 +399,17 @@ export default {
394
399
  },
395
400
 
396
401
  methods: {
402
+ /**
403
+ * A manifest string through the host's label lookup (`cnTranslate`), so a key
404
+ * such as "Mine" reads in the user's language.
405
+ *
406
+ * @param {string} text The text as written in the manifest.
407
+ * @return {string} The translated text.
408
+ */
409
+ tl(text) {
410
+ return typeof this.cnTranslate === 'function' ? this.cnTranslate(text) : text
411
+ },
412
+
397
413
  t,
398
414
 
399
415
  /**
@@ -91,6 +91,20 @@
91
91
  </ul>
92
92
  </section>
93
93
 
94
+ <p v-if="visibleGroups.length && pendingSections.length"
95
+ class="cn-related-objects-widget__section-status"
96
+ data-testid="cn-related-pending"
97
+ role="status">
98
+ {{ pendingLabel }}
99
+ </p>
100
+ <p v-for="failed in failedSections"
101
+ :key="`failed-${failed.key}`"
102
+ class="cn-related-objects-widget__section-error"
103
+ data-testid="cn-related-section-error"
104
+ :data-section="failed.key">
105
+ {{ t('nextcloud-vue', 'Could not load {section}.', { section: failed.label }) }}
106
+ </p>
107
+
94
108
  <div v-if="loading && !visibleGroups.length" class="cn-related-objects-widget__empty">
95
109
  {{ loadingLabel }}
96
110
  </div>
@@ -710,6 +724,12 @@ export default {
710
724
  groups: [],
711
725
  /** Active tab key. */
712
726
  activeKey: '',
727
+ /** Keys of the tabbed sections whose request is still in flight (`objects`, `files`, `relations`). */
728
+ pendingSections: [],
729
+ /** Tabbed sections whose request failed: `{ key, label }`. */
730
+ failedSections: [],
731
+ /** Bumped per `loadTabs()`; an answer from an older load is dropped. */
732
+ loadToken: 0,
713
733
  /** Key of the inline-expanded row (`{group}-{id}`) for leaves with no owning-app page (e.g. notes). */
714
734
  expandedKey: '',
715
735
  /** Whether a footer file upload is in flight. */
@@ -834,6 +854,22 @@ export default {
834
854
  )
835
855
  },
836
856
 
857
+ /**
858
+ * The line under the panel naming the sections still loading, once
859
+ * another section is already on screen.
860
+ *
861
+ * @return {string}
862
+ */
863
+ pendingLabel() {
864
+ const labels = {
865
+ objects: this.objectsLabel,
866
+ files: this.filesLabel,
867
+ relations: t('nextcloud-vue', 'Linked items'),
868
+ }
869
+ const names = this.pendingSections.map((key) => labels[key] || key).join(', ')
870
+ return t('nextcloud-vue', 'Still loading: {sections}', { sections: names })
871
+ },
872
+
837
873
  /** Placeholder shown in the body while the first fetch is in flight. */
838
874
  loadingLabel() {
839
875
  return t('nextcloud-vue', 'Loading …')
@@ -1330,16 +1366,27 @@ export default {
1330
1366
  * @return {Promise<object|null>}
1331
1367
  */
1332
1368
  async fetchSubResource(suffix) {
1369
+ return (await this.fetchSection(suffix)).data
1370
+ },
1371
+
1372
+ /**
1373
+ * GET an OpenRegister sub-resource and say whether it answered, so a
1374
+ * section can tell "nothing linked" from "could not load".
1375
+ *
1376
+ * @param {string} suffix - The sub-resource suffix.
1377
+ * @return {Promise<{ ok: boolean, data: object|null }>}
1378
+ */
1379
+ async fetchSection(suffix) {
1333
1380
  try {
1334
1381
  // `no-store`: relations change as the user links content; a stale
1335
1382
  // cached empty response would wrongly show the empty state on load.
1336
1383
  const response = await fetch(this.relatedUrl(suffix), { method: 'GET', headers: buildHeaders(), cache: 'no-store' })
1337
1384
  if (!response.ok) {
1338
- return null
1385
+ return { ok: false, data: null }
1339
1386
  }
1340
- return await response.json()
1387
+ return { ok: true, data: await response.json() }
1341
1388
  } catch {
1342
- return null
1389
+ return { ok: false, data: null }
1343
1390
  }
1344
1391
  },
1345
1392
 
@@ -1543,56 +1590,138 @@ export default {
1543
1590
  },
1544
1591
 
1545
1592
  /**
1546
- * Fetch every group directly from OpenRegister and build the tabs.
1593
+ * Fetch every group directly from OpenRegister and build the tabs,
1594
+ * one section at a time.
1595
+ *
1596
+ * 🔴 EACH SECTION SHOWS WHEN ITS OWN REQUEST RETURNS. This used to wait
1597
+ * for relations, uses, used, contracts and files together behind one
1598
+ * `Promise.all`, so the relations call (0.45 s on pipelinq's lead page)
1599
+ * sat behind uses/used (about 9 s) and the card said "Loading …" the
1600
+ * whole time. Now the groups are laid out up front in their fixed order
1601
+ * and filled in place as each answer arrives, so a slow section never
1602
+ * reorders or hides a fast one. A section still in flight is named in
1603
+ * `pendingSections`; one whose request failed in `failedSections`.
1547
1604
  *
1605
+ * @spec openspec/changes/r4-object-lock-url-and-credentials-copy/specs/related-objects-progressive/spec.md
1548
1606
  * @return {Promise<void>}
1549
1607
  */
1550
1608
  async loadTabs() {
1609
+ const token = ++this.loadToken
1610
+ const current = () => token === this.loadToken
1551
1611
  this.loading = true
1552
- try {
1553
- const [relations, uses, used, contracts, files] = await Promise.all([
1554
- this.fetchSubResource('relations'),
1555
- this.showObjects ? this.fetchSubResource('uses') : null,
1556
- this.showObjects ? this.fetchSubResource('used') : null,
1557
- (this.showObjects && this.showContracts) ? this.fetchSubResource('contracts') : null,
1558
- this.showFiles ? this.fetchSubResource('files') : null,
1559
- ])
1612
+ this.failedSections = []
1613
+
1614
+ const objectSuffixes = this.showObjects
1615
+ ? ['uses', 'used', ...(this.showContracts ? ['contracts'] : [])]
1616
+ : []
1617
+ const sections = [
1618
+ ...(this.showObjects ? [{ key: 'objects', label: this.objectsLabel }] : []),
1619
+ ...(this.showFiles ? [{ key: 'files', label: this.filesLabel }] : []),
1620
+ { key: 'relations', label: t('nextcloud-vue', 'Linked items') },
1621
+ ]
1622
+ this.pendingSections = sections.map((section) => section.key)
1560
1623
 
1561
- const groups = []
1624
+ // Lay out every group now, in its fixed order, empty. visibleGroups
1625
+ // hides an empty group, so nothing shows until a section fills it.
1626
+ const groups = []
1627
+ if (this.showObjects) {
1628
+ groups.push({ key: 'objects', label: this.objectsLabel, icon: 'FileTreeOutline', integrationId: '', items: [], total: 0 })
1629
+ }
1630
+ if (this.showFiles) {
1631
+ groups.push({ key: 'files', label: this.filesLabel, icon: 'Paperclip', integrationId: 'files', items: [], total: 0 })
1632
+ }
1633
+ for (const def of LEAF_GROUPS) {
1634
+ groups.push({
1635
+ key: def.key,
1636
+ label: this.leafLabel(def.key),
1637
+ // Prefer the registered integration's icon so it matches the
1638
+ // sidebar/linked-apps; fall back to the known-good default.
1639
+ icon: this.integrationIcon(def.integrationId) || def.icon,
1640
+ integrationId: def.integrationId,
1641
+ requiredApp: def.requiredApp,
1642
+ items: [],
1643
+ total: 0,
1644
+ })
1645
+ }
1646
+ this.groups = groups
1562
1647
 
1563
- if (this.showObjects) {
1564
- await this.resolveSchemaTitles([uses, used, contracts])
1565
- const objectItems = this.mergeObjectResults([uses, used, contracts])
1566
- groups.push({ key: 'objects', label: this.objectsLabel, icon: 'FileTreeOutline', integrationId: '', items: objectItems, total: objectItems.length })
1648
+ const setGroup = (key, items, total) => {
1649
+ const index = this.groups.findIndex((group) => group.key === key)
1650
+ if (index === -1) {
1651
+ return
1567
1652
  }
1568
-
1569
- if (this.showFiles) {
1570
- const fileResults = (files && files.results) || []
1571
- groups.push({ key: 'files', label: this.filesLabel, icon: 'Paperclip', integrationId: 'files', items: fileResults.map((f) => this.toFileRow(f)), total: files ? (files.total ?? fileResults.length) : 0 })
1653
+ const next = this.groups.slice()
1654
+ next[index] = { ...next[index], items, total }
1655
+ this.groups = next
1656
+ }
1657
+ const settle = (key, ok) => {
1658
+ this.pendingSections = this.pendingSections.filter((k) => k !== key)
1659
+ if (!ok) {
1660
+ const section = sections.find((s) => s.key === key)
1661
+ this.failedSections = [...this.failedSections, { key, label: section ? section.label : key }]
1572
1662
  }
1663
+ // Keep the tab the reader saw first: pin it, so a slower section
1664
+ // that sorts before it does not take the panel away.
1665
+ if (!this.activeKey && this.visibleGroups.length) {
1666
+ this.activeKey = this.visibleGroups[0].key
1667
+ }
1668
+ }
1573
1669
 
1670
+ const loadRelations = this.fetchSection('relations').then(({ ok, data }) => {
1671
+ if (!current()) {
1672
+ return
1673
+ }
1574
1674
  for (const def of LEAF_GROUPS) {
1575
- const block = relations && relations[def.responseKey]
1675
+ const block = data && data[def.responseKey]
1576
1676
  const results = (block && block.results) || []
1577
- groups.push({
1578
- key: def.key,
1579
- label: this.leafLabel(def.key),
1580
- // Prefer the registered integration's icon so it matches the
1581
- // sidebar/linked-apps; fall back to the known-good default.
1582
- icon: this.integrationIcon(def.integrationId) || def.icon,
1583
- integrationId: def.integrationId,
1584
- requiredApp: def.requiredApp,
1585
- items: results.map((r, i) => this.toLeafRow(r, i)),
1586
- total: block ? (block.total ?? results.length) : 0,
1587
- })
1677
+ setGroup(def.key, results.map((r, i) => this.toLeafRow(r, i)), block ? (block.total ?? results.length) : 0)
1588
1678
  }
1679
+ settle('relations', ok)
1680
+ })
1589
1681
 
1590
- this.groups = groups
1591
- if (!this.activeGroup && this.visibleGroups.length) {
1592
- this.activeKey = this.visibleGroups[0].key
1593
- }
1682
+ const loadFiles = this.showFiles
1683
+ ? this.fetchSection('files').then(({ ok, data }) => {
1684
+ if (!current()) {
1685
+ return
1686
+ }
1687
+ const fileResults = (data && data.results) || []
1688
+ setGroup('files', fileResults.map((f) => this.toFileRow(f)), data ? (data.total ?? fileResults.length) : 0)
1689
+ settle('files', ok)
1690
+ })
1691
+ : null
1692
+
1693
+ // uses, used and contracts merge into one Objects group: each answer
1694
+ // re-merges what has arrived so far, and the group stays pending
1695
+ // until the last of them is in.
1696
+ const envelopes = objectSuffixes.map(() => null)
1697
+ let objectsOk = true
1698
+ const loadObjects = objectSuffixes.length
1699
+ ? Promise.all(objectSuffixes.map((suffix, i) => this.fetchSection(suffix).then(async ({ ok, data }) => {
1700
+ if (!current()) {
1701
+ return
1702
+ }
1703
+ objectsOk = objectsOk && ok
1704
+ envelopes[i] = data
1705
+ await this.resolveSchemaTitles([data])
1706
+ if (!current()) {
1707
+ return
1708
+ }
1709
+ const objectItems = this.mergeObjectResults(envelopes)
1710
+ setGroup('objects', objectItems, objectItems.length)
1711
+ }))).then(() => {
1712
+ if (current()) {
1713
+ settle('objects', objectsOk)
1714
+ }
1715
+ })
1716
+ : null
1717
+
1718
+ try {
1719
+ await Promise.all([loadRelations, loadFiles, loadObjects])
1594
1720
  } finally {
1595
- this.loading = false
1721
+ if (current()) {
1722
+ this.loading = false
1723
+ this.pendingSections = []
1724
+ }
1596
1725
  }
1597
1726
  },
1598
1727
 
@@ -1896,6 +2025,20 @@ export default {
1896
2025
  font-style: italic;
1897
2026
  }
1898
2027
 
2028
+ .cn-related-objects-widget__section-status {
2029
+ margin: 0;
2030
+ padding: var(--default-grid-baseline, 4px) calc(2 * var(--default-grid-baseline, 4px));
2031
+ color: var(--color-text-maxcontrast);
2032
+ font-size: 0.85em;
2033
+ }
2034
+
2035
+ .cn-related-objects-widget__section-error {
2036
+ margin: 0;
2037
+ padding: var(--default-grid-baseline, 4px) calc(2 * var(--default-grid-baseline, 4px));
2038
+ color: var(--color-error-text, var(--color-error));
2039
+ font-size: 0.85em;
2040
+ }
2041
+
1899
2042
  .cn-related-objects-widget__empty-state {
1900
2043
  /* NcEmptyContent defaults to filling tall parents; inside a widget cell a
1901
2044
  compact block reads better. */
@@ -39,7 +39,7 @@
39
39
  </slot>
40
40
  </span>
41
41
  <h4 class="cn-kpi-card__title" :title="title || undefined">
42
- {{ title || t('nextcloud-vue', 'Objects') }}
42
+ {{ title ? tl(title) : t('nextcloud-vue', 'Objects') }}
43
43
  </h4>
44
44
  </div>
45
45
 
@@ -54,7 +54,7 @@
54
54
  <!-- @binding {string} formatted The default localized count string. -->
55
55
  <slot name="value" :count="count" :formatted="formattedCount">{{ formattedCount }}</slot>
56
56
  </span>
57
- <span class="cn-kpi-card__label cn-stats-block__count-label">{{ countLabel }}</span>
57
+ <span class="cn-kpi-card__label cn-stats-block__count-label">{{ countLabel ? tl(countLabel) : countLabel }}</span>
58
58
  </div>
59
59
  <div v-else-if="loading" class="cn-stats-block__loading">
60
60
  <NcLoadingIcon :size="16" />
@@ -164,6 +164,11 @@ export default {
164
164
  * @spec openspec/changes/screens-dashboard-parity/specs/dashboard-page/spec.md#requirement-the-board-kpi-tile
165
165
  */
166
166
  cnLook: { default: 'nextcloud' },
167
+ /**
168
+ * The host's label lookup (CnAppRoot provides it); identity without one, so a
169
+ * label that is not a key renders as written.
170
+ */
171
+ cnTranslate: { default: () => (key) => key },
167
172
  },
168
173
 
169
174
  props: {
@@ -453,6 +458,17 @@ export default {
453
458
  },
454
459
 
455
460
  methods: {
461
+ /**
462
+ * A manifest string through the host's label lookup (`cnTranslate`), so a key
463
+ * such as "Mine" reads in the user's language.
464
+ *
465
+ * @param {string} text The text as written in the manifest.
466
+ * @return {string} The translated text.
467
+ */
468
+ tl(text) {
469
+ return typeof this.cnTranslate === 'function' ? this.cnTranslate(text) : text
470
+ },
471
+
456
472
  formatBreakdownLabel(key) {
457
473
  return key.charAt(0).toUpperCase() + key.slice(1) + ':'
458
474
  },
@@ -174,3 +174,86 @@ describe('useObjectLock reaches the routes OpenRegister actually declares', () =
174
174
  })
175
175
  })
176
176
  })
177
+
178
+ /**
179
+ * Run the composable with getter inputs, the way CnDetailPage calls it.
180
+ *
181
+ * @param {object} store The object store double.
182
+ * @param {object} targets Getters for register, schema (cache key), id and slug.
183
+ * @param {Function} body What to do with the lock state.
184
+ * @return {Promise<*>} Whatever `body` returned.
185
+ */
186
+ async function withGetterLock(store, targets, body) {
187
+ const scope = effectScope()
188
+ let lock
189
+ scope.run(() => {
190
+ lock = useObjectLock(store, targets.register, targets.schema, targets.id, {
191
+ autoRenew: false,
192
+ schemaSlug: targets.slug,
193
+ })
194
+ })
195
+ try {
196
+ return await body(lock)
197
+ } finally {
198
+ scope.stop()
199
+ }
200
+ }
201
+
202
+ describe('useObjectLock reads getter inputs as values', () => {
203
+ beforeEach(() => {
204
+ mockAxios.post.mockReset().mockResolvedValue({ data: {} })
205
+ })
206
+
207
+ it('🔴 puts the getter VALUES in the lock URL, not their source', async () => {
208
+ await withGetterLock(storeWithLock(null), {
209
+ register: () => 'dossiq',
210
+ schema: () => 'dossiq-case',
211
+ id: () => 'case-1',
212
+ slug: () => 'case',
213
+ }, async (lock) => {
214
+ await lock.acquire()
215
+ })
216
+
217
+ const [url] = mockAxios.post.mock.calls[0]
218
+ expect(url).toBe('/apps/openregister/api/objects/dossiq/case/case-1/lock')
219
+ expect(url).not.toMatch(/=>/)
220
+ })
221
+
222
+ it('🔴 reads the cached lock under the getter value of the cache key', async () => {
223
+ await withGetterLock(storeWithLock('bram'), {
224
+ register: () => 'dossiq',
225
+ schema: () => 'dossiq-case',
226
+ id: () => 'case-1',
227
+ slug: () => 'case',
228
+ }, async (lock) => {
229
+ expect(lock.locked.value).toBe(true)
230
+ expect(lock.lockedBy.value).toBe('bram')
231
+ })
232
+ })
233
+
234
+ it('🔴 releases on the getter values too', async () => {
235
+ await withGetterLock(storeWithLock('anna'), {
236
+ register: () => 'dossiq',
237
+ schema: () => 'dossiq-case',
238
+ id: () => 'case-1',
239
+ slug: () => 'case',
240
+ }, async (lock) => {
241
+ await lock.release()
242
+ })
243
+
244
+ expect(mockAxios.post).toHaveBeenCalledWith('/apps/openregister/api/objects/dossiq/case/case-1/unlock')
245
+ })
246
+
247
+ it.each([
248
+ ['register', { register: () => '', schema: () => 'dossiq-case', id: () => 'case-1', slug: () => 'case' }],
249
+ ['schema slug', { register: () => 'dossiq', schema: () => 'dossiq-case', id: () => 'case-1', slug: () => undefined }],
250
+ ['object id', { register: () => 'dossiq', schema: () => 'dossiq-case', id: () => null, slug: () => 'case' }],
251
+ ])('sends nothing while the %s is unknown', async (_label, targets) => {
252
+ await withGetterLock(storeWithLock('anna'), targets, async (lock) => {
253
+ await lock.acquire()
254
+ await lock.release()
255
+ })
256
+
257
+ expect(mockAxios.post).not.toHaveBeenCalled()
258
+ })
259
+ })
@@ -5,7 +5,7 @@ import { getCurrentUser } from '@nextcloud/auth'
5
5
  import axios from '@nextcloud/axios'
6
6
  import { generateUrl } from '@nextcloud/router'
7
7
  import { tryOnScopeDispose } from '@vueuse/core'
8
- import { computed, onBeforeUnmount, onMounted, unref } from 'vue'
8
+ import { computed, onBeforeUnmount, onMounted, toValue } from 'vue'
9
9
 
10
10
  /**
11
11
  * Thrown when an `acquire()` POST returns 409 (Conflict) or 423
@@ -46,14 +46,14 @@ export class PermissionError extends Error {
46
46
  * other path that keeps `objectStore.objects[type][id]` fresh).
47
47
  *
48
48
  * @param {object} objectStore Pinia store instance (`useObjectStore()` result).
49
- * @param {string|import('vue').Ref<string>} register OR register slug.
50
- * @param {string|import('vue').Ref<string>} schema OR schema slug (also the GraphQL field name).
51
- * @param {string|import('vue').Ref<string>} id Object UUID.
49
+ * @param {string|import('vue').Ref<string>|function(): string} register OR register slug.
50
+ * @param {string|import('vue').Ref<string>|function(): string} schema OR schema slug (also the GraphQL field name).
51
+ * @param {string|import('vue').Ref<string>|function(): string} id Object UUID.
52
52
  * @param {object} [options] Optional config.
53
53
  * @param {boolean} [options.autoRenew] Renew the lock on a fixed interval while editing + visible.
54
54
  * @param {number} [options.renewIntervalMs] Renew every N ms (default 10 min).
55
55
  * @param {number} [options.lockDurationSec] Server-side TTL requested on acquire (default 30 min).
56
- * @param {string|import('vue').Ref<string>} [options.schemaSlug] The schema slug for the LOCK URL, when `schema` is the object-cache key rather than the slug (CnDetailPage passes `<register>-<schema>` as the key). Defaults to `schema`.
56
+ * @param {string|import('vue').Ref<string>|function(): string} [options.schemaSlug] The schema slug for the LOCK URL, when `schema` is the object-cache key rather than the slug (CnDetailPage passes `<register>-<schema>` as the key). Defaults to `schema`.
57
57
  * @return {{
58
58
  * locked: import('vue').ComputedRef<boolean>,
59
59
  * lockedByMe: import('vue').ComputedRef<boolean>,
@@ -62,6 +62,7 @@ export class PermissionError extends Error {
62
62
  * acquire: () => Promise<void>,
63
63
  * release: () => Promise<void>
64
64
  * }} Reactive lock state + actions.
65
+ * @spec openspec/changes/r4-object-lock-url-and-credentials-copy/specs/object-lock/spec.md
65
66
  */
66
67
  export function useObjectLock(objectStore, register, schema, id, options = {}) {
67
68
  const autoRenew = options.autoRenew !== false
@@ -74,14 +75,23 @@ export function useObjectLock(objectStore, register, schema, id, options = {}) {
74
75
  const isVisible = () => typeof document === 'undefined' || document.visibilityState !== 'hidden'
75
76
  let renewTimer = null
76
77
 
78
+ /*
79
+ * 🔴 `toValue`, NOT `unref`. CnDetailPage passes GETTER FUNCTIONS
80
+ * (`() => props.objectId`) so the lock follows the route. `unref` returns a
81
+ * function untouched, so the getter's SOURCE was interpolated into the URL:
82
+ * every detail page sent `/api/objects/()=>pn/()=>Ct.schema||Bn/()=>Ct.objectId/lock`
83
+ * and got a 404, and the cache lookup used the function as its key, so the
84
+ * banner never saw a lock either. `toValue` reads a plain value, a ref and a
85
+ * getter alike.
86
+ */
77
87
  function readType() {
78
- return unref(schema)
88
+ return toValue(schema)
79
89
  }
80
90
  function readId() {
81
- return unref(id)
91
+ return toValue(id)
82
92
  }
83
93
  function readRegister() {
84
- return unref(register)
94
+ return toValue(register)
85
95
  }
86
96
 
87
97
  function readSelfLock() {
@@ -127,7 +137,22 @@ export function useObjectLock(objectStore, register, schema, id, options = {}) {
127
137
  })
128
138
 
129
139
  function readSchemaSlug() {
130
- return unref(options.schemaSlug ?? schema)
140
+ return toValue(options.schemaSlug ?? schema)
141
+ }
142
+
143
+ /**
144
+ * Whether register, schema slug and object id are all known.
145
+ *
146
+ * A detail page mounts before its route params or object have resolved.
147
+ * Without this a lock call would go out with `undefined` in the path, a
148
+ * request OpenRegister can only answer with a 404.
149
+ *
150
+ * @spec openspec/changes/r4-object-lock-url-and-credentials-copy/specs/object-lock/spec.md
151
+ * @return {boolean} True when a lock URL can be built.
152
+ */
153
+ function hasTarget() {
154
+ return [readRegister(), readSchemaSlug(), readId()]
155
+ .every((part) => typeof part === 'string' ? part !== '' : typeof part === 'number')
131
156
  }
132
157
 
133
158
  /**
@@ -176,6 +201,9 @@ export function useObjectLock(objectStore, register, schema, id, options = {}) {
176
201
  }
177
202
 
178
203
  async function acquire() {
204
+ if (!hasTarget()) {
205
+ return
206
+ }
179
207
  try {
180
208
  await axios.post(endpoint(), { duration: lockDurationSec })
181
209
  } catch (e) {
@@ -208,6 +236,9 @@ export function useObjectLock(objectStore, register, schema, id, options = {}) {
208
236
 
209
237
  async function release() {
210
238
  stopRenewTimer()
239
+ if (!hasTarget()) {
240
+ return
241
+ }
211
242
  try {
212
243
  await axios.post(unlockEndpoint())
213
244
  } catch (e) {
@@ -251,7 +282,7 @@ export function useObjectLock(objectStore, register, schema, id, options = {}) {
251
282
  }
252
283
 
253
284
  function beaconRelease() {
254
- if (!lockedByMe.value) {
285
+ if (!lockedByMe.value || !hasTarget()) {
255
286
  return
256
287
  }
257
288
  try {
@@ -88,7 +88,7 @@
88
88
  }
89
89
 
90
90
  /* An invalid field: 2px error border on the control, 4px edge on the group. */
91
- .cn-look-board .cn-form-field--invalid input,
91
+ .cn-look-board .cn-form-field--invalid input:not([type='checkbox']):not([type='radio']),
92
92
  .cn-look-board .cn-form-field--invalid textarea,
93
93
  .cn-look-board .cn-form-field--invalid .select {
94
94
  border: var(--cn-field-error-border) solid var(--color-error);