@inploi/plugin-chatbot 1.0.7 → 2.1.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 (41) hide show
  1. package/.env +0 -1
  2. package/.env.example +0 -1
  3. package/.env.test +2 -0
  4. package/CHANGELOG.md +17 -0
  5. package/index.html +2 -1
  6. package/package.json +21 -8
  7. package/playwright.config.ts +82 -0
  8. package/public/mockServiceWorker.js +4 -9
  9. package/src/chatbot.api.ts +14 -14
  10. package/src/chatbot.constants.ts +3 -0
  11. package/src/chatbot.css +8 -15
  12. package/src/chatbot.dom.ts +10 -5
  13. package/src/chatbot.idb.ts +17 -0
  14. package/src/chatbot.state.ts +78 -144
  15. package/src/chatbot.ts +25 -35
  16. package/src/chatbot.utils.ts +27 -9
  17. package/src/index.dev.ts +7 -6
  18. package/src/interpreter/interpreter.ts +28 -20
  19. package/src/mocks/browser.ts +2 -2
  20. package/src/mocks/example.flows.ts +56 -18
  21. package/src/mocks/handlers.ts +37 -8
  22. package/src/style/palette.test.ts +20 -0
  23. package/src/style/palette.ts +69 -0
  24. package/src/ui/chat-bubble.tsx +20 -5
  25. package/src/ui/chat-input/chat-input.boolean.tsx +10 -5
  26. package/src/ui/chat-input/chat-input.file.tsx +8 -6
  27. package/src/ui/chat-input/chat-input.multiple-choice.tsx +52 -27
  28. package/src/ui/chat-input/chat-input.text.tsx +23 -17
  29. package/src/ui/chat-input/chat-input.tsx +47 -23
  30. package/src/ui/chatbot-header.tsx +34 -28
  31. package/src/ui/chatbot.tsx +83 -42
  32. package/src/ui/input-error.tsx +25 -31
  33. package/src/ui/job-application-content.tsx +68 -46
  34. package/src/ui/job-application-messages.tsx +42 -34
  35. package/src/ui/send-button.tsx +1 -1
  36. package/src/ui/typing-indicator.tsx +1 -1
  37. package/src/ui/useChatService.ts +18 -33
  38. package/src/ui/useFocus.ts +10 -0
  39. package/tests/integration.spec.ts +19 -0
  40. package/tests/test.ts +22 -0
  41. package/tsconfig.json +1 -1
@@ -1,11 +1,11 @@
1
- import { ApiClient, Logger } from '@inploi/sdk';
2
- import { useLayoutEffect, useRef } from 'react';
1
+ import { AnalyticsService, ApiClient, Logger } from '@inploi/sdk';
2
+ import { AnimatePresence } from 'framer-motion';
3
+ import { useEffect, useLayoutEffect } from 'react';
3
4
  import { match } from 'ts-pattern';
4
- import { JobApplication } from '~/chatbot.api';
5
+ import { StartedJobApplication, application, inputHeight, viewState } from '~/chatbot.state';
5
6
  import { submissionsToPayload } from '~/chatbot.utils';
6
7
 
7
8
  import { ERROR_MESSAGES } from '../chatbot.constants';
8
- import { useApplicationLocalState, useLocalState } from '../chatbot.state';
9
9
  import { createFlowInterpreter } from '../interpreter/interpreter';
10
10
  import { ChatInput } from './chat-input/chat-input';
11
11
  import { JobApplicationMessages } from './job-application-messages';
@@ -13,45 +13,62 @@ import { useChatService } from './useChatService';
13
13
 
14
14
  type JobApplicationContentProps = {
15
15
  apiClient: ApiClient;
16
- logger?: Logger;
17
- application: JobApplication;
16
+ logger: Logger;
17
+ currentApplication: StartedJobApplication;
18
+ analytics: AnalyticsService;
18
19
  };
19
20
 
