@conduction/nextcloud-vue 2.63.0 → 2.65.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 (111) hide show
  1. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +253 -10
  2. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
  3. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js +139 -46
  4. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
  5. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +87 -0
  6. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
  7. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js +26 -24
  8. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
  9. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue2.js +61 -16
  10. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue2.js.map +1 -1
  11. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue3.js.map +1 -1
  12. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue2.js +20 -0
  13. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue2.js.map +1 -1
  14. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue3.js +3 -2
  15. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue3.js.map +1 -1
  16. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue2.js +13 -0
  17. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue2.js.map +1 -1
  18. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js +2 -1
  19. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js.map +1 -1
  20. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js +33 -2
  21. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js.map +1 -1
  22. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js.map +1 -1
  23. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +55 -2
  24. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  25. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +153 -133
  26. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  27. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +37 -1
  28. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  29. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +12 -4
  30. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  31. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +125 -14
  32. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  33. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +87 -40
  34. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  35. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js +101 -7
  36. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js.map +1 -1
  37. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js +22 -5
  38. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js.map +1 -1
  39. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +30 -0
  40. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  41. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +2 -2
  42. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  43. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +5 -1
  44. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
  45. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js +4 -2
  46. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
  47. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js +63 -2
  48. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js.map +1 -1
  49. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js +96 -37
  50. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js.map +1 -1
  51. package/dist/esm/components/CnStagesWidget/stagesModel.js +5 -2
  52. package/dist/esm/components/CnStagesWidget/stagesModel.js.map +1 -1
  53. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +74 -5
  54. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
  55. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js +15 -8
  56. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
  57. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +14 -2
  58. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  59. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js +2 -1
  60. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  61. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js +73 -1
  62. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js.map +1 -1
  63. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js +14 -2
  64. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js.map +1 -1
  65. package/dist/esm/l10n/en.json.js +2 -1
  66. package/dist/esm/l10n/en.json.js.map +1 -1
  67. package/dist/esm/l10n/nl.json.js +2 -1
  68. package/dist/esm/l10n/nl.json.js.map +1 -1
  69. package/dist/esm/nextcloud-vue.css +495 -81
  70. package/dist/esm/store/useObjectStore.js +118 -33
  71. package/dist/esm/store/useObjectStore.js.map +1 -1
  72. package/dist/esm/utils/actionsDispatcher.js +14 -0
  73. package/dist/esm/utils/actionsDispatcher.js.map +1 -1
  74. package/dist/esm/utils/schemaRefSlug.js +19 -3
  75. package/dist/esm/utils/schemaRefSlug.js.map +1 -1
  76. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  77. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  78. package/dist/nextcloud-vue.cjs.js +7388 -6021
  79. package/dist/nextcloud-vue.cjs.js.map +2 -2
  80. package/dist/nextcloud-vue.css +495 -81
  81. package/l10n/en.json +2 -0
  82. package/l10n/nl.json +2 -0
  83. package/package.json +1 -1
  84. package/src/components/CnAppNav/CnAppNav.vue +441 -14
  85. package/src/components/CnAppRoot/CnAppRoot.md +4 -0
  86. package/src/components/CnAppRoot/CnAppRoot.vue +94 -1
  87. package/src/components/CnAuditTrailCard/CnAuditTrailCard.vue +61 -16
  88. package/src/components/CnAuditTrailWidget/CnAuditTrailWidget.vue +22 -1
  89. package/src/components/CnBreadcrumbs/CnBreadcrumbs.vue +14 -0
  90. package/src/components/CnCellRenderer/CnCellRenderer.vue +33 -2
  91. package/src/components/CnDashboardPage/CnDashboardPage.vue +72 -4
  92. package/src/components/CnDataTable/CnDataTable.vue +45 -1
  93. package/src/components/CnDetailPage/CnDetailPage.md +8 -0
  94. package/src/components/CnDetailPage/CnDetailPage.vue +169 -2
  95. package/src/components/CnHeaderWidget/CnHeaderWidget.vue +147 -9
  96. package/src/components/CnIndexPage/CnIndexPage.vue +32 -2
  97. package/src/components/CnObjectListWidget/CnObjectListWidget.vue +7 -1
  98. package/src/components/CnStagesWidget/CnStagesWidget.vue +189 -2
  99. package/src/components/CnStagesWidget/stagesModel.js +5 -2
  100. package/src/components/CnStatWidget/CnStatWidget.vue +89 -7
  101. package/src/components/CnTabsWidget/CnTabsWidget.vue +15 -2
  102. package/src/components/CnWidgetWrapper/CnWidgetWrapper.md +4 -0
  103. package/src/components/CnWidgetWrapper/CnWidgetWrapper.vue +107 -1
  104. package/src/css/detail-page.css +32 -4
  105. package/src/css/kpi-card.css +71 -1
  106. package/src/css/table.css +18 -6
  107. package/src/schemas/app-manifest-v2.schema.json +134 -3
  108. package/src/store/useObjectStore.js +118 -33
  109. package/src/utils/actionsDispatcher.js +14 -0
  110. package/src/utils/schemaRefSlug.js +19 -3
  111. package/src/utils/validateManifestV2.compiled.js +2 -2
