@nyaruka/temba-components 0.124.3 → 0.126.0

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 (61) hide show
  1. package/.eslintrc.js +3 -1
  2. package/CHANGELOG.md +23 -0
  3. package/demo/data/flows/sample-flow.json +926 -0
  4. package/demo/data/server/sample-flow.json +0 -0
  5. package/demo/flow/example.html +46 -0
  6. package/demo/index.html +155 -144
  7. package/demo/webchat/example.html +71 -0
  8. package/dist/temba-components.js +89 -41
  9. package/dist/temba-components.js.map +1 -1
  10. package/out-tsc/src/chart/TembaChart.js +118 -48
  11. package/out-tsc/src/chart/TembaChart.js.map +1 -1
  12. package/out-tsc/src/flow/Editor.js +70 -5
  13. package/out-tsc/src/flow/Editor.js.map +1 -1
  14. package/out-tsc/src/flow/EditorNode.js +140 -4
  15. package/out-tsc/src/flow/EditorNode.js.map +1 -1
  16. package/out-tsc/src/flow/Plumber.js +57 -0
  17. package/out-tsc/src/flow/Plumber.js.map +1 -1
  18. package/out-tsc/src/flow/config.js +70 -20
  19. package/out-tsc/src/flow/config.js.map +1 -1
  20. package/out-tsc/src/formfield/FormField.js +4 -1
  21. package/out-tsc/src/formfield/FormField.js.map +1 -1
  22. package/out-tsc/src/interfaces.js +1 -0
  23. package/out-tsc/src/interfaces.js.map +1 -1
  24. package/out-tsc/src/store/AppState.js +22 -5
  25. package/out-tsc/src/store/AppState.js.map +1 -1
  26. package/out-tsc/src/utils/index.js +3 -0
  27. package/out-tsc/src/utils/index.js.map +1 -1
  28. package/out-tsc/src/webchat/WebChat.js +2 -0
  29. package/out-tsc/src/webchat/WebChat.js.map +1 -1
  30. package/out-tsc/test/temba-chart.test.js +29 -15
  31. package/out-tsc/test/temba-chart.test.js.map +1 -1
  32. package/out-tsc/test/temba-flow-node-drag.test.js +257 -0
  33. package/out-tsc/test/temba-flow-node-drag.test.js.map +1 -0
  34. package/out-tsc/test/temba-formfield.test.js +94 -0
  35. package/out-tsc/test/temba-formfield.test.js.map +1 -0
  36. package/out-tsc/test/temba-integration-markdown.test.js +36 -0
  37. package/out-tsc/test/temba-integration-markdown.test.js.map +1 -0
  38. package/out-tsc/test/temba-select.test.js +14 -1
  39. package/out-tsc/test/temba-select.test.js.map +1 -1
  40. package/package.json +2 -1
  41. package/screenshots/truth/formfield/markdown-errors.png +0 -0
  42. package/screenshots/truth/formfield/no-errors.png +0 -0
  43. package/screenshots/truth/formfield/plain-text-errors.png +0 -0
  44. package/screenshots/truth/formfield/widget-only-markdown-errors.png +0 -0
  45. package/screenshots/truth/integration/checkbox-markdown-errors.png +0 -0
  46. package/src/chart/TembaChart.ts +130 -48
  47. package/src/flow/Editor.ts +78 -6
  48. package/src/flow/EditorNode.ts +164 -4
  49. package/src/flow/Plumber.ts +65 -0
  50. package/src/flow/config.ts +71 -20
  51. package/src/formfield/FormField.ts +4 -1
  52. package/src/interfaces.ts +2 -1
  53. package/src/store/AppState.ts +28 -7
  54. package/src/utils/index.ts +3 -0
  55. package/src/webchat/WebChat.ts +2 -0
  56. package/test/temba-chart.test.ts +35 -15
  57. package/test/temba-flow-node-drag.test.ts +337 -0
  58. package/test/temba-formfield.test.ts +121 -0
  59. package/test/temba-integration-markdown.test.ts +45 -0
  60. package/test/temba-select.test.ts +17 -0
  61. package/web-dev-server.config.mjs +43 -0