20
- export const JobApplicationContent = ({ application, logger, apiClient }: JobApplicationContentProps) => {
21
- const currentApplicationLocalState = useApplicationLocalState(application);
22
- const chatRef = useRef<HTMLDivElement>(null);
23
- const { chatService, isBotTyping, onSubmitSuccessFn, scrollToEnd } = useChatService({ application, chatRef, logger });
21
+ export const JobApplicationContent = ({
22
+ currentApplication,
23
+ logger,
24
+ apiClient,
25
+ analytics,
26
+ }: JobApplicationContentProps) => {
27
+ const { chatRef, chatService, isBotTyping, onSubmitSuccessFn, scrollToEnd } = useChatService();
28
+
29
+ const view = viewState.value;
30
+ useLayoutEffect(() => {
31
+ // This significantly improves performance for maximising the view
32
+ if (view === 'maximised') scrollToEnd({ behavior: 'instant' });
33
+ }, [scrollToEnd, view]);
34
+
35
+ useEffect(() => {
36
+ scrollToEnd({ behavior: 'smooth' });
37
+ }, [currentApplication.data.messages, scrollToEnd]);
24
38
 
25
39
  useLayoutEffect(() => {
26
- const { getApplicationLocalState, setInput, updateApplicationLocalState, updateApplicationCurrentNode } =
27
- useLocalState.getState();
28
- const applicationLocalState = getApplicationLocalState(application);
29
40
  scrollToEnd({ behavior: 'instant' });
30
- if (applicationLocalState?.isFinished) return;
41
+
42
+ const { state, application: currentApplication } = application.current$.value;
43
+ if (state !== 'loaded' || currentApplication.data.isFinished) return;
31
44
 
32
45
  const { interpret, abort } = createFlowInterpreter({
33
- flow: application.flow.nodes,
46
+ flow: currentApplication.flow.nodes,
34
47
  chatService,
35
- getSubmissions: () => getApplicationLocalState(application)?.submissions,
48
+ getSubmissions: () => application.current$.peek().application?.data.submissions,
36
49
  beforeStart: async node => {
37
- setInput({ application, data: null });
50
+ application.setInput(undefined);
38
51
 
39
- const isResuming = !node.isHead;
40
- if (isResuming) {
41
- await chatService.send({
42
- message: {
43
- type: 'system',
44
- text: 'Restored in progress application',
45
- variant: 'info',
52
+ const fromBeginning = currentApplication.data.messages.length === 0;
53
+ if (fromBeginning) {
54
+ analytics.log({
55
+ event: 'APPLY_START',
56
+ properties: { job_id: currentApplication.job.id },
57
+ customProperties: {
58
+ flow_id: currentApplication.flow.id,
46
59
  },
47
60
  });
61
+ } else {
62
+ // We restart the last node.
63
+ const restoredFromId = node.id;
64
+ application.removeLastGroupMessagesById(restoredFromId);
48
65
  }
49
66
  },
50
67
  onInterpret: node => {
51
- updateApplicationCurrentNode({ application, currentNodeId: node.id });
68
+ application.setCurrentNodeId(node.id);
52
69
  },
53
70
  onFlowEnd: async lastNode => {
54
- updateApplicationLocalState({ application, data: { isFinished: true } });
71
+ application.markAsFinished();
55
72
  return match(lastNode)
56
73
  .with({ type: 'abandon-flow' }, () => {
57
74
  chatService.send({
@@ -60,15 +77,16 @@ export const JobApplicationContent = ({ application, logger, apiClient }: JobApp
60
77
  text: 'Application ended',
61
78
  variant: 'success',
62
79
  },
80
+ groupId: 'system',
63
81
  });
64
82
  })
65
83
  .with({ type: 'complete-flow' }, async () => {
66
- const submissions = getApplicationLocalState(application)?.submissions;
84
+ const submissions = application.current$.peek().application?.data.submissions;
67
85
  if (!submissions) throw new Error(ERROR_MESSAGES.no_submissions);
68
86
 
69
- const response = await apiClient.fetch(`/flow/job/${application.job.id}`, {
87
+ const response = await apiClient.fetch(`/flow/apply`, {
70
88
  method: 'POST',
71
- body: JSON.stringify(submissionsToPayload(submissions)),
89
+ body: JSON.stringify(submissionsToPayload({ application: currentApplication, submissions })),
72
90
  });
73
91
 
74
92
  match(response)
@@ -79,44 +97,48 @@ export const JobApplicationContent = ({ application, logger, apiClient }: JobApp
79
97
  text: 'Application submitted',
80
98
  variant: 'success',
81
99
  },
100
+ groupId: 'system',
82
101
  });
83
102
  })
84
103
  .otherwise(response => {
85
- logger?.error(response);
104
+ logger.error(response);
86
105
  chatService.send({
87
106
  message: {
88
107
  type: 'system',
89
108
  text: 'Error submitting application',
90
109
  variant: 'error',
91
110
  },
111
+ groupId: 'system',
92
112
  });
93
113
  });
94
114
  })
95
115
  .otherwise(() => {
96
- logger?.error(ERROR_MESSAGES.invalid_end_node, lastNode);
116
+ logger.error(ERROR_MESSAGES.invalid_end_node, lastNode);
97
117
  });
98
118
  },
99
119
  });
100
120
 
101
- interpret(applicationLocalState?.currentNodeId);
121
+ interpret(currentApplication.data.currentNodeId);
102
122
 
103
123
  return abort;
104
- }, [apiClient, application, chatService, logger, scrollToEnd]);
124
+ }, [analytics, apiClient, chatService, logger, scrollToEnd]);
105
125
 
106
126
  return (
107
- <div
108
- ref={chatRef}
109
- class="relative max-w-full flex-grow flex flex-col hide-scrollbars overflow-y-scroll"
110
- style={{ WebkitOverflowScrolling: 'touch' }}
111
- >
112
- <JobApplicationMessages isBotTyping={isBotTyping} messages={currentApplicationLocalState?.messages ?? []} />
113
- <div class="sticky bottom-0 w-full p-2 border-t border-neutral-5 rounded-b-3xl bg-neutral-4/80 backdrop-blur-md backdrop-saturate-150">
114
- <ChatInput
115
- onInputChange={() => scrollToEnd({ behavior: 'smooth' })}
116
- application={application}
117
- onSubmit={onSubmitSuccessFn}
118
- />
127
+ <>
128
+ <div
129
+ ref={chatRef}
130
+ className="relative max-w-full flex-grow flex flex-col hide-scrollbars overflow-y-scroll"
131
+ style={{ WebkitOverflowScrolling: 'touch', paddingBottom: inputHeight.value }}
132
+ >
133
+ <AnimatePresence>
134
+ <JobApplicationMessages isBotTyping={isBotTyping} messages={currentApplication.data.messages} />
135
+ </AnimatePresence>
119
136
  </div>
120
- </div>
137
+ <ChatInput
138
+ input={currentApplication.data.currentInput}
139
+ onInputChange={() => scrollToEnd({ behavior: 'smooth' })}
140
+ onSubmit={onSubmitSuccessFn}
141
+ />
142
+ </>
121
143
  );
122
144
  };
@@ -1,8 +1,10 @@
1
+ import { AnimatePresence } from 'framer-motion';
1
2
  import { P, match } from 'ts-pattern';
2
3
 
3
4
  import { ChatMessage } from '../chatbot.state';
4
5
  import { ChatBubble } from './chat-bubble';
5
6
  import { FileThumbnail } from './chat-input/chat-input.file';
7
+ // import { AnimatePresence } from './motion/animate-presence';
6
8
  import { TypingIndicator } from './typing-indicator';
7
9
 
8
10
  type JobApplicationMessagesProps = {
@@ -17,40 +19,46 @@ const authorToSide = {
17
19
 
18
20
  export const JobApplicationMessages = ({ messages, isBotTyping }: JobApplicationMessagesProps) => {
19
21
  return (
20
- <div class="p-2 pt-[calc(var(--header-height)+0.5rem)] flex flex-col gap-2 flex-grow">
21
- {messages.map((message, i) =>
22
- match(message)
23
- .with({ type: 'system' }, message => (
24
- <p key={i} class="uppercase text-[10px] text-neutral-10 tracking-widest text-center py-2">
25
- {message.text}
26
- </p>
27
- ))
28
- .with({ type: 'text', author: P.union('bot', 'user') }, message => {
29
- return (
30
- <ChatBubble key={i} side={authorToSide[message.author]}>
31
- {message.text}
32
- </ChatBubble>
33
- );
34
- })
35
- .with({ type: 'image' }, image => (
36
- <img
37
- key={i}
38
- class="max-w-[min(100%,24rem)] w-full rounded-2xl shadow-surface-md"
39
- src={image.url}
40
- style={{ aspectRatio: image.width / image.height }}
41
- />
42
- ))
43
- .with({ type: 'file' }, file => {
44
- return (
45
- <FileThumbnail
46
- class={file.author === 'bot' ? 'self-start' : 'self-end'}
47
- file={{ name: file.fileName, sizeKb: file.fileSizeKb }}
48
- />
49
- );
50
- })
51
- .exhaustive(),
52
- )}
22
+ <ol
23
+ aria-label="Chat messages"
24
+ class="p-2 justify-end pt-[calc(var(--header-height)+1rem)] flex flex-col gap-2 flex-grow"
25
+ >
26
+ <AnimatePresence initial={false}>
27
+ {messages.map((message, i) => (
28
+ <li class="flex" key={i}>
29
+ {match(message)
30
+ .with({ type: 'system' }, message => (
31
+ <p class="uppercase w-full drop-shadow-[0_1.5px_white] text-[10px] text-neutral-8 select-none tracking-widest text-center py-2">
32
+ {message.text}
33
+ </p>
34
+ ))
35
+ .with({ type: 'text', author: P.union('bot', 'user') }, message => {
36
+ return (
37
+ <ChatBubble key={i} side={authorToSide[message.author]}>
38
+ {message.text}
39
+ </ChatBubble>
40
+ );
41
+ })
42
+ .with({ type: 'image' }, image => (
43
+ <img
44
+ class="max-w-[min(100%,24rem)] w-full rounded-2xl shadow-surface-md"
45
+ src={image.url}
46
+ style={{ aspectRatio: image.width / image.height }}
47
+ />
48
+ ))
49
+ .with({ type: 'file' }, file => {
50
+ return (
51
+ <FileThumbnail
52
+ class={file.author === 'bot' ? '' : 'ml-auto'}
53
+ file={{ name: file.fileName, sizeKb: file.fileSizeKb }}
54
+ />
55
+ );
56
+ })
57
+ .exhaustive()}
58
+ </li>
59
+ ))}
60
+ </AnimatePresence>
53
61
  <aside aria-hidden>{isBotTyping && <TypingIndicator />}</aside>
54
- </div>
62
+ </ol>
55
63
  );
56
64
  };
@@ -4,7 +4,7 @@ import { ComponentProps } from 'preact';
4
4
  export const SendButton = ({ class: className, ...props }: ComponentProps<'button'>) => (
5
5
  <button
6
6
  class={clsx(
7
- 'p-2 flex-shrink-0 bg-accent-9 active:bg-accent-12 active:text-accent-6 rounded-full text-lowest pointer-coarse:touch-hitbox disabled:opacity-50 disabled:cursor-not-allowed',
7
+ 'p-2 flex-shrink-0 bg-accent-7 active:bg-accent-10 active:text-accent-4 rounded-full text-lowest pointer-coarse:touch-hitbox disabled:opacity-50 disabled:cursor-not-allowed',
8
8
  className,
9
9
  )}
10
10
  {...props}
@@ -5,7 +5,7 @@ export const TypingIndicator = ({ className, ...props }: ComponentProps<'div'>)
5
5
  return (
6
6
  <div class={clsx('flex gap-1 p-4', className)} {...props}>
7
7
  {Array.from({ length: 3 }, (_, i) => (
8
- <div class="h-1.5 w-1.5 rounded-full bg-accent-9 animate-bounce" style={{ animationDelay: `${-i * 200}ms` }} />
8
+ <div class="h-1.5 w-1.5 rounded-full bg-accent-7 animate-bounce" style={{ animationDelay: `${-i * 200}ms` }} />
9
9
  ))}
10
10
  </div>
11
11
  );
@@ -1,21 +1,15 @@
1
- import { Logger } from '@inploi/sdk';
2
- import { Ref, useMemo, useState } from 'preact/hooks';
1
+ import { useMemo, useRef, useState } from 'preact/hooks';
3
2
  import { match } from 'ts-pattern';
4
- import { JobApplication } from '~/chatbot.api';
5
- import { useLocalState } from '~/chatbot.state';
3
+ import { application } from '~/chatbot.state';
4
+ import { AbortedError } from '~/chatbot.utils';
6
5
  import { ChatService } from '~/interpreter/interpreter';
7
6
 
8
7
  import { SubmitSuccessFn } from './chat-input/chat-input';
9
8
 
10
9
  const TYPING_SPEED_MS_PER_CHARACTER = 25;
11
10
 
12
- type UseChatServiceParams = {
13
- chatRef: Ref<HTMLDivElement>;
14
- application: JobApplication;
15
- logger?: Logger;
16
- };
17
-
18
- export const useChatService = ({ logger, application, chatRef }: UseChatServiceParams) => {
11
+ export const useChatService = () => {
12
+ const chatRef = useRef<HTMLDivElement>(null);
19
13
  const [isBotTyping, setIsBotTyping] = useState(false);
20
14
  const [onSubmitSuccessFn, setOnSubmitSuccessFn] = useState<SubmitSuccessFn>(() => () => {});
21
15
  const scrollToEnd = useMemo(
@@ -25,18 +19,14 @@ export const useChatService = ({ logger, application, chatRef }: UseChatServiceP
25
19
  );
26
20
 
27
21
  const chatService = useMemo(() => {
28
- const { setInput, updateSubmission, addMessage } = useLocalState.getState();
29
22
  const chatService: ChatService = {
30
- send: async ({ message, signal }) => {
23
+ send: async ({ message, signal, groupId }) => {
31
24
  await match(message)
32
25
  /** Delay sending and add typing indicator if bot is sending a message */
33
26
  .with({ author: 'bot', type: 'text' }, async message => {
34
- if (signal?.aborted) {
35
- logger?.info(`Aborted sending message`);
36
- return;
37
- }
27
+ if (signal?.aborted) throw new AbortedError();
38
28
  setIsBotTyping(true);
39
- const typingTime = message.text.length * TYPING_SPEED_MS_PER_CHARACTER;
29
+ const typingTime = Math.max(20, message.text.length) * TYPING_SPEED_MS_PER_CHARACTER;
40
30
  await new Promise(resolve => {
41
31
  return setTimeout(resolve, typingTime, { signal });
42
32
  });
@@ -45,24 +35,18 @@ export const useChatService = ({ logger, application, chatRef }: UseChatServiceP
45
35
  .otherwise(async () => void 0);
46
36
 
47
37
  /** The signal could have been aborted while typing */
48
- if (signal?.aborted) {
49
- logger?.info(`Aborted sending message`);
50
- return;
51
- }
52
- addMessage({ application, data: message });
53
- scrollToEnd({ behavior: 'smooth' });
38
+ if (signal?.aborted) throw new AbortedError();
39
+ application.addMessage(message, groupId);
54
40
  },
55
- input: async input => {
56
- setInput({ application, data: input });
41
+ input: async ({ input, signal }) => {
42
+ if (signal?.aborted) throw new AbortedError();
43
+ application.setInput(input);
57
44
 
58
45
  return await new Promise(resolve => {
59
46
  const submitFunction: SubmitSuccessFn = submission => {
60
- setInput({ application, data: null });
61
- updateSubmission({
62
- application,
63
- fieldKey: input.key,
64
- data: submission,
65
- });
47
+ if (signal?.aborted) throw new AbortedError();
48
+ application.setInput(undefined);
49
+ application.setSubmission(input.key, submission);
66
50
  resolve(submission as any);
67
51
  };
68
52
  setOnSubmitSuccessFn(() => submitFunction);
@@ -71,9 +55,10 @@ export const useChatService = ({ logger, application, chatRef }: UseChatServiceP
71
55
  };
72
56
 
73
57
  return chatService;
74
- }, [application, logger, scrollToEnd]);
58
+ }, []);
75
59
 
76
60
  return {
61
+ chatRef,
77
62
  chatService,
78
63
  isBotTyping,
79
64
  onSubmitSuccessFn,
@@ -0,0 +1,10 @@
1
+ import { useEffect, useRef } from 'preact/hooks';
2
+
3
+ export const useFocusOnMount = () => {
4
+ const focusRef = useRef<any>(null);
5
+ useEffect(() => {
6
+ focusRef.current?.focus();
7
+ }, []);
8
+
9
+ return focusRef;
10
+ };
@@ -0,0 +1,19 @@
1
+ import { flowHandler } from '~/mocks/handlers';
2
+
3
+ import { expect, test } from './test';
4
+
5
+ test('when backend responds with correct data, can start application', async ({ page, worker }) => {
6
+ await worker.use(flowHandler.success);
7
+ await page.goto('/', { waitUntil: 'domcontentloaded' });
8
+
9
+ await page.getByRole('button', { name: 'Apply for job Test flow' }).click();
10
+ await expect(page.getByText('Text node')).toBeVisible();
11
+ });
12
+
13
+ test('when backend responds with malformed data, user sees error message', async ({ page, worker }) => {
14
+ await worker.use(flowHandler.invalid_payload);
15
+ await page.goto('/', { waitUntil: 'domcontentloaded' });
16
+
17
+ await page.getByRole('button', { name: 'Apply for job Test flow' }).click();
18
+ await expect(page.getByText('Text node')).toBeVisible();
19
+ });
package/tests/test.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { test as base, expect } from '@playwright/test';
2
+ import { http } from 'msw';
3
+ import type { Config, MockServiceWorker } from 'playwright-msw';
4
+ import { createWorkerFixture } from 'playwright-msw';
5
+
6
+ const testFactory = (config?: Config) =>
7
+ base.extend<{
8
+ worker: MockServiceWorker;
9
+ http: typeof http;
10
+ }>({
11
+ worker: createWorkerFixture(
12
+ [
13
+ // default handlers go here
14
+ ],
15
+ config,
16
+ ),
17
+ http,
18
+ });
19
+
20
+ const test = testFactory();
21
+
22
+ export { test, expect };
package/tsconfig.json CHANGED
@@ -28,6 +28,6 @@
28
28
  "~/*": ["./src/*"]
29
29
  }
30
30
  },
31
- "include": ["src", ".eslintrc.cjs", "tailwind.config.ts", "vite.config.ts"],
31
+ "include": ["src", "tests", "playwright.config.ts", ".eslintrc.cjs", "tailwind.config.ts", "vite.config.ts"],
32
32
  "references": [{ "path": "./tsconfig.node.json" }]
33
33
  }