@nyaruka/temba-components 0.156.18 → 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 (56) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/temba-components.js +2119 -1617
  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 +74 -9
  7. package/src/display/Dropdown.ts +11 -0
  8. package/src/display/Label.ts +154 -2
  9. package/src/display/LeafletMap.ts +4 -3
  10. package/src/display/Options.ts +71 -16
  11. package/src/display/TembaUser.ts +32 -8
  12. package/src/events/eventRenderers.ts +243 -95
  13. package/src/excellent/caret-utils.ts +0 -1
  14. package/src/flow/AutoTranslate.ts +2 -2
  15. package/src/flow/Editor.ts +4 -4
  16. package/src/flow/NodeEditor.ts +2 -2
  17. package/src/flow/NodeTypeSelector.ts +0 -5
  18. package/src/flow/RevisionsWindow.ts +1 -3
  19. package/src/flow/actions/set_contact_language.ts +5 -4
  20. package/src/flow/nodes/shared.ts +14 -0
  21. package/src/flow/nodes/split_by_llm_categorize.ts +28 -8
  22. package/src/flow/utils.ts +39 -60
  23. package/src/form/ArrayEditor.ts +9 -11
  24. package/src/form/Checkbox.ts +2 -2
  25. package/src/form/ColorPicker.ts +5 -3
  26. package/src/form/Compose.ts +1 -1
  27. package/src/form/FieldElement.ts +8 -8
  28. package/src/form/KeyValueEditor.ts +4 -4
  29. package/src/form/MessageEditor.ts +2 -3
  30. package/src/form/RangePicker.ts +17 -17
  31. package/src/form/TembaSlider.ts +10 -10
  32. package/src/form/TemplateEditor.ts +4 -4
  33. package/src/form/TextInput.ts +19 -1
  34. package/src/form/select/Omnibox.ts +21 -20
  35. package/src/form/select/Select.ts +382 -173
  36. package/src/form/select/WorkspaceSelect.ts +7 -1
  37. package/src/interfaces.ts +1 -0
  38. package/src/languages.ts +56 -0
  39. package/src/layout/Accordion.ts +2 -2
  40. package/src/layout/Dialog.ts +1 -3
  41. package/src/layout/Modax.ts +1 -1
  42. package/src/list/ContentMenu.ts +1 -2
  43. package/src/list/SortableList.ts +156 -0
  44. package/src/list/TembaMenu.ts +159 -113
  45. package/src/live/ContactBadges.ts +2 -1
  46. package/src/live/ContactChat.ts +62 -45
  47. package/src/live/ContactDetails.ts +3 -1
  48. package/src/live/ContactFieldEditor.ts +36 -31
  49. package/src/live/FieldManager.ts +4 -4
  50. package/src/store/AppState.ts +3 -21
  51. package/src/store/Store.ts +0 -29
  52. package/src/styles/designTokens.ts +158 -0
  53. package/src/styles/pillVariants.ts +147 -0
  54. package/static/css/temba-components.css +141 -36
  55. package/web-dev-server.config.mjs +0 -1
  56. package/web-test-runner.config.mjs +98 -1
@@ -56,28 +56,134 @@ export enum Events {
56
56
  WEBHOOK_CALLED = 'webhook_called'
57
57
  }
58
58
 
