@nyaruka/temba-components 0.156.8 → 0.156.10

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 (59) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/temba-components.js +678 -631
  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 +64 -59
  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 +11 -4
  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 +11 -9
  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/store/Store.ts +6 -1
  59. package/src/utils.ts +21 -16
@@ -10,16 +10,14 @@ import {
10
10
  resultNameField,
11
11
  localizeRulesField,
12
12
  localizeCategoriesField,
13
- nodeOptionsAccordion,
14
- categoriesToLocalizationFormData,
15
- localizationFormDataToCategories
13
+ nodeOptionsAccordion
16
14
  } from './shared';
17
15
  import {
18
16
  createRulesArrayConfig,
19
17
  extractUserRules,
20
18
  casesToFormRules
21
19
  } from './shared-rules';
22
- import { SCHEMES } from '../utils';
20
+ import { SCHEMES, validateWith } from '../utils';
23
21
  import { html } from 'lit';
24
22
 
25
23
  // System contact properties that can be split on
@@ -89,18 +87,11 @@ export const split_by_contact_field: NodeConfig = {
89
87
  localizeCategories: localizeCategoriesField
90
88
  },
91
89
  layout: ['field', 'rules', nodeOptionsAccordion],
92
- validate: (formData: FormData) => {
93
- const errors: { [key: string]: string } = {};
94
-
90
+ validate: validateWith((formData, errors) => {
95
91
  if (!formData.field || formData.field.length === 0) {
96
92
  errors.field = 'A field is required';
97
93
  }
98
-
99
- return {
100
- valid: Object.keys(errors).length === 0,
101
- errors
102
- };
103
- },
94
+ }),
104
95
  toFormData: (node: Node, nodeUI?: any) => {
105
96
  // Get the field from the UI config operand (source of truth)
106
97
  const operand = nodeUI?.config?.operand || CONTACT_PROPERTIES.name;
@@ -206,7 +197,9 @@ export const split_by_contact_field: NodeConfig = {
206
197
  }
207
198
  };
208
199
  config.localizeRules = !!formData.localizeRules;
209
- config.localizeCategories = formData.result_name ? !!formData.localizeCategories : false;
200
+ config.localizeCategories = formData.result_name
201
+ ? !!formData.localizeCategories
202
+ : false;
210
203
  return config;
211
204
  },
212
205
  renderTitle: (node: Node, nodeUI?: any) => {
@@ -214,7 +207,5 @@ export const split_by_contact_field: NodeConfig = {
214
207
  },
215
208
 
216
209
  // Localization support for categories
217
- localizable: 'categories',
218
- toLocalizationFormData: categoriesToLocalizationFormData,
219
- fromLocalizationFormData: localizationFormDataToCategories
210
+ localizable: 'categories'
220
211
  };
@@ -10,15 +10,14 @@ import {
10
10
  resultNameField,
11
11
  localizeRulesField,
12
12
  localizeCategoriesField,
13
- nodeOptionsAccordion,
14
- categoriesToLocalizationFormData,
15
- localizationFormDataToCategories
13
+ nodeOptionsAccordion
16
14
  } from './shared';
17
15
  import {
18
16
  createRulesArrayConfig,
19
17
  extractUserRules,
20
18
  casesToFormRules
21
19
  } from './shared-rules';
20
+ import { validateWith } from '../utils';
22
21
 
