@conduction/nextcloud-vue 2.71.0 → 2.72.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 (40) hide show
  1. package/dist/esm/components/CnFlowDetail/CnRunDetailSidebar.vue2.js +2 -0
  2. package/dist/esm/components/CnFlowDetail/CnRunDetailSidebar.vue2.js.map +1 -1
  3. package/dist/esm/components/CnFlowDetail/CnRunDetailSidebar.vue3.js +7 -0
  4. package/dist/esm/components/CnFlowDetail/CnRunDetailSidebar.vue3.js.map +1 -1
  5. package/dist/esm/components/CnFlowStepOutcomes/CnFlowStepOutcomes.vue.js +10 -0
  6. package/dist/esm/components/CnFlowStepOutcomes/CnFlowStepOutcomes.vue.js.map +1 -0
  7. package/dist/esm/components/CnFlowStepOutcomes/CnFlowStepOutcomes.vue2.js +150 -0
  8. package/dist/esm/components/CnFlowStepOutcomes/CnFlowStepOutcomes.vue2.js.map +1 -0
  9. package/dist/esm/components/CnFlowStepOutcomes/CnFlowStepOutcomes.vue3.js +54 -0
  10. package/dist/esm/components/CnFlowStepOutcomes/CnFlowStepOutcomes.vue3.js.map +1 -0
  11. package/dist/esm/dialogs/CnFlowNodeEditModal.vue2.js +7 -10
  12. package/dist/esm/dialogs/CnFlowNodeEditModal.vue2.js.map +1 -1
  13. package/dist/esm/dialogs/CnFlowNodeEditModal.vue3.js +23 -13
  14. package/dist/esm/dialogs/CnFlowNodeEditModal.vue3.js.map +1 -1
  15. package/dist/esm/dialogs/CnRunNodeDialog.vue2.js +8 -6
  16. package/dist/esm/dialogs/CnRunNodeDialog.vue2.js.map +1 -1
  17. package/dist/esm/dialogs/CnRunNodeDialog.vue3.js.map +1 -1
  18. package/dist/esm/index.js +1 -0
  19. package/dist/esm/index.js.map +1 -1
  20. package/dist/esm/l10n/en.json.js +15 -1
  21. package/dist/esm/l10n/en.json.js.map +1 -1
  22. package/dist/esm/l10n/nl.json.js +15 -1
  23. package/dist/esm/l10n/nl.json.js.map +1 -1
  24. package/dist/esm/nextcloud-vue.css +64 -0
  25. package/dist/esm/utils/selectOptions.js +57 -0
  26. package/dist/esm/utils/selectOptions.js.map +1 -0
  27. package/dist/nextcloud-vue.cjs.js +5945 -5645
  28. package/dist/nextcloud-vue.cjs.js.map +2 -2
  29. package/dist/nextcloud-vue.css +64 -0
  30. package/l10n/en.json +15 -1
  31. package/l10n/nl.json +15 -1
  32. package/package.json +1 -1
  33. package/src/components/CnFlowDetail/CnRunDetailSidebar.vue +6 -0
  34. package/src/components/CnFlowStepOutcomes/CnFlowStepOutcomes.vue +250 -0
  35. package/src/components/CnFlowStepOutcomes/index.js +8 -0
  36. package/src/components/index.js +1 -0
  37. package/src/dialogs/CnFlowNodeEditModal.vue +41 -20
  38. package/src/dialogs/CnRunNodeDialog.vue +8 -6
  39. package/src/index.js +1 -0
  40. 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: 35684577 bytes
4
+ sha256: 40220f92c6defbd478644408d9d23a88047c47798fb52eefee28e6d70e0fbc9a
@@ -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.72.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": {
@@ -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 }
@@ -191,6 +191,7 @@ export { CnDeltaWidgetForm } from './CnDeltaWidgetForm/index.js'
191
191
  export { CnGaugeWidget } from './CnGaugeWidget/index.js'
192
192
  export { CnGaugeWidgetForm } from './CnGaugeWidgetForm/index.js'
193
193
  export { CnFlowRunsWidget } from './CnFlowRunsWidget/index.js'
194
+ export { CnFlowStepOutcomes } from './CnFlowStepOutcomes/index.js'
194
195
  export { CnFlowRunsWidgetForm } from './CnFlowRunsWidgetForm/index.js'
195
196
  export { CnTasksWidget } from './CnTasksWidget/index.js'
196
197
  export { CnTasksWidgetForm } from './CnTasksWidgetForm/index.js'
@@ -40,16 +40,27 @@
40
40
  :placeholder="t('nextcloud-vue', 'GET')"
41
41
  @update:modelValue="setKey(key, $event)" />
42
42
 
