@nyaruka/temba-components 0.167.0 → 0.168.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 (59) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/locales/es.js +3 -1
  3. package/dist/locales/es.js.map +1 -1
  4. package/dist/locales/fr.js +3 -1
  5. package/dist/locales/fr.js.map +1 -1
  6. package/dist/locales/pt.js +3 -1
  7. package/dist/locales/pt.js.map +1 -1
  8. package/dist/temba-components.js +2791 -1668
  9. package/dist/temba-components.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/Icons.ts +2 -0
  12. package/src/display/Button.ts +8 -6
  13. package/src/display/Chat.ts +541 -191
  14. package/src/display/Dropdown.ts +40 -17
  15. package/src/display/Label.ts +25 -4
  16. package/src/display/TembaDate.ts +11 -2
  17. package/src/display/Tip.ts +157 -18
  18. package/src/events/eventRenderers.ts +678 -268
  19. package/src/events.ts +16 -1
  20. package/src/flow/NodeEditor.ts +82 -9
  21. package/src/flow/actions/add_contact_urn.ts +1 -0
  22. package/src/flow/actions/play_audio.ts +1 -0
  23. package/src/flow/actions/send_broadcast.ts +17 -1
  24. package/src/flow/actions/send_msg.ts +12 -1
  25. package/src/flow/actions/set_run_result.ts +1 -0
  26. package/src/flow/actions/start_session.ts +10 -1
  27. package/src/flow/nodes/shared-rules.ts +2 -0
  28. package/src/flow/nodes/split_by_airtime.ts +6 -0
  29. package/src/flow/nodes/split_by_expression.ts +1 -0
  30. package/src/flow/nodes/split_by_llm.ts +2 -0
  31. package/src/flow/nodes/split_by_llm_categorize.ts +3 -1
  32. package/src/flow/nodes/split_by_random.ts +2 -1
  33. package/src/flow/nodes/split_by_resthook.ts +4 -1
  34. package/src/flow/nodes/split_by_webhook.ts +9 -1
  35. package/src/flow/types.ts +3 -0
  36. package/src/flow/utils.ts +49 -0
  37. package/src/form/DatePicker.ts +2 -1
  38. package/src/layout/Card.ts +9 -0
  39. package/src/layout/CardLayout.ts +426 -21
  40. package/src/layout/CardStack.ts +4 -1
  41. package/src/list/ContactList.ts +22 -17
  42. package/src/list/ContentList.ts +632 -29
  43. package/src/list/MsgList.ts +4 -2
  44. package/src/list/SortableList.ts +5 -1
  45. package/src/list/TembaMenu.ts +67 -6
  46. package/src/live/ContactChat.ts +655 -336
  47. package/src/live/ContactDetails.ts +961 -88
  48. package/src/live/ContactFieldEditor.ts +46 -11
  49. package/src/live/ContactFields.ts +57 -16
  50. package/src/live/ContactStoreElement.ts +15 -8
  51. package/src/locales/es.ts +3 -2
  52. package/src/locales/fr.ts +3 -2
  53. package/src/locales/pt.ts +3 -2
  54. package/src/simulator/Simulator.ts +28 -0
  55. package/src/styles/pillVariants.ts +13 -1
  56. package/web-test-runner.config.mjs +2 -0
  57. package/xliff/es.xlf +6 -3
  58. package/xliff/fr.xlf +6 -3
  59. package/xliff/pt.xlf +6 -3
@@ -1,47 +1,79 @@
1
- import { css, html, TemplateResult } from 'lit';
2
- import { ContactStoreElement } from './ContactStoreElement';
1
+ import { css, html, PropertyValues, TemplateResult } from 'lit';
2
+ import { property, state } from 'lit/decorators.js';
3
3
  import { Icon } from '../Icons';
4
- import { capitalize } from '../utils';
4
+ import { CustomEventType, Group, URN } from '../interfaces';
5
5
  import { getLanguageName } from '../languages';
6
+ import { capitalize, WebResponse } from '../utils';
7
+ import { Select } from '../form/select/Select';
8
+ import { TextInput } from '../form/TextInput';
9
+ import { ContactFieldEditor } from './ContactFieldEditor';
10
+ import { ContactStoreElement, getDestinationURN } from './ContactStoreElement';
6
11
 
7
- const STATUS = {
8
- active: 'Active',
9
- blocked: 'Blocked',
10
- stopped: 'Stopped',
11
- archived: 'Archived'
12
- };
12
+ interface Option {
13
+ name: string;
14
+ value: string;
15
+ }
13
16
 
14
- const SCHEMES = {
15
- tel: 'Phone',
16
- whatsapp: 'WhatsApp',
17
- fcm: 'Firebase Cloud Messaging',
18
- twitter: 'Twitter'
19
- };
17
+ const STATUS_OPTIONS: Option[] = [
18
+ { value: 'active', name: 'Active' },
19
+ { value: 'blocked', name: 'Blocked' },
20
+ { value: 'stopped', name: 'Stopped' },
21
+ { value: 'archived', name: 'Archived' }
22
+ ];
20
23
 