23
22
  export const split_by_expression: NodeConfig = {
24
23
  type: 'split_by_expression',
@@ -44,19 +43,11 @@ export const split_by_expression: NodeConfig = {
44
43
  localizeCategories: localizeCategoriesField
45
44
  },
46
45
  layout: ['operand', 'rules', nodeOptionsAccordion],
47
- validate: (formData: FormData) => {
48
- const errors: { [key: string]: string } = {};
49
-
50
- // Validate operand is provided
46
+ validate: validateWith((formData, errors) => {
51
47
  if (!formData.operand || formData.operand.trim() === '') {
52
48
  errors.operand = 'Expression is required';
53
49
  }
54
-
55
- return {
56
- valid: Object.keys(errors).length === 0,
57
- errors
58
- };
59
- },
50
+ }),
60
51
  toFormData: (node: Node, nodeUI?: any) => {
61
52
  // Extract rules from router cases using shared function
62
53
  const rules = casesToFormRules(node);
@@ -73,7 +64,9 @@ export const split_by_expression: NodeConfig = {
73
64
  toUIConfig: (formData: FormData) => {
74
65
  const config: Record<string, any> = {};
75
66
  config.localizeRules = !!formData.localizeRules;
76
- config.localizeCategories = formData.result_name ? !!formData.localizeCategories : false;
67
+ config.localizeCategories = formData.result_name
68
+ ? !!formData.localizeCategories
69
+ : false;
77
70
  return config;
78
71
  },
79
72
  fromFormData: (formData: FormData, originalNode: Node): Node => {
@@ -116,7 +109,5 @@ export const split_by_expression: NodeConfig = {
116
109
  },
117
110
 
118
111
  // Localization support for categories
119
- localizable: 'categories',
120
- toLocalizationFormData: categoriesToLocalizationFormData,
121
- fromLocalizationFormData: localizationFormDataToCategories
112
+ localizable: 'categories'
122
113
  };
@@ -1,100 +1,14 @@
1
1
  import { SPLIT_GROUPS, FormData, NodeConfig, FlowTypes } from '../types';
2
- import { Node, Category, Exit, Case } from '../../store/flow-definition.d';
2
+ import { Node, Case } from '../../store/flow-definition.d';
3
3
  import { generateUUID } from '../../utils';
4
+ import { validateWith } from '../utils';
4
5
  import {
5
6
  resultNameField,
6
7
  nodeOptionsAccordionSimple,
7
- categoriesToLocalizationFormData,
8
- localizationFormDataToCategories
8
+ buildCategoriesExitsCases,
9
+ appendOtherCategory
9
10
  } from './shared';
10
11
 
11
- // Helper function to create a switch router with group cases
12
- const createGroupRouter = (
13
- userGroups: { uuid: string; name: string }[],
14
- existingCategories: Category[] = [],
15
- existingExits: Exit[] = [],
16
- existingCases: Case[] = [],
17
- resultName: string = ''
18
- ) => {
19
- const categories: Category[] = [];
20
- const exits: Exit[] = [];
21
- const cases: Case[] = [];
22
-
23
- // Create categories, exits, and cases for each selected group
24
- userGroups.forEach((group) => {
25
- // Try to find existing category by group name
26
- const existingCategory = existingCategories.find(
27
- (cat) => cat.name === group.name
28
- );
29
- const existingExit = existingCategory
30
- ? existingExits.find((exit) => exit.uuid === existingCategory.exit_uuid)
31
- : null;
32
- const existingCase = existingCases.find(
33
- (c) => c.arguments?.[0] === group.uuid
34
- );
35
-
36
- const exitUuid = existingExit?.uuid || generateUUID();
37
- const categoryUuid = existingCategory?.uuid || generateUUID();
38
- const caseUuid = existingCase?.uuid || generateUUID();
39
-
40
- categories.push({
41
- uuid: categoryUuid,
42
- name: group.name,
43
- exit_uuid: exitUuid
44
- });
45
-
46
- exits.push({
47
- uuid: exitUuid,
48
- destination_uuid: existingExit?.destination_uuid || null
49
- });
50
-
51
- cases.push({
52
- uuid: caseUuid,
53
- type: 'has_group',
54
- arguments: [group.uuid, group.name],
55
- category_uuid: categoryUuid
56
- });
57
- });
58
-
59
- // Add default "Other" category for contacts not in any selected group
60
- const existingOtherCategory = existingCategories.find(
61
- (cat) =>
62
- cat.name === 'Other' &&
63
- !userGroups.some((group) => group.name === cat.name)
64
- );
65
- const existingOtherExit = existingOtherCategory
66
- ? existingExits.find(
67
- (exit) => exit.uuid === existingOtherCategory.exit_uuid
68
- )
69
- : null;
70
-
71
- const otherExitUuid = existingOtherExit?.uuid || generateUUID();
72
- const otherCategoryUuid = existingOtherCategory?.uuid || generateUUID();
73
-
74
- categories.push({
75
- uuid: otherCategoryUuid,
76
- name: 'Other',
77
- exit_uuid: otherExitUuid
78
- });
79
-
80
- exits.push({
81
- uuid: otherExitUuid,
82
- destination_uuid: existingOtherExit?.destination_uuid || null
83
- });
84
-
85
- return {
86
- router: {
87
- type: 'switch' as const,
88
- cases: cases,
89
- categories: categories,
90
- default_category_uuid: otherCategoryUuid,
91
- operand: '@contact.groups',
92
- result_name: resultName
93
- },
94
- exits: exits
95
- };
96
- };
97
-
98
12
  export const split_by_groups: NodeConfig = {
99
13
  type: 'split_by_groups',
100
14
  name: 'Split by Group',
@@ -133,9 +47,7 @@ export const split_by_groups: NodeConfig = {
133
47
  result_name: resultNameField
134
48
  },
135
49
  layout: ['groups', nodeOptionsAccordionSimple],
136
- validate: (formData: FormData) => {
137
- const errors: { [key: string]: string } = {};
138
-
50
+ validate: validateWith((formData, errors) => {
139
51
  if (
140
52
  !formData.groups ||
141
53
  !Array.isArray(formData.groups) ||
@@ -143,12 +55,7 @@ export const split_by_groups: NodeConfig = {
143
55
  ) {
144
56
  errors.groups = 'At least one group is required';
145
57
  }
146
-
147
- return {
148
- valid: Object.keys(errors).length === 0,
149
- errors
150
- };
151
- },
58
+ }),
152
59
  toFormData: (node: Node) => {
153
60
  // Extract groups from the existing node structure
154
61
  const groups: { uuid: string; name: string }[] = [];
@@ -171,33 +78,50 @@ export const split_by_groups: NodeConfig = {
171
78
  };
172
79
  },
173
80
  fromFormData: (formData: FormData, originalNode: Node): Node => {
174
- // Get selected groups
175
81
  const selectedGroups = (formData.groups || [])
176
82
  .filter((group: any) => group?.uuid || group?.arbitrary)
177
83
  .map((group: any) => ({
178
- uuid: group.uuid || generateUUID(), // Generate UUID for arbitrary groups
84
+ uuid: group.uuid || generateUUID(),
179
85
  name: group.name
180
86
  }));
181
87
 
182
- // Create router and exits using existing data when possible
183
88
  const existingCategories = originalNode.router?.categories || [];
184
89
  const existingExits = originalNode.exits || [];
185
90
  const existingCases = originalNode.router?.cases || [];
186
91
 
187
- const { router, exits } = createGroupRouter(
188
- selectedGroups,
92
+ const { categories, exits, cases } = buildCategoriesExitsCases(
93
+ selectedGroups.map((group) => ({
94
+ name: group.name,
95
+ case: {
96
+ type: 'has_group',
97
+ arguments: [group.uuid, group.name]
98
+ }
99
+ })),
100
+ existingCategories,
101
+ existingExits,
102
+ existingCases
103
+ );
104
+
105
+ const defaultCategoryUuid = appendOtherCategory(
106
+ categories,
107
+ exits,
189
108
  existingCategories,
190
109
  existingExits,
191
- existingCases,
192
- formData.result_name || ''
110
+ selectedGroups.map((g) => g.name)
193
111
  );
194
112
 
195
- // Return the complete node
196
113
  return {
197
114
  uuid: originalNode.uuid,
198
115
  actions: originalNode.actions || [],
199
- router: router,
200
- exits: exits
116
+ router: {
117
+ type: 'switch',
118
+ cases,
119
+ categories,
120
+ default_category_uuid: defaultCategoryUuid,
121
+ operand: '@contact.groups',
122
+ result_name: formData.result_name || ''
123
+ },
124
+ exits
201
125
  };
202
126
  },
203
127
  router: {
@@ -206,7 +130,5 @@ export const split_by_groups: NodeConfig = {
206
130
  },
207
131
 
208
132
  // Localization support for categories
209
- localizable: 'categories',
210
- toLocalizationFormData: categoriesToLocalizationFormData,
211
- fromLocalizationFormData: localizationFormDataToCategories
133
+ localizable: 'categories'
212
134
  };
@@ -2,10 +2,6 @@ import { ACTION_GROUPS, FormData, NodeConfig, FlowTypes } from '../types';
2
2
  import { CallLLM, Node } from '../../store/flow-definition';
3
3
  import { generateUUID, createSuccessFailureRouter } from '../../utils';
4
4
  import { html } from 'lit';
5
- import {
6
- categoriesToLocalizationFormData,
7
- localizationFormDataToCategories
8
- } from './shared';
9
5
  import {
10
6
  renderClamped,
11
7
  renderHighlightedText,
@@ -141,7 +137,5 @@ export const split_by_llm: NodeConfig = {
141
137
 
142
138
  // Localization support for categories
143
139
  localizable: 'categories',
144
- nonTranslatableCategories: 'all',
145
- toLocalizationFormData: categoriesToLocalizationFormData,
146
- fromLocalizationFormData: localizationFormDataToCategories
140
+ nonTranslatableCategories: 'all'
147
141
  };
@@ -2,10 +2,7 @@ import { FormData, NodeConfig, ACTION_GROUPS, Features } from '../types';
2
2
  import { CallLLM, Node } from '../../store/flow-definition';
3
3
  import { generateUUID, createMultiCategoryRouter } from '../../utils';
4
4
  import { html } from 'lit';
5
- import {
6
- categoriesToLocalizationFormData,
7
- localizationFormDataToCategories
8
- } from './shared';
5
+ import { validateWith } from '../utils';
9
6
 
10
7
  export const split_by_llm_categorize: NodeConfig = {
11
8
  type: 'split_by_llm_categorize',
@@ -53,48 +50,37 @@ export const split_by_llm_categorize: NodeConfig = {
53
50
  }
54
51
  },
55
52
  layout: ['llm', 'input', 'categories'],
56
- validate: (formData: FormData) => {
57
- const errors: { [key: string]: string } = {};
53
+ validate: validateWith((formData, errors) => {
54
+ if (!formData.categories || !Array.isArray(formData.categories)) return;
58
55
 
59
- // Check for duplicate category names
60
- if (formData.categories && Array.isArray(formData.categories)) {
61
- const categories = formData.categories.filter(
62
- (item: any) => item?.name && item.name.trim() !== ''
63
- );
64
-
65
- // Find all categories that have duplicates (case-insensitive)
66
- const duplicateCategories = [];
67
- const lowerCaseMap = new Map();
56
+ const categories = formData.categories.filter(
57
+ (item: any) => item?.name && item.name.trim() !== ''
58
+ );
68
59
 
69
- // First pass: map lowercase names to all original cases
70
- categories.forEach((category) => {
71
- const lowerName = category.name.trim().toLowerCase();
72
- if (!lowerCaseMap.has(lowerName)) {
73
- lowerCaseMap.set(lowerName, []);
74
- }
75
- lowerCaseMap.get(lowerName).push(category.name.trim());
76
- });
60
+ const duplicateCategories: string[] = [];
61
+ const lowerCaseMap = new Map<string, string[]>();
77
62
 
78
- // Second pass: collect all names that appear more than once
79
- lowerCaseMap.forEach((originalNames) => {
80
- if (originalNames.length > 1) {
81
- duplicateCategories.push(...originalNames);
82
- }
83
- });
63
+ categories.forEach((category) => {
64
+ const lowerName = category.name.trim().toLowerCase();
65
+ if (!lowerCaseMap.has(lowerName)) {
66
+ lowerCaseMap.set(lowerName, []);
67
+ }
68
+ lowerCaseMap.get(lowerName).push(category.name.trim());
69
+ });
84
70
 
85
- if (duplicateCategories.length > 0) {
86
- const uniqueDuplicates = [...new Set(duplicateCategories)];
87
- errors.categories = `Duplicate category names found: ${uniqueDuplicates.join(
88
- ', '
89
- )}`;
71
+ lowerCaseMap.forEach((originalNames) => {
72
+ if (originalNames.length > 1) {
73
+ duplicateCategories.push(...originalNames);
90
74
  }
91
- }
75
+ });
92
76
 
93
- return {
94
- valid: Object.keys(errors).length === 0,
95
- errors
96
- };
97
- },
77
+ if (duplicateCategories.length > 0) {
78
+ const uniqueDuplicates = [...new Set(duplicateCategories)];
79
+ errors.categories = `Duplicate category names found: ${uniqueDuplicates.join(
80
+ ', '
81
+ )}`;
82
+ }
83
+ }),
98
84
  render: (node: Node) => {
99
85
  const callLlmAction = node.actions?.find(
100
86
  (action) => action.type === 'call_llm'
@@ -181,7 +167,5 @@ export const split_by_llm_categorize: NodeConfig = {
181
167
 
182
168
  // Localization support for categories
183
169
  localizable: 'categories',
184
- nonTranslatableCategories: ['Failure'],
185
- toLocalizationFormData: categoriesToLocalizationFormData,
186
- fromLocalizationFormData: localizationFormDataToCategories
170
+ nonTranslatableCategories: ['Failure']
187
171
  };
@@ -1,53 +1,7 @@
1
1
  import { SPLIT_GROUPS, FormData, NodeConfig, FlowTypes } from '../types';
2
- import { Node, Category, Exit } from '../../store/flow-definition.d';
3
- import { generateUUID } from '../../utils';
4
- import {
5
- categoriesToLocalizationFormData,
6
- localizationFormDataToCategories
7
- } from './shared';
8
-
9
- // Helper function to create a random router with categories
10
- const createRandomRouter = (
11
- userCategories: string[],
12
- existingCategories: Category[] = [],
13
- existingExits: Exit[] = []
14
- ) => {
15
- const categories: Category[] = [];
16
- const exits: Exit[] = [];
17
-
18
- // Create categories and exits for user-defined buckets
19
- userCategories.forEach((categoryName) => {
20
- // Try to find existing category by name
21
- const existingCategory = existingCategories.find(
22
- (cat) => cat.name === categoryName
23
- );
24
- const existingExit = existingCategory
25
- ? existingExits.find((exit) => exit.uuid === existingCategory.exit_uuid)
26
- : null;
27
-
28
- const exitUuid = existingExit?.uuid || generateUUID();
29
- const categoryUuid = existingCategory?.uuid || generateUUID();
30
-
31
- categories.push({
32
- uuid: categoryUuid,
33
- name: categoryName,
34
- exit_uuid: exitUuid
35
- });
36
-
37
- exits.push({
38
- uuid: exitUuid,
39
- destination_uuid: existingExit?.destination_uuid || null
40
- });
41
- });
42
-
43
- return {
44
- router: {
45
- type: 'random' as const,
46
- categories: categories
47
- },
48
- exits: exits
49
- };
50
- };
2
+ import { Node } from '../../store/flow-definition.d';
3
+ import { validateWith } from '../utils';
4
+ import { buildCategoriesExitsCases } from './shared';
51
5
 
52
6
  export const split_by_random: NodeConfig = {
53
7
  type: 'split_by_random',
@@ -76,53 +30,41 @@ export const split_by_random: NodeConfig = {
76
30
  }
77
31
  },
78
32
  layout: ['categories'],
79
- validate: (formData: FormData) => {
80
- const errors: { [key: string]: string } = {};
81
-
82
- // Check for duplicate category names
83
- if (formData.categories && Array.isArray(formData.categories)) {
84
- const categories = formData.categories.filter(
85
- (item: any) => item?.name && item.name.trim() !== ''
86
- );
33
+ validate: validateWith((formData, errors) => {
34
+ if (!formData.categories || !Array.isArray(formData.categories)) return;
87
35
 
88
- // Ensure minimum buckets
89
- if (categories.length < 2) {
90
- errors.categories = 'At least 2 buckets are required for random split';
91
- }
36
+ const categories = formData.categories.filter(
37
+ (item: any) => item?.name && item.name.trim() !== ''
38
+ );
92
39
 
93
- // Find all categories that have duplicates (case-insensitive)
94
- const duplicateCategories = [];
95
- const lowerCaseMap = new Map();
40
+ if (categories.length < 2) {
41
+ errors.categories = 'At least 2 buckets are required for random split';
42
+ }
96
43
 
97
- // First pass: map lowercase names to all original cases
98
- categories.forEach((category) => {
99
- const lowerName = category.name.trim().toLowerCase();
100
- if (!lowerCaseMap.has(lowerName)) {
101
- lowerCaseMap.set(lowerName, []);
102
- }
103
- lowerCaseMap.get(lowerName).push(category.name.trim());
104
- });
44
+ const duplicateCategories: string[] = [];
45
+ const lowerCaseMap = new Map<string, string[]>();
105
46
 
106
- // Second pass: collect all names that appear more than once
107
- lowerCaseMap.forEach((originalNames) => {
108
- if (originalNames.length > 1) {
109
- duplicateCategories.push(...originalNames);
110
- }
111
- });
47
+ categories.forEach((category) => {
48
+ const lowerName = category.name.trim().toLowerCase();
49
+ if (!lowerCaseMap.has(lowerName)) {
50
+ lowerCaseMap.set(lowerName, []);
51
+ }
52
+ lowerCaseMap.get(lowerName).push(category.name.trim());
53
+ });
112
54
 
113
- if (duplicateCategories.length > 0) {
114
- const uniqueDuplicates = [...new Set(duplicateCategories)];
115
- errors.categories = `Duplicate bucket names found: ${uniqueDuplicates.join(
116
- ', '
117
- )}`;
55
+ lowerCaseMap.forEach((originalNames) => {
56
+ if (originalNames.length > 1) {
57
+ duplicateCategories.push(...originalNames);
118
58
  }
119
- }
59
+ });
120
60
 
121
- return {
122
- valid: Object.keys(errors).length === 0,
123
- errors
124
- };
125
- },
61
+ if (duplicateCategories.length > 0) {
62
+ const uniqueDuplicates = [...new Set(duplicateCategories)];
63
+ errors.categories = `Duplicate bucket names found: ${uniqueDuplicates.join(
64
+ ', '
65
+ )}`;
66
+ }
67
+ }),
126
68
  toFormData: (node: Node) => {
127
69
  // Extract categories from the existing node structure
128
70
  const categories =
@@ -134,27 +76,27 @@ export const split_by_random: NodeConfig = {
134
76
  };
135
77
  },
136
78
  fromFormData: (formData: FormData, originalNode: Node): Node => {
137
- // Get user categories
138
79
  const userCategories = (formData.categories || [])
139
80
  .filter((item: any) => item?.name?.trim())
140
81
  .map((item: any) => item.name.trim());
141
82
 
142
- // Create router and exits using existing data when possible
143
83
  const existingCategories = originalNode.router?.categories || [];
144
84
  const existingExits = originalNode.exits || [];
145
85
 
146
- const { router, exits } = createRandomRouter(
147
- userCategories,
86
+ const { categories, exits } = buildCategoriesExitsCases(
87
+ userCategories.map((name) => ({ name })),
148
88
  existingCategories,
149
89
  existingExits
150
90
  );
151
91
 
152
- // Return the complete node
153
92
  return {
154
93
  uuid: originalNode.uuid,
155
94
  actions: originalNode.actions || [],
156
- router: router,
157
- exits: exits
95
+ router: {
96
+ type: 'random',
97
+ categories
98
+ },
99
+ exits
158
100
  };
159
101
  },
160
102
  router: {
@@ -162,7 +104,5 @@ export const split_by_random: NodeConfig = {
162
104
  },
163
105
 
164
106
  // Localization support for categories
165
- localizable: 'categories',
166
- toLocalizationFormData: categoriesToLocalizationFormData,
167
- fromLocalizationFormData: localizationFormDataToCategories
107
+ localizable: 'categories'
168
108
  };
@@ -2,12 +2,8 @@ import { ACTION_GROUPS, FormData, NodeConfig, FlowTypes } from '../types';
2
2
  import { CallResthook, Node } from '../../store/flow-definition';
3
3
  import { generateUUID, createSuccessFailureRouter } from '../../utils';
4
4
  import { html } from 'lit';
5
- import {
6
- resultNameField,
7
- categoriesToLocalizationFormData,
8
- localizationFormDataToCategories
9
- } from './shared';
10
- import { renderClamped } from '../utils';
5
+ import { resultNameField } from './shared';
6
+ import { renderClamped, validateWith } from '../utils';
11
7
 
12
8
  export const split_by_resthook: NodeConfig = {
13
9
  type: 'split_by_resthook',
@@ -31,19 +27,11 @@ export const split_by_resthook: NodeConfig = {
31
27
  result_name: resultNameField
32
28
  },
33
29
  layout: ['resthook', 'result_name'],
34
- validate: (formData: FormData) => {
35
- const errors: { [key: string]: string } = {};
36
-
37
- // validate resthook is provided
30
+ validate: validateWith((formData, errors) => {
38
31
  if (!formData.resthook || formData.resthook.length === 0) {
39
32
  errors.resthook = 'A resthook is required';
40
33
  }
41
-
42
- return {
43
- valid: Object.keys(errors).length === 0,
44
- errors
45
- };
46
- },
34
+ }),
47
35
  render: (node: Node) => {
48
36
  const callResthookAction = node.actions?.find(
49
37
  (action) => action.type === 'call_resthook'
@@ -130,7 +118,5 @@ export const split_by_resthook: NodeConfig = {
130
118
 
131
119
  // Localization support for categories
132
120
  localizable: 'categories',
133
- nonTranslatableCategories: 'all',
134
- toLocalizationFormData: categoriesToLocalizationFormData,
135
- fromLocalizationFormData: localizationFormDataToCategories
121
+ nonTranslatableCategories: 'all'
136
122
  };