feat(editor): ✨ Add file upload input
This commit is contained in:
@ -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 <CreditCardIcon color="orange.500" {...props} />
|
||||
case InputBlockType.RATING:
|
||||
return <StarIcon color="orange.500" {...props} />
|
||||
case InputBlockType.FILE:
|
||||
return <UploadIcon color="orange.500" {...props} />
|
||||
case LogicBlockType.SET_VARIABLE:
|
||||
return <EditIcon color="purple.500" {...props} />
|
||||
case LogicBlockType.CONDITION:
|
||||
|
@ -43,6 +43,12 @@ export const BlockTypeLabel = ({ type }: Props): JSX.Element => {
|
||||
return <Text>Payment</Text>
|
||||
case InputBlockType.RATING:
|
||||
return <Text>Rating</Text>
|
||||
case InputBlockType.FILE:
|
||||
return (
|
||||
<Tooltip label="Upload Files">
|
||||
<Text>File</Text>
|
||||
</Tooltip>
|
||||
)
|
||||
case LogicBlockType.SET_VARIABLE:
|
||||
return <Text>Set variable</Text>
|
||||
case LogicBlockType.CONDITION:
|
||||
|
@ -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' },
|
||||
})
|
||||
)
|
||||
|
@ -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 <RatingInputContent block={block} />
|
||||
}
|
||||
case InputBlockType.FILE: {
|
||||
return <FileInputContent options={block.options} />
|
||||
}
|
||||
case LogicBlockType.SET_VARIABLE: {
|
||||
return <SetVariableContent block={block} />
|
||||
}
|
||||
|
@ -0,0 +1,12 @@
|
||||
import { Text } from '@chakra-ui/react'
|
||||
import { FileInputOptions } from 'models'
|
||||
|
||||
type Props = {
|
||||
options: FileInputOptions
|
||||
}
|
||||
|
||||
export const FileInputContent = ({ options: { isMultipleAllowed } }: Props) => (
|
||||
<Text noOfLines={0} pr="6">
|
||||
Collect {isMultipleAllowed ? 'files' : 'file'}
|
||||
</Text>
|
||||
)
|
@ -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 (
|
||||
<FileInputSettings
|
||||
options={block.options}
|
||||
onOptionsChange={handleOptionsChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case LogicBlockType.SET_VARIABLE: {
|
||||
return (
|
||||
<SetVariableSettings
|
||||
|
@ -0,0 +1,64 @@
|
||||
import { FormLabel, Stack } from '@chakra-ui/react'
|
||||
import { CodeEditor } from 'components/shared/CodeEditor'
|
||||
import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
|
||||
import { Input } from 'components/shared/Textbox'
|
||||
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
|
||||
import { FileInputOptions, Variable } from 'models'
|
||||
import React from 'react'
|
||||
|
||||
type Props = {
|
||||
options: FileInputOptions
|
||||
onOptionsChange: (options: FileInputOptions) => 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 (
|
||||
<Stack spacing={4}>
|
||||
<SwitchWithLabel
|
||||
id="switch"
|
||||
label="Allow multiple files?"
|
||||
initialValue={options.isMultipleAllowed}
|
||||
onCheckChange={handleLongChange}
|
||||
/>
|
||||
<Stack>
|
||||
<FormLabel mb="0">Placeholder:</FormLabel>
|
||||
<CodeEditor
|
||||
lang="html"
|
||||
onChange={handlePlaceholderLabelChange}
|
||||
value={options.labels.placeholder}
|
||||
height={'100px'}
|
||||
withVariableButton={false}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack>
|
||||
<FormLabel mb="0" htmlFor="button">
|
||||
Button label:
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="button"
|
||||
defaultValue={options.labels.button}
|
||||
onChange={handleButtonLabelChange}
|
||||
withVariableButton={false}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack>
|
||||
<FormLabel mb="0" htmlFor="variable">
|
||||
Save upload URL{options.isMultipleAllowed ? 's' : ''} in a variable:
|
||||
</FormLabel>
|
||||
<VariableSearchInput
|
||||
initialVariableId={options.variableId}
|
||||
onSelectVariable={handleVariableChange}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)
|
||||
}
|
@ -171,7 +171,12 @@ export const TypebotHeader = () => {
|
||||
<HStack right="40px" pos="absolute" display={['none', 'flex']}>
|
||||
<CollaborationMenuButton />
|
||||
{router.pathname.includes('/edit') && isNotDefined(rightPanel) && (
|
||||
<Button onClick={handlePreviewClick}>Preview</Button>
|
||||
<Button
|
||||
onClick={handlePreviewClick}
|
||||
isLoading={isNotDefined(typebot)}
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
)}
|
||||
<PublishButton />
|
||||
</HStack>
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
|
@ -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 })
|
||||
}
|
||||
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
51
apps/builder/playwright/tests/inputs/file.spec.ts
Normal file
51
apps/builder/playwright/tests/inputs/file.spec.ts
Normal file
@ -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]', '<strong>Upload now!!</strong>')
|
||||
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()
|
||||
})
|
@ -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')
|
||||
|
@ -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')
|
||||
|
@ -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 (
|
||||
<Wrap maxW="300px">
|
||||
{fileNames.map((name) => (
|
||||
<HStack as={WrapItem} key={name}>
|
||||
<FileIcon />
|
||||
<Link href={name} isExternal color="blue.500">
|
||||
{name.split('/').pop()}
|
||||
</Link>
|
||||
</HStack>
|
||||
))}
|
||||
</Wrap>
|
||||
)
|
||||
}
|
||||
|
@ -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:
|
||||
|
@ -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,
|
||||
|
@ -287,6 +287,25 @@ Used when executing a Google Sheets block. Make sure to set the required scopes
|
||||
|
||||
</p></details>
|
||||
|
||||
<details><summary><h3>S3 Storage (File upload input)</h3></summary>
|
||||
<p>
|
||||
|
||||
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:
|
||||
|
||||
</p></details>
|
||||
|
||||
:::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.
|
||||
|
||||
|
@ -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
|
||||
# https://docs.typebot.io/self-hosting/configuration
|
||||
|
@ -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",
|
||||
|
30
apps/viewer/pages/api/storage/upload-url.ts
Normal file
30
apps/viewer/pages/api/storage/upload-url.ts
Normal file
@ -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<void> => {
|
||||
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)
|
@ -32,7 +32,6 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
})),
|
||||
]
|
||||
}, [])
|
||||
console.log({ blocks: emptyWebhookBlocks })
|
||||
return res.send({ blocks: emptyWebhookBlocks })
|
||||
}
|
||||
return methodNotAllowed(res)
|
||||
|
131
apps/viewer/playwright/fixtures/typebots/fileUpload.json
Normal file
131
apps/viewer/playwright/fixtures/typebots/fileUpload.json
Normal file
@ -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": "<div>Hey there, upload please</div>",
|
||||
"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": "<strong>\n Click to upload\n </strong> or drag and drop<br>\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": "<div>Thank you!</div>",
|
||||
"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"
|
||||
}
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
39
apps/viewer/playwright/tests/fileUpload.spec.ts
Normal file
39
apps/viewer/playwright/tests/fileUpload.spec.ts
Normal file
@ -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/
|
||||
)
|
||||
})
|
Reference in New Issue
Block a user