@nyaruka/temba-components 0.129.6 → 0.129.8

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 (177) hide show
  1. package/.devcontainer/Dockerfile +11 -4
  2. package/.devcontainer/devcontainer.json +3 -2
  3. package/.github/workflows/build.yml +4 -14
  4. package/CHANGELOG.md +25 -1
  5. package/demo/components/flow/example.html +9 -2
  6. package/demo/components/flow/index.html +206 -0
  7. package/demo/components/message-editor/example.html +125 -0
  8. package/demo/components/textinput/completion.html +1 -0
  9. package/demo/data/flows/food-order.json +132 -0
  10. package/demo/data/flows/sample-flow.json +40 -24
  11. package/demo/index.html +1 -1
  12. package/dist/temba-components.js +518 -220
  13. package/dist/temba-components.js.map +1 -1
  14. package/out-tsc/src/display/Thumbnail.js +2 -1
  15. package/out-tsc/src/display/Thumbnail.js.map +1 -1
  16. package/out-tsc/src/events.js.map +1 -1
  17. package/out-tsc/src/flow/CanvasNode.js +10 -2
  18. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  19. package/out-tsc/src/flow/NodeEditor.js +245 -22
  20. package/out-tsc/src/flow/NodeEditor.js.map +1 -1
  21. package/out-tsc/src/flow/StickyNote.js +1 -1
  22. package/out-tsc/src/flow/StickyNote.js.map +1 -1
  23. package/out-tsc/src/flow/actions/call_webhook.js +26 -17
  24. package/out-tsc/src/flow/actions/call_webhook.js.map +1 -1
  25. package/out-tsc/src/flow/actions/send_email.js +1 -2
  26. package/out-tsc/src/flow/actions/send_email.js.map +1 -1
  27. package/out-tsc/src/flow/actions/send_msg.js +155 -7
  28. package/out-tsc/src/flow/actions/send_msg.js.map +1 -1
  29. package/out-tsc/src/flow/types.js.map +1 -1
  30. package/out-tsc/src/form/ArrayEditor.js +111 -38
  31. package/out-tsc/src/form/ArrayEditor.js.map +1 -1
  32. package/out-tsc/src/form/BaseListEditor.js +19 -4
  33. package/out-tsc/src/form/BaseListEditor.js.map +1 -1
  34. package/out-tsc/src/form/FormField.js +1 -1
  35. package/out-tsc/src/form/FormField.js.map +1 -1
  36. package/out-tsc/src/form/KeyValueEditor.js +1 -1
  37. package/out-tsc/src/form/KeyValueEditor.js.map +1 -1
  38. package/out-tsc/src/form/MediaPicker.js +13 -1
  39. package/out-tsc/src/form/MediaPicker.js.map +1 -1
  40. package/out-tsc/src/form/MessageEditor.js +422 -0
  41. package/out-tsc/src/form/MessageEditor.js.map +1 -0
  42. package/out-tsc/src/form/TextInput.js +12 -5
  43. package/out-tsc/src/form/TextInput.js.map +1 -1
  44. package/out-tsc/src/form/select/Select.js +4 -4
  45. package/out-tsc/src/form/select/Select.js.map +1 -1
  46. package/out-tsc/src/live/ContactChat.js +29 -4
  47. package/out-tsc/src/live/ContactChat.js.map +1 -1
  48. package/out-tsc/temba-modules.js +2 -0
  49. package/out-tsc/temba-modules.js.map +1 -1
  50. package/out-tsc/test/temba-field-config.test.js +4 -2
  51. package/out-tsc/test/temba-field-config.test.js.map +1 -1
  52. package/out-tsc/test/temba-message-editor.test.js +194 -0
  53. package/out-tsc/test/temba-message-editor.test.js.map +1 -0
  54. package/out-tsc/test/temba-node-editor.test.js +71 -0
  55. package/out-tsc/test/temba-node-editor.test.js.map +1 -1
  56. package/out-tsc/test/temba-select.test.js +1 -1
  57. package/out-tsc/test/temba-select.test.js.map +1 -1
  58. package/out-tsc/test/temba-textinput.test.js +16 -0
  59. package/out-tsc/test/temba-textinput.test.js.map +1 -1
  60. package/out-tsc/test/temba-webchat.test.js +4 -0
  61. package/out-tsc/test/temba-webchat.test.js.map +1 -1
  62. package/out-tsc/test/utils.test.js +2 -8
  63. package/out-tsc/test/utils.test.js.map +1 -1
  64. package/package.json +7 -4
  65. package/screenshots/truth/actions/add_contact_groups/editor/descriptive-group-names.png +0 -0
  66. package/screenshots/truth/actions/add_contact_groups/editor/long-group-names.png +0 -0
  67. package/screenshots/truth/actions/add_contact_groups/editor/many-groups.png +0 -0
  68. package/screenshots/truth/actions/add_contact_groups/editor/multiple-groups.png +0 -0
  69. package/screenshots/truth/actions/add_contact_groups/editor/single-group.png +0 -0
  70. package/screenshots/truth/actions/add_contact_groups/render/descriptive-group-names.png +0 -0
  71. package/screenshots/truth/actions/add_contact_groups/render/long-group-names.png +0 -0
  72. package/screenshots/truth/actions/add_contact_groups/render/many-groups.png +0 -0
  73. package/screenshots/truth/actions/add_contact_groups/render/multiple-groups.png +0 -0
  74. package/screenshots/truth/actions/add_contact_groups/render/single-group.png +0 -0
  75. package/screenshots/truth/actions/remove_contact_groups/editor/cleanup-groups.png +0 -0
  76. package/screenshots/truth/actions/remove_contact_groups/editor/long-descriptive-group-names.png +0 -0
  77. package/screenshots/truth/actions/remove_contact_groups/editor/many-groups.png +0 -0
  78. package/screenshots/truth/actions/remove_contact_groups/editor/multiple-groups.png +0 -0
  79. package/screenshots/truth/actions/remove_contact_groups/editor/single-group.png +0 -0
  80. package/screenshots/truth/actions/remove_contact_groups/render/cleanup-groups.png +0 -0
  81. package/screenshots/truth/actions/remove_contact_groups/render/long-descriptive-group-names.png +0 -0
  82. package/screenshots/truth/actions/remove_contact_groups/render/many-groups.png +0 -0
  83. package/screenshots/truth/actions/remove_contact_groups/render/multiple-groups.png +0 -0
  84. package/screenshots/truth/actions/remove_contact_groups/render/remove-from-all-groups.png +0 -0
  85. package/screenshots/truth/actions/remove_contact_groups/render/single-group.png +0 -0
  86. package/screenshots/truth/actions/send_email/editor/complex-business-email.png +0 -0
  87. package/screenshots/truth/actions/send_email/editor/empty-body.png +0 -0
  88. package/screenshots/truth/actions/send_email/editor/empty-subject.png +0 -0
  89. package/screenshots/truth/actions/send_email/editor/long-subject.png +0 -0
  90. package/screenshots/truth/actions/send_email/editor/multiline-body.png +0 -0
  91. package/screenshots/truth/actions/send_email/editor/multiple-recipients.png +0 -0
  92. package/screenshots/truth/actions/send_email/editor/simple-email.png +0 -0
  93. package/screenshots/truth/actions/send_email/editor/with-expressions.png +0 -0
  94. package/screenshots/truth/actions/send_email/render/complex-business-email.png +0 -0
  95. package/screenshots/truth/actions/send_email/render/empty-body.png +0 -0
  96. package/screenshots/truth/actions/send_email/render/empty-subject.png +0 -0
  97. package/screenshots/truth/actions/send_email/render/long-subject.png +0 -0
  98. package/screenshots/truth/actions/send_email/render/multiline-body.png +0 -0
  99. package/screenshots/truth/actions/send_email/render/multiple-recipients.png +0 -0
  100. package/screenshots/truth/actions/send_email/render/simple-email.png +0 -0
  101. package/screenshots/truth/actions/send_email/render/with-expressions.png +0 -0
  102. package/screenshots/truth/actions/send_msg/editor/long-quick-replies.png +0 -0
  103. package/screenshots/truth/actions/send_msg/editor/multiline-text-with-replies.png +0 -0
  104. package/screenshots/truth/actions/send_msg/editor/simple-text.png +0 -0
  105. package/screenshots/truth/actions/send_msg/editor/text-with-linebreaks.png +0 -0
  106. package/screenshots/truth/actions/send_msg/editor/text-with-many-quick-replies.png +0 -0
  107. package/screenshots/truth/actions/send_msg/editor/text-with-quick-replies.png +0 -0
  108. package/screenshots/truth/actions/send_msg/editor/text-without-quick-replies.png +0 -0
  109. package/screenshots/truth/actions/send_msg/render/long-quick-replies.png +0 -0
  110. package/screenshots/truth/actions/send_msg/render/multiline-text-with-replies.png +0 -0
  111. package/screenshots/truth/actions/send_msg/render/simple-text.png +0 -0
  112. package/screenshots/truth/actions/send_msg/render/text-with-linebreaks.png +0 -0
  113. package/screenshots/truth/actions/send_msg/render/text-with-many-quick-replies.png +0 -0
  114. package/screenshots/truth/actions/send_msg/render/text-with-quick-replies.png +0 -0
  115. package/screenshots/truth/actions/send_msg/render/text-without-quick-replies.png +0 -0
  116. package/screenshots/truth/editor/send_msg.png +0 -0
  117. package/screenshots/truth/editor/set_contact_language.png +0 -0
  118. package/screenshots/truth/editor/set_contact_name.png +0 -0
  119. package/screenshots/truth/editor/set_run_result.png +0 -0
  120. package/screenshots/truth/formfield/markdown-errors.png +0 -0
  121. package/screenshots/truth/formfield/no-errors.png +0 -0
  122. package/screenshots/truth/formfield/plain-text-errors.png +0 -0
  123. package/screenshots/truth/message-editor/autogrow-initial-content.png +0 -0
  124. package/screenshots/truth/message-editor/default.png +0 -0
  125. package/screenshots/truth/message-editor/drag-highlight.png +0 -0
  126. package/screenshots/truth/message-editor/filtered-attachments.png +0 -0
  127. package/screenshots/truth/message-editor/with-completion.png +0 -0
  128. package/screenshots/truth/message-editor/with-properties.png +0 -0
  129. package/screenshots/truth/sticky-note/blue-color.png +0 -0
  130. package/screenshots/truth/sticky-note/blue.png +0 -0
  131. package/screenshots/truth/sticky-note/color-picker-expanded.png +0 -0
  132. package/screenshots/truth/sticky-note/default.png +0 -0
  133. package/screenshots/truth/sticky-note/gray-color.png +0 -0
  134. package/screenshots/truth/sticky-note/gray.png +0 -0
  135. package/screenshots/truth/sticky-note/green-color.png +0 -0
  136. package/screenshots/truth/sticky-note/green.png +0 -0
  137. package/screenshots/truth/sticky-note/pink-color.png +0 -0
  138. package/screenshots/truth/sticky-note/pink.png +0 -0
  139. package/screenshots/truth/sticky-note/yellow-color.png +0 -0
  140. package/screenshots/truth/sticky-note/yellow.png +0 -0
  141. package/screenshots/truth/textinput/autogrow-initial.png +0 -0
  142. package/screenshots/truth/textinput/input-form.png +0 -0
  143. package/src/display/Thumbnail.ts +2 -1
  144. package/src/events.ts +6 -2
  145. package/src/flow/CanvasNode.ts +10 -2
  146. package/src/flow/NodeEditor.ts +269 -23
  147. package/src/flow/StickyNote.ts +1 -1
  148. package/src/flow/actions/call_webhook.ts +28 -18
  149. package/src/flow/actions/send_email.ts +1 -2
  150. package/src/flow/actions/send_msg.ts +178 -7
  151. package/src/flow/types.ts +21 -2
  152. package/src/form/ArrayEditor.ts +120 -42
  153. package/src/form/BaseListEditor.ts +22 -6
  154. package/src/form/FormField.ts +1 -1
  155. package/src/form/KeyValueEditor.ts +1 -1
  156. package/src/form/MediaPicker.ts +13 -1
  157. package/src/form/MessageEditor.ts +449 -0
  158. package/src/form/TextInput.ts +15 -7
  159. package/src/form/select/Select.ts +4 -4
  160. package/src/live/ContactChat.ts +32 -6
  161. package/src/store/flow-definition.d.ts +25 -4
  162. package/static/css/temba-components.css +2 -0
  163. package/static/mr/docs/en-us/editor.json +2588 -0
  164. package/stress-test.js +138 -0
  165. package/temba-modules.ts +2 -0
  166. package/test/temba-field-config.test.ts +4 -2
  167. package/test/temba-message-editor.test.ts +300 -0
  168. package/test/temba-node-editor.test.ts +94 -0
  169. package/test/temba-select.test.ts +1 -1
  170. package/test/temba-textinput.test.ts +26 -0
  171. package/test/temba-webchat.test.ts +5 -0
  172. package/test/utils.test.ts +2 -13
  173. package/test-assets/contacts/history.json +20 -2
  174. package/test-assets/style.css +2 -0
  175. package/web-dev-mock.mjs +433 -0
  176. package/web-dev-server.config.mjs +71 -6
  177. package/web-test-runner.config.mjs +9 -4
