@nyaruka/temba-components 0.129.11 → 0.130.1

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 (120) hide show
  1. package/CHANGELOG.md +13 -4
  2. package/demo/components/flow/example.html +5 -1
  3. package/demo/data/flows/sample-flow.json +144 -80
  4. package/dist/temba-components.js +290 -346
  5. package/dist/temba-components.js.map +1 -1
  6. package/out-tsc/src/flow/CanvasNode.js +3 -35
  7. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  8. package/out-tsc/src/flow/NodeEditor.js +44 -11
  9. package/out-tsc/src/flow/NodeEditor.js.map +1 -1
  10. package/out-tsc/src/flow/actions/add_contact_groups.js +14 -2
  11. package/out-tsc/src/flow/actions/add_contact_groups.js.map +1 -1
  12. package/out-tsc/src/flow/actions/add_contact_urn.js +1 -1
  13. package/out-tsc/src/flow/actions/add_contact_urn.js.map +1 -1
  14. package/out-tsc/src/flow/actions/add_input_labels.js +2 -1
  15. package/out-tsc/src/flow/actions/add_input_labels.js.map +1 -1
  16. package/out-tsc/src/flow/actions/remove_contact_groups.js +1 -1
  17. package/out-tsc/src/flow/actions/remove_contact_groups.js.map +1 -1
  18. package/out-tsc/src/flow/actions/send_email.js +9 -0
  19. package/out-tsc/src/flow/actions/send_email.js.map +1 -1
  20. package/out-tsc/src/flow/actions/send_msg.js +7 -8
  21. package/out-tsc/src/flow/actions/send_msg.js.map +1 -1
  22. package/out-tsc/src/flow/actions/set_contact_channel.js +25 -4
  23. package/out-tsc/src/flow/actions/set_contact_channel.js.map +1 -1
  24. package/out-tsc/src/flow/actions/set_contact_field.js +51 -1
  25. package/out-tsc/src/flow/actions/set_contact_field.js.map +1 -1
  26. package/out-tsc/src/flow/actions/set_contact_language.js +70 -2
  27. package/out-tsc/src/flow/actions/set_contact_language.js.map +1 -1
  28. package/out-tsc/src/flow/actions/set_contact_name.js +27 -2
  29. package/out-tsc/src/flow/actions/set_contact_name.js.map +1 -1
  30. package/out-tsc/src/flow/actions/set_contact_status.js +32 -2
  31. package/out-tsc/src/flow/actions/set_contact_status.js.map +1 -1
  32. package/out-tsc/src/flow/actions/set_run_result.js +13 -11
  33. package/out-tsc/src/flow/actions/set_run_result.js.map +1 -1
  34. package/out-tsc/src/flow/actions/split_by_expression_example.js +4 -4
  35. package/out-tsc/src/flow/actions/split_by_expression_example.js.map +1 -1
  36. package/out-tsc/src/flow/forms/index.js +2 -0
  37. package/out-tsc/src/flow/forms/index.js.map +1 -0
  38. package/out-tsc/src/flow/nodes/split_by_random.js +117 -0
  39. package/out-tsc/src/flow/nodes/split_by_random.js.map +1 -1
  40. package/out-tsc/src/flow/nodes/split_by_ticket.js +0 -1
  41. package/out-tsc/src/flow/nodes/split_by_ticket.js.map +1 -1
  42. package/out-tsc/src/flow/nodes/split_by_webhook.js +1 -3
  43. package/out-tsc/src/flow/nodes/split_by_webhook.js.map +1 -1
  44. package/out-tsc/src/flow/types.js.map +1 -1
  45. package/out-tsc/src/form/ArrayEditor.js +9 -25
  46. package/out-tsc/src/form/ArrayEditor.js.map +1 -1
  47. package/out-tsc/src/form/FieldRenderer.js +6 -64
  48. package/out-tsc/src/form/FieldRenderer.js.map +1 -1
  49. package/out-tsc/src/form/select/Select.js +35 -58
  50. package/out-tsc/src/form/select/Select.js.map +1 -1
  51. package/out-tsc/src/utils.js +3 -0
  52. package/out-tsc/src/utils.js.map +1 -1
  53. package/out-tsc/test/nodes/split_by_random.test.js +0 -6
  54. package/out-tsc/test/nodes/split_by_random.test.js.map +1 -1
  55. package/out-tsc/test/temba-field-renderer.test.js +6 -3
  56. package/out-tsc/test/temba-field-renderer.test.js.map +1 -1
  57. package/out-tsc/test/utils.test.js +18 -0
  58. package/out-tsc/test/utils.test.js.map +1 -1
  59. package/package.json +1 -1
  60. package/screenshots/truth/actions/add_contact_groups/editor/descriptive-group-names.png +0 -0
  61. package/screenshots/truth/actions/add_contact_groups/editor/long-group-names.png +0 -0
  62. package/screenshots/truth/actions/add_contact_groups/editor/many-groups.png +0 -0
  63. package/screenshots/truth/actions/add_contact_groups/editor/multiple-groups.png +0 -0
  64. package/screenshots/truth/actions/add_contact_groups/editor/single-group.png +0 -0
  65. package/screenshots/truth/actions/add_contact_groups/render/descriptive-group-names.png +0 -0
  66. package/screenshots/truth/actions/add_contact_groups/render/long-group-names.png +0 -0
  67. package/screenshots/truth/actions/add_contact_groups/render/many-groups.png +0 -0
  68. package/screenshots/truth/actions/add_contact_groups/render/multiple-groups.png +0 -0
  69. package/screenshots/truth/actions/add_contact_groups/render/single-group.png +0 -0
  70. package/screenshots/truth/actions/remove_contact_groups/editor/cleanup-groups.png +0 -0
  71. package/screenshots/truth/actions/remove_contact_groups/editor/long-descriptive-group-names.png +0 -0
  72. package/screenshots/truth/actions/remove_contact_groups/editor/many-groups.png +0 -0
  73. package/screenshots/truth/actions/remove_contact_groups/editor/multiple-groups.png +0 -0
  74. package/screenshots/truth/actions/remove_contact_groups/editor/remove-from-all-groups.png +0 -0
  75. package/screenshots/truth/actions/remove_contact_groups/editor/single-group.png +0 -0
  76. package/screenshots/truth/actions/remove_contact_groups/render/cleanup-groups.png +0 -0
  77. package/screenshots/truth/actions/remove_contact_groups/render/long-descriptive-group-names.png +0 -0
  78. package/screenshots/truth/actions/remove_contact_groups/render/many-groups.png +0 -0
  79. package/screenshots/truth/actions/remove_contact_groups/render/multiple-groups.png +0 -0
  80. package/screenshots/truth/actions/remove_contact_groups/render/remove-from-all-groups.png +0 -0
  81. package/screenshots/truth/actions/remove_contact_groups/render/single-group.png +0 -0
  82. package/screenshots/truth/editor/set_contact_language.png +0 -0
  83. package/screenshots/truth/editor/set_contact_name.png +0 -0
  84. package/screenshots/truth/editor/set_run_result.png +0 -0
  85. package/screenshots/truth/nodes/split_by_random/editor/ab-test-multiple-variants.png +0 -0
  86. package/screenshots/truth/nodes/split_by_random/editor/sampling-split.png +0 -0
  87. package/screenshots/truth/nodes/split_by_random/editor/three-way-split.png +0 -0
  88. package/screenshots/truth/nodes/split_by_random/editor/two-bucket-split.png +0 -0
  89. package/src/flow/CanvasNode.ts +2 -39
  90. package/src/flow/NodeEditor.ts +54 -13
  91. package/src/flow/actions/add_contact_groups.ts +17 -2
  92. package/src/flow/actions/add_contact_urn.ts +1 -1
  93. package/src/flow/actions/add_input_labels.ts +2 -1
  94. package/src/flow/actions/remove_contact_groups.ts +1 -1
  95. package/src/flow/actions/send_email.ts +11 -1
  96. package/src/flow/actions/send_msg.ts +20 -11
  97. package/src/flow/actions/set_contact_channel.ts +28 -5
  98. package/src/flow/actions/set_contact_field.ts +56 -2
  99. package/src/flow/actions/set_contact_language.ts +74 -3
  100. package/src/flow/actions/set_contact_name.ts +31 -3
  101. package/src/flow/actions/set_contact_status.ts +36 -3
  102. package/src/flow/actions/set_run_result.ts +13 -15
  103. package/src/flow/actions/split_by_expression_example.ts +4 -4
  104. package/src/flow/forms/index.ts +1 -0
  105. package/src/flow/nodes/split_by_random.ts +148 -0
  106. package/src/flow/nodes/split_by_ticket.ts +0 -1
  107. package/src/flow/nodes/split_by_webhook.ts +1 -3
  108. package/src/flow/types.ts +2 -1
  109. package/src/form/ArrayEditor.ts +6 -20
  110. package/src/form/FieldRenderer.ts +6 -65
  111. package/src/form/select/Select.ts +38 -66
  112. package/src/store/flow-definition.d.ts +6 -1
  113. package/src/utils.ts +4 -0
  114. package/static/api/fields.json +93 -1208
  115. package/static/api/workspace.json +23 -0
  116. package/test/nodes/split_by_random.test.ts +0 -7
  117. package/test/temba-field-renderer.test.ts +26 -13
  118. package/test/utils.test.ts +20 -0
  119. package/web-dev-server.config.mjs +2 -0
  120. package/web-test-runner.config.mjs +37 -0
