2
0

feat(logic): Add Redirect step

This commit is contained in:
Baptiste Arnaud
2022-01-20 07:21:08 +01:00
parent 8bbd8977b2
commit c43fd1d386
14 changed files with 311 additions and 46 deletions

View File

@ -259,3 +259,11 @@ export const ExpandIcon = (props: IconProps) => (
<line x1="3" y1="21" x2="10" y2="14"></line>
</Icon>
)
export const ExternalLinkIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15 3 21 3 21 9"></polyline>
<line x1="10" y1="14" x2="21" y2="3"></line>
</Icon>
)

View File

@ -5,6 +5,7 @@ import {
CheckSquareIcon,
EditIcon,
EmailIcon,
ExternalLinkIcon,
FilterIcon,
FlagIcon,
GlobeIcon,
@ -46,6 +47,8 @@ export const StepIcon = ({ type, ...props }: StepIconProps) => {
return <EditIcon {...props} />
case LogicStepType.CONDITION:
return <FilterIcon {...props} />
case LogicStepType.REDIRECT:
return <ExternalLinkIcon {...props} />
case IntegrationStepType.GOOGLE_SHEETS:
return <GoogleSheetsLogo {...props} />
case IntegrationStepType.GOOGLE_ANALYTICS:

View File

@ -40,6 +40,9 @@ export const StepTypeLabel = ({ type }: Props) => {
case LogicStepType.CONDITION: {
return <Text>Condition</Text>
}
case LogicStepType.REDIRECT: {
return <Text>Redirect</Text>
}
case IntegrationStepType.GOOGLE_SHEETS: {
return (
<Tooltip label="Google Sheets">

View File

@ -29,6 +29,7 @@ import { ConditionSettingsBody } from './bodies/ConditionSettingsBody'
import { GoogleAnalyticsSettings } from './bodies/GoogleAnalyticsSettings'
import { GoogleSheetsSettingsBody } from './bodies/GoogleSheetsSettingsBody'
import { PhoneNumberSettingsBody } from './bodies/PhoneNumberSettingsBody'
import { RedirectSettings } from './bodies/RedirectSettings'
import { SetVariableSettingsBody } from './bodies/SetVariableSettingsBody'
type Props = {
@ -149,6 +150,14 @@ export const StepSettings = ({ step }: { step: Step }) => {
/>
)
}
case LogicStepType.REDIRECT: {
return (
<RedirectSettings
options={step.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case IntegrationStepType.GOOGLE_SHEETS: {
return (
<GoogleSheetsSettingsBody

View File

@ -0,0 +1,40 @@
import { FormLabel, Stack } from '@chakra-ui/react'
import { DebouncedInput } from 'components/shared/DebouncedInput'
import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
import { RedirectOptions } from 'models'
import React from 'react'
type Props = {
options?: RedirectOptions
onOptionsChange: (options: RedirectOptions) => void
}
export const RedirectSettings = ({ options, onOptionsChange }: Props) => {
const handleUrlChange = (url?: string) => onOptionsChange({ ...options, url })
const handleIsNewTabChange = (isNewTab?: boolean) =>
onOptionsChange({ ...options, isNewTab })
return (
<Stack spacing={4}>
<Stack>
<FormLabel mb="0" htmlFor="tracking-id">
Url:
</FormLabel>
<DebouncedInput
id="tracking-id"
initialValue={options?.url ?? ''}
placeholder="Type a URL..."
delay={100}
onChange={handleUrlChange}
/>
</Stack>
<SwitchWithLabel
id="new-tab"
label="Open in new tab?"
initialValue={options?.isNewTab ?? false}
onCheckChange={handleIsNewTabChange}
/>
</Stack>
)
}

View File

@ -85,6 +85,10 @@ export const StepNodeContent = ({ step }: Props) => {
case LogicStepType.CONDITION: {
return <ConditionNodeContent step={step} />
}
case LogicStepType.REDIRECT: {
if (!step.options) return <Text color={'gray.500'}>Configure...</Text>
return <Text isTruncated>Redirect to {step.options?.url}</Text>
}
case IntegrationStepType.GOOGLE_SHEETS: {
if (!step.options) return <Text color={'gray.500'}>Configure...</Text>
return <Text>{step.options?.action}</Text>

View File

@ -6,10 +6,6 @@ import React from 'react'
export const SaveButton = () => {
const { save, isSavingLoading, hasUnsavedChanges } = useTypebot()
const onSaveClick = () => {
save()
}
return (
<>
{hasUnsavedChanges && (
@ -20,7 +16,7 @@ export const SaveButton = () => {
<Tooltip label="Save changes">
<IconButton
isDisabled={!hasUnsavedChanges}
onClick={onSaveClick}
onClick={save}
isLoading={isSavingLoading}
icon={
hasUnsavedChanges ? <SaveIcon /> : <CheckIcon color="green.400" />

View File

@ -13,7 +13,7 @@ export const headerHeight = 56
export const TypebotHeader = () => {
const router = useRouter()
const { typebot, updateTypebot } = useTypebot()
const { typebot, updateTypebot, save } = useTypebot()
const { setRightPanel } = useEditor()
const handleBackClick = () => {
@ -24,6 +24,12 @@ export const TypebotHeader = () => {
}
const handleNameSubmit = (name: string) => updateTypebot({ name })
const handlePreviewClick = async () => {
await save()
setRightPanel(RightPanel.PREVIEW)
}
return (
<Flex
w="full"
@ -95,14 +101,7 @@ export const TypebotHeader = () => {
<HStack right="40px" pos="absolute">
<SaveButton />
<Button
onClick={() => {
setRightPanel(RightPanel.PREVIEW)
}}
>
Preview
</Button>
<Button onClick={handlePreviewClick}>Preview</Button>
<PublishButton />
</HStack>
</Flex>

View File

@ -0,0 +1,107 @@
{
"id": "ckymkfh1e00562z1a3fjoua3e",
"createdAt": "2022-01-20T06:00:51.458Z",
"updatedAt": "2022-01-20T06:00:51.458Z",
"name": "My typebot",
"ownerId": "ckymkff1100362z1a85juyoa8",
"publishedTypebotId": null,
"folderId": null,
"blocks": {
"byId": {
"bsVJfEW7EZrUnAi9s5ev17": {
"id": "bsVJfEW7EZrUnAi9s5ev17",
"title": "Start",
"stepIds": ["9Ck2yveNjZNHhjyc4HCJAL"],
"graphCoordinates": { "x": 0, "y": 0 }
},
"bmdnpyvzopZ8YVfqsJY7Q8K": {
"id": "bmdnpyvzopZ8YVfqsJY7Q8K",
"title": "Block #2",
"graphCoordinates": { "x": 68, "y": 229 },
"stepIds": ["sas16Qqf4TmZEXSexmYpmSd"]
},
"bnsxmer7DD2R9DogoXTsvHJ": {
"id": "bnsxmer7DD2R9DogoXTsvHJ",
"title": "Block #3",
"graphCoordinates": { "x": 491, "y": 239 },
"stepIds": ["sqNGop2aYkXRvJqb9nGtFbD"]
}
},
"allIds": [
"bsVJfEW7EZrUnAi9s5ev17",
"bmdnpyvzopZ8YVfqsJY7Q8K",
"bnsxmer7DD2R9DogoXTsvHJ"
]
},
"steps": {
"byId": {
"9Ck2yveNjZNHhjyc4HCJAL": {
"id": "9Ck2yveNjZNHhjyc4HCJAL",
"type": "start",
"label": "Start",
"blockId": "bsVJfEW7EZrUnAi9s5ev17",
"edgeId": "totLsWG6AQfcFT39CsZwDy"
},
"sas16Qqf4TmZEXSexmYpmSd": {
"id": "sas16Qqf4TmZEXSexmYpmSd",
"blockId": "bmdnpyvzopZ8YVfqsJY7Q8K",
"type": "choice input",
"options": { "itemIds": ["mAgynXh3zmkmWzNyPGVAcf"] }
},
"sqNGop2aYkXRvJqb9nGtFbD": {
"id": "sqNGop2aYkXRvJqb9nGtFbD",
"blockId": "bnsxmer7DD2R9DogoXTsvHJ",
"type": "Redirect"
}
},
"allIds": [
"9Ck2yveNjZNHhjyc4HCJAL",
"sas16Qqf4TmZEXSexmYpmSd",
"sqNGop2aYkXRvJqb9nGtFbD"
]
},
"choiceItems": {
"byId": {
"mAgynXh3zmkmWzNyPGVAcf": {
"id": "mAgynXh3zmkmWzNyPGVAcf",
"stepId": "sas16Qqf4TmZEXSexmYpmSd",
"content": "Go to URL",
"edgeId": "7KgqWB88ufzhDwzvwHuEbN"
}
},
"allIds": ["mAgynXh3zmkmWzNyPGVAcf"]
},
"variables": { "byId": {}, "allIds": [] },
"edges": {
"byId": {
"totLsWG6AQfcFT39CsZwDy": {
"from": {
"blockId": "bsVJfEW7EZrUnAi9s5ev17",
"stepId": "9Ck2yveNjZNHhjyc4HCJAL"
},
"to": { "blockId": "bmdnpyvzopZ8YVfqsJY7Q8K" },
"id": "totLsWG6AQfcFT39CsZwDy"
},
"7KgqWB88ufzhDwzvwHuEbN": {
"from": {
"blockId": "bmdnpyvzopZ8YVfqsJY7Q8K",
"stepId": "sas16Qqf4TmZEXSexmYpmSd",
"nodeId": "mAgynXh3zmkmWzNyPGVAcf"
},
"to": { "blockId": "bnsxmer7DD2R9DogoXTsvHJ" },
"id": "7KgqWB88ufzhDwzvwHuEbN"
}
},
"allIds": ["totLsWG6AQfcFT39CsZwDy", "7KgqWB88ufzhDwzvwHuEbN"]
},
"theme": {
"general": {
"font": "Open Sans",
"background": { "type": "None", "content": "#ffffff" }
}
},
"settings": {
"typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 }
},
"publicId": null
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@ -0,0 +1,45 @@
import { preventUserFromRefreshing } from 'cypress/plugins/utils'
import { getIframeBody } from 'cypress/support'
describe('Redirect', () => {
beforeEach(() => {
cy.task('seed')
cy.signOut()
})
afterEach(() => {
cy.window().then((win) => {
win.removeEventListener('beforeunload', preventUserFromRefreshing)
})
})
it('should redirect to URL correctly', () => {
cy.loadTypebotFixtureInDatabase('typebots/logic/redirect.json')
cy.signIn('test2@gmail.com')
cy.visit('/typebots/typebot4/edit')
cy.findByText('Configure...').click()
cy.findByPlaceholderText('Type a URL...').type('google.com')
cy.findByRole('button', { name: 'Preview' }).click()
getIframeBody().findByRole('button', { name: 'Go to URL' }).click()
cy.url().should('eq', 'https://www.google.com/')
cy.go('back')
cy.window().then((win) => {
cy.stub(win, 'open').as('open')
})
cy.findByText('Redirect to google.com').click()
cy.findByRole('checkbox', { name: 'Open in new tab?' }).check({
force: true,
})
cy.findByRole('button', { name: 'Preview' }).click()
getIframeBody().findByRole('button', { name: 'Go to URL' }).click()
cy.get('@open').should(
'have.been.calledOnceWithExactly',
'https://google.com',
'_blank'
)
})
})

View File

@ -1,46 +1,64 @@
import {
LogicStep,
Target,
LogicStepType,
LogicalOperator,
ConditionStep,
Table,
Variable,
ComparisonOperators,
SetVariableStep,
RedirectStep,
} from 'models'
import { isDefined } from 'utils'
import { sanitizeUrl } from './utils'
import { isMathFormula, evaluateExpression, parseVariables } from './variable'
type EdgeId = string
export const executeLogic = (
step: LogicStep,
variables: Table<Variable>,
updateVariableValue: (variableId: string, expression: string) => void
): string | undefined => {
): EdgeId | undefined => {
switch (step.type) {
case LogicStepType.SET_VARIABLE: {
if (!step.options?.variableId || !step.options.expressionToEvaluate)
return
const expression = step.options.expressionToEvaluate
const evaluatedExpression = isMathFormula(expression)
? evaluateExpression(parseVariables({ text: expression, variables }))
: expression
updateVariableValue(step.options.variableId, evaluatedExpression)
return
}
case LogicStepType.CONDITION: {
const isConditionPassed =
step.options?.logicalOperator === LogicalOperator.AND
? step.options?.comparisons.allIds.every(
executeComparison(step, variables)
)
: step.options?.comparisons.allIds.some(
executeComparison(step, variables)
)
return isConditionPassed ? step.trueEdgeId : step.falseEdgeId
}
case LogicStepType.SET_VARIABLE:
return executeSetVariable(step, variables, updateVariableValue)
case LogicStepType.CONDITION:
return executeCondition(step, variables)
case LogicStepType.REDIRECT:
return executeRedirect(step, variables)
}
}
const executeSetVariable = (
step: SetVariableStep,
variables: Table<Variable>,
updateVariableValue: (variableId: string, expression: string) => void
): EdgeId | undefined => {
if (!step.options?.variableId || !step.options.expressionToEvaluate)
return step.edgeId
const expression = step.options.expressionToEvaluate
const evaluatedExpression = isMathFormula(expression)
? evaluateExpression(parseVariables({ text: expression, variables }))
: expression
updateVariableValue(step.options.variableId, evaluatedExpression)
return step.edgeId
}
const executeCondition = (
step: ConditionStep,
variables: Table<Variable>
): EdgeId | undefined => {
const isConditionPassed =
step.options?.logicalOperator === LogicalOperator.AND
? step.options?.comparisons.allIds.every(
executeComparison(step, variables)
)
: step.options?.comparisons.allIds.some(
executeComparison(step, variables)
)
return isConditionPassed ? step.trueEdgeId : step.falseEdgeId
}
const executeComparison =
(step: ConditionStep, variables: Table<Variable>) =>
(comparisonId: string) => {
@ -70,3 +88,15 @@ const executeComparison =
}
}
}
const executeRedirect = (
step: RedirectStep,
variables: Table<Variable>
): EdgeId | undefined => {
if (!step.options?.url) return step.edgeId
window.open(
sanitizeUrl(parseVariables({ text: step.options?.url, variables })),
step.options.isNewTab ? '_blank' : '_self'
)
return step.edgeId
}

View File

@ -0,0 +1,7 @@
export const sanitizeUrl = (url: string): string =>
url.startsWith('http') ||
url.startsWith('mailto:') ||
url.startsWith('tel:') ||
url.startsWith('sms:')
? url
: `https://${url}`

View File

@ -1,20 +1,36 @@
import { StepBase } from '.'
import { Table } from '../..'
export type LogicStep = SetVariableStep | ConditionStep
export type LogicStep = SetVariableStep | ConditionStep | RedirectStep
export enum LogicStepType {
SET_VARIABLE = 'Set variable',
CONDITION = 'Condition',
REDIRECT = 'Redirect',
}
export type LogicStepOptions = SetVariableOptions | ConditionOptions
export type LogicStepOptions =
| SetVariableOptions
| ConditionOptions
| RedirectOptions
export type SetVariableStep = StepBase & {
type: LogicStepType.SET_VARIABLE
options?: SetVariableOptions
}
export type ConditionStep = StepBase & {
type: LogicStepType.CONDITION
options: ConditionOptions
trueEdgeId?: string
falseEdgeId?: string
}
export type RedirectStep = StepBase & {
type: LogicStepType.REDIRECT
options?: RedirectOptions
}
export enum LogicalOperator {
OR = 'OR',
AND = 'AND',
@ -29,13 +45,6 @@ export enum ComparisonOperators {
IS_SET = 'Is set',
}
export type ConditionStep = StepBase & {
type: LogicStepType.CONDITION
options: ConditionOptions
trueEdgeId?: string
falseEdgeId?: string
}
export type ConditionOptions = {
comparisons: Table<Comparison>
logicalOperator?: LogicalOperator
@ -52,3 +61,8 @@ export type SetVariableOptions = {
variableId?: string
expressionToEvaluate?: string
}
export type RedirectOptions = {
url?: string
isNewTab?: boolean
}