mirror of
https://github.com/langgenius/dify.git
synced 2026-03-11 11:40:02 +08:00
test: add unit tests for chat/embedded-chatbot components (#32361)
Co-authored-by: akashseth-ifp <akash.seth@infocusp.com>
This commit is contained in:
parent
bcd5dd0f81
commit
b8fbd7b0f6
@ -0,0 +1,400 @@
|
||||
import type { ChatConfig, ChatItem, ChatItemInTree } from '../types'
|
||||
import type { EmbeddedChatbotContextValue } from './context'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { vi } from 'vitest'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import {
|
||||
AppSourceType,
|
||||
fetchSuggestedQuestions,
|
||||
submitHumanInputForm,
|
||||
} from '@/service/share'
|
||||
import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow'
|
||||
import { useChat } from '../chat/hooks'
|
||||
import ChatWrapper from './chat-wrapper'
|
||||
import { useEmbeddedChatbotContext } from './context'
|
||||
|
||||
vi.mock('./context', () => ({
|
||||
useEmbeddedChatbotContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../chat/hooks', () => ({
|
||||
useChat: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./inputs-form', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>inputs form</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../chat', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
chatNode,
|
||||
chatList,
|
||||
inputDisabled,
|
||||
questionIcon,
|
||||
answerIcon,
|
||||
onSend,
|
||||
onRegenerate,
|
||||
switchSibling,
|
||||
onHumanInputFormSubmit,
|
||||
onStopResponding,
|
||||
}: {
|
||||
chatNode: React.ReactNode
|
||||
chatList: ChatItem[]
|
||||
inputDisabled: boolean
|
||||
questionIcon?: React.ReactNode
|
||||
answerIcon?: React.ReactNode
|
||||
onSend: (message: string) => void
|
||||
onRegenerate: (chatItem: ChatItem, editedQuestion?: { message: string, files?: never[] }) => void
|
||||
switchSibling: (siblingMessageId: string) => void
|
||||
onHumanInputFormSubmit: (formToken: string, formData: Record<string, string>) => Promise<void>
|
||||
onStopResponding: () => void
|
||||
}) => (
|
||||
<div>
|
||||
<div>{chatNode}</div>
|
||||
{answerIcon}
|
||||
{chatList.map(item => <div key={item.id}>{item.content}</div>)}
|
||||
<div>
|
||||
chat count:
|
||||
{' '}
|
||||
{chatList.length}
|
||||
</div>
|
||||
{questionIcon}
|
||||
<button onClick={() => onSend('hello world')}>send through chat</button>
|
||||
<button onClick={() => onRegenerate({ id: 'answer-1', isAnswer: true, content: 'answer', parentMessageId: 'question-1' })}>regenerate answer</button>
|
||||
<button onClick={() => switchSibling('sibling-2')}>switch sibling</button>
|
||||
<button disabled={inputDisabled}>send message</button>
|
||||
<button onClick={onStopResponding}>stop responding</button>
|
||||
<button onClick={() => onHumanInputFormSubmit('form-token', { answer: 'ok' })}>submit human input</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/share', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/share')>()
|
||||
return {
|
||||
...actual,
|
||||
fetchSuggestedQuestions: vi.fn(),
|
||||
getUrl: vi.fn(() => '/chat-messages'),
|
||||
stopChatMessageResponding: vi.fn(),
|
||||
submitHumanInputForm: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/workflow', () => ({
|
||||
submitHumanInputForm: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockIsDify = vi.fn(() => false)
|
||||
vi.mock('./utils', () => ({
|
||||
isDify: () => mockIsDify(),
|
||||
}))
|
||||
|
||||
type UseChatReturn = ReturnType<typeof useChat>
|
||||
|
||||
const createContextValue = (overrides: Partial<EmbeddedChatbotContextValue> = {}): EmbeddedChatbotContextValue => ({
|
||||
appMeta: { tool_icons: {} },
|
||||
appData: {
|
||||
app_id: 'app-1',
|
||||
can_replace_logo: true,
|
||||
custom_config: {
|
||||
remove_webapp_brand: false,
|
||||
replace_webapp_logo: '',
|
||||
},
|
||||
enable_site: true,
|
||||
end_user_id: 'user-1',
|
||||
site: {
|
||||
title: 'Embedded App',
|
||||
icon_type: 'emoji',
|
||||
icon: 'bot',
|
||||
icon_background: '#000000',
|
||||
icon_url: '',
|
||||
use_icon_as_answer_icon: false,
|
||||
},
|
||||
},
|
||||
appParams: {} as ChatConfig,
|
||||
appChatListDataLoading: false,
|
||||
currentConversationId: '',
|
||||
currentConversationItem: undefined,
|
||||
appPrevChatList: [],
|
||||
pinnedConversationList: [],
|
||||
conversationList: [],
|
||||
newConversationInputs: {},
|
||||
newConversationInputsRef: { current: {} },
|
||||
handleNewConversationInputsChange: vi.fn(),
|
||||
inputsForms: [],
|
||||
handleNewConversation: vi.fn(),
|
||||
handleStartChat: vi.fn(),
|
||||
handleChangeConversation: vi.fn(),
|
||||
handleNewConversationCompleted: vi.fn(),
|
||||
chatShouldReloadKey: 'reload-key',
|
||||
isMobile: false,
|
||||
isInstalledApp: false,
|
||||
appSourceType: AppSourceType.webApp,
|
||||
allowResetChat: true,
|
||||
appId: 'app-1',
|
||||
disableFeedback: false,
|
||||
handleFeedback: vi.fn(),
|
||||
currentChatInstanceRef: { current: { handleStop: vi.fn() } },
|
||||
themeBuilder: undefined,
|
||||
clearChatList: false,
|
||||
setClearChatList: vi.fn(),
|
||||
isResponding: false,
|
||||
setIsResponding: vi.fn(),
|
||||
currentConversationInputs: {},
|
||||
setCurrentConversationInputs: vi.fn(),
|
||||
allInputsHidden: false,
|
||||
initUserVariables: {},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createUseChatReturn = (overrides: Partial<UseChatReturn> = {}): UseChatReturn => ({
|
||||
chatList: [],
|
||||
setTargetMessageId: vi.fn() as UseChatReturn['setTargetMessageId'],
|
||||
handleSend: vi.fn(),
|
||||
handleResume: vi.fn(),
|
||||
setIsResponding: vi.fn() as UseChatReturn['setIsResponding'],
|
||||
handleStop: vi.fn(),
|
||||
handleSwitchSibling: vi.fn(),
|
||||
isResponding: false,
|
||||
suggestedQuestions: [],
|
||||
handleRestart: vi.fn(),
|
||||
handleAnnotationEdited: vi.fn(),
|
||||
handleAnnotationAdded: vi.fn(),
|
||||
handleAnnotationRemoved: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('EmbeddedChatbot chat-wrapper', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue())
|
||||
vi.mocked(useChat).mockReturnValue(createUseChatReturn())
|
||||
})
|
||||
|
||||
describe('Welcome behavior', () => {
|
||||
it('should show opening message and suggested question for a new chat', () => {
|
||||
const handleSwitchSibling = vi.fn()
|
||||
vi.mocked(useChat).mockReturnValue(createUseChatReturn({
|
||||
handleSwitchSibling,
|
||||
chatList: [{ id: 'opening-1', isAnswer: true, isOpeningStatement: true, content: 'Welcome to the app', suggestedQuestions: ['How does it work?'] }],
|
||||
}))
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
|
||||
appPrevChatList: [
|
||||
{
|
||||
id: 'parent-node',
|
||||
content: 'parent',
|
||||
isAnswer: true,
|
||||
children: [
|
||||
{
|
||||
id: 'paused-workflow',
|
||||
content: 'paused',
|
||||
isAnswer: true,
|
||||
workflow_run_id: 'run-1',
|
||||
humanInputFormDataList: [{ label: 'Need info' }],
|
||||
} as unknown as ChatItem,
|
||||
],
|
||||
} as unknown as ChatItem,
|
||||
],
|
||||
}))
|
||||
|
||||
render(<ChatWrapper />)
|
||||
|
||||
expect(screen.getByText('How does it work?')).toBeInTheDocument()
|
||||
expect(handleSwitchSibling).toHaveBeenCalledWith('paused-workflow', expect.objectContaining({
|
||||
isPublicAPI: true,
|
||||
}))
|
||||
const resumeOptions = handleSwitchSibling.mock.calls[0]?.[1] as { onGetSuggestedQuestions: (responseItemId: string) => void }
|
||||
resumeOptions.onGetSuggestedQuestions('resume-1')
|
||||
expect(fetchSuggestedQuestions).toHaveBeenCalledWith('resume-1', AppSourceType.webApp, 'app-1')
|
||||
})
|
||||
|
||||
it('should hide or show welcome content based on chat state', () => {
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
|
||||
inputsForms: [{ variable: 'name', label: 'Name', required: true, type: InputVarType.textInput }],
|
||||
currentConversationId: '',
|
||||
allInputsHidden: false,
|
||||
}))
|
||||
vi.mocked(useChat).mockReturnValue(createUseChatReturn({
|
||||
chatList: [{ id: 'opening-1', isAnswer: true, isOpeningStatement: true, content: 'Welcome to the app' }],
|
||||
}))
|
||||
|
||||
render(<ChatWrapper />)
|
||||
|
||||
expect(screen.queryByText('Welcome to the app')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('inputs form')).toBeInTheDocument()
|
||||
|
||||
cleanup()
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
|
||||
inputsForms: [],
|
||||
currentConversationId: '',
|
||||
allInputsHidden: true,
|
||||
}))
|
||||
vi.mocked(useChat).mockReturnValue(createUseChatReturn({
|
||||
chatList: [{ id: 'opening-2', isAnswer: true, isOpeningStatement: true, content: 'Fallback welcome' }],
|
||||
}))
|
||||
|
||||
render(<ChatWrapper />)
|
||||
expect(screen.queryByText('inputs form')).not.toBeInTheDocument()
|
||||
|
||||
cleanup()
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
|
||||
appData: null,
|
||||
}))
|
||||
vi.mocked(useChat).mockReturnValue(createUseChatReturn({
|
||||
isResponding: false,
|
||||
chatList: [{ id: 'opening-3', isAnswer: true, isOpeningStatement: true, content: 'Should be hidden' }],
|
||||
}))
|
||||
|
||||
render(<ChatWrapper />)
|
||||
expect(screen.queryByText('Should be hidden')).not.toBeInTheDocument()
|
||||
|
||||
cleanup()
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue())
|
||||
vi.mocked(useChat).mockReturnValue(createUseChatReturn({
|
||||
isResponding: true,
|
||||
chatList: [{ id: 'opening-4', isAnswer: true, isOpeningStatement: true, content: 'Should be hidden while responding' }],
|
||||
}))
|
||||
render(<ChatWrapper />)
|
||||
expect(screen.queryByText('Should be hidden while responding')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Input and avatar behavior', () => {
|
||||
it('should disable sending when required fields are incomplete or uploading', () => {
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
|
||||
inputsForms: [{ variable: 'email', label: 'Email', required: true, type: InputVarType.textInput }],
|
||||
newConversationInputsRef: { current: {} },
|
||||
}))
|
||||
|
||||
render(<ChatWrapper />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'send message' })).toBeDisabled()
|
||||
|
||||
cleanup()
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
|
||||
inputsForms: [{ variable: 'file', label: 'File', required: true, type: InputVarType.multiFiles }],
|
||||
newConversationInputsRef: {
|
||||
current: {
|
||||
file: [
|
||||
{
|
||||
transferMethod: 'local_file',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
render(<ChatWrapper />)
|
||||
expect(screen.getByRole('button', { name: 'send message' })).toBeDisabled()
|
||||
|
||||
cleanup()
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
|
||||
inputsForms: [{ variable: 'singleFile', label: 'Single file', required: true, type: InputVarType.singleFile }],
|
||||
newConversationInputsRef: {
|
||||
current: {
|
||||
singleFile: {
|
||||
transferMethod: 'local_file',
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
render(<ChatWrapper />)
|
||||
expect(screen.getByRole('button', { name: 'send message' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should show the user name when avatar data is provided', () => {
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
|
||||
initUserVariables: {
|
||||
avatar_url: 'https://example.com/avatar.png',
|
||||
name: 'Alice',
|
||||
},
|
||||
}))
|
||||
|
||||
render(<ChatWrapper />)
|
||||
|
||||
expect(screen.getByRole('img', { name: 'Alice' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Human input submit behavior', () => {
|
||||
it('should submit via installed app service when the app is installed', async () => {
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
|
||||
isInstalledApp: true,
|
||||
}))
|
||||
|
||||
render(<ChatWrapper />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'submit human input' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitHumanInputFormService).toHaveBeenCalledWith('form-token', { answer: 'ok' })
|
||||
})
|
||||
expect(submitHumanInputForm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should submit via share service and support chat actions in web app mode', async () => {
|
||||
const handleSend = vi.fn()
|
||||
const handleSwitchSibling = vi.fn()
|
||||
const handleStop = vi.fn()
|
||||
vi.mocked(useChat).mockReturnValue(createUseChatReturn({
|
||||
handleSend,
|
||||
handleSwitchSibling,
|
||||
handleStop,
|
||||
chatList: [
|
||||
{ id: 'opening-1', isAnswer: true, isOpeningStatement: true, content: 'Welcome' },
|
||||
{ id: 'question-1', isAnswer: false, content: 'Question' },
|
||||
{ id: 'answer-1', isAnswer: true, content: 'Answer', parentMessageId: 'question-1' },
|
||||
] as ChatItemInTree[],
|
||||
}))
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
|
||||
isInstalledApp: false,
|
||||
appSourceType: AppSourceType.tryApp,
|
||||
isMobile: true,
|
||||
inputsForms: [{ variable: 'topic', label: 'Topic', required: false, type: InputVarType.textInput }],
|
||||
currentConversationId: 'conversation-1',
|
||||
}))
|
||||
mockIsDify.mockReturnValue(true)
|
||||
|
||||
render(<ChatWrapper />)
|
||||
|
||||
expect(screen.getByText('chat count: 3')).toBeInTheDocument()
|
||||
expect(screen.queryByText('inputs form')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send through chat' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'regenerate answer' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'switch sibling' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'stop responding' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'submit human input' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitHumanInputForm).toHaveBeenCalledWith('form-token', { answer: 'ok' })
|
||||
})
|
||||
expect(handleSend).toHaveBeenCalledTimes(2)
|
||||
const sendOptions = handleSend.mock.calls[0]?.[2] as { onGetSuggestedQuestions: (responseItemId: string) => void }
|
||||
sendOptions.onGetSuggestedQuestions('resp-1')
|
||||
expect(handleSwitchSibling).toHaveBeenCalledWith('sibling-2', expect.objectContaining({
|
||||
isPublicAPI: false,
|
||||
}))
|
||||
const switchOptions = handleSwitchSibling.mock.calls.find(call => call[0] === 'sibling-2')?.[1] as { onGetSuggestedQuestions: (responseItemId: string) => void }
|
||||
switchOptions.onGetSuggestedQuestions('resp-2')
|
||||
expect(fetchSuggestedQuestions).toHaveBeenCalledWith('resp-1', AppSourceType.tryApp, 'app-1')
|
||||
expect(fetchSuggestedQuestions).toHaveBeenCalledWith('resp-2', AppSourceType.tryApp, 'app-1')
|
||||
expect(handleStop).toHaveBeenCalled()
|
||||
expect(screen.queryByRole('img', { name: 'Alice' })).not.toBeInTheDocument()
|
||||
|
||||
cleanup()
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
|
||||
isMobile: true,
|
||||
currentConversationId: '',
|
||||
inputsForms: [{ variable: 'topic', label: 'Topic', required: false, type: InputVarType.textInput }],
|
||||
}))
|
||||
vi.mocked(useChat).mockReturnValue(createUseChatReturn({
|
||||
chatList: [{ id: 'opening-mobile', isAnswer: true, isOpeningStatement: true, content: 'Mobile welcome' }],
|
||||
}))
|
||||
|
||||
render(<ChatWrapper />)
|
||||
expect(screen.getByText('inputs form')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,362 @@
|
||||
/* eslint-disable next/no-img-element */
|
||||
import type { ImgHTMLAttributes } from 'react'
|
||||
import type { EmbeddedChatbotContextValue } from '../context'
|
||||
import type { AppData } from '@/models/share'
|
||||
import type { SystemFeatures } from '@/types/feature'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { vi } from 'vitest'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { InstallationScope, LicenseStatus } from '@/types/feature'
|
||||
import { useEmbeddedChatbotContext } from '../context'
|
||||
import Header from './index'
|
||||
|
||||
vi.mock('../context', () => ({
|
||||
useEmbeddedChatbotContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown', () => ({
|
||||
default: () => <div data-testid="view-form-dropdown" />,
|
||||
}))
|
||||
|
||||
// Mock next/image to render a normal img tag for testing
|
||||
vi.mock('next/image', () => ({
|
||||
__esModule: true,
|
||||
default: (props: ImgHTMLAttributes<HTMLImageElement> & { unoptimized?: boolean }) => {
|
||||
const { unoptimized: _, ...rest } = props
|
||||
return <img {...rest} />
|
||||
},
|
||||
}))
|
||||
|
||||
type GlobalPublicStoreMock = {
|
||||
systemFeatures: SystemFeatures
|
||||
setSystemFeatures: (systemFeatures: SystemFeatures) => void
|
||||
}
|
||||
|
||||
describe('EmbeddedChatbot Header', () => {
|
||||
const defaultAppData: AppData = {
|
||||
app_id: 'test-app-id',
|
||||
can_replace_logo: true,
|
||||
custom_config: {
|
||||
remove_webapp_brand: false,
|
||||
replace_webapp_logo: '',
|
||||
},
|
||||
enable_site: true,
|
||||
end_user_id: 'test-user-id',
|
||||
site: {
|
||||
title: 'Test Site',
|
||||
},
|
||||
}
|
||||
|
||||
const defaultContext: Partial<EmbeddedChatbotContextValue> = {
|
||||
appData: defaultAppData,
|
||||
currentConversationId: 'test-conv-id',
|
||||
inputsForms: [],
|
||||
allInputsHidden: false,
|
||||
}
|
||||
|
||||
const defaultSystemFeatures: SystemFeatures = {
|
||||
trial_models: [],
|
||||
plugin_installation_permission: {
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
restrict_to_marketplace_only: false,
|
||||
},
|
||||
sso_enforced_for_signin: false,
|
||||
sso_enforced_for_signin_protocol: '',
|
||||
sso_enforced_for_web: false,
|
||||
sso_enforced_for_web_protocol: '',
|
||||
enable_marketplace: false,
|
||||
enable_change_email: false,
|
||||
enable_email_code_login: false,
|
||||
enable_email_password_login: false,
|
||||
enable_social_oauth_login: false,
|
||||
is_allow_create_workspace: false,
|
||||
is_allow_register: false,
|
||||
is_email_setup: false,
|
||||
license: {
|
||||
status: LicenseStatus.NONE,
|
||||
expired_at: '',
|
||||
},
|
||||
branding: {
|
||||
enabled: true,
|
||||
workspace_logo: '',
|
||||
login_page_logo: '',
|
||||
favicon: '',
|
||||
application_title: '',
|
||||
},
|
||||
webapp_auth: {
|
||||
enabled: false,
|
||||
allow_sso: false,
|
||||
sso_config: { protocol: '' },
|
||||
allow_email_code_login: false,
|
||||
allow_email_password_login: false,
|
||||
},
|
||||
enable_trial_app: false,
|
||||
enable_explore_banner: false,
|
||||
}
|
||||
|
||||
const setupIframe = () => {
|
||||
const mockPostMessage = vi.fn()
|
||||
const mockTop = { postMessage: mockPostMessage }
|
||||
Object.defineProperty(window, 'self', { value: {}, configurable: true })
|
||||
Object.defineProperty(window, 'top', { value: mockTop, configurable: true })
|
||||
Object.defineProperty(window, 'parent', { value: mockTop, configurable: true })
|
||||
return mockPostMessage
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(defaultContext as EmbeddedChatbotContextValue)
|
||||
vi.mocked(useGlobalPublicStore).mockImplementation((selector: (s: GlobalPublicStoreMock) => unknown) => selector({
|
||||
systemFeatures: defaultSystemFeatures,
|
||||
setSystemFeatures: vi.fn(),
|
||||
}))
|
||||
|
||||
Object.defineProperty(window, 'self', { value: window, configurable: true })
|
||||
Object.defineProperty(window, 'top', { value: window, configurable: true })
|
||||
})
|
||||
|
||||
describe('Desktop Rendering', () => {
|
||||
it('should render desktop header with branding by default', async () => {
|
||||
render(<Header title="Test Chatbot" />)
|
||||
|
||||
expect(screen.getByTestId('webapp-brand')).toBeInTheDocument()
|
||||
expect(screen.getByText('share.chat.poweredBy')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render custom logo when provided in appData', () => {
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
|
||||
...defaultContext,
|
||||
appData: {
|
||||
...defaultAppData,
|
||||
custom_config: {
|
||||
...defaultAppData.custom_config,
|
||||
replace_webapp_logo: 'https://example.com/logo.png',
|
||||
},
|
||||
},
|
||||
} as EmbeddedChatbotContextValue)
|
||||
|
||||
render(<Header title="Test Chatbot" />)
|
||||
|
||||
const img = screen.getByAltText('logo')
|
||||
expect(img).toHaveAttribute('src', 'https://example.com/logo.png')
|
||||
})
|
||||
|
||||
it('should render workspace logo when branding is enabled and logo exists', () => {
|
||||
vi.mocked(useGlobalPublicStore).mockImplementation((selector: (s: GlobalPublicStoreMock) => unknown) => selector({
|
||||
systemFeatures: {
|
||||
...defaultSystemFeatures,
|
||||
branding: {
|
||||
...defaultSystemFeatures.branding,
|
||||
workspace_logo: 'https://example.com/workspace.png',
|
||||
},
|
||||
},
|
||||
setSystemFeatures: vi.fn(),
|
||||
}))
|
||||
|
||||
render(<Header title="Test Chatbot" />)
|
||||
|
||||
const img = screen.getByAltText('logo')
|
||||
expect(img).toHaveAttribute('src', 'https://example.com/workspace.png')
|
||||
})
|
||||
|
||||
it('should render Dify logo by default when no branding or custom logo is provided', () => {
|
||||
vi.mocked(useGlobalPublicStore).mockImplementation((selector: (s: GlobalPublicStoreMock) => unknown) => selector({
|
||||
systemFeatures: {
|
||||
...defaultSystemFeatures,
|
||||
branding: {
|
||||
...defaultSystemFeatures.branding,
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
setSystemFeatures: vi.fn(),
|
||||
}))
|
||||
render(<Header title="Test Chatbot" />)
|
||||
expect(screen.getByAltText('Dify logo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should NOT render branding when remove_webapp_brand is true', () => {
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
|
||||
...defaultContext,
|
||||
appData: {
|
||||
...defaultAppData,
|
||||
custom_config: {
|
||||
...defaultAppData.custom_config,
|
||||
remove_webapp_brand: true,
|
||||
},
|
||||
},
|
||||
} as EmbeddedChatbotContextValue)
|
||||
|
||||
render(<Header title="Test Chatbot" />)
|
||||
|
||||
expect(screen.queryByTestId('webapp-brand')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render reset button when allowResetChat is true and conversation exists', () => {
|
||||
render(<Header title="Test Chatbot" allowResetChat={true} />)
|
||||
|
||||
expect(screen.getByTestId('reset-chat-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onCreateNewChat when reset button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onCreateNewChat = vi.fn()
|
||||
render(<Header title="Test Chatbot" allowResetChat={true} onCreateNewChat={onCreateNewChat} />)
|
||||
|
||||
await user.click(screen.getByTestId('reset-chat-button'))
|
||||
expect(onCreateNewChat).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render ViewFormDropdown when conditions are met', () => {
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
|
||||
...defaultContext,
|
||||
inputsForms: [{ id: '1' }],
|
||||
allInputsHidden: false,
|
||||
} as EmbeddedChatbotContextValue)
|
||||
|
||||
render(<Header title="Test Chatbot" />)
|
||||
|
||||
expect(screen.getByTestId('view-form-dropdown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should NOT render ViewFormDropdown when inputs are hidden', () => {
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
|
||||
...defaultContext,
|
||||
inputsForms: [{ id: '1' }],
|
||||
allInputsHidden: true,
|
||||
} as EmbeddedChatbotContextValue)
|
||||
|
||||
render(<Header title="Test Chatbot" />)
|
||||
|
||||
expect(screen.queryByTestId('view-form-dropdown')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should NOT render ViewFormDropdown when currentConversationId is missing', () => {
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
|
||||
...defaultContext,
|
||||
currentConversationId: '',
|
||||
inputsForms: [{ id: '1' }],
|
||||
} as EmbeddedChatbotContextValue)
|
||||
|
||||
render(<Header title="Test Chatbot" />)
|
||||
|
||||
expect(screen.queryByTestId('view-form-dropdown')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Mobile Rendering', () => {
|
||||
it('should render mobile header with title', () => {
|
||||
render(<Header title="Mobile Chatbot" isMobile />)
|
||||
|
||||
expect(screen.getByText('Mobile Chatbot')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render customer icon in mobile header', () => {
|
||||
render(<Header title="Mobile Chatbot" isMobile customerIcon={<div data-testid="custom-icon" />} />)
|
||||
|
||||
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render mobile reset button when allowed', () => {
|
||||
render(<Header title="Mobile Chatbot" isMobile allowResetChat />)
|
||||
|
||||
expect(screen.getByTestId('mobile-reset-chat-button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Iframe Communication', () => {
|
||||
it('should send dify-chatbot-iframe-ready on mount', () => {
|
||||
const mockPostMessage = setupIframe()
|
||||
render(<Header title="Iframe" />)
|
||||
|
||||
expect(mockPostMessage).toHaveBeenCalledWith(
|
||||
{ type: 'dify-chatbot-iframe-ready' },
|
||||
'*',
|
||||
)
|
||||
})
|
||||
|
||||
it('should update expand button visibility and handle click', async () => {
|
||||
const user = userEvent.setup()
|
||||
const mockPostMessage = setupIframe()
|
||||
render(<Header title="Iframe" />)
|
||||
|
||||
window.dispatchEvent(new MessageEvent('message', {
|
||||
origin: 'https://parent.com',
|
||||
data: {
|
||||
type: 'dify-chatbot-config',
|
||||
payload: { isToggledByButton: true, isDraggable: false },
|
||||
},
|
||||
}))
|
||||
|
||||
const expandBtn = await screen.findByTestId('expand-button')
|
||||
expect(expandBtn).toBeInTheDocument()
|
||||
|
||||
await user.click(expandBtn)
|
||||
|
||||
expect(mockPostMessage).toHaveBeenCalledWith(
|
||||
{ type: 'dify-chatbot-expand-change' },
|
||||
'https://parent.com',
|
||||
)
|
||||
expect(expandBtn.querySelector('.i-ri-collapse-diagonal-2-line')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should NOT show expand button if isDraggable is true', async () => {
|
||||
setupIframe()
|
||||
render(<Header title="Iframe" />)
|
||||
|
||||
window.dispatchEvent(new MessageEvent('message', {
|
||||
origin: 'https://parent.com',
|
||||
data: {
|
||||
type: 'dify-chatbot-config',
|
||||
payload: { isToggledByButton: true, isDraggable: true },
|
||||
},
|
||||
}))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('expand-button')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore messages from different origins after security lock', async () => {
|
||||
setupIframe()
|
||||
render(<Header title="Iframe" />)
|
||||
|
||||
window.dispatchEvent(new MessageEvent('message', {
|
||||
origin: 'https://secure.com',
|
||||
data: { type: 'dify-chatbot-config', payload: { isToggledByButton: true, isDraggable: false } },
|
||||
}))
|
||||
|
||||
await screen.findByTestId('expand-button')
|
||||
|
||||
window.dispatchEvent(new MessageEvent('message', {
|
||||
origin: 'https://malicious.com',
|
||||
data: { type: 'dify-chatbot-config', payload: { isToggledByButton: false, isDraggable: false } },
|
||||
}))
|
||||
|
||||
expect(screen.getByTestId('expand-button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle document.referrer for targetOrigin', () => {
|
||||
const mockPostMessage = setupIframe()
|
||||
Object.defineProperty(document, 'referrer', { value: 'https://referrer.com', configurable: true })
|
||||
render(<Header title="Referrer" />)
|
||||
|
||||
expect(mockPostMessage).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'https://referrer.com',
|
||||
)
|
||||
})
|
||||
|
||||
it('should NOT add message listener if not in iframe', () => {
|
||||
const addSpy = vi.spyOn(window, 'addEventListener')
|
||||
render(<Header title="Direct" />)
|
||||
expect(addSpy).not.toHaveBeenCalledWith('message', expect.any(Function))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,6 +1,5 @@
|
||||
import type { FC } from 'react'
|
||||
import type { Theme } from '../theme/theme-context'
|
||||
import { RiCollapseDiagonal2Line, RiExpandDiagonal2Line, RiResetLeftLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -89,11 +88,13 @@ const Header: FC<IHeaderProps> = ({
|
||||
{/* powered by */}
|
||||
<div className="shrink-0">
|
||||
{!appData?.custom_config?.remove_webapp_brand && (
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-2',
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-2',
|
||||
)}
|
||||
data-testid="webapp-brand"
|
||||
>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
<div className="text-text-tertiary system-2xs-medium-uppercase">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
{
|
||||
systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
|
||||
? <img src={systemFeatures.branding.workspace_logo} alt="logo" className="block h-5 w-auto" />
|
||||
@ -112,11 +113,11 @@ const Header: FC<IHeaderProps> = ({
|
||||
<Tooltip
|
||||
popupContent={expanded ? t('chat.collapse', { ns: 'share' }) : t('chat.expand', { ns: 'share' })}
|
||||
>
|
||||
<ActionButton size="l" onClick={handleToggleExpand}>
|
||||
<ActionButton size="l" onClick={handleToggleExpand} data-testid="expand-button">
|
||||
{
|
||||
expanded
|
||||
? <RiCollapseDiagonal2Line className="h-[18px] w-[18px]" />
|
||||
: <RiExpandDiagonal2Line className="h-[18px] w-[18px]" />
|
||||
? <div className="i-ri-collapse-diagonal-2-line h-[18px] w-[18px]" />
|
||||
: <div className="i-ri-expand-diagonal-2-line h-[18px] w-[18px]" />
|
||||
}
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
@ -126,8 +127,8 @@ const Header: FC<IHeaderProps> = ({
|
||||
<Tooltip
|
||||
popupContent={t('chat.resetChat', { ns: 'share' })}
|
||||
>
|
||||
<ActionButton size="l" onClick={onCreateNewChat}>
|
||||
<RiResetLeftLine className="h-[18px] w-[18px]" />
|
||||
<ActionButton size="l" onClick={onCreateNewChat} data-testid="reset-chat-button">
|
||||
<div className="i-ri-reset-left-line h-[18px] w-[18px]" />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
@ -147,7 +148,7 @@ const Header: FC<IHeaderProps> = ({
|
||||
<div className="flex grow items-center space-x-3">
|
||||
{customerIcon}
|
||||
<div
|
||||
className="system-md-semibold truncate"
|
||||
className="truncate system-md-semibold"
|
||||
style={CssTransform(theme?.colorFontOnHeaderStyle ?? '')}
|
||||
>
|
||||
{title}
|
||||
@ -159,11 +160,11 @@ const Header: FC<IHeaderProps> = ({
|
||||
<Tooltip
|
||||
popupContent={expanded ? t('chat.collapse', { ns: 'share' }) : t('chat.expand', { ns: 'share' })}
|
||||
>
|
||||
<ActionButton size="l" onClick={handleToggleExpand}>
|
||||
<ActionButton size="l" onClick={handleToggleExpand} data-testid="mobile-expand-button">
|
||||
{
|
||||
expanded
|
||||
? <RiCollapseDiagonal2Line className={cn('h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
|
||||
: <RiExpandDiagonal2Line className={cn('h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
|
||||
? <div className={cn('i-ri-collapse-diagonal-2-line h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
|
||||
: <div className={cn('i-ri-expand-diagonal-2-line h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
|
||||
}
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
@ -173,8 +174,8 @@ const Header: FC<IHeaderProps> = ({
|
||||
<Tooltip
|
||||
popupContent={t('chat.resetChat', { ns: 'share' })}
|
||||
>
|
||||
<ActionButton size="l" onClick={onCreateNewChat}>
|
||||
<RiResetLeftLine className={cn('h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
|
||||
<ActionButton size="l" onClick={onCreateNewChat} data-testid="mobile-reset-chat-button">
|
||||
<div className={cn('i-ri-reset-left-line h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
240
web/app/components/base/chat/embedded-chatbot/index.spec.tsx
Normal file
240
web/app/components/base/chat/embedded-chatbot/index.spec.tsx
Normal file
@ -0,0 +1,240 @@
|
||||
import type { RefObject } from 'react'
|
||||
import type { ChatConfig } from '../types'
|
||||
import type { AppData, AppMeta, ConversationItem } from '@/models/share'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { vi } from 'vitest'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { defaultSystemFeatures } from '@/types/feature'
|
||||
import { useEmbeddedChatbot } from './hooks'
|
||||
import EmbeddedChatbot from './index'
|
||||
|
||||
vi.mock('./hooks', () => ({
|
||||
useEmbeddedChatbot: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
default: vi.fn(),
|
||||
MediaType: {
|
||||
mobile: 'mobile',
|
||||
tablet: 'tablet',
|
||||
pc: 'pc',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./chat-wrapper', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>chat area</div>,
|
||||
}))
|
||||
|
||||
vi.mock('./header', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>chat header</div>,
|
||||
}))
|
||||
|
||||
vi.mock('./theme/theme-context', () => ({
|
||||
useThemeContext: vi.fn(() => ({
|
||||
buildTheme: vi.fn(),
|
||||
theme: {
|
||||
backgroundHeaderColorStyle: '',
|
||||
},
|
||||
})),
|
||||
}))
|
||||
|
||||
const mockIsDify = vi.fn(() => false)
|
||||
vi.mock('./utils', () => ({
|
||||
isDify: () => mockIsDify(),
|
||||
}))
|
||||
|
||||
type EmbeddedChatbotHookReturn = ReturnType<typeof useEmbeddedChatbot>
|
||||
|
||||
const createHookReturn = (overrides: Partial<EmbeddedChatbotHookReturn> = {}): EmbeddedChatbotHookReturn => {
|
||||
const appData: AppData = {
|
||||
app_id: 'app-1',
|
||||
can_replace_logo: true,
|
||||
custom_config: {
|
||||
remove_webapp_brand: false,
|
||||
replace_webapp_logo: '',
|
||||
},
|
||||
enable_site: true,
|
||||
end_user_id: 'user-1',
|
||||
site: {
|
||||
title: 'Embedded App',
|
||||
chat_color_theme: 'blue',
|
||||
chat_color_theme_inverted: false,
|
||||
},
|
||||
}
|
||||
|
||||
const base: EmbeddedChatbotHookReturn = {
|
||||
appSourceType: 'webApp' as EmbeddedChatbotHookReturn['appSourceType'],
|
||||
isInstalledApp: false,
|
||||
appId: 'app-1',
|
||||
currentConversationId: '',
|
||||
currentConversationItem: undefined,
|
||||
removeConversationIdInfo: vi.fn(),
|
||||
handleConversationIdInfoChange: vi.fn(),
|
||||
appData,
|
||||
appParams: {} as ChatConfig,
|
||||
appMeta: { tool_icons: {} } as AppMeta,
|
||||
appPinnedConversationData: { data: [], has_more: false, limit: 20 },
|
||||
appConversationData: { data: [], has_more: false, limit: 20 },
|
||||
appConversationDataLoading: false,
|
||||
appChatListData: { data: [], has_more: false, limit: 20 },
|
||||
appChatListDataLoading: false,
|
||||
appPrevChatList: [],
|
||||
pinnedConversationList: [] as ConversationItem[],
|
||||
conversationList: [] as ConversationItem[],
|
||||
setShowNewConversationItemInList: vi.fn(),
|
||||
newConversationInputs: {},
|
||||
newConversationInputsRef: { current: {} } as unknown as RefObject<Record<string, unknown>>,
|
||||
handleNewConversationInputsChange: vi.fn(),
|
||||
inputsForms: [],
|
||||
handleNewConversation: vi.fn(),
|
||||
handleStartChat: vi.fn(),
|
||||
handleChangeConversation: vi.fn(),
|
||||
handleNewConversationCompleted: vi.fn(),
|
||||
newConversationId: '',
|
||||
chatShouldReloadKey: 'reload-key',
|
||||
allowResetChat: true,
|
||||
handleFeedback: vi.fn(),
|
||||
currentChatInstanceRef: { current: { handleStop: vi.fn() } },
|
||||
clearChatList: false,
|
||||
setClearChatList: vi.fn(),
|
||||
isResponding: false,
|
||||
setIsResponding: vi.fn(),
|
||||
currentConversationInputs: {},
|
||||
setCurrentConversationInputs: vi.fn(),
|
||||
allInputsHidden: false,
|
||||
initUserVariables: {},
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('EmbeddedChatbot index', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useBreakpoints).mockReturnValue(MediaType.mobile)
|
||||
vi.mocked(useEmbeddedChatbot).mockReturnValue(createHookReturn())
|
||||
vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({
|
||||
systemFeatures: {
|
||||
...defaultSystemFeatures,
|
||||
branding: {
|
||||
...defaultSystemFeatures.branding,
|
||||
enabled: true,
|
||||
workspace_logo: '',
|
||||
},
|
||||
},
|
||||
setSystemFeatures: vi.fn(),
|
||||
}))
|
||||
})
|
||||
|
||||
describe('Loading and chat content', () => {
|
||||
it('should show loading state before chat content', () => {
|
||||
vi.mocked(useEmbeddedChatbot).mockReturnValue(createHookReturn({ appChatListDataLoading: true }))
|
||||
|
||||
render(<EmbeddedChatbot />)
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
expect(screen.queryByText('chat area')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render chat content when loading finishes', () => {
|
||||
render(<EmbeddedChatbot />)
|
||||
|
||||
expect(screen.getByText('chat area')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Powered by branding', () => {
|
||||
it('should show workspace logo on mobile when branding is enabled', () => {
|
||||
vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({
|
||||
systemFeatures: {
|
||||
...defaultSystemFeatures,
|
||||
branding: {
|
||||
...defaultSystemFeatures.branding,
|
||||
enabled: true,
|
||||
workspace_logo: 'https://example.com/workspace-logo.png',
|
||||
},
|
||||
},
|
||||
setSystemFeatures: vi.fn(),
|
||||
}))
|
||||
|
||||
render(<EmbeddedChatbot />)
|
||||
|
||||
expect(screen.getByText('share.chat.poweredBy')).toBeInTheDocument()
|
||||
expect(screen.getByAltText('logo')).toHaveAttribute('src', 'https://example.com/workspace-logo.png')
|
||||
})
|
||||
|
||||
it('should show custom logo when workspace branding logo is unavailable', () => {
|
||||
vi.mocked(useEmbeddedChatbot).mockReturnValue(createHookReturn({
|
||||
appData: {
|
||||
app_id: 'app-1',
|
||||
can_replace_logo: true,
|
||||
custom_config: {
|
||||
remove_webapp_brand: false,
|
||||
replace_webapp_logo: 'https://example.com/custom-logo.png',
|
||||
},
|
||||
enable_site: true,
|
||||
end_user_id: 'user-1',
|
||||
site: {
|
||||
title: 'Embedded App',
|
||||
chat_color_theme: 'blue',
|
||||
chat_color_theme_inverted: false,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
render(<EmbeddedChatbot />)
|
||||
|
||||
expect(screen.getByText('share.chat.poweredBy')).toBeInTheDocument()
|
||||
expect(screen.getByAltText('logo')).toHaveAttribute('src', 'https://example.com/custom-logo.png')
|
||||
})
|
||||
|
||||
it('should hide powered by section when branding is removed', () => {
|
||||
vi.mocked(useEmbeddedChatbot).mockReturnValue(createHookReturn({
|
||||
appData: {
|
||||
app_id: 'app-1',
|
||||
can_replace_logo: true,
|
||||
custom_config: {
|
||||
remove_webapp_brand: true,
|
||||
replace_webapp_logo: '',
|
||||
},
|
||||
enable_site: true,
|
||||
end_user_id: 'user-1',
|
||||
site: {
|
||||
title: 'Embedded App',
|
||||
chat_color_theme: 'blue',
|
||||
chat_color_theme_inverted: false,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
render(<EmbeddedChatbot />)
|
||||
|
||||
expect(screen.queryByText('share.chat.poweredBy')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show powered by section on desktop', () => {
|
||||
vi.mocked(useBreakpoints).mockReturnValue(MediaType.pc)
|
||||
vi.mocked(useEmbeddedChatbot).mockReturnValue(createHookReturn({ appData: null }))
|
||||
mockIsDify.mockReturnValue(true)
|
||||
|
||||
render(<EmbeddedChatbot />)
|
||||
|
||||
expect(screen.queryByText('share.chat.poweredBy')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('chat header')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,263 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { useEmbeddedChatbotContext } from '../context'
|
||||
import InputsFormContent from './content'
|
||||
|
||||
vi.mock('../context', () => ({
|
||||
useEmbeddedChatbotContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({ token: 'test-token' }),
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
usePathname: () => '/',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
useToastContext: () => ({ notify: vi.fn() }),
|
||||
}))
|
||||
|
||||
// Mock CodeEditor to trigger onChange easily
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
|
||||
default: ({ value, onChange, placeholder }: { value: string, onChange: (v: string) => void, placeholder: string | React.ReactNode }) => (
|
||||
<textarea
|
||||
data-testid="mock-code-editor"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder={typeof placeholder === 'string' ? placeholder : 'json-placeholder'}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock FileUploaderInAttachmentWrapper to trigger onChange easily
|
||||
vi.mock('@/app/components/base/file-uploader', () => ({
|
||||
|
||||
FileUploaderInAttachmentWrapper: ({ value, onChange }: { value: any[], onChange: (v: any[]) => void }) => (
|
||||
<div data-testid="mock-file-uploader">
|
||||
<button onClick={() => onChange([new File([''], 'test.png', { type: 'image/png' })])}>Upload</button>
|
||||
<span>{value.length > 0 ? value[0].name : 'no file'}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const mockContextValue = {
|
||||
appParams: {
|
||||
system_parameters: {
|
||||
file_size_limit: 10,
|
||||
},
|
||||
},
|
||||
inputsForms: [
|
||||
{
|
||||
variable: 'text_var',
|
||||
label: 'Text Label',
|
||||
type: InputVarType.textInput,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
variable: 'num_var',
|
||||
label: 'Number Label',
|
||||
type: InputVarType.number,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
variable: 'para_var',
|
||||
label: 'Paragraph Label',
|
||||
type: InputVarType.paragraph,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
variable: 'bool_var',
|
||||
label: 'Bool Label',
|
||||
type: InputVarType.checkbox,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
variable: 'select_var',
|
||||
label: 'Select Label',
|
||||
type: InputVarType.select,
|
||||
options: ['Option 1', 'Option 2'],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
variable: 'file_var',
|
||||
label: 'File Label',
|
||||
type: InputVarType.singleFile,
|
||||
required: true,
|
||||
allowed_file_types: ['image'],
|
||||
allowed_file_extensions: ['.png'],
|
||||
allowed_file_upload_methods: ['local_upload'],
|
||||
},
|
||||
{
|
||||
variable: 'multi_file_var',
|
||||
label: 'Multi File Label',
|
||||
type: InputVarType.multiFiles,
|
||||
required: true,
|
||||
max_length: 5,
|
||||
allowed_file_types: ['image'],
|
||||
allowed_file_extensions: ['.png'],
|
||||
allowed_file_upload_methods: ['local_upload'],
|
||||
},
|
||||
{
|
||||
variable: 'json_var',
|
||||
label: 'JSON Label',
|
||||
type: InputVarType.jsonObject,
|
||||
required: true,
|
||||
json_schema: '{ "type": "object" }',
|
||||
},
|
||||
{
|
||||
variable: 'hidden_var',
|
||||
label: 'Hidden Label',
|
||||
type: InputVarType.textInput,
|
||||
hide: true,
|
||||
},
|
||||
],
|
||||
currentConversationId: null,
|
||||
currentConversationInputs: {},
|
||||
setCurrentConversationInputs: vi.fn(),
|
||||
newConversationInputs: {},
|
||||
newConversationInputsRef: { current: {} },
|
||||
handleNewConversationInputsChange: vi.fn(),
|
||||
}
|
||||
|
||||
describe('InputsFormContent', () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(mockContextValue as unknown as any)
|
||||
})
|
||||
|
||||
it('should render visible input forms', () => {
|
||||
render(<InputsFormContent />)
|
||||
|
||||
expect(screen.getAllByText(/Text Label/i).length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText(/Number Label/i).length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText(/Paragraph Label/i).length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText(/Bool Label/i).length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText(/Select Label/i).length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText(/File Label/i).length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText(/Multi File Label/i).length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText(/JSON Label/i).length).toBeGreaterThan(0)
|
||||
expect(screen.queryByText('Hidden Label')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render optional label for non-required fields', () => {
|
||||
render(<InputsFormContent />)
|
||||
expect(screen.queryAllByText(/panel.optional/i).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should handle text input changes', async () => {
|
||||
render(<InputsFormContent />)
|
||||
const inputs = screen.getAllByPlaceholderText('Text Label')
|
||||
await user.type(inputs[0], 'hello')
|
||||
|
||||
expect(mockContextValue.setCurrentConversationInputs).toHaveBeenCalled()
|
||||
expect(mockContextValue.handleNewConversationInputsChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle number input changes', async () => {
|
||||
render(<InputsFormContent />)
|
||||
const inputs = screen.getAllByPlaceholderText('Number Label')
|
||||
await user.type(inputs[0], '123')
|
||||
|
||||
expect(mockContextValue.setCurrentConversationInputs).toHaveBeenCalled()
|
||||
expect(mockContextValue.handleNewConversationInputsChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle paragraph input changes', async () => {
|
||||
render(<InputsFormContent />)
|
||||
const inputs = screen.getAllByPlaceholderText('Paragraph Label')
|
||||
await user.type(inputs[0], 'long text')
|
||||
|
||||
expect(mockContextValue.setCurrentConversationInputs).toHaveBeenCalled()
|
||||
expect(mockContextValue.handleNewConversationInputsChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle bool input changes', async () => {
|
||||
render(<InputsFormContent />)
|
||||
const checkbox = screen.getByTestId(/checkbox-/i)
|
||||
await user.click(checkbox)
|
||||
|
||||
expect(mockContextValue.setCurrentConversationInputs).toHaveBeenCalled()
|
||||
expect(mockContextValue.handleNewConversationInputsChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle select input changes', async () => {
|
||||
render(<InputsFormContent />)
|
||||
const selectTrigger = screen.getAllByText(/Select Label/i).find(el => el.tagName === 'SPAN')
|
||||
if (!selectTrigger)
|
||||
throw new Error('Select trigger not found')
|
||||
|
||||
await user.click(selectTrigger)
|
||||
const option = screen.getByText('Option 1')
|
||||
await user.click(option)
|
||||
|
||||
expect(mockContextValue.setCurrentConversationInputs).toHaveBeenCalled()
|
||||
expect(mockContextValue.handleNewConversationInputsChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle single file upload change', async () => {
|
||||
render(<InputsFormContent />)
|
||||
const uploadButtons = screen.getAllByText('Upload')
|
||||
await user.click(uploadButtons[0]) // First one is single file
|
||||
|
||||
expect(mockContextValue.setCurrentConversationInputs).toHaveBeenCalled()
|
||||
expect(mockContextValue.handleNewConversationInputsChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle multi files upload change', async () => {
|
||||
render(<InputsFormContent />)
|
||||
const uploadButtons = screen.getAllByText('Upload')
|
||||
await user.click(uploadButtons[1]) // Second one is multi files
|
||||
|
||||
expect(mockContextValue.setCurrentConversationInputs).toHaveBeenCalled()
|
||||
expect(mockContextValue.handleNewConversationInputsChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle JSON object change', async () => {
|
||||
render(<InputsFormContent />)
|
||||
const jsonEditor = screen.getByTestId('mock-code-editor')
|
||||
fireEvent.change(jsonEditor, { target: { value: '{ "a": 1 }' } })
|
||||
|
||||
expect(mockContextValue.setCurrentConversationInputs).toHaveBeenCalled()
|
||||
expect(mockContextValue.handleNewConversationInputsChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show tip when showTip is true', () => {
|
||||
render(<InputsFormContent showTip />)
|
||||
expect(screen.getByText(/chat.chatFormTip/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should set initial values from context', () => {
|
||||
const contextWithValues = {
|
||||
...mockContextValue,
|
||||
newConversationInputs: {
|
||||
text_var: 'initial value',
|
||||
},
|
||||
}
|
||||
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(contextWithValues as unknown as any)
|
||||
|
||||
render(<InputsFormContent />)
|
||||
expect(screen.getByDisplayValue('initial value')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use currentConversationInputs when currentConversationId exists', () => {
|
||||
const contextWithConv = {
|
||||
...mockContextValue,
|
||||
currentConversationId: 'conv-id',
|
||||
currentConversationInputs: {
|
||||
text_var: 'conv value',
|
||||
},
|
||||
}
|
||||
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(contextWithConv as unknown as any)
|
||||
|
||||
render(<InputsFormContent />)
|
||||
expect(screen.getByDisplayValue('conv value')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -45,12 +45,12 @@ const InputsFormContent = ({ showTip }: Props) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{visibleInputsForms.map(form => (
|
||||
<div key={form.variable} className="space-y-1">
|
||||
<div key={form.variable} className="space-y-1" data-testid={`inputs-form-item-${form.variable}`}>
|
||||
{form.type !== InputVarType.checkbox && (
|
||||
<div className="flex h-6 items-center gap-1">
|
||||
<div className="system-md-semibold text-text-secondary">{form.label}</div>
|
||||
<div className="text-text-secondary system-md-semibold">{form.label}</div>
|
||||
{!form.required && (
|
||||
<div className="system-xs-regular text-text-tertiary">{t('panel.optional', { ns: 'workflow' })}</div>
|
||||
<div className="text-text-tertiary system-xs-regular">{t('panel.optional', { ns: 'workflow' })}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@ -125,7 +125,7 @@ const InputsFormContent = ({ showTip }: Props) => {
|
||||
value={inputsFormValue?.[form.variable] || ''}
|
||||
onChange={v => handleFormChange(form.variable, v)}
|
||||
noWrapper
|
||||
className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1"
|
||||
className="h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1"
|
||||
placeholder={
|
||||
<div className="whitespace-pre">{form.json_schema}</div>
|
||||
}
|
||||
@ -134,7 +134,7 @@ const InputsFormContent = ({ showTip }: Props) => {
|
||||
</div>
|
||||
))}
|
||||
{showTip && (
|
||||
<div className="system-xs-regular text-text-tertiary">{t('chat.chatFormTip', { ns: 'share' })}</div>
|
||||
<div className="text-text-tertiary system-xs-regular">{t('chat.chatFormTip', { ns: 'share' })}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -0,0 +1,121 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { AppSourceType } from '@/service/share'
|
||||
import { useEmbeddedChatbotContext } from '../context'
|
||||
import InputsFormNode from './index'
|
||||
|
||||
vi.mock('../context', () => ({
|
||||
useEmbeddedChatbotContext: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock InputsFormContent to avoid complex integration in this test
|
||||
vi.mock('./content', () => ({
|
||||
default: () => <div data-testid="mock-inputs-form-content" />,
|
||||
}))
|
||||
|
||||
const mockContextValue = {
|
||||
appSourceType: AppSourceType.webApp,
|
||||
isMobile: false,
|
||||
currentConversationId: null,
|
||||
themeBuilder: null,
|
||||
handleStartChat: vi.fn(),
|
||||
allInputsHidden: false,
|
||||
inputsForms: [{ variable: 'test' }],
|
||||
}
|
||||
|
||||
describe('InputsFormNode', () => {
|
||||
const user = userEvent.setup()
|
||||
const setCollapsed = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(mockContextValue as unknown as any)
|
||||
})
|
||||
|
||||
it('should return null if allInputsHidden is true', () => {
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
|
||||
...mockContextValue,
|
||||
allInputsHidden: true,
|
||||
} as unknown as any)
|
||||
const { container } = render(<InputsFormNode collapsed={false} setCollapsed={setCollapsed} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null if inputsForms is empty', () => {
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
|
||||
...mockContextValue,
|
||||
inputsForms: [],
|
||||
} as unknown as any)
|
||||
const { container } = render(<InputsFormNode collapsed={false} setCollapsed={setCollapsed} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should render expanded state correctly', () => {
|
||||
render(<InputsFormNode collapsed={false} setCollapsed={setCollapsed} />)
|
||||
expect(screen.getByText(/chat.chatSettingsTitle/i)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('mock-inputs-form-content')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('inputs-form-start-chat-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render collapsed state correctly', () => {
|
||||
render(<InputsFormNode collapsed={true} setCollapsed={setCollapsed} />)
|
||||
expect(screen.getByText(/chat.chatSettingsTitle/i)).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('mock-inputs-form-content')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('inputs-form-edit-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle edit button click', async () => {
|
||||
render(<InputsFormNode collapsed={true} setCollapsed={setCollapsed} />)
|
||||
await user.click(screen.getByTestId('inputs-form-edit-button'))
|
||||
expect(setCollapsed).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should handle close button click', async () => {
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
|
||||
...mockContextValue,
|
||||
currentConversationId: 'conv-123',
|
||||
} as unknown as any)
|
||||
render(<InputsFormNode collapsed={false} setCollapsed={setCollapsed} />)
|
||||
await user.click(screen.getByTestId('inputs-form-close-button'))
|
||||
expect(setCollapsed).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should handle start chat button click', async () => {
|
||||
const handleStartChat = vi.fn(cb => cb())
|
||||
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
|
||||
...mockContextValue,
|
||||
handleStartChat,
|
||||
} as unknown as any)
|
||||
render(<InputsFormNode collapsed={false} setCollapsed={setCollapsed} />)
|
||||
await user.click(screen.getByTestId('inputs-form-start-chat-button'))
|
||||
expect(handleStartChat).toHaveBeenCalled()
|
||||
expect(setCollapsed).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should apply theme primary color to start chat button', () => {
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
|
||||
...mockContextValue,
|
||||
themeBuilder: {
|
||||
theme: {
|
||||
primaryColor: '#ff0000',
|
||||
},
|
||||
},
|
||||
} as unknown as any)
|
||||
render(<InputsFormNode collapsed={false} setCollapsed={setCollapsed} />)
|
||||
const button = screen.getByTestId('inputs-form-start-chat-button')
|
||||
expect(button).toHaveStyle({ backgroundColor: '#ff0000' })
|
||||
})
|
||||
|
||||
it('should apply tryApp styles when appSourceType is tryApp', () => {
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
|
||||
...mockContextValue,
|
||||
appSourceType: AppSourceType.tryApp,
|
||||
} as unknown as any)
|
||||
render(<InputsFormNode collapsed={false} setCollapsed={setCollapsed} />)
|
||||
const mainDiv = screen.getByTestId('inputs-form-node')
|
||||
expect(mainDiv).toHaveClass('mb-0 px-0')
|
||||
})
|
||||
})
|
||||
@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import InputsFormContent from '@/app/components/base/chat/embedded-chatbot/inputs-form/content'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { Message3Fill } from '@/app/components/base/icons/src/public/other'
|
||||
import { AppSourceType } from '@/service/share'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useEmbeddedChatbotContext } from '../context'
|
||||
@ -33,7 +32,10 @@ const InputsFormNode = ({
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className={cn('mb-6 flex flex-col items-center px-4 pt-6', isMobile && 'mb-4 pt-4', isTryApp && 'mb-0 px-0')}>
|
||||
<div
|
||||
data-testid="inputs-form-node"
|
||||
className={cn('mb-6 flex flex-col items-center px-4 pt-6', isMobile && 'mb-4 pt-4', isTryApp && 'mb-0 px-0')}
|
||||
>
|
||||
<div className={cn(
|
||||
'w-full max-w-[672px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-md',
|
||||
collapsed && 'border border-components-card-border bg-components-card-bg shadow-none',
|
||||
@ -46,13 +48,29 @@ const InputsFormNode = ({
|
||||
isMobile && 'px-4 py-3',
|
||||
)}
|
||||
>
|
||||
<Message3Fill className="h-6 w-6 shrink-0" />
|
||||
<div className="system-xl-semibold grow text-text-secondary">{t('chat.chatSettingsTitle', { ns: 'share' })}</div>
|
||||
<div className="i-custom-public-other-message-3-fill h-6 w-6 shrink-0" />
|
||||
<div className="grow text-text-secondary system-xl-semibold">{t('chat.chatSettingsTitle', { ns: 'share' })}</div>
|
||||
{collapsed && (
|
||||
<Button className="uppercase text-text-tertiary" size="small" variant="ghost" onClick={() => setCollapsed(false)}>{t('operation.edit', { ns: 'common' })}</Button>
|
||||
<Button
|
||||
className="uppercase text-text-tertiary"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
onClick={() => setCollapsed(false)}
|
||||
data-testid="inputs-form-edit-button"
|
||||
>
|
||||
{t('operation.edit', { ns: 'common' })}
|
||||
</Button>
|
||||
)}
|
||||
{!collapsed && currentConversationId && (
|
||||
<Button className="uppercase text-text-tertiary" size="small" variant="ghost" onClick={() => setCollapsed(true)}>{t('operation.close', { ns: 'common' })}</Button>
|
||||
<Button
|
||||
className="uppercase text-text-tertiary"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
onClick={() => setCollapsed(true)}
|
||||
data-testid="inputs-form-close-button"
|
||||
>
|
||||
{t('operation.close', { ns: 'common' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
@ -66,6 +84,7 @@ const InputsFormNode = ({
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={() => handleStartChat(() => setCollapsed(true))}
|
||||
data-testid="inputs-form-start-chat-button"
|
||||
style={
|
||||
themeBuilder?.theme
|
||||
? {
|
||||
|
||||
@ -0,0 +1,53 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ViewFormDropdown from './view-form-dropdown'
|
||||
|
||||
// Mock InputsFormContent to avoid complex integration in this test
|
||||
vi.mock('./content', () => ({
|
||||
default: () => <div data-testid="mock-inputs-form-content" />,
|
||||
}))
|
||||
|
||||
// Note: PortalToFollowElem is mocked globally in vitest.setup.ts
|
||||
// to render children in the normal DOM flow when open is true.
|
||||
|
||||
describe('ViewFormDropdown', () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
it('should render the trigger button', () => {
|
||||
render(<ViewFormDropdown />)
|
||||
expect(screen.getByTestId('view-form-dropdown-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show content initially', () => {
|
||||
render(<ViewFormDropdown />)
|
||||
expect(screen.queryByTestId('view-form-dropdown-content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show content when trigger is clicked', async () => {
|
||||
render(<ViewFormDropdown />)
|
||||
await user.click(screen.getByTestId('view-form-dropdown-trigger'))
|
||||
|
||||
expect(screen.getByTestId('view-form-dropdown-content')).toBeInTheDocument()
|
||||
expect(screen.getByText(/chat.chatSettingsTitle/i)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('mock-inputs-form-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close content when trigger is clicked again', async () => {
|
||||
render(<ViewFormDropdown />)
|
||||
const trigger = screen.getByTestId('view-form-dropdown-trigger')
|
||||
|
||||
await user.click(trigger) // Open
|
||||
expect(screen.getByTestId('view-form-dropdown-content')).toBeInTheDocument()
|
||||
|
||||
await user.click(trigger) // Close
|
||||
expect(screen.queryByTestId('view-form-dropdown-content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply iconColor class to the icon', async () => {
|
||||
render(<ViewFormDropdown iconColor="text-red-500" />)
|
||||
await user.click(screen.getByTestId('view-form-dropdown-trigger'))
|
||||
|
||||
const icon = screen.getByTestId('view-form-dropdown-trigger').querySelector('.i-ri-chat-settings-line')
|
||||
expect(icon).toHaveClass('text-red-500')
|
||||
})
|
||||
})
|
||||
@ -1,18 +1,18 @@
|
||||
import {
|
||||
RiChatSettingsLine,
|
||||
} from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
|
||||
import InputsFormContent from '@/app/components/base/chat/embedded-chatbot/inputs-form/content'
|
||||
import { Message3Fill } from '@/app/components/base/icons/src/public/other'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
iconColor?: string
|
||||
}
|
||||
const ViewFormDropdown = ({ iconColor }: Props) => {
|
||||
|
||||
const ViewFormDropdown = ({
|
||||
iconColor,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
@ -26,18 +26,23 @@ const ViewFormDropdown = ({ iconColor }: Props) => {
|
||||
crossAxis: 4,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => setOpen(v => !v)}
|
||||
>
|
||||
<ActionButton size="l" state={open ? ActionButtonState.Hover : ActionButtonState.Default}>
|
||||
<RiChatSettingsLine className={cn('h-[18px] w-[18px]', iconColor)} />
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
<ActionButton
|
||||
size="l"
|
||||
state={open ? ActionButtonState.Hover : ActionButtonState.Default}
|
||||
data-testid="view-form-dropdown-trigger"
|
||||
>
|
||||
<div className={cn('i-ri-chat-settings-line h-[18px] w-[18px] shrink-0', iconColor)} />
|
||||
</ActionButton>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[99]">
|
||||
<div className="w-[400px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg backdrop-blur-sm">
|
||||
<div
|
||||
data-testid="view-form-dropdown-content"
|
||||
className="w-[400px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg backdrop-blur-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3 rounded-t-2xl border-b border-divider-subtle px-6 py-4">
|
||||
<Message3Fill className="h-6 w-6 shrink-0" />
|
||||
<div className="system-xl-semibold grow text-text-secondary">{t('chat.chatSettingsTitle', { ns: 'share' })}</div>
|
||||
<div className="i-custom-public-other-message-3-fill h-6 w-6 shrink-0" />
|
||||
<div className="grow text-text-secondary system-xl-semibold">{t('chat.chatSettingsTitle', { ns: 'share' })}</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<InputsFormContent />
|
||||
@ -45,7 +50,6 @@ const ViewFormDropdown = ({ iconColor }: Props) => {
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1553,11 +1553,6 @@
|
||||
"count": 7
|
||||
}
|
||||
},
|
||||
"app/components/base/chat/embedded-chatbot/header/index.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/base/chat/embedded-chatbot/hooks.tsx": {
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 3
|
||||
@ -1569,23 +1564,10 @@
|
||||
}
|
||||
},
|
||||
"app/components/base/chat/embedded-chatbot/inputs-form/content.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 3
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"app/components/base/chat/embedded-chatbot/inputs-form/index.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/chat/utils.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 10
|
||||
|
||||
Loading…
Reference in New Issue
Block a user