@nyaruka/temba-components 0.129.7 → 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 (127) 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 +8 -3
  5. package/demo/components/flow/example.html +1 -1
  6. package/demo/components/message-editor/example.html +125 -0
  7. package/demo/components/textinput/completion.html +1 -0
  8. package/demo/data/flows/food-order.json +12 -21
  9. package/demo/data/flows/sample-flow.json +42 -26
  10. package/dist/temba-components.js +506 -218
  11. package/dist/temba-components.js.map +1 -1
  12. package/out-tsc/src/display/Thumbnail.js +2 -1
  13. package/out-tsc/src/display/Thumbnail.js.map +1 -1
  14. package/out-tsc/src/events.js.map +1 -1
  15. package/out-tsc/src/flow/NodeEditor.js +245 -22
  16. package/out-tsc/src/flow/NodeEditor.js.map +1 -1
  17. package/out-tsc/src/flow/actions/call_webhook.js +26 -17
  18. package/out-tsc/src/flow/actions/call_webhook.js.map +1 -1
  19. package/out-tsc/src/flow/actions/send_msg.js +147 -6
  20. package/out-tsc/src/flow/actions/send_msg.js.map +1 -1
  21. package/out-tsc/src/flow/types.js.map +1 -1
  22. package/out-tsc/src/form/ArrayEditor.js +111 -38
  23. package/out-tsc/src/form/ArrayEditor.js.map +1 -1
  24. package/out-tsc/src/form/BaseListEditor.js +19 -4
  25. package/out-tsc/src/form/BaseListEditor.js.map +1 -1
  26. package/out-tsc/src/form/FormField.js +1 -1
  27. package/out-tsc/src/form/FormField.js.map +1 -1
  28. package/out-tsc/src/form/KeyValueEditor.js +1 -1
  29. package/out-tsc/src/form/KeyValueEditor.js.map +1 -1
  30. package/out-tsc/src/form/MediaPicker.js +13 -1
  31. package/out-tsc/src/form/MediaPicker.js.map +1 -1
  32. package/out-tsc/src/form/MessageEditor.js +422 -0
  33. package/out-tsc/src/form/MessageEditor.js.map +1 -0
  34. package/out-tsc/src/form/TextInput.js +12 -5
  35. package/out-tsc/src/form/TextInput.js.map +1 -1
  36. package/out-tsc/src/form/select/Select.js +4 -4
  37. package/out-tsc/src/form/select/Select.js.map +1 -1
  38. package/out-tsc/src/live/ContactChat.js +27 -2
  39. package/out-tsc/src/live/ContactChat.js.map +1 -1
  40. package/out-tsc/temba-modules.js +2 -0
  41. package/out-tsc/temba-modules.js.map +1 -1
  42. package/out-tsc/test/temba-field-config.test.js +4 -2
  43. package/out-tsc/test/temba-field-config.test.js.map +1 -1
  44. package/out-tsc/test/temba-message-editor.test.js +194 -0
  45. package/out-tsc/test/temba-message-editor.test.js.map +1 -0
  46. package/out-tsc/test/temba-node-editor.test.js +71 -0
  47. package/out-tsc/test/temba-node-editor.test.js.map +1 -1
  48. package/out-tsc/test/temba-select.test.js +1 -1
  49. package/out-tsc/test/temba-select.test.js.map +1 -1
  50. package/out-tsc/test/temba-textinput.test.js +16 -0
  51. package/out-tsc/test/temba-textinput.test.js.map +1 -1
  52. package/out-tsc/test/temba-webchat.test.js +4 -0
  53. package/out-tsc/test/temba-webchat.test.js.map +1 -1
  54. package/out-tsc/test/utils.test.js +2 -8
  55. package/out-tsc/test/utils.test.js.map +1 -1
  56. package/package.json +7 -4
  57. package/screenshots/truth/actions/add_contact_groups/editor/descriptive-group-names.png +0 -0
  58. package/screenshots/truth/actions/add_contact_groups/editor/long-group-names.png +0 -0
  59. package/screenshots/truth/actions/add_contact_groups/editor/many-groups.png +0 -0
  60. package/screenshots/truth/actions/add_contact_groups/editor/multiple-groups.png +0 -0
  61. package/screenshots/truth/actions/add_contact_groups/editor/single-group.png +0 -0
  62. package/screenshots/truth/actions/remove_contact_groups/editor/cleanup-groups.png +0 -0
  63. package/screenshots/truth/actions/remove_contact_groups/editor/long-descriptive-group-names.png +0 -0
  64. package/screenshots/truth/actions/remove_contact_groups/editor/many-groups.png +0 -0
  65. package/screenshots/truth/actions/remove_contact_groups/editor/multiple-groups.png +0 -0
  66. package/screenshots/truth/actions/remove_contact_groups/editor/single-group.png +0 -0
  67. package/screenshots/truth/actions/send_email/editor/complex-business-email.png +0 -0
  68. package/screenshots/truth/actions/send_email/editor/empty-body.png +0 -0
  69. package/screenshots/truth/actions/send_email/editor/empty-subject.png +0 -0
  70. package/screenshots/truth/actions/send_email/editor/long-subject.png +0 -0
  71. package/screenshots/truth/actions/send_email/editor/multiline-body.png +0 -0
  72. package/screenshots/truth/actions/send_email/editor/multiple-recipients.png +0 -0
  73. package/screenshots/truth/actions/send_email/editor/simple-email.png +0 -0
  74. package/screenshots/truth/actions/send_email/editor/with-expressions.png +0 -0
  75. package/screenshots/truth/actions/send_msg/editor/long-quick-replies.png +0 -0
  76. package/screenshots/truth/actions/send_msg/editor/multiline-text-with-replies.png +0 -0
  77. package/screenshots/truth/actions/send_msg/editor/simple-text.png +0 -0
  78. package/screenshots/truth/actions/send_msg/editor/text-with-linebreaks.png +0 -0
  79. package/screenshots/truth/actions/send_msg/editor/text-with-many-quick-replies.png +0 -0
  80. package/screenshots/truth/actions/send_msg/editor/text-with-quick-replies.png +0 -0
  81. package/screenshots/truth/actions/send_msg/editor/text-without-quick-replies.png +0 -0
  82. package/screenshots/truth/editor/send_msg.png +0 -0
  83. package/screenshots/truth/editor/set_contact_language.png +0 -0
  84. package/screenshots/truth/editor/set_contact_name.png +0 -0
  85. package/screenshots/truth/editor/set_run_result.png +0 -0
  86. package/screenshots/truth/formfield/markdown-errors.png +0 -0
  87. package/screenshots/truth/formfield/no-errors.png +0 -0
  88. package/screenshots/truth/formfield/plain-text-errors.png +0 -0
  89. package/screenshots/truth/message-editor/autogrow-initial-content.png +0 -0
  90. package/screenshots/truth/message-editor/default.png +0 -0
  91. package/screenshots/truth/message-editor/drag-highlight.png +0 -0
  92. package/screenshots/truth/message-editor/filtered-attachments.png +0 -0
  93. package/screenshots/truth/message-editor/with-completion.png +0 -0
  94. package/screenshots/truth/message-editor/with-properties.png +0 -0
  95. package/screenshots/truth/textinput/autogrow-initial.png +0 -0
  96. package/screenshots/truth/textinput/input-form.png +0 -0
  97. package/src/display/Thumbnail.ts +2 -1
  98. package/src/events.ts +5 -0
  99. package/src/flow/NodeEditor.ts +269 -23
  100. package/src/flow/actions/call_webhook.ts +28 -18
  101. package/src/flow/actions/send_msg.ts +170 -6
  102. package/src/flow/types.ts +21 -2
  103. package/src/form/ArrayEditor.ts +120 -42
  104. package/src/form/BaseListEditor.ts +22 -6
  105. package/src/form/FormField.ts +1 -1
  106. package/src/form/KeyValueEditor.ts +1 -1
  107. package/src/form/MediaPicker.ts +13 -1
  108. package/src/form/MessageEditor.ts +449 -0
  109. package/src/form/TextInput.ts +15 -7
  110. package/src/form/select/Select.ts +4 -4
  111. package/src/live/ContactChat.ts +30 -4
  112. package/static/css/temba-components.css +2 -0
  113. package/static/mr/docs/en-us/editor.json +2588 -0
  114. package/stress-test.js +138 -0
  115. package/temba-modules.ts +2 -0
  116. package/test/temba-field-config.test.ts +4 -2
  117. package/test/temba-message-editor.test.ts +300 -0
  118. package/test/temba-node-editor.test.ts +94 -0
  119. package/test/temba-select.test.ts +1 -1
  120. package/test/temba-textinput.test.ts +26 -0
  121. package/test/temba-webchat.test.ts +5 -0
  122. package/test/utils.test.ts +2 -13
  123. package/test-assets/contacts/history.json +19 -0
  124. package/test-assets/style.css +2 -0
  125. package/web-dev-mock.mjs +433 -0
  126. package/web-dev-server.config.mjs +51 -5
  127. package/web-test-runner.config.mjs +9 -4
