@conduction/nextcloud-vue 2.71.0 → 2.73.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 (55) hide show
  1. package/dist/esm/components/CnFlowDetail/CnFlowDetail.vue2.js +25 -0
  2. package/dist/esm/components/CnFlowDetail/CnFlowDetail.vue2.js.map +1 -1
  3. package/dist/esm/components/CnFlowDetail/CnFlowDetail.vue3.js +3 -1
  4. package/dist/esm/components/CnFlowDetail/CnFlowDetail.vue3.js.map +1 -1
  5. package/dist/esm/components/CnFlowDetail/CnRunDetailSidebar.vue2.js +2 -0
  6. package/dist/esm/components/CnFlowDetail/CnRunDetailSidebar.vue2.js.map +1 -1
  7. package/dist/esm/components/CnFlowDetail/CnRunDetailSidebar.vue3.js +7 -0
  8. package/dist/esm/components/CnFlowDetail/CnRunDetailSidebar.vue3.js.map +1 -1
  9. package/dist/esm/components/CnFlowStepOutcomes/CnFlowStepOutcomes.vue.js +10 -0
  10. package/dist/esm/components/CnFlowStepOutcomes/CnFlowStepOutcomes.vue.js.map +1 -0
  11. package/dist/esm/components/CnFlowStepOutcomes/CnFlowStepOutcomes.vue2.js +150 -0
  12. package/dist/esm/components/CnFlowStepOutcomes/CnFlowStepOutcomes.vue2.js.map +1 -0
  13. package/dist/esm/components/CnFlowStepOutcomes/CnFlowStepOutcomes.vue3.js +54 -0
  14. package/dist/esm/components/CnFlowStepOutcomes/CnFlowStepOutcomes.vue3.js.map +1 -0
  15. package/dist/esm/components/CnGraphCanvas/CnFlowNode.vue2.js +36 -1
  16. package/dist/esm/components/CnGraphCanvas/CnFlowNode.vue2.js.map +1 -1
  17. package/dist/esm/components/CnGraphCanvas/CnFlowNode.vue3.js.map +1 -1
  18. package/dist/esm/components/CnGraphCanvas/CnGraphCanvas.vue2.js +37 -0
  19. package/dist/esm/components/CnGraphCanvas/CnGraphCanvas.vue2.js.map +1 -1
  20. package/dist/esm/components/CnGraphCanvas/CnGraphCanvas.vue3.js +4 -2
  21. package/dist/esm/components/CnGraphCanvas/CnGraphCanvas.vue3.js.map +1 -1
  22. package/dist/esm/dialogs/CnFlowNodeEditModal.vue2.js +7 -10
  23. package/dist/esm/dialogs/CnFlowNodeEditModal.vue2.js.map +1 -1
  24. package/dist/esm/dialogs/CnFlowNodeEditModal.vue3.js +23 -13
  25. package/dist/esm/dialogs/CnFlowNodeEditModal.vue3.js.map +1 -1
  26. package/dist/esm/dialogs/CnRunNodeDialog.vue2.js +8 -6
  27. package/dist/esm/dialogs/CnRunNodeDialog.vue2.js.map +1 -1
  28. package/dist/esm/dialogs/CnRunNodeDialog.vue3.js.map +1 -1
  29. package/dist/esm/index.js +1 -0
  30. package/dist/esm/index.js.map +1 -1
  31. package/dist/esm/l10n/en.json.js +15 -1
  32. package/dist/esm/l10n/en.json.js.map +1 -1
  33. package/dist/esm/l10n/nl.json.js +15 -1
  34. package/dist/esm/l10n/nl.json.js.map +1 -1
  35. package/dist/esm/nextcloud-vue.css +64 -0
  36. package/dist/esm/utils/selectOptions.js +57 -0
  37. package/dist/esm/utils/selectOptions.js.map +1 -0
  38. package/dist/nextcloud-vue.cjs.js +6050 -5649
  39. package/dist/nextcloud-vue.cjs.js.map +2 -2
  40. package/dist/nextcloud-vue.css +64 -0
  41. package/l10n/en.json +15 -1
  42. package/l10n/nl.json +15 -1
  43. package/package.json +1 -1
  44. package/scripts/openregister-reliance.js +206 -0
  45. package/src/components/CnFlowDetail/CnFlowDetail.vue +27 -0
  46. package/src/components/CnFlowDetail/CnRunDetailSidebar.vue +6 -0
  47. package/src/components/CnFlowStepOutcomes/CnFlowStepOutcomes.vue +250 -0
  48. package/src/components/CnFlowStepOutcomes/index.js +8 -0
  49. package/src/components/CnGraphCanvas/CnFlowNode.vue +36 -1
  50. package/src/components/CnGraphCanvas/CnGraphCanvas.vue +40 -1
  51. package/src/components/index.js +1 -0
  52. package/src/dialogs/CnFlowNodeEditModal.vue +41 -20
  53. package/src/dialogs/CnRunNodeDialog.vue +8 -6
  54. package/src/index.js +1 -0
  55. package/src/utils/selectOptions.js +54 -0
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: package/dist/nextcloud-vue.cjs.js.map
3
- size: 35657637 bytes
4
- sha256: b7b966a528988db88e213ce0afc88832aae9c6b60341877ba5226dc3d833f211
3
+ size: 35692438 bytes
4
+ sha256: 9054c705b23062b8dca68969cf561ce87cf95a1f2c6dea28e8e5cb78a2ca437c
@@ -26582,6 +26582,18 @@ a.cn-flow-runs-widget__row-content:hover .cn-flow-runs-widget__name[data-v-75628
26582
26582
  font-size: 0.85em;
26583
26583
  color: var(--color-text-maxcontrast);
26584
26584
  }