@@ -199,7 +199,13 @@
199
199
  <!-- @binding {boolean} isOwner Whether the user owns this app (ADR-079 §3). -->
200
200
  <!-- @binding {boolean} isAdmin Whether the user administers the instance; gates the Admin-settings link. -->
201
201
  <!-- @binding {string} appId The app id used to build the Admin-settings target. -->
202
- <slot name="menu"
202
+ <!-- `hideMenu` renders no navigation at all, so the content starts
203
+ at the left edge. An EMPTY #menu override cannot do that: an
204
+ empty slot falls back to the default CnAppNav (ensureValidVNode),
205
+ which is why launchpad passed a hidden empty span. -->
206
+ <slot
207
+ v-if="!hideMenu"
208
+ name="menu"
203
209
  :manifest="menuManifest"
204
210
  :permissions="permissions"
205
211
  :isOwner="isOwner"
@@ -700,6 +706,7 @@ import { BUILT_IN_FORMATTERS } from '../../utils/builtInFormatters.js'
700
706
  import { DEFAULT_FORGE, resolveForge } from '../../utils/forge.js'
701
707
  import { BUILT_IN_KB_PROVIDERS } from '../../utils/kbSearchProviders.js'
702
708
  import { installModalStack, uninstallModalStack } from '../../utils/modalStack.js'
709
+ import { resolveFilterTokens } from '../../utils/resolveFilterTokens.js'
703
710
  import { passesContextPredicates } from '../../utils/visibleIfContext.js'
704
711
 
705
712
  /**
@@ -1049,6 +1056,13 @@ export default {
1049
1056
  * the counts.
1050
1057
  */
1051
1058
  cnMenuCounts: this.cnMenuCounts,
1059
+ /**
1060
+ * Reactive `{ [menuItemId]: number }` totals for menu entries
1061
+ * whose `count` is an object (`{ register, schema, filter? }`),
1062
+ * populated by `_hydrateMenuItemCounts()`. Read by
1063
+ * `CnAppNav.resolveCount()`.
1064
+ */
1065
+ cnMenuItemCounts: this.cnMenuItemCounts,
1052
1066
  }
1053
1067
  },
1054
1068
 
@@ -1192,6 +1206,19 @@ export default {
1192
1206
  required: true,
1193
1207
  },
1194
1208
 
1209
+ /**
1210
+ * Render no app navigation at all: neither the default CnAppNav nor
1211
+ * the `#menu` slot, so the content starts at the left edge (a start
1212
+ * page without a menu). Off by default; the navigation renders as it
1213
+ * always has.
1214
+ *
1215
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-an-app-root-can-hide-its-menu
1216
+ */
1217
+ hideMenu: {
1218
+ type: Boolean,
1219
+ default: false,
1220
+ },
1221
+
1195
1222
  /**
1196
1223
  * Human-readable name shown in the Nextcloud top bar. When set it
1197
1224
  * overrides the technical `appId` so a virtual app shows its own name
@@ -1707,6 +1734,13 @@ export default {
1707
1734
  * picked up by CnAppNav's reactive render.
1708
1735
  */
1709
1736
  cnMenuCounts: reactive({}),