package/stress-test.js ADDED
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execSync } from 'child_process';
4
+ import { performance } from 'perf_hooks';
5
+
6
+ // Parse command line arguments
7
+ const args = process.argv.slice(2);
8
+ let testFile = '';
9
+ let maxRuns = 10;
10
+
11
+ // Parse arguments
12
+ for (let i = 0; i < args.length; i++) {
13
+ const arg = args[i];
14
+
15
+ if (arg.startsWith('--runs=')) {
16
+ maxRuns = parseInt(arg.split('=')[1]);
17
+ if (isNaN(maxRuns) || maxRuns <= 0) {
18
+ console.error('❌ Invalid runs value. Must be a positive integer.');
19
+ process.exit(1);
20
+ }
21
+ } else if (arg.startsWith('test/') && arg.endsWith('.test.ts')) {
22
+ testFile = arg;
23
+ } else if (!arg.startsWith('--')) {
24
+ testFile = arg;
25
+ }
26
+ }
27
+
28
+ // Validate test file
29
+ if (!testFile) {
30
+ console.error('❌ Usage: yarn stress-test <test-file> [--runs=N]');
31
+ console.error(' Example: yarn stress-test test/temba-webchat.test.ts --runs=100');
32
+ process.exit(1);
33
+ }
34
+
35
+ if (!testFile.startsWith('test/') || !testFile.endsWith('.test.ts')) {
36
+ console.error('❌ Test file must be in the test/ directory and end with .test.ts');
37
+ process.exit(1);
38
+ }
39
+
40
+ console.log(`🧪 Stress testing: ${testFile}`);
41
+ console.log(`🔄 Running up to ${maxRuns} times (or until failure)`);
42
+ console.log('');
43
+
44
+ let run = 1;
45
+ let totalTime = 0;
46
+ const runTimes = [];
47
+ let failures = 0;
48
+
49
+ const startTime = performance.now();
50
+
51
+ try {
52
+ while (run <= maxRuns) {
53
+ const runStartTime = performance.now();
54
+
55
+ process.stdout.write(`Run ${run.toString().padStart(3)}/${maxRuns}: `);
56
+
57
+ try {
58
+ // Run the test with minimal output
59
+ const result = execSync(`yarn test ${testFile}`, {
60
+ encoding: 'utf-8',
61
+ stdio: ['pipe', 'pipe', 'pipe']
62
+ });
63
+
64
+ const runEndTime = performance.now();
65
+ const runTime = runEndTime - runStartTime;
66
+ runTimes.push(runTime);
67
+ totalTime += runTime;
68
+
69
+ // Check if the test actually passed by looking for success indicators
70
+ if (result.includes('all tests passed') || result.includes('0 failed')) {
71
+ console.log(`✅ PASS (${(runTime / 1000).toFixed(2)}s)`);
72
+ } else {
73
+ console.log(`❌ FAIL (unexpected output)`);
74
+ console.log('Output:', result);
75
+ failures++;
76
+ break;
77
+ }
78
+
79
+ } catch (error) {
80
+ const runEndTime = performance.now();
81
+ const runTime = runEndTime - runStartTime;
82
+
83
+ console.log(`❌ FAIL (${(runTime / 1000).toFixed(2)}s)`);
84
+ console.log('');
85
+ console.log('💥 Test failed on run', run);
86
+ console.log('');
87
+ console.log('Error output:');
88
+ console.log(error.stdout || error.message);
89
+ if (error.stderr) {
90
+ console.log('');
91
+ console.log('Error details:');
92
+ console.log(error.stderr);
93
+ }
94
+ failures++;
95
+ break;
96
+ }
97
+
98
+ run++;
99
+ }
100
+ } catch (error) {
101
+ console.log('');
102
+ console.log('💥 Unexpected error:', error.message);
103
+ process.exit(1);
104
+ }
105
+
106
+ const endTime = performance.now();
107
+ const totalTestTime = endTime - startTime;
108
+
109
+ console.log('');
110
+ console.log('📊 Results Summary');
111
+ console.log('==================');
112
+ console.log(`Test file: ${testFile}`);
113
+ console.log(`Completed runs: ${run - 1}/${maxRuns}`);
114
+ console.log(`Failures: ${failures}`);
115
+ console.log(`Success rate: ${(((run - 1 - failures) / (run - 1)) * 100).toFixed(1)}%`);
116
+ console.log('');
117
+
118
+ if (runTimes.length > 0) {
119
+ const avgTime = runTimes.reduce((a, b) => a + b, 0) / runTimes.length;
120
+ const minTime = Math.min(...runTimes);
121
+ const maxTime = Math.max(...runTimes);
122
+
123
+ console.log('⏱️ Timing Statistics');
124
+ console.log('=====================');
125
+ console.log(`Total time: ${(totalTestTime / 1000).toFixed(2)}s`);
126
+ console.log(`Average run time: ${(avgTime / 1000).toFixed(2)}s`);
127
+ console.log(`Fastest run: ${(minTime / 1000).toFixed(2)}s`);
128
+ console.log(`Slowest run: ${(maxTime / 1000).toFixed(2)}s`);
129
+ console.log('');
130
+ }
131
+
132
+ if (failures === 0) {
133
+ console.log(`🎉 All ${run - 1} runs passed successfully!`);
134
+ process.exit(0);
135
+ } else {
136
+ console.log(`💥 Test failed after ${run - 1} runs`);
137
+ process.exit(1);
138
+ }
package/temba-modules.ts CHANGED
@@ -70,6 +70,7 @@ import { RangePicker } from './src/form/RangePicker';
70
70
  import { NodeEditor } from './src/flow/NodeEditor';
