@servicetitan/titan-chat-ui-common 3.0.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 (75) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/index.d.ts +5 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +5 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/models/__mocks__/support-chat.mock.d.ts +9 -0
  7. package/dist/models/__mocks__/support-chat.mock.d.ts.map +1 -0
  8. package/dist/models/__mocks__/support-chat.mock.js +82 -0
  9. package/dist/models/__mocks__/support-chat.mock.js.map +1 -0
  10. package/dist/models/chat-customizations.d.ts +42 -0
  11. package/dist/models/chat-customizations.d.ts.map +1 -0
  12. package/dist/models/chat-customizations.js +2 -0
  13. package/dist/models/chat-customizations.js.map +1 -0
  14. package/dist/models/file-descriptor.d.ts +7 -0
  15. package/dist/models/file-descriptor.d.ts.map +1 -0
  16. package/dist/models/file-descriptor.js +2 -0
  17. package/dist/models/file-descriptor.js.map +1 -0
  18. package/dist/models/index.d.ts +4 -0
  19. package/dist/models/index.d.ts.map +1 -0
  20. package/dist/models/index.js +4 -0
  21. package/dist/models/index.js.map +1 -0
  22. package/dist/models/support-chat.d.ts +76 -0
  23. package/dist/models/support-chat.d.ts.map +1 -0
  24. package/dist/models/support-chat.js +75 -0
  25. package/dist/models/support-chat.js.map +1 -0
  26. package/dist/stores/__tests__/chat-ui.store.test.d.ts +2 -0
  27. package/dist/stores/__tests__/chat-ui.store.test.d.ts.map +1 -0
  28. package/dist/stores/__tests__/chat-ui.store.test.js +424 -0
  29. package/dist/stores/__tests__/chat-ui.store.test.js.map +1 -0
  30. package/dist/stores/chat-ui-backend-echo.store.d.ts +13 -0
  31. package/dist/stores/chat-ui-backend-echo.store.d.ts.map +1 -0
  32. package/dist/stores/chat-ui-backend-echo.store.js +115 -0
  33. package/dist/stores/chat-ui-backend-echo.store.js.map +1 -0
  34. package/dist/stores/chat-ui-backend.store.d.ts +6 -0
  35. package/dist/stores/chat-ui-backend.store.d.ts.map +1 -0
  36. package/dist/stores/chat-ui-backend.store.js +3 -0
  37. package/dist/stores/chat-ui-backend.store.js.map +1 -0
  38. package/dist/stores/chat-ui.store.d.ts +153 -0
  39. package/dist/stores/chat-ui.store.d.ts.map +1 -0
  40. package/dist/stores/chat-ui.store.js +683 -0
  41. package/dist/stores/chat-ui.store.js.map +1 -0
  42. package/dist/stores/index.d.ts +4 -0
  43. package/dist/stores/index.d.ts.map +1 -0
  44. package/dist/stores/index.js +4 -0
  45. package/dist/stores/index.js.map +1 -0
  46. package/dist/utils/__tests__/text-utils.test.d.ts +2 -0
  47. package/dist/utils/__tests__/text-utils.test.d.ts.map +1 -0
  48. package/dist/utils/__tests__/text-utils.test.js +59 -0
  49. package/dist/utils/__tests__/text-utils.test.js.map +1 -0
  50. package/dist/utils/test-utils.d.ts +5 -0
  51. package/dist/utils/test-utils.d.ts.map +1 -0
  52. package/dist/utils/test-utils.js +17 -0
  53. package/dist/utils/test-utils.js.map +1 -0
  54. package/dist/utils/text-utils.d.ts +6 -0
  55. package/dist/utils/text-utils.d.ts.map +1 -0
  56. package/dist/utils/text-utils.js +33 -0
  57. package/dist/utils/text-utils.js.map +1 -0
  58. package/package.json +36 -0
  59. package/src/cypress.d.ts +10 -0
  60. package/src/index.ts +4 -0
  61. package/src/models/__mocks__/support-chat.mock.ts +105 -0
  62. package/src/models/chat-customizations.ts +50 -0
  63. package/src/models/file-descriptor.ts +6 -0
  64. package/src/models/index.ts +3 -0
  65. package/src/models/support-chat.ts +117 -0
  66. package/src/stores/__tests__/chat-ui.store.test.ts +531 -0
  67. package/src/stores/chat-ui-backend-echo.store.ts +98 -0
  68. package/src/stores/chat-ui-backend.store.ts +10 -0
  69. package/src/stores/chat-ui.store.ts +539 -0
  70. package/src/stores/index.ts +11 -0
  71. package/src/utils/__tests__/text-utils.test.ts +70 -0
  72. package/src/utils/test-utils.ts +22 -0
  73. package/src/utils/text-utils.ts +36 -0
  74. package/tsconfig.json +15 -0
  75. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,539 @@
