@inploi/plugin-chatbot 2.0.0 → 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.
@@ -1,10 +1,15 @@
1
1
  import { invariant } from '@inploi/core/common';
2
- import { http } from 'msw';
2
+ import { HttpResponse, http } from 'msw';
3
+ import { match } from 'ts-pattern';
3
4
 
4
- import { exampleFlows } from './example.flows';
5
+ import { automatedTestFlow, exampleFlows } from './example.flows';
5
6
 
6
- export const handlers = [
7
- http.get(`${import.meta.env.VITE_BASE_URL}/flow/job/:jobId`, ctx => {
7
+ // sandbox URL
8
+ const BASE_URL = 'https://preview.api.inploi.com';
9
+
10
+ export const flowHandler = {
11
+ /** Returns a valid flow. */
12
+ success: http.get(`${BASE_URL}/flow/job/:jobId`, ctx => {
8
13
  invariant(typeof ctx.params.jobId === 'string', 'Missing job id');
9
14
  const mockApplication = {
10
15
  job: {
@@ -16,13 +21,37 @@ export const handlers = [
16
21
  },
17
22
  flow: {
18
23
  id: 1,
19
- nodes: ctx.params.jobId === '1' ? exampleFlows.fromAlex : exampleFlows.byClaude,
24
+ nodes: match(ctx.params.jobId)
25
+ .with('1', () => exampleFlows.fromAlex)
26
+ .with('test', () => automatedTestFlow)
27
+ .otherwise(() => exampleFlows.helloWorld),
20
28
  version: 1,
21
29
  },
22
30
  };
23
- return new Response(JSON.stringify(mockApplication));
31
+ return new HttpResponse(JSON.stringify(mockApplication));
32
+ }),
33
+
34
+ /** Returns an invalid payload with a 200 code.
35
+ * E.g.: if the backend returns something incompatible with the frontend.
36
+ */
37
+ invalid_payload: http.get(`${BASE_URL}/flow/job/:jobId`, () => {
38
+ return new HttpResponse(JSON.stringify({ foo: 'bar' }), { status: 200 });
39
+ }),
40
+
41
+ /** Returns a laravel-structured error */
42
+ exception: http.get(`${BASE_URL}/flow/job/:jobId`, () => {
43
+ return new HttpResponse(JSON.stringify({ exception: 'server_error', message: 'Something bad happened' }), {
44
+ status: 400,
45
+ });
46
+ }),
47
+ };
48
+
49
+ export const browserHandlers = [
50
+ flowHandler.success,
51
+ http.post(`${BASE_URL}/flow/job/:jobId`, () => {
52
+ return new HttpResponse(JSON.stringify({ message: 'Success' }));
24
53
  }),
25
- http.post(`${import.meta.env.VITE_BASE_URL}/flow/job/:jobId`, () => {
26
- return new Response(JSON.stringify({ message: 'Success' }));
54
+ http.post(`${BASE_URL}/analytics/log`, () => {
55
+ return new HttpResponse(JSON.stringify({ message: 'Success' }));
27
56
  }),
28
57
  ];
@@ -0,0 +1,20 @@
1
+ import { expect, test } from 'bun:test';
2
+
3
+ import { PaletteShade, generatePalette } from './palette';
4
+
5
+ test('returns palette when given valid shade and hue', () => {
6
+ const palette = generatePalette(50);
7
+
8
+ for (const shade in palette) {
9
+ expect(palette[shade as PaletteShade]).toMatch(/\d+\.*\d* \d+\.*\d*% \d+\.*\d*%/);
10
+ }
11
+ });
12
+
13
+ test('returns different palettes with different hues', () => {
14
+ const palette1 = generatePalette(25);
15
+ const palette2 = generatePalette(26);
16
+
17
+ for (const shade in palette1) {
18
+ expect(palette1[shade as PaletteShade]).not.toEqual(palette2[shade as PaletteShade]);
19
+ }
20
+ });
@@ -0,0 +1,69 @@
1
+ import { Mode, converter, differenceEuclidean, formatCss, toGamut } from 'culori';
2
+ import { DiffFn } from 'culori/src/difference';
3
+
4
+ declare module 'culori' {
5
+ export function toGamut(from: Mode, to: Mode, distance: DiffFn, tolerance: number): (color: string) => string;
6
+ }
7
+
8
+ const hsl = converter('hsl');
9
+ const oklch = converter('oklch');
10
+
11
+ export type PaletteShade = '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '10' | '11' | '12';
12
+ const colorLevels: Record<PaletteShade, true> = {
13
+ '1': true,
14
+ '2': true,
15
+ '3': true,
16
+ '4': true,
17
+ '5': true,
18
+ '6': true,
19
+ '7': true,
20
+ '8': true,
21
+ '9': true,
22
+ '10': true,
23
+ '11': true,
24
+ '12': true,
25
+ };
26
+
27
+ const COLOR_LEVELS = Object.keys(colorLevels) as PaletteShade[];
28
+ const CHROMA_MULTIPLIER = 1.5;
29
+
30
+ const colorPresets: Record<PaletteShade, { l: number; c: number }> = {
31
+ '1': { l: 97.78, c: 0.0108 },
32
+ '2': { l: 93.56, c: 0.0321 },
33
+ '3': { l: 88.11, c: 0.0609 },
34
+ '4': { l: 82.67, c: 0.0908 },
35
+ '5': { l: 74.22, c: 0.1398 },
36
+ '6': { l: 64.78, c: 0.1472 },
37
+ '7': { l: 57.33, c: 0.1299 },
38
+ '8': { l: 46.89, c: 0.1067 },
39
+ '9': { l: 39.44, c: 0.0898 },
40
+ '10': { l: 32, c: 0.0726 },
41
+ '11': { l: 23.78, c: 0.054 },
42
+ '12': { l: 15.56, c: 0.0353 },
43
+ };
44
+
45
+ const getPaletteColor = (hueAngle: number) => (i: PaletteShade) => {
46
+ const { c, l } = colorPresets[i];
47
+ const color = 'oklch(' + l + '% ' + c * CHROMA_MULTIPLIER + ' ' + hueAngle + ')';
48
+ const oklchColor = oklch(toGamut('p3', 'oklch', differenceEuclidean('oklch'), 0)(color));
49
+
50
+ return formatCss(hsl(oklchColor)) as unknown as string;
51
+ };
52
+
53
+ export const generatePalette = (hueAngle: number) =>
54
+ Object.fromEntries(COLOR_LEVELS.map(level => [level, getPaletteColor(hueAngle)(level)] as const)) as Record<
55
+ PaletteShade,
56
+ string
57
+ >;
58
+
59
+ // gets the hue saturation and lightness from a `hsl()` string. Includes % signs.
60
+ const extractHsl = (color: string) => {
61
+ return color.replace('hsl(', '').replace(')', '');
62
+ };
63
+
64
+ export const formatCssVariables = (palette: Record<PaletteShade, string>) => {
65
+ const cssVariables = Object.entries(palette)
66
+ .map(([level, color]) => `--i-a-${level}: ${extractHsl(color)};`)
67
+ .join('\n');
68
+ return `#isdk {\n${cssVariables}\n}`;
69
+ };
@@ -9,8 +9,7 @@ const chatBubbleVariants = cva(
9
9
  variants: {
10
10
  side: {
11
11
  left: 'bg-lowest text-neutral-12 shadow-surface-md outline outline-1 outline-accent-11/[.08] rounded-bl-md',
12
- right:
13
- 'ml-auto bg-gradient-to-t from-[#2D51D2] to-[#4A7BEC] border border-[#405FCC] shadow-[inset_0_5px_3px_-3px_#6F99F1,inset_0_-5px_5px_-2px_#6F99F1DD,0_4px_6px_-1px_rgb(0_0_0_/_0.1),_0_2px_4px_-2px_rgb(0_0_0_/_0.1)] text-lowest rounded-br-md bubble-right',
12
+ right: 'ml-auto bg-accent-7 text-lowest rounded-br-md bubble-right',
14
13
  },
15
14
  transitionState: {
16
15
  entering: 'opacity-0 translate-y-8',
@@ -83,7 +83,7 @@ const FilenameBadge = ({ class: className, ...props }: ComponentProps<'li'>) =>
83
83
  1;
84
84
 
85
85
  export const ChatInputFile = ({ input, onSubmitSuccess }: ChatInputProps<'file'>) => {
86
- const submission = application.current$.value?.data.submissions[input.key];
86
+ const submission = application.current$.value.application?.data.submissions[input.key];
87
87
  const [files, setFiles] = useState<FileToUpload[]>(isFileSubmission(submission) ? submission.value : []);
88
88
  const [error, setError] = useState<FieldError>();
89
89
  const hiddenFileCount = files.length - FILENAMES_TO_SHOW_QTY;
@@ -48,7 +48,7 @@ const getResolver = (config: MultipleChoicePayload['config']) => {
48
48
  };
49
49
 
50
50
  export const ChatInputMultipleChoice = ({ input, onSubmitSuccess }: ChatInputProps<'multiple-choice'>) => {
51
- const submission = application.current$.value?.data.submissions[input.key];
51
+ const submission = application.current$.value.application?.data.submissions[input.key];
52
52
  const {
53
53
  register,
54
54
  handleSubmit,
@@ -57,7 +57,7 @@ const getResolver = (config: TextPayload['config']) =>
57
57
  valibotResolver(object({ text: inputFormatToSchema[config.format] }));
58
58
 
59
59
  export const ChatInputText = ({ input, onSubmitSuccess }: ChatInputProps<'text'>) => {
60
- const submission = application.current$.value?.data.submissions[input.key];
60
+ const submission = application.current$.value.application?.data.submissions[input.key];
61
61
  const {
62
62
  register,
63
63
  handleSubmit,
@@ -100,7 +100,7 @@ export const ChatInputText = ({ input, onSubmitSuccess }: ChatInputProps<'text'>
100
100
  }
101
101
  setRef(e);
102
102
  }}
103
- class="flex-grow outline outline-2 outline-neutral-6 px-3 py-1 rounded-full text-base placeholder:text-neutral-10 focus-visible:outline-accent-9 caret-accent-9"
103
+ class="flex-grow outline outline-2 outline-neutral-6 px-3 py-1 rounded-full text-base placeholder:text-neutral-8 focus-visible:outline-accent-9 caret-accent-9"
104
104
  placeholder={input.config.placeholder}
105
105
  />
106
106
  <SendButton />
@@ -24,10 +24,10 @@ export type ChatInput = DistributivePick<ChatbotInput, 'type' | 'config' | 'key'
24
24
 
25
25
  type ChatInputFactoryProps = {
26
26
  onSubmit: SubmitSuccessFn;
27
+ input: ChatInputType | undefined;
27
28
  onInputChange: (input?: ChatInputType['type']) => void;
28
29
  };
29
- export const ChatInput = ({ onSubmit, onInputChange }: ChatInputFactoryProps) => {
30
- const input = application.current$.value?.data.currentInput;
30
+ export const ChatInput = ({ onSubmit, onInputChange, input }: ChatInputFactoryProps) => {
31
31
  const inputWrapperRef = useRef<HTMLDivElement>(null);
32
32
 
33
33
  useEffect(() => {
@@ -1,7 +1,7 @@
1
1
  import clsx from 'clsx';
2
- import { ComponentProps } from 'preact';
2
+ import { ComponentChildren, ComponentProps } from 'preact';
3
3
 
4
- import { StartedJobApplication, application, cancelCurrentApplication, viewState } from '../chatbot.state';
4
+ import { application, viewState } from '../chatbot.state';
5
5
 
6
6
  const HeaderIconButton = ({ class: className, children, ...props }: ComponentProps<'button'>) => {
7
7
  return (
@@ -29,16 +29,13 @@ const HeaderIconButton = ({ class: className, children, ...props }: ComponentPro
29
29
  );
30
30
  };
31
31
 
32
- export const ChatbotHeader = ({ currentApplication }: { currentApplication?: StartedJobApplication }) => {
32
+ export const ChatbotHeader = ({ children }: { children?: ComponentChildren }) => {
33
33
  const view = viewState.value;
34
- const headerText = currentApplication
35
- ? `Applying for “${currentApplication.job.title}” at ${currentApplication.company.name}`
36
- : 'Applying';
37
34
 
38
35
  return (
39
36
  <header class="z-20 flex gap-2 h-[var(--header-height)] rounded-t-2xl left-0 outline outline-1 outline-neutral-5 right-0 mx-auto items-center absolute top-0 bg-neutral-1/90 backdrop-blur-md backdrop-saturate-150">
40
37
  <p id="chatbot-header" class="sr-only">
41
- {headerText}
38
+ {children}
42
39
  </p>
43
40
  <button
44
41
  tabIndex={-1}
@@ -53,7 +50,7 @@ export const ChatbotHeader = ({ currentApplication }: { currentApplication?: Sta
53
50
  class="flex-grow h-full overflow-hidden py-1 px-4"
54
51
  >
55
52
  <p aria-hidden class="font-bold text-sm tracking-tight text-neutral-12 truncate">
56
- {headerText}
53
+ {children}
57
54
  </p>
58
55
  </button>
59
56
 
@@ -67,7 +64,7 @@ export const ChatbotHeader = ({ currentApplication }: { currentApplication?: Sta
67
64
  >
68
65
  <path d="M12.5 9.5L8 5L3.5 9.5" />
69
66
  </HeaderIconButton>
70
- <HeaderIconButton key="close" aria-label="Close application" onClick={cancelCurrentApplication}>
67
+ <HeaderIconButton key="close" aria-label="Close application" onClick={application.cancel}>
71
68
  <path d="M12.5 6.5L8 11L3.5 6.5" />
72
69
  </HeaderIconButton>
73
70
  </>
@@ -1,8 +1,9 @@
1
1
  import { AnalyticsService, ApiClient, Logger } from '@inploi/sdk';
2
- import { FocusScope } from '@radix-ui/react-focus-scope';
2
+ import * as Dialog from '@radix-ui/react-dialog';
3
3
  import { LazyMotion, domAnimation } from 'framer-motion';
4
4
  import { AnimatePresence, Variants, m } from 'framer-motion';
5
5
  import { Suspense, lazy, useRef } from 'preact/compat';
6
+ import { P, match } from 'ts-pattern';
6
7
  import { HEADER_HEIGHT } from '~/chatbot.constants';
7
8
 
8
9
  import { application, viewState } from '../chatbot.state';
@@ -28,78 +29,66 @@ type ChatbotProps = {
28
29
  analytics: AnalyticsService;
29
30
  };
30
31
  export const Chatbot = ({ logger, apiClient, analytics }: ChatbotProps) => {
31
- const currentApplication = application.current$.value;
32
- const isApplying = currentApplication !== null;
32
+ const { state, application: currentApplication } = application.current$.value;
33
33
  const view = viewState.value;
34
-
34
+ const isApplying = state === 'loaded' && view === 'maximised';
35
35
  const drawerRef = useRef<HTMLDivElement>(null);
36
- // useEffect(() => {
37
- // function onVisualViewportChange() {
38
- // if (!drawerRef.current) return;
39
- // const visualViewportHeight = window.visualViewport?.height ?? 0;
40
- // const keyboardHeight = window.innerHeight - visualViewportHeight;
41
-
42
- // // Difference between window height and height excluding the keyboard
43
- // const diffFromInitial = window.innerHeight - visualViewportHeight;
44
-
45
- // const drawerHeight = drawerRef.current.getBoundingClientRect().height;
46
- // const offset = keyboardHeight - drawerHeight;
47
-
48
- // drawerRef.current.style.height = `${visualViewportHeight - offset}px`;
49
- // drawerRef.current.style.bottom = `${Math.max(diffFromInitial, 0)}px`;
50
- // }
51
-
52
- // window.visualViewport?.addEventListener('resize', onVisualViewportChange);
53
-
54
- // return () => window.visualViewport?.removeEventListener('resize', onVisualViewportChange);
55
- // }, []);
56
36
 
57
37
  return (
58
38
  <MotionProvider>
59
- <AnimatePresence>
60
- {isApplying && viewState.value === 'maximised' ? (
61
- <m.div
62
- key="bg"
63
- initial={{ opacity: 0 }}
64
- animate={{ opacity: 1 }}
65
- exit={{ opacity: 0 }}
66
- class="bg-neutral-12/60 fixed inset-0"
67
- />
68
- ) : null}
39
+ <Dialog.Root open={isApplying} modal={isApplying}>
40
+ <AnimatePresence>
41
+ <Dialog.Overlay key="bg" forceMount asChild>
42
+ {isApplying && (
43
+ <m.div
44
+ initial={{ opacity: 0 }}
45
+ animate={{ opacity: 1 }}
46
+ exit={{ opacity: 0 }}
47
+ class="bg-neutral-12/60 fixed inset-0"
48
+ />
49
+ )}
50
+ </Dialog.Overlay>
69
51
 
70
- {isApplying ? (
71
- <m.div
72
- key="content"
73
- ref={drawerRef}
74
- aria-modal="true"
75
- role="dialog"
76
- aria-labelledby="chatbot-header"
77
- variants={chatbotVariants}
78
- initial="closed"
79
- animate={view}
80
- exit="closed"
81
- style={{ '--header-height': `${HEADER_HEIGHT}px` }}
82
- class="isolate fixed left-2 right-2 mx-auto max-w-[450px] bottom-2 max-h-full focus:outline-none"
83
- >
84
- <FocusScope
85
- class="outline outline-1 h-full outline-neutral-5 relative flex flex-col bg-neutral-2 rounded-3xl overflow-hidden"
86
- loop
87
- trapped={false}
52
+ <Dialog.Content forceMount asChild>
53
+ <m.div
54
+ key="content"
55
+ ref={drawerRef}
56
+ aria-modal="true"
57
+ role="dialog"
58
+ aria-labelledby="chatbot-header"
59
+ variants={chatbotVariants}
60
+ initial="closed"
61
+ animate={match({ state, view })
62
+ .with({ state: 'idle' }, () => 'closed')
63
+ .with({ state: 'error' }, () => 'minimised')
64
+ .with({ state: P.union('loaded', 'loading') }, ({ view }) => view)
65
+ .exhaustive()}
66
+ exit="closed"
67
+ style={{ '--header-height': `${HEADER_HEIGHT}px` }}
68
+ class="isolate fixed left-2 right-2 mx-auto max-w-[450px] bottom-2 max-h-full focus:outline-none"
88
69
  >
89
- <ChatbotHeader currentApplication={currentApplication} />
70
+ <div class="outline outline-1 h-full outline-neutral-5 relative flex flex-col bg-neutral-2 rounded-3xl overflow-hidden">
71
+ <ChatbotHeader>
72
+ {currentApplication
73
+ ? `Applying for “${currentApplication.job.title}” at ${currentApplication.company.name}`
74
+ : 'inploi chatbot'}
75
+ </ChatbotHeader>
90
76
 
91
- <Suspense fallback={<div>loading…</div>}>
92
- <JobApplicationContent
93
- analytics={analytics}
94
- currentApplication={currentApplication}
95
- apiClient={apiClient}
96
- logger={logger}
97
- />
98
- </Suspense>
99
- </FocusScope>
100
- </m.div>
101
- ) : null}
102
- </AnimatePresence>
77
+ <Suspense fallback={<div>loading…</div>}>
78
+ {view === 'maximised' && state === 'loaded' && (
79
+ <JobApplicationContent
80
+ analytics={analytics}
81
+ currentApplication={currentApplication}
82
+ apiClient={apiClient}
83
+ logger={logger}
84
+ />
85
+ )}
86
+ </Suspense>
87
+ </div>
88
+ </m.div>
89
+ </Dialog.Content>
90
+ </AnimatePresence>
91
+ </Dialog.Root>
103
92
  </MotionProvider>
104
93
  );
105
94
  };
@@ -1,5 +1,4 @@
1
1
  import { AnalyticsService, ApiClient, Logger } from '@inploi/sdk';
2
- import { useFocusGuards } from '@radix-ui/react-focus-guards';
3
2
  import { AnimatePresence } from 'framer-motion';
4
3
  import { useEffect, useLayoutEffect } from 'react';
5
4
  import { match } from 'ts-pattern';
@@ -25,11 +24,7 @@ export const JobApplicationContent = ({
25
24
  apiClient,
26
25
  analytics,
27
26
  }: JobApplicationContentProps) => {
28
- const { chatRef, chatService, isBotTyping, onSubmitSuccessFn, scrollToEnd } = useChatService({
29
- logger,
30
- });
31
-
32
- useFocusGuards();
27
+ const { chatRef, chatService, isBotTyping, onSubmitSuccessFn, scrollToEnd } = useChatService();
33
28
 
34
29
  const view = viewState.value;
35
30
  useLayoutEffect(() => {
@@ -43,14 +38,15 @@ export const JobApplicationContent = ({
43
38
 
44
39
  useLayoutEffect(() => {
45
40
  scrollToEnd({ behavior: 'instant' });
46
- const currentApplication = application.current$.peek();
47
- if (!currentApplication || currentApplication.data.isFinished) return;
41
+
42
+ const { state, application: currentApplication } = application.current$.value;
43
+ if (state !== 'loaded' || currentApplication.data.isFinished) return;
48
44
 
49
45
  const { interpret, abort } = createFlowInterpreter({
50
46
  flow: currentApplication.flow.nodes,
51
47
  chatService,
52
- getSubmissions: () => application.current$.peek()?.data.submissions,
53
- beforeStart: async () => {
48
+ getSubmissions: () => application.current$.peek().application?.data.submissions,
49
+ beforeStart: async node => {
54
50
  application.setInput(undefined);
55
51
 
56
52
  const fromBeginning = currentApplication.data.messages.length === 0;
@@ -63,13 +59,9 @@ export const JobApplicationContent = ({
63
59
  },
64
60
  });
65
61
  } else {
66
- await chatService.send({
67
- message: {
68
- type: 'system',
69
- text: 'Restored in progress application',
70
- variant: 'info',
71
- },
72
- });
62
+ // We restart the last node.
63
+ const restoredFromId = node.id;
64
+ application.removeLastGroupMessagesById(restoredFromId);
73
65
  }
74
66
  },
75
67
  onInterpret: node => {
@@ -85,13 +77,14 @@ export const JobApplicationContent = ({
85
77
  text: 'Application ended',
86
78
  variant: 'success',
87
79
  },
80
+ groupId: 'system',
88
81
  });
89
82
  })
90
83
  .with({ type: 'complete-flow' }, async () => {
91
- const submissions = application.current$.peek()?.data.submissions;
84
+ const submissions = application.current$.peek().application?.data.submissions;
92
85
  if (!submissions) throw new Error(ERROR_MESSAGES.no_submissions);
93
86
 
94
- const response = await apiClient.fetch(`/flow/job/${currentApplication.job.id}`, {
87
+ const response = await apiClient.fetch(`/flow/apply`, {
95
88
  method: 'POST',
96
89
  body: JSON.stringify(submissionsToPayload({ application: currentApplication, submissions })),
97
90
  });
@@ -104,6 +97,7 @@ export const JobApplicationContent = ({
104
97
  text: 'Application submitted',
105
98
  variant: 'success',
106
99
  },
100
+ groupId: 'system',
107
101
  });
108
102
  })
109
103
  .otherwise(response => {
@@ -114,6 +108,7 @@ export const JobApplicationContent = ({
114
108
  text: 'Error submitting application',
115
109
  variant: 'error',
116
110
  },
111
+ groupId: 'system',
117
112
  });
118
113
  });
119
114
  })
@@ -139,7 +134,11 @@ export const JobApplicationContent = ({
139
134
  <JobApplicationMessages isBotTyping={isBotTyping} messages={currentApplication.data.messages} />
140
135
  </AnimatePresence>
141
136
  </div>
142
- <ChatInput onInputChange={() => scrollToEnd({ behavior: 'smooth' })} onSubmit={onSubmitSuccessFn} />
137
+ <ChatInput
138
+ input={currentApplication.data.currentInput}
139
+ onInputChange={() => scrollToEnd({ behavior: 'smooth' })}
140
+ onSubmit={onSubmitSuccessFn}
141
+ />
143
142
  </>
144
143
  );
145
144
  };
@@ -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,18 +1,14 @@
1
- import { Logger } from '@inploi/sdk';
2
1
  import { useMemo, useRef, useState } from 'preact/hooks';
3
2
  import { match } from 'ts-pattern';
4
3
  import { application } from '~/chatbot.state';
4
+ import { AbortedError } from '~/chatbot.utils';
5
5
  import { ChatService } from '~/interpreter/interpreter';
6
6
 
7
7
  import { SubmitSuccessFn } from './chat-input/chat-input';
8
8
 
9
9
  const TYPING_SPEED_MS_PER_CHARACTER = 25;
10
10
 
11
- type UseChatServiceParams = {
12
- logger?: Logger;
13
- };
14
-
15
- export const useChatService = ({ logger }: UseChatServiceParams) => {
11
+ export const useChatService = () => {
16
12
  const chatRef = useRef<HTMLDivElement>(null);
17
13
  const [isBotTyping, setIsBotTyping] = useState(false);
18
14
  const [onSubmitSuccessFn, setOnSubmitSuccessFn] = useState<SubmitSuccessFn>(() => () => {});
@@ -24,16 +20,13 @@ export const useChatService = ({ logger }: UseChatServiceParams) => {
24
20
 
25
21
  const chatService = useMemo(() => {
26
22
  const chatService: ChatService = {
27
- send: async ({ message, signal }) => {
23
+ send: async ({ message, signal, groupId }) => {
28
24
  await match(message)
29
25
  /** Delay sending and add typing indicator if bot is sending a message */
30
26
  .with({ author: 'bot', type: 'text' }, async message => {
31
- if (signal?.aborted) {
32
- logger?.info(`Aborted sending message`);
33
- return;
34
- }
27
+ if (signal?.aborted) throw new AbortedError();
35
28
  setIsBotTyping(true);
36
- const typingTime = message.text.length * TYPING_SPEED_MS_PER_CHARACTER;
29
+ const typingTime = Math.max(20, message.text.length) * TYPING_SPEED_MS_PER_CHARACTER;
37
30
  await new Promise(resolve => {
38
31
  return setTimeout(resolve, typingTime, { signal });
39
32
  });
@@ -42,17 +35,16 @@ export const useChatService = ({ logger }: UseChatServiceParams) => {
42
35
  .otherwise(async () => void 0);
43
36
 
44
37
  /** The signal could have been aborted while typing */
45
- if (signal?.aborted) {
46
- logger?.info(`Aborted sending message`);
47
- return;
48
- }
49
- application.addMessage(message);
38
+ if (signal?.aborted) throw new AbortedError();
39
+ application.addMessage(message, groupId);
50
40
  },
51
- input: async input => {
41
+ input: async ({ input, signal }) => {
42
+ if (signal?.aborted) throw new AbortedError();
52
43
  application.setInput(input);
53
44
 
54
45
  return await new Promise(resolve => {
55
46
  const submitFunction: SubmitSuccessFn = submission => {
47
+ if (signal?.aborted) throw new AbortedError();
56
48
  application.setInput(undefined);
57
49
  application.setSubmission(input.key, submission);
58
50
  resolve(submission as any);
@@ -63,7 +55,7 @@ export const useChatService = ({ logger }: UseChatServiceParams) => {
63
55
  };
64
56
 
65
57
  return chatService;
66
- }, [logger]);
58
+ }, []);
67
59
 
68
60
  return {
69
61
  chatRef,
@@ -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
  }