@nyaruka/temba-components 0.161.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 +16 -0
- package/dist/locales/es.js +2 -0
- package/dist/locales/es.js.map +1 -1
- package/dist/locales/fr.js +2 -0
- package/dist/locales/fr.js.map +1 -1
- package/dist/locales/pt.js +2 -0
- package/dist/locales/pt.js.map +1 -1
- package/dist/temba-components.js +501 -486
- package/dist/temba-components.js.map +1 -1
- package/package.json +3 -2
- package/src/display/Chat.ts +42 -7
- package/src/display/TembaUser.ts +7 -0
- package/src/flow/FlowSearch.ts +66 -2
- package/src/flow/NodeEditor.ts +109 -1
- package/src/layout/Dialog.ts +51 -0
- package/src/live/ContactChat.ts +120 -78
- package/src/live/ContactTimeline.ts +5 -1
- package/src/live/SocketService.ts +158 -0
- package/src/locales/es.ts +2 -0
- package/src/locales/fr.ts +2 -0
- package/src/locales/pt.ts +2 -0
- package/src/store/Store.ts +18 -0
- package/xliff/es.xlf +6 -0
- package/xliff/fr.xlf +6 -0
- package/xliff/pt.xlf +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nyaruka/temba-components",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.163.0",
|
|
4
4
|
"description": "Web components to support rapidpro and related projects",
|
|
5
5
|
"author": "Nyaruka <code@nyaruka.coim>",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"@jsplumb/browser-ui": "^6.2.10",
|
|
40
40
|
"@lit/localize": "^0.12.2",
|
|
41
41
|
"@open-wc/lit-helpers": "^0.7.0",
|
|
42
|
+
"centrifuge": "^5.7.0",
|
|
42
43
|
"chart.js": "^4.5.1",
|
|
43
44
|
"chartjs-adapter-luxon": "^1.3.1",
|
|
44
45
|
"chartjs-plugin-datalabels": "^2.2.0",
|
|
@@ -68,6 +69,7 @@
|
|
|
68
69
|
"@rollup/plugin-terser": "^0.4.4",
|
|
69
70
|
"@rollup/plugin-typescript": "^12.3.0",
|
|
70
71
|
"@types/leaflet": "1.4.4",
|
|
72
|
+
"@types/mocha": "^10.0.0",
|
|
71
73
|
"@types/node": "22.13.0",
|
|
72
74
|
"@types/remarkable": "^2.0.1",
|
|
73
75
|
"@typescript-eslint/eslint-plugin": "^7.5.0",
|
|
@@ -107,7 +109,6 @@
|
|
|
107
109
|
"sinon": "^21.0.0",
|
|
108
110
|
"svgstore": "^3.0.1",
|
|
109
111
|
"tslib": "2.8.1",
|
|
110
|
-
"@types/mocha": "^10.0.0",
|
|
111
112
|
"typescript": "5.9.3",
|
|
112
113
|
"yargs": "^17.7.2"
|
|
113
114
|
},
|
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;
|
|
@@ -838,6 +848,10 @@ export class Chat extends RapidElement {
|
|
|
838
848
|
private msgMap = new Map<string, ContactEvent>();
|
|
839
849
|
private metadataCache = new Map<string, ContactEvent>();
|
|
840
850
|
|
|
851
|
+
// bumped on reset so deferred addMessages work from before a reset is
|
|
852
|
+
// discarded rather than re-merged into the fresh view
|
|
853
|
+
private resetGeneration = 0;
|
|
854
|
+
|
|
841
855
|
public firstUpdated(
|
|
842
856
|
changed: PropertyValueMap<any> | Map<PropertyKey, unknown>
|
|
843
857
|
): void {
|
|
@@ -858,10 +872,16 @@ export class Chat extends RapidElement {
|
|
|
858
872
|
startTime = new Date();
|
|
859
873
|
}
|
|
860
874
|
|
|
875
|
+
const generation = this.resetGeneration;
|
|
861
876
|
const elapsed = new Date().getTime() - startTime.getTime();
|
|
862
877
|
window.setTimeout(
|
|
863
878
|
() => {
|
|
864
879
|
this.fetching = false;
|
|
880
|
+
|
|
881
|
+
// the chat was reset while we were waiting, our messages are stale
|
|
882
|
+
if (generation !== this.resetGeneration) {
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
865
885
|
// first add messages to the map
|
|
866
886
|
const newMessages = [];
|
|
867
887
|
for (const m of messages) {
|
|
@@ -905,6 +925,10 @@ export class Chat extends RapidElement {
|
|
|
905
925
|
}
|
|
906
926
|
|
|
907
927
|
window.setTimeout(() => {
|
|
928
|
+
if (generation !== this.resetGeneration) {
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
|
|
908
932
|
// when appending (new messages at bottom), adjust scroll to maintain visible content
|
|
909
933
|
// with column-reverse, new content at bottom increases scrollHeight
|
|
910
934
|
if (append && (isScrolledAway || maintainScroll)) {
|
|
@@ -1311,13 +1335,14 @@ export class Chat extends RapidElement {
|
|
|
1311
1335
|
(statusClass === 'failed' || statusClass === 'errored'));
|
|
1312
1336
|
const unsendableClass = hasError ? 'error' : '';
|
|
1313
1337
|
const deletedClass = msgEvent._deleted ? 'deleted' : '';
|
|
1338
|
+
const emptyClass = isEmptyMsg(msgEvent) ? 'empty' : '';
|
|
1314
1339
|
const latestClass = index === msgIds.length - 1 ? 'latest' : '';
|
|
1315
1340
|
const eventClass = msg._rendered ? 'is-event' : '';
|
|
1316
1341
|
const noteClass = msg.type === 'ticket_note_added' ? 'note' : '';
|
|
1317
1342
|
const matchClass =
|
|
1318
1343
|
this.highlightMessageUuid === msg.uuid ? 'search-match' : '';
|
|
1319
1344
|
return html`<div
|
|
1320
|
-
class="row message ${statusClass} ${unsendableClass} ${deletedClass} ${latestClass} ${eventClass} ${noteClass} ${matchClass}"
|
|
1345
|
+
class="row message ${statusClass} ${unsendableClass} ${deletedClass} ${emptyClass} ${latestClass} ${eventClass} ${noteClass} ${matchClass}"
|
|
1321
1346
|
data-uuid=${msg.uuid || nothing}
|
|
1322
1347
|
>
|
|
1323
1348
|
${this.renderMessage(
|
|
@@ -1416,6 +1441,9 @@ export class Chat extends RapidElement {
|
|
|
1416
1441
|
: message._deleted.user?.name || 'user'
|
|
1417
1442
|
: null;
|
|
1418
1443
|
|
|
1444
|
+
// messages with no text and no attachments get a muted placeholder
|
|
1445
|
+
const isEmpty = isEmptyMsg(message);
|
|
1446
|
+
|
|
1419
1447
|
// check if message has location attachment and text is just coordinates
|
|
1420
1448
|
const hasLocationAttachment = message.msg.attachments?.some((att) =>
|
|
1421
1449
|
att.startsWith('geo:')
|
|
@@ -1466,12 +1494,17 @@ export class Chat extends RapidElement {
|
|
|
1466
1494
|
Message deleted by ${deletedByText}
|
|
1467
1495
|
</div>
|
|
1468
1496
|
</div>`
|
|
1469
|
-
:
|
|
1497
|
+
: isEmpty
|
|
1470
1498
|
? html`<div class="bubble">
|
|
1471
1499
|
${name ? html`<div class="name">${name}</div>` : null}
|
|
1472
|
-
<div class="message-
|
|
1500
|
+
<div class="message-empty">Empty Message</div>
|
|
1473
1501
|
</div>`
|
|
1474
|
-
:
|
|
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}
|
|
1475
1508
|
|
|
1476
1509
|
<div class="attachments">
|
|
1477
1510
|
${(message.msg.attachments || []).map(
|
|
@@ -1486,7 +1519,9 @@ export class Chat extends RapidElement {
|
|
|
1486
1519
|
}
|
|
1487
1520
|
|
|
1488
1521
|
public reset() {
|
|
1522
|
+
this.resetGeneration++;
|
|
1489
1523
|
this.msgMap.clear();
|
|
1524
|
+
this.metadataCache.clear();
|
|
1490
1525
|
this.messageGroups = [];
|
|
1491
1526
|
this.hideBottomScroll = true;
|
|
1492
1527
|
this.hideTopScroll = true;
|
package/src/display/TembaUser.ts
CHANGED
|
@@ -116,6 +116,13 @@ export class TembaUser extends RapidElement {
|
|
|
116
116
|
if (changed.has('avatar')) {
|
|
117
117
|
if (this.avatar) {
|
|
118
118
|
this.bgimage = `url('${this.avatar}') center / contain no-repeat`;
|
|
119
|
+
} else {
|
|
120
|
+
// clearing the avatar must also clear the image, otherwise a reused
|
|
121
|
+
// element keeps showing the previous user's avatar - fall back to the
|
|
122
|
+
// system default or the initials/contact-icon branch
|
|
123
|
+
this.bgimage = this.system
|
|
124
|
+
? `url('${DEFAULT_AVATAR}') center / contain no-repeat`
|
|
125
|
+
: null;
|
|
119
126
|
}
|
|
120
127
|
}
|
|
121
128
|
}
|
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
|
>
|