@nyaruka/temba-components 0.157.0 → 0.157.1

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 (38) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/temba-components.js +1450 -1370
  3. package/dist/temba-components.js.map +1 -1
  4. package/package.json +1 -1
  5. package/src/display/Button.ts +102 -121
  6. package/src/display/Chat.ts +60 -9
  7. package/src/display/Dropdown.ts +11 -0
  8. package/src/display/Label.ts +1 -3
  9. package/src/display/LeafletMap.ts +4 -3
  10. package/src/display/TembaUser.ts +9 -3
  11. package/src/events/eventRenderers.ts +152 -67
  12. package/src/flow/AutoTranslate.ts +2 -2
  13. package/src/flow/Editor.ts +4 -4
  14. package/src/flow/NodeEditor.ts +2 -2
  15. package/src/flow/NodeTypeSelector.ts +0 -5
  16. package/src/flow/actions/set_contact_language.ts +5 -4
  17. package/src/flow/nodes/split_by_llm_categorize.ts +1 -6
  18. package/src/flow/utils.ts +1 -20
  19. package/src/form/ColorPicker.ts +5 -3
  20. package/src/form/select/Omnibox.ts +1 -3
  21. package/src/form/select/Select.ts +5 -4
  22. package/src/interfaces.ts +1 -0
  23. package/src/languages.ts +56 -0
  24. package/src/layout/Dialog.ts +1 -3
  25. package/src/list/ContentMenu.ts +1 -2
  26. package/src/list/SortableList.ts +1 -4
  27. package/src/list/TembaMenu.ts +159 -113
  28. package/src/live/ContactBadges.ts +2 -1
  29. package/src/live/ContactChat.ts +16 -1
  30. package/src/live/ContactDetails.ts +2 -1
  31. package/src/live/ContactFieldEditor.ts +0 -2
  32. package/src/store/AppState.ts +3 -21
  33. package/src/store/Store.ts +0 -29
  34. package/src/styles/designTokens.ts +31 -18
  35. package/src/styles/pillVariants.ts +24 -13
  36. package/static/css/temba-components.css +82 -55
  37. package/web-dev-server.config.mjs +0 -1
  38. package/web-test-runner.config.mjs +0 -1
@@ -106,17 +106,46 @@ const flowPill = (flow: any) =>
106
106
 
107
107
  const fieldPill = (field: any) => renderEntityPill('field', field.name);
108
108
 
109
+ const topicPill = (topic: any) =>
110
+ renderEntityPill('topic', topic.name, {
111
+ href: `/ticket/${topic.uuid}/open/`
112
+ });
113
+
109
114
  /**
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.
115
+ * Renders a user as a plain text link to the "All" ticket folder
116
+ * filtered by that assignee. Used for actor attribution in the
117
+ * chat history (ticket assigned / opened / closed events).
118
+ *
119
+ * The richer avatar-chip variant is parked in git history (see
120
+ * userPill) — we'll bring it back if denser surfaces need it.
114
121
  */
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
122
+ const userLink = (user: any): TemplateResult => {
123
+ const name =
124
+ user.name || [user.first_name, user.last_name].filter(Boolean).join(' ');
125
+ return html`<a
126
+ href="/ticket/all/open/?assignee=${user.uuid}"
127
+ onclick="goto(event, this)"
128
+ title=${name}
129
+ >${name}</a
119
130
  >`;
131
+ };
132
+
133
+ /**
134
+ * Renders a contact-data value (field text, name, URN, language,
135
+ * status, amount, etc.) as bold inline text, truncated with an
136
+ * ellipsis past a reasonable width. Pills are reserved for system-
137
+ * level objects (group, flow, field, topic, …) — these are just
138
+ * values, so the chip chrome would over-state them. The full value
139
+ * is exposed via `title` for hover inspection.
140
+ */
141
+ const valueText = (value: string | number) => {
142
+ const str = String(value);
143
+ return html`<span
144
+ title=${str}
145
+ style="display: inline-block; max-width: 18em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle; font-weight: 600;"
146
+ >${str}</span
147
+ >`;
148
+ };
120
149
 
121
150
  /**
122
151
  * Inline-flex wrapper style that text + pills share. Without it,
@@ -124,8 +153,13 @@ const valuePill = (value: string | number) =>
124
153
  * pills sit slightly above and the text appears to "float". flex
125
154
  * centering keeps the row of words and pills on one cross-axis.
126
155
  */
