@conduction/nextcloud-vue 2.127.0 → 2.128.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/CnDashboardPage/CnDashboardPage.vue.js +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +3 -113
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +12 -12
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
- package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +34 -1
- package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
- package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +16 -8
- package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +153 -8
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +169 -58
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
- package/dist/esm/components/CnTileWidget/CnTileWidget.vue.js +1 -1
- package/dist/esm/components/CnTileWidget/CnTileWidget.vue2.js +11 -0
- package/dist/esm/components/CnTileWidget/CnTileWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnTileWidget/CnTileWidget.vue3.js +14 -4
- package/dist/esm/components/CnTileWidget/CnTileWidget.vue3.js.map +1 -1
- package/dist/esm/library-style.js +1 -1
- package/dist/esm/mixins/pageFilters.js +142 -0
- package/dist/esm/mixins/pageFilters.js.map +1 -0
- package/dist/esm/nextcloud-vue.css +269 -74
- package/dist/esm/utils/actionLink.js +9 -3
- package/dist/esm/utils/actionLink.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 +1176 -828
- package/dist/nextcloud-vue.cjs.js.map +2 -2
- package/dist/nextcloud-vue.css +269 -74
- package/package.json +1 -1
- package/src/components/CnDashboardPage/CnDashboardPage.vue +3 -113
- package/src/components/CnDataTable/CnDataTable.vue +40 -1
- package/src/components/CnDetailPage/CnDetailPage.md +2 -0
- package/src/components/CnDetailPage/CnDetailPage.vue +237 -8
- package/src/components/CnTileWidget/CnTileWidget.vue +30 -2
- package/src/css/detail-page.css +42 -0
- package/src/css/dialog.css +13 -0
- package/src/css/look-board-detail.css +1 -1
- package/src/css/look-board-index.css +8 -0
- package/src/css/look-board.css +97 -0
- package/src/css/table.css +12 -0
- package/src/mixins/pageFilters.js +138 -0
- package/src/schemas/app-manifest-v2.schema.json +41 -3
- package/src/utils/actionLink.js +9 -3
- package/src/utils/validateManifestV2.compiled.js +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@conduction/nextcloud-vue",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.128.0",
|
|
4
4
|
"description": "Shared Vue component library for Conduction Nextcloud apps — complements @nextcloud/vue with higher-level components, OpenRegister integration, and NL Design System support",
|
|
5
5
|
"homepage": "https://github.com/ConductionNL/nextcloud-vue#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -1078,6 +1078,7 @@ import CnWidgetWrapper from '../CnWidgetWrapper/CnWidgetWrapper.vue'
|
|
|
1078
1078
|
import { interpolateUrlTokens } from '../../composables/useEndpointSource.js'
|
|
1079
1079
|
import { useIntegrationRegistry } from '../../composables/useIntegrationRegistry.js'
|
|
1080
1080
|
import { normalizeLook } from '../../composables/useLook.js'
|
|
1081
|
+
import { pageFilters } from '../../mixins/pageFilters.js'
|
|
1081
1082
|
import { pageViews } from '../../mixins/pageViews.js'
|
|
1082
1083
|
import { mergeUserLayout, resolveUserLayoutApi } from '../../store/plugins/dashboardLayouts.js'
|
|
1083
1084
|
import { headerActionAsLink } from '../../utils/actionLink.js'
|
|
@@ -1085,8 +1086,6 @@ import { dispatchAction } from '../../utils/actionsDispatcher.js'
|
|
|
1085
1086
|
import { isAppInstalled } from '../../utils/appInstalled.js'
|
|
1086
1087
|
import { contentHeaderLink } from '../../utils/contentHeaderLink.js'
|
|
1087
1088
|
import { pickDeclaredProps } from '../../utils/declaredProps.js'
|
|
1088
|
-
import { isOptionsFrom, loadOptionsFrom } from '../../utils/optionsFrom.js'
|
|
1089
|
-
import { generatedPageFilterOptions } from '../../utils/periodOptions.js'
|
|
1090
1089
|
import { isOptionalUnresolved, resolveFilterValue } from '../../utils/resolveFilterTokens.js'
|
|
1091
1090
|
import { userLocale } from '../../utils/userLocale.js'
|
|
1092
1091
|
import { compareVisibleWhen, evaluateWorkspaceCondition, isWorkspaceCondition, readVisibleWhenValue } from '../../utils/visibleWhen.js'
|
|
@@ -1317,7 +1316,7 @@ export default {
|
|
|
1317
1316
|
CnWidgetIcon,
|
|
1318
1317
|
},
|
|
1319
1318
|
|
|
1320
|
-
mixins: [pageViews],
|
|
1319
|
+
mixins: [pageFilters, pageViews],
|
|
1321
1320
|
|
|
1322
1321
|
inject: {
|
|
1323
1322
|
/**
|
|
@@ -2117,10 +2116,6 @@ export default {
|
|
|
2117
2116
|
|
|
2118
2117
|
data() {
|
|
2119
2118
|
return {
|
|
2120
|
-
/** Options read through a page filter's `optionsFrom`, by filter key. */
|
|
2121
|
-
pageFilterFetched: {},
|
|
2122
|
-
/** Whether a page filter's `optionsFrom` read is in flight, by filter key. */
|
|
2123
|
-
pageFilterLoading: {},
|
|
2124
2119
|
isEditing: false,
|
|
2125
2120
|
/**
|
|
2126
2121
|
* This user's arranged layout, when `userLayout` is on.
|
|
@@ -3086,112 +3081,6 @@ export default {
|
|
|
3086
3081
|
},
|
|
3087
3082
|
|
|
3088
3083
|
methods: {
|
|
3089
|
-
/**
|
|
3090
|
-
* Seed the reactive workspace context with each page filter's default
|
|
3091
|
-
* (its `default`, else the first option's value) so endpoint-bound
|
|
3092
|
-
* widgets have a value to interpolate on first render. Only writes keys
|
|
3093
|
-
* that aren't already present, so a value another widget set survives.
|
|
3094
|
-
*
|
|
3095
|
-
* @return {void}
|
|
3096
|
-
*/
|
|
3097
|
-
initPageFilters() {
|
|
3098
|
-
for (const pf of this.pageFilters || []) {
|
|
3099
|
-
if (!pf || !pf.key) {
|
|
3100
|
-
continue
|
|
3101
|
-
}
|
|
3102
|
-
if (this.workspaceContext[pf.key] !== undefined) {
|
|
3103
|
-
continue
|
|
3104
|
-
}
|
|
3105
|
-
const listed = Array.isArray(pf.options) ? pf.options : generatedPageFilterOptions(pf)
|
|
3106
|
-
const fallback = (listed && listed.length && pf.clearable !== true) ? listed[0].value : undefined
|
|
3107
|
-
const value = pf.default !== undefined ? pf.default : fallback
|
|
3108
|
-
if (value !== undefined) {
|
|
3109
|
-
this.workspaceContext[pf.key] = value
|
|
3110
|
-
}
|
|
3111
|
-
}
|
|
3112
|
-
},
|
|
3113
|
-
|
|
3114
|
-
/**
|
|
3115
|
-
* The currently-selected option object for a page filter's NcSelect
|
|
3116
|
-
* (matched by `value` against the workspace context), or null.
|
|
3117
|
-
*
|
|
3118
|
-
* @param {object} pf The page-filter descriptor.
|
|
3119
|
-
* @return {object|null}
|
|
3120
|
-
*/
|
|
3121
|
-
selectedPageFilterOption(pf) {
|
|
3122
|
-
if (!pf || !pf.key) {
|
|
3123
|
-
return null
|
|
3124
|
-
}
|
|
3125
|
-
const current = this.workspaceContext[pf.key]
|
|
3126
|
-
return this.pageFilterSegments(pf).find((o) => o.value === current) || null
|
|
3127
|
-
},
|
|
3128
|
-
|
|
3129
|
-
/**
|
|
3130
|
-
* A page filter's options, labels through the host translate: the
|
|
3131
|
-
* segments of a segmented filter and the options of a select.
|
|
3132
|
-
*
|
|
3133
|
-
* @spec openspec/changes/dashboard-board-termijnen/specs/dashboard-page/spec.md#requirement-a-page-filter-as-a-segmented-group-on-the-description-line
|
|
3134
|
-
* @param {object} pf The page-filter descriptor.
|
|
3135
|
-
* @return {Array<{value: (string|number), label: string}>}
|
|
3136
|
-
*/
|
|
3137
|
-
pageFilterSegments(pf) {
|
|
3138
|
-
const options = Array.isArray(pf.options) ? pf.options : (generatedPageFilterOptions(pf) || this.pageFilterFetched[pf.key] || [])
|
|
3139
|
-
return options.map((o) => ({ value: o.value, label: o.label ? this.effectiveTranslate(o.label) : String(o.value) }))
|
|
3140
|
-
},
|
|
3141
|
-
|
|
3142
|
-
/**
|
|
3143
|
-
* Read the options of every page filter with `optionsFrom`, then seed
|
|
3144
|
-
* a filter that is not clearable with its first option when it has no
|
|
3145
|
-
* value yet.
|
|
3146
|
-
*
|
|
3147
|
-
* @spec openspec/changes/page-filter-options-from/specs/dashboard-page/spec.md#requirement-a-page-filter-can-read-its-options
|
|
3148
|
-
* @return {Promise<void>}
|
|
3149
|
-
*/
|
|
3150
|
-
async loadPageFilterOptions() {
|
|
3151
|
-
const ctx = { workspace: this.workspaceContext || {} }
|
|
3152
|
-
await Promise.all((this.pageFilters || [])
|
|
3153
|
-
.filter((pf) => pf && pf.key && !Array.isArray(pf.options) && pf.optionsFrom && typeof pf.optionsFrom === 'object')
|
|
3154
|
-
.map(async (pf) => {
|
|
3155
|
-
const from = pf.optionsFrom
|
|
3156
|
-
if (!isOptionsFrom(from)) {
|
|
3157
|
-
return
|
|
3158
|
-
}
|
|
3159
|
-
this.pageFilterLoading = { ...this.pageFilterLoading, [pf.key]: true }
|
|
3160
|
-
let options
|
|
3161
|
-
try {
|
|
3162
|
-
options = (await loadOptionsFrom(from, ctx)).map((o) => ({ value: o.id, label: o.label }))
|
|
3163
|
-
} catch {
|
|
3164
|
-
options = []
|
|
3165
|
-
}
|
|
3166
|
-
this.pageFilterFetched = { ...this.pageFilterFetched, [pf.key]: options }
|
|
3167
|
-
this.pageFilterLoading = { ...this.pageFilterLoading, [pf.key]: false }
|
|
3168
|
-
if (pf.clearable !== true && pf.default === undefined && this.workspaceContext[pf.key] === undefined && options.length > 0) {
|
|
3169
|
-
this.workspaceContext[pf.key] = options[0].value
|
|
3170
|
-
}
|
|
3171
|
-
}))
|
|
3172
|
-
},
|
|
3173
|
-
|
|
3174
|
-
/**
|
|
3175
|
-
* Write a page filter's new selection into the reactive workspace
|
|
3176
|
-
* context so every `@page.<key>` / `@workspace.<key>` token re-resolves.
|
|
3177
|
-
*
|
|
3178
|
-
* @param {object} pf The page-filter descriptor.
|
|
3179
|
-
* @param {object|null} option The chosen NcSelect option (`{ value, label }`).
|
|
3180
|
-
* @return {void}
|
|
3181
|
-
*/
|
|
3182
|
-
onPageFilterChange(pf, option) {
|
|
3183
|
-
if (!pf || !pf.key) {
|
|
3184
|
-
return
|
|
3185
|
-
}
|
|
3186
|
-
const value = option && typeof option === 'object' ? option.value : (option ?? null)
|
|
3187
|
-
this.workspaceContext[pf.key] = value
|
|
3188
|
-
/**
|
|
3189
|
-
* @event page-filter-change Emitted when a page-level filter selection changes.
|
|
3190
|
-
* @type {{ key: string, value: (string|number|null) }}
|
|
3191
|
-
*/
|
|
3192
|
-
this.$emit('page-filter-change', { key: pf.key, value })
|
|
3193
|
-
},
|
|
3194
|
-
|
|
3195
3084
|
/**
|
|
3196
3085
|
* Re-emit the page-level CnActionsMenu `@refresh` to the host,
|
|
3197
3086
|
* passing the synthetic event through so a host listener can
|
|
@@ -4999,6 +4888,7 @@ export default {
|
|
|
4999
4888
|
// by the modal) must still fall through to content.title, which the
|
|
5000
4889
|
// form validates as required and so is always populated.
|
|
5001
4890
|
title: def.title || c.title,
|
|
4891
|
+
subtitle: c.subtitle ?? def.subtitle,
|
|
5002
4892
|
icon: c.icon ?? def.icon,
|
|
5003
4893
|
iconType: c.iconType ?? def.iconType,
|
|
5004
4894
|
backgroundColor: c.backgroundColor ?? def.backgroundColor,
|
|
@@ -313,6 +313,12 @@
|
|
|
313
313
|
<span class="hidden-visually">{{ translateLabel(indicator.text) }}</span>
|
|
314
314
|
</span>
|
|
315
315
|
</span>
|
|
316
|
+
<!-- A muted line above the value, from `col.overline` or
|
|
317
|
+
`col.overlineText` ("Zaken van" over the organisation). -->
|
|
318
|
+
<span
|
|
319
|
+
v-if="overlineValue(row, col)"
|
|
320
|
+
class="cn-table-cell__overline"
|
|
321
|
+
data-testid="cn-cell-overline">{{ overlineValue(row, col) }}</span>
|
|
316
322
|
<!-- @slot Per-column cell override (`#column-<key>`), scoped with { row, value }. Wins over CnCellRenderer. -->
|
|
317
323
|
<slot :name="'column-' + col.key" :row="row" :value="cellValue(row, col)">
|
|
318
324
|
<!-- Every column renders through CnCellRenderer: it resolves
|
|
@@ -1925,7 +1931,40 @@ export default {
|
|
|
1925
1931
|
* @return {string} The secondary text, or ''.
|
|
1926
1932
|
*/
|
|
1927
1933
|
secondaryValue(row, col) {
|
|
1928
|
-
|
|
1934
|
+
return this.cellLineValue(row, col?.secondary)
|
|
1935
|
+
},
|
|
1936
|
+
|
|
1937
|
+
/**
|
|
1938
|
+
* The muted line above a cell's value, from `col.overline` (read like
|
|
1939
|
+
* `secondary`) or else `col.overlineText`, a fixed text in the host's
|
|
1940
|
+
* translation (PtToegangsverzoeken "Zaken van"). Returns '' when there
|
|
1941
|
+
* is nothing to draw.
|
|
1942
|
+
*
|
|
1943
|
+
* @spec openspec/changes/table-cell-overline/specs/data-table/spec.md#requirement-a-table-column-can-carry-a-muted-line-above-its-value
|
|
1944
|
+
* @param {object} row The row.
|
|
1945
|
+
* @param {object} col The column definition.
|
|
1946
|
+
* @return {string} The overline text, or ''.
|
|
1947
|
+
*/
|
|
1948
|
+
overlineValue(row, col) {
|
|
1949
|
+
const fromRow = this.cellLineValue(row, col?.overline)
|
|
1950
|
+
if (fromRow !== '') {
|
|
1951
|
+
return fromRow
|
|
1952
|
+
}
|
|
1953
|
+
return typeof col?.overlineText === 'string' && col.overlineText !== '' ? this.translateLabel(col.overlineText) : ''
|
|
1954
|
+
},
|
|
1955
|
+
|
|
1956
|
+
/**
|
|
1957
|
+
* One extra line of a cell from its spec: a function is called with the
|
|
1958
|
+
* row; a string with `{field}` placeholders is a template filled from
|
|
1959
|
+
* the row; any other string is a field key. Shared by `secondary` and
|
|
1960
|
+
* `overline`.
|
|
1961
|
+
*
|
|
1962
|
+
* @spec openspec/changes/table-cell-overline/specs/data-table/spec.md#requirement-a-table-column-can-carry-a-muted-line-above-its-value
|
|
1963
|
+
* @param {object} row The row.
|
|
1964
|
+
* @param {Function|string|undefined} spec The line's spec.
|
|
1965
|
+
* @return {string} The text, or ''.
|
|
1966
|
+
*/
|
|
1967
|
+
cellLineValue(row, spec) {
|
|
1929
1968
|
if (typeof spec === 'function') {
|
|
1930
1969
|
const out = spec(row)
|
|
1931
1970
|
return out === null || out === undefined ? '' : String(out)
|
|
@@ -182,6 +182,8 @@ export default {
|
|
|
182
182
|
| `icon` | String | `''` | Optional MDI icon name rendered in the header via `CnIcon` |
|
|
183
183
|
| `iconSize` | Number | `28` | Icon size in pixels |
|
|
184
184
|
| `loadingLabel` | String | `'Loading...'` | Message shown below the spinner during loading |
|
|
185
|
+
| `band` | Array | `[]` | A row of widgets across the whole page, above the body and the side column (manifest `config.band`). Each entry is a widget id from `widgets`, a widget definition, or `{ widgetId, span, chrome }`: `span` is the entry's share of the row in twelfths (default 12), and `chrome` is `card` or `bare` (a banner defaults to bare). Each entry follows its `visibleWhen`. |
|
|
186
|
+
| `pageFilters` | Array | `[]` | Page-level filters (manifest `config.pageFilters`), as on a dashboard: each writes its choice into the page's workspace context, which the widgets read through `@page.<key>` / `@workspace.<key>`. Drawn in the band, above its widgets (PtPortal "Periode"). |
|
|
185
187
|
| `sidebarOpen` | Boolean | `true` | Whether the sidebar starts expanded |
|
|
186
188
|
| `objectType` | String | `''` | Registered object type slug for the sidebar |
|
|
187
189
|
| `objectId` | String\|Number | `''` | Object ID to display in the sidebar |
|
|
@@ -647,6 +647,90 @@
|
|
|
647
647
|
</template>
|
|
648
648
|
</CnNextStepCard>
|
|
649
649
|
|
|
650
|
+
<!-- The band (manifest `config.band`): a row of widgets across the whole
|
|
651
|
+
page, above the body and the side column (PtPortal: the traffic
|
|
652
|
+
figures and the start-page notice). Each entry takes `span`
|
|
653
|
+
twelfths of the row and the row wraps. -->
|
|
654
|
+
<div
|
|
655
|
+
v-if="showsBand"
|
|
656
|
+
class="cn-detail-page__band"
|
|
657
|
+
data-testid="cn-detail-page-band">
|
|
658
|
+
<!-- The page filters (`config.pageFilters`), right-aligned above the
|
|
659
|
+
band's widgets; each writes the page's workspace context. -->
|
|
660
|
+
<div
|
|
661
|
+
v-if="detailPageFilters.length > 0"
|
|
662
|
+
class="cn-detail-page__page-filters"
|
|
663
|
+
data-testid="cn-detail-page-page-filters">
|
|
664
|
+
<template v-for="pf in detailPageFilters" :key="pf.key">
|
|
665
|
+
<div v-if="pf.control === 'segmented'" class="cn-detail-page__page-filter">
|
|
666
|
+
<span v-if="pf.label" class="cn-detail-page__page-filter-label">{{ effectiveTranslate(pf.label) }}</span>
|
|
667
|
+
<CnSegmentedControl
|
|
668
|
+
size="compact"
|
|
669
|
+
mode="toggle"
|
|
670
|
+
:options="pageFilterSegments(pf)"
|
|
671
|
+
:modelValue="workspaceContext[pf.key]"
|
|
672
|
+
:ariaLabel="pf.label ? effectiveTranslate(pf.label) : pf.key"
|
|
673
|
+
:data-testid="'cn-page-filter-' + pf.key"
|
|
674
|
+
@update:modelValue="onPageFilterChange(pf, $event)" />
|
|
675
|
+
</div>
|
|
676
|
+
<label v-else class="cn-detail-page__page-filter">
|
|
677
|
+
<span v-if="pf.label" class="cn-detail-page__page-filter-label">{{ effectiveTranslate(pf.label) }}</span>
|
|
678
|
+
<NcSelect
|
|
679
|
+
:modelValue="selectedPageFilterOption(pf)"
|
|
680
|
+
:options="pageFilterSegments(pf)"
|
|
681
|
+
:clearable="pf.clearable === true"
|
|
682
|
+
:placeholder="pf.placeholder ? effectiveTranslate(pf.placeholder) : undefined"
|
|
683
|
+
:loading="pageFilterLoading[pf.key] === true"
|
|
684
|
+
:inputLabel="pf.label ? effectiveTranslate(pf.label) : pf.key"
|
|
685
|
+
label="label"
|
|
686
|
+
:data-testid="'cn-page-filter-' + pf.key"
|
|
687
|
+
@update:modelValue="onPageFilterChange(pf, $event)" />
|
|
688
|
+
</label>
|
|
689
|
+
</template>
|
|
690
|
+
</div>
|
|
691
|
+
<div
|
|
692
|
+
v-for="entry in bandWidgets"
|
|
693
|
+
:key="`band-${entry.widget.id}`"
|
|
694
|
+
class="cn-detail-page__band-item"
|
|
695
|
+
:class="{ 'cn-detail-page__band-item--full': entry.span === 12 }"
|
|
696
|
+
:style="{ '--cn-band-span': entry.span }"
|
|
697
|
+
:data-testid="`cn-detail-page-band-${entry.widget.id}`">
|
|
698
|
+
<!--
|
|
699
|
+
@slot `widget-${widget.id}`
|
|
700
|
+
@description The same per-widget slot the body grid and the side
|
|
701
|
+
column offer, for a widget placed in the band.
|
|
702
|
+
-->
|
|
703
|
+
<slot
|
|
704
|
+
:name="`widget-${entry.widget.id}`"
|
|
705
|
+
:item="{ id: entry.widget.id, widgetId: entry.widget.id }"
|
|
706
|
+
:widget="entry.widget"
|
|
707
|
+
:objectId="objectId"
|
|
708
|
+
:object="resolvedObject"
|
|
709
|
+
:objectData="resolvedObject"
|
|
710
|
+
:objectType="resolvedObjectType"
|
|
711
|
+
:register="register"
|
|
712
|
+
:schema="schema">
|
|
713
|
+
<CnDetailWidgetHost
|
|
714
|
+
:widget="entry.widget"
|
|
715
|
+
:chrome="entry.chrome"
|
|
716
|
+
:showCardTitle="true"
|
|
717
|
+
:showActions="effectiveShowWidgetActions"
|
|
718
|
+
:objectId="objectId"
|
|
719
|
+
:object="currentObject"
|
|
720
|
+
:objectType="resolvedObjectType"
|
|
721
|
+
:schemaObject="currentSchema"
|
|
722
|
+
:register="register"
|
|
723
|
+
:schema="schema"
|
|
724
|
+
:store="effectiveObjectStore"
|
|
725
|
+
:surface="surface"
|
|
726
|
+
:integrationContext="effectiveIntegrationContext"
|
|
727
|
+
:hideEmpty="hideEmpty"
|
|
728
|
+
:cnRegistry="cnRegistry"
|
|
729
|
+
:availableWidgets="widgets" />
|
|
730
|
+
</slot>
|
|
731
|
+
</div>
|
|
732
|
+
</div>
|
|
733
|
+
|
|
650
734
|
<!-- Not-found state: the record behind the address does not exist. -->
|
|
651
735
|
<div v-if="objectNotFound" class="cn-detail-page__not-found" data-testid="cn-detail-page-not-found">
|
|
652
736
|
<!--
|
|
@@ -1275,7 +1359,7 @@
|
|
|
1275
1359
|
import { getCurrentUser } from '@nextcloud/auth'
|
|
1276
1360
|
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
|
|
1277
1361
|
import { translate as t } from '@nextcloud/l10n'
|
|
1278
|
-
import { NcActionButton, NcActionCaption, NcActionLink, NcActionSeparator, NcButton, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
|
|
1362
|
+
import { NcActionButton, NcActionCaption, NcActionLink, NcActionSeparator, NcButton, NcEmptyContent, NcLoadingIcon, NcSelect } from '@nextcloud/vue'
|
|
1279
1363
|
import { computed, markRaw, provide, ref, watch } from 'vue'
|
|
1280
1364
|
import AlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue'
|
|
1281
1365
|
import ArrowLeft from 'vue-material-design-icons/ArrowLeft.vue'
|
|
@@ -1319,9 +1403,10 @@ import { useObjectPresence } from '../../composables/useObjectPresence.js'
|
|
|
1319
1403
|
import { useObjectSubscription } from '../../composables/useObjectSubscription.js'
|
|
1320
1404
|
import { createRefLabelResolver } from '../../composables/useRefLabels.js'
|
|
1321
1405
|
import { gridLayout } from '../../mixins/gridLayout.js'
|
|
1406
|
+
import { pageFilters } from '../../mixins/pageFilters.js'
|
|
1322
1407
|
import { pageViews } from '../../mixins/pageViews.js'
|
|
1323
1408
|
import { useObjectStore } from '../../store/index.js'
|
|
1324
|
-
import { headerActionAsLink } from '../../utils/actionLink.js'
|
|
1409
|
+
import { headerActionAsLink, resolveManifestHref, routeHref } from '../../utils/actionLink.js'
|
|
1325
1410
|
import { isAppInstalled } from '../../utils/appInstalled.js'
|
|
1326
1411
|
import { compactLayoutRows } from '../../utils/dashboardPlacement.js'
|
|
1327
1412
|
import { defaultDetailGrid } from '../../utils/defaultDetailGrid.js'
|
|
@@ -1345,6 +1430,8 @@ import { firstFilled } from '../../utils/fillTemplate.js'
|
|
|
1345
1430
|
import { cnGridCellStyle, hasGridRow } from '../../utils/grid.js'
|
|
1346
1431
|
import { isActivityWidget, resolveHeaderMeta } from '../../utils/headerMeta.js'
|
|
1347
1432
|
import { prefixUrl } from '../../utils/headers.js'
|
|
1433
|
+
import { interpolateUrlTokens } from '../../utils/interpolateUrlTokens.js'
|
|
1434
|
+
import { followLinkClick } from '../../utils/linkNavigation.js'
|
|
1348
1435
|
import { patchStoredSelf } from '../../utils/patchStoredSelf.js'
|
|
1349
1436
|
import { setReadState } from '../../utils/recordInteractions.js'
|
|
1350
1437
|
import { saveEdited } from '../../utils/saveEdited.js'
|
|
@@ -1562,6 +1649,7 @@ export default {
|
|
|
1562
1649
|
CnFormDialog,
|
|
1563
1650
|
CnDashboardGrid,
|
|
1564
1651
|
CnSegmentedControl,
|
|
1652
|
+
NcSelect,
|
|
1565
1653
|
ViewDashboardOutline,
|
|
1566
1654
|
CnLifecycleActions,
|
|
1567
1655
|
CnActionButtons,
|
|
@@ -1584,7 +1672,7 @@ export default {
|
|
|
1584
1672
|
Restore,
|
|
1585
1673
|
},
|
|
1586
1674
|
|
|
1587
|
-
mixins: [gridLayout, pageViews],
|
|
1675
|
+
mixins: [gridLayout, pageFilters, pageViews],
|
|
1588
1676
|
|
|
1589
1677
|
inject: {
|
|
1590
1678
|
/**
|
|
@@ -2777,6 +2865,39 @@ export default {
|
|
|
2777
2865
|
default: () => [],
|
|
2778
2866
|
},
|
|
2779
2867
|
|
|
2868
|
+
/**
|
|
2869
|
+
* A row of widgets across the whole page, above the body and the side
|
|
2870
|
+
* column (manifest `config.band`). Each entry is the id of a widget
|
|
2871
|
+
* declared in `widgets`, a widget definition, or `{ widgetId, span,
|
|
2872
|
+
* chrome }`; `span` is the entry's share of the row in twelfths
|
|
2873
|
+
* (default 12) and the row wraps; `chrome` is `card` or `bare` (a
|
|
2874
|
+
* banner defaults to bare, anything else to card, so a widget that
|
|
2875
|
+
* draws its own card, such as a KPI, asks for bare). Each entry
|
|
2876
|
+
* follows its `visibleWhen`. Empty (the default) draws no band.
|
|
2877
|
+
*
|
|
2878
|
+
* @type {Array<string|object>}
|
|
2879
|
+
* @spec openspec/changes/detail-band/specs/detail-page/spec.md#requirement-a-detail-page-can-draw-a-band-across-the-page-above-the-body-and-the-side-column
|
|
2880
|
+
*/
|
|
2881
|
+
band: {
|
|
2882
|
+
type: Array,
|
|
2883
|
+
default: () => [],
|
|
2884
|
+
},
|
|
2885
|
+
|
|
2886
|
+
/**
|
|
2887
|
+
* Page-level filters (manifest `config.pageFilters`), the same a
|
|
2888
|
+
* dashboard takes: each writes its choice into the page's workspace
|
|
2889
|
+
* context, which every widget on the page reads through
|
|
2890
|
+
* `@page.<key>` / `@workspace.<key>`. Drawn in the band, above its
|
|
2891
|
+
* widgets (PtPortal "Periode").
|
|
2892
|
+
*
|
|
2893
|
+
* @type {Array<{key: string, label?: string, type?: string, control?: ('select'|'segmented'), options?: Array<{value: (string|number), label: string}>, optionsFrom?: object, clearable?: boolean, placeholder?: string, default?: (string|number)}>}
|
|
2894
|
+
* @spec openspec/changes/detail-page-filters/specs/detail-page/spec.md#requirement-a-detail-page-can-draw-page-filters-that-its-widgets-read
|
|
2895
|
+
*/
|
|
2896
|
+
pageFilters: {
|
|
2897
|
+
type: Array,
|
|
2898
|
+
default: () => [],
|
|
2899
|
+
},
|
|
2900
|
+
|
|
2780
2901
|
/**
|
|
2781
2902
|
* A field path whose value picks the side column and its order for
|
|
2782
2903
|
* this record (manifest `config.sideColumnFrom`), read like a
|
|
@@ -3109,10 +3230,16 @@ export default {
|
|
|
3109
3230
|
// which no register answers.
|
|
3110
3231
|
{ schemaSlug: () => props.schema || sidebarSchema() },
|
|
3111
3232
|
)
|
|
3233
|
+
// The page's workspace context, as a dashboard provides it: page
|
|
3234
|
+
// filters write their choice here and a widget reads it through
|
|
3235
|
+
// `@page.<key>` / `@workspace.<key>`.
|
|
3236
|
+
const workspaceContext = ref({})
|
|
3237
|
+
provide('cnWorkspaceContext', workspaceContext)
|
|
3112
3238
|
return {
|
|
3113
3239
|
...registryExposed,
|
|
3114
3240
|
subscriptionStatus: subscription.status,
|
|
3115
3241
|
lockState: lock,
|
|
3242
|
+
workspaceContext,
|
|
3116
3243
|
}
|
|
3117
3244
|
},
|
|
3118
3245
|
|
|
@@ -3545,7 +3672,46 @@ export default {
|
|
|
3545
3672
|
* @spec openspec/changes/detail-action-model-and-case-surfaces/specs/detail-action-model/spec.md#requirement-primary-action-follows-the-stage
|
|
3546
3673
|
*/
|
|
3547
3674
|
primaryActionHref() {
|
|
3548
|
-
|
|
3675
|
+
if (this.stagePrimaryEntry) {
|
|
3676
|
+
return this.stagePrimaryEntry.href || ''
|
|
3677
|
+
}
|
|
3678
|
+
return this.primaryActionLink?.href || ''
|
|
3679
|
+
},
|
|
3680
|
+
|
|
3681
|
+
/**
|
|
3682
|
+
* The page's `primaryAction` as a link, when no stage action stands in
|
|
3683
|
+
* for it: `href` (its record tokens resolved) opens in a new tab, as the
|
|
3684
|
+
* schema says; `route` (with `query`) is an in-app link a plain click
|
|
3685
|
+
* routes to (PtPortal "Nieuwe pagina" opens the page create flow).
|
|
3686
|
+
*
|
|
3687
|
+
* @return {{href: string, to: object|null, target: string}|null} The link, or null.
|
|
3688
|
+
* @spec openspec/changes/detail-band/specs/detail-page/spec.md#requirement-a-detail-pages-own-primary-action-is-a-link
|
|
3689
|
+
*/
|
|
3690
|
+
primaryActionLink() {
|
|
3691
|
+
const action = this.primaryAction
|
|
3692
|
+
if (!action || typeof action !== 'object') {
|
|
3693
|
+
return null
|
|
3694
|
+
}
|
|
3695
|
+
if (typeof action.href === 'string' && action.href !== '') {
|
|
3696
|
+
const href = resolveManifestHref(interpolateUrlTokens(action.href, this.headerLinkTokenCtx))
|
|
3697
|
+
return href ? { href, to: null, target: '_blank' } : null
|
|
3698
|
+
}
|
|
3699
|
+
if (typeof action.route === 'string' && action.route !== '') {
|
|
3700
|
+
const to = { name: action.route, ...(action.query && typeof action.query === 'object' ? { query: action.query } : {}) }
|
|
3701
|
+
const href = routeHref(to, this.$router)
|
|
3702
|
+
return href ? { href, to, target: '_self' } : null
|
|
3703
|
+
}
|
|
3704
|
+
return null
|
|
3705
|
+
},
|
|
3706
|
+
|
|
3707
|
+
/**
|
|
3708
|
+
* The token context a header link resolves its URL against: this record.
|
|
3709
|
+
*
|
|
3710
|
+
* @return {{objectId: (string|number), object: object}} The context.
|
|
3711
|
+
* @spec openspec/changes/detail-band/specs/detail-page/spec.md#requirement-a-detail-header-link-builds-its-url-from-the-record
|
|
3712
|
+
*/
|
|
3713
|
+
headerLinkTokenCtx() {
|
|
3714
|
+
return { objectId: this.objectId, object: this.resolvedObject || {} }
|
|
3549
3715
|
},
|
|
3550
3716
|
|
|
3551
3717
|
/**
|
|
@@ -3555,7 +3721,10 @@ export default {
|
|
|
3555
3721
|
* @spec openspec/changes/detail-action-model-and-case-surfaces/specs/detail-action-model/spec.md#requirement-primary-action-follows-the-stage
|
|
3556
3722
|
*/
|
|
3557
3723
|
primaryActionTarget() {
|
|
3558
|
-
|
|
3724
|
+
if (this.stagePrimaryEntry) {
|
|
3725
|
+
return this.stagePrimaryEntry.linkTarget || undefined
|
|
3726
|
+
}
|
|
3727
|
+
return this.primaryActionLink?.target || undefined
|
|
3559
3728
|
},
|
|
3560
3729
|
|
|
3561
3730
|
/**
|
|
@@ -3951,6 +4120,57 @@ export default {
|
|
|
3951
4120
|
* @return {boolean} True when it has widgets and the body is showing.
|
|
3952
4121
|
* @spec openspec/changes/detail-action-model-and-case-surfaces/specs/detail-action-model/spec.md#requirement-side-column
|
|
3953
4122
|
*/
|
|
4123
|
+
/**
|
|
4124
|
+
* The band's widgets, resolved and visible, each with its span.
|
|
4125
|
+
*
|
|
4126
|
+
* @return {Array<{widget: object, span: number, chrome: string}>}
|
|
4127
|
+
* @spec openspec/changes/detail-band/specs/detail-page/spec.md#requirement-a-detail-page-can-draw-a-band-across-the-page-above-the-body-and-the-side-column
|
|
4128
|
+
*/
|
|
4129
|
+
bandWidgets() {
|
|
4130
|
+
const declared = Array.isArray(this.widgets) ? this.widgets : []
|
|
4131
|
+
return (Array.isArray(this.band) ? this.band : [])
|
|
4132
|
+
.map((entry, index) => {
|
|
4133
|
+
let widget = null
|
|
4134
|
+
let span = 12
|
|
4135
|
+
if (typeof entry === 'string') {
|
|
4136
|
+
widget = declared.find((candidate) => candidate && candidate.id === entry) || null
|
|
4137
|
+
} else if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
|
|
4138
|
+
span = Number.isInteger(entry.span) ? Math.min(12, Math.max(1, entry.span)) : 12
|
|
4139
|
+
if (typeof entry.widgetId === 'string') {
|
|
4140
|
+
widget = declared.find((candidate) => candidate && candidate.id === entry.widgetId) || null
|
|
4141
|
+
} else if (typeof entry.type === 'string') {
|
|
4142
|
+
widget = entry.id ? entry : { ...entry, id: `cn-band-${index}` }
|
|
4143
|
+
}
|
|
4144
|
+
}
|
|
4145
|
+
if (!widget) {
|
|
4146
|
+
return null
|
|
4147
|
+
}
|
|
4148
|
+
const chrome = ['card', 'bare'].includes(entry?.chrome) ? entry.chrome : (widget.type === 'banner' ? 'bare' : 'card')
|
|
4149
|
+
return { widget, span, chrome }
|
|
4150
|
+
})
|
|
4151
|
+
.filter((entry) => entry && this.sideWidgetVisible(entry.widget))
|
|
4152
|
+
},
|
|
4153
|
+
|
|
4154
|
+
/**
|
|
4155
|
+
* Whether the band renders: it has a visible widget and the record shows.
|
|
4156
|
+
*
|
|
4157
|
+
* @return {boolean}
|
|
4158
|
+
* @spec openspec/changes/detail-band/specs/detail-page/spec.md#requirement-a-detail-page-can-draw-a-band-across-the-page-above-the-body-and-the-side-column
|
|
4159
|
+
*/
|
|
4160
|
+
showsBand() {
|
|
4161
|
+
return (this.bandWidgets.length > 0 || this.detailPageFilters.length > 0) && this.showsBody
|
|
4162
|
+
},
|
|
4163
|
+
|
|
4164
|
+
/**
|
|
4165
|
+
* The declared page filters that carry a key.
|
|
4166
|
+
*
|
|
4167
|
+
* @return {Array<object>}
|
|
4168
|
+
* @spec openspec/changes/detail-page-filters/specs/detail-page/spec.md#requirement-a-detail-page-can-draw-page-filters-that-its-widgets-read
|
|
4169
|
+
*/
|
|
4170
|
+
detailPageFilters() {
|
|
4171
|
+
return (Array.isArray(this.pageFilters) ? this.pageFilters : []).filter((pf) => pf && pf.key)
|
|
4172
|
+
},
|
|
4173
|
+
|
|
3954
4174
|
showsSideColumn() {
|
|
3955
4175
|
return this.sideColumnWidgets.length > 0 && this.showsBody
|
|
3956
4176
|
},
|
|
@@ -4316,7 +4536,7 @@ export default {
|
|
|
4316
4536
|
.map((pinned) => pinned.inline)
|
|
4317
4537
|
const combined = inline.length > 0 ? [...authored, ...inline] : authored
|
|
4318
4538
|
// An entry (or child) with `href` becomes a link CnActionButtons renders as an anchor.
|
|
4319
|
-
const linked = combined.map(headerActionAsLink)
|
|
4539
|
+
const linked = combined.map((action) => headerActionAsLink(action, this.headerLinkTokenCtx))
|
|
4320
4540
|
const declared = linked.some((action, index) => action !== combined[index]) ? linked : combined
|
|
4321
4541
|
if (!(this.foldsEditIntoActions && this.canEditRecord)) {
|
|
4322
4542
|
return declared
|
|
@@ -5416,6 +5636,10 @@ export default {
|
|
|
5416
5636
|
|
|
5417
5637
|
created() {
|
|
5418
5638
|
this.pushAiContext()
|
|
5639
|
+
// Seeded before the first render, so a widget's first request already
|
|
5640
|
+
// carries the page filter's value.
|
|
5641
|
+
this.initPageFilters()
|
|
5642
|
+
this.loadPageFilterOptions()
|
|
5419
5643
|
},
|
|
5420
5644
|
|
|
5421
5645
|
mounted() {
|
|
@@ -5658,16 +5882,21 @@ export default {
|
|
|
5658
5882
|
* the `run()` CnActionButtons bound for it, and announced to the host
|
|
5659
5883
|
* with its declaration. A link action is left to the browser and
|
|
5660
5884
|
* announces nothing. Without a stage action this is the page's
|
|
5661
|
-
* `primaryAction`, announced as before
|
|
5885
|
+
* `primaryAction`, announced as before, and when it is an in-app link
|
|
5886
|
+
* a plain click routes there.
|
|
5662
5887
|
*
|
|
5888
|
+
* @param {MouseEvent} [event] The click.
|
|
5663
5889
|
* @return {void}
|
|
5664
5890
|
* @spec openspec/changes/detail-action-model-and-case-surfaces/specs/detail-action-model/spec.md#requirement-primary-action-follows-the-stage
|
|
5665
5891
|
* @spec openspec/changes/header-action-href/specs/header-action-href/spec.md#requirement-a-header-action-with-href-is-a-link-that-runs-and-emits-nothing
|
|
5666
5892
|
*/
|
|
5667
|
-
onPrimaryActionClick() {
|
|
5893
|
+
onPrimaryActionClick(event) {
|
|
5668
5894
|
const entry = this.stagePrimaryEntry
|
|
5669
5895
|
if (!entry) {
|
|
5670
5896
|
this.$emit('primary-action', this.primaryAction)
|
|
5897
|
+
if (this.primaryActionLink?.to) {
|
|
5898
|
+
followLinkClick(event, this.primaryActionLink.to, this.$router)
|
|
5899
|
+
}
|
|
5671
5900
|
return
|
|
5672
5901
|
}
|
|
5673
5902
|
if (entry.href) {
|
|
@@ -42,8 +42,13 @@
|
|
|
42
42
|
<img v-else-if="tile.iconType === 'url'" :src="tile.icon" alt="">
|
|
43
43
|
<span v-else-if="tile.iconType === 'emoji'" class="cn-tile-widget__emoji">{{ tile.icon }}</span>
|
|
44
44
|
</div>
|
|
45
|
-
<div class="cn-tile-
|
|
46
|
-
{
|
|
45
|
+
<div class="cn-tile-widget__text">
|
|
46
|
+
<div class="cn-tile-widget__title" :style="{ color: resolvedTextColor }">
|
|
47
|
+
{{ resolvedTitle }}
|
|
48
|
+
</div>
|
|
49
|
+
<div v-if="resolvedSubtitle" class="cn-tile-widget__subtitle" :style="{ color: resolvedTextColor }">
|
|
50
|
+
{{ resolvedSubtitle }}
|
|
51
|
+
</div>
|
|
47
52
|
</div>
|
|
48
53
|
</a>
|
|
49
54
|
</div>
|
|
@@ -108,6 +113,17 @@ export default {
|
|
|
108
113
|
return this.tile.title ? fn(this.tile.title) : this.tile.title
|
|
109
114
|
},
|
|
110
115
|
|
|
116
|
+
/**
|
|
117
|
+
* The optional line under the title (`tile.subtitle`), translated like
|
|
118
|
+
* the title. Empty when the tile names none.
|
|
119
|
+
*
|
|
120
|
+
* @return {string}
|
|
121
|
+
*/
|
|
122
|
+
resolvedSubtitle() {
|
|
123
|
+
const fn = typeof this.cnTranslate === 'function' ? this.cnTranslate : (k) => k
|
|
124
|
+
return this.tile.subtitle ? fn(this.tile.subtitle) : ''
|
|
125
|
+
},
|
|
126
|
+
|
|
111
127
|
/**
|
|
112
128
|
* The anchor href. `route` tiles resolve through the host router so
|
|
113
129
|
* middle-click / ctrl-click open the correct full URL (including the
|
|
@@ -278,6 +294,18 @@ export default {
|
|
|
278
294
|
font-size: 48px;
|
|
279
295
|
}
|
|
280
296
|
|
|
297
|
+
/* The text block takes no box of its own outside the board look, so the
|
|
298
|
+
title stays a direct flex item of the link. */
|
|
299
|
+
.cn-tile-widget__text {
|
|
300
|
+
display: contents;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
.cn-tile-widget__subtitle {
|
|
304
|
+
font-size: 13px;
|
|
305
|
+
text-align: center;
|
|
306
|
+
opacity: 0.85;
|
|
307
|
+
}
|
|
308
|
+
|
|
281
309
|
.cn-tile-widget__title {
|
|
282
310
|
font-size: 16px;
|
|
283
311
|
font-weight: 700;
|