@nyaruka/temba-components 0.124.2 → 0.125.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 (51) hide show
  1. package/.eslintrc.js +3 -1
  2. package/CHANGELOG.md +20 -0
  3. package/demo/chart/example.html +346 -26
  4. package/demo/data/flows/sample-flow.json +1072 -0
  5. package/demo/data/server/opened-tickets.json +15 -3
  6. package/demo/data/server/sample-flow.json +0 -0
  7. package/demo/flow/example.html +46 -0
  8. package/demo/index.html +155 -144
  9. package/demo/webchat/example.html +71 -0
  10. package/dist/temba-components.js +255 -245
  11. package/dist/temba-components.js.map +1 -1
  12. package/out-tsc/src/chart/TembaChart.js +395 -65
  13. package/out-tsc/src/chart/TembaChart.js.map +1 -1
  14. package/out-tsc/src/flow/EditorNode.js +2 -1
  15. package/out-tsc/src/flow/EditorNode.js.map +1 -1
  16. package/out-tsc/src/flow/config.js +70 -20
  17. package/out-tsc/src/flow/config.js.map +1 -1
  18. package/out-tsc/src/formfield/FormField.js +4 -1
  19. package/out-tsc/src/formfield/FormField.js.map +1 -1
  20. package/out-tsc/src/store/Store.js +1 -0
  21. package/out-tsc/src/store/Store.js.map +1 -1
  22. package/out-tsc/src/utils/index.js +40 -0
  23. package/out-tsc/src/utils/index.js.map +1 -1
  24. package/out-tsc/src/webchat/WebChat.js +2 -0
  25. package/out-tsc/src/webchat/WebChat.js.map +1 -1
  26. package/out-tsc/test/temba-chart.test.js +6 -18
  27. package/out-tsc/test/temba-chart.test.js.map +1 -1
  28. package/out-tsc/test/temba-formfield.test.js +94 -0
  29. package/out-tsc/test/temba-formfield.test.js.map +1 -0
  30. package/out-tsc/test/temba-integration-markdown.test.js +36 -0
  31. package/out-tsc/test/temba-integration-markdown.test.js.map +1 -0
  32. package/out-tsc/test/temba-select.test.js +14 -1
  33. package/out-tsc/test/temba-select.test.js.map +1 -1
  34. package/package.json +2 -1
  35. package/screenshots/truth/formfield/markdown-errors.png +0 -0
  36. package/screenshots/truth/formfield/no-errors.png +0 -0
  37. package/screenshots/truth/formfield/plain-text-errors.png +0 -0
  38. package/screenshots/truth/formfield/widget-only-markdown-errors.png +0 -0
  39. package/screenshots/truth/integration/checkbox-markdown-errors.png +0 -0
  40. package/src/chart/TembaChart.ts +418 -71
  41. package/src/flow/EditorNode.ts +2 -1
  42. package/src/flow/config.ts +71 -20
  43. package/src/formfield/FormField.ts +4 -1
  44. package/src/store/Store.ts +1 -0
  45. package/src/utils/index.ts +43 -0
  46. package/src/webchat/WebChat.ts +2 -0
  47. package/test/temba-chart.test.ts +7 -23
  48. package/test/temba-formfield.test.ts +121 -0
  49. package/test/temba-integration-markdown.test.ts +45 -0
  50. package/test/temba-select.test.ts +17 -0
  51. package/web-dev-server.config.mjs +18 -0
@@ -13,58 +13,89 @@ export interface UIConfig {
13
13
  render?: (node: any, action: any) => TemplateResult;
14
14
  }
15
15
 
