2
0

feat(engine): Add Rating input

This commit is contained in:
Baptiste Arnaud
2022-06-07 19:09:08 +02:00
parent 301097623b
commit b1aecf843b
19 changed files with 455 additions and 28 deletions

View File

@ -457,3 +457,9 @@ export const PlayIcon = (props: IconProps) => (
<polygon points="5 3 19 12 5 21 5 3"></polygon>
</Icon>
)
export const StarIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
</Icon>
)

View File

@ -18,6 +18,7 @@ import {
NumberIcon,
PhoneIcon,
SendEmailIcon,
StarIcon,
TextIcon,
WebhookIcon,
} from 'assets/icons'
@ -65,6 +66,8 @@ export const StepIcon = ({ type, ...props }: StepIconProps) => {
return <CheckSquareIcon color="orange.500" {...props} />
case InputStepType.PAYMENT:
return <CreditCardIcon color="orange.500" {...props} />
case InputStepType.RATING:
return <StarIcon color="orange.500" {...props} />
case LogicStepType.SET_VARIABLE:
return <EditIcon color="purple.500" {...props} />
case LogicStepType.CONDITION:
@ -91,7 +94,5 @@ export const StepIcon = ({ type, ...props }: StepIconProps) => {
return <SendEmailIcon {...props} />
case 'start':
return <FlagIcon {...props} />
default:
return <></>
}
}

View File

