@nyaruka/temba-components 0.156.9 → 0.156.11

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 (58) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/temba-components.js +587 -523
  3. package/dist/temba-components.js.map +1 -1
  4. package/package.json +1 -1
  5. package/src/display/Chat.ts +8 -8
  6. package/src/display/FloatingTab.ts +2 -2
  7. package/src/display/Options.ts +8 -2
  8. package/src/flow/CanvasMenu.ts +20 -25
  9. package/src/flow/CanvasNode.ts +16 -12
  10. package/src/flow/DragManager.ts +93 -33
  11. package/src/flow/Editor.ts +59 -54
  12. package/src/flow/EditorToolbar.ts +19 -20
  13. package/src/flow/FlowSearch.ts +9 -7
  14. package/src/flow/MessageTable.ts +181 -74
  15. package/src/flow/NodeEditor.ts +55 -72
  16. package/src/flow/RevisionsWindow.ts +2 -4
  17. package/src/flow/ZoomManager.ts +1 -2
  18. package/src/flow/actions/play_audio.ts +1 -28
  19. package/src/flow/actions/say_msg.ts +1 -40
  20. package/src/flow/actions/send_broadcast.ts +1 -2
  21. package/src/flow/actions/send_email.ts +5 -56
  22. package/src/flow/actions/send_msg.ts +10 -2
  23. package/src/flow/actions/start_session.ts +1 -2
  24. package/src/flow/categoryLocalization.ts +1 -5
  25. package/src/flow/categoryUtils.ts +139 -0
  26. package/src/flow/nodes/shared-rules.ts +6 -16
  27. package/src/flow/nodes/shared.ts +113 -6
  28. package/src/flow/nodes/split_by_airtime.ts +41 -63
  29. package/src/flow/nodes/split_by_contact_field.ts +8 -17
  30. package/src/flow/nodes/split_by_expression.ts +8 -17
  31. package/src/flow/nodes/split_by_groups.ts +34 -112
  32. package/src/flow/nodes/split_by_llm.ts +1 -7
  33. package/src/flow/nodes/split_by_llm_categorize.ts +27 -43
  34. package/src/flow/nodes/split_by_random.ts +39 -99
  35. package/src/flow/nodes/split_by_resthook.ts +5 -19
  36. package/src/flow/nodes/split_by_run_result.ts +8 -17
  37. package/src/flow/nodes/split_by_scheme.ts +39 -124
  38. package/src/flow/nodes/split_by_subflow.ts +1 -7
  39. package/src/flow/nodes/split_by_ticket.ts +1 -7
  40. package/src/flow/nodes/split_by_webhook.ts +2 -8
  41. package/src/flow/nodes/wait_for_audio.ts +1 -7
  42. package/src/flow/nodes/wait_for_dial.ts +2 -8
  43. package/src/flow/nodes/wait_for_digits.ts +5 -7
  44. package/src/flow/nodes/wait_for_menu.ts +5 -7
  45. package/src/flow/nodes/wait_for_response.ts +10 -18
  46. package/src/flow/types.ts +27 -0
  47. package/src/flow/utils.ts +111 -3
  48. package/src/form/Compose.ts +84 -7
  49. package/src/form/MessageEditor.ts +5 -3
  50. package/src/form/RichEditor.ts +3 -1
  51. package/src/form/TemplateEditor.ts +5 -1
  52. package/src/form/select/Select.ts +12 -10
  53. package/src/layout/AccordionSection.ts +9 -3
  54. package/src/layout/Modax.ts +1 -3
  55. package/src/live/ContactChat.ts +54 -46
  56. package/src/simulator/Simulator.ts +9 -3
  57. package/src/store/AppState.ts +1 -1
  58. package/src/utils.ts +21 -16