16
+ const COLORS = {
17
+ send: '#3498db',
18
+ update: '#01c1af',
19
+ broadcast: '#8e5ea7',
20
+ call: '#e68628',
21
+ create: '#df419f',
22
+ save: '#1a777c',
23
+ split: '#aaaaaa',
24
+ execute: '#666666',
25
+ wait: '#4d7dad',
26
+ add: '#309c42',
27
+ remove: '#e74c3c'
28
+ };
29
+
16
30
  export const EDITOR_CONFIG: {
17
31
  [key: string]: UIConfig;
18
32
  } = {
19
33
  add_input_labels: {
20
34
  name: 'Add Labels',
21
- color: '#01c1af'
35
+ color: COLORS.update
22
36
  },
23
37
  add_contact_urn: {
24
38
  name: 'Add Contact URN',
25
- color: '#01c1af'
39
+ color: COLORS.update
26
40
  },
27
41
  set_contact_field: {
28
42
  name: 'Update Contact Field',
29
- color: '#01c1af'
43
+ color: COLORS.update
44
+ },
45
+ set_contact_channel: {
46
+ name: 'Update Contact Channel',
47
+ color: COLORS.update
48
+ },
49
+ set_contact_language: {
50
+ name: 'Update Contact Language',
51
+ color: COLORS.update
30
52
  },
31
53
  send_broadcast: {
32
54
  name: 'Send Broadcast',
33
- color: '#8e5ea7;'
55
+ color: COLORS.broadcast
34
56
  },
35
57
  set_run_result: {
36
58
  name: 'Save Flow Result',
37
- color: '#1a777c',
59
+ color: COLORS.save,
38
60
  render: renderSetRunResult
39
61
  },
40
62
  send_msg: {
41
63
  name: 'Send Message',
42
- color: '#3498db',
64
+ color: COLORS.send,
43
65
  render: renderSendMsg
44
66
  },
45
67
  send_email: {
46
68
  name: 'Send Email',
47
- color: '#8e5ea7'
69
+ color: COLORS.broadcast
70
+ },
71
+ start_session: {
72
+ name: 'Start Somebody Else',
73
+ color: COLORS.broadcast
74
+ },
75
+ open_ticket: {
76
+ name: 'Open Ticket',
77
+ color: COLORS.execute
48
78
  },
49
- start_session: { name: 'Start Somebody Else', color: '#df419f' },
50
79
  call_webhook: {
51
80
  name: 'Call Webhook',
52
- color: '#e68628',
81
+ color: COLORS.call,
53
82
  render: renderCallWebhook
54
83
  },
84
+ enter_flow: {
85
+ name: 'Enter Subflow',
86
+ color: COLORS.execute
87
+ },
55
88
  call_llm: {
56
89
  name: 'Call AI',
57
- color: '#e68628'
90
+ color: COLORS.call
58
91
  },
59
92
  transfer_airtime: {
60
93
  name: 'Send Airtime',
61
- color: '#e68628'
94
+ color: COLORS.call
62
95
  },
63
- wait_for_response: { name: 'Wait for Response', color: '#4d7dad' },
64
- split_by_expression: { name: 'Split by Expression', color: '#aaaaaa' },
65
- split_by_contact_field: {
66
- name: 'Split by <Contact Field Name>',
67
- color: '#aaaaaa'
96
+ wait_for_response: {
97
+ name: 'Wait for Response',
98
+ color: COLORS.wait
68
99
  },
69
100
  set_contact_name: {
70
101
  name: 'Update Contact',
@@ -73,19 +104,39 @@ export const EDITOR_CONFIG: {
73
104
  },
74
105
  add_contact_groups: {
75
106
  name: 'Add to Group',
76
- color: '#309c42',
107
+ color: COLORS.add,
77
108
  render: renderAddToGroups
78
109
  },
79
110
  remove_contact_groups: {
80
111
  name: 'Remove from Group',
81
- color: '#666'
112
+ color: COLORS.remove
82
113
  },
83
114
  request_optin: {
84
115
  name: 'Request Opt-in',
85
- color: '#3498db'
116
+ color: COLORS.send
86
117
  },
87
118
  split_by_run_result: {
88
119
  name: 'Split by Flow Result',
89
- color: '#aaaaaa'
120
+ color: COLORS.split
121
+ },
122
+ split_by_expression: {
123
+ name: 'Split by Expression',
124
+ color: COLORS.split
125
+ },
126
+ split_by_contact_field: {
127
+ name: 'Split by <Contact Field Name>',
128
+ color: COLORS.split
129
+ },
130
+ split_by_groups: {
131
+ name: 'Split by Group',
132
+ color: COLORS.split
133
+ },
134
+ split_by_scheme: {
135
+ name: 'Split by URN Type',
136
+ color: COLORS.split
137
+ },
138
+ split_by_random: {
139
+ name: 'Split by Random',
140
+ color: COLORS.split
90
141
  }
91
142
  };
@@ -1,5 +1,6 @@
1
1
  import { TemplateResult, html, css, LitElement } from 'lit';
2
2
  import { property } from 'lit/decorators.js';
3
+ import { renderMarkdown } from '../markdown';
3
4
 
4
5
  /**
5
6
  * A small wrapper to display labels and help text in a smartmin style.
@@ -94,7 +95,9 @@ export class FormField extends LitElement {
94
95
  public render(): TemplateResult {
95
96
  const errors = !this.hideErrors
96
97
  ? (this.errors || []).map((error: string) => {
97
- return html` <div class="alert-error">${error}</div> `;
98
+ return html`
99
+ <div class="alert-error">${renderMarkdown(error)}</div>
100
+ `;
98
101
  })
99
102
  : [];
100
103
 
@@ -561,6 +561,7 @@ export class Store extends RapidElement {
561
561
  const orginalUser = last[parts[parts.length - 1]];
562
562
  orginalUser.avatar = user.avatar;
563
563
  orginalUser.name = getFullName(user);
564
+ orginalUser.uuid = user.uuid;
564
565
  last[parts[parts.length - 1]].avatar = user.avatar;
565
566
  }
566
567
  });
@@ -44,14 +44,17 @@ export enum Color {
44
44
 
45
45
  export const log = (message: string | object, styling = '', details = []) => {
46
46
  if (styling === '') {
47
+ // eslint-disable-next-line no-console
47
48
  console.log(message);
48
49
  return;
49
50
  }
50
51
 
51
52
  if (typeof message === 'object') {
53
+ // eslint-disable-next-line no-console
52
54
  console.log('%c' + JSON.stringify(message, null, 2), styling);
53
55
  return;
54
56
  }
57
+ // eslint-disable-next-line no-console
55
58
  console.log('%c' + message, styling, ...details);
56
59
  };
57
60
 
@@ -802,6 +805,46 @@ export const hslToHex = (h, s, l) => {
802
805
  return `#${f(0)}${f(8)}${f(4)}`;
803
806
  };
804
807
 
808
+ export const darkenColor = (color: string, factor: number): string => {
809
+ // If rgba or rgb
810
+ const rgbaMatch = color.match(
811
+ /rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([0-9.]+))?\)/
812
+ );
813
+ if (rgbaMatch) {
814
+ // eslint-disable-next-line prefer-const
815
+ let [r, g, b, a] = rgbaMatch
816
+ .slice(1)
817
+ .map((v, i) => (i < 3 ? parseInt(v) : parseFloat(v)));
818
+ r = Math.max(0, Math.floor(r * (1 - factor)));
819
+ g = Math.max(0, Math.floor(g * (1 - factor)));
820
+ b = Math.max(0, Math.floor(b * (1 - factor)));
821
+ if (rgbaMatch[4] !== undefined) {
822
+ return `rgba(${r},${g},${b},${a})`;
823
+ }
824
+ return `rgb(${r},${g},${b})`;
825
+ }
826
+ // If hex
827
+ if (color.startsWith('#')) {
828
+ let hex = color.replace('#', '');
829
+ if (hex.length === 3) {
830
+ hex = hex
831
+ .split('')
832
+ .map((c) => c + c)
833
+ .join('');
834
+ }
835
+ const num = parseInt(hex, 16);
836
+ let r = (num >> 16) & 255;
837
+ let g = (num >> 8) & 255;
838
+ let b = num & 255;
839
+ r = Math.max(0, Math.floor(r * (1 - factor)));
840
+ g = Math.max(0, Math.floor(g * (1 - factor)));
841
+ b = Math.max(0, Math.floor(b * (1 - factor)));
842
+ return `rgb(${r},${g},${b})`;
843
+ }
844
+ // fallback
845
+ return color;
846
+ };
847
+
805
848
  export const renderAvatar = (input: {
806
849
  name?: string;
807
850
  user?: User;
@@ -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
 
@@ -145,23 +146,6 @@ describe('temba-chart', () => {
145
146
  expect(tickCallback.call({}, 604800, 9, [])).to.equal('7d'); // 1 week in seconds
146
147
  expect(tickCallback.call({}, 1209600, 10, [])).to.equal('14d'); // 2 weeks in seconds
147
148
  });
148
-
149
- it('respects formatDuration property state', async () => {
150
- const chart: TembaChart = await getChart();
151
-
152
- // Test default state
153
- expect(chart.formatDuration).to.equal(false);
154
-
155
- chart.data = sampleData;
156
- await chart.updateComplete;
157
-
158
- // Test that formatDuration property can be toggled
159
- chart.formatDuration = true;
160
- expect(chart.formatDuration).to.equal(true);
161
-
162
- chart.formatDuration = false;
163
- expect(chart.formatDuration).to.equal(false);
164
- });
165
149
  });
166
150
 
167
151
  describe('formatDurationFromSeconds', () => {
@@ -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
  });
@@ -1,5 +1,7 @@
1
1
  import replace from '@rollup/plugin-replace';
2
2
  import { fromRollup } from '@web/dev-server-rollup';
3
+ import fs from 'fs';
4
+ import path from 'path';
3
5
 
4
6
  const replacePlugin = fromRollup(replace);
5
7
 
@@ -11,4 +13,20 @@ export default {
11
13
  'process.env.NODE_ENV': JSON.stringify('development'),
12
14
  }),
13
15
  ],
16
+
17
+ middlewares: [
18
+ async (ctx, next) => {
19
+ if (ctx.path.startsWith('/flow/revisions/')) {
20
+ const parts = ctx.path.split('/');
21
+ const uuid = parts[3];
22
+ ctx.set('Content-Type', 'application/json');
23
+ ctx.body = fs.readFileSync(
24
+ path.resolve(`./demo/data/flows/${uuid}.json`),
25
+ 'utf-8',
26
+ );
27
+ } else {
28
+ await next();
29
+ }
30
+ },
31
+ ],
14
32
  };