@nyaruka/temba-components 0.156.8 → 0.156.10

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 (59) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/temba-components.js +678 -631
  3. package/dist/temba-components.js.map +1 -1
  4. package/package.json +1 -1
  5. package/src/display/Chat.ts +8 -8
  6. package/src/display/FloatingTab.ts +2 -2
  7. package/src/display/Options.ts +8 -2
  8. package/src/flow/CanvasMenu.ts +20 -25
  9. package/src/flow/CanvasNode.ts +16 -12
  10. package/src/flow/DragManager.ts +93 -33
  11. package/src/flow/Editor.ts +64 -59
  12. package/src/flow/EditorToolbar.ts +19 -20
  13. package/src/flow/FlowSearch.ts +9 -7
  14. package/src/flow/MessageTable.ts +181 -74
  15. package/src/flow/NodeEditor.ts +55 -72
  16. package/src/flow/RevisionsWindow.ts +2 -4
  17. package/src/flow/ZoomManager.ts +1 -2
  18. package/src/flow/actions/play_audio.ts +1 -28
  19. package/src/flow/actions/say_msg.ts +1 -40
  20. package/src/flow/actions/send_broadcast.ts +1 -2
  21. package/src/flow/actions/send_email.ts +5 -56
  22. package/src/flow/actions/send_msg.ts +10 -2
  23. package/src/flow/actions/start_session.ts +1 -2
  24. package/src/flow/categoryLocalization.ts +1 -5
  25. package/src/flow/categoryUtils.ts +139 -0
  26. package/src/flow/nodes/shared-rules.ts +6 -16
  27. package/src/flow/nodes/shared.ts +113 -6
  28. package/src/flow/nodes/split_by_airtime.ts +41 -63
  29. package/src/flow/nodes/split_by_contact_field.ts +8 -17
  30. package/src/flow/nodes/split_by_expression.ts +8 -17
  31. package/src/flow/nodes/split_by_groups.ts +34 -112
  32. package/src/flow/nodes/split_by_llm.ts +1 -7
  33. package/src/flow/nodes/split_by_llm_categorize.ts +27 -43
  34. package/src/flow/nodes/split_by_random.ts +39 -99
  35. package/src/flow/nodes/split_by_resthook.ts +5 -19
  36. package/src/flow/nodes/split_by_run_result.ts +8 -17
  37. package/src/flow/nodes/split_by_scheme.ts +39 -124
  38. package/src/flow/nodes/split_by_subflow.ts +1 -7
  39. package/src/flow/nodes/split_by_ticket.ts +1 -7
  40. package/src/flow/nodes/split_by_webhook.ts +2 -8
  41. package/src/flow/nodes/wait_for_audio.ts +1 -7
  42. package/src/flow/nodes/wait_for_dial.ts +2 -8
  43. package/src/flow/nodes/wait_for_digits.ts +5 -7
  44. package/src/flow/nodes/wait_for_menu.ts +5 -7
  45. package/src/flow/nodes/wait_for_response.ts +10 -18
  46. package/src/flow/types.ts +27 -0
  47. package/src/flow/utils.ts +111 -3
  48. package/src/form/Compose.ts +11 -4
  49. package/src/form/MessageEditor.ts +5 -3
  50. package/src/form/RichEditor.ts +3 -1
  51. package/src/form/TemplateEditor.ts +5 -1
  52. package/src/form/select/Select.ts +11 -9
  53. package/src/layout/AccordionSection.ts +9 -3
  54. package/src/layout/Modax.ts +1 -3
  55. package/src/live/ContactChat.ts +54 -46
  56. package/src/simulator/Simulator.ts +9 -3
  57. package/src/store/AppState.ts +1 -1
  58. package/src/store/Store.ts +6 -1
  59. package/src/utils.ts +21 -16