@@ -524,8 +524,7 @@ export class ZoomManager {
524
524
  const dx = canvasX - this.loupeCursorCanvas.x;
525
525
  const dy = canvasY - this.loupeCursorCanvas.y;
526
526
  const moved =
527
- Math.abs(dx) > visibleRadius * 0.5 ||
528
- Math.abs(dy) > visibleRadius * 0.5;
527
+ Math.abs(dx) > visibleRadius * 0.5 || Math.abs(dy) > visibleRadius * 0.5;
529
528
 
530
529
  if (
531
530
  !this.loupeClone ||
@@ -42,32 +42,5 @@ export const play_audio: ActionConfig = {
42
42
  audio_url: (data.audio_url || '').trim()
43
43
  } as PlayAudio;
44
44
  },
45
- localizable: ['audio_url'],
46
- toLocalizationFormData: (
47
- action: PlayAudio,
48
- localization: Record<string, any>
49
- ) => {
50
- const formData: FormData = {
51
- uuid: action.uuid
52
- };
53
-
54
- if (localization.audio_url && Array.isArray(localization.audio_url)) {
55
- formData.audio_url = localization.audio_url[0] || '';
56
- } else {
57
- formData.audio_url = '';
58
- }
59
-
60
- return formData;
61
- },
62
- fromLocalizationFormData: (formData: FormData, action: PlayAudio) => {
63
- const localization: Record<string, any> = {};
64
-
65
- if (formData.audio_url && formData.audio_url.trim() !== '') {
66
- if (formData.audio_url !== action.audio_url) {
67
- localization.audio_url = [formData.audio_url];
68
- }
69
- }
70
-
71
- return localization;
72
- }
45
+ localizable: ['audio_url']
73
46
  };
@@ -63,44 +63,5 @@ export const say_msg: ActionConfig = {
63
63
  formData.text = formData.text.trim();
64
64
  }
65
65
  },
66
- localizable: ['text', 'audio_url'],
67
- toLocalizationFormData: (
68
- action: SayMsg,
69
- localization: Record<string, any>
70
- ) => {
71
- const formData: FormData = {
72
- uuid: action.uuid
73
- };
74
-
75
- if (localization.text && Array.isArray(localization.text)) {
76
- formData.text = localization.text[0] || '';
77
- } else {
78
- formData.text = '';
79
- }
80
-
81
- if (localization.audio_url && Array.isArray(localization.audio_url)) {
82
- formData.audio_url = localization.audio_url[0] || '';
83
- } else {
84
- formData.audio_url = '';
85
- }
86
-
87
- return formData;
88
- },
89
- fromLocalizationFormData: (formData: FormData, action: SayMsg) => {
90
- const localization: Record<string, any> = {};
91
-
92
- if (formData.text && formData.text.trim() !== '') {
93
- if (formData.text !== action.text) {
94
- localization.text = [formData.text];
95
- }
96
- }
97
-
98
- if (formData.audio_url && formData.audio_url.trim() !== '') {
99
- if (formData.audio_url !== action.audio_url) {
100
- localization.audio_url = [formData.audio_url];
101
- }
102
- }
103
-
104
- return localization;
105
- }
66
+ localizable: ['text', 'audio_url']
106
67
  };
@@ -220,8 +220,7 @@ export const send_broadcast: ActionConfig = {
220
220
 
221
221
  if (attachments.length > 0) {
222
222
  if (
223
- JSON.stringify(attachments) !==
224
- JSON.stringify(action.attachments || [])
223
+ JSON.stringify(attachments) !== JSON.stringify(action.attachments || [])
225
224
  ) {
226
225
  localization.attachments = attachments;
227
226
  }
@@ -1,16 +1,11 @@
1
1
  import { html } from 'lit-html';
2
- import {
3
- ActionConfig,
4
- ACTION_GROUPS,
5
- FormData,
6
- ValidationResult,
7
- FlowTypes
8
- } from '../types';
2
+ import { ActionConfig, ACTION_GROUPS, FormData, FlowTypes } from '../types';
9
3
  import { Node, SendEmail } from '../../store/flow-definition';
10
4
  import {
11
5
  renderStringList,
12
6
  renderClamped,
13
- renderHighlightedText
7
+ renderHighlightedText,
8
+ validateWith
14
9
  } from '../utils';
15
10
  import { Icon } from '../../Icons';
16
11
 
@@ -67,55 +62,9 @@ export const send_email: ActionConfig = {
67
62
  };
68
63
  },
69
64
  localizable: ['subject', 'body'],
70
- toLocalizationFormData: (
71
- action: SendEmail,
72
- localization: Record<string, any>
73
- ) => {
74
- const formData: FormData = {
75
- uuid: action.uuid
76
- };
77
-
78
- if (localization.subject && Array.isArray(localization.subject)) {
79
- formData.subject = localization.subject[0] || '';
80
- } else {
81
- formData.subject = '';
82
- }
83
-
84
- if (localization.body && Array.isArray(localization.body)) {
85
- formData.body = localization.body[0] || '';
86
- } else {
87
- formData.body = '';
88
- }
89
-
90
- return formData;
91
- },
92
- fromLocalizationFormData: (formData: FormData, action: SendEmail) => {
93
- const localization: Record<string, any> = {};
94
-
95
- if (formData.subject && formData.subject.trim() !== '') {
96
- if (formData.subject !== action.subject) {
97
- localization.subject = [formData.subject];
98
- }
99
- }
100
-
101
- if (formData.body && formData.body.trim() !== '') {
102
- if (formData.body !== action.body) {
103
- localization.body = [formData.body];
104
- }
105
- }
106
-
107
- return localization;
108
- },
109
- validate: (formData: FormData): ValidationResult => {
110
- const errors: { [key: string]: string } = {};
111
-
65
+ validate: validateWith((formData, errors) => {
112
66
  if (!formData.addresses || formData.addresses.length === 0) {
113
67
  errors.addresses = 'At least one recipient email address is required';
114
68
  }
115
-
116
- return {
117
- valid: Object.keys(errors).length === 0,
118
- errors
119
- };
120
- }
69
+ })
121
70
  };
