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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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>, }) => <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', () => ({ vi.mock('@/app/components/base/features/new-feature-panel/moderation/form-generation', () => ({
default: ({ default: ({
onChange, onChange,
@ -146,7 +131,14 @@ describe('ExternalDataToolModal', () => {
}) })
fireEvent.click(screen.getByText('pick-extension')) fireEvent.click(screen.getByText('pick-extension'))
fireEvent.click(screen.getByText('open-emoji-picker')) 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')) fireEvent.click(screen.getByText('operation.save'))
await waitFor(() => { await waitFor(() => {
@ -155,8 +147,8 @@ describe('ExternalDataToolModal', () => {
api_based_extension_id: 'extension-1', api_based_extension_id: 'extension-1',
}, },
enabled: true, enabled: true,
icon: 'sparkles', icon: expect.any(String),
icon_background: '#fff', icon_background: '#E4FBCC',
label: 'Search', label: 'Search',
type: 'api', type: 'api',
variable: 'search_api', variable: 'search_api',
@ -168,8 +160,8 @@ describe('ExternalDataToolModal', () => {
api_based_extension_id: 'extension-1', api_based_extension_id: 'extension-1',
}, },
enabled: true, enabled: true,
icon: 'sparkles', icon: expect.any(String),
icon_background: '#fff', icon_background: '#E4FBCC',
label: 'Search', label: 'Search',
type: 'api', type: 'api',
variable: 'search_api', variable: 'search_api',

View File

@ -217,13 +217,10 @@ const ExternalDataToolModal: FC<ExternalDataToolModalProps> = ({
{ {
showEmojiPicker && ( showEmojiPicker && (
<EmojiPicker <EmojiPicker
open={showEmojiPicker}
onOpenChange={setShowEmojiPicker}
onSelect={(icon, icon_background) => { onSelect={(icon, icon_background) => {
handleValueChange({ 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 type { App } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react' 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 { beforeEach, describe, expect, it, vi } from 'vitest'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
@ -30,6 +31,7 @@ vi.mock('ahooks', () => ({
})) }))
vi.mock('@/next/navigation', () => ({ vi.mock('@/next/navigation', () => ({
useRouter: vi.fn(), useRouter: vi.fn(),
useParams: () => ({}),
})) }))
vi.mock('@/utils/create-app-tracking', () => ({ vi.mock('@/utils/create-app-tracking', () => ({
trackCreateApp: vi.fn(), trackCreateApp: vi.fn(),
@ -55,19 +57,6 @@ vi.mock('@/app/components/base/app-icon', () => ({
<button type="button" onClick={onClick}>open-icon-picker</button> <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', () => ({ vi.mock('@/utils/app-redirection', () => ({
getRedirection: vi.fn(), getRedirection: vi.fn(),
})) }))
@ -216,14 +205,22 @@ describe('CreateAppModal', () => {
expect(onCreateFromTemplate).toHaveBeenCalled() 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) mockCreateApp.mockResolvedValue({ id: 'chat-app', mode: AppModeEnum.CHAT } as App)
renderModal() renderModal()
fireEvent.click(screen.getByText('app.newApp.forBeginners')) fireEvent.click(screen.getByText('app.newApp.forBeginners'))
fireEvent.click(screen.getByText('app.types.chatbot')) fireEvent.click(screen.getByText('app.types.chatbot'))
fireEvent.click(screen.getByText('open-icon-picker')) await user.click(screen.getByText('open-icon-picker'))
fireEvent.click(screen.getByText('select-image-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()
})
fireEvent.change(screen.getByPlaceholderText('app.newApp.appNamePlaceholder'), { fireEvent.change(screen.getByPlaceholderText('app.newApp.appNamePlaceholder'), {
target: { value: 'Keyboard App' }, target: { value: 'Keyboard App' },
}) })
@ -237,9 +234,9 @@ describe('CreateAppModal', () => {
expect(mockCreateApp).toHaveBeenCalledWith({ expect(mockCreateApp).toHaveBeenCalledWith({
name: 'Keyboard App', name: 'Keyboard App',
description: 'Created from shortcut', description: 'Created from shortcut',
icon_type: 'image', icon_type: 'emoji',
icon: 'file-1', icon: '🤖',
icon_background: undefined, icon_background: '#E4FBCC',
mode: AppModeEnum.CHAT, mode: AppModeEnum.CHAT,
}) })
}) })
@ -254,7 +251,8 @@ describe('CreateAppModal', () => {
expect(mockCreateApp).not.toHaveBeenCalled() 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({ mockUseProviderContext.mockReturnValue({
plan: { plan: {
type: AppModeEnum.ADVANCED_CHAT, type: AppModeEnum.ADVANCED_CHAT,
@ -267,11 +265,16 @@ describe('CreateAppModal', () => {
renderModal() renderModal()
fireEvent.click(screen.getByText('open-icon-picker')) await user.click(screen.getByText('open-icon-picker'))
expect(screen.getByText('select-image-icon')).toBeInTheDocument() await waitFor(() => {
fireEvent.click(screen.getByText('close-icon-picker')) 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)?.() ahooksMocks.keyPressHandlers.at(-1)?.()

View File

@ -220,12 +220,13 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
/> />
{showAppIconPicker && ( {showAppIconPicker && (
<AppIconPicker <AppIconPicker
open={showAppIconPicker}
initialEmoji={appIcon.type === 'emoji'
? { icon: appIcon.icon, background: appIcon.background }
: undefined}
onOpenChange={setShowAppIconPicker}
onSelect={(payload) => { onSelect={(payload) => {
setAppIcon(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 userEvent from '@testing-library/user-event'
import DuplicateAppModal from '../index' 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', () => { describe('DuplicateAppModal', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
@ -94,14 +85,21 @@ describe('DuplicateAppModal', () => {
) )
await user.click(screen.getByText('open-icon-picker')) 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' })) await user.click(screen.getByRole('button', { name: 'duplicate' }))
expect(onConfirm).toHaveBeenCalledWith({ expect(onConfirm).toHaveBeenCalledWith({
name: 'Demo App', name: 'Demo App',
icon_type: 'image', icon_type: 'emoji',
icon: 'file-1', icon: '🤖',
icon_background: undefined, icon_background: '#E4FBCC',
}) })
expect(onHide).toHaveBeenCalled() expect(onHide).toHaveBeenCalled()
}) })
@ -127,7 +125,7 @@ describe('DuplicateAppModal', () => {
expect(onHide).toHaveBeenCalledTimes(1) 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 onConfirm = vi.fn()
const user = userEvent.setup() const user = userEvent.setup()
@ -144,16 +142,32 @@ describe('DuplicateAppModal', () => {
) )
await user.click(screen.getByText('open-icon-picker')) 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('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' })) await user.click(screen.getByRole('button', { name: 'duplicate' }))
expect(onConfirm).toHaveBeenCalledWith({ expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({
name: 'Image App', name: 'Image App',
icon_type: 'image', icon_type: 'emoji',
icon: 'original-file', icon: expect.any(String),
icon_background: undefined, icon_background: '#E4FBCC',
}) }))
}) })
}) })

View File

@ -109,15 +109,13 @@ const DuplicateAppModal = ({
</Dialog> </Dialog>
{showAppIconPicker && ( {showAppIconPicker && (
<AppIconPicker <AppIconPicker
open={showAppIconPicker}
initialEmoji={appIcon.type === 'emoji'
? { icon: appIcon.icon, background: appIcon.background }
: undefined}
onOpenChange={setShowAppIconPicker}
onSelect={(payload) => { onSelect={(payload) => {
setAppIcon(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 className="mr-2" onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button variant="primary" onClick={onClickSave} loading={saveLoading}>{t('operation.save', { ns: 'common' })}</Button> <Button variant="primary" onClick={onClickSave} loading={saveLoading}>{t('operation.save', { ns: 'common' })}</Button>
</div> </div>
{showAppIconPicker && (
<div onClick={e => e.stopPropagation()}>
<AppIconPicker
onSelect={(payload) => {
setAppIcon(payload)
setShowAppIconPicker(false)
}}
onClose={() => {
setAppIcon(createAppIcon(appInfo))
setShowAppIconPicker(false)
}}
/>
</div>
)}
</DialogContent> </DialogContent>
</Dialog> </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 => ({ const createMockApp = (overrides: Partial<App> = {}): App => ({
id: 'app-123', id: 'app-123',
name: 'Demo App', name: 'Demo App',
@ -315,17 +303,23 @@ describe('SwitchAppModal', () => {
mockSwitchApp.mockResolvedValueOnce({ new_app_id: 'new-app-003' }) mockSwitchApp.mockResolvedValueOnce({ new_app_id: 'new-app-003' })
await user.click(screen.getByText('open-icon-picker')) 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 user.click(screen.getByRole('button', { name: 'app.switchStart' }))
await waitFor(() => { await waitFor(() => {
expect(mockSwitchApp).toHaveBeenCalledWith(expect.objectContaining({ expect(mockSwitchApp).toHaveBeenCalledWith(expect.objectContaining({
appID: appDetail.id, appID: appDetail.id,
icon_type: 'image', icon_type: 'emoji',
icon: 'file-id-1', icon: '🚀',
icon_background: undefined, icon_background: '#E4FBCC',
})) }))
}) })
}) })
@ -335,9 +329,14 @@ describe('SwitchAppModal', () => {
renderComponent() renderComponent()
await user.click(screen.getByText('open-icon-picker')) await user.click(screen.getByText('open-icon-picker'))
expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument() await waitFor(() => {
await user.click(screen.getByText('close-app-icon-picker')) expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
expect(screen.queryByTestId('app-icon-picker')).not.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')) await user.click(screen.getByText('app.removeOriginal'))
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()

View File

@ -149,15 +149,13 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo
</div> </div>
{showAppIconPicker && ( {showAppIconPicker && (
<AppIconPicker <AppIconPicker
open={showAppIconPicker}
initialEmoji={appIcon.type === 'emoji'
? { icon: appIcon.icon, background: appIcon.background }
: undefined}
onOpenChange={setShowAppIconPicker}
onSelect={(payload) => { onSelect={(payload) => {
setAppIcon(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 renderPicker = (props: Partial<ComponentProps<typeof AppIconPicker>> = {}) => {
const onSelect = vi.fn() 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(() => { beforeEach(() => {
@ -157,8 +157,9 @@ describe('AppIconPicker', () => {
it('should render emoji and image tabs when upload is enabled', async () => { it('should render emoji and image tabs when upload is enabled', async () => {
renderPicker() renderPicker()
expect(await screen.findByText(/emoji/i))!.toBeInTheDocument() expect(screen.getByRole('dialog', { name: /emoji/i })).toBeInTheDocument()
expect(screen.getByText(/image/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(/cancel/i))!.toBeInTheDocument()
expect(screen.getByText(/ok/i))!.toBeInTheDocument() expect(screen.getByText(/ok/i))!.toBeInTheDocument()
}) })
@ -173,12 +174,12 @@ describe('AppIconPicker', () => {
}) })
describe('User Interactions', () => { describe('User Interactions', () => {
it('should call onClose when cancel is clicked', async () => { it('should close when cancel is clicked', async () => {
const { onClose } = renderPicker() const { onOpenChange } = renderPicker()
await userEvent.click(screen.getByText(/cancel/i)) await userEvent.click(screen.getByText(/cancel/i))
expect(onClose).toHaveBeenCalledTimes(1) expect(onOpenChange).toHaveBeenCalledWith(false)
}) })
it('should switch between emoji and image tabs', async () => { it('should switch between emoji and image tabs', async () => {
@ -187,7 +188,7 @@ describe('AppIconPicker', () => {
await userEvent.click(screen.getByText(/image/i)) await userEvent.click(screen.getByText(/image/i))
expect(screen.getByText(/drop.*here/i))!.toBeInTheDocument() 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() 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 () => { it('should not call onSelect when no emoji has been selected', async () => {
const { onSelect } = renderPicker() const { onSelect } = renderPicker()

View File

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

View File

@ -1,15 +1,15 @@
import type { FC } from 'react'
import type { Area } from 'react-easy-crop' import type { Area } from 'react-easy-crop'
import type { OnImageInput } from './ImageInput' import type { OnImageInput } from './ImageInput'
import type { AppIconType, ImageFile } from '@/types/app' import type { AppIconType, ImageFile } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn' 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 { RiImageCircleAiLine } from '@remixicon/react'
import { useCallback, useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config' import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config'
import Divider from '../divider' import Divider from '../divider'
import { defaultEmojiBackground } from '../emoji-picker/constants'
import EmojiPickerInner from '../emoji-picker/Inner' import EmojiPickerInner from '../emoji-picker/Inner'
import { useLocalFileUploader } from '../image-uploader/hooks' import { useLocalFileUploader } from '../image-uploader/hooks'
import ImageInput from './ImageInput' import ImageInput from './ImageInput'
@ -31,8 +31,10 @@ export type AppIconImageSelection = {
export type AppIconSelection = AppIconEmojiSelection | AppIconImageSelection export type AppIconSelection = AppIconEmojiSelection | AppIconImageSelection
type AppIconPickerProps = { type AppIconPickerProps = {
open: boolean
onOpenChange: (open: boolean) => void
onSelect?: (payload: AppIconSelection) => void onSelect?: (payload: AppIconSelection) => void
onClose?: () => void enableImageUpload?: boolean
initialEmoji?: { initialEmoji?: {
icon: string icon: string
background?: string | null background?: string | null
@ -40,11 +42,50 @@ type AppIconPickerProps = {
className?: string className?: string
} }
const AppIconPicker: FC<AppIconPickerProps> = ({ function AppIconPicker({
open,
onOpenChange,
onSelect, onSelect,
onClose, enableImageUpload = true,
initialEmoji, 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 { t } = useTranslation()
const tabs = [ const tabs = [
@ -52,11 +93,17 @@ const AppIconPicker: FC<AppIconPickerProps> = ({
{ key: 'image', label: t('iconPicker.image', { ns: 'app' }), icon: <RiImageCircleAiLine className="size-4" /> }, { key: 'image', label: t('iconPicker.image', { ns: 'app' }), icon: <RiImageCircleAiLine className="size-4" /> },
] ]
const [activeTab, setActiveTab] = useState<AppIconType>('emoji') const [activeTab, setActiveTab] = useState<AppIconType>('emoji')
const showImageUpload = enableImageUpload && !DISABLE_UPLOAD_IMAGE_AS_ICON
const [emoji, setEmoji] = useState<{ emoji: string, background: string }>() const [emoji, setEmoji] = useState<{ emoji: string, background: string } | undefined>(() => {
const handleSelectEmoji = useCallback((emoji: string, background: string) => { if (!initialEmoji?.icon)
setEmoji({ emoji, background }) return undefined
}, [setEmoji])
return {
emoji: initialEmoji.icon,
background: initialEmoji.background ?? defaultEmojiBackground,
}
})
const [uploading, setUploading] = useState<boolean>() const [uploading, setUploading] = useState<boolean>()
@ -71,6 +118,7 @@ const AppIconPicker: FC<AppIconPickerProps> = ({
fileId: imageFile.fileId, fileId: imageFile.fileId,
url: imageFile.url, url: imageFile.url,
}) })
onOpenChange(false)
} }
}, },
}) })
@ -94,6 +142,7 @@ const AppIconPicker: FC<AppIconPickerProps> = ({
icon: emoji.emoji, icon: emoji.emoji,
background: emoji.background, background: emoji.background,
}) })
onOpenChange(false)
} }
} }
else { else {
@ -111,54 +160,55 @@ const AppIconPicker: FC<AppIconPickerProps> = ({
} }
return ( 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!', className)}>
<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!')}> <DialogTitle className="sr-only">
{t('iconPicker.emoji', { ns: 'app' })}
</DialogTitle>
{!DISABLE_UPLOAD_IMAGE_AS_ICON && ( {showImageUpload && (
<div className="w-full p-2 pb-0"> <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"> <div className="flex items-center justify-center gap-2 rounded-xl bg-background-body p-1 text-text-primary">
{tabs.map(tab => ( {tabs.map(tab => (
<button <button
type="button" type="button"
key={tab.key} key={tab.key}
className={cn( className={cn(
'flex h-8 flex-1 shrink-0 items-center justify-center rounded-lg p-2 system-sm-medium text-text-tertiary', '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', activeTab === tab.key && 'bg-components-main-nav-nav-button-bg-active text-text-accent shadow-md',
)} )}
onClick={() => setActiveTab(tab.key as AppIconType)} onClick={() => setActiveTab(tab.key as AppIconType)}
> >
{tab.icon} {tab.icon}
{' '} {' '}
&nbsp; &nbsp;
{tab.label} {tab.label}
</button> </button>
))} ))}
</div>
</div> </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> </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' 'use client'
import type { EmojiMartData } from '@emoji-mart/data' 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 data from '@emoji-mart/data'
import { import {
MagnifyingGlassIcon, MagnifyingGlassIcon,
@ -12,31 +12,10 @@ import { useState } from 'react'
import Divider from '@/app/components/base/divider' import Divider from '@/app/components/base/divider'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
import { searchEmoji } from '@/utils/emoji' import { searchEmoji } from '@/utils/emoji'
import { backgroundColors, defaultEmojiBackground } from './constants'
init({ data }) init({ data })
const backgroundColors = [
'#FFEAD5',
'#E4FBCC',
'#D3F8DF',
'#E0F2FE',
'#E0EAFF',
'#EFF1F5',
'#FBE8FF',
'#FCE7F6',
'#FEF7C3',
'#E6F4D7',
'#D5F5F6',
'#D1E9FF',
'#D1E0FF',
'#D5D9EB',
'#ECE9FE',
'#FFE4E8',
]
type IEmojiPickerInnerProps = { type IEmojiPickerInnerProps = {
emoji?: string emoji?: string
background?: string background?: string
@ -44,28 +23,32 @@ type IEmojiPickerInnerProps = {
className?: string className?: string
} }
const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({ function EmojiPickerInner({
emoji, emoji,
background, background,
onSelect, onSelect,
className, className,
}) => { }: IEmojiPickerInnerProps) {
const { categories } = data as EmojiMartData const { categories } = data as EmojiMartData
const [selectedEmoji, setSelectedEmoji] = useState(emoji || '') const [selectedEmoji, setSelectedEmoji] = useState(emoji || '')
const [selectedBackground, setSelectedBackground] = useState(background || backgroundColors[0]) const [selectedBackground, setSelectedBackground] = useState(background || defaultEmojiBackground)
const [showStyleColors, setShowStyleColors] = useState(!!emoji) const [showStyleColors, setShowStyleColors] = useState(!!emoji)
const [searchedEmojis, setSearchedEmojis] = useState<string[]>([]) const [searchedEmojis, setSearchedEmojis] = useState<string[]>([])
const [isSearching, setIsSearching] = useState(false) const [isSearching, setIsSearching] = useState(false)
const styleColorsLabelId = React.useId() const styleColorsLabelId = React.useId()
React.useEffect(() => { const handleEmojiSelect = (emoji: string) => {
if (selectedEmoji) { setSelectedEmoji(emoji)
/* v8 ignore next 2 - @preserve */ setShowStyleColors(true)
if (selectedBackground) onSelect?.(emoji, selectedBackground)
onSelect?.(selectedEmoji, selectedBackground) }
}
}, [onSelect, selectedEmoji, selectedBackground]) const handleBackgroundSelect = (background: string) => {
setSelectedBackground(background)
if (selectedEmoji)
onSelect?.(selectedEmoji, background)
}
return ( return (
<div className={cn(className, 'flex flex-col')}> <div className={cn(className, 'flex flex-col')}>
@ -108,8 +91,7 @@ const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({
aria-label={emoji} aria-label={emoji}
className="inline-flex size-10 items-center justify-center rounded-lg border-none bg-transparent p-0" className="inline-flex size-10 items-center justify-center rounded-lg border-none bg-transparent p-0"
onClick={() => { onClick={() => {
setSelectedEmoji(emoji) handleEmojiSelect(emoji)
setShowStyleColors(true)
}} }}
> >
<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"> <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} aria-label={emoji}
className="inline-flex size-10 items-center justify-center rounded-lg border-none bg-transparent p-0" className="inline-flex size-10 items-center justify-center rounded-lg border-none bg-transparent p-0"
onClick={() => { onClick={() => {
setSelectedEmoji(emoji) handleEmojiSelect(emoji)
setShowStyleColors(true)
}} }}
> >
<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"> <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={() => { onClick={() => {
setSelectedBackground(color) handleBackgroundSelect(color)
}} }}
> >
<span <span

View File

@ -46,13 +46,11 @@ describe('EmojiPickerInner', () => {
expect(screen.getByPlaceholderText('Search emojis...'))!.toBeInTheDocument() 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} />) render(<EmojiPickerInner emoji="rabbit" background="#E4FBCC" onSelect={mockOnSelect} />)
expect(screen.getByText('Choose Style'))!.toBeInTheDocument() expect(screen.getByText('Choose Style'))!.toBeInTheDocument()
await waitFor(() => { expect(mockOnSelect).not.toHaveBeenCalled()
expect(mockOnSelect).toHaveBeenCalledWith('rabbit', '#E4FBCC')
})
}) })
}) })

View File

@ -26,26 +26,27 @@ vi.mock('@/utils/emoji', () => ({
describe('EmojiPicker', () => { describe('EmojiPicker', () => {
const mockOnSelect = vi.fn() const mockOnSelect = vi.fn()
const mockOnClose = vi.fn() const mockOnOpenChange = vi.fn()
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
}) })
describe('Rendering', () => { describe('Rendering', () => {
it('renders nothing when isModal is false', () => { it('renders nothing when closed', () => {
const { container } = render( const { container } = render(
<EmojiPicker isModal={false} />, <EmojiPicker open={false} onOpenChange={mockOnOpenChange} />,
) )
expect(container.firstChild).toBeNull() expect(container.firstChild).toBeNull()
}) })
it('renders modal when isModal is true', async () => { it('renders modal when open', async () => {
await act(async () => { await act(async () => {
render( render(
<EmojiPicker isModal={true} />, <EmojiPicker open onOpenChange={mockOnOpenChange} />,
) )
}) })
expect(screen.getByRole('dialog', { name: /Emoji/i }))!.toBeInTheDocument()
expect(screen.getByPlaceholderText('Search emojis...'))!.toBeInTheDocument() expect(screen.getByPlaceholderText('Search emojis...'))!.toBeInTheDocument()
expect(screen.getByText(/Cancel/i))!.toBeInTheDocument() expect(screen.getByText(/Cancel/i))!.toBeInTheDocument()
expect(screen.getByText(/OK/i))!.toBeInTheDocument() expect(screen.getByText(/OK/i))!.toBeInTheDocument()
@ -54,7 +55,7 @@ describe('EmojiPicker', () => {
it('OK button is disabled initially', async () => { it('OK button is disabled initially', async () => {
await act(async () => { await act(async () => {
render( render(
<EmojiPicker />, <EmojiPicker open onOpenChange={mockOnOpenChange} />,
) )
}) })
const okButton = screen.getByText(/OK/i).closest('button') const okButton = screen.getByText(/OK/i).closest('button')
@ -65,7 +66,7 @@ describe('EmojiPicker', () => {
const customClass = 'custom-wrapper-class' const customClass = 'custom-wrapper-class'
await act(async () => { await act(async () => {
render( render(
<EmojiPicker className={customClass} />, <EmojiPicker open onOpenChange={mockOnOpenChange} className={customClass} />,
) )
}) })
const dialog = screen.getByRole('dialog') const dialog = screen.getByRole('dialog')
@ -77,7 +78,7 @@ describe('EmojiPicker', () => {
it('calls onSelect with selected emoji and background when OK is clicked', async () => { it('calls onSelect with selected emoji and background when OK is clicked', async () => {
await act(async () => { await act(async () => {
render( 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)) 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 () => { await act(async () => {
render( render(
<EmojiPicker onClose={mockOnClose} />, <EmojiPicker open onOpenChange={mockOnOpenChange} />,
) )
}) })
@ -107,7 +108,7 @@ describe('EmojiPicker', () => {
fireEvent.click(cancelButton) 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> </pre>
</div> </div>
{open && ( <EmojiPicker
<EmojiPicker open={open}
onSelect={(emoji, background) => { onOpenChange={setOpen}
setSelection({ emoji, background }) onSelect={(emoji, background) => setSelection({ emoji, background })}
setOpen(false) />
}}
onClose={() => setOpen(false)}
/>
)}
</div> </div>
) )
} }
export const Playground: Story = { export const Playground: Story = {
args: {
open: false,
onOpenChange: () => {},
},
render: () => <EmojiPickerDemo />, render: () => <EmojiPickerDemo />,
parameters: { parameters: {
docs: { docs: {
@ -73,15 +73,11 @@ const [selection, setSelection] = useState<{ emoji: string; background: string }
return ( return (
<> <>
<button onClick={() => setOpen(true)}>Open emoji picker</button> <button onClick={() => setOpen(true)}>Open emoji picker</button>
{open && ( <EmojiPicker
<EmojiPicker open={open}
onSelect={(emoji, background) => { onOpenChange={setOpen}
setSelection({ emoji, background }) onSelect={(emoji, background) => setSelection({ emoji, background })}
setOpen(false) />
}}
onClose={() => setOpen(false)}
/>
)}
</> </>
) )
`.trim(), `.trim(),

View File

@ -1,75 +1,95 @@
'use client' 'use client'
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn' 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 * as React from 'react' import { useState } from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider' import Divider from '@/app/components/base/divider'
import EmojiPickerInner from './Inner' import EmojiPickerInner from './Inner'
type IEmojiPickerProps = { type EmojiPickerProps = {
isModal?: boolean open: boolean
onOpenChange: (open: boolean) => void
onSelect?: (emoji: string, background: string) => void onSelect?: (emoji: string, background: string) => void
onClose?: () => void
className?: string className?: string
} }
const EmojiPicker: FC<IEmojiPickerProps> = ({ function EmojiPicker({
isModal = true, open,
onOpenChange,
onSelect, onSelect,
onClose,
className, 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 { t } = useTranslation()
const [selectedEmoji, setSelectedEmoji] = useState('') const [selectedEmoji, setSelectedEmoji] = useState('')
const [selectedBackground, setSelectedBackground] = useState<string>() const [selectedBackground, setSelectedBackground] = useState<string>()
const handleSelectEmoji = useCallback((emoji: string, background: string) => { return (
setSelectedEmoji(emoji) <DialogContent
setSelectedBackground(background) className={cn(
}, [setSelectedEmoji, setSelectedBackground]) '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 <EmojiPickerInner
? ( className="pt-3"
<Dialog open> onSelect={(emoji, background) => {
<DialogContent setSelectedEmoji(emoji)
className={cn( setSelectedBackground(background)
'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, <Divider className="mt-3 mb-0" />
)} <div className="flex w-full items-center justify-center gap-2 p-3">
> <Button
className="w-full"
<EmojiPickerInner onClick={() => onOpenChange(false)}
className="pt-3" >
onSelect={handleSelectEmoji} {t('iconPicker.cancel', { ns: 'app' })}
/> </Button>
<Divider className="mt-3 mb-0" /> <Button
<div className="flex w-full items-center justify-center gap-2 p-3"> disabled={selectedEmoji === '' || !selectedBackground}
<Button variant="primary"
className="w-full" className="w-full"
onClick={() => { onClick={() => {
onClose?.() onSelect?.(selectedEmoji, selectedBackground!)
}} onOpenChange(false)
> }}
{t('iconPicker.cancel', { ns: 'app' })} >
</Button> {t('iconPicker.ok', { ns: 'app' })}
<Button </Button>
disabled={selectedEmoji === '' || !selectedBackground} </div>
variant="primary" </DialogContent>
className="w-full" )
onClick={() => {
onSelect?.(selectedEmoji, selectedBackground!)
}}
>
{t('iconPicker.ok', { ns: 'app' })}
</Button>
</div>
</DialogContent>
</Dialog>
)
: <></>
} }
export default EmojiPicker export default EmojiPicker

View File

@ -81,7 +81,7 @@ export const useImageFiles = () => {
filesRef.current = newFiles filesRef.current = newFiles
setFiles(newFiles) setFiles(newFiles)
}, },
}, !!params.token) }, !!params?.token)
} }
} }
const handleClear = () => { const handleClear = () => {
@ -145,13 +145,13 @@ export const useLocalFileUploader = ({ limit, disabled = false, onUpload }: useL
toast.error(errorMessage) toast.error(errorMessage)
onUpload({ ...imageFile, progress: -1 }) onUpload({ ...imageFile, progress: -1 })
}, },
}, !!params.token) }, !!params?.token)
}, false) }, false)
reader.addEventListener('error', () => { reader.addEventListener('error', () => {
toast.error(t('imageUploader.uploadFromComputerReadError', { ns: 'common' })) toast.error(t('imageUploader.uploadFromComputerReadError', { ns: 'common' }))
}, false) }, false)
reader.readAsDataURL(file) reader.readAsDataURL(file)
}, [disabled, limit, t, onUpload, params.token]) }, [disabled, limit, t, onUpload, params?.token])
return { disabled, handleLocalFileUpload } return { disabled, handleLocalFileUpload }
} }
type useClipboardUploaderProps = { 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 => ({ const createPipelineTemplate = (overrides: Partial<PipelineTemplate> = {}): PipelineTemplate => ({
id: 'pipeline-1', id: 'pipeline-1',
name: 'Test Pipeline', name: 'Test Pipeline',
@ -96,8 +69,6 @@ describe('EditPipelineInfo', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockToastError.mockReset() mockToastError.mockReset()
_mockOnSelect = undefined
_mockOnClose = undefined
}) })
describe('Rendering', () => { describe('Rendering', () => {
@ -303,7 +274,7 @@ describe('EditPipelineInfo', () => {
// Open icon picker // Open icon picker
const appIcon = container.querySelector('[class*="cursor-pointer"]') const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!) 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 () => { 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 = { const props = {
...defaultProps, ...defaultProps,
pipeline: createImagePipelineTemplate(), pipeline: createImagePipelineTemplate(),
@ -368,13 +339,14 @@ describe('EditPipelineInfo', () => {
// Open picker // Open picker
const appIcon = container.querySelector('[class*="cursor-pointer"]') const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!) 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 // Close without selection - should revert to original image icon
const closeButton = screen.getByTestId('close-picker') fireEvent.click(screen.getByRole('button', { name: /iconPicker\.cancel/ }))
fireEvent.click(closeButton)
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 () => { it('should switch from image icon to emoji icon when selected', async () => {
@ -392,8 +364,11 @@ describe('EditPipelineInfo', () => {
// Open picker and select emoji // Open picker and select emoji
const appIcon = container.querySelector('[class*="cursor-pointer"]') const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!) fireEvent.click(appIcon!)
const selectEmojiButton = screen.getByTestId('select-emoji') const emojiButton = document.querySelector('em-emoji')?.closest('button')
fireEvent.click(selectEmojiButton) 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) const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton) fireEvent.click(saveButton)
@ -403,7 +378,8 @@ describe('EditPipelineInfo', () => {
expect.objectContaining({ expect.objectContaining({
icon_info: expect.objectContaining({ icon_info: expect.objectContaining({
icon_type: 'emoji', icon_type: 'emoji',
icon: '🎯', icon: expect.any(String),
icon_background: '#E4FBCC',
}), }),
}), }),
expect.any(Object), expect.any(Object),
@ -411,34 +387,14 @@ describe('EditPipelineInfo', () => {
}) })
}) })
it('should switch from emoji icon to image icon when selected', async () => { it('should switch to the image tab in the real picker', () => {
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
callbacks.onSuccess()
return Promise.resolve()
})
const { container } = render(<EditPipelineInfo {...defaultProps} />) const { container } = render(<EditPipelineInfo {...defaultProps} />)
// Open picker and select image
const appIcon = container.querySelector('[class*="cursor-pointer"]') const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!) fireEvent.click(appIcon!)
const selectImageButton = screen.getByTestId('select-image') fireEvent.click(screen.getByRole('button', { name: /iconPicker\.image/ }))
fireEvent.click(selectImageButton)
const saveButton = screen.getByText(/operation\.save/i) expect(screen.getByRole('button', { name: /iconPicker\.ok/ })).toBeInTheDocument()
fireEvent.click(saveButton)
await waitFor(() => {
expect(mockUpdatePipeline).toHaveBeenCalledWith(
expect.objectContaining({
icon_info: expect.objectContaining({
icon_type: 'image',
icon: 'new-file-id',
}),
}),
expect.any(Object),
)
})
}) })
}) })
@ -446,7 +402,7 @@ describe('EditPipelineInfo', () => {
describe('AppIconPicker', () => { describe('AppIconPicker', () => {
it('should not show picker initially', () => { it('should not show picker initially', () => {
render(<EditPipelineInfo {...defaultProps} />) 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', () => { it('should open picker when icon is clicked', () => {
@ -454,43 +410,42 @@ describe('EditPipelineInfo', () => {
const appIcon = container.querySelector('[class*="cursor-pointer"]') const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!) 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 { container } = render(<EditPipelineInfo {...defaultProps} />)
const appIcon = container.querySelector('[class*="cursor-pointer"]') const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!) fireEvent.click(appIcon!)
const selectEmojiButton = screen.getByTestId('select-emoji') fireEvent.click(screen.getByRole('button', { name: '#E4FBCC' }))
fireEvent.click(selectEmojiButton) fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
// Picker should close await waitFor(() => {
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument() 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 { container } = render(<EditPipelineInfo {...defaultProps} />)
const appIcon = container.querySelector('[class*="cursor-pointer"]') const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!) fireEvent.click(appIcon!)
const selectImageButton = screen.getByTestId('select-image') fireEvent.click(screen.getByRole('button', { name: /iconPicker\.image/ }))
fireEvent.click(selectImageButton)
// Picker should close expect(screen.getByRole('button', { name: /iconPicker\.ok/ })).toBeInTheDocument()
expect(screen.queryByTestId('app-icon-picker')).not.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 { container } = render(<EditPipelineInfo {...defaultProps} />)
const appIcon = container.querySelector('[class*="cursor-pointer"]') const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!) fireEvent.click(appIcon!)
const closeButton = screen.getByTestId('close-picker') fireEvent.click(screen.getByRole('button', { name: /iconPicker\.cancel/ }))
fireEvent.click(closeButton)
// Picker should close await waitFor(() => {
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument() expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
})
}) })
it('should save with new emoji icon selection', async () => { it('should save with new emoji icon selection', async () => {
@ -504,8 +459,8 @@ describe('EditPipelineInfo', () => {
// Open picker and select new emoji // Open picker and select new emoji
const appIcon = container.querySelector('[class*="cursor-pointer"]') const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!) fireEvent.click(appIcon!)
const selectEmojiButton = screen.getByTestId('select-emoji') fireEvent.click(screen.getByRole('button', { name: '#E4FBCC' }))
fireEvent.click(selectEmojiButton) fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
const saveButton = screen.getByText(/operation\.save/i) const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton) fireEvent.click(saveButton)
@ -515,8 +470,8 @@ describe('EditPipelineInfo', () => {
expect.objectContaining({ expect.objectContaining({
icon_info: expect.objectContaining({ icon_info: expect.objectContaining({
icon_type: 'emoji', icon_type: 'emoji',
icon: '🎯', icon: '📊',
icon_background: '#FFEAD5', icon_background: '#E4FBCC',
}), }),
}), }),
expect.any(Object), 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) => { mockUpdatePipeline.mockImplementation((_data, callbacks) => {
callbacks.onSuccess() callbacks.onSuccess()
return Promise.resolve() 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"]') const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!) fireEvent.click(appIcon!)
const selectImageButton = screen.getByTestId('select-image') const emojiButton = document.querySelector('em-emoji')?.closest('button')
fireEvent.click(selectImageButton) 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) const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton) fireEvent.click(saveButton)
@ -545,9 +502,9 @@ describe('EditPipelineInfo', () => {
expect(mockUpdatePipeline).toHaveBeenCalledWith( expect(mockUpdatePipeline).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
icon_info: expect.objectContaining({ icon_info: expect.objectContaining({
icon_type: 'image', icon_type: 'emoji',
icon: 'new-file-id', icon: expect.any(String),
icon_url: 'https://new-icon.com/icon.png', icon_background: '#E4FBCC',
}), }),
}), }),
expect.any(Object), expect.any(Object),

View File

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

View File

@ -1,5 +1,6 @@
import type { DataSet } from '@/models/datasets' import type { DataSet } from '@/models/datasets'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' 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 { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import RenameDatasetModal from '../index' 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' // The mock returns 'ns.key' format, e.g., 'common.operation.cancel'
describe('RenameDatasetModal', () => { 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 // 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') const appIcon = screen.getByTestId('app-icon')
await act(async () => { await act(async () => {
fireEvent.click(appIcon) fireEvent.click(appIcon)
}) })
// Picker should now be visible await waitFor(() => {
// Picker should now be visible expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
expect(screen.getByTestId('app-icon-picker'))!.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} />) render(<RenameDatasetModal {...defaultProps} />)
// Open picker await user.click(screen.getByTestId('app-icon'))
const appIcon = screen.getByTestId('app-icon') await waitFor(() => {
await act(async () => { expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
fireEvent.click(appIcon)
}) })
await user.click(screen.getByRole('button', { name: '#E4FBCC' }))
// Select emoji await user.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
const selectEmojiBtn = screen.getByTestId('select-emoji') await waitFor(() => {
await act(async () => { expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
fireEvent.click(selectEmojiBtn)
}) })
// 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 // Save and verify new icon is used
const saveButton = screen.getByText('common.operation.save') const saveButton = screen.getByText('common.operation.save')
await act(async () => { await act(async () => {
@ -931,9 +879,9 @@ describe('RenameDatasetModal', () => {
datasetId: 'dataset-1', datasetId: 'dataset-1',
body: expect.objectContaining({ body: expect.objectContaining({
icon_info: { icon_info: {
icon: '🚀', icon: '📊',
icon_type: 'emoji', icon_type: 'emoji',
icon_background: '#E0F2FE', icon_background: '#E4FBCC',
icon_url: undefined, 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} />) render(<RenameDatasetModal {...defaultProps} />)
// Open picker await user.click(screen.getByTestId('app-icon'))
const appIcon = screen.getByTestId('app-icon') await waitFor(() => {
await act(async () => { expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
fireEvent.click(appIcon) })
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') const saveButton = screen.getByText('common.operation.save')
await act(async () => { await act(async () => {
fireEvent.click(saveButton) fireEvent.click(saveButton)
@ -1001,10 +913,10 @@ describe('RenameDatasetModal', () => {
datasetId: 'dataset-1', datasetId: 'dataset-1',
body: expect.objectContaining({ body: expect.objectContaining({
icon_info: { icon_info: {
icon: 'new-file', icon: '📊',
icon_type: 'image', icon_type: 'emoji',
icon_background: undefined, icon_background: '#E0F2FE',
icon_url: 'https://new.png', icon_url: undefined,
}, },
}), }),
}) })
@ -1020,45 +932,14 @@ describe('RenameDatasetModal', () => {
fireEvent.click(appIcon) fireEvent.click(appIcon)
}) })
// Close picker without selecting const user = userEvent.setup()
const closeBtn = screen.getByTestId('close-picker') await waitFor(() => {
await act(async () => { expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
fireEvent.click(closeBtn) })
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 // Save and verify original icon is preserved
const saveButton = screen.getByText('common.operation.save') 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 { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast' import { toast } from '@langgenius/dify-ui/toast'
import { RiCloseLine } from '@remixicon/react' import { RiCloseLine } from '@remixicon/react'
import { useCallback, useRef, useState } from 'react' import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea' 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: '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 || '' }) : { type: 'emoji' as const, icon: dataset.icon_info?.icon || '', background: dataset.icon_info?.icon_background || '' })
const [showAppIconPicker, setShowAppIconPicker] = useState(false) 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(() => { const handleOpenAppIconPicker = useCallback(() => {
setShowAppIconPicker(true) setShowAppIconPicker(true)
previousAppIcon.current = appIcon }, [])
}, [appIcon])
const handleSelectAppIcon = useCallback((icon: AppIconSelection) => { const handleSelectAppIcon = useCallback((icon: AppIconSelection) => {
setAppIcon(icon) setAppIcon(icon)
setShowAppIconPicker(false)
}, [])
const handleCloseAppIconPicker = useCallback(() => {
setAppIcon(previousAppIcon.current)
setShowAppIconPicker(false)
}, []) }, [])
const onConfirm: MouseEventHandler = useCallback(async () => { const onConfirm: MouseEventHandler = useCallback(async () => {
if (!name.trim()) { 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 className="mr-2" onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button disabled={loading} variant="primary" onClick={onConfirm}>{t('operation.save', { ns: 'common' })}</Button> <Button disabled={loading} variant="primary" onClick={onConfirm}>{t('operation.save', { ns: 'common' })}</Button>
</div> </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> </DialogContent>
</Dialog> </Dialog>
) )

View File

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

View File

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

View File

@ -258,7 +258,7 @@ describe('useFormState', () => {
expect(result.current.showAppIconPicker).toBe(true) 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()) const { result } = renderHook(() => useFormState())
act(() => { act(() => {
@ -273,7 +273,7 @@ describe('useFormState', () => {
}) })
}) })
expect(result.current.showAppIconPicker).toBe(false) expect(result.current.showAppIconPicker).toBe(true)
expect(result.current.iconInfo).toEqual({ expect(result.current.iconInfo).toEqual({
icon_type: 'emoji', icon_type: 'emoji',
icon: '🎉', 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()) const { result } = renderHook(() => useFormState())
act(() => { act(() => {
@ -297,7 +297,7 @@ describe('useFormState', () => {
}) })
}) })
expect(result.current.showAppIconPicker).toBe(false) expect(result.current.showAppIconPicker).toBe(true)
expect(result.current.iconInfo).toEqual({ expect(result.current.iconInfo).toEqual({
icon_type: 'image', icon_type: 'image',
icon: 'file-123', 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()) const { result } = renderHook(() => useFormState())
act(() => { act(() => {
@ -322,15 +322,10 @@ describe('useFormState', () => {
}) })
act(() => { act(() => {
result.current.handleOpenAppIconPicker() result.current.setShowAppIconPicker(false)
})
act(() => {
result.current.handleCloseAppIconPicker()
}) })
expect(result.current.showAppIconPicker).toBe(false) expect(result.current.showAppIconPicker).toBe(false)
// After close, icon should be restored to the icon before opening
expect(result.current.iconInfo).toEqual({ expect(result.current.iconInfo).toEqual({
icon_type: 'emoji', icon_type: 'emoji',
icon: '🎉', icon: '🎉',

View File

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

View File

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

View File

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

View File

@ -1,6 +1,5 @@
import type { EnvironmentVariable } from '@/app/components/workflow/types' import type { EnvironmentVariable } from '@/app/components/workflow/types'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useState } from 'react'
import { createMockProviderContextValue } from '@/__mocks__/provider-context' import { createMockProviderContextValue } from '@/__mocks__/provider-context'
import Conversion from '../conversion' 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 // Silence expected console.error from Dialog/Modal rendering
beforeEach(() => { beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {}) vi.spyOn(console, 'error').mockImplementation(() => {})
@ -767,7 +714,7 @@ describe('PublishAsKnowledgePipelineModal', () => {
const appIcon = getAppIcon() const appIcon = getAppIcon()
fireEvent.click(appIcon) fireEvent.click(appIcon)
fireEvent.click(screen.getByTestId('picker-emoji-option')) fireEvent.click(screen.getByRole('button', { name: '#E4FBCC' }))
// Click OK to confirm selection // Click OK to confirm selection
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ })) fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
@ -1087,7 +1034,7 @@ describe('Integration Tests', () => {
// Open picker and select an emoji // Open picker and select an emoji
const appIcon = getAppIcon() const appIcon = getAppIcon()
fireEvent.click(appIcon) 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: /iconPicker\.ok/ }))
fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.publish/i })) 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import PublishAsKnowledgePipelineModal from '../publish-as-knowledge-pipeline-modal' 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 }) => ( DialogContent: ({ children, className }: { children: React.ReactNode, className?: string }) => (
<div data-testid="modal" className={className}>{children}</div> <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', () => ({ 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', () => ({ vi.mock('es-toolkit/function', () => ({
noop: () => {}, noop: () => {},
})) }))
@ -190,41 +177,46 @@ describe('PublishAsKnowledgePipelineModal', () => {
expect(mockOnConfirm).not.toHaveBeenCalled() 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} />) render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
expect(screen.queryByTestId('icon-picker')).not.toBeInTheDocument() expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('app-icon')) 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} />) render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('app-icon')) 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} />) render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('app-icon')) 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} />) render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('app-icon')) 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', () => { it('should trim name and description before submitting', () => {

View File

@ -51,17 +51,7 @@ const PublishAsKnowledgePipelineModal = ({
icon_url: '', 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 = () => { const handleConfirm = () => {
if (confirmDisabled) if (confirmDisabled)
@ -141,8 +131,12 @@ const PublishAsKnowledgePipelineModal = ({
</div> </div>
{showAppIconPicker && ( {showAppIconPicker && (
<AppIconPicker <AppIconPicker
open={showAppIconPicker}
initialEmoji={pipelineIcon.icon_type === 'emoji'
? { icon: pipelineIcon.icon, background: pipelineIcon.icon_background }
: undefined}
onOpenChange={setShowAppIconPicker}
onSelect={handleSelectIcon} onSelect={handleSelectIcon}
onClose={handleCloseIconPicker}
/> />
)} )}
</DialogContent> </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', () => { describe('EditCustomCollectionModal', () => {
const mockOnHide = vi.fn() const mockOnHide = vi.fn()
const mockOnAdd = vi.fn() const mockOnAdd = vi.fn()

View File

@ -386,12 +386,10 @@ const EditCustomCollectionModal: FC<Props> = ({
</div> </div>
{showEmojiPicker && ( {showEmojiPicker && (
<EmojiPicker <EmojiPicker
open={showEmojiPicker}
onOpenChange={setShowEmojiPicker}
onSelect={(icon, icon_background) => { onSelect={(icon, icon_background) => {
setEmoji({ content: icon, background: 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' }), 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 // Mock the plugins service to avoid React Query issues from TabSlider
vi.mock('@/service/use-plugins', () => ({ vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: () => ({ useInstalledPluginList: () => ({
@ -695,9 +670,8 @@ describe('MCPModal', () => {
if (appIconContainer) { if (appIconContainer) {
fireEvent.click(appIconContainer) fireEvent.click(appIconContainer)
// The mocked AppIconPicker should now be visible
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('app-icon-picker'))!.toBeInTheDocument() expect(screen.getByPlaceholderText('Search emojis...'))!.toBeInTheDocument()
}) })
} }
}) })
@ -712,16 +686,14 @@ describe('MCPModal', () => {
fireEvent.click(appIconContainer) fireEvent.click(appIconContainer)
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('app-icon-picker'))!.toBeInTheDocument() expect(screen.getByPlaceholderText('Search emojis...'))!.toBeInTheDocument()
}) })
// Click the select emoji button fireEvent.click(screen.getByRole('button', { name: '#E4FBCC' }))
const selectBtn = screen.getByTestId('select-emoji-btn') fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
fireEvent.click(selectBtn)
// The picker should be closed
await waitFor(() => { 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) fireEvent.click(appIconContainer)
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('app-icon-picker'))!.toBeInTheDocument() expect(screen.getByPlaceholderText('Search emojis...'))!.toBeInTheDocument()
}) })
// Click the close button fireEvent.click(screen.getByRole('button', { name: /iconPicker\.cancel/ }))
const closeBtn = screen.getByTestId('close-picker-btn')
fireEvent.click(closeBtn)
// The picker should be closed
await waitFor(() => { 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) => { const handleIconSelect = (payload: AppIconSelection) => {
actions.setAppIcon(payload) actions.setAppIcon(payload)
actions.setShowAppIconPicker(false)
}
const handleIconClose = () => {
actions.resetIcon()
actions.setShowAppIconPicker(false)
} }
const isSubmitDisabled = !state.name || !state.url || !state.serverIdentifier || state.isFetchingIcon const isSubmitDisabled = !state.name || !state.url || !state.serverIdentifier || state.isFetchingIcon
@ -260,8 +254,12 @@ const MCPModalContent: FC<MCPModalContentProps> = ({
{state.showAppIconPicker && ( {state.showAppIconPicker && (
<AppIconPicker <AppIconPicker
open={state.showAppIconPicker}
initialEmoji={state.appIcon.type === 'emoji'
? { icon: state.appIcon.icon, background: state.appIcon.background }
: undefined}
onOpenChange={actions.setShowAppIconPicker}
onSelect={handleIconSelect} onSelect={handleIconSelect}
onClose={handleIconClose}
/> />
)} )}
</> </>

View File

@ -19,6 +19,7 @@ vi.mock('@/next/navigation', () => ({
}), }),
usePathname: () => '/app/workflow-app-id', usePathname: () => '/app/workflow-app-id',
useSearchParams: () => new URLSearchParams(), useSearchParams: () => new URLSearchParams(),
useParams: () => ({}),
})) }))
// Mock app context // 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 // Mock AppIcon - simplified for testing
vi.mock('@/app/components/base/app-icon', () => ({ vi.mock('@/app/components/base/app-icon', () => ({
default: ({ onClick, icon, background }: { onClick?: () => void, icon: string, background: string }) => ( default: ({ onClick, icon, background }: { onClick?: () => void, icon: string, background: string }) => (
@ -814,8 +806,9 @@ describe('WorkflowToolDrawer', () => {
await user.click(iconButton) await user.click(iconButton)
// Assert // Assert
// Assert await waitFor(() => {
expect(screen.getByTestId('emoji-picker'))!.toBeInTheDocument() expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
})
}) })
it('should update emoji on selection', async () => { it('should update emoji on selection', async () => {
@ -834,14 +827,19 @@ describe('WorkflowToolDrawer', () => {
const iconButton = screen.getByTestId('app-icon') const iconButton = screen.getByTestId('app-icon')
await user.click(iconButton) await user.click(iconButton)
// Select emoji await waitFor(() => {
await user.click(screen.getByTestId('select-emoji')) expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'app.iconPicker.ok' })) })
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 // Assert
const updatedIcon = screen.getByTestId('app-icon') const updatedIcon = screen.getByTestId('app-icon')
expect(updatedIcon)!.toHaveAttribute('data-icon', '🚀') expect(updatedIcon)!.toHaveAttribute('data-icon', '🔧')
expect(updatedIcon)!.toHaveAttribute('data-background', '#f0f0f0') expect(updatedIcon)!.toHaveAttribute('data-background', '#E4FBCC')
}) })
it('should close emoji picker on close button', async () => { it('should close emoji picker on close button', async () => {
@ -859,43 +857,15 @@ describe('WorkflowToolDrawer', () => {
const iconButton = screen.getByTestId('app-icon') const iconButton = screen.getByTestId('app-icon')
await user.click(iconButton) 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' })) expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
// 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()
}) })
it('should update labels when label selector changes', async () => { 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 { beforeEach, describe, expect, it, vi } from 'vitest'
import { WorkflowToolDrawer } from '../index' 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', () => ({ vi.mock('@/app/components/base/app-icon', () => ({
default: ({ onClick, icon }: { onClick?: () => void, icon: string }) => ( default: ({ onClick, icon }: { onClick?: () => void, icon: string }) => (
<button data-testid="app-icon" onClick={onClick}>{icon}</button> <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.type(screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder'), 'Created Tool')
await user.click(screen.getByTestId('append-label')) await user.click(screen.getByTestId('append-label'))
await user.click(screen.getByTestId('app-icon')) await user.click(screen.getByTestId('app-icon'))
await user.click(screen.getByTestId('select-emoji')) await waitFor(() => {
await user.click(screen.getByRole('button', { name: 'app.iconPicker.ok' })) 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' })) await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({
workflow_app_id: 'workflow-app-1', workflow_app_id: 'workflow-app-1',
label: 'Created Tool', label: 'Created Tool',
icon: { content: '🚀', background: '#000000' }, icon: { content: '🔧', background: '#E4FBCC' },
labels: ['label1', 'new-label'], 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 type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderOutputSchema, WorkflowToolProviderParameter, WorkflowToolProviderRequest } from '../types'
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { import {
Drawer, Drawer,
DrawerBackdrop, DrawerBackdrop,
@ -21,8 +20,7 @@ import * as React from 'react'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon' import AppIcon from '@/app/components/base/app-icon'
import Divider from '@/app/components/base/divider' import AppIconPicker from '@/app/components/base/app-icon-picker'
import EmojiPickerInner from '@/app/components/base/emoji-picker/Inner'
import { Infotip } from '@/app/components/base/infotip' import { Infotip } from '@/app/components/base/infotip'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea' 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({ export function WorkflowToolDrawer({
isAdd, isAdd,
payload, payload,
@ -449,17 +402,19 @@ export function WorkflowToolDrawer({
</div> </div>
</div> </div>
</WorkflowToolDrawerFrame> </WorkflowToolDrawerFrame>
{showEmojiPicker && ( <AppIconPicker
<WorkflowToolEmojiPicker open={showEmojiPicker}
onSelect={(icon, icon_background) => { enableImageUpload={false}
setEmoji({ content: icon, background: icon_background }) initialEmoji={{
setShowEmojiPicker(false) icon: emoji.content,
}} background: emoji.background,
onClose={() => { }}
setShowEmojiPicker(false) onOpenChange={setShowEmojiPicker}
}} onSelect={(payload) => {
/> if (payload.type === 'emoji')
)} setEmoji({ content: payload.icon, background: payload.background })
}}
/>
{confirmModalOpen && ( {confirmModalOpen && (
<ConfirmModal <ConfirmModal
show={confirmModalOpen} show={confirmModalOpen}