@conduction/nextcloud-vue 2.114.0 → 2.115.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 (118) hide show
  1. package/dist/esm/components/CnAdvancedFormDialog/CnPropertyValueCell.vue.js +1 -1
  2. package/dist/esm/components/CnAdvancedFormDialog/CnPropertyValueCell.vue2.js +1 -1
  3. package/dist/esm/components/CnAdvancedFormDialog/CnPropertyValueCell.vue2.js.map +1 -1
  4. package/dist/esm/components/CnAdvancedFormDialog/CnPropertyValueCell.vue3.js.map +1 -1
  5. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue.js +1 -1
  6. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js +6 -2
  7. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js.map +1 -1
  8. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js.map +1 -1
  9. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue.js +1 -1
  10. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue2.js +2 -1
  11. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue2.js.map +1 -1
  12. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue3.js.map +1 -1
  13. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
  14. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +19 -2
  15. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  16. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +22 -3
  17. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  18. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue.js +1 -1
  19. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js +2 -2
  20. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js.map +1 -1
  21. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js.map +1 -1
  22. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +37 -1
  23. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  24. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  25. package/dist/esm/components/CnIndexPage/rowsToCsv.js +90 -0
  26. package/dist/esm/components/CnIndexPage/rowsToCsv.js.map +1 -0
  27. package/dist/esm/components/CnMacrosWidget/CnMacrosWidget.vue.js +9 -0
  28. package/dist/esm/components/CnMacrosWidget/CnMacrosWidget.vue.js.map +1 -0
  29. package/dist/esm/components/CnMacrosWidget/CnMacrosWidget.vue2.js +161 -0
  30. package/dist/esm/components/CnMacrosWidget/CnMacrosWidget.vue2.js.map +1 -0
  31. package/dist/esm/components/CnMacrosWidget/CnMacrosWidget.vue3.js +97 -0
  32. package/dist/esm/components/CnMacrosWidget/CnMacrosWidget.vue3.js.map +1 -0
  33. package/dist/esm/components/CnRetentionWidget/CnRetentionRecomputeDialog.vue.js +9 -0
  34. package/dist/esm/components/CnRetentionWidget/CnRetentionRecomputeDialog.vue.js.map +1 -0
  35. package/dist/esm/components/CnRetentionWidget/CnRetentionRecomputeDialog.vue2.js +63 -0
  36. package/dist/esm/components/CnRetentionWidget/CnRetentionRecomputeDialog.vue2.js.map +1 -0
  37. package/dist/esm/components/CnRetentionWidget/CnRetentionRecomputeDialog.vue3.js +54 -0
  38. package/dist/esm/components/CnRetentionWidget/CnRetentionRecomputeDialog.vue3.js.map +1 -0
  39. package/dist/esm/components/CnRetentionWidget/CnRetentionWidget.vue.js +1 -1
  40. package/dist/esm/components/CnRetentionWidget/CnRetentionWidget.vue2.js +15 -9
  41. package/dist/esm/components/CnRetentionWidget/CnRetentionWidget.vue2.js.map +1 -1
  42. package/dist/esm/components/CnRetentionWidget/CnRetentionWidget.vue3.js +55 -63
  43. package/dist/esm/components/CnRetentionWidget/CnRetentionWidget.vue3.js.map +1 -1
  44. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue.js +1 -1
  45. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue2.js +2 -1
  46. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue2.js.map +1 -1
  47. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue3.js.map +1 -1
  48. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue.js +1 -1
  49. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js +13 -4
  50. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js.map +1 -1
  51. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js +2 -1
  52. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js.map +1 -1
  53. package/dist/esm/components/CnStepper/CnStepper.vue.js +1 -1
  54. package/dist/esm/components/CnStepper/CnStepper.vue2.js.map +1 -1
  55. package/dist/esm/components/CnStepper/CnStepper.vue3.js.map +1 -1
  56. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue.js +1 -1
  57. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +10 -5
  58. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  59. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js +1 -1
  60. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  61. package/dist/esm/components/CnTasksWidget/CnTasksWidget.vue.js +1 -1
  62. package/dist/esm/components/CnTasksWidget/CnTasksWidget.vue2.js.map +1 -1
  63. package/dist/esm/components/CnTasksWidget/CnTasksWidget.vue3.js +2 -2
  64. package/dist/esm/components/CnTasksWidget/CnTasksWidget.vue3.js.map +1 -1
  65. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js +32 -20
  66. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js.map +1 -1
  67. package/dist/esm/composables/indexSources.js +43 -11
  68. package/dist/esm/composables/indexSources.js.map +1 -1
  69. package/dist/esm/composables/useTaskInboxStore.js +41 -0
  70. package/dist/esm/composables/useTaskInboxStore.js.map +1 -1
  71. package/dist/esm/index.js +1 -0
  72. package/dist/esm/index.js.map +1 -1
  73. package/dist/esm/l10n/en.json.js +4 -1
  74. package/dist/esm/l10n/en.json.js.map +1 -1
  75. package/dist/esm/l10n/nl.json.js +9 -1
  76. package/dist/esm/l10n/nl.json.js.map +1 -1
  77. package/dist/esm/library-style.js +1 -1
  78. package/dist/esm/nextcloud-vue.css +482 -330
  79. package/dist/esm/utils/contentHeaderLink.js +37 -1
  80. package/dist/esm/utils/contentHeaderLink.js.map +1 -1
  81. package/dist/esm/utils/declaredMacros.js +31 -0
  82. package/dist/esm/utils/declaredMacros.js.map +1 -0
  83. package/dist/esm/utils/libraryWidgetKeys.js +1 -0
  84. package/dist/esm/utils/libraryWidgetKeys.js.map +1 -1
  85. package/dist/nextcloud-vue.cjs.js +2673 -1977
  86. package/dist/nextcloud-vue.cjs.js.map +2 -2
  87. package/dist/nextcloud-vue.css +482 -330
  88. package/l10n/en.json +4 -1
  89. package/l10n/nl.json +9 -1
  90. package/package.json +1 -1
  91. package/src/components/CnAdvancedFormDialog/CnPropertyValueCell.vue +1 -1
  92. package/src/components/CnCellRenderer/CnCellRenderer.vue +6 -2
  93. package/src/components/CnChoiceCards/CnChoiceCards.vue +2 -1
  94. package/src/components/CnDashboardPage/CnDashboardPage.vue +45 -3
  95. package/src/components/CnDetailWidgetHost/CnDetailWidgetHost.vue +2 -2
  96. package/src/components/CnIndexPage/CnIndexPage.vue +37 -1
  97. package/src/components/CnIndexPage/rowsToCsv.js +87 -0
  98. package/src/components/CnMacrosWidget/CnMacrosWidget.md +30 -0
  99. package/src/components/CnMacrosWidget/CnMacrosWidget.vue +266 -0
  100. package/src/components/CnMacrosWidget/index.js +6 -0
  101. package/src/components/CnRetentionWidget/CnRetentionRecomputeDialog.vue +104 -0
  102. package/src/components/CnRetentionWidget/CnRetentionWidget.vue +53 -38
  103. package/src/components/CnSegmentedControl/CnSegmentedControl.vue +2 -1
  104. package/src/components/CnSetupWizard/CnSetupWizard.vue +28 -4
  105. package/src/components/CnStepper/CnStepper.vue +2 -1
  106. package/src/components/CnTabsWidget/CnTabsWidget.vue +11 -6
  107. package/src/components/CnTasksWidget/CnTasksWidget.vue +2 -2
  108. package/src/components/CnWidgetGrid/registerDashboardWidgets.js +12 -0
  109. package/src/components/index.js +1 -0
  110. package/src/composables/indexSources.js +43 -11
  111. package/src/composables/useTaskInboxStore.js +41 -0
  112. package/src/css/look-board-detail.css +4 -3
  113. package/src/css/look-board-index.css +62 -0
  114. package/src/css/look-board.css +20 -0
  115. package/src/index.js +1 -0
  116. package/src/utils/contentHeaderLink.js +35 -0
  117. package/src/utils/declaredMacros.js +28 -0
  118. package/src/utils/libraryWidgetKeys.js +1 -0