43
- <!-- A `select` field with `optionsFrom` renders as a picker fed
44
- by the URL the OWNING APP declared — never a bare uuid
45
- text box. -->
46
- <NcSelect v-else-if="widgetFor(key) === 'select'"
47
- :modelValue="selectedOption(key)"
48
- :options="selectOptions[key] || []"
49
- :inputLabel="labelFor(key)"
50
- :loading="selectLoading[key] === true"
51
- :placeholder="t('nextcloud-vue', 'Pick one…')"
52
- @update:modelValue="setKey(key, $event ? $event.id : '')" />
43
+ <!-- A `select` field renders as a picker, never a bare text box.
44
+ Its choices come from the URL the OWNING APP declared
45
+ (`optionsFrom`) or from the form itself (`options`), for a
46
+ short fixed vocabulary with no endpoint of its own. -->
47
+ <template v-else-if="widgetFor(key) === 'select'">
48
+ <NcSelect :modelValue="selectedOption(key)"
49
+ class="cn-flow-node-edit__select"
50
+ :options="selectOptions[key] || []"
51
+ :inputLabel="labelFor(key)"
52
+ :loading="selectLoading[key] === true"
53
+ :placeholder="t('nextcloud-vue', 'Pick one…')"
54
+ @update:modelValue="setKey(key, $event ? $event.id : '')" />
55
+ <!-- NcSelect has no helper text of its own, so the help
56
+ follows the control, the next thing a screen reader
57
+ reads after it. -->
58
+ <p v-if="hintFor(key)"
59
+ class="cn-flow-node-edit__field-help"
60
+ :data-testid="`flow-node-help-${key}`">
61
+ {{ hintFor(key) }}
62
+ </p>
63
+ </template>
53
64
 
54
65
  <NcTextArea v-else-if="widgetFor(key) === 'textarea'"
55
66
  :modelValue="String(draft.config[key] ?? '')"
@@ -170,6 +181,7 @@ import {
170
181
  } from '@nextcloud/vue'
171
182
  import CnCronField from '../components/CnCronField/CnCronField.vue'
172
183
  import { useFlowStore } from '../composables/useFlowStore.js'
184
+ import { hasStaticOptions, normaliseSelectOptions } from '../utils/selectOptions.js'
173
185
 
174
186
  /** The verbs an HTTP-shaped `method` option can take. */
175
187
  /**
@@ -287,7 +299,7 @@ export default {
287
299
 
288
300
  /**
289
301
  * The node's per-field declarations from the catalogue's `configForm`
290
- * ({key, label, type, help, required, optionsFrom}), keyed by config
302
+ * ({key, label, type, help, required, optionsFrom, options}), keyed by config
291
303
  * key. `configKeys` is the degraded form. Either way the node's OWNER
292
304
  * declares the vocabulary — this dialog never invents fields.
293
305
  *
@@ -398,7 +410,9 @@ export default {
398
410
  created() {
399
411
  // Select fields need their options; everything else is local.
400
412
  for (const [key, spec] of Object.entries(this.fieldSpecs)) {
401
- if (spec?.type === 'select' && spec?.optionsFrom) {
413
+ if (hasStaticOptions(spec)) {
414
+ this.selectOptions = { ...this.selectOptions, [key]: normaliseSelectOptions(spec.options) }
415
+ } else if (spec?.type === 'select' && spec?.optionsFrom) {
402
416
  this.loadSelectOptions(key, spec)
403
417
  }
404
418
  }
@@ -634,7 +648,7 @@ export default {
634
648
  */
