@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.
- package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +19 -14
- package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
- package/dist/esm/components/CnAppNav/CnAppNav.vue3.js +2 -2
- package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +77 -26
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js +22 -14
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
- package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue2.js +23 -4
- package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnBrandBar/CnBrandBar.vue.js +9 -0
- package/dist/esm/components/CnBrandBar/CnBrandBar.vue.js.map +1 -0
- package/dist/esm/components/CnBrandBar/CnBrandBar.vue2.js +107 -0
- package/dist/esm/components/CnBrandBar/CnBrandBar.vue2.js.map +1 -0
- package/dist/esm/components/CnBrandBar/CnBrandBar.vue3.js +79 -0
- package/dist/esm/components/CnBrandBar/CnBrandBar.vue3.js.map +1 -0
- package/dist/esm/components/CnCardGrid/CnCardGrid.vue2.js +6 -0
- package/dist/esm/components/CnCardGrid/CnCardGrid.vue2.js.map +1 -1
- package/dist/esm/components/CnCardGrid/CnCardGrid.vue3.js +3 -2
- package/dist/esm/components/CnCardGrid/CnCardGrid.vue3.js.map +1 -1
- package/dist/esm/components/CnCredentials/CnCredentials.vue2.js +11 -10
- package/dist/esm/components/CnCredentials/CnCredentials.vue2.js.map +1 -1
- package/dist/esm/components/CnCredentials/CnCredentials.vue3.js.map +1 -1
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +8 -5
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
- package/dist/esm/components/CnEnvironmentBanner/CnEnvironmentBanner.vue2.js +4 -1
- package/dist/esm/components/CnEnvironmentBanner/CnEnvironmentBanner.vue2.js.map +1 -1
- package/dist/esm/components/CnEnvironmentBanner/CnEnvironmentBanner.vue3.js.map +1 -1
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +7 -0
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js +14 -7
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
- package/dist/esm/components/CnFormPage/CnFormPage.vue2.js +32 -0
- package/dist/esm/components/CnFormPage/CnFormPage.vue2.js.map +1 -1
- package/dist/esm/components/CnFormPage/CnFormPage.vue3.js +6 -3
- package/dist/esm/components/CnFormPage/CnFormPage.vue3.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +144 -6
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +14 -13
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
- package/dist/esm/components/CnIndexPage/selfModeActions.js +90 -14
- package/dist/esm/components/CnIndexPage/selfModeActions.js.map +1 -1
- package/dist/esm/components/CnIndexPage/useSelfFetchList.js +15 -3
- package/dist/esm/components/CnIndexPage/useSelfFetchList.js.map +1 -1
- package/dist/esm/components/CnObjectCard/CnObjectCard.vue2.js +26 -0
- package/dist/esm/components/CnObjectCard/CnObjectCard.vue2.js.map +1 -1
- package/dist/esm/components/CnObjectCard/CnObjectCard.vue3.js +26 -9
- package/dist/esm/components/CnObjectCard/CnObjectCard.vue3.js.map +1 -1
- package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +19 -3
- package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue2.js +18 -2
- package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue2.js.map +1 -1
- package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue3.js +2 -2
- package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue3.js.map +1 -1
- package/dist/esm/components/CnRelatedObjectsWidget/CnRelatedObjectsWidget.vue2.js +153 -38
- package/dist/esm/components/CnRelatedObjectsWidget/CnRelatedObjectsWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnRelatedObjectsWidget/CnRelatedObjectsWidget.vue3.js +74 -56
- package/dist/esm/components/CnRelatedObjectsWidget/CnRelatedObjectsWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnStatsBlock/CnStatsBlock.vue2.js +16 -0
- package/dist/esm/components/CnStatsBlock/CnStatsBlock.vue2.js.map +1 -1
- package/dist/esm/components/CnStatsBlock/CnStatsBlock.vue3.js +2 -2
- package/dist/esm/components/CnStatsBlock/CnStatsBlock.vue3.js.map +1 -1
- package/dist/esm/composables/useObjectLock.js +41 -10
- package/dist/esm/composables/useObjectLock.js.map +1 -1
- package/dist/esm/directives/cnSelectAria.js +55 -0
- package/dist/esm/directives/cnSelectAria.js.map +1 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/l10n/en.json.js +7 -1
- package/dist/esm/l10n/en.json.js.map +1 -1
- package/dist/esm/l10n/nl.json.js +8 -2
- package/dist/esm/l10n/nl.json.js.map +1 -1
- package/dist/esm/nextcloud-vue.css +154 -2
- package/dist/esm/store/useObjectStore.js +37 -9
- package/dist/esm/store/useObjectStore.js.map +1 -1
- package/dist/esm/utils/cnFetch.js +17 -2
- package/dist/esm/utils/cnFetch.js.map +1 -1
- package/dist/esm/utils/objectSignals.js +44 -0
- package/dist/esm/utils/objectSignals.js.map +1 -0
- package/dist/esm/utils/resolveBrand.js +38 -0
- package/dist/esm/utils/resolveBrand.js.map +1 -0
- package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
- package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
- package/dist/nextcloud-vue.cjs.js +9640 -8649
- package/dist/nextcloud-vue.cjs.js.map +2 -2
- package/dist/nextcloud-vue.css +154 -2
- package/l10n/en.json +7 -1
- package/l10n/nl.json +8 -2
- package/package.json +1 -1
- package/src/components/CnAppNav/CnAppNav.vue +21 -16
- package/src/components/CnAppRoot/CnAppRoot.vue +53 -0
- package/src/components/CnBannerWidget/CnBannerWidget.vue +23 -4
- package/src/components/CnBrandBar/CnBrandBar.vue +230 -0
- package/src/components/CnCardGrid/CnCardGrid.md +1 -0
- package/src/components/CnCardGrid/CnCardGrid.vue +7 -0
- package/src/components/CnCredentials/CnCredentials.vue +17 -16
- package/src/components/CnDetailPage/CnDetailPage.vue +8 -5
- package/src/components/CnEnvironmentBanner/CnEnvironmentBanner.vue +4 -1
- package/src/components/CnFormDialog/CnFormDialog.vue +10 -0
- package/src/components/CnFormPage/CnFormPage.vue +33 -0
- package/src/components/CnIndexPage/CnIndexPage.md +15 -0
- package/src/components/CnIndexPage/CnIndexPage.vue +157 -18
- package/src/components/CnIndexPage/selfModeActions.js +89 -13
- package/src/components/CnIndexPage/useSelfFetchList.js +15 -3
- package/src/components/CnObjectCard/CnObjectCard.md +1 -0
- package/src/components/CnObjectCard/CnObjectCard.vue +89 -1
- package/src/components/CnObjectListWidget/CnObjectListWidget.vue +19 -3
- package/src/components/CnQuickFilterBar/CnQuickFilterBar.vue +20 -4
- package/src/components/CnRelatedObjectsWidget/CnRelatedObjectsWidget.vue +181 -38
- package/src/components/CnStatsBlock/CnStatsBlock.vue +18 -2
- package/src/composables/__tests__/useObjectLock.endpoints.spec.js +83 -0
- package/src/composables/useObjectLock.js +41 -10
- package/src/css/form-field.css +1 -1
- package/src/css/look-board-index.css +35 -1
- package/src/directives/cnSelectAria.js +54 -0
- package/src/index.js +1 -0
- package/src/schemas/app-manifest-v2.schema.json +6 -1
- package/src/store/useObjectStore.js +37 -9
- package/src/utils/cnFetch.js +16 -1
- package/src/utils/index.js +1 -0
- package/src/utils/objectSignals.js +41 -0
- package/src/utils/resolveBrand.js +37 -0
- 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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
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
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
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 =
|
|
1675
|
+
const block = data && data[def.responseKey]
|
|
1576
1676
|
const results = (block && block.results) || []
|
|
1577
|
-
|
|
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
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
50
|
-
* @param {string|import('vue').Ref<string
|
|
51
|
-
* @param {string|import('vue').Ref<string
|
|
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
|
|
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
|
|
88
|
+
return toValue(schema)
|
|
79
89
|
}
|
|
80
90
|
function readId() {
|
|
81
|
-
return
|
|
91
|
+
return toValue(id)
|
|
82
92
|
}
|
|
83
93
|
function readRegister() {
|
|
84
|
-
return
|
|
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
|
|
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 {
|
package/src/css/form-field.css
CHANGED
|
@@ -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);
|