@@ -1,13 +1,67 @@
1
1
  import { html } from 'lit-html';
2
- import { ActionConfig, COLORS } from '../types';
2
+ import { ActionConfig, COLORS, ValidationResult } from '../types';
3
3
  import { Node, SetContactField } from '../../store/flow-definition';
4
4
 
5
5
  export const set_contact_field: ActionConfig = {
6
- name: 'Update Contact',
6
+ name: 'Update Field',
7
7
  color: COLORS.update,
8
8
  render: (_node: Node, action: SetContactField) => {
9
9
  return html`<div>
10
10
  Set <b>${action.field.name}</b> to <b>${action.value}</b>
11
11
  </div>`;
12
+ },
13
+ form: {
14
+ field: {
15
+ type: 'select',
16
+ label: 'Field',
17
+ required: true,
18
+ searchable: true,
19
+ clearable: false,
20
+ nameKey: 'name',
21
+ valueKey: 'key',
22
+ endpoint: '/api/v2/fields.json',
23
+ helpText: 'Select the contact field to update',
24
+ allowCreate: true,
25
+ createArbitraryOption: (input: string) => ({ key: input, name: input })
26
+ },
27
+ value: {
28
+ type: 'text',
29
+ label: 'Value',
30
+ placeholder: 'Enter field value...',
31
+ required: true,
32
+ evaluated: true,
33
+ helpText:
34
+ 'The new value for the contact field. You can use expressions like @contact.name'
35
+ }
36
+ },
37
+ fromFormData: (formData: SetContactField): SetContactField => {
38
+ const field = formData.field[0];
39
+ return {
40
+ uuid: formData.uuid,
41
+ type: 'set_contact_field',
42
+ field: { name: field.name, key: field.key },
43
+ value: formData.value
44
+ };
45
+ },
46
+ validate: (formData: SetContactField): ValidationResult => {
47
+ const errors: { [key: string]: string } = {};
48
+
49
+ if (!formData.field) {
50
+ errors.field = 'Field is required';
51
+ }
52
+
53
+ if (!formData.value || formData.value.trim() === '') {
54
+ errors.value = 'Field value is required';
55
+ }
56
+
57
+ return {
58
+ valid: Object.keys(errors).length === 0,
59
+ errors
60
+ };
61
+ },
62
+ sanitize: (formData: SetContactField): void => {
63
+ if (formData.value && typeof formData.value === 'string') {
64
+ formData.value = formData.value.trim();
65
+ }
12
66
  }
13
67
  };
