@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
@@ -464,6 +464,48 @@
464
464
  flex: 1 1 300px;
465
465
  }
466
466
 
467
+ /* The band (`config.band`): a wrapping row across the page, above the body
468
+ and the side column. An entry takes `--cn-band-span` twelfths of the row,
469
+ less its share of the gaps, and wraps under 220px. It is a full line in the
470
+ with-side flex row, like every child but the body and the column. */
471
+ .cn-detail-page__band {
472
+ display: flex;
473
+ flex-wrap: wrap;
474
+ gap: calc(5 * var(--default-grid-baseline));
475
+ margin-block-end: calc(5 * var(--default-grid-baseline));
476
+ }
477
+
478
+ /* The page filters: a right-aligned row above the band's widgets, a label
479
+ before each control. */
480
+ .cn-detail-page__page-filters {
481
+ display: flex;
482
+ flex: 1 1 100%;
483
+ flex-wrap: wrap;
484
+ justify-content: flex-end;
485
+ align-items: center;
486
+ gap: calc(2 * var(--default-grid-baseline)) calc(4 * var(--default-grid-baseline));
487
+ }
488
+
489
+ .cn-detail-page__page-filter {
490
+ display: inline-flex;
491
+ align-items: center;
492
+ gap: calc(2 * var(--default-grid-baseline));
493
+ }
494
+
495
+ .cn-detail-page__page-filter-label {
496
+ color: var(--color-text-maxcontrast);
497
+ font-size: 14px;
498
+ }
499
+
500
+ .cn-detail-page__band-item {
501
+ flex: 1 1 calc(var(--cn-band-span, 12) / 12 * 100% - calc(5 * var(--default-grid-baseline)));
502
+ min-width: min(220px, 100%);
503
+ }
504
+
505
+ .cn-detail-page__band-item--full {
506
+ flex-basis: 100%;
507
+ }
508
+
467
509
  .cn-detail-page__side {
468
510
  display: flex;
469
511
  flex-direction: column;
@@ -314,6 +314,19 @@ body:has(.cn-look-board):not(:has(.cn-look-nextcloud)) .modal-mask {
314
314
  border-radius: 8px;
315
315
  }
316
316
 
317
+ /* A field's help line sits right under the control in 13px muted ink, flush
318
+ with the label, as the boards draw it (PtUitnodigen "De uitnodiging met een
319
+ eenmalige code gaat naar dit adres.", PtAccountUitgeven). The ink carries
320
+ `!important` against a theme that colours every span and p (thematiq).
321
+ @spec openspec/changes/board-dialog-field-help/specs/board-dialog-field-help/spec.md#requirement-a-board-dialog-fields-help-line-is-13px-muted-under-the-control */
322
+ .cn-dialog-board .dialog__content .input-field__helper-text-message,
323
+ .cn-dialog-board .dialog__content .textarea__helper-text-message {
324
+ padding: 4px 0 0;
325
+ color: var(--color-text-maxcontrast) !important;
326
+ font-size: 13px;
327
+ line-height: 1.3;
328
+ }
329
+
317
330
  .cn-dialog-board .dialog__content .input-field:has(.input-field__label) .input-field__input:not(:focus)::placeholder,
318
331
  .cn-dialog-board .dialog__content .textarea__input:not(:focus)::placeholder {
319
332
  opacity: 1 !important;
@@ -575,7 +575,7 @@
575
575
  }
576
576
 
577
577
  /* The same for a CnDetailCard (DqTaak "Notities", DqZaakEmail "E-mail",
578
- DqZaakTaken "Afspraken", DqHandhaving "Historie"). */
578
+ DqZaakTaken "Afspraken", DqHandhaving "Historie", PtInzending "Bewijslog"). */
579
579
  .cn-look-board.cn-detail-page .cn-detail-card__header .cn-detail-card__icon {
580
580
  display: none;
581
581
  }
@@ -1560,6 +1560,14 @@
1560
1560
  color: var(--color-primary-element-light-text);
1561
1561
  }
1562
1562
 