156
+ // min-height keeps the row a consistent height across renderers
157
+ // whether or not they contain a pill (which is ~22px tall) — without
158
+ // it, plain-text events like "Cleared language" sit a few pixels
159
+ // shorter than "Removed from <group pill>" and the chat history
160
+ // looks unevenly spaced.
127
161
  const eventLineStyle =
128
- 'display: inline-flex; align-items: center; flex-wrap: wrap; justify-content: center; gap: 2px 4px;';
162
+ 'display: inline-flex; align-items: center; flex-wrap: wrap; justify-content: center; gap: 2px 4px; min-height: 24px;';
129
163
 
130
164
  const renderInfoList = (
131
165
  singular: string,
@@ -180,9 +214,13 @@ export const renderChatStartedEvent = (
180
214
  };
181
215
 
182
216
  export const renderUpdateEvent = (event: UpdateFieldEvent): TemplateResult => {
183
- return event.value
217
+ // Treat both a missing value object and an empty-string text as a
218
+ // cleared field — backfill / reset payloads sometimes arrive as
219
+ // `value: { text: '' }`, which would otherwise render with an
220
+ // empty value pill.
221
+ return event.value && event.value.text
184
222
  ? html`<div style=${eventLineStyle}>
185
- Updated ${fieldPill(event.field)} to ${valuePill(event.value.text)}
223
+ Updated ${fieldPill(event.field)} to ${valueText(event.value.text)}
186
224
  </div>`
187
225
  : html`<div style=${eventLineStyle}>
188
226
  Cleared ${fieldPill(event.field)}
@@ -190,18 +228,24 @@ export const renderUpdateEvent = (event: UpdateFieldEvent): TemplateResult => {
190
228
  };
191
229
 
192
230
  export const renderNameChanged = (event: NameChangedEvent): TemplateResult => {
231
+ if (!event.name) {
232
+ return html`<div style=${eventLineStyle}>Cleared name</div>`;
233
+ }
193
234
  return html`<div style=${eventLineStyle}>
194
- Updated name to ${valuePill(event.name)}
235
+ Updated name to ${valueText(event.name)}
195
236
  </div>`;
196
237
  };
197
238
 
198
239
  export const renderContactURNsChanged = (
199
240
  event: URNsChangedEvent
200
241
  ): TemplateResult => {
242
+ if (!event.urns || event.urns.length === 0) {
243
+ return html`<div style=${eventLineStyle}>Cleared URNs</div>`;
244
+ }
201
245
  return html`<div style=${eventLineStyle}>
202
246
  Updated URNs to
203
247
  ${oxfordFn(event.urns, (urn: string) =>
204
- valuePill(urn.split(':')[1].split('?')[0])
248
+ valueText(urn.split(':')[1].split('?')[0])
205
249
  )}
206
250
  </div>`;
207
251
  };
@@ -212,83 +256,110 @@ export const renderTicketAction = (
212
256
  ): TemplateResult => {
213
257
  const ticketUUID = event.ticket?.uuid || event.ticket_uuid;
214
258
 
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
-
259
+ // Notes in the real chat history now go through Chat.ts#renderNote
260
+ // (see ContactChat.ts: ticket_note_added bypasses prerender). This
261
+ // path only runs for non-chat consumers (e.g. the flow Simulator).
262
+ // Render notes as a simple inline italic line — the chat-bubble
263
+ // styling lives in Chat.ts so the two can't drift.
231
264
  if (action === 'noted') {
232
- return html`${actionNote}`;
265
+ return event.note
266
+ ? html`<div style="white-space: pre-wrap; font-style: italic;">
267
+ ${event.note}
268
+ </div>`
269
+ : null;
233
270
  }
234
271
 
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>
272
+ // closed ticket is in the closed folder now; reopened → it's
273
+ // back in open. Linking the word "ticket" lets a reader jump to
274
+ // wherever the ticket actually lives.
275
+ const folder = action === 'closed' ? 'closed' : 'open';
276
+ const href = `/ticket/all/${folder}/${ticketUUID}/`;
277
+ return event._user
278
+ ? html`<div style=${eventLineStyle}>
279
+ ${userLink(event._user)} ${action} a <a href=${href}>ticket</a>
239
280
  </div>`
240
- : html`<div>
241
- A
242
- <strong><a href="/ticket/all/closed/${ticketUUID}/">ticket</a></strong>
243
- was <strong>${action}</strong>
281
+ : html`<div style=${eventLineStyle}>
282
+ A <a href=${href}>ticket</a> was ${action}
244
283
  </div>`;
245
-
246
- return html`<div style="${actionNote ? 'margin-bottom: 1em;' : ''}">
247
- ${description}
248
- </div>
249
- ${actionNote}`;
250
284
  };
251
285
 
252
286
  export const renderTicketAssigneeChanged = (
253
287
  event: TicketEvent
254
288
  ): TemplateResult => {
289
+ const ticketUUID = event.ticket?.uuid || event.ticket_uuid;
290
+ // Link the word "ticket" in assignee events too, so the noun is
291
+ // consistently interactive across open / close / reopen / assigned
292
+ // rows (the contact-history page can show events from any of the
293
+ // contact's tickets, so the jump-to-ticket affordance is useful).
294
+ const ticketLink = html`<a href="/ticket/all/open/${ticketUUID}/"
295
+ >ticket</a
296
+ >`;
297
+ const ticketLinkCapitalized = html`<a href="/ticket/all/open/${ticketUUID}/"
298
+ >This ticket</a
299
+ >`;
255
300
  if (event._user) {
256
301
  if (event.assignee) {
257
- return html`<div>
258
- <strong>${event._user.name}</strong> assigned this ticket to
259
- <strong>${event.assignee.name}</strong>
302
+ // Self-assignment ("took the ticket") reads naturally as one
303
+ // user link + verb, rather than "<user> assigned to <same user>".
304
+ // Match on uuid when present, falling back to email — depending
305
+ // on the API surface a user payload may carry one or the other.
306
+ const sameUser =
307
+ (event._user.uuid && event._user.uuid === event.assignee.uuid) ||
308
+ (event._user.email && event._user.email === event.assignee.email);
309
+ if (sameUser) {
310
+ return html`<div style=${eventLineStyle}>
311
+ ${userLink(event._user)} took this ${ticketLink}
312
+ </div>`;
313
+ }
314
+ return html`<div style=${eventLineStyle}>
315
+ ${userLink(event._user)} assigned this ${ticketLink} to
316
+ ${userLink(event.assignee)}
260
317
  </div>`;
261
318
  } else {
262
- return html`<div>
263
- <strong>${event._user.name}</strong> unassigned this ticket
319
+ return html`<div style=${eventLineStyle}>
320
+ ${userLink(event._user)} unassigned this ${ticketLink}
264
321
  </div>`;
265
322
  }
266
323
  } else {
267
324
  if (event.assignee) {
268
- return html`<div>
269
- This ticket was assigned to <strong>${event.assignee.name}</strong>
325
+ return html`<div style=${eventLineStyle}>
326
+ ${ticketLinkCapitalized} was assigned to ${userLink(event.assignee)}
270
327
  </div>`;
271
328
  } else {
272
- return html`<div>This ticket was unassigned</div>`;
329
+ return html`<div style=${eventLineStyle}>
330
+ ${ticketLinkCapitalized} was unassigned
331
+ </div>`;
273
332
  }
274
333
  }
275
334
  };
276
335
 
277
336
  export const renderTicketOpened = (event: TicketEvent): TemplateResult => {
278
- return html`<div>${event.ticket.topic.name} ticket was opened</div>`;
337
+ const ticketUUID = event.ticket.uuid;
338
+ const href = `/ticket/all/open/${ticketUUID}/`;
339
+ // ticket.topic is optional in events.ts — guard so a payload
340
+ // without one degrades to "A ticket was opened" rather than
341
+ // throwing inside topicPill.
342
+ const topic = event.ticket.topic;
343
+ const tail = topic ? html` in ${topicPill(topic)}` : null;
344
+ return event._user
345
+ ? html`<div style=${eventLineStyle}>
346
+ ${userLink(event._user)} opened a <a href=${href}>ticket</a>${tail}
347
+ </div>`
348
+ : html`<div style=${eventLineStyle}>
349
+ A <a href=${href}>ticket</a> was opened${tail}
350
+ </div>`;
279
351
  };
280
352
 
281
353
  export const renderContactGroupsEvent = (
282
354
  event: ContactGroupsEvent
283
355
  ): 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) {
356
+ if (event.groups_added) {
357
+ return renderInfoList('Added to', 'Added to', event.groups_added);
358
+ } else if (event.groups_removed) {
288
359
  return renderInfoList(
289
360
  'Removed from',
290
361
  'Removed from',
291
- groupsEvent.groups_removed
362
+ event.groups_removed
292
363
  );
293
364
  }
294
365
  };
@@ -299,23 +370,31 @@ export const renderAirtimeTransferredEvent = (
299
370
  if (parseFloat(event.amount) === 0) {
300
371
  return html`<div>Airtime transfer failed</div>`;
301
372
  }
302
- return html`<div>
303
- Transferred <strong>${event.amount}</strong> ${event.currency} of airtime
373
+ return html`<div style=${eventLineStyle}>
374
+ Transferred ${valueText(event.amount)} ${event.currency} of airtime
304
375
  </div>`;
305
376
  };
306
377
 
307
378
  export const renderContactLanguageChangedEvent = (
308
379
  event: ContactLanguageChangedEvent
309
380
  ): TemplateResult => {
310
- return html`<div>
311
- Language updated to <strong>${event.language}</strong>
381
+ if (!event.language) {
382
+ return html`<div style=${eventLineStyle}>Cleared language</div>`;
383
+ }
384
+ return html`<div style=${eventLineStyle}>
385
+ Language updated to ${valueText(event.language)}
312
386
  </div>`;
313
387
  };
314
388
 
315
389
  export const renderContactStatusChangedEvent = (
316
390
  event: ContactStatusChangedEvent
317
391
  ): TemplateResult => {
318
- return html`<div>Status updated to <strong>${event.status}</strong></div>`;
392
+ if (!event.status) {
393
+ return html`<div style=${eventLineStyle}>Cleared status</div>`;
394
+ }
395
+ return html`<div style=${eventLineStyle}>
396
+ Status updated to ${valueText(event.status)}
397
+ </div>`;
319
398
  };
320
399
 
321
400
  export const renderCallEvent = (event: CallEvent): TemplateResult => {
@@ -561,7 +640,7 @@ export const renderEvent = (
561
640
  content = renderTicketAction(event as TicketEvent, 'closed');
562
641
  break;
563
642
  case Events.TICKET_OPENED:
564
- content = renderTicketAction(event as TicketEvent, 'opened');
643
+ content = renderTicketOpened(event as TicketEvent);
565
644
  break;
566
645
  case Events.TICKET_NOTE_ADDED:
567
646
  content = renderTicketAction(event as TicketEvent, 'noted');
@@ -569,11 +648,17 @@ export const renderEvent = (
569
648
  case Events.TICKET_REOPENED:
570
649
  content = renderTicketAction(event as TicketEvent, 'reopened');
571
650
  break;
572
- case Events.TICKET_TOPIC_CHANGED:
573
- content = html`<div>
574
- Topic changed to <strong>${(event as TicketEvent).topic.name}</strong>
575
- </div>`;
651
+ case Events.TICKET_TOPIC_CHANGED: {
652
+ // event.topic is optional — guard so a payload without one
653
+ // degrades cleanly instead of throwing inside topicPill.
654
+ const newTopic = (event as TicketEvent).topic;
655
+ content = newTopic
656
+ ? html`<div style=${eventLineStyle}>
657
+ Topic changed to ${topicPill(newTopic)}
658
+ </div>`
659
+ : null;
576
660
  break;
661
+ }
577
662
  default:
578
663
  return null;
579
664
  }
@@ -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,`
@@ -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);
@@ -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;
@@ -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
@@ -74,7 +74,8 @@ const ENDPOINT_PILL_TYPES: { pattern: RegExp; type: string }[] = [
74
74
  { pattern: /\/contacts(\.json|\/|\?|$)/, type: 'contact' },
75
75
  { pattern: /\/labels(\.json|\/|\?|$)/, type: 'label' },
76
76
  { pattern: /\/flows(\.json|\/|\?|$)/, type: 'flow' },
77
- { pattern: /\/fields(\.json|\/|\?|$)/, type: 'field' }
77
+ { pattern: /\/fields(\.json|\/|\?|$)/, type: 'field' },
78
+ { pattern: /\/topics(\.json|\/|\?|$)/, type: 'topic' }
78
79
  ];
79
80
 
80
81
  export class Select<T extends SelectOption> extends FieldElement {
@@ -2204,9 +2205,9 @@ export class Select<T extends SelectOption> extends FieldElement {
2204
2205
  class="option-name"
2205
2206
  style="display:flex; align-items:center; gap:6px;"
2206
2207
  >
2207
- ${icon
2208
- ? html`<temba-icon name="${icon}"></temba-icon>`
2209
- : null}<span>${this.renderHighlightedName(option)}</span>
2208
+ ${icon ? html`<temba-icon name="${icon}"></temba-icon>` : null}<span
2209
+ >${this.renderHighlightedName(option)}</span
2210
+ >
2210
2211
  </div>
2211
2212
  `;
2212
2213
  }
package/src/interfaces.ts CHANGED
@@ -59,6 +59,7 @@ export interface NamedUser extends User {
59
59
 
60
60
  export interface User {
61
61
  id?: number;
62
+ uuid?: string;
62
63
  first_name?: string;
63
64
  last_name?: string;
64
65
  name?: string;
@@ -0,0 +1,56 @@
1
+ const intlLanguageNames = new Intl.DisplayNames(['en'], {
2
+ type: 'language',
3
+ fallback: 'none'
4
+ });
5
+
6
+ const ADDITIONAL_LANGUAGE_NAMES: { [code: string]: string } = {
7
+ aab: 'Alumu-Tesu',
8
+ aac: 'Ari',
9
+ aas: 'Aasáx',
10
+ abp: 'Abellen Ayta',
11
+ acf: 'Saint Lucian Creole French',
12
+ aec: 'Saidi Arabic',
13
+ afb: 'Gulf Arabic',
14
+ apd: 'Sudanese Arabic',
15
+ ayl: 'Libyan Arabic',
16
+ blk: "Pa'o Karen",
17
+ bog: 'Bamako Sign Language',
18
+ bzs: 'Brazilian Sign Language',
19
+ csn: 'Colombian Sign Language',
20
+ dag: 'Dagbani',
21
+ ecs: 'Ecuadorian Sign Language',
22
+ frk: 'Frankish',
23
+ fsl: 'French Sign Language',
24
+ fuv: 'Nigerian Fulfulde',
25
+ gcr: 'Guianese Creole French',
26
+ gpe: 'Ghanaian Pidgin English',
27
+ gux: 'Gourmanchéma',
28
+ ise: 'Italian Sign Language',
29
+ ksw: "S'gaw Karen",
30
+ kun: 'Kunama',
31
+ kyu: 'Western Kayah',
32
+ laj: 'Lango',
33
+ nyj: 'Nyanga',
34
+ prd: 'Parsi-Dari',
35
+ prl: 'Peruvian Sign Language',
36
+ pst: 'Central Pashto',
37
+ rop: 'Kriol',
38
+ tdt: 'Tetun Dili',
39
+ toi: 'Tonga',
40
+ tuv: 'Turkana',
41
+ vsl: 'Venezuelan Sign Language'
42
+ };
43
+
44
+ export function getLanguageName(code: string): string {
45
+ if (!code) return '';
46
+ if (code === 'und') return 'Unknown';
47
+
48
+ try {
49
+ const name = intlLanguageNames.of(code);
50
+ if (name) return name;
51
+ } catch {
52
+ // fall through to additional lookup
53
+ }
54
+
55
+ return ADDITIONAL_LANGUAGE_NAMES[code] || code;
56
+ }
@@ -181,8 +181,6 @@ export class Dialog extends ResizeElement {
181
181
 
182
182
  temba-button {
183
183
  margin-left: 10px;
184
- --button-y: 0.4em;
185
- --button-x: 1em;
186
184
  }
187
185
 
188
186
  .dialog-body temba-loading {
@@ -555,7 +553,7 @@ export class Dialog extends ResizeElement {
555
553
  ?destructive=${button.type == 'primary' && this.destructive}
556
554
  ?primary=${button.type == 'primary' && !this.destructive}
557
555
  ?secondary=${button.type == 'secondary'}
558
- ?submitting=${this.submitting}
556
+ ?submitting=${this.submitting && button.type == 'primary'}
559
557
  ?disabled=${this.disabled && !button.closes}
560
558
  index=${index}
561
559
  @click=${this.handleClick}