Allow user to share a flow publicly and make it duplicatable

Closes #360
This commit is contained in:
Baptiste Arnaud
2023-11-23 12:05:31 +01:00
parent 8a07392821
commit bb41226a04
130 changed files with 1150 additions and 2012 deletions

View File

@@ -16,10 +16,17 @@ import { GraphDndProvider } from '@/features/graph/providers/GraphDndProvider'
import { GraphProvider } from '@/features/graph/providers/GraphProvider'
import { GroupsCoordinatesProvider } from '@/features/graph/providers/GroupsCoordinateProvider'
import { EventsCoordinatesProvider } from '@/features/graph/providers/EventsCoordinateProvider'
import { TypebotNotFoundPage } from './TypebotNotFoundPage'
export const EditorPage = () => {
const { typebot, isReadOnly } = useTypebot()
const { typebot, currentUserMode, is404 } = useTypebot()
const backgroundImage = useColorModeValue(
'radial-gradient(#c6d0e1 1px, transparent 0)',
'radial-gradient(#2f2f39 1px, transparent 0)'
)
const bgColor = useColorModeValue('#f4f5f8', 'gray.850')
if (is404) return <TypebotNotFoundPage />
return (
<EditorProvider>
<Seo title={typebot?.name ? `${typebot.name} | Editor` : 'Editor'} />
@@ -30,18 +37,19 @@ export const EditorPage = () => {
flex="1"
pos="relative"
h="full"
bgColor={useColorModeValue('#f4f5f8', 'gray.850')}
backgroundImage={useColorModeValue(
'radial-gradient(#c6d0e1 1px, transparent 0)',
'radial-gradient(#2f2f39 1px, transparent 0)'
)}
bgColor={bgColor}
backgroundImage={backgroundImage}
backgroundSize="40px 40px"
backgroundPosition="-19px -19px"
>
{typebot ? (
<GraphDndProvider>
{!isReadOnly && <BlocksSideBar />}
<GraphProvider isReadOnly={isReadOnly}>
{currentUserMode === 'write' && <BlocksSideBar />}
<GraphProvider
isReadOnly={
currentUserMode === 'read' || currentUserMode === 'guest'
}
>
<GroupsCoordinatesProvider groups={typebot.groups}>
<EventsCoordinatesProvider events={typebot.events}>
<Graph flex="1" typebot={typebot} key={typebot.id} />

View File

@@ -12,6 +12,7 @@ import {
import {
BuoyIcon,
ChevronLeftIcon,
PlayIcon,
RedoIcon,
UndoIcon,
} from '@/components/icons'
@@ -23,7 +24,7 @@ import Link from 'next/link'
import { EditableEmojiOrImageIcon } from '@/components/EditableEmojiOrImageIcon'
import { useUndoShortcut } from '@/hooks/useUndoShortcut'
import { useDebouncedCallback } from 'use-debounce'
import { CollaborationMenuButton } from '@/features/collaboration/components/CollaborationMenuButton'
import { ShareTypebotButton } from '@/features/share/components/ShareTypebotButton'
import { PublishButton } from '@/features/publish/components/PublishButton'
import { headerHeight } from '../constants'
import { RightPanel, useEditor } from '../providers/EditorProvider'
@@ -31,6 +32,7 @@ import { useTypebot } from '../providers/TypebotProvider'
import { SupportBubble } from '@/components/SupportBubble'
import { isCloudProdInstance } from '@/helpers/isCloudProdInstance'
import { useTranslate } from '@tolgee/react'
import { GuestTypebotHeader } from './UnauthenticatedTypebotHeader'
export const TypebotHeader = () => {
const { t } = useTranslate()
@@ -45,6 +47,7 @@ export const TypebotHeader = () => {
canUndo,
canRedo,
isSavingLoading,
currentUserMode,
} = useTypebot()
const {
setRightPanel,
@@ -58,6 +61,7 @@ export const TypebotHeader = () => {
setUndoShortcutTooltipOpen(false)
}, 1000)
const { isOpen, onOpen } = useDisclosure()
const headerBgColor = useColorModeValue('white', 'gray.900')
const handleNameSubmit = (name: string) =>
updateTypebot({ updates: { name } })
@@ -86,6 +90,7 @@ export const TypebotHeader = () => {
: window.open('https://docs.typebot.io', '_blank')
}
if (currentUserMode === 'guest') return <GuestTypebotHeader />
return (
<Flex
w="full"
@@ -95,7 +100,7 @@ export const TypebotHeader = () => {
h={`${headerHeight}px`}
zIndex={100}
pos="relative"
bgColor={useColorModeValue('white', 'gray.900')}
bgColor={headerBgColor}
flexShrink={0}
>
{isOpen && <SupportBubble autoShowDelay={0} />}
@@ -203,33 +208,35 @@ export const TypebotHeader = () => {
)
</HStack>
<HStack>
<Tooltip
label={isUndoShortcutTooltipOpen ? 'Changes reverted!' : 'Undo'}
isOpen={isUndoShortcutTooltipOpen ? true : undefined}
hasArrow={isUndoShortcutTooltipOpen}
>
<IconButton
display={['none', 'flex']}
icon={<UndoIcon />}
size="sm"
aria-label="Undo"
onClick={undo}
isDisabled={!canUndo}
/>
</Tooltip>
{currentUserMode === 'write' && (
<HStack>
<Tooltip
label={isUndoShortcutTooltipOpen ? 'Changes reverted!' : 'Undo'}
isOpen={isUndoShortcutTooltipOpen ? true : undefined}
hasArrow={isUndoShortcutTooltipOpen}
>
<IconButton
display={['none', 'flex']}
icon={<UndoIcon />}
size="sm"
aria-label="Undo"
onClick={undo}
isDisabled={!canUndo}
/>
</Tooltip>
<Tooltip label="Redo">
<IconButton
display={['none', 'flex']}
icon={<RedoIcon />}
size="sm"
aria-label="Redo"
onClick={redo}
isDisabled={!canRedo}
/>
</Tooltip>
</HStack>
<Tooltip label="Redo">
<IconButton
display={['none', 'flex']}
icon={<RedoIcon />}
size="sm"
aria-label="Redo"
onClick={redo}
isDisabled={!canRedo}
/>
</Tooltip>
</HStack>
)}
<Button leftIcon={<BuoyIcon />} onClick={handleHelpClick} size="sm">
{t('editor.headers.helpButton.label')}
</Button>
@@ -246,19 +253,20 @@ export const TypebotHeader = () => {
<HStack right="40px" pos="absolute" display={['none', 'flex']}>
<Flex pos="relative">
<CollaborationMenuButton isLoading={isNotDefined(typebot)} />
<ShareTypebotButton isLoading={isNotDefined(typebot)} />
</Flex>
{router.pathname.includes('/edit') && isNotDefined(rightPanel) && (
<Button
colorScheme="gray"
onClick={handlePreviewClick}
isLoading={isNotDefined(typebot)}
leftIcon={<PlayIcon />}
size="sm"
>
{t('editor.headers.previewButton.label')}
</Button>
)}
<PublishButton size="sm" />
{currentUserMode === 'write' && <PublishButton size="sm" />}
</HStack>
</Flex>
)

View File

@@ -0,0 +1,51 @@
import { ChevronLeftIcon } from '@/components/icons'
import { useUser } from '@/features/account/hooks/useUser'
import {
Button,
Flex,
Heading,
Link,
VStack,
Text,
Spinner,
} from '@chakra-ui/react'
import { useRouter } from 'next/router'
import { useEffect } from 'react'
export const TypebotNotFoundPage = () => {
const { replace, asPath } = useRouter()
const { user, isLoading } = useUser()
useEffect(() => {
if (user || isLoading) return
replace({
pathname: '/signin',
query: {
redirectPath: asPath,
},
})
}, [asPath, isLoading, replace, user])
return (
<Flex justify="center" align="center" w="full" h="100vh">
{user ? (
<VStack spacing={6}>
<VStack>
<Heading>404</Heading>
<Text fontSize="xl">Typebot not found.</Text>
</VStack>
<Button
as={Link}
href="/typebots"
colorScheme="blue"
leftIcon={<ChevronLeftIcon />}
>
Dashboard
</Button>
</VStack>
) : (
<Spinner />
)}
</Flex>
)
}

View File

@@ -0,0 +1,167 @@
import {
Flex,
HStack,
Button,
useColorModeValue,
Divider,
Text,
} from '@chakra-ui/react'
import { CopyIcon, PlayIcon } from '@/components/icons'
import { useRouter } from 'next/router'
import React from 'react'
import { isNotDefined } from '@typebot.io/lib'
import Link from 'next/link'
import { headerHeight } from '../constants'
import { RightPanel, useEditor } from '../providers/EditorProvider'
import { useTypebot } from '../providers/TypebotProvider'
import { useTranslate } from '@tolgee/react'
import { TypebotLogo } from '@/components/TypebotLogo'
import { EmojiOrImageIcon } from '@/components/EmojiOrImageIcon'
import { useUser } from '@/features/account/hooks/useUser'
export const GuestTypebotHeader = () => {
const { t } = useTranslate()
const router = useRouter()
const { user } = useUser()
const { typebot, save } = useTypebot()
const {
setRightPanel,
rightPanel,
setStartPreviewAtGroup,
setStartPreviewAtEvent,
} = useEditor()
const handlePreviewClick = async () => {
setStartPreviewAtGroup(undefined)
setStartPreviewAtEvent(undefined)
save().then()
setRightPanel(RightPanel.PREVIEW)
}
return (
<Flex
w="full"
borderBottomWidth="1px"
justify="center"
align="center"
h={`${headerHeight}px`}
zIndex={100}
pos="relative"
bgColor={useColorModeValue('white', 'gray.900')}
flexShrink={0}
>
<HStack
display={['none', 'flex']}
pos={{ base: 'absolute', xl: 'static' }}
right={{ base: 280, xl: 0 }}
>
<Button
as={Link}
href={`/typebots/${typebot?.id}/edit`}
colorScheme={router.pathname.includes('/edit') ? 'blue' : 'gray'}
variant={router.pathname.includes('/edit') ? 'outline' : 'ghost'}
size="sm"
>
{t('editor.headers.flowButton.label')}
</Button>
<Button
as={Link}
href={`/typebots/${typebot?.id}/theme`}
colorScheme={router.pathname.endsWith('theme') ? 'blue' : 'gray'}
variant={router.pathname.endsWith('theme') ? 'outline' : 'ghost'}
size="sm"
>
{t('editor.headers.themeButton.label')}
</Button>
<Button
as={Link}
href={`/typebots/${typebot?.id}/settings`}
colorScheme={router.pathname.endsWith('settings') ? 'blue' : 'gray'}
variant={router.pathname.endsWith('settings') ? 'outline' : 'ghost'}
size="sm"
>
{t('editor.headers.settingsButton.label')}
</Button>
</HStack>
<HStack
pos="absolute"
left="1rem"
justify="center"
align="center"
spacing="6"
>
<HStack alignItems="center" spacing={3}>
{typebot && (
<EmojiOrImageIcon icon={typebot.icon} emojiFontSize="2xl" />
)}
<Text
noOfLines={2}
maxW="150px"
overflow="hidden"
fontSize="14px"
minW="30px"
minH="20px"
>
{typebot?.name}
</Text>
</HStack>
</HStack>
<HStack
right="1rem"
pos="absolute"
display={['none', 'flex']}
spacing={4}
>
<HStack>
{typebot?.id && (
<Button
as={Link}
href={
!user
? {
pathname: `/register`,
query: {
redirectPath: `/typebots/${typebot.id}/duplicate`,
},
}
: `/typebots/${typebot.id}/duplicate`
}
leftIcon={<CopyIcon />}
isLoading={isNotDefined(typebot)}
size="sm"
>
Duplicate
</Button>
)}
{router.pathname.includes('/edit') && isNotDefined(rightPanel) && (
<Button
colorScheme="blue"
onClick={handlePreviewClick}
isLoading={isNotDefined(typebot)}
leftIcon={<PlayIcon />}
size="sm"
>
Play bot
</Button>
)}
</HStack>
{!user && (
<>
<Divider orientation="vertical" h="25px" borderColor="gray.400" />
<Button
as={Link}
href={`/register`}
leftIcon={<TypebotLogo width="20px" />}
variant="outline"
size="sm"
>
Try Typebot
</Button>
</>
)}
</HStack>
</Flex>
)
}

View File

@@ -207,7 +207,7 @@ test('Preview from group should work', async ({ page }) => {
page.locator('typebot-standard').locator('text="Hello this is group 2"')
).toBeVisible()
await page.click('[aria-label="Close"]')
await page.click('text="Preview"')
await page.click('text="Test"')
await expect(
page.locator('typebot-standard').locator('text="Hello this is group 1"')
).toBeVisible()

View File

@@ -23,8 +23,11 @@ const initialState = {
future: [],
}
type Params = { isReadOnly?: boolean }
export const useUndo = <T extends { updatedAt: Date }>(
initialPresent?: T
initialPresent?: T,
params?: Params
): [T | undefined, Actions<T>] => {
const [history, setHistory] = useState<History<T>>(initialState)
const presentRef = useRef<T | null>(initialPresent ?? null)
@@ -33,6 +36,7 @@ export const useUndo = <T extends { updatedAt: Date }>(
const canRedo = history.future.length !== 0
const undo = useCallback(() => {
if (params?.isReadOnly) return
const { past, present, future } = history
if (past.length === 0 || !present) return
@@ -47,9 +51,10 @@ export const useUndo = <T extends { updatedAt: Date }>(
future: [present, ...future],
})
presentRef.current = newPresent
}, [history])
}, [history, params?.isReadOnly])
const redo = useCallback(() => {
if (params?.isReadOnly) return
const { past, present, future } = history
if (future.length === 0) return
const next = future[0]
@@ -61,11 +66,12 @@ export const useUndo = <T extends { updatedAt: Date }>(
future: newFuture,
})
presentRef.current = next
}, [history])
}, [history, params?.isReadOnly])
const set = useCallback(
(newPresentArg: T | ((current: T) => T) | undefined) => {
const { past, present } = history
if (isDefined(present) && params?.isReadOnly) return
const newPresent =
typeof newPresentArg === 'function'
? newPresentArg(presentRef.current as T)
@@ -92,16 +98,17 @@ export const useUndo = <T extends { updatedAt: Date }>(
})
presentRef.current = newPresent
},
[history]
[history, params?.isReadOnly]
)
const flush = useCallback(() => {
if (params?.isReadOnly) return
setHistory({
present: presentRef.current ?? undefined,
past: [],
future: [],
})
}, [])
}, [params?.isReadOnly])
return [history.present, { set, undo, redo, flush, canUndo, canRedo }]
}

View File

@@ -7,6 +7,7 @@ import {
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import { isDefined, omit } from '@typebot.io/lib'
import { edgesAction, EdgesActions } from './typebotActions/edges'
@@ -52,7 +53,8 @@ const typebotContext = createContext<
typebot?: TypebotV6
publishedTypebot?: PublicTypebotV6
publishedTypebotVersion?: PublicTypebot['version']
isReadOnly?: boolean
currentUserMode: 'guest' | 'read' | 'write'
is404: boolean
isPublished: boolean
isSavingLoading: boolean
save: () => Promise<TypebotV6 | undefined>
@@ -84,6 +86,7 @@ export const TypebotProvider = ({
}) => {
const { push } = useRouter()
const { showToast } = useToast()
const [is404, setIs404] = useState(false)
const {
data: typebotData,
@@ -96,13 +99,10 @@ export const TypebotProvider = ({
retry: 0,
onError: (error) => {
if (error.data?.httpStatus === 404) {
showToast({
status: 'info',
description: "Couldn't find typebot. Redirecting...",
})
push('/typebots')
setIs404(true)
return
}
setIs404(false)
showToast({
title: 'Could not fetch typebot',
description: error.message,
@@ -112,6 +112,9 @@ export const TypebotProvider = ({
},
})
},
onSuccess: () => {
setIs404(false)
},
}
)
@@ -119,7 +122,10 @@ export const TypebotProvider = ({
trpc.typebot.getPublishedTypebot.useQuery(
{ typebotId: typebotId as string, migrateToLatestVersion: true },
{
enabled: isDefined(typebotId),
enabled:
isDefined(typebotId) &&
(typebotData?.currentUserMode === 'read' ||
typebotData?.currentUserMode === 'write'),
onError: (error) => {
showToast({
title: 'Could not fetch published typebot',
@@ -153,11 +159,16 @@ export const TypebotProvider = ({
const typebot = typebotData?.typebot as TypebotV6
const publishedTypebot = (publishedTypebotData?.publishedTypebot ??
undefined) as PublicTypebotV6 | undefined
const isReadOnly = ['read', 'guest'].includes(
typebotData?.currentUserMode ?? 'guest'
)
const [
localTypebot,
{ redo, undo, flush, canRedo, canUndo, set: setLocalTypebot },
] = useUndo<TypebotV6>(undefined)
] = useUndo<TypebotV6>(undefined, {
isReadOnly,
})
useEffect(() => {
if (!typebot && isDefined(localTypebot)) setLocalTypebot(undefined)
@@ -182,7 +193,7 @@ export const TypebotProvider = ({
const saveTypebot = useCallback(
async (updates?: Partial<TypebotV6>) => {
if (!localTypebot || !typebot || typebotData?.isReadOnly) return
if (!localTypebot || !typebot || isReadOnly) return
const typebotToSave = { ...localTypebot, ...updates }
if (dequal(omit(typebot, 'updatedAt'), omit(typebotToSave, 'updatedAt')))
return
@@ -194,13 +205,7 @@ export const TypebotProvider = ({
setLocalTypebot({ ...newTypebot })
return newTypebot
},
[
localTypebot,
setLocalTypebot,
typebot,
typebotData?.isReadOnly,
updateTypebot,
]
[isReadOnly, localTypebot, setLocalTypebot, typebot, updateTypebot]
)
useAutoSave(
@@ -232,7 +237,7 @@ export const TypebotProvider = ({
)
useEffect(() => {
if (!localTypebot || !typebot || typebotData?.isReadOnly) return
if (!localTypebot || !typebot || isReadOnly) return
if (!areTypebotsEqual(localTypebot, typebot)) {
window.addEventListener('beforeunload', preventUserFromRefreshing)
}
@@ -240,7 +245,7 @@ export const TypebotProvider = ({
return () => {
window.removeEventListener('beforeunload', preventUserFromRefreshing)
}
}, [localTypebot, typebot, typebotData?.isReadOnly])
}, [localTypebot, typebot, isReadOnly])
const updateLocalTypebot = async ({
updates,
@@ -249,7 +254,7 @@ export const TypebotProvider = ({
updates: UpdateTypebotPayload
save?: boolean
}) => {
if (!localTypebot) return
if (!localTypebot || isReadOnly) return
const newTypebot = { ...localTypebot, ...updates }
setLocalTypebot(newTypebot)
if (save) await saveTypebot(newTypebot)
@@ -269,8 +274,9 @@ export const TypebotProvider = ({
typebot: localTypebot,
publishedTypebot,
publishedTypebotVersion: publishedTypebotData?.version,
isReadOnly: typebotData?.isReadOnly,
currentUserMode: typebotData?.currentUserMode ?? 'guest',
isSavingLoading: isSaving,
is404,
save: saveTypebot,
undo,
redo,