1563
+ /* A row the manifest marks as asking for attention (`config.rowClass` with
1564
+ `class: "cn-row--attention"`) is tinted warm, as the boards draw the open
1565
+ requests on top of a list (PtToegangsverzoeken, PtUitnodigingen).
1566
+ @spec openspec/changes/board-attention-row/specs/board-attention-row/spec.md#requirement-a-board-table-row-marked-for-attention-is-tinted-warm */
1567
+ .cn-look-board .cn-data-table tr.cn-row--attention > td:not(.cn-look-nextcloud *) {
1568
+ background: color-mix(in srgb, var(--color-warning) 4%, var(--color-main-background));
1569
+ }
1570
+
1563
1571
  /* ---- A kanban column's show-more button ---------------------------------- */
1564
1572
  /* The board draws "Toon 12 meer" in the soft ink, its dashed edge in the 3:1
1565
1573
  control colour, 6px in at the sides (DqWerkbord); the view draws the main
@@ -1659,6 +1659,103 @@ body:has(.cn-look-board):not(:has(.cn-look-nextcloud)) .v-popper__popper.action-
1659
1659
  align-self: flex-start;
1660
1660
  }
1661
1661
 
1662
+ /* ---- Warning and error banners (CnBannerWidget) ------------------------- */
1663
+ /* The boards draw a warning or error banner as a tinted box with a 1px edge
1664
+ in its own tone, radius 10, 12px 14px of padding, the text in the main ink
1665
+ (PtInzending "De ontvangstbevestiging kwam niet aan.", PtAccounts
1666
+ "aanmeldingen wachten op goedkeuring"), not NcNoteCard's 4px edge bar. A
1667
+ settings card's notes take the same box, success included
1668
+ (PtBeheerinstellingen "Er is een eigen ondertekeningssleutel ingesteld").
1669
+ @spec openspec/changes/board-banner-note-tone/specs/board-banner-note-tone/spec.md#requirement-a-board-warning-or-error-banner-is-a-tinted-box-with-an-edge-in-its-tone */
1670
+ .cn-look-board .cn-banner-widget .notecard.notecard--warning:not(.cn-look-nextcloud *),
1671
+ .cn-look-board .settings-section .notecard.notecard--warning:not(.cn-look-nextcloud *) {
1672
+ --cn-banner-ground: color-mix(in srgb, var(--color-warning) 13%, var(--color-main-background));
1673
+ --cn-banner-edge: color-mix(in srgb, var(--color-warning) 50%, var(--color-main-background));
1674
+ }
1675
+
1676
+ .cn-look-board .cn-banner-widget .notecard.notecard--error:not(.cn-look-nextcloud *),
1677
+ .cn-look-board .settings-section .notecard.notecard--error:not(.cn-look-nextcloud *) {
1678
+ --cn-banner-ground: color-mix(in srgb, var(--color-error) 7%, var(--color-main-background));
1679
+ --cn-banner-edge: color-mix(in srgb, var(--color-error) 30%, var(--color-main-background));
1680
+ }
1681
+
1682
+ .cn-look-board .settings-section .notecard.notecard--success:not(.cn-look-nextcloud *) {
1683
+ --cn-banner-ground: color-mix(in srgb, var(--color-success) 9%, var(--color-main-background));
1684
+ --cn-banner-edge: color-mix(in srgb, var(--color-success) 30%, var(--color-main-background));
1685
+ }
1686
+
1687
+ .cn-look-board .cn-banner-widget .notecard.notecard--warning:not(.cn-look-nextcloud *),
1688
+ .cn-look-board .cn-banner-widget .notecard.notecard--error:not(.cn-look-nextcloud *),
1689
+ .cn-look-board .settings-section .notecard.notecard--warning:not(.cn-look-nextcloud *),
1690
+ .cn-look-board .settings-section .notecard.notecard--error:not(.cn-look-nextcloud *),
1691
+ .cn-look-board .settings-section .notecard.notecard--success:not(.cn-look-nextcloud *) {
1692
+ --note-card-icon-size: 18px;
1693
+ gap: 10px;
1694
+ margin: 0;
1695
+ padding: 12px 14px;
1696
+ border: 1px solid var(--cn-banner-edge);
1697
+ border-radius: 10px;
1698
+ background-color: var(--cn-banner-ground) !important;
1699
+ color: var(--color-main-text) !important;
1700
+ font-size: 14px;
1701
+ line-height: 1.5;
1702
+ }
1703
+
1704
+ /* A stat-row cell that holds only a banner is the banner: no card around it. */
1705
+ .cn-look-board .cn-index-page-widgets__cell .cn-widget-wrapper:has(.cn-banner-widget):not(:has(.cn-widget-wrapper__header)):not(.cn-look-nextcloud *) {
1706
+ border: none;
1707
+ background: transparent;
1708
+ }
1709
+
1710
+ .cn-look-board .cn-index-page-widgets__cell .cn-widget-wrapper:has(.cn-banner-widget):not(:has(.cn-widget-wrapper__header)):not(.cn-look-nextcloud *) > .cn-widget-wrapper__content {
1711
+ padding: 0;
1712
+ }
1713
+
1714
+ /* ---- Link tiles (CnTileWidget) ------------------------------------------ */
1715
+ /* The board draws a link tile as a rounded card, its icon on the left beside
1716
+ a 15px bold title and a 13px line under it, all left-aligned (PtThemas
1717
+ "Themacatalogus", "Portalen en hun thema's"), not a square with a centred
1718
+ 48px icon.
1719
+ @spec openspec/changes/board-link-tile/specs/board-link-tile/spec.md#requirement-a-board-link-tile-is-a-rounded-card-with-its-icon-beside-the-title */
1720
+ .cn-look-board .cn-tile-widget:not(.cn-look-nextcloud *) {
1721
+ border-radius: var(--cn-board-card-radius, 12px);
1722
+ }
1723
+
1724
+ .cn-look-board .cn-tile-widget .cn-tile-widget__link:not(.cn-look-nextcloud *) {
1725
+ flex-direction: row;
1726
+ justify-content: flex-start;
1727
+ gap: 14px;
1728
+ padding: 20px 22px;
1729
+ }
1730
+
1731
+ .cn-look-board .cn-tile-widget .cn-tile-widget__link:not(.cn-look-nextcloud *):hover {
1732
+ transform: none;
1733
+ }
1734
+
1735
+ .cn-look-board .cn-tile-widget .cn-tile-widget__icon:not(.cn-look-nextcloud *),
1736
+ .cn-look-board .cn-tile-widget .cn-tile-widget__icon--svg:not(.cn-look-nextcloud *) {
1737
+ width: 24px;
1738
+ height: 24px;
1739
+ font-size: 24px;
1740
+ }
1741
+
1742
+ .cn-look-board .cn-tile-widget .cn-tile-widget__text:not(.cn-look-nextcloud *) {
1743
+ display: flex;
1744
+ flex-direction: column;
1745
+ gap: 2px;
1746
+ min-width: 0;
1747
+ }
1748
+
1749
+ .cn-look-board .cn-tile-widget .cn-tile-widget__title:not(.cn-look-nextcloud *),
1750
+ .cn-look-board .cn-tile-widget .cn-tile-widget__subtitle:not(.cn-look-nextcloud *) {
1751
+ text-align: start;
1752
+ }
1753
+
1754
+ .cn-look-board .cn-tile-widget .cn-tile-widget__title:not(.cn-look-nextcloud *) {
1755
+ font-size: 15px;
1756
+ line-height: 1.2;
1757
+ }
1758
+
1662
1759
  /* A record list's notice is the board's note box too: 10px 14px, radius 10
1663
1760
  (DqZaaktypeOverzicht).
1664
1761
  @spec openspec/changes/board-record-list-notice-box/specs/board-record-list-notice-box/spec.md#requirement-a-board-record-list-notice-is-the-boards-note-box */