@@ -1,11 +1,82 @@
1
1
  import { html } from 'lit-html';
2
- import { ActionConfig, COLORS } from '../types';
2
+ import { ActionConfig, COLORS, ValidationResult } from '../types';
3
3
  import { Node, SetContactLanguage } from '../../store/flow-definition';
4
+ import { getStore } from '../../store/Store';
4
5
 
5
6
  export const set_contact_language: ActionConfig = {
6
- name: 'Update Contact',
7
+ name: 'Update Language',
7
8
  color: COLORS.update,
8
9
  render: (_node: Node, action: SetContactLanguage) => {
9
- return html`<div>Set contact language to <b>${action.language}</b></div>`;
10
+ const languageNames = new Intl.DisplayNames(['en'], {
11
+ type: 'language'
12
+ });
13
+ return html`<div>Set to <b>${languageNames.of(action.language)}</b></div>`;
14
+ },
15
+ form: {
16
+ language: {
17
+ type: 'select',
18
+ label: 'Language',
19
+ required: true,
20
+ searchable: true,
21
+ clearable: false,
22
+ valueKey: 'value',
23
+ nameKey: 'name',
24
+ helpText: 'Select the language to set for the contact',
25
+ getDynamicOptions: () => {
26
+ const store = getStore();
27
+ const workspace = store?.getState().workspace;
28
+ if (workspace?.languages && Array.isArray(workspace.languages)) {
29
+ const languageNames = new Intl.DisplayNames(['en'], {
30
+ type: 'language'
31
+ });
32
+ return workspace.languages.map((languageCode: string) => ({
33
+ value: languageCode,
34
+ name: languageNames.of(languageCode) || languageCode
35
+ }));
36
+ }
37
+ return [];
38
+ }
39
+ }
40
+ },
41
+ toFormData: (action: SetContactLanguage) => {
42
+ // Convert the language code back to the option object format expected by the form
43
+ if (action.language) {
44
+ const languageNames = new Intl.DisplayNames(['en'], {
45
+ type: 'language'
46
+ });
47
+ return {
48
+ language: [
49
+ {
50
+ value: action.language,
51
+ name: languageNames.of(action.language) || action.language
52
+ }
53
+ ],
54
+ uuid: action.uuid
55
+ };
56
+ }
57
+ return {
58
+ language: null,
59
+ uuid: action.uuid
60
+ };
61
+ },
62
+ fromFormData: (formData: any): SetContactLanguage => {
63
+ return {
64
+ uuid: formData.uuid,
65
+ type: 'set_contact_language',
66
+ language: formData.language[0].value
67
+ };
68
+ },
69
+
70
+ validate: (formData: any): ValidationResult => {
71
+ const errors: { [key: string]: string } = {};
72
+
73
+ if (!formData.language) {
74
+ errors.language = 'Language is required';
75
+ }
76
+
77
+ return {
78
+ valid: Object.keys(errors).length === 0,
79
+ errors
80
+ };
10
81
  }
11
82
  };