71
71
  import { KeyValueEditor } from './src/form/KeyValueEditor';
72
72
  import { TembaArrayEditor } from './src/form/ArrayEditor';
73
+ import { MessageEditor } from './src/form/MessageEditor';
73
74
  import './src/form/BaseListEditor'; // Import base class
74
75
 
75
76
  export function addCustomElement(name: string, comp: any) {
@@ -151,3 +152,4 @@ addCustomElement('temba-workspace-select', WorkspaceSelect);
151
152
  addCustomElement('temba-chart', TembaChart);
152
153
  addCustomElement('temba-key-value-editor', KeyValueEditor);
153
154
  addCustomElement('temba-array-editor', TembaArrayEditor);
155
+ addCustomElement('temba-message-editor', MessageEditor);
@@ -122,7 +122,8 @@ describe('Field Configuration System', () => {
122
122
  await (el as any).updateComplete;
123
123
 
124
124
  expect(el).to.exist;
125
- expect(el.shadowRoot?.querySelector('.add-btn')).to.exist;
125
+ // ArrayEditor with maintainEmptyItem=true doesn't show add button
126
+ expect(el.shadowRoot?.querySelector('.add-btn')).to.not.exist;
126
127
  });
127
128
 
128
129
  it('should render with initial items', async () => {
@@ -146,7 +147,8 @@ describe('Field Configuration System', () => {
146
147
 
147
148
  expect(el).to.exist;
148
149
  const items = el.shadowRoot?.querySelectorAll('.array-item');
149
- expect(items?.length).to.equal(2);
150
+ // Expects 3 items: 2 initial items + 1 auto-generated empty item
151
+ expect(items?.length).to.equal(3);
150
152
  });
151
153
  });
152
154
  });
