@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,9 +10,7 @@ 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,
@@ -20,6 +18,7 @@ import {
20
18
  casesToFormRules
21
19
  } from './shared-rules';
22
20
  import { getStore } from '../../store/Store';
21
+ import { validateWith } from '../utils';
23
22
 
24
23
  // delimit index options (first through 20th)
25
24
  const DELIMIT_INDEX_OPTIONS = [
@@ -125,19 +124,11 @@ export const split_by_run_result: NodeConfig = {
125
124
  'rules',
126
125
  nodeOptionsAccordion
127
126
  ],
128
- validate: (formData: FormData) => {
129
- const errors: { [key: string]: string } = {};
130
-
131
- // Validate result is provided
127
+ validate: validateWith((formData, errors) => {
132
128
  if (!formData.result || formData.result.length === 0) {
133
129
  errors.result = 'A flow result is required';
134
130
  }
135
-
136
- return {
137
- valid: Object.keys(errors).length === 0,
138
- errors
139
- };
140
- },
131
+ }),
141
132
  toFormData: (node: Node, nodeUI?: any) => {
142
133
  // Get the result from the UI config operand (source of truth)
143
134
  // Normalize: toUIConfig saves { id, name, type } but the Select component
@@ -274,12 +265,12 @@ export const split_by_run_result: NodeConfig = {
274
265
  : 'split_by_run_result';
275
266
 
276
267
  config.localizeRules = !!formData.localizeRules;
277
- config.localizeCategories = formData.result_name ? !!formData.localizeCategories : false;
268
+ config.localizeCategories = formData.result_name
269
+ ? !!formData.localizeCategories
270
+ : false;
278
271
  return config;
279
272
  },
280
273
 
281
274
  // Localization support for categories
282
- localizable: 'categories',
283
- toLocalizationFormData: categoriesToLocalizationFormData,
284
- fromLocalizationFormData: localizationFormDataToCategories
275
+ localizable: 'categories'
285
276
  };
@@ -1,15 +1,13 @@
1
1
  import { SPLIT_GROUPS, FormData, NodeConfig, FlowTypes } from '../types';
2
- import { Node, Category, Exit, Case } from '../../store/flow-definition.d';
3
- import { generateUUID } from '../../utils';
4
- import { SCHEMES } from '../utils';
2
+ import { Node, Case } from '../../store/flow-definition.d';
3
+ import { SCHEMES, validateWith } from '../utils';
5
4
  import {
6
5
  resultNameField,
7
6
  nodeOptionsAccordionSimple,
8
- categoriesToLocalizationFormData,
9
- localizationFormDataToCategories
7
+ buildCategoriesExitsCases,
8
+ appendOtherCategory
10
9
  } from './shared';
11
10
 
12
- // Helper function to get scheme options for the select dropdown
13
11
  const getSchemeOptions = () => {
14
12
  return SCHEMES.map((scheme) => ({
15
13
  value: scheme.scheme,
@@ -17,95 +15,8 @@ const getSchemeOptions = () => {
17
15
  }));
18
16
  };
19
17
 
20
- // Helper function to create a switch router with scheme cases
21
- const createSchemeRouter = (
22
- selectedSchemes: string[],
23
- existingCategories: Category[] = [],
24
- existingExits: Exit[] = [],
25
- existingCases: Case[] = [],
26
- resultName: string = ''
27
- ) => {
28
- const categories: Category[] = [];
29
- const exits: Exit[] = [];
30
- const cases: Case[] = [];
31
-
32
- // Create categories, exits, and cases for each selected scheme
33
- selectedSchemes.forEach((scheme) => {
34
- const schemeObj = SCHEMES.find((s) => s.scheme === scheme);
35
- const schemeName = schemeObj?.name || scheme;
36
-
37
- // Try to find existing category by scheme name
38
- const existingCategory = existingCategories.find(
39
- (cat) => cat.name === schemeName
40
- );
41
- const existingExit = existingCategory
42
- ? existingExits.find((exit) => exit.uuid === existingCategory.exit_uuid)
43
- : null;
44
- const existingCase = existingCases.find((c) => c.arguments?.[0] === scheme);
45
-
46
- const exitUuid = existingExit?.uuid || generateUUID();
47
- const categoryUuid = existingCategory?.uuid || generateUUID();
48
- const caseUuid = existingCase?.uuid || generateUUID();
49
-
50
- categories.push({
51
- uuid: categoryUuid,
52
- name: schemeName,
53
- exit_uuid: exitUuid
54
- });
55
-
56
- exits.push({
57
- uuid: exitUuid,
58
- destination_uuid: existingExit?.destination_uuid || null
59
- });
60
-
61
- cases.push({
62
- uuid: caseUuid,
63
- type: 'has_only_phrase',
64
- arguments: [scheme],
65
- category_uuid: categoryUuid
66
- });
67
- });
68
-
69
- // Add default "Other" category for schemes not in the selected list
70
- const existingOtherCategory = existingCategories.find((cat) => {
71
- const matchesSelected = selectedSchemes.some((scheme) => {
72
- const schemeObj = SCHEMES.find((s) => s.scheme === scheme);
73
- return schemeObj?.name === cat.name;
74
- });
75
- return cat.name === 'Other' && !matchesSelected;
76
- });
77
- const existingOtherExit = existingOtherCategory
78
- ? existingExits.find(
79
- (exit) => exit.uuid === existingOtherCategory.exit_uuid
80
- )
81
- : null;
82
-
83
- const otherExitUuid = existingOtherExit?.uuid || generateUUID();
84
- const otherCategoryUuid = existingOtherCategory?.uuid || generateUUID();
85
-
86
- categories.push({
87
- uuid: otherCategoryUuid,
88
- name: 'Other',
89
- exit_uuid: otherExitUuid
90
- });
91
-
92
- exits.push({
93
- uuid: otherExitUuid,
94
- destination_uuid: existingOtherExit?.destination_uuid || null
95
- });
96
-
97
- return {
98
- router: {
99
- type: 'switch' as const,
100
- cases: cases,
101
- categories: categories,
102
- default_category_uuid: otherCategoryUuid,
103
- operand: '@(urn_parts(contact.urn).scheme)',
104
- result_name: resultName
105
- },
106
- exits: exits
107
- };
108
- };
18
+ const getSchemeName = (scheme: string) =>
19
+ SCHEMES.find((s) => s.scheme === scheme)?.name || scheme;
109
20
 
110
21
  export const split_by_scheme: NodeConfig = {
111
22
  type: 'split_by_scheme',
@@ -127,9 +38,7 @@ export const split_by_scheme: NodeConfig = {
127
38
  result_name: resultNameField
128
39
  },
129
40
  layout: ['schemes', nodeOptionsAccordionSimple],
130
- validate: (formData: FormData) => {
131
- const errors: { [key: string]: string } = {};
132
-
41
+ validate: validateWith((formData, errors) => {
133
42
  if (
134
43
  !formData.schemes ||
135
44
  !Array.isArray(formData.schemes) ||
@@ -137,14 +46,8 @@ export const split_by_scheme: NodeConfig = {
137
46
  ) {
138
47
  errors.schemes = 'At least one channel type is required';
139
48
  }
140
-
141
- return {
142
- valid: Object.keys(errors).length === 0,
143
- errors
144
- };
145
- },
49
+ }),
146
50
  toFormData: (node: Node) => {
147
- // Extract schemes from the existing node structure
148
51
  const schemes: string[] = [];
149
52
 
150
53
  if (node.router?.cases) {
@@ -157,43 +60,57 @@ export const split_by_scheme: NodeConfig = {
157
60
 
158
61
  return {
159
62
  uuid: node.uuid,
160
- schemes: schemes.map((scheme) => {
161
- const schemeObj = SCHEMES.find((s) => s.scheme === scheme);
162
- return {
163
- value: scheme,
164
- name: schemeObj?.name || scheme
165
- };
166
- }),
63
+ schemes: schemes.map((scheme) => ({
64
+ value: scheme,
65
+ name: getSchemeName(scheme)
66
+ })),
167
67
  result_name: node.router?.result_name || ''
168
68
  };
169
69
  },
170
70
  fromFormData: (formData: FormData, originalNode: Node): Node => {
171
- // Get selected schemes (handle both array of objects and array of strings)
172
71
  const selectedSchemes = (formData.schemes || [])
173
72
  .filter((scheme: any) => scheme)
174
73
  .map((scheme: any) =>
175
74
  typeof scheme === 'string' ? scheme : scheme.value
176
75
  );
177
76
 
178
- // Create router and exits using existing data when possible
179
77
  const existingCategories = originalNode.router?.categories || [];
180
78
  const existingExits = originalNode.exits || [];
181
79
  const existingCases = originalNode.router?.cases || [];
182
80
 
183
- const { router, exits } = createSchemeRouter(
184
- selectedSchemes,
81
+ const { categories, exits, cases } = buildCategoriesExitsCases(
82
+ selectedSchemes.map((scheme) => ({
83
+ name: getSchemeName(scheme),
84
+ case: {
85
+ type: 'has_only_phrase',
86
+ arguments: [scheme]
87
+ }
88
+ })),
89
+ existingCategories,
90
+ existingExits,
91
+ existingCases
92
+ );
93
+
94
+ const defaultCategoryUuid = appendOtherCategory(
95
+ categories,
96
+ exits,
185
97
  existingCategories,
186
98
  existingExits,
187
- existingCases,
188
- formData.result_name || ''
99
+ selectedSchemes.map(getSchemeName)
189
100
  );
190
101
 
191
- // Return the complete node
192
102
  return {
193
103
  uuid: originalNode.uuid,
194
104
  actions: originalNode.actions || [],
195
- router: router,
196
- exits: exits
105
+ router: {
106
+ type: 'switch',
107
+ cases,
108
+ categories,
109
+ default_category_uuid: defaultCategoryUuid,
110
+ operand: '@(urn_parts(contact.urn).scheme)',
111
+ result_name: formData.result_name || ''
112
+ },
113
+ exits
197
114
  };
198
115
  },
199
116
  router: {
@@ -202,7 +119,5 @@ export const split_by_scheme: NodeConfig = {
202
119
  },
203
120
 
204
121
  // Localization support for categories
205
- localizable: 'categories',
206
- toLocalizationFormData: categoriesToLocalizationFormData,
207
- fromLocalizationFormData: localizationFormDataToCategories
122
+ localizable: 'categories'
208
123
  };
@@ -4,10 +4,6 @@ import { generateUUID } from '../../utils';
4
4
  import { html } from 'lit';
5
5
  import { renderFlowLinks } from '../utils';
6
6
  import { shouldExcludeFlow } from '../flow-utils';
7
- import {
8
- categoriesToLocalizationFormData,
9
- localizationFormDataToCategories
10
- } from './shared';
11
7
 
12
8
  export const split_by_subflow: NodeConfig = {
13
9
  type: 'split_by_subflow',
@@ -293,7 +289,5 @@ export const split_by_subflow: NodeConfig = {
293
289
 
294
290
  // Localization support for categories
295
291
  localizable: 'categories',
296
- nonTranslatableCategories: 'all',
297
- toLocalizationFormData: categoriesToLocalizationFormData,
298
- fromLocalizationFormData: localizationFormDataToCategories
292
+ nonTranslatableCategories: 'all'
299
293
  };
@@ -2,10 +2,6 @@ import { ACTION_GROUPS, FormData, NodeConfig, FlowTypes } from '../types';
2
2
  import { Node, OpenTicket } 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
 
10
6
  export const split_by_ticket: NodeConfig = {
11
7
  type: 'split_by_ticket',
@@ -147,7 +143,5 @@ export const split_by_ticket: NodeConfig = {
147
143
 
148
144
  // Localization support for categories
149
145
  localizable: 'categories',
150
- nonTranslatableCategories: 'all',
151
- toLocalizationFormData: categoriesToLocalizationFormData,
152
- fromLocalizationFormData: localizationFormDataToCategories
146
+ nonTranslatableCategories: 'all'
153
147
  };
@@ -2,11 +2,7 @@ import { ACTION_GROUPS, FormData, NodeConfig, FlowTypes } from '../types';
2
2
  import { CallWebhook, 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';
5
+ import { resultNameField } from './shared';
10
6
  import { renderClamped, renderHighlightedText } from '../utils';
11
7
 
12
8
  // Default headers as {key, value} arrays (format used by key-value editor)
@@ -267,7 +263,5 @@ export const split_by_webhook: NodeConfig = {
267
263
 
268
264
  // Localization support for categories
269
265
  localizable: 'categories',
270
- nonTranslatableCategories: 'all',
271
- toLocalizationFormData: categoriesToLocalizationFormData,
272
- fromLocalizationFormData: localizationFormDataToCategories
266
+ nonTranslatableCategories: 'all'
273
267
  };
@@ -1,10 +1,6 @@
1
1
  import { SPLIT_GROUPS, FormData, NodeConfig, FlowTypes } from '../types';
2
2
  import { Node, Category, Exit } from '../../store/flow-definition';
3
3
  import { generateUUID } from '../../utils';
4
- import {
5
- categoriesToLocalizationFormData,
6
- localizationFormDataToCategories
7
- } from './shared';
8
4
 
9
5
  export const wait_for_audio: NodeConfig = {
10
6
  type: 'wait_for_audio',
@@ -84,7 +80,5 @@ export const wait_for_audio: NodeConfig = {
84
80
  };
85
81
  },
86
82
  localizable: 'categories',
87
- nonTranslatableCategories: 'all',
88
- toLocalizationFormData: categoriesToLocalizationFormData,
89
- fromLocalizationFormData: localizationFormDataToCategories
83
+ nonTranslatableCategories: 'all'
90
84
  };
@@ -1,11 +1,7 @@
1
1
  import { SPLIT_GROUPS, FormData, NodeConfig, FlowTypes } from '../types';
2
2
  import { Node, Category, Exit } from '../../store/flow-definition';
3
3
  import { generateUUID } from '../../utils';
4
- import {
5
- resultNameField,
6
- categoriesToLocalizationFormData,
7
- localizationFormDataToCategories
8
- } from './shared';
4
+ import { resultNameField } from './shared';
9
5
 
10
6
  const DIAL_CATEGORIES = ['Answered', 'No Answer', 'Busy', 'Failed'];
11
7
  const DIAL_CASES = [
@@ -171,7 +167,5 @@ export const wait_for_dial: NodeConfig = {
171
167
  };
172
168
  },
173
169
  localizable: 'categories',
174
- nonTranslatableCategories: 'all',
175
- toLocalizationFormData: categoriesToLocalizationFormData,
176
- fromLocalizationFormData: localizationFormDataToCategories
170
+ nonTranslatableCategories: 'all'
177
171
  };
@@ -10,9 +10,7 @@ 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,
@@ -62,7 +60,9 @@ export const wait_for_digits: NodeConfig = {
62
60
  toUIConfig: (formData: FormData) => {
63
61
  const config: Record<string, any> = {};
64
62
  config.localizeRules = !!formData.localizeRules;
65
- config.localizeCategories = formData.result_name ? !!formData.localizeCategories : false;
63
+ config.localizeCategories = formData.result_name
64
+ ? !!formData.localizeCategories
65
+ : false;
66
66
  return config;
67
67
  },
68
68
  fromFormData: (formData: FormData, originalNode: Node): Node => {
@@ -100,7 +100,5 @@ export const wait_for_digits: NodeConfig = {
100
100
  exits
101
101
  };
102
102
  },
103
- localizable: 'categories',
104
- toLocalizationFormData: categoriesToLocalizationFormData,
105
- fromLocalizationFormData: localizationFormDataToCategories
103
+ localizable: 'categories'
106
104
  };
@@ -5,9 +5,7 @@ import {
5
5
  resultNameField,
6
6
  localizeRulesField,
7
7
  localizeCategoriesField,
8
- nodeOptionsAccordion,
9
- categoriesToLocalizationFormData,
10
- localizationFormDataToCategories
8
+ nodeOptionsAccordion
11
9
  } from './shared';
12
10
 
13
11
  // Menu digits in display order: 1-9 then 0
@@ -105,7 +103,9 @@ export const wait_for_menu: NodeConfig = {
105
103
  toUIConfig: (formData: FormData) => {
106
104
  const config: Record<string, any> = {};
107
105
  config.localizeRules = !!formData.localizeRules;
108
- config.localizeCategories = formData.result_name ? !!formData.localizeCategories : false;
106
+ config.localizeCategories = formData.result_name
107
+ ? !!formData.localizeCategories
108
+ : false;
109
109
  return config;
110
110
  },
111
111
  fromFormData: (formData: FormData, originalNode: Node): Node => {
@@ -221,7 +221,5 @@ export const wait_for_menu: NodeConfig = {
221
221
  exits
222
222
  };
223
223
  },
224
- localizable: 'categories',
225
- toLocalizationFormData: categoriesToLocalizationFormData,
226
- fromLocalizationFormData: localizationFormDataToCategories
224
+ localizable: 'categories'
227
225
  };
@@ -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
  const TIMEOUT_OPTIONS = [
24
23
  { value: '60', name: '1 minute' },
@@ -127,17 +126,10 @@ export const wait_for_response: NodeConfig = {
127
126
  gap: '0.5rem'
128
127
  }
129
128
  ],
130
- validate: (_formData: FormData) => {
131
- const errors: { [key: string]: string } = {};
132
-
133
- // No validation needed - allow multiple rules to use same category name
134
- // Rules with the same category name will be merged to use the same exit
135
-
136
- return {
137
- valid: Object.keys(errors).length === 0,
138
- errors
139
- };
140
- },
129
+ validate: validateWith(() => {
130
+ // No validation needed - allow multiple rules to use same category name.
131
+ // Rules with the same category name will be merged to use the same exit.
132
+ }),
141
133
  toFormData: (node: Node, nodeUI?: any) => {
142
134
  // Extract rules from router cases using shared function
143
135
  const rules = casesToFormRules(node);
@@ -165,7 +157,9 @@ export const wait_for_response: NodeConfig = {
165
157
  toUIConfig: (formData: FormData) => {
166
158
  const config: Record<string, any> = {};
167
159
  config.localizeRules = !!formData.localizeRules;
168
- config.localizeCategories = formData.result_name ? !!formData.localizeCategories : false;
160
+ config.localizeCategories = formData.result_name
161
+ ? !!formData.localizeCategories
162
+ : false;
169
163
  return config;
170
164
  },
171
165
  fromFormData: (formData: FormData, originalNode: Node): Node => {
@@ -413,7 +407,5 @@ export const wait_for_response: NodeConfig = {
413
407
 
414
408
  // Localization support for categories
415
409
  localizable: 'categories',
416
- nonTranslatableCategories: ['Timeout'],
417
- toLocalizationFormData: categoriesToLocalizationFormData,
418
- fromLocalizationFormData: localizationFormDataToCategories
410
+ nonTranslatableCategories: ['Timeout']
419
411
  };
package/src/flow/types.ts CHANGED
@@ -17,6 +17,33 @@ export const FlowTypes = {
17
17
 
18
18
  export type FlowType = (typeof FlowTypes)[keyof typeof FlowTypes];
19
19
 
20
+ /**
21
+ * Shortcut entry shown in the canvas context menu. The `type` is the
22
+ * action or node type to open the editor with when selected.
23
+ */
24
+ export interface ContextMenuShortcut {
25
+ type: string;
26
+ name: string;
27
+ icon: string;
28
+ }
29
+
30
+ /**
31
+ * Per-flow-type context menu shortcuts.
32
+ */
33
+ export const CONTEXT_MENU_SHORTCUTS: Record<FlowType, ContextMenuShortcut[]> = {
34
+ [FlowTypes.MESSAGE]: [
35
+ { type: 'send_msg', name: 'Send Message', icon: 'send' },
36
+ { type: 'wait_for_response', name: 'Wait for Response', icon: 'message' }
37
+ ],
38
+ [FlowTypes.VOICE]: [
39
+ { type: 'say_msg', name: 'Say Message', icon: 'send' },
40
+ { type: 'wait_for_menu', name: 'Wait for Menu', icon: 'dots-grid' }
41
+ ],
42
+ [FlowTypes.BACKGROUND]: [
43
+ { type: 'set_contact_field', name: 'Update Contact', icon: 'contact' }
44
+ ]
45
+ };
46
+
20
47
  /**
21
48
  * Features - defines the features available in the account
22
49
  */
package/src/flow/utils.ts CHANGED
@@ -1,17 +1,125 @@
1
1
  import { html, TemplateResult } from 'lit-html';
2
2
  import { Action, NamedObject, FlowPosition } from '../store/flow-definition';
3
- import { FlowIssue } from '../store/AppState';
3
+ import { FlowIssue, zustand } from '../store/AppState';
4
4
  import { CustomEventType } from '../interfaces';
5
5
  import { tokenize, TokenType } from '../excellent/tokenizer';
6
6
  import { TOKEN_COLORS } from '../excellent/token-styles';
7
7
  import { messageParser, sessionParser } from '../excellent/helpers';
8
+ import { FormData, ValidationResult, NodeConfig, ActionConfig } from './types';
9
+ import {
10
+ categoriesToLocalizationFormData,
11
+ localizationFormDataToCategories
12
+ } from './nodes/shared';
8
13
 
9
- const languageNames = new Intl.DisplayNames(['en'], { type: 'language' });
14
+ /**
15
+ * Wraps a validation check into the standard `validate` signature. The check
16
+ * populates an `errors` object; the wrapper computes `valid` and returns the
17
+ * ValidationResult. Eliminates the `const errors = {}; ...; return {valid, errors}`
18
+ * boilerplate that appears in every node and action config.
19
+ */
20
+ export function validateWith(
21
+ check: (formData: FormData, errors: { [key: string]: string }) => void
22
+ ): (formData: FormData) => ValidationResult {
23
+ return (formData: FormData) => {
24
+ const errors: { [key: string]: string } = {};
25
+ check(formData, errors);
26
+ return { valid: Object.keys(errors).length === 0, errors };
27
+ };
28
+ }
29
+
30
+ function makeTextToLocalizationFormData(fields: readonly string[]) {
31
+ return (
32
+ action: { uuid: string },
33
+ localization: Record<string, any>
34
+ ): FormData => {
35
+ const formData: FormData = { uuid: action.uuid };
36
+ fields.forEach((field) => {
37
+ const value = localization[field];
38
+ formData[field] = Array.isArray(value) ? value[0] || '' : '';
39
+ });
40
+ return formData;
41
+ };
42
+ }
43
+
44
+ function makeTextFromLocalizationFormData(fields: readonly string[]) {
45
+ return (
46
+ formData: FormData,
47
+ action: Record<string, any>
48
+ ): Record<string, any> => {
49
+ const localization: Record<string, any> = {};
50
+ fields.forEach((field) => {
51
+ const value = formData[field];
52
+ if (value && value.trim() !== '' && value !== action[field]) {
53
+ localization[field] = [value];
54
+ }
55
+ });
56
+ return localization;
57
+ };
58
+ }
59
+
60
+ type ToLocalizationFn = (
61
+ nodeOrAction: any,
62
+ localization: Record<string, any>
63
+ ) => FormData;
64
+
65
+ type FromLocalizationFn = (
66
+ formData: FormData,
67
+ nodeOrAction: any
68
+ ) => Record<string, any>;
69
+
70
+ /**
71
+ * Returns the `toLocalizationFormData` impl for a node or action config. Uses
72
+ * the explicit impl if the config provides one; otherwise infers from the
73
+ * `localizable` shape: `'categories'` → category helper, `string[]` → text
74
+ * factory. Returns undefined if the config isn't localizable.
75
+ */
76
+ export function resolveToLocalizationFormData(
77
+ config: NodeConfig | ActionConfig
78
+ ): ToLocalizationFn | undefined {
79
+ if (config.toLocalizationFormData) {
80
+ return config.toLocalizationFormData as ToLocalizationFn;
81
+ }
82
+ if (config.localizable === 'categories') {
83
+ return categoriesToLocalizationFormData;
84
+ }
85
+ if (Array.isArray(config.localizable)) {
86
+ return makeTextToLocalizationFormData(config.localizable);
87
+ }
88
+ return undefined;
89
+ }
90
+
91
+ /**
92
+ * Mirror of resolveToLocalizationFormData for `fromLocalizationFormData`.
93
+ */
94
+ export function resolveFromLocalizationFormData(
95
+ config: NodeConfig | ActionConfig
96
+ ): FromLocalizationFn | undefined {
97
+ if (config.fromLocalizationFormData) {
98
+ return config.fromLocalizationFormData as FromLocalizationFn;
99
+ }
100
+ if (config.localizable === 'categories') {
101
+ return localizationFormDataToCategories;
102
+ }
103
+ if (Array.isArray(config.localizable)) {
104
+ return makeTextFromLocalizationFormData(config.localizable);
105
+ }
106
+ return undefined;
107
+ }
108
+
109
+ const intlLanguageNames = new Intl.DisplayNames(['en'], { type: 'language' });
10
110
 
11
111
  export function getLanguageDisplayName(code: string): string {
12
112
  if (code === 'und') return 'Unknown';
113
+
114
+ // Prefer names from the RapidPro languages endpoint, which supplies
115
+ // ISO 639-3 codes (e.g. prd, pst) that Intl.DisplayNames doesn't cover.
116
+ const storeName = zustand.getState().languageNames?.[code];
117
+ if (storeName) {
118
+ return storeName;
119
+ }
120
+
13
121
  try {
14
- return languageNames.of(code) || code;
122
+ return intlLanguageNames.of(code) || code;
15
123
  } catch {
16
124
  return code;
17
125
  }