@conduction/nextcloud-vue 2.96.0 → 2.98.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +17 -0
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
- package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue.js +1 -1
- package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js +33 -1
- package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js.map +1 -1
- package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js +383 -359
- package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js.map +1 -1
- package/dist/esm/components/CnChartWidget/CnChartWidget.vue.js +1 -1
- package/dist/esm/components/CnChartWidget/CnChartWidget.vue2.js +106 -2
- package/dist/esm/components/CnChartWidget/CnChartWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnChartWidget/CnChartWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue.js +9 -0
- package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue.js.map +1 -0
- package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue2.js +964 -0
- package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue2.js.map +1 -0
- package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue3.js +282 -0
- package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue3.js.map +1 -0
- package/dist/esm/components/CnChecklistRunWidget/index.js +28 -0
- package/dist/esm/components/CnChecklistRunWidget/index.js.map +1 -0
- package/dist/esm/components/CnConversationThread/CnConversationThread.vue.js +1 -1
- package/dist/esm/components/CnConversationThread/CnConversationThread.vue2.js +17 -3
- package/dist/esm/components/CnConversationThread/CnConversationThread.vue2.js.map +1 -1
- package/dist/esm/components/CnConversationThread/CnConversationThread.vue3.js +61 -43
- package/dist/esm/components/CnConversationThread/CnConversationThread.vue3.js.map +1 -1
- package/dist/esm/components/CnConversationThread/CnConversationWidget.vue.js +1 -1
- package/dist/esm/components/CnConversationThread/CnConversationWidget.vue2.js +159 -18
- package/dist/esm/components/CnConversationThread/CnConversationWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnConversationThread/CnConversationWidget.vue3.js +9 -7
- package/dist/esm/components/CnConversationThread/CnConversationWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +1 -0
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
- package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +26 -8
- package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
- package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +188 -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/CnFactsWidget/CnFactsWidget.vue.js +1 -1
- package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js +48 -3
- package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js +46 -12
- package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue.js +1 -1
- package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue2.js +3 -3
- package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue2.js.map +1 -1
- package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue3.js.map +1 -1
- package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue.js +1 -1
- package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue2.js +27 -0
- package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue2.js.map +1 -1
- package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue3.js +16 -2
- package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue3.js.map +1 -1
- package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue.js +1 -1
- package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js +144 -0
- package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js.map +1 -1
- package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js +488 -399
- package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js.map +1 -1
- package/dist/esm/components/CnFilesBrowser/filesBrowserColumns.js +8 -2
- package/dist/esm/components/CnFilesBrowser/filesBrowserColumns.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +344 -21
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +29 -7
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue.js +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue2.js +2 -2
- package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue2.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue3.js +2 -2
- package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue3.js.map +1 -1
- package/dist/esm/components/CnIndexPage/useNamedSource.js +1 -1
- package/dist/esm/components/CnIndexPage/useNamedSource.js.map +1 -1
- package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue.js +1 -1
- package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js +13 -0
- package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js.map +1 -1
- package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js +5 -1
- package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js.map +1 -1
- package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue.js +9 -0
- package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue.js.map +1 -0
- package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue2.js +220 -0
- package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue2.js.map +1 -0
- package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue3.js +121 -0
- package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue3.js.map +1 -0
- package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue.js +1 -1
- package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue2.js +83 -9
- package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue2.js.map +1 -1
- package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue3.js +80 -11
- package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue3.js.map +1 -1
- package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
- package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js.map +1 -1
- package/dist/esm/composables/indexSources.js +420 -5
- package/dist/esm/composables/indexSources.js.map +1 -1
- package/dist/esm/composables/useOrganisationStore.js +115 -0
- package/dist/esm/composables/useOrganisationStore.js.map +1 -0
- package/dist/esm/index.js +2 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/l10n/en.json.js +21 -1
- package/dist/esm/l10n/en.json.js.map +1 -1
- package/dist/esm/l10n/nl.json.js +36 -1
- package/dist/esm/l10n/nl.json.js.map +1 -1
- package/dist/esm/library-style.js +1 -1
- package/dist/esm/nextcloud-vue.css +725 -383
- package/dist/esm/utils/builtInFormatters.js +34 -1
- package/dist/esm/utils/builtInFormatters.js.map +1 -1
- package/dist/esm/utils/libraryWidgetKeys.js +1 -0
- package/dist/esm/utils/libraryWidgetKeys.js.map +1 -1
- package/dist/esm/utils/matrixCells.js +158 -0
- package/dist/esm/utils/matrixCells.js.map +1 -0
- package/dist/esm/utils/nextcloudDirectory.js +121 -0
- package/dist/esm/utils/nextcloudDirectory.js.map +1 -0
- package/dist/esm/utils/queryPresets.js +13 -2
- package/dist/esm/utils/queryPresets.js.map +1 -1
- package/dist/esm/utils/scopeListLayout.js +37 -1
- package/dist/esm/utils/scopeListLayout.js.map +1 -1
- package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
- package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
- package/dist/nextcloud-vue.cjs.js +14443 -10519
- package/dist/nextcloud-vue.cjs.js.map +2 -2
- package/dist/nextcloud-vue.css +725 -383
- package/l10n/en.json +21 -1
- package/l10n/nl.json +36 -1
- package/package.json +2 -2
- package/scripts/merge-l10n.js +196 -0
- package/src/components/CnAppRoot/CnAppRoot.md +1 -0
- package/src/components/CnAppRoot/CnAppRoot.vue +17 -0
- package/src/components/CnCellRenderer/CnCellRenderer.vue +57 -1
- package/src/components/CnChartWidget/CnChartWidget.md +2 -1
- package/src/components/CnChartWidget/CnChartWidget.vue +106 -2
- package/src/components/CnChecklistRunWidget/CnChecklistRunWidget.vue +1269 -0
- package/src/components/CnChecklistRunWidget/index.js +27 -0
- package/src/components/CnConversationThread/CnConversationThread.md +12 -0
- package/src/components/CnConversationThread/CnConversationThread.vue +30 -4
- package/src/components/CnConversationThread/CnConversationWidget.vue +167 -24
- package/src/components/CnDashboardPage/CnDashboardPage.vue +1 -0
- package/src/components/CnDataTable/CnDataTable.vue +26 -8
- package/src/components/CnDetailPage/CnDetailPage.md +48 -0
- package/src/components/CnDetailPage/CnDetailPage.vue +188 -5
- package/src/components/CnFactsWidget/CnFactsWidget.vue +114 -3
- package/src/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue +3 -3
- package/src/components/CnFeaturesBoard/CnFeaturesBoard.vue +38 -2
- package/src/components/CnFilesBrowser/CnFilesBrowser.vue +508 -236
- package/src/components/CnFilesBrowser/filesBrowserColumns.js +8 -2
- package/src/components/CnIndexPage/CnIndexPage.md +28 -0
- package/src/components/CnIndexPage/CnIndexPage.vue +367 -27
- package/src/components/CnIndexPage/CnIndexPageWidgets.vue +13 -4
- package/src/components/CnIndexPage/useNamedSource.js +1 -1
- package/src/components/CnObjectSidebar/CnFilesTab.vue +17 -0
- package/src/components/CnOrderedListField/CnOrderedListField.vue +386 -0
- package/src/components/CnOrderedListField/index.js +7 -0
- package/src/components/CnUserSettingsDialog/CnUserSettingsDialog.vue +139 -10
- package/src/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
- package/src/components/index.js +2 -0
- package/src/composables/indexSources.js +419 -4
- package/src/composables/useOrganisationStore.js +111 -0
- package/src/index.js +2 -0
- package/src/schemas/app-manifest-v2.schema.json +42 -5
- package/src/utils/builtInFormatters.js +33 -0
- package/src/utils/libraryWidgetKeys.js +1 -0
- package/src/utils/matrixCells.js +154 -0
- package/src/utils/nextcloudDirectory.js +117 -0
- package/src/utils/queryPresets.js +13 -2
- package/src/utils/scopeListLayout.js +37 -1
- package/src/utils/validateManifestV2.compiled.js +3 -3
|
@@ -1232,11 +1232,13 @@ import CnSegmentedControl from '../CnSegmentedControl/CnSegmentedControl.vue'
|
|
|
1232
1232
|
import CnSummaryAggregates from '../CnSummaryAggregates/CnSummaryAggregates.vue'
|
|
1233
1233
|
import CnTranslatedBadge from '../CnTranslatedBadge/CnTranslatedBadge.vue'
|
|
1234
1234
|
import CnDetailHeaderChips from './CnDetailHeaderChips.vue'
|
|
1235
|
+
import { resolveRecordSource } from '../../composables/indexSources.js'
|
|
1235
1236
|
import { useIntegrationRegistry } from '../../composables/useIntegrationRegistry.js'
|
|
1236
1237
|
import { normalizeLook } from '../../composables/useLook.js'
|
|
1237
1238
|
import { useObjectLock } from '../../composables/useObjectLock.js'
|
|
1238
1239
|
import { useObjectPresence } from '../../composables/useObjectPresence.js'
|
|
1239
1240
|
import { useObjectSubscription } from '../../composables/useObjectSubscription.js'
|
|
1241
|
+
import { createRefLabelResolver } from '../../composables/useRefLabels.js'
|
|
1240
1242
|
import { gridLayout } from '../../mixins/gridLayout.js'
|
|
1241
1243
|
import { pageViews } from '../../mixins/pageViews.js'
|
|
1242
1244
|
import { useObjectStore } from '../../store/index.js'
|
|
@@ -1909,6 +1911,23 @@ export default {
|
|
|
1909
1911
|
default: null,
|
|
1910
1912
|
},
|
|
1911
1913
|
|
|
1914
|
+
/**
|
|
1915
|
+
* Name of a registered entity source that reads ONE record (manifest
|
|
1916
|
+
* `config.entitySource`), for a record that is not a register/schema
|
|
1917
|
+
* object. The same registry and the same name as `CnIndexPage`'s
|
|
1918
|
+
* `entitySource`: `organisations` shows the OpenRegister organisation
|
|
1919
|
+
* whose uuid is `objectId`. The record feeds the header, pills, facts,
|
|
1920
|
+
* widgets and the `@objectId` / `@object.*` tokens exactly as a fetched
|
|
1921
|
+
* object does. Wins over `register` + `schema`. Empty (the default)
|
|
1922
|
+
* leaves the page on its object store.
|
|
1923
|
+
*
|
|
1924
|
+
* @type {string}
|
|
1925
|
+
*/
|
|
1926
|
+
entitySource: {
|
|
1927
|
+
type: String,
|
|
1928
|
+
default: '',
|
|
1929
|
+
},
|
|
1930
|
+
|
|
1912
1931
|
/**
|
|
1913
1932
|
* OpenRegister register slug. Pair with `schema` to opt into the
|
|
1914
1933
|
* schema-driven mode: the page fuses the two into an internal
|
|
@@ -2863,6 +2882,10 @@ export default {
|
|
|
2863
2882
|
pillCounts: {},
|
|
2864
2883
|
/** Sequence of the last pill count round, so a late answer does not overwrite a newer one. */
|
|
2865
2884
|
pillCountSeq: 0,
|
|
2885
|
+
/** The labels of the records pills with `resolve` point at, by pill key. */
|
|
2886
|
+
pillRefLabels: {},
|
|
2887
|
+
/** Sequence of the last pill label lookup, so a late answer for another record is dropped. */
|
|
2888
|
+
pillRefSeq: 0,
|
|
2866
2889
|
/** `register/schema/id` the read-state call was last made for, so it is sent once per load. */
|
|
2867
2890
|
readStateSentFor: '',
|
|
2868
2891
|
/** Whether the user is arranging the chosen view (`userLayout`). */
|
|
@@ -3384,7 +3407,16 @@ export default {
|
|
|
3384
3407
|
const pill = pillCountSource(config)
|
|
3385
3408
|
? resolveCountPill(config, this.pillCounts[key], (text) => this.effectiveTranslate(text))
|
|
3386
3409
|
: resolvePill(config, this.resolvedObject)
|
|
3387
|
-
|
|
3410
|
+
if (!pill) {
|
|
3411
|
+
return null
|
|
3412
|
+
}
|
|
3413
|
+
// `resolve`: the referenced record's label replaces the id, and
|
|
3414
|
+
// keys the colour too ("Woo-verzoek", not a uuid).
|
|
3415
|
+
const resolved = this.pillRefLabels[key]
|
|
3416
|
+
if (typeof resolved === 'string' && resolved !== '') {
|
|
3417
|
+
return { ...pill, key, label: this.effectiveTranslate(resolved), colorKey: resolved }
|
|
3418
|
+
}
|
|
3419
|
+
return { ...pill, key, label: this.effectiveTranslate(pill.label) }
|
|
3388
3420
|
})
|
|
3389
3421
|
.filter(Boolean)
|
|
3390
3422
|
},
|
|
@@ -3396,6 +3428,34 @@ export default {
|
|
|
3396
3428
|
* @return {string} The key.
|
|
3397
3429
|
* @spec openspec/changes/screens-relation-pages/specs/relation-pages/spec.md#requirement-a-header-pill-can-be-a-constant-or-a-count
|
|
3398
3430
|
*/
|
|
3431
|
+
/**
|
|
3432
|
+
* What the resolved pills depend on, as one key: a change looks again.
|
|
3433
|
+
*
|
|
3434
|
+
* @return {string}
|
|
3435
|
+
* @spec openspec/changes/detail-pill-resolve/specs/detail-page/spec.md#requirement-a-header-pill-can-show-a-referenced-record-s-label
|
|
3436
|
+
*/
|
|
3437
|
+
pillResolveKey() {
|
|
3438
|
+
return JSON.stringify(this.pillResolveTargets)
|
|
3439
|
+
},
|
|
3440
|
+
|
|
3441
|
+
/**
|
|
3442
|
+
* The pills with `resolve: { register, schema, labelField? }` and the
|
|
3443
|
+
* id each one shows: `[key, register, schema, labelField, id]`. Empty
|
|
3444
|
+
* while the field is empty.
|
|
3445
|
+
*
|
|
3446
|
+
* @return {Array<Array<string>>}
|
|
3447
|
+
* @spec openspec/changes/detail-pill-resolve/specs/detail-page/spec.md#requirement-a-header-pill-can-show-a-referenced-record-s-label
|
|
3448
|
+
*/
|
|
3449
|
+
pillResolveTargets() {
|
|
3450
|
+
return [['type', this.typePill], ['status', this.statusPill]]
|
|
3451
|
+
.filter(([, config]) => config && config.resolve && config.resolve.register && config.resolve.schema && typeof config.field === 'string')
|
|
3452
|
+
.map(([key, config]) => {
|
|
3453
|
+
const pill = resolvePill({ field: config.field }, this.resolvedObject)
|
|
3454
|
+
return pill ? [key, config.resolve.register, config.resolve.schema, config.resolve.labelField || '', pill.colorKey] : null
|
|
3455
|
+
})
|
|
3456
|
+
.filter(Boolean)
|
|
3457
|
+
},
|
|
3458
|
+
|
|
3399
3459
|
pillCountKey() {
|
|
3400
3460
|
return JSON.stringify([this.objectId, pillCountSource(this.typePill), pillCountSource(this.statusPill)])
|
|
3401
3461
|
},
|
|
@@ -3598,6 +3658,9 @@ export default {
|
|
|
3598
3658
|
* @return {boolean}
|
|
3599
3659
|
*/
|
|
3600
3660
|
awaitingObject() {
|
|
3661
|
+
if (this.hasRecordSourceFetch) {
|
|
3662
|
+
return this.objectFetchPending && !this.currentObject
|
|
3663
|
+
}
|
|
3601
3664
|
return this.hasSchemaDrivenFetch
|
|
3602
3665
|
&& this.objectFetchPending
|
|
3603
3666
|
&& typeof this.effectiveObjectStore?.fetchObject === 'function'
|
|
@@ -3671,7 +3734,45 @@ export default {
|
|
|
3671
3734
|
* the fetch path entirely.
|
|
3672
3735
|
*/
|
|
3673
3736
|
hasSchemaDrivenFetch() {
|
|
3674
|
-
return Boolean(this.register && this.schema && this.objectId)
|
|
3737
|
+
return !this.entitySource && Boolean(this.register && this.schema && this.objectId)
|
|
3738
|
+
},
|
|
3739
|
+
|
|
3740
|
+
/**
|
|
3741
|
+
* The record source named by `entitySource`, or null. An unknown name,
|
|
3742
|
+
* or a source that cannot read one record, is null with a warning.
|
|
3743
|
+
*
|
|
3744
|
+
* @return {object|null}
|
|
3745
|
+
*/
|
|
3746
|
+
recordSource() {
|
|
3747
|
+
if (!this.entitySource) {
|
|
3748
|
+
return null
|
|
3749
|
+
}
|
|
3750
|
+
try {
|
|
3751
|
+
return resolveRecordSource(this.entitySource)
|
|
3752
|
+
} catch (err) {
|
|
3753
|
+
// eslint-disable-next-line no-console -- the console is the only channel a host app can read the detail on
|
|
3754
|
+
console.warn('[CnDetailPage] the record source could not start; no record is shown.', err)
|
|
3755
|
+
return null
|
|
3756
|
+
}
|
|
3757
|
+
},
|
|
3758
|
+
|
|
3759
|
+
/**
|
|
3760
|
+
* Whether the page reads its record through a record source.
|
|
3761
|
+
*
|
|
3762
|
+
* @return {boolean}
|
|
3763
|
+
*/
|
|
3764
|
+
hasRecordSourceFetch() {
|
|
3765
|
+
return Boolean(this.recordSource && this.objectId)
|
|
3766
|
+
},
|
|
3767
|
+
|
|
3768
|
+
/**
|
|
3769
|
+
* Whether the page reads its own record, from the object store or a
|
|
3770
|
+
* record source, so Refresh and `cn:page:refresh` have something to do.
|
|
3771
|
+
*
|
|
3772
|
+
* @return {boolean}
|
|
3773
|
+
*/
|
|
3774
|
+
selfFetchesRecord() {
|
|
3775
|
+
return this.hasSchemaDrivenFetch || this.hasRecordSourceFetch
|
|
3675
3776
|
},
|
|
3676
3777
|
|
|
3677
3778
|
/**
|
|
@@ -3886,7 +3987,7 @@ export default {
|
|
|
3886
3987
|
}
|
|
3887
3988
|
// `$.vnode.props`, not `$attrs`: `refresh` is a declared emit, and
|
|
3888
3989
|
// Vue keeps declared emits out of `$attrs`.
|
|
3889
|
-
return Boolean(this.$.vnode.props?.onRefresh) || this.
|
|
3990
|
+
return Boolean(this.$.vnode.props?.onRefresh) || this.selfFetchesRecord
|
|
3890
3991
|
},
|
|
3891
3992
|
|
|
3892
3993
|
/**
|
|
@@ -3930,6 +4031,9 @@ export default {
|
|
|
3930
4031
|
* @return {object|null}
|
|
3931
4032
|
*/
|
|
3932
4033
|
currentObject() {
|
|
4034
|
+
if (this.recordSource) {
|
|
4035
|
+
return this.objectId ? (this.recordSource.record(this.objectId) ?? null) : null
|
|
4036
|
+
}
|
|
3933
4037
|
const store = this.effectiveObjectStore
|
|
3934
4038
|
if (!store) {
|
|
3935
4039
|
return null
|
|
@@ -4605,6 +4709,14 @@ export default {
|
|
|
4605
4709
|
},
|
|
4606
4710
|
},
|
|
4607
4711
|
|
|
4712
|
+
// A pill with `resolve` looks its record up again for another value.
|
|
4713
|
+
pillResolveKey: {
|
|
4714
|
+
immediate: true,
|
|
4715
|
+
handler() {
|
|
4716
|
+
this.refreshPillRefLabels()
|
|
4717
|
+
},
|
|
4718
|
+
},
|
|
4719
|
+
|
|
4608
4720
|
// The header's count pills count again for another record or other lists.
|
|
4609
4721
|
pillCountKey: {
|
|
4610
4722
|
immediate: true,
|
|
@@ -5028,7 +5140,7 @@ export default {
|
|
|
5028
5140
|
// Schema-driven (manifest) detail pages self-fetch — without this
|
|
5029
5141
|
// the Refresh action has no host listener to act on and does
|
|
5030
5142
|
// nothing. Re-fetch the object + schema and spin the action.
|
|
5031
|
-
if (!this.
|
|
5143
|
+
if (!this.selfFetchesRecord) {
|
|
5032
5144
|
return
|
|
5033
5145
|
}
|
|
5034
5146
|
this.internalRefreshing = true
|
|
@@ -5041,6 +5153,42 @@ export default {
|
|
|
5041
5153
|
}
|
|
5042
5154
|
},
|
|
5043
5155
|
|
|
5156
|
+
/**
|
|
5157
|
+
* Look up the records the `resolve` pills point at, through the same
|
|
5158
|
+
* reference-label resolver the index tables use. A failed lookup keeps
|
|
5159
|
+
* the raw value.
|
|
5160
|
+
*
|
|
5161
|
+
* @return {Promise<void>}
|
|
5162
|
+
* @spec openspec/changes/detail-pill-resolve/specs/detail-page/spec.md#requirement-a-header-pill-can-show-a-referenced-record-s-label
|
|
5163
|
+
*/
|
|
5164
|
+
async refreshPillRefLabels() {
|
|
5165
|
+
const targets = this.pillResolveTargets
|
|
5166
|
+
const seq = ++this.pillRefSeq
|
|
5167
|
+
if (targets.length === 0) {
|
|
5168
|
+
this.pillRefLabels = {}
|
|
5169
|
+
return
|
|
5170
|
+
}
|
|
5171
|
+
if (!this._pillRefResolver) {
|
|
5172
|
+
this._pillRefResolver = createRefLabelResolver(() => {
|
|
5173
|
+
try {
|
|
5174
|
+
return this.objectStore || useObjectStore()
|
|
5175
|
+
} catch {
|
|
5176
|
+
return null
|
|
5177
|
+
}
|
|
5178
|
+
})
|
|
5179
|
+
}
|
|
5180
|
+
const labels = {}
|
|
5181
|
+
await Promise.all(targets.map(async ([key, register, schema, labelField, id]) => {
|
|
5182
|
+
const found = await this._pillRefResolver.resolve(register, schema, [id], labelField).catch(() => ({}))
|
|
5183
|
+
if (found && typeof found[id] === 'string') {
|
|
5184
|
+
labels[key] = found[id]
|
|
5185
|
+
}
|
|
5186
|
+
}))
|
|
5187
|
+
if (seq === this.pillRefSeq) {
|
|
5188
|
+
this.pillRefLabels = labels
|
|
5189
|
+
}
|
|
5190
|
+
},
|
|
5191
|
+
|
|
5044
5192
|
/**
|
|
5045
5193
|
* Count the lists behind the header's count pills (`countFrom`): one
|
|
5046
5194
|
* list request each (`_limit=1`, the `total`). A failed count, or a
|
|
@@ -5083,7 +5231,7 @@ export default {
|
|
|
5083
5231
|
*/
|
|
5084
5232
|
onPageRefreshBus(payload) {
|
|
5085
5233
|
this.refreshPillCounts()
|
|
5086
|
-
if (!this.
|
|
5234
|
+
if (!this.selfFetchesRecord) {
|
|
5087
5235
|
return
|
|
5088
5236
|
}
|
|
5089
5237
|
if (this.internalRefreshing) {
|
|
@@ -5308,6 +5456,10 @@ export default {
|
|
|
5308
5456
|
},
|
|
5309
5457
|
|
|
5310
5458
|
async fetchObjectIfNeeded() {
|
|
5459
|
+
if (this.recordSource) {
|
|
5460
|
+
await this.fetchSourceRecord()
|
|
5461
|
+
return
|
|
5462
|
+
}
|
|
5311
5463
|
// Create archetype: no object to fetch, but the create form needs
|
|
5312
5464
|
// the schema — register the type and fetch its schema, then stop.
|
|
5313
5465
|
if (!this.hasSchemaDrivenFetch) {
|
|
@@ -5371,6 +5523,37 @@ export default {
|
|
|
5371
5523
|
}
|
|
5372
5524
|
},
|
|
5373
5525
|
|
|
5526
|
+
/**
|
|
5527
|
+
* Read the record through the record source. A refusal (403) or a
|
|
5528
|
+
* missing record (404) shows the not-found state; which records the
|
|
5529
|
+
* viewer may read is the server's decision.
|
|
5530
|
+
*
|
|
5531
|
+
* @return {Promise<void>}
|
|
5532
|
+
*/
|
|
5533
|
+
async fetchSourceRecord() {
|
|
5534
|
+
const source = this.recordSource
|
|
5535
|
+
const objectId = this.objectId
|
|
5536
|
+
if (!objectId) {
|
|
5537
|
+
this.objectFetchPending = false
|
|
5538
|
+
return
|
|
5539
|
+
}
|
|
5540
|
+
this.objectFetchPending = true
|
|
5541
|
+
this.objectNotFound = false
|
|
5542
|
+
try {
|
|
5543
|
+
await source.loadRecord(String(objectId))
|
|
5544
|
+
} catch (err) {
|
|
5545
|
+
// eslint-disable-next-line no-console -- the failure is already handled; the console is the only channel a host app can read the detail on
|
|
5546
|
+
console.error('[CnDetailPage] record source fetch failed:', err)
|
|
5547
|
+
} finally {
|
|
5548
|
+
// A newer fetch for another id owns the flags now.
|
|
5549
|
+
if (objectId === this.objectId) {
|
|
5550
|
+
const status = typeof source.recordError === 'function' ? source.recordError(String(objectId)) : null
|
|
5551
|
+
this.objectNotFound = !this.currentObject && (status === 403 || status === 404)
|
|
5552
|
+
this.objectFetchPending = false
|
|
5553
|
+
}
|
|
5554
|
+
}
|
|
5555
|
+
},
|
|
5556
|
+
|
|
5374
5557
|
/**
|
|
5375
5558
|
* Whether a grid-layout item resolves to an integration-typed
|
|
5376
5559
|
* widget — `def.type === 'integration'` with a string
|
|
@@ -101,6 +101,28 @@
|
|
|
101
101
|
<span v-if="block.subtitle" class="cn-facts-widget__lead-subtitle">{{ block.subtitle }}</span>
|
|
102
102
|
</span>
|
|
103
103
|
</div>
|
|
104
|
+
<div
|
|
105
|
+
v-else-if="block.kind === 'meter'"
|
|
106
|
+
class="cn-facts-widget__meter"
|
|
107
|
+
:class="{ 'cn-facts-widget__meter--over': block.over }"
|
|
108
|
+
:data-testid="`cn-fact-${block.key}`">
|
|
109
|
+
<div class="cn-facts-widget__meter-head">
|
|
110
|
+
<span class="cn-facts-widget__meter-label">{{ block.label }}</span>
|
|
111
|
+
<span class="cn-facts-widget__meter-value">{{ block.value }}</span>
|
|
112
|
+
</div>
|
|
113
|
+
<div
|
|
114
|
+
v-if="block.limit !== null"
|
|
115
|
+
class="cn-facts-widget__meter-track"
|
|
116
|
+
role="meter"
|
|
117
|
+
:aria-label="block.label"
|
|
118
|
+
:aria-valuenow="block.amount"
|
|
119
|
+
aria-valuemin="0"
|
|
120
|
+
:aria-valuemax="block.limit"
|
|
121
|
+
:aria-valuetext="block.value"
|
|
122
|
+
:data-testid="`cn-fact-${block.key}-bar`">
|
|
123
|
+
<span class="cn-facts-widget__meter-bar" :style="{ width: `${block.percent}%` }" />
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
104
126
|
<div v-else-if="block.kind === 'pill'">
|
|
105
127
|
<CnStatusBadge
|
|
106
128
|
:label="block.value"
|
|
@@ -133,6 +155,7 @@
|
|
|
133
155
|
|
|
134
156
|
<script>
|
|
135
157
|
import { emit } from '@nextcloud/event-bus'
|
|
158
|
+
import { formatFileSize } from '@nextcloud/files'
|
|
136
159
|
import { translate as t } from '@nextcloud/l10n'
|
|
137
160
|
import { NcLoadingIcon } from '@nextcloud/vue'
|
|
138
161
|
import Check from 'vue-material-design-icons/Check.vue'
|
|
@@ -152,7 +175,7 @@ import { hasUnresolvedDeepTokens, resolveDeepTokens } from '../../utils/resolveF
|
|
|
152
175
|
import { userDisplayName } from '../../utils/userDisplayName.js'
|
|
153
176
|
|
|
154
177
|
/** Kinds of entry a `fields[]` item can be. A labelled item without `as` is a row. */
|
|
155
|
-
const KINDS = Object.freeze(['row', 'title', 'line', 'pill', 'lead', 'text'])
|
|
178
|
+
const KINDS = Object.freeze(['row', 'title', 'line', 'pill', 'lead', 'text', 'meter'])
|
|
156
179
|
|
|
157
180
|
/**
|
|
158
181
|
* The label resolver every facts widget on the page shares, made on first use
|
|
@@ -260,13 +283,14 @@ export default {
|
|
|
260
283
|
* The widget configuration.
|
|
261
284
|
*
|
|
262
285
|
* - `fields` — the entries, in order. Each is `{ path, label?, as?, from?, format?, map?, itemPath?, parts?, separator?, route?, variant?, variantMap?, colorMap?, icon?, copy?, reveal?, text?, title?, subtitle?, initialsPath?, tone? }`.
|
|
263
|
-
* `as` is `row` (default when `label` or `icon` is set), `title`, `line`, `text` (a line in the normal ink), `lead` (an icon tile or initials beside `title` and `subtitle`) or `
|
|
286
|
+
* `as` is `row` (default when `label` or `icon` is set), `title`, `line`, `text` (a line in the normal ink), `lead` (an icon tile or initials beside `title` and `subtitle`), `pill` or `meter`.
|
|
287
|
+
* A `meter` shows the amount at `path` against the limit at `limitPath` (or a constant `limit`) as "12 of 50" with a bar; past the limit the bar turns red and the text says "over the limit"; without a positive limit it shows the amount and no bar.
|
|
264
288
|
* A row's `format` may also be `email`, `phone` or `url` (a link) or `masked` (the last four characters; `reveal: true` adds Show). `copy: true` adds a copy button; `icon` replaces the label.
|
|
265
289
|
* A part may be `{ text }`, a constant; a `line` or `text` entry without `path` or `parts` shows its `text`.
|
|
266
290
|
* `resolve: { register, schema, labelField? }` on an entry or a part turns a value that is a reference (an id, or a list of ids) into the label of the referenced object (`labelField`, else `title`, then `name`): the case type or status of a task shows its name, not its uuid. The ids are fetched once per register and schema through the object store and shared by every facts widget on the page; the entry stays empty until its label is known, and when no label can be read. A resolved value works as a pill too: `map` is applied to the resolved label, and `variantMap` / `colorMap` look the tone up by the value of `resolve.toneField` on the referenced object when that is set (a status named by its `name`, coloured by its `state`), then by the resolved label, then by the stored value.
|
|
267
291
|
* `format: 'user'` shows a Nextcloud user id as the user's display name, read once per id; the entry stays empty until the name is known.
|
|
268
292
|
* - `action` — an action drawn as a button under the facts; `headerAction` — one drawn in the card header (reported through the `header` event).
|
|
269
|
-
* `format` is `text` (default), `date` (board short date), `date-long` (always with the year), `list` (an array joined by ", ", each item read at `itemPath`) or `currency` (a money amount in the reader's language, "€ 1.234,50": the code in `currency`, else the record's `currencyField`, else the app's reporting currency, else EUR; `decimals` defaults to 2).
|
|
293
|
+
* `format` is `text` (default), `date` (board short date), `date-long` (always with the year), `list` (an array joined by ", ", each item read at `itemPath`), `bytes` (a size, "1.5 MB") or `currency` (a money amount in the reader's language, "€ 1.234,50": the code in `currency`, else the record's `currencyField`, else the app's reporting currency, else EUR; `decimals` defaults to 2).
|
|
270
294
|
* `map` turns a raw value into a label (translated). `parts` (on a `line`) lists paths, or `{ path, label?, format? }`, joined by `separator` (default " · "); empty parts are dropped.
|
|
271
295
|
* - `source` — `{ register, schema, id }` or `{ url }`; omitted = the current object. An entry with `from: "object"` reads the current object even then (a client card that also shows the ticket's channel).
|
|
272
296
|
* - `link` — `{ label, route? | href? }` at the foot.
|
|
@@ -420,6 +444,9 @@ export default {
|
|
|
420
444
|
block.initials = block.icon ? '' : this.initialsOf(readPath(record, field.initialsPath))
|
|
421
445
|
block.tone = ['success', 'info', 'warning', 'error'].includes(field.tone) ? field.tone : 'neutral'
|
|
422
446
|
}
|
|
447
|
+
if (kind === 'meter') {
|
|
448
|
+
Object.assign(block, this.meterOf(record, field))
|
|
449
|
+
}
|
|
423
450
|
if (kind === 'title' && field.route) {
|
|
424
451
|
block.to = this.resolveRoute(field.route, record)
|
|
425
452
|
}
|
|
@@ -546,6 +573,42 @@ export default {
|
|
|
546
573
|
return field.label || field.icon ? 'row' : 'line'
|
|
547
574
|
},
|
|
548
575
|
|
|
576
|
+
/**
|
|
577
|
+
* A meter entry's parts: the amount at `path` against the limit at
|
|
578
|
+
* `limitPath` (or a constant `limit`), both formatted with the entry's
|
|
579
|
+
* `format`. Without a positive numeric limit there is no bar, only the
|
|
580
|
+
* amount. Past the limit the text says so, so the state never rests on
|
|
581
|
+
* the bar's colour alone.
|
|
582
|
+
*
|
|
583
|
+
* @param {object} record The object.
|
|
584
|
+
* @param {object} field The entry.
|
|
585
|
+
* @return {{label: string, value: string, amount: number|null, limit: number|null, percent: number, over: boolean}}
|
|
586
|
+
* @spec openspec/changes/facts-meter-row/specs/facts-meter-row/spec.md#requirement-a-facts-entry-can-be-a-meter
|
|
587
|
+
*/
|
|
588
|
+
meterOf(record, field) {
|
|
589
|
+
const amount = Number(readPath(record, field.path))
|
|
590
|
+
const rawLimit = typeof field.limitPath === 'string' && field.limitPath !== '' ? readPath(record, field.limitPath) : field.limit
|
|
591
|
+
const limit = rawLimit === null || rawLimit === undefined || rawLimit === '' ? NaN : Number(rawLimit)
|
|
592
|
+
const label = this.tr(field.label || '')
|
|
593
|
+
const value = this.valueOf(record, field)
|
|
594
|
+
if (!Number.isFinite(amount) || !Number.isFinite(limit) || limit <= 0) {
|
|
595
|
+
return { label, value, amount: Number.isFinite(amount) ? amount : null, limit: null, percent: 0, over: false }
|
|
596
|
+
}
|
|
597
|
+
const over = amount > limit
|
|
598
|
+
const limitText = this.format(limit, field, record)
|
|
599
|
+
return {
|
|
600
|
+
label,
|
|
601
|
+
value: over
|
|
602
|
+
? t('nextcloud-vue', '{value} of {limit}, over the limit', { value, limit: limitText })
|
|
603
|
+
: t('nextcloud-vue', '{value} of {limit}', { value, limit: limitText }),
|
|
604
|
+
|
|
605
|
+
amount,
|
|
606
|
+
limit,
|
|
607
|
+
percent: Math.max(0, Math.min(100, (amount / limit) * 100)),
|
|
608
|
+
over,
|
|
609
|
+
}
|
|
610
|
+
},
|
|
611
|
+
|
|
549
612
|
/**
|
|
550
613
|
* The display text of an entry of any kind: a lead's title, the parts of
|
|
551
614
|
* a line or text, a constant `text`, else the value at `path`.
|
|
@@ -988,6 +1051,10 @@ export default {
|
|
|
988
1051
|
? formatBoardDate(date, new Date(date.getFullYear() + 1, 0, 1))
|
|
989
1052
|
: formatBoardDate(date)
|
|
990
1053
|
}
|
|
1054
|
+
if (format === 'bytes') {
|
|
1055
|
+
const bytes = Number(raw)
|
|
1056
|
+
return Number.isFinite(bytes) && bytes >= 0 ? formatFileSize(bytes) : ''
|
|
1057
|
+
}
|
|
991
1058
|
if (format === 'user') {
|
|
992
1059
|
return userDisplayName(raw)
|
|
993
1060
|
}
|
|
@@ -1125,6 +1192,50 @@ export default {
|
|
|
1125
1192
|
color: var(--color-text-maxcontrast);
|
|
1126
1193
|
}
|
|
1127
1194
|
|
|
1195
|
+
.cn-facts-widget__meter {
|
|
1196
|
+
display: flex;
|
|
1197
|
+
flex-direction: column;
|
|
1198
|
+
gap: 4px;
|
|
1199
|
+
font-size: 14px;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
.cn-facts-widget__meter-head {
|
|
1203
|
+
display: flex;
|
|
1204
|
+
justify-content: space-between;
|
|
1205
|
+
gap: 16px;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
.cn-facts-widget__meter-label {
|
|
1209
|
+
color: var(--color-text-maxcontrast);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
.cn-facts-widget__meter-value {
|
|
1213
|
+
font-weight: 500;
|
|
1214
|
+
text-align: end;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
.cn-facts-widget__meter-track {
|
|
1218
|
+
height: 6px;
|
|
1219
|
+
border-radius: 3px;
|
|
1220
|
+
background: var(--color-background-darker);
|
|
1221
|
+
overflow: hidden;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
.cn-facts-widget__meter-bar {
|
|
1225
|
+
display: block;
|
|
1226
|
+
height: 100%;
|
|
1227
|
+
border-radius: inherit;
|
|
1228
|
+
background: var(--color-primary-element);
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
.cn-facts-widget__meter--over .cn-facts-widget__meter-value {
|
|
1232
|
+
color: var(--color-error-text);
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
.cn-facts-widget__meter--over .cn-facts-widget__meter-bar {
|
|
1236
|
+
background: var(--color-error);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1128
1239
|
.cn-facts-widget__muted {
|
|
1129
1240
|
margin: 0;
|
|
1130
1241
|
color: var(--color-text-maxcontrast);
|
|
@@ -223,8 +223,8 @@ export default {
|
|
|
223
223
|
/**
|
|
224
224
|
* Board look options, passed to CnFeaturesBoard: `layout` (`cards` or
|
|
225
225
|
* `rows`), `tabs`, `filter` (`chips` or `segmented`), `statusLabels`,
|
|
226
|
-
* `next`, `issueLabels`, `subtitle`, `notice`, `
|
|
227
|
-
* and `suggestButton`. Ignored in the default look.
|
|
226
|
+
* `next`, `issueLabels`, `subtitle`, `notice`, `noticeLink`,
|
|
227
|
+
* `summaries`, `documentationButton` and `suggestButton`. Ignored in the default look.
|
|
228
228
|
*
|
|
229
229
|
* @type {object}
|
|
230
230
|
*/
|
|
@@ -460,7 +460,7 @@ export default {
|
|
|
460
460
|
* @return {object}
|
|
461
461
|
*/
|
|
462
462
|
boardOptions() {
|
|
463
|
-
const keys = ['layout', 'tabs', 'filter', 'statusLabels', 'next', 'issueLabels', 'subtitle', 'notice', 'documentationButton', 'suggestButton']
|
|
463
|
+
const keys = ['layout', 'tabs', 'filter', 'statusLabels', 'next', 'issueLabels', 'subtitle', 'notice', 'noticeLink', 'summaries', 'documentationButton', 'suggestButton']
|
|
464
464
|
const options = {}
|
|
465
465
|
for (const key of keys) {
|
|
466
466
|
if (this.board && this.board[key] !== undefined && this.board[key] !== null) {
|
|
@@ -195,7 +195,7 @@
|
|
|
195
195
|
data-testid="cn-features-board-feature">
|
|
196
196
|
<span class="cn-features-board__row-text">
|
|
197
197
|
<span class="cn-features-board__row-title">{{ feature.title }}</span>
|
|
198
|
-
<span v-if="feature.summary" class="cn-features-board__row-summary">{{ feature.summary }}</span>
|
|
198
|
+
<span v-if="summaries && feature.summary" class="cn-features-board__row-summary">{{ feature.summary }}</span>
|
|
199
199
|
</span>
|
|
200
200
|
<CnStatusBadge
|
|
201
201
|
:label="statusText(feature)"
|
|
@@ -271,7 +271,16 @@
|
|
|
271
271
|
type="info"
|
|
272
272
|
class="cn-features-board__notice"
|
|
273
273
|
data-testid="cn-features-board-notice">
|
|
274
|
-
|
|
274
|
+
<template v-if="noticeParts.link">
|
|
275
|
+
{{ noticeParts.before }}<a
|
|
276
|
+
:href="noticeParts.link.href"
|
|
277
|
+
target="_blank"
|
|
278
|
+
rel="noopener noreferrer"
|
|
279
|
+
data-testid="cn-features-board-notice-link">{{ noticeParts.link.text }}</a>{{ noticeParts.after }}
|
|
280
|
+
</template>
|
|
281
|
+
<template v-else>
|
|
282
|
+
{{ noticeParts.before }}
|
|
283
|
+
</template>
|
|
275
284
|
</NcNoteCard>
|
|
276
285
|
</template>
|
|
277
286
|
</div>
|
|
@@ -397,6 +406,10 @@ export default {
|
|
|
397
406
|
suggestUrl: { type: String, default: '' },
|
|
398
407
|
/** Show the feature-request form as the header's primary button; without it, it is an entry in the Actions menu. */
|
|
399
408
|
suggestButton: { type: Boolean, default: true },
|
|
409
|
+
/** In the `rows` layout, draw each feature's summary under its title; false shows the title and status only. */
|
|
410
|
+
summaries: { type: Boolean, default: true },
|
|
411
|
+
/** A link inside the notice: `{ text, href }`; the first occurrence of `text` in the translated notice opens `href` in a new tab. Only http(s) and site-relative hrefs link. */
|
|
412
|
+
noticeLink: { type: Object, default: null },
|
|
400
413
|
/** Target of the OpenBuilt entry in the Actions menu. */
|
|
401
414
|
openbuiltUrl: { type: String, default: '' },
|
|
402
415
|
/** Target of the AI skills entry in the Actions menu. */
|
|
@@ -421,8 +434,10 @@ export default {
|
|
|
421
434
|
return readerLanguage()
|
|
422
435
|
},
|
|
423
436
|
|
|
437
|
+
/** The groups, their captions through the app's translate function. */
|
|
424
438
|
groups() {
|
|
425
439
|
return groupFeatures(this.features, this.categories, this.language)
|
|
440
|
+
.map((group) => ({ ...group, label: group.label ? this.tr(group.label) : '' }))
|
|
426
441
|
},
|
|
427
442
|
|
|
428
443
|
allFeatures() {
|
|
@@ -466,6 +481,27 @@ export default {
|
|
|
466
481
|
return this.title ? this.tr(this.title) : t('nextcloud-vue', 'Features and roadmap')
|
|
467
482
|
},
|
|
468
483
|
|
|
484
|
+
/**
|
|
485
|
+
* The notice split around its link: the text before, the link and the
|
|
486
|
+
* text after. Without a usable `noticeLink` (no match in the
|
|
487
|
+
* translated notice, or an href that is not http(s) or site-relative)
|
|
488
|
+
* `before` is the whole notice.
|
|
489
|
+
*
|
|
490
|
+
* @return {{before: string, link: ({text: string, href: string}|null), after: string}} The parts.
|
|
491
|
+
*/
|
|
492
|
+
noticeParts() {
|
|
493
|
+
const text = this.notice ? this.tr(this.notice) : ''
|
|
494
|
+
const link = this.noticeLink
|
|
495
|
+
const label = link && typeof link.text === 'string' ? this.tr(link.text) : ''
|
|
496
|
+
const href = link && typeof link.href === 'string' ? link.href.trim() : ''
|
|
497
|
+
const index = label ? text.indexOf(label) : -1
|
|
498
|
+
const allowed = (/^https?:\/\//i.test(href) || href.startsWith('/')) && safeHref(href) === href
|
|
499
|
+
if (index === -1 || !allowed) {
|
|
500
|
+
return { before: text, link: null, after: '' }
|
|
501
|
+
}
|
|
502
|
+
return { before: text.slice(0, index), link: { text: label, href }, after: text.slice(index + label.length) }
|
|
503
|
+
},
|
|
504
|
+
|
|
469
505
|
resolvedSubtitle() {
|
|
470
506
|
if (!this.subtitle) {
|
|
471
507
|
return ''
|