@@ -0,0 +1,300 @@
1
+ import { fixture, assert, expect } from '@open-wc/testing';
2
+ import { MessageEditor } from '../src/form/MessageEditor';
3
+ import { assertScreenshot, getClip, getComponent } from './utils.test';
4
+
5
+ export const getHTML = (options: any = {}) => {
6
+ const attrs = Object.keys(options)
7
+ .map((key) => `${key}="${options[key]}"`)
8
+ .join(' ');
9
+ return `<temba-message-editor ${attrs}></temba-message-editor>`;
10
+ };
11
+
12
+ describe('temba-message-editor', () => {
13
+ it('can be created', async () => {
14
+ const editor: MessageEditor = await fixture(getHTML());
15
+ assert.instanceOf(editor, MessageEditor);
16
+ });
17
+
18
+ it('has default properties', async () => {
19
+ const editor = (await getComponent(
20
+ 'temba-message-editor'
21
+ )) as MessageEditor;
22
+
23
+ expect(editor.name).to.equal('');
24
+ expect(editor.value).to.equal('');
25
+ expect(editor.placeholder).to.equal('');
26
+ expect(editor.textarea).to.be.true;
27
+ expect(editor.autogrow).to.be.true;
28
+ expect(editor.minHeight).to.equal(60);
29
+ expect(editor.attachments).to.deep.equal([]);
30
+ expect(editor.maxAttachments).to.equal(3);
31
+
32
+ await assertScreenshot(
33
+ 'message-editor/default',
34
+ getClip(editor as HTMLElement)
35
+ );
36
+ });
37
+
38
+ it('can set properties', async () => {
39
+ const editor = (await getComponent('temba-message-editor', {
40
+ name: 'message',
41
+ value: 'Hello world',
42
+ placeholder: 'Type your message...',
43
+ 'max-attachments': '5'
44
+ })) as MessageEditor;
45
+
46
+ expect(editor.name).to.equal('message');
47
+ expect(editor.value).to.equal('Hello world');
48
+ expect(editor.placeholder).to.equal('Type your message...');
49
+ expect(editor.maxAttachments).to.equal(5);
50
+
51
+ await assertScreenshot(
52
+ 'message-editor/with-properties',
53
+ getClip(editor as HTMLElement)
54
+ );
55
+ });
56
+
57
+ it('renders completion component', async () => {
58
+ const editor = (await getComponent('temba-message-editor', {
59
+ value: 'Test message',
60
+ placeholder: 'Enter message'
61
+ })) as MessageEditor;
62
+
63
+ const completion = editor.shadowRoot.querySelector(
64
+ 'temba-completion'
65
+ ) as any;
66
+ expect(completion).to.not.be.null;
67
+ expect(completion.hasAttribute('widgetOnly')).to.be.true;
68
+
69
+ await assertScreenshot(
70
+ 'message-editor/with-completion',
71
+ getClip(editor as HTMLElement)
72
+ );
73
+ });
74
+
75
+ it('filters runtime attachments', async () => {
76
+ const attachments = [
77
+ 'image/jpeg:http://example.com/image.jpg',
78
+ 'image:@fields.profile_pic',
79
+ 'video:@fields.intro_video',
80
+ 'application/pdf:http://example.com/doc.pdf'
81
+ ];
82
+
83
+ const editor = (await getComponent('temba-message-editor', {
84
+ attachments: JSON.stringify(attachments)
85
+ })) as MessageEditor;
86
+
87
+ // Wait for component to update
88
+ await editor.updateComplete;
89
+
90
+ const mediaPicker = editor.shadowRoot.querySelector(
91
+ 'temba-media-picker'
92
+ ) as any;
93
+ expect(mediaPicker).to.not.be.null;
94
+
95
+ // Should only have the static attachments (those with '/' in content type)
96
+ expect(mediaPicker.attachments.length).to.equal(2);
97
+ expect(mediaPicker.attachments[0].content_type).to.equal('image/jpeg');
98
+ expect(mediaPicker.attachments[1].content_type).to.equal('application/pdf');
99
+
100
+ await assertScreenshot(
101
+ 'message-editor/filtered-attachments',
102
+ getClip(editor as HTMLElement)
103
+ );
104
+ });
105
+
106
+ it('handles completion change events', async () => {
107
+ const editor = (await getComponent(
108
+ 'temba-message-editor'
109
+ )) as MessageEditor;
110
+ let changeEvent: CustomEvent = null;
111
+
112
+ editor.addEventListener('change', (e: CustomEvent) => {
113
+ changeEvent = e;
114
+ });
115
+
116
+ const completion = editor.shadowRoot.querySelector(
117
+ 'temba-completion'
118
+ ) as any;
119
+ completion.value = 'New message';
120
+ completion.dispatchEvent(new Event('change'));
121
+
122
+ expect(editor.value).to.equal('New message');
123
+ expect(changeEvent).to.not.be.null;
124
+ });
125
+
126
+ it('handles media picker change events', async () => {
127
+ const editor = (await getComponent(
128
+ 'temba-message-editor'
129
+ )) as MessageEditor;
130
+ let changeEvent: CustomEvent = null;
131
+
132
+ editor.addEventListener('change', (e: CustomEvent) => {
133
+ changeEvent = e;
134
+ });
135
+
136
+ const mediaPicker = editor.shadowRoot.querySelector(
137
+ 'temba-media-picker'
138
+ ) as any;
139
+ mediaPicker.attachments = [
140
+ {
141
+ content_type: 'image/jpeg',
142
+ url: 'http://example.com/test.jpg',
143
+ filename: 'test.jpg',
144
+ size: 1024
145
+ }
146
+ ];
147
+ mediaPicker.dispatchEvent(new Event('change'));
148
+
149
+ expect(editor.attachments).to.include(
150
+ 'image/jpeg:http://example.com/test.jpg'
151
+ );
152
+ expect(changeEvent).to.not.be.null;
153
+ });
154
+
155
+ it('preserves runtime attachments when media changes', async () => {
156
+ const initialAttachments = [
157
+ 'image:@fields.profile_pic',
158
+ 'image/jpeg:http://example.com/existing.jpg'
159
+ ];
160
+
161
+ const editor = (await getComponent('temba-message-editor', {
162
+ attachments: JSON.stringify(initialAttachments)
163
+ })) as MessageEditor;
164
+
165
+ await editor.updateComplete;
166
+
167
+ // Simulate media picker change
168
+ const mediaPicker = editor.shadowRoot.querySelector(
169
+ 'temba-media-picker'
170
+ ) as any;
171
+ mediaPicker.attachments = [
172
+ {
173
+ content_type: 'image/png',
174
+ url: 'http://example.com/new.png',
175
+ filename: 'new.png',
176
+ size: 2048
177
+ }
178
+ ];
179
+ mediaPicker.dispatchEvent(new Event('change'));
180
+
181
+ // Should preserve runtime attachments and add new static ones
182
+ expect(editor.attachments).to.include('image:@fields.profile_pic');
183
+ expect(editor.attachments).to.include(
184
+ 'image/png:http://example.com/new.png'
185
+ );
186
+ expect(editor.attachments.length).to.equal(2);
187
+ });
188
+
189
+ it('supports drag and drop highlighting', async () => {
190
+ const editor = (await getComponent(
191
+ 'temba-message-editor'
192
+ )) as MessageEditor;
193
+ const container = editor.shadowRoot.querySelector(
194
+ '.message-editor-container'
195
+ );
196
+
197
+ // Simulate drag enter
198
+ const dragEvent = new DragEvent('dragenter', {
199
+ bubbles: true,
200
+ dataTransfer: new DataTransfer()
201
+ });
202
+ container.dispatchEvent(dragEvent);
203
+
204
+ // Wait for the update
205
+ await editor.updateComplete;
206
+
207
+ expect(editor.pendingDrop).to.be.true;
208
+ expect(container.classList.contains('highlight')).to.be.true;
209
+
210
+ await assertScreenshot(
211
+ 'message-editor/drag-highlight',
212
+ getClip(editor as HTMLElement)
213
+ );
214
+
215
+ // Simulate drag leave
216
+ const dragLeaveEvent = new DragEvent('dragleave', {
217
+ bubbles: true,
218
+ dataTransfer: new DataTransfer()
219
+ });
220
+ container.dispatchEvent(dragLeaveEvent);
221
+
222
+ expect(editor.pendingDrop).to.be.false;
223
+ });
224
+
225
+ it('focuses completion on focus', async () => {
226
+ const editor = (await getComponent(
227
+ 'temba-message-editor'
228
+ )) as MessageEditor;
229
+ const completion = editor.shadowRoot.querySelector(
230
+ 'temba-completion'
231
+ ) as any;
232
+
233
+ let focusCalled = false;
234
+ completion.focus = () => {
235
+ focusCalled = true;
236
+ };
237
+
238
+ editor.focus();
239
+ expect(focusCalled).to.be.true;
240
+ });
241
+
242
+ it('clicks completion on click', async () => {
243
+ const editor = (await getComponent(
244
+ 'temba-message-editor'
245
+ )) as MessageEditor;
246
+ const completion = editor.shadowRoot.querySelector(
247
+ 'temba-completion'
248
+ ) as any;
249
+
250
+ let clickCalled = false;
251
+ completion.click = () => {
252
+ clickCalled = true;
253
+ };
254
+
255
+ editor.click();
256
+ expect(clickCalled).to.be.true;
257
+ });
258
+
259
+ it('initializes with correct height for long text content', async () => {
260
+ const longText =
261
+ 'This is a very long text that should span multiple lines and cause the autogrow functionality to kick in and expand the textarea to accommodate all the content. This text should be long enough to trigger the autogrow behavior during initialization.';
262
+
263
+ const editor = (await getComponent('temba-message-editor', {
264
+ value: longText,
265
+ 'min-height': '60'
266
+ })) as MessageEditor;
267
+
268
+ // Wait for component to fully render
269
+ await editor.updateComplete;
270
+
271
+ // Get the text input element to verify its height
272
+ const completion = editor.shadowRoot.querySelector(
273
+ 'temba-completion'
274
+ ) as any;
275
+ expect(completion).to.not.be.null;
276
+
277
+ // The completion should have the long text value
278
+ expect(completion.value).to.equal(longText);
279
+
280
+ // Get the actual TextInput component inside the completion
281
+ const textInput = completion.getTextInput();
282
+ expect(textInput).to.not.be.null;
283
+
284
+ // The textarea should be in autogrow mode
285
+ expect(textInput.autogrow).to.be.true;
286
+ expect(textInput.textarea).to.be.true;
287
+
288
+ // Check that the autogrow div has been updated with content
289
+ const autogrowDiv = textInput.shadowRoot.querySelector(
290
+ '.grow-wrap > div'
291
+ ) as HTMLDivElement;
292
+ expect(autogrowDiv).to.not.be.null;
293
+ expect(autogrowDiv.innerText).to.include(longText);
294
+
295
+ await assertScreenshot(
296
+ 'message-editor/autogrow-initial-content',
297
+ getClip(editor as HTMLElement)
298
+ );
299
+ });
300
+ });
@@ -48,6 +48,34 @@ describe('temba-node-editor', () => {
48
48
  expect(el.action).to.equal(action);
49
49
  });
