@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.
- package/.env +0 -1
- package/.env.example +0 -1
- package/.env.test +2 -0
- package/CHANGELOG.md +6 -0
- package/index.html +2 -1
- package/package.json +12 -3
- package/playwright.config.ts +82 -0
- package/public/mockServiceWorker.js +4 -9
- package/src/chatbot.css +0 -14
- package/src/chatbot.dom.ts +11 -0
- package/src/chatbot.state.ts +40 -15
- package/src/chatbot.ts +12 -7
- package/src/chatbot.utils.ts +6 -0
- package/src/index.dev.ts +7 -12
- package/src/interpreter/interpreter.ts +28 -20
- package/src/mocks/browser.ts +2 -2
- package/src/mocks/example.flows.ts +55 -17
- package/src/mocks/handlers.ts +37 -8
- package/src/style/palette.test.ts +20 -0
- package/src/style/palette.ts +69 -0
- package/src/ui/chat-bubble.tsx +1 -2
- package/src/ui/chat-input/chat-input.file.tsx +1 -1
- package/src/ui/chat-input/chat-input.multiple-choice.tsx +1 -1
- package/src/ui/chat-input/chat-input.text.tsx +2 -2
- package/src/ui/chat-input/chat-input.tsx +2 -2
- package/src/ui/chatbot-header.tsx +6 -9
- package/src/ui/chatbot.tsx +54 -65
- package/src/ui/job-application-content.tsx +19 -20
- package/src/ui/send-button.tsx +1 -1
- package/src/ui/typing-indicator.tsx +1 -1
- package/src/ui/useChatService.ts +11 -19
- package/tests/integration.spec.ts +19 -0
- package/tests/test.ts +22 -0
- package/tsconfig.json +1 -1
package/src/mocks/handlers.ts
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
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
|
|
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
|
|
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(`${
|
|
26
|
-
return new
|
|
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
|
+
};
|
package/src/ui/chat-bubble.tsx
CHANGED
|
@@ -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-
|
|
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 {
|
|
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 = ({
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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={
|
|
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
|
</>
|
package/src/ui/chatbot.tsx
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { AnalyticsService, ApiClient, Logger } from '@inploi/sdk';
|
|
2
|
-
import
|
|
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
|
-
<
|
|
60
|
-
|
|
61
|
-
<
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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/
|
|
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
|
|
137
|
+
<ChatInput
|
|
138
|
+
input={currentApplication.data.currentInput}
|
|
139
|
+
onInputChange={() => scrollToEnd({ behavior: 'smooth' })}
|
|
140
|
+
onSubmit={onSubmitSuccessFn}
|
|
141
|
+
/>
|
|
143
142
|
</>
|
|
144
143
|
);
|
|
145
144
|
};
|
package/src/ui/send-button.tsx
CHANGED
|
@@ -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-
|
|
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-
|
|
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
|
);
|
package/src/ui/useChatService.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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
|
}
|