1737
+ /**
1738
+ * Reactive `{ [menuItemId]: number }` map of filtered totals, one
1739
+ * per menu entry whose `count` is an object. Provided to
1740
+ * descendants as `cnMenuItemCounts`. Populated by
1741
+ * `_hydrateMenuItemCounts()` at mount.
1742
+ */
1743
+ cnMenuItemCounts: reactive({}),
1710
1744
  /**
1711
1745
  * Open state of the host NcAppSettingsDialog. Toggled
1712
1746
  * to `true` by the provided `cnOpenUserSettings()`
@@ -2925,6 +2959,7 @@ export default {
2925
2959
  this._validateRegistry()
2926
2960
  this._warnCustomComponentsDeprecation()
2927
2961
  this._hydrateMenuCounts()
2962
+ this._hydrateMenuItemCounts()
2928
2963
  return
2929
2964
  }
2930
2965
 
@@ -2953,6 +2988,7 @@ export default {
2953
2988
  this._validateRegistry()
2954
2989
  this._warnCustomComponentsDeprecation()
2955
2990
  this._hydrateMenuCounts()
2991
+ this._hydrateMenuItemCounts()
2956
2992
  },
2957
2993
 
2958
2994
  beforeUnmount() {
@@ -3609,6 +3645,63 @@ export default {
3609
3645
  })
3610
3646
  },
3611
3647
 
3648
+ /**
3649
+ * Fetch the total of every menu entry (top level and children) whose
3650
+ * `count` is an object `{ register, schema, filter? }`: one
3651
+ * `GET /api/objects/{register}/{schema}?_limit=1` per entry with the
3652
+ * filter's tokens (`@me`, `@today`, ...) resolved at fetch time, the
3653
+ * response `total` written to `cnMenuItemCounts[item.id]`. The
3654
+ * request goes straight through axios rather than the object store,
3655
+ * so the index page's whole-schema total in the store is left alone.
3656
+ * Failures leave the badge unrendered.
3657
+ *
3658
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-menu-entry-counts-a-filtered-list
3659
+ * @return {void}
3660
+ * @private
3661
+ */
3662
+ _hydrateMenuItemCounts() {
3663
+ const collect = (items) => {
3664
+ const out = []
3665
+ for (const item of items ?? []) {
3666
+ const count = item?.count
3667
+ if (count && typeof count === 'object' && typeof item.id === 'string' && count.register && count.schema) {
3668
+ out.push(item)
3669
+ }
3670
+ if (Array.isArray(item?.children)) {
3671
+ out.push(...collect(item.children))
3672
+ }
3673
+ }
3674
+ return out
3675
+ }
3676
+ for (const item of collect(this.manifest?.menu)) {
3677
+ this._fetchMenuItemCount(item)
3678
+ }
3679
+ },
3680
+
3681
+ /**
3682
+ * One filtered-count request for a menu entry; see
3683
+ * `_hydrateMenuItemCounts`.
3684
+ *
3685
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-menu-entry-counts-a-filtered-list
3686
+ * @param {{ id: string, count: { register: string, schema: string, filter?: object } }} item The menu entry.
3687
+ * @return {Promise<void>}
3688
+ * @private
3689
+ */
3690
+ async _fetchMenuItemCount(item) {
3691
+ try {
3692
+ const { register, schema, filter } = item.count
3693
+ const resolved = resolveFilterTokens(filter && typeof filter === 'object' ? filter : {}, {})
3694
+ const url = generateUrl(`/apps/openregister/api/objects/${encodeURIComponent(register)}/${encodeURIComponent(schema)}`)
3695
+ const { data } = await axios.get(url, { params: { _limit: 1, ...resolved } })
3696
+ const total = data?.total
3697
+ if (typeof total === 'number' && total >= 0) {
3698
+ this.cnMenuItemCounts[item.id] = total
3699
+ }
3700
+ } catch {
3701
+ // Non-fatal: the badge stays unrendered.
3702
+ }
3703
+ },
3704
+
3612
3705
  /**
3613
3706
  * Hydrate all menu counts with a single `POST /api/objects/counts`
3614
3707
  * (OpenRegister batched-counts endpoint). Distributes each returned
@@ -69,12 +69,27 @@ export default {
69
69
  components: { CnDetailCard, NcLoadingIcon },
70
70
 
71
71
  props: {
72
- /** OpenRegister register id (slug or uuid). */
73
- register: { type: String, required: true },
74
- /** OpenRegister schema id (slug or uuid). */
75
- schema: { type: String, required: true },
76
- /** Parent object id. */
77
- objectId: { type: String, required: true },
72
+ /** OpenRegister register id (slug or uuid). Optional for `scope: "app"`. */
73
+ register: { type: String, default: '' },
74
+ /** OpenRegister schema id (slug or uuid). Optional for `scope: "app"`. */
75
+ schema: { type: String, default: '' },
76
+ /** Parent object id. Required for `scope: "object"` (the default); unused for `scope: "app"`. */
77
+ objectId: { type: String, default: '' },
78
+ /**
79
+ * What the card lists. `object` (the default) is one object's trail
80
+ * and needs `register`, `schema` and `objectId`. `app` is the
81
+ * app-wide feed: the entries of every object the caller may read
82
+ * (OpenRegister's `/audit-trails/readable`), narrowed to `register`
83
+ * and `schema` when those are set.
84
+ *
85
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-the-audit-trail-widget-reads-the-app-wide-feed
86
+ */
87
+ scope: {
88
+ type: String,
89
+ default: 'object',
90
+ validator: (value) => ['object', 'app'].includes(value),
91
+ },
92
+
78
93
  /** Rendering surface — passed for AD-19 surface fallback consumers. */