50
50
 
51
+ it('renders send_msg action with message editor', async () => {
52
+ const action = {
53
+ uuid: 'test-action-uuid',
54
+ type: 'send_msg',
55
+ text: 'Hello @contact.name, check this out!',
56
+ attachments: [
57
+ 'image/jpeg:http://example.com/photo.jpg',
58
+ 'image:@fields.profile_pic'
59
+ ],
60
+ quick_replies: ['Yes', 'No']
61
+ };
62
+
63
+ const el = (await fixture(html`
64
+ <temba-node-editor .action=${action} .isOpen=${true}></temba-node-editor>
65
+ `)) as NodeEditorElement;
66
+
67
+ await el.updateComplete;
68
+ expect(el.shadowRoot).to.not.be.null;
69
+ expect(el.action).to.equal(action);
70
+
71
+ // Check that the message editor component is rendered
72
+ const messageEditor = el.shadowRoot.querySelector(
73
+ 'temba-message-editor'
74
+ ) as any;
75
+ expect(messageEditor).to.not.be.null;
76
+ expect(messageEditor.value).to.equal(action.text);
77
+ });
78
+
51
79
  it('renders set_run_result action', async () => {
52
80
  const action = {
53
81
  uuid: 'test-action-uuid',
@@ -350,4 +378,70 @@ describe('temba-node-editor', () => {
350
378
  await assertDialogScreenshot(el, `editor/${actionType.type}`);
351
379
  }
352
380
  });
381
+
382
+ it('displays bubble count for group value counts', async () => {
383
+ const action = {
384
+ uuid: 'test-action-uuid',
385
+ type: 'send_msg',
386
+ text: 'Hello world',
387
+ quick_replies: ['Yes', 'No', 'Maybe'],
388
+ attachments: ['image:@contact.photo', 'document:@contact.resume']
389
+ };
390
+
391
+ const el = (await fixture(html`
392
+ <temba-node-editor .action=${action} .isOpen=${true}></temba-node-editor>
393
+ `)) as NodeEditorElement;
394
+
395
+ await el.updateComplete;
396
+
397
+ // Wait for form data to be fully initialized and re-render to complete
398
+ await new Promise((resolve) => setTimeout(resolve, 200));
399
+ await el.updateComplete;
400
+
401
+ // Check that bubble counts are displayed
402
+ const shadowRoot = el.shadowRoot;
403
+ const bubbles = shadowRoot.querySelectorAll('.group-count-bubble');
404
+
405
+ // Should have bubbles for groups with values
406
+ expect(bubbles.length).to.be.greaterThan(0);
407
+
408
+ // Check specific bubble values (trim to handle whitespace in rendered text)
409
+ const bubbleTexts = Array.from(bubbles).map((bubble) =>
410
+ bubble.textContent?.trim()
411
+ );
412
+
413
+ // Runtime attachments group should show bubble when collapsed and has values
414
+ expect(bubbleTexts).to.include('2'); // 2 runtime attachments
415
+ // Note: Quick replies group auto-expands when it has content, so no bubble is shown
416
+ });
417
+
418
+ it('shows arrow when group has no values', async () => {
419
+ const action = {
420
+ uuid: 'test-action-uuid',
421
+ type: 'send_msg',
422
+ text: 'Hello world'
423
+ // No quick_replies or attachments provided
424
+ };
425
+
426
+ const el = (await fixture(html`
427
+ <temba-node-editor .action=${action} .isOpen=${true}></temba-node-editor>
428
+ `)) as NodeEditorElement;
429
+
430
+ await el.updateComplete;
431
+
432
+ // Wait for form data initialization
433
+ await new Promise((resolve) => setTimeout(resolve, 200));
434
+ await el.updateComplete;
435
+
436
+ // Check that arrows are displayed instead of bubbles
437
+ const shadowRoot = el.shadowRoot;
438
+ const bubbles = shadowRoot.querySelectorAll('.group-count-bubble');
439
+ const arrows = shadowRoot.querySelectorAll('.group-toggle-icon');
440
+
441
+ // Should have no bubbles when counts are 0
442
+ expect(bubbles.length).to.equal(0);
443
+
444
+ // Should have arrows for collapsible groups
445
+ expect(arrows.length).to.be.greaterThan(0);
446
+ });
353
447
  });
