@nyaruka/temba-components 0.162.0 → 0.163.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.
- package/CHANGELOG.md +10 -0
- package/dist/temba-components.js +312 -297
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/display/Chat.ts +26 -7
- package/src/flow/FlowSearch.ts +66 -2
- package/src/flow/NodeEditor.ts +109 -1
- package/src/layout/Dialog.ts +51 -0
- package/src/live/ContactTimeline.ts +5 -1
package/package.json
CHANGED
package/src/display/Chat.ts
CHANGED
|
@@ -113,6 +113,13 @@ export interface MsgEvent extends ContactEvent {
|
|
|
113
113
|
_logs_url?: string;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
// whether a message has nothing to show in its bubble (and isn't deleted)
|
|
117
|
+
const isEmptyMsg = (event: MsgEvent): boolean =>
|
|
118
|
+
!event._deleted &&
|
|
119
|
+
!!event.msg &&
|
|
120
|
+
!event.msg.text &&
|
|
121
|
+
(event.msg.attachments || []).length === 0;
|
|
122
|
+
|
|
116
123
|
const TIME_FORMAT = { hour: 'numeric', minute: '2-digit' } as any;
|
|
117
124
|
const VERBOSE_FORMAT = {
|
|
118
125
|
weekday: undefined,
|
|
@@ -351,13 +358,15 @@ export class Chat extends RapidElement {
|
|
|
351
358
|
color: rgba(192, 24, 41, 0.6);
|
|
352
359
|
}
|
|
353
360
|
|
|
354
|
-
.deleted .bubble
|
|
361
|
+
.deleted .bubble,
|
|
362
|
+
.empty .bubble {
|
|
355
363
|
background: #fff;
|
|
356
364
|
color: #999;
|
|
357
365
|
border: 1px solid #e0e0e0;
|
|
358
366
|
}
|
|
359
367
|
|
|
360
|
-
.deleted .bubble .name
|
|
368
|
+
.deleted .bubble .name,
|
|
369
|
+
.empty .bubble .name {
|
|
361
370
|
color: #aaa;
|
|
362
371
|
}
|
|
363
372
|
|
|
@@ -369,7 +378,8 @@ export class Chat extends RapidElement {
|
|
|
369
378
|
font-size: 13px;
|
|
370
379
|
}
|
|
371
380
|
|
|
372
|
-
.message-deleted
|
|
381
|
+
.message-deleted,
|
|
382
|
+
.message-empty {
|
|
373
383
|
font-style: italic;
|
|
374
384
|
margin-bottom: 0;
|
|
375
385
|
line-height: 1.2;
|
|
@@ -1325,13 +1335,14 @@ export class Chat extends RapidElement {
|
|
|
1325
1335
|
(statusClass === 'failed' || statusClass === 'errored'));
|
|
1326
1336
|
const unsendableClass = hasError ? 'error' : '';
|
|
1327
1337
|
const deletedClass = msgEvent._deleted ? 'deleted' : '';
|
|
1338
|
+
const emptyClass = isEmptyMsg(msgEvent) ? 'empty' : '';
|
|
1328
1339
|
const latestClass = index === msgIds.length - 1 ? 'latest' : '';
|
|
1329
1340
|
const eventClass = msg._rendered ? 'is-event' : '';
|
|
1330
1341
|
const noteClass = msg.type === 'ticket_note_added' ? 'note' : '';
|
|
1331
1342
|
const matchClass =
|
|
1332
1343
|
this.highlightMessageUuid === msg.uuid ? 'search-match' : '';
|
|
1333
1344
|
return html`<div
|
|
1334
|
-
class="row message ${statusClass} ${unsendableClass} ${deletedClass} ${latestClass} ${eventClass} ${noteClass} ${matchClass}"
|
|
1345
|
+
class="row message ${statusClass} ${unsendableClass} ${deletedClass} ${emptyClass} ${latestClass} ${eventClass} ${noteClass} ${matchClass}"
|
|
1335
1346
|
data-uuid=${msg.uuid || nothing}
|
|
1336
1347
|
>
|
|
1337
1348
|
${this.renderMessage(
|
|
@@ -1430,6 +1441,9 @@ export class Chat extends RapidElement {
|
|
|
1430
1441
|
: message._deleted.user?.name || 'user'
|
|
1431
1442
|
: null;
|
|
1432
1443
|
|
|
1444
|
+
// messages with no text and no attachments get a muted placeholder
|
|
1445
|
+
const isEmpty = isEmptyMsg(message);
|
|
1446
|
+
|
|
1433
1447
|
// check if message has location attachment and text is just coordinates
|
|
1434
1448
|
const hasLocationAttachment = message.msg.attachments?.some((att) =>
|
|
1435
1449
|
att.startsWith('geo:')
|
|
@@ -1480,12 +1494,17 @@ export class Chat extends RapidElement {
|
|
|
1480
1494
|
Message deleted by ${deletedByText}
|
|
1481
1495
|
</div>
|
|
1482
1496
|
</div>`
|
|
1483
|
-
:
|
|
1497
|
+
: isEmpty
|
|
1484
1498
|
? html`<div class="bubble">
|
|
1485
1499
|
${name ? html`<div class="name">${name}</div>` : null}
|
|
1486
|
-
<div class="message-
|
|
1500
|
+
<div class="message-empty">Empty Message</div>
|
|
1487
1501
|
</div>`
|
|
1488
|
-
:
|
|
1502
|
+
: message.msg.text && !textIsCoordinates
|
|
1503
|
+
? html`<div class="bubble">
|
|
1504
|
+
${name ? html`<div class="name">${name}</div>` : null}
|
|
1505
|
+
<div class="message-text">${messageText}</div>
|
|
1506
|
+
</div>`
|
|
1507
|
+
: null}
|
|
1489
1508
|
|
|
1490
1509
|
<div class="attachments">
|
|
1491
1510
|
${(message.msg.attachments || []).map(
|
package/src/flow/FlowSearch.ts
CHANGED
|
@@ -315,6 +315,29 @@ function getTypeName(
|
|
|
315
315
|
return 'Unknown';
|
|
316
316
|
}
|
|
317
317
|
|
|
318
|
+
/**
|
|
319
|
+
* Build the display fields for a result that matched on its type name
|
|
320
|
+
* (e.g. "email" matching a "Send Email" action). Shows the first content
|
|
321
|
+
* text as an unhighlighted preview when available, otherwise highlights
|
|
322
|
+
* the match within the type name itself. Returns null if the type name
|
|
323
|
+
* doesn't match the query.
|
|
324
|
+
*/
|
|
325
|
+
function makeTypeNameResult(
|
|
326
|
+
typeName: string,
|
|
327
|
+
query: string,
|
|
328
|
+
contentTexts: string[]
|
|
329
|
+
): Pick<SearchResult, 'fullText' | 'matchStart' | 'matchLength'> | null {
|
|
330
|
+
const idx = typeName.toLowerCase().indexOf(query);
|
|
331
|
+
if (idx === -1) {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
const preview = contentTexts.find((t) => t.trim());
|
|
335
|
+
if (preview) {
|
|
336
|
+
return { fullText: preview, matchStart: 0, matchLength: 0 };
|
|
337
|
+
}
|
|
338
|
+
return { fullText: typeName, matchStart: idx, matchLength: query.length };
|
|
339
|
+
}
|
|
340
|
+
|
|
318
341
|
/**
|
|
319
342
|
* Get the localized name for a category, falling back to the original name.
|
|
320
343
|
*/
|
|
@@ -640,11 +663,13 @@ export class FlowSearch extends LitElement {
|
|
|
640
663
|
if (node.actions) {
|
|
641
664
|
for (const action of node.actions) {
|
|
642
665
|
const actionConfig = ACTION_CONFIG[action.type];
|
|
666
|
+
const typeName = getTypeName(actionConfig, undefined);
|
|
643
667
|
const searchAction = localizeAction(
|
|
644
668
|
action,
|
|
645
669
|
langLocalization?.[action.uuid]
|
|
646
670
|
);
|
|
647
671
|
const texts = getActionSearchTexts(searchAction);
|
|
672
|
+
let matched = false;
|
|
648
673
|
for (const text of texts) {
|
|
649
674
|
const lowerText = text.toLowerCase();
|
|
650
675
|
const idx = lowerText.indexOf(query);
|
|
@@ -652,15 +677,31 @@ export class FlowSearch extends LitElement {
|
|
|
652
677
|
results.push({
|
|
653
678
|
nodeUuid: node.uuid,
|
|
654
679
|
action,
|
|
655
|
-
typeName
|
|
680
|
+
typeName,
|
|
656
681
|
color: getColor(actionConfig, undefined),
|
|
657
682
|
fullText: text,
|
|
658
683
|
matchStart: idx,
|
|
659
684
|
matchLength: query.length
|
|
660
685
|
});
|
|
686
|
+
matched = true;
|
|
661
687
|
break; // One match per action is enough
|
|
662
688
|
}
|
|
663
689
|
}
|
|
690
|
+
|
|
691
|
+
// Fall back to matching the action type name (e.g. "email"
|
|
692
|
+
// matches all "Send Email" actions)
|
|
693
|
+
if (!matched) {
|
|
694
|
+
const typeMatch = makeTypeNameResult(typeName, query, texts);
|
|
695
|
+
if (typeMatch) {
|
|
696
|
+
results.push({
|
|
697
|
+
nodeUuid: node.uuid,
|
|
698
|
+
action,
|
|
699
|
+
typeName,
|
|
700
|
+
color: getColor(actionConfig, undefined),
|
|
701
|
+
...typeMatch
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
}
|
|
664
705
|
}
|
|
665
706
|
}
|
|
666
707
|
} else {
|
|
@@ -679,6 +720,8 @@ export class FlowSearch extends LitElement {
|
|
|
679
720
|
}
|
|
680
721
|
}
|
|
681
722
|
|
|
723
|
+
const typeName = getTypeName(undefined, nodeConfig);
|
|
724
|
+
let matched = false;
|
|
682
725
|
for (const text of nodeTexts) {
|
|
683
726
|
const lowerText = text.toLowerCase();
|
|
684
727
|
const idx = lowerText.indexOf(query);
|
|
@@ -686,15 +729,31 @@ export class FlowSearch extends LitElement {
|
|
|
686
729
|
results.push({
|
|
687
730
|
nodeUuid: node.uuid,
|
|
688
731
|
action: null,
|
|
689
|
-
typeName
|
|
732
|
+
typeName,
|
|
690
733
|
color: getColor(undefined, nodeConfig),
|
|
691
734
|
fullText: text,
|
|
692
735
|
matchStart: idx,
|
|
693
736
|
matchLength: query.length
|
|
694
737
|
});
|
|
738
|
+
matched = true;
|
|
695
739
|
break; // One match per node is enough
|
|
696
740
|
}
|
|
697
741
|
}
|
|
742
|
+
|
|
743
|
+
// Fall back to matching the node type name (e.g. "wait" matches
|
|
744
|
+
// all "Wait for Response" nodes)
|
|
745
|
+
if (!matched) {
|
|
746
|
+
const typeMatch = makeTypeNameResult(typeName, query, nodeTexts);
|
|
747
|
+
if (typeMatch) {
|
|
748
|
+
results.push({
|
|
749
|
+
nodeUuid: node.uuid,
|
|
750
|
+
action: null,
|
|
751
|
+
typeName,
|
|
752
|
+
color: getColor(undefined, nodeConfig),
|
|
753
|
+
...typeMatch
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
}
|
|
698
757
|
}
|
|
699
758
|
}
|
|
700
759
|
|
|
@@ -838,6 +897,11 @@ export class FlowSearch extends LitElement {
|
|
|
838
897
|
// Replace newlines with spaces for single-line display
|
|
839
898
|
const text = result.fullText.replace(/\n/g, ' ');
|
|
840
899
|
|
|
900
|
+
// Type-name matches show an unhighlighted content preview
|
|
901
|
+
if (matchLength === 0) {
|
|
902
|
+
return html`${text}`;
|
|
903
|
+
}
|
|
904
|
+
|
|
841
905
|
// Show leading context before the match, then the match, then trailing.
|
|
842
906
|
// CSS text-overflow:ellipsis clips the trailing text, so we keep the match
|
|
843
907
|
// near the start. When the match is near the end of the text, show more
|
package/src/flow/NodeEditor.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
SPLIT_GROUP_METADATA
|
|
21
21
|
} from './types';
|
|
22
22
|
import { CustomEventType } from '../interfaces';
|
|
23
|
+
import { Dialog } from '../layout/Dialog';
|
|
23
24
|
import { generateUUID } from '../utils';
|
|
24
25
|
import {
|
|
25
26
|
formatIssueMessage,
|
|
@@ -477,6 +478,11 @@ export class NodeEditor extends RapidElement {
|
|
|
477
478
|
@state()
|
|
478
479
|
private originalFormData: FormData = {};
|
|
479
480
|
|
|
481
|
+
// snapshot of getComparableFormData(), refreshed on any non-user mutation
|
|
482
|
+
// (init, async resolve) — the baseline for detecting unsaved edits when
|
|
483
|
+
// dismissing via escape
|
|
484
|
+
private pristineFormData = '';
|
|
485
|
+
|
|
480
486
|
@state()
|
|
481
487
|
private errors: { [key: string]: string } = {};
|
|
482
488
|
|
|
@@ -515,24 +521,112 @@ export class NodeEditor extends RapidElement {
|
|
|
515
521
|
private issuesByAction!: Map<string, FlowIssue[]>;
|
|
516
522
|
|
|
517
523
|
private boundHandleEscape = this.handleEscapeKey.bind(this);
|
|
524
|
+
private boundSwallowEscapeUp = this.swallowEscapeUp.bind(this);
|
|
518
525
|
|
|
519
526
|
connectedCallback(): void {
|
|
520
527
|
super.connectedCallback();
|
|
521
528
|
this.initializeFormData();
|
|
522
529
|
document.addEventListener('keydown', this.boundHandleEscape);
|
|
530
|
+
document.addEventListener('keyup', this.boundSwallowEscapeUp, true);
|
|
523
531
|
}
|
|
524
532
|
|
|
525
533
|
disconnectedCallback(): void {
|
|
526
534
|
super.disconnectedCallback();
|
|
527
535
|
document.removeEventListener('keydown', this.boundHandleEscape);
|
|
536
|
+
document.removeEventListener('keyup', this.boundSwallowEscapeUp, true);
|
|
528
537
|
}
|
|
529
538
|
|
|
530
539
|
private handleEscapeKey(event: KeyboardEvent): void {
|
|
531
540
|
if (event.key === 'Escape' && this.isOpen) {
|
|
541
|
+
const path = event.composedPath();
|
|
542
|
+
|
|
543
|
+
// an escape aimed at an open select dropdown just closes the dropdown
|
|
544
|
+
if (
|
|
545
|
+
path.some(
|
|
546
|
+
(el) =>
|
|
547
|
+
el instanceof Element &&
|
|
548
|
+
el.tagName === 'TEMBA-SELECT' &&
|
|
549
|
+
(el as any).isOpen?.()
|
|
550
|
+
)
|
|
551
|
+
) {
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (!this.ownsEscape(path)) {
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (
|
|
560
|
+
this.hasUnsavedChanges() &&
|
|
561
|
+
!window.confirm(Dialog.UNSAVED_CHANGES_MESSAGE)
|
|
562
|
+
) {
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
532
565
|
this.handleCancel();
|
|
533
566
|
}
|
|
534
567
|
}
|
|
535
568
|
|
|
569
|
+
// an escape routed through a dialog other than our own (e.g. a nested
|
|
570
|
+
// dialog or modax opened from a field) belongs to that dialog; an escape
|
|
571
|
+
// with no dialog in its path (canvas or body focus) is ours to handle
|
|
572
|
+
private ownsEscape(path: EventTarget[]): boolean {
|
|
573
|
+
for (const el of path) {
|
|
574
|
+
if (el instanceof Element && el.tagName === 'TEMBA-DIALOG') {
|
|
575
|
+
return el === this.shadowRoot?.querySelector('temba-dialog');
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return true;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// temba-dialog handles escape itself on KEYUP reaching its container, which
|
|
582
|
+
// would prompt a second time after the keydown handler above has already
|
|
583
|
+
// asked (or dismiss unprompted if its keyup ever arrived first). While the
|
|
584
|
+
// editor is open, escape within our own dialog is owned entirely by the
|
|
585
|
+
// keydown handler, so stop those keyups before the dialog sees them —
|
|
586
|
+
// but leave escapes belonging to a dialog layered above us alone.
|
|
587
|
+
private swallowEscapeUp(event: KeyboardEvent): void {
|
|
588
|
+
if (
|
|
589
|
+
event.key === 'Escape' &&
|
|
590
|
+
this.isOpen &&
|
|
591
|
+
this.ownsEscape(event.composedPath())
|
|
592
|
+
) {
|
|
593
|
+
event.stopPropagation();
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
private hasUnsavedChanges(): boolean {
|
|
598
|
+
return this.getComparableFormData() !== this.pristineFormData;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// JSON of formData with array-editor scaffolding filtered out: array editors
|
|
602
|
+
// seed a blank row (carrying field defaults, e.g. an attachment type) into
|
|
603
|
+
// form data on first render, which must not count as a user edit
|
|
604
|
+
private getComparableFormData(): string {
|
|
605
|
+
const form = this.getConfig()?.form;
|
|
606
|
+
const comparable = { ...this.formData };
|
|
607
|
+
|
|
608
|
+
if (form) {
|
|
609
|
+
Object.entries(form).forEach(([fieldName, fieldConfig]) => {
|
|
610
|
+
const value = comparable[fieldName];
|
|
611
|
+
if (fieldConfig.type === 'array' && Array.isArray(value)) {
|
|
612
|
+
// same emptiness semantics as ArrayEditor.isEmptyItem
|
|
613
|
+
const isEmpty =
|
|
614
|
+
fieldConfig.isEmptyItem ||
|
|
615
|
+
((item: any) => {
|
|
616
|
+
const values = Object.values(item || {});
|
|
617
|
+
return (
|
|
618
|
+
values.length === 0 ||
|
|
619
|
+
values.every((v) => v === undefined || v === null || v === '')
|
|
620
|
+
);
|
|
621
|
+
});
|
|
622
|
+
comparable[fieldName] = value.filter((item: any) => !isEmpty(item));
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return JSON.stringify(comparable);
|
|
628
|
+
}
|
|
629
|
+
|
|
536
630
|
willUpdate(changedProperties: PropertyValues): void {
|
|
537
631
|
super.willUpdate(changedProperties);
|
|
538
632
|
if (
|
|
@@ -719,14 +813,24 @@ export class NodeEditor extends RapidElement {
|
|
|
719
813
|
this.formData[key] = { ...value };
|
|
720
814
|
}
|
|
721
815
|
});
|
|
816
|
+
|
|
817
|
+
// fresh form, so nothing to warn about discarding yet
|
|
818
|
+
this.pristineFormData = this.getComparableFormData();
|
|
722
819
|
}
|
|
723
820
|
|
|
724
821
|
private async resolveFormData(): Promise<void> {
|
|
725
822
|
const config = this.getConfig();
|
|
726
823
|
if (!config?.resolveFormData) return;
|
|
727
824
|
|
|
825
|
+
const input = this.formData;
|
|
728
826
|
try {
|
|
729
|
-
const resolved = await config.resolveFormData(
|
|
827
|
+
const resolved = await config.resolveFormData(input);
|
|
828
|
+
|
|
829
|
+
// if the user edited while the resolve was in flight, applying the
|
|
830
|
+
// stale result would overwrite their edit — and rebasing the baseline
|
|
831
|
+
// below would then mask the loss as a clean form
|
|
832
|
+
if (this.formData !== input) return;
|
|
833
|
+
|
|
730
834
|
if (resolved && resolved !== this.formData) {
|
|
731
835
|
this.formData = resolved;
|
|
732
836
|
this.processFormDataForEditing();
|
|
@@ -749,6 +853,9 @@ export class NodeEditor extends RapidElement {
|
|
|
749
853
|
});
|
|
750
854
|
}
|
|
751
855
|
|
|
856
|
+
// resolving isn't a user edit; rebase the unsaved-changes baseline
|
|
857
|
+
this.pristineFormData = this.getComparableFormData();
|
|
858
|
+
|
|
752
859
|
this.requestUpdate();
|
|
753
860
|
}
|
|
754
861
|
} catch (error) {
|
|
@@ -2690,6 +2797,7 @@ export class NodeEditor extends RapidElement {
|
|
|
2690
2797
|
.open="${this.isOpen}"
|
|
2691
2798
|
.originX=${this.dialogOrigin?.x ?? null}
|
|
2692
2799
|
.originY=${this.dialogOrigin?.y ?? null}
|
|
2800
|
+
.checkForChanges=${() => this.hasUnsavedChanges()}
|
|
2693
2801
|
@temba-button-clicked=${this.handleDialogButtonClick}
|
|
2694
2802
|
primaryButtonName="Save"
|
|
2695
2803
|
cancelButtonName="Cancel"
|
package/src/layout/Dialog.ts
CHANGED
|
@@ -20,6 +20,9 @@ export class DialogButton {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export class Dialog extends ResizeElement {
|
|
23
|
+
public static readonly UNSAVED_CHANGES_MESSAGE =
|
|
24
|
+
'You have unsaved changes. Are you sure you want to discard them?';
|
|
25
|
+
|
|
23
26
|
static get widths(): { [size: string]: string } {
|
|
24
27
|
return {
|
|
25
28
|
small: '400px',
|
|
@@ -273,12 +276,29 @@ export class Dialog extends ResizeElement {
|
|
|
273
276
|
@property({ attribute: false })
|
|
274
277
|
onButtonClicked: (button: Button) => void;
|
|
275
278
|
|
|
279
|
+
// when set, consulted instead of the built-in edit tracking to decide
|
|
280
|
+
// whether escape should confirm before dismissing — for owners that can
|
|
281
|
+
// compare actual values (and so ignore edits that were reverted)
|
|
282
|
+
@property({ attribute: false })
|
|
283
|
+
checkForChanges?: () => boolean;
|
|
284
|
+
|
|
276
285
|
@property({ type: Number })
|
|
277
286
|
originX: number | null = null;
|
|
278
287
|
|
|
279
288
|
@property({ type: Number })
|
|
280
289
|
originY: number | null = null;
|
|
281
290
|
|
|
291
|
+
// whether any input/change has bubbled from our content since opening;
|
|
292
|
+
// the default signal that escape would discard the user's edits
|
|
293
|
+
private contentEdited = false;
|
|
294
|
+
|
|
295
|
+
// elements the user has actually interacted with (pointer or key) since the
|
|
296
|
+
// dialog opened; components fire synthetic change events on programmatic
|
|
297
|
+
// value changes (initialization of preset values, async option resolution),
|
|
298
|
+
// so an input/change only counts as an edit when it originates from an
|
|
299
|
+
// element the user has touched
|
|
300
|
+
private interactedElements = new WeakSet<EventTarget>();
|
|
301
|
+
|
|
282
302
|
scrollOffset: any = 0;
|
|
283
303
|
|
|
284
304
|
public constructor() {
|
|
@@ -322,6 +342,8 @@ export class Dialog extends ResizeElement {
|
|
|
322
342
|
super.updated(changes);
|
|
323
343
|
|
|
324
344
|
if (changes.has('open')) {
|
|
345
|
+
this.contentEdited = false;
|
|
346
|
+
this.interactedElements = new WeakSet<EventTarget>();
|
|
325
347
|
const body = document.querySelector('body');
|
|
326
348
|
|
|
327
349
|
if (this.open) {
|
|
@@ -457,8 +479,33 @@ export class Dialog extends ResizeElement {
|
|
|
457
479
|
return this.shadowRoot.querySelector(`temba-button[primary]`);
|
|
458
480
|
}
|
|
459
481
|
|
|
482
|
+
public hasUnsavedChanges(): boolean {
|
|
483
|
+
return this.checkForChanges ? this.checkForChanges() : this.contentEdited;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
private handleContentInteraction(event: Event) {
|
|
487
|
+
// the path covers the interacted element and its ancestors, so a change
|
|
488
|
+
// fired later by a containing component (e.g. a select whose inner input
|
|
489
|
+
// was clicked) still matches
|
|
490
|
+
event.composedPath().forEach((el) => this.interactedElements.add(el));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
private handleContentEdited(event: Event) {
|
|
494
|
+
// only the originating element is checked (not its ancestors, which the
|
|
495
|
+
// paths of unrelated interactions also pass through)
|
|
496
|
+
if (this.interactedElements.has(event.composedPath()[0])) {
|
|
497
|
+
this.contentEdited = true;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
460
501
|
private handleKeyUp(event: KeyboardEvent) {
|
|
461
502
|
if (event.key === 'Escape') {
|
|
503
|
+
if (
|
|
504
|
+
this.hasUnsavedChanges() &&
|
|
505
|
+
!window.confirm(Dialog.UNSAVED_CHANGES_MESSAGE)
|
|
506
|
+
) {
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
462
509
|
this.clickCancel();
|
|
463
510
|
}
|
|
464
511
|
}
|
|
@@ -533,6 +580,10 @@ export class Dialog extends ResizeElement {
|
|
|
533
580
|
}"></div>
|
|
534
581
|
<div
|
|
535
582
|
@keyup=${this.handleKeyUp}
|
|
583
|
+
@keydown=${this.handleContentInteraction}
|
|
584
|
+
@pointerdown=${this.handleContentInteraction}
|
|
585
|
+
@input=${this.handleContentEdited}
|
|
586
|
+
@change=${this.handleContentEdited}
|
|
536
587
|
style=${styleMap(dialogStyle)}
|
|
537
588
|
class="dialog-container"
|
|
538
589
|
>
|
|
@@ -708,7 +708,11 @@ export class ContactTimeline extends EndpointMonitorElement {
|
|
|
708
708
|
// message events display the message text; flow-bearing events show a
|
|
709
709
|
// clickable flow pill linking to the flow (the "start" action is implied)
|
|
710
710
|
const body = isMessage
|
|
711
|
-
? html`<div class="title-text"
|
|
711
|
+
? html`<div class="title-text">
|
|
712
|
+
<temba-expression-highlight
|
|
713
|
+
>${event.message}</temba-expression-highlight
|
|
714
|
+
>
|
|
715
|
+
</div>`
|
|
712
716
|
: event.flow
|
|
713
717
|
? html`<temba-label
|
|
714
718
|
type="flow"
|