2
0

♻️ Normalize data

This commit is contained in:
Baptiste Arnaud
2022-01-06 09:40:56 +01:00
parent 6c1e0fd345
commit 9fa4c7dffa
114 changed files with 1545 additions and 1632 deletions

View File

@ -1,4 +1,4 @@
import { Block, PublicTypebot } from 'bot-engine'
import { PublicTypebot } from 'models'
import {
createContext,
Dispatch,
@ -8,6 +8,7 @@ import {
useState,
} from 'react'
import { Coordinates } from './GraphContext'
import produce from 'immer'
type Position = Coordinates & { scale: number }
@ -35,31 +36,13 @@ export const AnalyticsGraphProvider = ({
const [graphPosition, setGraphPosition] = useState(graphPositionDefaultValue)
const updateBlocks = (blocks: Block[]) => {
if (!typebot) return
setTypebot({
...typebot,
blocks: [...blocks],
})
}
const updateBlockPosition = (blockId: string, newPosition: Coordinates) => {
if (!typebot) return
blockId === 'start-block'
? setTypebot({
...typebot,
startBlock: {
...typebot.startBlock,
graphCoordinates: newPosition,
},
})
: updateBlocks(
typebot.blocks.map((block) =>
block.id === blockId
? { ...block, graphCoordinates: newPosition }
: block
)
)
setTypebot(
produce(typebot, (nextTypebot) => {
nextTypebot.blocks.byId[blockId].graphCoordinates = newPosition
})
)
}
return (

View File

@ -1,4 +1,4 @@
import { Step, StepType } from 'bot-engine'
import { Step, StepType } from 'models'
import {
createContext,
Dispatch,

View File

@ -1,4 +1,4 @@
import { Block, Step, StepType, Target } from 'bot-engine'
import { Block, Step, Target } from 'models'
import {
createContext,
Dispatch,
@ -41,26 +41,22 @@ export type Node = Omit<Block, 'steps'> & {
})[]
}
export type NewBlockPayload = {
x: number
y: number
type?: StepType
step?: Step
}
const graphPositionDefaultValue = { x: 400, y: 100, scale: 1 }
type ConnectingIdsProps = {
source: { blockId: string; stepId: string }
target?: Target
} | null
type PreviewingIdsProps = { sourceId?: string; targetId?: string }
const graphContext = createContext<{
graphPosition: Position
setGraphPosition: Dispatch<SetStateAction<Position>>
connectingIds: { blockId: string; stepId: string; target?: Target } | null
setConnectingIds: Dispatch<
SetStateAction<{ blockId: string; stepId: string; target?: Target } | null>
>
previewingIds: { sourceId?: string; targetId?: string }
setPreviewingIds: Dispatch<
SetStateAction<{ sourceId?: string; targetId?: string }>
>
connectingIds: ConnectingIdsProps
setConnectingIds: Dispatch<SetStateAction<ConnectingIdsProps>>
previewingIds: PreviewingIdsProps
setPreviewingIds: Dispatch<SetStateAction<PreviewingIdsProps>>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
}>({
@ -70,15 +66,8 @@ const graphContext = createContext<{
export const GraphProvider = ({ children }: { children: ReactNode }) => {
const [graphPosition, setGraphPosition] = useState(graphPositionDefaultValue)
const [connectingIds, setConnectingIds] = useState<{
blockId: string
stepId: string
target?: Target
} | null>(null)
const [previewingIds, setPreviewingIds] = useState<{
sourceId?: string
targetId?: string
}>({})
const [connectingIds, setConnectingIds] = useState<ConnectingIdsProps>(null)
const [previewingIds, setPreviewingIds] = useState<PreviewingIdsProps>({})
return (
<graphContext.Provider

View File

@ -1,402 +0,0 @@
import { useToast } from '@chakra-ui/react'
import {
Block,
PublicTypebot,
Settings,
Step,
StepType,
Target,
Theme,
Typebot,
} from 'bot-engine'
import { deepEqual } from 'fast-equals'
import { useRouter } from 'next/router'
import {
createContext,
ReactNode,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import {
createPublishedTypebot,
parseTypebotToPublicTypebot,
updatePublishedTypebot,
} from 'services/publicTypebot'
import {
checkIfPublished,
checkIfTypebotsAreEqual,
parseDefaultPublicId,
parseNewBlock,
parseNewStep,
updateTypebot,
} from 'services/typebots'
import {
fetcher,
insertItemInList,
omit,
preventUserFromRefreshing,
} from 'services/utils'
import useSWR from 'swr'
import { isDefined } from 'utils'
import { NewBlockPayload, Coordinates } from './GraphContext'
const typebotContext = createContext<{
typebot?: Typebot
publishedTypebot?: PublicTypebot
isPublished: boolean
isPublishing: boolean
hasUnsavedChanges: boolean
isSavingLoading: boolean
save: () => void
updateStep: (
ids: { stepId: string; blockId: string },
updates: Partial<Step>
) => void
addNewBlock: (props: NewBlockPayload) => void
updateBlockPosition: (blockId: string, newPositon: Coordinates) => void
removeBlock: (blockId: string) => void
addStepToBlock: (
blockId: string,
step: StepType | Step,
index: number
) => void
removeStepFromBlock: (blockId: string, stepId: string) => void
updateTarget: (connectingIds: {
blockId: string
stepId: string
target?: Target
}) => void
undo: () => void
updateTheme: (theme: Theme) => void
updateSettings: (settings: Settings) => void
updatePublicId: (publicId: string) => void
publishTypebot: () => void
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
}>({})
export const TypebotContext = ({
children,
typebotId,
}: {
children: ReactNode
typebotId?: string
}) => {
const router = useRouter()
const toast = useToast({
position: 'top-right',
status: 'error',
})
const [undoStack, setUndoStack] = useState<Typebot[]>([])
const { typebot, publishedTypebot, isLoading, mutate } = useFetchedTypebot({
typebotId,
onError: (error) =>
toast({
title: 'Error while fetching typebot',
description: error.message,
}),
})
const [localTypebot, setLocalTypebot] = useState<Typebot>()
const [localPublishedTypebot, setLocalPublishedTypebot] =
useState<PublicTypebot>()
const [isSavingLoading, setIsSavingLoading] = useState(false)
const [isPublishing, setIsPublishing] = useState(false)
const hasUnsavedChanges = useMemo(
() =>
isDefined(typebot) &&
isDefined(localTypebot) &&
!deepEqual(localTypebot, typebot),
[typebot, localTypebot]
)
const isPublished = useMemo(
() =>
isDefined(typebot) &&
isDefined(publishedTypebot) &&
checkIfPublished(typebot, publishedTypebot),
[typebot, publishedTypebot]
)
useEffect(() => {
if (!localTypebot || !typebot) return
if (!checkIfTypebotsAreEqual(localTypebot, typebot)) {
pushNewTypebotInUndoStack(localTypebot)
window.removeEventListener('beforeunload', preventUserFromRefreshing)
window.addEventListener('beforeunload', preventUserFromRefreshing)
} else {
window.removeEventListener('beforeunload', preventUserFromRefreshing)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localTypebot])
useEffect(() => {
if (isLoading) return
if (!typebot) {
toast({ status: 'info', description: "Couldn't find typebot" })
router.replace('/typebots')
return
}
setLocalTypebot({ ...typebot })
if (publishedTypebot) setLocalPublishedTypebot({ ...publishedTypebot })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoading])
const pushNewTypebotInUndoStack = (typebot: Typebot) => {
setUndoStack([...undoStack, typebot])
}
const undo = () => {
const lastTypebot = [...undoStack].pop()
setUndoStack(undoStack.slice(0, -1))
setLocalTypebot(lastTypebot)
}
const saveTypebot = async () => {
if (!localTypebot) return
setIsSavingLoading(true)
const { error } = await updateTypebot(localTypebot.id, localTypebot)
setIsSavingLoading(false)
if (error) return toast({ title: error.name, description: error.message })
mutate({ typebot: localTypebot })
window.removeEventListener('beforeunload', preventUserFromRefreshing)
}
const updateBlocks = (blocks: Block[]) => {
if (!localTypebot) return
setLocalTypebot({
...localTypebot,
blocks: [...blocks],
})
}
const updateStep = (
{ blockId, stepId }: { blockId: string; stepId: string },
updates: Partial<Omit<Step, 'id' | 'type'>>
) => {
if (!localTypebot) return
setLocalTypebot({
...localTypebot,
blocks: localTypebot.blocks.map((block) =>
block.id === blockId
? {
...block,
steps: block.steps.map((step) =>
step.id === stepId ? { ...step, ...updates } : step
),
}
: block
),
})
}
const addNewBlock = ({ x, y, type, step }: NewBlockPayload) => {
if (!localTypebot) return
updateBlocks([
...localTypebot.blocks.filter((block) => block.steps.length > 0),
parseNewBlock({
step,
type,
totalBlocks: localTypebot.blocks.length,
initialCoordinates: {
x,
y,
},
}),
])
}
const updateBlockPosition = (blockId: string, newPosition: Coordinates) => {
if (!localTypebot) return
blockId === 'start-block'
? setLocalTypebot({
...localTypebot,
startBlock: {
...localTypebot.startBlock,
graphCoordinates: newPosition,
},
})
: updateBlocks(
localTypebot.blocks.map((block) =>
block.id === blockId
? { ...block, graphCoordinates: newPosition }
: block
)
)
}
const addStepToBlock = (
blockId: string,
step: StepType | Step,
index: number
) => {
if (!localTypebot) return
updateBlocks(
localTypebot.blocks
.map((block) =>
block.id === blockId
? {
...block,
steps: insertItemInList<Step>(
block.steps,
index,
typeof step === 'string'
? parseNewStep(step as StepType, block.id)
: { ...step, blockId: block.id }
),
}
: block
)
.filter((block) => block.steps.length > 0)
)
}
const removeStepFromBlock = (blockId: string, stepId: string) => {
if (!localTypebot) return
updateBlocks(
localTypebot.blocks.map((block) =>
block.id === blockId
? {
...block,
steps: [...block.steps.filter((step) => step.id !== stepId)],
}
: block
)
)
}
const updateTarget = ({
blockId,
stepId,
target,
}: {
blockId: string
stepId: string
target?: Target
}) => {
if (!localTypebot) return
blockId === 'start-block'
? setLocalTypebot({
...localTypebot,
startBlock: {
...localTypebot.startBlock,
steps: [{ ...localTypebot.startBlock.steps[0], target }],
},
})
: updateBlocks(
localTypebot.blocks.map((block) =>
block.id === blockId
? {
...block,
steps: [
...block.steps.map((step) =>
step.id === stepId ? { ...step, target } : step
),
],
}
: block
)
)
}
const removeBlock = (blockId: string) => {
if (!localTypebot) return
const blocks = [...localTypebot.blocks.filter((b) => b.id !== blockId)]
setLocalTypebot({ ...localTypebot, blocks })
}
const updateTheme = (theme: Theme) => {
if (!localTypebot) return
setLocalTypebot({ ...localTypebot, theme })
}
const updateSettings = (settings: Settings) => {
if (!localTypebot) return
setLocalTypebot({ ...localTypebot, settings })
}
const updatePublicId = (publicId: string) => {
if (!localTypebot) return
setLocalTypebot({ ...localTypebot, publicId })
}
const publishTypebot = async () => {
if (!localTypebot) return
if (!localPublishedTypebot) {
const newPublicId = parseDefaultPublicId(
localTypebot.name,
localTypebot.id
)
updatePublicId(newPublicId)
localTypebot.publicId = newPublicId
}
if (hasUnsavedChanges || !localPublishedTypebot) await saveTypebot()
setIsPublishing(true)
if (localPublishedTypebot) {
const { error } = await updatePublishedTypebot(
localPublishedTypebot.id,
omit(parseTypebotToPublicTypebot(localTypebot), 'id')
)
setIsPublishing(false)
if (error) return toast({ title: error.name, description: error.message })
} else {
const { error } = await createPublishedTypebot(
omit(parseTypebotToPublicTypebot(localTypebot), 'id')
)
setIsPublishing(false)
if (error) return toast({ title: error.name, description: error.message })
}
mutate({ typebot: localTypebot })
}
return (
<typebotContext.Provider
value={{
typebot: localTypebot,
publishedTypebot: localPublishedTypebot,
updateStep,
addNewBlock,
addStepToBlock,
updateTarget,
removeStepFromBlock,
updateBlockPosition,
hasUnsavedChanges,
isSavingLoading,
save: saveTypebot,
removeBlock,
undo,
updateTheme,
updateSettings,
updatePublicId,
publishTypebot,
isPublishing,
isPublished,
}}
>
{children}
</typebotContext.Provider>
)
}
export const useTypebot = () => useContext(typebotContext)
export const useFetchedTypebot = ({
typebotId,
onError,
}: {
typebotId?: string
onError: (error: Error) => void
}) => {
const { data, error, mutate } = useSWR<
{ typebot: Typebot; publishedTypebot?: PublicTypebot },
Error
>(typebotId ? `/api/typebots/${typebotId}` : null, fetcher)
if (error) onError(error)
return {
typebot: data?.typebot,
publishedTypebot: data?.publishedTypebot,
isLoading: !error && !data,
mutate,
}
}

View File

@ -0,0 +1,232 @@
import { useToast } from '@chakra-ui/react'
import { deepEqual } from 'fast-equals'
import { PublicTypebot, Settings, Theme, Typebot } from 'models'
import { useRouter } from 'next/router'
import {
createContext,
ReactNode,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import {
createPublishedTypebot,
parseTypebotToPublicTypebot,
updatePublishedTypebot,
} from 'services/publicTypebot'
import {
checkIfPublished,
checkIfTypebotsAreEqual,
parseDefaultPublicId,
updateTypebot,
} from 'services/typebots'
import { fetcher, omit, preventUserFromRefreshing } from 'services/utils'
import useSWR from 'swr'
import { isDefined } from 'utils'
import { BlocksActions, blocksActions } from './actions/blocks'
import { useImmer, Updater } from 'use-immer'
import { stepsAction, StepsActions } from './actions/steps'
type UpdateTypebotPayload = Partial<{
theme: Theme
settings: Settings
publicId: string
}>
const typebotContext = createContext<
{
typebot?: Typebot
publishedTypebot?: PublicTypebot
isPublished: boolean
isPublishing: boolean
hasUnsavedChanges: boolean
isSavingLoading: boolean
save: () => void
undo: () => void
updateTypebot: (updates: UpdateTypebotPayload) => void
publishTypebot: () => void
} & BlocksActions &
StepsActions
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
>({})
export const TypebotContext = ({
children,
typebotId,
}: {
children: ReactNode
typebotId?: string
}) => {
const router = useRouter()
const toast = useToast({
position: 'top-right',
status: 'error',
})
const [undoStack, setUndoStack] = useState<Typebot[]>([])
const { typebot, publishedTypebot, isLoading, mutate } = useFetchedTypebot({
typebotId,
onError: (error) =>
toast({
title: 'Error while fetching typebot',
description: error.message,
}),
})
const [localTypebot, setLocalTypebot] = useImmer<Typebot | undefined>(
undefined
)
const [localPublishedTypebot, setLocalPublishedTypebot] =
useState<PublicTypebot>()
const [isSavingLoading, setIsSavingLoading] = useState(false)
const [isPublishing, setIsPublishing] = useState(false)
const hasUnsavedChanges = useMemo(
() =>
isDefined(typebot) &&
isDefined(localTypebot) &&
!deepEqual(localTypebot, typebot),
[typebot, localTypebot]
)
const isPublished = useMemo(
() =>
isDefined(typebot) &&
isDefined(publishedTypebot) &&
checkIfPublished(typebot, publishedTypebot),
[typebot, publishedTypebot]
)
useEffect(() => {
if (!localTypebot || !typebot) return
if (!checkIfTypebotsAreEqual(localTypebot, typebot)) {
pushNewTypebotInUndoStack(localTypebot)
window.removeEventListener('beforeunload', preventUserFromRefreshing)
window.addEventListener('beforeunload', preventUserFromRefreshing)
} else {
window.removeEventListener('beforeunload', preventUserFromRefreshing)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localTypebot])
useEffect(() => {
if (isLoading) return
if (!typebot) {
toast({ status: 'info', description: "Couldn't find typebot" })
router.replace('/typebots')
return
}
setLocalTypebot({ ...typebot })
if (publishedTypebot) setLocalPublishedTypebot({ ...publishedTypebot })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoading])
const pushNewTypebotInUndoStack = (typebot: Typebot) => {
setUndoStack([...undoStack, typebot])
}
const undo = () => {
const lastTypebot = [...undoStack].pop()
setUndoStack(undoStack.slice(0, -1))
setLocalTypebot(lastTypebot)
}
const saveTypebot = async (typebot?: Typebot) => {
if (!localTypebot) return
setIsSavingLoading(true)
const { error } = await updateTypebot(
typebot?.id ?? localTypebot.id,
typebot ?? localTypebot
)
setIsSavingLoading(false)
if (error) return toast({ title: error.name, description: error.message })
mutate({ typebot: typebot ?? localTypebot })
window.removeEventListener('beforeunload', preventUserFromRefreshing)
}
const updateLocalTypebot = ({
publicId,
settings,
theme,
}: UpdateTypebotPayload) => {
setLocalTypebot((typebot) => {
if (!typebot) return
if (publicId) typebot.publicId = publicId
if (settings) typebot.settings = settings
if (theme) typebot.theme = theme
})
}
const publishTypebot = async () => {
if (!localTypebot) return
const newLocalTypebot = { ...localTypebot }
if (!localPublishedTypebot) {
const newPublicId = parseDefaultPublicId(
localTypebot.name,
localTypebot.id
)
updateLocalTypebot({ publicId: newPublicId })
newLocalTypebot.publicId = newPublicId
}
if (hasUnsavedChanges || !localPublishedTypebot)
await saveTypebot(newLocalTypebot)
setIsPublishing(true)
if (localPublishedTypebot) {
const { error } = await updatePublishedTypebot(
localPublishedTypebot.id,
omit(parseTypebotToPublicTypebot(newLocalTypebot), 'id')
)
setIsPublishing(false)
if (error) return toast({ title: error.name, description: error.message })
} else {
const { data, error } = await createPublishedTypebot(
omit(parseTypebotToPublicTypebot(newLocalTypebot), 'id')
)
setLocalPublishedTypebot(data)
setIsPublishing(false)
if (error) return toast({ title: error.name, description: error.message })
}
mutate({ typebot: localTypebot })
}
return (
<typebotContext.Provider
value={{
typebot: localTypebot,
publishedTypebot: localPublishedTypebot,
hasUnsavedChanges,
isSavingLoading,
save: saveTypebot,
undo,
publishTypebot,
isPublishing,
isPublished,
updateTypebot: updateLocalTypebot,
...blocksActions(setLocalTypebot as Updater<Typebot>),
...stepsAction(setLocalTypebot as Updater<Typebot>),
}}
>
{children}
</typebotContext.Provider>
)
}
export const useTypebot = () => useContext(typebotContext)
export const useFetchedTypebot = ({
typebotId,
onError,
}: {
typebotId?: string
onError: (error: Error) => void
}) => {
const { data, error, mutate } = useSWR<
{ typebot: Typebot; publishedTypebot?: PublicTypebot },
Error
>(typebotId ? `/api/typebots/${typebotId}` : null, fetcher)
if (error) onError(error)
return {
typebot: data?.typebot,
publishedTypebot: data?.publishedTypebot,
isLoading: !error && !data,
mutate,
}
}

View File

@ -0,0 +1,40 @@
import { Coordinates } from 'contexts/GraphContext'
import { Block, Step, StepType, Typebot } from 'models'
import { parseNewBlock } from 'services/typebots'
import { Updater } from 'use-immer'
import { createStepDraft, deleteStepDraft } from './steps'
export type BlocksActions = {
createBlock: (props: Coordinates & { step: StepType | Step }) => void
updateBlock: (blockId: string, updates: Partial<Omit<Block, 'id'>>) => void
deleteBlock: (blockId: string) => void
}
export const blocksActions = (setTypebot: Updater<Typebot>): BlocksActions => ({
createBlock: ({ x, y, step }: Coordinates & { step: StepType | Step }) => {
setTypebot((typebot) => {
const newBlock = parseNewBlock({
totalBlocks: typebot.blocks.allIds.length,
initialCoordinates: { x, y },
})
typebot.blocks.byId[newBlock.id] = newBlock
typebot.blocks.allIds.push(newBlock.id)
createStepDraft(typebot, step, newBlock.id)
})
},
updateBlock: (blockId: string, updates: Partial<Omit<Block, 'id'>>) =>
setTypebot((typebot) => {
typebot.blocks.byId[blockId] = {
...typebot.blocks.byId[blockId],
...updates,
}
}),
deleteBlock: (blockId: string) =>
setTypebot((typebot) => {
const block = typebot.blocks.byId[blockId]
block.stepIds.forEach((stepId) => deleteStepDraft(typebot, stepId))
delete typebot.blocks.byId[blockId]
const index = typebot.blocks.allIds.indexOf(blockId)
if (index !== -1) typebot.blocks.allIds.splice(index, 1)
}),
})

View File

@ -0,0 +1,51 @@
import { Step, StepType, Typebot } from 'models'
import { parseNewStep } from 'services/typebots'
import { Updater } from 'use-immer'
import { WritableDraft } from 'immer/dist/types/types-external'
export type StepsActions = {
createStep: (blockId: string, step: StepType | Step, index?: number) => void
updateStep: (
stepId: string,
updates: Partial<Omit<Step, 'id' | 'type'>>
) => void
deleteStep: (stepId: string) => void
}
export const stepsAction = (setTypebot: Updater<Typebot>): StepsActions => ({
createStep: (blockId: string, step: StepType | Step, index?: number) => {
setTypebot((typebot) => {
createStepDraft(typebot, step, blockId, index)
})
},
updateStep: (stepId: string, updates: Partial<Omit<Step, 'id' | 'type'>>) =>
setTypebot((typebot) => {
typebot.steps.byId[stepId] = { ...typebot.steps.byId[stepId], ...updates }
}),
deleteStep: (stepId: string) => {
setTypebot((typebot) => {
deleteStepDraft(typebot, stepId)
})
},
})
export const deleteStepDraft = (
typebot: WritableDraft<Typebot>,
stepId: string
) => {
delete typebot.steps.byId[stepId]
const index = typebot.steps.allIds.indexOf(stepId)
if (index !== -1) typebot.steps.allIds.splice(index, 1)
}
export const createStepDraft = (
typebot: WritableDraft<Typebot>,
step: StepType | Step,
blockId: string,
index?: number
) => {
const newStep = typeof step === 'string' ? parseNewStep(step, blockId) : step
typebot.steps.byId[newStep.id] = newStep
typebot.steps.allIds.push(newStep.id)
typebot.blocks.byId[blockId].stepIds.splice(index ?? 0, 0, newStep.id)
}

View File

@ -0,0 +1 @@
export { TypebotContext } from './TypebotContext'