@@ -1,11 +1,39 @@
1
1
  import { html } from 'lit-html';
2
- import { ActionConfig, COLORS } from '../types';
2
+ import { ActionConfig, COLORS, ValidationResult } from '../types';
3
3
  import { Node, SetContactName } from '../../store/flow-definition';
4
4
 
5
5
  export const set_contact_name: ActionConfig = {
6
- name: 'Update Contact',
6
+ name: 'Update Name',
7
7
  color: COLORS.update,
8
8
  render: (_node: Node, action: SetContactName) => {
9
- return html`<div>Set contact name to <b>${action.name}</b></div>`;
9
+ return html`<div>Set to <b>${action.name}</b></div>`;
10
+ },
11
+ form: {
12
+ name: {
13
+ type: 'text',
14
+ label: 'Name',
15
+ placeholder: 'Enter contact name...',
16
+ required: true,
17
+ evaluated: true,
18
+ helpText:
19
+ 'The new name for the contact. You can use expressions like @contact.name'
20
+ }
21
+ },
22
+ validate: (formData: SetContactName): ValidationResult => {
23
+ const errors: { [key: string]: string } = {};
24
+
25
+ if (!formData.name || formData.name.trim() === '') {
26
+ errors.name = 'Name is required';
27
+ }
28
+
29
+ return {
30
+ valid: Object.keys(errors).length === 0,
31
+ errors
32
+ };
33
+ },
34
+ sanitize: (formData: SetContactName): void => {
35
+ if (formData.name && typeof formData.name === 'string') {
36
+ formData.name = formData.name.trim();
37
+ }
10
38
  }
11
39
  };
@@ -1,11 +1,44 @@
1
1
  import { html } from 'lit-html';
2
- import { ActionConfig, COLORS } from '../types';
2
+ import { ActionConfig, COLORS, ValidationResult } from '../types';
3
3
  import { Node, SetContactStatus } from '../../store/flow-definition';
4
+ import { titleCase } from '../../utils';
4
5
 