59
+ /**
60
+ * Renders a single DS pill of the given type. temba-label auto-resolves
61
+ * the icon from `type` (via PILL_TYPE_ICONS), so we don't pass `icon`
62
+ * unless the consumer explicitly overrides it. When `href` is provided
63
+ * the pill is wrapped in a navigation anchor (SPA goto); otherwise it's
64
+ * rendered inline as a plain pill. Single source of truth for the
65
+ * "entity pill in chat history" look — inline margin keeps wrapping
66
+ * airy, and inline style works regardless of host-page Tailwind reach.
67
+ */
68
+ const renderEntityPill = (
69
+ pillType: string,
70
+ name: string,
71
+ opts: { href?: string; icon?: string } = {}
72
+ ): TemplateResult => {
73
+ const pill = opts.icon
74
+ ? html`<temba-label
75
+ icon=${opts.icon}
76
+ type=${pillType}
77
+ ?clickable=${!!opts.href}
78
+ style="margin: 1px 2px; vertical-align: middle;"
79
+ >${name}</temba-label
80
+ >`
81
+ : html`<temba-label
82
+ type=${pillType}
83
+ ?clickable=${!!opts.href}
84
+ style="margin: 1px 2px; vertical-align: middle;"
85
+ >${name}</temba-label
86
+ >`;
87
+ return opts.href
88
+ ? html`<a
89
+ href=${opts.href}
90
+ onclick="goto(event, this)"
91
+ style="vertical-align: middle;"
92
+ >${pill}</a
93
+ >`
94
+ : pill;
95
+ };
96
+
97
+ const groupPill = (item: any) =>
98
+ renderEntityPill('group', item.name, {
99
+ href: `/contact/group/${item.uuid}/`
100
+ });
101
+
102
+ const flowPill = (flow: any) =>
103
+ renderEntityPill('flow', flow.name, {
104
+ href: `/flow/editor/${flow.uuid}/`
105
+ });
106
+
107
+ const fieldPill = (field: any) => renderEntityPill('field', field.name);
108
+
109
+ const topicPill = (topic: any) =>
110
+ renderEntityPill('topic', topic.name, {
111
+ href: `/ticket/${topic.uuid}/open/`
112
+ });
113
+
114
+ /**
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.
121
+ */
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
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
+ };
149
+
150
+ /**
151
+ * Inline-flex wrapper style that text + pills share. Without it,
152
+ * plain text sits on its own baseline while vertical-align: middle
153
+ * pills sit slightly above and the text appears to "float". flex
154
+ * centering keeps the row of words and pills on one cross-axis.
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.
161
+ const eventLineStyle =
162
+ 'display: inline-flex; align-items: center; flex-wrap: wrap; justify-content: center; gap: 2px 4px; min-height: 24px;';
163
+
59
164
  const renderInfoList = (
60
165
  singular: string,
61
166
  plural: string,
62
167
  items: any[]
63
168
  ): TemplateResult => {
64
169
  if (items.length === 1) {
65
- return html`<div>${singular} <strong>${items[0].name}</strong></div>`;
66
- } else {
67
- const list = items.map((item) => item.name);
68
- if (list.length === 2) {
69
- return html`<div>
70
- ${plural} <strong>${list[0]}</strong> and <strong>${list[1]}</strong>
71
- </div>`;
72
- } else {
73
- const last = list.pop();
74
- const middle = list.map(
75
- (name, index) =>
76
- html`<strong>${name}</strong>${index < list.length - 1 ? ', ' : ''}`
77
- );
78
- return html`<div>${plural} ${middle}, and <strong>${last}</strong></div>`;
79
- }
170
+ return html`<div style=${eventLineStyle}>
171
+ ${singular} ${groupPill(items[0])}
172
+ </div>`;
173
+ }
174
+ if (items.length === 2) {
175
+ return html`<div style=${eventLineStyle}>
176
+ ${plural} ${groupPill(items[0])} and ${groupPill(items[1])}
177
+ </div>`;
80
178
  }
179
+ // No commas between pills — the flex `gap` on eventLineStyle
180
+ // already provides visual separation, and a pill list reads as a
181
+ // single "set" rather than a sentence.
182
+ const middle = items.slice(0, -1).map((item) => groupPill(item));
183
+ const last = items[items.length - 1];
184
+ return html`<div style=${eventLineStyle}>
185
+ ${plural} ${middle} and ${groupPill(last)}
186
+ </div>`;
81
187
  };
82
188
 
83
189
  export const renderRunEvent = (event: RunEvent): TemplateResult => {
@@ -92,11 +198,8 @@ export const renderRunEvent = (event: RunEvent): TemplateResult => {
92
198
  }
93
199
  }
94
200
 
95
- return html`<div>
96
- ${verb}
97
- <a href="/flow/editor/${event.flow.uuid}/"
98
- ><strong>${event.flow.name}</strong></a
99
- >
201
+ return html`<div style=${eventLineStyle}>
202
+ ${verb} ${flowPill(event.flow)}
100
203
  </div>`;
101
204
  };
102
205
 
@@ -111,28 +214,38 @@ export const renderChatStartedEvent = (
111
214
  };
112
215
 
113
216
  export const renderUpdateEvent = (event: UpdateFieldEvent): TemplateResult => {
114
- return event.value
115
- ? html`<div>
116
- Updated <strong>${event.field.name}</strong> to
117
- <strong>${event.value.text}</strong>
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
222
+ ? html`<div style=${eventLineStyle}>
223
+ Updated ${fieldPill(event.field)} to ${valueText(event.value.text)}
118
224
  </div>`
119
- : html`<div>Cleared <strong>${event.field.name}</strong></div>`;
225
+ : html`<div style=${eventLineStyle}>
226
+ Cleared ${fieldPill(event.field)}
227
+ </div>`;
120
228
  };
121
229
 
122
230
  export const renderNameChanged = (event: NameChangedEvent): TemplateResult => {
123
- return html`<div>
124
- Updated <strong>name</strong> to <strong>${event.name}</strong>
231
+ if (!event.name) {
232
+ return html`<div style=${eventLineStyle}>Cleared name</div>`;
233
+ }
234
+ return html`<div style=${eventLineStyle}>
235
+ Updated name to ${valueText(event.name)}
125
236
  </div>`;
126
237
  };
127
238
 
128
239
  export const renderContactURNsChanged = (
129
240
  event: URNsChangedEvent
130
241
  ): TemplateResult => {
131
- return html`<div>
132
- Updated <strong>URNs</strong> to
133
- ${oxfordFn(
134
- event.urns,
135
- (urn: string) => html`<strong>${urn.split(':')[1].split('?')[0]}</strong>`
242
+ if (!event.urns || event.urns.length === 0) {
243
+ return html`<div style=${eventLineStyle}>Cleared URNs</div>`;
244
+ }
245
+ return html`<div style=${eventLineStyle}>
246
+ Updated URNs to
247
+ ${oxfordFn(event.urns, (urn: string) =>
248
+ valueText(urn.split(':')[1].split('?')[0])
136
249
  )}
137
250
  </div>`;
138
251
  };
@@ -143,87 +256,110 @@ export const renderTicketAction = (
143
256
  ): TemplateResult => {
144
257
  const ticketUUID = event.ticket?.uuid || event.ticket_uuid;
145
258
 
146
- const actionNote = event.note
147
- ? html`<div
148
- 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;"
149
- >
150
- <div style="color: #8e830fff; font-size: 1em;margin-bottom:0.25em; ">
151
- <strong>${event._user ? event._user.name : 'Someone'}</strong> added a
152
- note
153
- <temba-date
154
- value=${event.created_on.toISOString()}
155
- display="relative"
156
- ></temba-date>
157
- </div>
158
- <div style="white-space: pre-wrap;">${event.note}</div>
159
- </div>`
160
- : null;
161
-
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.
162
264
  if (action === 'noted') {
163
- 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;
164
270
  }
165
271
 
166
- const description = event._user
167
- ? html`<div>
168
- <strong>${event._user.name}</strong> ${action} a
169
- <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>
170
280
  </div>`
171
- : html`<div>
172
- A
173
- <strong><a href="/ticket/all/closed/${ticketUUID}/">ticket</a></strong>
174
- was <strong>${action}</strong>
281
+ : html`<div style=${eventLineStyle}>
282
+ A <a href=${href}>ticket</a> was ${action}
175
283
  </div>`;
176
-
177
- return html`<div style="${actionNote ? 'margin-bottom: 1em;' : ''}">
178
- ${description}
179
- </div>
180
- ${actionNote}`;
181
284
  };
182
285
 
183
286
  export const renderTicketAssigneeChanged = (
184
287
  event: TicketEvent
185
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
+ >`;
186
300
  if (event._user) {
187
301
  if (event.assignee) {
188
- return html`<div>
189
- <strong>${event._user.name}</strong> assigned this ticket to
190
- <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)}
191
317
  </div>`;
192
318
  } else {
193
- return html`<div>
194
- <strong>${event._user.name}</strong> unassigned this ticket
319
+ return html`<div style=${eventLineStyle}>
320
+ ${userLink(event._user)} unassigned this ${ticketLink}
195
321
  </div>`;
196
322
  }
197
323
  } else {
198
324
  if (event.assignee) {
199
- return html`<div>
200
- This ticket was assigned to <strong>${event.assignee.name}</strong>
325
+ return html`<div style=${eventLineStyle}>
326
+ ${ticketLinkCapitalized} was assigned to ${userLink(event.assignee)}
201
327
  </div>`;
202
328
  } else {
203
- return html`<div>This ticket was unassigned</div>`;
329
+ return html`<div style=${eventLineStyle}>
330
+ ${ticketLinkCapitalized} was unassigned
331
+ </div>`;
204
332
  }
205
333
  }
206
334
  };
207
335
 
208
336
  export const renderTicketOpened = (event: TicketEvent): TemplateResult => {
209
- 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>`;
210
351
  };
211
352
 
212
353
  export const renderContactGroupsEvent = (
213
354
  event: ContactGroupsEvent
214
355
  ): TemplateResult => {
215
- const groupsEvent = event as ContactGroupsEvent;
216
- if (groupsEvent.groups_added) {
356
+ if (event.groups_added) {
357
+ return renderInfoList('Added to', 'Added to', event.groups_added);
358
+ } else if (event.groups_removed) {
217
359
  return renderInfoList(
218
- 'Added to group',
219
- 'Added to groups',
220
- groupsEvent.groups_added
221
- );
222
- } else if (groupsEvent.groups_removed) {
223
- return renderInfoList(
224
- 'Removed from group',
225
- 'Removed from groups',
226
- groupsEvent.groups_removed
360
+ 'Removed from',
361
+ 'Removed from',
362
+ event.groups_removed
227
363
  );
228
364
  }
229
365
  };
@@ -234,23 +370,31 @@ export const renderAirtimeTransferredEvent = (
234
370
  if (parseFloat(event.amount) === 0) {
235
371
  return html`<div>Airtime transfer failed</div>`;
236
372
  }
237
- return html`<div>
238
- Transferred <strong>${event.amount}</strong> ${event.currency} of airtime
373
+ return html`<div style=${eventLineStyle}>
374
+ Transferred ${valueText(event.amount)} ${event.currency} of airtime
239
375
  </div>`;
240
376
  };
241
377
 
242
378
  export const renderContactLanguageChangedEvent = (
243
379
  event: ContactLanguageChangedEvent
244
380
  ): TemplateResult => {
245
- return html`<div>
246
- 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)}
247
386
  </div>`;
248
387
  };
249
388
 
250
389
  export const renderContactStatusChangedEvent = (
251
390
  event: ContactStatusChangedEvent
252
391
  ): TemplateResult => {
253
- 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>`;
254
398
  };
255
399
 
256
400
  export const renderCallEvent = (event: CallEvent): TemplateResult => {
@@ -375,9 +519,7 @@ export const renderBroadcastCreated = (event: any): TemplateResult | null => {
375
519
  export const renderSessionTriggered = (event: any): TemplateResult | null => {
376
520
  const flow = event.flow;
377
521
  if (flow) {
378
- return html`<div>
379
- Started somebody else in <strong>${flow.name}</strong>
380
- </div>`;
522
+ return html`<div>Started somebody else in ${flowPill(flow)}</div>`;
381
523
  }
382
524
  return null;
383
525
  };
@@ -498,7 +640,7 @@ export const renderEvent = (
498
640
  content = renderTicketAction(event as TicketEvent, 'closed');
499
641
  break;
500
642
  case Events.TICKET_OPENED:
501
- content = renderTicketAction(event as TicketEvent, 'opened');
643
+ content = renderTicketOpened(event as TicketEvent);
502
644
  break;
503
645
  case Events.TICKET_NOTE_ADDED:
504
646
  content = renderTicketAction(event as TicketEvent, 'noted');
@@ -506,11 +648,17 @@ export const renderEvent = (
506
648
  case Events.TICKET_REOPENED:
507
649
  content = renderTicketAction(event as TicketEvent, 'reopened');
508
650
  break;
509
- case Events.TICKET_TOPIC_CHANGED:
510
- content = html`<div>
511
- Topic changed to <strong>${(event as TicketEvent).topic.name}</strong>
512
- </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;
513
660
  break;
661
+ }
514
662
  default:
515
663
  return null;
516
664
  }
@@ -294,4 +294,3 @@ export function setCaretRange(
294
294
  selection.removeAllRanges();
295
295
  selection.addRange(range);
296
296
  }
297
-
@@ -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
 
@@ -308,9 +308,7 @@ export class RevisionsWindow extends RapidElement {
308
308
  // too, or a sweeping edit (4+ label areas) would still strand the
309
309
  // no-op group as an empty-summary row.
310
310
  const fitsLabelCap =
311
- isNoOp ||
312
- !groupHasRealChange ||
313
- prospective.size <= MAX_GROUP_LABELS;
311
+ isNoOp || !groupHasRealChange || prospective.size <= MAX_GROUP_LABELS;
314
312
 
315
313
  if (withinWindow && sameAuthor && fitsLabelCap) {
316
314
  group.push(rev);
@@ -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
@@ -69,6 +69,14 @@ const advancedSection = {
69
69
  !!(formData.localizeRules || formData.localizeCategories)
70
70
  };
71
71
 
72
+ const categoriesLocalizationSection = {
73
+ label: 'Localization',
74
+ localizable: false,
75
+ items: ['localizeCategories'],
76
+ collapsed: true,
77
+ getValueCount: (formData: FormData) => !!formData.localizeCategories
78
+ };
79
+
72
80
  export const nodeOptionsAccordion: AccordionLayoutConfig = {
73
81
  type: 'accordion',
74
82
  multi: true,
@@ -81,6 +89,12 @@ export const nodeOptionsAccordionSimple: AccordionLayoutConfig = {
81
89
  sections: [resultNameSection]
82
90
  };
83
91
 
92
+ export const nodeOptionsAccordionCategoriesOnly: AccordionLayoutConfig = {
93
+ type: 'accordion',
94
+ multi: true,
95
+ sections: [resultNameSection, categoriesLocalizationSection]
96
+ };
97
+
84
98
  /**
85
99
  * Shared category localization functions for router nodes.
86
100
  * These provide a consistent way to localize category names across all router types.