2
0

🚸 (account) Improve account form and fix cyclic dependencies

This commit is contained in:
Baptiste Arnaud
2023-01-18 11:40:38 +01:00
parent c711f3660f
commit 49058da206
12 changed files with 112 additions and 154 deletions

View File

@@ -14,13 +14,13 @@ import {
Tag, Tag,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { EditIcon, PlusIcon, TrashIcon } from '@/components/icons' import { EditIcon, PlusIcon, TrashIcon } from '@/components/icons'
import { useTypebot } from '@/features/editor' import { useTypebot } from '@/features/editor/providers/TypebotProvider/TypebotProvider'
import cuid from 'cuid' import cuid from 'cuid'
import { Variable } from 'models' import { Variable } from 'models'
import React, { useState, useRef, ChangeEvent, useEffect } from 'react' import React, { useState, useRef, ChangeEvent, useEffect } from 'react'
import { byId, isDefined, isNotDefined } from 'utils' import { byId, isDefined, isNotDefined } from 'utils'
import { useOutsideClick } from '@/hooks/useOutsideClick' import { useOutsideClick } from '@/hooks/useOutsideClick'
import { useParentModal } from '@/features/graph' import { useParentModal } from '@/features/graph/providers/ParentModalProvider'
type Props = { type Props = {
initialVariableId?: string initialVariableId?: string

View File

@@ -355,8 +355,8 @@ export const SendEmailIcon = (props: IconProps) => (
export const GithubIcon = (props: IconProps) => ( export const GithubIcon = (props: IconProps) => (
<Icon viewBox="0 0 98 96" xmlns="http://www.w3.org/2000/svg" {...props}> <Icon viewBox="0 0 98 96" xmlns="http://www.w3.org/2000/svg" {...props}>
<path <path
fill-rule="evenodd" fillRule="evenodd"
clip-rule="evenodd" clipRule="evenodd"
d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z"
fill={useColorModeValue('#24292f', 'white')} fill={useColorModeValue('#24292f', 'white')}
/> />

View File

@@ -10,7 +10,7 @@ import { Variable } from 'models'
import React, { ChangeEvent, useEffect, useRef, useState } from 'react' import React, { ChangeEvent, useEffect, useRef, useState } from 'react'
import { useDebouncedCallback } from 'use-debounce' import { useDebouncedCallback } from 'use-debounce'
import { env } from 'utils' import { env } from 'utils'
import { VariablesButton } from '../../features/variables/components/VariablesButton' import { VariablesButton } from '@/features/variables'
import { MoreInfoTooltip } from '../MoreInfoTooltip' import { MoreInfoTooltip } from '../MoreInfoTooltip'
export type TextBoxProps = { export type TextBoxProps = {

View File

@@ -5,27 +5,28 @@ import {
ReactNode, ReactNode,
useContext, useContext,
useEffect, useEffect,
useMemo,
useState, useState,
} from 'react' } from 'react'
import { isDefined, isNotDefined } from 'utils' import { env, isDefined, isNotDefined } from 'utils'
import { dequal } from 'dequal'
import { User } from 'db' import { User } from 'db'
import { setUser as setSentryUser } from '@sentry/nextjs' import { setUser as setSentryUser } from '@sentry/nextjs'
import { useToast } from '@/hooks/useToast' import { useToast } from '@/hooks/useToast'
import { updateUserQuery } from './queries/updateUserQuery' import { updateUserQuery } from './queries/updateUserQuery'
import { useDebouncedCallback } from 'use-debounce'
const userContext = createContext<{ const userContext = createContext<{
user?: User user?: User
isLoading: boolean isLoading: boolean
isSaving: boolean
hasUnsavedChanges: boolean
currentWorkspaceId?: string currentWorkspaceId?: string
updateUser: (newUser: Partial<User>) => void updateUser: (newUser: Partial<User>) => void
saveUser: (newUser?: Partial<User>) => Promise<void> }>({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment isLoading: false,
//@ts-ignore updateUser: () => {
}>({}) console.log('updateUser not implemented')
},
})
const debounceTimeout = 1000
export const UserProvider = ({ children }: { children: ReactNode }) => { export const UserProvider = ({ children }: { children: ReactNode }) => {
const router = useRouter() const router = useRouter()
@@ -34,13 +35,6 @@ export const UserProvider = ({ children }: { children: ReactNode }) => {
const { showToast } = useToast() const { showToast } = useToast()
const [currentWorkspaceId, setCurrentWorkspaceId] = useState<string>() const [currentWorkspaceId, setCurrentWorkspaceId] = useState<string>()
const [isSaving, setIsSaving] = useState(false)
const hasUnsavedChanges = useMemo(
() => !dequal(session?.user, user),
[session?.user, user]
)
useEffect(() => { useEffect(() => {
if (isDefined(user) || isNotDefined(session)) return if (isDefined(user) || isNotDefined(session)) return
setCurrentWorkspaceId( setCurrentWorkspaceId(
@@ -70,31 +64,36 @@ export const UserProvider = ({ children }: { children: ReactNode }) => {
const isSigningIn = () => ['/signin', '/register'].includes(router.pathname) const isSigningIn = () => ['/signin', '/register'].includes(router.pathname)
const updateUser = (newUser: Partial<User>) => { const updateUser = (updates: Partial<User>) => {
if (isNotDefined(user)) return if (isNotDefined(user)) return
setUser({ ...user, ...newUser }) const newUser = { ...user, ...updates }
setUser(newUser)
saveUser(newUser)
} }
const saveUser = async (newUser?: Partial<User>) => { const saveUser = useDebouncedCallback(
async (newUser?: Partial<User>) => {
if (isNotDefined(user)) return if (isNotDefined(user)) return
setIsSaving(true)
if (newUser) updateUser(newUser)
const { error } = await updateUserQuery(user.id, { ...user, ...newUser }) const { error } = await updateUserQuery(user.id, { ...user, ...newUser })
if (error) showToast({ title: error.name, description: error.message }) if (error) showToast({ title: error.name, description: error.message })
await refreshUser() await refreshUser()
setIsSaving(false) },
env('E2E_TEST') === 'true' ? 0 : debounceTimeout
)
useEffect(() => {
return () => {
saveUser.flush()
} }
}, [saveUser])
return ( return (
<userContext.Provider <userContext.Provider
value={{ value={{
user, user,
isSaving,
isLoading: status === 'loading', isLoading: status === 'loading',
hasUnsavedChanges,
currentWorkspaceId, currentWorkspaceId,
updateUser, updateUser,
saveUser,
}} }}
> >
{children} {children}

View File

@@ -7,13 +7,10 @@ test.describe.configure({ mode: 'parallel' })
test('should display user info properly', async ({ page }) => { test('should display user info properly', async ({ page }) => {
await page.goto('/typebots') await page.goto('/typebots')
await page.click('text=Settings & Members') await page.click('text=Settings & Members')
const saveButton = page.locator('button:has-text("Save")')
await expect(saveButton).toBeHidden()
expect( expect(
page.locator('input[type="email"]').getAttribute('disabled') page.locator('input[type="email"]').getAttribute('disabled')
).toBeDefined() ).toBeDefined()
await page.fill('#name', 'John Doe') await page.getByRole('textbox', { name: 'Name:' }).fill('John Doe')
expect(saveButton).toBeVisible()
await page.setInputFiles('input[type="file"]', getTestAsset('avatar.jpg')) await page.setInputFiles('input[type="file"]', getTestAsset('avatar.jpg'))
await expect(page.locator('img >> nth=1')).toHaveAttribute( await expect(page.locator('img >> nth=1')).toHaveAttribute(
'src', 'src',

View File

@@ -1,35 +1,28 @@
import { import { Stack, HStack, Avatar, Text, Tooltip } from '@chakra-ui/react'
Stack,
HStack,
Avatar,
Button,
FormControl,
FormLabel,
Input,
Tooltip,
Flex,
Text,
} from '@chakra-ui/react'
import { UploadIcon } from '@/components/icons' import { UploadIcon } from '@/components/icons'
import React, { ChangeEvent } from 'react' import React, { useState } from 'react'
import { isDefined } from 'utils'
import { ApiTokensList } from './ApiTokensList' import { ApiTokensList } from './ApiTokensList'
import { UploadButton } from '@/components/ImageUploadContent/UploadButton' import { UploadButton } from '@/components/ImageUploadContent/UploadButton'
import { useUser } from '@/features/account' import { useUser } from '@/features/account'
import { Input } from '@/components/inputs/Input'
export const MyAccountForm = () => { export const MyAccountForm = () => {
const { user, updateUser, saveUser, hasUnsavedChanges, isSaving } = useUser() const { user, updateUser } = useUser()
const [name, setName] = useState(user?.name ?? '')
const [email, setEmail] = useState(user?.email ?? '')
const handleFileUploaded = async (url: string) => { const handleFileUploaded = async (url: string) => {
updateUser({ image: url }) updateUser({ image: url })
} }
const handleNameChange = (e: ChangeEvent<HTMLInputElement>) => { const handleNameChange = (newName: string) => {
updateUser({ name: e.target.value }) setName(newName)
updateUser({ name: newName })
} }
const handleEmailChange = (e: ChangeEvent<HTMLInputElement>) => { const handleEmailChange = (newEmail: string) => {
updateUser({ email: e.target.value }) setEmail(newEmail)
updateUser({ email: newEmail })
} }
return ( return (
@@ -56,40 +49,26 @@ export const MyAccountForm = () => {
</Stack> </Stack>
</HStack> </HStack>
<FormControl>
<FormLabel htmlFor="name">Name</FormLabel>
<Input id="name" value={user?.name ?? ''} onChange={handleNameChange} />
</FormControl>
{isDefined(user?.email) && (
<Tooltip
label="Updating email is not available."
placement="left"
hasArrow
>
<FormControl>
<FormLabel htmlFor="email">Email address</FormLabel>
<Input <Input
id="email" value={name}
type="email" onChange={handleNameChange}
isDisabled label="Name:"
value={user?.email ?? ''} withVariableButton={false}
onChange={handleEmailChange} debounceTimeout={0}
/> />
</FormControl> <Tooltip label="Updating email is not available. Contact the support if you want to change it.">
<span>
<Input
type="email"
value={email}
onChange={handleEmailChange}
label="Email address:"
withVariableButton={false}
debounceTimeout={0}
isDisabled
/>
</span>
</Tooltip> </Tooltip>
)}
{hasUnsavedChanges && (
<Flex justifyContent="flex-end">
<Button
colorScheme="blue"
onClick={() => saveUser()}
isLoading={isSaving}
>
Save
</Button>
</Flex>
)}
{user && <ApiTokensList user={user} />} {user && <ApiTokensList user={user} />}
</Stack> </Stack>

View File

@@ -7,20 +7,20 @@ import { AppearanceRadioGroup } from './AppearanceRadioGroup'
export const UserPreferencesForm = () => { export const UserPreferencesForm = () => {
const { setColorMode } = useColorMode() const { setColorMode } = useColorMode()
const { saveUser, user } = useUser() const { user, updateUser } = useUser()
useEffect(() => { useEffect(() => {
if (!user?.graphNavigation) if (!user?.graphNavigation)
saveUser({ graphNavigation: GraphNavigation.TRACKPAD }) updateUser({ graphNavigation: GraphNavigation.TRACKPAD })
}, [saveUser, user?.graphNavigation]) }, [updateUser, user?.graphNavigation])
const changeGraphNavigation = async (value: string) => { const changeGraphNavigation = async (value: string) => {
await saveUser({ graphNavigation: value as GraphNavigation }) updateUser({ graphNavigation: value as GraphNavigation })
} }
const changeAppearance = async (value: string) => { const changeAppearance = async (value: string) => {
setColorMode(value) setColorMode(value)
await saveUser({ preferredAppAppearance: value }) updateUser({ preferredAppAppearance: value })
} }
return ( return (

View File

@@ -23,7 +23,7 @@ export const OnboardingModal = ({ totalTypebots }: Props) => {
'/bots/onboarding.json', '/bots/onboarding.json',
'/bots/onboarding-dark.json' '/bots/onboarding-dark.json'
) )
const { user, saveUser } = useUser() const { user, updateUser } = useUser()
const { isOpen, onOpen, onClose } = useDisclosure() const { isOpen, onOpen, onClose } = useDisclosure()
const [typebot, setTypebot] = useState<Typebot>() const [typebot, setTypebot] = useState<Typebot>()
const confettiCanvaContainer = useRef<HTMLCanvasElement | null>(null) const confettiCanvaContainer = useRef<HTMLCanvasElement | null>(null)
@@ -88,15 +88,15 @@ export const OnboardingModal = ({ totalTypebots }: Props) => {
const isCompany = answer.variableId === 'cl126jqww000w2e6dq9yv4ifq' const isCompany = answer.variableId === 'cl126jqww000w2e6dq9yv4ifq'
const isCategories = answer.variableId === 'cl126mo3t001b2e6dvyi16bkd' const isCategories = answer.variableId === 'cl126mo3t001b2e6dvyi16bkd'
const isOtherCategories = answer.variableId === 'cl126q38p001q2e6d0hj23f6b' const isOtherCategories = answer.variableId === 'cl126q38p001q2e6d0hj23f6b'
if (isName) saveUser({ name: answer.content }) if (isName) updateUser({ name: answer.content })
if (isCompany) saveUser({ company: answer.content }) if (isCompany) updateUser({ company: answer.content })
if (isCategories) { if (isCategories) {
const onboardingCategories = answer.content.split(', ') const onboardingCategories = answer.content.split(', ')
saveUser({ onboardingCategories }) updateUser({ onboardingCategories })
setChosenCategories(onboardingCategories) setChosenCategories(onboardingCategories)
} }
if (isOtherCategories) if (isOtherCategories)
saveUser({ updateUser({
onboardingCategories: [...chosenCategories, answer.content], onboardingCategories: [...chosenCategories, answer.content],
}) })
} }

View File

@@ -17,11 +17,11 @@ import {
useState, useState,
} from 'react' } from 'react'
import { isDefined, isNotDefined, omit } from 'utils' import { isDefined, isNotDefined, omit } from 'utils'
import { edgesAction, EdgesActions } from './actions/edges'
import { itemsAction, ItemsActions } from './actions/items'
import { GroupsActions, groupsActions } from './actions/groups' import { GroupsActions, groupsActions } from './actions/groups'
import { blocksAction, BlocksActions } from './actions/blocks' import { blocksAction, BlocksActions } from './actions/blocks'
import { variablesAction, VariablesActions } from './actions/variables' import { variablesAction, VariablesActions } from './actions/variables'
import { edgesAction, EdgesActions } from './actions/edges'
import { itemsAction, ItemsActions } from './actions/items'
import { dequal } from 'dequal' import { dequal } from 'dequal'
import cuid from 'cuid' import cuid from 'cuid'
import { useToast } from '@/hooks/useToast' import { useToast } from '@/hooks/useToast'
@@ -30,17 +30,19 @@ import useUndo from '../../hooks/useUndo'
import { useLinkedTypebots } from '@/hooks/useLinkedTypebots' import { useLinkedTypebots } from '@/hooks/useLinkedTypebots'
import { updateTypebotQuery } from '../../queries/updateTypebotQuery' import { updateTypebotQuery } from '../../queries/updateTypebotQuery'
import { preventUserFromRefreshing } from '@/utils/helpers' import { preventUserFromRefreshing } from '@/utils/helpers'
import { updatePublishedTypebotQuery } from '@/features/publish'
import { saveWebhookQuery } from '@/features/blocks/integrations/webhook/queries/saveWebhookQuery'
import { import {
createPublishedTypebotQuery, createPublishedTypebotQuery,
updatePublishedTypebotQuery,
deletePublishedTypebotQuery, deletePublishedTypebotQuery,
} from '@/features/publish/queries'
import { saveWebhookQuery } from '@/features/blocks/integrations/webhook/queries/saveWebhookQuery'
import {
checkIfTypebotsAreEqual, checkIfTypebotsAreEqual,
checkIfPublished, checkIfPublished,
parseTypebotToPublicTypebot, parseTypebotToPublicTypebot,
parseDefaultPublicId, parseDefaultPublicId,
parsePublicTypebotToTypebot, parsePublicTypebotToTypebot,
} from '@/features/publish' } from '@/features/publish/utils'
import { useAutoSave } from '@/hooks/useAutoSave' import { useAutoSave } from '@/hooks/useAutoSave'
const autoSaveTimeout = 10000 const autoSaveTimeout = 10000
@@ -60,6 +62,7 @@ type UpdateTypebotPayload = Partial<{
export type SetTypebot = ( export type SetTypebot = (
newPresent: Typebot | ((current: Typebot) => Typebot) newPresent: Typebot | ((current: Typebot) => Typebot)
) => void ) => void
const typebotContext = createContext< const typebotContext = createContext<
{ {
typebot?: Typebot typebot?: Typebot

View File

@@ -5,7 +5,6 @@ import {
DraggableBlockType, DraggableBlockType,
BlockIndices, BlockIndices,
} from 'models' } from 'models'
import { removeEmptyGroups } from './groups'
import { WritableDraft } from 'immer/dist/types/types-external' import { WritableDraft } from 'immer/dist/types/types-external'
import { SetTypebot } from '../TypebotProvider' import { SetTypebot } from '../TypebotProvider'
import produce from 'immer' import produce from 'immer'
@@ -13,7 +12,7 @@ import { cleanUpEdgeDraft, deleteEdgeDraft } from './edges'
import cuid from 'cuid' import cuid from 'cuid'
import { byId, isWebhookBlock, blockHasItems } from 'utils' import { byId, isWebhookBlock, blockHasItems } from 'utils'
import { duplicateItemDraft } from './items' import { duplicateItemDraft } from './items'
import { parseNewBlock } from '@/features/graph' import { parseNewBlock } from '@/features/graph/utils'
export type BlocksActions = { export type BlocksActions = {
createBlock: ( createBlock: (
@@ -30,7 +29,7 @@ export type BlocksActions = {
deleteBlock: (indices: BlockIndices) => void deleteBlock: (indices: BlockIndices) => void
} }
const blocksAction = (setTypebot: SetTypebot): BlocksActions => ({ export const blocksAction = (setTypebot: SetTypebot): BlocksActions => ({
createBlock: ( createBlock: (
groupId: string, groupId: string,
block: DraggableBlock | DraggableBlockType, block: DraggableBlock | DraggableBlockType,
@@ -78,7 +77,7 @@ const removeBlockFromGroup =
typebot.groups[groupIndex].blocks.splice(blockIndex, 1) typebot.groups[groupIndex].blocks.splice(blockIndex, 1)
} }
const createBlockDraft = ( export const createBlockDraft = (
typebot: WritableDraft<Typebot>, typebot: WritableDraft<Typebot>,
block: DraggableBlock | DraggableBlockType, block: DraggableBlock | DraggableBlockType,
groupId: string, groupId: string,
@@ -134,7 +133,7 @@ const moveBlockToGroup = (
typebot.groups[groupIndex].blocks.splice(blockIndex ?? 0, 0, newBlock) typebot.groups[groupIndex].blocks.splice(blockIndex ?? 0, 0, newBlock)
} }
const duplicateBlockDraft = export const duplicateBlockDraft =
(groupId: string) => (groupId: string) =>
(block: Block): Block => { (block: Block): Block => {
const blockId = cuid() const blockId = cuid()
@@ -162,4 +161,19 @@ const duplicateBlockDraft =
} }
} }
export { blocksAction, createBlockDraft, duplicateBlockDraft } export const deleteGroupDraft =
(typebot: WritableDraft<Typebot>) => (groupIndex: number) => {
cleanUpEdgeDraft(typebot, typebot.groups[groupIndex].id)
typebot.groups.splice(groupIndex, 1)
}
export const removeEmptyGroups = (typebot: WritableDraft<Typebot>) => {
const emptyGroupsIndices = typebot.groups.reduce<number[]>(
(arr, group, idx) => {
group.blocks.length === 0 && arr.push(idx)
return arr
},
[]
)
emptyGroupsIndices.forEach(deleteGroupDraft(typebot))
}

View File

@@ -1,16 +1,12 @@
import cuid from 'cuid' import cuid from 'cuid'
import { produce } from 'immer' import { produce } from 'immer'
import { WritableDraft } from 'immer/dist/internal' import { Group, DraggableBlock, DraggableBlockType, BlockIndices } from 'models'
import {
Group,
DraggableBlock,
DraggableBlockType,
BlockIndices,
Typebot,
} from 'models'
import { SetTypebot } from '../TypebotProvider' import { SetTypebot } from '../TypebotProvider'
import { cleanUpEdgeDraft } from './edges' import {
import { createBlockDraft, duplicateBlockDraft } from './blocks' deleteGroupDraft,
createBlockDraft,
duplicateBlockDraft,
} from './blocks'
import { Coordinates } from '@/features/graph' import { Coordinates } from '@/features/graph'
export type GroupsActions = { export type GroupsActions = {
@@ -82,21 +78,4 @@ const groupsActions = (setTypebot: SetTypebot): GroupsActions => ({
), ),
}) })
const deleteGroupDraft = export { groupsActions }
(typebot: WritableDraft<Typebot>) => (groupIndex: number) => {
cleanUpEdgeDraft(typebot, typebot.groups[groupIndex].id)
typebot.groups.splice(groupIndex, 1)
}
const removeEmptyGroups = (typebot: WritableDraft<Typebot>) => {
const emptyGroupsIndices = typebot.groups.reduce<number[]>(
(arr, group, idx) => {
group.blocks.length === 0 && arr.push(idx)
return arr
},
[]
)
emptyGroupsIndices.forEach(deleteGroupDraft(typebot))
}
export { groupsActions, removeEmptyGroups }

View File

@@ -13,19 +13,6 @@ module.exports = {
}, },
}, },
rules: { rules: {
'no-restricted-imports': [
'error',
{
patterns: [
'*/src/*',
'src/*',
'*/src',
'@/features/*/*',
'!@/features/blocks/*',
'!@/features/*/api',
],
},
],
'@typescript-eslint/no-namespace': 'off', '@typescript-eslint/no-namespace': 'off',
}, },
} }