@conduction/nextcloud-vue 2.97.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/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/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/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/CnIndexPage/CnIndexPage.vue2.js +43 -3
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.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 +238 -2
- 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 +15 -1
- package/dist/esm/l10n/en.json.js.map +1 -1
- package/dist/esm/l10n/nl.json.js +30 -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 +424 -206
- 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/validateManifestV2.compiled.js +3 -3
- package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
- package/dist/nextcloud-vue.cjs.js +12538 -9716
- package/dist/nextcloud-vue.cjs.js.map +2 -2
- package/dist/nextcloud-vue.css +424 -206
- package/l10n/en.json +15 -1
- package/l10n/nl.json +30 -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/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/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/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue +3 -3
- package/src/components/CnFeaturesBoard/CnFeaturesBoard.vue +38 -2
- package/src/components/CnIndexPage/CnIndexPage.md +28 -0
- package/src/components/CnIndexPage/CnIndexPage.vue +43 -3
- 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 +237 -1
- package/src/composables/useOrganisationStore.js +111 -0
- package/src/index.js +2 -0
- package/src/schemas/app-manifest-v2.schema.json +33 -3
- 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/validateManifestV2.compiled.js +3 -3
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPDX-FileCopyrightText: 2026 Conduction B.V.
|
|
3
|
+
* SPDX-License-Identifier: EUPL-1.2
|
|
4
|
+
*
|
|
5
|
+
* CnChecklistRunWidget — self-registers the `checklist-run` widget type (a
|
|
6
|
+
* checklist answered a page of questions at a time, with a note and photos)
|
|
7
|
+
* into the shared dashboardWidgetRegistry at module load. Renderer only: the
|
|
8
|
+
* questions and where the answers go are declared in the manifest.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import CnChecklistRunWidget from './CnChecklistRunWidget.vue'
|
|
12
|
+
import { registerDashboardWidget } from '../CnWidgetGrid/dashboardWidgetRegistry.js'
|
|
13
|
+
|
|
14
|
+
registerDashboardWidget('checklist-run', {
|
|
15
|
+
renderer: CnChecklistRunWidget,
|
|
16
|
+
form: null,
|
|
17
|
+
defaultContent: {
|
|
18
|
+
questions: { register: '', schema: '', filter: {} },
|
|
19
|
+
pageSize: 1,
|
|
20
|
+
},
|
|
21
|
+
displayName: 'Checklist run',
|
|
22
|
+
icon: 'ClipboardCheckOutline',
|
|
23
|
+
surfaces: ['detail-page', 'app-dashboard'],
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
export { CnChecklistRunWidget }
|
|
27
|
+
export default CnChecklistRunWidget
|
|
@@ -408,6 +408,7 @@ import { fillTemplate, joinTemplateParts } from '../../utils/fillTemplate.js'
|
|
|
408
408
|
import { lensUnavailableText, readLensReports } from '../../utils/lensAvailability.js'
|
|
409
409
|
import { followLinkClick, openRowTarget, resolveHref } from '../../utils/linkNavigation.js'
|
|
410
410
|
import { nextSortState } from '../../utils/multiColumnSort.js'
|
|
411
|
+
import { readPath } from '../../utils/readPath.js'
|
|
411
412
|
import { isNewTabClick, isNewTabHandled, isRowMiddleClick, markNewTabHandled, preventMiddleClickAutoscroll } from '../../utils/rowAuxClick.js'
|
|
412
413
|
import { DEFAULT_ROW_INDICATOR_CAP, resolveRowIndicators } from '../../utils/rowIndicators.js'
|
|
413
414
|
import { columnsFromSchema } from '../../utils/schema.js'
|
|
@@ -1841,8 +1842,11 @@ export default {
|
|
|
1841
1842
|
if (v === undefined) {
|
|
1842
1843
|
return this.aggregateLoading ? '…' : '—'
|
|
1843
1844
|
}
|
|
1845
|
+
if (v === null && !(typeof col.aggregate.zeroLabel === 'string' && col.aggregate.zeroLabel !== '')) {
|
|
1846
|
+
return '—'
|
|
1847
|
+
}
|
|
1844
1848
|
const zeroLabel = col.aggregate.zeroLabel
|
|
1845
|
-
if ((v === 0 || v === '0') && typeof zeroLabel === 'string' && zeroLabel !== '') {
|
|
1849
|
+
if ((v === 0 || v === '0' || v === null) && typeof zeroLabel === 'string' && zeroLabel !== '') {
|
|
1846
1850
|
return typeof this.cnTranslate === 'function' ? this.cnTranslate(zeroLabel) : zeroLabel
|
|
1847
1851
|
}
|
|
1848
1852
|
return v
|
|
@@ -1872,17 +1876,21 @@ export default {
|
|
|
1872
1876
|
},
|
|
1873
1877
|
|
|
1874
1878
|
/**
|
|
1875
|
-
* For every column that declares `aggregate
|
|
1876
|
-
*
|
|
1877
|
-
*
|
|
1879
|
+
* For every column that declares `aggregate`, issue one request per
|
|
1880
|
+
* visible row against the related OpenRegister collection and cache the
|
|
1881
|
+
* answer in `aggregateValues`: `op: "count"` asks `_limit=0` for the
|
|
1882
|
+
* total, `op: "max"` asks for the one object with the highest `field`
|
|
1883
|
+
* (`_limit=1`, sorted descending) and keeps that value, null when there
|
|
1884
|
+
* is none (shown as `zeroLabel`, else a dash).
|
|
1878
1885
|
* Batched with `Promise.all`; a per-request failure degrades that one
|
|
1879
1886
|
* cell to `'—'` (logged), never the page. A monotonic request id
|
|
1880
1887
|
* discards a stale batch when `rows` / columns change mid-flight.
|
|
1881
1888
|
*
|
|
1882
1889
|
* @return {Promise<void>}
|
|
1890
|
+
* @spec openspec/changes/masked-formatter-max-aggregate/specs/index-page/spec.md#requirement-a-column-aggregate-can-take-the-highest-value
|
|
1883
1891
|
*/
|
|
1884
1892
|
async loadAggregates() {
|
|
1885
|
-
const aggCols = this.effectiveColumns.filter((c) => c && c.aggregate && c.aggregate.op === 'count')
|
|
1893
|
+
const aggCols = this.effectiveColumns.filter((c) => c && c.aggregate && (c.aggregate.op === 'count' || (c.aggregate.op === 'max' && c.aggregate.field)))
|
|
1886
1894
|
if (aggCols.length === 0) {
|
|
1887
1895
|
if (Object.keys(this.aggregateValues).length > 0) {
|
|
1888
1896
|
this.aggregateValues = {}
|
|
@@ -1903,11 +1911,21 @@ export default {
|
|
|
1903
1911
|
continue
|
|
1904
1912
|
}
|
|
1905
1913
|
const where = this.resolveAggregateWhere(agg.where, row)
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1914
|
+
// `max`: the highest `field` among the related objects, as the
|
|
1915
|
+
// first of them sorted on it descending ("Laatste contact").
|
|
1916
|
+
const isMax = agg.op === 'max'
|
|
1917
|
+
const params = isMax
|
|
1918
|
+
? { ...where, _limit: 1, [`_order[${agg.field}]`]: 'desc' }
|
|
1919
|
+
: { ...where, _limit: 0 }
|
|
1920
|
+
jobs.push(axios.get(generateUrl(`/apps/openregister/api/objects/${agg.register}/${agg.schema}`), { params })
|
|
1909
1921
|
.then((res) => {
|
|
1910
1922
|
const d = res && res.data
|
|
1923
|
+
if (isMax) {
|
|
1924
|
+
const first = d && Array.isArray(d.results) ? d.results[0] : undefined
|
|
1925
|
+
const top = first === undefined ? undefined : readPath(first, agg.field)
|
|
1926
|
+
next[rowKey][col.key] = top === undefined || top === '' ? null : top
|
|
1927
|
+
return
|
|
1928
|
+
}
|
|
1911
1929
|
next[rowKey][col.key] = (d && (d.total ?? (Array.isArray(d.results) ? d.results.length : undefined))) ?? 0
|
|
1912
1930
|
})
|
|
1913
1931
|
.catch((e) => {
|
|
@@ -434,6 +434,54 @@ Four levels of action and the case surfaces around them. All opt in. See [the re
|
|
|
434
434
|
|
|
435
435
|
`previousTo` and `nextTo` (default `null`) take `useListNavigation`'s `previousRoute` / `nextRoute` and turn the previous and next controls into real links, so they can be opened in a new tab. The link navigates by itself; `previous-record` / `next-record` are still emitted, with `{ event, to }`, and `goPrevious` / `goNext` do not push again when they receive that payload.
|
|
436
436
|
|
|
437
|
+
## Records that are not objects (`config.entitySource`)
|
|
438
|
+
|
|
439
|
+
A detail page reads its record from the object store through `register` +
|
|
440
|
+
`schema`. A record that is not a register/schema object names a registered
|
|
441
|
+
entity source instead, the same registry and the same name `CnIndexPage`
|
|
442
|
+
lists it with. `organisations` reads the OpenRegister organisation whose uuid
|
|
443
|
+
is the route's id (`GET /apps/openregister/api/organisations/{uuid}`):
|
|
444
|
+
|
|
445
|
+
```json
|
|
446
|
+
{
|
|
447
|
+
"id": "organisation-detail",
|
|
448
|
+
"route": "/organisations/:id",
|
|
449
|
+
"type": "detail",
|
|
450
|
+
"config": {
|
|
451
|
+
"entitySource": "organisations",
|
|
452
|
+
"statusPill": { "field": "status" },
|
|
453
|
+
"typePill": { "field": "type" },
|
|
454
|
+
"headerFields": [{ "key": "slug" }],
|
|
455
|
+
"layout": [{ "id": "1", "widgetId": "facts", "gridX": 0, "gridY": 0, "gridWidth": 6 }],
|
|
456
|
+
"widgets": [{
|
|
457
|
+
"id": "facts",
|
|
458
|
+
"type": "facts",
|
|
459
|
+
"title": "Details",
|
|
460
|
+
"content": { "fields": [
|
|
461
|
+
{ "path": "slug", "label": "Slug" },
|
|
462
|
+
{ "path": "created", "label": "Created", "format": "date" },
|
|
463
|
+
{ "path": "contactEmail", "label": "Contact", "format": "email" },
|
|
464
|
+
{ "path": "kvk", "label": "KvK" }
|
|
465
|
+
] }
|
|
466
|
+
}]
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
The record feeds everything a fetched object feeds: the title, the pills, the
|
|
472
|
+
header fields, the widgets, and the `@objectId` / `@object.*` tokens. A tab
|
|
473
|
+
that lists register objects belonging to the organisation filters on
|
|
474
|
+
`@objectId`, e.g. `{ "type": "object-list", "content": { "register": "...",
|
|
475
|
+
"schema": "...", "filter": { "tenantRef": "@objectId" } } }`. The record's
|
|
476
|
+
`id` is its uuid, as an object's is.
|
|
477
|
+
|
|
478
|
+
`entitySource` wins over `register` + `schema`. A name that is unknown, or a
|
|
479
|
+
source that cannot read one record, shows no record and warns in the console.
|
|
480
|
+
A refusal (403) or a missing record (404) shows the not-found state; which
|
|
481
|
+
records the viewer may read is OpenRegister's decision. Refresh and
|
|
482
|
+
`cn:page:refresh` re-read the record. There is no Edit: a record source has
|
|
483
|
+
no schema form.
|
|
484
|
+
|
|
437
485
|
## Not found
|
|
438
486
|
|
|
439
487
|
When the schema-driven fetch answers 404 the page shows a not-found state whose back button is a router link to `notFoundRoute` (default `null`, which means the app root `/`). `notFoundRouteLabel` names that page in the button's "Back to …" label.
|
|
@@ -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
|
|
@@ -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 ''
|
|
@@ -535,6 +535,34 @@ What the source brings, all overridable by the manifest:
|
|
|
535
535
|
- Scope stays the endpoint's session decision. The loader drops any
|
|
536
536
|
user-naming key, so a manifest cannot ask for someone else's inbox.
|
|
537
537
|
|
|
538
|
+
### The `organisations` source: OpenRegister organisations
|
|
539
|
+
|
|
540
|
+
`entitySource: "organisations"` lists the OpenRegister organisations the
|
|
541
|
+
viewer belongs to, from `GET /apps/openregister/api/organisations`. An
|
|
542
|
+
organisation is not a register/schema object, so self-fetch cannot reach it.
|
|
543
|
+
|
|
544
|
+
```json
|
|
545
|
+
{
|
|
546
|
+
"id": "organisations",
|
|
547
|
+
"route": "/organisations",
|
|
548
|
+
"type": "index",
|
|
549
|
+
"config": { "entitySource": "organisations", "rowRoute": "organisation-detail" }
|
|
550
|
+
}
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
- Columns: name, status (a pill with the lifecycle state in words), type and
|
|
554
|
+
created. A manifest `columns` list still wins; every field of the
|
|
555
|
+
organisation can be a column (`slug`, `contactEmail`, `kvk`, ...).
|
|
556
|
+
- A row's `id` is the organisation's uuid, so `rowRoute` pushes
|
|
557
|
+
`{ name: rowRoute, params: { id: <uuid> } }`.
|
|
558
|
+
- No Add, Copy or Delete: creating an organisation is not this page's object
|
|
559
|
+
form, and OpenRegister has no delete for one.
|
|
560
|
+
- Which organisations are listed is OpenRegister's decision from the
|
|
561
|
+
session. The endpoint takes no parameters, so no config key reaches it.
|
|
562
|
+
|
|
563
|
+
The same name works on a `type: "detail"` page, which then shows ONE
|
|
564
|
+
organisation (see CnDetailPage, "Records that are not objects").
|
|
565
|
+
|
|
538
566
|
### Search fields on a named source
|
|
539
567
|
|
|
540
568
|
Available from the release that follows 3.2.0. An app on 3.2.0 or older ignores `sidebar.fields`, so bump the dependency before declaring them.
|