@conduction/nextcloud-vue 2.77.0 → 2.78.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 (82) hide show
  1. package/dist/esm/components/CnAiCompanion/CnAiCompanion.vue2.js +23 -0
  2. package/dist/esm/components/CnAiCompanion/CnAiCompanion.vue2.js.map +1 -1
  3. package/dist/esm/components/CnAiCompanion/CnAiCompanion.vue3.js.map +1 -1
  4. package/dist/esm/components/CnBoardView/CnBoardView.vue2.js +2 -1
  5. package/dist/esm/components/CnBoardView/CnBoardView.vue2.js.map +1 -1
  6. package/dist/esm/components/CnBoardView/CnBoardView.vue3.js.map +1 -1
  7. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js +37 -0
  8. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js.map +1 -1
  9. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js +8 -7
  10. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js.map +1 -1
  11. package/dist/esm/components/CnChartWidget/CnChartWidget.vue2.js +37 -0
  12. package/dist/esm/components/CnChartWidget/CnChartWidget.vue2.js.map +1 -1
  13. package/dist/esm/components/CnChartWidget/CnChartWidget.vue3.js +2 -2
  14. package/dist/esm/components/CnChartWidget/CnChartWidget.vue3.js.map +1 -1
  15. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +29 -4
  16. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  17. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  18. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +7 -2
  19. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  20. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  21. package/dist/esm/components/CnDateRangePicker/CnDateRangePicker.vue.js +1 -1
  22. package/dist/esm/components/CnDateRangePicker/CnDateRangePicker.vue2.js +45 -2
  23. package/dist/esm/components/CnDateRangePicker/CnDateRangePicker.vue2.js.map +1 -1
  24. package/dist/esm/components/CnDateRangePicker/CnDateRangePicker.vue3.js.map +1 -1
  25. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +7 -1
  26. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  27. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  28. package/dist/esm/components/CnFormPage/CnFormPage.vue2.js +4 -1
  29. package/dist/esm/components/CnFormPage/CnFormPage.vue2.js.map +1 -1
  30. package/dist/esm/components/CnFormPage/CnFormPage.vue3.js.map +1 -1
  31. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +6 -0
  32. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  33. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  34. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +36 -0
  35. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
  36. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js +35 -23
  37. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
  38. package/dist/esm/components/CnStatusBadge/CnStatusBadge.vue2.js +6 -2
  39. package/dist/esm/components/CnStatusBadge/CnStatusBadge.vue2.js.map +1 -1
  40. package/dist/esm/components/CnStatusBadge/CnStatusBadge.vue3.js.map +1 -1
  41. package/dist/esm/composables/useFormDraft.js +11 -1
  42. package/dist/esm/composables/useFormDraft.js.map +1 -1
  43. package/dist/esm/composables/useObjectPresence.js +15 -4
  44. package/dist/esm/composables/useObjectPresence.js.map +1 -1
  45. package/dist/esm/l10n/en.json.js +7 -0
  46. package/dist/esm/l10n/en.json.js.map +1 -1
  47. package/dist/esm/l10n/nl.json.js +7 -0
  48. package/dist/esm/l10n/nl.json.js.map +1 -1
  49. package/dist/esm/nextcloud-vue.css +212 -1
  50. package/dist/esm/utils/badgeVariants.js +32 -0
  51. package/dist/esm/utils/badgeVariants.js.map +1 -0
  52. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  53. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  54. package/dist/nextcloud-vue.cjs.js +533 -233
  55. package/dist/nextcloud-vue.cjs.js.map +2 -2
  56. package/dist/nextcloud-vue.css +212 -1
  57. package/l10n/en.json +7 -0
  58. package/l10n/nl.json +7 -0
  59. package/package.json +1 -1
  60. package/src/components/CnAiCompanion/CnAiCompanion.vue +23 -0
  61. package/src/components/CnBoardView/CnBoardView.vue +2 -1
  62. package/src/components/CnCellRenderer/CnCellRenderer.md +1 -0
  63. package/src/components/CnCellRenderer/CnCellRenderer.vue +50 -8
  64. package/src/components/CnChartWidget/CnChartWidget.vue +39 -2
  65. package/src/components/CnDashboardPage/CnDashboardPage.vue +29 -4
  66. package/src/components/CnDataTable/CnDataTable.vue +7 -2
  67. package/src/components/CnDateRangePicker/CnDateRangePicker.vue +44 -1
  68. package/src/components/CnFormDialog/CnFormDialog.vue +7 -1
  69. package/src/components/CnFormPage/CnFormPage.vue +4 -1
  70. package/src/components/CnIndexPage/CnIndexPage.vue +6 -0
  71. package/src/components/CnStatWidget/CnStatWidget.vue +52 -2
  72. package/src/components/CnStatusBadge/CnStatusBadge.vue +6 -2
  73. package/src/composables/__tests__/useObjectPresence.spec.js +24 -0
  74. package/src/composables/useFormDraft.js +11 -1
  75. package/src/composables/useObjectPresence.js +15 -4
  76. package/src/css/badge.css +31 -0
  77. package/src/css/kpi-card.css +51 -0
  78. package/src/css/look-board-index.css +36 -0
  79. package/src/css/look-board.css +8 -0
  80. package/src/schemas/app-manifest-v2.schema.json +5 -5
  81. package/src/utils/badgeVariants.js +29 -0
  82. package/src/utils/validateManifestV2.compiled.js +2 -2
