@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.
- package/CHANGELOG.md +17 -0
- package/dist/temba-components.js +2119 -1617
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/display/Button.ts +102 -121
- package/src/display/Chat.ts +74 -9
- package/src/display/Dropdown.ts +11 -0
- package/src/display/Label.ts +154 -2
- package/src/display/LeafletMap.ts +4 -3
- package/src/display/Options.ts +71 -16
- package/src/display/TembaUser.ts +32 -8
- package/src/events/eventRenderers.ts +243 -95
- package/src/excellent/caret-utils.ts +0 -1
- package/src/flow/AutoTranslate.ts +2 -2
- package/src/flow/Editor.ts +4 -4
- package/src/flow/NodeEditor.ts +2 -2
- package/src/flow/NodeTypeSelector.ts +0 -5
- package/src/flow/RevisionsWindow.ts +1 -3
- package/src/flow/actions/set_contact_language.ts +5 -4
- package/src/flow/nodes/shared.ts +14 -0
- package/src/flow/nodes/split_by_llm_categorize.ts +28 -8
- package/src/flow/utils.ts +39 -60
- package/src/form/ArrayEditor.ts +9 -11
- package/src/form/Checkbox.ts +2 -2
- package/src/form/ColorPicker.ts +5 -3
- package/src/form/Compose.ts +1 -1
- package/src/form/FieldElement.ts +8 -8
- package/src/form/KeyValueEditor.ts +4 -4
- package/src/form/MessageEditor.ts +2 -3
- package/src/form/RangePicker.ts +17 -17
- package/src/form/TembaSlider.ts +10 -10
- package/src/form/TemplateEditor.ts +4 -4
- package/src/form/TextInput.ts +19 -1
- package/src/form/select/Omnibox.ts +21 -20
- package/src/form/select/Select.ts +382 -173
- package/src/form/select/WorkspaceSelect.ts +7 -1
- package/src/interfaces.ts +1 -0
- package/src/languages.ts +56 -0
- package/src/layout/Accordion.ts +2 -2
- package/src/layout/Dialog.ts +1 -3
- package/src/layout/Modax.ts +1 -1
- package/src/list/ContentMenu.ts +1 -2
- package/src/list/SortableList.ts +156 -0
- package/src/list/TembaMenu.ts +159 -113
- package/src/live/ContactBadges.ts +2 -1
- package/src/live/ContactChat.ts +62 -45
- package/src/live/ContactDetails.ts +3 -1
- package/src/live/ContactFieldEditor.ts +36 -31
- package/src/live/FieldManager.ts +4 -4
- package/src/store/AppState.ts +3 -21
- package/src/store/Store.ts +0 -29
- package/src/styles/designTokens.ts +158 -0
- package/src/styles/pillVariants.ts +147 -0
- package/static/css/temba-components.css +141 -36
- package/web-dev-server.config.mjs +0 -1
- 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
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
325
|
+
return html`<div style=${eventLineStyle}>
|
|
326
|
+
${ticketLinkCapitalized} was assigned to ${userLink(event.assignee)}
|
|
201
327
|
</div>`;
|
|
202
328
|
} else {
|
|
203
|
-
return html`<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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
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
|
-
'
|
|
219
|
-
'
|
|
220
|
-
|
|
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
|
|
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
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
}
|
|
@@ -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 {
|
|
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 =
|
|
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,`
|
package/src/flow/Editor.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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 ?
|
|
3647
|
+
(baseLanguage ? getLanguageName(baseLanguage) : '') ||
|
|
3648
3648
|
'Primary language';
|
|
3649
3649
|
const isBaseSelected =
|
|
3650
3650
|
!this.languageCode ||
|
package/src/flow/NodeEditor.ts
CHANGED
|
@@ -37,7 +37,7 @@ import {
|
|
|
37
37
|
fromStore,
|
|
38
38
|
zustand
|
|
39
39
|
} from '../store/AppState';
|
|
40
|
-
import {
|
|
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
|
-
?
|
|
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 {
|
|
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 =
|
|
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:
|
|
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:
|
|
55
|
+
name: getLanguageName(action.language)
|
|
55
56
|
}
|
|
56
57
|
],
|
|
57
58
|
uuid: action.uuid
|
package/src/flow/nodes/shared.ts
CHANGED
|
@@ -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.
|