package/l10n/en.json CHANGED
@@ -1929,7 +1929,10 @@
1929
1929
  "or": "or",
1930
1930
  "no value": "no value",
1931
1931
  "yes": "yes",
1932
- "no": "no"
1932
+ "no": "no",
1933
+ "Recompute nomination": "Recompute nomination",
1934
+ "Recompute": "Recompute",
1935
+ "Task deadline": "Deadline"
1933
1936
  },
1934
1937
  "plurals": {
1935
1938
  "{count} member": [
package/l10n/nl.json CHANGED
@@ -2141,7 +2141,15 @@
2141
2141
  "or": "of",
2142
2142
  "no value": "geen waarde",
2143
2143
  "yes": "ja",
2144
- "no": "nee"
2144
+ "no": "nee",
2145
+ "Recompute nomination": "Nominatie herberekenen",
2146
+ "Recompute": "Herberekenen",
2147
+ "Task deadline": "Deadline",
2148
+ "The macro did not run: {error}": "De macro is niet uitgevoerd: {error}",
2149
+ "Last run: {name}, {date}": "Laatst uitgevoerd: {name}, {date}",
2150
+ "This record has no macros": "Er zijn geen macro's voor dit item",
2151
+ "Running …": "Bezig …",
2152
+ "Execute": "Uitvoeren"
2145
2153
  },
2146
2154
  "plurals": {
2147
2155
  "{count} member": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/nextcloud-vue",
3
- "version": "2.114.0",
3
+ "version": "2.115.0",
4
4
  "description": "Shared Vue component library for Conduction Nextcloud apps — complements @nextcloud/vue with higher-level components, OpenRegister integration, and NL Design System support",
5
5
  "homepage": "https://github.com/ConductionNL/nextcloud-vue#readme",
6
6
  "bugs": {
@@ -251,7 +251,7 @@ export default {
251
251
  /** Full JSON schema object */
252
252
  schema: { type: Object, default: null },
253
253
  /** Resolved current value (formData[key] ?? objectValue) */
254
- value: { type: [Boolean, String, Number, Object, Array], default: null },
254
+ value: { type: [String, Number, Boolean, Object, Array], default: null }, // eslint-disable-line vue/prefer-prop-type-boolean-first -- an empty field value must stay a string
255
255
  /** Whether this property is editable at all */
256
256
  isEditable: { type: Boolean, default: true },
257
257
  /** Whether this row is currently selected for editing */
@@ -456,9 +456,13 @@ export default {
456
456
  },
457
457
 
458
458
  props: {
459
- /** The raw cell value */
459
+ /**
460
+ * The raw cell value. String precedes Boolean: with Boolean first Vue
461
+ * casts an empty string to `true`, and an empty cell drew a check mark.
462
+ */
460
463
  value: {
461
- type: [Boolean, String, Number, Array, Object],
464
+ // eslint-disable-next-line vue/prefer-prop-type-boolean-first -- an empty string must stay a string
465
+ type: [String, Number, Boolean, Array, Object],
462
466
  default: null,
463
467
  },
464
468
 
@@ -180,7 +180,8 @@ export default {
180
180
  * @type {string|number|boolean|Array|null}
181
181
  */
182
182
  modelValue: {
183
- type: [Boolean, String, Number, Array, Object],
183
+ // eslint-disable-next-line vue/prefer-prop-type-boolean-first -- an option value of '' must stay a string
184
+ type: [String, Number, Boolean, Array, Object],
184
185
  default: null,
185
186
  },
186
187
 
@@ -42,11 +42,28 @@
42
42
  group ends the description line ("Week 41 · alle teams ·
43
43
  [Deze week | Dit kwartaal | Dit jaar]"). -->
44
44
  <div
45
- v-if="datePeriodInDescription || descriptionPageFilters.length > 0"
45
+ v-if="datePeriodInDescription || descriptionPageFilters.length > 0 || descriptionSelectFilters.length > 0"
46
46
  class="cn-dashboard-page__description cn-dashboard-page__description--with-period"
47
47
  data-testid="cn-dashboard-page-subtitle">
48
48
  <span v-if="subtitleText">{{ subtitleText }}</span>
49
49
  <span v-if="subtitleText" aria-hidden="true">&middot;</span>
50
+ <!-- A select page filter with `placement: "description"`
51
+ (DqDoorlooptijd "alle zaaktypen"): a compact select on the
52
+ description line, before the period group. -->
53
+ <template v-for="pf in descriptionSelectFilters" :key="pf.key">
54
+ <NcSelect
55
+ class="cn-dashboard-page__description-select"
56
+ :modelValue="selectedPageFilterOption(pf)"
57
+ :options="pageFilterSegments(pf)"
58
+ :clearable="pf.clearable === true"
59
+ :placeholder="pf.placeholder ? effectiveTranslate(pf.placeholder) : undefined"
60
+ :loading="pageFilterLoading[pf.key] === true"
61
+ :ariaLabelCombobox="pf.label ? effectiveTranslate(pf.label) : pf.key"
62
+ label="label"
63
+ :data-testid="'cn-page-filter-' + pf.key"
64
+ @update:modelValue="onPageFilterChange(pf, $event)" />
65
+ <span aria-hidden="true">&middot;</span>
66
+ </template>
50
67
  <CnSegmentedControl
51
68
  v-if="datePeriodInDescription"
52
69
  size="compact"
@@ -1721,7 +1738,8 @@ export default {
1721
1738
  * mount. The control is a select, or with `control: 'segmented'` a
1722
1739
  * compact segmented group; `placement: 'description'` puts a segmented
1723
1740
  * group on the header's description line, after the period group
1724
- * (DqProcesanalyse "Gemiddeld | Mediaan"). An endpoint widget can read
1741
+ * (DqProcesanalyse "Gemiddeld | Mediaan"), and a select before it
1742
+ * (DqDoorlooptijd "alle zaaktypen"). An endpoint widget can read
1725
1743
  * the value in its `responsePath` too (`phaseDays.@workspace.measure`).
1726
1744
  * `optionsFrom` reads the options instead of listing them: `{ url,
1727
1745
  * params?, responsePath?, valueField?, labelField? }` from an app
@@ -2446,10 +2464,26 @@ export default {
2446
2464
  * @return {Array<object>}
2447
2465
  */
2448
2466
  rowPageFilters() {
2449
- const inDescription = this.descriptionPageFilters
2467
+ const inDescription = [...this.descriptionPageFilters, ...this.descriptionSelectFilters]
2450
2468
  return (this.pageFilters || []).filter((pf) => pf && pf.key && !inDescription.includes(pf))
2451
2469
  },
2452
2470
 
2471
+ /**
2472
+ * The select page filters drawn on the description line
2473
+ * (`placement: "description"`, any control but `segmented`), before
2474
+ * the period group: DqDoorlooptijd's "alle zaaktypen". Without a
2475
+ * header row they fall back to the filter row.
2476
+ *
2477
+ * @spec openspec/changes/dashboard-select-filter-in-description/specs/dashboard-select-filter-in-description/spec.md#requirement-a-select-page-filter-can-sit-on-the-description-line
2478
+ * @return {Array<object>}
2479
+ */
2480
+ descriptionSelectFilters() {
2481
+ if (!this.showHeader) {
2482
+ return []
2483
+ }
2484
+ return (this.pageFilters || []).filter((pf) => pf && pf.key && pf.control !== 'segmented' && pf.placement === 'description')
2485
+ },
2486
+
2453
2487
  /**
2454
2488
  * The edit button's label through the app's translate: a manifest's
2455
2489
  * `editLabel` ("Edit layout", DqDashboard) reads in the user's language.
@@ -5186,6 +5220,14 @@ export default {
5186
5220
  gap: 8px;
5187
5221
  }
5188
5222
 
5223
+ /* A select on the description line takes the width of its choice, not a
5224
+ form field's, and no margin of its own. */
5225
+ .cn-dashboard-page__description-select {
5226
+ min-width: 180px;
5227
+ max-width: 280px;
5228
+ margin: 0;
5229
+ }
5230
+
5189
5231
  .cn-dashboard-page__date-segmented {
5190
5232
  display: flex;
5191
5233
  align-items: center;
@@ -333,7 +333,7 @@ import CnDetailWidgetHeaderItems from './CnDetailWidgetHeaderItems.vue'
333
333
  import { useIntegrationRegistry } from '../../composables/useIntegrationRegistry.js'
334
334
  import { normalizeLook } from '../../composables/useLook.js'
335
335
  import { isAppInstalled } from '../../utils/appInstalled.js'
336
- import { widgetHeaderLink } from '../../utils/contentHeaderLink.js'
336
+ import { resolveHeaderLink, widgetHeaderLink } from '../../utils/contentHeaderLink.js'
337
337
  import { pickDeclaredProps } from '../../utils/declaredProps.js'
338
338
  import { unwrapAppConfig } from '../../utils/formatMetric.js'
339
339
  import { isActivityWidget } from '../../utils/headerMeta.js'
@@ -712,7 +712,7 @@ export default {
712
712
  * @return {object|null} `{ label, route?, params?, query?, href? }` or null.
713
713
  */
714
714
  cardHeaderLink() {
715
- return this.rendererHeader.link || (this.showHeaderLink ? widgetHeaderLink(this.widget) : null)
715
+ return this.rendererHeader.link || (this.showHeaderLink ? resolveHeaderLink(widgetHeaderLink(this.widget), { objectId: this.objectId, object: this.object }) : null)
716
716
  },
717
717
 
718
718
  /**
@@ -1431,6 +1431,7 @@ import { buildDefaultActions } from './defaultActions.js'
1431
1431
  import { dispatchAction } from './manifestActionDispatch.js'
1432
1432
  import { applyManualOrder, dropInOrder, manualOrderKey, moveInOrder, visibleIdsOf } from './manualOrder.js'
1433
1433
  import { orderColumns, personalColumnsKey, reconcilePersonalColumns } from './personalColumns.js'
1434
+ import { rowsToCsv } from './rowsToCsv.js'
1434
1435
  import { createSelfModeActions, resolveRowTarget } from './selfModeActions.js'
1435
1436
  import { applyRowPatches, normalisePaneWidth, rowIdOf, splitLayoutFor } from './splitView.js'
1436
1437
  import { useNamedSource } from './useNamedSource.js'
@@ -9510,6 +9511,8 @@ export default {
9510
9511
  this.onHeaderAction({ action: button.run, id: button.run })
9511
9512
  } else if (this.register && this.exportSchemaSlug) {
9512
9513
  this.onExportClick(button.format)
9514
+ } else if (this.isNamedSource && !this.hasMassExportListener()) {
9515
+ this.downloadNamedRows()
9513
9516
  } else {
9514
9517
  this.showExportDialog = true
9515
9518
  }
@@ -9536,13 +9539,46 @@ export default {
9536
9539
  * @return {boolean}
9537
9540
  */
9538
9541
  headerExportAvailable(button) {
9539
- if (!this.isNamedSource || (button && button.run)) {
9542
+ if (!this.isNamedSource || (button && button.run) || this.hasMassExportListener()) {
9540
9543
  return true
9541
9544
  }
9545
+ // A named source exports the rows it holds as CSV (downloadNamedRows).
9546
+ return this.renderedColumns.some((col) => col && typeof col.key === 'string' && col.key !== '')
9547
+ },
9548
+
9549
+ /**
9550
+ * Whether the consumer listens to `@mass-export`.
9551
+ *
9552
+ * @return {boolean}
9553
+ */
9554
+ hasMassExportListener() {
9542
9555
  const props = this.$ && this.$.vnode && this.$.vnode.props
9543
9556
  return Boolean(props && typeof props.onMassExport === 'function')
9544
9557
  },
9545
9558
 
9559
+ /**
9560
+ * Download the rows of a named or endpoint source as CSV: every row the
9561
+ * source holds for the current query, with the table's columns. Such a
9562
+ * source has no server export, and a Download that does nothing is
9563
+ * worse than none.
9564
+ *
9565
+ * @spec openspec/changes/index-named-source-export/specs/index-named-source-export/spec.md#requirement-a-named-source-list-downloads-its-rows
9566
+ * @return {void}
9567
+ */
9568
+ downloadNamedRows() {
9569
+ const csv = rowsToCsv(this.namedRows, this.renderedColumns)
9570
+ const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' })
9571
+ const url = URL.createObjectURL(blob)
9572
+ const link = document.createElement('a')
9573
+ const name = String(this.title || this.entitySource || 'export').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'export'
9574
+ link.href = url
9575
+ link.download = `${name}.csv`
9576
+ document.body.appendChild(link)
9577
+ link.click()
9578
+ link.remove()
9579
+ URL.revokeObjectURL(url)
9580
+ },
9581
+
9546
9582
  /**
9547
9583
  * Click dispatch from CnActionsBar's `@header-action`. Looks
9548
9584
  * up the resolved entry by id, invokes its handler if any,
@@ -0,0 +1,87 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * The rows a list shows, as a CSV file: one column per table column, headed by
6
+ * its label. Used by an index page whose rows come from a named or endpoint
7
+ * source, which has no server export of its own.
8
+ *
9
+ * @spec openspec/changes/index-named-source-export/specs/index-named-source-export/spec.md#requirement-a-named-source-list-downloads-its-rows
10
+ */
11
+
12
+ /**
13
+ * A value as the text of one CSV cell. A list joins its items, a record reads
14
+ * its name, an empty value is an empty cell.
15
+ *
16
+ * @param {unknown} value The cell value.
17
+ * @return {string} The cell text.
18
+ */
19
+ export function cellText(value) {
20
+ if (value === null || value === undefined) {
21
+ return ''
22
+ }
23
+ if (Array.isArray(value)) {
24
+ return value.map(cellText).filter((item) => item !== '').join('; ')
25
+ }
26
+ if (typeof value === 'object') {
27
+ for (const key of ['title', 'name', 'label', 'displayName', 'id']) {
28
+ if (value[key] !== undefined && value[key] !== null && typeof value[key] !== 'object') {
29
+ return String(value[key])
30
+ }
31
+ }
32
+ return JSON.stringify(value)
33
+ }
34
+ return String(value)
35
+ }
36
+
37
+ /**
38
+ * One CSV field: quoted when it holds a separator, a quote or a line break,
39
+ * and a leading `=`, `+`, `-` or `@` is neutralised with a `'` so a
40
+ * spreadsheet does not read the cell as a formula.
41
+ *
42
+ * @param {string} text The cell text.
43
+ * @return {string} The field.
44
+ */
45
+ export function csvField(text) {
46
+ let out = String(text)
47
+ if (/^[=+\-@\t\r]/.test(out)) {
48
+ out = `'${out}`
49
+ }
50
+ return /[",\r\n]/.test(out) ? `"${out.replace(/"/g, '""')}"` : out
51
+ }
52
+
53
+ /**
54
+ * The value of a column in a row: a dotted key reads a nested field, a flat
55
+ * key falls back to the row's `@self`, as the table reads it.
56
+ *
57
+ * @param {object} row The row.
58
+ * @param {string} key The column key.
59
+ * @return {unknown} The value.
60
+ */
61
+ function columnValue(row, key) {
62
+ if (key.includes('.')) {
63
+ return key.split('.').reduce((obj, part) => obj?.[part], row)
64
+ }
65
+ if (row?.[key] === undefined && row?.['@self'] && typeof row['@self'] === 'object') {
66
+ return row['@self'][key]
67
+ }
68
+ return row?.[key]
69
+ }
70
+
71
+ /**
72
+ * The rows as CSV text, a header line of the column labels first. Columns
73
+ * without a key (selection, row actions) are left out.
74
+ *
75
+ * @param {Array<object>} rows The rows.
76
+ * @param {Array<{key: string, label?: string}>} columns The table columns.
77
+ * @return {string} The CSV text, lines ended with CRLF.
78
+ */
79
+ export function rowsToCsv(rows, columns) {
80
+ const cols = (Array.isArray(columns) ? columns : [])
81
+ .filter((col) => col && typeof col.key === 'string' && col.key !== '' && col.widget !== 'actions')
82
+ const lines = [cols.map((col) => csvField(col.label || col.key)).join(',')]
83
+ for (const row of Array.isArray(rows) ? rows : []) {
84
+ lines.push(cols.map((col) => csvField(cellText(columnValue(row, col.key)))).join(','))
85
+ }
86
+ return lines.join('\r\n') + '\r\n'
87
+ }
@@ -0,0 +1,30 @@
1
+ # CnMacrosWidget
2
+
3
+ The macros a record's schema declares, each with a button that runs it: `type: "macros"` on a detail page.
4
+
5
+ A macro is an OpenRegister declared action (`configuration['x-openregister-action']`) with `macro: true` and a bound
6
+ manual flow. The widget lists them in declaration order and runs the pressed one through
7
+ `POST /api/objects/{register}/{schema}/{id}/actions/{action}`, as the person and under the action's own right. After a
8
+ run it asks the page to refresh (`cn:page:refresh`). The last run is read from the record's audit trail
9
+ (`action.macro`); when the trail cannot be read the line is left out.
10
+
11
+ ## Content
12
+
13
+ | Key | Type | Description |
14
+ |---|---|---|
15
+ | `description` | `string` | The line under the title, translated. |
16
+ | `emptyText` | `string` | Shown when the schema declares no macros, translated. |
17
+
18
+ ## Example
19
+
20
+ ```json
21
+ { "id": "case-macros", "type": "macros", "title": "Macros", "content": { "description": "Several steps at once; then you see what changed" } }
22
+ ```
23
+
24
+ ## Props
25
+
26
+ | Prop | Type | Default | Description |
27
+ |------|------|---------|-------------|
28
+ | `content` | Object | `{}` | The widget's content above: `description` and `emptyText`, both translated |
29
+ | `objectData` | Object | `null` | The bound record |
30
+ | `objectId` | String \| Number | `''` | The bound record's id |
@@ -0,0 +1,266 @@
1
+ <!--
2
+ - SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ - SPDX-License-Identifier: EUPL-1.2
4
+ -->
5
+
6
+ <!--
7
+ CnMacrosWidget — the macros a record's schema declares, each with a button
8
+ that runs it (`type: "macros"`, DqZaakAssistent "Macro's").
9
+
10
+ A macro is a declared action (`configuration['x-openregister-action']`) with
11
+ `macro: true` and a bound manual flow; OpenRegister runs it as the person,
12
+ under the action's own right, through
13
+ `POST /api/objects/{register}/{schema}/{id}/actions/{action}`. The widget
14
+ lists what the schema declares and runs what is pressed: it adds no rights and
15
+ no list of its own. The last run comes from the record's audit trail
16
+ (`action.macro`), and after a run the page is asked to refresh.
17
+ -->
18
+ <template>
19
+ <div class="cn-macros-widget" data-testid="cn-macros-widget">
20
+ <p v-if="content.description" class="cn-macros-widget__description">
21
+ {{ tr(content.description) }}
22
+ </p>
23
+ <p v-if="loading" class="cn-macros-widget__muted">
24
+ {{ t('nextcloud-vue', 'Loading …') }}
25
+ </p>
26
+ <p v-else-if="macros.length === 0" class="cn-macros-widget__muted" data-testid="cn-macros-widget-empty">
27
+ {{ content.emptyText ? tr(content.emptyText) : t('nextcloud-vue', 'This record has no macros') }}
28
+ </p>
29
+ <ul v-else class="cn-macros-widget__list">
30
+ <li
31
+ v-for="macro in macros"
32
+ :key="macro.key"
33
+ class="cn-macros-widget__row"
34
+ :data-testid="`cn-macros-widget-row-${macro.key}`">
35
+ <div class="cn-macros-widget__text">
36
+ <span class="cn-macros-widget__name">{{ macro.name }}</span>
37
+ <span v-if="macro.description" class="cn-macros-widget__hint">{{ macro.description }}</span>
38
+ </div>
39
+ <NcButton
40
+ :disabled="running !== ''"
41
+ :data-testid="`cn-macros-widget-run-${macro.key}`"
42
+ @click="run(macro)">
43
+ {{ running === macro.key ? t('nextcloud-vue', 'Running …') : t('nextcloud-vue', 'Execute') }}
44
+ </NcButton>
45
+ </li>
46
+ </ul>
47
+ <NcNoteCard
48
+ v-if="error"
49
+ type="error"
50
+ data-testid="cn-macros-widget-error">
51
+ {{ error }}
52
+ </NcNoteCard>
53
+ <NcNoteCard
54
+ v-else-if="lastRun"
55
+ type="success"
56
+ data-testid="cn-macros-widget-last-run">
57
+ {{ t('nextcloud-vue', 'Last run: {name}, {date}', { name: lastRun.name, date: lastRun.date }) }}
58
+ </NcNoteCard>
59
+ </div>
60
+ </template>
61
+
62
+ <script>
63
+ import axios from '@nextcloud/axios'
64
+ import { emit } from '@nextcloud/event-bus'
65
+ import { translate as t } from '@nextcloud/l10n'
66
+ import { generateUrl } from '@nextcloud/router'
67
+ import { NcButton, NcNoteCard } from '@nextcloud/vue'
68
+ import { declaredMacros } from '../../utils/declaredMacros.js'
69
+
70
+ export default {
71
+ name: 'CnMacrosWidget',
72
+
73
+ components: {
74
+ NcButton,
75
+ NcNoteCard,
76
+ },
77
+
78
+ inject: {
79
+ cnTranslate: { default: null },
80
+ },
81
+
82
+ props: {
83
+ /**
84
+ * The widget's content: `description` (the line under the title),
85
+ * `emptyText`, both translated.
86
+ */
87
+ content: {
88
+ type: Object,
89
+ default: () => ({}),
90
+ },
91
+
92
+ /** The bound record. */
93
+ objectData: {
94
+ type: Object,
95
+ default: null,
96
+ },
97
+
98
+ /** The bound record's id. */
99
+ objectId: {
100
+ type: [String, Number],
101
+ default: '',
102
+ },
103
+ },
104
+
105
+ data() {
106
+ return {
107
+ macros: [],
108
+ loading: false,
109
+ running: '',
110
+ error: '',
111
+ lastRun: null,
112
+ }
113
+ },
114
+
115
+ computed: {
116
+ /** @return {{register: string, schema: string, id: string}} Where the record lives. */
117
+ target() {
118
+ const self = this.objectData?.['@self'] || {}
119
+ return {
120
+ register: String(self.register ?? ''),
121
+ schema: String(self.schema ?? ''),
122
+ id: String(this.objectId || self.id || this.objectData?.id || ''),
123
+ }
124
+ },
125
+ },
126
+
127
+ watch: {
128
+ 'target.schema': {
129
+ immediate: true,
130
+ handler() {
131
+ this.load()
132
+ },
133
+ },
134
+ },
135
+
136
+ methods: {
137
+ t,
138
+
139
+ /**
140
+ * Translate an app-supplied text through the host app when it offers it.
141
+ *
142
+ * @param {string} text The text.
143
+ * @return {string} The translated text.
144
+ */
145
+ tr(text) {
146
+ return typeof this.cnTranslate === 'function' ? this.cnTranslate(text) : text
147
+ },
148
+
149
+ /**
150
+ * Read the schema's macros and the record's last macro run.
151
+ *
152
+ * @return {Promise<void>}
153
+ */
154
+ async load() {
155
+ const { register, schema, id } = this.target
156
+ if (schema === '' || id === '') {
157
+ this.macros = []
158
+ return
159
+ }
160
+ this.loading = true
161
+ try {
162
+ const res = await axios.get(generateUrl('/apps/openregister/api/schemas/{schema}', { schema }), register ? { params: { register } } : undefined)
163
+ this.macros = declaredMacros(res?.data || null)
164
+ } catch {
165
+ this.macros = []
166
+ } finally {
167
+ this.loading = false
168
+ }
169
+ if (this.macros.length > 0) {
170
+ await this.loadLastRun()
171
+ }
172
+ },
173
+
174
+ /**
175
+ * The record's most recent macro run, from its audit trail. Left empty
176
+ * when the trail cannot be read: it is a convenience, not a fact the
177
+ * widget may invent.
178
+ *
179
+ * @return {Promise<void>}
180
+ */
181
+ async loadLastRun() {
182
+ const { register, schema, id } = this.target
183
+ try {
184
+ const res = await axios.get(
185
+ generateUrl('/apps/openregister/api/objects/{register}/{schema}/{id}/audit-trails', { register, schema, id }),
186
+ { params: { action: 'action.macro', _limit: 1, '_sort[created]': 'DESC' } },
187
+ )
188
+ const entry = (res?.data?.results || [])[0]
189
+ const key = entry?.changed?.declaredAction
190
+ const macro = this.macros.find((candidate) => candidate.key === key)
191
+ this.lastRun = macro && entry.created
192
+ ? { name: macro.name, date: new Date(entry.created).toLocaleString() }
193
+ : null
194
+ } catch {
195
+ this.lastRun = null
196
+ }
197
+ },
198
+
199
+ /**
200
+ * Run one macro as the person, then ask the page to refresh.
201
+ *
202
+ * @param {{key: string, name: string}} macro The macro.
203
+ * @return {Promise<void>}
204
+ */
205
+ async run(macro) {
206
+ const { register, schema, id } = this.target
207
+ this.running = macro.key
208
+ this.error = ''
209
+ try {
210
+ await axios.post(generateUrl('/apps/openregister/api/objects/{register}/{schema}/{id}/actions/{action}', { register, schema, id, action: macro.key }))
211
+ this.lastRun = { name: macro.name, date: new Date().toLocaleString() }
212
+ emit('cn:page:refresh', { source: 'cn-macros-widget' })
213
+ } catch (e) {
214
+ this.error = t('nextcloud-vue', 'The macro did not run: {error}', { error: e?.response?.data?.error || e?.message || '' })
215
+ } finally {
216
+ this.running = ''
217
+ }
218
+ },
219
+ },
220
+ }
221
+ </script>
222
+
223
+ <style scoped>
224
+ .cn-macros-widget {
225
+ display: flex;
226
+ flex-direction: column;
227
+ gap: 12px;
228
+ }
229
+
230
+ .cn-macros-widget__description,
231
+ .cn-macros-widget__muted {
232
+ margin: 0;
233
+ color: var(--color-text-maxcontrast);
234
+ }
235
+
236
+ .cn-macros-widget__list {
237
+ margin: 0;
238
+ padding: 0;
239
+ list-style: none;
240
+ }
241
+
242
+ .cn-macros-widget__row {
243
+ display: flex;
244
+ align-items: center;
245
+ justify-content: space-between;
246
+ gap: 12px;
247
+ padding-block: 10px;
248
+ border-top: 1px solid var(--color-border);
249
+ }
250
+
251
+ .cn-macros-widget__text {
252
+ display: flex;
253
+ flex-direction: column;
254
+ gap: 2px;
255
+ min-width: 0;
256
+ }
257
+
258
+ .cn-macros-widget__name {
259
+ font-weight: 600;
260
+ }
261
+
262
+ .cn-macros-widget__hint {
263
+ color: var(--color-text-maxcontrast);
264
+ font-size: 13px;
265
+ }
266
+ </style>
@@ -0,0 +1,6 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ */
5
+
6
+ export { default as CnMacrosWidget, default } from './CnMacrosWidget.vue'