package/src/css/table.css CHANGED
@@ -186,6 +186,18 @@
186
186
  text-overflow: ellipsis;
187
187
  }
188
188
 
189
+ /* The overline of a cell (`columns[].overline` / `overlineText`): muted and
190
+ smaller, on its own line above the value. Same theme hooks as the
191
+ secondary line. */
192
+ .cn-data-table .cn-table-cell__overline {
193
+ display: block;
194
+ color: var(--cn-table-secondary-color, var(--color-text-maxcontrast));
195
+ font-size: var(--cn-table-secondary-size, 0.9em);
196
+ line-height: 1.35;
197
+ overflow: hidden;
198
+ text-overflow: ellipsis;
199
+ }
200
+
189
201
  /* Back to wrapping, for a column holding prose rather than a label:
190
202
  `columns: [{ key: 'description', cellClass: 'cn-cell--wrap' }]`. */
191
203
  .cn-data-table th.cn-cell--wrap,
@@ -0,0 +1,138 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ */
5
+
6
+ import { isOptionsFrom, loadOptionsFrom } from '../utils/optionsFrom.js'
7
+ import { generatedPageFilterOptions } from '../utils/periodOptions.js'
8
+
9
+ /**
10
+ * Page-level filters (manifest `config.pageFilters`), shared by CnDashboardPage
11
+ * and CnDetailPage: seeding each filter's value, reading its options and
12
+ * writing a choice into the page's workspace context, where a widget reads it
13
+ * through `@page.<key>` / `@workspace.<key>`.
14
+ *
15
+ * The host provides `pageFilters` (the declared filters), `workspaceContext`
16
+ * (the reactive bag it provides as `cnWorkspaceContext`) and
17
+ * `effectiveTranslate` (the host app's translate).
18
+ *
19
+ * @spec openspec/changes/detail-page-filters/specs/detail-page/spec.md#requirement-a-detail-page-can-draw-page-filters-that-its-widgets-read
20
+ */
21
+ export const pageFilters = {
22
+ data() {
23
+ return {
24
+ /** Options read through a page filter's `optionsFrom`, by filter key. */
25
+ pageFilterFetched: {},
26
+ /** Whether a page filter's `optionsFrom` read is in flight, by filter key. */
27
+ pageFilterLoading: {},
28
+ }
29
+ },
30
+
31
+ methods: {
32
+ /**
33
+ * Seed the reactive workspace context with each page filter's default
34
+ * (its `default`, else the first option's value) so endpoint-bound
35
+ * widgets have a value to interpolate on first render. Only writes keys
36
+ * that aren't already present, so a value another widget set survives.
37
+ *
38
+ * @return {void}
39
+ */
40
+ initPageFilters() {
41
+ for (const pf of this.pageFilters || []) {
42
+ if (!pf || !pf.key) {
43
+ continue
44
+ }
45
+ if (this.workspaceContext[pf.key] !== undefined) {
46
+ continue
47
+ }
48
+ const listed = Array.isArray(pf.options) ? pf.options : generatedPageFilterOptions(pf)
49
+ const fallback = (listed && listed.length && pf.clearable !== true) ? listed[0].value : undefined
50
+ const value = pf.default !== undefined ? pf.default : fallback
51
+ if (value !== undefined) {
52
+ this.workspaceContext[pf.key] = value
53
+ }
54
+ }
55
+ },
56
+
57
+ /**
58
+ * The currently-selected option object for a page filter's NcSelect
59
+ * (matched by `value` against the workspace context), or null.
60
+ *
61
+ * @param {object} pf The page-filter descriptor.
62
+ * @return {object|null}
63
+ */
64
+ selectedPageFilterOption(pf) {
65
+ if (!pf || !pf.key) {
66
+ return null
67
+ }
68
+ const current = this.workspaceContext[pf.key]
69
+ return this.pageFilterSegments(pf).find((o) => o.value === current) || null
70
+ },
71
+
72
+ /**
73
+ * A page filter's options, labels through the host translate: the
74
+ * segments of a segmented filter and the options of a select.
75
+ *
76
+ * @spec openspec/changes/dashboard-board-termijnen/specs/dashboard-page/spec.md#requirement-a-page-filter-as-a-segmented-group-on-the-description-line
77
+ * @param {object} pf The page-filter descriptor.
78
+ * @return {Array<{value: (string|number), label: string}>}
79
+ */
80
+ pageFilterSegments(pf) {
81
+ const options = Array.isArray(pf.options) ? pf.options : (generatedPageFilterOptions(pf) || this.pageFilterFetched[pf.key] || [])
82
+ return options.map((o) => ({ value: o.value, label: o.label ? this.effectiveTranslate(o.label) : String(o.value) }))
83
+ },
84
+
85
+ /**
86
+ * Read the options of every page filter with `optionsFrom`, then seed
87
+ * a filter that is not clearable with its first option when it has no
88
+ * value yet.
89
+ *
90
+ * @spec openspec/changes/page-filter-options-from/specs/dashboard-page/spec.md#requirement-a-page-filter-can-read-its-options
91
+ * @return {Promise<void>}
92
+ */
93
+ async loadPageFilterOptions() {
94
+ const ctx = { workspace: this.workspaceContext || {} }
95
+ await Promise.all((this.pageFilters || [])
96
+ .filter((pf) => pf && pf.key && !Array.isArray(pf.options) && pf.optionsFrom && typeof pf.optionsFrom === 'object')
97
+ .map(async (pf) => {
98
+ const from = pf.optionsFrom
99
+ if (!isOptionsFrom(from)) {
100
+ return
101
+ }
102
+ this.pageFilterLoading = { ...this.pageFilterLoading, [pf.key]: true }
103
+ let options
104
+ try {
105
+ options = (await loadOptionsFrom(from, ctx)).map((o) => ({ value: o.id, label: o.label }))
106
+ } catch {
107
+ options = []
108
+ }
109
+ this.pageFilterFetched = { ...this.pageFilterFetched, [pf.key]: options }
110
+ this.pageFilterLoading = { ...this.pageFilterLoading, [pf.key]: false }
111
+ if (pf.clearable !== true && pf.default === undefined && this.workspaceContext[pf.key] === undefined && options.length > 0) {
112
+ this.workspaceContext[pf.key] = options[0].value
113
+ }
114
+ }))
115
+ },
116
+
117
+ /**
118
+ * Write a page filter's new selection into the reactive workspace
119
+ * context so every `@page.<key>` / `@workspace.<key>` token re-resolves.
120
+ *
121
+ * @param {object} pf The page-filter descriptor.
122
+ * @param {object|null} option The chosen NcSelect option (`{ value, label }`).
123
+ * @return {void}
124
+ */
125
+ onPageFilterChange(pf, option) {
126
+ if (!pf || !pf.key) {
127
+ return
128
+ }
129
+ const value = option && typeof option === 'object' ? option.value : (option ?? null)
130
+ this.workspaceContext[pf.key] = value
131
+ /**
132
+ * @event page-filter-change Emitted when a page-level filter selection changes.
133
+ * @type {{ key: string, value: (string|number|null) }}
134
+ */
135
+ this.$emit('page-filter-change', { key: pf.key, value })
136
+ },
137
+ },
138
+ }
@@ -3,7 +3,7 @@
3
3
  "$id": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest-v2.schema.json",