@@ -927,7 +927,7 @@ describe('temba-select', () => {
927
927
 
928
928
  await openSelect(clock, select);
929
929
  // Cached results should be available immediately, but give some time for rendering
930
- await waitForSelectPagination(select, clock, 15, 30);
930
+ await waitForSelectPagination(select, clock, 15, 10);
931
931
  assert.equal(select.visibleOptions.length, 15);
932
932
 
933
933
  // close and reopen once more (previous bug failed on third opening)
@@ -197,4 +197,30 @@ describe('temba-textinput', () => {
197
197
  await assertScreenshot('textinput/input-updated', getClip(input));
198
198
  expect(widget.value).to.equal('Updated by attribute change');
199
199
  });
200
+
201
+ it('initializes autogrow with content', async () => {
202
+ const longText =
203
+ 'This is a very long text that should span multiple lines and cause the autogrow functionality to kick in and expand the textarea to accommodate all the content during initialization.';
204
+
205
+ const input: TextInput = await createInput(
206
+ getInputHTML({
207
+ value: longText,
208
+ textarea: true,
209
+ autogrow: true
210
+ })
211
+ );
212
+
213
+ // Wait for component to fully render
214
+ await input.updateComplete;
215
+
216
+ // Check that autogrow div has been updated with initial content
217
+ const autogrowDiv = input.shadowRoot.querySelector(
218
+ '.grow-wrap > div'
219
+ ) as HTMLDivElement;
220
+ expect(autogrowDiv).to.not.be.null;
221
+ expect(autogrowDiv.innerText).to.include(longText);
222
+ expect(autogrowDiv.innerText).to.include('\n'); // Should have the newline character added
223
+
224
+ await assertScreenshot('textinput/autogrow-initial', getClip(input));
225
+ });
200
226
  });
