2
0

feat(editor): Add file upload input

This commit is contained in:
Baptiste Arnaud
2022-06-12 17:34:33 +02:00
parent d4c52d47b3
commit 75365a0d82
48 changed files with 1022 additions and 587 deletions

View File

@ -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:

View File

@ -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:

View File

@ -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' },
})
)

View File

@ -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} />
}

View File

@ -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>
)

View File

@ -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

View File

@ -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>
)
}

View File

@ -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>

View File

@ -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)
}

View File

@ -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)

View File

@ -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

View File

@ -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",

View File

@ -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 })
}

View File

@ -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()
})
})

View 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()
})

View File

@ -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')

View File

@ -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')

View File

@ -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>
)
}

View File

@ -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:

View File

@ -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,