(editor) Actions on multiple groups

You can now select groups, move, copy, delete them easily

Closes #830, closes #1092
This commit is contained in:
Baptiste Arnaud
2024-01-23 10:31:31 +01:00
parent c08ab3d007
commit 00dcb135f3
32 changed files with 1043 additions and 282 deletions

View File

@@ -24,6 +24,7 @@ import { IntegrationBlockType } from '@typebot.io/schemas/features/blocks/integr
import { LogicBlockType } from '@typebot.io/schemas/features/blocks/logic/constants'
import { BlockV6 } from '@typebot.io/schemas'
import { enabledBlocks } from '@typebot.io/forge-repository'
import { useDebouncedCallback } from 'use-debounce'
// Integration blocks migrated to forged blocks
const legacyIntegrationBlocks = [
@@ -42,6 +43,8 @@ export const BlocksSideBar = () => {
const [isLocked, setIsLocked] = useState(true)
const [isExtended, setIsExtended] = useState(true)
const closeSideBar = useDebouncedCallback(() => setIsExtended(false), 200)
const handleMouseMove = (event: MouseEvent) => {
if (!draggedBlockType) return
const { clientX, clientY } = event
@@ -75,11 +78,14 @@ export const BlocksSideBar = () => {
const handleLockClick = () => setIsLocked(!isLocked)
const handleDockBarEnter = () => setIsExtended(true)
const handleDockBarEnter = () => {
closeSideBar.flush()
setIsExtended(true)
}
const handleMouseLeave = () => {
if (isLocked) return
setIsExtended(false)
closeSideBar()
}
return (
@@ -200,12 +206,14 @@ export const BlocksSideBar = () => {
<Flex
pos="absolute"
h="100%"
right="-50px"
w="50px"
right="-70px"
w="450px"
top="0"
justify="center"
justify="flex-end"
pr="10"
align="center"
onMouseEnter={handleDockBarEnter}
zIndex={-1}
>
<Flex w="5px" h="20px" bgColor="gray.400" rounded="md" />
</Flex>

View File

@@ -14,7 +14,6 @@ import { TypebotHeader } from './TypebotHeader'
import { Graph } from '@/features/graph/components/Graph'
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'
@@ -50,13 +49,11 @@ export const EditorPage = () => {
currentUserMode === 'read' || currentUserMode === 'guest'
}
>
<GroupsCoordinatesProvider groups={typebot.groups}>
<EventsCoordinatesProvider events={typebot.events}>
<Graph flex="1" typebot={typebot} key={typebot.id} />
<BoardMenuButton pos="absolute" right="40px" top="20px" />
<RightPanel />
</EventsCoordinatesProvider>
</GroupsCoordinatesProvider>
<EventsCoordinatesProvider events={typebot.events}>
<Graph flex="1" typebot={typebot} key={typebot.id} />
<BoardMenuButton pos="absolute" right="40px" top="20px" />
<RightPanel />
</EventsCoordinatesProvider>
</GraphProvider>
</GraphDndProvider>
) : (

View File

@@ -22,7 +22,6 @@ import { isDefined, isNotDefined } from '@typebot.io/lib'
import { EditableTypebotName } from './EditableTypebotName'
import Link from 'next/link'
import { EditableEmojiOrImageIcon } from '@/components/EditableEmojiOrImageIcon'
import { useUndoShortcut } from '@/hooks/useUndoShortcut'
import { useDebouncedCallback } from 'use-debounce'
import { ShareTypebotButton } from '@/features/share/components/ShareTypebotButton'
import { PublishButton } from '@/features/publish/components/PublishButton'
@@ -33,6 +32,7 @@ import { SupportBubble } from '@/components/SupportBubble'
import { isCloudProdInstance } from '@/helpers/isCloudProdInstance'
import { useTranslate } from '@tolgee/react'
import { GuestTypebotHeader } from './UnauthenticatedTypebotHeader'
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'
export const TypebotHeader = () => {
const { t } = useTranslate()
@@ -60,6 +60,11 @@ export const TypebotHeader = () => {
const hideUndoShortcutTooltipLater = useDebouncedCallback(() => {
setUndoShortcutTooltipOpen(false)
}, 1000)
const [isRedoShortcutTooltipOpen, setRedoShortcutTooltipOpen] =
useState(false)
const hideRedoShortcutTooltipLater = useDebouncedCallback(() => {
setRedoShortcutTooltipOpen(false)
}, 1000)
const { isOpen, onOpen } = useDisclosure()
const headerBgColor = useColorModeValue('white', 'gray.900')
@@ -76,12 +81,21 @@ export const TypebotHeader = () => {
setRightPanel(RightPanel.PREVIEW)
}
useUndoShortcut(() => {
if (!canUndo) return
hideUndoShortcutTooltipLater.flush()
setUndoShortcutTooltipOpen(true)
hideUndoShortcutTooltipLater()
undo()
useKeyboardShortcuts({
undo: () => {
if (!canUndo) return
hideUndoShortcutTooltipLater.flush()
setUndoShortcutTooltipOpen(true)
hideUndoShortcutTooltipLater()
undo()
},
redo: () => {
if (!canRedo) return
hideUndoShortcutTooltipLater.flush()
setRedoShortcutTooltipOpen(true)
hideRedoShortcutTooltipLater()
redo()
},
})
const handleHelpClick = () => {
@@ -229,7 +243,15 @@ export const TypebotHeader = () => {
/>
</Tooltip>
<Tooltip label={t('editor.header.redoButton.label')}>
<Tooltip
label={
isRedoShortcutTooltipOpen
? t('editor.header.undo.tooltip.label')
: t('editor.header.redoButton.label')
}
isOpen={isRedoShortcutTooltipOpen ? true : undefined}
hasArrow={isRedoShortcutTooltipOpen}
>
<IconButton
display={['none', 'flex']}
icon={<RedoIcon />}

View File

@@ -23,11 +23,15 @@ const initialState = {
future: [],
}
type Params = { isReadOnly?: boolean }
type Params<T extends { updatedAt: Date }> = {
isReadOnly?: boolean
onUndo?: (state: T) => void
onRedo?: (state: T) => void
}
export const useUndo = <T extends { updatedAt: Date }>(
initialPresent?: T,
params?: Params
params?: Params<T>
): [T | undefined, Actions<T>] => {
const [history, setHistory] = useState<History<T>>(initialState)
const presentRef = useRef<T | null>(initialPresent ?? null)
@@ -51,7 +55,8 @@ export const useUndo = <T extends { updatedAt: Date }>(
future: [present, ...future],
})
presentRef.current = newPresent
}, [history, params?.isReadOnly])
if (params?.onUndo) params.onUndo(newPresent)
}, [history, params])
const redo = useCallback(() => {
if (params?.isReadOnly) return
@@ -66,7 +71,8 @@ export const useUndo = <T extends { updatedAt: Date }>(
future: newFuture,
})
presentRef.current = next
}, [history, params?.isReadOnly])
if (params?.onRedo) params.onRedo(next)
}, [history, params])
const set = useCallback(
(newPresentArg: T | ((current: T) => T) | undefined) => {

View File

@@ -25,6 +25,7 @@ import { isPublished as isPublishedHelper } from '@/features/publish/helpers/isP
import { convertPublicTypebotToTypebot } from '@/features/publish/helpers/convertPublicTypebotToTypebot'
import { trpc } from '@/lib/trpc'
import { EventsActions, eventsActions } from './typebotActions/events'
import { useGroupsStore } from '@/features/graph/hooks/useGroupsStore'
const autoSaveTimeout = 10000
@@ -87,6 +88,9 @@ export const TypebotProvider = ({
}) => {
const { showToast } = useToast()
const [is404, setIs404] = useState(false)
const setGroupsCoordinates = useGroupsStore(
(state) => state.setGroupsCoordinates
)
const {
data: typebotData,
@@ -168,10 +172,19 @@ export const TypebotProvider = ({
{ redo, undo, flush, canRedo, canUndo, set: setLocalTypebot },
] = useUndo<TypebotV6>(undefined, {
isReadOnly,
onUndo: (t) => {
setGroupsCoordinates(t.groups)
},
onRedo: (t) => {
setGroupsCoordinates(t.groups)
},
})
useEffect(() => {
if (!typebot && isDefined(localTypebot)) setLocalTypebot(undefined)
if (!typebot && isDefined(localTypebot)) {
setLocalTypebot(undefined)
setGroupsCoordinates(undefined)
}
if (isFetchingTypebot || !typebot) return
if (
typebot.id !== localTypebot?.id ||
@@ -179,12 +192,14 @@ export const TypebotProvider = ({
new Date(localTypebot.updatedAt).getTime()
) {
setLocalTypebot({ ...typebot })
setGroupsCoordinates(typebot.groups)
flush()
}
}, [
flush,
isFetchingTypebot,
localTypebot,
setGroupsCoordinates,
setLocalTypebot,
showToast,
typebot,

View File

@@ -1,14 +1,21 @@
import { createId } from '@paralleldrive/cuid2'
import { produce } from 'immer'
import { BlockIndices, BlockV6, GroupV6 } from '@typebot.io/schemas'
import { Draft, produce } from 'immer'
import {
BlockIndices,
BlockV6,
BlockWithItems,
Edge,
GroupV6,
TypebotV6,
} from '@typebot.io/schemas'
import { SetTypebot } from '../TypebotProvider'
import {
deleteGroupDraft,
createBlockDraft,
duplicateBlockDraft,
} from './blocks'
import { isEmpty } from '@typebot.io/lib'
import { Coordinates } from '@/features/graph/types'
import { blockHasItems, byId, isEmpty } from '@typebot.io/lib'
import { Coordinates, CoordinatesMap } from '@/features/graph/types'
import { parseUniqueKey } from '@typebot.io/lib/parseUniqueKey'
export type GroupsActions = {
@@ -23,8 +30,15 @@ export type GroupsActions = {
groupIndex: number,
updates: Partial<Omit<GroupV6, 'id'>>
) => void
pasteGroups: (
groups: GroupV6[],
edges: Edge[],
oldToNewIdsMapping: Map<string, string>
) => void
updateGroupsCoordinates: (newCoord: CoordinatesMap) => void
duplicateGroup: (groupIndex: number) => void
deleteGroup: (groupIndex: number) => void
deleteGroups: (groupIds: string[]) => void
}
const groupsActions = (setTypebot: SetTypebot): GroupsActions => ({
@@ -59,6 +73,17 @@ const groupsActions = (setTypebot: SetTypebot): GroupsActions => ({
typebot.groups[groupIndex] = { ...block, ...updates }
})
),
updateGroupsCoordinates: (newCoord: CoordinatesMap) => {
setTypebot((typebot) =>
produce(typebot, (typebot) => {
typebot.groups.forEach((group) => {
if (newCoord[group.id]) {
group.graphCoordinates = newCoord[group.id]
}
})
})
)
},
duplicateGroup: (groupIndex: number) =>
setTypebot((typebot) =>
produce(typebot, (typebot) => {
@@ -76,7 +101,7 @@ const groupsActions = (setTypebot: SetTypebot): GroupsActions => ({
...group,
title: groupTitle,
id,
blocks: group.blocks.map((block) => duplicateBlockDraft(block)),
blocks: group.blocks.map(duplicateBlockDraft),
graphCoordinates: {
x: group.graphCoordinates.x + 200,
y: group.graphCoordinates.y + 100,
@@ -91,6 +116,117 @@ const groupsActions = (setTypebot: SetTypebot): GroupsActions => ({
deleteGroupDraft(typebot)(groupIndex)
})
),
deleteGroups: (groupIds: string[]) =>
setTypebot((typebot) =>
produce(typebot, (typebot) => {
groupIds.forEach((groupId) => {
deleteGroupByIdDraft(typebot)(groupId)
})
})
),
pasteGroups: (
groups: GroupV6[],
edges: Edge[],
oldToNewIdsMapping: Map<string, string>
) => {
const createdGroups: GroupV6[] = []
setTypebot((typebot) =>
produce(typebot, (typebot) => {
const edgesToCreate: Edge[] = []
groups.forEach((group) => {
const groupTitle = isEmpty(group.title)
? ''
: parseUniqueKey(
group.title,
typebot.groups.map((g) => g.title)
)
const newGroup: GroupV6 = {
...group,
title: groupTitle,
blocks: group.blocks.map((block) => {
const newBlock = { ...block }
const blockId = createId()
oldToNewIdsMapping.set(newBlock.id, blockId)
if (blockHasItems(newBlock)) {
newBlock.items = newBlock.items?.map((item) => {
const id = createId()
let outgoingEdgeId = item.outgoingEdgeId
if (outgoingEdgeId) {
const edge = edges.find(byId(outgoingEdgeId))
console.log(edge)
if (edge) {
outgoingEdgeId = createId()
edgesToCreate.push({
...edge,
id: outgoingEdgeId,
})
oldToNewIdsMapping.set(item.id, id)
}
}
return {
...item,
blockId,
id,
outgoingEdgeId,
}
}) as BlockWithItems['items']
}
let outgoingEdgeId = newBlock.outgoingEdgeId
if (outgoingEdgeId) {
const edge = edges.find(byId(outgoingEdgeId))
if (edge) {
outgoingEdgeId = createId()
edgesToCreate.push({
...edge,
id: outgoingEdgeId,
})
}
}
return {
...newBlock,
id: blockId,
outgoingEdgeId,
}
}),
}
typebot.groups.push(newGroup)
createdGroups.push(newGroup)
})
edgesToCreate.forEach((edge) => {
if (!('blockId' in edge.from)) return
const fromBlockId = oldToNewIdsMapping.get(edge.from.blockId)
const toGroupId = oldToNewIdsMapping.get(edge.to.groupId)
if (!fromBlockId || !toGroupId) return
const newEdge: Edge = {
...edge,
from: {
...edge.from,
blockId: fromBlockId,
itemId: edge.from.itemId
? oldToNewIdsMapping.get(edge.from.itemId)
: undefined,
},
to: {
...edge.to,
groupId: toGroupId,
blockId: edge.to.blockId
? oldToNewIdsMapping.get(edge.to.blockId)
: undefined,
},
}
typebot.edges.push(newEdge)
})
})
)
},
})
const deleteGroupByIdDraft =
(typebot: Draft<TypebotV6>) => (groupId: string) => {
const groupIndex = typebot.groups.findIndex(byId(groupId))
if (groupIndex === -1) return
deleteGroupDraft(typebot)(groupIndex)
}
export { groupsActions }