fix: normalize app icon picker dialog state (#36621)

This commit is contained in:
yyh
2026-05-25 18:39:52 +08:00
committed by GitHub
parent b1f0a11d84
commit fe86fa31ec
39 changed files with 616 additions and 918 deletions

View File

@@ -60,21 +60,6 @@ vi.mock('@/app/components/base/app-icon', () => ({
}) => <button onClick={onClick}>open-emoji-picker</button>,
}))
vi.mock('@/app/components/base/emoji-picker', () => ({
default: ({
onClose,
onSelect,
}: {
onClose: () => void
onSelect: (icon: string, background: string) => void
}) => (
<div>
<button onClick={() => onSelect('sparkles', '#fff')}>select-emoji</button>
<button onClick={onClose}>close-emoji</button>
</div>
),
}))
vi.mock('@/app/components/base/features/new-feature-panel/moderation/form-generation', () => ({
default: ({
onChange,
@@ -146,7 +131,14 @@ describe('ExternalDataToolModal', () => {
})
fireEvent.click(screen.getByText('pick-extension'))
fireEvent.click(screen.getByText('open-emoji-picker'))
fireEvent.click(screen.getByText('select-emoji'))
await waitFor(() => {
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
})
const emojiButton = document.querySelector('em-emoji')?.closest('button')
expect(emojiButton).toBeTruthy()
fireEvent.click(emojiButton!)
fireEvent.click(screen.getByRole('button', { name: '#E4FBCC' }))
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
fireEvent.click(screen.getByText('operation.save'))
await waitFor(() => {
@@ -155,8 +147,8 @@ describe('ExternalDataToolModal', () => {
api_based_extension_id: 'extension-1',
},
enabled: true,
icon: 'sparkles',
icon_background: '#fff',
icon: expect.any(String),
icon_background: '#E4FBCC',
label: 'Search',
type: 'api',
variable: 'search_api',
@@ -168,8 +160,8 @@ describe('ExternalDataToolModal', () => {
api_based_extension_id: 'extension-1',
},
enabled: true,
icon: 'sparkles',
icon_background: '#fff',
icon: expect.any(String),
icon_background: '#E4FBCC',
label: 'Search',
type: 'api',
variable: 'search_api',

View File

@@ -217,13 +217,10 @@ const ExternalDataToolModal: FC<ExternalDataToolModalProps> = ({
{
showEmojiPicker && (
<EmojiPicker
open={showEmojiPicker}
onOpenChange={setShowEmojiPicker}
onSelect={(icon, icon_background) => {
handleValueChange({ icon, icon_background })
setShowEmojiPicker(false)
}}
onClose={() => {
handleValueChange({ icon: '', icon_background: '' })
setShowEmojiPicker(false)
}}
/>
)

View File

@@ -1,5 +1,6 @@
import type { App } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
@@ -30,6 +31,7 @@ vi.mock('ahooks', () => ({
}))
vi.mock('@/next/navigation', () => ({
useRouter: vi.fn(),
useParams: () => ({}),
}))
vi.mock('@/utils/create-app-tracking', () => ({
trackCreateApp: vi.fn(),
@@ -55,19 +57,6 @@ vi.mock('@/app/components/base/app-icon', () => ({
<button type="button" onClick={onClick}>open-icon-picker</button>
),
}))
vi.mock('@/app/components/base/app-icon-picker', () => ({
default: ({ onSelect, onClose }: { onSelect: (payload: Record<string, unknown>) => void, onClose: () => void }) => (
<div>
<button
type="button"
onClick={() => onSelect({ type: 'image', fileId: 'file-1', url: 'https://example.com/icon.png' })}
>
select-image-icon
</button>
<button type="button" onClick={onClose}>close-icon-picker</button>
</div>
),
}))
vi.mock('@/utils/app-redirection', () => ({
getRedirection: vi.fn(),
}))
@@ -216,14 +205,22 @@ describe('CreateAppModal', () => {
expect(onCreateFromTemplate).toHaveBeenCalled()
})
it('creates a beginner chat app with the keyboard shortcut and selected image icon', async () => {
it('creates a beginner chat app with the keyboard shortcut and selected icon style', async () => {
const user = userEvent.setup()
mockCreateApp.mockResolvedValue({ id: 'chat-app', mode: AppModeEnum.CHAT } as App)
renderModal()
fireEvent.click(screen.getByText('app.newApp.forBeginners'))
fireEvent.click(screen.getByText('app.types.chatbot'))
fireEvent.click(screen.getByText('open-icon-picker'))
fireEvent.click(screen.getByText('select-image-icon'))
await user.click(screen.getByText('open-icon-picker'))
await waitFor(() => {
expect(screen.getByPlaceholderText('Search emojis...')).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()
})
fireEvent.change(screen.getByPlaceholderText('app.newApp.appNamePlaceholder'), {
target: { value: 'Keyboard App' },
})
@@ -237,9 +234,9 @@ describe('CreateAppModal', () => {
expect(mockCreateApp).toHaveBeenCalledWith({
name: 'Keyboard App',
description: 'Created from shortcut',
icon_type: 'image',
icon: 'file-1',
icon_background: undefined,
icon_type: 'emoji',
icon: '🤖',
icon_background: '#E4FBCC',
mode: AppModeEnum.CHAT,
})
})
@@ -254,7 +251,8 @@ describe('CreateAppModal', () => {
expect(mockCreateApp).not.toHaveBeenCalled()
})
it('ignores the keyboard shortcut when the app quota is exhausted and closes the icon picker', () => {
it('ignores the keyboard shortcut when the app quota is exhausted and closes the icon picker', async () => {
const user = userEvent.setup()
mockUseProviderContext.mockReturnValue({
plan: {
type: AppModeEnum.ADVANCED_CHAT,
@@ -267,11 +265,16 @@ describe('CreateAppModal', () => {
renderModal()
fireEvent.click(screen.getByText('open-icon-picker'))
expect(screen.getByText('select-image-icon')).toBeInTheDocument()
fireEvent.click(screen.getByText('close-icon-picker'))
await user.click(screen.getByText('open-icon-picker'))
await waitFor(() => {
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
})
await user.click(screen.getByRole('button', { name: /iconPicker\.cancel/ }))
await waitFor(() => {
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
})
expect(screen.queryByText('select-image-icon')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
ahooksMocks.keyPressHandlers.at(-1)?.()

View File

@@ -220,12 +220,13 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
/>
{showAppIconPicker && (
<AppIconPicker
open={showAppIconPicker}
initialEmoji={appIcon.type === 'emoji'
? { icon: appIcon.icon, background: appIcon.background }
: undefined}
onOpenChange={setShowAppIconPicker}
onSelect={(payload) => {
setAppIcon(payload)
setShowAppIconPicker(false)
}}
onClose={() => {
setShowAppIconPicker(false)
}}
/>
)}

View File

@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import DuplicateAppModal from '../index'
@@ -32,15 +32,6 @@ vi.mock('@/app/components/base/app-icon', () => ({
),
}))
vi.mock('@/app/components/base/app-icon-picker', () => ({
default: ({ onSelect, onClose }: { onSelect: (payload: Record<string, unknown>) => void, onClose: () => void }) => (
<div data-testid="app-icon-picker">
<button type="button" onClick={() => onSelect({ type: 'image', fileId: 'file-1', url: 'https://example.com/icon.png' })}>select-icon</button>
<button type="button" onClick={onClose}>close-icon-picker</button>
</div>
),
}))
describe('DuplicateAppModal', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -94,14 +85,21 @@ describe('DuplicateAppModal', () => {
)
await user.click(screen.getByText('open-icon-picker'))
await user.click(screen.getByText('select-icon'))
await waitFor(() => {
expect(screen.getByPlaceholderText('Search emojis...')).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()
})
await user.click(screen.getByRole('button', { name: 'duplicate' }))
expect(onConfirm).toHaveBeenCalledWith({
name: 'Demo App',
icon_type: 'image',
icon: 'file-1',
icon_background: undefined,
icon_type: 'emoji',
icon: '🤖',
icon_background: '#E4FBCC',
})
expect(onHide).toHaveBeenCalled()
})
@@ -127,7 +125,7 @@ describe('DuplicateAppModal', () => {
expect(onHide).toHaveBeenCalledTimes(1)
})
it('should restore the original image icon when the picker closes without selecting', async () => {
it('should preserve the current image icon when the picker closes without selecting', async () => {
const onConfirm = vi.fn()
const user = userEvent.setup()
@@ -144,16 +142,32 @@ describe('DuplicateAppModal', () => {
)
await user.click(screen.getByText('open-icon-picker'))
await user.click(screen.getByText('select-icon'))
await waitFor(() => {
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
})
const emojiButton = document.querySelector('em-emoji')?.closest('button')
expect(emojiButton).toBeTruthy()
await user.click(emojiButton!)
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()
})
await user.click(screen.getByText('open-icon-picker'))
await user.click(screen.getByText('close-icon-picker'))
await waitFor(() => {
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
})
await user.click(screen.getByRole('button', { name: /iconPicker\.cancel/ }))
await waitFor(() => {
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
})
await user.click(screen.getByRole('button', { name: 'duplicate' }))
expect(onConfirm).toHaveBeenCalledWith({
expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({
name: 'Image App',
icon_type: 'image',
icon: 'original-file',
icon_background: undefined,
})
icon_type: 'emoji',
icon: expect.any(String),
icon_background: '#E4FBCC',
}))
})
})

View File

@@ -109,15 +109,13 @@ const DuplicateAppModal = ({
</Dialog>
{showAppIconPicker && (
<AppIconPicker
open={showAppIconPicker}
initialEmoji={appIcon.type === 'emoji'
? { icon: appIcon.icon, background: appIcon.background }
: undefined}
onOpenChange={setShowAppIconPicker}
onSelect={(payload) => {
setAppIcon(payload)
setShowAppIconPicker(false)
}}
onClose={() => {
setAppIcon(icon_type === 'image'
? { type: 'image', url: icon_url!, fileId: icon }
: { type: 'emoji', icon, background: icon_background! })
setShowAppIconPicker(false)
}}
/>
)}

View File

@@ -478,22 +478,16 @@ const SettingsModal: FC<ISettingsModalProps> = ({
<Button className="mr-2" onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button variant="primary" onClick={onClickSave} loading={saveLoading}>{t('operation.save', { ns: 'common' })}</Button>
</div>
{showAppIconPicker && (
<div onClick={e => e.stopPropagation()}>
<AppIconPicker
onSelect={(payload) => {
setAppIcon(payload)
setShowAppIconPicker(false)
}}
onClose={() => {
setAppIcon(createAppIcon(appInfo))
setShowAppIconPicker(false)
}}
/>
</div>
)}
</DialogContent>
</Dialog>
<AppIconPicker
open={showAppIconPicker}
initialEmoji={appIcon.type === 'emoji'
? { icon: appIcon.icon, background: appIcon.background }
: undefined}
onOpenChange={setShowAppIconPicker}
onSelect={setAppIcon}
/>
</>
)
}

View File

@@ -84,18 +84,6 @@ vi.mock('@/app/components/base/app-icon', () => ({
),
}))
vi.mock('@/app/components/base/app-icon-picker', () => ({
default: ({ onSelect, onClose }: {
onSelect: (payload: { type: 'image', url: string, fileId: string }) => void
onClose: () => void
}) => (
<div data-testid="app-icon-picker">
<button onClick={() => onSelect({ type: 'image', url: 'https://example.com/icon.png', fileId: 'file-id-1' })}>select-app-icon</button>
<button onClick={onClose}>close-app-icon-picker</button>
</div>
),
}))
const createMockApp = (overrides: Partial<App> = {}): App => ({
id: 'app-123',
name: 'Demo App',
@@ -315,17 +303,23 @@ describe('SwitchAppModal', () => {
mockSwitchApp.mockResolvedValueOnce({ new_app_id: 'new-app-003' })
await user.click(screen.getByText('open-icon-picker'))
expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
await waitFor(() => {
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
})
await user.click(screen.getByText('select-app-icon'))
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()
})
await user.click(screen.getByRole('button', { name: 'app.switchStart' }))
await waitFor(() => {
expect(mockSwitchApp).toHaveBeenCalledWith(expect.objectContaining({
appID: appDetail.id,
icon_type: 'image',
icon: 'file-id-1',
icon_background: undefined,
icon_type: 'emoji',
icon: '🚀',
icon_background: '#E4FBCC',
}))
})
})
@@ -335,9 +329,14 @@ describe('SwitchAppModal', () => {
renderComponent()
await user.click(screen.getByText('open-icon-picker'))
expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
await user.click(screen.getByText('close-app-icon-picker'))
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
await waitFor(() => {
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
})
await user.click(screen.getByRole('button', { name: /iconPicker\.cancel/ }))
await waitFor(() => {
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
})
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
await user.click(screen.getByText('app.removeOriginal'))
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()

View File

@@ -149,15 +149,13 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo
</div>
{showAppIconPicker && (
<AppIconPicker
open={showAppIconPicker}
initialEmoji={appIcon.type === 'emoji'
? { icon: appIcon.icon, background: appIcon.background }
: undefined}
onOpenChange={setShowAppIconPicker}
onSelect={(payload) => {
setAppIcon(payload)
setShowAppIconPicker(false)
}}
onClose={() => {
setAppIcon(appDetail.icon_type === 'image'
? { type: 'image' as const, url: appDetail.icon_url, fileId: appDetail.icon }
: { type: 'emoji' as const, icon: appDetail.icon, background: appDetail.icon_background })
setShowAppIconPicker(false)
}}
/>
)}

View File

@@ -125,11 +125,11 @@ describe('AppIconPicker', () => {
const renderPicker = (props: Partial<ComponentProps<typeof AppIconPicker>> = {}) => {
const onSelect = vi.fn()
const onClose = vi.fn()
const onOpenChange = vi.fn()
const { container } = render(<AppIconPicker onSelect={onSelect} onClose={onClose} {...props} />)
const { container } = render(<AppIconPicker open onOpenChange={onOpenChange} onSelect={onSelect} {...props} />)
return { onSelect, onClose, container }
return { onSelect, onOpenChange, container }
}
beforeEach(() => {
@@ -157,8 +157,9 @@ describe('AppIconPicker', () => {
it('should render emoji and image tabs when upload is enabled', async () => {
renderPicker()
expect(await screen.findByText(/emoji/i))!.toBeInTheDocument()
expect(screen.getByText(/image/i))!.toBeInTheDocument()
expect(screen.getByRole('dialog', { name: /emoji/i })).toBeInTheDocument()
expect(await screen.findByRole('button', { name: /emoji/i }))!.toBeInTheDocument()
expect(screen.getByRole('button', { name: /image/i }))!.toBeInTheDocument()
expect(screen.getByText(/cancel/i))!.toBeInTheDocument()
expect(screen.getByText(/ok/i))!.toBeInTheDocument()
})
@@ -173,12 +174,12 @@ describe('AppIconPicker', () => {
})
describe('User Interactions', () => {
it('should call onClose when cancel is clicked', async () => {
const { onClose } = renderPicker()
it('should close when cancel is clicked', async () => {
const { onOpenChange } = renderPicker()
await userEvent.click(screen.getByText(/cancel/i))
expect(onClose).toHaveBeenCalledTimes(1)
expect(onOpenChange).toHaveBeenCalledWith(false)
})
it('should switch between emoji and image tabs', async () => {
@@ -187,7 +188,7 @@ describe('AppIconPicker', () => {
await userEvent.click(screen.getByText(/image/i))
expect(screen.getByText(/drop.*here/i))!.toBeInTheDocument()
await userEvent.click(screen.getByText(/emoji/i))
await userEvent.click(screen.getByRole('button', { name: /emoji/i }))
expect(screen.getByPlaceholderText(/search/i))!.toBeInTheDocument()
})
@@ -214,6 +215,14 @@ describe('AppIconPicker', () => {
})
})
it('should close through the dialog open change contract when Escape is pressed', async () => {
const { onOpenChange } = renderPicker()
await userEvent.keyboard('{Escape}')
expect(onOpenChange).toHaveBeenCalledWith(false, expect.anything())
})
it('should not call onSelect when no emoji has been selected', async () => {
const { onSelect } = renderPicker()

View File

@@ -48,20 +48,20 @@ const AppIconPickerDemo = () => {
</pre>
</div>
{open && (
<AppIconPicker
onSelect={(result) => {
setSelection(result)
setOpen(false)
}}
onClose={() => setOpen(false)}
/>
)}
<AppIconPicker
open={open}
onOpenChange={setOpen}
onSelect={setSelection}
/>
</div>
)
}
export const Playground: Story = {
args: {
open: false,
onOpenChange: () => {},
},
render: () => <AppIconPickerDemo />,
parameters: {
docs: {
@@ -74,15 +74,11 @@ const [selection, setSelection] = useState<AppIconSelection | null>(null)
return (
<>
<button onClick={() => setOpen(true)}>Choose icon…</button>
{open && (
<AppIconPicker
onSelect={(result) => {
setSelection(result)
setOpen(false)
}}
onClose={() => setOpen(false)}
/>
)}
<AppIconPicker
open={open}
onOpenChange={setOpen}
onSelect={setSelection}
/>
</>
)
`.trim(),

View File

@@ -1,15 +1,15 @@
import type { FC } from 'react'
import type { Area } from 'react-easy-crop'
import type { OnImageInput } from './ImageInput'
import type { AppIconType, ImageFile } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { RiImageCircleAiLine } from '@remixicon/react'
import { useCallback, useState } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config'
import Divider from '../divider'
import { defaultEmojiBackground } from '../emoji-picker/constants'
import EmojiPickerInner from '../emoji-picker/Inner'
import { useLocalFileUploader } from '../image-uploader/hooks'
import ImageInput from './ImageInput'
@@ -31,8 +31,10 @@ export type AppIconImageSelection = {
export type AppIconSelection = AppIconEmojiSelection | AppIconImageSelection
type AppIconPickerProps = {
open: boolean
onOpenChange: (open: boolean) => void
onSelect?: (payload: AppIconSelection) => void
onClose?: () => void
enableImageUpload?: boolean
initialEmoji?: {
icon: string
background?: string | null
@@ -40,11 +42,50 @@ type AppIconPickerProps = {
className?: string
}
const AppIconPicker: FC<AppIconPickerProps> = ({
function AppIconPicker({
open,
onOpenChange,
onSelect,
onClose,
enableImageUpload = true,
initialEmoji,
}) => {
className,
}: AppIconPickerProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{open
? (
<AppIconPickerContent
key={`${initialEmoji?.icon ?? ''}:${initialEmoji?.background ?? ''}`}
initialEmoji={initialEmoji}
enableImageUpload={enableImageUpload}
className={className}
onOpenChange={onOpenChange}
onSelect={onSelect}
/>
)
: null}
</Dialog>
)
}
type AppIconPickerContentProps = {
className?: string
initialEmoji?: {
icon: string
background?: string | null
}
enableImageUpload: boolean
onOpenChange: (open: boolean) => void
onSelect?: (payload: AppIconSelection) => void
}
function AppIconPickerContent({
className,
initialEmoji,
enableImageUpload,
onOpenChange,
onSelect,
}: AppIconPickerContentProps) {
const { t } = useTranslation()
const tabs = [
@@ -52,11 +93,17 @@ const AppIconPicker: FC<AppIconPickerProps> = ({
{ key: 'image', label: t('iconPicker.image', { ns: 'app' }), icon: <RiImageCircleAiLine className="size-4" /> },
]
const [activeTab, setActiveTab] = useState<AppIconType>('emoji')
const showImageUpload = enableImageUpload && !DISABLE_UPLOAD_IMAGE_AS_ICON
const [emoji, setEmoji] = useState<{ emoji: string, background: string }>()
const handleSelectEmoji = useCallback((emoji: string, background: string) => {
setEmoji({ emoji, background })
}, [setEmoji])
const [emoji, setEmoji] = useState<{ emoji: string, background: string } | undefined>(() => {
if (!initialEmoji?.icon)
return undefined
return {
emoji: initialEmoji.icon,
background: initialEmoji.background ?? defaultEmojiBackground,
}
})
const [uploading, setUploading] = useState<boolean>()
@@ -71,6 +118,7 @@ const AppIconPicker: FC<AppIconPickerProps> = ({
fileId: imageFile.fileId,
url: imageFile.url,
})
onOpenChange(false)
}
},
})
@@ -94,6 +142,7 @@ const AppIconPicker: FC<AppIconPickerProps> = ({
icon: emoji.emoji,
background: emoji.background,
})
onOpenChange(false)
}
}
else {
@@ -111,54 +160,55 @@ const AppIconPicker: FC<AppIconPickerProps> = ({
}
return (
<Dialog open>
<DialogContent className={cn('w-full overflow-hidden! border-none text-left align-middle', s.container, 'h-[min(462px,calc(100dvh-2rem))]! max-h-none! w-[362px]! p-0!')}>
<DialogContent className={cn('w-full overflow-hidden! border-none text-left align-middle', s.container, 'h-[min(462px,calc(100dvh-2rem))]! max-h-none! w-[362px]! p-0!', className)}>
<DialogTitle className="sr-only">
{t('iconPicker.emoji', { ns: 'app' })}
</DialogTitle>
{!DISABLE_UPLOAD_IMAGE_AS_ICON && (
<div className="w-full p-2 pb-0">
<div className="flex items-center justify-center gap-2 rounded-xl bg-background-body p-1 text-text-primary">
{tabs.map(tab => (
<button
type="button"
key={tab.key}
className={cn(
'flex h-8 flex-1 shrink-0 items-center justify-center rounded-lg p-2 system-sm-medium text-text-tertiary',
activeTab === tab.key && 'bg-components-main-nav-nav-button-bg-active text-text-accent shadow-md',
)}
onClick={() => setActiveTab(tab.key as AppIconType)}
>
{tab.icon}
{' '}
{showImageUpload && (
<div className="w-full p-2 pb-0">
<div className="flex items-center justify-center gap-2 rounded-xl bg-background-body p-1 text-text-primary">
{tabs.map(tab => (
<button
type="button"
key={tab.key}
className={cn(
'flex h-8 flex-1 shrink-0 items-center justify-center rounded-lg p-2 system-sm-medium text-text-tertiary',
activeTab === tab.key && 'bg-components-main-nav-nav-button-bg-active text-text-accent shadow-md',
)}
onClick={() => setActiveTab(tab.key as AppIconType)}
>
{tab.icon}
{' '}
&nbsp;
{tab.label}
</button>
))}
</div>
{tab.label}
</button>
))}
</div>
)}
{activeTab === 'emoji' && (
<EmojiPickerInner
className={cn('flex-1 overflow-hidden pt-2')}
emoji={initialEmoji?.icon}
background={initialEmoji?.background ?? undefined}
onSelect={handleSelectEmoji}
/>
)}
{activeTab === 'image' && <ImageInput className={cn('flex-1 overflow-hidden')} onImageInput={handleImageInput} />}
<Divider className="m-0" />
<div className="flex w-full items-center justify-center gap-2 p-3">
<Button className="w-full" onClick={() => onClose?.()}>
{t('iconPicker.cancel', { ns: 'app' })}
</Button>
<Button variant="primary" className="w-full" disabled={uploading} loading={uploading} onClick={handleSelect}>
{t('iconPicker.ok', { ns: 'app' })}
</Button>
</div>
</DialogContent>
</Dialog>
)}
{activeTab === 'emoji' && (
<EmojiPickerInner
className={cn('flex-1 overflow-hidden pt-2')}
emoji={initialEmoji?.icon}
background={initialEmoji?.background ?? undefined}
onSelect={(emoji, background) => setEmoji({ emoji, background })}
/>
)}
{activeTab === 'image' && <ImageInput className={cn('flex-1 overflow-hidden')} onImageInput={handleImageInput} />}
<Divider className="m-0" />
<div className="flex w-full items-center justify-center gap-2 p-3">
<Button className="w-full" onClick={() => onOpenChange(false)}>
{t('iconPicker.cancel', { ns: 'app' })}
</Button>
<Button variant="primary" className="w-full" disabled={uploading} loading={uploading} onClick={handleSelect}>
{t('iconPicker.ok', { ns: 'app' })}
</Button>
</div>
</DialogContent>
)
}

View File

@@ -1,6 +1,6 @@
'use client'
import type { EmojiMartData } from '@emoji-mart/data'
import type { ChangeEvent, FC } from 'react'
import type { ChangeEvent } from 'react'
import data from '@emoji-mart/data'
import {
MagnifyingGlassIcon,
@@ -12,31 +12,10 @@ import { useState } from 'react'
import Divider from '@/app/components/base/divider'
import Input from '@/app/components/base/input'
import { searchEmoji } from '@/utils/emoji'
import { backgroundColors, defaultEmojiBackground } from './constants'
init({ data })
const backgroundColors = [
'#FFEAD5',
'#E4FBCC',
'#D3F8DF',
'#E0F2FE',
'#E0EAFF',
'#EFF1F5',
'#FBE8FF',
'#FCE7F6',
'#FEF7C3',
'#E6F4D7',
'#D5F5F6',
'#D1E9FF',
'#D1E0FF',
'#D5D9EB',
'#ECE9FE',
'#FFE4E8',
]
type IEmojiPickerInnerProps = {
emoji?: string
background?: string
@@ -44,28 +23,32 @@ type IEmojiPickerInnerProps = {
className?: string
}
const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({
function EmojiPickerInner({
emoji,
background,
onSelect,
className,
}) => {
}: IEmojiPickerInnerProps) {
const { categories } = data as EmojiMartData
const [selectedEmoji, setSelectedEmoji] = useState(emoji || '')
const [selectedBackground, setSelectedBackground] = useState(background || backgroundColors[0])
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()
React.useEffect(() => {
if (selectedEmoji) {
/* v8 ignore next 2 - @preserve */
if (selectedBackground)
onSelect?.(selectedEmoji, selectedBackground)
}
}, [onSelect, selectedEmoji, selectedBackground])
const handleEmojiSelect = (emoji: string) => {
setSelectedEmoji(emoji)
setShowStyleColors(true)
onSelect?.(emoji, selectedBackground)
}
const handleBackgroundSelect = (background: string) => {
setSelectedBackground(background)
if (selectedEmoji)
onSelect?.(selectedEmoji, background)
}
return (
<div className={cn(className, 'flex flex-col')}>
@@ -108,8 +91,7 @@ const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({
aria-label={emoji}
className="inline-flex size-10 items-center justify-center rounded-lg border-none bg-transparent p-0"
onClick={() => {
setSelectedEmoji(emoji)
setShowStyleColors(true)
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">
@@ -136,8 +118,7 @@ const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({
aria-label={emoji}
className="inline-flex size-10 items-center justify-center rounded-lg border-none bg-transparent p-0"
onClick={() => {
setSelectedEmoji(emoji)
setShowStyleColors(true)
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">
@@ -194,7 +175,7 @@ const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({
)
}
onClick={() => {
setSelectedBackground(color)
handleBackgroundSelect(color)
}}
>
<span

View File

@@ -46,13 +46,11 @@ describe('EmojiPickerInner', () => {
expect(screen.getByPlaceholderText('Search emojis...'))!.toBeInTheDocument()
})
it('initializes selected emoji and background when provided', async () => {
it('initializes selected emoji and background when provided', () => {
render(<EmojiPickerInner emoji="rabbit" background="#E4FBCC" onSelect={mockOnSelect} />)
expect(screen.getByText('Choose Style'))!.toBeInTheDocument()
await waitFor(() => {
expect(mockOnSelect).toHaveBeenCalledWith('rabbit', '#E4FBCC')
})
expect(mockOnSelect).not.toHaveBeenCalled()
})
})

View File

@@ -26,26 +26,27 @@ vi.mock('@/utils/emoji', () => ({
describe('EmojiPicker', () => {
const mockOnSelect = vi.fn()
const mockOnClose = vi.fn()
const mockOnOpenChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('renders nothing when isModal is false', () => {
it('renders nothing when closed', () => {
const { container } = render(
<EmojiPicker isModal={false} />,
<EmojiPicker open={false} onOpenChange={mockOnOpenChange} />,
)
expect(container.firstChild).toBeNull()
})
it('renders modal when isModal is true', async () => {
it('renders modal when open', async () => {
await act(async () => {
render(
<EmojiPicker isModal={true} />,
<EmojiPicker open onOpenChange={mockOnOpenChange} />,
)
})
expect(screen.getByRole('dialog', { name: /Emoji/i }))!.toBeInTheDocument()
expect(screen.getByPlaceholderText('Search emojis...'))!.toBeInTheDocument()
expect(screen.getByText(/Cancel/i))!.toBeInTheDocument()
expect(screen.getByText(/OK/i))!.toBeInTheDocument()
@@ -54,7 +55,7 @@ describe('EmojiPicker', () => {
it('OK button is disabled initially', async () => {
await act(async () => {
render(
<EmojiPicker />,
<EmojiPicker open onOpenChange={mockOnOpenChange} />,
)
})
const okButton = screen.getByText(/OK/i).closest('button')
@@ -65,7 +66,7 @@ describe('EmojiPicker', () => {
const customClass = 'custom-wrapper-class'
await act(async () => {
render(
<EmojiPicker className={customClass} />,
<EmojiPicker open onOpenChange={mockOnOpenChange} className={customClass} />,
)
})
const dialog = screen.getByRole('dialog')
@@ -77,7 +78,7 @@ describe('EmojiPicker', () => {
it('calls onSelect with selected emoji and background when OK is clicked', async () => {
await act(async () => {
render(
<EmojiPicker onSelect={mockOnSelect} />,
<EmojiPicker open onOpenChange={mockOnOpenChange} onSelect={mockOnSelect} />,
)
})
@@ -95,10 +96,10 @@ describe('EmojiPicker', () => {
expect(mockOnSelect).toHaveBeenCalledWith(expect.any(String), expect.any(String))
})
it('calls onClose when Cancel is clicked', async () => {
it('closes when Cancel is clicked', async () => {
await act(async () => {
render(
<EmojiPicker onClose={mockOnClose} />,
<EmojiPicker open onOpenChange={mockOnOpenChange} />,
)
})
@@ -107,7 +108,7 @@ describe('EmojiPicker', () => {
fireEvent.click(cancelButton)
})
expect(mockOnClose).toHaveBeenCalled()
expect(mockOnOpenChange).toHaveBeenCalledWith(false)
})
})
})

View File

@@ -0,0 +1,23 @@
export const backgroundColors = [
'#FFEAD5',
'#E4FBCC',
'#D3F8DF',
'#E0F2FE',
'#E0EAFF',
'#EFF1F5',
'#FBE8FF',
'#FCE7F6',
'#FEF7C3',
'#E6F4D7',
'#D5F5F6',
'#D1E9FF',
'#D1E0FF',
'#D5D9EB',
'#ECE9FE',
'#FFE4E8',
]
export const defaultEmojiBackground = backgroundColors[0]!

View File

@@ -47,20 +47,20 @@ const EmojiPickerDemo = () => {
</pre>
</div>
{open && (
<EmojiPicker
onSelect={(emoji, background) => {
setSelection({ emoji, background })
setOpen(false)
}}
onClose={() => setOpen(false)}
/>
)}
<EmojiPicker
open={open}
onOpenChange={setOpen}
onSelect={(emoji, background) => setSelection({ emoji, background })}
/>
</div>
)
}
export const Playground: Story = {
args: {
open: false,
onOpenChange: () => {},
},
render: () => <EmojiPickerDemo />,
parameters: {
docs: {
@@ -73,15 +73,11 @@ const [selection, setSelection] = useState<{ emoji: string; background: string }
return (
<>
<button onClick={() => setOpen(true)}>Open emoji picker…</button>
{open && (
<EmojiPicker
onSelect={(emoji, background) => {
setSelection({ emoji, background })
setOpen(false)
}}
onClose={() => setOpen(false)}
/>
)}
<EmojiPicker
open={open}
onOpenChange={setOpen}
onSelect={(emoji, background) => setSelection({ emoji, background })}
/>
</>
)
`.trim(),

View File

@@ -1,75 +1,95 @@
'use client'
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import EmojiPickerInner from './Inner'
type IEmojiPickerProps = {
isModal?: boolean
type EmojiPickerProps = {
open: boolean
onOpenChange: (open: boolean) => void
onSelect?: (emoji: string, background: string) => void
onClose?: () => void
className?: string
}
const EmojiPicker: FC<IEmojiPickerProps> = ({
isModal = true,
function EmojiPicker({
open,
onOpenChange,
onSelect,
onClose,
className,
}) => {
}: EmojiPickerProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{open
? (
<EmojiPickerContent
className={className}
onOpenChange={onOpenChange}
onSelect={onSelect}
/>
)
: null}
</Dialog>
)
}
type EmojiPickerContentProps = {
className?: string
onOpenChange: (open: boolean) => void
onSelect?: (emoji: string, background: string) => void
}
function EmojiPickerContent({
className,
onOpenChange,
onSelect,
}: EmojiPickerContentProps) {
const { t } = useTranslation()
const [selectedEmoji, setSelectedEmoji] = useState('')
const [selectedBackground, setSelectedBackground] = useState<string>()
const handleSelectEmoji = useCallback((emoji: string, background: string) => {
setSelectedEmoji(emoji)
setSelectedBackground(background)
}, [setSelectedEmoji, setSelectedBackground])
return (
<DialogContent
className={cn(
'max-h-none w-full overflow-hidden! text-left align-middle',
'flex max-h-[552px] flex-col rounded-xl border-[0.5px] border-divider-subtle p-0 shadow-xl',
className,
)}
>
<DialogTitle className="sr-only">
{t('iconPicker.emoji', { ns: 'app' })}
</DialogTitle>
return isModal
? (
<Dialog open>
<DialogContent
className={cn(
'max-h-none w-full overflow-hidden! text-left align-middle',
'flex max-h-[552px] flex-col rounded-xl border-[0.5px] border-divider-subtle p-0 shadow-xl',
className,
)}
>
<EmojiPickerInner
className="pt-3"
onSelect={handleSelectEmoji}
/>
<Divider className="mt-3 mb-0" />
<div className="flex w-full items-center justify-center gap-2 p-3">
<Button
className="w-full"
onClick={() => {
onClose?.()
}}
>
{t('iconPicker.cancel', { ns: 'app' })}
</Button>
<Button
disabled={selectedEmoji === '' || !selectedBackground}
variant="primary"
className="w-full"
onClick={() => {
onSelect?.(selectedEmoji, selectedBackground!)
}}
>
{t('iconPicker.ok', { ns: 'app' })}
</Button>
</div>
</DialogContent>
</Dialog>
)
: <></>
<EmojiPickerInner
className="pt-3"
onSelect={(emoji, background) => {
setSelectedEmoji(emoji)
setSelectedBackground(background)
}}
/>
<Divider className="mt-3 mb-0" />
<div className="flex w-full items-center justify-center gap-2 p-3">
<Button
className="w-full"
onClick={() => onOpenChange(false)}
>
{t('iconPicker.cancel', { ns: 'app' })}
</Button>
<Button
disabled={selectedEmoji === '' || !selectedBackground}
variant="primary"
className="w-full"
onClick={() => {
onSelect?.(selectedEmoji, selectedBackground!)
onOpenChange(false)
}}
>
{t('iconPicker.ok', { ns: 'app' })}
</Button>
</div>
</DialogContent>
)
}
export default EmojiPicker

View File

@@ -81,7 +81,7 @@ export const useImageFiles = () => {
filesRef.current = newFiles
setFiles(newFiles)
},
}, !!params.token)
}, !!params?.token)
}
}
const handleClear = () => {
@@ -145,13 +145,13 @@ export const useLocalFileUploader = ({ limit, disabled = false, onUpload }: useL
toast.error(errorMessage)
onUpload({ ...imageFile, progress: -1 })
},
}, !!params.token)
}, !!params?.token)
}, false)
reader.addEventListener('error', () => {
toast.error(t('imageUploader.uploadFromComputerReadError', { ns: 'common' }))
}, false)
reader.readAsDataURL(file)
}, [disabled, limit, t, onUpload, params.token])
}, [disabled, limit, t, onUpload, params?.token])
return { disabled, handleLocalFileUpload }
}
type useClipboardUploaderProps = {

View File

@@ -29,33 +29,6 @@ vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => {
}
})
// Mock AppIconPicker to capture interactions
let _mockOnSelect: ((icon: { type: 'emoji' | 'image', icon?: string, background?: string, fileId?: string, url?: string }) => void) | undefined
let _mockOnClose: (() => void) | undefined
vi.mock('@/app/components/base/app-icon-picker', () => ({
default: ({ onSelect, onClose }: {
onSelect: (icon: { type: 'emoji' | 'image', icon?: string, background?: string, fileId?: string, url?: string }) => void
onClose: () => void
}) => {
_mockOnSelect = onSelect
_mockOnClose = onClose
return (
<div data-testid="app-icon-picker">
<button data-testid="select-emoji" onClick={() => onSelect({ type: 'emoji', icon: '🎯', background: '#FFEAD5' })}>
Select Emoji
</button>
<button data-testid="select-image" onClick={() => onSelect({ type: 'image', fileId: 'new-file-id', url: 'https://new-icon.com/icon.png' })}>
Select Image
</button>
<button data-testid="close-picker" onClick={onClose}>
Close Picker
</button>
</div>
)
},
}))
const createPipelineTemplate = (overrides: Partial<PipelineTemplate> = {}): PipelineTemplate => ({
id: 'pipeline-1',
name: 'Test Pipeline',
@@ -96,8 +69,6 @@ describe('EditPipelineInfo', () => {
beforeEach(() => {
vi.clearAllMocks()
mockToastError.mockReset()
_mockOnSelect = undefined
_mockOnClose = undefined
})
describe('Rendering', () => {
@@ -303,7 +274,7 @@ describe('EditPipelineInfo', () => {
// Open icon picker
const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!)
expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
})
it('should save correct icon_info when starting with image icon type', async () => {
@@ -358,7 +329,7 @@ describe('EditPipelineInfo', () => {
})
})
it('should revert to initial image icon when picker is closed without selection', () => {
it('should revert to initial image icon when picker is closed without selection', async () => {
const props = {
...defaultProps,
pipeline: createImagePipelineTemplate(),
@@ -368,13 +339,14 @@ describe('EditPipelineInfo', () => {
// Open picker
const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!)
expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
// Close without selection - should revert to original image icon
const closeButton = screen.getByTestId('close-picker')
fireEvent.click(closeButton)
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.cancel/ }))
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
await waitFor(() => {
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
})
})
it('should switch from image icon to emoji icon when selected', async () => {
@@ -392,8 +364,11 @@ describe('EditPipelineInfo', () => {
// Open picker and select emoji
const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!)
const selectEmojiButton = screen.getByTestId('select-emoji')
fireEvent.click(selectEmojiButton)
const emojiButton = document.querySelector('em-emoji')?.closest('button')
expect(emojiButton).toBeTruthy()
fireEvent.click(emojiButton!)
fireEvent.click(screen.getByRole('button', { name: '#E4FBCC' }))
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
@@ -403,7 +378,8 @@ describe('EditPipelineInfo', () => {
expect.objectContaining({
icon_info: expect.objectContaining({
icon_type: 'emoji',
icon: '🎯',
icon: expect.any(String),
icon_background: '#E4FBCC',
}),
}),
expect.any(Object),
@@ -411,34 +387,14 @@ describe('EditPipelineInfo', () => {
})
})
it('should switch from emoji icon to image icon when selected', async () => {
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
callbacks.onSuccess()
return Promise.resolve()
})
it('should switch to the image tab in the real picker', () => {
const { container } = render(<EditPipelineInfo {...defaultProps} />)
// Open picker and select image
const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!)
const selectImageButton = screen.getByTestId('select-image')
fireEvent.click(selectImageButton)
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.image/ }))
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
await waitFor(() => {
expect(mockUpdatePipeline).toHaveBeenCalledWith(
expect.objectContaining({
icon_info: expect.objectContaining({
icon_type: 'image',
icon: 'new-file-id',
}),
}),
expect.any(Object),
)
})
expect(screen.getByRole('button', { name: /iconPicker\.ok/ })).toBeInTheDocument()
})
})
@@ -446,7 +402,7 @@ describe('EditPipelineInfo', () => {
describe('AppIconPicker', () => {
it('should not show picker initially', () => {
render(<EditPipelineInfo {...defaultProps} />)
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
})
it('should open picker when icon is clicked', () => {
@@ -454,43 +410,42 @@ describe('EditPipelineInfo', () => {
const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!)
expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
})
it('should close picker and update icon when emoji is selected', () => {
it('should close picker and update icon when emoji style is selected', async () => {
const { container } = render(<EditPipelineInfo {...defaultProps} />)
const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!)
const selectEmojiButton = screen.getByTestId('select-emoji')
fireEvent.click(selectEmojiButton)
fireEvent.click(screen.getByRole('button', { name: '#E4FBCC' }))
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
// Picker should close
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
await waitFor(() => {
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
})
})
it('should close picker and update icon when image is selected', () => {
it('should keep picker open when only switching to image tab', () => {
const { container } = render(<EditPipelineInfo {...defaultProps} />)
const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!)
const selectImageButton = screen.getByTestId('select-image')
fireEvent.click(selectImageButton)
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.image/ }))
// Picker should close
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /iconPicker\.ok/ })).toBeInTheDocument()
})
it('should revert icon when picker is closed without selection', () => {
it('should revert icon when picker is closed without selection', async () => {
const { container } = render(<EditPipelineInfo {...defaultProps} />)
const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!)
const closeButton = screen.getByTestId('close-picker')
fireEvent.click(closeButton)
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.cancel/ }))
// Picker should close
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
await waitFor(() => {
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
})
})
it('should save with new emoji icon selection', async () => {
@@ -504,8 +459,8 @@ describe('EditPipelineInfo', () => {
// Open picker and select new emoji
const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!)
const selectEmojiButton = screen.getByTestId('select-emoji')
fireEvent.click(selectEmojiButton)
fireEvent.click(screen.getByRole('button', { name: '#E4FBCC' }))
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
@@ -515,8 +470,8 @@ describe('EditPipelineInfo', () => {
expect.objectContaining({
icon_info: expect.objectContaining({
icon_type: 'emoji',
icon: '🎯',
icon_background: '#FFEAD5',
icon: '📊',
icon_background: '#E4FBCC',
}),
}),
expect.any(Object),
@@ -524,19 +479,21 @@ describe('EditPipelineInfo', () => {
})
})
it('should save with new image icon selection', async () => {
it('should save after confirming a real emoji selection from an image icon', async () => {
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
callbacks.onSuccess()
return Promise.resolve()
})
const { container } = render(<EditPipelineInfo {...defaultProps} />)
const { container } = render(<EditPipelineInfo {...defaultProps} pipeline={createImagePipelineTemplate()} />)
// Open picker and select new image
const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!)
const selectImageButton = screen.getByTestId('select-image')
fireEvent.click(selectImageButton)
const emojiButton = document.querySelector('em-emoji')?.closest('button')
expect(emojiButton).toBeTruthy()
fireEvent.click(emojiButton!)
fireEvent.click(screen.getByRole('button', { name: '#E4FBCC' }))
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
@@ -545,9 +502,9 @@ describe('EditPipelineInfo', () => {
expect(mockUpdatePipeline).toHaveBeenCalledWith(
expect.objectContaining({
icon_info: expect.objectContaining({
icon_type: 'image',
icon: 'new-file-id',
icon_url: 'https://new-icon.com/icon.png',
icon_type: 'emoji',
icon: expect.any(String),
icon_background: '#E4FBCC',
}),
}),
expect.any(Object),

View File

@@ -4,7 +4,7 @@ import { Button } from '@langgenius/dify-ui/button'
import { toast } from '@langgenius/dify-ui/toast'
import { RiCloseLine } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useRef, useState } from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import AppIconPicker from '@/app/components/base/app-icon-picker'
@@ -31,11 +31,6 @@ const EditPipelineInfo = ({
)
const [description, setDescription] = useState(pipeline.description)
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
const previousAppIcon = useRef<AppIconSelection>(
iconInfo.icon_type === 'image'
? { type: 'image' as const, url: iconInfo.icon_url || '', fileId: iconInfo.icon || '' }
: { type: 'emoji' as const, icon: iconInfo.icon || '', background: iconInfo.icon_background || '' },
)
const handleAppNameChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value
@@ -44,17 +39,10 @@ const EditPipelineInfo = ({
const handleOpenAppIconPicker = useCallback(() => {
setShowAppIconPicker(true)
previousAppIcon.current = appIcon
}, [appIcon])
}, [])
const handleSelectAppIcon = useCallback((icon: AppIconSelection) => {
setAppIcon(icon)
setShowAppIconPicker(false)
}, [])
const handleCloseAppIconPicker = useCallback(() => {
setAppIcon(previousAppIcon.current)
setShowAppIconPicker(false)
}, [])
const handleDescriptionChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => {
@@ -156,8 +144,12 @@ const EditPipelineInfo = ({
</div>
{showAppIconPicker && (
<AppIconPicker
open={showAppIconPicker}
initialEmoji={appIcon.type === 'emoji'
? { icon: appIcon.icon, background: appIcon.background }
: undefined}
onOpenChange={setShowAppIconPicker}
onSelect={handleSelectAppIcon}
onClose={handleCloseAppIconPicker}
/>
)}
</div>

View File

@@ -1,5 +1,6 @@
import type { DataSet } from '@/models/datasets'
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 RenameDatasetModal from '../index'
@@ -33,24 +34,6 @@ vi.mock('../../../base/app-icon', () => ({
),
}))
// Mock AppIconPicker - simplified mock to test onSelect and onClose callbacks
vi.mock('../../../base/app-icon-picker', () => ({
default: ({ onSelect, onClose }: {
onSelect?: (icon: { type: string, icon?: string, background?: string, fileId?: string, url?: string }) => void
onClose?: () => void
}) => (
<div data-testid="app-icon-picker">
<button data-testid="select-emoji" onClick={() => onSelect?.({ type: 'emoji', icon: '🚀', background: '#E0F2FE' })}>
Select Emoji
</button>
<button data-testid="select-image" onClick={() => onSelect?.({ type: 'image', fileId: 'new-file', url: 'https://new.png' })}>
Select Image
</button>
<button data-testid="close-picker" onClick={onClose}>Close</button>
</div>
),
}))
// The mock returns 'ns.key' format, e.g., 'common.operation.cancel'
describe('RenameDatasetModal', () => {
@@ -859,67 +842,32 @@ describe('RenameDatasetModal', () => {
// Initially picker should not be visible
// Initially picker should not be visible
// Initially picker should not be visible
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
const appIcon = screen.getByTestId('app-icon')
await act(async () => {
fireEvent.click(appIcon)
})
// Picker should now be visible
// Picker should now be visible
expect(screen.getByTestId('app-icon-picker'))!.toBeInTheDocument()
await waitFor(() => {
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
})
})
it('should select emoji icon and close picker (handleSelectAppIcon)', async () => {
it('should select emoji style and close picker (handleSelectAppIcon)', async () => {
const user = userEvent.setup()
render(<RenameDatasetModal {...defaultProps} />)
// Open picker
const appIcon = screen.getByTestId('app-icon')
await act(async () => {
fireEvent.click(appIcon)
await user.click(screen.getByTestId('app-icon'))
await waitFor(() => {
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
})
// Select emoji
const selectEmojiBtn = screen.getByTestId('select-emoji')
await act(async () => {
fireEvent.click(selectEmojiBtn)
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()
})
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
// Save and verify new icon is used
const saveButton = screen.getByText('common.operation.save')
await act(async () => {
@@ -931,9 +879,9 @@ describe('RenameDatasetModal', () => {
datasetId: 'dataset-1',
body: expect.objectContaining({
icon_info: {
icon: '🚀',
icon: '📊',
icon_type: 'emoji',
icon_background: '#E0F2FE',
icon_background: '#E4FBCC',
icon_url: undefined,
},
}),
@@ -941,56 +889,20 @@ describe('RenameDatasetModal', () => {
})
})
it('should select image icon and close picker (handleSelectAppIcon)', async () => {
it('should update emoji style through the picker (handleSelectAppIcon)', async () => {
const user = userEvent.setup()
render(<RenameDatasetModal {...defaultProps} />)
// Open picker
const appIcon = screen.getByTestId('app-icon')
await act(async () => {
fireEvent.click(appIcon)
await user.click(screen.getByTestId('app-icon'))
await waitFor(() => {
expect(screen.getByPlaceholderText('Search emojis...')).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()
})
// Select image
const selectImageBtn = screen.getByTestId('select-image')
await act(async () => {
fireEvent.click(selectImageBtn)
})
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
// Picker should close after selection
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
// Save and verify new image icon is used
const saveButton = screen.getByText('common.operation.save')
await act(async () => {
fireEvent.click(saveButton)
@@ -1001,10 +913,10 @@ describe('RenameDatasetModal', () => {
datasetId: 'dataset-1',
body: expect.objectContaining({
icon_info: {
icon: 'new-file',
icon_type: 'image',
icon_background: undefined,
icon_url: 'https://new.png',
icon: '📊',
icon_type: 'emoji',
icon_background: '#E0F2FE',
icon_url: undefined,
},
}),
})
@@ -1020,45 +932,14 @@ describe('RenameDatasetModal', () => {
fireEvent.click(appIcon)
})
// Close picker without selecting
const closeBtn = screen.getByTestId('close-picker')
await act(async () => {
fireEvent.click(closeBtn)
const user = userEvent.setup()
await waitFor(() => {
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
})
await user.click(screen.getByRole('button', { name: /iconPicker\.cancel/ }))
await waitFor(() => {
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
})
// Picker should close
// Picker should close
// Picker should close
// Picker should close
// Picker should close
// Picker should close
// Picker should close
// Picker should close
// Picker should close
// Picker should close
// Picker should close
// Picker should close
// Picker should close
// Picker should close
// Picker should close
// Picker should close
// Picker should close
// Picker should close
// Picker should close
// Picker should close
// Picker should close
// Picker should close
// Picker should close
// Picker should close
// Picker should close
// Picker should close
// Picker should close
// Picker should close
// Picker should close
// Picker should close
// Picker should close
// Picker should close
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
// Save and verify original icon is preserved
const saveButton = screen.getByText('common.operation.save')

View File

@@ -7,7 +7,7 @@ import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { RiCloseLine } from '@remixicon/react'
import { useCallback, useRef, useState } from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
@@ -32,20 +32,11 @@ const RenameDatasetModal = ({ show, dataset, onSuccess, onClose }: RenameDataset
? { type: 'image' as const, url: dataset.icon_info?.icon_url || '', fileId: dataset.icon_info?.icon || '' }
: { type: 'emoji' as const, icon: dataset.icon_info?.icon || '', background: dataset.icon_info?.icon_background || '' })
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
const previousAppIcon = useRef<AppIconSelection>(dataset.icon_info?.icon_type === 'image'
? { type: 'image' as const, url: dataset.icon_info?.icon_url || '', fileId: dataset.icon_info?.icon || '' }
: { type: 'emoji' as const, icon: dataset.icon_info?.icon || '', background: dataset.icon_info?.icon_background || '' })
const handleOpenAppIconPicker = useCallback(() => {
setShowAppIconPicker(true)
previousAppIcon.current = appIcon
}, [appIcon])
}, [])
const handleSelectAppIcon = useCallback((icon: AppIconSelection) => {
setAppIcon(icon)
setShowAppIconPicker(false)
}, [])
const handleCloseAppIconPicker = useCallback(() => {
setAppIcon(previousAppIcon.current)
setShowAppIconPicker(false)
}, [])
const onConfirm: MouseEventHandler = useCallback(async () => {
if (!name.trim()) {
@@ -125,7 +116,16 @@ const RenameDatasetModal = ({ show, dataset, onSuccess, onClose }: RenameDataset
<Button className="mr-2" onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button disabled={loading} variant="primary" onClick={onConfirm}>{t('operation.save', { ns: 'common' })}</Button>
</div>
{showAppIconPicker && (<AppIconPicker onSelect={handleSelectAppIcon} onClose={handleCloseAppIconPicker} />)}
{showAppIconPicker && (
<AppIconPicker
open={showAppIconPicker}
initialEmoji={appIcon.type === 'emoji'
? { icon: appIcon.icon, background: appIcon.background }
: undefined}
onOpenChange={setShowAppIconPicker}
onSelect={handleSelectAppIcon}
/>
)}
</DialogContent>
</Dialog>
)

View File

@@ -127,7 +127,7 @@ describe('BasicInfoSection', () => {
showAppIconPicker: false,
handleOpenAppIconPicker: vi.fn(),
handleSelectAppIcon: vi.fn(),
handleCloseAppIconPicker: vi.fn(),
setShowAppIconPicker: vi.fn(),
permission: DatasetPermission.onlyMe,
setPermission: vi.fn(),
selectedMemberIDs: ['user-1'],

View File

@@ -24,7 +24,7 @@ type BasicInfoSectionProps = {
showAppIconPicker: boolean
handleOpenAppIconPicker: () => void
handleSelectAppIcon: (icon: AppIconSelection) => void
handleCloseAppIconPicker: () => void
setShowAppIconPicker: (show: boolean) => void
permission: DatasetPermission | undefined
setPermission: (value: DatasetPermission | undefined) => void
selectedMemberIDs: string[]
@@ -43,7 +43,7 @@ const BasicInfoSection = ({
showAppIconPicker,
handleOpenAppIconPicker,
handleSelectAppIcon,
handleCloseAppIconPicker,
setShowAppIconPicker,
permission,
setPermission,
selectedMemberIDs,
@@ -113,8 +113,12 @@ const BasicInfoSection = ({
{showAppIconPicker && (
<AppIconPicker
open={showAppIconPicker}
initialEmoji={iconInfo.icon_type === 'emoji'
? { icon: iconInfo.icon, background: iconInfo.icon_background }
: undefined}
onOpenChange={setShowAppIconPicker}
onSelect={handleSelectAppIcon}
onClose={handleCloseAppIconPicker}
/>
)}
</>

View File

@@ -258,7 +258,7 @@ describe('useFormState', () => {
expect(result.current.showAppIconPicker).toBe(true)
})
it('should select emoji icon and close picker', () => {
it('should select emoji icon without owning picker close state', () => {
const { result } = renderHook(() => useFormState())
act(() => {
@@ -273,7 +273,7 @@ describe('useFormState', () => {
})
})
expect(result.current.showAppIconPicker).toBe(false)
expect(result.current.showAppIconPicker).toBe(true)
expect(result.current.iconInfo).toEqual({
icon_type: 'emoji',
icon: '🎉',
@@ -282,7 +282,7 @@ describe('useFormState', () => {
})
})
it('should select image icon and close picker', () => {
it('should select image icon without owning picker close state', () => {
const { result } = renderHook(() => useFormState())
act(() => {
@@ -297,7 +297,7 @@ describe('useFormState', () => {
})
})
expect(result.current.showAppIconPicker).toBe(false)
expect(result.current.showAppIconPicker).toBe(true)
expect(result.current.iconInfo).toEqual({
icon_type: 'image',
icon: 'file-123',
@@ -306,7 +306,7 @@ describe('useFormState', () => {
})
})
it('should restore previous icon when picker is closed', () => {
it('should close picker through open state setter without changing icon', () => {
const { result } = renderHook(() => useFormState())
act(() => {
@@ -322,15 +322,10 @@ describe('useFormState', () => {
})
act(() => {
result.current.handleOpenAppIconPicker()
})
act(() => {
result.current.handleCloseAppIconPicker()
result.current.setShowAppIconPicker(false)
})
expect(result.current.showAppIconPicker).toBe(false)
// After close, icon should be restored to the icon before opening
expect(result.current.iconInfo).toEqual({
icon_type: 'emoji',
icon: '🎉',

View File

@@ -5,7 +5,7 @@ import type { Member } from '@/models/common'
import type { IconInfo, SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { toast } from '@langgenius/dify-ui/toast'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { isReRankModelSelected } from '@/app/components/datasets/common/check-rerank-model'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
@@ -39,7 +39,6 @@ export const useFormState = () => {
// Icon state
const [iconInfo, setIconInfo] = useState(currentDataset?.icon_info || DEFAULT_APP_ICON)
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
const previousAppIcon = useRef(DEFAULT_APP_ICON)
// Permission state
const [permission, setPermission] = useState(currentDataset?.permission)
@@ -83,8 +82,7 @@ export const useFormState = () => {
// Icon handlers
const handleOpenAppIconPicker = useCallback(() => {
setShowAppIconPicker(true)
previousAppIcon.current = iconInfo
}, [iconInfo])
}, [])
const handleSelectAppIcon = useCallback((icon: AppIconSelection) => {
const newIconInfo: IconInfo = {
@@ -94,12 +92,6 @@ export const useFormState = () => {
icon_url: icon.type === 'emoji' ? undefined : icon.url,
}
setIconInfo(newIconInfo)
setShowAppIconPicker(false)
}, [])
const handleCloseAppIconPicker = useCallback(() => {
setIconInfo(previousAppIcon.current)
setShowAppIconPicker(false)
}, [])
// External retrieval settings handler
@@ -223,9 +215,9 @@ export const useFormState = () => {
// Icon
iconInfo,
showAppIconPicker,
setShowAppIconPicker,
handleOpenAppIconPicker,
handleSelectAppIcon,
handleCloseAppIconPicker,
// Permission
permission,

View File

@@ -26,9 +26,9 @@ const Form = () => {
// Icon
iconInfo,
showAppIconPicker,
setShowAppIconPicker,
handleOpenAppIconPicker,
handleSelectAppIcon,
handleCloseAppIconPicker,
// Permission
permission,
@@ -78,9 +78,9 @@ const Form = () => {
setDescription={setDescription}
iconInfo={iconInfo}
showAppIconPicker={showAppIconPicker}
setShowAppIconPicker={setShowAppIconPicker}
handleOpenAppIconPicker={handleOpenAppIconPicker}
handleSelectAppIcon={handleSelectAppIcon}
handleCloseAppIconPicker={handleCloseAppIconPicker}
permission={permission}
setPermission={setPermission}
selectedMemberIDs={selectedMemberIDs}

View File

@@ -198,15 +198,13 @@ const CreateAppModal = ({
</Dialog>
{showAppIconPicker && (
<AppIconPicker
open={showAppIconPicker}
initialEmoji={appIcon.type === 'emoji'
? { icon: appIcon.icon, background: appIcon.background }
: undefined}
onOpenChange={setShowAppIconPicker}
onSelect={(payload) => {
setAppIcon(payload)
setShowAppIconPicker(false)
}}
onClose={() => {
setShowAppIconPicker(false)
}}
/>
)}

View File

@@ -1,6 +1,5 @@
import type { EnvironmentVariable } from '@/app/components/workflow/types'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useState } from 'react'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
import Conversion from '../conversion'
@@ -348,58 +347,6 @@ vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
),
}))
vi.mock('@/app/components/base/app-icon-picker', () => ({
default: function MockAppIconPicker({ onSelect, onClose }: {
onSelect?: (payload:
| { type: 'emoji', icon: string, background: string }
| { type: 'image', fileId: string, url: string },
) => void
onClose?: () => void
}) {
const [activeTab, setActiveTab] = useState<'emoji' | 'image'>('emoji')
const [selectedEmoji, setSelectedEmoji] = useState({ icon: '😀', background: '#FFFFFF' })
return (
<div data-testid="app-icon-picker">
<button type="button" onClick={() => setActiveTab('emoji')}>iconPicker.emoji</button>
<button type="button" onClick={() => setActiveTab('image')}>iconPicker.image</button>
{activeTab === 'emoji' && (
<button
type="button"
data-testid="picker-emoji-option"
onClick={() => setSelectedEmoji({ icon: '🎯', background: '#FFAA00' })}
>
picker-emoji-option
</button>
)}
{activeTab === 'image' && <div data-testid="picker-image-panel">picker-image-panel</div>}
<button type="button" onClick={() => onClose?.()}>iconPicker.cancel</button>
<button
type="button"
onClick={() => {
if (activeTab === 'emoji') {
onSelect?.({
type: 'emoji',
icon: selectedEmoji.icon,
background: selectedEmoji.background,
})
return
}
onSelect?.({
type: 'image',
fileId: 'test-file-id',
url: 'https://example.com/icon.png',
})
}}
>
iconPicker.ok
</button>
</div>
)
},
}))
// Silence expected console.error from Dialog/Modal rendering
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -767,7 +714,7 @@ describe('PublishAsKnowledgePipelineModal', () => {
const appIcon = getAppIcon()
fireEvent.click(appIcon)
fireEvent.click(screen.getByTestId('picker-emoji-option'))
fireEvent.click(screen.getByRole('button', { name: '#E4FBCC' }))
// Click OK to confirm selection
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
@@ -1087,7 +1034,7 @@ describe('Integration Tests', () => {
// Open picker and select an emoji
const appIcon = getAppIcon()
fireEvent.click(appIcon)
fireEvent.click(screen.getByTestId('picker-emoji-option'))
fireEvent.click(screen.getByRole('button', { name: '#E4FBCC' }))
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.publish/i }))

View File

@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import PublishAsKnowledgePipelineModal from '../publish-as-knowledge-pipeline-modal'
@@ -23,6 +23,9 @@ vi.mock('@langgenius/dify-ui/dialog', () => ({
DialogContent: ({ children, className }: { children: React.ReactNode, className?: string }) => (
<div data-testid="modal" className={className}>{children}</div>
),
DialogTitle: ({ children, className }: { children: React.ReactNode, className?: string }) => (
<h2 className={className}>{children}</h2>
),
}))
vi.mock('@langgenius/dify-ui/button', () => ({
@@ -61,22 +64,6 @@ vi.mock('@/app/components/base/app-icon', () => ({
),
}))
vi.mock('@/app/components/base/app-icon-picker', () => ({
default: ({ onSelect, onClose }: { onSelect: (item: { type: string, icon: string, background: string, url: string }) => void, onClose: () => void }) => (
<div data-testid="icon-picker">
<button data-testid="select-emoji" onClick={() => onSelect({ type: 'emoji', icon: '🎉', background: '#eee', url: '' })}>
Select Emoji
</button>
<button data-testid="select-image" onClick={() => onSelect({ type: 'image', icon: '', background: '', url: 'http://img.png' })}>
Select Image
</button>
<button data-testid="close-picker" onClick={onClose}>
Close
</button>
</div>
),
}))
vi.mock('es-toolkit/function', () => ({
noop: () => {},
}))
@@ -190,41 +177,46 @@ describe('PublishAsKnowledgePipelineModal', () => {
expect(mockOnConfirm).not.toHaveBeenCalled()
})
it('should show icon picker when app icon clicked', () => {
it('should show icon picker when app icon clicked', async () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
expect(screen.queryByTestId('icon-picker')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('app-icon'))
expect(screen.getByTestId('icon-picker')).toBeInTheDocument()
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
})
it('should update icon when emoji is selected', () => {
it('should update icon when emoji style is selected', async () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('app-icon'))
fireEvent.click(screen.getByTestId('select-emoji'))
fireEvent.click(screen.getByRole('button', { name: '#E4FBCC' }))
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
expect(screen.queryByTestId('icon-picker')).not.toBeInTheDocument()
await waitFor(() => {
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
})
})
it('should update icon when image is selected', () => {
it('should keep icon picker open until confirmation', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('app-icon'))
fireEvent.click(screen.getByTestId('select-image'))
fireEvent.click(screen.getByRole('button', { name: '#E4FBCC' }))
expect(screen.queryByTestId('icon-picker')).not.toBeInTheDocument()
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
})
it('should close icon picker when close is clicked', () => {
it('should close icon picker when cancel is clicked', async () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('app-icon'))
fireEvent.click(screen.getByTestId('close-picker'))
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.cancel/ }))
expect(screen.queryByTestId('icon-picker')).not.toBeInTheDocument()
await waitFor(() => {
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
})
})
it('should trim name and description before submitting', () => {

View File

@@ -51,17 +51,7 @@ const PublishAsKnowledgePipelineModal = ({
icon_url: '',
})
}
setShowAppIconPicker(false)
}, [])
const handleCloseIconPicker = useCallback(() => {
setPipelineIcon({
icon_type: pipelineIcon.icon_type,
icon: pipelineIcon.icon,
icon_background: pipelineIcon.icon_background,
icon_url: pipelineIcon.icon_url,
})
setShowAppIconPicker(false)
}, [pipelineIcon])
const handleConfirm = () => {
if (confirmDisabled)
@@ -141,8 +131,12 @@ const PublishAsKnowledgePipelineModal = ({
</div>
{showAppIconPicker && (
<AppIconPicker
open={showAppIconPicker}
initialEmoji={pipelineIcon.icon_type === 'emoji'
? { icon: pipelineIcon.icon, background: pipelineIcon.icon_background }
: undefined}
onOpenChange={setShowAppIconPicker}
onSelect={handleSelectIcon}
onClose={handleCloseIconPicker}
/>
)}
</DialogContent>

View File

@@ -53,18 +53,6 @@ vi.mock('@/context/i18n', async () => {
}
})
// Mock EmojiPicker
vi.mock('@/app/components/base/emoji-picker', () => ({
default: ({ onSelect, onClose }: { onSelect: (icon: string, background: string) => void, onClose: () => void }) => {
return (
<div data-testid="emoji-picker">
<button data-testid="select-emoji" onClick={() => onSelect('🚀', '#FF0000')}>Select Emoji</button>
<button data-testid="close-emoji-picker" onClick={onClose}>Close</button>
</div>
)
},
}))
describe('EditCustomCollectionModal', () => {
const mockOnHide = vi.fn()
const mockOnAdd = vi.fn()

View File

@@ -386,12 +386,10 @@ const EditCustomCollectionModal: FC<Props> = ({
</div>
{showEmojiPicker && (
<EmojiPicker
open={showEmojiPicker}
onOpenChange={setShowEmojiPicker}
onSelect={(icon, icon_background) => {
setEmoji({ content: icon, background: icon_background })
setShowEmojiPicker(false)
}}
onClose={() => {
setShowEmojiPicker(false)
}}
/>
)}

View File

@@ -11,31 +11,6 @@ vi.mock('@/service/common', () => ({
uploadRemoteFileInfo: vi.fn().mockResolvedValue({ url: 'https://example.com/icon.png' }),
}))
// Mock the AppIconPicker component
type IconPayload = {
type: string
icon: string
background: string
}
type AppIconPickerProps = {
onSelect: (payload: IconPayload) => void
onClose: () => void
}
vi.mock('@/app/components/base/app-icon-picker', () => ({
default: ({ onSelect, onClose }: AppIconPickerProps) => (
<div data-testid="app-icon-picker">
<button data-testid="select-emoji-btn" onClick={() => onSelect({ type: 'emoji', icon: '🎉', background: '#FF0000' })}>
Select Emoji
</button>
<button data-testid="close-picker-btn" onClick={onClose}>
Close Picker
</button>
</div>
),
}))
// Mock the plugins service to avoid React Query issues from TabSlider
vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: () => ({
@@ -695,9 +670,8 @@ describe('MCPModal', () => {
if (appIconContainer) {
fireEvent.click(appIconContainer)
// The mocked AppIconPicker should now be visible
await waitFor(() => {
expect(screen.getByTestId('app-icon-picker'))!.toBeInTheDocument()
expect(screen.getByPlaceholderText('Search emojis...'))!.toBeInTheDocument()
})
}
})
@@ -712,16 +686,14 @@ describe('MCPModal', () => {
fireEvent.click(appIconContainer)
await waitFor(() => {
expect(screen.getByTestId('app-icon-picker'))!.toBeInTheDocument()
expect(screen.getByPlaceholderText('Search emojis...'))!.toBeInTheDocument()
})
// Click the select emoji button
const selectBtn = screen.getByTestId('select-emoji-btn')
fireEvent.click(selectBtn)
fireEvent.click(screen.getByRole('button', { name: '#E4FBCC' }))
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
// The picker should be closed
await waitFor(() => {
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
})
}
})
@@ -736,16 +708,13 @@ describe('MCPModal', () => {
fireEvent.click(appIconContainer)
await waitFor(() => {
expect(screen.getByTestId('app-icon-picker'))!.toBeInTheDocument()
expect(screen.getByPlaceholderText('Search emojis...'))!.toBeInTheDocument()
})
// Click the close button
const closeBtn = screen.getByTestId('close-picker-btn')
fireEvent.click(closeBtn)
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.cancel/ }))
// The picker should be closed
await waitFor(() => {
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
})
}
})

View File

@@ -117,12 +117,6 @@ const MCPModalContent: FC<MCPModalContentProps> = ({
const handleIconSelect = (payload: AppIconSelection) => {
actions.setAppIcon(payload)
actions.setShowAppIconPicker(false)
}
const handleIconClose = () => {
actions.resetIcon()
actions.setShowAppIconPicker(false)
}
const isSubmitDisabled = !state.name || !state.url || !state.serverIdentifier || state.isFetchingIcon
@@ -260,8 +254,12 @@ const MCPModalContent: FC<MCPModalContentProps> = ({
{state.showAppIconPicker && (
<AppIconPicker
open={state.showAppIconPicker}
initialEmoji={state.appIcon.type === 'emoji'
? { icon: state.appIcon.icon, background: state.appIcon.background }
: undefined}
onOpenChange={actions.setShowAppIconPicker}
onSelect={handleIconSelect}
onClose={handleIconClose}
/>
)}
</>

View File

@@ -19,6 +19,7 @@ vi.mock('@/next/navigation', () => ({
}),
usePathname: () => '/app/workflow-app-id',
useSearchParams: () => new URLSearchParams(),
useParams: () => ({}),
}))
// Mock app context
@@ -68,15 +69,6 @@ vi.mock('@/app/components/plugins/hooks', () => ({
}),
}))
// Mock EmojiPickerInner - simplified for testing
vi.mock('@/app/components/base/emoji-picker/Inner', () => ({
default: ({ onSelect }: { onSelect: (icon: string, background: string) => void }) => (
<div data-testid="emoji-picker">
<button data-testid="select-emoji" onClick={() => onSelect('🚀', '#f0f0f0')}>Select Emoji</button>
</div>
),
}))
// Mock AppIcon - simplified for testing
vi.mock('@/app/components/base/app-icon', () => ({
default: ({ onClick, icon, background }: { onClick?: () => void, icon: string, background: string }) => (
@@ -814,8 +806,9 @@ describe('WorkflowToolDrawer', () => {
await user.click(iconButton)
// Assert
// Assert
expect(screen.getByTestId('emoji-picker'))!.toBeInTheDocument()
await waitFor(() => {
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
})
})
it('should update emoji on selection', async () => {
@@ -834,14 +827,19 @@ describe('WorkflowToolDrawer', () => {
const iconButton = screen.getByTestId('app-icon')
await user.click(iconButton)
// Select emoji
await user.click(screen.getByTestId('select-emoji'))
await user.click(screen.getByRole('button', { name: 'app.iconPicker.ok' }))
await waitFor(() => {
expect(screen.getByPlaceholderText('Search emojis...')).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()
})
// Assert
const updatedIcon = screen.getByTestId('app-icon')
expect(updatedIcon)!.toHaveAttribute('data-icon', '🚀')
expect(updatedIcon)!.toHaveAttribute('data-background', '#f0f0f0')
expect(updatedIcon)!.toHaveAttribute('data-icon', '🔧')
expect(updatedIcon)!.toHaveAttribute('data-background', '#E4FBCC')
})
it('should close emoji picker on close button', async () => {
@@ -859,43 +857,15 @@ describe('WorkflowToolDrawer', () => {
const iconButton = screen.getByTestId('app-icon')
await user.click(iconButton)
expect(screen.getByTestId('emoji-picker'))!.toBeInTheDocument()
await waitFor(() => {
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
})
await user.click(screen.getByRole('button', { name: /iconPicker\.cancel/ }))
await waitFor(() => {
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
})
await user.click(screen.getByRole('button', { name: 'app.iconPicker.cancel' }))
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
expect(screen.queryByTestId('emoji-picker')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
})
it('should update labels when label selector changes', async () => {

View File

@@ -4,14 +4,6 @@ import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { WorkflowToolDrawer } from '../index'
vi.mock('@/app/components/base/emoji-picker/Inner', () => ({
default: ({ onSelect }: { onSelect: (icon: string, background: string) => void }) => (
<div data-testid="emoji-picker">
<button data-testid="select-emoji" onClick={() => onSelect('🚀', '#000000')}>Emoji</button>
</div>
),
}))
vi.mock('@/app/components/base/app-icon', () => ({
default: ({ onClick, icon }: { onClick?: () => void, icon: string }) => (
<button data-testid="app-icon" onClick={onClick}>{icon}</button>
@@ -98,14 +90,20 @@ describe('WorkflowToolDrawer', () => {
await user.type(screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder'), 'Created Tool')
await user.click(screen.getByTestId('append-label'))
await user.click(screen.getByTestId('app-icon'))
await user.click(screen.getByTestId('select-emoji'))
await user.click(screen.getByRole('button', { name: 'app.iconPicker.ok' }))
await waitFor(() => {
expect(screen.getByPlaceholderText('Search emojis...')).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()
})
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({
workflow_app_id: 'workflow-app-1',
label: 'Created Tool',
icon: { content: '🚀', background: '#000000' },
icon: { content: '🔧', background: '#E4FBCC' },
labels: ['label1', 'new-label'],
}))
})

View File

@@ -3,7 +3,6 @@ import type { DrawerRootProps } from '@langgenius/dify-ui/drawer'
import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderOutputSchema, WorkflowToolProviderParameter, WorkflowToolProviderRequest } from '../types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import {
Drawer,
DrawerBackdrop,
@@ -21,8 +20,7 @@ import * as React from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import Divider from '@/app/components/base/divider'
import EmojiPickerInner from '@/app/components/base/emoji-picker/Inner'
import AppIconPicker from '@/app/components/base/app-icon-picker'
import { Infotip } from '@/app/components/base/infotip'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
@@ -126,51 +124,6 @@ const WorkflowToolDrawerFrame = ({ title, closeLabel, onHide, children }: Workfl
)
}
type WorkflowToolEmojiPickerProps = {
onSelect: (icon: string, background: string) => void
onClose: () => void
}
const WorkflowToolEmojiPicker = ({ onSelect, onClose }: WorkflowToolEmojiPickerProps) => {
const { t } = useTranslation()
const [selectedEmoji, setSelectedEmoji] = useState('')
const [selectedBackground, setSelectedBackground] = useState<string>()
return (
<Dialog open disablePointerDismissal>
<DialogContent
backdropProps={{ forceRender: true }}
className="flex max-h-[552px] w-[480px]! flex-col overflow-hidden rounded-xl border-[0.5px] border-divider-subtle p-0! shadow-xl"
>
<DialogTitle className="sr-only">
{t('iconPicker.emoji', { ns: 'app' })}
</DialogTitle>
<EmojiPickerInner
className="pt-3"
onSelect={(emoji, background) => {
setSelectedEmoji(emoji)
setSelectedBackground(background)
}}
/>
<Divider className="mt-3 mb-0" />
<div className="flex w-full items-center justify-center gap-2 p-3">
<Button className="w-full" onClick={onClose}>
{t('iconPicker.cancel', { ns: 'app' })}
</Button>
<Button
disabled={selectedEmoji === '' || !selectedBackground}
variant="primary"
className="w-full"
onClick={() => onSelect(selectedEmoji, selectedBackground!)}
>
{t('iconPicker.ok', { ns: 'app' })}
</Button>
</div>
</DialogContent>
</Dialog>
)
}
export function WorkflowToolDrawer({
isAdd,
payload,
@@ -449,17 +402,19 @@ export function WorkflowToolDrawer({
</div>
</div>
</WorkflowToolDrawerFrame>
{showEmojiPicker && (
<WorkflowToolEmojiPicker
onSelect={(icon, icon_background) => {
setEmoji({ content: icon, background: icon_background })
setShowEmojiPicker(false)
}}
onClose={() => {
setShowEmojiPicker(false)
}}
/>
)}
<AppIconPicker
open={showEmojiPicker}
enableImageUpload={false}
initialEmoji={{
icon: emoji.content,
background: emoji.background,
}}
onOpenChange={setShowEmojiPicker}
onSelect={(payload) => {
if (payload.type === 'emoji')
setEmoji({ content: payload.icon, background: payload.background })
}}
/>
{confirmModalOpen && (
<ConfirmModal
show={confirmModalOpen}