@@ -2,6 +2,19 @@ import { html } from 'lit-html';
2
2
  import { ActionConfig, COLORS } from '../types';
3
3
  import { Node, CallWebhook } from '../../store/flow-definition';
4
4
 
5
+ const defaultPost = `@(json(object(
6
+ "contact", object(
7
+ "uuid", contact.uuid,
8
+ "name", contact.name,
9
+ "urn", contact.urn
10
+ ),
11
+ "flow", object(
12
+ "uuid", run.flow.uuid,
13
+ "name", run.flow.name
14
+ ),
15
+ "results", foreach_value(results, extract_object, "value", "category")
16
+ )))`;
17
+
5
18
  export const call_webhook: ActionConfig = {
6
19
  name: 'Call Webhook',
7
20
  color: COLORS.call,
@@ -19,7 +32,7 @@ export const call_webhook: ActionConfig = {
19
32
  required: true,
20
33
  options: ['GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'PATCH'],
21
34
  maxWidth: '120px',
22
- searchable: true
35
+ searchable: false
23
36
  },
24
37
  url: {
25
38
  type: 'text',
@@ -51,23 +64,10 @@ export const call_webhook: ActionConfig = {
51
64
  ? values.method[0].value || values.method[0].name
52
65
  : values.method;
53
66
 
54
- const defaultTemplate = `@(json(object(
55
- "contact", object(
56
- "uuid", contact.uuid,
57
- "name", contact.name,
58
- "urn", contact.urn
59
- ),
60
- "flow", object(
61
- "uuid", run.flow.uuid,
62
- "name", run.flow.name
63
- ),
64
- "results", foreach_value(results, extract_object, "value", "category")
65
- )))`;
66
-
67
67
  if (method === 'POST') {
68
68
  // For POST, provide the template if body is empty or was never set by user
69
69
  if (!currentValue || currentValue.trim() === '') {
70
- return defaultTemplate;
70
+ return defaultPost;
71
71
  }
72
72
  } else {
73
73
  // For non-POST methods, clear the body if it was auto-generated or empty
@@ -80,7 +80,7 @@ export const call_webhook: ActionConfig = {
80
80
  isOriginallyEmpty ||
81
81
  !currentValue ||
82
82
  currentValue.trim() === '' ||
83
- currentValue.trim() === defaultTemplate.trim()
83
+ currentValue.trim() === defaultPost.trim()
84
84
  ) {
85
85
  return '';
86
86
  }
@@ -100,7 +100,10 @@ export const call_webhook: ActionConfig = {
100
100
  items: ['headers'],
101
101
  collapsible: true,
102
102
  collapsed: true,
103
- helpText: 'Configure authentication or custom headers'
103
+ helpText: 'Configure authentication or custom headers',
104
+ getGroupValueCount: (formData: any) => {
105
+ return formData.headers?.length + 10 || 0;
106
+ }
104
107
  },
105
108
  {
106
109
  type: 'group',
@@ -108,7 +111,14 @@ export const call_webhook: ActionConfig = {
108
111
  items: ['body'],
109
112
  collapsible: true,
110
113
  collapsed: true,
111
- helpText: 'Configure the request payload'
114
+ helpText: 'Configure the request payload',
115
+ getGroupValueCount: (formData: any) => {
116
+ return !!(
117
+ formData.body &&
118
+ formData.body.trim() !== '' &&
119
+ formData.body !== defaultPost
120
+ );
121
+ }
112
122
  }
113
123
  ],
114
124
  toFormData: (action: CallWebhook) => {
@@ -41,8 +41,7 @@ export const send_email: ActionConfig = {
41
41
  type: 'textarea',
42
42
  required: true,
43
43
  evaluated: true,
44
- rows: 4,
45
- minHeight: 75
44
+ minHeight: 175
46
45
  }
47
46
  },
48
47
  validate: (action: SendEmail): ValidationResult => {
@@ -15,25 +15,36 @@ export const send_msg: ActionConfig = {
15
15
  ${action.quick_replies.map((reply) => {
16
16
  return html`<div class="quick-reply">${reply}</div>`;
17
17
  })}
18
+ ${action.template
19
+ ? html`<div
20
+ style="border: 1px solid var(--color-widget-border);padding: 0.5em;margin-top: 1em;border-radius: var(--curvature); display:flex;background: rgba(0,0,0,.03);"
21
+ >
22
+ <temba-icon name="channel_wac"></temba-icon>
23
+ <div style="margin-left:0.5em">${action.template.name}</div>
24
+ </div>`
25
+ : null}
18
26
  </div>`
19
27
  : null}
20
28
  `;
21
29
  },
22
30
  form: {
23
31
  text: {
24
- type: 'textarea',
25
- label: 'Message Text',
32
+ type: 'message-editor',
33
+ label: 'Message',
26
34
  helpText:
27
- 'Enter the message to send. You can use expressions like @contact.name',
35
+ 'Enter the message to send with optional attachments. You can use expressions like @contact.name',
28
36
  required: true,
29
37
  evaluated: true,
30
- rows: 5,
31
- minHeight: 75
38
+ placeholder: 'Type your message here...',
39
+ maxAttachments: 10,
40
+ accept: '',
41
+ endpoint: '/api/v2/media.json',
42
+ counter: 'temba-charcount',
43
+ gsm: true,
44
+ autogrow: true
32
45
  },
33
46
  quick_replies: {
34
47
  type: 'select',
35
- label: 'Quick Replies',
36
- helpText: 'Add quick reply options for this message',
37
48
  options: [],
38
49
  multi: true,
39
50
  tags: true,
@@ -41,6 +52,137 @@ export const send_msg: ActionConfig = {
41
52
  placeholder: 'Add quick replies...',
42
53
  maxItems: 10,
43
54
  evaluated: true
55
+ },
56
+ runtime_attachments: {
57
+ type: 'array',
58
+ helpText: 'Add dynamic attachments using expressions',
59
+ itemLabel: 'Attachment',
60
+ maxItems: 10,
61
+ isEmptyItem: (item: any) => {
62
+ return !item.expression || item.expression.trim() === '';
63
+ },
64
+ itemConfig: {
65
+ type: {
66
+ type: 'select',
67
+ options: [
68
+ { value: 'image', label: 'Image' },
69
+ { value: 'audio', label: 'Audio' },
70
+ { value: 'video', label: 'Video' },
71
+ { value: 'document', label: 'Document' }
72
+ ],
73
+ required: true,
74
+ searchable: false
75
+ },
76
+ expression: {
77
+ type: 'text',
78
+ placeholder: 'Expression (e.g. @contact.photo)',
79
+ required: true,
80
+ evaluated: true
81
+ }
82
+ }
83
+ }
84
+ },
85
+ layout: [
86
+ 'text',
87
+ {
88
+ type: 'group',
89
+ label: 'Quick Replies',
90
+ items: ['quick_replies'],
91
+ collapsible: true,
92
+ collapsed: (formData: any) => {
93
+ // Collapse only if there are no quick replies
94
+ return !formData.quick_replies || formData.quick_replies.length === 0;
95
+ },
96
+ getGroupValueCount: (formData: any) => {
97
+ return formData.quick_replies?.length || 0;
98
+ }
99
+ },
100
+ {
101
+ type: 'group',
102
+ label: 'Runtime Attachments',
103
+ items: ['runtime_attachments'],
104
+ collapsible: true,
105
+ collapsed: true,
106
+ helpText: 'Add dynamic attachments that are evaluated at runtime',
107
+ getGroupValueCount: (formData: any) => {
108
+ return (
109
+ formData.runtime_attachments?.filter(
110
+ (item: any) =>
111
+ item && item.expression && item.expression.trim() !== ''
112
+ ).length || 0
113
+ );
114
+ }
115
+ }
116
+ ],
117
+ toFormData: (action: SendMsg) => {
118
+ // Extract runtime attachments from the text field attachments
119
+ const runtimeAttachments: { type: string; expression: string }[] = [];
120
+ const staticAttachments: string[] = [];
121
+
122
+ if (action.attachments && Array.isArray(action.attachments)) {
123
+ action.attachments.forEach((attachment) => {
124
+ if (typeof attachment === 'string' && attachment.includes(':')) {
125
+ const colonIndex = attachment.indexOf(':');
126
+ const contentType = attachment.substring(0, colonIndex);
127
+ const value = attachment.substring(colonIndex + 1);
128
+
129
+ if (!contentType.includes('/')) {
130
+ // This is a runtime attachment
131
+ runtimeAttachments.push({
132
+ type: contentType,
133
+ expression: value
134
+ });
135
+ } else {
136
+ // This is a static attachment
137
+ staticAttachments.push(attachment);
138
+ }
139
+ }
140
+ });
141
+ }
142
+
143
+ return {
144
+ uuid: action.uuid,
145
+ text: action.text || '',
146
+ attachments: staticAttachments,
147
+ runtime_attachments: runtimeAttachments,
148
+ quick_replies: (action.quick_replies || []).map((reply) => ({
149
+ name: reply,
150
+ value: reply
151
+ }))
152
+ };
153
+ },
154
+ fromFormData: (data: Record<string, any>) => {
155
+ const result = {
156
+ uuid: data.uuid,
157
+ type: 'send_msg',
158
+ text: data.text || '',
159
+ attachments: [],
160
+ quick_replies: (data.quick_replies || []).map((reply: any) =>
161
+ typeof reply === 'string' ? reply : reply.value || reply.name || reply
162
+ )
163
+ };
164
+
165
+ // Combine static attachments from text field with runtime attachments
166
+ const staticAttachments = data.attachments || [];
167
+ const runtimeAttachments = (data.runtime_attachments || [])
168
+ .filter((item: any) => item && item.type && item.expression) // Filter out invalid items
169
+ .map(
170
+ (item: { type: string; expression: string }) =>
171
+ `${item.type}:${item.expression}`
172
+ );
173
+
174
+ result.attachments = [...staticAttachments, ...runtimeAttachments];
175
+
176
+ // Remove quick_replies if empty to match original format
177
+ if (result.quick_replies.length === 0) {
178
+ delete (result as any).quick_replies;
179
+ }
180
+
181
+ return result as SendMsg;
182
+ },
183
+ sanitize: (formData: any): void => {
184
+ if (formData.text && typeof formData.text === 'string') {
185
+ formData.text = formData.text.trim();
44
186
  }
45
187
  },
46
188
  validate: (action: SendMsg): ValidationResult => {
@@ -50,6 +192,35 @@ export const send_msg: ActionConfig = {
50
192
  errors.text = 'Message text is required';
51
193
  }
52
194
 
195
+ const attachments = action.attachments || [];
196
+ if (attachments.length > 10) {
197
+ const staticAttachments = attachments.filter(
198
+ (attachment) =>
199
+ typeof attachment === 'string' &&
200
+ attachment.substring(0, attachment.indexOf(':')).includes('/')
201
+ );
202
+
203
+ const runtimeAttachments = attachments.filter(
204
+ (attachment) =>
205
+ typeof attachment === 'string' &&
206
+ !attachment.substring(0, attachment.indexOf(':')).includes('/')
207
+ );
208
+
209
+ if (runtimeAttachments.length > 0) {
210
+ errors.runtime_attachments =
211
+ 'Each message can only have up to 10 attachments';
212
+ }
213
+
214
+ if (staticAttachments.length > 0) {
215
+ const message = 'Each message can only have up to 10 total attachments';
216
+ if (errors.text) {
217
+ errors.text += ` ${message}`;
218
+ } else {
219
+ errors.text = message;
220
+ }
221
+ }
222
+ }
223
+
53
224
  return {
54
225
  valid: Object.keys(errors).length === 0,
55
226
  errors
package/src/flow/types.ts CHANGED
@@ -162,6 +162,7 @@ export interface SelectFieldConfig extends BaseFieldConfig {
162
162
  type: 'select';
163
163
  options: string[] | { value: string; label: string }[];
164
164
  multi?: boolean;
165
+ clearable?: boolean;
165
166
  searchable?: boolean;
166
167
  tags?: boolean;
167
168
  placeholder?: string;
@@ -170,6 +171,7 @@ export interface SelectFieldConfig extends BaseFieldConfig {
170
171
  nameKey?: string;
171
172
  endpoint?: string;
172
173
  emails?: boolean;
174
+ flavor?: 'small' | 'large';
173
175
  }
174
176
 
175
177
  export interface KeyValueFieldConfig extends BaseFieldConfig {
@@ -193,6 +195,7 @@ export interface ArrayFieldConfig extends BaseFieldConfig {
193
195
  value: any,
194
196
  allItems: any[]
195
197
  ) => any[];
198
+ isEmptyItem?: (item: any) => boolean;
196
199
  }
197
200
 
198
201
  export interface CheckboxFieldConfig extends BaseFieldConfig {
@@ -201,13 +204,27 @@ export interface CheckboxFieldConfig extends BaseFieldConfig {
201
204
  animateChange?: string;
202
205
  }
203
206
 
207
+ export interface MessageEditorFieldConfig extends BaseFieldConfig {
208
+ type: 'message-editor';
209
+ placeholder?: string;
210
+ minHeight?: number;
211
+ maxAttachments?: number;
212
+ accept?: string;
213
+ endpoint?: string;
214
+ counter?: string;
215
+ gsm?: boolean;
216
+ autogrow?: boolean;
217
+ disableCompletion?: boolean;
218
+ }
219
+
204
220
  export type FieldConfig =
205
221
  | TextFieldConfig
206
222
  | TextareaFieldConfig
207
223
  | SelectFieldConfig
208
224
  | KeyValueFieldConfig
209
225
  | ArrayFieldConfig
210
- | CheckboxFieldConfig;
226
+ | CheckboxFieldConfig
227
+ | MessageEditorFieldConfig;
211
228
 
212
229
  // Layout configurations for better form organization
213
230
  // Recursive layout system - any layout item can contain other layout items
@@ -228,8 +245,9 @@ export interface GroupLayoutConfig {
228
245
  label: string;
229
246
  items: LayoutItem[]; // can contain fields, rows, or other groups
230
247
  collapsible?: boolean;
231
- collapsed?: boolean; // initial state if collapsible
248
+ collapsed?: boolean | ((formData: any) => boolean); // initial state if collapsible - can be a function
232
249
  helpText?: string;
250
+ getGroupValueCount?: (formData: any) => number; // optional function to get count for bubble display
233
251
  }
234
252
 
235
253
  export type LayoutItem =
@@ -249,6 +267,7 @@ export interface ActionConfig {
249
267
 
250
268
  // Action editor configuration (legacy)
251
269
  // Form-level transformations
270
+ sanitize?: (formData: any) => any;
252
271
  toFormData?: (action: Action) => any;
253
272
  fromFormData?: (formData: any) => Action;
254
273
 
@@ -1,6 +1,6 @@
1
1
  import { html, css, TemplateResult } from 'lit';
2
2
  import { customElement, property } from 'lit/decorators.js';
3
- import { FieldConfig } from '../flow/types';
3
+ import { FieldConfig, SelectFieldConfig } from '../flow/types';
4
4
  import { BaseListEditor, ListItem } from './BaseListEditor';
5
5
 
6
6
  @customElement('temba-array-editor')
@@ -19,6 +19,12 @@ export class TembaArrayEditor extends BaseListEditor<ListItem> {
19
19
  allItems: any[]
20
20
  ) => any[];
21
21
 
22
+ @property({ type: Function })
23
+ isEmptyItemFn?: (item: any) => boolean;
24
+
25
+ @property({ type: Boolean })
26
+ maintainEmptyItem = true; // Enable by default for better UX
27
+
22
28
  constructor() {
23
29
  super();
24
30
  this._items = [];
@@ -37,11 +43,36 @@ export class TembaArrayEditor extends BaseListEditor<ListItem> {
37
43
 
38
44
  // Implement abstract methods
39
45
  isEmptyItem(item: ListItem): boolean {
40
- return Object.values(item).every(
46
+ // Use configurable function if provided
47
+ if (this.isEmptyItemFn) {
48
+ return this.isEmptyItemFn(item);
49
+ }
50
+
51
+ // Default behavior: check if all values are empty
52
+ const values = Object.values(item);
53
+ if (values.length === 0) {
54
+ return true;
55
+ }
56
+
57
+ return values.every(
41
58
  (value) => value === undefined || value === null || value === ''
42
59
  );
43
60
  }
44
61
 
62
+ // Override cleanItems to be more permissive for form data
63
+ protected cleanItems(items: ListItem[]): any {
64
+ // For runtime attachments, keep items that have at least one non-empty field
65
+ return items.filter((item) => {
66
+ const values = Object.values(item);
67
+ return (
68
+ values.length > 0 &&
69
+ values.some(
70
+ (value) => value !== undefined && value !== null && value !== ''
71
+ )
72
+ );
73
+ });
74
+ }
75
+
45
76
  createEmptyItem(): ListItem {
46
77
  return {};
47
78
  }
@@ -83,6 +114,14 @@ export class TembaArrayEditor extends BaseListEditor<ListItem> {
83
114
  return config.computeValue(item, currentValue);
84
115
  }
85
116
 
117
+ // For select fields, ensure we return the right type
118
+ if (config.type === 'select') {
119
+ const selectConfig = config as SelectFieldConfig;
120
+ if (currentValue === undefined || currentValue === null) {
121
+ return selectConfig.multi ? [] : '';
122
+ }
123
+ }
124
+
86
125
  return currentValue;
87
126
  }
88
127
 
@@ -112,13 +151,64 @@ export class TembaArrayEditor extends BaseListEditor<ListItem> {
112
151
  this.handleFieldChange(itemIndex, fieldName, e.target.value)}
113
152
  ></temba-textinput>`;
114
153
 
115
- case 'select':
154
+ case 'select': {
155
+ const selectConfig = config as SelectFieldConfig;
156
+ const fieldValue = this.computeFieldValue(itemIndex, fieldName, config);
157
+
116
158
  return html`<temba-select
117
- .value=${computedValue || ''}
118
- .options=${config.options}
119
- @change=${(e: any) =>
120
- this.handleFieldChange(itemIndex, fieldName, e.target.value)}
121
- ></temba-select>`;
159
+ class="form-control"
160
+ ?clearable="${selectConfig.clearable || false}"
161
+ ?searchable="${selectConfig.searchable || false}"
162
+ ?tags="${selectConfig.tags || false}"
163
+ ?multi="${selectConfig.multi || false}"
164
+ ?emails="${selectConfig.emails || false}"
165
+ placeholder="${selectConfig.placeholder || ''}"
166
+ maxItems="${selectConfig.maxItems || 0}"
167
+ valueKey="${selectConfig.valueKey || 'value'}"
168
+ nameKey="${selectConfig.nameKey || 'name'}"
169
+ endpoint="${selectConfig.endpoint || ''}"
170
+ value="${fieldValue || ''}"
171
+ flavor="small"
172
+ @change="${(e: Event) => {
173
+ const target = e.target as any;
174
+ let value: any;
175
+
176
+ // For temba-select, extract the correct value
177
+ if (target.tagName === 'TEMBA-SELECT') {
178
+ if (target.multi || target.emails || target.tags) {
179
+ value = target.values || [];
180
+ } else {
181
+ // Single select: extract value from first selected option
182
+ const values = target.values || [];
183
+ value =
184
+ values.length > 0 && values[0]
185
+ ? values[0].value !== undefined
186
+ ? values[0].value
187
+ : values[0]
188
+ : '';
189
+ }
190
+ } else {
191
+ value = target.value;
192
+ }
193
+
194
+ this.handleFieldChange(itemIndex, fieldName, value);
195
+ }}"
196
+ >
197
+ ${selectConfig.options?.map((option: any) => {
198
+ if (typeof option === 'string') {
199
+ return html`<temba-option
200
+ name="${option}"
201
+ value="${option}"
202
+ ></temba-option>`;
203
+ } else {
204
+ return html`<temba-option
205
+ name="${option.label || option.name}"
206
+ value="${option.value}"
207
+ ></temba-option>`;
208
+ }
209
+ })}
210
+ </temba-select>`;
211
+ }
122
212
 
123
213
  default:
124
214
  return html`<span>Unsupported field type: ${config.type}</span>`;
@@ -130,29 +220,25 @@ export class TembaArrayEditor extends BaseListEditor<ListItem> {
130
220
 
131
221
  return html`
132
222
  <div class="array-item">
133
- <div class="item-header">
134
- <span class="item-title">${this.itemLabel} ${index + 1}</span>
223
+ <div class="item-fields">
224
+ ${Object.entries(this.itemConfig).map(
225
+ ([fieldName, config]) => html`
226
+ <div class="field">
227
+ ${this.renderField(index, fieldName, config)}
228
+ </div>
229
+ `
230
+ )}
135
231
  ${canRemove
136
232
  ? html`
137
233
  <button
138
234
  @click=${() => this.removeItem(index)}
139
235
  class="remove-btn"
140
236
  >
141
- Remove
237
+ <temba-icon name="x"></temba-icon>
142
238
  </button>
143
239
  `
144
240
  : ''}
145
241
  </div>
146
- <div class="item-fields">
147
- ${Object.entries(this.itemConfig).map(
148
- ([fieldName, config]) => html`
149
- <div class="field">
150
- <label>${config.label}${config.required ? ' *' : ''}</label>
151
- ${this.renderField(index, fieldName, config)}
152
- </div>
153
- `
154
- )}
155
- </div>
156
242
  </div>
157
243
  `;
158
244
  }
@@ -171,27 +257,15 @@ export class TembaArrayEditor extends BaseListEditor<ListItem> {
171
257
 
172
258
  static styles = css`
173
259
  .array-editor {
174
- border: 1px solid #e0e0e0;
175
- border-radius: 6px;
176
- padding: 16px;
177
- background: #fafafa;
178
260
  }
179
261
 
180
262
  .array-item {
181
- border: 1px solid #d0d0d0;
182
- border-radius: 4px;
183
- padding: 16px;
184
- margin-bottom: 12px;
185
- background: white;
186
263
  }
187
264
 
188
265
  .item-header {
189
266
  display: flex;
190
267
  justify-content: space-between;
191
268
  align-items: center;
192
- margin-bottom: 12px;
193
- padding-bottom: 8px;
194
- border-bottom: 1px solid #eee;
195
269
  }
196
270
 
197
271
  .item-title {
@@ -200,8 +274,17 @@ export class TembaArrayEditor extends BaseListEditor<ListItem> {
200
274
  }
201
275
 
202
276
  .item-fields {
203
- display: grid;
277
+ display: flex;
204
278
  gap: 12px;
279
+ align-items: center;
280
+ }
281
+
282
+ .field {
283
+ flex: 1;
284
+ }
285
+
286
+ .field:first-child {
287
+ flex: 0 0 140px; /* Fixed width for type dropdown */
205
288
  }
206
289
 
207
290
  .field label {
@@ -214,7 +297,7 @@ export class TembaArrayEditor extends BaseListEditor<ListItem> {
214
297
 
215
298
  .add-btn,
216
299
  .remove-btn {
217
- padding: 8px 16px;
300
+ padding: 8px;
218
301
  border: 1px solid #ccc;
219
302
  border-radius: 4px;
220
303
  background: white;
@@ -228,13 +311,8 @@ export class TembaArrayEditor extends BaseListEditor<ListItem> {
228
311
  }
229
312
 
230
313
  .remove-btn {
231
- background: #fff5f5;
232
- border-color: #fecaca;
233
- color: #dc2626;
234
- }
235
-
236
- .remove-btn:hover {
237
- background: #fef2f2;
314
+ background: #fefefe;
315
+ color: #999;
238
316
  }
239
317
  `;
240
318
  }
@@ -52,10 +52,12 @@ export abstract class BaseListEditor<
52
52
  }
53
53
 
54
54
  protected shouldShowAddButton(): boolean {
55
- return (
56
- !this.maintainEmptyItem &&
57
- (!this.maxItems || this._items.length < this.maxItems)
58
- );
55
+ // Never show add button when maintaining empty items (auto-add behavior)
56
+ if (this.maintainEmptyItem) {
57
+ return false;
58
+ }
59
+
60
+ return !this.maxItems || this._items.length < this.maxItems;
59
61
  }
60
62
 
61
63
  render(): TemplateResult {
@@ -65,7 +67,7 @@ export abstract class BaseListEditor<
65
67
  <div class=${this.getContainerClass()}>
66
68
  <div
67
69
  class="list-items"
68
- style="gap: 8px; display: grid; grid-template-columns: 1fr;"
70
+ style="display: grid; grid-template-columns: 1fr; gap: 8px;"
69
71
  >
70
72
  ${items.map((item, index) => this.renderItem(item, index))}
71
73
  </div>
@@ -89,7 +91,8 @@ export abstract class BaseListEditor<
89
91
 
90
92
  if (this.maintainEmptyItem) {
91
93
  const hasEmptyItem = items.some((item) => this.isEmptyItem(item));
92
- if (!hasEmptyItem) {
94
+ // Only add empty item if we haven't reached maxItems and don't already have an empty item
95
+ if (!hasEmptyItem && (!this.maxItems || items.length < this.maxItems)) {
93
96
  items.push(this.createEmptyItem());
94
97
  }
95
98
  }
@@ -111,6 +114,19 @@ export abstract class BaseListEditor<
111
114
  fieldValue: any
112
115
  ) {
113
116
  const updatedItems = [...this._items];
117
+
118
+ // If editing beyond the current array (auto-generated empty row), check maxItems
119
+ if (index >= this._items.length) {
120
+ if (this.maxItems && this._items.length >= this.maxItems) {
121
+ // Don't allow adding new items if we've reached maxItems
122
+ return;
123
+ }
124
+ // Extend the array to include the new item
125
+ while (updatedItems.length <= index) {
126
+ updatedItems.push(this.createEmptyItem());
127
+ }
128
+ }
129
+
114
130
  const currentItem = updatedItems[index] || this.createEmptyItem();
115
131
 
116
132
  updatedItems[index] = {