635
649
  widgetFor(key) {
636
650
  const spec = this.fieldSpecs[key]
637
- if (spec?.type === 'select' && spec?.optionsFrom) {
651
+ if (spec?.type === 'select' && (spec?.optionsFrom || hasStaticOptions(spec))) {
638
652
  return 'select'
639
653
  }
640
654
  if (spec?.type === 'boolean') {
@@ -730,13 +744,7 @@ export default {
730
744
  ? generateUrl(url)
731
745
  : url)
732
746
  const rows = Array.isArray(response.data) ? response.data : (response.data?.results || [])
733
- const options = rows.map((row) => {
734
- const id = row.id ?? row.value ?? row['@self']?.uuid ?? row.uuid
735
- return {
736
- id,
737
- label: row.label || row.name || row.title || String(id),
738
- }
739
- }).filter((o) => o.id !== undefined && o.id !== null && o.id !== '')
747
+ const options = normaliseSelectOptions(rows)
740
748
  this.selectOptions = { ...this.selectOptions, [key]: options }
741
749
  } catch (error) {
742
750
  // A picker that could not load degrades to showing the stored
@@ -930,6 +938,19 @@ export default {
930
938
  color: var(--color-text-maxcontrast);
931
939
  }
932
940
 
941
+ /* Full width, like the text fields around it: at its natural width the
942
+ picker cut every option label in half. */
943
+ .cn-flow-node-edit__select {
944
+ width: 100%;
945
+ }
946
+
947
+ .cn-flow-node-edit__field-help {
948
+ margin: 4px 0 0;
949
+ padding-inline-start: 8px;
950
+ font-size: 0.85em;
951
+ color: var(--color-text-maxcontrast);
952
+ }
953
+
933
954
  .cn-flow-node-edit__advanced summary {
934
955
  cursor: pointer;
935
956
  color: var(--color-text-maxcontrast);
@@ -88,6 +88,7 @@
88
88
  import { translate as t } from '@nextcloud/l10n'
89
89
  import { NcButton, NcCheckboxRadioSwitch, NcDialog, NcLoadingIcon, NcNoteCard, NcSelect, NcTextArea, NcTextField } from '@nextcloud/vue'
90
90
  import { prefixUrl } from '../utils/headers.js'
91
+ import { hasStaticOptions, normaliseSelectOptions } from '../utils/selectOptions.js'
91
92
 
92
93
  /**
93
94
  * The dialog `run-node` opens to collect a directly-invoked flow node's own
@@ -128,7 +129,9 @@ export default {
128
129
  title: { type: String, default: '' },
129
130
  /**
130
131
  * The node's declared config form — `IFlowNodeConfigForm::configForm()`'s
131
- * shape, unchanged: `[{key, label, type, help?, required?, optionsFrom?}]`.
132
+ * shape, unchanged: `[{key, label, type, help?, required?, optionsFrom?, options?}]`.
133
+ * A `select` takes its choices from `optionsFrom` (a url) or `options`
134
+ * (strings, or `{value, label}` pairs declared in the form itself).
132
135
  *
133
136
  * @type {Array<object>}
134
137
  */
@@ -177,7 +180,9 @@ export default {
177
180
  }
178
181
  this.values = seed
179
182
  for (const field of (fields || [])) {
180
- if (field.type === 'select' && field.optionsFrom) {
183
+ if (hasStaticOptions(field)) {
184
+ this.options = { ...this.options, [field.key]: normaliseSelectOptions(field.options, (s) => this.tr(s)) }
185
+ } else if (field.type === 'select' && field.optionsFrom) {
181
186
  this.loadOptions(field)
182
187
  }
183
188
  }
@@ -274,10 +279,7 @@ export default {
274
279
  const url = prefixUrl(raw)
275
280
  const response = await axios.get(url)
276
281
  const rows = Array.isArray(response.data) ? response.data : (response.data?.results || [])
277
- const opts = rows.map((row) => {
278
- const id = row.id ?? row.value ?? row['@self']?.uuid ?? row.uuid
279
- return { id, label: row.label || row.name || row.title || String(id) }
280
- }).filter((o) => o.id !== undefined && o.id !== null && o.id !== '')
282
+ const opts = normaliseSelectOptions(rows)
281
283
  this.options = { ...this.options, [field.key]: opts }
282
284
  } catch (error) {
283
285
  // A picker that fails to load degrades to an empty list rather
package/src/index.js CHANGED
@@ -129,6 +129,7 @@ export {
129
129
  CnFlowRunsWidgetForm,
130
130
  CnFlowSidebar,
131
131
  CnFlowsPage,
132
+ CnFlowStepOutcomes,
132
133
  CnFolderSidebar,
133
134
  CnFolderTree,
134
135
  CnFormBuilder,
@@ -0,0 +1,54 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * The choices a node's `select` field offers, in the `{id, label}` shape
6
+ * NcSelect takes.
7
+ *
8
+ * A field gets its choices one of two ways. `optionsFrom` names a url the
9
+ * owning app answers. `options` lists them in the form itself, for a short
10
+ * fixed vocabulary that has no endpoint of its own. Both dialogs that render a
11
+ * node's configForm read the same shapes through this module, so the two
12
+ * cannot come to accept different ones.
13
+ */
14
+
15
+ /**
16
+ * Normalise rows into `{id, label}` options.
17
+ *
18
+ * Accepts plain strings and numbers, `{id, label}`, `{value, label}`, and
19
+ * OpenRegister objects (uuid from `@self`, label from name or title). A row
20
+ * without an id is dropped rather than shown as a blank option.
21
+ *
22
+ * @param {Array<unknown>} rows The raw rows.
23
+ * @param {(label: string) => string} [translate] Applied to each declared label.
24
+ * @return {Array<{id: string|number, label: string}>} The options.
25
+ */
26
+ export function normaliseSelectOptions(rows, translate = (s) => s) {
27
+ if (!Array.isArray(rows)) {
28
+ return []
29
+ }
30
+
31
+ return rows.map((row) => {
32
+ if (typeof row === 'string' || typeof row === 'number') {
33
+ return { id: row, label: String(row) }
34
+ }
35
+ if (!row || typeof row !== 'object') {
36
+ return { id: undefined, label: '' }
37
+ }
38
+
39
+ const id = row.id ?? row.value ?? row['@self']?.uuid ?? row.uuid
40
+ const declared = row.label || row.name || row.title
41
+
42
+ return { id, label: declared ? String(translate(declared)) : String(id) }
43
+ }).filter((option) => option.id !== undefined && option.id !== null && option.id !== '')
44
+ }
45
+
46
+ /**
47
+ * Whether a field declares its choices in the form itself.
48
+ *
49
+ * @param {object} field The field declaration.
50
+ * @return {boolean} True for a `select` with an `options` list.
51
+ */
52
+ export function hasStaticOptions(field) {
53
+ return field?.type === 'select' && Array.isArray(field?.options)
54
+ }