diff --git a/apps/builder/components/editor/BlocksSideBar/BlockIcon.tsx b/apps/builder/components/editor/BlocksSideBar/BlockIcon.tsx index 6b99aca1c..2ca6aa2b4 100644 --- a/apps/builder/components/editor/BlocksSideBar/BlockIcon.tsx +++ b/apps/builder/components/editor/BlocksSideBar/BlockIcon.tsx @@ -20,6 +20,7 @@ import { SendEmailIcon, StarIcon, TextIcon, + UploadIcon, WebhookIcon, } from 'assets/icons' import { @@ -68,6 +69,8 @@ export const BlockIcon = ({ type, ...props }: BlockIconProps) => { return case InputBlockType.RATING: return + case InputBlockType.FILE: + return case LogicBlockType.SET_VARIABLE: return case LogicBlockType.CONDITION: diff --git a/apps/builder/components/editor/BlocksSideBar/BlockTypeLabel.tsx b/apps/builder/components/editor/BlocksSideBar/BlockTypeLabel.tsx index 7cec5b93a..70b670dd8 100644 --- a/apps/builder/components/editor/BlocksSideBar/BlockTypeLabel.tsx +++ b/apps/builder/components/editor/BlocksSideBar/BlockTypeLabel.tsx @@ -43,6 +43,12 @@ export const BlockTypeLabel = ({ type }: Props): JSX.Element => { return Payment case InputBlockType.RATING: return Rating + case InputBlockType.FILE: + return ( + + File + + ) case LogicBlockType.SET_VARIABLE: return Set variable case LogicBlockType.CONDITION: diff --git a/apps/builder/components/shared/CodeEditor.tsx b/apps/builder/components/shared/CodeEditor.tsx index bdb7d8962..0b7744744 100644 --- a/apps/builder/components/shared/CodeEditor.tsx +++ b/apps/builder/components/shared/CodeEditor.tsx @@ -19,12 +19,14 @@ type Props = { isReadOnly?: boolean debounceTimeout?: number withVariableButton?: boolean + height?: string onChange?: (value: string) => void } export const CodeEditor = ({ value, lang, onChange, + height = '250px', withVariableButton = true, isReadOnly = false, debounceTimeout = 1000, @@ -92,7 +94,7 @@ export const CodeEditor = ({ extensions.push( EditorView.theme({ '&': { maxHeight: '500px' }, - '.cm-gutter,.cm-content': { minHeight: isReadOnly ? '0' : '250px' }, + '.cm-gutter,.cm-content': { minHeight: isReadOnly ? '0' : height }, '.cm-scroller': { overflow: 'auto' }, }) ) diff --git a/apps/builder/components/shared/Graph/Nodes/BlockNode/BlockNodeContent/BlockNodeContent.tsx b/apps/builder/components/shared/Graph/Nodes/BlockNode/BlockNodeContent/BlockNodeContent.tsx index 207f64342..53dc07b9b 100644 --- a/apps/builder/components/shared/Graph/Nodes/BlockNode/BlockNodeContent/BlockNodeContent.tsx +++ b/apps/builder/components/shared/Graph/Nodes/BlockNode/BlockNodeContent/BlockNodeContent.tsx @@ -19,6 +19,7 @@ import { WithVariableContent, } from './contents' import { ConfigureContent } from './contents/ConfigureContent' +import { FileInputContent } from './contents/FileInputContent' import { ImageBubbleContent } from './contents/ImageBubbleContent' import { PaymentInputContent } from './contents/PaymentInputContent' import { PlaceholderContent } from './contents/PlaceholderContent' @@ -80,6 +81,9 @@ export const BlockNodeContent = ({ block, indices }: Props): JSX.Element => { case InputBlockType.RATING: { return } + case InputBlockType.FILE: { + return + } case LogicBlockType.SET_VARIABLE: { return } diff --git a/apps/builder/components/shared/Graph/Nodes/BlockNode/BlockNodeContent/contents/FileInputContent.tsx b/apps/builder/components/shared/Graph/Nodes/BlockNode/BlockNodeContent/contents/FileInputContent.tsx new file mode 100644 index 000000000..281bccc92 --- /dev/null +++ b/apps/builder/components/shared/Graph/Nodes/BlockNode/BlockNodeContent/contents/FileInputContent.tsx @@ -0,0 +1,12 @@ +import { Text } from '@chakra-ui/react' +import { FileInputOptions } from 'models' + +type Props = { + options: FileInputOptions +} + +export const FileInputContent = ({ options: { isMultipleAllowed } }: Props) => ( + + Collect {isMultipleAllowed ? 'files' : 'file'} + +) diff --git a/apps/builder/components/shared/Graph/Nodes/BlockNode/SettingsPopoverContent/SettingsPopoverContent.tsx b/apps/builder/components/shared/Graph/Nodes/BlockNode/SettingsPopoverContent/SettingsPopoverContent.tsx index bdb0c6adf..03d761795 100644 --- a/apps/builder/components/shared/Graph/Nodes/BlockNode/SettingsPopoverContent/SettingsPopoverContent.tsx +++ b/apps/builder/components/shared/Graph/Nodes/BlockNode/SettingsPopoverContent/SettingsPopoverContent.tsx @@ -29,6 +29,7 @@ import { import { ChoiceInputSettingsBody } from './bodies/ChoiceInputSettingsBody' import { CodeSettings } from './bodies/CodeSettings' import { ConditionSettingsBody } from './bodies/ConditionSettingsBody' +import { FileInputSettings } from './bodies/FileInputSettings' import { GoogleAnalyticsSettings } from './bodies/GoogleAnalyticsSettings' import { GoogleSheetsSettingsBody } from './bodies/GoogleSheetsSettingsBody' import { PaymentSettings } from './bodies/PaymentSettings' @@ -173,6 +174,14 @@ export const BlockSettings = ({ /> ) } + case InputBlockType.FILE: { + return ( + + ) + } case LogicBlockType.SET_VARIABLE: { return ( void +} + +export const FileInputSettings = ({ options, onOptionsChange }: Props) => { + const handleButtonLabelChange = (button: string) => + onOptionsChange({ ...options, labels: { ...options.labels, button } }) + const handlePlaceholderLabelChange = (placeholder: string) => + onOptionsChange({ ...options, labels: { ...options.labels, placeholder } }) + const handleLongChange = (isMultipleAllowed: boolean) => + onOptionsChange({ ...options, isMultipleAllowed }) + const handleVariableChange = (variable?: Variable) => + onOptionsChange({ ...options, variableId: variable?.id }) + + return ( + + + + Placeholder: + + + + + Button label: + + + + + + Save upload URL{options.isMultipleAllowed ? 's' : ''} in a variable: + + + + + ) +} diff --git a/apps/builder/components/shared/TypebotHeader/TypebotHeader.tsx b/apps/builder/components/shared/TypebotHeader/TypebotHeader.tsx index 3ca9bc125..001310a7a 100644 --- a/apps/builder/components/shared/TypebotHeader/TypebotHeader.tsx +++ b/apps/builder/components/shared/TypebotHeader/TypebotHeader.tsx @@ -171,7 +171,12 @@ export const TypebotHeader = () => { {router.pathname.includes('/edit') && isNotDefined(rightPanel) && ( - + )} diff --git a/apps/builder/components/shared/buttons/UploadButton.tsx b/apps/builder/components/shared/buttons/UploadButton.tsx index 21301c2e8..e6b67d2f1 100644 --- a/apps/builder/components/shared/buttons/UploadButton.tsx +++ b/apps/builder/components/shared/buttons/UploadButton.tsx @@ -1,6 +1,7 @@ import { Button, ButtonProps, chakra } from '@chakra-ui/react' import React, { ChangeEvent, useState } from 'react' -import { compressFile, uploadFile } from 'services/utils' +import { compressFile } from 'services/utils' +import { uploadFiles } from 'utils' type UploadButtonProps = { filePath: string @@ -20,11 +21,15 @@ export const UploadButton = ({ if (!e.target?.files) return setIsUploading(true) const file = e.target.files[0] - const { url } = await uploadFile( - await compressFile(file), - filePath + (includeFileName ? `/${file.name}` : '') - ) - if (url) onFileUploaded(url) + const urls = await uploadFiles({ + files: [ + { + file: await compressFile(file), + path: filePath + (includeFileName ? `/${file.name}` : ''), + }, + ], + }) + if (urls.length) onFileUploaded(urls[0]) setIsUploading(false) } diff --git a/apps/builder/components/templates/TemplatesModal.tsx b/apps/builder/components/templates/TemplatesModal.tsx index 8b56b3024..784b5493a 100644 --- a/apps/builder/components/templates/TemplatesModal.tsx +++ b/apps/builder/components/templates/TemplatesModal.tsx @@ -43,7 +43,6 @@ export const TemplatesModal = ({ isOpen, onClose, onTypebotChoose }: Props) => { const fetchTemplate = async (template: TemplateProps) => { setSelectedTemplate(template) const { data, error } = await sendRequest(`/templates/${template.fileName}`) - console.log(data, error) if (error) return showToast({ title: error.name, description: error.message }) setTypebot(data as Typebot) diff --git a/apps/builder/layouts/results/SubmissionContent.tsx b/apps/builder/layouts/results/SubmissionContent.tsx index 6b71ca4fc..0cee647c2 100644 --- a/apps/builder/layouts/results/SubmissionContent.tsx +++ b/apps/builder/layouts/results/SubmissionContent.tsx @@ -132,7 +132,7 @@ export const SubmissionsContent = ({ return convertResultsToTableData(results, resultHeader) } - const tableData: { [key: string]: string }[] = useMemo( + const tableData: { [key: string]: string | JSX.Element }[] = useMemo( () => publishedTypebot ? convertResultsToTableData(results, resultHeader) : [], // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/apps/builder/package.json b/apps/builder/package.json index a508be6d9..1ef99589c 100644 --- a/apps/builder/package.json +++ b/apps/builder/package.json @@ -37,6 +37,7 @@ "@udecode/plate-link": "^11.0.0", "@udecode/plate-ui-link": "^11.0.0", "@udecode/plate-ui-toolbar": "^11.0.0", + "aws-sdk": "^2.1152.0", "bot-engine": "*", "browser-image-compression": "^2.0.0", "canvas-confetti": "^1.5.1", @@ -57,7 +58,6 @@ "kbar": "^0.1.0-beta.34", "micro": "^9.3.4", "micro-cors": "^0.1.1", - "minio": "^7.0.28", "models": "*", "next": "^12.1.6", "next-auth": "4.3.4", @@ -85,12 +85,12 @@ }, "devDependencies": { "@playwright/test": "^1.22.0", + "@types/aws-sdk": "^2.7.0", "@types/canvas-confetti": "^1.4.2", "@types/emoji-mart": "^3.0.9", "@types/google-spreadsheet": "^3.2.1", "@types/jsonwebtoken": "8.5.8", "@types/micro-cors": "^0.1.2", - "@types/minio": "^7.0.13", "@types/node": "^17.0.33", "@types/nodemailer": "^6.4.4", "@types/nprogress": "^0.2.0", diff --git a/apps/builder/pages/api/storage/upload-url.ts b/apps/builder/pages/api/storage/upload-url.ts index 6abf21f4c..e682bc660 100644 --- a/apps/builder/pages/api/storage/upload-url.ts +++ b/apps/builder/pages/api/storage/upload-url.ts @@ -1,8 +1,7 @@ import { withSentry } from '@sentry/nextjs' -import { Client } from 'minio' import { NextApiRequest, NextApiResponse } from 'next' import { getSession } from 'next-auth/react' -import { badRequest, methodNotAllowed } from 'utils' +import { badRequest, generatePresignedUrl, methodNotAllowed } from 'utils' const handler = async ( req: NextApiRequest, @@ -21,27 +20,14 @@ const handler = async ( !process.env.S3_ACCESS_KEY || !process.env.S3_SECRET_KEY ) - return res.send({ - message: - 'S3 not properly configured. Missing one of those variables: S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY', - }) - - const s3 = new Client({ - endPoint: process.env.S3_ENDPOINT, - port: process.env.S3_PORT ? Number(process.env.S3_PORT) : undefined, - useSSL: - process.env.S3_SSL && process.env.S3_SSL === 'false' ? false : true, - accessKey: process.env.S3_ACCESS_KEY, - secretKey: process.env.S3_SECRET_KEY, - region: process.env.S3_REGION, - }) - + return badRequest( + res, + 'S3 not properly configured. Missing one of those variables: S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY' + ) const filePath = req.query.filePath as string | undefined - if (!filePath) return badRequest(res) - const presignedUrl = await s3.presignedPutObject( - process.env.S3_BUCKET ?? 'typebot', - filePath - ) + const fileType = req.query.fileType as string | undefined + if (!filePath || !fileType) return badRequest(res) + const presignedUrl = generatePresignedUrl({ fileType, filePath }) return res.status(200).send({ presignedUrl }) } diff --git a/apps/builder/playwright/tests/collaboration.spec.ts b/apps/builder/playwright/tests/collaboration.spec.ts index 911003534..e729d0f61 100644 --- a/apps/builder/playwright/tests/collaboration.spec.ts +++ b/apps/builder/playwright/tests/collaboration.spec.ts @@ -108,6 +108,6 @@ test.describe('Collaborator', () => { await page.click('text=Group #1', { force: true }) await expect(page.locator('input[value="Group #1"]')).toBeHidden() await page.goto(`/typebots/${typebotId}/results`) - await expect(page.locator('text="content199"')).toBeVisible() + await expect(page.locator('text="See logs" >> nth=10')).toBeVisible() }) }) diff --git a/apps/builder/playwright/tests/inputs/file.spec.ts b/apps/builder/playwright/tests/inputs/file.spec.ts new file mode 100644 index 000000000..b3ef57973 --- /dev/null +++ b/apps/builder/playwright/tests/inputs/file.spec.ts @@ -0,0 +1,51 @@ +import test, { expect } from '@playwright/test' +import { + createTypebots, + parseDefaultGroupWithBlock, +} from '../../services/database' +import { defaultFileInputOptions, InputBlockType } from 'models' +import { typebotViewer } from '../../services/selectorUtils' +import cuid from 'cuid' +import path from 'path' + +test('options should work', async ({ page }) => { + const typebotId = cuid() + await createTypebots([ + { + id: typebotId, + ...parseDefaultGroupWithBlock({ + type: InputBlockType.FILE, + options: defaultFileInputOptions, + }), + }, + ]) + + await page.goto(`/typebots/${typebotId}/edit`) + + await page.click('text=Preview') + await expect( + typebotViewer(page).locator(`text=Click to upload`) + ).toBeVisible() + await typebotViewer(page) + .locator(`input[type="file"]`) + .setInputFiles([path.join(__dirname, '../../fixtures/avatar.jpg')]) + await expect(typebotViewer(page).locator(`text=File uploaded`)).toBeVisible() + await page.click('text="Collect file"') + await page.click('text="Allow multiple files?"') + await page.fill('div[contenteditable=true]', 'Upload now!!') + await page.fill('[value="Upload"]', 'Go') + await page.click('text="Restart"') + await expect(typebotViewer(page).locator(`text="Upload now!!"`)).toBeVisible() + await typebotViewer(page) + .locator(`input[type="file"]`) + .setInputFiles([ + path.join(__dirname, '../../fixtures/avatar.jpg'), + path.join(__dirname, '../../fixtures/avatar.jpg'), + path.join(__dirname, '../../fixtures/avatar.jpg'), + ]) + await expect(typebotViewer(page).locator(`text="3"`)).toBeVisible() + await typebotViewer(page).locator('text="Go 3 files"').click() + await expect( + typebotViewer(page).locator(`text="3 files uploaded"`) + ).toBeVisible() +}) diff --git a/apps/builder/playwright/tests/integrations/sendEmail.spec.ts b/apps/builder/playwright/tests/integrations/sendEmail.spec.ts index b6267b487..49156cf7f 100644 --- a/apps/builder/playwright/tests/integrations/sendEmail.spec.ts +++ b/apps/builder/playwright/tests/integrations/sendEmail.spec.ts @@ -66,7 +66,7 @@ test.describe('Send email block', () => { 'email1@gmail.com, email2@gmail.com' ) await page.fill('[data-testid="subject-input"]', 'Email subject') - await page.click('text="Custom body?"') + await page.click('text="Custom content?"') await page.fill('[data-testid="body-input"]', 'Here is my email') await page.click('text=Preview') diff --git a/apps/builder/playwright/tests/settings.spec.ts b/apps/builder/playwright/tests/settings.spec.ts index 835fc216f..5f676555a 100644 --- a/apps/builder/playwright/tests/settings.spec.ts +++ b/apps/builder/playwright/tests/settings.spec.ts @@ -36,8 +36,6 @@ test.describe.parallel('Settings page', () => { ).toBeVisible() await page.click('text=Prefill input') await page.click('text=Theme') - await page.waitForTimeout(1000) - await page.click('text=Settings') await expect( typebotViewer(page).locator( `input[placeholder="${defaultTextInputOptions.labels.placeholder}"]` @@ -77,8 +75,12 @@ test.describe.parallel('Settings page', () => { } ) await page.goto(`/typebots/${typebotId}/settings`) + await expect( + typebotViewer(page).locator( + `input[placeholder="${defaultTextInputOptions.labels.placeholder}"]` + ) + ).toHaveValue('Baptiste') await page.click('button:has-text("Metadata")') - await page.waitForTimeout(1000) // Fav icon const favIconImg = page.locator('img >> nth=0') diff --git a/apps/builder/services/typebots/results.tsx b/apps/builder/services/typebots/results.tsx index 06b651e05..8d7bce169 100644 --- a/apps/builder/services/typebots/results.tsx +++ b/apps/builder/services/typebots/results.tsx @@ -1,11 +1,17 @@ -import { ResultWithAnswers, VariableWithValue, ResultHeaderCell } from 'models' +import { + ResultWithAnswers, + VariableWithValue, + ResultHeaderCell, + InputBlockType, +} from 'models' import useSWRInfinite from 'swr/infinite' import { stringify } from 'qs' import { Answer } from 'db' import { isDefined, isEmpty, sendRequest } from 'utils' import { fetcher } from 'services/utils' -import { HStack, Text } from '@chakra-ui/react' -import { CodeIcon, CalendarIcon } from 'assets/icons' +import { HStack, Text, Wrap, WrapItem } from '@chakra-ui/react' +import { CodeIcon, CalendarIcon, FileIcon } from 'assets/icons' +import { Link } from '@chakra-ui/react' import { BlockIcon } from 'components/editor/BlocksSideBar/BlockIcon' const paginationLimit = 50 @@ -147,28 +153,47 @@ const HeaderIcon = ({ header }: { header: ResultHeaderCell }) => export const convertResultsToTableData = ( results: ResultWithAnswers[] | undefined, - header: ResultHeaderCell[] -): { [key: string]: string }[] => + headerCells: ResultHeaderCell[] +): { [key: string]: JSX.Element | string }[] => (results ?? []).map((result) => ({ 'Submitted at': parseDateToReadable(result.createdAt), ...[...result.answers, ...result.variables].reduce<{ - [key: string]: string + [key: string]: JSX.Element | string }>((o, answerOrVariable) => { if ('groupId' in answerOrVariable) { const answer = answerOrVariable as Answer - const key = answer.variableId - ? header.find((h) => h.variableId === answer.variableId)?.label - : header.find((h) => h.blockId === answer.blockId)?.label - if (!key) return o + const header = answer.variableId + ? headerCells.find((h) => h.variableId === answer.variableId) + : headerCells.find((h) => h.blockId === answer.blockId) + if (!header || !header.blockId || !header.blockType) return o return { ...o, - [key]: answer.content, + [header.label]: parseContent(answer.content, header.blockType), } } const variable = answerOrVariable as VariableWithValue if (isDefined(o[variable.id])) return o - const key = header.find((h) => h.variableId === variable.id)?.label + const key = headerCells.find((h) => h.variableId === variable.id)?.label if (!key) return o return { ...o, [key]: variable.value } }, {}), })) + +const parseContent = (str: string, blockType: InputBlockType) => + blockType === InputBlockType.FILE ? parseFileContent(str) : str + +const parseFileContent = (str: string) => { + const fileNames = str.split(', ') + return ( + + {fileNames.map((name) => ( + + + + {name.split('/').pop()} + + + ))} + + ) +} diff --git a/apps/builder/services/typebots/typebots.ts b/apps/builder/services/typebots/typebots.ts index 286d5aee2..cc1087686 100644 --- a/apps/builder/services/typebots/typebots.ts +++ b/apps/builder/services/typebots/typebots.ts @@ -39,6 +39,7 @@ import { ConditionBlock, defaultPaymentInputOptions, defaultRatingInputOptions, + defaultFileInputOptions, } from 'models' import { Typebot } from 'models' import useSWR from 'swr' @@ -329,6 +330,8 @@ const parseDefaultBlockOptions = (type: BlockWithOptionsType): BlockOptions => { return defaultPaymentInputOptions case InputBlockType.RATING: return defaultRatingInputOptions + case InputBlockType.FILE: + return defaultFileInputOptions case LogicBlockType.SET_VARIABLE: return defaultSetVariablesOptions case LogicBlockType.REDIRECT: diff --git a/apps/builder/services/utils/utils.ts b/apps/builder/services/utils/utils.ts index 77b3c3c52..0db36481c 100644 --- a/apps/builder/services/utils/utils.ts +++ b/apps/builder/services/utils/utils.ts @@ -1,7 +1,6 @@ import imageCompression from 'browser-image-compression' import { Parser } from 'htmlparser2' import { Block, Typebot } from 'models' -import { sendRequest } from 'utils' export const fetcher = async (input: RequestInfo, init?: RequestInit) => { const res = await fetch(input, init) @@ -37,26 +36,6 @@ export const toKebabCase = (value: string) => { return matched.map((x) => x.toLowerCase()).join('-') } -export const uploadFile = async (file: File, filePath: string) => { - const { data } = await sendRequest<{ presignedUrl: string }>( - `/api/storage/upload-url?filePath=${encodeURIComponent(filePath)}` - ) - - if (!data?.presignedUrl) - return { - url: null, - } - - await fetch(data.presignedUrl, { - method: 'PUT', - body: file, - }) - - return { - url: data.presignedUrl.split('?')[0], - } -} - export const compressFile = async (file: File) => { const options = { maxSizeMB: 0.5, diff --git a/apps/docs/docs/self-hosting/configuration.mdx b/apps/docs/docs/self-hosting/configuration.mdx index 59a2b1893..933c225f6 100644 --- a/apps/docs/docs/self-hosting/configuration.mdx +++ b/apps/docs/docs/self-hosting/configuration.mdx @@ -287,6 +287,25 @@ Used when executing a Google Sheets block. Make sure to set the required scopes

+

S3 Storage (File upload input)

+

+ +Used for the file upload input. It can be any S3 compatible object storage service (Minio, Digital Oceans Space, AWS S3...) + +| Parameter | Default | Description | +| ------------- | ------- | -------------------------------------------------------------- | +| S3_ACCESS_KEY | -- | S3 access key. Also used to check if upload feature is enabled | +| S3_SECRET_KEY | -- | S3 secret key. | +| S3_BUCKET | typebot | Name of the bucket where assets will be uploaded in. | +| S3_PORT | -- | S3 Host port number | +| S3_ENDPOINT | -- | S3 secret key. | +| S3_SSL | true | Use SSL when establishing the connection. | +| S3_REGION | -- | S3 region. | + +Your bucket must have the following policy that tells S3 to allow public read when an object is located under the public folder: + +

+ :::note If you're self-hosting Typebot, [sponsoring me](https://github.com/sponsors/baptisteArno) is a great way to give back to the community and to contribute to the long-term sustainability of the project. diff --git a/apps/viewer/.env.local.example b/apps/viewer/.env.local.example index cecd54291..94b10502e 100644 --- a/apps/viewer/.env.local.example +++ b/apps/viewer/.env.local.example @@ -2,5 +2,12 @@ ENCRYPTION_SECRET=SgVkYp2s5v8y/B?E(H+MbQeThWmZq4t6 #256-bits secret (can be gene NEXT_PUBLIC_VIEWER_URL=http://localhost:3001 DATABASE_URL=postgresql://postgres:typebot@localhost:5432/typebot +S3_ACCESS_KEY=minio +S3_SECRET_KEY=minio123 +S3_BUCKET=typebot +S3_PORT=9000 +S3_ENDPOINT=localhost +S3_SSL=false + # For more configuration options check out: -# https://docs.typebot.io/self-hosting/configuration \ No newline at end of file +# https://docs.typebot.io/self-hosting/configuration diff --git a/apps/viewer/package.json b/apps/viewer/package.json index 999c4a33a..1306eaf81 100644 --- a/apps/viewer/package.json +++ b/apps/viewer/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@sentry/nextjs": "^6.19.7", + "aws-sdk": "^2.1152.0", "bot-engine": "*", "cors": "^2.8.5", "cuid": "^2.1.8", @@ -31,6 +32,7 @@ }, "devDependencies": { "@playwright/test": "^1.22.0", + "@types/aws-sdk": "^2.7.0", "@types/cors": "^2.8.12", "@types/google-spreadsheet": "^3.2.1", "@types/node": "^17.0.33", diff --git a/apps/viewer/pages/api/storage/upload-url.ts b/apps/viewer/pages/api/storage/upload-url.ts new file mode 100644 index 000000000..a99cfc262 --- /dev/null +++ b/apps/viewer/pages/api/storage/upload-url.ts @@ -0,0 +1,30 @@ +import { withSentry } from '@sentry/nextjs' +import { NextApiRequest, NextApiResponse } from 'next' +import { badRequest, generatePresignedUrl, methodNotAllowed } from 'utils' + +const handler = async ( + req: NextApiRequest, + res: NextApiResponse +): Promise => { + res.setHeader('Access-Control-Allow-Origin', '*') + if (req.method === 'GET') { + if ( + !process.env.S3_ENDPOINT || + !process.env.S3_ACCESS_KEY || + !process.env.S3_SECRET_KEY + ) + return badRequest( + res, + 'S3 not properly configured. Missing one of those variables: S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY' + ) + const filePath = req.query.filePath as string | undefined + const fileType = req.query.fileType as string | undefined + if (!filePath || !fileType) return badRequest(res) + const presignedUrl = generatePresignedUrl({ fileType, filePath }) + + return res.status(200).send({ presignedUrl }) + } + return methodNotAllowed(res) +} + +export default withSentry(handler) diff --git a/apps/viewer/pages/api/typebots/[typebotId]/webhookBlocks.ts b/apps/viewer/pages/api/typebots/[typebotId]/webhookBlocks.ts index a33d578b3..d2ddb2eb1 100644 --- a/apps/viewer/pages/api/typebots/[typebotId]/webhookBlocks.ts +++ b/apps/viewer/pages/api/typebots/[typebotId]/webhookBlocks.ts @@ -32,7 +32,6 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { })), ] }, []) - console.log({ blocks: emptyWebhookBlocks }) return res.send({ blocks: emptyWebhookBlocks }) } return methodNotAllowed(res) diff --git a/apps/viewer/playwright/fixtures/typebots/fileUpload.json b/apps/viewer/playwright/fixtures/typebots/fileUpload.json new file mode 100644 index 000000000..5f3e2f326 --- /dev/null +++ b/apps/viewer/playwright/fixtures/typebots/fileUpload.json @@ -0,0 +1,131 @@ +{ + "id": "cl45ojo7z01383q1av699t0qj", + "createdAt": "2022-06-08T14:22:14.879Z", + "updatedAt": "2022-06-08T16:19:32.893Z", + "icon": null, + "name": "My typebot", + "publishedTypebotId": "cl45ol3j8000f2e6gcifqf21t", + "folderId": null, + "groups": [ + { + "id": "cl45ojo7y00013q1aaysi2o6i", + "blocks": [ + { + "id": "cl45ojo7y00023q1aavrwd411", + "type": "start", + "label": "Start", + "groupId": "cl45ojo7y00013q1aaysi2o6i", + "outgoingEdgeId": "cl45ojxvc00082e6gw1xqnxpp" + } + ], + "title": "Start", + "graphCoordinates": { "x": 0, "y": 0 } + }, + { + "id": "cl45ojrrd00062e6g17tuu9t0", + "blocks": [ + { + "id": "cl45ojrre00072e6gk91592pj", + "type": "text", + "groupId": "cl45ojrrd00062e6g17tuu9t0", + "content": { + "html": "
Hey there, upload please
", + "richText": [ + { + "type": "p", + "children": [{ "text": "Hey there, upload please" }] + } + ], + "plainText": "Hey there, upload please" + } + }, + { + "id": "cl45ojzs300092e6gkno525c4", + "type": "file input", + "groupId": "cl45ojrrd00062e6g17tuu9t0", + "options": { + "labels": { + "button": "Upload", + "placeholder": "\n Click to upload\n or drag and drop
\n (size limit: 10MB)" + }, + "variableId": "vcl45ok77i000a2e6g79ye53a2", + "isMultipleAllowed": true + }, + "outgoingEdgeId": "cl45okfgz000d2e6g7z3wnqgq" + } + ], + "title": "Group #1", + "graphCoordinates": { "x": 416, "y": 98 } + }, + { + "id": "cl45ok963000b2e6g2ky0wkvx", + "blocks": [ + { + "id": "cl45ok963000c2e6g9snvbhw4", + "type": "text", + "groupId": "cl45ok963000b2e6g2ky0wkvx", + "content": { + "html": "
Thank you!
", + "richText": [ + { "type": "p", "children": [{ "text": "Thank you!" }] } + ], + "plainText": "Thank you!" + } + } + ], + "title": "Group #2", + "graphCoordinates": { "x": 863, "y": 249 } + } + ], + "variables": [{ "id": "vcl45ok77i000a2e6g79ye53a2", "name": "Files" }], + "edges": [ + { + "id": "cl45ojxvc00082e6gw1xqnxpp", + "to": { "groupId": "cl45ojrrd00062e6g17tuu9t0" }, + "from": { + "blockId": "cl45ojo7y00023q1aavrwd411", + "groupId": "cl45ojo7y00013q1aaysi2o6i" + } + }, + { + "id": "cl45okfgz000d2e6g7z3wnqgq", + "to": { "groupId": "cl45ok963000b2e6g2ky0wkvx" }, + "from": { + "blockId": "cl45ojzs300092e6gkno525c4", + "groupId": "cl45ojrrd00062e6g17tuu9t0" + } + } + ], + "theme": { + "chat": { + "inputs": { + "color": "#303235", + "backgroundColor": "#FFFFFF", + "placeholderColor": "#9095A0" + }, + "buttons": { "color": "#FFFFFF", "backgroundColor": "#0042DA" }, + "hostAvatar": { + "url": "https://avatars.githubusercontent.com/u/16015833?v=4", + "isEnabled": true + }, + "hostBubbles": { "color": "#303235", "backgroundColor": "#F7F8FF" }, + "guestBubbles": { "color": "#FFFFFF", "backgroundColor": "#FF8E21" } + }, + "general": { "font": "Open Sans", "background": { "type": "None" } } + }, + "settings": { + "general": { + "isBrandingEnabled": true, + "isInputPrefillEnabled": true, + "isHideQueryParamsEnabled": true, + "isNewResultOnRefreshEnabled": false + }, + "metadata": { + "description": "Build beautiful conversational forms and embed them directly in your applications without a line of code. Triple your response rate and collect answers that has more value compared to a traditional form." + }, + "typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 } + }, + "publicId": "my-typebot-699t0qj", + "customDomain": null, + "workspaceId": "proWorkspace" +} diff --git a/apps/viewer/playwright/services/database.ts b/apps/viewer/playwright/services/database.ts index 3c0ecc60e..c4a9a7af0 100644 --- a/apps/viewer/playwright/services/database.ts +++ b/apps/viewer/playwright/services/database.ts @@ -8,7 +8,7 @@ import { Typebot, Webhook, } from 'models' -import { PrismaClient, WorkspaceRole } from 'db' +import { Plan, PrismaClient, WorkspaceRole } from 'db' import { readFileSync } from 'fs' import { encrypt } from 'utils' @@ -46,6 +46,7 @@ export const createUser = () => create: { id: proWorkspaceId, name: 'Pro workspace', + plan: Plan.PRO, }, }, }, diff --git a/apps/viewer/playwright/tests/fileUpload.spec.ts b/apps/viewer/playwright/tests/fileUpload.spec.ts new file mode 100644 index 000000000..567af06f7 --- /dev/null +++ b/apps/viewer/playwright/tests/fileUpload.spec.ts @@ -0,0 +1,39 @@ +import test, { expect } from '@playwright/test' +import cuid from 'cuid' +import path from 'path' +import { typebotViewer } from '../services/selectorUtils' +import { importTypebotInDatabase } from '../services/database' + +test('should work as expected', async ({ page, context }) => { + const typebotId = cuid() + await importTypebotInDatabase( + path.join(__dirname, '../fixtures/typebots/fileUpload.json'), + { id: typebotId, publicId: `${typebotId}-public` } + ) + await page.goto(`/${typebotId}-public`) + await typebotViewer(page) + .locator(`input[type="file"]`) + .setInputFiles([ + path.join(__dirname, '../fixtures/typebots/api.json'), + path.join(__dirname, '../fixtures/typebots/fileUpload.json'), + path.join(__dirname, '../fixtures/typebots/hugeGroup.json'), + ]) + await expect(typebotViewer(page).locator(`text="3"`)).toBeVisible() + await typebotViewer(page).locator('text="Upload 3 files"').click() + await expect( + typebotViewer(page).locator(`text="3 files uploaded"`) + ).toBeVisible() + await page.goto(`http://localhost:3000/typebots/${typebotId}/results`) + await expect(page.locator('text="api.json"')).toHaveAttribute( + 'href', + /.+\/api\.json/ + ) + await expect(page.locator('text="fileUpload.json"')).toHaveAttribute( + 'href', + /.+\/fileUpload\.json/ + ) + await expect(page.locator('text="api.json"')).toHaveAttribute( + 'href', + /.+\/api\.json/ + ) +}) diff --git a/packages/bot-engine/src/assets/style.css b/packages/bot-engine/src/assets/style.css index ed1741a39..1e24fde8f 100644 --- a/packages/bot-engine/src/assets/style.css +++ b/packages/bot-engine/src/assets/style.css @@ -218,3 +218,26 @@ textarea { .rating-icon-container:active svg { filter: brightness(0.75); } + +.upload-progress-bar { + background-color: var(--typebot-button-bg-color); +} + +.total-files-indicator { + background-color: var(--typebot-button-bg-color); + color: var(--typebot-button-color); + font-size: 10px; +} + +.typebot-upload-input { + transition: border-color 100ms ease-out; +} + +.typebot-upload-input.dragging-over { + border-color: var(--typebot-button-bg-color); +} + +.secondary-button { + background-color: var(--typebot-host-bubble-bg-color); + color: var(--typebot-host-bubble-color); +} diff --git a/packages/bot-engine/src/components/ChatGroup/ChatBlock/InputChatBlock.tsx b/packages/bot-engine/src/components/ChatGroup/ChatBlock/InputChatBlock.tsx index 391207efa..450035504 100644 --- a/packages/bot-engine/src/components/ChatGroup/ChatBlock/InputChatBlock.tsx +++ b/packages/bot-engine/src/components/ChatGroup/ChatBlock/InputChatBlock.tsx @@ -11,6 +11,9 @@ import { parseVariables } from '../../../services/variable' import { isInputValid } from 'services/inputs' import { PaymentForm } from './inputs/PaymentForm' import { RatingForm } from './inputs/RatingForm' +import { FileUploadForm } from './inputs/FileUploadForm' + +export type InputSubmitContent = { label?: string; value: string } export const InputChatBlock = ({ block, @@ -21,7 +24,10 @@ export const InputChatBlock = ({ block: InputBlock hasGuestAvatar: boolean hasAvatar: boolean - onTransitionEnd: (answerContent?: string, isRetry?: boolean) => void + onTransitionEnd: ( + answerContent?: InputSubmitContent, + isRetry?: boolean + ) => void }) => { const { typebot } = useTypebot() const { addAnswer } = useAnswers() @@ -34,17 +40,17 @@ export const InputChatBlock = ({ ? variableId && typebot.variables.find(byId(variableId))?.value : undefined - const handleSubmit = async (content: string) => { - setAnswer(content) - const isRetry = !isInputValid(content, block.type) + const handleSubmit = async ({ label, value }: InputSubmitContent) => { + setAnswer(label ?? value) + const isRetry = !isInputValid(value, block.type) if (!isRetry && addAnswer) await addAnswer({ blockId: block.id, groupId: block.groupId, - content, + content: value, variableId: variableId ?? null, }) - if (!isEditting) onTransitionEnd(content, isRetry) + if (!isEditting) onTransitionEnd({ label, value }, isRetry) setIsEditting(false) } @@ -87,7 +93,7 @@ const Input = ({ hasGuestAvatar, }: { block: InputBlock - onSubmit: (value: string) => void + onSubmit: (value: InputSubmitContent) => void defaultValue?: string hasGuestAvatar: boolean }) => { @@ -113,10 +119,14 @@ const Input = ({ return ( onSubmit(block.options.labels.success ?? 'Success')} + onSuccess={() => + onSubmit({ value: block.options.labels.success ?? 'Success' }) + } /> ) case InputBlockType.RATING: return + case InputBlockType.FILE: + return } } diff --git a/packages/bot-engine/src/components/ChatGroup/ChatBlock/index.tsx b/packages/bot-engine/src/components/ChatGroup/ChatBlock/index.tsx deleted file mode 100644 index e00b48c0a..000000000 --- a/packages/bot-engine/src/components/ChatGroup/ChatBlock/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { InputChatBlock } from './InputChatBlock' diff --git a/packages/bot-engine/src/components/ChatGroup/ChatBlock/inputs/ChoiceForm.tsx b/packages/bot-engine/src/components/ChatGroup/ChatBlock/inputs/ChoiceForm.tsx index 5a73cd445..13722cb62 100644 --- a/packages/bot-engine/src/components/ChatGroup/ChatBlock/inputs/ChoiceForm.tsx +++ b/packages/bot-engine/src/components/ChatGroup/ChatBlock/inputs/ChoiceForm.tsx @@ -1,11 +1,12 @@ import { useAnswers } from 'contexts/AnswersContext' import { ChoiceInputBlock } from 'models' import React, { useState } from 'react' +import { InputSubmitContent } from '../InputChatBlock' import { SendButton } from './SendButton' type ChoiceFormProps = { block: ChoiceInputBlock - onSubmit: (value: string) => void + onSubmit: (value: InputSubmitContent) => void } export const ChoiceForm = ({ block, onSubmit }: ChoiceFormProps) => { @@ -15,7 +16,7 @@ export const ChoiceForm = ({ block, onSubmit }: ChoiceFormProps) => { const handleClick = (itemIndex: number) => (e: React.MouseEvent) => { e.preventDefault() if (block.options?.isMultipleChoice) toggleSelectedItemIndex(itemIndex) - else onSubmit(block.items[itemIndex].content ?? '') + else onSubmit({ value: block.items[itemIndex].content ?? '' }) } const toggleSelectedItemIndex = (itemIndex: number) => { @@ -29,11 +30,11 @@ export const ChoiceForm = ({ block, onSubmit }: ChoiceFormProps) => { } const handleSubmit = () => - onSubmit( - selectedIndices + onSubmit({ + value: selectedIndices .map((itemIndex) => block.items[itemIndex].content) - .join(', ') - ) + .join(', '), + }) const isUniqueFirstButton = resultValues && diff --git a/packages/bot-engine/src/components/ChatGroup/ChatBlock/inputs/DateForm.tsx b/packages/bot-engine/src/components/ChatGroup/ChatBlock/inputs/DateForm.tsx index 839a3d479..ccbf8d435 100644 --- a/packages/bot-engine/src/components/ChatGroup/ChatBlock/inputs/DateForm.tsx +++ b/packages/bot-engine/src/components/ChatGroup/ChatBlock/inputs/DateForm.tsx @@ -1,9 +1,10 @@ import { DateInputOptions } from 'models' import React, { useState } from 'react' +import { InputSubmitContent } from '../InputChatBlock' import { SendButton } from './SendButton' type DateInputProps = { - onSubmit: (inputValue: `${string} to ${string}` | string) => void + onSubmit: (inputValue: InputSubmitContent) => void options?: DateInputOptions } @@ -23,9 +24,11 @@ export const DateForm = ({ onSubmit={(e) => { if (inputValues.from === '' && inputValues.to === '') return e.preventDefault() - onSubmit( - `${inputValues.from}${isRange ? ` to ${inputValues.to}` : ''}` - ) + onSubmit({ + value: `${inputValues.from}${ + isRange ? ` to ${inputValues.to}` : '' + }`, + }) }} >
diff --git a/packages/bot-engine/src/components/ChatGroup/ChatBlock/inputs/FileUploadForm.tsx b/packages/bot-engine/src/components/ChatGroup/ChatBlock/inputs/FileUploadForm.tsx new file mode 100644 index 000000000..7370dfee9 --- /dev/null +++ b/packages/bot-engine/src/components/ChatGroup/ChatBlock/inputs/FileUploadForm.tsx @@ -0,0 +1,236 @@ +import { useAnswers } from 'contexts/AnswersContext' +import { useTypebot } from 'contexts/TypebotContext' +import { FileInputBlock } from 'models' +import React, { ChangeEvent, FormEvent, useState, DragEvent } from 'react' +import { uploadFiles } from 'utils' +import { InputSubmitContent } from '../InputChatBlock' +import { SendButton, Spinner } from './SendButton' + +type Props = { + block: FileInputBlock + onSubmit: (url: InputSubmitContent) => void +} + +const tenMB = 10 * 1024 * 1024 +export const FileUploadForm = ({ + block: { + id, + options: { isMultipleAllowed, labels }, + }, + onSubmit, +}: Props) => { + const { isPreview } = useTypebot() + const { resultId } = useAnswers() + const [selectedFiles, setSelectedFiles] = useState([]) + const [isUploading, setIsUploading] = useState(false) + const [uploadProgressPercent, setUploadProgressPercent] = useState(20) + const [isDraggingOver, setIsDraggingOver] = useState(false) + const [errorMessage, setErrorMessage] = useState() + + const handleFileChange = async (e: ChangeEvent) => { + if (!e.target.files) return + onNewFiles(e.target.files) + } + + const onNewFiles = (files: FileList) => { + setErrorMessage(undefined) + const newFiles = Array.from(files) + if (newFiles.some((file) => file.size > tenMB)) + return setErrorMessage('A file is larger than 10MB') + if (!isMultipleAllowed && files) return startSingleFileUpload(newFiles[0]) + setSelectedFiles([...selectedFiles, ...newFiles]) + } + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + if (selectedFiles.length === 0) return + startFilesUpload(selectedFiles) + } + + const startSingleFileUpload = async (file: File) => { + if (isPreview) + return onSubmit({ + label: `File uploaded`, + value: 'http://fake-upload-url.com', + }) + setIsUploading(true) + const urls = await uploadFiles({ + files: [ + { + file, + path: `public/results/${resultId}/${id}`, + }, + ], + }) + setIsUploading(false) + if (urls.length) return onSubmit({ label: `File uploaded`, value: urls[0] }) + setErrorMessage('An error occured while uploading the file') + } + const startFilesUpload = async (files: File[]) => { + if (isPreview) + return onSubmit({ + label: `${files.length} file${files.length > 1 ? 's' : ''} uploaded`, + value: files + .map((_, idx) => `http://fake-upload-url.com/${idx}`) + .join(', '), + }) + setIsUploading(true) + const urls = await uploadFiles({ + files: files.map((file) => ({ + file: file, + path: `public/results/${resultId}/${id}/${file.name}`, + })), + onUploadProgress: setUploadProgressPercent, + }) + if (urls.length !== files.length) + return setErrorMessage('An error occured while uploading the files') + onSubmit({ + label: `${urls.length} file${urls.length > 1 ? 's' : ''} uploaded`, + value: urls.join(', '), + }) + } + + const handleDragOver = (e: DragEvent) => { + e.preventDefault() + setIsDraggingOver(true) + } + + const handleDragLeave = () => setIsDraggingOver(false) + + const handleDropFile = (e: DragEvent) => { + e.preventDefault() + e.stopPropagation() + if (!e.dataTransfer.files) return + onNewFiles(e.dataTransfer.files) + } + + const clearFiles = () => setSelectedFiles([]) + + return ( +
+