@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.
Files changed (45) hide show
  1. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
  2. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +3 -113
  3. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  4. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +12 -12
  5. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  6. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +34 -1
  7. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  8. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +16 -8
  9. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  10. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +153 -8
  11. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  12. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +169 -58
  13. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  14. package/dist/esm/components/CnTileWidget/CnTileWidget.vue.js +1 -1
  15. package/dist/esm/components/CnTileWidget/CnTileWidget.vue2.js +11 -0
  16. package/dist/esm/components/CnTileWidget/CnTileWidget.vue2.js.map +1 -1
  17. package/dist/esm/components/CnTileWidget/CnTileWidget.vue3.js +14 -4
  18. package/dist/esm/components/CnTileWidget/CnTileWidget.vue3.js.map +1 -1
  19. package/dist/esm/library-style.js +1 -1
  20. package/dist/esm/mixins/pageFilters.js +142 -0
  21. package/dist/esm/mixins/pageFilters.js.map +1 -0
  22. package/dist/esm/nextcloud-vue.css +269 -74
  23. package/dist/esm/utils/actionLink.js +9 -3
  24. package/dist/esm/utils/actionLink.js.map +1 -1
  25. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  26. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  27. package/dist/nextcloud-vue.cjs.js +1176 -828
  28. package/dist/nextcloud-vue.cjs.js.map +2 -2
  29. package/dist/nextcloud-vue.css +269 -74
  30. package/package.json +1 -1
  31. package/src/components/CnDashboardPage/CnDashboardPage.vue +3 -113
  32. package/src/components/CnDataTable/CnDataTable.vue +40 -1
  33. package/src/components/CnDetailPage/CnDetailPage.md +2 -0
  34. package/src/components/CnDetailPage/CnDetailPage.vue +237 -8
  35. package/src/components/CnTileWidget/CnTileWidget.vue +30 -2
  36. package/src/css/detail-page.css +42 -0
  37. package/src/css/dialog.css +13 -0
  38. package/src/css/look-board-detail.css +1 -1
  39. package/src/css/look-board-index.css +8 -0
  40. package/src/css/look-board.css +97 -0
  41. package/src/css/table.css +12 -0
  42. package/src/mixins/pageFilters.js +138 -0
  43. package/src/schemas/app-manifest-v2.schema.json +41 -3
  44. package/src/utils/actionLink.js +9 -3
  45. 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.127.0",
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
- const spec = col?.secondary
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
- return this.stagePrimaryEntry?.href || ''
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
- return this.stagePrimaryEntry?.linkTarget || undefined
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-widget__title" :style="{ color: resolvedTextColor }">
46
- {{ resolvedTitle }}
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;