@nyaruka/temba-components 0.157.0 → 0.158.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 (48) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/temba-components.js +1617 -1590
  3. package/dist/temba-components.js.map +1 -1
  4. package/orca/setup.sh +81 -0
  5. package/orca.yaml +3 -0
  6. package/package.json +1 -1
  7. package/src/display/Button.ts +102 -121
  8. package/src/display/Chat.ts +60 -9
  9. package/src/display/Dropdown.ts +11 -0
  10. package/src/display/Label.ts +1 -3
  11. package/src/display/LeafletMap.ts +4 -3
  12. package/src/display/TembaUser.ts +9 -3
  13. package/src/events/eventRenderers.ts +151 -71
  14. package/src/flow/AutoTranslate.ts +2 -2
  15. package/src/flow/CanvasNode.ts +14 -6
  16. package/src/flow/DragManager.ts +4 -2
  17. package/src/flow/Editor.ts +4 -4
  18. package/src/flow/NodeEditor.ts +2 -2
  19. package/src/flow/NodeTypeSelector.ts +0 -5
  20. package/src/flow/actions/set_contact_language.ts +5 -4
  21. package/src/flow/nodes/split_by_llm_categorize.ts +1 -6
  22. package/src/flow/utils.ts +2 -20
  23. package/src/form/ColorPicker.ts +5 -3
  24. package/src/form/DatePicker.ts +2 -1
  25. package/src/form/select/Omnibox.ts +1 -3
  26. package/src/form/select/Select.ts +5 -4
  27. package/src/interfaces.ts +1 -0
  28. package/src/languages.ts +56 -0
  29. package/src/layout/Dialog.ts +1 -3
  30. package/src/layout/Tab.ts +0 -15
  31. package/src/layout/TabPane.ts +73 -163
  32. package/src/list/ContentMenu.ts +1 -2
  33. package/src/list/SortableList.ts +1 -4
  34. package/src/list/TembaMenu.ts +159 -113
  35. package/src/live/ContactBadges.ts +2 -1
  36. package/src/live/ContactChat.ts +22 -3
  37. package/src/live/ContactDetails.ts +42 -36
  38. package/src/live/ContactFieldEditor.ts +35 -57
  39. package/src/live/ContactFields.ts +1 -2
  40. package/src/live/ContactNotepad.ts +9 -1
  41. package/src/live/ContactPending.ts +1 -0
  42. package/src/store/AppState.ts +3 -21
  43. package/src/store/Store.ts +0 -29
  44. package/src/styles/designTokens.ts +33 -18
  45. package/src/styles/pillVariants.ts +24 -13
  46. package/static/css/temba-components.css +84 -55
  47. package/web-dev-server.config.mjs +0 -1
  48. package/web-test-runner.config.mjs +0 -1
@@ -13,6 +13,7 @@ import {
13
13
  UpdateFieldEvent,
14
14
  URNsChangedEvent
15
15
  } from '../events';
16
+ import { getLanguageName } from '../languages';
16
17
  import { oxfordFn } from '../utils';
17
18
 