@@ -16,6 +16,11 @@ const TAG = 'temba-webchat';
16
16
  const getWebChat = async (attrs: any = {}) => {
17
17
  const webChat = (await getComponent(TAG, attrs, '', 400, 600)) as WebChat;
18
18
 
19
+ // Ensure component is fully initialized before returning
20
+ await webChat.updateComplete;
21
+ clock.tick(100);
22
+ await webChat.updateComplete;
23
+
19
24
  return webChat;
20
25
  };
21
26
 
@@ -7,7 +7,7 @@ interface Clip {
7
7
  height: number;
8
8
  }
9
9
 
10
- import { expect, fixture, html, assert, waitUntil } from '@open-wc/testing';
10
+ import { expect, fixture, html, assert } from '@open-wc/testing';
11
11
  import MouseHelper from './MouseHelper';
12
12
  import { Store } from '../src/store/Store';
13
13
  import { stub } from 'sinon';
@@ -200,18 +200,7 @@ export const waitForCondition = async (
200
200
  }
201
201
  };
202
202
 
203
- export const assertScreenshot = async (
204
- filename: string,
205
- clip: Clip,
206
- waitFor?: { clock?: any; predicate?: () => boolean }
207
- ) => {
208
- if (waitFor) {
209
- if (waitFor.clock) {
210
- waitFor.clock.restore();
211
- }
212
- await waitUntil(waitFor.predicate);
213
- }
214
-
203
+ export const assertScreenshot = async (filename: string, clip: Clip) => {
215
204
  // detect if we're running in copilot's environment and use adaptive threshold
216
205
  const isCopilotEnvironment = (window as any).isCopilotEnvironment;
217
206
  const threshold = isCopilotEnvironment ? 1.0 : 0.1;
@@ -217,6 +217,25 @@
217
217
  },
218
218
  "logs_url": null
219
219
  },