26585
+
26586
+ /* Full width, like the text fields around it: at its natural width the
26587
+ picker cut every option label in half. */
26588
+ .cn-flow-node-edit__select[data-v-e107484a] {
26589
+ width: 100%;
26590
+ }
26591
+ .cn-flow-node-edit__field-help[data-v-e107484a] {
26592
+ margin: 4px 0 0;
26593
+ padding-inline-start: 8px;
26594
+ font-size: 0.85em;
26595
+ color: var(--color-text-maxcontrast);
26596
+ }
26585
26597
  .cn-flow-node-edit__advanced summary[data-v-e107484a] {
26586
26598
  cursor: pointer;
26587
26599
  color: var(--color-text-maxcontrast);
@@ -28036,6 +28048,58 @@ to {
28036
28048
  }
28037
28049
 
28038
28050
 
28051
+ .cn-flow-step-outcomes[data-v-5ac5ad4f] {
28052
+ margin: 4px 0 0;
28053
+ font-size: 0.9em;
28054
+ }
28055
+ .cn-flow-step-outcomes__list[data-v-5ac5ad4f] {
28056
+ margin: 0;
28057
+ padding: 0;
28058
+ }
28059
+
28060
+ /* Stacked, name over value: the run sidebar is narrow, and a side-by-side
28061
+ row wrapped mid-list. The host's dt/dd margins and alignment are reset. */
28062
+ .cn-flow-step-outcomes__row[data-v-5ac5ad4f] {
28063
+ display: block;
28064
+ margin-block: 4px;
28065
+ padding-block: 2px;
28066
+ padding-inline-start: 8px;
28067
+ border-inline-start: 3px solid var(--color-border);
28068
+ text-align: start;
28069
+ }
28070
+ .cn-flow-step-outcomes__row--success[data-v-5ac5ad4f] {
28071
+ border-inline-start-color: var(--color-success);
28072
+ }
28073
+ .cn-flow-step-outcomes__row--warning[data-v-5ac5ad4f] {
28074
+ border-inline-start-color: var(--color-warning);
28075
+ }
28076
+ .cn-flow-step-outcomes__row--error[data-v-5ac5ad4f] {
28077
+ border-inline-start-color: var(--color-error);
28078
+ }
28079
+ .cn-flow-step-outcomes__name[data-v-5ac5ad4f] {
28080
+ display: block;
28081
+ white-space: normal;
28082
+ overflow-wrap: anywhere;
28083
+ margin: 0;
28084
+ padding: 0;
28085
+ font-weight: bold;
28086
+ text-align: start;
28087
+ }
28088
+ .cn-flow-step-outcomes__value[data-v-5ac5ad4f] {
28089
+ display: block;
28090
+ white-space: normal;
28091
+ margin: 0;
28092
+ padding: 0;
28093
+ text-align: start;
28094
+ color: var(--color-main-text);
28095
+ overflow-wrap: anywhere;
28096
+ }
28097
+ .cn-flow-step-outcomes__truncated[data-v-5ac5ad4f] {
28098
+ margin: 4px 0 0;
28099
+ color: var(--color-text-maxcontrast);
28100
+ }
28101
+
28102
+
28039
28103
  .cn-run-sidebar[data-v-48e781f0] {
28040
28104
  display: flex;
28041
28105
  flex-direction: column;
package/l10n/en.json CHANGED
@@ -1106,7 +1106,21 @@
1106
1106
  "Loading…": "Loading…",
1107
1107
  "A register and schema are required": "A register and schema are required",
1108
1108
  "Change the search or try again.": "Change the search or try again.",
1109
- "Try again later.": "Try again later."
1109
+ "Try again later.": "Try again later.",
1110
+ "Who this step reached": "Who this step reached",
1111
+ "Each list shows its first entries only.": "Each list shows its first entries only.",
1112
+ "Delivered": "Delivered",
1113
+ "Opted out": "Opted out",
1114
+ "Not sent, the opt-out check did not answer": "Not sent, the opt-out check did not answer",
1115
+ "Skipped by their notification settings": "Skipped by their notification settings",
1116
+ "Skipped, sending is switched off": "Skipped, sending is switched off",
1117
+ "Held back by the send limit": "Held back by the send limit",
1118
+ "Refused by the step’s address rule": "Refused by the step’s address rule",
1119
+ "Not a known user or group": "Not a known user or group",
1120
+ "Failed": "Failed",
1121
+ "external recipients are off": "external recipients are off",
1122
+ "not on the item": "not on the item",
1123
+ "not a valid address": "not a valid address"
1110
1124
  },
1111
1125
  "plurals": {
1112
1126
  "%n year": ["%n year", "%n years"],
package/l10n/nl.json CHANGED
@@ -1150,7 +1150,21 @@
1150
1150
  "Loading…": "Laden…",
1151
1151
  "A register and schema are required": "Een register en schema zijn verplicht",
1152
1152
  "Change the search or try again.": "Pas de zoekopdracht aan of probeer het opnieuw.",
1153
- "Try again later.": "Probeer het later opnieuw."
1153
+ "Try again later.": "Probeer het later opnieuw.",
1154
+ "Who this step reached": "Wie deze stap bereikte",
1155
+ "Each list shows its first entries only.": "Elke lijst toont alleen de eerste vermeldingen.",
1156
+ "Delivered": "Bezorgd",
1157
+ "Opted out": "Afgemeld",
1158
+ "Not sent, the opt-out check did not answer": "Niet verstuurd, de afmeldcontrole gaf geen antwoord",
1159
+ "Skipped by their notification settings": "Overgeslagen door hun meldingsinstellingen",
1160
+ "Skipped, sending is switched off": "Overgeslagen, verzenden staat uit",
1161
+ "Held back by the send limit": "Tegengehouden door de verzendlimiet",
1162
+ "Refused by the step’s address rule": "Geweigerd door de adresregel van de stap",
1163
+ "Not a known user or group": "Geen bekende gebruiker of groep",
1164
+ "Failed": "Mislukt",
1165
+ "external recipients are off": "externe ontvangers staan uit",
1166
+ "not on the item": "staat niet op het item",
1167
+ "not a valid address": "geen geldig adres"
1154
1168
  },
1155
1169
  "plurals": {
1156
1170
  "%n year": ["%n jaar", "%n jaar"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/nextcloud-vue",
3
- "version": "2.71.0",
3
+ "version": "2.73.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": {
@@ -0,0 +1,206 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ */
5
+
6
+ /**
7
+ * Scores every component under src/components on how much it relies on
8
+ * OpenRegister and writes the result to docs/components/_generated/reliance.json
9
+ * for the documentation site.
10
+ *
11
+ * Two numbers per component:
12
+ * - direct: lines inside the component's own directory that reference
13
+ * OpenRegister (store imports, API paths, composables, the register and
14
+ * schema selector).
15
+ * - transitive: files outside the directory, reached by following the actual
16
+ * import statements, that carry such a reference. A wrapper with no
17
+ * reference of its own still counts the children it cannot render without,
18
+ * while importing the widget registry alone does not drag in every widget.
19
+ *
20
+ * The tier follows the sum: none (0), light (1 to 3), medium (4 to 10) and
21
+ * heavy (11 and more). A component with tier none does not touch OpenRegister
22
+ * itself nor through anything it imports.
23
+ *
24
+ * Usage: node scripts/openregister-reliance.js
25
+ */
26
+
27
+ const fs = require('fs')
28
+ const path = require('path')
29
+
30
+ /** Patterns that mark a direct OpenRegister reliance; each hit counts once per line. */
31
+ const DIRECT_MARKERS = [
32
+ /\/apps\/openregister/,
33
+ /\bopenregister\b/i,
34
+ /useObjectStore|useRegisterStore|useSchemaStore|createObjectStore|createCrudStore/,
35
+ /from '[^']*\/store(\/index\.js)?'/,
36
+ /from '[^']*\/store\/[^']+'/,
37
+ /objectService|ObjectService/,
38
+ /CnRegisterSchemaSelect/,
39
+ ]
40
+
41
+ const SOURCE_FILE = /\.(vue|js|ts|mjs)$/
42
+ const TEST_FILE = /\.(spec|test)\./
43
+
44
+ /**
45
+ * @param {string} dir directory to walk
46
+ * @return {string[]} source files below it, tests excluded
47
+ */
48
+ function walk(dir) {
49
+ const files = []
50
+ for (const entry of fs.readdirSync(dir)) {
51
+ const full = path.join(dir, entry)
52
+ if (fs.statSync(full).isDirectory()) {
53
+ if (entry !== 'node_modules' && entry !== '__tests__') {
54
+ files.push(...walk(full))
55
+ }
56
+ } else if (SOURCE_FILE.test(entry) && !TEST_FILE.test(entry)) {
57
+ files.push(full)
58
+ }
59
+ }
60
+ return files
61
+ }
62
+
63
+ /**
64
+ * @param {string} source file contents
65
+ * @return {number} lines with a direct marker, comments excluded
66
+ */
67
+ function countDirect(source) {
68
+ let count = 0
69
+ for (const line of source.split('\n')) {
70
+ const trimmed = line.trimStart()
71
+ if (trimmed.startsWith('*') || trimmed.startsWith('//') || trimmed.startsWith('/*')) {
72
+ continue
73
+ }
74
+ if (DIRECT_MARKERS.some((marker) => marker.test(line))) {
75
+ count++
76
+ }
77
+ }
78
+ return count
79
+ }
80
+
81
+ /**
82
+ * Resolves a relative import to a file on disk the way the bundler does.
83
+ *
84
+ * @param {string} fromFile importing file
85
+ * @param {string} specifier the relative import path
86
+ * @return {string|null}
87
+ */
88
+ function resolveImport(fromFile, specifier) {
89
+ const base = path.resolve(path.dirname(fromFile), specifier)
90
+ const candidates = [base, base + '.js', base + '.mjs', base + '.ts', base + '.vue', path.join(base, 'index.js'), path.join(base, 'index.mjs'), path.join(base, 'index.ts')]
91
+ return candidates.find((candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isFile()) ?? null
92
+ }
93
+
94
+ /**
95
+ * @param {string} source file contents
96
+ * @return {string[]} relative import specifiers, static and dynamic
97
+ */
98
+ function relativeImports(source) {
99
+ return [...source.matchAll(/(?:from\s+|import\s*\(\s*|import\s+)'(\.{1,2}\/[^']+)'/g)].map((match) => match[1])
100
+ }
101
+
102
+ /**
103
+ * @param {number} score direct lines plus transitive files
104
+ * @return {'none'|'light'|'medium'|'heavy'}
105
+ */
106
+ function tierOf(score) {
107
+ if (score === 0) {
108
+ return 'none'
109
+ }
110
+ if (score <= 3) {
111
+ return 'light'
112
+ }
113
+ if (score <= 10) {
114
+ return 'medium'
115
+ }
116
+ return 'heavy'
117
+ }
118
+
119
+ /**
120
+ * @param {string} componentName e.g. CnDeleteDialog
121
+ * @return {string} the docs page slug, e.g. cn-delete-dialog
122
+ */
123
+ function pageSlug(componentName) {
124
+ return componentName.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
125
+ }
126
+
127
+ /**
128
+ * @param {string} srcDir the src directory holding components/
129
+ * @param {string|null} docsComponentsDir where the component pages live, to link only existing pages
130
+ * @return {Record<string, {direct: number, transitive: number, tier: string, via: string[], page: string|null}>}
131
+ */
132
+ function computeReliance(srcDir, docsComponentsDir = null) {
133
+ const componentsDir = path.join(srcDir, 'components')
134
+ const files = new Map()
135
+ for (const file of walk(srcDir)) {
136
+ const source = fs.readFileSync(file, 'utf8')
137
+ files.set(file, {
138
+ direct: countDirect(source),
139
+ imports: relativeImports(source).map((specifier) => resolveImport(file, specifier)).filter(Boolean),
140
+ })
141
+ }
142
+
143
+ const reachable = (startFiles) => {
144
+ const own = new Set(startFiles)
145
+ const seen = new Set()
146
+ const queue = [...startFiles]
147
+ while (queue.length > 0) {
148
+ const file = queue.pop()
149
+ for (const next of (files.get(file) ?? { imports: [] }).imports) {
150
+ if (!own.has(next) && !seen.has(next)) {
151
+ seen.add(next)
152
+ queue.push(next)
153
+ }
154
+ }
155
+ }
156
+ return [...seen]
157
+ }
158
+
159
+ const result = {}
160
+ for (const entry of fs.readdirSync(componentsDir).sort()) {
161
+ const dir = path.join(componentsDir, entry)
162
+ if (!fs.statSync(dir).isDirectory() || !/^Cn[A-Z]/.test(entry)) {
163
+ continue
164
+ }
165
+ const ownFiles = walk(dir)
166
+ const direct = ownFiles.reduce((sum, file) => sum + (files.get(file)?.direct ?? 0), 0)
167
+ const relyingFiles = reachable(ownFiles).filter((file) => (files.get(file)?.direct ?? 0) > 0)
168
+ const via = new Set(relyingFiles
169
+ .filter((file) => file.startsWith(componentsDir + path.sep))
170
+ .map((file) => file.slice(componentsDir.length + 1).split(path.sep)[0])
171
+ .filter((name) => name !== entry))
172
+ const slug = pageSlug(entry)
173
+ const hasPage = docsComponentsDir !== null && fs.existsSync(path.join(docsComponentsDir, slug + '.md'))
174
+ result[entry] = {
175
+ direct,
176
+ transitive: relyingFiles.length,
177
+ tier: tierOf(direct + relyingFiles.length),
178
+ via: [...via].sort(),
179
+ page: hasPage ? slug : null,
180
+ }
181
+ }
182
+ return result
183
+ }
184
+
185
+ /**
186
+ * @param {string} repoRoot the repository root
187
+ * @return {{outFile: string, components: object}}
188
+ */
189
+ function writeReliance(repoRoot) {
190
+ const components = computeReliance(path.join(repoRoot, 'src'), path.join(repoRoot, 'docs/components'))
191
+ const outFile = path.join(repoRoot, 'docs/components/_generated/reliance.json')
192
+ fs.mkdirSync(path.dirname(outFile), { recursive: true })
193
+ fs.writeFileSync(outFile, JSON.stringify({ generatedBy: 'scripts/openregister-reliance.js', components }, null, '\t') + '\n')
194
+ return { outFile, components }
195
+ }
196
+
197
+ module.exports = { computeReliance, countDirect, tierOf, pageSlug, writeReliance }
198
+
199
+ if (require.main === module) {
200
+ const { outFile, components } = writeReliance(path.resolve(__dirname, '..'))
201
+ const tiers = {}
202
+ for (const item of Object.values(components)) {
203
+ tiers[item.tier] = (tiers[item.tier] ?? 0) + 1
204
+ }
205
+ console.log(`wrote ${path.relative(process.cwd(), outFile)}: ${Object.keys(components).length} components`, tiers)
206
+ }
@@ -200,6 +200,8 @@
200
200
  :maxZoom="maxZoom"
201
201
  @zoomChange="zoom = $event"
202
202
  @nodeSelect="onNodeSelect"
203
+ @nodeActivate="onNodeActivate"
204
+ @nodeMenu="onNodeMenu"
203
205
  @edgeSelect="onEdgeSelect"
204
206
  @edgeLabelClick="onEdgeLabelClick"
205
207
  @edgeLabelContext="onEdgeLabelContext"
@@ -1713,6 +1715,31 @@ export default {
1713
1715
  this.openNodeMenu({ item: id, event: mouse })
1714
1716
  },
1715
1717
 
1718
+ /**
1719
+ * Enter or Space on a step: open its editor, as a double-click does.
1720
+ *
1721
+ * @param {string} id The step id.
1722
+ * @return {void}
1723
+ */
1724
+ onNodeActivate(id) {
1725
+ this.store.selectedNodeId = id
1726
+ this.store.editingNodeId = id
1727
+ },
1728
+
1729
+ /**
1730
+ * Shift+F10 or the menu key on a step: its menu, at the step.
1731
+ *
1732
+ * @param {object} request The node's request.
1733
+ * @param {string} request.id The step id.
1734
+ * @param {number} request.clientX Where to place the menu.
1735
+ * @param {number} request.clientY Where to place the menu.
1736
+ * @return {void}
1737
+ */
1738
+ onNodeMenu({ id, clientX, clientY }) {
1739
+ this.store.selectedNodeId = id
1740
+ this.openNodeMenu({ item: id, event: { clientX, clientY } })
1741
+ },
1742
+
1716
1743
  /**
1717
1744
  * Place a palette node where it was dropped.
1718
1745
  *
@@ -226,6 +226,10 @@
226
226
  <strong>{{ step.transition }}</strong>
227
227
  <span class="cn-run-sidebar__hint"> · {{ step.status }}</span>
228
228
  <span v-if="step.error" class="cn-run-sidebar__error"> · {{ step.error }}</span>
229
+ <!-- A send step's outcome lists, by name: who got it, who
230
+ opted out, who the opt-out check could not answer for. -->
231
+ <CnFlowStepOutcomes v-if="step.report && step.report.messaging"
232
+ :report="step.report.messaging" />
229
233
  </li>
230
234
  </ol>
231
235
  </component>
@@ -241,6 +245,7 @@ import CheckboxMarkedOutline from 'vue-material-design-icons/CheckboxMarkedOutli
241
245
  import DatabaseOutline from 'vue-material-design-icons/DatabaseOutline.vue'
242
246
  import FormatListBulleted from 'vue-material-design-icons/FormatListBulleted.vue'
243
247
  import Replay from 'vue-material-design-icons/Replay.vue'
248
+ import CnFlowStepOutcomes from '../CnFlowStepOutcomes/CnFlowStepOutcomes.vue'
244
249
  import { FLOW_RUN_ACTIVE_STATUSES, useFlowStore } from '../../composables/useFlowStore.js'
245
250
 
246
251
  export default {
@@ -249,6 +254,7 @@ export default {
249
254
  components: {
250
255
  ArrowLeft,
251
256
  CheckboxMarkedOutline,
257
+ CnFlowStepOutcomes,
252
258
  DatabaseOutline,
253
259
  FormatListBulleted,
254
260
  NcAppSidebarTab,
@@ -0,0 +1,250 @@
1
+ <!--
2
+ CnFlowStepOutcomes: who a send step reached, and who it did not, by name.
3
+
4
+ A messaging step (send email, send notification, post to Talk) writes its
5
+ per-recipient outcome onto its run-log entry as `report.messaging`: one
6
+ bucket per outcome, each `{count, sample}`. Nothing rendered it, so the run
7
+ log could not show that a person was skipped because they opted out, or
8
+ because the opt-out check did not answer.
9
+
10
+ Only buckets with someone in them are listed. A bucket this component does
11
+ not know is still shown, under its own key made readable, so a new outcome
12
+ on the server is never hidden by an older library.
13
+
14
+ SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>
15
+ SPDX-License-Identifier: EUPL-1.2
16
+ -->
17
+ <template>
18
+ <div v-if="rows.length" class="cn-flow-step-outcomes" data-testid="flow-step-outcomes">
19
+ <dl class="cn-flow-step-outcomes__list" :aria-label="t('nextcloud-vue', 'Who this step reached')">
20
+ <div v-for="row in rows"
21
+ :key="row.key"
22
+ class="cn-flow-step-outcomes__row"
23
+ :class="`cn-flow-step-outcomes__row--${row.tone}`"
24
+ :data-testid="`flow-step-outcome-${row.key}`">
25
+ <dt class="cn-flow-step-outcomes__name">
26
+ {{ row.label }}
27
+ </dt>
28
+ <dd class="cn-flow-step-outcomes__value">
29
+ <span class="cn-flow-step-outcomes__count">{{ row.count }}</span>
30
+ <span v-if="row.sample.length" class="cn-flow-step-outcomes__sample"> · {{ row.sample.join(', ') }}</span>
31
+ </dd>
32
+ </div>
33
+ </dl>
34
+ <p v-if="report && report.truncated"
35
+ class="cn-flow-step-outcomes__truncated"
36
+ data-testid="flow-step-outcomes-truncated">
37
+ {{ t('nextcloud-vue', 'Each list shows its first entries only.') }}
38
+ </p>
39
+ </div>
40
+ </template>
41
+
42
+ <script>
43
+ import { translate as t } from '@nextcloud/l10n'
44
+
45
+ /**
46
+ * The buckets a messaging report carries, in reading order: who got it first,
47
+ * then each reason someone did not. `tone` marks the row for styling only.
48
+ */
49
+ const KNOWN_BUCKETS = [
50
+ { key: 'delivered', tone: 'success' },
51
+ { key: 'optedOut', tone: 'neutral' },
52
+ { key: 'authorityUnavailable', tone: 'warning' },
53
+ { key: 'skippedByPreference', tone: 'neutral' },
54
+ { key: 'skippedByKillSwitch', tone: 'neutral' },
55
+ { key: 'rateLimited', tone: 'warning' },
56
+ { key: 'refusedRecipients', tone: 'warning' },
57
+ { key: 'unknownRecipients', tone: 'warning' },
58
+ { key: 'failed', tone: 'error' },
59
+ ]
60
+
61
+ /** Report keys that are facts about the send, not outcome buckets. */
62
+ const NOT_BUCKETS = ['channel', 'actor', 'recipients', 'truncated']
63
+
64
+ export default {
65
+ name: 'CnFlowStepOutcomes',
66
+
67
+ props: {
68
+ /**
69
+ * A step's messaging report: the run-log entry's `report.messaging`.
70
+ * Each outcome bucket is `{count, sample}`; a `refusedRecipients`
71
+ * sample entry is `{recipient, reason}`. `null` renders nothing.
72
+ */
73
+ report: {
74
+ type: Object,
75
+ default: null,
76
+ },
77
+
78
+ /**
79
+ * Names to use instead of the built-in ones, keyed by bucket
80
+ * (`{ optedOut: 'Unsubscribed' }`). Pass already-translated text.
81
+ */
82
+ labels: {
83
+ type: Object,
84
+ default: () => ({}),
85
+ },
86
+ },
87
+
88
+ computed: {
89
+ /**
90
+ * The non-empty buckets, known ones first in reading order, then any
91
+ * bucket the server added that this component does not know.
92
+ *
93
+ * @return {Array<object>} One row per bucket: key, label, count, sample, tone.
94
+ */
95
+ rows() {
96
+ const report = this.report
97
+ if (!report || typeof report !== 'object') {
98
+ return []
99
+ }
100
+
101
+ const known = KNOWN_BUCKETS.map((bucket) => bucket.key)
102
+ const extra = Object.keys(report).filter((key) => !known.includes(key) && !NOT_BUCKETS.includes(key))
103
+ const ordered = [
104
+ ...KNOWN_BUCKETS,
105
+ ...extra.map((key) => ({ key, tone: 'neutral' })),
106
+ ]
107
+
108
+ return ordered
109
+ .map(({ key, tone }) => ({ key, tone, bucket: report[key] }))
110
+ .filter(({ bucket }) => bucket && typeof bucket === 'object' && Number(bucket.count) > 0)
111
+ .map(({ key, tone, bucket }) => ({
112
+ key,
113
+ tone,
114
+ label: this.labelFor(key),
115
+ count: Number(bucket.count),
116
+ sample: (Array.isArray(bucket.sample) ? bucket.sample : []).map((entry) => this.describeEntry(entry)),
117
+ }))
118
+ },
119
+ },
120
+
121
+ methods: {
122
+ t,
123
+
124
+ /**
125
+ * The readable name of a bucket: the host's, then the built-in one,
126
+ * then the key itself (`heldForReview` reads "Held for review").
127
+ *
128
+ * @param {string} key The bucket key.
129
+ * @return {string} The name.
130
+ */
131
+ labelFor(key) {
132
+ if (this.labels && this.labels[key]) {
133
+ return String(this.labels[key])
134
+ }
135
+
136
+ const builtIn = {
137
+ delivered: t('nextcloud-vue', 'Delivered'),
138
+ optedOut: t('nextcloud-vue', 'Opted out'),
139
+ authorityUnavailable: t('nextcloud-vue', 'Not sent, the opt-out check did not answer'),
140
+ skippedByPreference: t('nextcloud-vue', 'Skipped by their notification settings'),
141
+ skippedByKillSwitch: t('nextcloud-vue', 'Skipped, sending is switched off'),
142
+ rateLimited: t('nextcloud-vue', 'Held back by the send limit'),
143
+ refusedRecipients: t('nextcloud-vue', 'Refused by the step’s address rule'),
144
+ unknownRecipients: t('nextcloud-vue', 'Not a known user or group'),
145
+ failed: t('nextcloud-vue', 'Failed'),
146
+ }
147
+ if (builtIn[key]) {
148
+ return builtIn[key]
149
+ }
150
+
151
+ const words = String(key).replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[-_]+/g, ' ').trim().toLowerCase()
152
+
153
+ return words.charAt(0).toUpperCase() + words.slice(1)
154
+ },
155
+
156
+ /**
157
+ * One sample entry as text. A refused address carries its reason.
158
+ *
159
+ * @param {string|object} entry The sample entry.
160
+ * @return {string} The text.
161
+ */
162
+ describeEntry(entry) {
163
+ if (entry && typeof entry === 'object') {
164
+ const who = String(entry.recipient ?? entry.id ?? '')
165
+ const reason = this.reasonFor(entry.reason)
166
+
167
+ return reason ? `${who} (${reason})` : who
168
+ }
169
+
170
+ return String(entry)
171
+ },
172
+
173
+ /**
174
+ * Why the step's address rule refused an address, in words.
175
+ *
176
+ * @param {string} [code] The refusal code.
177
+ * @return {string} The reason, or the code when it is not known.
178
+ */
179
+ reasonFor(code) {
180
+ const reasons = {
181
+ 'external-recipients-off': t('nextcloud-vue', 'external recipients are off'),
182
+ 'not-on-item': t('nextcloud-vue', 'not on the item'),
183
+ 'invalid-address': t('nextcloud-vue', 'not a valid address'),
184
+ }
185
+
186
+ return code ? (reasons[code] || String(code)) : ''
187
+ },
188
+ },
189
+ }
190
+ </script>
191
+
192
+ <style scoped>
193
+ .cn-flow-step-outcomes {
194
+ margin: 4px 0 0;
195
+ font-size: 0.9em;
196
+ }
197
+
198
+ .cn-flow-step-outcomes__list {
199
+ margin: 0;
200
+ padding: 0;
201
+ }
202
+
203
+ /* Stacked, name over value: the run sidebar is narrow, and a side-by-side
204
+ row wrapped mid-list. The host's dt/dd margins and alignment are reset. */
205
+ .cn-flow-step-outcomes__row {
206
+ display: block;
207
+ margin-block: 4px;
208
+ padding-block: 2px;
209
+ padding-inline-start: 8px;
210
+ border-inline-start: 3px solid var(--color-border);
211
+ text-align: start;
212
+ }
213
+
214
+ .cn-flow-step-outcomes__row--success {
215
+ border-inline-start-color: var(--color-success);
216
+ }
217
+
218
+ .cn-flow-step-outcomes__row--warning {
219
+ border-inline-start-color: var(--color-warning);
220
+ }
221
+
222
+ .cn-flow-step-outcomes__row--error {
223
+ border-inline-start-color: var(--color-error);
224
+ }
225
+
226
+ .cn-flow-step-outcomes__name {
227
+ display: block;
228
+ white-space: normal;
229
+ overflow-wrap: anywhere;
230
+ margin: 0;
231
+ padding: 0;
232
+ font-weight: bold;
233
+ text-align: start;
234
+ }
235
+
236
+ .cn-flow-step-outcomes__value {
237
+ display: block;
238
+ white-space: normal;
239
+ margin: 0;
240
+ padding: 0;
241
+ text-align: start;
242
+ color: var(--color-main-text);
243
+ overflow-wrap: anywhere;
244
+ }
245
+
246
+ .cn-flow-step-outcomes__truncated {
247
+ margin: 4px 0 0;
248
+ color: var(--color-text-maxcontrast);
249
+ }
250
+ </style>
@@ -0,0 +1,8 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ */
5
+ import CnFlowStepOutcomes from './CnFlowStepOutcomes.vue'
6
+
7
+ export default CnFlowStepOutcomes
8
+ export { CnFlowStepOutcomes }