2
0

Add recent section in icon and emoji picker

Closes #536
This commit is contained in:
Baptiste Arnaud
2023-06-20 14:04:33 +02:00
parent 36623930bc
commit eaadc59b1f
12 changed files with 161 additions and 87 deletions

View File

@@ -65,7 +65,7 @@ export const ColorPicker = ({ value, defaultValue, onColorChange }: Props) => {
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent width="170px"> <PopoverContent width="170px">
<PopoverArrow bg={displayedValue} /> <PopoverArrow />
<PopoverCloseButton color="white" /> <PopoverCloseButton color="white" />
<PopoverHeader <PopoverHeader
height="100px" height="100px"

View File

@@ -5,6 +5,7 @@ import {
HStack, HStack,
useColorModeValue, useColorModeValue,
SimpleGrid, SimpleGrid,
Text,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { iconNames } from './iconNames' import { iconNames } from './iconNames'
@@ -17,6 +18,9 @@ type Props = {
onIconSelected: (url: string) => void onIconSelected: (url: string) => void
} }
const localStorageRecentIconNamesKey = 'recentIconNames'
const localStorageDefaultIconColorKey = 'defaultIconColor'
export const IconPicker = ({ onIconSelected }: Props) => { export const IconPicker = ({ onIconSelected }: Props) => {
const initialIconColor = useColorModeValue('#222222', '#ffffff') const initialIconColor = useColorModeValue('#222222', '#ffffff')
const scrollContainer = useRef<HTMLDivElement>(null) const scrollContainer = useRef<HTMLDivElement>(null)
@@ -28,11 +32,22 @@ export const IconPicker = ({ onIconSelected }: Props) => {
const [selectedColor, setSelectedColor] = useState(initialIconColor) const [selectedColor, setSelectedColor] = useState(initialIconColor)
const isWhite = useMemo( const isWhite = useMemo(
() => () =>
selectedColor.toLowerCase() === '#ffffff' || initialIconColor === '#222222' &&
selectedColor.toLowerCase() === '#fff' || (selectedColor.toLowerCase() === '#ffffff' ||
selectedColor === 'white', selectedColor.toLowerCase() === '#fff' ||
[selectedColor] selectedColor === 'white'),
[initialIconColor, selectedColor]
) )
const [recentIconNames, setRecentIconNames] = useState([])
useEffect(() => {
const recentIconNames = localStorage.getItem(localStorageRecentIconNamesKey)
const defaultIconColor = localStorage.getItem(
localStorageDefaultIconColorKey
)
if (recentIconNames) setRecentIconNames(JSON.parse(recentIconNames))
if (defaultIconColor) setSelectedColor(defaultIconColor)
}, [])
useEffect(() => { useEffect(() => {
if (!bottomElement.current) return if (!bottomElement.current) return
@@ -70,10 +85,15 @@ export const IconPicker = ({ onIconSelected }: Props) => {
const updateColor = (color: string) => { const updateColor = (color: string) => {
if (!color.startsWith('#')) return if (!color.startsWith('#')) return
localStorage.setItem(localStorageDefaultIconColorKey, color)
setSelectedColor(color) setSelectedColor(color)
} }
const selectIcon = async (iconName: string) => { const selectIcon = async (iconName: string) => {
localStorage.setItem(
localStorageRecentIconNamesKey,
JSON.stringify([...new Set([iconName, ...recentIconNames].slice(0, 30))])
)
const svg = await (await fetch(`/icons/${iconName}.svg`)).text() const svg = await (await fetch(`/icons/${iconName}.svg`)).text()
const dataUri = `data:image/svg+xml;utf8,${svg const dataUri = `data:image/svg+xml;utf8,${svg
.replace('<svg', `<svg fill='${encodeURIComponent(selectedColor)}'`) .replace('<svg', `<svg fill='${encodeURIComponent(selectedColor)}'`)
@@ -89,34 +109,69 @@ export const IconPicker = ({ onIconSelected }: Props) => {
onChange={searchIcon} onChange={searchIcon}
withVariableButton={false} withVariableButton={false}
/> />
<ColorPicker defaultValue={selectedColor} onColorChange={updateColor} /> <ColorPicker value={selectedColor} onColorChange={updateColor} />
</HStack> </HStack>
<SimpleGrid <Stack overflowY="scroll" maxH="350px" ref={scrollContainer} spacing={4}>
spacing={0} {recentIconNames.length > 0 && (
minChildWidth="38px" <Stack>
overflowY="scroll" <Text fontSize="xs" color="gray.400" fontWeight="semibold" pl="2">
maxH="350px" RECENT
ref={scrollContainer} </Text>
overflow="scroll" <SimpleGrid
> spacing={0}
{displayedIconNames.map((iconName) => ( gridTemplateColumns={`repeat(auto-fill, minmax(38px, 1fr))`}
<Button bgColor={isWhite ? 'gray.400' : undefined}
size="sm" rounded="md"
variant={isWhite ? 'solid' : 'ghost'} >
colorScheme={isWhite ? 'blackAlpha' : undefined} {recentIconNames.map((iconName) => (
fontSize="xl" <Button
w="38px" size="sm"
h="38px" variant={'ghost'}
p="2" fontSize="xl"
key={iconName} w="38px"
onClick={() => selectIcon(iconName)} h="38px"
p="2"
key={iconName}
onClick={() => selectIcon(iconName)}
>
<Icon name={iconName} color={selectedColor} />
</Button>
))}
</SimpleGrid>
</Stack>
)}
<Stack>
{recentIconNames.length > 0 && (
<Text fontSize="xs" color="gray.400" fontWeight="semibold" pl="2">
ICONS
</Text>
)}
<SimpleGrid
spacing={0}
gridTemplateColumns={`repeat(auto-fill, minmax(38px, 1fr))`}
bgColor={isWhite ? 'gray.400' : undefined}
rounded="md"
> >
<Icon name={iconName} color={selectedColor} /> {displayedIconNames.map((iconName) => (
</Button> <Button
))} size="sm"
variant={'ghost'}
fontSize="xl"
w="38px"
h="38px"
p="2"
key={iconName}
onClick={() => selectIcon(iconName)}
>
<Icon name={iconName} color={selectedColor} />
</Button>
))}
</SimpleGrid>
</Stack>
<div ref={bottomElement} /> <div ref={bottomElement} />
</SimpleGrid> </Stack>
</Stack> </Stack>
) )
} }

View File

@@ -21,6 +21,8 @@ const objects = emojis['Objects']
const symbols = emojis['Symbols'] const symbols = emojis['Symbols']
const flags = emojis['Flags'] const flags = emojis['Flags']
const localStorageRecentEmojisKey = 'recentEmojis'
export const EmojiSearchableList = ({ export const EmojiSearchableList = ({
onEmojiSelected, onEmojiSelected,
}: { }: {
@@ -38,6 +40,13 @@ export const EmojiSearchableList = ({
const [filteredSymbols, setFilteredSymbols] = useState(symbols) const [filteredSymbols, setFilteredSymbols] = useState(symbols)
const [filteredFlags, setFilteredFlags] = useState(flags) const [filteredFlags, setFilteredFlags] = useState(flags)
const [totalDisplayedCategories, setTotalDisplayedCategories] = useState(1) const [totalDisplayedCategories, setTotalDisplayedCategories] = useState(1)
const [recentEmojis, setRecentEmojis] = useState([])
useEffect(() => {
const recentIconNames = localStorage.getItem(localStorageRecentEmojisKey)
if (!recentIconNames) return
setRecentEmojis(JSON.parse(recentIconNames))
}, [])
useEffect(() => { useEffect(() => {
if (!bottomElement.current) return if (!bottomElement.current) return
@@ -85,84 +94,88 @@ export const EmojiSearchableList = ({
setFilteredFlags(flags) setFilteredFlags(flags)
} }
const selectEmoji = (emoji: string) => {
localStorage.setItem(
localStorageRecentEmojisKey,
JSON.stringify([...new Set([emoji, ...recentEmojis].slice(0, 30))])
)
onEmojiSelected(emoji)
}
return ( return (
<Stack> <Stack>
<ClassicInput placeholder="Search..." onChange={handleSearchChange} /> <ClassicInput placeholder="Search..." onChange={handleSearchChange} />
<Stack ref={scrollContainer} overflow="scroll" maxH="350px" spacing={4}> <Stack ref={scrollContainer} overflow="scroll" maxH="350px" spacing={4}>
{recentEmojis.length > 0 && (
<Stack>
<Text fontSize="xs" color="gray.400" fontWeight="semibold" pl="2">
RECENT
</Text>
<EmojiGrid emojis={recentEmojis} onEmojiClick={selectEmoji} />
</Stack>
)}
{filteredPeople.length > 0 && ( {filteredPeople.length > 0 && (
<Stack> <Stack>
<Text fontSize="sm" pl="2"> <Text fontSize="xs" color="gray.400" fontWeight="semibold" pl="2">
People PEOPLE
</Text> </Text>
<EmojiGrid emojis={filteredPeople} onEmojiClick={onEmojiSelected} /> <EmojiGrid emojis={filteredPeople} onEmojiClick={selectEmoji} />
</Stack> </Stack>
)} )}
{filteredAnimals.length > 0 && totalDisplayedCategories >= 2 && ( {filteredAnimals.length > 0 && totalDisplayedCategories >= 2 && (
<Stack> <Stack>
<Text fontSize="sm" pl="2"> <Text fontSize="xs" color="gray.400" fontWeight="semibold" pl="2">
Animals & Nature ANIMALS & NATURE
</Text> </Text>
<EmojiGrid <EmojiGrid emojis={filteredAnimals} onEmojiClick={selectEmoji} />
emojis={filteredAnimals}
onEmojiClick={onEmojiSelected}
/>
</Stack> </Stack>
)} )}
{filteredFood.length > 0 && totalDisplayedCategories >= 3 && ( {filteredFood.length > 0 && totalDisplayedCategories >= 3 && (
<Stack> <Stack>
<Text fontSize="sm" pl="2"> <Text fontSize="xs" color="gray.400" fontWeight="semibold" pl="2">
Food & Drink FOOD & DRINK
</Text> </Text>
<EmojiGrid emojis={filteredFood} onEmojiClick={onEmojiSelected} /> <EmojiGrid emojis={filteredFood} onEmojiClick={selectEmoji} />
</Stack> </Stack>
)} )}
{filteredTravel.length > 0 && totalDisplayedCategories >= 4 && ( {filteredTravel.length > 0 && totalDisplayedCategories >= 4 && (
<Stack> <Stack>
<Text fontSize="sm" pl="2"> <Text fontSize="xs" color="gray.400" fontWeight="semibold" pl="2">
Travel & Places TRAVEL & PLACES
</Text> </Text>
<EmojiGrid emojis={filteredTravel} onEmojiClick={onEmojiSelected} /> <EmojiGrid emojis={filteredTravel} onEmojiClick={selectEmoji} />
</Stack> </Stack>
)} )}
{filteredActivities.length > 0 && totalDisplayedCategories >= 5 && ( {filteredActivities.length > 0 && totalDisplayedCategories >= 5 && (
<Stack> <Stack>
<Text fontSize="sm" pl="2"> <Text fontSize="xs" color="gray.400" fontWeight="semibold" pl="2">
Activities ACTIVITIES
</Text> </Text>
<EmojiGrid <EmojiGrid emojis={filteredActivities} onEmojiClick={selectEmoji} />
emojis={filteredActivities}
onEmojiClick={onEmojiSelected}
/>
</Stack> </Stack>
)} )}
{filteredObjects.length > 0 && totalDisplayedCategories >= 6 && ( {filteredObjects.length > 0 && totalDisplayedCategories >= 6 && (
<Stack> <Stack>
<Text fontSize="sm" pl="2"> <Text fontSize="xs" color="gray.400" fontWeight="semibold" pl="2">
Objects OBJECTS
</Text> </Text>
<EmojiGrid <EmojiGrid emojis={filteredObjects} onEmojiClick={selectEmoji} />
emojis={filteredObjects}
onEmojiClick={onEmojiSelected}
/>
</Stack> </Stack>
)} )}
{filteredSymbols.length > 0 && totalDisplayedCategories >= 7 && ( {filteredSymbols.length > 0 && totalDisplayedCategories >= 7 && (
<Stack> <Stack>
<Text fontSize="sm" pl="2"> <Text fontSize="xs" color="gray.400" fontWeight="semibold" pl="2">
Symbols SYMBOLS
</Text> </Text>
<EmojiGrid <EmojiGrid emojis={filteredSymbols} onEmojiClick={selectEmoji} />
emojis={filteredSymbols}
onEmojiClick={onEmojiSelected}
/>
</Stack> </Stack>
)} )}
{filteredFlags.length > 0 && totalDisplayedCategories >= 8 && ( {filteredFlags.length > 0 && totalDisplayedCategories >= 8 && (
<Stack> <Stack>
<Text fontSize="sm" pl="2"> <Text fontSize="xs" color="gray.400" fontWeight="semibold" pl="2">
Flags FLAGS
</Text> </Text>
<EmojiGrid emojis={filteredFlags} onEmojiClick={onEmojiSelected} /> <EmojiGrid emojis={filteredFlags} onEmojiClick={selectEmoji} />
</Stack> </Stack>
)} )}
<div ref={bottomElement} /> <div ref={bottomElement} />
@@ -180,7 +193,11 @@ const EmojiGrid = ({
}) => { }) => {
const handleClick = (emoji: string) => () => onEmojiClick(emoji) const handleClick = (emoji: string) => () => onEmojiClick(emoji)
return ( return (
<SimpleGrid spacing={0} columns={7}> <SimpleGrid
spacing={0}
gridTemplateColumns={`repeat(auto-fill, minmax(32px, 1fr))`}
rounded="md"
>
{emojis.map((emoji) => ( {emojis.map((emoji) => (
<GridItem <GridItem
as={Button} as={Button}

View File

@@ -58,7 +58,7 @@ test.describe('Payment input block', () => {
.locator(`[placeholder="MM / YY"]`) .locator(`[placeholder="MM / YY"]`)
.fill('12 / 25') .fill('12 / 25')
await stripePaymentForm(page).locator(`[placeholder="CVC"]`).fill('240') await stripePaymentForm(page).locator(`[placeholder="CVC"]`).fill('240')
await page.locator(`text="Pay 30€"`).click() await page.getByRole('button', { name: 'Pay 30,00 €' }).click()
await expect( await expect(
page.locator(`text="Your card has been declined."`) page.locator(`text="Your card has been declined."`)
).toBeVisible() ).toBeVisible()
@@ -68,7 +68,7 @@ test.describe('Payment input block', () => {
const zipInput = stripePaymentForm(page).getByPlaceholder('90210') const zipInput = stripePaymentForm(page).getByPlaceholder('90210')
const isZipInputVisible = await zipInput.isVisible() const isZipInputVisible = await zipInput.isVisible()
if (isZipInputVisible) await zipInput.fill('12345') if (isZipInputVisible) await zipInput.fill('12345')
await page.locator(`text="Pay 30€"`).click() await page.getByRole('button', { name: 'Pay 30,00 €' }).click()
await expect(page.locator(`text="Success"`)).toBeVisible() await expect(page.locator(`text="Success"`)).toBeVisible()
}) })
}) })

View File

@@ -23,12 +23,9 @@ test.describe('Google Analytics block', () => {
await page.goto(`/typebots/${typebotId}/edit`) await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Configure...') await page.click('text=Configure...')
await page.fill('input[placeholder="G-123456..."]', 'G-VWX9WG1TNS') await page.fill('input[placeholder="G-123456..."]', 'G-VWX9WG1TNS')
await page.fill('input[placeholder="Example: Typebot"]', 'Typebot') await page.fill('input[placeholder="Example: conversion"]', 'conversion')
await page.fill(
'input[placeholder="Example: Submit email"]',
'Submit email'
)
await page.click('text=Advanced') await page.click('text=Advanced')
await page.fill('input[placeholder="Example: Typebot"]', 'Typebot')
await page.fill('input[placeholder="Example: Campaign Z"]', 'Campaign Z') await page.fill('input[placeholder="Example: Campaign Z"]', 'Campaign Z')
await page.fill('input[placeholder="Example: 0"]', '0') await page.fill('input[placeholder="Example: 0"]', '0')
}) })

View File

@@ -59,7 +59,7 @@ export const MetadataForm = ({
rounded="md" rounded="md"
/> />
</PopoverTrigger> </PopoverTrigger>
<PopoverContent p="4"> <PopoverContent p="4" w="400px">
<ImageUploadContent <ImageUploadContent
filePath={`typebots/${typebotId}/favIcon`} filePath={`typebots/${typebotId}/favIcon`}
defaultUrl={metadata.favIconUrl ?? ''} defaultUrl={metadata.favIconUrl ?? ''}
@@ -85,7 +85,7 @@ export const MetadataForm = ({
rounded="md" rounded="md"
/> />
</PopoverTrigger> </PopoverTrigger>
<PopoverContent p="4"> <PopoverContent p="4" w="500px">
<ImageUploadContent <ImageUploadContent
filePath={`typebots/${typebotId}/ogImage`} filePath={`typebots/${typebotId}/ogImage`}
defaultUrl={metadata.imageUrl} defaultUrl={metadata.imageUrl}

View File

@@ -87,6 +87,7 @@ export const AvatarForm = ({
p="4" p="4"
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
w="500px"
> >
<ImageUploadContent <ImageUploadContent
filePath={uploadFilePath} filePath={uploadFilePath}

View File

@@ -8,6 +8,7 @@ import {
Text, Text,
Image, Image,
Button, Button,
Portal,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { isNotEmpty } from '@typebot.io/lib' import { isNotEmpty } from '@typebot.io/lib'
import { Background, BackgroundType } from '@typebot.io/schemas' import { Background, BackgroundType } from '@typebot.io/schemas'
@@ -57,14 +58,16 @@ export const BackgroundContent = ({
<Button>Select an image</Button> <Button>Select an image</Button>
)} )}
</PopoverTrigger> </PopoverTrigger>
<PopoverContent p="4"> <Portal>
<ImageUploadContent <PopoverContent p="4" w="500px">
filePath={`typebots/${typebot?.id}/background`} <ImageUploadContent
defaultUrl={background.content} filePath={`typebots/${typebot?.id}/background`}
onSubmit={handleContentChange} defaultUrl={background.content}
excludedTabs={['giphy', 'icon']} onSubmit={handleContentChange}
/> excludedTabs={['giphy', 'icon']}
</PopoverContent> />
</PopoverContent>
</Portal>
</Popover> </Popover>
) )
default: default:

View File

@@ -41,7 +41,9 @@ test.beforeAll(async () => {
test('can switch between workspaces and access typebot', async ({ page }) => { test('can switch between workspaces and access typebot', async ({ page }) => {
await page.goto('/typebots') await page.goto('/typebots')
await expect(page.locator('text="Pro typebot"')).toBeVisible() await expect(page.locator('text="Pro typebot"')).toBeVisible({
timeout: 20000,
})
await page.click('text=Pro workspace') await page.click('text=Pro workspace')
await page.click('text="Starter workspace"') await page.click('text="Starter workspace"')
await expect(page.locator('text="Pro typebot"')).toBeHidden() await expect(page.locator('text="Pro typebot"')).toBeHidden()

View File

@@ -19,7 +19,6 @@ import { I18nProvider } from '@/locales'
import { TypebotProvider } from '@/features/editor/providers/TypebotProvider' import { TypebotProvider } from '@/features/editor/providers/TypebotProvider'
import { WorkspaceProvider } from '@/features/workspace/WorkspaceProvider' import { WorkspaceProvider } from '@/features/workspace/WorkspaceProvider'
import { isCloudProdInstance } from '@/helpers/isCloudProdInstance' import { isCloudProdInstance } from '@/helpers/isCloudProdInstance'
import en from '@/locales/en'
const { ToastContainer, toast } = createStandaloneToast(customTheme) const { ToastContainer, toast } = createStandaloneToast(customTheme)

View File

@@ -17,7 +17,7 @@ export const Button = (props: Props) => {
{...buttonProps} {...buttonProps}
disabled={props.isDisabled || props.isLoading} disabled={props.isDisabled || props.isLoading}
class={ class={
'py-2 px-4 font-semibold focus:outline-none filter hover:brightness-90 active:brightness-75 disabled:opacity-50 disabled:cursor-not-allowed disabled:brightness-100' + 'py-2 px-4 font-semibold focus:outline-none filter hover:brightness-90 active:brightness-75 disabled:opacity-50 disabled:cursor-not-allowed disabled:brightness-100 flex justify-center' +
(props.variant === 'secondary' (props.variant === 'secondary'
? ' secondary-button' ? ' secondary-button'
: ' typebot-button') + : ' typebot-button') +

View File

@@ -3,7 +3,7 @@ import { JSX } from 'solid-js'
export const Spinner = (props: JSX.SvgSVGAttributes<SVGSVGElement>) => ( export const Spinner = (props: JSX.SvgSVGAttributes<SVGSVGElement>) => (
<svg <svg
{...props} {...props}
class={'animate-spin h-5 w-5 ' + props.class} class={'animate-spin h-6 w-6 ' + props.class}
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"