2
0

🚸 (publish) Improve invalid public ID feedback

Also remove the 4 char min length rule for self-hosted versions

Closes #267
This commit is contained in:
Baptiste Arnaud
2023-01-20 11:20:11 +01:00
parent fe2952d407
commit 0febaf9760
9 changed files with 68 additions and 29 deletions

View File

@ -261,10 +261,9 @@ export const TypebotProvider = ({
await saveTypebot() await saveTypebot()
} }
if (!publishedTypebot) { if (!publishedTypebot) {
const newPublicId = parseDefaultPublicId( const newPublicId =
localTypebot.name, localTypebot.publicId ??
localTypebot.id parseDefaultPublicId(localTypebot.name, localTypebot.id)
)
updateLocalTypebot({ publicId: newPublicId, publishedTypebotId }) updateLocalTypebot({ publicId: newPublicId, publishedTypebotId })
newLocalTypebot.publicId = newPublicId newLocalTypebot.publicId = newPublicId
await saveTypebot() await saveTypebot()

View File

@ -11,30 +11,27 @@ import {
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { EditIcon } from '@/components/icons' import { EditIcon } from '@/components/icons'
import { CopyButton } from '@/components/CopyButton' import { CopyButton } from '@/components/CopyButton'
import { useToast } from '@/hooks/useToast'
import React, { useState } from 'react' import React, { useState } from 'react'
type EditableUrlProps = { type EditableUrlProps = {
hostname: string hostname: string
pathname?: string pathname?: string
isValid: (newPathname: string) => Promise<boolean> | boolean
onPathnameChange: (pathname: string) => void onPathnameChange: (pathname: string) => void
} }
export const EditableUrl = ({ export const EditableUrl = ({
hostname, hostname,
pathname, pathname,
isValid,
onPathnameChange, onPathnameChange,
}: EditableUrlProps) => { }: EditableUrlProps) => {
const { showToast } = useToast()
const [value, setValue] = useState(pathname) const [value, setValue] = useState(pathname)
const handleSubmit = (newPathname: string) => { const handleSubmit = async (newPathname: string) => {
if (/^[a-z0-9-]*$/.test(newPathname)) return onPathnameChange(newPathname) if (newPathname === pathname) return
if (await isValid(newPathname)) return onPathnameChange(newPathname)
setValue(pathname) setValue(pathname)
showToast({
title: 'Invalid ID',
description: 'Should contain only contain letters, numbers and dashes.',
})
} }
return ( return (

View File

@ -10,6 +10,7 @@ import { CustomDomainsDropdown } from '@/features/customDomains'
import { TypebotHeader, useTypebot } from '@/features/editor' import { TypebotHeader, useTypebot } from '@/features/editor'
import { useWorkspace } from '@/features/workspace' import { useWorkspace } from '@/features/workspace'
import { useToast } from '@/hooks/useToast' import { useToast } from '@/hooks/useToast'
import { isCloudProdInstance } from '@/utils/helpers'
import { import {
Flex, Flex,
Heading, Heading,
@ -32,14 +33,6 @@ export const SharePage = () => {
const { showToast } = useToast() const { showToast } = useToast()
const handlePublicIdChange = async (publicId: string) => { const handlePublicIdChange = async (publicId: string) => {
if (publicId === typebot?.publicId) return
if (publicId.length < 4)
return showToast({ description: 'ID must be longer than 4 characters' })
const { data } = await isPublicDomainAvailableQuery(publicId)
if (!data?.isAvailable)
return showToast({ description: 'ID is already taken' })
updateTypebot({ publicId }) updateTypebot({ publicId })
} }
@ -59,6 +52,40 @@ export const SharePage = () => {
const handleCustomDomainChange = (customDomain: string | undefined) => const handleCustomDomainChange = (customDomain: string | undefined) =>
updateTypebot({ customDomain }) updateTypebot({ customDomain })
const checkIfPathnameIsValid = (pathname: string) => {
const isCorrectlyFormatted =
/^([a-z0-9]+-[a-z0-9]+)*$/.test(pathname) || /^[a-z0-9]*$/.test(pathname)
if (!isCorrectlyFormatted) {
showToast({
description:
'Should contain only contain letters, numbers. Words can be separated by dashes.',
})
return false
}
return true
}
const checkIfPublicIdIsValid = async (publicId: string) => {
const isLongerThanAllowed = publicId.length >= 4
if (!isLongerThanAllowed && isCloudProdInstance) {
showToast({
description: 'Should be longer than 4 characters',
})
return false
}
if (!checkIfPathnameIsValid(publicId)) return false
const { data } = await isPublicDomainAvailableQuery(publicId)
if (!data?.isAvailable) {
showToast({ description: 'ID is already taken' })
return false
}
return true
}
return ( return (
<Flex flexDir="column" pb="40"> <Flex flexDir="column" pb="40">
<Seo title="Share" /> <Seo title="Share" />
@ -75,6 +102,7 @@ export const SharePage = () => {
getViewerUrl({ isBuilder: true }) ?? 'https://typebot.io' getViewerUrl({ isBuilder: true }) ?? 'https://typebot.io'
} }
pathname={publicId} pathname={publicId}
isValid={checkIfPublicIdIsValid}
onPathnameChange={handlePublicIdChange} onPathnameChange={handlePublicIdChange}
/> />
)} )}
@ -83,6 +111,7 @@ export const SharePage = () => {
<EditableUrl <EditableUrl
hostname={'https://' + typebot.customDomain.split('/')[0]} hostname={'https://' + typebot.customDomain.split('/')[0]}
pathname={typebot.customDomain.split('/')[1]} pathname={typebot.customDomain.split('/')[1]}
isValid={checkIfPathnameIsValid}
onPathnameChange={handlePathnameChange} onPathnameChange={handlePathnameChange}
/> />
<IconButton <IconButton

View File

@ -29,7 +29,22 @@ test('should not be able to submit taken url ID', async ({ page }) => {
]) ])
await page.goto(`/typebots/${typebotId}/share`) await page.goto(`/typebots/${typebotId}/share`)
await page.getByText(`${typebotId}-public`).click() await page.getByText(`${typebotId}-public`).click()
await page.getByRole('textbox').fill('id with spaces')
await page.getByRole('textbox').press('Enter')
await expect(
page
.getByText(
'Should contain only contain letters, numbers. Words can be separated by dashes.'
)
.nth(0)
).toBeVisible()
await page.getByText(`${typebotId}-public`).click()
await page.getByRole('textbox').fill('taken-url-id') await page.getByRole('textbox').fill('taken-url-id')
await page.getByRole('textbox').press('Enter') await page.getByRole('textbox').press('Enter')
await expect(page.getByText('ID is already taken').nth(0)).toBeVisible() await expect(page.getByText('ID is already taken').nth(0)).toBeVisible()
await page.getByText(`${typebotId}-public`).click()
await page.getByRole('textbox').fill('new-valid-id')
await page.getByRole('textbox').press('Enter')
await expect(page.getByText('new-valid-id')).toBeVisible()
await expect(page.getByText(`${typebotId}-public`)).toBeHidden()
}) })

View File

@ -1,6 +1,6 @@
import prisma from '@/lib/prisma' import prisma from '@/lib/prisma'
import { NextApiRequest, NextApiResponse } from 'next' import { NextApiRequest, NextApiResponse } from 'next'
import { badRequest, methodNotAllowed, notAuthenticated } from 'utils/api' import { methodNotAllowed, notAuthenticated } from 'utils/api'
import { getAuthenticatedUser } from '@/features/auth/api' import { getAuthenticatedUser } from '@/features/auth/api'
const handler = async (req: NextApiRequest, res: NextApiResponse) => { const handler = async (req: NextApiRequest, res: NextApiResponse) => {
@ -8,8 +8,9 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (!user) return notAuthenticated(res) if (!user) return notAuthenticated(res)
if (req.method === 'GET') { if (req.method === 'GET') {
const publicId = req.query.publicId as string | undefined const publicId = req.query.publicId as string | undefined
if (!publicId) return badRequest(res, 'publicId is required') const exists = await prisma.typebot.count({
const exists = await prisma.typebot.count({ where: { publicId } }) where: { publicId: publicId ?? '' },
})
return res.send({ isAvailable: Boolean(!exists) }) return res.send({ isAvailable: Boolean(!exists) })
} }
return methodNotAllowed(res) return methodNotAllowed(res)

View File

@ -51,7 +51,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
}) })
const typebots = await prisma.typebot.updateMany({ const typebots = await prisma.typebot.updateMany({
where: canWriteTypebots(typebotId, user), where: canWriteTypebots(typebotId, user),
data: { isArchived: true }, data: { isArchived: true, publicId: null },
}) })
return res.send({ typebots }) return res.send({ typebots })
} }

