@inploi/plugin-chatbot 2.1.0 → 2.1.1
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/cdn/index.js +56 -0
- package/package.json +14 -3
- package/.env +0 -2
- package/.env.example +0 -2
- package/.env.test +0 -2
- package/.eslintrc.cjs +0 -10
- package/CHANGELOG.md +0 -91
- package/bunfig.toml +0 -2
- package/happydom.ts +0 -10
- package/index.html +0 -29
- package/playwright.config.ts +0 -82
- package/postcss.config.cjs +0 -7
- package/src/chatbot.api.ts +0 -46
- package/src/chatbot.constants.ts +0 -9
- package/src/chatbot.css +0 -93
- package/src/chatbot.dom.ts +0 -28
- package/src/chatbot.idb.ts +0 -17
- package/src/chatbot.state.ts +0 -114
- package/src/chatbot.ts +0 -59
- package/src/chatbot.utils.ts +0 -56
- package/src/index.cdn.ts +0 -12
- package/src/index.dev.ts +0 -31
- package/src/index.ts +0 -1
- package/src/interpreter/interpreter.test.ts +0 -69
- package/src/interpreter/interpreter.ts +0 -249
- package/src/mocks/browser.ts +0 -5
- package/src/mocks/example.flows.ts +0 -801
- package/src/mocks/handlers.ts +0 -57
- package/src/style/palette.test.ts +0 -20
- package/src/style/palette.ts +0 -69
- package/src/ui/chat-bubble.tsx +0 -51
- package/src/ui/chat-input/chat-input.boolean.tsx +0 -62
- package/src/ui/chat-input/chat-input.file.tsx +0 -213
- package/src/ui/chat-input/chat-input.multiple-choice.tsx +0 -117
- package/src/ui/chat-input/chat-input.text.tsx +0 -111
- package/src/ui/chat-input/chat-input.tsx +0 -81
- package/src/ui/chatbot-header.tsx +0 -95
- package/src/ui/chatbot.tsx +0 -94
- package/src/ui/input-error.tsx +0 -33
- package/src/ui/job-application-content.tsx +0 -144
- package/src/ui/job-application-messages.tsx +0 -64
- package/src/ui/loading-indicator.tsx +0 -37
- package/src/ui/send-button.tsx +0 -27
- package/src/ui/transition.tsx +0 -1
- package/src/ui/typing-indicator.tsx +0 -12
- package/src/ui/useChatService.ts +0 -67
- package/src/ui/useFocus.ts +0 -10
- package/src/vite-env.d.ts +0 -1
- package/tailwind.config.ts +0 -119
- package/tests/integration.spec.ts +0 -19
- package/tests/test.ts +0 -22
- package/tsconfig.json +0 -33
- package/tsconfig.node.json +0 -10
- package/types.d.ts +0 -2
- package/vite.config.ts +0 -18
- /package/{public → cdn}/mockServiceWorker.js +0 -0
package/src/mocks/handlers.ts
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
import { invariant } from '@inploi/core/common';
|
|
2
|
-
import { HttpResponse, http } from 'msw';
|
|
3
|
-
import { match } from 'ts-pattern';
|
|
4
|
-
|
|
5
|
-
import { automatedTestFlow, exampleFlows } from './example.flows';
|
|
6
|
-
|
|
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 => {
|
|
13
|
-
invariant(typeof ctx.params.jobId === 'string', 'Missing job id');
|
|
14
|
-
const mockApplication = {
|
|
15
|
-
job: {
|
|
16
|
-
id: +ctx.params.jobId,
|
|
17
|
-
title: 'Test job',
|
|
18
|
-
},
|
|
19
|
-
company: {
|
|
20
|
-
name: 'Test company',
|
|
21
|
-
},
|
|
22
|
-
flow: {
|
|
23
|
-
id: 1,
|
|
24
|
-
nodes: match(ctx.params.jobId)
|
|
25
|
-
.with('1', () => exampleFlows.fromAlex)
|
|
26
|
-
.with('test', () => automatedTestFlow)
|
|
27
|
-
.otherwise(() => exampleFlows.helloWorld),
|
|
28
|
-
version: 1,
|
|
29
|
-
},
|
|
30
|
-
};
|
|
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' }));
|
|
53
|
-
}),
|
|
54
|
-
http.post(`${BASE_URL}/analytics/log`, () => {
|
|
55
|
-
return new HttpResponse(JSON.stringify({ message: 'Success' }));
|
|
56
|
-
}),
|
|
57
|
-
];
|
|
@@ -1,20 +0,0 @@
|
|
|
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
|
-
});
|
package/src/style/palette.ts
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import type { VariantProps } from 'class-variance-authority';
|
|
2
|
-
import { cva } from 'class-variance-authority';
|
|
3
|
-
import { Variants, m } from 'framer-motion';
|
|
4
|
-
import type { ComponentProps } from 'react';
|
|
5
|
-
|
|
6
|
-
const chatBubbleVariants = cva(
|
|
7
|
-
'max-w-[min(100%,24rem)] flex-shrink-1 min-w-[2rem] text-md py-2 px-3 rounded-[18px] min-h-[36px] break-words',
|
|
8
|
-
{
|
|
9
|
-
variants: {
|
|
10
|
-
side: {
|
|
11
|
-
left: 'bg-lowest text-neutral-12 shadow-surface-md outline outline-1 outline-accent-11/[.08] rounded-bl-md',
|
|
12
|
-
right: 'ml-auto bg-accent-7 text-lowest rounded-br-md bubble-right',
|
|
13
|
-
},
|
|
14
|
-
transitionState: {
|
|
15
|
-
entering: 'opacity-0 translate-y-8',
|
|
16
|
-
entered: 'opacity-100 translate-y-0',
|
|
17
|
-
exiting: 'opacity-0 scale-0',
|
|
18
|
-
exited: '',
|
|
19
|
-
},
|
|
20
|
-
},
|
|
21
|
-
|
|
22
|
-
defaultVariants: {
|
|
23
|
-
side: 'left',
|
|
24
|
-
},
|
|
25
|
-
},
|
|
26
|
-
);
|
|
27
|
-
|
|
28
|
-
type ChatBubbleVariants = VariantProps<typeof chatBubbleVariants>;
|
|
29
|
-
|
|
30
|
-
const motionVariants: Variants = {
|
|
31
|
-
hidden: { y: '100%', scale: 0.75 },
|
|
32
|
-
shown: { y: 0, scale: 1 },
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
type ChatBubbleProps = ComponentProps<'p'> & ChatBubbleVariants;
|
|
36
|
-
export const ChatBubble = ({ children, className, transitionState, side, ...props }: ChatBubbleProps) => {
|
|
37
|
-
return (
|
|
38
|
-
<m.p
|
|
39
|
-
variants={motionVariants}
|
|
40
|
-
initial="hidden"
|
|
41
|
-
animate="shown"
|
|
42
|
-
transition={{ type: 'spring', damping: 25, stiffness: 500 }}
|
|
43
|
-
data-transition={transitionState}
|
|
44
|
-
style={{ transformOrigin: side === 'left' ? '0% 50%' : '100% 50%' }}
|
|
45
|
-
class={chatBubbleVariants({ className, side, transitionState })}
|
|
46
|
-
{...props}
|
|
47
|
-
>
|
|
48
|
-
{children}
|
|
49
|
-
</m.p>
|
|
50
|
-
);
|
|
51
|
-
};
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import { P, match } from 'ts-pattern';
|
|
2
|
-
import { parse, picklist } from 'valibot';
|
|
3
|
-
|
|
4
|
-
import { useFocusOnMount } from '../useFocus';
|
|
5
|
-
import { ChatInputProps } from './chat-input';
|
|
6
|
-
|
|
7
|
-
export type BooleanChoicePayload = {
|
|
8
|
-
type: 'boolean';
|
|
9
|
-
config: { labels: { true: string; false: string } };
|
|
10
|
-
key: string;
|
|
11
|
-
value: 'true' | 'false';
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
const options = ['true', 'false'] as const;
|
|
15
|
-
const AnswerSchema = picklist(options);
|
|
16
|
-
const FIELD_NAME = 'answer';
|
|
17
|
-
|
|
18
|
-
export const ChatInputBoolean = ({ input, onSubmitSuccess }: ChatInputProps<'boolean'>) => {
|
|
19
|
-
const focusRef = useFocusOnMount();
|
|
20
|
-
|
|
21
|
-
return (
|
|
22
|
-
<form
|
|
23
|
-
noValidate
|
|
24
|
-
class="flex gap-2 items-center"
|
|
25
|
-
onSubmit={e => {
|
|
26
|
-
e.preventDefault();
|
|
27
|
-
|
|
28
|
-
/** nativeEvent is a hidden property not exposed by JSX's typings */
|
|
29
|
-
const value = match(e as any)
|
|
30
|
-
.with(
|
|
31
|
-
{
|
|
32
|
-
nativeEvent: {
|
|
33
|
-
submitter: P.select(P.union(P.instanceOf(HTMLButtonElement), P.instanceOf(HTMLInputElement))),
|
|
34
|
-
},
|
|
35
|
-
},
|
|
36
|
-
submitter => {
|
|
37
|
-
return submitter.value;
|
|
38
|
-
},
|
|
39
|
-
)
|
|
40
|
-
.otherwise(() => {
|
|
41
|
-
throw new Error('invalid form');
|
|
42
|
-
});
|
|
43
|
-
const answer = parse(AnswerSchema, value);
|
|
44
|
-
onSubmitSuccess(answer);
|
|
45
|
-
}}
|
|
46
|
-
>
|
|
47
|
-
{options.map((value, i) => {
|
|
48
|
-
return (
|
|
49
|
-
<button
|
|
50
|
-
ref={i === 0 ? focusRef : null}
|
|
51
|
-
type="submit"
|
|
52
|
-
name={FIELD_NAME}
|
|
53
|
-
value={value}
|
|
54
|
-
class="flex-1 overflow-hidden rounded-2xl block px-2.5 py-2.5 selection:bg-transparent transition-all bg-lowest ring-transparent ring-0 focus-visible:ring-offset-2 focus-visible:ring-4 focus-visible:ring-accent-7 ease-expo-out duration-300 outline outline-2 outline-neutral-12/5 text-neutral-12 active:outline-accent-9 active:bg-accent-4 active:text-accent-11"
|
|
55
|
-
>
|
|
56
|
-
<p class="truncate text-center text-base">{input.config.labels[value]}</p>
|
|
57
|
-
</button>
|
|
58
|
-
);
|
|
59
|
-
})}
|
|
60
|
-
</form>
|
|
61
|
-
);
|
|
62
|
-
};
|
|
@@ -1,213 +0,0 @@
|
|
|
1
|
-
import { invariant } from '@inploi/core/common';
|
|
2
|
-
import clsx from 'clsx';
|
|
3
|
-
import { ComponentProps } from 'preact';
|
|
4
|
-
import { useState } from 'preact/hooks';
|
|
5
|
-
import { FieldError } from 'react-hook-form';
|
|
6
|
-
import { P, match } from 'ts-pattern';
|
|
7
|
-
import { application } from '~/chatbot.state';
|
|
8
|
-
import { isSubmissionOfType } from '~/chatbot.utils';
|
|
9
|
-
|
|
10
|
-
import { InputError } from '../input-error';
|
|
11
|
-
import { SendButton } from '../send-button';
|
|
12
|
-
import { useFocusOnMount } from '../useFocus';
|
|
13
|
-
import { ChatInputProps } from './chat-input';
|
|
14
|
-
|
|
15
|
-
export type FileToUpload = { name: string; data: string; sizeKb: number };
|
|
16
|
-
export type FilePayload = {
|
|
17
|
-
type: 'file';
|
|
18
|
-
config: { extensions: string[]; fileSizeLimitKib?: number; allowMultiple: boolean };
|
|
19
|
-
key: string;
|
|
20
|
-
value: FileToUpload[];
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
const toBase64 = (file: File) =>
|
|
24
|
-
new Promise<string>((resolve, reject) => {
|
|
25
|
-
const reader = new FileReader();
|
|
26
|
-
reader.readAsDataURL(file);
|
|
27
|
-
reader.onload = () => {
|
|
28
|
-
if (!reader.result) return reject('No result from reader');
|
|
29
|
-
return resolve(reader.result.toString());
|
|
30
|
-
};
|
|
31
|
-
reader.onerror = reject;
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
const kbToReadableSize = (kb: number) =>
|
|
35
|
-
match(kb)
|
|
36
|
-
.with(P.number.lte(1000), () => `${Math.round(kb)}KB`)
|
|
37
|
-
.with(P.number.lt(1000 * 10), () => `${(kb / 1000).toFixed(1)}MB`)
|
|
38
|
-
.otherwise(() => `${Math.round(kb / 1000)}MB`);
|
|
39
|
-
|
|
40
|
-
const addFileSizesKb = (files: FileToUpload[]) => files.reduce((acc, cur) => acc + cur.sizeKb, 0);
|
|
41
|
-
|
|
42
|
-
const isFileSubmission = isSubmissionOfType('file');
|
|
43
|
-
|
|
44
|
-
const FILENAMES_TO_SHOW_QTY = 3;
|
|
45
|
-
|
|
46
|
-
export const FileThumbnail = ({
|
|
47
|
-
file,
|
|
48
|
-
class: className,
|
|
49
|
-
...props
|
|
50
|
-
}: ComponentProps<'div'> & { file: Pick<FileToUpload, 'name' | 'sizeKb'> }) => {
|
|
51
|
-
const extension = file.name.split('.').pop();
|
|
52
|
-
const fileName = file.name.replace(new RegExp(`.${extension}$`), '');
|
|
53
|
-
|
|
54
|
-
return (
|
|
55
|
-
<div
|
|
56
|
-
class={clsx(
|
|
57
|
-
'bg-accent-1 max-w-full flex gap-2 px-3 py-2 text-sm rounded-lg outline outline-neutral-4 overflow-hidden',
|
|
58
|
-
className,
|
|
59
|
-
)}
|
|
60
|
-
{...props}
|
|
61
|
-
>
|
|
62
|
-
<p aria-label="File name" class="overflow-hidden text-accent-12 flex flex-grow">
|
|
63
|
-
<span class="block truncate">{fileName}</span>
|
|
64
|
-
<span>.{extension}</span>
|
|
65
|
-
</p>
|
|
66
|
-
|
|
67
|
-
<p aria-label="File size" class="text-neutral-10">
|
|
68
|
-
{kbToReadableSize(file.sizeKb)}
|
|
69
|
-
</p>
|
|
70
|
-
</div>
|
|
71
|
-
);
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
const FilenameBadge = ({ class: className, ...props }: ComponentProps<'li'>) => (
|
|
75
|
-
<li
|
|
76
|
-
class={clsx(
|
|
77
|
-
'text-xs block outline outline-1 outline-neutral-6 text-neutral-11 bg-neutral-1 px-1 py-0.5 rounded-md',
|
|
78
|
-
className,
|
|
79
|
-
)}
|
|
80
|
-
{...props}
|
|
81
|
-
/>
|
|
82
|
-
);
|
|
83
|
-
1;
|
|
84
|
-
|
|
85
|
-
export const ChatInputFile = ({ input, onSubmitSuccess }: ChatInputProps<'file'>) => {
|
|
86
|
-
const submission = application.current$.value.application?.data.submissions[input.key];
|
|
87
|
-
const [files, setFiles] = useState<FileToUpload[]>(isFileSubmission(submission) ? submission.value : []);
|
|
88
|
-
const [error, setError] = useState<FieldError>();
|
|
89
|
-
const hiddenFileCount = files.length - FILENAMES_TO_SHOW_QTY;
|
|
90
|
-
const totalSize = addFileSizesKb(files);
|
|
91
|
-
const focusRef = useFocusOnMount();
|
|
92
|
-
return (
|
|
93
|
-
<form
|
|
94
|
-
class="flex flex-col gap-1"
|
|
95
|
-
onSubmit={e => {
|
|
96
|
-
e.preventDefault();
|
|
97
|
-
setError(undefined);
|
|
98
|
-
if (files.length === 0) {
|
|
99
|
-
setError({ type: 'required', message: 'Please select a file' });
|
|
100
|
-
}
|
|
101
|
-
if (input.config.fileSizeLimitKib && totalSize > input.config.fileSizeLimitKib) {
|
|
102
|
-
setError({
|
|
103
|
-
type: 'max',
|
|
104
|
-
message: `File size exceeds limit of ${kbToReadableSize(input.config.fileSizeLimitKib)}`,
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
return onSubmitSuccess(files);
|
|
108
|
-
}}
|
|
109
|
-
>
|
|
110
|
-
<div class="flex gap-2 items-center">
|
|
111
|
-
<label
|
|
112
|
-
ref={focusRef}
|
|
113
|
-
for="dropzone-file"
|
|
114
|
-
class="p-4 flex flex-col overflow-hidden items-center justify-center w-full h-48 border border-neutral-8 border-dashed rounded-2xl bg-neutral-2 cursor-pointer"
|
|
115
|
-
>
|
|
116
|
-
{files.length > 0 ? (
|
|
117
|
-
<>
|
|
118
|
-
<ul class="max-w-full flex gap-1 flex-wrap justify-center overflow-hidden p-1">
|
|
119
|
-
{files.slice(0, FILENAMES_TO_SHOW_QTY).map(file => {
|
|
120
|
-
const extension = file.name.split('.').pop();
|
|
121
|
-
const fileName = file.name.replace(new RegExp(`.${extension}$`), '');
|
|
122
|
-
return (
|
|
123
|
-
<FilenameBadge class="flex overflow-hidden">
|
|
124
|
-
<span class="block truncate">{fileName}</span>
|
|
125
|
-
<span>.{extension}</span>
|
|
126
|
-
</FilenameBadge>
|
|
127
|
-
);
|
|
128
|
-
})}
|
|
129
|
-
{hiddenFileCount > 0 ? (
|
|
130
|
-
<FilenameBadge>
|
|
131
|
-
+{hiddenFileCount} file{hiddenFileCount !== 1 ? 's' : ''}
|
|
132
|
-
</FilenameBadge>
|
|
133
|
-
) : null}
|
|
134
|
-
</ul>
|
|
135
|
-
|
|
136
|
-
<p class="text-xs text-neutral-11">
|
|
137
|
-
{kbToReadableSize(totalSize)} {files.length > 1 ? 'total' : ''}
|
|
138
|
-
</p>
|
|
139
|
-
</>
|
|
140
|
-
) : (
|
|
141
|
-
<div class="flex flex-col justify-center pt-5 pb-6 gap-4">
|
|
142
|
-
<header class="flex flex-col gap-0 items-center">
|
|
143
|
-
<svg
|
|
144
|
-
class="w-8 h-8 text-neutral-11 mb-1"
|
|
145
|
-
aria-hidden="true"
|
|
146
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
147
|
-
fill="none"
|
|
148
|
-
viewBox="0 0 20 16"
|
|
149
|
-
>
|
|
150
|
-
<path
|
|
151
|
-
stroke="currentColor"
|
|
152
|
-
stroke-linecap="round"
|
|
153
|
-
stroke-linejoin="round"
|
|
154
|
-
stroke-width="1.5"
|
|
155
|
-
d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5 5.5 5.5 0 0 0 5.207 5.021C5.137 5.017 5.071 5 5 5a4 4 0 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2"
|
|
156
|
-
/>
|
|
157
|
-
</svg>
|
|
158
|
-
<p class="tracking-[-0.01em] text-neutral-12 dark:text-gray-400">
|
|
159
|
-
{input.config.allowMultiple ? 'Select files' : 'Select a file'} to upload
|
|
160
|
-
</p>
|
|
161
|
-
{input.config.fileSizeLimitKib ? (
|
|
162
|
-
<p class="text-xs text-neutral-10">(max {kbToReadableSize(input.config.fileSizeLimitKib)})</p>
|
|
163
|
-
) : null}
|
|
164
|
-
</header>
|
|
165
|
-
<aside class="flex flex-col gap-2 items-center">
|
|
166
|
-
<p id="accepted-filetypes" class="sr-only">
|
|
167
|
-
Accepted file extensions
|
|
168
|
-
</p>
|
|
169
|
-
<ul aria-describedby="accepted-filetypes" class="flex gap-2 flex-wrap justify-center">
|
|
170
|
-
{input.config.extensions.map(ext => (
|
|
171
|
-
<li class="text-[11px] tracking-wide uppercase outline ring-2 ring-lowest outline-1 outline-neutral-6 text-neutral-9 bg-neutral-1 px-1 py-0.5 rounded-md">
|
|
172
|
-
{ext.replace('.', '')}
|
|
173
|
-
</li>
|
|
174
|
-
))}
|
|
175
|
-
</ul>
|
|
176
|
-
</aside>
|
|
177
|
-
</div>
|
|
178
|
-
)}
|
|
179
|
-
|
|
180
|
-
<input
|
|
181
|
-
id="dropzone-file"
|
|
182
|
-
onInput={async e => {
|
|
183
|
-
invariant(e.target instanceof HTMLInputElement);
|
|
184
|
-
const files = e.target.files ? Array.from(e.target.files) : [];
|
|
185
|
-
const filesToUpload = await Promise.allSettled(
|
|
186
|
-
files.map(async file => {
|
|
187
|
-
const data = await toBase64(file);
|
|
188
|
-
return {
|
|
189
|
-
name: file.name,
|
|
190
|
-
data: data,
|
|
191
|
-
sizeKb: file.size / 1000,
|
|
192
|
-
};
|
|
193
|
-
}),
|
|
194
|
-
);
|
|
195
|
-
if (filesToUpload.some(({ status }) => status === 'rejected')) {
|
|
196
|
-
return setError({ type: 'invalid', message: 'Invalid file' });
|
|
197
|
-
}
|
|
198
|
-
const validFiles = filesToUpload
|
|
199
|
-
.map(promise => (promise.status === 'fulfilled' ? promise.value : null))
|
|
200
|
-
.filter(Boolean) as FileToUpload[];
|
|
201
|
-
setFiles(validFiles);
|
|
202
|
-
}}
|
|
203
|
-
multiple={input.config.allowMultiple}
|
|
204
|
-
type="file"
|
|
205
|
-
class="sr-only"
|
|
206
|
-
/>
|
|
207
|
-
</label>
|
|
208
|
-
<SendButton disabled={files.length === 0} />
|
|
209
|
-
</div>
|
|
210
|
-
{error && <InputError error={error} />}
|
|
211
|
-
</form>
|
|
212
|
-
);
|
|
213
|
-
};
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
import { valibotResolver } from '@hookform/resolvers/valibot';
|
|
2
|
-
import { useForm } from 'react-hook-form';
|
|
3
|
-
import { boolean, maxLength, minLength, object, record, transform } from 'valibot';
|
|
4
|
-
import { application } from '~/chatbot.state';
|
|
5
|
-
import { isSubmissionOfType } from '~/chatbot.utils';
|
|
6
|
-
|
|
7
|
-
import { InputError } from '../input-error';
|
|
8
|
-
import { SendButton } from '../send-button';
|
|
9
|
-
import { useFocusOnMount } from '../useFocus';
|
|
10
|
-
import { ChatInputProps } from './chat-input';
|
|
11
|
-
|
|
12
|
-
export type MultipleChoicePayload = {
|
|
13
|
-
type: 'multiple-choice';
|
|
14
|
-
key: string;
|
|
15
|
-
config: { options: { value: string; label: string }[]; minSelected?: number; maxSelected?: number };
|
|
16
|
-
value: string[];
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
const submitIfSingleChecked = (form: HTMLFormElement) => {
|
|
20
|
-
const formObj = Object.fromEntries(new FormData(form).entries());
|
|
21
|
-
const isSingleChecked = Object.keys(formObj).length;
|
|
22
|
-
if (!isSingleChecked) return;
|
|
23
|
-
form.dispatchEvent(new Event('submit', { bubbles: true }));
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
const isMultipleChoiceSubmission = isSubmissionOfType('multiple-choice');
|
|
27
|
-
|
|
28
|
-
const getResolver = (config: MultipleChoicePayload['config']) => {
|
|
29
|
-
const length = {
|
|
30
|
-
min: config.minSelected ?? 0,
|
|
31
|
-
max: config.maxSelected ?? config.options.length,
|
|
32
|
-
};
|
|
33
|
-
return valibotResolver(
|
|
34
|
-
object({
|
|
35
|
-
checked: transform(
|
|
36
|
-
record(boolean()),
|
|
37
|
-
o =>
|
|
38
|
-
Object.entries(o)
|
|
39
|
-
.filter(([_, v]) => v)
|
|
40
|
-
.map(([k, _]) => k),
|
|
41
|
-
[
|
|
42
|
-
maxLength(length.max, `Please select at most ${length.max} option${length.max !== 1 ? 's' : ''}`),
|
|
43
|
-
minLength(length.min, `Please select at least ${length.min} option${length.min !== 1 ? 's' : ''}`),
|
|
44
|
-
],
|
|
45
|
-
),
|
|
46
|
-
}),
|
|
47
|
-
);
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
export const ChatInputMultipleChoice = ({ input, onSubmitSuccess }: ChatInputProps<'multiple-choice'>) => {
|
|
51
|
-
const submission = application.current$.value.application?.data.submissions[input.key];
|
|
52
|
-
const {
|
|
53
|
-
register,
|
|
54
|
-
handleSubmit,
|
|
55
|
-
formState: { errors },
|
|
56
|
-
} = useForm({
|
|
57
|
-
defaultValues: {
|
|
58
|
-
checked: isMultipleChoiceSubmission(submission)
|
|
59
|
-
? Object.fromEntries(submission.value.map(key => [key, true]))
|
|
60
|
-
: {},
|
|
61
|
-
},
|
|
62
|
-
resolver: getResolver(input.config),
|
|
63
|
-
});
|
|
64
|
-
const focusRef = useFocusOnMount();
|
|
65
|
-
const isSingleChoice = input.config.minSelected === 1 && input.config.maxSelected === 1;
|
|
66
|
-
|
|
67
|
-
return (
|
|
68
|
-
<form
|
|
69
|
-
noValidate
|
|
70
|
-
class="flex flex-col gap-1"
|
|
71
|
-
onChange={e => {
|
|
72
|
-
if (isSingleChoice) {
|
|
73
|
-
submitIfSingleChecked(e.currentTarget);
|
|
74
|
-
}
|
|
75
|
-
}}
|
|
76
|
-
onSubmit={handleSubmit(submission => {
|
|
77
|
-
// react-hook-form does not play well with zod's transform
|
|
78
|
-
const checked = submission.checked as unknown as string[];
|
|
79
|
-
onSubmitSuccess(checked);
|
|
80
|
-
})}
|
|
81
|
-
>
|
|
82
|
-
<div class="flex items-center gap-2">
|
|
83
|
-
<div class="flex flex-wrap gap-3 flex-1 p-1 w-full">
|
|
84
|
-
{input.config.options.map((option, i) => {
|
|
85
|
-
const id = `checked.${option.value}` as const;
|
|
86
|
-
const { ref: setRef, ...props } = register(id);
|
|
87
|
-
return (
|
|
88
|
-
<div key={option.value}>
|
|
89
|
-
<input
|
|
90
|
-
autoFocus={i === 0}
|
|
91
|
-
ref={(e: HTMLInputElement | null) => {
|
|
92
|
-
if (e && i === 0) {
|
|
93
|
-
focusRef.current = e;
|
|
94
|
-
}
|
|
95
|
-
setRef(e);
|
|
96
|
-
}}
|
|
97
|
-
id={id}
|
|
98
|
-
{...props}
|
|
99
|
-
class="peer sr-only"
|
|
100
|
-
type="checkbox"
|
|
101
|
-
></input>
|
|
102
|
-
<label
|
|
103
|
-
class="rounded-2xl block px-2.5 py-1 selection:bg-transparent transition-all bg-lowest ring-transparent ring-0 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-4 peer-focus-visible:ring-accent-7 active:outline-neutral-10 ease-expo-out duration-300 outline outline-2 outline-neutral-12/5 text-neutral-11 peer-checked:outline-accent-9 peer-checked:bg-accent-4 peer-checked:text-accent-11"
|
|
104
|
-
htmlFor={id}
|
|
105
|
-
>
|
|
106
|
-
{option.label}
|
|
107
|
-
</label>
|
|
108
|
-
</div>
|
|
109
|
-
);
|
|
110
|
-
})}
|
|
111
|
-
</div>
|
|
112
|
-
{!isSingleChoice && <SendButton />}
|
|
113
|
-
</div>
|
|
114
|
-
<InputError error={errors.checked?.root} />
|
|
115
|
-
</form>
|
|
116
|
-
);
|
|
117
|
-
};
|