✨ (editor) Actions on multiple groups
You can now select groups, move, copy, delete them easily Closes #830, closes #1092
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user