18
19
  export enum Events {
@@ -106,17 +107,46 @@ const flowPill = (flow: any) =>
106
107
 
107
108
  const fieldPill = (field: any) => renderEntityPill('field', field.name);
108
109
 
110
+ const topicPill = (topic: any) =>
111
+ renderEntityPill('topic', topic.name, {
112
+ href: `/ticket/${topic.uuid}/open/`
113
+ });
114
+
109
115
  /**
110
- * Renders a generic value as a neutral pill (white bg, gray border).
111
- * Used for "after" values in update/change events visually paired
112
- * with the type pill on the left side of the line, without claiming
113
- * a domain hue.
116
+ * Renders a user as a plain text link to the "All" ticket folder
117
+ * filtered by that assignee. Used for actor attribution in the
118
+ * chat history (ticket assigned / opened / closed events).
119
+ *
120
+ * The richer avatar-chip variant is parked in git history (see
121
+ * userPill) — we'll bring it back if denser surfaces need it.
114
122
  */
115
- const valuePill = (value: string | number) =>
116
- html`<span
117
- style="display: inline-flex; align-items: center; height: 20px; padding: 0 8px; margin: 1px 2px; border-radius: 999px; border: 1px solid var(--border-strong, #d2d6dc); background: #fff; color: var(--text-1, #1a1f26); font-size: 11.5px; font-weight: 400; line-height: 1; vertical-align: middle;"
118
- >${value}</span
123
+ const userLink = (user: any): TemplateResult => {
124
+ const name =
125
+ user.name || [user.first_name, user.last_name].filter(Boolean).join(' ');
126
+ return html`<a
127
+ href="/ticket/all/open/?assignee=${user.uuid}"
128
+ onclick="goto(event, this)"
129
+ title=${name}
130
+ >${name}</a
119
131
  >`;
132
+ };
133
+
134
+ /**
135
+ * Renders a contact-data value (field text, name, URN, language,
136
+ * status, amount, etc.) as bold inline text, truncated with an
137
+ * ellipsis past a reasonable width. Pills are reserved for system-
138
+ * level objects (group, flow, field, topic, …) — these are just
139
+ * values, so the chip chrome would over-state them. The full value
140
+ * is exposed via `title` for hover inspection.
141
+ */
142
+ const valueText = (value: string | number) => {
143
+ const str = String(value);
144
+ return html`<span
145
+ title=${str}
146
+ style="display: inline-block; max-width: 18em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle; font-weight: 600;"
147
+ >${str}</span
148
+ >`;
149
+ };
120
150
 
121
151
  /**
122
152
  * Inline-flex wrapper style that text + pills share. Without it,
@@ -124,8 +154,13 @@ const valuePill = (value: string | number) =>
124
154
  * pills sit slightly above and the text appears to "float". flex
125
155
  * centering keeps the row of words and pills on one cross-axis.
126
156
  */
157
+ // min-height keeps the row a consistent height across renderers
158
+ // whether or not they contain a pill (which is ~22px tall) — without
159
+ // it, plain-text events like "Cleared language" sit a few pixels
160
+ // shorter than "Removed from <group pill>" and the chat history
161
+ // looks unevenly spaced.
127
162
  const eventLineStyle =
128
- 'display: inline-flex; align-items: center; flex-wrap: wrap; justify-content: center; gap: 2px 4px;';
163
+ 'display: inline-flex; align-items: center; flex-wrap: wrap; justify-content: center; gap: 2px 4px; min-height: 24px;';
129
164
 
130
165
  const renderInfoList = (
131
166
  singular: string,
@@ -180,9 +215,13 @@ export const renderChatStartedEvent = (
180
215
  };
181
216
 
182
217
  export const renderUpdateEvent = (event: UpdateFieldEvent): TemplateResult => {
183
- return event.value
218
+ // Treat both a missing value object and an empty-string text as a
219
+ // cleared field — backfill / reset payloads sometimes arrive as
220
+ // `value: { text: '' }`, which would otherwise render with an
221
+ // empty value pill.
222
+ return event.value && event.value.text
184
223
  ? html`<div style=${eventLineStyle}>
185
- Updated ${fieldPill(event.field)} to ${valuePill(event.value.text)}
224
+ Updated ${fieldPill(event.field)} to ${valueText(event.value.text)}
186
225
  </div>`
187
226
  : html`<div style=${eventLineStyle}>
188
227
  Cleared ${fieldPill(event.field)}
@@ -190,18 +229,24 @@ export const renderUpdateEvent = (event: UpdateFieldEvent): TemplateResult => {
190
229
  };
191
230
 
192
231
  export const renderNameChanged = (event: NameChangedEvent): TemplateResult => {
232
+ if (!event.name) {
233
+ return html`<div style=${eventLineStyle}>Cleared name</div>`;
234
+ }
193
235
  return html`<div style=${eventLineStyle}>
194
- Updated name to ${valuePill(event.name)}
236
+ Updated name to ${valueText(event.name)}
195
237
  </div>`;
196
238
  };
197
239
 
198
240
  export const renderContactURNsChanged = (
199
241
  event: URNsChangedEvent
200
242
  ): TemplateResult => {
243
+ if (!event.urns || event.urns.length === 0) {
244
+ return html`<div style=${eventLineStyle}>Cleared URNs</div>`;
245
+ }
201
246
  return html`<div style=${eventLineStyle}>
202
247
  Updated URNs to
203
248
  ${oxfordFn(event.urns, (urn: string) =>
204
- valuePill(urn.split(':')[1].split('?')[0])
249
+ valueText(urn.split(':')[1].split('?')[0])
205
250
  )}
206
251
  </div>`;
207
252
  };
@@ -212,84 +257,105 @@ export const renderTicketAction = (
212
257
  ): TemplateResult => {
213
258
  const ticketUUID = event.ticket?.uuid || event.ticket_uuid;
214
259
 
215
- const actionNote = event.note
216
- ? html`<div
217
- style="width:85%; background: #fffac3; padding: 1em;margin-bottom: 1em;margin-top:1em; border: 1px solid #ffe97f;border-radius: var(--curvature);line-height: 1.2em; word-break: break-word;"
218
- >
219
- <div style="color: #8e830fff; font-size: 1em;margin-bottom:0.25em; ">
220
- <strong>${event._user ? event._user.name : 'Someone'}</strong> added a
221
- note
222
- <temba-date
223
- value=${event.created_on.toISOString()}
224
- display="relative"
225
- ></temba-date>
226
- </div>
227
- <div style="white-space: pre-wrap;">${event.note}</div>
228
- </div>`
229
- : null;
230
-
260
+ // Notes in the real chat history now go through Chat.ts#renderNote
261
+ // (see ContactChat.ts: ticket_note_added bypasses prerender). This
262
+ // path only runs for non-chat consumers (e.g. the flow Simulator).
263
+ // Render notes as a simple inline italic line — the chat-bubble
264
+ // styling lives in Chat.ts so the two can't drift.
231
265
  if (action === 'noted') {
232
- return html`${actionNote}`;
266
+ return event.note
267
+ ? html`<div style="white-space: pre-wrap; font-style: italic;">
268
+ ${event.note}
269
+ </div>`
270
+ : null;
233
271
  }
234
272
 
235
- const description = event._user
236
- ? html`<div>
237
- <strong>${event._user.name}</strong> ${action} a
238
- <strong><a href="/ticket/all/closed/${ticketUUID}/">ticket</a></strong>
273
+ // closed ticket is in the closed folder now; reopened → it's
274
+ // back in open. Linking the word "ticket" lets a reader jump to
275
+ // wherever the ticket actually lives.
276
+ const folder = action === 'closed' ? 'closed' : 'open';
277
+ const href = `/ticket/all/${folder}/${ticketUUID}/`;
278
+ return event._user
279
+ ? html`<div style=${eventLineStyle}>
280
+ ${userLink(event._user)} ${action} a <a href=${href}>ticket</a>
239
281
  </div>`
240
- : html`<div>
241
- A
242
- <strong><a href="/ticket/all/closed/${ticketUUID}/">ticket</a></strong>
243
- was <strong>${action}</strong>
282
+ : html`<div style=${eventLineStyle}>
283
+ A <a href=${href}>ticket</a> was ${action}
244
284
  </div>`;
245
-
246
- return html`<div style="${actionNote ? 'margin-bottom: 1em;' : ''}">
247
- ${description}
248
- </div>
249
- ${actionNote}`;
250
285
  };
251
286
 
252
287
  export const renderTicketAssigneeChanged = (
253
288
  event: TicketEvent
254
289
  ): TemplateResult => {
290
+ const ticketUUID = event.ticket?.uuid || event.ticket_uuid;
291
+ // Link the word "ticket" in assignee events too, so the noun is
292
+ // consistently interactive across open / close / reopen / assigned
293
+ // rows (the contact-history page can show events from any of the
294
+ // contact's tickets, so the jump-to-ticket affordance is useful).
295
+ const ticketLink = html`<a href="/ticket/all/open/${ticketUUID}/">ticket</a>`;
296
+ const ticketLinkCapitalized = html`<a href="/ticket/all/open/${ticketUUID}/"
297
+ >This ticket</a
298
+ >`;
255
299
  if (event._user) {
256
300
  if (event.assignee) {
257
- return html`<div>
258
- <strong>${event._user.name}</strong> assigned this ticket to
259
- <strong>${event.assignee.name}</strong>
301
+ // Self-assignment ("took the ticket") reads naturally as one
302
+ // user link + verb, rather than "<user> assigned to <same user>".
303
+ // Match on uuid when present, falling back to email — depending
304
+ // on the API surface a user payload may carry one or the other.
305
+ const sameUser =
306
+ (event._user.uuid && event._user.uuid === event.assignee.uuid) ||
307
+ (event._user.email && event._user.email === event.assignee.email);
308
+ if (sameUser) {
309
+ return html`<div style=${eventLineStyle}>
310
+ ${userLink(event._user)} took this ${ticketLink}
311
+ </div>`;
312
+ }
313
+ return html`<div style=${eventLineStyle}>
314
+ ${userLink(event._user)} assigned this ${ticketLink} to
315
+ ${userLink(event.assignee)}
260
316
  </div>`;
261
317
  } else {
262
- return html`<div>
263
- <strong>${event._user.name}</strong> unassigned this ticket
318
+ return html`<div style=${eventLineStyle}>
319
+ ${userLink(event._user)} unassigned this ${ticketLink}
264
320
  </div>`;
265
321
  }
266
322
  } else {
267
323
  if (event.assignee) {
268
- return html`<div>
269
- This ticket was assigned to <strong>${event.assignee.name}</strong>
324
+ return html`<div style=${eventLineStyle}>
325
+ ${ticketLinkCapitalized} was assigned to ${userLink(event.assignee)}
270
326
  </div>`;
271
327
  } else {
272
- return html`<div>This ticket was unassigned</div>`;
328
+ return html`<div style=${eventLineStyle}>
329
+ ${ticketLinkCapitalized} was unassigned
330
+ </div>`;
273
331
  }
274
332
  }
275
333
  };
276
334
 
277
335
  export const renderTicketOpened = (event: TicketEvent): TemplateResult => {
278
- return html`<div>${event.ticket.topic.name} ticket was opened</div>`;
336
+ const ticketUUID = event.ticket.uuid;
337
+ const href = `/ticket/all/open/${ticketUUID}/`;
338
+ // ticket.topic is optional in events.ts — guard so a payload
339
+ // without one degrades to "A ticket was opened" rather than
340
+ // throwing inside topicPill.
341
+ const topic = event.ticket.topic;
342
+ const tail = topic ? html` in ${topicPill(topic)}` : null;
343
+ return event._user
344
+ ? html`<div style=${eventLineStyle}>
345
+ ${userLink(event._user)} opened a <a href=${href}>ticket</a>${tail}
346
+ </div>`
347
+ : html`<div style=${eventLineStyle}>
348
+ A <a href=${href}>ticket</a> was opened${tail}
349
+ </div>`;
279
350
  };
280
351
 
281
352
  export const renderContactGroupsEvent = (
282
353
  event: ContactGroupsEvent
283
354
  ): TemplateResult => {
284
- const groupsEvent = event as ContactGroupsEvent;
285
- if (groupsEvent.groups_added) {
286
- return renderInfoList('Added to', 'Added to', groupsEvent.groups_added);
287
- } else if (groupsEvent.groups_removed) {
288
- return renderInfoList(
289
- 'Removed from',
290
- 'Removed from',
291
- groupsEvent.groups_removed
292
- );
355
+ if (event.groups_added) {
356
+ return renderInfoList('Added to', 'Added to', event.groups_added);
357
+ } else if (event.groups_removed) {
358
+ return renderInfoList('Removed from', 'Removed from', event.groups_removed);
293
359
  }
294
360
  };
295
361
 
@@ -299,23 +365,31 @@ export const renderAirtimeTransferredEvent = (
299
365
  if (parseFloat(event.amount) === 0) {
300
366
  return html`<div>Airtime transfer failed</div>`;
301
367
  }
302
- return html`<div>
303
- Transferred <strong>${event.amount}</strong> ${event.currency} of airtime
368
+ return html`<div style=${eventLineStyle}>
369
+ Transferred ${valueText(event.amount)} ${event.currency} of airtime
304
370
  </div>`;
305
371
  };
306
372
 
307
373
  export const renderContactLanguageChangedEvent = (
308
374
  event: ContactLanguageChangedEvent
309
375
  ): TemplateResult => {
310
- return html`<div>
311
- Language updated to <strong>${event.language}</strong>
376
+ if (!event.language) {
377
+ return html`<div style=${eventLineStyle}>Cleared language</div>`;
378
+ }
379
+ return html`<div style=${eventLineStyle}>
380
+ Language updated to ${valueText(getLanguageName(event.language))}
312
381
  </div>`;
313
382
  };
314
383
 
315
384
  export const renderContactStatusChangedEvent = (
316
385
  event: ContactStatusChangedEvent
317
386
  ): TemplateResult => {
318
- return html`<div>Status updated to <strong>${event.status}</strong></div>`;
387
+ if (!event.status) {
388
+ return html`<div style=${eventLineStyle}>Cleared status</div>`;
389
+ }
390
+ return html`<div style=${eventLineStyle}>
391
+ Status updated to ${valueText(event.status)}
392
+ </div>`;
319
393
  };
320
394
 
321
395
  export const renderCallEvent = (event: CallEvent): TemplateResult => {
@@ -561,7 +635,7 @@ export const renderEvent = (
561
635
  content = renderTicketAction(event as TicketEvent, 'closed');
562
636
  break;
563
637
  case Events.TICKET_OPENED:
564
- content = renderTicketAction(event as TicketEvent, 'opened');
638
+ content = renderTicketOpened(event as TicketEvent);
565
639
  break;
566
640
  case Events.TICKET_NOTE_ADDED:
567
641
  content = renderTicketAction(event as TicketEvent, 'noted');
@@ -569,11 +643,17 @@ export const renderEvent = (
569
643
  case Events.TICKET_REOPENED:
570
644
  content = renderTicketAction(event as TicketEvent, 'reopened');
571
645
  break;
572
- case Events.TICKET_TOPIC_CHANGED:
573
- content = html`<div>
574
- Topic changed to <strong>${(event as TicketEvent).topic.name}</strong>
575
- </div>`;
646
+ case Events.TICKET_TOPIC_CHANGED: {
647
+ // event.topic is optional — guard so a payload without one
648
+ // degrades cleanly instead of throwing inside topicPill.
649
+ const newTopic = (event as TicketEvent).topic;
650
+ content = newTopic
651
+ ? html`<div style=${eventLineStyle}>
652
+ Topic changed to ${topicPill(newTopic)}
653
+ </div>`
654
+ : null;
576
655
  break;
656
+ }
577
657
  default:
578
658
  return null;
579
659
  }
@@ -6,7 +6,7 @@ import { getStore } from '../store/Store';
6
6
  import { AppState, fromStore, zustand } from '../store/AppState';
7
7
  import { FlowDefinition } from '../store/flow-definition';
8
8
  import { TranslationEntry, buildTranslationBundles } from './flow-translations';
9
- import { getLanguageDisplayName } from './utils';
9
+ import { getLanguageName } from '../languages';
10
10
  import { LLMModel, hasLLMRole } from './flow-utils';
11
11
 
12
12
  interface TranslationModel {
@@ -701,7 +701,7 @@ export class AutoTranslate extends RapidElement {
701
701
  }
702
702
 
703
703
  const selected = this.selectedModel ? [this.selectedModel] : [];
704
- const languageName = getLanguageDisplayName(this.languageCode);
704
+ const languageName = getLanguageName(this.languageCode);
705
705
  const aiClause = this.brand
706
706
  ? html`${this.brand} uses AI for automatic translation, which can make
707
707
  mistakes,`
@@ -219,13 +219,18 @@ export class CanvasNode extends RapidElement {
219
219
  background: repeating-linear-gradient(120deg, tomato, tomato 6px, #ff7056 0, #ff7056 18px) !important;
220
220
  }
221
221
 
222
- /* Disable links on actions/nodes with issues */
222
+ /* Disable links on actions/nodes with issues so clicks fall through
223
+ to open the editor instead of navigating. */
223
224
  .action-content.has-issues .linked-name div,
224
225
  .node.has-issues > .router .linked-name div {
225
226
  text-decoration: none !important;
226
227
  cursor: default !important;
227
228
  pointer-events: none;
228
229
  }
230
+ .action-content.has-issues .linked-pill,
231
+ .node.has-issues > .router .linked-pill {
232
+ pointer-events: none;
233
+ }
229
234
 
230
235
  .action.sortable {
231
236
  display: flex;
@@ -1094,13 +1099,16 @@ export class CanvasNode extends RapidElement {
1094
1099
  }
1095
1100
 
1096
1101
  /**
1097
- * Returns true if the click target is inside a `.linked-name` that is
1098
- * still active (i.e. the containing action/node has no issues).
1099
- * When an action/node has issues, links are visually disabled and clicks
1100
- * should fall through to open the editor instead.
1102
+ * Returns true if the click target is inside a `.linked-name` or
1103
+ * `.linked-pill` whose containing action/node has no issues. Active
1104
+ * links handle their own navigation, so click-vs-drag and node-edit
1105
+ * handlers bail out on them. When the action/node has issues, links are
1106
+ * visually disabled (see CSS) and clicks fall through to open the
1107
+ * editor instead.
1101
1108
  */
1102
1109
  private isActiveLink(target: HTMLElement, action?: Action): boolean {
1103
- if (!target.closest('.linked-name')) return false;
1110
+ if (!target.closest('.linked-name') && !target.closest('.linked-pill'))
1111
+ return false;
1104
1112
  if (action) return !this.issuesByAction?.has(action.uuid);
1105
1113
  return !(
1106
1114
  this.issuesByNode?.has(this.node.uuid) ||
@@ -127,7 +127,8 @@ export class DragManager {
127
127
  if (
128
128
  target.classList.contains('exit') ||
129
129
  target.closest('.exit') ||
130
- target.closest('.linked-name')
130
+ target.closest('.linked-name') ||
131
+ target.closest('.linked-pill')
131
132
  ) {
132
133
  return;
133
134
  }
@@ -186,7 +187,8 @@ export class DragManager {
186
187
  if (
187
188
  target.classList.contains('exit') ||
188
189
  target.closest('.exit') ||
189
- target.closest('.linked-name')
190
+ target.closest('.linked-name') ||
191
+ target.closest('.linked-pill')
190
192
  ) {
191
193
  return;
192
194
  }
@@ -22,12 +22,12 @@ import {
22
22
  } from '../utils';
23
23
  import { TEMBA_COMPONENTS_VERSION } from '../version';
24
24
  import {
25
- getLanguageDisplayName,
26
25
  getNodeBounds,
27
26
  calculateReflowPositions,
28
27
  NodeBounds,
29
28
  snapToGrid
30
29
  } from './utils';
30
+ import { getLanguageName } from '../languages';
31
31
  import { ACTION_CONFIG, NODE_CONFIG } from './config';
32
32
  import { PRIMARY_LANGUAGE_OPTION_VALUE } from './EditorToolbar';
33
33
  import {
@@ -1711,7 +1711,7 @@ export class Editor extends RapidElement {
1711
1711
  // Use languages from workspace if available
1712
1712
  if (this.workspace?.languages && this.workspace.languages.length > 0) {
1713
1713
  return this.workspace.languages
1714
- .map((code) => ({ code, name: getLanguageDisplayName(code) }))
1714
+ .map((code) => ({ code, name: getLanguageName(code) }))
1715
1715
  .filter((lang) => lang.code && lang.name);
1716
1716
  }
1717
1717
 
@@ -1722,7 +1722,7 @@ export class Editor extends RapidElement {
1722
1722
  ) {
1723
1723
  return this.definition._ui.languages.map((lang: any) => {
1724
1724
  const code = typeof lang === 'string' ? lang : lang.iso || lang.code;
1725
- return { code, name: getLanguageDisplayName(code) };
1725
+ return { code, name: getLanguageName(code) };
1726
1726
  });
1727
1727
  }
1728
1728
 
@@ -3644,7 +3644,7 @@ export class Editor extends RapidElement {
3644
3644
  const baseLanguage = this.definition?.language;
3645
3645
  const baseLanguageName =
3646
3646
  availableLanguages.find((lang) => lang.code === baseLanguage)?.name ||
3647
- (baseLanguage ? getLanguageDisplayName(baseLanguage) : '') ||
3647
+ (baseLanguage ? getLanguageName(baseLanguage) : '') ||
3648
3648
  'Primary language';
3649
3649
  const isBaseSelected =
3650
3650
  !this.languageCode ||
@@ -37,7 +37,7 @@ import {
37
37
  fromStore,
38
38
  zustand
39
39
  } from '../store/AppState';
40
- import { getStore } from '../store/Store';
40
+ import { getLanguageName } from '../languages';
41
41
 
42
42
  export class NodeEditor extends RapidElement {
43
43
  static get styles() {
@@ -2656,7 +2656,7 @@ export class NodeEditor extends RapidElement {
2656
2656
  const dialogSize = config?.dialogSize || 'medium'; // Default to 'large' if not specified
2657
2657
 
2658
2658
  const languageName = this.isTranslating
2659
- ? getStore().getLanguageName(this.languageCode)
2659
+ ? getLanguageName(this.languageCode)
2660
2660
  : '';
2661
2661
 
2662
2662
  let header = config?.name || 'Edit';
@@ -213,11 +213,6 @@ export class NodeTypeSelector extends RapidElement {
213
213
  background: rgba(0, 0, 0, 0.03);
214
214
  border-radius: 0 0 var(--curvature) var(--curvature);
215
215
  }
216
-
217
- temba-button {
218
- --button-y: 0.5em;
219
- --button-x: 1.25em;
220
- }
221
216
  `;
222
217
  }
223
218
 
@@ -8,14 +8,15 @@ import {
8
8
  } from '../types';
9
9
  import { Node, SetContactLanguage } from '../../store/flow-definition';
10
10
  import { getStore } from '../../store/Store';
11
- import { getLanguageDisplayName, renderClamped } from '../utils';
11
+ import { renderClamped } from '../utils';
12
+ import { getLanguageName } from '../../languages';
12
13
 
13
14
  export const set_contact_language: ActionConfig = {
14
15
  name: 'Update Language',
15
16
  group: ACTION_GROUPS.contacts,
16
17
  flowTypes: [FlowTypes.VOICE, FlowTypes.MESSAGE, FlowTypes.BACKGROUND],
17
18
  render: (_node: Node, action: SetContactLanguage) => {
18
- const name = getLanguageDisplayName(action.language);
19
+ const name = getLanguageName(action.language);
19
20
  return renderClamped(
20
21
  html`Set to <strong>${name}</strong>`,
21
22
  `Set to ${name}`
@@ -37,7 +38,7 @@ export const set_contact_language: ActionConfig = {
37
38
  if (workspace?.languages && Array.isArray(workspace.languages)) {
38
39
  return workspace.languages.map((languageCode: string) => ({
39
40
  value: languageCode,
40
- name: getLanguageDisplayName(languageCode)
41
+ name: getLanguageName(languageCode)
41
42
  }));
42
43
  }
43
44
  return [];
@@ -51,7 +52,7 @@ export const set_contact_language: ActionConfig = {
51
52
  language: [
52
53
  {
53
54
  value: action.language,
54
- name: getLanguageDisplayName(action.language)
55
+ name: getLanguageName(action.language)
55
56
  }
56
57
  ],
57
58
  uuid: action.uuid
@@ -1,9 +1,4 @@
1
- import {
2
- FormData,
3
- NodeConfig,
4
- ACTION_GROUPS,
5
- FlowTypes
6
- } from '../types';
1
+ import { FormData, NodeConfig, ACTION_GROUPS, FlowTypes } from '../types';
7
2
  import { CallLLM, Node } from '../../store/flow-definition';
8
3
  import { generateUUID, createMultiCategoryRouter } from '../../utils';
9
4
  import { html } from 'lit';
package/src/flow/utils.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { html, TemplateResult } from 'lit-html';
2
2
  import { iconToPillType } from '../styles/pillVariants';
3
3
  import { Action, NamedObject, FlowPosition } from '../store/flow-definition';
4
- import { FlowIssue, zustand } from '../store/AppState';
4
+ import { FlowIssue } from '../store/AppState';
5
5
  import { CustomEventType } from '../interfaces';
6
6
  import { tokenize, TokenType } from '../excellent/tokenizer';
7
7
  import { TOKEN_COLORS } from '../excellent/token-styles';
@@ -107,25 +107,6 @@ export function resolveFromLocalizationFormData(
107
107
  return undefined;
108
108
  }
109
109
 
110
- const intlLanguageNames = new Intl.DisplayNames(['en'], { type: 'language' });
111
-
112
- export function getLanguageDisplayName(code: string): string {
113
- if (code === 'und') return 'Unknown';
114
-
115
- // Prefer names from the RapidPro languages endpoint, which supplies
116
- // ISO 639-3 codes (e.g. prd, pst) that Intl.DisplayNames doesn't cover.
117
- const storeName = zustand.getState().languageNames?.[code];
118
- if (storeName) {
119
- return storeName;
120
- }
121
-
122
- try {
123
- return intlLanguageNames.of(code) || code;
124
- } catch {
125
- return code;
126
- }
127
- }
128
-
129
110
  const IS_MAC =
130
111
  typeof navigator !== 'undefined' &&
131
112
  /Mac|iPod|iPhone|iPad/.test(navigator.platform);
@@ -405,6 +386,7 @@ const renderLinkedObject = (
405
386
 
406
387
  const pillType = iconToPillType(icon);
407
388
  return html`<temba-label
389
+ class="linked-pill"
408
390
  icon=${icon || ''}
409
391
  type=${pillType || 'neutral'}
410
392
  clickable
@@ -29,7 +29,6 @@ export class ColorPicker extends FieldElement {
29
29
  :host {
30
30
  color: var(--color-text);
31
31
  display: inline-block;
32
- --curvature: 0.55em;
33
32
  width: 100%;
34
33
 
35
34
  --temba-textinput-padding: 0.4em;
@@ -37,13 +36,16 @@ export class ColorPicker extends FieldElement {
37
36
 
38
37
  temba-textinput {
39
38
  margin-left: 0.3em;
40
- width: 5em;
39
+ width: 7em;
40
+ --temba-textinput-min-height: 0;
41
+ --temba-textinput-padding: 4px 8px;
42
+ --temba-textinput-font-size: 12.5px;
41
43
  }
42
44
 
43
45
  .wrapper {
44
46
  border: 1px solid var(--color-widget-border);
45
47
  padding: calc(var(--curvature) / 2);
46
- border-radius: calc(var(--curvature) * 1.5);
48
+ border-radius: var(--curvature);
47
49
  transition: all calc(var(--transition-speed) * 2) var(--bounce);
48
50
 
49
51
  display: flex;
@@ -19,6 +19,7 @@ export class DatePicker extends FieldElement {
19
19
  .container {
20
20
  border-radius: var(--curvature);
21
21
  border: 1px solid var(--color-widget-border);
22
+ background: var(--color-widget-bg);
22
23
  display: flex;
23
24
  cursor: pointer;
24
25
  box-shadow: var(--widget-box-shadow);
@@ -59,7 +60,7 @@ export class DatePicker extends FieldElement {
59
60
  }
60
61
 
61
62
  .tz-wrapper {
62
- background: #efefef;
63
+ background: var(--sunken);
63
64
  display: flex;
64
65
  flex-direction: row;
65
66
  align-items: center;
@@ -120,9 +120,7 @@ export class Omnibox extends Select<OmniOption> {
120
120
  option.count !== undefined &&
121
121
  option.count !== null
122
122
  ) {
123
- return html`<div
124
- style="display:flex; align-items:center; gap:6px;"
125
- >
123
+ return html`<div style="display:flex; align-items:center; gap:6px;">
126
124
  ${base}<span
127
125
  style="opacity:0.7; font-size:11px; font-variant-numeric: tabular-nums; font-weight: var(--w-medium);"
128
126
  >${option.count.toLocaleString()}</span