@@ -41,7 +41,10 @@ export class Compose extends FieldElement {
41
41
 
42
42
  .container:focus-within {
43
43
  border-color: var(--color-focus);
44
- box-shadow: var(--widget-box-shadow-focused, 0 0 0 3px rgba(0, 123, 255, 0.25));
44
+ box-shadow: var(
45
+ --widget-box-shadow-focused,
46
+ 0 0 0 3px rgba(0, 123, 255, 0.25)
47
+ );
45
48
  }
46
49
 
47
50
  .editor-wrapper {
@@ -115,14 +118,15 @@ export class Compose extends FieldElement {
115
118
  color: #888;
116
119
  background: rgba(0, 0, 0, 0.04);
117
120
  border-radius: 8px;
118
- transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out;
121
+ transition:
122
+ background-color 0.2s ease-in-out,
123
+ color 0.2s ease-in-out;
119
124
  }
120
125
 
121
126
  .shortcut-icon:hover {
122
127
  color: var(--color-text-dark);
123
128
  background: rgba(0, 0, 0, 0.08);
124
129
  }
125
-
126
130
  `;
127
131
  }
128
132
 
@@ -347,7 +351,10 @@ export class Compose extends FieldElement {
347
351
 
348
352
  disconnectedCallback() {
349
353
  super.disconnectedCallback();
350
- this.removeEventListener('keydown', this.handleHostKeyDown as EventListener);
354
+ this.removeEventListener(
355
+ 'keydown',
356
+ this.handleHostKeyDown as EventListener
357
+ );
351
358
  }
352
359
 
353
360
  private handleShortcutIconClick() {
@@ -59,7 +59,7 @@ export class MessageEditor extends FieldElement {
59
59
  position: relative;
60
60
  }
61
61
 
62
- ::slotted([slot="icons"]) {
62
+ ::slotted([slot='icons']) {
63
63
  position: absolute;
64
64
  bottom: 4px;
65
65
  left: 36px;
@@ -69,7 +69,7 @@ export class MessageEditor extends FieldElement {
69
69
  z-index: 1;
70
70
  }
71
71
 
72
- .has-attachments ::slotted([slot="icons"]) {
72
+ .has-attachments ::slotted([slot='icons']) {
73
73
  left: 4px;
74
74
  }
75
75
 
@@ -155,7 +155,9 @@ export class MessageEditor extends FieldElement {
155
155
  padding: 6px;
156
156
  border-radius: 8px;
157
157
  background: rgba(0, 0, 0, 0.04);
158
- transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out;
158
+ transition:
159
+ background-color 0.2s ease-in-out,
160
+ color 0.2s ease-in-out;
159
161
  }
160
162
 
161
163
  .attachment-icon:hover {
@@ -678,7 +678,9 @@ export class RichEditor extends FieldElement {
678
678
  <div class="comp-container">
679
679
  <div id="anchor" style=${styleMap(anchorStyles)}></div>
680
680
  <div
681
- class="input-container ${this.flavor !== 'default' ? this.flavor : ''}"
681
+ class="input-container ${this.flavor !== 'default'
682
+ ? this.flavor
683
+ : ''}"
682
684
  style=${this.minHeight
683
685
  ? `--textarea-min-height: ${this.minHeight}px`
684
686
  : ''}
@@ -491,7 +491,11 @@ export class TemplateEditor extends FieldElement {
491
491
  >
492
492
  </temba-select>
493
493
  ${this.templateWarning && this.selectedTemplate
494
- ? html`<temba-alert level="warning" style="margin-bottom: 0.5em; display: block;">${this.templateWarning}</temba-alert>`
494
+ ? html`<temba-alert
495
+ level="warning"
496
+ style="margin-bottom: 0.5em; display: block;"
497
+ >${this.templateWarning}</temba-alert
498
+ >`
495
499
  : null}
496
500
  ${content ? html` <div class="template">${content}</div>` : null}
497
501
  </div>
@@ -866,9 +866,7 @@ export class Select<T extends SelectOption> extends FieldElement {
866
866
  }
867
867
 
868
868
  private updateEnterHintPosition() {
869
- const hint = this.shadowRoot.querySelector(
870
- '.enter-hint'
871
- ) as HTMLElement;
869
+ const hint = this.shadowRoot.querySelector('.enter-hint') as HTMLElement;
872
870
  if (!hint) {
873
871
  this.removeHintRepositionListeners();
874
872
  return;
@@ -2346,7 +2344,9 @@ export class Select<T extends SelectOption> extends FieldElement {
2346
2344
  : null
2347
2345
  }</slot>
2348
2346
  ${
2349
- !this.tags && !this.emails && !(this.clearable && this.values.length > 0 && !this.isMultiMode)
2347
+ !this.tags &&
2348
+ !this.emails &&
2349
+ !(this.clearable && this.values.length > 0 && !this.isMultiMode)
2350
2350
  ? html`<div
2351
2351
  class="right-side arrow"
2352
2352
  style="display:block;margin-right:5px"
@@ -2412,11 +2412,13 @@ export class Select<T extends SelectOption> extends FieldElement {
2412
2412
  : null
2413
2413
  }
2414
2414
  </temba-options>
2415
- ${showEnterHint
2416
- ? html`<div class="enter-hint">
2417
- <span style="position:relative;top:3px">↵</span> ${msg('to add')}
2418
- </div>`
2419
- : null}
2415
+ ${
2416
+ showEnterHint
2417
+ ? html`<div class="enter-hint">
2418
+ <span style="position:relative;top:3px">↵</span> ${msg('to add')}
2419
+ </div>`
2420
+ : null
2421
+ }
2420
2422
  `;
2421
2423
  }
2422
2424
 
@@ -194,9 +194,15 @@ export class AccordionSection extends LitElement {
194
194
  @mouseenter=${this.handleMouseEnter}
195
195
  @mouseleave=${this.handleMouseLeave}
196
196
  >
197
- <div class="accordion-title">${this.icon
198
- ? html`<temba-icon name=${this.icon} size="1" style="margin-right:6px;color:#999;"></temba-icon>`
199
- : ''}${this.label}</div>
197
+ <div class="accordion-title">
198
+ ${this.icon
199
+ ? html`<temba-icon
200
+ name=${this.icon}
201
+ size="1"
202
+ style="margin-right:6px;color:#999;"
203
+ ></temba-icon>`
204
+ : ''}${this.label}
205
+ </div>
200
206
  ${this.hasError
201
207
  ? html`<temba-icon
202
208
  name="alert_warning"
@@ -312,9 +312,7 @@ export class Modax extends RapidElement {
312
312
  } else {
313
313
  // apply or clear header colors from response
314
314
  const headerBg = response.headers.get('X-Temba-Header-Bg');
315
- const headerText = response.headers.get(
316
- 'X-Temba-Header-Text'
317
- );
315
+ const headerText = response.headers.get('X-Temba-Header-Text');
318
316
  if (headerBg) {
319
317
  this.style.setProperty('--header-bg', headerBg);
320
318
  } else {
@@ -68,8 +68,6 @@ export class ContactChat extends ContactStoreElement {
68
68
  --compose-padding: 3px;
69
69
  --compose-curvature: none;
70
70
  border-top: 1px inset rgba(0, 0, 0, 0.05);
71
-
72
-
73
71
  }
74
72
 
75
73
  .chat-wrapper {
@@ -612,9 +610,7 @@ export class ContactChat extends ContactStoreElement {
612
610
  this.refreshId = null;
613
611
  }
614
612
  window.setTimeout(() => {
615
- const input = this.shadowRoot.querySelector(
616
- '.search-input'
617
- ) as any;
613
+ const input = this.shadowRoot.querySelector('.search-input') as any;
618
614
  if (input) {
619
615
  input.focus();
620
616
  }
@@ -1125,7 +1121,13 @@ export class ContactChat extends ContactStoreElement {
1125
1121
  private fetchingNewer = false;
1126
1122
 
1127
1123
  private fetchNewerMessages() {
1128
- if (!this.searchMode || !this.afterUUID || !this.chat || this.fetchingNewer || this.blockFetchingNewer) {
1124
+ if (
1125
+ !this.searchMode ||
1126
+ !this.afterUUID ||
1127
+ !this.chat ||
1128
+ this.fetchingNewer ||
1129
+ this.blockFetchingNewer
1130
+ ) {
1129
1131
  return;
1130
1132
  }
1131
1133
 
@@ -1141,28 +1143,30 @@ export class ContactChat extends ContactStoreElement {
1141
1143
  this.currentTicket?.uuid,
1142
1144
  null,
1143
1145
  this.afterUUID
1144
- ).then((page: ContactHistoryPage) => {
1145
- if (!this.chat) {
1146
- this.fetchingNewer = false;
1147
- return;
1148
- }
1146
+ )
1147
+ .then((page: ContactHistoryPage) => {
1148
+ if (!this.chat) {
1149
+ this.fetchingNewer = false;
1150
+ return;
1151
+ }
1149
1152
 
1150
- const messages = this.createMessages(page);
1151
- messages.reverse();
1153
+ const messages = this.createMessages(page);
1154
+ messages.reverse();
1152
1155
 
1153
- if (messages.length === 0) {
1154
- this.blockFetchingNewer = true;
1155
- this.fetchingNewer = false;
1156
- return;
1157
- }
1156
+ if (messages.length === 0) {
1157
+ this.blockFetchingNewer = true;
1158
+ this.fetchingNewer = false;
1159
+ return;
1160
+ }
1158
1161
 
1159
- // maintainScroll=true keeps the user's visual position stable
1160
- // so they must actively scroll down to trigger the next fetch
1161
- // fetchingNewer is reset in fetchComplete after scroll settles
1162
- this.chat.addMessages(messages, null, true, true);
1163
- }).catch(() => {
1164
- this.fetchingNewer = false;
1165
- });
1162
+ // maintainScroll=true keeps the user's visual position stable
1163
+ // so they must actively scroll down to trigger the next fetch
1164
+ // fetchingNewer is reset in fetchComplete after scroll settles
1165
+ this.chat.addMessages(messages, null, true, true);
1166
+ })
1167
+ .catch(() => {
1168
+ this.fetchingNewer = false;
1169
+ });
1166
1170
  }
1167
1171
 
1168
1172
  private getTembaCompose(): TemplateResult {
@@ -1330,7 +1334,11 @@ export class ContactChat extends ContactStoreElement {
1330
1334
  ${this.currentContact
1331
1335
  ? html`<div class="chat-container">
1332
1336
  ${this.showSearch && this.searchMode
1333
- ? html`<div class="search-overlay ${this.searchClosing ? 'closing' : ''}">
1337
+ ? html`<div
1338
+ class="search-overlay ${this.searchClosing
1339
+ ? 'closing'
1340
+ : ''}"
1341
+ >
1334
1342
  <div class="search-input-wrapper">
1335
1343
  <temba-textinput
1336
1344
  class="search-input"
@@ -1350,10 +1358,7 @@ export class ContactChat extends ContactStoreElement {
1350
1358
  this.searchLoading}
1351
1359
  title="Search (Enter)"
1352
1360
  >
1353
- <temba-icon
1354
- name="search"
1355
- size="0.8"
1356
- ></temba-icon>
1361
+ <temba-icon name="search" size="0.8"></temba-icon>
1357
1362
  </button>
1358
1363
  </div>
1359
1364
  ${this.searchLoading
@@ -1362,22 +1367,25 @@ export class ContactChat extends ContactStoreElement {
1362
1367
  ></span>`
1363
1368
  : this.searchResults.length > 0
1364
1369
  ? html`<span class="search-counter"
1365
- >${this.searchIndex + 1} /
1366
- ${this.searchResults.length}</span
1367
- ><button
1368
- class="search-btn ${this.searchFocused && this.searchQuery.trim() === this.lastSearchedQuery ? 'enter-target' : ''}"
1369
- @click=${this.handleSearchNext}
1370
- title="Older match (Enter)"
1371
- >
1372
- &#x25B2;
1373
- </button>
1374
- <button
1375
- class="search-btn"
1376
- @click=${this.handleSearchPrev}
1377
- title="Newer match (Shift+Enter)"
1378
- >
1379
- &#x25BC;
1380
- </button>`
1370
+ >${this.searchIndex + 1} /
1371
+ ${this.searchResults.length}</span
1372
+ ><button
1373
+ class="search-btn ${this.searchFocused &&
1374
+ this.searchQuery.trim() === this.lastSearchedQuery
1375
+ ? 'enter-target'
1376
+ : ''}"
1377
+ @click=${this.handleSearchNext}
1378
+ title="Older match (Enter)"
1379
+ >
1380
+ &#x25B2;
1381
+ </button>
1382
+ <button
1383
+ class="search-btn"
1384
+ @click=${this.handleSearchPrev}
1385
+ title="Newer match (Shift+Enter)"
1386
+ >
1387
+ &#x25BC;
1388
+ </button>`
1381
1389
  : null}
1382
1390
  <button
1383
1391
  class="search-btn"
@@ -1947,13 +1947,19 @@ export class Simulator extends RapidElement {
1947
1947
  totalElapsedMs === null
1948
1948
  ? 'n/a'
1949
1949
  : this.formatWebhookDuration(totalElapsedMs);
1950
- const renderWebhookLogContent = (log: WebhookLog, singleAttempt = false) => {
1950
+ const renderWebhookLogContent = (
1951
+ log: WebhookLog,
1952
+ singleAttempt = false
1953
+ ) => {
1951
1954
  const request = this.formatWebhookValue(log.request) || 'No request body';
1952
- const response = this.formatWebhookValue(log.response) || 'No response body';
1955
+ const response =
1956
+ this.formatWebhookValue(log.response) || 'No response body';
1953
1957
 
1954
1958
  return html`
1955
1959
  <div
1956
- class="webhook-log-content ${singleAttempt ? 'webhook-single-log' : ''}"
1960
+ class="webhook-log-content ${singleAttempt
1961
+ ? 'webhook-single-log'
1962
+ : ''}"
1957
1963
  >
1958
1964
  <div class="webhook-log-section">
1959
1965
  <h4>Request</h4>
@@ -192,7 +192,7 @@ export interface AppState {
192
192
  issuesByAction: Map<string, FlowIssue[]>;
193
193
 
194
194
  languageCode: string;
195
- languageNames: { [code: string]: Language };
195
+ languageNames: { [code: string]: string };
196
196
  workspace: Workspace;
197
197
  isTranslating: boolean;
198
198
  viewingRevision: boolean;
@@ -30,6 +30,7 @@ import { sourceLocale, targetLocales } from '../locales/locale-codes';
30
30
  import { getFullName } from '../display/TembaUser';
31
31
  import { AppState, zustand } from './AppState';
32
32
  import { StoreApi } from 'zustand/vanilla';
33
+ import { getLanguageDisplayName } from '../flow/utils';
33
34
 
34
35
  const { setLocale } = configureLocalization({
35
36
  sourceLocale,
@@ -379,7 +380,11 @@ export class Store extends RapidElement {
379
380
  }
380
381
 
381
382
  public getLanguageName(iso: string) {
382
- return this.languages[iso];
383
+ const name = this.languages[iso];
384
+ if (!name || name === 'und' || iso === 'und') {
385
+ return getLanguageDisplayName(iso);
386
+ }
387
+ return name;
383
388
  }
384
389
 
385
390
  public isDynamicGroup(uuid: string): boolean {
package/src/utils.ts CHANGED
@@ -7,6 +7,13 @@ import { Attachment, ContactField, Shortcut, Ticket, User } from './interfaces';
7
7
  import ColorHash from 'color-hash';
8
8
  import { Toast } from './display/Toast';
9
9
  import { v4 as generateUUID, v7 as generateUUIDv7 } from 'uuid';
10
+ import {
11
+ categoryNamesEqual,
12
+ findCategoryByName,
13
+ isSystemCategory
14
+ } from './flow/categoryUtils';
15
+
16
+ export { isSystemCategory } from './flow/categoryUtils';
10
17
 
11
18
  export const DEFAULT_MEDIA_ENDPOINT = '/api/v2/media.json';
12
19
 
@@ -1096,8 +1103,9 @@ export const createOrPreserveCategory = (
1096
1103
  const { name, existingCategories, existingExits, existingCases, caseConfig } =
1097
1104
  params;
1098
1105
 
1099
- // Find existing category
1100
- const existingCategory = existingCategories.find((cat) => cat.name === name);
1106
+ // Find existing category (case-insensitive so renames that only change
1107
+ // casing preserve UUIDs and destinations)
1108
+ const existingCategory = findCategoryByName(existingCategories, name);
1101
1109
  const existingExit = existingCategory
1102
1110
  ? existingExits.find((exit) => exit.uuid === existingCategory.exit_uuid)
1103
1111
  : null;
@@ -1247,13 +1255,6 @@ export const createMultiCategoryRouter = (
1247
1255
  };
1248
1256
  };
1249
1257
 
1250
- // Helper function to check if a category is a system category
1251
- export const isSystemCategory = (categoryName: string): boolean => {
1252
- return ['No Response', 'Other', 'All Responses', 'Timeout'].includes(
1253
- categoryName
1254
- );
1255
- };
1256
-
1257
1258
  // Helper function to check if a UUID belongs to a system category
1258
1259
  export const isSystemCategoryUuid = (
1259
1260
  uuid: string,
@@ -1529,16 +1530,18 @@ export const createRulesRouter = (
1529
1530
  // Name is "Other" if there are user rules, "All Responses" if there are no user rules
1530
1531
  const defaultCategoryName = userRules.length > 0 ? 'Other' : 'All Responses';
1531
1532
 
1532
- // Try to find existing default category by name (prefer exact match)
1533
- let existingDefaultCategory = existingCategories.find(
1534
- (cat) => cat.name === defaultCategoryName
1533
+ // Try to find existing default category by name (case-insensitive)
1534
+ let existingDefaultCategory = findCategoryByName(
1535
+ existingCategories,
1536
+ defaultCategoryName
1535
1537
  );
1536
1538
 
1537
- // If no exact match, try to find the other possible default category name
1539
+ // If no match, try the other possible default category name
1538
1540
  if (!existingDefaultCategory) {
1539
1541
  const alternateName = userRules.length > 0 ? 'All Responses' : 'Other';
1540
- existingDefaultCategory = existingCategories.find(
1541
- (cat) => cat.name === alternateName
1542
+ existingDefaultCategory = findCategoryByName(
1543
+ existingCategories,
1544
+ alternateName
1542
1545
  );
1543
1546
  }
1544
1547
 
@@ -1564,7 +1567,9 @@ export const createRulesRouter = (
1564
1567
 
1565
1568
  // Find the default category (either "Other" or "All Responses")
1566
1569
  const defaultCategory = categories.find(
1567
- (cat) => cat.name === 'Other' || cat.name === 'All Responses'
1570
+ (cat) =>
1571
+ categoryNamesEqual(cat.name, 'Other') ||
1572
+ categoryNamesEqual(cat.name, 'All Responses')
1568
1573
  );
1569
1574
 
1570
1575
  return {