4
4
  "title": "Conduction App Manifest v2",
5
5
  "description": "v2 schema for the JSON-driven page and navigation manifest consumed by @conduction/nextcloud-vue. Introduces a uniform widgets[] array on every page type with a per-slot grid coordinate system, a typed actions[] discriminator, and the required $schema field for version detection. v1 manifests continue to validate against app-manifest.schema.json (unchanged).",
6
- "version": "2.144.0",
6
+ "version": "2.145.0",
7
7
  "type": "object",
8
8
  "required": ["$schema", "version"],
9
9
  "allOf": [
@@ -1581,9 +1581,14 @@
1581
1581
  "type": "string",
1582
1582
  "description": "Named vue-router route to navigate to on click."
1583
1583
  },
1584
+ "query": {
1585
+ "type": "object",
1586
+ "additionalProperties": { "type": "string" },
1587
+ "description": "Query for `route`, e.g. { \"action\": \"create\" } to open an index page's create flow (detail-band, 2.140.0)."
1588
+ },
1584
1589
  "href": {
1585
1590
  "type": "string",
1586
- "description": "External URL opened in a new tab on click."
1591
+ "description": "External URL opened in a new tab on click. On a detail page it may name the record's fields as @object.<field> tokens."
1587
1592
  },
1588
1593
  "payload": {
1589
1594
  "description": "Free-form payload echoed back to the host inside the @primary-action-click event."
@@ -4600,6 +4605,39 @@
4600
4605
  "$ref": "#/$defs/headerPill",
4601
4606
  "description": "type='detail': a pill above the title saying where the record stands."
4602
4607
  },