220
+ {
221
+ "type": "run_started",
222
+ "created_on": "2021-03-30T22:20:26.704467+00:00",
223
+ "run_uuid": "0198c846-2cf4-7992-9bd0-9b6581e42358",
224
+ "flow": {
225
+ "uuid": "d076d716-8071-417e-b188-d9746db223a1",
226
+ "name": "Favorites"
227
+ }
228
+ },
229
+ {
230
+ "type": "run_ended",
231
+ "created_on": "2021-03-30T22:20:26.704467+00:00",
232
+ "run_uuid": "0198c846-da2b-799f-8af9-aaf8857f947f",
233
+ "flow": {
234
+ "uuid": "d076d716-8071-417e-b188-d9746db223a1",
235
+ "name": "Favorites"
236
+ },
237
+ "status": "completed"
238
+ },
220
239
  {
221
240
  "uuid": "01988a77-979e-7768-a940-8d9c348e24fe",
222
241
  "type": "msg_received",
@@ -60,6 +60,8 @@ html {
60
60
  --error-rgb: 255, 99, 71;
61
61
  --success-rgb: 102, 186, 104;
62
62
 
63
+ --color-label: #333;
64
+
63
65
  --selection-light-rgb: 240, 240, 240;
64
66
  --selection-dark-rgb: 180, 180, 180;
65
67