@@ -1646,19 +1646,24 @@ export default {
1646
1646
  * handed a `:schema` for at all) still get type-aware rendering.
1647
1647
  * `enumLabels` rides with `enum` rather than getting its own `if`
1648
1648
  * branch: a column can't usefully declare labels without also
1649
- * declaring the codes they label.
1649
+ * declaring the codes they label. A column `colorMap` (raw value to
1650
+ * badge variant) colours the enum pill from the manifest and wins over
1651
+ * the schema property's own `colorMap` / `x-color-map`.
1650
1652
  *
1651
1653
  * @param {object} col Column definition.
1652
1654
  * @return {object} Property definition for CnCellRenderer.
1655
+ * @spec openspec/changes/screens-cell-pill-parity/specs/cell-pill-tones/spec.md#requirement-a-manifest-column-colours-its-enum-pills
1653
1656
  */
1654
1657
  columnProperty(col) {
1655
1658
  const base = this.getSchemaProperty(col.key)
1656
- if (col && (col.format || col.type || col.enum)) {
1659
+ const colorMap = col && col.colorMap && typeof col.colorMap === 'object' && !Array.isArray(col.colorMap) ? col.colorMap : null
1660
+ if (col && (col.format || col.type || col.enum || colorMap)) {
1657
1661
  return {
1658
1662
  ...base,
1659
1663
  ...(col.type ? { type: col.type } : {}),
1660
1664
  ...(col.format ? { format: col.format } : {}),
1661
1665
  ...(col.enum ? { enum: col.enum, enumLabels: col.enumLabels || base.enumLabels } : {}),
1666
+ ...(colorMap ? { colorMap } : {}),
1662
1667
  }
1663
1668
  }
1664
1669
  return base
@@ -69,6 +69,44 @@ export const DEFAULT_DATE_RANGE_PRESETS = Object.freeze([
69
69
  { id: 'custom', label: 'Custom range', days: null },
70
70
  ])
71
71
 
72
+ /**
73
+ * The library's own translations of the default preset labels. Literal
74
+ * `t()` calls, so the strings are extracted into the `nextcloud-vue`
75
+ * catalogue.
76
+ *
77
+ * @type {{[label: string]: function(): string}}
78
+ */
79
+ const LIBRARY_PRESET_LABELS = Object.freeze({
80
+ 'Last 8 hours': () => t('nextcloud-vue', 'Last 8 hours'),
81
+ 'Last 24 hours': () => t('nextcloud-vue', 'Last 24 hours'),
82
+ Today: () => t('nextcloud-vue', 'Today'),
83
+ 'Last 7 days': () => t('nextcloud-vue', 'Last 7 days'),
84
+ 'Last 30 days': () => t('nextcloud-vue', 'Last 30 days'),
85
+ 'Last 90 days': () => t('nextcloud-vue', 'Last 90 days'),
86
+ 'Custom range': () => t('nextcloud-vue', 'Custom range'),
87
+ })
88
+
89
+ /**
90
+ * A preset label in the user's language: the host translation when the app's
91
+ * catalogue has the key, else the library's own translation of a default
92
+ * label ("Last 30 days"), else the label as written.
93
+ *
94
+ * @spec openspec/changes/screens-dashboard-i18n/specs/dashboard-page/spec.md#requirement-date-range-preset-labels-are-translated
95
+ * @param {string} label The preset label (an i18n key).
96
+ * @param {function(string): string} [translate] The host translate function.
97
+ * @return {string} The translated label.
98
+ */
99
+ export function translatePresetLabel(label, translate) {
100
+ if (typeof label !== 'string' || label === '') {
101
+ return ''
102
+ }
103
+ const own = typeof translate === 'function' ? translate(label) : label
104
+ if (typeof own === 'string' && own !== '' && own !== label) {
105
+ return own
106
+ }
107
+ return Object.hasOwn(LIBRARY_PRESET_LABELS, label) ? LIBRARY_PRESET_LABELS[label]() : label
108
+ }
109
+
72
110
  /**
73
111
  * Resolve a `period` preset into its calendar-aligned `{ from, to }`
74
112
  * window — the CURRENT week / month / quarter / year to date, not a
@@ -196,6 +234,11 @@ export default {
196
234
  NcSelect,
197
235
  },
198
236
 
237
+ inject: {
238
+ /** Host translate function provided by CnAppRoot, for app-authored preset labels. */
239
+ cnTranslate: { default: () => (key) => key },
240
+ },
241
+
199
242
  props: {
200
243
  /**
201
244
  * Current range value. `from` / `to` are ISO-8601 UTC strings;
@@ -327,7 +370,7 @@ export default {
327
370
  },
328
371
 
329
372
  presetOptions() {
330
- return this.presets.map((p) => ({ id: p.id, label: p.label, days: p.days }))
373
+ return this.presets.map((p) => ({ id: p.id, label: translatePresetLabel(p.label, this.cnTranslate), days: p.days }))
331
374
  },
332
375
 
333
376
  selectedPresetOption() {
@@ -1466,11 +1466,17 @@ export default {
1466
1466
 
1467
1467
  /**
1468
1468
  * What the draft indicator announces, or '' when it says nothing.
1469
+ * While the dialog shows a failed save it never says Saved: it says
1470
+ * the draft is kept on this device, so it cannot be read as the
1471
+ * server save that just failed.
1469
1472
  *
1470
1473
  * @return {string} The text.
1474
+ * @spec openspec/changes/cell-labels-and-draft-indicator/specs/cell-labels-and-draft-indicator/spec.md#requirement-the-draft-indicator-never-reads-as-a-server-save-after-a-failed-save
1471
1475
  */
1472
1476
  draftIndicatorLabel() {
1473
- return draftIndicatorText(this.draftState, this.draftSavedAt)
1477
+ const saveFailed = this.result !== null && typeof this.result === 'object'
1478
+ && (Boolean(this.result.error) || this.result.success === false)
1479
+ return draftIndicatorText(this.draftState, this.draftSavedAt, Date.now(), { saveFailed })
1474
1480
  },
1475
1481
 
1476
1482
  isCreateMode() {
@@ -954,11 +954,14 @@ export default {
954
954
 
955
955
  /**
956
956
  * What the draft indicator announces: Saving, then Saved with a relative time.
957
+ * After a failed submit it says the draft is kept on this device instead,
958
+ * so it cannot be read as the server save that just failed.
957
959
  *
958
960
  * @return {string} The text, or '' before anything is typed.
961
+ * @spec openspec/changes/cell-labels-and-draft-indicator/specs/cell-labels-and-draft-indicator/spec.md#requirement-the-draft-indicator-never-reads-as-a-server-save-after-a-failed-save
959
962
  */
960
963
  draftIndicatorLabel() {
961
- return draftIndicatorText(this.draftState, this.draftSavedAt)
964
+ return draftIndicatorText(this.draftState, this.draftSavedAt, Date.now(), { saveFailed: Boolean(this.lastError) })
962
965
  },
963
966
  },
964
967
 
@@ -5156,6 +5156,7 @@ export default {
5156
5156
  * stable key and a variant.
5157
5157
  *
5158
5158
  * @spec openspec/changes/zuiddrecht-pixel-gaps-3/specs/zuiddrecht-pixel-gaps-3/spec.md#requirement-an-index-page-can-take-the-board-header
5159
+ * @spec openspec/changes/screens-index-header-buttons-parity/specs/index-list-board-look/spec.md#requirement-the-add-header-button-carries-a-plus
5159
5160
  * @return {Array<{key: string, label: string, action: string, variant: string, icon: string, format: string}>}
5160
5161
  */
5161
5162
  resolvedHeaderButtons() {
@@ -5168,6 +5169,11 @@ export default {
5168
5169
  if (label === '' && button.action === 'add') {
5169
5170
  label = this.resolvedAddLabel
5170
5171
  }
5172
+ // The board's primary button reads "+ New request": the Add
5173
+ // button takes the plus unless the manifest names an icon.
5174
+ if (this.isBoardLook && button.action === 'add' && icon === '') {
5175
+ icon = 'Plus'
5176
+ }
5171
5177
  // The board's header: Export reads "Download" with its icon and
5172
5178
  // the Actions menu reads "Actions", without the manifest saying so.
5173
5179
  if (this.isBoardLook && label === '' && button.action === 'export') {
@@ -33,11 +33,14 @@
33
33
  </div>
34
34
 
35
35
  <div class="cn-kpi-card__body cn-stat-widget__body">
36
- <div v-if="content.label || rangePresets.length" class="cn-kpi-card__title cn-stat-widget__label">
36
+ <div
37
+ v-if="content.label || rangePresets.length"
38
+ class="cn-kpi-card__title cn-stat-widget__label"
39
+ :class="{ 'cn-kpi-card__title--badge-end': hasEndBadge }">
37
40
  <!-- Board look: the icon is an 18px glyph before the label, and
38
41
  only when the tile links to a filtered list. -->
39
42
  <span
40
- v-if="isBoardLook && isLinked && resolvedIcon"
43
+ v-if="isBoardLook && isLinked && resolvedIcon && !hasEndBadge"
41
44
  class="cn-kpi-card__glyph cn-stat-widget__glyph"
42
45
  data-testid="cn-stat-widget-glyph"
43
46
  aria-hidden="true">
@@ -66,6 +69,17 @@
66
69
  {{ effectiveTranslate(preset.label || preset.id) }}
67
70
  </option>
68
71
  </select>
72
+ <!-- Board look, `content.iconPlacement: "end"`: the icon sits in a
73
+ 32px circle at the right of the label row, tinted by the
74
+ tile's state (DqDashboard). -->
75
+ <span
76
+ v-if="hasEndBadge"
77
+ class="cn-kpi-card__badge cn-stat-widget__badge-icon"
78
+ :class="'cn-kpi-card__badge--' + stateTone"
79
+ data-testid="cn-stat-widget-icon-badge"
80
+ aria-hidden="true">
81
+ <CnWidgetIcon :name="resolvedIcon" :size="18" />
82
+ </span>
69
83
  </div>
70
84
 
71
85
  <div class="cn-kpi-card__value-row cn-stat-widget__value-row">
@@ -1330,9 +1344,45 @@ export default {
1330
1344
  if (layout === 'vertical' || layout === 'stacked') {
1331
1345
  return layout
1332
1346
  }
1347
+ // Board look: every KPI tile on the screens is stacked, so a tile
1348
+ // that names no layout takes it. An explicit `horizontal` stays.
1349
+ if (this.isBoardLook && layout === undefined) {
1350
+ return 'stacked'
1351
+ }
1333
1352
  return 'horizontal'
1334
1353
  },
1335
1354
 
1355
+ /**
1356
+ * Whether the board look draws the icon in a tinted circle at the
1357
+ * right of the label row (`content.iconPlacement: "end"`), as the
1358
+ * DqDashboard tiles. Off without the board look and without an icon.
1359
+ *
1360
+ * @spec openspec/changes/screens-stat-tile-parity/specs/dashboard-page/spec.md#requirement-the-board-kpi-tile-can-carry-its-icon-at-the-end
1361
+ * @return {boolean}
1362
+ */
1363
+ hasEndBadge() {
1364
+ return this.isBoardLook
1365
+ && (this.content || {}).iconPlacement === 'end'
1366
+ && Boolean(this.resolvedIcon)
1367
+ },
1368
+
1369
+ /**
1370
+ * The tile's state as a tone name for the end badge: the variant the
1371
+ * value colour comes from (`success`, `warning`, `error`), else
1372
+ * `primary`. A custom `valueColor` has no tone and reads as primary.
1373
+ *
1374
+ * @spec openspec/changes/screens-stat-tile-parity/specs/dashboard-page/spec.md#requirement-the-board-kpi-tile-can-carry-its-icon-at-the-end
1375
+ * @return {'primary'|'success'|'warning'|'error'}
1376
+ */
1377
+ stateTone() {
1378
+ const color = this.variantColor
1379
+ if (!color) {
1380
+ return 'primary'
1381
+ }
1382
+ const tone = ['success', 'warning', 'error'].find((name) => VARIANT_COLORS[name] === color)
1383
+ return tone || 'primary'
1384
+ },
1385
+
1336
1386
  /**
1337
1387
  * Whether the tile takes the stacked board look (`content.layout:
1338
1388
  * "stacked"`): a plain muted label, the number at 34px in the text
@@ -10,6 +10,8 @@
10
10
  </template>
11
11
 
12
12
  <script>
13
+ import { isBadgeVariant } from '../../utils/badgeVariants.js'
14
+
13
15
  /**
14
16
  * CnStatusBadge — Color-coded pill badge for status, priority, or category display.
15
17
  *
@@ -39,12 +41,14 @@ export default {
39
41
  },
40
42
 
41
43
  /**
42
- * Color variant: 'default', 'primary', 'success', 'warning', 'error', 'info'
44
+ * Color variant: 'default', 'primary', 'success', 'warning', 'error',
45
+ * 'info', or one of the two board tones without a Nextcloud role,
46
+ * 'purple' (work in progress) and 'teal' (handed on).
43
47
  */
44
48
  variant: {
45
49
  type: String,
46
50
  default: 'default',
47
- validator: (v) => ['default', 'primary', 'success', 'warning', 'error', 'info'].includes(v),
51
+ validator: (v) => isBadgeVariant(v),
48
52
  },
49
53
 
50
54
  /** Size: 'small' or 'medium' */
@@ -40,6 +40,7 @@ jest.mock('@nextcloud/router', () => ({
40
40
  jest.mock('@nextcloud/auth', () => ({
41
41
  __esModule: true,
42
42
  getCurrentUser: () => ({ uid: 'anna' }),
43
+ getRequestToken: () => 'token-123',
43
44
  }))
44
45
 
45
46
  /** The subscription the composable made, so a test can push into it. */
@@ -246,6 +247,29 @@ describe('useObjectPresence', () => {
246
247
  scope.stop()
247
248
  })
248
249
 
250
+ it('🔴 a closing tab departs with a beacon that carries the request token', async () => {
251
+ // A beacon cannot set headers, and the departure route keeps the CSRF
252
+ // check, so the token has to travel in the form body. The old beacon
253
+ // sent no body at all and could never pass that check.
254
+ const sendBeacon = jest.fn(() => true)
255
+ const original = navigator.sendBeacon
256
+ Object.defineProperty(navigator, 'sendBeacon', { value: sendBeacon, configurable: true, writable: true })
257
+ try {
258
+ await withPresence(async () => {
259
+ await nextTick()
260
+ await Promise.resolve()
261
+ window.dispatchEvent(new Event('beforeunload'))
262
+ })
263
+ } finally {
264
+ Object.defineProperty(navigator, 'sendBeacon', { value: original, configurable: true, writable: true })
265
+ }
266
+
267
+ const call = sendBeacon.mock.calls.find(([url]) => url === '/apps/openregister/api/objects/dossiq/case/case-1/presence?_method=DELETE')
268
+ expect(call).toBeDefined()
269
+ expect(call[1]).toBeInstanceOf(FormData)
270
+ expect(call[1].get('requesttoken')).toBe('token-123')
271
+ })
272
+
249
273
  it('does nothing at all when disabled', async () => {
250
274
  const scope = effectScope()
251
275
  scope.run(() => {
@@ -127,12 +127,22 @@ export function readDraft(key, { objectUpdated = '', now = Date.now() } = {}) {
127
127
  * What the draft indicator says: "Saving" while a local write is pending,
128
128
  * "Saved just now" / "Saved 3 minutes ago" once it landed, nothing before that.
129
129
  *
130
+ * After a failed save (`options.saveFailed`) it never says Saving or Saved:
131
+ * next to a server error those words read as if the record had been saved.
132
+ * It then says "Draft kept on this device" when a local copy is stored, and
133
+ * nothing otherwise.
134
+ *
130
135
  * @param {string} state `idle`, `saving` or `saved`.
131
136
  * @param {number} [savedAt] When the last write landed, in epoch ms.
132
137
  * @param {number} [now] The current epoch ms, for testing.
138
+ * @param {{saveFailed?: boolean}} [options] `saveFailed`: the form shows a failed save.
133
139
  * @return {string} The text, or '' when it says nothing.
140
+ * @spec openspec/changes/cell-labels-and-draft-indicator/specs/cell-labels-and-draft-indicator/spec.md#requirement-the-draft-indicator-never-reads-as-a-server-save-after-a-failed-save
134
141
  */
135
- export function draftIndicatorText(state, savedAt = 0, now = Date.now()) {
142
+ export function draftIndicatorText(state, savedAt = 0, now = Date.now(), options = {}) {
143
+ if (options && options.saveFailed === true) {
144
+ return state === 'saved' ? t('nextcloud-vue', 'Draft kept on this device') : ''
145
+ }
136
146
  if (state === 'saving') {
137
147
  return t('nextcloud-vue', 'Saving')
138
148
  }
@@ -1,7 +1,7 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  // Copyright (C) 2026 Conduction B.V.
3
3
 
4
- import { getCurrentUser } from '@nextcloud/auth'
4
+ import { getCurrentUser, getRequestToken } from '@nextcloud/auth'
5
5
  import axios from '@nextcloud/axios'
6
6
  import { generateUrl } from '@nextcloud/router'
7
7
  import { computed, getCurrentScope, onScopeDispose, ref, unref, watch } from 'vue'
@@ -201,7 +201,15 @@ export function useObjectPresence(register, schema, objectUuid, options = {}) {
201
201
  * the browser and survives the navigation, which is the only mechanism
202
202
  * that does.
203
203
  *
204
+ * 🔴 THE TOKEN RIDES IN THE BODY. A beacon cannot set headers, so the
205
+ * `requesttoken` header axios adds is missing, and the departure route
206
+ * keeps Nextcloud's CSRF check. Nextcloud also reads the token from a
207
+ * POST field, so it goes in a form body. Without it the beacon answers
208
+ * 412; before OpenRegister registered POST on `/presence` it answered 405
209
+ * (seen on cloud.conduction.nl on every dossiq case page).
210
+ *
204
211
  * @return {void}
212
+ * @spec openspec/changes/no-calls-to-missing-apps-and-presence-beacon/specs/object-presence/spec.md#requirement-a-closing-tab-departs-through-a-route-that-exists
205
213
  */
206
214
  function beaconDepart() {
207
215
  const url = presenceUrl()
@@ -209,9 +217,12 @@ export function useObjectPresence(register, schema, objectUuid, options = {}) {
209
217
  return
210
218
  }
211
219
  try {
212
- // No DELETE from a beacon — it is always a POST — so the server's
213
- // own POST release path is used. Both reach one implementation.
214
- navigator.sendBeacon?.(url.replace(/\/presence$/, '/presence?_method=DELETE'))
220
+ // No DELETE from a beacon, it is always a POST, so the server's
221
+ // POST departure route is used, marked `_method=DELETE`. Both reach
222
+ // one implementation.
223
+ const body = new FormData()
224
+ body.append('requesttoken', getRequestToken() || '')
225
+ navigator.sendBeacon?.(url.replace(/\/presence$/, '/presence?_method=DELETE'), body)
215
226
  } catch {
216
227
  // Best effort.
217
228
  }
package/src/css/badge.css CHANGED
@@ -2,6 +2,15 @@
2
2
  @conduction/nextcloud-vue — Status Badge
3
3
  ======================================== */
4
4
 
5
+ /* The two board tones without a Nextcloud colour role (screens-cell-pill-parity).
6
+ A theme may redefine them; the six role variants below use Nextcloud's own. */
7
+ :root {
8
+ --cn-status-purple-bg: #e8e6f6;
9
+ --cn-status-purple-text: #4a3f8f;
10
+ --cn-status-teal-bg: #e3f1f3;
11
+ --cn-status-teal-text: #1d5e66;
12
+ }
13
+
5
14
  .cn-status-badge {
6
15
  display: inline-flex;
7
16
  align-items: center;
@@ -51,6 +60,18 @@
51
60
  color: var(--color-info-text, var(--color-info, #0082c9));
52
61
  }
53
62
 
63
+ /* Work in progress ("In behandeling"), a board tone. */
64
+ .cn-status-badge--purple {
65
+ background-color: var(--cn-status-purple-bg);
66
+ color: var(--cn-status-purple-text);
67
+ }
68
+
69
+ /* Handed on ("Omgezet naar een zaak"), a board tone. */
70
+ .cn-status-badge--teal {
71
+ background-color: var(--cn-status-teal-bg);
72
+ color: var(--cn-status-teal-text);
73
+ }
74
+
54
75
  /* Solid variants — solid background with white text for use on colored backgrounds */
55
76
  .cn-status-badge--solid.cn-status-badge--default {
56
77
  background-color: var(--color-background-dark);
@@ -81,3 +102,13 @@
81
102
  background-color: var(--color-info, #0082c9);
82
103
  color: white;
83
104
  }
105
+
106
+ .cn-status-badge--solid.cn-status-badge--purple {
107
+ background-color: var(--cn-status-purple-text);
108
+ color: white;
109
+ }
110
+
111
+ .cn-status-badge--solid.cn-status-badge--teal {
112
+ background-color: var(--cn-status-teal-text);
113
+ color: white;
114
+ }
@@ -463,6 +463,57 @@ a.cn-kpi-card--stacked:focus-visible {
463
463
  gap: 6px;
464
464
  }
465
465
 
466
+ /* A linked board tile is one link: the label is not underlined link text, in
467
+ any layout (screens-stat-tile-parity). The NL Design theme underlines every
468
+ `a` with `!important`, so the opt-out needs it too. */
469
+ a.cn-kpi-card--board,
470
+ a.cn-kpi-card--board:hover,
471
+ a.cn-kpi-card--board:focus-visible,
472
+ a.cn-kpi-card--board * {
473
+ text-decoration: none !important;
474
+ }
475
+
476
+ /* `content.iconPlacement: "end"` (DqDashboard): label left, the icon in a
477
+ 32px circle right, tinted by the tile's state. */
478
+ .cn-kpi-card--board .cn-kpi-card__title--badge-end {
479
+ justify-content: space-between;
480
+ gap: 8px;
481
+ }
482
+
483
+ .cn-kpi-card__badge {
484
+ display: inline-flex;
485
+ flex: 0 0 auto;
486
+ align-items: center;
487
+ justify-content: center;
488
+ width: 32px;
489
+ height: 32px;
490
+ margin-inline-start: auto;
491
+ border-radius: 50%;
492
+ color: var(--color-primary-element-light-text, var(--color-primary-element));
493
+ background: var(--color-primary-element-light, color-mix(in srgb, var(--color-primary-element) 12%, transparent));
494
+ }
495
+
496
+ .cn-kpi-card__badge svg {
497
+ width: 18px;
498
+ height: 18px;
499
+ fill: currentcolor;
500
+ }
501
+
502
+ .cn-kpi-card__badge--success {
503
+ color: var(--color-text-success, var(--color-success-text));
504
+ background: color-mix(in srgb, var(--color-text-success, var(--color-success-text)) 12%, var(--color-main-background));
505
+ }
506
+
507
+ .cn-kpi-card__badge--warning {
508
+ color: var(--color-element-warning, var(--color-warning-text));
509
+ background: color-mix(in srgb, var(--color-element-warning, var(--color-warning-text)) 12%, var(--color-main-background));
510
+ }
511
+
512
+ .cn-kpi-card__badge--error {
513
+ color: var(--color-text-error, var(--color-error-text));
514
+ background: color-mix(in srgb, var(--color-text-error, var(--color-error-text)) 10%, var(--color-main-background));
515
+ }
516
+
466
517
  .cn-kpi-card__glyph {
467
518
  display: inline-flex;
468
519
  flex: 0 0 auto;
@@ -45,6 +45,42 @@
45
45
  margin-inline-start: 0;
46
46
  }
47
47
 
48
+ /* The header buttons keep the board's button under a theme that styles every
49
+ secondary or primary button with `!important` (the nldesign theme: a
50
+ primary border, 8px 16px padding): Download and the Actions menu are 40px,
51
+ a grey border on the main background (PqTickets, DqZaken), the primary
52
+ button is the primary fill without a border. The buildiq square is not a
53
+ direct child button and keeps its own rule. */
54
+ .cn-look-board .cn-index-page .cn-index-page__header-buttons > .button-vue--secondary,
55
+ .cn-look-board .cn-index-page .cn-index-page__header-buttons > .action-item .button-vue {
56
+ height: var(--cn-board-control-height) !important;
57
+ min-height: var(--cn-board-control-height) !important;
58
+ padding: 0 14px !important;
59
+ border: 1px solid var(--color-border-dark) !important;
60
+ border-radius: var(--cn-board-control-radius) !important;
61
+ background: var(--color-main-background) !important;
62
+ color: var(--color-main-text) !important;
63
+ }
64
+
65
+ .cn-look-board .cn-index-page .cn-index-page__header-buttons > .button-vue--secondary:hover,
66
+ .cn-look-board .cn-index-page .cn-index-page__header-buttons > .action-item .button-vue:hover {
67
+ background: var(--color-background-hover) !important;
68
+ }
69
+
70
+ .cn-look-board .cn-index-page .cn-index-page__header-buttons > .button-vue--primary {
71
+ height: var(--cn-board-control-height) !important;
72
+ min-height: var(--cn-board-control-height) !important;
73
+ padding: 0 16px !important;
74
+ border: 0 !important;
75
+ border-radius: var(--cn-board-control-radius) !important;
76
+ background: var(--color-primary-element) !important;
77
+ color: var(--color-primary-element-text) !important;
78
+ }
79
+
80
+ .cn-look-board .cn-index-page .cn-index-page__header-buttons > .button-vue--primary:hover {
81
+ background: var(--color-primary-element-hover) !important;
82
+ }
83
+
48
84
  /* ----------------------------------------------------------------- toolbar */
49
85
 
50
86
  /* No band, no padding, no radius. The DOM is already in reading order (row 1,
@@ -435,3 +435,11 @@
435
435
  color: var(--color-main-text);
436
436
  opacity: 1;
437
437
  }
438
+
439
+ /* ---- Status pills (screens-cell-pill-parity) ---------------------------- */
440
+ /* The board draws a primary pill in the deep primary ink on the light primary
441
+ ground ("Verzoek", "Nieuw"); the default look keeps the brighter
442
+ primary-element text. The other tones already match the board. */
443
+ .cn-look-board .cn-status-badge--primary:not(.cn-status-badge--solid):not(.cn-look-nextcloud *) {
444
+ color: var(--color-primary-element-light-text);
445
+ }
@@ -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.75.0",
6
+ "version": "2.76.0",
7
7
  "type": "object",
8
8
  "required": ["$schema", "version"],
9
9
  "allOf": [
@@ -1094,7 +1094,7 @@
1094
1094
  "pillColors": {
1095
1095
  "type": "object",
1096
1096
  "description": "Pill value to badge variant. A value without an entry takes the default variant.",
1097
- "additionalProperties": { "type": "string", "enum": ["default", "primary", "success", "warning", "error", "info"] }
1097
+ "additionalProperties": { "type": "string", "enum": ["default", "primary", "success", "warning", "error", "info", "purple", "teal"] }
1098
1098
  },
1099
1099
  "due": { "type": "string", "description": "The date field of the footer's due text, marked by `dueRule`." },
1100
1100
  "owner": { "type": "string", "description": "The field of the owner shown as an avatar (initials) at the footer's end." }
@@ -1143,7 +1143,7 @@
1143
1143
  "colorMap": {
1144
1144
  "type": "object",
1145
1145
  "description": "Raw value to badge variant. Keys are matched ignoring case.",
1146
- "additionalProperties": { "type": "string", "enum": ["default", "primary", "success", "warning", "error", "info"] }
1146
+ "additionalProperties": { "type": "string", "enum": ["default", "primary", "success", "warning", "error", "info", "purple", "teal"] }
1147
1147
  },
1148
1148
  "labels": {
1149
1149
  "type": "object",
@@ -1152,7 +1152,7 @@
1152
1152
  },
1153
1153
  "variant": {
1154
1154
  "type": "string",
1155
- "enum": ["default", "primary", "success", "warning", "error", "info"],
1155
+ "enum": ["default", "primary", "success", "warning", "error", "info", "purple", "teal"],
1156
1156
  "default": "default",
1157
1157
  "description": "Badge variant for a value `colorMap` does not name."
1158
1158
  }
@@ -3586,7 +3586,7 @@
3586
3586
  },
3587
3587
  "columns": {
3588
3588
  "type": "array",
3589
- "description": "Table columns (for type='index' and type='logs'). Items may be strings (legacy shorthand) or column objects ({ key, label, sortable?, width?, formatter?, formatterOptions?, widget?, widgetProps?, format?, aggregate? }). `formatter` resolves against cnFormatters (built-ins incl. currency / conditionalPhrase — configured via `formatterOptions`); `widget` resolves against cnCellWidgets (built-ins incl. badge / fkResolve / link / avatar / date).",
3589
+ "description": "Table columns (for type='index' and type='logs'). Items may be strings (legacy shorthand) or column objects ({ key, label, sortable?, width?, formatter?, formatterOptions?, widget?, widgetProps?, format?, aggregate?, colorMap? }). `colorMap` maps an enum column's raw values to a pill tone (default, primary, success, warning, error, info, purple, teal) and wins over the schema property's own `colorMap` / `x-color-map`. `formatter` resolves against cnFormatters (built-ins incl. currency / conditionalPhrase — configured via `formatterOptions`); `widget` resolves against cnCellWidgets (built-ins incl. badge / fkResolve / link / avatar / date).",
3590
3590
  "items": {
3591
3591
  "oneOf": [
3592
3592
  { "type": "string" },
@@ -0,0 +1,29 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * badgeVariants: the colour variants a CnStatusBadge (a status pill) may take.
6
+ *
7
+ * The first six follow the Nextcloud colour roles. `purple` and `teal` are the
8
+ * two tones the drawn boards use for a status that has no Nextcloud role, such
9
+ * as "In behandeling" (work in progress) and "Omgezet naar een zaak" (handed
10
+ * on). One list, so the badge, the board card pill and the manifest schema
11
+ * accept the same names.
12
+ *
13
+ * @module utils/badgeVariants
14
+ * @spec openspec/changes/screens-cell-pill-parity/specs/cell-pill-tones/spec.md#requirement-a-status-pill-has-eight-tones
15
+ */
16
+
17
+ /** Every variant a status pill accepts, in the order the docs list them. */
18
+ export const BADGE_VARIANTS = Object.freeze(['default', 'primary', 'success', 'warning', 'error', 'info', 'purple', 'teal'])
19
+
20
+ /**
21
+ * Whether a value names a badge variant.
22
+ *
23
+ * @param {unknown} variant The candidate variant.
24
+ * @return {boolean} True for one of `BADGE_VARIANTS`.
25
+ * @spec openspec/changes/screens-cell-pill-parity/specs/cell-pill-tones/spec.md#requirement-a-status-pill-has-eight-tones
26
+ */
27
+ export function isBadgeVariant(variant) {
28
+ return typeof variant === 'string' && BADGE_VARIANTS.includes(variant)
29
+ }