4608
+ "band": {
4609
+ "type": "array",
4610
+ "description": "type='detail': a row of widgets across the whole page, above the body and the side column (detail-band). An item is the id of a widget declared on the page, a widget definition, or { widgetId, span, chrome } where span is the share of the row in twelfths (default 12) and chrome is card or bare. The row wraps; each item follows its visibleWhen.",
4611
+ "items": {
4612
+ "oneOf": [
4613
+ { "type": "string", "minLength": 1 },
4614
+ {
4615
+ "type": "object",
4616
+ "additionalProperties": false,
4617
+ "required": ["widgetId"],
4618
+ "properties": {
4619
+ "widgetId": { "type": "string", "minLength": 1 },
4620
+ "span": { "type": "integer", "minimum": 1, "maximum": 12 },
4621
+ "chrome": { "type": "string", "enum": ["card", "bare"], "description": "card (default) or bare, for a widget that draws its own card; a banner defaults to bare." }
4622
+ }
4623
+ },
4624
+ {
4625
+ "type": "object",
4626
+ "additionalProperties": true,
4627
+ "required": ["type"],
4628
+ "not": { "required": ["widgetId"] },
4629
+ "properties": {
4630
+ "id": { "type": "string" },
4631
+ "type": { "type": "string", "minLength": 1 },
4632
+ "title": { "type": "string" },
4633
+ "span": { "type": "integer", "minimum": 1, "maximum": 12 },
4634
+ "chrome": { "type": "string", "enum": ["card", "bare"], "description": "card (default) or bare, for a widget that draws its own card; a banner defaults to bare." },
4635
+ "content": { "type": "object", "additionalProperties": true }
4636
+ }
4637
+ }
4638
+ ]
4639
+ }
4640
+ },
4603
4641
  "sideColumn": {
4604
4642
  "type": "array",
4605
4643
  "description": "type='detail': a column of cards beside the body, for the record's facts. An item is a widget definition, or the id of a widget declared on the page. The column stacks under the body on a narrow page.",
@@ -5290,7 +5328,7 @@
5290
5328
  },
