@nyaruka/temba-components 0.132.0 → 0.133.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 +20 -1
- package/demo/components/flow/example.html +1 -0
- package/demo/static/css/tailwind.css +30019 -0
- package/dist/temba-components.js +434 -402
- package/dist/temba-components.js.map +1 -1
- package/out-tsc/src/display/Chat.js +26 -6
- package/out-tsc/src/display/Chat.js.map +1 -1
- package/out-tsc/src/display/FloatingTab.js +4 -4
- package/out-tsc/src/display/FloatingTab.js.map +1 -1
- package/out-tsc/src/events.js.map +1 -1
- package/out-tsc/src/flow/CanvasNode.js +124 -58
- package/out-tsc/src/flow/CanvasNode.js.map +1 -1
- package/out-tsc/src/flow/Editor.js +66 -30
- package/out-tsc/src/flow/Editor.js.map +1 -1
- package/out-tsc/src/layout/FloatingWindow.js +1 -2
- package/out-tsc/src/layout/FloatingWindow.js.map +1 -1
- package/out-tsc/src/list/ContentMenu.js +1 -0
- package/out-tsc/src/list/ContentMenu.js.map +1 -1
- package/out-tsc/src/list/SortableList.js +3 -2
- package/out-tsc/src/list/SortableList.js.map +1 -1
- package/out-tsc/src/live/ContactChat.js +63 -35
- package/out-tsc/src/live/ContactChat.js.map +1 -1
- package/out-tsc/src/store/AppState.js +31 -0
- package/out-tsc/src/store/AppState.js.map +1 -1
- package/out-tsc/src/utils.js +3 -3
- package/out-tsc/src/utils.js.map +1 -1
- package/out-tsc/test/ActionHelper.js +6 -5
- package/out-tsc/test/ActionHelper.js.map +1 -1
- package/out-tsc/test/actions/send_broadcast.test.js +1 -1
- package/out-tsc/test/actions/send_broadcast.test.js.map +1 -1
- package/out-tsc/test/temba-contact-chat.test.js +1 -1
- package/out-tsc/test/temba-contact-chat.test.js.map +1 -1
- package/out-tsc/test/temba-floating-window.test.js +0 -2
- package/out-tsc/test/temba-floating-window.test.js.map +1 -1
- package/out-tsc/test/temba-flow-editor-node.test.js +109 -0
- package/out-tsc/test/temba-flow-editor-node.test.js.map +1 -1
- package/out-tsc/test/temba-utils-uuid.test.js +45 -1
- package/out-tsc/test/temba-utils-uuid.test.js.map +1 -1
- package/out-tsc/test/utils.test.js +2 -2
- package/out-tsc/test/utils.test.js.map +1 -1
- package/package.json +1 -1
- package/screenshots/truth/actions/add_contact_groups/render/descriptive-group-names.png +0 -0
- package/screenshots/truth/actions/add_contact_groups/render/long-group-names.png +0 -0
- package/screenshots/truth/actions/add_contact_groups/render/many-groups.png +0 -0
- package/screenshots/truth/actions/add_contact_groups/render/multiple-groups.png +0 -0
- package/screenshots/truth/actions/add_contact_groups/render/single-group.png +0 -0
- package/screenshots/truth/actions/add_contact_urn/render/expression-facebook.png +0 -0
- package/screenshots/truth/actions/add_contact_urn/render/expression-phone.png +0 -0
- package/screenshots/truth/actions/add_contact_urn/render/facebook-id.png +0 -0
- package/screenshots/truth/actions/add_contact_urn/render/instagram-handle.png +0 -0
- package/screenshots/truth/actions/add_contact_urn/render/line-id.png +0 -0
- package/screenshots/truth/actions/add_contact_urn/render/phone-number.png +0 -0
- package/screenshots/truth/actions/add_contact_urn/render/telegram-id.png +0 -0
- package/screenshots/truth/actions/add_contact_urn/render/viber-id.png +0 -0
- package/screenshots/truth/actions/add_contact_urn/render/wechat-id.png +0 -0
- package/screenshots/truth/actions/add_contact_urn/render/whatsapp.png +0 -0
- package/screenshots/truth/actions/remove_contact_groups/render/cleanup-groups.png +0 -0
- package/screenshots/truth/actions/remove_contact_groups/render/long-descriptive-group-names.png +0 -0
- package/screenshots/truth/actions/remove_contact_groups/render/many-groups.png +0 -0
- package/screenshots/truth/actions/remove_contact_groups/render/multiple-groups.png +0 -0
- package/screenshots/truth/actions/remove_contact_groups/render/remove-from-all-groups.png +0 -0
- package/screenshots/truth/actions/remove_contact_groups/render/single-group.png +0 -0
- package/screenshots/truth/actions/send_broadcast/render/contacts-only.png +0 -0
- package/screenshots/truth/actions/send_broadcast/render/groups-and-contacts.png +0 -0
- package/screenshots/truth/actions/send_broadcast/render/groups-only.png +0 -0
- package/screenshots/truth/actions/send_broadcast/render/many-groups.png +0 -0
- package/screenshots/truth/actions/send_broadcast/render/multiline-text.png +0 -0
- package/screenshots/truth/actions/send_broadcast/render/with-attachments.png +0 -0
- package/screenshots/truth/actions/send_email/render/complex-business-email.png +0 -0
- package/screenshots/truth/actions/send_email/render/empty-body.png +0 -0
- package/screenshots/truth/actions/send_email/render/empty-subject.png +0 -0
- package/screenshots/truth/actions/send_email/render/long-subject.png +0 -0
- package/screenshots/truth/actions/send_email/render/multiline-body.png +0 -0
- package/screenshots/truth/actions/send_email/render/multiple-recipients.png +0 -0
- package/screenshots/truth/actions/send_email/render/simple-email.png +0 -0
- package/screenshots/truth/actions/send_email/render/with-expressions.png +0 -0
- package/screenshots/truth/actions/send_msg/render/long-quick-replies.png +0 -0
- package/screenshots/truth/actions/send_msg/render/multiline-text-with-replies.png +0 -0
- package/screenshots/truth/actions/send_msg/render/simple-text.png +0 -0
- package/screenshots/truth/actions/send_msg/render/text-with-linebreaks.png +0 -0
- package/screenshots/truth/actions/send_msg/render/text-with-many-quick-replies.png +0 -0
- package/screenshots/truth/actions/send_msg/render/text-with-quick-replies.png +0 -0
- package/screenshots/truth/actions/send_msg/render/text-without-quick-replies.png +0 -0
- package/screenshots/truth/actions/start_session/render/contact-query.png +0 -0
- package/screenshots/truth/actions/start_session/render/contacts-only.png +0 -0
- package/screenshots/truth/actions/start_session/render/create-contact.png +0 -0
- package/screenshots/truth/actions/start_session/render/groups-and-contacts.png +0 -0
- package/screenshots/truth/actions/start_session/render/groups-only.png +0 -0
- package/screenshots/truth/actions/start_session/render/many-recipients.png +0 -0
- package/screenshots/truth/contacts/chat-failure.png +0 -0
- package/screenshots/truth/contacts/chat-for-archived-contact.png +0 -0
- package/screenshots/truth/contacts/chat-for-blocked-contact.png +0 -0
- package/screenshots/truth/contacts/chat-for-stopped-contact.png +0 -0
- package/screenshots/truth/contacts/chat-sends-attachments-only.png +0 -0
- package/screenshots/truth/contacts/chat-sends-text-and-attachments.png +0 -0
- package/screenshots/truth/contacts/chat-sends-text-only.png +0 -0
- package/screenshots/truth/floating-tab/default.png +0 -0
- package/screenshots/truth/floating-tab/gray.png +0 -0
- package/screenshots/truth/floating-tab/green.png +0 -0
- package/screenshots/truth/floating-tab/hover.png +0 -0
- package/screenshots/truth/floating-tab/purple.png +0 -0
- package/screenshots/truth/nodes/split_by_llm/render/information-extraction.png +0 -0
- package/screenshots/truth/nodes/split_by_llm/render/sentiment-analysis.png +0 -0
- package/screenshots/truth/nodes/split_by_llm/render/summarization.png +0 -0
- package/screenshots/truth/nodes/split_by_llm/render/translation-task.png +0 -0
- package/screenshots/truth/nodes/split_by_llm_categorize/editor/feedback-categorization.png +0 -0
- package/screenshots/truth/nodes/split_by_llm_categorize/render/basic-categorization.png +0 -0
- package/screenshots/truth/nodes/split_by_llm_categorize/render/custom-input-and-result-name.png +0 -0
- package/screenshots/truth/nodes/split_by_llm_categorize/render/feedback-categorization.png +0 -0
- package/screenshots/truth/nodes/split_by_llm_categorize/render/many-categories.png +0 -0
- package/screenshots/truth/nodes/split_by_llm_categorize/render/minimal-categories.png +0 -0
- package/screenshots/truth/nodes/split_by_random/render/ab-test-multiple-variants.png +0 -0
- package/screenshots/truth/nodes/split_by_random/render/sampling-split.png +0 -0
- package/screenshots/truth/nodes/split_by_random/render/three-way-split.png +0 -0
- package/screenshots/truth/nodes/split_by_random/render/two-bucket-split.png +0 -0
- package/screenshots/truth/nodes/wait_for_digits/render/basic-digits-wait.png +0 -0
- package/screenshots/truth/nodes/wait_for_digits/render/phone-number-collection.png +0 -0
- package/screenshots/truth/nodes/wait_for_digits/render/single-digit-with-timeout.png +0 -0
- package/screenshots/truth/nodes/wait_for_digits/render/verification-code.png +0 -0
- package/screenshots/truth/nodes/wait_for_response/render/basic-wait.png +0 -0
- package/screenshots/truth/nodes/wait_for_response/render/custom-result-name.png +0 -0
- package/screenshots/truth/nodes/wait_for_response/render/no-timeout.png +0 -0
- package/screenshots/truth/nodes/wait_for_response/render/short-timeout.png +0 -0
- package/src/display/Chat.ts +29 -7
- package/src/display/FloatingTab.ts +4 -4
- package/src/events.ts +1 -4
- package/src/flow/CanvasNode.ts +130 -57
- package/src/flow/Editor.ts +84 -30
- package/src/layout/FloatingWindow.ts +1 -3
- package/src/list/ContentMenu.ts +1 -0
- package/src/list/SortableList.ts +3 -2
- package/src/live/ContactChat.ts +68 -42
- package/src/store/AppState.ts +41 -0
- package/src/utils.ts +3 -3
- package/test/ActionHelper.ts +13 -5
- package/test/actions/send_broadcast.test.ts +2 -1
- package/test/temba-contact-chat.test.ts +1 -1
- package/test/temba-floating-window.test.ts +0 -2
- package/test/temba-flow-editor-node.test.ts +129 -0
- package/test/temba-utils-uuid.test.ts +61 -1
- package/test/utils.test.ts +7 -2
- package/test-assets/contacts/history.json +22 -9
- package/web-test-runner.config.mjs +3 -3
package/src/live/ContactChat.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from '../interfaces';
|
|
11
11
|
import {
|
|
12
12
|
fetchResults,
|
|
13
|
+
generateUUIDv7,
|
|
13
14
|
getUrl,
|
|
14
15
|
oxfordFn,
|
|
15
16
|
postJSON,
|
|
@@ -525,10 +526,12 @@ export class ContactChat extends ContactStoreElement {
|
|
|
525
526
|
private chat: Chat;
|
|
526
527
|
|
|
527
528
|
ticket = null;
|
|
528
|
-
|
|
529
|
-
|
|
529
|
+
beforeUUID: string = null; // for scrolling back through history
|
|
530
|
+
afterUUID: string = null; // for polling new messages
|
|
530
531
|
refreshId = null;
|
|
531
532
|
polling = false;
|
|
533
|
+
pollingInterval = 2000; // start at 2 seconds
|
|
534
|
+
lastFetchTime: number = null;
|
|
532
535
|
|
|
533
536
|
constructor() {
|
|
534
537
|
super();
|
|
@@ -582,10 +585,12 @@ export class ContactChat extends ContactStoreElement {
|
|
|
582
585
|
}
|
|
583
586
|
this.blockFetching = false;
|
|
584
587
|
this.ticket = null;
|
|
585
|
-
this.
|
|
586
|
-
this.
|
|
588
|
+
this.beforeUUID = null;
|
|
589
|
+
this.afterUUID = null;
|
|
587
590
|
this.refreshId = null;
|
|
588
591
|
this.polling = false;
|
|
592
|
+
this.pollingInterval = 2000;
|
|
593
|
+
this.lastFetchTime = null;
|
|
589
594
|
this.errorMessage = null;
|
|
590
595
|
|
|
591
596
|
const compose = this.shadowRoot.querySelector('temba-compose') as Compose;
|
|
@@ -634,6 +639,8 @@ export class ContactChat extends ContactStoreElement {
|
|
|
634
639
|
if (response.status < 400) {
|
|
635
640
|
const msg = this.createChatForMessageEvent(response.json.event);
|
|
636
641
|
this.chat.addMessages([msg], null, true);
|
|
642
|
+
// reset polling interval to 2 seconds after sending a message
|
|
643
|
+
this.pollingInterval = 2000;
|
|
637
644
|
this.checkForNewMessages();
|
|
638
645
|
composeEle.reset();
|
|
639
646
|
this.fireCustomEvent(CustomEventType.MessageSent, {
|
|
@@ -651,30 +658,28 @@ export class ContactChat extends ContactStoreElement {
|
|
|
651
658
|
|
|
652
659
|
private getEndpoint() {
|
|
653
660
|
if (this.contact) {
|
|
654
|
-
return `/contact/
|
|
661
|
+
return `/contact/chat/${this.contact}/`;
|
|
655
662
|
}
|
|
656
663
|
return null;
|
|
657
664
|
}
|
|
658
665
|
|
|
659
|
-
private scheduleRefresh() {
|
|
660
|
-
// knock five seconds off the newest event time so we are
|
|
661
|
-
// a little more aggressive about refreshing short term
|
|
662
|
-
let window = new Date().getTime() - this.newestEventTime / 1000 - 5000;
|
|
663
|
-
|
|
666
|
+
private scheduleRefresh(hasNewEvents = false) {
|
|
664
667
|
if (this.refreshId) {
|
|
665
668
|
clearTimeout(this.refreshId);
|
|
666
669
|
this.refreshId = null;
|
|
667
670
|
}
|
|
668
671
|
|
|
669
|
-
//
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
672
|
+
// reset to 2 seconds if we received new events
|
|
673
|
+
if (hasNewEvents) {
|
|
674
|
+
this.pollingInterval = 2000;
|
|
675
|
+
} else {
|
|
676
|
+
// increase interval by 1 second up to max of 15 seconds
|
|
677
|
+
this.pollingInterval = Math.min(this.pollingInterval + 1000, 15000);
|
|
678
|
+
}
|
|
674
679
|
|
|
675
680
|
this.refreshId = setTimeout(() => {
|
|
676
681
|
this.checkForNewMessages();
|
|
677
|
-
},
|
|
682
|
+
}, this.pollingInterval);
|
|
678
683
|
}
|
|
679
684
|
|
|
680
685
|
public getEventMessage(event: ContactEvent): ChatEvent {
|
|
@@ -857,9 +862,12 @@ export class ContactChat extends ContactStoreElement {
|
|
|
857
862
|
if (page.events) {
|
|
858
863
|
let messages = [];
|
|
859
864
|
page.events.forEach((event) => {
|
|
860
|
-
|
|
861
|
-
if (
|
|
862
|
-
this.
|
|
865
|
+
// track the UUID of the newest event for polling
|
|
866
|
+
if (
|
|
867
|
+
!this.afterUUID ||
|
|
868
|
+
event.uuid.toLowerCase() > this.afterUUID.toLowerCase()
|
|
869
|
+
) {
|
|
870
|
+
this.afterUUID = event.uuid;
|
|
863
871
|
}
|
|
864
872
|
|
|
865
873
|
if (event.type === 'ticket_note_added') {
|
|
@@ -914,8 +922,9 @@ export class ContactChat extends ContactStoreElement {
|
|
|
914
922
|
|
|
915
923
|
const chat = this.chat;
|
|
916
924
|
const contactChat = this;
|
|
917
|
-
if (this.currentContact && this.
|
|
925
|
+
if (this.currentContact && this.afterUUID) {
|
|
918
926
|
this.polling = true;
|
|
927
|
+
this.lastFetchTime = Date.now();
|
|
919
928
|
const endpoint = this.getEndpoint();
|
|
920
929
|
if (!endpoint) {
|
|
921
930
|
return;
|
|
@@ -924,23 +933,24 @@ export class ContactChat extends ContactStoreElement {
|
|
|
924
933
|
const fetchContact = this.currentContact.uuid;
|
|
925
934
|
|
|
926
935
|
fetchContactHistory(
|
|
927
|
-
false,
|
|
928
936
|
endpoint,
|
|
929
937
|
this.currentTicket?.uuid,
|
|
930
938
|
null,
|
|
931
|
-
this.
|
|
939
|
+
this.afterUUID
|
|
932
940
|
).then((page: ContactHistoryPage) => {
|
|
933
941
|
if (fetchContact === this.currentContact.uuid) {
|
|
934
|
-
this.lastEventTime = page.next_before;
|
|
935
942
|
const messages = this.createMessages(page);
|
|
943
|
+
const hasNewEvents = messages.length > 0;
|
|
936
944
|
if (messages.length === 0) {
|
|
937
945
|
contactChat.blockFetching = true;
|
|
938
946
|
}
|
|
939
947
|
messages.reverse();
|
|
940
948
|
chat.addMessages(messages, null, true);
|
|
949
|
+
this.polling = false;
|
|
950
|
+
this.scheduleRefresh(hasNewEvents);
|
|
951
|
+
} else {
|
|
952
|
+
this.polling = false;
|
|
941
953
|
}
|
|
942
|
-
this.polling = false;
|
|
943
|
-
this.scheduleRefresh();
|
|
944
954
|
});
|
|
945
955
|
}
|
|
946
956
|
}
|
|
@@ -959,19 +969,37 @@ export class ContactChat extends ContactStoreElement {
|
|
|
959
969
|
return;
|
|
960
970
|
}
|
|
961
971
|
|
|
972
|
+
// initialize anchor UUID if not set (first fetch)
|
|
973
|
+
if (!this.beforeUUID && !this.afterUUID) {
|
|
974
|
+
// generate a UUID v7 for current time as the anchor
|
|
975
|
+
const anchorUUID = generateUUIDv7();
|
|
976
|
+
this.beforeUUID = anchorUUID;
|
|
977
|
+
this.afterUUID = anchorUUID;
|
|
978
|
+
}
|
|
979
|
+
|
|
962
980
|
fetchContactHistory(
|
|
963
|
-
false,
|
|
964
981
|
endpoint,
|
|
965
982
|
this.currentTicket?.uuid,
|
|
966
|
-
this.
|
|
983
|
+
this.beforeUUID,
|
|
984
|
+
null
|
|
967
985
|
).then((page: ContactHistoryPage) => {
|
|
968
|
-
this.lastEventTime = page.next_before;
|
|
969
986
|
const messages = this.createMessages(page);
|
|
970
987
|
messages.reverse();
|
|
971
988
|
|
|
972
989
|
if (messages.length === 0) {
|
|
973
990
|
contactChat.blockFetching = true;
|
|
991
|
+
} else if (page.next) {
|
|
992
|
+
// update beforeUUID for next fetch of older messages
|
|
993
|
+
this.beforeUUID = page.next;
|
|
994
|
+
} else {
|
|
995
|
+
// no more history, mark end and show oldest event date
|
|
996
|
+
contactChat.blockFetching = true;
|
|
997
|
+
if (page.events && page.events.length > 0) {
|
|
998
|
+
const oldestEvent = page.events[page.events.length - 1];
|
|
999
|
+
chat.setEndOfHistory(new Date(oldestEvent.created_on));
|
|
1000
|
+
}
|
|
974
1001
|
}
|
|
1002
|
+
|
|
975
1003
|
chat.addMessages(messages);
|
|
976
1004
|
this.scheduleRefresh();
|
|
977
1005
|
});
|
|
@@ -1238,34 +1266,32 @@ export const fetchContact = (endpoint: string): Promise<Contact> => {
|
|
|
1238
1266
|
});
|
|
1239
1267
|
};
|
|
1240
1268
|
export const fetchContactHistory = (
|
|
1241
|
-
reset: boolean,
|
|
1242
1269
|
endpoint: string,
|
|
1243
|
-
ticket: string,
|
|
1244
|
-
before:
|
|
1245
|
-
after:
|
|
1270
|
+
ticket: string = undefined,
|
|
1271
|
+
before: string = undefined,
|
|
1272
|
+
after: string = undefined
|
|
1246
1273
|
): Promise<ContactHistoryPage> => {
|
|
1247
|
-
if (reset) {
|
|
1248
|
-
pendingRequests.forEach((controller) => {
|
|
1249
|
-
controller.abort();
|
|
1250
|
-
});
|
|
1251
|
-
pendingRequests = [];
|
|
1252
|
-
}
|
|
1253
|
-
|
|
1254
1274
|
return new Promise<ContactHistoryPage>((resolve) => {
|
|
1255
1275
|
const controller = new AbortController();
|
|
1256
1276
|
pendingRequests.push(controller);
|
|
1257
1277
|
|
|
1258
1278
|
let url = endpoint;
|
|
1279
|
+
const params = [];
|
|
1280
|
+
|
|
1259
1281
|
if (before) {
|
|
1260
|
-
|
|
1282
|
+
params.push(`before=${before}`);
|
|
1261
1283
|
}
|
|
1262
1284
|
|
|
1263
1285
|
if (after) {
|
|
1264
|
-
|
|
1286
|
+
params.push(`after=${after}`);
|
|
1265
1287
|
}
|
|
1266
1288
|
|
|
1267
1289
|
if (ticket) {
|
|
1268
|
-
|
|
1290
|
+
params.push(`ticket=${ticket}`);
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
if (params.length > 0) {
|
|
1294
|
+
url += (url.includes('?') ? '&' : '?') + params.join('&');
|
|
1269
1295
|
}
|
|
1270
1296
|
|
|
1271
1297
|
const store = document.querySelector('temba-store') as Store;
|
package/src/store/AppState.ts
CHANGED
|
@@ -88,6 +88,7 @@ export interface AppState {
|
|
|
88
88
|
|
|
89
89
|
setFlowContents: (flow: FlowContents) => void;
|
|
90
90
|
setFlowInfo: (info: FlowInfo) => void;
|
|
91
|
+
setRevision: (revision: number) => void;
|
|
91
92
|
setLanguageCode: (languageCode: string) => void;
|
|
92
93
|
setDirtyDate: (date: Date) => void;
|
|
93
94
|
expandCanvas: (width: number, height: number) => void;
|
|
@@ -216,6 +217,12 @@ export const zustand = createStore<AppState>()(
|
|
|
216
217
|
});
|
|
217
218
|
},
|
|
218
219
|
|
|
220
|
+
setRevision: (revision: number) => {
|
|
221
|
+
set((state: AppState) => {
|
|
222
|
+
state.flowDefinition.revision = revision;
|
|
223
|
+
});
|
|
224
|
+
},
|
|
225
|
+
|
|
219
226
|
setLanguageCode: (languageCode: string) => {
|
|
220
227
|
set((state: AppState) => {
|
|
221
228
|
state.languageCode = languageCode;
|
|
@@ -264,10 +271,44 @@ export const zustand = createStore<AppState>()(
|
|
|
264
271
|
}
|
|
265
272
|
|
|
266
273
|
state.flowDefinition = produce(state.flowDefinition, (draft) => {
|
|
274
|
+
// For each node being removed, check if we should reroute connections
|
|
275
|
+
uuids.forEach((removedUuid) => {
|
|
276
|
+
const removedNode = draft.nodes.find(
|
|
277
|
+
(n) => n.uuid === removedUuid
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
if (!removedNode || !removedNode.exits.length) return;
|
|
281
|
+
|
|
282
|
+
// Get all destinations (filter out null/undefined)
|
|
283
|
+
const destinations = removedNode.exits
|
|
284
|
+
.map((exit) => exit.destination_uuid)
|
|
285
|
+
.filter((dest) => dest);
|
|
286
|
+
|
|
287
|
+
// Only proceed if all exits have destinations and they all point to the same place
|
|
288
|
+
if (
|
|
289
|
+
destinations.length === removedNode.exits.length &&
|
|
290
|
+
destinations.every((dest) => dest === destinations[0])
|
|
291
|
+
) {
|
|
292
|
+
const targetDestination = destinations[0];
|
|
293
|
+
|
|
294
|
+
// Find all nodes with exits pointing to the node being removed
|
|
295
|
+
draft.nodes.forEach((node) => {
|
|
296
|
+
node.exits.forEach((exit) => {
|
|
297
|
+
if (exit.destination_uuid === removedUuid) {
|
|
298
|
+
// Reroute to the same destination the removed node was going to
|
|
299
|
+
exit.destination_uuid = targetDestination;
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// Remove the nodes
|
|
267
307
|
draft.nodes = draft.nodes.filter(
|
|
268
308
|
(node) => !uuids.includes(node.uuid)
|
|
269
309
|
);
|
|
270
310
|
|
|
311
|
+
// Clear any remaining connections to removed nodes that weren't rerouted
|
|
271
312
|
draft.nodes.forEach((node) => {
|
|
272
313
|
node.exits.forEach((exit) => {
|
|
273
314
|
if (uuids.includes(exit.destination_uuid)) {
|
package/src/utils.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { Dialog } from './layout/Dialog';
|
|
|
5
5
|
import { Attachment, ContactField, Shortcut, Ticket, User } from './interfaces';
|
|
6
6
|
import ColorHash from 'color-hash';
|
|
7
7
|
import { Toast } from './display/Toast';
|
|
8
|
-
import { v4 as generateUUID } from 'uuid';
|
|
8
|
+
import { v4 as generateUUID, v7 as generateUUIDv7 } from 'uuid';
|
|
9
9
|
|
|
10
10
|
export const DEFAULT_MEDIA_ENDPOINT = '/api/v2/media.json';
|
|
11
11
|
|
|
@@ -918,8 +918,8 @@ export const getMiddle = (a: DOMRect, b: DOMRect) => {
|
|
|
918
918
|
return a.top + a.height / 2 - b.height / 2;
|
|
919
919
|
};
|
|
920
920
|
|
|
921
|
-
// Export the UUID
|
|
922
|
-
export { generateUUID };
|
|
921
|
+
// Export the UUID functions from the uuid package
|
|
922
|
+
export { generateUUID, generateUUIDv7 };
|
|
923
923
|
|
|
924
924
|
// Helper types for router creation
|
|
925
925
|
export interface RouterCategory {
|
package/test/ActionHelper.ts
CHANGED
|
@@ -71,12 +71,13 @@ export class ActionTest<T extends Action> {
|
|
|
71
71
|
*/
|
|
72
72
|
private async assertDialogScreenshot(
|
|
73
73
|
el: HTMLElement,
|
|
74
|
-
screenshotName: string
|
|
74
|
+
screenshotName: string,
|
|
75
|
+
waitForNetwork: boolean = false
|
|
75
76
|
) {
|
|
76
77
|
const dialog = el.shadowRoot
|
|
77
78
|
.querySelector('temba-dialog')
|
|
78
79
|
.shadowRoot.querySelector('.dialog-container') as HTMLElement;
|
|
79
|
-
await assertScreenshot(screenshotName, getClip(dialog));
|
|
80
|
+
await assertScreenshot(screenshotName, getClip(dialog), waitForNetwork);
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
/**
|
|
@@ -84,22 +85,29 @@ export class ActionTest<T extends Action> {
|
|
|
84
85
|
* 1. Renders the action in a flow node (with screenshot)
|
|
85
86
|
* 2. Opens the node editor (with screenshot)
|
|
86
87
|
* 3. Simulates save and validates round-trip conversion
|
|
88
|
+
* @param waitForNetwork - If true, waits longer for network idle (useful for slow loading resources)
|
|
87
89
|
*/
|
|
88
|
-
async testAction(
|
|
90
|
+
async testAction(
|
|
91
|
+
action: T,
|
|
92
|
+
testName: string,
|
|
93
|
+
waitForNetwork: boolean = false
|
|
94
|
+
) {
|
|
89
95
|
it(`${testName}`, async () => {
|
|
90
96
|
// Step 1: Render action in flow node
|
|
91
97
|
const flowNode = await this.renderAction(action);
|
|
92
98
|
expect(flowNode.querySelector('.body')).to.exist;
|
|
93
99
|
await assertScreenshot(
|
|
94
100
|
`actions/${this.actionName}/render/${testName}`,
|
|
95
|
-
getClip(flowNode)
|
|
101
|
+
getClip(flowNode),
|
|
102
|
+
waitForNetwork
|
|
96
103
|
);
|
|
97
104
|
|
|
98
105
|
// Step 2: Open node editor
|
|
99
106
|
const nodeEditor = await this.openNodeEditor(action);
|
|
100
107
|
await this.assertDialogScreenshot(
|
|
101
108
|
nodeEditor,
|
|
102
|
-
`actions/${this.actionName}/editor/${testName}
|
|
109
|
+
`actions/${this.actionName}/editor/${testName}`,
|
|
110
|
+
waitForNetwork
|
|
103
111
|
);
|
|
104
112
|
|
|
105
113
|
// Step 3: Test round-trip conversion (simulates save workflow)
|
|
@@ -51,7 +51,6 @@ describe('temba-floating-window', () => {
|
|
|
51
51
|
)) as FloatingWindow;
|
|
52
52
|
|
|
53
53
|
expect(window.hidden).to.equal(true);
|
|
54
|
-
expect(window.classList.contains('hidden')).to.equal(true);
|
|
55
54
|
});
|
|
56
55
|
|
|
57
56
|
it('can be shown and hidden', async () => {
|
|
@@ -74,7 +73,6 @@ describe('temba-floating-window', () => {
|
|
|
74
73
|
window.hide();
|
|
75
74
|
await window.updateComplete;
|
|
76
75
|
expect(window.hidden).to.equal(true);
|
|
77
|
-
expect(window.classList.contains('hidden')).to.equal(true);
|
|
78
76
|
});
|
|
79
77
|
|
|
80
78
|
it('fires close event when close button clicked', async () => {
|
|
@@ -1200,6 +1200,135 @@ describe('EditorNode', () => {
|
|
|
1200
1200
|
// 3. New JSPlumb connections are created with connectIds
|
|
1201
1201
|
// This sequence ensures JSPlumb visuals stay in sync with the flow definition
|
|
1202
1202
|
});
|
|
1203
|
+
|
|
1204
|
+
it('reroutes connections when removing node with multiple exits pointing to same destination', async () => {
|
|
1205
|
+
// Test case: node with multiple exits, but all point to the same destination
|
|
1206
|
+
const mockNode: Node = {
|
|
1207
|
+
uuid: 'test-node',
|
|
1208
|
+
actions: [
|
|
1209
|
+
{
|
|
1210
|
+
type: 'send_msg',
|
|
1211
|
+
uuid: 'action-1',
|
|
1212
|
+
text: 'Hello',
|
|
1213
|
+
quick_replies: []
|
|
1214
|
+
} as any
|
|
1215
|
+
],
|
|
1216
|
+
exits: [
|
|
1217
|
+
{ uuid: 'exit-1', destination_uuid: 'node-after' },
|
|
1218
|
+
{ uuid: 'exit-2', destination_uuid: 'node-after' },
|
|
1219
|
+
{ uuid: 'exit-3', destination_uuid: 'node-after' }
|
|
1220
|
+
]
|
|
1221
|
+
};
|
|
1222
|
+
|
|
1223
|
+
editorNode['node'] = mockNode;
|
|
1224
|
+
|
|
1225
|
+
const mockFlowDefinition = {
|
|
1226
|
+
nodes: [
|
|
1227
|
+
{
|
|
1228
|
+
uuid: 'node-before',
|
|
1229
|
+
exits: [{ uuid: 'exit-before', destination_uuid: 'test-node' }]
|
|
1230
|
+
},
|
|
1231
|
+
mockNode,
|
|
1232
|
+
{
|
|
1233
|
+
uuid: 'node-after',
|
|
1234
|
+
exits: []
|
|
1235
|
+
}
|
|
1236
|
+
]
|
|
1237
|
+
};
|
|
1238
|
+
|
|
1239
|
+
// Verify all exits point to the same destination
|
|
1240
|
+
const destinations = mockNode.exits
|
|
1241
|
+
.map((exit) => exit.destination_uuid)
|
|
1242
|
+
.filter((dest) => dest);
|
|
1243
|
+
|
|
1244
|
+
expect(destinations).to.have.length(3);
|
|
1245
|
+
expect(destinations.every((dest) => dest === 'node-after')).to.be.true;
|
|
1246
|
+
|
|
1247
|
+
// Find incoming connections
|
|
1248
|
+
const incomingConnections: {
|
|
1249
|
+
exitUuid: string;
|
|
1250
|
+
sourceNodeUuid: string;
|
|
1251
|
+
}[] = [];
|
|
1252
|
+
|
|
1253
|
+
for (const node of mockFlowDefinition.nodes) {
|
|
1254
|
+
if (node.uuid !== mockNode.uuid) {
|
|
1255
|
+
for (const exit of node.exits) {
|
|
1256
|
+
if (exit.destination_uuid === mockNode.uuid) {
|
|
1257
|
+
incomingConnections.push({
|
|
1258
|
+
exitUuid: exit.uuid,
|
|
1259
|
+
sourceNodeUuid: node.uuid
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// Verify we found incoming connections
|
|
1267
|
+
expect(incomingConnections).to.have.length(1);
|
|
1268
|
+
expect(incomingConnections[0].exitUuid).to.equal('exit-before');
|
|
1269
|
+
|
|
1270
|
+
// This validates that when a node has multiple exits but they all point
|
|
1271
|
+
// to the same destination, the rerouting logic should still apply
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1274
|
+
it('does not reroute connections when node has exits with different destinations', async () => {
|
|
1275
|
+
// Test case: node with multiple exits pointing to different destinations
|
|
1276
|
+
const mockNode: Node = {
|
|
1277
|
+
uuid: 'test-node',
|
|
1278
|
+
actions: [
|
|
1279
|
+
{
|
|
1280
|
+
type: 'send_msg',
|
|
1281
|
+
uuid: 'action-1',
|
|
1282
|
+
text: 'Hello',
|
|
1283
|
+
quick_replies: []
|
|
1284
|
+
} as any
|
|
1285
|
+
],
|
|
1286
|
+
exits: [
|
|
1287
|
+
{ uuid: 'exit-1', destination_uuid: 'node-after-1' },
|
|
1288
|
+
{ uuid: 'exit-2', destination_uuid: 'node-after-2' }
|
|
1289
|
+
]
|
|
1290
|
+
};
|
|
1291
|
+
|
|
1292
|
+
const destinations = mockNode.exits
|
|
1293
|
+
.map((exit) => exit.destination_uuid)
|
|
1294
|
+
.filter((dest) => dest);
|
|
1295
|
+
|
|
1296
|
+
// Verify exits point to different destinations
|
|
1297
|
+
expect(destinations).to.have.length(2);
|
|
1298
|
+
expect(destinations.every((dest) => dest === destinations[0])).to.be
|
|
1299
|
+
.false;
|
|
1300
|
+
|
|
1301
|
+
// This validates that rerouting does NOT apply when exits point to different places
|
|
1302
|
+
});
|
|
1303
|
+
|
|
1304
|
+
it('does not reroute connections when node has exits with null destinations', async () => {
|
|
1305
|
+
// Test case: node with some exits having null destinations
|
|
1306
|
+
const mockNode: Node = {
|
|
1307
|
+
uuid: 'test-node',
|
|
1308
|
+
actions: [
|
|
1309
|
+
{
|
|
1310
|
+
type: 'send_msg',
|
|
1311
|
+
uuid: 'action-1',
|
|
1312
|
+
text: 'Hello',
|
|
1313
|
+
quick_replies: []
|
|
1314
|
+
} as any
|
|
1315
|
+
],
|
|
1316
|
+
exits: [
|
|
1317
|
+
{ uuid: 'exit-1', destination_uuid: 'node-after' },
|
|
1318
|
+
{ uuid: 'exit-2', destination_uuid: null }
|
|
1319
|
+
]
|
|
1320
|
+
};
|
|
1321
|
+
|
|
1322
|
+
const destinations = mockNode.exits
|
|
1323
|
+
.map((exit) => exit.destination_uuid)
|
|
1324
|
+
.filter((dest) => dest);
|
|
1325
|
+
|
|
1326
|
+
// Verify not all exits have destinations
|
|
1327
|
+
expect(destinations).to.have.length(1);
|
|
1328
|
+
expect(destinations.length).to.not.equal(mockNode.exits.length);
|
|
1329
|
+
|
|
1330
|
+
// This validates that rerouting does NOT apply when some exits have no destination
|
|
1331
|
+
});
|
|
1203
1332
|
});
|
|
1204
1333
|
|
|
1205
1334
|
describe('add action button', () => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { assert } from '@open-wc/testing';
|
|
2
|
-
import { generateUUID } from '../src/utils';
|
|
2
|
+
import { generateUUID, generateUUIDv7 } from '../src/utils';
|
|
3
3
|
|
|
4
4
|
describe('UUID Generation', () => {
|
|
5
5
|
it('generates a valid UUID v4 format', () => {
|
|
@@ -45,4 +45,64 @@ describe('UUID Generation', () => {
|
|
|
45
45
|
// All should be unique
|
|
46
46
|
assert.equal(uuids.size, count, 'All generated UUIDs should be unique');
|
|
47
47
|
});
|
|
48
|
+
|
|
49
|
+
it('generates a valid UUID v7 format', () => {
|
|
50
|
+
const uuid = generateUUIDv7();
|
|
51
|
+
|
|
52
|
+
// check that it's a string
|
|
53
|
+
assert.isString(uuid);
|
|
54
|
+
|
|
55
|
+
// check UUID v7 format: xxxxxxxx-xxxx-7xxx-yxxx-xxxxxxxxxxxx
|
|
56
|
+
const uuidPattern =
|
|
57
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
58
|
+
assert.match(uuid, uuidPattern, 'Should match UUID v7 format');
|
|
59
|
+
|
|
60
|
+
// check length
|
|
61
|
+
assert.equal(uuid.length, 36);
|
|
62
|
+
|
|
63
|
+
// check that it contains hyphens in the right places
|
|
64
|
+
assert.equal(uuid[8], '-');
|
|
65
|
+
assert.equal(uuid[13], '-');
|
|
66
|
+
assert.equal(uuid[18], '-');
|
|
67
|
+
assert.equal(uuid[23], '-');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('generates unique UUIDs v7', () => {
|
|
71
|
+
const uuid1 = generateUUIDv7();
|
|
72
|
+
const uuid2 = generateUUIDv7();
|
|
73
|
+
const uuid3 = generateUUIDv7();
|
|
74
|
+
|
|
75
|
+
// all should be different
|
|
76
|
+
assert.notEqual(uuid1, uuid2);
|
|
77
|
+
assert.notEqual(uuid2, uuid3);
|
|
78
|
+
assert.notEqual(uuid1, uuid3);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('generates time-ordered UUIDs v7', () => {
|
|
82
|
+
const uuid1 = generateUUIDv7();
|
|
83
|
+
// small delay to ensure different timestamp
|
|
84
|
+
const delayPromise = new Promise((resolve) => setTimeout(resolve, 5));
|
|
85
|
+
return delayPromise.then(() => {
|
|
86
|
+
const uuid2 = generateUUIDv7();
|
|
87
|
+
|
|
88
|
+
// uuid v7 should be sortable by timestamp
|
|
89
|
+
// the first uuid should come before the second when compared as strings
|
|
90
|
+
assert.isTrue(
|
|
91
|
+
uuid1 < uuid2,
|
|
92
|
+
'Earlier UUID should be lexicographically smaller'
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('generates many unique UUIDs v7', () => {
|
|
98
|
+
const uuids = new Set();
|
|
99
|
+
const count = 1000;
|
|
100
|
+
|
|
101
|
+
for (let i = 0; i < count; i++) {
|
|
102
|
+
uuids.add(generateUUIDv7());
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// all should be unique
|
|
106
|
+
assert.equal(uuids.size, count, 'All generated UUIDs should be unique');
|
|
107
|
+
});
|
|
48
108
|
});
|
package/test/utils.test.ts
CHANGED
|
@@ -240,7 +240,11 @@ export const waitForCondition = async (
|
|
|
240
240
|
}
|
|
241
241
|
};
|
|
242
242
|
|
|
243
|
-
export const assertScreenshot = async (
|
|
243
|
+
export const assertScreenshot = async (
|
|
244
|
+
filename: string,
|
|
245
|
+
clip: Clip,
|
|
246
|
+
waitForNetwork: boolean = false
|
|
247
|
+
) => {
|
|
244
248
|
// detect if we're running in copilot's environment and use adaptive threshold
|
|
245
249
|
const isCopilotEnvironment = (window as any).isCopilotEnvironment;
|
|
246
250
|
const threshold = isCopilotEnvironment ? 1.0 : 0.1;
|
|
@@ -251,7 +255,8 @@ export const assertScreenshot = async (filename: string, clip: Clip) => {
|
|
|
251
255
|
`${filename}.png`,
|
|
252
256
|
clip,
|
|
253
257
|
exclude,
|
|
254
|
-
threshold
|
|
258
|
+
threshold,
|
|
259
|
+
waitForNetwork
|
|
255
260
|
);
|
|
256
261
|
} catch (error) {
|
|
257
262
|
if (error.message) {
|