View File

@ -9,7 +9,7 @@ export const canWriteTypebots = (
user: Pick<User, 'email' | 'id'> user: Pick<User, 'email' | 'id'>
): Prisma.TypebotWhereInput => ): Prisma.TypebotWhereInput =>
isNotEmpty(env('E2E_TEST')) isNotEmpty(env('E2E_TEST'))
? {} ? { id: typeof typebotIds === 'string' ? typebotIds : { in: typebotIds } }
: { : {
id: typeof typebotIds === 'string' ? typebotIds : { in: typebotIds }, id: typeof typebotIds === 'string' ? typebotIds : { in: typebotIds },
OR: [ OR: [

View File

@ -62,9 +62,8 @@ export const getServerSideProps: GetServerSideProps = async (
const getTypebotFromPublicId = async ( const getTypebotFromPublicId = async (
publicId?: string publicId?: string
): Promise<TypebotPageProps['publishedTypebot'] | null> => { ): Promise<TypebotPageProps['publishedTypebot'] | null> => {
if (!publicId) return null
const publishedTypebot = await prisma.publicTypebot.findFirst({ const publishedTypebot = await prisma.publicTypebot.findFirst({
where: { typebot: { publicId } }, where: { typebot: { publicId: publicId ?? '' } },
include: { include: {
typebot: { select: { name: true, isClosed: true, isArchived: true } }, typebot: { select: { name: true, isClosed: true, isArchived: true } },
}, },

View File

@ -52,9 +52,8 @@ export const getServerSideProps: GetServerSideProps = async (
const getTypebotFromPublicId = async ( const getTypebotFromPublicId = async (
publicId?: string publicId?: string
): Promise<TypebotPageV2Props['typebot'] | null> => { ): Promise<TypebotPageV2Props['typebot'] | null> => {
if (!publicId) return null
const typebot = (await prisma.typebot.findUnique({ const typebot = (await prisma.typebot.findUnique({
where: { publicId }, where: { publicId: publicId ?? '' },
select: { select: {
theme: true, theme: true,
name: true, name: true,