@@ -172,7 +172,11 @@ export const send_msg: ActionConfig = {
172
172
 
173
173
  if (!contentType.includes('/')) {
174
174
  runtimeAttachments.push({
175
- type: { name: ATTACHMENT_TYPE_NAMES[contentType] || titleCase(contentType), value: contentType },
175
+ type: {
176
+ name:
177
+ ATTACHMENT_TYPE_NAMES[contentType] || titleCase(contentType),
178
+ value: contentType
179
+ },
176
180
  expression: value
177
181
  });
178
182
  } else {
@@ -319,7 +323,11 @@ export const send_msg: ActionConfig = {
319
323
 
320
324
  if (!contentType.includes('/')) {
321
325
  runtimeAttachments.push({
322
- type: { name: ATTACHMENT_TYPE_NAMES[contentType] || titleCase(contentType), value: contentType },
326
+ type: {
327
+ name:
328
+ ATTACHMENT_TYPE_NAMES[contentType] || titleCase(contentType),
329
+ value: contentType
330
+ },
323
331
  expression: value
324
332
  });
325
333
  } else {
@@ -236,8 +236,7 @@ export const start_session: ActionConfig = {
236
236
  const recipients = formData.recipients || [];
237
237
  action.contacts = recipients
238
238
  .filter(
239
- (r: any) =>
240
- r.type === 'contact' || (!r.type && !r.expression && r.id)
239
+ (r: any) => r.type === 'contact' || (!r.type && !r.expression && r.id)
241
240
  )
242
241
  .map((c: any) => ({ uuid: c.id, name: c.name }));
243
242
  action.groups = recipients
@@ -1,10 +1,6 @@
1
1
  import { Category } from '../store/flow-definition';
2
2
  import { NODE_CONFIG } from './config';
3
-
4
- const SYSTEM_CATEGORIES_ALLOWED_FOR_TRANSLATION = new Set([
5
- 'Other',
6
- 'No Response'
7
- ]);
3
+ import { SYSTEM_CATEGORIES_ALLOWED_FOR_TRANSLATION } from './categoryUtils';
8
4
 
9
5
  export function getTranslatableCategoriesForNode(
10
6
  nodeType: string | undefined,
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Shared helpers and constants for router category handling.
3
+ *
4
+ * This module is the single source of truth for:
5
+ * - reserved category names (forbidden to users across all node types)
6
+ * - system category names (auto-generated; filtered from user-editable rules)
7
+ * - case-insensitive category name comparison and lookup
8
+ */
9
+
10
+ /**
11
+ * Names reserved for system-generated categories. Users may not create
12
+ * categories whose names collide with these (matching is case-insensitive).
13
+ */
14
+ export const RESERVED_CATEGORY_NAMES = [
15
+ 'Other',
16
+ 'All Responses',
17
+ 'No Response',
18
+ 'Failure',
19
+ 'Success',
20
+ 'Timeout'
21
+ ] as const;
22
+
23
+ /**
24
+ * Categories that are auto-generated by the system and should be filtered out
25
+ * when converting router data back into user-editable form rules.
26
+ */
27
+ export const SYSTEM_CATEGORY_NAMES = [
28
+ 'Other',
29
+ 'All Responses',
30
+ 'No Response',
31
+ 'Timeout'
32
+ ] as const;
33
+
34
+ /**
35
+ * System categories permitted to be localized even on nodes that otherwise
36
+ * block translation of system categories.
37
+ */
38
+ export const SYSTEM_CATEGORIES_ALLOWED_FOR_TRANSLATION: ReadonlySet<string> =
39
+ new Set(['Other', 'No Response']);
40
+
41
+ const RESERVED_LOWER = new Set<string>(
42
+ RESERVED_CATEGORY_NAMES.map((n) => n.toLowerCase())
43
+ );
44
+
45
+ const SYSTEM_LOWER = new Set<string>(
46
+ SYSTEM_CATEGORY_NAMES.map((n) => n.toLowerCase())
47
+ );
48
+
49
+ const normalize = (name: string | undefined | null): string =>
50
+ (name || '').trim().toLowerCase();
51
+
52
+ /** Case-insensitive check for reserved category names. */
53
+ export const isReservedCategoryName = (name: string): boolean =>
54
+ RESERVED_LOWER.has(normalize(name));
55
+
56
+ /** Case-insensitive check for auto-generated system categories. */
57
+ export const isSystemCategory = (name: string): boolean =>
58
+ SYSTEM_LOWER.has(normalize(name));
59
+
60
+ /** Case-insensitive equality test for two category names. */
61
+ export const categoryNamesEqual = (a: string, b: string): boolean =>
62
+ normalize(a) === normalize(b);
63
+
64
+ /** Case-insensitive lookup of a category by name. */
65
+ export const findCategoryByName = <T extends { name: string }>(
66
+ categories: T[],
67
+ name: string
68
+ ): T | undefined => {
69
+ const target = normalize(name);
70
+ return categories.find((cat) => normalize(cat.name) === target);
71
+ };
72
+
73
+ /**
74
+ * Returns the subset of the provided names that collide with a reserved
75
+ * category name. Originals (with their casing) are preserved in the output;
76
+ * duplicates are de-duplicated.
77
+ */
78
+ export const findReservedNames = (names: string[]): string[] => {
79
+ const seen = new Set<string>();
80
+ const result: string[] = [];
81
+ for (const name of names) {
82
+ const trimmed = (name || '').trim();
83
+ if (!trimmed) continue;
84
+ if (!isReservedCategoryName(trimmed)) continue;
85
+ const key = trimmed.toLowerCase();
86
+ if (seen.has(key)) continue;
87
+ seen.add(key);
88
+ result.push(trimmed);
89
+ }
90
+ return result;
91
+ };
92
+
93
+ /**
94
+ * Form fields whose entries become router category names. Kept in one place so
95
+ * the reserved-name validator can be applied uniformly across node types.
96
+ *
97
+ * - categories[].name (split_by_random)
98
+ * - groups[].name (split_by_groups — the group name becomes the
99
+ * category name, so a group literally named "Other"
100
+ * must be rejected)
101
+ * - rules[].category (split_by_expression, wait_for_response)
102
+ */
103
+ const CATEGORY_FIELD_SHAPES: Array<{
104
+ fieldName: string;
105
+ getName: (item: any) => unknown;
106
+ }> = [
107
+ { fieldName: 'categories', getName: (item) => item?.name },
108
+ { fieldName: 'groups', getName: (item) => item?.name },
109
+ { fieldName: 'rules', getName: (item) => item?.category }
110
+ ];
111
+
112
+ /**
113
+ * Scans form data for user-authored category names that collide with reserved
114
+ * system names, returning an `errors` map keyed by form field name. Checks all
115
+ * known category-bearing field shapes so callers don't have to know which one
116
+ * a given node uses.
117
+ */
118
+ export const collectReservedCategoryErrors = (formData: {
119
+ [key: string]: any;
120
+ }): { [fieldName: string]: string } => {
121
+ const errors: { [fieldName: string]: string } = {};
122
+
123
+ for (const { fieldName, getName } of CATEGORY_FIELD_SHAPES) {
124
+ const value = formData[fieldName];
125
+ if (!Array.isArray(value)) continue;
126
+
127
+ const names = value
128
+ .map((item) => getName(item))
129
+ .filter((name): name is string => typeof name === 'string');
130
+
131
+ const reservedUsed = findReservedNames(names);
132
+ if (reservedUsed.length > 0) {
133
+ errors[fieldName] =
134
+ `Reserved category names cannot be used: ${reservedUsed.join(', ')}`;
135
+ }
136
+ }
137
+
138
+ return errors;
139
+ };
@@ -4,6 +4,7 @@ import {
4
4
  operatorsToSelectOptions
5
5
  } from '../operators';
6
6
  import { generateDefaultCategoryName } from '../../utils';
7
+ import { isSystemCategory } from '../categoryUtils';
7
8
  import { FormData } from '../types';
8
9
  import { zustand } from '../../store/AppState';
9
10
 
@@ -301,15 +302,6 @@ export const casesToFormRules = (node: any) => {
301
302
  return rules;
302
303
  };
303
304
 
304
- /**
305
- * Helper to check if a category is a system category
306
- */
307
- function isSystemCategory(categoryName: string): boolean {
308
- return ['No Response', 'Other', 'All Responses', 'Timeout'].includes(
309
- categoryName
310
- );
311
- }
312
-
313
305
  /**
314
306
  * Creates a complete rules array configuration for forms.
315
307
  * This is the shared configuration used by both wait_for_response and split_by_expression.
@@ -340,13 +332,11 @@ export const createRulesArrayConfig = (
340
332
 
341
333
  // Default to the last rule's non-location operator that has at least one operand,
342
334
  // falling back to the first non-location operator option
343
- const lastWithOperand = [...items]
344
- .reverse()
345
- .find((item) => {
346
- const opValue = getOperatorValue(item.operator);
347
- const config = opValue ? getOperatorConfig(opValue) : undefined;
348
- return config && config.operands >= 1 && config.filter !== 'locations';
349
- });
335
+ const lastWithOperand = [...items].reverse().find((item) => {
336
+ const opValue = getOperatorValue(item.operator);
337
+ const config = opValue ? getOperatorConfig(opValue) : undefined;
338
+ return config && config.operands >= 1 && config.filter !== 'locations';
339
+ });
350
340
 
351
341
  const nonLocationOptions = currentOptions.filter((o: any) => {
352
342
  const config = getOperatorConfig(o.value);
@@ -4,8 +4,10 @@ import {
4
4
  AccordionLayoutConfig,
5
5
  CheckboxFieldConfig
6
6
  } from '../types';
7
- import { Node } from '../../store/flow-definition';
7
+ import { Node, Category, Exit, Case } from '../../store/flow-definition';
8
8
  import { getOperatorConfig } from '../operators';
9
+ import { generateUUID } from '../../utils';
10
+ import { categoryNamesEqual, findCategoryByName } from '../categoryUtils';
9
11
 
10
12
  /**
11
13
  * Shared result_name field configuration for router nodes.
@@ -124,10 +126,9 @@ export function categoriesToLocalizationFormData(
124
126
  rulesData[c.uuid] = {
125
127
  operatorName,
126
128
  originalArguments: [...c.arguments],
127
- localizedArguments:
128
- caseLocalization?.arguments
129
- ? [...caseLocalization.arguments]
130
- : c.arguments.map(() => '')
129
+ localizedArguments: caseLocalization?.arguments
130
+ ? [...caseLocalization.arguments]
131
+ : c.arguments.map(() => '')
131
132
  };
132
133
  });
133
134
 
@@ -173,7 +174,8 @@ export function localizationFormDataToCategories(
173
174
 
174
175
  // Save if any argument differs from original and is non-empty
175
176
  const hasLocalization = localized.some(
176
- (arg: string, i: number) => arg?.trim() && arg.trim() !== (original[i] || '')
177
+ (arg: string, i: number) =>
178
+ arg?.trim() && arg.trim() !== (original[i] || '')
177
179
  );
178
180
 
179
181
  if (hasLocalization) {
@@ -186,3 +188,108 @@ export function localizationFormDataToCategories(
186
188
 
187
189
  return localizationData;
188
190
  }
191
+
192
+ /**
193
+ * Describes a category to build for a router node. When `case` is provided,
194
+ * a matching switch-router case is also built and linked to the category.
195
+ */
196
+ export interface CategoryEntry {
197
+ name: string;
198
+ case?: {
199
+ type: string;
200
+ arguments: string[];
201
+ };
202
+ }
203
+
204
+ /**
205
+ * Builds categories, exits, and (optionally) cases for a router node,
206
+ * preserving UUIDs and exit destinations from existing data when possible.
207
+ * Categories/exits are matched by category name; cases by their first argument.
208
+ */
209
+ export function buildCategoriesExitsCases(
210
+ entries: CategoryEntry[],
211
+ existingCategories: Category[],
212
+ existingExits: Exit[],
213
+ existingCases: Case[] = []
214
+ ): { categories: Category[]; exits: Exit[]; cases: Case[] } {
215
+ const categories: Category[] = [];
216
+ const exits: Exit[] = [];
217
+ const cases: Case[] = [];
218
+
219
+ entries.forEach((entry) => {
220
+ const existingCategory = findCategoryByName(existingCategories, entry.name);
221
+ const existingExit = existingCategory
222
+ ? existingExits.find((exit) => exit.uuid === existingCategory.exit_uuid)
223
+ : null;
224
+
225
+ const exitUuid = existingExit?.uuid || generateUUID();
226
+ const categoryUuid = existingCategory?.uuid || generateUUID();
227
+
228
+ categories.push({
229
+ uuid: categoryUuid,
230
+ name: entry.name,
231
+ exit_uuid: exitUuid
232
+ });
233
+
234
+ exits.push({
235
+ uuid: exitUuid,
236
+ destination_uuid: existingExit?.destination_uuid || null
237
+ });
238
+
239
+ if (entry.case) {
240
+ const matchArg = entry.case.arguments[0];
241
+ const existingCase = existingCases.find(
242
+ (c) => c.arguments?.[0] === matchArg
243
+ );
244
+ cases.push({
245
+ uuid: existingCase?.uuid || generateUUID(),
246
+ type: entry.case.type,
247
+ arguments: entry.case.arguments,
248
+ category_uuid: categoryUuid
249
+ });
250
+ }
251
+ });
252
+
253
+ return { categories, exits, cases };
254
+ }
255
+
256
+ /**
257
+ * Appends a default "Other" category and its exit to the given arrays,
258
+ * preserving the UUID/destination of an existing "Other" unless the user
259
+ * selected an item also named "Other" (in which case the existing one was
260
+ * already consumed by buildCategoriesExitsCases). Returns the Other category
261
+ * UUID for use as `default_category_uuid`.
262
+ */
263
+ export function appendOtherCategory(
264
+ categories: Category[],
265
+ exits: Exit[],
266
+ existingCategories: Category[],
267
+ existingExits: Exit[],
268
+ userItemNames: string[]
269
+ ): string {
270
+ const userHasOther = userItemNames.some((name) =>
271
+ categoryNamesEqual(name, 'Other')
272
+ );
273
+ const existingOther = userHasOther
274
+ ? null
275
+ : findCategoryByName(existingCategories, 'Other');
276
+ const existingOtherExit = existingOther
277
+ ? existingExits.find((exit) => exit.uuid === existingOther.exit_uuid)
278
+ : null;
279
+
280
+ const otherExitUuid = existingOtherExit?.uuid || generateUUID();
281
+ const otherCategoryUuid = existingOther?.uuid || generateUUID();
282
+
283
+ categories.push({
284
+ uuid: otherCategoryUuid,
285
+ name: 'Other',
286
+ exit_uuid: otherExitUuid
287
+ });
288
+
289
+ exits.push({
290
+ uuid: otherExitUuid,
291
+ destination_uuid: existingOtherExit?.destination_uuid || null
292
+ });
293
+
294
+ return otherCategoryUuid;
295
+ }
@@ -7,13 +7,10 @@ import {
7
7
  } from '../types';
8
8
  import { TransferAirtime, Node } from '../../store/flow-definition';
9
9
  import { generateUUID, createSuccessFailureRouter } from '../../utils';
10
+ import { validateWith } from '../utils';
10
11
  import { html } from 'lit';
11
12
  import { CURRENCY_OPTIONS, CURRENCIES } from '../currencies';
12
- import {
13
- resultNameField,
14
- categoriesToLocalizationFormData,
15
- localizationFormDataToCategories
16
- } from './shared';
13
+ import { resultNameField } from './shared';
17
14
 
18
15
  export const split_by_airtime: NodeConfig = {
19
16
  type: 'split_by_airtime',
@@ -55,68 +52,51 @@ export const split_by_airtime: NodeConfig = {
55
52
  result_name: resultNameField
56
53
  },
57
54
  layout: ['amounts', 'result_name'],
58
- validate: (formData: FormData) => {
59
- const errors: { [key: string]: string } = {};
60
-
61
- // Validate that we have at least one amount
62
- if (formData.amounts && Array.isArray(formData.amounts)) {
63
- const validAmounts = formData.amounts.filter(
64
- (item: any) =>
65
- item?.currency && item?.amount && item.amount.trim() !== ''
66
- );
67
-
68
- if (validAmounts.length === 0) {
69
- errors.amounts = 'At least one currency and amount is required';
70
- return {
71
- valid: false,
72
- errors
73
- };
74
- }
75
-
76
- // Check for duplicate currencies
77
- const currencies = new Set();
78
- const duplicates: string[] = [];
55
+ validate: validateWith((formData, errors) => {
56
+ if (!formData.amounts || !Array.isArray(formData.amounts)) {
57
+ errors.amounts = 'At least one currency and amount is required';
58
+ return;
59
+ }
79
60
 
80
- validAmounts.forEach((item: any) => {
81
- // Extract currency code from selection
82
- const currencyCode =
83
- Array.isArray(item.currency) && item.currency.length > 0
84
- ? item.currency[0].value
85
- : typeof item.currency === 'string'
86
- ? item.currency
87
- : item.currency?.value;
61
+ const validAmounts = formData.amounts.filter(
62
+ (item: any) => item?.currency && item?.amount && item.amount.trim() !== ''
63
+ );
88
64
 
89
- if (currencies.has(currencyCode)) {
90
- duplicates.push(currencyCode);
91
- } else {
92
- currencies.add(currencyCode);
93
- }
94
- });
65
+ if (validAmounts.length === 0) {
66
+ errors.amounts = 'At least one currency and amount is required';
67
+ return;
68
+ }
95
69
 
96
- if (duplicates.length > 0) {
97
- errors.amounts = `Duplicate currencies found: ${duplicates.join(', ')}`;
70
+ const currencies = new Set();
71
+ const duplicates: string[] = [];
72
+
73
+ validAmounts.forEach((item: any) => {
74
+ const currencyCode =
75
+ Array.isArray(item.currency) && item.currency.length > 0
76
+ ? item.currency[0].value
77
+ : typeof item.currency === 'string'
78
+ ? item.currency
79
+ : item.currency?.value;
80
+
81
+ if (currencies.has(currencyCode)) {
82
+ duplicates.push(currencyCode);
83
+ } else {
84
+ currencies.add(currencyCode);
98
85
  }
86
+ });
99
87
 
100
- // Validate amounts are numeric
101
- for (const item of validAmounts) {
102
- const amount = item.amount.trim();
103
- if (isNaN(Number(amount)) || Number(amount) <= 0) {
104
- errors.amounts = 'All amounts must be valid positive numbers';
105
- return {
106
- valid: false,
107
- errors
108
- };
109
- }
110
- }
111
- } else {
112
- errors.amounts = 'At least one currency and amount is required';
88
+ if (duplicates.length > 0) {
89
+ errors.amounts = `Duplicate currencies found: ${duplicates.join(', ')}`;
113
90
  }
114
91
 
115
- return {
116
- valid: Object.keys(errors).length === 0,
117
- errors
118
- };
119
- },
92
+ for (const item of validAmounts) {
93
+ const amount = item.amount.trim();
94
+ if (isNaN(Number(amount)) || Number(amount) <= 0) {
95
+ errors.amounts = 'All amounts must be valid positive numbers';
96
+ return;
97
+ }
98
+ }
99
+ }),
120
100
  render: (node: Node) => {
121
101
  const transferAirtimeAction = node.actions?.find(
122
102
  (action) => action.type === 'transfer_airtime'
@@ -254,7 +234,5 @@ export const split_by_airtime: NodeConfig = {
254
234
 
255
235
  // Localization support for categories
256
236
  localizable: 'categories',
257
- nonTranslatableCategories: 'all',
258
- toLocalizationFormData: categoriesToLocalizationFormData,
259
- fromLocalizationFormData: localizationFormDataToCategories
237
+ nonTranslatableCategories: 'all'
260
238
  };