79
94
  surface: {
80
95
  type: String,
@@ -116,13 +131,38 @@ export default {
116
131
  displayedEntries() {
117
132
  return this.entries.slice(0, this.maxDisplay)
118
133
  },
134
+
135
+ /**
136
+ * The path the card reads (prefixed at fetch time), or '' when it has nothing to read: the
137
+ * object's own trail, or the app-wide readable feed narrowed to the
138
+ * register and schema when set.
139
+ *
140
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-the-audit-trail-widget-reads-the-app-wide-feed
141
+ * @return {string}
142
+ */
143
+ fetchUrl() {
144
+ const params = new URLSearchParams({ limit: String(this.maxDisplay) })
145
+ if (this.scope === 'app') {
146
+ if (this.register) {
147
+ params.set('register', this.register)
148
+ }
149
+ if (this.schema) {
150
+ params.set('schema', this.schema)
151
+ }
152
+ return `${this.apiBase}/audit-trails/readable?${params.toString()}`
153
+ }
154
+ if (!this.register || !this.schema || !this.objectId) {
155
+ return ''
156
+ }
157
+ return `${this.apiBase}/objects/${this.register}/${this.schema}/${this.objectId}/audit-trail?${params.toString()}`
158
+ },
119
159
  },
120
160
 
121
161
  watch: {
122
- objectId: {
162
+ fetchUrl: {
123
163
  immediate: true,
124
- handler(id) {
125
- if (id) {
164
+ handler(url) {
165
+ if (url) {
126
166
  this.fetchEntries()
127
167
  }
128
168
  },
@@ -130,20 +170,25 @@ export default {
130
170
  },
131
171
 
132
172
  methods: {
173
+ /**
174
+ * Read the entries from `fetchUrl`. The readable feed answers
175
+ * `{ rows, nextCursor }`, the object trail `{ results }`.
176
+ *
177
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-the-audit-trail-widget-reads-the-app-wide-feed
178
+ * @return {Promise<void>}
179
+ */
133
180
  async fetchEntries() {
134
- if (!this.register || !this.schema || !this.objectId) {
181
+ const url = this.fetchUrl
182
+ if (!url) {
135
183
  return
136
184
  }
137
185
  this.loading = true
138
186
  try {
139
- const params = new URLSearchParams({ limit: String(this.maxDisplay) })
140
- const response = await fetch(
141
- prefixUrl(`${this.apiBase}/objects/${this.register}/${this.schema}/${this.objectId}/audit-trail?${params.toString()}`),
142
- { headers: buildHeaders() },
143
- )
187
+ const response = await fetch(prefixUrl(url), { headers: buildHeaders() })
144
188
  if (response.ok) {
145
189
  const data = await response.json()
146
- this.entries = data.results || data || []
190
+ const list = data.results || data.rows || data
191
+ this.entries = Array.isArray(list) ? list : []
147
192
  } else {
148
193
  this.entries = []
149
194
  }
@@ -5,10 +5,11 @@
5
5
 
6
6
  <template>
7
7
  <CnAuditTrailCard
8
- v-if="resolvedRegister && resolvedSchema && resolvedObjectId"
8
+ v-if="resolvedScope === 'app' || (resolvedRegister && resolvedSchema && resolvedObjectId)"
9
9
  :register="resolvedRegister"
10
10
  :schema="resolvedSchema"
11
11
  :objectId="resolvedObjectId"
12
+ :scope="resolvedScope"
12
13
  :title="resolvedTitle"
13
14
  :maxDisplay="resolvedMaxDisplay" />
14
15
  </template>
@@ -70,6 +71,15 @@ export default {
70
71
  title: { type: String, default: '' },
71
72
  /** Maximum audit rows to render (0 falls back to the card default). */
72
73
  maxDisplay: { type: Number, default: 0 },
74
+ /**
75
+ * `app` lists the app-wide feed (every object the caller may read,
76
+ * narrowed to `register` / `schema` when set) and needs no object;
77
+ * `object` (the default) is one object's trail. Also read from
78
+ * `content.scope`.
79
+ *
80
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-the-audit-trail-widget-reads-the-app-wide-feed
81
+ */
82
+ scope: { type: String, default: '' },
73
83
  /** Stored widget content blob (CnDashboardPage registry branch): `{ title, maxDisplay, register?, schema?, objectId? }`. */
74
84
  content: { type: Object, default: () => ({}) },
75
85
  },
@@ -115,6 +125,17 @@ export default {
115
125
  return typeof s === 'string' ? s : (s && (s.slug || s.name || s.id)) || ''
116
126
  },
117
127
 
128
+ /**
129
+ * The feed scope: explicit prop → content → `object`.
130
+ *
131
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-the-audit-trail-widget-reads-the-app-wide-feed
132
+ * @return {('object'|'app')}
133
+ */
134
+ resolvedScope() {
135
+ const s = this.scope || this.content.scope
136
+ return s === 'app' ? 'app' : 'object'
137
+ },
138
+
118
139
  /**
119
140
  * The card title: explicit prop → content → '' (card default).
120
141
  *
@@ -18,6 +18,7 @@
18
18
  <NcBreadcrumbs
19
19
  v-if="crumbs.length > 0"
20
20
  :aria-label="ariaLabel"
21
+ :rootIcon="rootText ? '' : undefined"
21
22
  data-testid="cn-breadcrumbs">
22
23
  <!-- `name` falls back to '' because NcBreadcrumb declares it a REQUIRED
23
24
  String and warns per render on the label-less icon-only crumb. The
@@ -97,6 +98,19 @@ export default {
97
98
  type: String,
98
99
  default: () => t('nextcloud-vue', 'Breadcrumbs'),
99
100
  },
101
+
102
+ /**
103
+ * Draw the FIRST crumb's label as text. NcBreadcrumbs gives the root
104
+ * crumb a home icon and prints its `label` only as the accessible
105
+ * name, so "All cases" read as a house. Off by default, which keeps
106
+ * the home icon. A first crumb with its own `icon` keeps that icon.
107
+ *
108
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-2/specs/zuiddrecht-pixel-gaps-2/spec.md#requirement-a-declared-breadcrumb-label-shows-as-text
109
+ */
110
+ rootText: {
111
+ type: Boolean,
112
+ default: false,
113
+ },
100
114
  },
101
115
 
102
116
  methods: {
@@ -177,6 +177,9 @@ import { safeHref } from '../../utils/safeHref.js'
177
177
  import { formatValue } from '../../utils/schema.js'
178
178
  import { CnStatusBadge } from '../CnStatusBadge/index.js'
179
179
 
180
+ /** A uuid in any version, the shape OpenRegister ids take. */
181
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
182
+
180
183
  /**
181
184
  * Module-level set of column keys already warned about for a
182
185
  * `widget:"link"` declaration with no resolvable target — guarantees
@@ -483,13 +486,20 @@ export default {
483
486
  *
484
487
  * @return {string}
485
488
  * @spec openspec/changes/workplace-dashboard-primitives/specs/workplace-dashboard-primitives/spec.md#requirement-avatar-and-date-cells
489
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-small-opt-ins-from-the-decidiq-and-learniq-lanes
486
490
  */
487
491
  dateLabel() {
488
492
  const date = parseDateValue(this.value)
489
493
  if (date === null) {
490
494
  return ''
491
495
  }
492
- const options = { day: 'numeric', month: 'short', year: 'numeric' }
496
+ // `widgetProps.showTime: true` adds the time of day ("5 Oct 2026,
497
+ // 08:30"); `widgetProps.timeOnly: true` shows just the time
498
+ // ("08:30"), for a timetable row. Default: the date alone.
499
+ const props = this.widgetProps || {}
500
+ const options = props.timeOnly === true
501
+ ? { hour: '2-digit', minute: '2-digit' }
502
+ : { day: 'numeric', month: 'short', year: 'numeric', ...(props.showTime === true ? { hour: '2-digit', minute: '2-digit' } : {}) }
493
503
  let locale
494
504
  try {
495
505
  locale = getCanonicalLocale()
@@ -738,6 +748,27 @@ export default {
738
748
  return undefined
739
749
  },
740
750
 
751
+ /**
752
+ * Whether the cell draws a uuid, and so takes the monospace uuid
753
+ * style. The property's `format: "uuid"` alone used to decide this,
754
+ * so a reference that a formatter or a widget had resolved to its
755
+ * name was still drawn as code. Now the value has to be a uuid AND
756
+ * nothing resolves it: a resolving widget or formatter, or a value
757
+ * that is not a uuid, renders in the normal text style.
758
+ *
759
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-resolved-reference-is-not-drawn-as-a-uuid
760
+ * @return {boolean}
761
+ */
762
+ showsUuid() {
763
+ if (this.property?.format !== 'uuid') {
764
+ return false
765
+ }
766
+ if (this.widgetComponent || this.widget || this.hasFormatter) {
767
+ return false
768
+ }
769
+ return typeof this.value === 'string' && UUID_PATTERN.test(this.value)
770
+ },
771
+
741
772
  cellClass() {
742
773
  const classes = []
743
774
  if (this.propertyType === 'boolean') {
@@ -749,7 +780,7 @@ export default {
749
780
  if (this.property?.format === 'date-time' || this.property?.format === 'date') {
750
781
  classes.push('cn-cell-renderer--date')
751
782
  }
752
- if (this.property?.format === 'uuid') {
783
+ if (this.showsUuid) {
753
784
  classes.push('cn-cell-renderer--uuid')
754
785
  }
755
786
  if (this.propertyType === 'integer' || this.propertyType === 'number') {
@@ -11,8 +11,14 @@
11
11
  -->
12
12
  <template>
13
13
  <div class="cn-dashboard-page" data-testid="cn-dashboard-page">
14
- <!-- Header -->
15
- <div class="cn-dashboard-page__header" data-testid="cn-dashboard-page-header">
14
+ <!-- Header. `showHeader: false` drops the whole row (title, description,
15
+ header actions, edit toggle) for a page that opens with its own
16
+ heading, such as a greeting; the title then stays as a visually
17
+ hidden heading so the main landmark keeps its name. -->
18
+ <h2 v-if="!showHeader && title" class="hidden-visually" data-testid="cn-dashboard-page-hidden-title">
19
+ {{ resolvedTitle }}
20
+ </h2>
21
+ <div v-if="showHeader" class="cn-dashboard-page__header" data-testid="cn-dashboard-page-header">
16
22
  <div class="cn-dashboard-page__header-left">
17
23
  <div v-if="title || $slots['title-meta']" class="cn-dashboard-page__title-row">
18
24
  <h2 v-if="title" class="cn-dashboard-page__title">
@@ -320,6 +326,7 @@
320
326
  :borderless="widgetBorderless(item)"
321
327
  :flush="item.flush !== false"
322
328
  :buttons="getWidgetButtons(item)"
329
+ :headerLink="getWidgetHeaderLink(item)"
323
330
  :styleConfig="item.styleConfig || {}"
324
331
  :titleIconPosition="getWidgetTitleIconPosition(item)"
325
332
  :titleIconColor="getWidgetTitleIconColor(item)"
@@ -397,9 +404,11 @@
397
404
  :iconUrl="getWidgetIconUrl(item)"
398
405
  :iconClass="getWidgetIconClass(item)"
399
406
  :showTitle="widgetShowTitle(item)"
407
+ :showActions="widgetShowActions(item)"
400
408
  :borderless="widgetBorderless(item)"
401
409
  :flush="item.flush !== false"
402
410
  :buttons="getWidgetButtons(item)"
411
+ :headerLink="getWidgetHeaderLink(item)"
403
412
  :styleConfig="item.styleConfig || {}"
404
413
  :titleIconPosition="getWidgetTitleIconPosition(item)"
405
414
  :titleIconColor="getWidgetTitleIconColor(item)"
@@ -478,6 +487,7 @@
478
487
  :flush="item.flush !== false"
479
488
  :class="{ 'cn-dashboard-page__card-fit': isCardWidget(item) }"
480
489
  :buttons="getWidgetButtons(item)"
490
+ :headerLink="getWidgetHeaderLink(item)"
481
491
  :styleConfig="item.styleConfig || {}"
482
492
  :documentationUrl="getWidgetDocumentationUrl(item)"
483
493
  :docsAnchor="getWidgetDocsAnchor(item)"
@@ -499,9 +509,11 @@
499
509
  :iconUrl="getWidgetIconUrl(item)"
500
510
  :iconClass="getWidgetIconClass(item)"
501
511
  :showTitle="widgetShowTitle(item)"
512
+ :showActions="widgetShowActions(item)"
502
513
  :borderless="widgetBorderless(item)"
503
514
  :flush="item.flush !== false"
504
515
  :buttons="getWidgetButtons(item)"
516
+ :headerLink="getWidgetHeaderLink(item)"
505
517
  :styleConfig="item.styleConfig || {}"
506
518
  :titleIconPosition="getWidgetTitleIconPosition(item)"
507
519
  :titleIconColor="getWidgetTitleIconColor(item)"
@@ -536,7 +548,9 @@
536
548
  :iconUrl="getWidgetIconUrl(item)"
537
549
  :iconClass="getWidgetIconClass(item)"
538
550
  :showTitle="widgetShowTitle(item)"
551
+ :showActions="widgetShowActions(item)"
539
552
  :buttons="getWidgetButtons(item)"
553
+ :headerLink="getWidgetHeaderLink(item)"
540
554
  :styleConfig="item.styleConfig || {}"
541
555
  :showRefresh="getWidgetShowRefresh(item)"
542
556
  :documentationUrl="getWidgetDocumentationUrl(item)"
@@ -566,6 +580,7 @@
566
580
  :titleIconVariant="getWidgetTitleIconVariant(item)"
567
581
  :class="{ 'cn-dashboard-page__card-fit': isCardWidget(item) }"
568
582
  :buttons="getWidgetButtons(item)"
583
+ :headerLink="getWidgetHeaderLink(item)"
569
584
  :styleConfig="item.styleConfig || {}"
570
585
  :documentationUrl="getWidgetDocumentationUrl(item)"
571
586
  :docsAnchor="getWidgetDocsAnchor(item)"
@@ -921,6 +936,34 @@ export default {
921
936
  default: '',
922
937
  },
923
938
 
939
+ /**
940
+ * Whether the header row renders: title, description, header actions
941
+ * and the edit toggle. `false` (manifest `config.showHeader: false`)
942
+ * drops it for a page whose first widget is its heading; the title
943
+ * then renders visually hidden so the page keeps an accessible name.
944
+ *
945
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-dashboard-page-can-hide-its-header
946
+ */
947
+ showHeader: {
948
+ type: Boolean,
949
+ default: true,
950
+ },
951
+
952
+ /**
953
+ * Whether a widget draws its overflow Actions menu when neither its
954
+ * placement nor its definition says (`showActions`). `false` (manifest
955
+ * `config.showWidgetActions: false`) drops the menu from every such
956
+ * widget, for a dashboard whose widgets carry a header link and
957
+ * nothing else. A widget that sets `showActions: true` keeps its menu.
958
+ * Card widgets have no menu either way.
959
+ *
960
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-2/specs/zuiddrecht-pixel-gaps-2/spec.md#requirement-a-dashboard-can-drop-the-widget-actions-menu
961
+ */
962
+ showWidgetActions: {
963
+ type: Boolean,
964
+ default: true,
965
+ },
966
+
924
967
  /**
925
968
  * Widget definitions array. Each widget defines metadata for rendering.
926
969
  *
@@ -3355,11 +3398,20 @@ export default {
3355
3398
  * exactly that reason; there is now one rule for every family, so a
3356
3399
  * custom slot and a registered widget cannot disagree about it.
3357
3400
  *
3401
+ * A greeting header that declares `content.ground: true` sits on the
3402
+ * page ground, so it asks for no card either: one key on the widget
3403
+ * instead of a second one on its placement.
3404
+ *
3405
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-2/specs/zuiddrecht-pixel-gaps-2/spec.md#requirement-a-greeting-can-sit-on-the-page-ground
3358
3406
  * @param {object} item the layout placement.
3359
3407
  * @return {boolean}
3360
3408
  */
3361
3409
  widgetBorderless(item) {
3362
- return item.borderless === true
3410
+ if (item.borderless === true) {
3411
+ return true
3412
+ }
3413
+ const def = this.getWidgetDef(item.widgetId)
3414
+ return Boolean(def && def.type === 'header' && this.getWidgetContent(item).ground === true)
3363
3415
  },
3364
3416
 
3365
3417
  /**
@@ -3376,7 +3428,7 @@ export default {
3376
3428
  const def = this.getWidgetDef(item.widgetId)
3377
3429
  const value = item.showActions !== undefined ? item.showActions : def?.showActions
3378
3430
  if (value === undefined || value === null) {
3379
- return !this.isCardWidget(item)
3431
+ return this.showWidgetActions && !this.isCardWidget(item)
3380
3432
  }
3381
3433
  return value !== false
3382
3434
  },
@@ -3410,6 +3462,22 @@ export default {
3410
3462
  return def?.buttons || []
3411
3463
  },
3412
3464
 
3465
+ /**
3466
+ * The widget's header text link (`headerLink` on the layout entry,
3467
+ * else on the widget definition), forwarded to CnWidgetWrapper.
3468
+ *
3469
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-widget-header-carries-a-text-link
3470
+ * @param {object} item Layout placement entry.
3471
+ * @return {object|null}
3472
+ */
3473
+ getWidgetHeaderLink(item) {
3474
+ if (item?.headerLink && typeof item.headerLink === 'object') {
3475
+ return item.headerLink
3476
+ }
3477
+ const def = this.getWidgetDef(item.widgetId)
3478
+ return (def?.headerLink && typeof def.headerLink === 'object') ? def.headerLink : null
3479
+ },
3480
+
3413
3481
  /**
3414
3482
  * Whether a widget's overflow Actions menu shows the Refresh item.
3415
3483
  * An explicit per-widget flag wins — on the widget definition or
@@ -278,6 +278,14 @@
278
278
  :row="row"
279
279
  :rowKey="rowKey" />
280
280
  </slot>
281
+ <!-- A second, muted line under the value, from `col.secondary`:
282
+ a field key, a "{field} · {other}" template, or a function
283
+ of the row. Lets one column read "title" over "number ·
284
+ requester" without a custom cell. -->
285
+ <span
286
+ v-if="secondaryValue(row, col)"
287
+ class="cn-table-cell__secondary"
288
+ data-testid="cn-cell-secondary">{{ secondaryValue(row, col) }}</span>
281
289
  </td>
282
290
 
283
291
  <!-- Row actions -->
@@ -444,7 +452,13 @@ export default {
444
452
  * reader is looking. `columnsFromSchema` fills it from the JSON Schema property
445
453
  * description automatically, so schema-driven tables get it for free.
446
454
  *
447
- * @type {Array<{key: string, label: string, description: string, sortable: boolean, width: string, class: string, cellClass: string}|string>}
455
+ * `secondary` draws a second, muted line under the cell's value: a field
456
+ * key (`"number"`), a template with `{field}` placeholders
457
+ * (`"{number} · {requester}"`, dotted paths allowed), or a function of
458
+ * the row. Empty fields leave their placeholder blank; a line that
459
+ * resolves to nothing is not drawn.
460
+ *
461
+ * @type {Array<{key: string, label: string, description: string, sortable: boolean, width: string, class: string, cellClass: string, secondary: (string|((row: object) => string))}|string>}
448
462
  */
449
463
  columns: {
450
464
  type: Array,
@@ -1415,6 +1429,36 @@ export default {
1415
1429
  * @param {object} col The column definition.
1416
1430
  * @return {unknown} The value handed to the slot / CnCellRenderer.
1417
1431
  */
1432
+ /**
1433
+ * The secondary line of a cell, from `col.secondary`: a function is
1434
+ * called with the row; a string with `{field}` placeholders is a
1435
+ * template filled from the row (dotted paths allowed); any other
1436
+ * string is a field key. Returns '' when there is nothing to draw.
1437
+ *
1438
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-table-column-carries-a-secondary-line
1439
+ * @param {object} row The row.
1440
+ * @param {object} col The column definition.
1441
+ * @return {string} The secondary text, or ''.
1442
+ */
1443
+ secondaryValue(row, col) {
1444
+ const spec = col?.secondary
1445
+ if (typeof spec === 'function') {
1446
+ const out = spec(row)
1447
+ return out === null || out === undefined ? '' : String(out)
1448
+ }
1449
+ if (typeof spec !== 'string' || spec === '') {
1450
+ return ''
1451
+ }
1452
+ const asText = (v) => (v === null || v === undefined ? '' : String(v))
1453
+ if (spec.includes('{')) {
1454
+ const filled = spec.replace(/\{([^}]+)\}/g, (_, key) => asText(this.getCellValue(row, key.trim())))
1455
+ // A template whose every field is empty leaves only separators:
1456
+ // nothing worth a line.
1457
+ return /[\p{L}\p{N}]/u.test(filled) ? filled.trim() : ''
1458
+ }
1459
+ return asText(this.getCellValue(row, spec))
1460
+ },
1461
+
1418
1462
  cellValue(row, col) {
1419
1463
  if (col && col.aggregate) {
1420
1464
  const cached = this.aggregateValues[String(row[this.rowKey])]
@@ -430,3 +430,11 @@ Four levels of action and the case surfaces around them. All opt in. See [the re
430
430
  ## Not found
431
431
 
432
432
  When the schema-driven fetch answers 404 the page shows a not-found state whose back button is a router link to `notFoundRoute` (default `null`, which means the app root `/`). `notFoundRouteLabel` names that page in the button's "Back to …" label.
433
+
434
+ ### Type eyebrow and breadcrumb
435
+
436
+ `showTypeEyebrow` (default `true`) can drop the type label above the record name. `breadcrumb` (`{ label, route?, params?, href?, icon? }`) draws a breadcrumb line above the header: that crumb, then the record's name as the current crumb. The label shows as text; `icon` draws an icon instead.
437
+
438
+ ### Header card and header widget
439
+
440
+ `headerCard` (default `false`) draws the header as a bordered card. `headerWidget` (default `''`) names a widget in `widgets` to render inside the header under the title, without a card of its own and out of the body grid: the stages bars of the board's case card.