2
0

♻️ (builder) Change to features-centric folder structure

This commit is contained in:
Baptiste Arnaud
2022-11-15 09:35:48 +01:00
committed by Baptiste Arnaud
parent 3686465a85
commit 643571fe7d
683 changed files with 3907 additions and 3643 deletions

View File

@ -0,0 +1,123 @@
import test, { expect } from '@playwright/test'
import cuid from 'cuid'
import { CollaborationType, Plan, WorkspaceRole } from 'db'
import prisma from '@/lib/prisma'
import { InputBlockType, defaultTextInputOptions } from 'models'
import {
createTypebots,
injectFakeResults,
} from 'utils/playwright/databaseActions'
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
import { userId } from 'utils/playwright/databaseSetup'
import { createFolder } from '@/test/utils/databaseActions'
test.describe('Typebot owner', () => {
test('Can invite collaborators', async ({ page }) => {
const typebotId = cuid()
const guestWorkspaceId = cuid()
await prisma.workspace.create({
data: {
id: guestWorkspaceId,
name: 'Guest Workspace',
plan: Plan.FREE,
members: {
createMany: {
data: [{ role: WorkspaceRole.ADMIN, userId }],
},
},
},
})
await createTypebots([
{
id: typebotId,
name: 'Guest typebot',
workspaceId: guestWorkspaceId,
...parseDefaultGroupWithBlock({
type: InputBlockType.TEXT,
options: defaultTextInputOptions,
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('button[aria-label="Show collaboration menu"]')
await expect(page.locator('text=Free user')).toBeHidden()
await page.fill(
'input[placeholder="colleague@company.com"]',
'guest@email.com'
)
await page.click('text=Can view')
await page.click('text=Can edit')
await page.click('text=Invite')
await expect(page.locator('text=Pending')).toBeVisible()
await expect(page.locator('text=Free user')).toBeHidden()
await page.fill(
'input[placeholder="colleague@company.com"]',
'other-user@email.com'
)
await page.click('text=Can edit')
await page.click('text=Can view')
await page.click('text=Invite')
await expect(page.locator('text=James Doe')).toBeVisible()
await page.click('text="guest@email.com"')
await page.click('text="Remove"')
await expect(page.locator('text="guest@email.com"')).toBeHidden()
})
})
test.describe('Guest', () => {
test('should have shared typebots displayed', async ({ page }) => {
const typebotId = cuid()
const guestWorkspaceId = cuid()
await prisma.workspace.create({
data: {
id: guestWorkspaceId,
name: 'Guest Workspace #2',
plan: Plan.FREE,
members: {
createMany: {
data: [{ role: WorkspaceRole.GUEST, userId }],
},
},
},
})
await createTypebots([
{
id: typebotId,
name: 'Guest typebot',
workspaceId: guestWorkspaceId,
...parseDefaultGroupWithBlock({
type: InputBlockType.TEXT,
options: defaultTextInputOptions,
}),
},
{
name: 'Another typebot',
workspaceId: guestWorkspaceId,
},
])
await prisma.collaboratorsOnTypebots.create({
data: {
typebotId,
userId,
type: CollaborationType.READ,
},
})
await createFolder(guestWorkspaceId, 'Guest folder')
await injectFakeResults({ typebotId, count: 10 })
await page.goto(`/typebots`)
await page.click('text=Pro workspace')
await page.click('text=Guest workspace #2')
await expect(page.locator('text=Guest typebot')).toBeVisible()
await expect(page.locator('text=Another typebot')).toBeHidden()
await expect(page.locator('text=Guest folder')).toBeHidden()
await page.click('text=Guest typebot')
await page.click('button[aria-label="Show collaboration menu"]')
await page.click('text=Everyone at Guest workspace')
await expect(page.locator('text="Remove"')).toBeHidden()
await expect(page.locator('text=John Doe')).toBeVisible()
await page.click('text=Group #1', { force: true })
await expect(page.locator('input[value="Group #1"]')).toBeHidden()
await page.goto(`/typebots/${typebotId}/results`)
await expect(page.locator('text="See logs" >> nth=9')).toBeVisible()
})
})

View File

@ -0,0 +1,259 @@
import {
Stack,
HStack,
Input,
Button,
Menu,
MenuButton,
MenuItem,
MenuList,
SkeletonCircle,
SkeletonText,
Text,
Tag,
Flex,
} from '@chakra-ui/react'
import { ChevronLeftIcon } from '@/components/icons'
import { useToast } from '@/hooks/useToast'
import { useTypebot } from '@/features/editor'
import { useWorkspace } from '@/features/workspace'
import { CollaborationType, WorkspaceRole } from 'db'
import React, { FormEvent, useState } from 'react'
import { CollaboratorItem } from './CollaboratorButton'
import { EmojiOrImageIcon } from '@/components/EmojiOrImageIcon'
import { useCollaborators } from '../../hooks/useCollaborators'
import { useInvitations } from '../../hooks/useInvitations'
import { updateInvitationQuery } from '../../queries/updateInvitationQuery'
import { deleteInvitationQuery } from '../../queries/deleteInvitationQuery'
import { updateCollaboratorQuery } from '../../queries/updateCollaboratorQuery'
import { deleteCollaboratorQuery } from '../../queries/deleteCollaboratorQuery'
import { sendInvitationQuery } from '../../queries/sendInvitationQuery'
export const CollaborationList = () => {
const { currentRole, workspace } = useWorkspace()
const { typebot } = useTypebot()
const [invitationType, setInvitationType] = useState<CollaborationType>(
CollaborationType.READ
)
const [invitationEmail, setInvitationEmail] = useState('')
const [isSendingInvitation, setIsSendingInvitation] = useState(false)
const hasFullAccess =
(currentRole && currentRole !== WorkspaceRole.GUEST) || false
const { showToast } = useToast()
const {
collaborators,
isLoading: isCollaboratorsLoading,
mutate: mutateCollaborators,
} = useCollaborators({
typebotId: typebot?.id,
onError: (e) =>
showToast({
title: "Couldn't fetch collaborators",
description: e.message,
}),
})
const {
invitations,
isLoading: isInvitationsLoading,
mutate: mutateInvitations,
} = useInvitations({
typebotId: typebot?.id,
onError: (e) =>
showToast({
title: "Couldn't fetch invitations",
description: e.message,
}),
})
const handleChangeInvitationCollabType =
(email: string) => async (type: CollaborationType) => {
if (!typebot || !hasFullAccess) return
const { error } = await updateInvitationQuery(typebot?.id, email, {
email,
typebotId: typebot.id,
type,
})
if (error)
return showToast({ title: error.name, description: error.message })
mutateInvitations({
invitations: (invitations ?? []).map((i) =>
i.email === email ? { ...i, type } : i
),
})
}
const handleDeleteInvitation = (email: string) => async () => {
if (!typebot || !hasFullAccess) return
const { error } = await deleteInvitationQuery(typebot?.id, email)
if (error)
return showToast({ title: error.name, description: error.message })
mutateInvitations({
invitations: (invitations ?? []).filter((i) => i.email !== email),
})
}
const handleChangeCollaborationType =
(userId: string) => async (type: CollaborationType) => {
if (!typebot || !hasFullAccess) return
const { error } = await updateCollaboratorQuery(typebot?.id, userId, {
userId,
type,
typebotId: typebot.id,
})
if (error)
return showToast({ title: error.name, description: error.message })
mutateCollaborators({
collaborators: (collaborators ?? []).map((c) =>
c.userId === userId ? { ...c, type } : c
),
})
}
const handleDeleteCollaboration = (userId: string) => async () => {
if (!typebot || !hasFullAccess) return
const { error } = await deleteCollaboratorQuery(typebot?.id, userId)
if (error)
return showToast({ title: error.name, description: error.message })
mutateCollaborators({
collaborators: (collaborators ?? []).filter((c) => c.userId !== userId),
})
}
const handleInvitationSubmit = async (e: FormEvent) => {
e.preventDefault()
if (!typebot || !hasFullAccess) return
setIsSendingInvitation(true)
const { error } = await sendInvitationQuery(typebot.id, {
email: invitationEmail,
type: invitationType,
})
setIsSendingInvitation(false)
mutateInvitations({ invitations: invitations ?? [] })
mutateCollaborators({ collaborators: collaborators ?? [] })
if (error)
return showToast({ title: error.name, description: error.message })
showToast({ status: 'success', title: 'Invitation sent! 📧' })
setInvitationEmail('')
}
return (
<Stack spacing={4} py="4">
<HStack as="form" onSubmit={handleInvitationSubmit} px="4">
<Input
size="sm"
placeholder="colleague@company.com"
name="inviteEmail"
value={invitationEmail}
onChange={(e) => setInvitationEmail(e.target.value)}
rounded="md"
isDisabled={!hasFullAccess}
/>
{hasFullAccess && (
<CollaborationTypeMenuButton
type={invitationType}
onChange={setInvitationType}
/>
)}
<Button
size="sm"
colorScheme="blue"
isLoading={isSendingInvitation}
flexShrink={0}
type="submit"
isDisabled={!hasFullAccess}
>
Invite
</Button>
</HStack>
{workspace && (
<Flex py="2" px="4" justifyContent="space-between">
<HStack minW={0}>
<EmojiOrImageIcon icon={workspace.icon} />
<Text fontSize="15px" noOfLines={1}>
Everyone at {workspace.name}
</Text>
</HStack>
<Tag flexShrink={0}>
{convertCollaborationTypeEnumToReadable(
CollaborationType.FULL_ACCESS
)}
</Tag>
</Flex>
)}
{invitations?.map(({ email, type }) => (
<CollaboratorItem
key={email}
email={email}
type={type}
isOwner={hasFullAccess}
onDeleteClick={handleDeleteInvitation(email)}
onChangeCollaborationType={handleChangeInvitationCollabType(email)}
isGuest
/>
))}
{collaborators?.map(({ user, type, userId }) => (
<CollaboratorItem
key={userId}
email={user.email ?? ''}
image={user.image ?? undefined}
name={user.name ?? undefined}
type={type}
isOwner={hasFullAccess}
onDeleteClick={handleDeleteCollaboration(userId ?? '')}
onChangeCollaborationType={handleChangeCollaborationType(userId)}
/>
))}
{(isCollaboratorsLoading || isInvitationsLoading) && (
<HStack p="4">
<SkeletonCircle boxSize="32px" />
<SkeletonText width="200px" noOfLines={2} />
</HStack>
)}
</Stack>
)
}
const CollaborationTypeMenuButton = ({
type,
onChange,
}: {
type: CollaborationType
onChange: (type: CollaborationType) => void
}) => {
return (
<Menu placement="bottom-end">
<MenuButton
flexShrink={0}
size="sm"
as={Button}
rightIcon={<ChevronLeftIcon transform={'rotate(-90deg)'} />}
>
{convertCollaborationTypeEnumToReadable(type)}
</MenuButton>
<MenuList minW={0}>
<Stack maxH={'35vh'} overflowY="scroll" spacing="0">
<MenuItem onClick={() => onChange(CollaborationType.READ)}>
{convertCollaborationTypeEnumToReadable(CollaborationType.READ)}
</MenuItem>
<MenuItem onClick={() => onChange(CollaborationType.WRITE)}>
{convertCollaborationTypeEnumToReadable(CollaborationType.WRITE)}
</MenuItem>
</Stack>
</MenuList>
</Menu>
)
}
export const convertCollaborationTypeEnumToReadable = (
type: CollaborationType
) => {
switch (type) {
case CollaborationType.READ:
return 'Can view'
case CollaborationType.WRITE:
return 'Can edit'
case CollaborationType.FULL_ACCESS:
return 'Full access'
}
}

View File

@ -0,0 +1,36 @@
import {
Popover,
PopoverTrigger,
PopoverContent,
IconButton,
Tooltip,
} from '@chakra-ui/react'
import { UsersIcon } from '@/components/icons'
import React from 'react'
import { CollaborationList } from './CollaborationList'
export const CollaborationMenuButton = ({
isLoading,
}: {
isLoading: boolean
}) => {
return (
<Popover isLazy placement="bottom-end">
<PopoverTrigger>
<span>
<Tooltip label="Invite users to collaborate">
<IconButton
isLoading={isLoading}
icon={<UsersIcon />}
aria-label="Show collaboration menu"
size="sm"
/>
</Tooltip>
</span>
</PopoverTrigger>
<PopoverContent shadow="lg" width="430px">
<CollaborationList />
</PopoverContent>
</Popover>
)
}

View File

@ -0,0 +1,105 @@
import {
Avatar,
HStack,
Menu,
MenuButton,
MenuItem,
MenuList,
Stack,
Tag,
Text,
} from '@chakra-ui/react'
import { CollaborationType } from 'db'
import React from 'react'
import { convertCollaborationTypeEnumToReadable } from './CollaborationList'
type Props = {
image?: string
name?: string
email: string
type: CollaborationType
isGuest?: boolean
isOwner: boolean
onDeleteClick: () => void
onChangeCollaborationType: (type: CollaborationType) => void
}
export const CollaboratorItem = ({
email,
name,
image,
type,
isGuest = false,
isOwner,
onDeleteClick,
onChangeCollaborationType,
}: Props) => {
const handleEditClick = () =>
onChangeCollaborationType(CollaborationType.WRITE)
const handleViewClick = () =>
onChangeCollaborationType(CollaborationType.READ)
return (
<Menu placement="bottom-end">
<MenuButton _hover={{ backgroundColor: 'gray.100' }} borderRadius="md">
<CollaboratorIdentityContent
email={email}
name={name}
image={image}
isGuest={isGuest}
tag={convertCollaborationTypeEnumToReadable(type)}
/>
</MenuButton>
{isOwner && (
<MenuList shadow="lg">
<MenuItem onClick={handleEditClick}>
{convertCollaborationTypeEnumToReadable(CollaborationType.WRITE)}
</MenuItem>
<MenuItem onClick={handleViewClick}>
{convertCollaborationTypeEnumToReadable(CollaborationType.READ)}
</MenuItem>
<MenuItem color="red.500" onClick={onDeleteClick}>
Remove
</MenuItem>
</MenuList>
)}
</Menu>
)
}
export const CollaboratorIdentityContent = ({
name,
tag,
isGuest = false,
image,
email,
}: {
name?: string
tag?: string
image?: string
isGuest?: boolean
email: string
}) => (
<HStack justifyContent="space-between" maxW="full" py="2" px="4">
<HStack minW={0}>
<Avatar name={name} src={image} size="sm" />
<Stack spacing={0} minW="0">
{name && (
<Text textAlign="left" fontSize="15px">
{name}
</Text>
)}
<Text
color="gray.500"
fontSize={name ? '14px' : 'inherit'}
noOfLines={1}
>
{email}
</Text>
</Stack>
</HStack>
<HStack flexShrink={0}>
{isGuest && <Tag color="gray.400">Pending</Tag>}
<Tag>{tag}</Tag>
</HStack>
</HStack>
)

View File

@ -0,0 +1 @@
export * from './CollaborationMenuButton'

View File

@ -0,0 +1,22 @@
import { fetcher } from '@/utils/helpers'
import useSWR from 'swr'
import { Collaborator } from '../types'
export const useCollaborators = ({
typebotId,
onError,
}: {
typebotId?: string
onError: (error: Error) => void
}) => {
const { data, error, mutate } = useSWR<
{ collaborators: Collaborator[] },
Error
>(typebotId ? `/api/typebots/${typebotId}/collaborators` : null, fetcher)
if (error) onError(error)
return {
collaborators: data?.collaborators,
isLoading: !error && !data,
mutate,
}
}

View File

@ -0,0 +1,26 @@
import { fetcher } from '@/utils/helpers'
import { Invitation } from 'db'
import useSWR from 'swr'
import { env } from 'utils'
export const useInvitations = ({
typebotId,
onError,
}: {
typebotId?: string
onError: (error: Error) => void
}) => {
const { data, error, mutate } = useSWR<{ invitations: Invitation[] }, Error>(
typebotId ? `/api/typebots/${typebotId}/invitations` : null,
fetcher,
{
dedupingInterval: env('E2E_TEST') === 'true' ? 0 : undefined,
}
)
if (error) onError(error)
return {
invitations: data?.invitations,
isLoading: !error && !data,
mutate,
}
}

View File

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

View File

@ -0,0 +1,7 @@
import { sendRequest } from 'utils'
export const deleteCollaboratorQuery = (typebotId: string, userId: string) =>
sendRequest({
method: 'DELETE',
url: `/api/typebots/${typebotId}/collaborators/${userId}`,
})

View File

@ -0,0 +1,7 @@
import { sendRequest } from 'utils'
export const deleteInvitationQuery = (typebotId: string, email: string) =>
sendRequest({
method: 'DELETE',
url: `/api/typebots/${typebotId}/invitations/${email}`,
})

View File

@ -0,0 +1,12 @@
import { CollaborationType } from 'db'
import { sendRequest } from 'utils'
export const sendInvitationQuery = (
typebotId: string,
{ email, type }: { email: string; type: CollaborationType }
) =>
sendRequest({
method: 'POST',
url: `/api/typebots/${typebotId}/invitations`,
body: { email, type },
})

View File

@ -0,0 +1,13 @@
import { CollaboratorsOnTypebots } from 'db'
import { sendRequest } from 'utils'
export const updateCollaboratorQuery = (
typebotId: string,
userId: string,
collaborator: CollaboratorsOnTypebots
) =>
sendRequest({
method: 'PATCH',
url: `/api/typebots/${typebotId}/collaborators/${userId}`,
body: collaborator,
})

View File

@ -0,0 +1,13 @@
import { Invitation } from 'db'
import { sendRequest } from 'utils'
export const updateInvitationQuery = (
typebotId: string,
email: string,
invitation: Omit<Invitation, 'createdAt' | 'id'>
) =>
sendRequest({
method: 'PATCH',
url: `/api/typebots/${typebotId}/invitations/${email}`,
body: invitation,
})

View File

@ -0,0 +1,9 @@
import { CollaboratorsOnTypebots } from 'db'
export type Collaborator = CollaboratorsOnTypebots & {
user: {
name: string | null
image: string | null
email: string | null
}
}