5
6
  export const set_contact_status: ActionConfig = {
6
- name: 'Update Contact',
7
+ name: 'Update Status',
7
8
  color: COLORS.update,
8
9
  render: (_node: Node, action: SetContactStatus) => {
9
- return html`<div>Set contact status to <b>${action.status}</b></div>`;
10
+ return html`<div>Set to <b>${titleCase(action.status)}</b></div>`;
11
+ },
12
+ form: {
13
+ status: {
14
+ type: 'select',
15
+ label: 'Status',
16
+ required: true,
17
+ searchable: false,
18
+ clearable: false,
19
+ options: [
20
+ { value: 'active', name: 'Active' },
21
+ { value: 'archived', name: 'Archived' },
22
+ { value: 'stopped', name: 'Stopped' },
23
+ { value: 'blocked', name: 'Blocked' }
24
+ ],
25
+ helpText: 'Select the status to set for the contact'
26
+ }
27
+ },
28
+ validate: (formData: SetContactStatus): ValidationResult => {
29
+ const errors: { [key: string]: string } = {};
30
+
31
+ if (!formData.status) {
32
+ errors.status = 'Status is required';
33
+ } else if (
34
+ !['active', 'archived', 'stopped', 'blocked'].includes(formData.status)
35
+ ) {
36
+ errors.status = 'Invalid status selected';
37
+ }
38
+
39
+ return {
40
+ valid: Object.keys(errors).length === 0,
41
+ errors
42
+ };
10
43
  }
11
44
  };