1
+ import { EventEmitter } from 'events';
2
+ import { injectable, symbolToken } from '@servicetitan/react-ioc';
3
+ import { action, computed, makeObservable, observable, runInAction } from 'mobx';
4
+ import { nanoid } from 'nanoid';
5
+ import {
6
+ ChatConfiguration,
7
+ ChatCustomizations,
8
+ ChatEndReason,
9
+ ChatError,
10
+ ChatErrorOptions,
11
+ ChatMessageModelBase,
12
+ ChatMessageModelFile,
13
+ ChatMessageModelText,
14
+ ChatMessageModelTimeout,
15
+ ChatMessageModelWelcome,
16
+ ChatMessageState,
17
+ ChatParticipantIcon,
18
+ ChatParticipantModel,
19
+ ChatRunState,
20
+ ChatTimer,
21
+ } from '../models';
22
+ import { FileDescriptor } from '../models/file-descriptor';
23
+
24
+ export const symbolAgent = Symbol('SupportChatAgent');
25
+ export const symbolUser = Symbol('SupportChatUser');
26
+
27
+ export enum ChatUiEvent {
28
+ eventRun = 'eventRun',
29
+ eventDestroy = 'eventDestroy',
30
+ eventRestart = 'eventRestart',
31
+ eventCancel = 'eventCancel',
32
+ eventEndChat = 'eventEndChat',
33
+ eventRecover = 'eventRecover',
34
+ eventChasitorTyping = 'eventChasitorTyping',
35
+ eventTimerRestart = 'eventTimerRestart',
36
+ eventMessageSend = 'eventMessageSend',
37
+ eventMessageSendRetry = 'eventMessageSendRetry',
38
+ eventMessageSendFile = 'eventMessageSendFile',
39
+ eventMessageSendFileRetry = 'eventMessageSendFileRetry',
40
+ }
41
+
42
+ export type ChatUiEventListener<T = void> = (
43
+ resolve: (value: T | PromiseLike<T>) => void,
44
+ reject: (reason?: any) => void,
45
+ ...args: unknown[]
46
+ ) => void;
47
+
48
+ export interface IChatUiStore<T extends ChatCustomizations = ChatCustomizations> {
49
+ scrollCounter: number;
50
+
51
+ status: ChatRunState;
52
+ get isStarting(): boolean;
53
+ get isStarted(): boolean;
54
+ get isError(): boolean;
55
+ get isEnded(): boolean;
56
+ get customizations(): T;
57
+ timer?: ChatTimer;
58
+ endReason?: ChatEndReason;
59
+
60
+ participants: { [key: symbol]: ChatParticipantModel };
61
+ messages: ChatMessageModelBase[];
62
+ isAgentTyping: boolean;
63
+ isFilePickerEnabled: boolean;
64
+ file?: FileDescriptor;
65
+ error?: ChatError;
66
+
67
+ get agent(): ChatParticipantModel;
68
+ get user(): ChatParticipantModel;
69
+
70
+ on<T>(event: string, listener: ChatUiEventListener<T>): void;
71
+ off<T>(event: string, listener: ChatUiEventListener<T>): void;
72
+
73
+ setCustomizationContext(customizationContext?: T): void;
74
+ setIncomingSound(soundUrl: string): void;
75
+ setTimer(timer?: ChatTimer): void;
76
+ setFile(file: FileDescriptor | undefined): void;
77
+ setFilePickerEnabled(isEnabled: boolean): void;
78
+ setAgentTyping(typing: boolean): void;
79
+ setMessageState(message: ChatMessageModelBase, state: ChatMessageState, data?: any): void;
80
+ setMessages(messages: ChatMessageModelBase[]): void;
81
+ setAgentName(name: string): void;
82
+ setAgentIcon(icon: ChatParticipantIcon): void;
83
+ setChatError(chatError: ChatError): void;
84
+ setError<T = any>(message: string, options?: ChatErrorOptions<T>): void;
85
+ setStatus(status: ChatRunState): void;
86
+ resetError(runState: ChatRunState): void;
87
+ reset(resetStatus?: boolean): void;
88
+ resetFile(): void;
89
+
90
+ triggerScroll(): void;
91
+ configure(configuration?: ChatConfiguration): void;
92
+ run(configuration?: ChatConfiguration, data?: unknown): Promise<void>;
93
+ recover(): Promise<void>;
94
+ destroy(): Promise<void>;
95
+ chasitorTyping(isTyping: boolean): Promise<void>;
96
+ restartTimers(): Promise<void>;
97
+ restart(): Promise<void>;
98
+ cancelChat(): Promise<void>;
99
+ endChat(endReason?: ChatEndReason): Promise<void>;
100
+ sendMessageText(messageText: string): Promise<void>;
101
+ sendMessageRetry(message: ChatMessageModelBase): Promise<void>;
102
+ addMessage(isAgent: boolean, message: string, data?: any): void;
103
+ addMessageWelcome(message: string): void;
104
+ }
105
+ export const CHAT_UI_STORE_TOKEN = symbolToken<IChatUiStore>('CHAT_UI_STORE_TOKEN');
106
+
107
+ @injectable()
108
+ export class ChatUiStore<T extends ChatCustomizations> implements IChatUiStore<T> {
109
+ @observable isAgentTyping = false;
110
+
111
+ @observable status = ChatRunState.Offline;
112
+
113
+ @observable isFilePickerEnabled = false;
114
+
115
+ file?: FileDescriptor;
116
+
117
+ @observable.ref error?: ChatError;
118
+
119
+ @observable endReason?: ChatEndReason;
120
+
121
+ @observable messages: ChatMessageModelBase[] = [];
122
+
123
+ @observable currentFileMessage?: ChatMessageModelFile;
124
+
125
+ @observable scrollCounter = 1;
126
+
127
+ @observable timer?: ChatTimer;
128
+
129
+ @observable participants: { [key: symbol]: ChatParticipantModel } = {
130
+ [symbolAgent]: {
131
+ isAgent: true,
132
+ name: 'Agent',
133
+ icon: ChatParticipantIcon.Initials,
134
+ },
135
+ [symbolUser]: {
136
+ isAgent: false,
137
+ name: 'User',
138
+ icon: ChatParticipantIcon.Initials,
139
+ },
140
+ };
141
+
142
+ @computed
143
+ get agent() {
144
+ return this.participants[symbolAgent];
145
+ }
146
+
147
+ @computed
148
+ get user() {
149
+ return this.participants[symbolUser];
150
+ }
151
+
152
+ @computed
153
+ get isStarted() {
154
+ return this.status === ChatRunState.Started;
155
+ }
156
+
157
+ @computed
158
+ get isError() {
159
+ return this.status === ChatRunState.Error;
160
+ }
161
+
162
+ @computed
163
+ get isEnded() {
164
+ return this.status === ChatRunState.Ended;
165
+ }
166
+
167
+ @computed
168
+ get isStarting() {
169
+ return this.status === ChatRunState.Initializing;
170
+ }
171
+
172
+ @computed
173
+ get customizations() {
174
+ return this.customizationContext;
175
+ }
176
+
177
+ @observable
178
+ protected customizationContext: T = {} as T;
179
+ private eventEmitter = new EventEmitter();
180
+ private incomingMessageSound?: HTMLAudioElement;
181
+ private incomingMessageSoundPromise?: Promise<void>;
182
+
183
+ constructor() {
184
+ makeObservable(this);
185
+ }
186
+
187
+ on = <T>(event: string, listener: ChatUiEventListener<T>) => {
188
+ this.eventEmitter.on(event, listener);
189
+ };
190
+
191
+ off = <T>(event: string, listener: ChatUiEventListener<T>) => {
192
+ this.eventEmitter.off(event, listener);
193
+ };
194
+
195
+ @action
196
+ setCustomizationContext(customizationContext?: T) {
197
+ this.customizationContext = customizationContext ?? ({} as T);
198
+ this.triggerScroll();
199
+ }
200
+
201
+ setIncomingSound(soundUrl: string) {
202
+ if (this.incomingMessageSound) {
203
+ return;
204
+ }
205
+ this.incomingMessageSound = new Audio(soundUrl);
206
+ }
207
+
208
+ setFile(file?: FileDescriptor) {
209
+ this.file = file;
210
+ }
211
+
212
+ @action
213
+ setFilePickerEnabled(isEnabled: boolean) {
214
+ this.isFilePickerEnabled = isEnabled;
215
+ this.setFile();
216
+ this.triggerScroll();
217
+ }
218
+
219
+ @action
220
+ setTimer(timer?: ChatTimer) {
221
+ this.timer = timer;
222
+ }
223
+
224
+ @action
225
+ resetError(runState: ChatRunState) {
226
+ this.error = undefined;
227
+ this.setStatus(runState);
228
+ this.triggerScroll();
229
+ }
230
+
231
+ @action
232
+ setChatError(chatError: ChatError) {
233
+ this.error = chatError;
234
+ this.setStatus(ChatRunState.Error);
235
+ this.triggerScroll();
236
+ }
237
+
238
+ @action
239
+ setError<T = any>(message: string, options?: ChatErrorOptions<T>) {
240
+ this.setChatError(new ChatError(message, options));
241
+ }
242
+
243
+ @action
244
+ setStatus(status: ChatRunState) {
245
+ this.status = status;
246
+ }
247
+
248
+ @action
249
+ setMessages(messages: ChatMessageModelBase[]) {
250
+ this.messages = messages;
251
+ this.triggerScroll();
252
+ }
253
+
254
+ @action
255
+ setAgentName(name: string) {
256
+ this.agent.name = name;
257
+ }
258
+
259
+ @action
260
+ setAgentIcon(icon: ChatParticipantIcon) {
261
+ this.agent.icon = icon;
262
+ }
263
+
264
+ @action
265
+ setAgentTyping(isAgentTyping: boolean) {
266
+ this.isAgentTyping = isAgentTyping;
267
+ this.triggerScroll();
268
+ }
269
+
270
+ @action
271
+ setEndReason(reason?: ChatEndReason) {
272
+ this.endReason = reason;
273
+ this.triggerScroll();
274
+ }
275
+
276
+ @action
277
+ setEndedChatState(endReason: ChatEndReason) {
278
+ this.setEndReason(endReason);
279
+ this.setStatus(ChatRunState.Ended);
280
+ this.setAgentTyping(false);
281
+ this.resetFile();
282
+ this.triggerScroll();
283
+ }
284
+
285
+ @action
286
+ configure(configuration?: ChatConfiguration) {
287
+ if (!configuration) {
288
+ return;
289
+ }
290
+ if (configuration.agentName) {
291
+ this.setAgentName(configuration.agentName);
292
+ }
293
+ if (configuration.agentIcon) {
294
+ this.setAgentIcon(configuration.agentIcon);
295
+ }
296
+ }
297
+
298
+ async run(configuration?: ChatConfiguration, data?: unknown) {
299
+ this.configure(configuration);
300
+ if (!this.eventEmitter.listeners(ChatUiEvent.eventRun).length) {
301
+ throw new Error("No listeners for ChatUiEvent.eventRun ('eventRun')");
302
+ }
303
+ await this.emitAsync(ChatUiEvent.eventRun, data);
304
+ }
305
+
306
+ async recover() {
307
+ await this.emitAsync(ChatUiEvent.eventRecover, this.error);
308
+ }
309
+
310
+ async destroy() {
311
+ this.reset();
312
+ await this.emitAsync(ChatUiEvent.eventDestroy);
313
+ }
314
+
315
+ async restart() {
316
+ this.reset();
317
+ await this.emitAsync(ChatUiEvent.eventRestart);
318
+ }
319
+
320
+ async cancelChat() {
321
+ await this.emitAsync(ChatUiEvent.eventCancel);
322
+ }
323
+
324
+ async endChat(endReason?: ChatEndReason) {
325
+ await this.emitAsync(ChatUiEvent.eventEndChat, endReason);
326
+ }
327
+
328
+ async chasitorTyping(isTyping: boolean) {
329
+ await this.emitAsync(ChatUiEvent.eventChasitorTyping, isTyping);
330
+ }
331
+
332
+ @action
333
+ async sendMessageText(message: string) {
334
+ if (this.isFilePickerEnabled && this.file) {
335
+ // Send text message with file
336
+ runInAction(() => {
337
+ this.isFilePickerEnabled = false;
338
+ this.currentFileMessage = this.addMessageFile(this.file!);
339
+ });
340
+ let messageModel: ChatMessageModelText | undefined;
341
+ if (message.trim() !== '') {
342
+ messageModel = this.addMessage(false, message);
343
+ }
344
+ await this.emitAsync(
345
+ ChatUiEvent.eventMessageSendFile,
346
+ this.currentFileMessage,
347
+ this.file,
348
+ messageModel
349
+ );
350
+ } else {
351
+ // Send plain text message
352
+ await this.sendMessageTextInternal(message);
353
+ }
354
+ }
355
+
356
+ @action
357
+ async sendMessageRetry(messageModel: ChatMessageModelBase) {
358
+ this.resetError(ChatRunState.Started);
359
+ this.setMessageState(messageModel, ChatMessageState.Delivering);
360
+ await this.emitAsync(ChatUiEvent.eventMessageSendRetry, messageModel);
361
+ await this.restartTimers();
362
+ }
363
+
364
+ @action
365
+ async sendMessageFileRetry(messageModel: ChatMessageModelFile) {
366
+ this.resetError(ChatRunState.Started);
367
+ this.setMessageState(messageModel, ChatMessageState.Delivering);
368
+ await this.emitAsync(ChatUiEvent.eventMessageSendFileRetry, messageModel);
369
+ }
370
+
371
+ getParticipant(participantKey: symbol): ChatParticipantModel {
372
+ const participant = this.participants[participantKey];
373
+ return { ...participant };
374
+ }
375
+
376
+ @action
377
+ addMessage(isAgent: boolean, message: string, data?: any) {
378
+ return this.addMessageInternal<ChatMessageModelText>({
379
+ type: 'message',
380
+ state: ChatMessageState.Delivering,
381
+ participant: this.getParticipant(isAgent ? symbolAgent : symbolUser),
382
+ data,
383
+ message,
384
+ });
385
+ }
386
+
387
+ @action
388
+ addMessageTimeout() {
389
+ const isNewChat =
390
+ this.messages.length === 0 || this.messages.every(m => m.type === 'welcome');
391
+ if (isNewChat) {
392
+ return;
393
+ }
394
+ return this.addMessageInternal<ChatMessageModelTimeout>({
395
+ type: 'timeout',
396
+ participant: this.agent,
397
+ state: ChatMessageState.Delivered,
398
+ });
399
+ }
400
+
401
+ @action
402
+ addMessageWelcome(message: string) {
403
+ return this.addMessageInternal<ChatMessageModelWelcome>({
404
+ type: 'welcome',
405
+ state: ChatMessageState.Delivered,
406
+ participant: this.getParticipant(symbolAgent),
407
+ message,
408
+ });
409
+ }
410
+
411
+ @action
412
+ addMessageFile(file: FileDescriptor) {
413
+ return this.addMessageInternal<ChatMessageModelFile>({
414
+ fileName: file.displayName ?? 'attachment',
415
+ type: 'file',
416
+ state: ChatMessageState.Delivering,
417
+ participant: this.user,
418
+ });
419
+ }
420
+
421
+ @action
422
+ async restartTimers() {
423
+ this.removeMessageByType('timeout');
424
+ await this.emitAsync(ChatUiEvent.eventTimerRestart);
425
+ }
426
+
427
+ @action
428
+ setMessageState(message: ChatMessageModelBase, state: ChatMessageState, data?: any) {
429
+ const foundMessage = this.messages.find(m => m.id === message.id);
430
+ if (foundMessage) {
431
+ foundMessage.state = state;
432
+ if (data) {
433
+ foundMessage.data = data;
434
+ }
435
+ }
436
+ }
437
+
438
+ @action
439
+ resetFile() {
440
+ this.currentFileMessage = undefined;
441
+ this.file = undefined;
442
+ this.isFilePickerEnabled = false;
443
+ this.triggerScroll();
444
+ }
445
+
446
+ @action
447
+ reset(resetStatus = true) {
448
+ this.setMessages([]);
449
+ if (resetStatus) {
450
+ this.status = ChatRunState.Offline;
451
+ }
452
+ this.error = undefined;
453
+ this.isAgentTyping = false;
454
+ this.endReason = undefined;
455
+ this.resetFile();
456
+ }
457
+
458
+ getLastFailedMessages() {
459
+ const result: ChatMessageModelText[] = [];
460
+ for (let i = this.messages.length - 1; i >= 0; i--) {
461
+ const message = this.messages[i];
462
+ if (message.type === 'message' && !message.participant.isAgent) {
463
+ if (message.state !== ChatMessageState.Failed) {
464
+ break;
465
+ }
466
+ result.push(message as ChatMessageModelText);
467
+ }
468
+ }
469
+ return result;
470
+ }
471
+
472
+ triggerScroll() {
473
+ setTimeout(() => {
474
+ runInAction(() => {
475
+ this.scrollCounter++;
476
+ });
477
+ }, 0);
478
+ }
479
+
480
+ protected async emitAsync<T>(event: string, ...args: unknown[]) {
481
+ if (!this.eventEmitter.listenerCount(event)) {
482
+ return Promise.resolve();
483
+ }
484
+ return new Promise<T>((resolve, reject) => {
485
+ this.eventEmitter.emit(event, resolve, reject, ...args);
486
+ });
487
+ }
488
+
489
+ protected addMessageInternal<T extends ChatMessageModelBase>(
490
+ message: Omit<T, 'id' | 'timestamp'>
491
+ ): T {
492
+ if (message.type === 'timeout') {
493
+ this.removeMessageByType('timeout'); // Remove previous timeout messages
494
+ } else {
495
+ this.restartTimers().then(() => {});
496
+ }
497
+ this.setMessages([...this.messages, this.createMessage<T>(message)]);
498
+ if (message.participant.isAgent) {
499
+ this.beepIncoming().catch(() => {});
500
+ }
501
+ return this.messages.at(-1) as T;
502
+ }
503
+
504
+ protected removeMessageByType(type: string) {
505
+ if (this.messages.some(m => m.type === type)) {
506
+ this.setMessages(this.messages.filter(m => m.type !== type));
507
+ }
508
+ }
509
+
510
+ private async sendMessageTextInternal(message: string) {
511
+ if (message.trim() === '') {
512
+ return;
513
+ }
514
+ const messageModel = this.addMessage(false, message);
515
+ await this.emitAsync(ChatUiEvent.eventMessageSend, messageModel);
516
+ }
517
+
518
+ private createMessage<T extends ChatMessageModelBase>(rest: Omit<T, 'id' | 'timestamp'>) {
519
+ return {
520
+ id: nanoid(),
521
+ timestamp: new Date(),
522
+ ...rest,
523
+ } as T;
524
+ }
525
+
526
+ private async beepIncoming() {
527
+ if (!this.incomingMessageSound) {
528
+ return;
529
+ }
530
+ try {
531
+ if (!this.incomingMessageSoundPromise) {
532
+ this.incomingMessageSoundPromise = this.incomingMessageSound.play();
533
+ }
534
+ await this.incomingMessageSoundPromise;
535
+ } finally {
536
+ this.incomingMessageSoundPromise = undefined;
537
+ }
538
+ }
539
+ }
@@ -0,0 +1,11 @@
1
+ export {
2
+ CHAT_UI_STORE_TOKEN,
3
+ IChatUiStore,
4
+ ChatUiStore,
5
+ ChatUiEvent,
6
+ symbolUser,
7
+ symbolAgent,
8
+ ChatUiEventListener,
9
+ } from './chat-ui.store';
10
+ export { ChatUiBackendEchoStore } from './chat-ui-backend-echo.store';
11
+ export { CHAT_UI_BACKEND_STORE_TOKEN, IChatUiBackendStore } from './chat-ui-backend.store';
@@ -0,0 +1,70 @@
1
+ import { expect } from '@jest/globals';
2
+ import {
3
+ extractFilenameAndExt,
4
+ formatChatMessageDate,
5
+ getFirstName,
6
+ getNameInitials,
7
+ getNameInitialsFirst,
8
+ } from '../text-utils';
9
+
10
+ describe('text-utils', () => {
11
+ test('should get initials from name', () => {
12
+ expect(getNameInitials('')).toEqual('');
13
+ expect(getNameInitials('A')).toEqual('A');
14
+ expect(getNameInitials('AB')).toEqual('A');
15
+ expect(getNameInitials('abcd')).toEqual('A');
16
+ expect(getNameInitials(' abcd ')).toEqual('A');
17
+ expect(getNameInitials(' a b c ')).toEqual('AB');
18
+ expect(getNameInitials('abcd ef')).toEqual('AE');
19
+ expect(getNameInitials('a b c d')).toEqual('AB');
20
+ });
21
+
22
+ test('should extract filename and ext', () => {
23
+ expect(extractFilenameAndExt('')).toEqual(['', '']);
24
+ expect(extractFilenameAndExt('a')).toEqual(['a', '']);
25
+ expect(extractFilenameAndExt('a.')).toEqual(['a.', '']);
26
+ expect(extractFilenameAndExt('a.b')).toEqual(['a.', 'b']);
27
+ expect(extractFilenameAndExt('a.b.c')).toEqual(['a.b.', 'c']);
28
+ expect(extractFilenameAndExt('aaaa.bbbb.cccc')).toEqual(['aaaa.bbbb.', 'cccc']);
29
+ });
30
+
31
+ test('should getFirstName', () => {
32
+ expect(getFirstName('')).toEqual('');
33
+ expect(getFirstName('A')).toEqual('A');
34
+ expect(getFirstName('AB')).toEqual('AB');
35
+ expect(getFirstName('abcd')).toEqual('abcd');
36
+ expect(getFirstName(' abcd ')).toEqual('abcd');
37
+ expect(getFirstName(' a b c ')).toEqual('a');
38
+ expect(getFirstName('abcd ef')).toEqual('abcd');
39
+ expect(getFirstName('a b c d')).toEqual('a');
40
+ });
41
+
42
+ test('should getNameInitialsFirst', () => {
43
+ expect(getNameInitials('')).toEqual('');
44
+ expect(getNameInitials('A')).toEqual('A');
45
+ expect(getNameInitials('AB')).toEqual('A');
46
+ expect(getNameInitials('abcd')).toEqual('A');
47
+ expect(getNameInitials(' abcd ')).toEqual('A');
48
+ expect(getNameInitials(' a b c ')).toEqual('AB');
49
+ expect(getNameInitials('abcd ef')).toEqual('AE');
50
+ expect(getNameInitials('a b c d')).toEqual('AB');
51
+ });
52
+
53
+ test('should formatChatMessageDate', () => {
54
+ const date = new Date('2023-10-01T12:00:00Z');
55
+ expect(formatChatMessageDate(date)).toEqual('12:00 PM');
56
+ expect(formatChatMessageDate(new Date('2023-10-01T00:00:00Z'))).toEqual('12:00 AM');
57
+ expect(formatChatMessageDate(new Date('2023-10-01T23:59:59Z'))).toEqual('11:59 PM');
58
+ });
59
+
60
+ test('should getNameInitialsFirst', () => {
61
+ expect(getNameInitialsFirst('')).toEqual('');
62
+ expect(getNameInitialsFirst('A')).toEqual('A');
63
+ expect(getNameInitialsFirst('AB')).toEqual('A');
64
+ expect(getNameInitialsFirst('abcd')).toEqual('A');
65
+ expect(getNameInitialsFirst(' abcd ')).toEqual('A');
66
+ expect(getNameInitialsFirst(' a b c ')).toEqual('A');
67
+ expect(getNameInitialsFirst('abcd ef')).toEqual('A');
68
+ expect(getNameInitialsFirst('a b c d')).toEqual('A');
69
+ });
70
+ });
@@ -0,0 +1,22 @@
1
+ import { Container } from '@servicetitan/react-ioc';
2
+
3
+ type Newable<T> = new (...args: never[]) => T;
4
+
5
+ export const initTestContainer = (
6
+ serviceIdentifier: Newable<unknown> | Newable<unknown>[],
7
+ initDependenciesFn: (container: Container) => void
8
+ ) => {
9
+ const rootContainer = new Container();
10
+ if (Array.isArray(serviceIdentifier)) {
11
+ serviceIdentifier.forEach(identifier => rootContainer.bind(identifier).toSelf());
12
+ } else {
13
+ rootContainer.bind(serviceIdentifier).toSelf();
14
+ }
15
+
16
+ return () => {
17
+ const container = new Container();
18
+ container.parent = rootContainer;
19
+ initDependenciesFn(container);
20
+ return container;
21
+ };
22
+ };
@@ -0,0 +1,36 @@
1
+ export function formatChatMessageDate(date: Date) {
2
+ return date.toLocaleTimeString('en-US', {
3
+ hour: '2-digit',
4
+ minute: '2-digit',
5
+ });
6
+ }
7
+
8
+ export const getFirstName = (name: string): string => {
9
+ const parts = name.trim().split(' ');
10
+ return parts[0];
11
+ };
12
+
13
+ export const getNameInitials = (name: string): string => {
14
+ const parts = name.trim().split(' ');
15
+ return parts
16
+ .filter(Boolean)
17
+ .splice(0, 2)
18
+ .map(p => p[0]!.toUpperCase())
19
+ .join('');
20
+ };
21
+
22
+ export const getNameInitialsFirst = (name: string): string => {
23
+ const initials = getNameInitials(name);
24
+ return initials.length > 0 ? initials[0] : '';
25
+ };
26
+
27
+ export const extractFilenameAndExt = (fileName: string): [string, string] => {
28
+ const lastIndex = fileName.lastIndexOf('.');
29
+ if (lastIndex === -1) {
30
+ return [fileName, ''];
31
+ }
32
+ return [
33
+ fileName.substring(0, lastIndex) + '.',
34
+ fileName.substring(lastIndex + 1, fileName.length),
35
+ ];
36
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "extends": "@servicetitan/startup/tsconfig/base",
3
+ "compilerOptions": {
4
+ "composite": true,
5
+ "outDir": "dist",
6
+ "rootDir": "src",
7
+ "moduleResolution": "bundler"
8
+ },
9
+ "include": [
10
+ "src/**/*"
11
+ ],
12
+ "exclude": [
13
+ "node_modules"
14
+ ]
15
+ }