refactor(web): replace emoji-mart with self-hosted Frimousse picker (#42469)

This commit is contained in:
Stephen Zhou authored and GitHub committed 2026-09-18 03:04:57 +00:00
1 parent 6d88df5d7c
commit 228541c15d
39 files changed
+2376 -472

No files matched your search

+22 -24
View File
@@ -185,9 +185,6 @@ catalogs:
'@egoist/tailwindcss-icons':
specifier: 1.9.2
version: 1.9.2
'@emoji-mart/data':
specifier: 1.2.1
version: 1.2.1
'@eslint-community/eslint-plugin-eslint-comments':
specifier: 4.8.1
version: 4.8.1
@@ -464,9 +461,6 @@ catalogs:
embla-carousel-react:
specifier: 8.6.0
version: 8.6.0
emoji-mart:
specifier: 5.6.0
version: 5.6.0
es-toolkit:
specifier: 1.52.0
version: 1.52.0
@@ -536,6 +530,9 @@ catalogs:
foxact:
specifier: 0.3.11
version: 0.3.11
frimousse:
specifier: 0.3.0
version: 0.3.0
fuse.js:
specifier: 7.5.0
version: 7.5.0
@@ -1360,9 +1357,6 @@ importers:
'@base-ui/react':
specifier: 'catalog:'
version: 1.8.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
'@emoji-mart/data':
specifier: 'catalog:'
version: 1.2.1
'@floating-ui/react':
specifier: 'catalog:'
version: 0.27.20(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
@@ -1486,9 +1480,6 @@ importers:
embla-carousel-react:
specifier: 'catalog:'
version: 8.6.0(react@19.3.0)
emoji-mart:
specifier: 'catalog:'
version: 5.6.0
es-toolkit:
specifier: 'catalog:'
version: 1.52.0
@@ -1498,6 +1489,9 @@ importers:
foxact:
specifier: 'catalog:'
version: 0.3.11(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
frimousse:
specifier: 'catalog:'
version: 0.3.0(@typescript/typescript6@6.0.2)(react@19.3.0)
fuse.js:
specifier: 'catalog:'
version: 7.5.0
@@ -2177,9 +2171,6 @@ packages:
'@emnapi/wasi-threads@1.2.2':
resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==}
'@emoji-mart/data@1.2.1':
resolution: {integrity: sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==}
'@es-joy/jsdoccomment@0.92.0':
resolution: {integrity: sha512-WivZqV5jPTYDQJgIMp0hTsyQESKYNY6yCJr4b0l84tYtQntkAMkc6zARh2vlRQyeg4aYHPUkCUDpvr2IWAiGpQ==}
engines: {node: ^22.22.2 || >=24.15.0}
@@ -6346,9 +6337,6 @@ packages:
embla-carousel@8.6.0:
resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==}
emoji-mart@5.6.0:
resolution: {integrity: sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==}
emoji-regex-xs@2.0.1:
resolution: {integrity: sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==}
engines: {node: '>=10.0.0'}
@@ -6810,6 +6798,15 @@ packages:
react-dom:
optional: true
frimousse@0.3.0:
resolution: {integrity: sha512-kO6LMoKY/cLAYEhXXtqLRaLIE6L/DagpFPrUZaLv3LsUa1/8Iza3HhwZcgN8eZ+weXnhv69eoclNUPohcCa/IQ==}
peerDependencies:
react: ^18 || ^19
typescript: '>=5.1.0'
peerDependenciesMeta:
typescript:
optional: true
fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
@@ -10193,8 +10190,6 @@ snapshots:
tslib: 2.8.1
optional: true
'@emoji-mart/data@1.2.1': {}
'@es-joy/jsdoccomment@0.92.0':
dependencies:
'@types/estree': 1.0.9
@@ -13962,8 +13957,6 @@ snapshots:
embla-carousel@8.6.0: {}
emoji-mart@5.6.0: {}
emoji-regex-xs@2.0.1: {}
empathic@2.0.1: {}
@@ -14584,6 +14577,12 @@ snapshots:
react: 19.3.0
react-dom: 19.3.0(react@19.3.0)
frimousse@0.3.0(@typescript/typescript6@6.0.2)(react@19.3.0):
dependencies:
react: 19.3.0
optionalDependencies:
typescript: '@typescript/typescript6@6.0.2'
fs-constants@1.0.0:
optional: true
@@ -18102,7 +18101,6 @@ time:
'@chromatic-com/storybook@5.3.1': '2026-09-02T13:43:27.595Z'
'@cucumber/cucumber@13.2.1': '2026-08-04T07:20:28.681Z'
'@egoist/tailwindcss-icons@1.9.2': '2026-01-31T10:48:44.594Z'
'@emoji-mart/data@1.2.1': '2024-04-25T15:36:14.556Z'
'@eslint-community/eslint-plugin-eslint-comments@4.8.1': '2026-09-12T22:48:47.148Z'
'@eslint-react/eslint-plugin@5.19.1': '2026-09-14T17:56:33.726Z'
'@eslint/markdown@8.0.3': '2026-07-01T13:28:44.339Z'
@@ -18196,7 +18194,6 @@ time:
embla-carousel-autoplay@8.6.0: '2025-04-04T17:37:46.303Z'
embla-carousel-fade@8.6.0: '2025-04-04T17:37:50.278Z'
embla-carousel-react@8.6.0: '2025-04-04T17:37:53.976Z'
emoji-mart@5.6.0: '2024-04-25T14:22:21.440Z'
es-toolkit@1.52.0: '2026-08-28T07:22:17.687Z'
eslint-config-flat-gitignore@2.4.0: '2026-08-27T07:24:43.063Z'
eslint-markdown@0.17.0: '2026-09-16T06:20:36.564Z'
@@ -18220,6 +18217,7 @@ time:
eventsource-parser@4.1.1: '2026-09-15T18:30:29.195Z'
fast-deep-equal@3.1.3: '2020-06-08T07:27:28.474Z'
foxact@0.3.11: '2026-09-05T12:25:54.102Z'
frimousse@0.3.0: '2025-07-15T10:44:32.958Z'
fuse.js@7.5.0: '2026-07-13T17:23:36.705Z'
happy-dom@20.14.5: '2026-09-12T00:07:47.688Z'
hast-util-to-jsx-runtime@2.3.6: '2025-03-05T11:30:29.166Z'
+1 -2
View File
@@ -68,7 +68,6 @@ catalog:
'@chromatic-com/storybook': 5.3.1
'@cucumber/cucumber': 13.2.1
'@egoist/tailwindcss-icons': 1.9.2
'@emoji-mart/data': 1.2.1
'@eslint-community/eslint-plugin-eslint-comments': 4.8.1
'@eslint-react/eslint-plugin': 5.19.1
'@eslint/markdown': 8.0.3
@@ -162,7 +161,6 @@ catalog:
embla-carousel-autoplay: 8.6.0
embla-carousel-fade: 8.6.0
embla-carousel-react: 8.6.0
emoji-mart: 5.6.0
es-toolkit: 1.52.0
eslint: 10.10.0
eslint-config-flat-gitignore: 2.4.0
@@ -186,6 +184,7 @@ catalog:
eventsource-parser: 4.1.1
fast-deep-equal: 3.1.3
foxact: 0.3.11
frimousse: 0.3.0
fuse.js: 7.5.0
happy-dom: 20.14.5
hast-util-to-jsx-runtime: 2.3.6
+2
View File
@@ -40,6 +40,8 @@ const generatedIgnores = [
'web/public/embed.min.js',
'web/public/pdf.worker.min.mjs',
'web/public/vs/**',
// Vendored Emojibase JSON is served verbatim.
'web/public/emoji/emojibase-*/**',
]
const formatterUnstableInputs = ['web/app/components/develop/template/*.mdx']
@@ -218,7 +218,7 @@ describe('dataset-config/card-item', () => {
const iconElement = nameElement.parentElement?.firstElementChild as HTMLElement
expect(iconElement)!.toHaveStyle({ background: '#FFF4ED' })
expect(iconElement.querySelector('em-emoji'))!.toHaveAttribute('id', '📙')
expect(iconElement).toHaveTextContent('📙')
})
it('should apply mask overlay on mobile when drawer is open', async () => {
@@ -1,4 +1,5 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { mockEmojiData } from '@/test/emoji-picker'
import ExternalDataToolModal from '../external-data-tool-modal'
const mockToastError = vi.fn()
@@ -131,9 +132,9 @@ describe('ExternalDataToolModal', () => {
fireEvent.click(screen.getByText('pick-extension'))
fireEvent.click(screen.getByText('open-emoji-picker'))
await waitFor(() => {
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
})
const emojiButton = document.querySelector('em-emoji')?.closest('button')
const emojiButton = await screen.findByRole('gridcell', { name: 'Grinning face' })
expect(emojiButton).toBeTruthy()
fireEvent.click(emojiButton!)
fireEvent.click(screen.getByRole('button', { name: '#E4FBCC' }))
@@ -228,3 +229,5 @@ describe('ExternalDataToolModal', () => {
expect(mockOnCancel).toHaveBeenCalled()
})
})
mockEmojiData()
@@ -4,6 +4,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { useRouter } from '@/next/navigation'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { mockEmojiData } from '@/test/emoji-picker'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
import { trackCreateApp } from '@/utils/create-app-tracking'
@@ -265,12 +266,12 @@ describe('CreateAppModal', () => {
fireEvent.click(screen.getByText('app.types.chatbot'))
fireEvent.click(screen.getByText('open-icon-picker'))
await waitFor(() => {
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: '#E4FBCC' }))
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
await waitFor(() => {
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument()
})
fireEvent.change(screen.getByPlaceholderText('app.newApp.appNamePlaceholder'), {
target: { value: 'Keyboard App' },
@@ -309,14 +310,14 @@ describe('CreateAppModal', () => {
fireEvent.click(screen.getByText('open-icon-picker'))
await waitFor(() => {
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.cancel/ }))
await waitFor(() => {
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument()
})
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument()
ahooksMocks.keyPressHandlers.at(-1)?.()
@@ -379,3 +380,5 @@ describe('CreateAppModal', () => {
})
})
})
mockEmojiData()
@@ -41,7 +41,7 @@ vi.mock('@/app/components/base/app-icon-picker', () => ({
<div>
<input placeholder="Search emojis..." />
<button type="button" onClick={() => {}}>
<em-emoji />
<span>😀</span>
</button>
<button
type="button"
@@ -256,7 +256,7 @@ describe('DuplicateAppModal', () => {
await waitFor(() => {
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
})
const emojiButton = document.querySelector('em-emoji')?.closest('button')
const emojiButton = screen.getByText('😀').closest('button')
expect(emojiButton).toBeTruthy()
await user.click(emojiButton!)
await user.click(screen.getByRole('button', { name: '#E4FBCC', hidden: true }))
@@ -52,7 +52,7 @@ const APIKeyInfoPanel: FC = () => {
isCloud ? 'flex h-8 items-center space-x-1' : 'mb-6 leading-8',
)}
>
{isCloud && <em-emoji id="😀" />}
{isCloud && <span>😀</span>}
{isCloud ? (
<div>
{t(($) => $['apiKeyInfo.cloud.trial.title'], {
@@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { mockEmojiData } from '@/test/emoji-picker'
import { AppModeEnum } from '@/types/app'
import SwitchAppModal from '../index'
@@ -295,13 +296,13 @@ describe('SwitchAppModal', () => {
await user.click(screen.getByText('open-icon-picker'))
await waitFor(() => {
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
})
await user.click(screen.getByRole('button', { name: '#E4FBCC' }))
await user.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
await waitFor(() => {
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument()
})
await user.click(screen.getByRole('button', { name: 'app.switchStart' }))
@@ -325,13 +326,13 @@ describe('SwitchAppModal', () => {
await user.click(screen.getByText('open-icon-picker'))
await waitFor(() => {
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
})
await user.click(screen.getByRole('button', { name: /iconPicker\.cancel/ }))
await waitFor(() => {
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument()
})
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument()
await user.click(screen.getByText('app.removeOriginal'))
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
@@ -398,3 +399,5 @@ describe('SwitchAppModal', () => {
})
})
})
mockEmojiData()
@@ -544,10 +544,10 @@ describe('AppCard', () => {
it('should render app icon', () => {
// AppIcon component renders the emoji icon from app data
const { container } = render(<AppCard app={mockApp} />)
const emojiIcon = container.querySelector(`em-emoji[id="${mockApp.icon}"]`)
const emojiIcon = screen.getByText(mockApp.icon!)
const imageIcon = container.querySelector('img')
expect(emojiIcon || imageIcon).toBeTruthy()
expect(emojiIcon?.parentElement).toHaveAttribute('aria-hidden', 'true')
expect(emojiIcon).toHaveAttribute('aria-hidden', 'true')
})
it('should treat a redundant image icon as decorative', () => {
@@ -43,7 +43,7 @@ describe('ImportFromMarketplaceTemplateModal', () => {
expect(screen.getByText('Human Input: Writing Assistant')).toBeInTheDocument()
expect(screen.queryByText('technologist')).not.toBeInTheDocument()
expect(document.querySelector('em-emoji')?.parentElement).toHaveAttribute('aria-hidden', 'true')
expect(screen.getByText('🧑‍💻')).toHaveAttribute('aria-hidden', 'true')
expect(
screen.getByRole('dialog', { name: /marketplace\.template\.modalTitle/ }),
).toBeInTheDocument()
@@ -2,11 +2,8 @@
import type { FC } from 'react'
import type { AppIconType } from '@/types/app'
import data from '@emoji-mart/data'
import { cn } from '@langgenius/dify-ui/cn'
import { init } from 'emoji-mart'
init({ data })
import { resolveEmoji } from '@/utils/emoji'
type AnswerIconProps = {
iconType?: AppIconType | null
@@ -32,10 +29,8 @@ const AnswerIcon: FC<AnswerIconProps> = ({ iconType, icon, background, imageUrl
<div className={wrapperClassName} style={{ background: background || '#D5F5F6' }}>
{isValidImageIcon ? (
<img src={imageUrl} className="size-full rounded-full" alt="answer icon" />
) : icon && icon !== '' ? (
<em-emoji id={icon} />
) : (
<em-emoji id="🤖" />
resolveEmoji(icon)
)}
</div>
)
@@ -3,6 +3,7 @@ import type { Area } from 'react-easy-crop'
import type { ImageFile } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { mockEmojiData } from '@/test/emoji-picker'
import { TransferMethod } from '@/types/app'
import AppIconPicker from '../index'
import 'vitest-canvas-mock'
@@ -109,10 +110,6 @@ vi.mock('../../image-uploader/hooks', () => ({
},
}))
vi.mock('@/utils/emoji', () => ({
searchEmoji: vi.fn().mockResolvedValue(['grinning', 'sunglasses']),
}))
describe('AppIconPicker', () => {
const originalCreateElement = document.createElement.bind(document)
const originalCreateObjectURL = globalThis.URL.createObjectURL
@@ -205,11 +202,11 @@ describe('AppIconPicker', () => {
it('should call onSelect with emoji data after emoji selection', async () => {
const { onSelect } = renderPicker()
await waitFor(() => {
expect(document.querySelector('em-emoji')?.closest('button'))!.toBeInTheDocument()
await waitFor(async () => {
expect(await screen.findByRole('gridcell', { name: 'Grinning face' }))!.toBeInTheDocument()
})
const firstEmoji = document.querySelector('em-emoji')?.closest('button')
const firstEmoji = await screen.findByRole('gridcell', { name: 'Grinning face' })
if (!firstEmoji) throw new Error('Could not find emoji option')
await userEvent.click(firstEmoji)
@@ -371,3 +368,5 @@ describe('AppIconPicker', () => {
})
})
})
mockEmojiData()
+3 -17
View File
@@ -1,25 +1,13 @@
'use client'
import type { FC } from 'react'
import type { AppIconType } from '@/types/app'
import data from '@emoji-mart/data'
import { cn } from '@langgenius/dify-ui/cn'
import { RiEditLine } from '@remixicon/react'
import { useHover } from 'ahooks'
import { cva } from 'class-variance-authority'
import { init } from 'emoji-mart'
import * as React from 'react'
import { useRef, useSyncExternalStore } from 'react'
init({ data })
const subscribeHydrationState = () => () => {}
const useIsHydrated = () =>
useSyncExternalStore(
subscribeHydrationState,
() => true,
() => false,
)
import { useRef } from 'react'
import { resolveEmoji } from '@/utils/emoji'
type AppIconProps = {
size?: 'xs' | 'tiny' | 'small' | 'medium' | 'large' | 'xl' | 'xxl'
@@ -113,9 +101,7 @@ const AppIcon: FC<AppIconProps> = ({
}) => {
const isValidImageIcon = iconType === 'image' && imageUrl
const isDecorative = decorative && !onClick
const emojiIcon = icon && icon !== '' ? icon : '🤖'
const isHydrated = useIsHydrated()
const Icon = isHydrated ? <em-emoji key={emojiIcon} id={emojiIcon} /> : emojiIcon
const Icon = resolveEmoji(icon)
const wrapperRef = useRef<HTMLSpanElement>(null)
const isHovering = useHover(wrapperRef)
const handleKeyDown = (event: React.KeyboardEvent<HTMLSpanElement>) => {
+74 -99
View File
@@ -1,19 +1,15 @@
'use client'
import type { EmojiMartData } from '@emoji-mart/data'
import data from '@emoji-mart/data'
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'
import { cn } from '@langgenius/dify-ui/cn'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@langgenius/dify-ui/input-group'
import { init } from 'emoji-mart'
import { InputGroup, InputGroupAddon } from '@langgenius/dify-ui/input-group'
import { EmojiPicker } from 'frimousse'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { searchEmoji } from '@/utils/emoji'
import { resolveEmoji } from '@/utils/emoji'
import { basePath } from '@/utils/var'
import { backgroundColors, defaultEmojiBackground } from './constants'
init({ data })
type IEmojiPickerInnerProps = {
emoji?: string
background?: string
@@ -21,15 +17,37 @@ type IEmojiPickerInnerProps = {
className?: string
}
const listComponents: NonNullable<React.ComponentProps<typeof EmojiPicker.List>['components']> = {
CategoryHeader: ({ category, ...props }) => (
<div
{...props}
className="bg-components-panel-bg px-3 pb-1 system-xs-medium-uppercase text-text-primary"
>
{category.label}
</div>
),
Row: ({ children, ...props }) => (
<div {...props} className="scroll-my-1 gap-1 px-3 pb-1">
{children}
</div>
),
Emoji: ({ emoji, ...props }) => (
<button
{...props}
type="button"
className="flex h-10 min-w-0 flex-1 items-center justify-center rounded-lg text-xl focus-visible:outline-2 focus-visible:outline-offset-2 data-[active]:bg-state-base-hover"
>
{emoji.emoji}
</button>
),
}
function EmojiPickerInner({ emoji, background, onSelect, className }: IEmojiPickerInnerProps) {
const { t } = useTranslation()
const { categories } = data as EmojiMartData
const [selectedEmoji, setSelectedEmoji] = useState(emoji || '')
const [selectedEmoji, setSelectedEmoji] = useState(() => (emoji ? resolveEmoji(emoji) : ''))
const [selectedBackground, setSelectedBackground] = useState(background || defaultEmojiBackground)
const [showStyleColors, setShowStyleColors] = useState(!!emoji)
const [searchedEmojis, setSearchedEmojis] = useState<string[]>([])
const [isSearching, setIsSearching] = useState(false)
const styleColorsLabelId = React.useId()
const handleEmojiSelect = (emoji: string) => {
@@ -44,88 +62,43 @@ function EmojiPickerInner({ emoji, background, onSelect, className }: IEmojiPick
}
return (
<div className={cn(className, 'flex flex-col')}>
<div className="flex w-full flex-col items-center px-3 pb-2">
<InputGroup>
<InputGroupInput
type="search"
aria-label={t(($) => $['operation.search'], { ns: 'common' })}
placeholder="Search emojis..."
onValueChange={async (value) => {
if (value === '') {
setIsSearching(false)
} else {
setIsSearching(true)
const emojis = await searchEmoji(value)
setSearchedEmojis(emojis)
}
}}
/>
<InputGroupAddon className="ps-3 pe-2">
<MagnifyingGlassIcon className="size-5 text-text-quaternary" aria-hidden="true" />
</InputGroupAddon>
</InputGroup>
</div>
<Divider className="my-3" />
<div className="max-h-50 w-full overflow-x-hidden overflow-y-auto px-3">
{isSearching && (
<>
<div key="category-search" className="flex flex-col">
<p className="mb-1 system-xs-medium-uppercase text-text-primary">Search</p>
<div className="grid size-full grid-cols-8 gap-1">
{searchedEmojis.map((emoji: string, index: number) => {
return (
<button
type="button"
key={`emoji-search-${index}`}
aria-label={emoji}
className="inline-flex size-10 items-center justify-center rounded-lg border-none bg-transparent p-0"
onClick={() => {
handleEmojiSelect(emoji)
}}
>
<span className="flex size-8 cursor-pointer items-center justify-center rounded-lg p-1 ring-components-input-border-hover ring-offset-1 hover:ring-1">
<em-emoji id={emoji} />
</span>
</button>
)
})}
</div>
</div>
</>
)}
{categories.map((category, index: number) => {
return (
<div key={`category-${index}`} className="flex flex-col">
<p className="mb-1 system-xs-medium-uppercase text-text-primary">{category.id}</p>
<div className="grid size-full grid-cols-8 gap-1">
{category.emojis.map((emoji, index: number) => {
return (
<button
type="button"
key={`emoji-${index}`}
aria-label={emoji}
className="inline-flex size-10 items-center justify-center rounded-lg border-none bg-transparent p-0"
onClick={() => {
handleEmojiSelect(emoji)
}}
>
<span className="flex size-8 cursor-pointer items-center justify-center rounded-lg p-1 ring-components-input-border-hover ring-offset-1 hover:ring-1">
<em-emoji id={emoji} />
</span>
</button>
)
})}
</div>
</div>
)
})}
</div>
<div className={cn(className, 'flex min-h-0 w-full min-w-0 flex-col')}>
<EmojiPicker.Root
className="flex min-h-0 flex-1 flex-col"
locale="en"
columns={8}
emojibaseUrl={`${basePath}/emoji/emojibase-17.0.0`}
onEmojiSelect={({ emoji }) => handleEmojiSelect(emoji)}
>
<div className="px-3 pb-2">
<InputGroup>
<EmojiPicker.Search
aria-label={t(($) => $['operation.search'], { ns: 'common' })}
placeholder={t(($) => $['operation.search'], { ns: 'common' })}
className="h-8 min-w-0 flex-1 bg-transparent px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-components-input-border-hover"
/>
<InputGroupAddon className="ps-3 pe-2">
<span
className="i-heroicons-magnifying-glass size-5 text-text-quaternary"
aria-hidden="true"
/>
</InputGroupAddon>
</InputGroup>
</div>
<Divider className="my-3" />
<EmojiPicker.Viewport className="relative h-50 min-h-0 w-full overflow-x-hidden overflow-y-auto">
<EmojiPicker.Loading className="block p-3 text-text-tertiary">
{t(($) => $.loading, { ns: 'common' })}
</EmojiPicker.Loading>
<EmojiPicker.Empty className="block p-3 text-text-tertiary">
{t(($) => $.noData, { ns: 'common' })}
</EmojiPicker.Empty>
<EmojiPicker.List components={listComponents} />
</EmojiPicker.Viewport>
</EmojiPicker.Root>
{/* Color Select */}
<div className={cn('flex items-center justify-between p-3 pb-0')}>
<div className={cn('flex shrink-0 items-center justify-between p-3 pb-0')}>
<p id={styleColorsLabelId} className="mb-2 system-xs-medium-uppercase text-text-primary">
Choose Style
</p>
@@ -134,7 +107,7 @@ function EmojiPickerInner({ emoji, background, onSelect, className }: IEmojiPick
type="button"
aria-labelledby={styleColorsLabelId}
aria-expanded="true"
className="i-heroicons-chevron-down size-4 cursor-pointer border-none bg-transparent p-0 text-text-quaternary"
className="i-heroicons-chevron-down size-4 cursor-pointer border-none bg-transparent p-0 text-text-quaternary focus-visible:outline-2 focus-visible:outline-offset-2"
onClick={() => setShowStyleColors(!showStyleColors)}
/>
) : (
@@ -142,13 +115,13 @@ function EmojiPickerInner({ emoji, background, onSelect, className }: IEmojiPick
type="button"
aria-labelledby={styleColorsLabelId}
aria-expanded="false"
className="i-heroicons-chevron-up size-4 cursor-pointer border-none bg-transparent p-0 text-text-quaternary"
className="i-heroicons-chevron-up size-4 cursor-pointer border-none bg-transparent p-0 text-text-quaternary focus-visible:outline-2 focus-visible:outline-offset-2"
onClick={() => setShowStyleColors(!showStyleColors)}
/>
)}
</div>
{showStyleColors && (
<div className="grid w-full grid-cols-8 gap-1 px-3">
<div className="grid w-full shrink-0 grid-cols-8 gap-1 px-3">
{backgroundColors.map((color) => {
return (
<button
@@ -156,10 +129,10 @@ function EmojiPickerInner({ emoji, background, onSelect, className }: IEmojiPick
key={color}
aria-label={color}
className={cn(
'cursor-pointer',
'cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2',
'border-none bg-transparent p-0',
'ring-components-input-border-hover ring-offset-1 hover:ring-1',
'inline-flex size-10 items-center justify-center rounded-lg',
'inline-flex h-10 w-full min-w-0 items-center justify-center rounded-lg',
color === selectedBackground ? 'ring-1 ring-components-input-border-hover' : '',
)}
onClick={() => {
@@ -167,10 +140,12 @@ function EmojiPickerInner({ emoji, background, onSelect, className }: IEmojiPick
}}
>
<span
className={cn('flex size-8 items-center justify-center rounded-lg p-1')}
className={cn(
'flex size-8 max-w-full items-center justify-center rounded-lg p-1 text-xl',
)}
style={{ background: color }}
>
{selectedEmoji !== '' && <em-emoji id={selectedEmoji} />}
{selectedEmoji}
</span>
</button>
)
@@ -0,0 +1,83 @@
import { page, userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import AppIconPicker from '../../app-icon-picker'
import { backgroundColors } from '../constants'
import EmojiPickerInner from '../Inner'
// Real layout is needed to verify virtualized rows remain reachable using the keyboard.
it('selects a searched emoji with the keyboard and keeps style controls usable', async () => {
const onSelect = vi.fn()
await render(
<div style={{ width: 380 }}>
<EmojiPickerInner onSelect={onSelect} />
</div>,
)
const search = page.getByRole('searchbox')
await search.fill('rabbit face')
await expect.element(page.getByRole('gridcell', { name: 'Rabbit face' })).toBeVisible()
await userEvent.keyboard('{Enter}')
await expect.poll(() => onSelect.mock.lastCall).toEqual(['🐰', '#FFEAD5'])
await page.getByRole('button', { name: '#E4FBCC' }).click()
expect(onSelect).toHaveBeenLastCalledWith('🐰', '#E4FBCC')
await search.fill('')
await expect
.element(page.getByRole('gridcell', { name: 'Grinning face', exact: true }))
.toBeVisible()
await search.click()
await userEvent.keyboard(
'{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}{Enter}',
)
expect(onSelect).toHaveBeenCalledTimes(3)
})
// The production dialog is narrower than the old fixed-width eight-column grid.
// Verify every edge option is visible and clickable, rather than hiding overflow.
it('fits emoji and style options inside the app icon dialog', async () => {
const originalFontSize = document.documentElement.style.fontSize
document.documentElement.style.fontSize = '16px'
onTestFinished(() => {
document.documentElement.style.fontSize = originalFontSize
})
const onSelect = vi.fn()
await render(
<AppIconPicker
open
onOpenChange={() => {}}
onSelect={onSelect}
initialEmoji={{ icon: 'robot_face' }}
/>,
)
const dialog = page.getByRole('dialog')
const firstEmoji = page.getByRole('gridcell', { name: 'Grinning face', exact: true })
await expect.element(firstEmoji).toBeVisible()
const bounds = dialog.element().getBoundingClientRect()
const options = [
...dialog.element().querySelectorAll('[role="row"] [role="gridcell"]'),
...backgroundColors.map((color) =>
page.getByRole('button', { name: color, exact: true }).element(),
),
]
for (const option of options) {
const rect = option.getBoundingClientRect()
expect(rect.left).toBeGreaterThanOrEqual(bounds.left)
expect(rect.right).toBeLessThanOrEqual(bounds.right)
}
const footerTop = page
.getByRole('button', { name: 'app.iconPicker.ok', exact: true })
.element()
.getBoundingClientRect().top
for (const color of backgroundColors) {
expect(
page.getByRole('button', { name: color, exact: true }).element().getBoundingClientRect()
.bottom,
).toBeLessThanOrEqual(footerTop)
}
await page.getByRole('gridcell', { name: 'Face with tears of joy', exact: true }).click()
await page.getByRole('button', { name: backgroundColors.at(-1)!, exact: true }).click()
await page.getByRole('button', { name: 'app.iconPicker.ok', exact: true }).click()
expect(onSelect).toHaveBeenCalledWith({
type: 'emoji',
icon: '😂',
background: backgroundColors.at(-1),
})
})
@@ -1,181 +1,55 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { mockEmojiData } from '@/test/emoji-picker'
import EmojiPickerInner from '../Inner'
vi.mock('@emoji-mart/data', () => ({
default: {
categories: [
{
id: 'nature',
emojis: ['rabbit', 'bear'],
},
{
id: 'food',
emojis: ['apple', 'orange'],
},
],
},
}))
vi.mock('@/utils/var', () => ({ basePath: '/console' }))
vi.mock('emoji-mart', () => ({
init: vi.fn(),
}))
vi.mock('@/utils/emoji', () => ({
searchEmoji: vi.fn().mockResolvedValue(['dog', 'cat']),
}))
mockEmojiData()
describe('EmojiPickerInner', () => {
const mockOnSelect = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
// Define the custom element to avoid "Unknown custom element" warnings
if (!customElements.get('em-emoji')) {
customElements.define(
'em-emoji',
class extends HTMLElement {
static get observedAttributes() {
return ['id']
}
},
)
}
it('selects Unicode from self-hosted data and preserves the chosen background', async () => {
const onSelect = vi.fn()
render(<EmojiPickerInner onSelect={onSelect} />)
fireEvent.click(await screen.findByRole('gridcell', { name: 'Grinning face' }))
expect(onSelect).toHaveBeenLastCalledWith('😀', '#FFEAD5')
fireEvent.click(screen.getByRole('button', { name: '#E4FBCC' }))
expect(onSelect).toHaveBeenLastCalledWith('😀', '#E4FBCC')
expect(fetch).toHaveBeenCalledTimes(2)
expect(fetch).toHaveBeenCalledWith(
'/console/emoji/emojibase-17.0.0/en/data.json',
expect.any(Object),
)
expect(fetch).toHaveBeenCalledWith(
'/console/emoji/emojibase-17.0.0/en/messages.json',
expect.any(Object),
)
})
describe('Rendering', () => {
it('renders initial categories and emojis correctly', () => {
render(<EmojiPickerInner onSelect={mockOnSelect} />)
expect(screen.getByText('nature'))!.toBeInTheDocument()
expect(screen.getByText('food'))!.toBeInTheDocument()
expect(screen.getByPlaceholderText('Search emojis...'))!.toBeInTheDocument()
})
it('initializes selected emoji and background when provided', () => {
render(<EmojiPickerInner emoji="rabbit" background="#E4FBCC" onSelect={mockOnSelect} />)
expect(screen.getByText('Choose Style'))!.toBeInTheDocument()
expect(mockOnSelect).not.toHaveBeenCalled()
})
it('filters search, selects a result, shows empty state and restores categories on clear', async () => {
const onSelect = vi.fn()
render(<EmojiPickerInner onSelect={onSelect} />)
await screen.findByRole('gridcell', { name: 'Grinning face' })
const search = screen.getByRole('searchbox')
fireEvent.change(search, { target: { value: 'rabbit' } })
fireEvent.click(await screen.findByRole('gridcell', { name: 'Rabbit face' }))
expect(onSelect).toHaveBeenLastCalledWith('🐰', '#FFEAD5')
fireEvent.change(search, { target: { value: 'zzzz-no-emoji' } })
await screen.findByText('common.noData')
fireEvent.change(search, { target: { value: '' } })
await screen.findByRole('gridcell', { name: 'Grinning face' })
})
describe('User Interactions', () => {
it('calls searchEmoji and displays results when typing in search input', async () => {
render(<EmojiPickerInner onSelect={mockOnSelect} />)
const searchInput = screen.getByPlaceholderText('Search emojis...')
await act(async () => {
fireEvent.change(searchInput, { target: { value: 'anim' } })
})
await waitFor(() => {
expect(screen.getByText('Search'))!.toBeInTheDocument()
})
const searchSection = screen.getByText('Search').parentElement
expect(searchSection?.querySelectorAll('em-emoji').length).toBe(2)
})
it('updates selected emoji and calls onSelect when an emoji is clicked', async () => {
render(<EmojiPickerInner onSelect={mockOnSelect} />)
const emojiButton = screen.getByRole('button', { name: 'rabbit' })
await act(async () => {
fireEvent.click(emojiButton)
})
expect(mockOnSelect).toHaveBeenCalledWith('rabbit', expect.any(String))
})
it('toggles style colors display when clicking the chevron', async () => {
render(<EmojiPickerInner onSelect={mockOnSelect} />)
expect(screen.queryByText('#FFEAD5')).not.toBeInTheDocument()
const toggleButton = screen.getByRole('button', { name: 'Choose Style' })
expect(toggleButton)!.toBeInTheDocument()
await act(async () => {
fireEvent.click(toggleButton!)
})
expect(screen.getByText('Choose Style'))!.toBeInTheDocument()
const colorOptions = document.querySelectorAll('[style^="background:"]')
expect(colorOptions.length).toBeGreaterThan(0)
})
it('updates background color and calls onSelect when a color is clicked', async () => {
render(<EmojiPickerInner onSelect={mockOnSelect} />)
const toggleButton = screen.getByRole('button', { name: 'Choose Style' })
await act(async () => {
fireEvent.click(toggleButton!)
})
const emojiButton = screen.getByRole('button', { name: 'rabbit' })
await act(async () => {
fireEvent.click(emojiButton)
})
mockOnSelect.mockClear()
const colorOptions = screen.getAllByRole('button', { name: /^#/ })
await act(async () => {
fireEvent.click(colorOptions[1]!)
})
expect(mockOnSelect).toHaveBeenCalledWith('rabbit', '#E4FBCC')
})
it('updates selected emoji when clicking a search result', async () => {
render(<EmojiPickerInner onSelect={mockOnSelect} />)
const searchInput = screen.getByPlaceholderText('Search emojis...')
await act(async () => {
fireEvent.change(searchInput, { target: { value: 'anim' } })
})
await screen.findByText('Search')
const searchEmoji = screen.getByRole('button', { name: 'dog' })
await act(async () => {
fireEvent.click(searchEmoji)
})
expect(mockOnSelect).toHaveBeenCalledWith('dog', expect.any(String))
})
it('toggles style colors display back and forth', async () => {
render(<EmojiPickerInner onSelect={mockOnSelect} />)
const toggleButton = screen.getByRole('button', { name: 'Choose Style' })
await act(async () => {
fireEvent.click(toggleButton!)
})
expect(screen.getByText('Choose Style'))!.toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Choose Style' }))
})
expect(screen.queryByText('#FFEAD5')).not.toBeInTheDocument()
})
it('clears search results when input is cleared', async () => {
render(<EmojiPickerInner onSelect={mockOnSelect} />)
const searchInput = screen.getByPlaceholderText('Search emojis...')
await act(async () => {
fireEvent.change(searchInput, { target: { value: 'anim' } })
})
await screen.findByText('Search')
await act(async () => {
fireEvent.change(searchInput, { target: { value: '' } })
})
expect(screen.queryByText('Search')).not.toBeInTheDocument()
})
it('resolves a saved ID in style previews and only emits after a user action', async () => {
const onSelect = vi.fn()
render(<EmojiPickerInner emoji="rabbit" onSelect={onSelect} />)
const color = screen.getByRole('button', { name: '#E4FBCC' })
expect(color).toHaveTextContent('🐰')
expect(onSelect).not.toHaveBeenCalled()
fireEvent.click(color)
expect(onSelect).toHaveBeenCalledWith('🐰', '#E4FBCC')
fireEvent.click(screen.getByRole('button', { name: 'Choose Style' }))
await waitFor(() =>
expect(screen.queryByRole('button', { name: '#E4FBCC' })).not.toBeInTheDocument(),
)
})
})
@@ -1,28 +1,8 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import { mockEmojiData } from '@/test/emoji-picker'
import EmojiPicker from '../index'
vi.mock('@emoji-mart/data', () => ({
default: {
categories: [
{
id: 'category1',
name: 'Category 1',
emojis: ['emoji1', 'emoji2'],
},
],
},
}))
vi.mock('emoji-mart', () => ({
init: vi.fn(),
SearchIndex: {
search: vi.fn().mockResolvedValue([{ skins: [{ native: '🔍' }] }]),
},
}))
vi.mock('@/utils/emoji', () => ({
searchEmoji: vi.fn().mockResolvedValue(['🔍']),
}))
mockEmojiData()
describe('EmojiPicker', () => {
const mockOnSelect = vi.fn()
@@ -43,7 +23,7 @@ describe('EmojiPicker', () => {
render(<EmojiPicker open onOpenChange={mockOnOpenChange} />)
})
expect(screen.getByRole('dialog', { name: /Emoji/i }))!.toBeInTheDocument()
expect(screen.getByPlaceholderText('Search emojis...'))!.toBeInTheDocument()
expect(screen.getByPlaceholderText('common.operation.search'))!.toBeInTheDocument()
expect(screen.getByText(/Cancel/i))!.toBeInTheDocument()
expect(screen.getByText(/OK/i))!.toBeInTheDocument()
})
@@ -63,9 +43,7 @@ describe('EmojiPicker', () => {
render(<EmojiPicker open onOpenChange={mockOnOpenChange} onSelect={mockOnSelect} />)
})
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'emoji1' }))
})
fireEvent.click(await screen.findByRole('gridcell', { name: 'Grinning face' }))
const okButton = screen.getByText(/OK/i)
expect(okButton.closest('button')).not.toBeDisabled()
@@ -5,6 +5,7 @@ import { fireEvent, screen, render as testingLibraryRender, waitFor } from '@tes
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { ChunkingMode } from '@/models/datasets'
import { mockEmojiData } from '@/test/emoji-picker'
import EditPipelineInfo from '../edit-pipeline-info'
const render = (ui: ReactElement) =>
@@ -282,7 +283,7 @@ describe('EditPipelineInfo', () => {
// Open icon picker
fireEvent.click(getIconButton())
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
})
it('should save correct icon_info when starting with image icon type', async () => {
@@ -346,13 +347,13 @@ describe('EditPipelineInfo', () => {
// Open picker
fireEvent.click(getIconButton())
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
// Close without selection - should revert to original image icon
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.cancel/ }))
await waitFor(() => {
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument()
})
})
@@ -370,7 +371,7 @@ describe('EditPipelineInfo', () => {
// Open picker and select emoji
fireEvent.click(getIconButton())
const emojiButton = document.querySelector('em-emoji')?.closest('button')
const emojiButton = await screen.findByRole('gridcell', { name: 'Grinning face' })
expect(emojiButton).toBeTruthy()
fireEvent.click(emojiButton!)
fireEvent.click(screen.getByRole('button', { name: '#E4FBCC' }))
@@ -407,14 +408,14 @@ describe('EditPipelineInfo', () => {
describe('AppIconPicker', () => {
it('should not show picker initially', () => {
render(<EditPipelineInfo {...defaultProps} />)
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument()
})
it('should open picker when icon is clicked', () => {
render(<EditPipelineInfo {...defaultProps} />)
fireEvent.click(getIconButton())
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
})
it('should close picker and update icon when emoji style is selected', async () => {
@@ -425,7 +426,7 @@ describe('EditPipelineInfo', () => {
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
await waitFor(() => {
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument()
})
})
@@ -445,7 +446,7 @@ describe('EditPipelineInfo', () => {
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.cancel/ }))
await waitFor(() => {
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument()
})
})
@@ -488,7 +489,7 @@ describe('EditPipelineInfo', () => {
render(<EditPipelineInfo {...defaultProps} pipeline={createImagePipelineTemplate()} />)
fireEvent.click(getIconButton())
const emojiButton = document.querySelector('em-emoji')?.closest('button')
const emojiButton = await screen.findByRole('gridcell', { name: 'Grinning face' })
expect(emojiButton).toBeTruthy()
fireEvent.click(emojiButton!)
fireEvent.click(screen.getByRole('button', { name: '#E4FBCC' }))
@@ -577,3 +578,5 @@ describe('EditPipelineInfo', () => {
})
})
})
mockEmojiData()
@@ -3,6 +3,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import { mockEmojiData } from '@/test/emoji-picker'
import RenameDatasetModal from '../index'
const { mockToast } = vi.hoisted(() => {
@@ -42,7 +43,7 @@ vi.mock('@/app/components/base/app-icon-picker', () => ({
let selectedBackground = '#FFEAD5'
return (
<div>
<input placeholder="Search emojis..." />
<input placeholder="common.operation.search" />
<button
type="button"
aria-label="#E4FBCC"
@@ -910,7 +911,7 @@ describe('RenameDatasetModal', () => {
// Initially picker should not be visible
// Initially picker should not be visible
// Initially picker should not be visible
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument()
const appIcon = screen.getByTestId('app-icon')
await act(async () => {
@@ -918,7 +919,7 @@ describe('RenameDatasetModal', () => {
})
await waitFor(() => {
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
})
})
@@ -928,12 +929,12 @@ describe('RenameDatasetModal', () => {
await user.click(screen.getByTestId('app-icon'))
await waitFor(() => {
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
})
await user.click(screen.getByRole('button', { name: '#E4FBCC' }))
await user.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
await waitFor(() => {
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument()
})
// Save and verify new icon is used
@@ -963,12 +964,12 @@ describe('RenameDatasetModal', () => {
await user.click(screen.getByTestId('app-icon'))
await waitFor(() => {
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
})
await user.click(screen.getByRole('button', { name: '#E0F2FE' }))
await user.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
await waitFor(() => {
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument()
})
const saveButton = screen.getByText('common.operation.save')
@@ -1002,11 +1003,11 @@ describe('RenameDatasetModal', () => {
const user = userEvent.setup()
await waitFor(() => {
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
})
await user.click(screen.getByRole('button', { name: /iconPicker\.cancel/ }))
await waitFor(() => {
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument()
})
// Save and verify original icon is preserved
@@ -1363,3 +1364,5 @@ describe('RenameDatasetModal', () => {
})
})
})
mockEmojiData()
@@ -5,6 +5,7 @@ import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { mockEmojiData } from '@/test/emoji-picker'
import { AppModeEnum } from '@/types/app'
import CreateAppModal from '../index'
@@ -28,16 +29,6 @@ const triggerHotkey = (hotkey: string) => {
registration?.handler()
}
vi.mock('emoji-mart', () => ({
init: vi.fn(),
SearchIndex: { search: vi.fn().mockResolvedValue([]) },
}))
vi.mock('@emoji-mart/data', () => ({
default: {
categories: [{ id: 'people', emojis: ['😀'] }],
},
}))
vi.mock('@/next/navigation', () => ({
useParams: () => ({}),
}))
@@ -332,35 +323,28 @@ describe('CreateAppModal', () => {
})
it('should update icon payload when selecting emoji and confirming', async () => {
vi.useFakeTimers()
try {
const { onConfirm } = await setup({
appIconType: 'image',
appIcon: 'file-123',
appIconUrl: 'https://example.com/icon.png',
})
const { onConfirm } = await setup({
appIconType: 'image',
appIcon: 'file-123',
appIconUrl: 'https://example.com/icon.png',
})
const pickerDialog = openAppIconPicker()
const pickerDialog = openAppIconPicker()
fireEvent.click(within(pickerDialog).getByRole('button', { name: '😀' }))
fireEvent.click(await within(pickerDialog).findByRole('gridcell', { name: 'Grinning face' }))
fireEvent.click(within(pickerDialog).getByRole('button', { name: 'app.iconPicker.ok' }))
fireEvent.click(within(pickerDialog).getByRole('button', { name: 'app.iconPicker.ok' }))
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ }))
await act(async () => {
vi.advanceTimersByTime(300)
})
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ }))
await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1))
expect(onConfirm).toHaveBeenCalledTimes(1)
const payload = onConfirm.mock.calls[0]![0]
expect(payload).toMatchObject({
icon_type: 'emoji',
icon: '😀',
icon_background: '#FFEAD5',
})
} finally {
vi.useRealTimers()
}
expect(onConfirm).toHaveBeenCalledTimes(1)
const payload = onConfirm.mock.calls[0]![0]
expect(payload).toMatchObject({
icon_type: 'emoji',
icon: '😀',
icon_background: '#FFEAD5',
})
})
it('should allow changing only the background for the current emoji icon', async () => {
@@ -558,3 +542,5 @@ it('edits an existing app without waiting for application quota data', async ()
await userEvent.setup().click(save)
await waitFor(() => expect(onConfirm).toHaveBeenCalledOnce())
})
mockEmojiData()
@@ -1,6 +1,7 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { mockEmojiData } from '@/test/emoji-picker'
import PublishAsKnowledgePipelineModal from '../publish-as-knowledge-pipeline-modal'
vi.mock('@/app/components/workflow/store', () => ({
@@ -157,11 +158,11 @@ describe('PublishAsKnowledgePipelineModal', () => {
it('should show icon picker when app icon clicked', async () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument()
fireEvent.click(getIconButton())
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
})
it('should update icon when emoji style is selected', async () => {
@@ -172,7 +173,7 @@ describe('PublishAsKnowledgePipelineModal', () => {
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
await waitFor(() => {
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument()
})
})
@@ -182,7 +183,7 @@ describe('PublishAsKnowledgePipelineModal', () => {
fireEvent.click(getIconButton())
fireEvent.click(screen.getByRole('button', { name: '#E4FBCC' }))
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
})
it('should close icon picker when cancel is clicked', async () => {
@@ -192,7 +193,7 @@ describe('PublishAsKnowledgePipelineModal', () => {
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.cancel/ }))
await waitFor(() => {
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument()
})
})
@@ -215,3 +216,5 @@ describe('PublishAsKnowledgePipelineModal', () => {
expect(mockOnConfirm).toHaveBeenCalledWith('Trimmed Name', expect.any(Object), 'Some desc')
})
})
mockEmojiData()
@@ -4,6 +4,7 @@ import { zSsoProtocol } from '@dify/contracts/api/console/system-features/zod.ge
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { mockEmojiData } from '@/test/emoji-picker'
import MCPModal from '../modal'
// Mock the service API
@@ -689,7 +690,7 @@ describe('MCPModal', () => {
fireEvent.click(appIconContainer)
await waitFor(() => {
expect(screen.getByPlaceholderText('Search emojis...'))!.toBeInTheDocument()
expect(screen.getByPlaceholderText('common.operation.search'))!.toBeInTheDocument()
})
}
})
@@ -706,14 +707,14 @@ describe('MCPModal', () => {
fireEvent.click(appIconContainer)
await waitFor(() => {
expect(screen.getByPlaceholderText('Search emojis...'))!.toBeInTheDocument()
expect(screen.getByPlaceholderText('common.operation.search'))!.toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: '#E4FBCC' }))
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
await waitFor(() => {
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument()
})
}
})
@@ -730,13 +731,13 @@ describe('MCPModal', () => {
fireEvent.click(appIconContainer)
await waitFor(() => {
expect(screen.getByPlaceholderText('Search emojis...'))!.toBeInTheDocument()
expect(screen.getByPlaceholderText('common.operation.search'))!.toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.cancel/ }))
await waitFor(() => {
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument()
})
}
})
@@ -898,3 +899,5 @@ describe('MCPModal', () => {
})
})
})
mockEmojiData()
@@ -127,15 +127,15 @@ describe('AgentDetailSection', () => {
})
it('renders the current agent avatar, name, and role', () => {
const { container } = renderAgentDetailSection()
renderAgentDetailSection()
const agentName = screen.getByText('Research Agent')
const agentAvatar = container.querySelector('em-emoji')?.parentElement
const agentAvatar = screen.getByText('🧪')
expect(agentName).toBeInTheDocument()
expect(screen.getByText('Research Assistant')).toBeInTheDocument()
expect(screen.queryByText('agent')).not.toBeInTheDocument()
expect(screen.queryByText('agentV2.agentDetail.title')).not.toBeInTheDocument()
expect(container.querySelector('em-emoji')).toHaveAttribute('id', '🧪')
expect(agentAvatar).toHaveTextContent('🧪')
expect(agentAvatar).toHaveClass('h-10', 'w-10', 'rounded-full')
})
-1
View File
@@ -1,5 +1,4 @@
import './types/i18n'
import './types/jsx'
import './types/assets'
declare global {
+1 -5
View File
@@ -1,10 +1,6 @@
import { renderHook, waitFor } from '@testing-library/react'
import { useAppFavicon } from './use-app-favicon'
vi.mock('@/utils/emoji', () => ({
searchEmoji: vi.fn(async () => '🤖'),
}))
const firstAppIcon = 'https://example.com/app-one.png'
const secondAppIcon = 'https://example.com/app-two.png'
@@ -76,7 +72,7 @@ describe('useAppFavicon', () => {
it('renders emoji icons as SVG favicons', async () => {
renderHook(() =>
useAppFavicon({ icon_type: 'emoji', icon: 'robot', icon_background: '#ffffff' }),
useAppFavicon({ icon_type: 'emoji', icon: 'robot_face', icon_background: '#ffffff' }),
)
await waitFor(() => {
+3 -3
View File
@@ -1,7 +1,7 @@
import type { AppIconType } from '@/types/app'
import { useEffect } from 'react'
import { appDefaultIconBackground } from '@/config'
import { searchEmoji } from '@/utils/emoji'
import { resolveEmoji } from '@/utils/emoji'
type UseAppFaviconOptions = {
enable?: boolean
@@ -27,11 +27,11 @@ export function useAppFavicon(options: UseAppFaviconOptions) {
} else if (icon_type === 'link') {
href = icon
} else if (icon_type !== 'emoji' || icon) {
const emoji = icon ? await searchEmoji(icon) : '🤖'
const emoji = resolveEmoji(icon)
href =
'data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22>' +
`<rect width=%22100%25%22 height=%22100%25%22 fill=%22${encodeURIComponent(icon_background || appDefaultIconBackground)}%22 rx=%2230%22 ry=%2230%22 />` +
`<text x=%2212.5%22 y=%221em%22 font-size=%2275%22>${emoji}</text>` +
`<text x=%2212.5%22 y=%221em%22 font-size=%2275%22>${emoji.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')}</text>` +
'</svg>'
type = 'image/svg+xml'
}
+1 -2
View File
@@ -38,7 +38,6 @@
"@amplitude/analytics-browser": "catalog:",
"@amplitude/plugin-session-replay-browser": "catalog:",
"@base-ui/react": "catalog:",
"@emoji-mart/data": "catalog:",
"@floating-ui/react": "catalog:",
"@formatjs/intl-localematcher": "catalog:",
"@heroicons/react": "catalog:",
@@ -80,10 +79,10 @@
"embla-carousel-autoplay": "catalog:",
"embla-carousel-fade": "catalog:",
"embla-carousel-react": "catalog:",
"emoji-mart": "catalog:",
"es-toolkit": "catalog:",
"fast-deep-equal": "catalog:",
"foxact": "catalog:",
"frimousse": "catalog:",
"fuse.js": "catalog:",
"hast-util-to-jsx-runtime": "catalog:",
"html-entities": "catalog:",
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Missive.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+16
View File
@@ -0,0 +1,16 @@
# Self-hosted emoji data
`emojibase-17.0.0/en/{data,messages}.json` are vendored files from
`emojibase-data@17.0.0` (MIT; license included). Frimousse uses only these two
files. A final newline is added where needed to satisfy EditorConfig; JSON contents
are unchanged. Keep `locale="en"` to preserve the existing English search vocabulary.
The URL includes the deployment base path and never falls back to a CDN.
To update, extract these files and LICENSE from the corresponding npm tarball,
use a new versioned directory, and update the picker URL together.
`web/utils/emoji-legacy.json` is the frozen compatibility map generated from
`@emoji-mart/data@1.2.1/sets/15/native.json`: each `emojis` key maps to its first
skin's `native` value, followed by every `aliases` key mapped through its target.
It is retained independently of picker data updates so persisted IDs keep working.
Emoji Mart data is MIT licensed: https://github.com/missive/emoji-mart.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017-2019 Miles Johnson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1 @@
[{"label":"regional indicator A","hexcode":"1F1E6","emoji":"🇦","text":"","type":1,"version":0},{"label":"regional indicator B","hexcode":"1F1E7","emoji":"🇧","text":"","type":1,"version":0},{"label":"regional indicator C","hexcode":"1F1E8","emoji":"🇨","text":"","type":1,"version":0},{"label":"regional indicator D","hexcode":"1F1E9","emoji":"🇩","text":"","type":1,"version":0},{"label":"regional indicator E","hexcode":"1F1EA","emoji":"🇪","text":"","type":1,"version":0},{"label":"regional indicator F","hexcode":"1F1EB","emoji":"🇫","text":"","type":1,"version":0},{"label":"regional indicator G","hexcode":"1F1EC","emoji":"🇬","text":"","type":1,"version":0},{"label":"regional indicator H","hexcode":"1F1ED","emoji":"🇭","text":"","type":1,"version":0},{"label":"regional indicator I","hexcode":"1F1EE","emoji":"🇮","text":"","type":1,"version":0},{"label":"regional indicator J","hexcode":"1F1EF","emoji":"🇯","text":"","type":1,"version":0},{"label":"regional indicator K","hexcode":"1F1F0","emoji":"🇰","text":"","type":1,"version":0},{"label":"regional indicator L","hexcode":"1F1F1","emoji":"🇱","text":"","type":1,"version":0},{"label":"regional indicator M","hexcode":"1F1F2","emoji":"🇲","text":"","type":1,"version":0},{"label":"regional indicator N","hexcode":"1F1F3","emoji":"🇳","text":"","type":1,"version":0},{"label":"regional indicator O","hexcode":"1F1F4","emoji":"🇴","text":"","type":1,"version":0},{"label":"regional indicator P","hexcode":"1F1F5","emoji":"🇵","text":"","type":1,"version":0},{"label":"regional indicator Q","hexcode":"1F1F6","emoji":"🇶","text":"","type":1,"version":0},{"label":"regional indicator R","hexcode":"1F1F7","emoji":"🇷","text":"","type":1,"version":0},{"label":"regional indicator S","hexcode":"1F1F8","emoji":"🇸","text":"","type":1,"version":0},{"label":"regional indicator T","hexcode":"1F1F9","emoji":"🇹","text":"","type":1,"version":0},{"label":"regional indicator U","hexcode":"1F1FA","emoji":"🇺","text":"","type":1,"version":0},{"label":"regional indicator V","hexcode":"1F1FB","emoji":"🇻","text":"","type":1,"version":0},{"label":"regional indicator W","hexcode":"1F1FC","emoji":"🇼","text":"","type":1,"version":0},{"label":"regional indicator X","hexcode":"1F1FD","emoji":"🇽","text":"","type":1,"version":0},{"label":"regional indicator Y","hexcode":"1F1FE","emoji":"🇾","text":"","type":1,"version":0},{"label":"regional indicator Z","hexcode":"1F1FF","emoji":"🇿","text":"","type":1,"version":0},{"label":"grinning face","hexcode":"1F600","tags":["cheerful","cheery","face","grin","grinning","happy","laugh","nice","smile","smiling","teeth"],"emoji":"😀","text":"","type":1,"order":1,"group":0,"subgroup":0,"version":1},{"label":"grinning face with big eyes","hexcode":"1F603","tags":["awesome","big","eyes","face","grin","grinning","happy","mouth","open","smile","smiling","teeth","yay"],"emoji":"😃","text":"","type":1,"order":2,"group":0,"subgroup":0,"version":0.6},{"label":"grinning face with smiling eyes","hexcode":"1F604","tags":["eye","eyes","face","grin","grinning","happy","laugh","lol","mouth","open","smile","smiling"],"emoji":"😄","text":"","type":1,"order":3,"group":0,"subgroup":0,"version":0.6,"emoticon":":D"},{"label":"beaming face with smiling eyes","hexcode":"1F601","tags":["beaming","eye","eyes","face","grin","grinning","happy","nice","smile","smiling","teeth"],"emoji":"😁","text":"","type":1,"order":4,"group":0,"subgroup":0,"version":0.6},{"label":"grinning squinting face","hexcode":"1F606","tags":["closed","eyes","face","grinning","haha","hahaha","happy","laugh","lol","mouth","open","rofl","smile","smiling","squinting"],"emoji":"😆","text":"","type":1,"order":5,"group":0,"subgroup":0,"version":0.6,"emoticon":["xD","XD"]},{"label":"grinning face with sweat","hexcode":"1F605","tags":["cold","dejected","excited","face","grinning","mouth","nervous","open","smile","smiling","stress","stressed","sweat"],"emoji":"😅","text":"","type":1,"order":6,"group":0,"subgroup":0,"version":0.6},{"label":"rolling on the floor laughing","hexcode":"1F923","tags":["crying","face","floor","funny","haha","happy","hehe","hilarious","joy","laugh","lmao","lol","rofl","roflmao","rolling","tear"],"emoji":"🤣","text":"","type":1,"order":7,"group":0,"subgroup":0,"version":3,"emoticon":":'D"},{"label":"face with tears of joy","hexcode":"1F602","tags":["crying","face","feels","funny","haha","happy","hehe","hilarious","joy","laugh","lmao","lol","rofl","roflmao","tear"],"emoji":"😂","text":"","type":1,"order":8,"group":0,"subgroup":0,"version":0.6,"emoticon":":')"},{"label":"slightly smiling face","hexcode":"1F642","tags":["face","happy","slightly","smile","smiling"],"emoji":"🙂","text":"","type":1,"order":9,"group":0,"subgroup":0,"version":1,"emoticon":":)"},{"label":"upside-down face","hexcode":"1F643","tags":["face","hehe","smile","upside-down"],"emoji":"🙃","text":"","type":1,"order":10,"group":0,"subgroup":0,"version":1},{"laLine truncated
@@ -0,0 +1 @@
{"groups":[{"key":"smileys-emotion","message":"smileys & emotion","order":0},{"key":"people-body","message":"people & body","order":1},{"key":"component","message":"components","order":2},{"key":"animals-nature","message":"animals & nature","order":3},{"key":"food-drink","message":"food & drink","order":4},{"key":"travel-places","message":"travel & places","order":5},{"key":"activities","message":"activities","order":6},{"key":"objects","message":"objects","order":7},{"key":"symbols","message":"symbols","order":8},{"key":"flags","message":"flags","order":9}],"skinTones":[{"key":"dark","message":"dark skin tone"},{"key":"light","message":"light skin tone"},{"key":"medium","message":"medium skin tone"},{"key":"medium-dark","message":"medium-dark skin tone"},{"key":"medium-light","message":"medium-light skin tone"}],"subgroups":[{"key":"face-smiling","message":"smiling","order":0},{"key":"face-affection","message":"affectionate","order":1},{"key":"face-tongue","message":"with tongue","order":2},{"key":"face-hand","message":"with hands","order":3},{"key":"face-neutral-skeptical","message":"neutral / skeptical","order":4},{"key":"face-sleepy","message":"sleepy","order":5},{"key":"face-unwell","message":"unwell","order":6},{"key":"face-hat","message":"with hats","order":7},{"key":"face-glasses","message":"with glasses","order":8},{"key":"face-concerned","message":"concerned","order":9},{"key":"face-negative","message":"negative","order":10},{"key":"face-costume","message":"costumed & creatures","order":11},{"key":"cat-face","message":"cat faces","order":12},{"key":"monkey-face","message":"monkey faces","order":13},{"key":"heart","message":"hearts","order":14},{"key":"emotion","message":"emotions","order":15},{"key":"hand-fingers-open","message":"fingers open","order":16},{"key":"hand-fingers-partial","message":"hand signs","order":17},{"key":"hand-single-finger","message":"finger pointing","order":18},{"key":"hand-fingers-closed","message":"fingers closed","order":19},{"key":"hands","message":"hands","order":20},{"key":"hand-prop","message":"hand props","order":21},{"key":"body-parts","message":"body parts","order":22},{"key":"person","message":"people","order":23},{"key":"person-gesture","message":"gestures","order":24},{"key":"person-role","message":"roles & careers","order":25},{"key":"person-fantasy","message":"fantasy","order":26},{"key":"person-activity","message":"activities","order":27},{"key":"person-sport","message":"athletics","order":28},{"key":"person-resting","message":"resting","order":29},{"key":"family","message":"family","order":30},{"key":"person-symbol","message":"people symbols","order":31},{"key":"skin-tone","message":"skin tones","order":32},{"key":"hair-style","message":"hair styles","order":33},{"key":"animal-mammal","message":"mammals","order":34},{"key":"animal-bird","message":"birds","order":35},{"key":"animal-amphibian","message":"amphibians","order":36},{"key":"animal-reptile","message":"reptiles","order":37},{"key":"animal-marine","message":"marine life","order":38},{"key":"animal-bug","message":"bugs","order":39},{"key":"plant-flower","message":"flowers","order":40},{"key":"plant-other","message":"other plants","order":41},{"key":"food-fruit","message":"fruit","order":42},{"key":"food-vegetable","message":"vegetables","order":43},{"key":"food-prepared","message":"cooked / prepared","order":44},{"key":"food-asian","message":"asian","order":45},{"key":"food-marine","message":"seafood","order":46},{"key":"food-sweet","message":"sweets & candy","order":46},{"key":"drink","message":"drink","order":47},{"key":"dishware","message":"dishware","order":48},{"key":"place-map","message":"globes & maps","order":49},{"key":"place-geographic","message":"geographic locations","order":50},{"key":"place-building","message":"buildings","order":51},{"key":"place-religious","message":"religious buildings","order":52},{"key":"place-other","message":"other places","order":53},{"key":"transport-ground","message":"ground transportation","order":54},{"key":"transport-water","message":"water transportation","order":55},{"key":"transport-air","message":"air transportation","order":56},{"key":"hotel","message":"hotel","order":57},{"key":"time","message":"time","order":58},{"key":"sky-weather","message":"weather","order":59},{"key":"event","message":"events & holidays","order":60},{"key":"award-medal","message":"award medals","order":61},{"key":"sport","message":"sports","order":62},{"key":"game","message":"games & hobbies","order":63},{"key":"arts-crafts","message":"arts & crafts","order":64},{"key":"clothing","message":"clothing","order":65},{"key":"sound","message":"sound","order":66},{"key":"music","message":"music","order":67},{"key":"musical-instrument","message":"musical instruments","order":68},{"key":"phone","message":"phone","order":69},{"key":"computer","message":"computer","order":70},{"key":"light-video","message":"light, film & video","order":71},{"key":"book-paper","message":"books & paper","ordLine truncated
+22
View File
@@ -0,0 +1,22 @@
import data from '@/public/emoji/emojibase-17.0.0/en/data.json'
import messages from '@/public/emoji/emojibase-17.0.0/en/messages.json'
/** Exercise the real picker with the same data served in production, without network. */
export function mockEmojiData() {
beforeEach(() => {
// oxlint-disable-next-line no-restricted-globals -- Reset Frimousse's own cache to exercise its data loader.
localStorage.removeItem('frimousse/data/en')
sessionStorage.removeItem('frimousse/metadata')
sessionStorage.setItem(
'frimousse/metadata',
JSON.stringify({ emojiVersion: 17, countryFlags: true }),
)
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const url = String(input)
if (!/^\/.*emoji\/emojibase-17\.0\.0\/en\/(?:data|messages)\.json$/.test(url))
throw new Error(`Unexpected emoji data URL: ${url}`)
return new Response(JSON.stringify(url.endsWith('/messages.json') ? messages : data))
})
})
afterEach(() => vi.restoreAllMocks())
}
-12
View File
@@ -1,12 +0,0 @@
// TypeScript type definitions for custom JSX elements
// Custom JSX elements for emoji-mart web components
import 'react'
declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'em-emoji': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>
}
}
}
+17
View File
@@ -0,0 +1,17 @@
import { resolveEmoji } from '../emoji'
describe('persisted emoji compatibility', () => {
it.each([
['rabbit', '🐰'],
['robot_face', '🤖'],
['satisfied', '😆'],
[':+1:', '👍'],
['🐻', '🐻'],
['👩🏽‍💻', '👩🏽‍💻'],
['unknown-id', 'unknown-id'],
['', '🤖'],
[undefined, '🤖'],
])('resolves %s to %s', (value, expected) => {
expect(resolveEmoji(value)).toBe(expected)
})
})
File diff suppressed because it is too large. Load diff
+7 -9
View File
@@ -1,11 +1,9 @@
import type { Emoji } from '@emoji-mart/data'
import { SearchIndex } from 'emoji-mart'
import legacyEmojis from './emoji-legacy.json'
export async function searchEmoji(value: string) {
const emojis: Emoji[] = (await SearchIndex.search(value)) || []
const results = emojis.map((emoji) => {
return emoji.skins[0]!.native
})
return results
/** Resolve persisted emoji-mart IDs (including aliases) without loading picker data. */
export function resolveEmoji(value?: string | null): string {
if (!value) return '🤖'
const id = value.replace(/^:|:$/g, '')
if (Object.hasOwn(legacyEmojis, id)) return legacyEmojis[id as keyof typeof legacyEmojis]
return value
}
-4
View File
@@ -68,10 +68,6 @@ export default defineConfig(({ command, mode, isPreview }) => {
server: {
port: 3000,
},
ssr: {
// SyntaxError: Named export not found. The requested module is a CommonJS module, which may not support all module.exports as named exports
noExternal: ['emoji-mart'],
},
}
: {}),