21
24
  export class ContactDetails extends ContactStoreElement {
25
+ @property({ type: Boolean })
26
+ editable = false;
27
+
28
+ @property({ type: Boolean })
29
+ anon = false;
30
+
31
+ @property({ type: Array })
32
+ schemes: Option[] = [];
33
+
34
+ @state()
35
+ private newScheme = '';
36
+
37
+ @state()
38
+ private newUrn = '';
39
+
40
+ @state()
41
+ private urnDialogOpen = false;
42
+
43
+ @state()
44
+ private draftUrns: URN[] = [];
45
+
46
+ @state()
47
+ private saving = new Set<string>();
48
+
49
+ @state()
50
+ private failed = new Set<string>();
51
+
52
+ private saveGeneration = 0;
53
+
54
+ private saveGenerations = new Map<string, number>();
55
+
56
+ private fieldSaveGenerations = new Map<string, number>();
57
+
58
+ private groupsSave: Promise<WebResponse> | null = null;
59
+
22
60
  static get styles() {
23
61
  return css`
24
62
  .wrapper {
25
- padding-top: 0em;
63
+ padding-top: 0;
26
64
  }
27
65
 
28
- /* Mirrors the disabled <temba-contact-field> row so the Groups
29
- entry reads as just another field — same label color/size,
30
- same horizontal inset, same bottom separator. Margin matches
31
- the combined .wrapper + :host margins of contact-field
32
- (0.5em + 1em) so spacing between rows stays uniform. */
33
66
  .row {
34
67
  padding-bottom: 0.6em;
35
68
  border-bottom: 1px solid #ececec;
36
- margin-bottom: 1.5em;
69
+ margin-bottom: 1em;
37
70
  }
38
71
 
39
72
  .row .label {
40
73
  color: var(--text-2);
41
74
  font-size: 11px;
42
75
  font-weight: var(--w-medium);
43
- margin-top: 0.25em;
44
- margin-left: 0.25em;
76
+ margin: 0 0 4px 2px;
45
77
  }
46
78
 
47
79
  .row .value {
@@ -53,10 +85,227 @@ export class ContactDetails extends ContactStoreElement {
53
85
  gap: 0.4em;
54
86
  }
55
87
 
56
- .group {
88
+ .editable-row {
89
+ position: relative;
90
+ margin-bottom: 1em;
91
+ }
92
+
93
+ .editable-row > label {
94
+ display: block;
95
+ color: var(--text-2);
96
+ font-size: 12px;
97
+ font-weight: var(--w-medium);
98
+ margin: 0 0 4px 2px;
99
+ }
100
+
101
+ .editable-row temba-select {
102
+ display: block;
103
+ --color-widget-bg: white;
104
+ }
105
+
106
+ .editable-row.saving {
107
+ pointer-events: none;
108
+ opacity: 0.7;
109
+ }
110
+
111
+ .editable-row.failed {
112
+ --color-widget-border: rgba(var(--error-rgb), 0.5);
113
+ }
114
+
115
+ .error {
116
+ position: absolute;
117
+ right: 8px;
118
+ top: 29px;
119
+ z-index: 1;
120
+ --icon-color: rgb(var(--error-rgb));
121
+ }
122
+
123
+ .dynamic-groups {
124
+ display: flex;
125
+ flex-wrap: wrap;
126
+ gap: 0.4em;
127
+ margin: 0.5em 0 0 0.25em;
128
+ }
129
+
130
+ .urn-display {
131
+ display: grid;
132
+ grid-template-columns: minmax(0, 1fr) auto;
133
+ gap: 8px;
134
+ align-items: start;
135
+ margin-bottom: 14px;
136
+ }
137
+
138
+ .urn-display.editable {
139
+ grid-template-columns: minmax(0, 1fr) auto 24px;
140
+ }
141
+
142
+ .urn-display temba-contact-field {
143
+ --contact-field-separator: none;
144
+ --contact-field-padding-bottom: 0;
145
+ --contact-field-wrapper-margin-bottom: 0;
146
+ margin-bottom: 0;
147
+ }
148
+
149
+ .urn-display.only-unsendable {
150
+ border-left: 3px solid rgba(var(--error-rgb), 0.65);
151
+ background: rgba(var(--error-rgb), 0.05);
152
+ padding: 6px 8px 6px 10px;
153
+ }
154
+
155
+ .urn-display.only-unsendable temba-contact-field {
156
+ --contact-field-value-icon-color: rgb(var(--error-rgb));
157
+ }
158
+
159
+ .urn-more-count {
160
+ display: flex;
161
+ align-items: center;
162
+ align-self: center;
163
+ height: 24px;
164
+ margin-top: 22px;
165
+ color: var(--text-2);
166
+ font-size: 12px;
167
+ white-space: nowrap;
168
+ }
169
+
170
+ .urn-edit-icon {
171
+ display: flex;
172
+ width: 24px;
173
+ height: 24px;
174
+ margin-top: 22px;
175
+ justify-self: center;
176
+ cursor: pointer;
177
+ --icon-color: var(--text-2);
178
+ }
179
+
180
+ .urn-edit-icon:hover,
181
+ .urn-edit-icon:focus-visible {
182
+ --icon-color: var(--text-1);
183
+ }
184
+
185
+ .urn-sendability {
186
+ display: flex;
187
+ align-items: center;
188
+ justify-content: center;
189
+ --icon-color: var(--text-3);
190
+ }
191
+
192
+ temba-textinput .urn-sendability {
193
+ margin: 0 8px 0 10px;
194
+ }
195
+
196
+ .urn-delete-action {
197
+ display: flex;
198
+ align-items: center;
199
+ margin: 0 10px 0 4px;
200
+ }
201
+
202
+ .urn-delete-icon {
203
+ display: flex;
204
+ cursor: pointer;
205
+ --icon-color: var(--text-2);
206
+ }
207
+
208
+ .urn-delete-icon:hover,
209
+ .urn-delete-icon:focus-visible {
210
+ --icon-color: rgb(var(--error-rgb));
211
+ }
212
+
213
+ .urn-dialog {
214
+ display: grid;
215
+ gap: 20px;
216
+ padding: 24px;
217
+ width: 100%;
218
+ box-sizing: border-box;
219
+ }
220
+
221
+ .urn-list {
222
+ display: block;
223
+ width: 100%;
224
+ }
225
+
226
+ .urn-row {
227
+ display: grid;
228
+ grid-template-columns: 18px minmax(0, 1fr);
229
+ gap: 10px;
230
+ align-items: center;
231
+ width: 100%;
232
+ min-width: 0;
233
+ box-sizing: border-box;
234
+ }
235
+
236
+ .urn-row temba-textinput {
237
+ display: block;
238
+ width: 100%;
239
+ min-width: 0;
240
+ }
241
+
242
+ .drag-slot {
243
+ align-self: end;
244
+ display: flex;
245
+ align-items: center;
246
+ justify-content: center;
247
+ height: calc(var(--input-h, 34px) + 2px);
248
+ box-sizing: border-box;
249
+ }
250
+
251
+ .drag-handle {
252
+ cursor: grab;
253
+ --icon-color: var(--text-2);
254
+ }
255
+
256
+ .drag-handle:active {
257
+ cursor: grabbing;
258
+ }
259
+
260
+ .add-urn-fields {
261
+ display: grid;
262
+ grid-template-columns: minmax(120px, 0.8fr) minmax(160px, 1.2fr) 72px;
263
+ gap: 8px;
264
+ align-items: end;
265
+ margin-left: 28px;
266
+ padding-top: 16px;
267
+ border-top: 1px solid var(--border-light, #ececec);
268
+ }
269
+
270
+ .add-urn-fields label {
271
+ display: block;
272
+ color: var(--text-2);
273
+ font-size: 12px;
274
+ font-weight: var(--w-medium);
275
+ margin: 0 0 4px 2px;
276
+ }
277
+
278
+ .add-urn-fields temba-select,
279
+ .add-urn-fields temba-textinput {
280
+ display: block;
281
+ width: 100%;
282
+ min-width: 0;
283
+ }
284
+
285
+ .add-urn-button {
286
+ min-height: var(--input-h, 34px);
287
+ display: flex;
288
+ align-items: center;
289
+ justify-content: flex-end;
290
+ }
291
+
292
+ .add-urn-button temba-button {
293
+ --button-height: calc(var(--input-h, 34px) + 2px);
294
+ }
295
+
296
+ .add-urn-error {
297
+ grid-column: 1 / -1;
298
+ color: rgb(var(--error-rgb));
299
+ font-size: 12px;
300
+ }
301
+
302
+ @media (max-width: 480px) {
303
+ .add-urn-fields {
304
+ grid-template-columns: 1fr;
305
+ margin-left: 28px;
306
+ }
57
307
  }
58
308
 
59
- /* the last row needs no separator — the card edge closes it off */
60
309
  temba-contact-field.last-field {
61
310
  --contact-field-separator: none;
62
311
  margin-bottom: 0;
@@ -64,36 +313,652 @@ export class ContactDetails extends ContactStoreElement {
64
313
  `;
65
314
  }
66
315
 
67
- // Not sure if we want to include name here or not, so hold onto this for the moment
68
- // ${this.data.name ? html`<temba-contact-field name="Name" value=${this.data.name} disabled></temba-contact-field>`:null}
316
+ private getSchemeName(scheme: string): string {
317
+ return (
318
+ this.schemes.find((option) => option.value === scheme)?.name ||
319
+ capitalize(scheme as any)
320
+ );
321
+ }
69
322
 
70
- public render(): TemplateResult {
71
- if (!this.data) {
323
+ private getManualGroups(): Group[] {
324
+ return this.data.groups.filter((group: Group) => !group.is_dynamic);
325
+ }
326
+
327
+ private getDynamicGroups(): Group[] {
328
+ return this.data.groups.filter((group: Group) => group.is_dynamic);
329
+ }
330
+
331
+ private getLanguageOptions(): Option[] {
332
+ const codes = [...(this.store.getWorkspace()?.languages || [])];
333
+ if (this.data.language && !codes.includes(this.data.language)) {
334
+ codes.unshift(this.data.language);
335
+ }
336
+ return [
337
+ { value: 'none', name: 'No Preference' },
338
+ ...codes.map((code) => ({ value: code, name: getLanguageName(code) }))
339
+ ];
340
+ }
341
+
342
+ private setSaveState(key: string, status: 'saving' | 'failed' | 'ready') {
343
+ const saving = new Set(this.saving);
344
+ const failed = new Set(this.failed);
345
+ saving.delete(key);
346
+ failed.delete(key);
347
+ if (status === 'saving') saving.add(key);
348
+ if (status === 'failed') failed.add(key);
349
+ this.saving = saving;
350
+ this.failed = failed;
351
+ }
352
+
353
+ private finishSave(
354
+ contactId: string,
355
+ key: string,
356
+ generation: number,
357
+ status: 'failed' | 'ready'
358
+ ) {
359
+ if (
360
+ this.contact !== contactId ||
361
+ this.saveGenerations.get(key) !== generation
362
+ ) {
72
363
  return;
73
364
  }
365
+ this.saveGenerations.delete(key);
366
+ this.setSaveState(key, status);
367
+ }
74
368
 
75
- const lang = getLanguageName(this.data.language);
369
+ private async save(key: string, payload: any): Promise<WebResponse> {
370
+ const contactId = this.contact;
371
+ if (
372
+ !contactId ||
373
+ !this.data ||
374
+ this.data.uuid !== contactId ||
375
+ this.url !== `${this.endpoint}${contactId}`
376
+ ) {
377
+ return { status: 409, json: {}, headers: new Headers() };
378
+ }
76
379
 
77
- return html`
78
- <div class="wrapper">
79
- ${this.data.urns.map((urn) => {
80
- let scheme = SCHEMES[urn.scheme];
81
- if (!scheme) {
82
- scheme = capitalize(urn.scheme as any);
380
+ const contactUuid = this.data.uuid;
381
+ const generation = ++this.saveGeneration;
382
+ const fields = Object.keys(payload);
383
+ this.saveGenerations.set(key, generation);
384
+ fields.forEach((field) => this.fieldSaveGenerations.set(field, generation));
385
+ this.setSaveState(key, 'saving');
386
+ try {
387
+ const response = await this.store.postJSON(
388
+ `${this.writeEndpoint}${contactUuid}`,
389
+ payload
390
+ );
391
+ if (response.status !== 200) {
392
+ fields.forEach((field) => {
393
+ if (this.fieldSaveGenerations.get(field) === generation) {
394
+ this.fieldSaveGenerations.delete(field);
83
395
  }
84
- return html`<temba-contact-field
85
- name=${scheme}
86
- value=${urn.display || urn.path}
396
+ });
397
+ this.finishSave(contactId, key, generation, 'failed');
398
+ return response;
399
+ }
400
+ if (
401
+ this.contact === contactId &&
402
+ this.data?.uuid === contactId &&
403
+ this.url === `${this.endpoint}${contactId}`
404
+ ) {
405
+ const updated = { ...this.data };
406
+ let hasUpdates = false;
407
+ fields.forEach((field) => {
408
+ if (this.fieldSaveGenerations.get(field) === generation) {
409
+ if (response.json[field] !== undefined) {
410
+ updated[field] = response.json[field];
411
+ hasUpdates = true;
412
+ }
413
+ this.fieldSaveGenerations.delete(field);
414
+ }
415
+ });
416
+ if (hasUpdates) {
417
+ this.setContact(updated, contactId);
418
+ }
419
+ }
420
+ this.finishSave(contactId, key, generation, 'ready');
421
+ return response;
422
+ } catch (error) {
423
+ fields.forEach((field) => {
424
+ if (this.fieldSaveGenerations.get(field) === generation) {
425
+ this.fieldSaveGenerations.delete(field);
426
+ }
427
+ });
428
+ this.finishSave(contactId, key, generation, 'failed');
429
+ return { status: 500, json: {}, headers: new Headers() };
430
+ }
431
+ }
432
+
433
+ public willUpdate(changed: PropertyValues): void {
434
+ if (changed.has('contact') || changed.has('endpoint')) {
435
+ const url = this.contact ? `${this.endpoint}${this.contact}` : null;
436
+ if (url !== this.url) {
437
+ this.data = null;
438
+ }
439
+ this.saveGeneration++;
440
+ this.saveGenerations.clear();
441
+ this.fieldSaveGenerations.clear();
442
+ this.groupsSave = null;
443
+ this.saving.clear();
444
+ this.failed.clear();
445
+ this.urnDialogOpen = false;
446
+ this.draftUrns = [];
447
+ this.newScheme = '';
448
+ this.newUrn = '';
449
+ }
450
+ super.willUpdate(changed);
451
+ }
452
+
453
+ public async handleTextChanged(event: Event) {
454
+ const field = event.currentTarget as ContactFieldEditor;
455
+ const value = field.value;
456
+ const response = await this.save(field.key, {
457
+ [field.key]: field.key === 'name' && !value ? null : value
458
+ });
459
+ field.handleResponse(
460
+ response,
461
+ response.status === 200 ? response.json[field.key] || '' : value
462
+ );
463
+ }
464
+
465
+ public handleDraftUrnChanged(event: Event, index: number) {
466
+ this.draftUrns[index].path = (event.currentTarget as TextInput).value;
467
+ }
468
+
469
+ public handleNewSchemeChanged(event: Event) {
470
+ const select = event.currentTarget as Select<Option>;
471
+ this.newScheme = select.values[0]?.value || '';
472
+ }
473
+
474
+ public handleNewUrnChanged(event: Event) {
475
+ this.newUrn = (event.currentTarget as TextInput).value;
476
+ }
477
+
478
+ public showUrnDialog() {
479
+ this.newScheme = this.newScheme || this.schemes[0]?.value || '';
480
+ this.newUrn = '';
481
+ this.draftUrns = this.data.urns.map((urn) => ({ ...urn }));
482
+ this.setSaveState('urn-dialog', 'ready');
483
+ this.urnDialogOpen = true;
484
+ }
485
+
486
+ public hideUrnDialog() {
487
+ this.urnDialogOpen = false;
488
+ this.newUrn = '';
489
+ this.draftUrns = [];
490
+ this.setSaveState('urn-dialog', 'ready');
491
+ }
492
+
493
+ public handleAddUrn() {
494
+ const scheme = this.newScheme || this.schemes[0]?.value;
495
+ const path = this.newUrn.trim();
496
+ if (!scheme || !path) return;
497
+
498
+ this.draftUrns = [...this.draftUrns, { scheme, path }];
499
+ this.newUrn = '';
500
+ }
501
+
502
+ public async handleUrnDialogButton(event: CustomEvent) {
503
+ event.stopImmediatePropagation();
504
+ if (event.detail?.button?.name === 'Save') {
505
+ const response = await this.save('urn-dialog', {
506
+ urns: this.draftUrns
507
+ .filter((urn) => urn.path.trim())
508
+ .map((urn) => `${urn.scheme}:${urn.path.trim()}`)
509
+ });
510
+ if (response.status === 200) {
511
+ this.hideUrnDialog();
512
+ }
513
+ } else {
514
+ this.hideUrnDialog();
515
+ }
516
+ }
517
+
518
+ public handleUrnOrderChanged(event: CustomEvent) {
519
+ event.stopPropagation();
520
+ const [fromIndex, toIndex] = event.detail.swap;
521
+ const urns = [...this.draftUrns];
522
+ const moved = urns.splice(fromIndex, 1)[0];
523
+ urns.splice(toIndex, 0, moved);
524
+ this.draftUrns = urns;
525
+ }
526
+
527
+ public handleUrnOrderKeyDown(event: KeyboardEvent, index: number) {
528
+ if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;
529
+
530
+ const toIndex = index + (event.key === 'ArrowUp' ? -1 : 1);
531
+ if (toIndex < 0 || toIndex >= this.draftUrns.length) return;
532
+
533
+ event.preventDefault();
534
+ event.stopPropagation();
535
+ const urns = [...this.draftUrns];
536
+ const moved = urns.splice(index, 1)[0];
537
+ urns.splice(toIndex, 0, moved);
538
+ this.draftUrns = urns;
539
+ void this.updateComplete.then(() => {
540
+ (
541
+ this.shadowRoot.querySelector(
542
+ `#urn-${toIndex} .drag-handle`
543
+ ) as HTMLElement
544
+ )?.focus();
545
+ });
546
+ }
547
+
548
+ public handleDeleteUrn(event: Event, index: number) {
549
+ event.preventDefault();
550
+ event.stopPropagation();
551
+ this.draftUrns = this.draftUrns.filter((_, urnIndex) => urnIndex !== index);
552
+ }
553
+
554
+ public handleDeleteUrnKeyDown(event: KeyboardEvent, index: number) {
555
+ if (event.key === 'Enter' || event.key === ' ') {
556
+ this.handleDeleteUrn(event, index);
557
+ }
558
+ }
559
+
560
+ public handleUrnEditKeyDown(event: KeyboardEvent) {
561
+ if (event.key === 'Enter' || event.key === ' ') {
562
+ event.preventDefault();
563
+ this.showUrnDialog();
564
+ }
565
+ }
566
+
567
+ private prepareUrnGhost = (ghost: HTMLElement) => {
568
+ ghost.style.setProperty('display', 'grid', 'important');
569
+ ghost.style.setProperty(
570
+ 'grid-template-columns',
571
+ '18px minmax(0, 1fr)',
572
+ 'important'
573
+ );
574
+ ghost.style.setProperty('column-gap', '10px', 'important');
575
+ ghost.style.setProperty('align-items', 'center', 'important');
576
+
577
+ const dragSlot = ghost.querySelector('.drag-slot') as HTMLElement;
578
+ if (dragSlot) {
579
+ dragSlot.style.setProperty('display', 'flex', 'important');
580
+ dragSlot.style.setProperty('align-items', 'center', 'important');
581
+ dragSlot.style.setProperty('justify-content', 'center', 'important');
582
+ dragSlot.style.setProperty('align-self', 'end', 'important');
583
+ dragSlot.style.setProperty(
584
+ 'height',
585
+ 'calc(var(--input-h, 34px) + 2px)',
586
+ 'important'
587
+ );
588
+ dragSlot.style.setProperty('box-sizing', 'border-box', 'important');
589
+ }
590
+ };
591
+
592
+ public handleSearch(event: CustomEvent) {
593
+ event.stopPropagation();
594
+ this.fireCustomEvent(CustomEventType.ButtonClicked, event.detail);
595
+ }
596
+
597
+ public async handleGroupsChanged(event: Event) {
598
+ const select = event.currentTarget as Select<any>;
599
+ const groupsSave = this.save('groups', {
600
+ groups: select.values.map((group) => group.uuid)
601
+ });
602
+ this.groupsSave = groupsSave;
603
+ try {
604
+ await groupsSave;
605
+ } finally {
606
+ if (this.groupsSave === groupsSave) {
607
+ this.groupsSave = null;
608
+ }
609
+ }
610
+ }
611
+
612
+ public async handleLanguageChanged(event: Event) {
613
+ const select = event.currentTarget as Select<Option>;
614
+ await this.save('language', {
615
+ language:
616
+ !select.values[0]?.value || select.values[0].value === 'none'
617
+ ? null
618
+ : select.values[0].value
619
+ });
620
+ }
621
+
622
+ public async handleStatusChanged(event: Event) {
623
+ const select = event.currentTarget as Select<Option>;
624
+ const status = select.values[0]?.value;
625
+ const contactId = this.contact;
626
+ if (this.groupsSave) {
627
+ await this.groupsSave;
628
+ }
629
+ if (this.contact !== contactId || this.data?.uuid !== contactId) return;
630
+ if (!status || status === this.data.status) return;
631
+
632
+ const payload: any = { status };
633
+ if (this.data.status === 'active') {
634
+ // Leaving active normally clears group membership. Include the current
635
+ // manual groups so an inline status edit preserves that membership.
636
+ payload.groups = this.getManualGroups().map((group) => group.uuid);
637
+ }
638
+ await this.save('status', payload);
639
+ }
640
+
641
+ private renderFailure(key: string): TemplateResult {
642
+ return this.failed.has(key)
643
+ ? html`<temba-tip class="error" text="Failed to save changes.">
644
+ <temba-icon name=${Icon.alert_warning}></temba-icon>
645
+ </temba-tip>`
646
+ : null;
647
+ }
648
+
649
+ private renderEditableSelect(
650
+ key: string,
651
+ label: string,
652
+ options: Option[],
653
+ value: string,
654
+ handler: (event: Event) => void
655
+ ): TemplateResult {
656
+ return html`<div
657
+ class="editable-row ${this.saving.has(key)
658
+ ? 'saving'
659
+ : ''} ${this.failed.has(key) ? 'failed' : ''}"
660
+ >
661
+ <label>${label}</label>
662
+ ${this.renderFailure(key)}
663
+ <temba-select
664
+ valueKey="value"
665
+ .values=${options.filter((option) => option.value === (value || ''))}
666
+ @change=${handler}
667
+ >
668
+ ${options.map(
669
+ (option) =>
670
+ html`<temba-option
671
+ name=${option.name}
672
+ value=${option.value}
673
+ ></temba-option>`
674
+ )}
675
+ </temba-select>
676
+ </div>`;
677
+ }
678
+
679
+ private renderDynamicGroups(editable: boolean): TemplateResult {
680
+ const groups = this.getDynamicGroups();
681
+ if (!groups.length) return null;
682
+ return html`<div class="smart-groups ${editable ? 'editable-row' : 'row'}">
683
+ ${editable
684
+ ? html`<label>Smart Groups</label>`
685
+ : html`<div class="label">Smart Groups</div>`}
686
+ <div class=${editable ? 'dynamic-groups' : 'value'}>
687
+ ${groups.map(
688
+ (group) =>
689
+ html`<temba-label
690
+ onclick="goto(event)"
691
+ href="/contact/group/${group.uuid}/"
692
+ icon=${Icon.group_smart}
693
+ type="group"
694
+ clickable
695
+ >
696
+ ${group.name}
697
+ </temba-label>`
698
+ )}
699
+ </div>
700
+ </div>`;
701
+ }
702
+
703
+ private renderDisplayedUrn(
704
+ urn: URN | undefined,
705
+ className = ''
706
+ ): TemplateResult {
707
+ const unsendable = urn?.channel === null;
708
+ return html`<temba-contact-field
709
+ class=${className}
710
+ name=${urn ? this.getSchemeName(urn.scheme) : 'URN'}
711
+ value=${urn ? urn.display || urn.path : 'No URNs'}
712
+ valueIcon=${unsendable ? Icon.contact_stopped : ''}
713
+ valueIconLabel=${unsendable ? 'Not sendable: no channel available' : ''}
714
+ disabled
715
+ ></temba-contact-field>`;
716
+ }
717
+
718
+ private renderUrnSendability(urn: URN): TemplateResult {
719
+ if (urn.channel !== null) return null;
720
+ const label = 'Not sendable: no channel available';
721
+ return html`<temba-tip
722
+ class="urn-sendability unsendable"
723
+ text=${label}
724
+ position="top"
725
+ slot="prefix"
726
+ >
727
+ <temba-icon name=${Icon.contact_stopped} aria-label=${label}></temba-icon>
728
+ </temba-tip>`;
729
+ }
730
+
731
+ private renderUrnDialog(): TemplateResult {
732
+ const selectedScheme = this.newScheme || this.schemes[0]?.value || '';
733
+ return html`<temba-dialog
734
+ header="Edit URNs"
735
+ size="medium"
736
+ primaryButtonName="Save"
737
+ cancelButtonName="Cancel"
738
+ .open=${this.urnDialogOpen}
739
+ ?disabled=${this.saving.has('urn-dialog')}
740
+ ?submitting=${this.saving.has('urn-dialog')}
741
+ @temba-button-clicked=${this.handleUrnDialogButton}
742
+ >
743
+ <div class="urn-dialog">
744
+ <temba-sortable-list
745
+ class="urn-list"
746
+ dragHandle="drag-handle"
747
+ gap="12px"
748
+ .prepareGhost=${this.prepareUrnGhost}
749
+ @temba-order-changed=${this.handleUrnOrderChanged}
750
+ >
751
+ ${this.draftUrns.map((urn, index) => {
752
+ return html`
753
+ <div
754
+ id="urn-${index}"
755
+ class="urn-row sortable ${this.draftUrns.length > 1
756
+ ? ''
757
+ : 'single'}"
758
+ >
759
+ <div class="drag-slot">
760
+ ${this.draftUrns.length > 1
761
+ ? html`<temba-icon
762
+ class="drag-handle"
763
+ name=${Icon.drag}
764
+ title="Drag to reorder"
765
+ aria-label="Reorder ${this.getSchemeName(
766
+ urn.scheme
767
+ )} URN. Use the up and down arrow keys to move it."
768
+ role="button"
769
+ tabindex="0"
770
+ @keydown=${(event: KeyboardEvent) =>
771
+ this.handleUrnOrderKeyDown(event, index)}
772
+ ></temba-icon>`
773
+ : null}
774
+ </div>
775
+ <temba-textinput
776
+ class="urn-input"
777
+ name="urn-${index}"
778
+ label=${this.getSchemeName(urn.scheme)}
779
+ .value=${urn.path}
780
+ @input=${(event: Event) =>
781
+ this.handleDraftUrnChanged(event, index)}
782
+ @change=${(event: Event) =>
783
+ this.handleDraftUrnChanged(event, index)}
784
+ >
785
+ ${this.renderUrnSendability(urn)}
786
+ <temba-tip
787
+ class="urn-delete-action"
788
+ text="Delete URN"
789
+ position="top"
790
+ >
791
+ <temba-icon
792
+ class="urn-delete-icon"
793
+ name=${Icon.delete}
794
+ aria-label="Delete URN"
795
+ role="button"
796
+ tabindex="0"
797
+ @click=${(event: Event) =>
798
+ this.handleDeleteUrn(event, index)}
799
+ @keydown=${(event: KeyboardEvent) =>
800
+ this.handleDeleteUrnKeyDown(event, index)}
801
+ ></temba-icon>
802
+ </temba-tip>
803
+ </temba-textinput>
804
+ </div>
805
+ `;
806
+ })}
807
+ </temba-sortable-list>
808
+ ${this.failed.has('urn-dialog')
809
+ ? html`<div class="add-urn-error">
810
+ Failed to save URNs. Check the addresses and try again.
811
+ </div>`
812
+ : null}
813
+ ${this.schemes.length
814
+ ? html`<div class="add-urn-fields">
815
+ <div>
816
+ <label>URN Type</label>
817
+ <temba-select
818
+ valueKey="value"
819
+ .values=${this.schemes.filter(
820
+ (option) => option.value === selectedScheme
821
+ )}
822
+ @change=${this.handleNewSchemeChanged}
823
+ >
824
+ ${this.schemes.map(
825
+ (option) =>
826
+ html`<temba-option
827
+ name=${option.name}
828
+ value=${option.value}
829
+ ></temba-option>`
830
+ )}
831
+ </temba-select>
832
+ </div>
833
+ <temba-textinput
834
+ name="urn"
835
+ label="Address"
836
+ .value=${this.newUrn}
837
+ @input=${this.handleNewUrnChanged}
838
+ ></temba-textinput>
839
+ <div class="add-urn-button">
840
+ <temba-button
841
+ name="Add"
842
+ icon=${Icon.add}
843
+ light
844
+ ?disabled=${!this.newUrn.trim()}
845
+ @click=${this.handleAddUrn}
846
+ ></temba-button>
847
+ </div>
848
+ </div>`
849
+ : null}
850
+ </div>
851
+ </temba-dialog>`;
852
+ }
853
+
854
+ private renderPrimaryUrn(editable: boolean): TemplateResult {
855
+ if (this.anon || (!editable && this.data.urns.length === 0)) return null;
856
+ const additional = Math.max(0, this.data.urns.length - 1);
857
+ const displayedUrn = getDestinationURN(this.data) || this.data.urns[0];
858
+ const noUrns = this.data.urns.length === 0;
859
+ const onlyUnsendable =
860
+ this.data.urns.length === 1 && this.data.urns[0].channel === null;
861
+ return html`<div
862
+ class="urn-display primary-urn ${editable
863
+ ? 'editable'
864
+ : ''} ${onlyUnsendable ? 'only-unsendable' : ''}"
865
+ aria-label=${noUrns
866
+ ? 'This contact has no URNs'
867
+ : onlyUnsendable
868
+ ? 'This contact has no sendable URNs'
869
+ : 'Contact URN'}
870
+ >
871
+ ${this.renderDisplayedUrn(displayedUrn, 'primary-urn')}
872
+ ${editable && additional
873
+ ? html`<span
874
+ class="urn-more-count"
875
+ aria-label="${additional} additional URNs"
876
+ >+${additional} more</span
877
+ >`
878
+ : null}
879
+ ${editable
880
+ ? html`<temba-icon
881
+ class="urn-edit-icon"
882
+ name=${Icon.edit}
883
+ title="Edit URNs"
884
+ aria-label="Edit URNs"
885
+ role="button"
886
+ tabindex="0"
887
+ @click=${this.showUrnDialog}
888
+ @keydown=${this.handleUrnEditKeyDown}
889
+ ></temba-icon>`
890
+ : null}
891
+ </div>
892
+ ${editable ? this.renderUrnDialog() : null}`;
893
+ }
894
+
895
+ private renderEditable(): TemplateResult {
896
+ const manualGroups = this.getManualGroups();
897
+ return html`
898
+ ${this.renderPrimaryUrn(true)}
899
+ <temba-contact-field
900
+ key="name"
901
+ name="Name"
902
+ value=${this.data.name || ''}
903
+ @change=${this.handleTextChanged}
904
+ @temba-button-clicked=${this.handleSearch}
905
+ ></temba-contact-field>
906
+ ${this.anon && this.data.ref
907
+ ? html`<temba-contact-field
908
+ name="Ref"
909
+ value=${this.data.ref}
87
910
  disabled
88
- ></temba-contact-field>`;
89
- })}
90
- ${this.data.groups.length > 0
91
- ? html` <div class="row">
92
- <div class="label">Groups</div>
93
- <div class="value">
94
- ${this.data.groups.map((group) => {
95
- return html`<temba-label
96
- class="group"
911
+ ></temba-contact-field>`
912
+ : null}
913
+ ${this.data.status === 'active'
914
+ ? html`<div
915
+ class="editable-row ${this.saving.has('groups')
916
+ ? 'saving'
917
+ : ''} ${this.failed.has('groups') ? 'failed' : ''}"
918
+ >
919
+ <label>Groups</label>
920
+ ${this.renderFailure('groups')}
921
+ <temba-select
922
+ endpoint="/api/v2/groups.json?manual_only=1"
923
+ valueKey="uuid"
924
+ .values=${manualGroups}
925
+ @change=${this.handleGroupsChanged}
926
+ searchable
927
+ clearable
928
+ multi
929
+ ></temba-select>
930
+ </div>`
931
+ : null}
932
+ ${this.renderDynamicGroups(true)}
933
+ ${this.renderEditableSelect(
934
+ 'status',
935
+ 'Status',
936
+ STATUS_OPTIONS,
937
+ this.data.status,
938
+ this.handleStatusChanged
939
+ )}
940
+ ${this.renderEditableSelect(
941
+ 'language',
942
+ 'Language',
943
+ this.getLanguageOptions(),
944
+ this.data.language || 'none',
945
+ this.handleLanguageChanged
946
+ )}
947
+ `;
948
+ }
949
+
950
+ private renderReadonly(): TemplateResult {
951
+ const lang = getLanguageName(this.data.language);
952
+ const manualGroups = this.getManualGroups();
953
+ return html`
954
+ ${this.renderPrimaryUrn(false)}
955
+ ${manualGroups.length
956
+ ? html`<div class="row">
957
+ <div class="label">Groups</div>
958
+ <div class="value">
959
+ ${manualGroups.map(
960
+ (group) =>
961
+ html`<temba-label
97
962
  onclick="goto(event)"
98
963
  href="/contact/group/${group.uuid}/"
99
964
  icon=${group.is_dynamic ? Icon.group_smart : Icon.group}
@@ -101,46 +966,54 @@ export class ContactDetails extends ContactStoreElement {
101
966
  clickable
102
967
  >
103
968
  ${group.name}
104
- </temba-label>`;
105
- })}
106
- </div>
107
- </div>`
108
- : null}
109
- ${this.data.ref
110
- ? html`<temba-contact-field
111
- name="Ref"
112
- value=${this.data.ref}
113
- disabled
114
- ></temba-contact-field>`
115
- : null}
969
+ </temba-label>`
970
+ )}
971
+ </div>
972
+ </div>`
973
+ : null}
974
+ ${this.renderDynamicGroups(false)}
975
+ ${this.data.ref
976
+ ? html`<temba-contact-field
977
+ name="Ref"
978
+ value=${this.data.ref}
979
+ disabled
980
+ ></temba-contact-field>`
981
+ : null}
982
+ <temba-contact-field
983
+ name="Status"
984
+ value=${STATUS_OPTIONS.find(
985
+ (option) => option.value === this.data.status
986
+ )?.name || capitalize(this.data.status)}
987
+ disabled
988
+ ></temba-contact-field>
989
+ ${lang
990
+ ? html`<temba-contact-field
991
+ name="Language"
992
+ value=${lang}
993
+ disabled
994
+ ></temba-contact-field>`
995
+ : null}
996
+ `;
997
+ }
116
998
 
117
- <temba-contact-field
118
- name="Status"
119
- value=${STATUS[this.data.status]}
120
- disabled
121
- ></temba-contact-field>
122
- ${lang
123
- ? html`<temba-contact-field
124
- name="Language"
125
- value=${lang}
126
- disabled
127
- ></temba-contact-field>`
128
- : null}
999
+ public render(): TemplateResult {
1000
+ if (!this.data) return super.render();
129
1001
 
130
- <temba-contact-field
131
- name="Created"
132
- value=${this.data.created_on}
133
- type="datetime"
134
- disabled
135
- ></temba-contact-field>
136
- <temba-contact-field
137
- class="last-field"
138
- name="Last Seen"
139
- value=${this.data.last_seen_on}
140
- type="datetime"
141
- disabled
142
- ></temba-contact-field>
143
- </div>
144
- `;
1002
+ return html`<div class="wrapper">
1003
+ ${this.editable ? this.renderEditable() : this.renderReadonly()}
1004
+ <temba-contact-field
1005
+ name="Created"
1006
+ value=${this.data.created_on}
1007
+ type="datetime"
1008
+ disabled
1009
+ ></temba-contact-field>
1010
+ <temba-contact-field
1011
+ class="last-field"
1012
+ name="Last Seen"
1013
+ value=${this.data.last_seen_on}
1014
+ type="datetime"
1015
+ disabled
1016
+ ></temba-contact-field>
1017
+ </div>`;
145
1018
  }
146
1019
  }