@conduction/nextcloud-vue 2.75.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 (67) hide show
  1. package/dist/esm/components/CnCardGrid/CnCardGrid.vue2.js +6 -0
  2. package/dist/esm/components/CnCardGrid/CnCardGrid.vue2.js.map +1 -1
  3. package/dist/esm/components/CnCardGrid/CnCardGrid.vue3.js +3 -2
  4. package/dist/esm/components/CnCardGrid/CnCardGrid.vue3.js.map +1 -1
  5. package/dist/esm/components/CnCredentials/CnCredentials.vue2.js +11 -10
  6. package/dist/esm/components/CnCredentials/CnCredentials.vue2.js.map +1 -1
  7. package/dist/esm/components/CnCredentials/CnCredentials.vue3.js.map +1 -1
  8. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +8 -5
  9. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  10. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  11. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +144 -6
  12. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  13. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +12 -11
  14. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  15. package/dist/esm/components/CnIndexPage/selfModeActions.js +90 -14
  16. package/dist/esm/components/CnIndexPage/selfModeActions.js.map +1 -1
  17. package/dist/esm/components/CnIndexPage/useSelfFetchList.js +15 -3
  18. package/dist/esm/components/CnIndexPage/useSelfFetchList.js.map +1 -1
  19. package/dist/esm/components/CnObjectCard/CnObjectCard.vue2.js +26 -0
  20. package/dist/esm/components/CnObjectCard/CnObjectCard.vue2.js.map +1 -1
  21. package/dist/esm/components/CnObjectCard/CnObjectCard.vue3.js +26 -9
  22. package/dist/esm/components/CnObjectCard/CnObjectCard.vue3.js.map +1 -1
  23. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +19 -3
  24. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
  25. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
  26. package/dist/esm/components/CnRelatedObjectsWidget/CnRelatedObjectsWidget.vue2.js +153 -38
  27. package/dist/esm/components/CnRelatedObjectsWidget/CnRelatedObjectsWidget.vue2.js.map +1 -1
  28. package/dist/esm/components/CnRelatedObjectsWidget/CnRelatedObjectsWidget.vue3.js +74 -56
  29. package/dist/esm/components/CnRelatedObjectsWidget/CnRelatedObjectsWidget.vue3.js.map +1 -1
  30. package/dist/esm/composables/useObjectLock.js +41 -10
  31. package/dist/esm/composables/useObjectLock.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/l10n/en.json.js +7 -1
  35. package/dist/esm/l10n/en.json.js.map +1 -1
  36. package/dist/esm/l10n/nl.json.js +8 -2
  37. package/dist/esm/l10n/nl.json.js.map +1 -1
  38. package/dist/esm/nextcloud-vue.css +88 -1
  39. package/dist/esm/store/useObjectStore.js +37 -9
  40. package/dist/esm/store/useObjectStore.js.map +1 -1
  41. package/dist/esm/utils/objectSignals.js +44 -0
  42. package/dist/esm/utils/objectSignals.js.map +1 -0
  43. package/dist/nextcloud-vue.cjs.js +871 -328
  44. package/dist/nextcloud-vue.cjs.js.map +2 -2
  45. package/dist/nextcloud-vue.css +88 -1
  46. package/l10n/en.json +7 -1
  47. package/l10n/nl.json +8 -2
  48. package/package.json +1 -1
  49. package/src/components/CnCardGrid/CnCardGrid.md +1 -0
  50. package/src/components/CnCardGrid/CnCardGrid.vue +7 -0
  51. package/src/components/CnCredentials/CnCredentials.vue +17 -16
  52. package/src/components/CnDetailPage/CnDetailPage.vue +8 -5
  53. package/src/components/CnIndexPage/CnIndexPage.md +15 -0
  54. package/src/components/CnIndexPage/CnIndexPage.vue +155 -16
  55. package/src/components/CnIndexPage/selfModeActions.js +89 -13
  56. package/src/components/CnIndexPage/useSelfFetchList.js +15 -3
  57. package/src/components/CnObjectCard/CnObjectCard.md +1 -0
  58. package/src/components/CnObjectCard/CnObjectCard.vue +89 -1
  59. package/src/components/CnObjectListWidget/CnObjectListWidget.vue +19 -3
  60. package/src/components/CnRelatedObjectsWidget/CnRelatedObjectsWidget.vue +181 -38
  61. package/src/composables/__tests__/useObjectLock.endpoints.spec.js +83 -0
  62. package/src/composables/useObjectLock.js +41 -10
  63. package/src/css/look-board-index.css +35 -1
  64. package/src/index.js +1 -0
  65. package/src/store/useObjectStore.js +37 -9
  66. package/src/utils/index.js +1 -0
  67. package/src/utils/objectSignals.js +41 -0