@@ -412,6 +412,7 @@ export class WebChat extends LitElement {
412
412
  private sendSockMessage(
413
413
  cmd: GetHistoryCmd | StartChatCmd | SendMsgCmd | Ack
414
414
  ) {
415
+ // eslint-disable-next-line no-console
415
416
  console.log('out', cmd);
416
417
  this.sock.send(JSON.stringify(cmd));
417
418
  }
@@ -447,6 +448,7 @@ export class WebChat extends LitElement {
447
448
  this.sock.onmessage = function (event: MessageEvent) {
448
449
  webChat.status = ChatStatus.CONNECTED;
449
450
  const msg = JSON.parse(event.data) as SockMsg;
451
+ // eslint-disable-next-line no-console
450
452
  console.log('in', msg);
451
453
  if (msg.type === 'chat_started') {
452
454
  const response = msg as StartChatResponse;
@@ -61,11 +61,11 @@ describe('temba-chart', () => {
61
61
  const chart: TembaChart = await getChart();
62
62
 
63
63
  // Test that formatDuration property exists and defaults to false
64
- expect(chart.formatDuration).to.equal(false);
64
+ expect(chart.yType).to.equal('count');
65
65
 
66
66
  // Test that we can set formatDuration to true
67
- chart.formatDuration = true;
68
- expect(chart.formatDuration).to.equal(true);
67
+ chart.yType = 'duration';
68
+ expect(chart.yType).to.equal('duration');
69
69
  });
70
70
 
71
71
  it('formats duration values correctly', async () => {
@@ -83,7 +83,7 @@ describe('temba-chart', () => {
83
83
  ]
84
84
  };
85
85
 
86
- chart.formatDuration = true;
86
+ chart.yType = 'duration';
87
87
  chart.data = durationData;
88
88
  await chart.updateComplete;
89
89
 
@@ -91,7 +91,7 @@ describe('temba-chart', () => {
91
91
  await new Promise((resolve) => setTimeout(resolve, 100));
92
92
 
93
93
  // Test that the chart was created and has the duration formatting enabled
94
- expect(chart.formatDuration).to.equal(true);
94
+ expect(chart.yType).to.equal('duration');
95
95
  expect(chart.chart).to.exist;
96
96
 
97
97
  // Test that the chart configuration includes the duration formatting
@@ -115,6 +115,7 @@ describe('temba-chart', () => {
115
115
  dataset: { label: 'Process Time' },
116
116
  parsed: { y: 68787 }
117
117
  };
118
+
118
119
  expect(tooltipCallback.call({}, mockContext)).to.equal(
119
120
  'Process Time: 19h 6m'
120
121
  );
@@ -122,7 +123,7 @@ describe('temba-chart', () => {
122
123
 
123
124
  it('formats various duration edge cases correctly', async () => {
124
125
  const chart: TembaChart = await getChart();
125
- chart.formatDuration = true;
126
+ chart.yType = 'duration';
126
127
  chart.data = sampleData;
127
128
  await chart.updateComplete;
128
129
 
@@ -146,21 +147,40 @@ describe('temba-chart', () => {
146
147
  expect(tickCallback.call({}, 1209600, 10, [])).to.equal('14d'); // 2 weeks in seconds
147
148
  });
148
149
 
149
- it('respects formatDuration property state', async () => {
150
- const chart: TembaChart = await getChart();
150
+ it('applies xFormat when xType is time', async () => {
151
+ const chart: TembaChart = await getChart({
152
+ xType: 'time',
153
+ xFormat: 'DD'
154
+ });
155
+
156
+ chart.data = sampleData;
157
+ await chart.updateComplete;
158
+
159
+ // Wait for the chart to be created after data is set
160
+ await new Promise((resolve) => setTimeout(resolve, 50));
161
+
162
+ expect(chart.chart).to.exist;
163
+ expect(chart.chart.options.scales.x.type).to.equal('time');
164
+ expect(
165
+ (chart.chart.options.scales.x as any).time.displayFormats.day
166
+ ).to.equal('DD');
167
+ });
151
168
 
152
- // Test default state
153
- expect(chart.formatDuration).to.equal(false);
169
+ it('does not include time config when xType is category', async () => {
170
+ const chart: TembaChart = await getChart({
171
+ xType: 'category',
172
+ xFormat: 'DD'
173
+ });
154
174
 
155
175
  chart.data = sampleData;
156
176
  await chart.updateComplete;
157
177
 
158
- // Test that formatDuration property can be toggled
159
- chart.formatDuration = true;
160
- expect(chart.formatDuration).to.equal(true);
178
+ // Wait for the chart to be created after data is set
179
+ await new Promise((resolve) => setTimeout(resolve, 50));
161
180
 
162
- chart.formatDuration = false;
163
- expect(chart.formatDuration).to.equal(false);
181
+ expect(chart.chart).to.exist;
182
+ expect(chart.chart.options.scales.x.type).to.equal('category');
183
+ expect((chart.chart.options.scales.x as any).time).to.be.undefined;
164
184
  });
165
185
  });
166
186
 
@@ -0,0 +1,337 @@
1
+ import { fixture, assert } from '@open-wc/testing';
2
+ import { html } from 'lit';
3
+
4
+ describe('temba-flow-node drag and drop functionality', () => {
5
+ it('should add drag styling and event listeners to elements', async () => {
6
+ // Create a simple div to test our drag functionality
7
+ const testElement = await fixture(html`
8
+ <div
9
+ class="node"
10
+ style="position: absolute; left: 100px; top: 100px; width: 200px; height: 100px; background: white; border: 1px solid #ccc; cursor: move;"
11
+ >
12
+ Test Node
13
+ <div class="exit" style="padding: 5px; background: red;">Exit</div>
14
+ </div>
15
+ `);
16
+
17
+ // Test that the node has the correct cursor style
18
+ const computedStyle = window.getComputedStyle(testElement);
19
+ assert.equal(computedStyle.cursor, 'move');
20
+
21
+ // Test drag event simulation
22
+ let dragStarted = false;
23
+ let dragEnded = false;
24
+ let newPosition = { left: 0, top: 0 };
25
+
26
+ // Simulate our drag implementation
27
+ let isDragging = false;
28
+ let dragStartPos = { x: 0, y: 0 };
29
+ let nodeStartPos = { left: 100, top: 100 };
30
+
31
+ const handleMouseDown = (event: MouseEvent) => {
32
+ const target = event.target as HTMLElement;
33
+ if (target.classList.contains('exit') || target.closest('.exit')) {
34
+ return;
35
+ }
36
+
37
+ isDragging = true;
38
+ dragStarted = true;
39
+ dragStartPos = { x: event.clientX, y: event.clientY };
40
+ nodeStartPos = { left: 100, top: 100 };
41
+ event.preventDefault();
42
+ event.stopPropagation();
43
+ };
44
+
45
+ const handleMouseMove = (event: MouseEvent) => {
46
+ if (!isDragging) return;
47
+
48
+ const deltaX = event.clientX - dragStartPos.x;
49
+ const deltaY = event.clientY - dragStartPos.y;
50
+
51
+ const newLeft = nodeStartPos.left + deltaX;
52
+ const newTop = nodeStartPos.top + deltaY;
53
+
54
+ // Update position
55
+ (testElement as HTMLElement).style.left = `${newLeft}px`;
56
+ (testElement as HTMLElement).style.top = `${newTop}px`;
57
+ };
58
+
59
+ const handleMouseUp = (event: MouseEvent) => {
60
+ if (!isDragging) return;
61
+
62
+ isDragging = false;
63
+ dragEnded = true;
64
+
65
+ const deltaX = event.clientX - dragStartPos.x;
66
+ const deltaY = event.clientY - dragStartPos.y;
67
+
68
+ newPosition = {
69
+ left: nodeStartPos.left + deltaX,
70
+ top: nodeStartPos.top + deltaY
71
+ };
72
+ };
73
+
74
+ // Add event listeners
75
+ testElement.addEventListener('mousedown', handleMouseDown);
76
+ document.addEventListener('mousemove', handleMouseMove);
77
+ document.addEventListener('mouseup', handleMouseUp);
78
+
79
+ // Test drag from node (should work)
80
+ const mouseDownEvent = new MouseEvent('mousedown', {
81
+ clientX: 150,
82
+ clientY: 150,
83
+ bubbles: true
84
+ });
85
+ testElement.dispatchEvent(mouseDownEvent);
86
+
87
+ assert.isTrue(dragStarted, 'Drag should start when clicking on node');
88
+
89
+ const mouseMoveEvent = new MouseEvent('mousemove', {
90
+ clientX: 200,
91
+ clientY: 200,
92
+ bubbles: true
93
+ });
94
+ document.dispatchEvent(mouseMoveEvent);
95
+
96
+ const mouseUpEvent = new MouseEvent('mouseup', {
97
+ clientX: 200,
98
+ clientY: 200,
99
+ bubbles: true
100
+ });
101
+ document.dispatchEvent(mouseUpEvent);
102
+
103
+ assert.isTrue(dragEnded, 'Drag should end on mouse up');
104
+ assert.equal(
105
+ newPosition.left,
106
+ 150,
107
+ 'New left position should be calculated correctly'
108
+ );
109
+ assert.equal(
110
+ newPosition.top,
111
+ 150,
112
+ 'New top position should be calculated correctly'
113
+ );
114
+
115
+ // Test that position was visually updated
116
+ assert.equal((testElement as HTMLElement).style.left, '150px');
117
+ assert.equal((testElement as HTMLElement).style.top, '150px');
118
+
119
+ // Reset for next test
120
+ dragStarted = false;
121
+ dragEnded = false;
122
+
123
+ // Test drag from exit element (should not work)
124
+ const exitElement = testElement.querySelector('.exit') as HTMLElement;
125
+ const exitMouseDownEvent = new MouseEvent('mousedown', {
126
+ clientX: 150,
127
+ clientY: 150,
128
+ bubbles: true
129
+ });
130
+ exitElement.dispatchEvent(exitMouseDownEvent);
131
+
132
+ assert.isFalse(
133
+ dragStarted,
134
+ 'Drag should not start when clicking on exit element'
135
+ );
136
+
137
+ // Clean up
138
+ document.removeEventListener('mousemove', handleMouseMove);
139
+ document.removeEventListener('mouseup', handleMouseUp);
140
+ });
141
+
142
+ it('should snap node positions to 20px grid during drag and drop', async () => {
143
+ // Create a test node element
144
+ const testElement = await fixture(html`
145
+ <div
146
+ class="node"
147
+ style="position: absolute; left: 100px; top: 100px; width: 200px; height: 100px; background: white; border: 1px solid #ccc; cursor: move;"
148
+ >
149
+ Test Node for Grid Snapping
150
+ </div>
151
+ `);
152
+
153
+ // Helper function to snap values to 20px grid (same as implementation)
154
+ const snapToGrid = (value: number): number => {
155
+ return Math.round(value / 20) * 20;
156
+ };
157
+
158
+ // Simulate drag implementation with grid snapping
159
+ let isDragging = false;
160
+ let dragStartPos = { x: 0, y: 0 };
161
+ let nodeStartPos = { left: 100, top: 100 };
162
+ let finalPosition = { left: 0, top: 0 };
163
+
164
+ const handleMouseDown = (event: MouseEvent) => {
165
+ isDragging = true;
166
+ dragStartPos = { x: event.clientX, y: event.clientY };
167
+ // Get current position from element style
168
+ const currentLeft =
169
+ parseInt((testElement as HTMLElement).style.left) || 0;
170
+ const currentTop = parseInt((testElement as HTMLElement).style.top) || 0;
171
+ nodeStartPos = { left: currentLeft, top: currentTop };
172
+ event.preventDefault();
173
+ event.stopPropagation();
174
+ };
175
+
176
+ const handleMouseMove = (event: MouseEvent) => {
177
+ if (!isDragging) return;
178
+
179
+ const deltaX = event.clientX - dragStartPos.x;
180
+ const deltaY = event.clientY - dragStartPos.y;
181
+
182
+ const newLeft = nodeStartPos.left + deltaX;
183
+ const newTop = nodeStartPos.top + deltaY;
184
+
185
+ // Snap to 20px grid
186
+ const snappedLeft = snapToGrid(newLeft);
187
+ const snappedTop = snapToGrid(newTop);
188
+
189
+ // Update position with snapped values
190
+ (testElement as HTMLElement).style.left = `${snappedLeft}px`;
191
+ (testElement as HTMLElement).style.top = `${snappedTop}px`;
192
+ };
193
+
194
+ const handleMouseUp = (event: MouseEvent) => {
195
+ if (!isDragging) return;
196
+ isDragging = false;
197
+
198
+ const deltaX = event.clientX - dragStartPos.x;
199
+ const deltaY = event.clientY - dragStartPos.y;
200
+
201
+ const newLeft = nodeStartPos.left + deltaX;
202
+ const newTop = nodeStartPos.top + deltaY;
203
+
204
+ // Snap final position to grid
205
+ finalPosition = {
206
+ left: snapToGrid(newLeft),
207
+ top: snapToGrid(newTop)
208
+ };
209
+ };
210
+
211
+ // Add event listeners
212
+ testElement.addEventListener('mousedown', handleMouseDown);
213
+ document.addEventListener('mousemove', handleMouseMove);
214
+ document.addEventListener('mouseup', handleMouseUp);
215
+
216
+ // Test Case 1: Drag to position that should snap to grid
217
+ // Starting at (100, 100), drag by (33, 27) pixels
218
+ // Should result in (133, 127) -> snapped to (140, 120)
219
+ const mouseDownEvent1 = new MouseEvent('mousedown', {
220
+ clientX: 150,
221
+ clientY: 150,
222
+ bubbles: true
223
+ });
224
+ testElement.dispatchEvent(mouseDownEvent1);
225
+
226
+ const mouseMoveEvent1 = new MouseEvent('mousemove', {
227
+ clientX: 183, // 150 + 33
228
+ clientY: 177, // 150 + 27
229
+ bubbles: true
230
+ });
231
+ document.dispatchEvent(mouseMoveEvent1);
232
+
233
+ // Check that position is snapped during drag
234
+ assert.equal((testElement as HTMLElement).style.left, '140px');
235
+ assert.equal((testElement as HTMLElement).style.top, '120px');
236
+
237
+ const mouseUpEvent1 = new MouseEvent('mouseup', {
238
+ clientX: 183,
239
+ clientY: 177,
240
+ bubbles: true
241
+ });
242
+ document.dispatchEvent(mouseUpEvent1);
243
+
244
+ // Check final snapped position
245
+ assert.equal(
246
+ finalPosition.left,
247
+ 140,
248
+ 'Final left position should be snapped to 140px'
249
+ );
250
+ assert.equal(
251
+ finalPosition.top,
252
+ 120,
253
+ 'Final top position should be snapped to 120px'
254
+ );
255
+
256
+ // Test Case 2: Test different snap scenarios
257
+ // Reset position
258
+ nodeStartPos = { left: 60, top: 80 };
259
+ (testElement as HTMLElement).style.left = '60px';
260
+ (testElement as HTMLElement).style.top = '80px';
261
+
262
+ const mouseDownEvent2 = new MouseEvent('mousedown', {
263
+ clientX: 100,
264
+ clientY: 100,
265
+ bubbles: true
266
+ });
267
+ testElement.dispatchEvent(mouseDownEvent2);
268
+
269
+ // Drag by (15, 25) -> should snap to nearest 20px grid
270
+ // (60 + 15, 80 + 25) = (75, 105) -> snapped to (80, 100)
271
+ const mouseMoveEvent2 = new MouseEvent('mousemove', {
272
+ clientX: 115, // 100 + 15
273
+ clientY: 125, // 100 + 25
274
+ bubbles: true
275
+ });
276
+ document.dispatchEvent(mouseMoveEvent2);
277
+
278
+ assert.equal((testElement as HTMLElement).style.left, '80px');
279
+ assert.equal((testElement as HTMLElement).style.top, '100px');
280
+
281
+ const mouseUpEvent2 = new MouseEvent('mouseup', {
282
+ clientX: 115,
283
+ clientY: 125,
284
+ bubbles: true
285
+ });
286
+ document.dispatchEvent(mouseUpEvent2);
287
+
288
+ assert.equal(
289
+ finalPosition.left,
290
+ 80,
291
+ 'Final left position should be snapped to 80px'
292
+ );
293
+ assert.equal(
294
+ finalPosition.top,
295
+ 100,
296
+ 'Final top position should be snapped to 100px'
297
+ );
298
+
299
+ // Test Case 3: Test exact grid positions remain unchanged
300
+ nodeStartPos = { left: 120, top: 160 }; // Already on grid
301
+ (testElement as HTMLElement).style.left = '120px';
302
+ (testElement as HTMLElement).style.top = '160px';
303
+
304
+ const mouseDownEvent3 = new MouseEvent('mousedown', {
305
+ clientX: 200,
306
+ clientY: 200,
307
+ bubbles: true
308
+ });
309
+ testElement.dispatchEvent(mouseDownEvent3);
310
+
311
+ // Small movement that should stay on same grid position
312
+ const mouseMoveEvent3 = new MouseEvent('mousemove', {
313
+ clientX: 202, // +2 pixels
314
+ clientY: 203, // +3 pixels
315
+ bubbles: true
316
+ });
317
+ document.dispatchEvent(mouseMoveEvent3);
318
+
319
+ // (120 + 2, 160 + 3) = (122, 163) -> snapped to (120, 160)
320
+ assert.equal((testElement as HTMLElement).style.left, '120px');
321
+ assert.equal((testElement as HTMLElement).style.top, '160px');
322
+
323
+ const mouseUpEvent3 = new MouseEvent('mouseup', {
324
+ clientX: 202,
325
+ clientY: 203,
326
+ bubbles: true
327
+ });
328
+ document.dispatchEvent(mouseUpEvent3);
329
+
330
+ assert.equal(finalPosition.left, 120, 'Position should remain on grid');
331
+ assert.equal(finalPosition.top, 160, 'Position should remain on grid');
332
+
333
+ // Clean up
334
+ document.removeEventListener('mousemove', handleMouseMove);
335
+ document.removeEventListener('mouseup', handleMouseUp);
336
+ });
337
+ });
@@ -0,0 +1,121 @@
1
+ import { html, fixture, expect } from '@open-wc/testing';
2
+ import { FormField } from '../src/formfield/FormField';
3
+ import { assertScreenshot, getClip } from './utils.test';
4
+
5
+ describe('temba-field', () => {
6
+ it('renders field with plain text errors', async () => {
7
+ const formField: FormField = await fixture(html`
8
+ <temba-field
9
+ label="Test Field"
10
+ name="test"
11
+ .errors=${['This is a plain text error', 'Another error message']}
12
+ >
13
+ <input type="text" />
14
+ </temba-field>
15
+ `);
16
+
17
+ await formField.updateComplete;
18
+
19
+ // Check that errors are rendered
20
+ const errorElements = formField.shadowRoot.querySelectorAll('.alert-error');
21
+ expect(errorElements.length).to.equal(2);
22
+ expect(errorElements[0].textContent.trim()).to.equal(
23
+ 'This is a plain text error'
24
+ );
25
+ expect(errorElements[1].textContent.trim()).to.equal(
26
+ 'Another error message'
27
+ );
28
+
29
+ await assertScreenshot('formfield/plain-text-errors', getClip(formField));
30
+ });
31
+
32
+ it('renders field with markdown errors', async () => {
33
+ const formField: FormField = await fixture(html`
34
+ <temba-field
35
+ label="Test Field"
36
+ name="test"
37
+ .errors=${[
38
+ 'This is **bold** text',
39
+ 'This has a [link](https://example.com)',
40
+ 'This is *italic* and **bold** with a [link](https://example.com)'
41
+ ]}
42
+ >
43
+ <input type="text" />
44
+ </temba-field>
45
+ `);
46
+
47
+ await formField.updateComplete;
48
+
49
+ // Check that errors are rendered
50
+ const errorElements = formField.shadowRoot.querySelectorAll('.alert-error');
51
+ expect(errorElements.length).to.equal(3);
52
+
53
+ // First error should have bold text
54
+ const firstError = errorElements[0];
55
+ const boldElement = firstError.querySelector('strong');
56
+ expect(boldElement).to.not.be.null;
57
+ expect(boldElement.textContent).to.equal('bold');
58
+
59
+ // Second error should have a link
60
+ const secondError = errorElements[1];
61
+ const linkElement = secondError.querySelector('a');
62
+ expect(linkElement).to.not.be.null;
63
+ expect(linkElement.getAttribute('href')).to.equal('https://example.com');
64
+ expect(linkElement.textContent).to.equal('link');
65
+
66
+ // Third error should have both bold, italic, and link
67
+ const thirdError = errorElements[2];
68
+ const thirdBoldElement = thirdError.querySelector('strong');
69
+ const thirdItalicElement = thirdError.querySelector('em');
70
+ const thirdLinkElement = thirdError.querySelector('a');
71
+ expect(thirdBoldElement).to.not.be.null;
72
+ expect(thirdItalicElement).to.not.be.null;
73
+ expect(thirdLinkElement).to.not.be.null;
74
+
75
+ await assertScreenshot('formfield/markdown-errors', getClip(formField));
76
+ });
77
+
78
+ it('renders field without errors', async () => {
79
+ const formField: FormField = await fixture(html`
80
+ <temba-field label="Test Field" name="test">
81
+ <input type="text" />
82
+ </temba-field>
83
+ `);
84
+
85
+ await formField.updateComplete;
86
+
87
+ // Check that no errors are rendered
88
+ const errorElements = formField.shadowRoot.querySelectorAll('.alert-error');
89
+ expect(errorElements.length).to.equal(0);
90
+
91
+ await assertScreenshot('formfield/no-errors', getClip(formField));
92
+ });
93
+
94
+ it('renders in widget-only mode with errors', async () => {
95
+ const formField: FormField = await fixture(html`
96
+ <temba-field
97
+ widget_only
98
+ .errors=${['Widget only **error** with [link](https://example.com)']}
99
+ >
100
+ <input type="text" />
101
+ </temba-field>
102
+ `);
103
+
104
+ await formField.updateComplete;
105
+
106
+ // Check that error is rendered in widget-only mode
107
+ const errorElements = formField.shadowRoot.querySelectorAll('.alert-error');
108
+ expect(errorElements.length).to.equal(1);
109
+
110
+ const errorElement = errorElements[0];
111
+ const boldElement = errorElement.querySelector('strong');
112
+ const linkElement = errorElement.querySelector('a');
113
+ expect(boldElement).to.not.be.null;
114
+ expect(linkElement).to.not.be.null;
115
+
116
+ await assertScreenshot(
117
+ 'formfield/widget-only-markdown-errors',
118
+ getClip(formField)
119
+ );
120
+ });
121
+ });
@@ -0,0 +1,45 @@
1
+ import { html, fixture, expect } from '@open-wc/testing';
2
+ import { Checkbox } from '../src/checkbox/Checkbox';
3
+ import { assertScreenshot, getClip } from './utils.test';
4
+
5
+ describe('FormElement markdown integration', () => {
6
+ it('renders checkbox with markdown errors', async () => {
7
+ const checkbox: Checkbox = await fixture(html`
8
+ <temba-checkbox
9
+ label="Accept Terms"
10
+ .errors=${[
11
+ 'Please read the **terms and conditions** at [this link](https://example.com)',
12
+ 'This field *requires* acceptance'
13
+ ]}
14
+ ></temba-checkbox>
15
+ `);
16
+
17
+ await checkbox.updateComplete;
18
+
19
+ // Check that errors are rendered with markdown
20
+ const errorElements = checkbox.shadowRoot
21
+ .querySelectorAll('temba-field')[0]
22
+ .shadowRoot.querySelectorAll('.alert-error');
23
+ expect(errorElements.length).to.equal(2);
24
+
25
+ // First error should have bold text and link
26
+ const firstError = errorElements[0];
27
+ const boldElement = firstError.querySelector('strong');
28
+ const linkElement = firstError.querySelector('a');
29
+ expect(boldElement).to.not.be.null;
30
+ expect(boldElement.textContent).to.equal('terms and conditions');
31
+ expect(linkElement).to.not.be.null;
32
+ expect(linkElement.getAttribute('href')).to.equal('https://example.com');
33
+
34
+ // Second error should have italic text
35
+ const secondError = errorElements[1];
36
+ const italicElement = secondError.querySelector('em');
37
+ expect(italicElement).to.not.be.null;
38
+ expect(italicElement.textContent).to.equal('requires');
39
+
40
+ await assertScreenshot(
41
+ 'integration/checkbox-markdown-errors',
42
+ getClip(checkbox)
43
+ );
44
+ });
45
+ });
@@ -5,6 +5,7 @@ import { Options } from '../src/options/Options';
5
5
  import { Select, SelectOption } from '../src/select/Select';
6
6
  import {
7
7
  assertScreenshot,
8
+ delay,
8
9
  getClip,
9
10
  getOptions,
10
11
  loadStore,
@@ -660,6 +661,22 @@ describe('temba-select', () => {
660
661
 
661
662
  await openSelect(clock, select);
662
663
 
664
+ // Wait for pagination to complete - keep checking until fetching is false
665
+ // and we have the expected number of results (15 = 3 pages * 5 items)
666
+ let attempts = 0;
667
+ const maxAttempts = 10;
668
+ while (select.fetching || select.visibleOptions.length < 15) {
669
+ if (attempts >= maxAttempts) {
670
+ throw new Error(
671
+ `Pagination did not complete after ${maxAttempts} attempts. fetching: ${select.fetching}, visibleOptions: ${select.visibleOptions.length}`
672
+ );
673
+ }
674
+ await select.updateComplete;
675
+ clock.runAll();
676
+ attempts++;
677
+ await delay(100);
678
+ }
679
+
663
680
  // should have all three pages visible right away
664
681
  assert.equal(select.visibleOptions.length, 15);
665
682
  });