5291
5329
  "pageFilters": {
5292
5330
  "type": "array",
5293
- "description": "type='dashboard': page-level filter controls in the header. Each selection is written into the page's workspace context, so a widget reads it through a `@page.<key>` / `@workspace.<key>` token (an endpoint widget also in its `responsePath`). Maps to the CnDashboardPage `pageFilters` prop.",
5331
+ "description": "type='dashboard' and type='detail': page-level filter controls, in a dashboard's header and in a detail page's band (detail-page-filters, 2.141.0). Each selection is written into the page's workspace context, so a widget reads it through a `@page.<key>` / `@workspace.<key>` token (an endpoint widget also in its `responsePath`). Maps to the CnDashboardPage `pageFilters` prop.",
5294
5332
  "items": {
5295
5333
  "type": "object",
5296
5334
  "required": ["key"],
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import { generateUrl, getRootUrl } from '@nextcloud/router'
19
+ import { interpolateUrlTokens } from './interpolateUrlTokens.js'
19
20
  import { followLinkClick } from './linkNavigation.js'
20
21
 
21
22
  /** An absolute URL a manifest link may name, matching the schema's lowercase pattern. */
@@ -80,16 +81,21 @@ export function resolveManifestHref(href) {
80
81
  * function `handler` is dropped like any other, with a one-time warning.
81
82
  *
82
83
  * @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
84
+ * With `tokenCtx` the `href` first resolves its `@object.<field>`,
85
+ * `@objectId`, `@page.<key>` and `@config.<key>` tokens, so a detail page can
86
+ * link to a URL built from its record (PtPortal "Website bekijken").
87
+ *
83
88
  * @param {object} entry The manifest header action.
89
+ * @param {object|null} [tokenCtx] The token context (`{ objectId?, object?, workspace?, config? }`).
84
90
  * @return {object} The entry, or a copy in link form.
85
91
  */
86
- export function headerActionAsLink(entry) {
92
+ export function headerActionAsLink(entry, tokenCtx = null) {
87
93
  if (!entry || typeof entry !== 'object') {
88
94
  return entry
89
95
  }
90
96
  let resolved = entry
91
97
  if (Array.isArray(entry.children)) {
92
- const children = entry.children.map(headerActionAsLink)
98
+ const children = entry.children.map((child) => headerActionAsLink(child, tokenCtx))
93
99
  if (children.some((child, index) => child !== entry.children[index])) {
94
100
  resolved = { ...entry, children }
95
101
  }
@@ -99,7 +105,7 @@ export function headerActionAsLink(entry) {
99
105
  }
100
106
  const { href: declared, ...withoutHref } = resolved
101
107
  const { handler: _handler, ...rest } = withoutHref
102
- const href = resolveManifestHref(declared)
108
+ const href = resolveManifestHref(tokenCtx ? interpolateUrlTokens(declared, tokenCtx) : declared)
103
109
  if (!href) {
104
110
  if (!warnedHrefs.has(declared)) {
105
111
  warnedHrefs.add(declared)