@@ -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. */
@@ -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. */
@@ -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 {
@@ -343,7 +343,10 @@
343
343
  }
344
344
 
345
345
  .cn-look-board .cn-index-page__main.cn-index-page__main--table {
346
- flex: 0 1 auto;
346
+ /* The body is a row: the card takes the width left beside a sidebar (the
347
+ board's table card spans the content), and keeps the height of its rows. */
348
+ flex: 1 1 auto;
349
+ align-self: flex-start;
347
350
  border: 1px solid var(--color-border);
348
351
  border-radius: var(--cn-board-card-radius, 12px);
349
352
  background: var(--color-main-background);
@@ -533,6 +536,37 @@
533
536
  background: var(--color-primary-element-light);
534
537
  }
535
538
 
539
+ /* The status accent outranks the board's border, also on hover and selection. */
540
+ .cn-look-board .cn-object-card.cn-object-card--board.cn-object-card--accent {
541
+ border-inline-start-width: 4px;
542
+ padding-inline-start: 13px;
543
+ }
544
+
545
+ .cn-look-board .cn-object-card.cn-object-card--board.cn-object-card--accent-success,
546
+ .cn-look-board .cn-object-card.cn-object-card--board.cn-object-card--accent-success:hover {
547
+ border-inline-start-color: var(--color-element-success);
548
+ }
549
+
550
+ .cn-look-board .cn-object-card.cn-object-card--board.cn-object-card--accent-warning,
551
+ .cn-look-board .cn-object-card.cn-object-card--board.cn-object-card--accent-warning:hover {
552
+ border-inline-start-color: var(--color-element-warning);
553
+ }
554
+
555
+ .cn-look-board .cn-object-card.cn-object-card--board.cn-object-card--accent-error,
556
+ .cn-look-board .cn-object-card.cn-object-card--board.cn-object-card--accent-error:hover {
557
+ border-inline-start-color: var(--color-element-error);
558
+ }
559
+
560
+ .cn-look-board .cn-object-card.cn-object-card--board.cn-object-card--accent-info,
561
+ .cn-look-board .cn-object-card.cn-object-card--board.cn-object-card--accent-info:hover {
562
+ border-inline-start-color: var(--color-element-info);
563
+ }
564
+
565
+ .cn-look-board .cn-object-card.cn-object-card--board.cn-object-card--accent-primary,
566
+ .cn-look-board .cn-object-card.cn-object-card--board.cn-object-card--accent-primary:hover {
567
+ border-inline-start-color: var(--color-primary-element);
568
+ }
569
+
536
570
  .cn-look-board .cn-object-card--board .cn-object-card__content {
537
571
  display: flex;
538
572
  flex: 1 1 auto;
package/src/index.js CHANGED
@@ -542,6 +542,7 @@ export { cnFetch, cnFetchJson, CnHttpError, configureCnFetch } from './utils/cnF
542
542
  // CnLockIndicator, CnObjectCard and CnDataTable, and available to apps that
543
543
  // need the same answer without rendering a padlock.
544
544
  export { isLockedByCurrentUser, isObjectLocked, lockHolder, readLockPayload, resolveObjectLock } from './utils/objectLock.js'
545
+ export { dispatchObjectsChanged } from './utils/objectSignals.js'
545
546
  export { columnsFromSchema, fieldsFromSchema, filtersFromSchema, findRepeatingWrite, formatValue, journeyItemTargets, validateValue } from './utils/index.js'
546
547
  export { DYNAMIC_KEY_PREFIX, EXTENDS_FORM_KEY, PREFILL_KEY, splitDynamicFormData, usesArrayValues, valueArrayFor, valueRecordsFor } from './utils/index.js'
547
548
  // The OpenRegister schema API contract — shared so Buildiq and OpenRegister cannot
@@ -240,9 +240,10 @@ const baseActions = {
240
240
  * @param {object} [slugs] Optional slug hints for live-updates transport
241
241
  * @param {string|null} [slugs.registerSlug] Canonical register slug (e.g. 'zaken')
242
242
  * @param {string|null} [slugs.schemaSlug] Canonical schema slug (e.g. 'meldingen')
243
+ * @param {string} [slugs.collectionUrl] Endpoint `fetchCollection` lists from instead of `/register/schema`; every other call keeps the pair URL
243
244
  */
244
245
  registerObjectType(slug, schemaId, registerId, slugs = {}) {
245
- const { registerSlug = null, schemaSlug = null } = slugs
246
+ const { registerSlug = null, schemaSlug = null, collectionUrl = null } = slugs
246
247
  // Replace entire objects so Vue 2 reactivity detects the change
247
248
  // (Vue 2 cannot track new properties added to existing reactive objects)
248
249
  this.objectTypeRegistry = {
@@ -252,6 +253,7 @@ const baseActions = {
252
253
  register: registerId,
253
254
  registerSlug,
254
255
  schemaSlug,
256
+ ...(collectionUrl ? { collectionUrl } : {}),
255
257
  },
256
258
  }
257
259
  this.collections = { ...this.collections, [slug]: [] }
@@ -496,6 +498,31 @@ const baseActions = {
496
498
  return url
497
499
  },
498
500
 
501
+ /**
502
+ * Build the list URL for a type registered with a `collectionUrl`. `_order`
503
+ * goes in the bracket form (`_order[key]=dir`) every PHP endpoint parses.
504
+ *
505
+ * @param {object} config The type config
506
+ * @param {object} [params] Query parameters
507
+ * @return {string} Full URL including query string
508
+ */
509
+ _buildCollectionUrl(config, params = {}) {
510
+ const { _order: order, ...rest } = params
511
+ const merged = { ...rest }
512
+ if (order && typeof order === 'object') {
513
+ for (const [key, dir] of Object.entries(order)) {
514
+ merged[`_order[${key}]`] = dir
515
+ }
516
+ }
517
+ const lang = this._resolveLanguage()
518
+ if (lang) {
519
+ merged._lang = lang
520
+ }
521
+ const url = prefixUrl(config.collectionUrl)
522
+ const query = buildQueryString(merged)
523
+ return query && url.includes('?') ? url + '&' + query.slice(1) : url + query
524
+ },
525
+
499
526
  /**
500
527
  * Fetch for a type, retrying ONCE with its `schemaFallback` when the
501
528
  * schema path segment answers 404. A type without a fallback (every type
@@ -685,14 +712,15 @@ const baseActions = {
685
712
  }
686
713
  }
687
714
 
688
- const response = await this._fetchWithSchemaFallback(
689
- type,
690
- (schema) => this._buildUrlWithParams(type, fetchParams, null, schema),
691
- {
692
- method: 'GET',
693
- headers: this._buildHeaders(),
694
- },
695
- )
715
+ const config = this._getTypeConfig(type)
716
+ const init = { method: 'GET', headers: this._buildHeaders() }
717
+ const response = config.collectionUrl
718
+ ? await trackedFetch(this._buildCollectionUrl(config, fetchParams), init)
719
+ : await this._fetchWithSchemaFallback(
720
+ type,
721
+ (schema) => this._buildUrlWithParams(type, fetchParams, null, schema),
722
+ init,
723
+ )
696
724
 
697
725
  if (!response.ok) {
698
726
  const failure = await parseResponseError(response, type)
@@ -19,3 +19,4 @@ export { validateFieldValue } from './formValidation.js'
19
19
  export { computeObjectDiff } from './computeObjectDiff.js'
20
20
  export { foldAuditTrailEntries } from './auditTrailDiff.js'
21
21
  export { isLockedByCurrentUser, isObjectLocked, lockHolder, readLockPayload, resolveObjectLock } from './objectLock.js'
22
+ export { dispatchObjectsChanged } from './objectSignals.js'