@@ -7,7 +7,9 @@ export const set_run_result: ActionConfig = {
7
7
  name: 'Save Flow Result',
8
8
  color: COLORS.save,
9
9
  render: (_node: Node, action: SetRunResult) => {
10
- return html`<div>Save ${action.value} as <b>${action.name}</b></div>`;
10
+ return html`<div>
11
+ Save <b>${action.value}</b> as <b>${action.name}</b>
12
+ </div>`;
11
13
  },
12
14
  form: {
13
15
  name: {
@@ -28,7 +30,15 @@ export const set_run_result: ActionConfig = {
28
30
  },
29
31
  searchable: true,
30
32
  clearable: false,
31
- options: []
33
+ getDynamicOptions: () => {
34
+ const store = getStore();
35
+ return store
36
+ ? store
37
+ .getState()
38
+ .getFlowResults()
39
+ .map((r) => ({ value: r.name, name: r.name }))
40
+ : [];
41
+ }
32
42
  },
33
43
  value: {
34
44
  type: 'text',
@@ -48,21 +58,9 @@ export const set_run_result: ActionConfig = {
48
58
  },
49
59
  layout: ['name', 'value', 'category'],
50
60
  toFormData: (action: SetRunResult) => {
51
- // Get existing flow results to populate the select options
52
- const store = getStore();
53
- const flowResults = store ? store.getState().getFlowResults() : [];
54
-
55
- // Update the form configuration with dynamic options
56
- const config = set_run_result;
57
- if (config.form && config.form.name && config.form.name.type === 'select') {
58
- (config.form.name as any).options = flowResults.map(
59
- (result) => result.name
60
- );
61
- }
62
-
63
61
  return {
64
62
  uuid: action.uuid,
65
- name: action.name || '',
63
+ name: action.name ? [{ name: action.name, value: action.name }] : [],
66
64
  value: action.value || '',
67
65
  category: action.category || ''
68
66
  };
@@ -36,10 +36,10 @@ export const split_by_expression: ActionConfig = {
36
36
  label: 'Operator',
37
37
  required: true,
38
38
  options: [
39
- { value: 'contains', label: 'contains' },
40
- { value: 'equals', label: 'equals' },
41
- { value: 'starts_with', label: 'starts with' },
42
- { value: 'regex', label: 'regex' }
39
+ { value: 'contains', name: 'contains' },
40
+ { value: 'equals', name: 'equals' },
41
+ { value: 'starts_with', name: 'starts with' },
42
+ { value: 'regex', name: 'regex' }
43
43
  ]
44
44
  },
45
45
  operand: {
@@ -0,0 +1 @@
1
+ // No unified contact form exports - each action has its own form configuration
@@ -1,9 +1,157 @@
1
1
  import { COLORS, NodeConfig } from '../types';
2
+ import { Node, Category, Exit } from '../../store/flow-definition.d';
3
+ import { generateUUID } from '../../utils';
4
+
5
+ // Helper function to create a random router with categories
6
+ const createRandomRouter = (
7
+ userCategories: string[],
8
+ existingCategories: Category[] = [],
9
+ existingExits: Exit[] = []
10
+ ) => {
11
+ const categories: Category[] = [];
12
+ const exits: Exit[] = [];
13
+
14
+ // Create categories and exits for user-defined buckets
15
+ userCategories.forEach((categoryName) => {
16
+ // Try to find existing category by name
17
+ const existingCategory = existingCategories.find(
18
+ (cat) => cat.name === categoryName
19
+ );
20
+ const existingExit = existingCategory
21
+ ? existingExits.find((exit) => exit.uuid === existingCategory.exit_uuid)
22
+ : null;
23
+
24
+ const exitUuid = existingExit?.uuid || generateUUID();
25
+ const categoryUuid = existingCategory?.uuid || generateUUID();
26
+
27
+ categories.push({
28
+ uuid: categoryUuid,
29
+ name: categoryName,
30
+ exit_uuid: exitUuid
31
+ });
32
+
33
+ exits.push({
34
+ uuid: exitUuid,
35
+ destination_uuid: existingExit?.destination_uuid || null
36
+ });
37
+ });
38
+
39
+ return {
40
+ router: {
41
+ type: 'random' as const,
42
+ categories: categories
43
+ },
44
+ exits: exits
45
+ };
46
+ };
2
47
 
3
48
  export const split_by_random: NodeConfig = {
4
49
  type: 'split_by_random',
5
50
  name: 'Split by Random',
6
51
  color: COLORS.split,
52
+ form: {
53
+ categories: {
54
+ type: 'array',
55
+ label: 'Buckets',
56
+ helpText: 'Define the buckets to randomly split contacts into',
57
+ required: true,
58
+ itemLabel: 'Bucket',
59
+ minItems: 2,
60
+ maxItems: 10,
61
+ isEmptyItem: (item: any) => {
62
+ return !item.name || item.name.trim() === '';
63
+ },
64
+ itemConfig: {
65
+ name: {
66
+ type: 'text',
67
+ placeholder: 'Bucket name',
68
+ required: true
69
+ }
70
+ }
71
+ }
72
+ },
73
+ layout: ['categories'],
74
+ validate: (formData: any) => {
75
+ const errors: { [key: string]: string } = {};
76
+
77
+ // Check for duplicate category names
78
+ if (formData.categories && Array.isArray(formData.categories)) {
79
+ const categories = formData.categories.filter(
80
+ (item: any) => item?.name && item.name.trim() !== ''
81
+ );
82
+
83
+ // Ensure minimum buckets
84
+ if (categories.length < 2) {
85
+ errors.categories = 'At least 2 buckets are required for random split';
86
+ }
87
+
88
+ // Find all categories that have duplicates (case-insensitive)
89
+ const duplicateCategories = [];
90
+ const lowerCaseMap = new Map();
91
+
92
+ // First pass: map lowercase names to all original cases
93
+ categories.forEach((category) => {
94
+ const lowerName = category.name.trim().toLowerCase();
95
+ if (!lowerCaseMap.has(lowerName)) {
96
+ lowerCaseMap.set(lowerName, []);
97
+ }
98
+ lowerCaseMap.get(lowerName).push(category.name.trim());
99
+ });
100
+
101
+ // Second pass: collect all names that appear more than once
102
+ lowerCaseMap.forEach((originalNames) => {
103
+ if (originalNames.length > 1) {
104
+ duplicateCategories.push(...originalNames);
105
+ }
106
+ });
107
+
108
+ if (duplicateCategories.length > 0) {
109
+ const uniqueDuplicates = [...new Set(duplicateCategories)];
110
+ errors.categories = `Duplicate bucket names found: ${uniqueDuplicates.join(
111
+ ', '
112
+ )}`;
113
+ }
114
+ }
115
+
116
+ return {
117
+ valid: Object.keys(errors).length === 0,
118
+ errors
119
+ };
120
+ },
121
+ toFormData: (node: Node) => {
122
+ // Extract categories from the existing node structure
123
+ const categories =
124
+ node.router?.categories?.map((cat) => ({ name: cat.name })) || [];
125
+
126
+ return {
127
+ uuid: node.uuid,
128
+ categories: categories
129
+ };
130
+ },
131
+ fromFormData: (formData: any, originalNode: Node): Node => {
132
+ // Get user categories
133
+ const userCategories = (formData.categories || [])
134
+ .filter((item: any) => item?.name?.trim())
135
+ .map((item: any) => item.name.trim());
136
+
137
+ // Create router and exits using existing data when possible
138
+ const existingCategories = originalNode.router?.categories || [];
139
+ const existingExits = originalNode.exits || [];
140
+
141
+ const { router, exits } = createRandomRouter(
142
+ userCategories,
143
+ existingCategories,
144
+ existingExits
145
+ );
146
+
147
+ // Return the complete node
148
+ return {
149
+ uuid: originalNode.uuid,
150
+ actions: originalNode.actions || [],
151
+ router: router,
152
+ exits: exits
153
+ };
154
+ },
7
155
  router: {
8
156
  type: 'random'
9
157
  }
@@ -24,7 +24,6 @@ export const split_by_ticket: NodeConfig = {
24
24
  label: 'Assignee',
25
25
  required: false,
26
26
  placeholder: 'Select an agent (optional)',
27
- options: [],
28
27
  endpoint: '/api/v2/users.json',
29
28
  valueKey: 'uuid',
30
29
  getName: (item: {
@@ -136,9 +136,7 @@ export const split_by_webhook: NodeConfig = {
136
136
 
137
137
  return {
138
138
  uuid: node.uuid,
139
- method: callWebhookAction?.method
140
- ? [{ value: callWebhookAction.method, name: callWebhookAction.method }]
141
- : [{ value: 'GET', name: 'GET' }],
139
+ method: callWebhookAction?.method || 'GET',
142
140
  url: callWebhookAction?.url || '',
143
141
  headers: callWebhookAction?.headers || [],
144
142
  body: callWebhookAction?.body || ''
package/src/flow/types.ts CHANGED
@@ -143,7 +143,7 @@ export interface TextareaFieldConfig extends BaseFieldConfig {
143
143
 
144
144
  export interface SelectFieldConfig extends BaseFieldConfig {
145
145
  type: 'select';
146
- options?: string[] | { value: string; label: string }[];
146
+ options?: string[] | { value: string; name: string }[];
147
147
  multi?: boolean;
148
148
  clearable?: boolean;
149
149
  searchable?: boolean;
@@ -158,6 +158,7 @@ export interface SelectFieldConfig extends BaseFieldConfig {
158
158
  flavor?: 'small' | 'large';
159
159
  createArbitraryOption?: (input: string, options: any[]) => any;
160
160
  allowCreate?: boolean;
161
+ getDynamicOptions?: () => Array<{ value: string; name: string }>;
161
162
  }
162
163
 
163
164
  export interface KeyValueFieldConfig extends BaseFieldConfig {
@@ -1,6 +1,6 @@
1
1
  import { html, css, TemplateResult } from 'lit';
2
2
  import { customElement, property } from 'lit/decorators.js';
3
- import { FieldConfig, SelectFieldConfig } from '../flow/types';
3
+ import { FieldConfig } from '../flow/types';
4
4
  import { BaseListEditor, ListItem } from './BaseListEditor';
5
5
  import { FieldRenderer } from './FieldRenderer';
6
6
 
@@ -116,12 +116,13 @@ export class TembaArrayEditor extends BaseListEditor<ListItem> {
116
116
  }
117
117
 
118
118
  // For select fields, ensure we return the right type
119
- if (config.type === 'select') {
119
+ /*if (config.type === 'select') {
120
+ console.log('computeFieldValue select', currentValue, config);
120
121
  const selectConfig = config as SelectFieldConfig;
121
122
  if (currentValue === undefined || currentValue === null) {
122
123
  return selectConfig.multi ? [] : '';
123
124
  }
124
- }
125
+ }*/
125
126
 
126
127
  return currentValue;
127
128
  }
@@ -144,23 +145,8 @@ export class TembaArrayEditor extends BaseListEditor<ListItem> {
144
145
 
145
146
  // Handle different field types and their change events
146
147
  if (config.type === 'select') {
147
- // For temba-select, extract the correct value
148
- if (target.tagName === 'TEMBA-SELECT') {
149
- if (target.multi || target.emails || target.tags) {
150
- value = target.values || [];
151
- } else {
152
- // Single select: extract value from first selected option
153
- const values = target.values || [];
154
- value =
155
- values.length > 0 && values[0]
156
- ? values[0].value !== undefined
157
- ? values[0].value
158
- : values[0]
159
- : '';
160
- }
161
- } else {
162
- value = target.value;
163
- }
148
+ // Use consistent temba-select value normalization
149
+ value = target.values;
164
150
  } else {
165
151
  // For other field types, use the target value directly
166
152
  value = target.value;
@@ -207,73 +207,17 @@ export class FieldRenderer {
207
207
  style
208
208
  } = context;
209
209
 
210
- // Ensure proper value handling for multi vs single select
211
- const normalizedValue = (() => {
212
- if (config.multi) {
213
- // Multi-select: ensure we have an array and convert strings to option objects
214
- const valueArray = Array.isArray(value) ? value : value ? [value] : [];
215
- return valueArray.map((val) => {
216
- if (typeof val === 'string') {
217
- // Convert string values to option objects
218
- return { name: val, value: val };
219
- }
220
- return val;
221
- });
222
- } else {
223
- // Single select: use the value as-is
224
- return value || '';
225
- }
226
- })();
227
-
228
- if (typeof normalizedValue === 'string') {
229
- return html`<temba-select
230
- name="${fieldName}"
231
- ?required="${config.required}"
232
- .errors="${errors}"
233
- value="${config.multi ? '' : normalizedValue}"
234
- .values="${config.multi ? normalizedValue : undefined}"
235
- ?multi="${config.multi}"
236
- ?searchable="${config.searchable}"
237
- ?tags="${config.tags}"
238
- ?emails="${config.emails}"
239
- ?clearable="${config.clearable || false}"
240
- label="${showLabel ? config.label : ''}"
241
- placeholder="${config.placeholder || ''}"
242
- maxItems="${config.maxItems || 0}"
243
- valueKey="${config.valueKey || 'value'}"
244
- nameKey="${config.nameKey || 'name'}"
245
- endpoint="${config.endpoint || ''}"
246
- .helpText="${config.helpText || ''}"
247
- flavor="${flavor || config.flavor || 'small'}"
248
- class="${extraClasses}"
249
- style="${style}"
250
- .getName=${config.getName}
251
- .createArbitraryOption=${config.createArbitraryOption}
252
- ?allowCreate="${config.allowCreate || false}"
253
- @change="${onChange || (() => {})}"
254
- >
255
- ${config.options?.map((option: any) => {
256
- if (typeof option === 'string') {
257
- return html`<temba-option
258
- name="${option}"
259
- value="${option}"
260
- ></temba-option>`;
261
- } else {
262
- return html`<temba-option
263
- name="${option.label || option.name}"
264
- value="${option.value}"
265
- ></temba-option>`;
266
- }
267
- })}
268
- </temba-select>`;
269
- }
210
+ // Get options - use dynamic options if available, otherwise use static options
211
+ const optionsToRender = config.getDynamicOptions
212
+ ? config.getDynamicOptions()
213
+ : config.options;
270
214
 
271
215
  return html`<temba-select
272
216
  name="${fieldName}"
273
217
  label="${showLabel ? config.label : ''}"
274
218
  ?required="${config.required}"
275
219
  .errors="${errors}"
276
- .values="${normalizedValue}"
220
+ .values=${value}
277
221
  ?multi="${config.multi}"
278
222
  ?searchable="${config.searchable}"
279
223
  ?tags="${config.tags}"
@@ -293,7 +237,7 @@ export class FieldRenderer {
293
237
  ?allowCreate="${config.allowCreate || false}"
294
238
  @change="${onChange || (() => {})}"
295
239
  >
296
- ${config.options?.map((option: any) => {
240
+ ${optionsToRender?.map((option: any) => {
297
241
  if (typeof option === 'string') {
298
242
  return html`<temba-option
299
243
  name="${option}"
@@ -445,9 +389,6 @@ export class FieldRenderer {
445
389
  }
446
390
  }
447
391
 
448
- /**
449
- * Context object for field rendering that provides additional options
450
- */
451
392
  export interface FieldRenderContext {
452
393
  /** Array of error messages for the field */
453
394
  errors?: string[];