@ -10,8 +10,10 @@ import React from 'react'
type Props = { type: StepType }
export const StepTypeLabel = ({ type }: Props) => {
export const StepTypeLabel = ({ type }: Props): JSX.Element => {
switch (type) {
case 'start':
return <Text>Start</Text>
case BubbleStepType.TEXT:
case InputStepType.TEXT:
return <Text>Text</Text>
@ -39,6 +41,8 @@ export const StepTypeLabel = ({ type }: Props) => {
return <Text>Button</Text>
case InputStepType.PAYMENT:
return <Text>Payment</Text>
case InputStepType.RATING:
return <Text>Rating</Text>
case LogicStepType.SET_VARIABLE:
return <Text>Set variable</Text>
case LogicStepType.CONDITION:
@ -79,7 +83,5 @@ export const StepTypeLabel = ({ type }: Props) => {
return <Text>Pabbly</Text>
case IntegrationStepType.EMAIL:
return <Text>Email</Text>
default:
return <></>
}
}

View File

@ -15,7 +15,7 @@ import {
LogicStepType,
Step,
StepOptions,
TextBubbleStep,
StepWithOptions,
Webhook,
} from 'models'
import { useRef } from 'react'
@ -33,6 +33,7 @@ import { GoogleAnalyticsSettings } from './bodies/GoogleAnalyticsSettings'
import { GoogleSheetsSettingsBody } from './bodies/GoogleSheetsSettingsBody'
import { PaymentSettings } from './bodies/PaymentSettings'
import { PhoneNumberSettingsBody } from './bodies/PhoneNumberSettingsBody'
import { RatingInputSettings } from './bodies/RatingInputSettingsBody'
import { RedirectSettings } from './bodies/RedirectSettings'
import { SendEmailSettings } from './bodies/SendEmailSettings'
import { SetVariableSettings } from './bodies/SetVariableSettings'
@ -41,7 +42,7 @@ import { WebhookSettings } from './bodies/WebhookSettings'
import { ZapierSettings } from './bodies/ZapierSettings'
type Props = {
step: Exclude<Step, TextBubbleStep>
step: StepWithOptions | ConditionStep
webhook?: Webhook
onExpandClick: () => void
onStepChange: (updates: Partial<Step>) => void
@ -87,10 +88,10 @@ export const StepSettings = ({
step,
onStepChange,
}: {
step: Step
step: StepWithOptions | ConditionStep
webhook?: Webhook
onStepChange: (step: Partial<Step>) => void
}) => {
}): JSX.Element => {
const handleOptionsChange = (options: StepOptions) => {
onStepChange({ options } as Partial<Step>)
}
@ -164,6 +165,14 @@ export const StepSettings = ({
/>
)
}
case InputStepType.RATING: {
return (
<RatingInputSettings
options={step.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case LogicStepType.SET_VARIABLE: {
return (
<SetVariableSettings
@ -258,8 +267,5 @@ export const StepSettings = ({
/>
)
}
default: {
return <></>
}
}
}

View File

@ -0,0 +1,149 @@
import { FormLabel, Stack } from '@chakra-ui/react'
import { DropdownList } from 'components/shared/DropdownList'
import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
import { Input } from 'components/shared/Textbox'
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
import { RatingInputOptions, Variable } from 'models'
import React from 'react'
type RatingInputSettingsProps = {
options: RatingInputOptions
onOptionsChange: (options: RatingInputOptions) => void
}
export const RatingInputSettings = ({
options,
onOptionsChange,
}: RatingInputSettingsProps) => {
const handleLengthChange = (length: number) =>
onOptionsChange({ ...options, length })
const handleTypeChange = (buttonType: 'Icons' | 'Numbers') =>
onOptionsChange({ ...options, buttonType })
const handleCustomIconCheck = (isEnabled: boolean) =>
onOptionsChange({
...options,
customIcon: { ...options.customIcon, isEnabled },
})
const handleIconSvgChange = (svg: string) =>
onOptionsChange({ ...options, customIcon: { ...options.customIcon, svg } })
const handleLeftLabelChange = (left: string) =>
onOptionsChange({ ...options, labels: { ...options.labels, left } })
const handleMiddleLabelChange = (middle: string) =>
onOptionsChange({ ...options, labels: { ...options.labels, middle } })
const handleRightLabelChange = (right: string) =>
onOptionsChange({ ...options, labels: { ...options.labels, right } })
const handleButtonLabelChange = (button: string) =>
onOptionsChange({ ...options, labels: { ...options.labels, button } })
const handleVariableChange = (variable?: Variable) =>
onOptionsChange({ ...options, variableId: variable?.id })
return (
<Stack spacing={4}>
<Stack>
<FormLabel mb="0" htmlFor="button">
Maximum:
</FormLabel>
<DropdownList
onItemSelect={handleLengthChange}
items={[3, 4, 5, 6, 7, 8, 9, 10]}
currentItem={options.length}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="button">
Type:
</FormLabel>
<DropdownList
onItemSelect={handleTypeChange}
items={['Icons', 'Numbers']}
currentItem={options.buttonType}
/>
</Stack>
{options.buttonType === 'Icons' && (
<SwitchWithLabel
id="switch"
label="Custom icon?"
initialValue={options.customIcon.isEnabled}
onCheckChange={handleCustomIconCheck}
/>
)}
{options.buttonType === 'Icons' && options.customIcon.isEnabled && (
<Stack>
<FormLabel mb="0" htmlFor="svg">
Icon SVG:
</FormLabel>
<Input
id="svg"
defaultValue={options.customIcon.svg}
onChange={handleIconSvgChange}
placeholder="<svg>...</svg>"
/>
</Stack>
)}
<Stack>
<FormLabel mb="0" htmlFor="button">
1 label:
</FormLabel>
<Input
id="button"
defaultValue={options.labels.left}
onChange={handleLeftLabelChange}
placeholder="Not likely at all"
/>
</Stack>
{options.length >= 4 && (
<Stack>
<FormLabel mb="0" htmlFor="button">
{Math.floor(options.length / 2)} label:
</FormLabel>
<Input
id="button"
defaultValue={options.labels.middle}
onChange={handleMiddleLabelChange}
placeholder="Neutral"
/>
</Stack>
)}
<Stack>
<FormLabel mb="0" htmlFor="button">
{options.length} label:
</FormLabel>
<Input
id="button"
defaultValue={options.labels.right}
onChange={handleRightLabelChange}
placeholder="Extremely likely"
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="button">
Button label:
</FormLabel>
<Input
id="button"
defaultValue={options.labels.button}
onChange={handleButtonLabelChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="variable">
Save answer in a variable:
</FormLabel>
<VariableSearchInput
initialVariableId={options.variableId}
onSelectVariable={handleVariableChange}
/>
</Stack>
</Stack>
)
}

View File

@ -9,8 +9,10 @@ import React, { useEffect, useRef, useState } from 'react'
import {
BubbleStep,
BubbleStepContent,
ConditionStep,
DraggableStep,
Step,
StepWithOptions,
TextBubbleContent,
TextBubbleStep,
} from 'models'
@ -210,11 +212,16 @@ export const StepNode = ({
</Flex>
</PopoverTrigger>
{hasSettingsPopover(step) && (
<SettingsPopoverContent
step={step}
onExpandClick={handleExpandClick}
onStepChange={handleStepUpdate}
/>
<>
<SettingsPopoverContent
step={step}
onExpandClick={handleExpandClick}
onStepChange={handleStepUpdate}
/>
<SettingsModal isOpen={isModalOpen} onClose={handleModalClose}>
<StepSettings step={step} onStepChange={handleStepUpdate} />
</SettingsModal>
</>
)}
{isMediaBubbleStep(step) && (
<MediaBubblePopoverContent
@ -222,17 +229,15 @@ export const StepNode = ({
onContentChange={handleContentChange}
/>
)}
<SettingsModal isOpen={isModalOpen} onClose={handleModalClose}>
<StepSettings step={step} onStepChange={handleStepUpdate} />
</SettingsModal>
</Popover>
)}
</ContextMenu>
)
}
const hasSettingsPopover = (step: Step): step is Exclude<Step, BubbleStep> =>
!isBubbleStep(step)
const hasSettingsPopover = (
step: Step
): step is StepWithOptions | ConditionStep => !isBubbleStep(step)
const isMediaBubbleStep = (
step: Step

View File

@ -22,6 +22,7 @@ import { ConfigureContent } from './contents/ConfigureContent'
import { ImageBubbleContent } from './contents/ImageBubbleContent'
import { PaymentInputContent } from './contents/PaymentInputContent'
import { PlaceholderContent } from './contents/PlaceholderContent'
import { RatingInputContent } from './contents/RatingInputContent'
import { SendEmailContent } from './contents/SendEmailContent'
import { TypebotLinkContent } from './contents/TypebotLinkContent'
import { ProviderWebhookContent } from './contents/ZapierContent'
@ -30,7 +31,7 @@ type Props = {
step: Step | StartStep
indices: StepIndices
}
export const StepNodeContent = ({ step, indices }: Props) => {
export const StepNodeContent = ({ step, indices }: Props): JSX.Element => {
if (isInputStep(step) && !isChoiceInput(step) && step.options.variableId) {
return <WithVariableContent step={step} />
}
@ -72,6 +73,9 @@ export const StepNodeContent = ({ step, indices }: Props) => {
case InputStepType.PAYMENT: {
return <PaymentInputContent step={step} />
}
case InputStepType.RATING: {
return <RatingInputContent step={step} />
}
case LogicStepType.SET_VARIABLE: {
return <SetVariableContent step={step} />
}
@ -144,8 +148,5 @@ export const StepNodeContent = ({ step, indices }: Props) => {
case 'start': {
return <Text>Start</Text>
}
default: {
return <Text>No input</Text>
}
}
}

View File

@ -0,0 +1,12 @@
import { Text } from '@chakra-ui/react'
import { RatingInputStep } from 'models'
type Props = {
step: RatingInputStep
}
export const RatingInputContent = ({ step }: Props) => (
<Text noOfLines={0} pr="6">
Rate from 1 to {step.options.length}
</Text>
)

View File

@ -0,0 +1,65 @@
import test, { expect } from '@playwright/test'
import {
createTypebots,
parseDefaultBlockWithStep,
} from '../../services/database'
import { defaultRatingInputOptions, InputStepType } from 'models'
import { typebotViewer } from '../../services/selectorUtils'
import cuid from 'cuid'
const boxSvg = `<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
<line x1="12" y1="22.08" x2="12" y2="12"></line>
</svg>`
test('options should work', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultBlockWithStep({
type: InputStepType.RATING,
options: defaultRatingInputOptions,
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview')
await expect(typebotViewer(page).locator(`text=Send`)).toBeHidden()
await typebotViewer(page).locator(`text=8`).click()
await typebotViewer(page).locator(`text=Send`).click()
await expect(typebotViewer(page).locator(`text=8`)).toBeVisible()
await page.click('text=Rate from 1 to 10')
await page.click('text=10')
await page.click('text=5')
await page.click('text=Numbers')
await page.click('text=Icons')
await page.click('text="Custom icon?"')
await page.fill('[placeholder="<svg>...</svg>"]', boxSvg)
await page.fill('[placeholder="Not likely at all"]', 'Not likely at all')
await page.fill('[placeholder="Neutral"]', 'Neutral')
await page.fill('[placeholder="Extremely likely"]', 'Extremely likely')
await page.click('text="Restart"')
await expect(typebotViewer(page).locator(`text=8`)).toBeHidden()
await expect(typebotViewer(page).locator(`text=4`)).toBeHidden()
await expect(
typebotViewer(page).locator(`text=Not likely at all`)
).toBeVisible()
await expect(typebotViewer(page).locator(`text=Neutral`)).toBeVisible()
await expect(
typebotViewer(page).locator(`text=Extremely likely`)
).toBeVisible()
await typebotViewer(page).locator(`svg >> nth=4`).click()
await typebotViewer(page).locator(`text=Send`).click()
await expect(typebotViewer(page).locator(`text=5`)).toBeVisible()
})

View File

@ -38,6 +38,7 @@ import {
ChoiceInputStep,
ConditionStep,
defaultPaymentInputOptions,
defaultRatingInputOptions,
} from 'models'
import { Typebot } from 'models'
import useSWR from 'swr'
@ -326,6 +327,8 @@ const parseDefaultStepOptions = (type: StepWithOptionsType): StepOptions => {
return defaultChoiceInputOptions
case InputStepType.PAYMENT:
return defaultPaymentInputOptions
case InputStepType.RATING:
return defaultRatingInputOptions
case LogicStepType.SET_VARIABLE:
return defaultSetVariablesOptions
case LogicStepType.REDIRECT:

View File

@ -23,7 +23,7 @@ It means you can apply operations on existing variables:
But also set the variable to the current date for example:
```js
new Date()
new Date().toISOString()
```
## Random ID

View File

@ -198,3 +198,23 @@ textarea {
.ping span {
background-color: var(--typebot-button-bg-color);
}
.rating-icon-container svg {
width: 42px;
height: 42px;
stroke: var(--typebot-button-bg-color);
fill: var(--typebot-host-bubble-bg-color);
transition: fill 100ms ease-out;
}
.rating-icon-container.selected svg {
fill: var(--typebot-button-bg-color);
}
.rating-icon-container:hover svg {
filter: brightness(0.9);
}
.rating-icon-container:active svg {
filter: brightness(0.75);
}

View File

@ -10,6 +10,7 @@ import { useTypebot } from 'contexts/TypebotContext'
import { parseVariables } from '../../../services/variable'
import { isInputValid } from 'services/inputs'
import { PaymentForm } from './inputs/PaymentForm'
import { RatingForm } from './inputs/RatingForm'
export const InputChatStep = ({
step,
@ -115,5 +116,7 @@ const Input = ({
onSuccess={() => onSubmit(step.options.labels.success ?? 'Success')}
/>
)
case InputStepType.RATING:
return <RatingForm step={step} onSubmit={onSubmit} />
}
}

View File

@ -69,7 +69,7 @@ export const ChoiceForm = ({ step, onSubmit }: ChoiceFormProps) => {
</div>
<div className="flex">
{selectedIndices.length > 0 && (
<SendButton label={step.options?.buttonLabel ?? 'Send'} />
<SendButton label={step.options?.buttonLabel ?? 'Send'} disableIcon />
)}
</div>
</form>

View File

@ -0,0 +1,108 @@
import { RatingInputOptions, RatingInputStep } from 'models'
import React, { FormEvent, useRef, useState } from 'react'
import { isDefined, isEmpty, isNotDefined } from 'utils'
import { SendButton } from './SendButton'
type Props = {
step: RatingInputStep
onSubmit: (value: string) => void
}
export const RatingForm = ({ step, onSubmit }: Props) => {
const [rating, setRating] = useState<number>()
const scaleElement = useRef<HTMLDivElement | null>(null)
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
if (isNotDefined(rating)) return
onSubmit(rating.toString())
}
const handleClick = (rating: number) => setRating(rating)
return (
<form className="flex flex-col" onSubmit={handleSubmit}>
<div className="flex flex-col" ref={scaleElement}>
<div className="flex">
{Array.from(Array(step.options.length)).map((_, idx) => (
<RatingButton
{...step.options}
key={idx}
rating={rating}
idx={idx + 1}
onClick={handleClick}
/>
))}
</div>
<div className="flex justify-between mr-2 mb-2">
{<span className="text-sm w-full ">{step.options.labels.left}</span>}
{!isEmpty(step.options.labels.middle) && (
<span className="text-sm w-full text-center">
{step.options.labels.middle}
</span>
)}
{
<span className="text-sm w-full text-right">
{step.options.labels.right}
</span>
}
</div>
</div>
<div className="flex justify-end mr-2">
{isDefined(rating) && (
<SendButton
label={step.options?.labels.button ?? 'Send'}
disableIcon
/>
)}
</div>
</form>
)
}
const RatingButton = ({
rating,
idx,
buttonType,
customIcon,
onClick,
}: Pick<RatingInputOptions, 'buttonType' | 'customIcon'> & {
rating: number | undefined
idx: number
onClick: (idx: number) => void
}) => {
if (buttonType === 'Numbers')
return (
<button
onClick={(e) => {
e.preventDefault()
onClick(idx)
}}
className={
'py-2 px-4 mr-2 mb-2 text-left font-semibold rounded-md transition-all filter hover:brightness-90 active:brightness-75 duration-100 focus:outline-none typebot-button ' +
(isDefined(rating) && idx <= rating ? '' : 'selectable')
}
>
{idx}
</button>
)
return (
<div
className={
'flex justify-center items-center rating-icon-container cursor-pointer mr-2 mb-2 ' +
(isDefined(rating) && idx <= rating ? 'selected' : '')
}
onClick={() => onClick(idx)}
dangerouslySetInnerHTML={{
__html:
customIcon.isEnabled && !isEmpty(customIcon.svg)
? customIcon.svg
: defaultIcon,
}}
/>
)
}
const defaultIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-star"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>`

View File

@ -7,3 +7,4 @@ export * from './date'
export * from './choice'
export * from './payment'
export * from './phone'
export * from './rating'

View File

@ -9,6 +9,7 @@ import {
phoneNumberInputOptionsSchema,
phoneNumberInputStepSchema,
} from './phone'
import { ratingInputOptionsSchema, ratingInputStepSchema } from './rating'
import { textInputOptionsSchema, textInputSchema } from './text'
import { urlInputOptionsSchema, urlInputSchema } from './url'
@ -22,6 +23,7 @@ export const inputStepOptionsSchema = textInputOptionsSchema
.or(phoneNumberInputOptionsSchema)
.or(dateInputOptionsSchema)
.or(paymentInputOptionsSchema)
.or(ratingInputOptionsSchema)
export const inputStepSchema = textInputSchema
.or(numberInputSchema)
@ -31,6 +33,7 @@ export const inputStepSchema = textInputSchema
.or(phoneNumberInputStepSchema)
.or(choiceInputSchema)
.or(paymentInputSchema)
.or(ratingInputStepSchema)
export type InputStep = z.infer<typeof inputStepSchema>
export type InputStepOptions = z.infer<typeof inputStepOptionsSchema>

View File

@ -0,0 +1,41 @@
import { z } from 'zod'
import {
defaultButtonLabel,
InputStepType,
optionBaseSchema,
stepBaseSchema,
} from '../shared'
export const defaultRatingInputOptions: RatingInputOptions = {
buttonType: 'Numbers',
length: 10,
labels: { button: defaultButtonLabel },
customIcon: { isEnabled: false },
}
export const ratingInputOptionsSchema = optionBaseSchema.and(
z.object({
buttonType: z.literal('Icons').or(z.literal('Numbers')),
length: z.number(),
labels: z.object({
left: z.string().optional(),
right: z.string().optional(),
middle: z.string().optional(),
button: z.string(),
}),
customIcon: z.object({
isEnabled: z.boolean(),
svg: z.string().optional(),
}),
})
)
export const ratingInputStepSchema = stepBaseSchema.and(
z.object({
type: z.literal(InputStepType.RATING),
options: ratingInputOptionsSchema,
})
)
export type RatingInputStep = z.infer<typeof ratingInputStepSchema>
export type RatingInputOptions = z.infer<typeof ratingInputOptionsSchema>

View File

@ -39,6 +39,7 @@ export enum InputStepType {
PHONE = 'phone number input',
CHOICE = 'choice input',
PAYMENT = 'payment input',
RATING = 'rating input',
}
export enum LogicStepType {