mirror of
https://github.com/langgenius/dify.git
synced 2026-07-31 09:19:29 +08:00
fix(chat): derive themes per chat root (#39790)
This commit is contained in:
parent
44a3ea7c0c
commit
3d01e8389a
@ -1,11 +1,11 @@
|
||||
import type { RefObject } from 'react'
|
||||
import type { ChatConfig } from '@/app/components/base/chat/types'
|
||||
import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share'
|
||||
import { fireEvent, renderHook, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ChatWithHistory from '@/app/components/base/chat/chat-with-history'
|
||||
import { useChatWithHistoryContext } from '@/app/components/base/chat/chat-with-history/context'
|
||||
import { useChatWithHistory } from '@/app/components/base/chat/chat-with-history/hooks'
|
||||
import { useThemeContext } from '@/app/components/base/chat/embedded-chatbot/theme/theme-context'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
@ -14,6 +14,20 @@ vi.mock('@/app/components/base/chat/chat-with-history/hooks', () => ({
|
||||
useChatWithHistory: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/chat/chat-with-history/chat-wrapper', () => {
|
||||
const ChatThemeProbe = () => {
|
||||
const { theme } = useChatWithHistoryContext()
|
||||
|
||||
return (
|
||||
<span aria-label="chat theme">
|
||||
{theme?.primaryColor}:{String(theme?.chatColorThemeInverted)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return { default: ChatThemeProbe }
|
||||
})
|
||||
|
||||
vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
default: vi.fn(),
|
||||
MediaType: {
|
||||
@ -109,24 +123,17 @@ describe('Base Chat Flow', () => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useBreakpoints).mockReturnValue(MediaType.pc)
|
||||
vi.mocked(useChatWithHistory).mockReturnValue(defaultHookReturn)
|
||||
renderHook(() => useThemeContext()).result.current.buildTheme()
|
||||
})
|
||||
|
||||
// Chat-with-history shell integration across layout, responsive shell, and theme setup.
|
||||
describe('Chat With History Shell', () => {
|
||||
it('builds theme, updates the document title, and expands the collapsed desktop sidebar on hover', async () => {
|
||||
const themeBuilder = renderHook(() => useThemeContext()).result.current
|
||||
it('updates the document title and expands the collapsed desktop sidebar on hover', () => {
|
||||
const { container } = render(<ChatWithHistory className="chat-history-shell" />)
|
||||
|
||||
const titles = screen.getAllByText('Test Chat')
|
||||
expect(titles.length).toBeGreaterThan(0)
|
||||
expect(useDocumentTitle).toHaveBeenCalledWith('Test Chat')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(themeBuilder.theme.primaryColor).toBe('blue')
|
||||
expect(themeBuilder.theme.chatColorThemeInverted).toBe(false)
|
||||
})
|
||||
|
||||
vi.mocked(useChatWithHistory).mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
sidebarCollapseState: true,
|
||||
@ -147,6 +154,27 @@ describe('Base Chat Flow', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('renders a new theme when site configuration changes', () => {
|
||||
const { rerender } = render(<ChatWithHistory />)
|
||||
|
||||
expect(screen.getByLabelText('chat theme')).toHaveTextContent('blue:false')
|
||||
|
||||
vi.mocked(useChatWithHistory).mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
appData: {
|
||||
...mockAppData,
|
||||
site: {
|
||||
...mockAppData.site,
|
||||
chat_color_theme: '#654321',
|
||||
chat_color_theme_inverted: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
rerender(<ChatWithHistory />)
|
||||
|
||||
expect(screen.getByLabelText('chat theme')).toHaveTextContent('#654321:true')
|
||||
})
|
||||
|
||||
it('falls back to the mobile loading shell when site metadata is unavailable', () => {
|
||||
vi.mocked(useBreakpoints).mockReturnValue(MediaType.mobile)
|
||||
vi.mocked(useChatWithHistory).mockReturnValue({
|
||||
|
||||
@ -18,18 +18,9 @@ vi.mock('../style.module.css', () => ({
|
||||
pluginInstallIcon: 'pluginInstallIcon',
|
||||
},
|
||||
}))
|
||||
const mockThemeBuilder = {
|
||||
buildTheme: vi.fn(),
|
||||
theme: {
|
||||
primaryColor: '#123456',
|
||||
},
|
||||
}
|
||||
vi.mock('copy-to-clipboard', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/app/components/base/chat/embedded-chatbot/theme/theme-context', () => ({
|
||||
useThemeContext: () => mockThemeBuilder,
|
||||
}))
|
||||
const mockWindowOpen = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
const mockedCopy = vi.mocked(copy)
|
||||
const originalCompressionStream = globalThis.CompressionStream
|
||||
@ -83,7 +74,7 @@ describe('Embedded', () => {
|
||||
globalThis.CompressionStream = originalCompressionStream
|
||||
})
|
||||
|
||||
it('builds theme and copies iframe snippet', async () => {
|
||||
it('copies iframe snippet', async () => {
|
||||
await act(async () => {
|
||||
render(<Embedded {...baseProps} />)
|
||||
})
|
||||
@ -103,10 +94,6 @@ describe('Embedded', () => {
|
||||
fireEvent.click(innerDiv ?? actionButton)
|
||||
})
|
||||
|
||||
expect(mockThemeBuilder.buildTheme).toHaveBeenCalledWith(
|
||||
siteInfo.chat_color_theme,
|
||||
siteInfo.chat_color_theme_inverted,
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(mockedCopy).toHaveBeenCalledWith(expect.stringContaining('/chatbot/token'))
|
||||
})
|
||||
@ -217,6 +204,7 @@ describe('Embedded', () => {
|
||||
await waitFor(() => {
|
||||
const codeBlock = document.querySelector('pre')
|
||||
expect(codeBlock?.textContent ?? '').toContain("token: 'token'")
|
||||
expect(codeBlock?.textContent ?? '').toContain('background-color: #000000')
|
||||
})
|
||||
|
||||
const actionButton = getCopyButton()
|
||||
|
||||
@ -10,10 +10,10 @@ import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgeni
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { Suspense, use, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Suspense, use, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import { useThemeContext } from '@/app/components/base/chat/embedded-chatbot/theme/theme-context'
|
||||
import { createTheme } from '@/app/components/base/chat/embedded-chatbot/theme/theme'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { langGeniusVersionInfoAtom } from '@/context/version-state'
|
||||
import { basePath } from '@/utils/var'
|
||||
@ -142,7 +142,10 @@ const EmbeddedContent = ({
|
||||
const latestResolvedIframeUrlRef = useRef('')
|
||||
|
||||
const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom)
|
||||
const themeBuilder = useThemeContext()
|
||||
const theme = createTheme(
|
||||
siteInfo?.chat_color_theme ?? null,
|
||||
siteInfo?.chat_color_theme_inverted ?? false,
|
||||
)
|
||||
const isTestEnv =
|
||||
langGeniusVersionInfo.current_env === 'TESTING' ||
|
||||
langGeniusVersionInfo.current_env === 'DEVELOPMENT'
|
||||
@ -171,18 +174,11 @@ const EmbeddedContent = ({
|
||||
url: appBaseUrl,
|
||||
token: accessToken,
|
||||
webAppRoute,
|
||||
primaryColor: themeBuilder.theme?.primaryColor ?? '#1C64F2',
|
||||
primaryColor: theme.primaryColor,
|
||||
isTestEnv,
|
||||
inputValues: hiddenInputValues,
|
||||
}),
|
||||
[
|
||||
accessToken,
|
||||
appBaseUrl,
|
||||
hiddenInputValues,
|
||||
isTestEnv,
|
||||
themeBuilder.theme?.primaryColor,
|
||||
webAppRoute,
|
||||
],
|
||||
[accessToken, appBaseUrl, hiddenInputValues, isTestEnv, theme.primaryColor, webAppRoute],
|
||||
)
|
||||
|
||||
const onClickCopy = async () => {
|
||||
@ -218,13 +214,6 @@ const EmbeddedContent = ({
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
themeBuilder.buildTheme(
|
||||
siteInfo?.chat_color_theme ?? null,
|
||||
siteInfo?.chat_color_theme_inverted ?? false,
|
||||
)
|
||||
}, [siteInfo?.chat_color_theme, siteInfo?.chat_color_theme_inverted, themeBuilder])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-8 mb-4 system-sm-medium text-text-primary">
|
||||
|
||||
@ -37,12 +37,6 @@ vi.mock('@/next/navigation', () => ({
|
||||
useParams: vi.fn(() => ({})),
|
||||
}))
|
||||
|
||||
vi.mock('../../embedded-chatbot/theme/theme-context', () => ({
|
||||
useThemeContext: vi.fn(() => ({
|
||||
buildTheme: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/dropdown-menu', () => import('@/__mocks__/base-ui-dropdown-menu'))
|
||||
vi.mock('@langgenius/dify-ui/tooltip', () => import('@/__mocks__/base-ui-tooltip'))
|
||||
|
||||
|
||||
@ -48,7 +48,7 @@ const ChatWrapper = () => {
|
||||
handleFeedback,
|
||||
currentChatInstanceRef,
|
||||
appData,
|
||||
themeBuilder,
|
||||
theme,
|
||||
sidebarCollapseState,
|
||||
clearChatList,
|
||||
setClearChatList,
|
||||
@ -456,7 +456,7 @@ const ChatWrapper = () => {
|
||||
suggestedQuestions={suggestedQuestions}
|
||||
answerIcon={answerIcon}
|
||||
hideProcessDetail
|
||||
themeBuilder={themeBuilder}
|
||||
theme={theme}
|
||||
switchSibling={doSwitchSibling}
|
||||
inputDisabled={inputDisabled}
|
||||
sidebarCollapseState={sidebarCollapseState}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import type { RefObject } from 'react'
|
||||
import type { ChatProps } from '../chat'
|
||||
import type { ThemeBuilder } from '../embedded-chatbot/theme/theme-context'
|
||||
import type { Theme } from '../embedded-chatbot/theme/theme'
|
||||
import type { Callback, ChatConfig, ChatItemInTree, Feedback } from '../types'
|
||||
import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
@ -37,7 +37,7 @@ export type ChatWithHistoryContextValue = {
|
||||
appId?: string
|
||||
handleFeedback: (messageId: string, feedback: Feedback) => void
|
||||
currentChatInstanceRef: RefObject<{ handleStop: () => void }>
|
||||
themeBuilder?: ThemeBuilder
|
||||
theme?: Theme
|
||||
sidebarCollapseState?: boolean
|
||||
handleSidebarCollapse: (state: boolean) => void
|
||||
clearChatList?: boolean
|
||||
|
||||
@ -7,7 +7,7 @@ import { useEffect, useState } from 'react'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useThemeContext } from '../embedded-chatbot/theme/theme-context'
|
||||
import { createTheme } from '../embedded-chatbot/theme/theme'
|
||||
import ChatWrapper from './chat-wrapper'
|
||||
import { ChatWithHistoryContext, useChatWithHistoryContext } from './context'
|
||||
import Header from './header'
|
||||
@ -19,24 +19,13 @@ type ChatWithHistoryProps = {
|
||||
className?: string
|
||||
}
|
||||
const ChatWithHistory: FC<ChatWithHistoryProps> = ({ className }) => {
|
||||
const {
|
||||
appData,
|
||||
appChatListDataLoading,
|
||||
chatShouldReloadKey,
|
||||
isMobile,
|
||||
themeBuilder,
|
||||
sidebarCollapseState,
|
||||
} = useChatWithHistoryContext()
|
||||
const { appData, appChatListDataLoading, chatShouldReloadKey, isMobile, sidebarCollapseState } =
|
||||
useChatWithHistoryContext()
|
||||
const isSidebarCollapsed = sidebarCollapseState
|
||||
const customConfig = appData?.custom_config
|
||||
const site = appData?.site
|
||||
|
||||
const [showSidePanel, setShowSidePanel] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
themeBuilder?.buildTheme(site?.chat_color_theme, site?.chat_color_theme_inverted)
|
||||
}, [site, customConfig, themeBuilder])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSidebarCollapsed) setShowSidePanel(false)
|
||||
}, [isSidebarCollapsed])
|
||||
@ -100,7 +89,6 @@ const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({
|
||||
}) => {
|
||||
const media = useBreakpoints()
|
||||
const isMobile = media === MediaType.mobile
|
||||
const themeBuilder = useThemeContext()
|
||||
|
||||
const {
|
||||
appData,
|
||||
@ -141,6 +129,10 @@ const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({
|
||||
allInputsHidden,
|
||||
initUserVariables,
|
||||
} = useChatWithHistory(installedAppInfo)
|
||||
const theme = createTheme(
|
||||
appData?.site?.chat_color_theme ?? null,
|
||||
appData?.site?.chat_color_theme_inverted ?? false,
|
||||
)
|
||||
|
||||
return (
|
||||
<ChatWithHistoryContext.Provider
|
||||
@ -173,7 +165,7 @@ const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({
|
||||
appId,
|
||||
handleFeedback,
|
||||
currentChatInstanceRef,
|
||||
themeBuilder,
|
||||
theme,
|
||||
sidebarCollapseState,
|
||||
handleSidebarCollapse,
|
||||
clearChatList,
|
||||
|
||||
@ -44,7 +44,7 @@ const defaultContextValues: Partial<ChatWithHistoryContextValue> = {
|
||||
currentConversationId: '',
|
||||
handleStartChat: mockHandleStartChat,
|
||||
allInputsHidden: false,
|
||||
themeBuilder: undefined,
|
||||
theme: undefined,
|
||||
inputsForms: [{ variable: 'test_var', type: InputVarType.textInput, label: 'Test Label' }],
|
||||
currentConversationInputs: {},
|
||||
newConversationInputs: {},
|
||||
@ -112,9 +112,7 @@ describe('InputsFormNode', () => {
|
||||
|
||||
setMockContext({
|
||||
currentConversationId: '',
|
||||
themeBuilder: {
|
||||
theme: { primaryColor: themeColor },
|
||||
} as unknown as ChatWithHistoryContextValue['themeBuilder'],
|
||||
theme: { primaryColor: themeColor } as unknown as ChatWithHistoryContextValue['theme'],
|
||||
})
|
||||
|
||||
render(<InputsFormNode collapsed={false} setCollapsed={setCollapsed} />)
|
||||
|
||||
@ -14,14 +14,8 @@ type Props = Readonly<{
|
||||
|
||||
const InputsFormNode = ({ collapsed, setCollapsed }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
isMobile,
|
||||
currentConversationId,
|
||||
handleStartChat,
|
||||
allInputsHidden,
|
||||
themeBuilder,
|
||||
inputsForms,
|
||||
} = useChatWithHistoryContext()
|
||||
const { isMobile, currentConversationId, handleStartChat, allInputsHidden, theme, inputsForms } =
|
||||
useChatWithHistoryContext()
|
||||
|
||||
if (allInputsHidden || inputsForms.length === 0) return null
|
||||
|
||||
@ -77,9 +71,9 @@ const InputsFormNode = ({ collapsed, setCollapsed }: Props) => {
|
||||
className="w-full"
|
||||
onClick={() => handleStartChat(() => setCollapsed(true))}
|
||||
style={
|
||||
themeBuilder?.theme
|
||||
theme
|
||||
? {
|
||||
backgroundColor: themeBuilder?.theme.primaryColor,
|
||||
backgroundColor: theme.primaryColor,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import type { SpeechToTextTarget } from '@/app/components/base/voice-input/types
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { createTheme } from '../../embedded-chatbot/theme/theme'
|
||||
import Chat from '../index'
|
||||
|
||||
// ─── Why each mock exists ─────────────────────────────────────────────────────
|
||||
@ -33,8 +34,8 @@ vi.mock('../answer', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../question', () => ({
|
||||
default: ({ item }: { item: ChatItem }) => (
|
||||
<div data-testid="question-item" data-id={item.id}>
|
||||
default: ({ item, theme }: { item: ChatItem; theme?: { primaryColor: string } }) => (
|
||||
<div data-testid="question-item" data-id={item.id} data-theme-color={theme?.primaryColor}>
|
||||
{item.content}
|
||||
</div>
|
||||
),
|
||||
@ -48,6 +49,7 @@ vi.mock('../chat-input-area', () => ({
|
||||
footerNotice,
|
||||
onBeforeSpeechToText,
|
||||
speechToTextTarget,
|
||||
theme,
|
||||
}: {
|
||||
customPlaceholder?: string
|
||||
disabled?: boolean
|
||||
@ -55,6 +57,7 @@ vi.mock('../chat-input-area', () => ({
|
||||
footerNotice?: string
|
||||
onBeforeSpeechToText?: () => Promise<unknown>
|
||||
speechToTextTarget?: SpeechToTextTarget
|
||||
theme?: { primaryColor: string }
|
||||
}) => (
|
||||
<div
|
||||
data-testid="chat-input-area"
|
||||
@ -70,6 +73,7 @@ vi.mock('../chat-input-area', () => ({
|
||||
? speechToTextTarget.appSourceType
|
||||
: speechToTextTarget?.type
|
||||
}
|
||||
data-theme-color={theme?.primaryColor}
|
||||
>
|
||||
{footerNotice}
|
||||
</div>
|
||||
@ -668,15 +672,14 @@ describe('Chat', () => {
|
||||
expect(screen.getByTestId('question-item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass theme from themeBuilder to Question', () => {
|
||||
const mockTheme = { chatBubbleColorStyle: 'test' }
|
||||
const themeBuilder = { theme: mockTheme }
|
||||
it('should pass theme to Question', () => {
|
||||
const theme = createTheme('#123456')
|
||||
|
||||
renderChat({
|
||||
themeBuilder: themeBuilder as unknown as ChatProps['themeBuilder'],
|
||||
theme,
|
||||
chatList: [makeChatItem({ id: 'q1', isAnswer: false })],
|
||||
})
|
||||
expect(screen.getByTestId('question-item')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('question-item')).toHaveAttribute('data-theme-color', '#123456')
|
||||
})
|
||||
|
||||
it('should pass switchSibling to Question component', () => {
|
||||
@ -944,15 +947,14 @@ describe('Chat', () => {
|
||||
expect(screen.getByTestId('chat-input-area')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass theme from themeBuilder to ChatInputArea', () => {
|
||||
const mockTheme = { someThemeProperty: true }
|
||||
const themeBuilder = { theme: mockTheme }
|
||||
it('should pass theme to ChatInputArea', () => {
|
||||
const theme = createTheme('#654321')
|
||||
|
||||
renderChat({
|
||||
noChatInput: false,
|
||||
themeBuilder: themeBuilder as unknown as ChatProps['themeBuilder'],
|
||||
theme,
|
||||
})
|
||||
expect(screen.getByTestId('chat-input-area')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('chat-input-area')).toHaveAttribute('data-theme-color', '#654321')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { Theme } from '../../embedded-chatbot/theme/theme-context'
|
||||
import type { Theme } from '../../embedded-chatbot/theme/theme'
|
||||
import type { ChatConfig, ChatItem, OnRegenerate } from '../../types'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
@ -6,7 +6,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import * as React from 'react'
|
||||
import { ThemeBuilder } from '../../embedded-chatbot/theme/theme-context'
|
||||
import { createTheme } from '../../embedded-chatbot/theme/theme'
|
||||
import { ChatContextProvider } from '../context-provider'
|
||||
import Question from '../question'
|
||||
|
||||
@ -402,9 +402,7 @@ describe('Question component', () => {
|
||||
})
|
||||
|
||||
it('should apply theme bubble styles when theme provided', () => {
|
||||
const themeBuilder = new ThemeBuilder()
|
||||
themeBuilder.buildTheme('#ff0000', false)
|
||||
const theme = themeBuilder.theme
|
||||
const theme = createTheme('#ff0000')
|
||||
|
||||
renderWithProvider(makeItem(), vi.fn() as unknown as OnRegenerate, { theme })
|
||||
|
||||
@ -783,9 +781,7 @@ describe('Question component', () => {
|
||||
})
|
||||
|
||||
it('should render theme styles only in non-edit mode', () => {
|
||||
const themeBuilder = new ThemeBuilder()
|
||||
themeBuilder.buildTheme('#00ff00', true)
|
||||
const theme = themeBuilder.theme
|
||||
const theme = createTheme('#00ff00', true)
|
||||
|
||||
renderWithProvider(makeItem(), vi.fn() as unknown as OnRegenerate, { theme })
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Theme } from '../../embedded-chatbot/theme/theme-context'
|
||||
import type { Theme } from '../../embedded-chatbot/theme/theme'
|
||||
import type { EnableType, OnSend } from '../../types'
|
||||
import type { InputForm } from '../type'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { FC, Ref } from 'react'
|
||||
import type { Theme } from '../../embedded-chatbot/theme/theme-context'
|
||||
import type { Theme } from '../../embedded-chatbot/theme/theme'
|
||||
import type { EnableType } from '../../types'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import type { ThemeBuilder } from '../embedded-chatbot/theme/theme-context'
|
||||
import type { Theme } from '../embedded-chatbot/theme/theme'
|
||||
import type { ChatConfig, ChatItem, Feedback, OnRegenerate, OnSend } from '../types'
|
||||
import type { HumanInputFormSubmitData } from './answer/human-input-content/type'
|
||||
import type { AnswerActionPosition } from './answer/operation'
|
||||
@ -62,7 +62,7 @@ export type ChatProps = {
|
||||
chatAnswerContainerInner?: string
|
||||
hideProcessDetail?: boolean
|
||||
hideLogModal?: boolean
|
||||
themeBuilder?: ThemeBuilder
|
||||
theme?: Theme
|
||||
switchSibling?: (siblingMessageId: string) => void
|
||||
showFeatureBar?: boolean
|
||||
showFileUpload?: boolean
|
||||
@ -123,7 +123,7 @@ const Chat: FC<ChatProps> = ({
|
||||
chatAnswerContainerInner,
|
||||
hideProcessDetail,
|
||||
hideLogModal,
|
||||
themeBuilder,
|
||||
theme,
|
||||
switchSibling,
|
||||
showFeatureBar,
|
||||
showFileUpload,
|
||||
@ -242,7 +242,7 @@ const Chat: FC<ChatProps> = ({
|
||||
key={item.id}
|
||||
item={item}
|
||||
questionIcon={questionIcon}
|
||||
theme={themeBuilder?.theme}
|
||||
theme={theme}
|
||||
enableEdit={config?.questionEditEnable}
|
||||
switchSibling={switchSibling}
|
||||
hideAvatar={hideAvatar}
|
||||
@ -298,7 +298,7 @@ const Chat: FC<ChatProps> = ({
|
||||
onSend={onSend}
|
||||
inputs={inputs}
|
||||
inputsForm={inputsForm}
|
||||
theme={themeBuilder?.theme}
|
||||
theme={theme}
|
||||
isResponding={isResponding}
|
||||
readonly={readonly}
|
||||
sendButtonLabel={sendButtonLabel}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import type { Theme } from '../embedded-chatbot/theme/theme-context'
|
||||
import type { Theme } from '../embedded-chatbot/theme/theme'
|
||||
import type { ChatItem } from '../types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
|
||||
@ -190,7 +190,7 @@ const createContextValue = (
|
||||
disableFeedback: false,
|
||||
handleFeedback: vi.fn(),
|
||||
currentChatInstanceRef: { current: { handleStop: vi.fn() } },
|
||||
themeBuilder: undefined,
|
||||
theme: undefined,
|
||||
clearChatList: false,
|
||||
setClearChatList: vi.fn(),
|
||||
isResponding: false,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { ReactElement, RefObject } from 'react'
|
||||
import type { ChatConfig } from '../../types'
|
||||
import type { Theme } from '../theme/theme'
|
||||
import type { AppData, AppMeta, ConversationItem } from '@/models/share'
|
||||
import { screen } from '@testing-library/react'
|
||||
import { vi } from 'vitest'
|
||||
@ -40,16 +41,14 @@ vi.mock('../chat-wrapper', () => ({
|
||||
|
||||
vi.mock('../header', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>chat header</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../theme/theme-context', () => ({
|
||||
useThemeContext: vi.fn(() => ({
|
||||
buildTheme: vi.fn(),
|
||||
theme: {
|
||||
backgroundHeaderColorStyle: '',
|
||||
},
|
||||
})),
|
||||
default: ({ theme }: { theme?: Theme }) => (
|
||||
<div role="banner">
|
||||
<span>chat header</span>
|
||||
<span aria-label="chat theme">
|
||||
{theme?.primaryColor}:{String(theme?.chatColorThemeInverted)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const mockIsDify = vi.fn(() => false)
|
||||
@ -59,25 +58,25 @@ vi.mock('../utils', () => ({
|
||||
|
||||
type EmbeddedChatbotHookReturn = ReturnType<typeof useEmbeddedChatbot>
|
||||
|
||||
const createAppData = (chatColorTheme = 'blue', chatColorThemeInverted = false): 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: chatColorTheme,
|
||||
chat_color_theme_inverted: chatColorThemeInverted,
|
||||
},
|
||||
})
|
||||
|
||||
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,
|
||||
@ -86,7 +85,7 @@ const createHookReturn = (
|
||||
currentConversationItem: undefined,
|
||||
removeConversationIdInfo: vi.fn(),
|
||||
handleConversationIdInfoChange: vi.fn(),
|
||||
appData,
|
||||
appData: createAppData(),
|
||||
appParams: {} as ChatConfig,
|
||||
appMeta: { tool_icons: {} } as AppMeta,
|
||||
appPinnedConversationData: { data: [], has_more: false, limit: 20 },
|
||||
@ -154,6 +153,53 @@ describe('EmbeddedChatbot index', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Theme ownership', () => {
|
||||
it('keeps themes isolated between chat roots', () => {
|
||||
vi.mocked(useEmbeddedChatbot)
|
||||
.mockReturnValueOnce(
|
||||
createHookReturn({
|
||||
appData: createAppData('#FF0000'),
|
||||
}),
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
createHookReturn({
|
||||
appData: createAppData('#00FF00', true),
|
||||
}),
|
||||
)
|
||||
|
||||
render(
|
||||
<>
|
||||
<EmbeddedChatbot />
|
||||
<EmbeddedChatbot />
|
||||
</>,
|
||||
)
|
||||
|
||||
expect(screen.getAllByLabelText('chat theme')).toHaveLength(2)
|
||||
expect(screen.getAllByLabelText('chat theme')[0]).toHaveTextContent('#FF0000:false')
|
||||
expect(screen.getAllByLabelText('chat theme')[1]).toHaveTextContent('#00FF00:true')
|
||||
})
|
||||
|
||||
it('renders a new theme when site configuration changes', () => {
|
||||
vi.mocked(useEmbeddedChatbot).mockReturnValue(
|
||||
createHookReturn({
|
||||
appData: createAppData('#123456'),
|
||||
}),
|
||||
)
|
||||
const { rerender } = render(<EmbeddedChatbot />)
|
||||
|
||||
expect(screen.getByLabelText('chat theme')).toHaveTextContent('#123456:false')
|
||||
|
||||
vi.mocked(useEmbeddedChatbot).mockReturnValue(
|
||||
createHookReturn({
|
||||
appData: createAppData('#654321', true),
|
||||
}),
|
||||
)
|
||||
rerender(<EmbeddedChatbot />)
|
||||
|
||||
expect(screen.getByLabelText('chat theme')).toHaveTextContent('#654321:true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Powered by branding', () => {
|
||||
it('should show workspace logo on mobile when branding is enabled', () => {
|
||||
mockBrandingWorkspaceLogo = 'https://example.com/workspace-logo.png'
|
||||
|
||||
@ -49,7 +49,7 @@ const ChatWrapper = () => {
|
||||
disableFeedback,
|
||||
handleFeedback,
|
||||
currentChatInstanceRef,
|
||||
themeBuilder,
|
||||
theme,
|
||||
clearChatList,
|
||||
setClearChatList,
|
||||
setIsResponding,
|
||||
@ -464,7 +464,7 @@ const ChatWrapper = () => {
|
||||
suggestedQuestions={suggestedQuestions}
|
||||
answerIcon={answerIcon}
|
||||
hideProcessDetail
|
||||
themeBuilder={themeBuilder}
|
||||
theme={theme}
|
||||
switchSibling={doSwitchSibling}
|
||||
inputDisabled={inputDisabled}
|
||||
sendOnEnter={sendOnEnter}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import type { RefObject } from 'react'
|
||||
import type { ChatConfig, ChatItem, Feedback } from '../types'
|
||||
import type { ThemeBuilder } from './theme/theme-context'
|
||||
import type { Theme } from './theme/theme'
|
||||
import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { createContext, useContext } from 'use-context-selector'
|
||||
@ -35,7 +35,7 @@ export type EmbeddedChatbotContextValue = {
|
||||
disableFeedback?: boolean
|
||||
handleFeedback: (messageId: string, feedback: Feedback) => void
|
||||
currentChatInstanceRef: RefObject<{ handleStop: () => void }>
|
||||
themeBuilder?: ThemeBuilder
|
||||
theme?: Theme
|
||||
clearChatList?: boolean
|
||||
setClearChatList: (state: boolean) => void
|
||||
isResponding?: boolean
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { FC } from 'react'
|
||||
import type { Theme } from '../theme/theme-context'
|
||||
import type { Theme } from '../theme/theme'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
import type { AppData } from '@/models/share'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ChatWrapper from '@/app/components/base/chat/embedded-chatbot/chat-wrapper'
|
||||
import Header from '@/app/components/base/chat/embedded-chatbot/header'
|
||||
@ -15,7 +14,7 @@ import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { AppSourceType } from '@/service/share'
|
||||
import { EmbeddedChatbotContext, useEmbeddedChatbotContext } from './context'
|
||||
import { useEmbeddedChatbot } from './hooks'
|
||||
import { useThemeContext } from './theme/theme-context'
|
||||
import { createTheme } from './theme/theme'
|
||||
import { CssTransform } from './theme/utils'
|
||||
import { isDify } from './utils'
|
||||
|
||||
@ -27,20 +26,15 @@ const Chatbot = () => {
|
||||
appChatListDataLoading,
|
||||
chatShouldReloadKey,
|
||||
handleNewConversation,
|
||||
themeBuilder,
|
||||
theme,
|
||||
} = useEmbeddedChatbotContext()
|
||||
const { t } = useTranslation()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
|
||||
const customConfig = appData?.custom_config
|
||||
const site = appData?.site
|
||||
|
||||
const difyIcon = <LogoHeader />
|
||||
|
||||
useEffect(() => {
|
||||
themeBuilder?.buildTheme(site?.chat_color_theme, site?.chat_color_theme_inverted)
|
||||
}, [site, customConfig, themeBuilder])
|
||||
|
||||
useDocumentTitle(site?.title || 'Chat')
|
||||
|
||||
return (
|
||||
@ -51,9 +45,7 @@ const Chatbot = () => {
|
||||
isMobile ? 'h-[calc(100vh-60px)] shadow-xs' : 'h-screen bg-chatbot-bg',
|
||||
)}
|
||||
style={
|
||||
isMobile
|
||||
? Object.assign({}, CssTransform(themeBuilder?.theme?.backgroundHeaderColorStyle ?? ''))
|
||||
: {}
|
||||
isMobile ? Object.assign({}, CssTransform(theme?.backgroundHeaderColorStyle ?? '')) : {}
|
||||
}
|
||||
>
|
||||
<Header
|
||||
@ -61,7 +53,7 @@ const Chatbot = () => {
|
||||
allowResetChat={allowResetChat}
|
||||
title={site?.title || ''}
|
||||
customerIcon={isDify() ? difyIcon : ''}
|
||||
theme={themeBuilder?.theme}
|
||||
theme={theme}
|
||||
onCreateNewChat={handleNewConversation}
|
||||
/>
|
||||
<div
|
||||
@ -108,7 +100,6 @@ const Chatbot = () => {
|
||||
const EmbeddedChatbotWrapper = () => {
|
||||
const media = useBreakpoints()
|
||||
const isMobile = media === MediaType.mobile
|
||||
const themeBuilder = useThemeContext()
|
||||
|
||||
const {
|
||||
appData,
|
||||
@ -143,6 +134,10 @@ const EmbeddedChatbotWrapper = () => {
|
||||
allInputsHidden,
|
||||
initUserVariables,
|
||||
} = useEmbeddedChatbot(AppSourceType.webApp)
|
||||
const theme = createTheme(
|
||||
appData?.site?.chat_color_theme ?? null,
|
||||
appData?.site?.chat_color_theme_inverted ?? false,
|
||||
)
|
||||
|
||||
return (
|
||||
<EmbeddedChatbotContext.Provider
|
||||
@ -172,7 +167,7 @@ const EmbeddedChatbotWrapper = () => {
|
||||
appId,
|
||||
handleFeedback,
|
||||
currentChatInstanceRef,
|
||||
themeBuilder,
|
||||
theme,
|
||||
clearChatList,
|
||||
setClearChatList,
|
||||
isResponding,
|
||||
|
||||
@ -18,7 +18,7 @@ const mockContextValue = {
|
||||
appSourceType: AppSourceType.webApp,
|
||||
isMobile: false,
|
||||
currentConversationId: null,
|
||||
themeBuilder: null,
|
||||
theme: undefined,
|
||||
handleStartChat: vi.fn(),
|
||||
allInputsHidden: false,
|
||||
inputsForms: [{ variable: 'test' }],
|
||||
@ -98,10 +98,8 @@ describe('InputsFormNode', () => {
|
||||
it('should apply theme primary color to start chat button', () => {
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
|
||||
...mockContextValue,
|
||||
themeBuilder: {
|
||||
theme: {
|
||||
primaryColor: '#ff0000',
|
||||
},
|
||||
theme: {
|
||||
primaryColor: '#ff0000',
|
||||
},
|
||||
} as unknown as any)
|
||||
render(<InputsFormNode collapsed={false} setCollapsed={setCollapsed} />)
|
||||
|
||||
@ -18,7 +18,7 @@ const InputsFormNode = ({ collapsed, setCollapsed }: Props) => {
|
||||
appSourceType,
|
||||
isMobile,
|
||||
currentConversationId,
|
||||
themeBuilder,
|
||||
theme,
|
||||
handleStartChat,
|
||||
allInputsHidden,
|
||||
inputsForms,
|
||||
@ -87,9 +87,9 @@ const InputsFormNode = ({ collapsed, setCollapsed }: Props) => {
|
||||
className="w-full"
|
||||
onClick={() => handleStartChat(() => setCollapsed(true))}
|
||||
style={
|
||||
themeBuilder?.theme
|
||||
theme
|
||||
? {
|
||||
backgroundColor: themeBuilder?.theme.primaryColor,
|
||||
backgroundColor: theme.primaryColor,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
|
||||
@ -1,221 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { Theme, ThemeBuilder, useThemeContext } from '../theme-context'
|
||||
|
||||
// Scenario: Theme class configures colors from chatColorTheme and chatColorThemeInverted flags.
|
||||
describe('Theme', () => {
|
||||
describe('Default colors', () => {
|
||||
it('should use default primary color when chatColorTheme is null', () => {
|
||||
const theme = new Theme(null, false)
|
||||
|
||||
expect(theme.primaryColor).toBe('#1C64F2')
|
||||
})
|
||||
|
||||
it('should use gradient background header when chatColorTheme is null', () => {
|
||||
const theme = new Theme(null, false)
|
||||
|
||||
expect(theme.backgroundHeaderColorStyle).toBe(
|
||||
'backgroundImage: linear-gradient(to right, #2563eb, #0ea5e9)',
|
||||
)
|
||||
})
|
||||
|
||||
it('should have empty chatBubbleColorStyle when chatColorTheme is null', () => {
|
||||
const theme = new Theme(null, false)
|
||||
|
||||
expect(theme.chatBubbleColorStyle).toBe('')
|
||||
})
|
||||
|
||||
it('should use default colors when chatColorTheme is empty string', () => {
|
||||
const theme = new Theme('', false)
|
||||
|
||||
expect(theme.primaryColor).toBe('#1C64F2')
|
||||
expect(theme.backgroundHeaderColorStyle).toBe(
|
||||
'backgroundImage: linear-gradient(to right, #2563eb, #0ea5e9)',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Custom color (configCustomColor)', () => {
|
||||
it('should set primaryColor to chatColorTheme value', () => {
|
||||
const theme = new Theme('#FF5733', false)
|
||||
|
||||
expect(theme.primaryColor).toBe('#FF5733')
|
||||
})
|
||||
|
||||
it('should set backgroundHeaderColorStyle to solid custom color', () => {
|
||||
const theme = new Theme('#FF5733', false)
|
||||
|
||||
expect(theme.backgroundHeaderColorStyle).toBe('backgroundColor: #FF5733')
|
||||
})
|
||||
|
||||
it('should include primary color in backgroundButtonDefaultColorStyle', () => {
|
||||
const theme = new Theme('#FF5733', false)
|
||||
|
||||
expect(theme.backgroundButtonDefaultColorStyle).toContain('#FF5733')
|
||||
})
|
||||
|
||||
it('should set roundedBackgroundColorStyle with 5% opacity rgba', () => {
|
||||
const theme = new Theme('#FF5733', false)
|
||||
|
||||
// #FF5733 → r=255 g=87 b=51
|
||||
expect(theme.roundedBackgroundColorStyle).toBe('backgroundColor: rgba(255,87,51,0.05)')
|
||||
})
|
||||
|
||||
it('should set chatBubbleColorStyle with 15% opacity rgba', () => {
|
||||
const theme = new Theme('#FF5733', false)
|
||||
|
||||
expect(theme.chatBubbleColorStyle).toBe('backgroundColor: rgba(255,87,51,0.15)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Inverted color (configInvertedColor)', () => {
|
||||
it('should use white background header when inverted with no custom color', () => {
|
||||
const theme = new Theme(null, true)
|
||||
|
||||
expect(theme.backgroundHeaderColorStyle).toBe('backgroundColor: #ffffff')
|
||||
})
|
||||
|
||||
it('should set colorFontOnHeaderStyle to default primaryColor when inverted with no custom color', () => {
|
||||
const theme = new Theme(null, true)
|
||||
|
||||
expect(theme.colorFontOnHeaderStyle).toBe('color: #1C64F2')
|
||||
})
|
||||
|
||||
it('should set headerBorderBottomStyle when inverted', () => {
|
||||
const theme = new Theme(null, true)
|
||||
|
||||
expect(theme.headerBorderBottomStyle).toBe('borderBottom: 1px solid #ccc')
|
||||
})
|
||||
|
||||
it('should set colorPathOnHeader to primaryColor when inverted', () => {
|
||||
const theme = new Theme(null, true)
|
||||
|
||||
expect(theme.colorPathOnHeader).toBe('#1C64F2')
|
||||
})
|
||||
|
||||
it('should have empty headerBorderBottomStyle when not inverted', () => {
|
||||
const theme = new Theme(null, false)
|
||||
|
||||
expect(theme.headerBorderBottomStyle).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Custom color + inverted combined', () => {
|
||||
it('should override background to white even when custom color is set', () => {
|
||||
const theme = new Theme('#FF5733', true)
|
||||
|
||||
// configCustomColor runs first (solid bg), then configInvertedColor overrides to white
|
||||
expect(theme.backgroundHeaderColorStyle).toBe('backgroundColor: #ffffff')
|
||||
})
|
||||
|
||||
it('should use custom primaryColor for colorFontOnHeaderStyle when inverted', () => {
|
||||
const theme = new Theme('#FF5733', true)
|
||||
|
||||
expect(theme.colorFontOnHeaderStyle).toBe('color: #FF5733')
|
||||
})
|
||||
|
||||
it('should set colorPathOnHeader to custom primaryColor when inverted', () => {
|
||||
const theme = new Theme('#FF5733', true)
|
||||
|
||||
expect(theme.colorPathOnHeader).toBe('#FF5733')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: ThemeBuilder manages a lazily-created Theme instance and rebuilds on config change.
|
||||
describe('ThemeBuilder', () => {
|
||||
describe('theme getter', () => {
|
||||
it('should create a default Theme when _theme is undefined (first access)', () => {
|
||||
const builder = new ThemeBuilder()
|
||||
|
||||
const theme = builder.theme
|
||||
|
||||
expect(theme).toBeInstanceOf(Theme)
|
||||
expect(theme.primaryColor).toBe('#1C64F2')
|
||||
})
|
||||
|
||||
it('should return the same Theme instance on subsequent accesses', () => {
|
||||
const builder = new ThemeBuilder()
|
||||
|
||||
const first = builder.theme
|
||||
const second = builder.theme
|
||||
|
||||
expect(first).toBe(second)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildTheme', () => {
|
||||
it('should create a Theme with the given color on first call', () => {
|
||||
const builder = new ThemeBuilder()
|
||||
|
||||
builder.buildTheme('#AABBCC', false)
|
||||
|
||||
expect(builder.theme.primaryColor).toBe('#AABBCC')
|
||||
})
|
||||
|
||||
it('should not rebuild the Theme when called again with the same config', () => {
|
||||
const builder = new ThemeBuilder()
|
||||
builder.buildTheme('#AABBCC', false)
|
||||
const themeAfterFirstBuild = builder.theme
|
||||
|
||||
builder.buildTheme('#AABBCC', false)
|
||||
|
||||
// Same instance: no rebuild occurred
|
||||
expect(builder.theme).toBe(themeAfterFirstBuild)
|
||||
})
|
||||
|
||||
it('should rebuild the Theme when chatColorTheme changes', () => {
|
||||
const builder = new ThemeBuilder()
|
||||
builder.buildTheme('#AABBCC', false)
|
||||
const originalTheme = builder.theme
|
||||
|
||||
builder.buildTheme('#FF0000', false)
|
||||
|
||||
expect(builder.theme).not.toBe(originalTheme)
|
||||
expect(builder.theme.primaryColor).toBe('#FF0000')
|
||||
})
|
||||
|
||||
it('should rebuild the Theme when chatColorThemeInverted changes', () => {
|
||||
const builder = new ThemeBuilder()
|
||||
builder.buildTheme('#AABBCC', false)
|
||||
const originalTheme = builder.theme
|
||||
|
||||
builder.buildTheme('#AABBCC', true)
|
||||
|
||||
expect(builder.theme).not.toBe(originalTheme)
|
||||
expect(builder.theme.chatColorThemeInverted).toBe(true)
|
||||
})
|
||||
|
||||
it('should use default args (null, false) when called with no arguments', () => {
|
||||
const builder = new ThemeBuilder()
|
||||
|
||||
builder.buildTheme()
|
||||
|
||||
expect(builder.theme.chatColorTheme).toBeNull()
|
||||
expect(builder.theme.chatColorThemeInverted).toBe(false)
|
||||
})
|
||||
|
||||
it('should store chatColorTheme and chatColorThemeInverted on the built Theme', () => {
|
||||
const builder = new ThemeBuilder()
|
||||
|
||||
builder.buildTheme('#123456', true)
|
||||
|
||||
expect(builder.theme.chatColorTheme).toBe('#123456')
|
||||
expect(builder.theme.chatColorThemeInverted).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: useThemeContext returns a ThemeBuilder from the nearest ThemeContext.
|
||||
describe('useThemeContext', () => {
|
||||
it('should return a ThemeBuilder instance from the default context', () => {
|
||||
const { result } = renderHook(() => useThemeContext())
|
||||
|
||||
expect(result.current).toBeInstanceOf(ThemeBuilder)
|
||||
})
|
||||
|
||||
it('should expose a valid theme on the returned ThemeBuilder', () => {
|
||||
const { result } = renderHook(() => useThemeContext())
|
||||
|
||||
expect(result.current.theme).toBeInstanceOf(Theme)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,59 @@
|
||||
import { createTheme } from '../theme'
|
||||
|
||||
describe('createTheme', () => {
|
||||
it('uses the default palette when no custom color is configured', () => {
|
||||
const theme = createTheme()
|
||||
|
||||
expect(theme).toMatchObject({
|
||||
chatColorTheme: null,
|
||||
chatColorThemeInverted: false,
|
||||
primaryColor: '#1C64F2',
|
||||
backgroundHeaderColorStyle: 'backgroundImage: linear-gradient(to right, #2563eb, #0ea5e9)',
|
||||
headerBorderBottomStyle: '',
|
||||
colorFontOnHeaderStyle: 'color: white',
|
||||
colorPathOnHeader: 'text-text-primary-on-surface',
|
||||
backgroundButtonDefaultColorStyle: 'backgroundColor: #1C64F2',
|
||||
roundedBackgroundColorStyle: 'backgroundColor: rgb(245 248 255)',
|
||||
chatBubbleColorStyle: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('treats an empty custom color as the default palette', () => {
|
||||
expect(createTheme('')).toMatchObject({
|
||||
chatColorTheme: '',
|
||||
primaryColor: '#1C64F2',
|
||||
backgroundHeaderColorStyle: 'backgroundImage: linear-gradient(to right, #2563eb, #0ea5e9)',
|
||||
chatBubbleColorStyle: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('derives the custom palette without mutating another theme', () => {
|
||||
const firstTheme = createTheme('#FF5733')
|
||||
const secondTheme = createTheme('#123456')
|
||||
|
||||
expect(firstTheme).toMatchObject({
|
||||
chatColorTheme: '#FF5733',
|
||||
primaryColor: '#FF5733',
|
||||
backgroundHeaderColorStyle: 'backgroundColor: #FF5733',
|
||||
backgroundButtonDefaultColorStyle: 'backgroundColor: #FF5733; color: color: white;',
|
||||
roundedBackgroundColorStyle: 'backgroundColor: rgba(255,87,51,0.05)',
|
||||
chatBubbleColorStyle: 'backgroundColor: rgba(255,87,51,0.15)',
|
||||
})
|
||||
expect(secondTheme.primaryColor).toBe('#123456')
|
||||
expect(firstTheme.primaryColor).toBe('#FF5733')
|
||||
expect(Object.isFrozen(firstTheme)).toBe(true)
|
||||
expect(Reflect.set(firstTheme, 'primaryColor', '#000000')).toBe(false)
|
||||
expect(firstTheme.primaryColor).toBe('#FF5733')
|
||||
})
|
||||
|
||||
it('derives the inverted palette from the configured primary color', () => {
|
||||
expect(createTheme('#FF5733', true)).toMatchObject({
|
||||
chatColorThemeInverted: true,
|
||||
primaryColor: '#FF5733',
|
||||
backgroundHeaderColorStyle: 'backgroundColor: #ffffff',
|
||||
headerBorderBottomStyle: 'borderBottom: 1px solid #ccc',
|
||||
colorFontOnHeaderStyle: 'color: #FF5733',
|
||||
colorPathOnHeader: '#FF5733',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,74 +0,0 @@
|
||||
import { createContext, useContext } from 'use-context-selector'
|
||||
import { hexToRGBA } from './utils'
|
||||
|
||||
export class Theme {
|
||||
public chatColorTheme: string | null
|
||||
public chatColorThemeInverted: boolean
|
||||
|
||||
public primaryColor = '#1C64F2'
|
||||
public backgroundHeaderColorStyle = 'backgroundImage: linear-gradient(to right, #2563eb, #0ea5e9)'
|
||||
public headerBorderBottomStyle = ''
|
||||
public colorFontOnHeaderStyle = 'color: white'
|
||||
public colorPathOnHeader = 'text-text-primary-on-surface'
|
||||
public backgroundButtonDefaultColorStyle = 'backgroundColor: #1C64F2'
|
||||
public roundedBackgroundColorStyle = 'backgroundColor: rgb(245 248 255)'
|
||||
public chatBubbleColorStyle = ''
|
||||
|
||||
constructor(chatColorTheme: string | null = null, chatColorThemeInverted = false) {
|
||||
this.chatColorTheme = chatColorTheme
|
||||
this.chatColorThemeInverted = chatColorThemeInverted
|
||||
this.configCustomColor()
|
||||
this.configInvertedColor()
|
||||
}
|
||||
|
||||
private configCustomColor() {
|
||||
if (this.chatColorTheme !== null && this.chatColorTheme !== '') {
|
||||
this.primaryColor = this.chatColorTheme ?? '#1C64F2'
|
||||
this.backgroundHeaderColorStyle = `backgroundColor: ${this.primaryColor}`
|
||||
this.backgroundButtonDefaultColorStyle = `backgroundColor: ${this.primaryColor}; color: ${this.colorFontOnHeaderStyle};`
|
||||
this.roundedBackgroundColorStyle = `backgroundColor: ${hexToRGBA(this.primaryColor, 0.05)}`
|
||||
this.chatBubbleColorStyle = `backgroundColor: ${hexToRGBA(this.primaryColor, 0.15)}`
|
||||
}
|
||||
}
|
||||
|
||||
private configInvertedColor() {
|
||||
if (this.chatColorThemeInverted) {
|
||||
this.backgroundHeaderColorStyle = 'backgroundColor: #ffffff'
|
||||
this.colorFontOnHeaderStyle = `color: ${this.primaryColor}`
|
||||
this.headerBorderBottomStyle = 'borderBottom: 1px solid #ccc'
|
||||
this.colorPathOnHeader = this.primaryColor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ThemeBuilder {
|
||||
private _theme?: Theme
|
||||
private buildChecker = false
|
||||
|
||||
public get theme() {
|
||||
if (this._theme === undefined) {
|
||||
this._theme = new Theme()
|
||||
return this._theme
|
||||
} else {
|
||||
return this._theme
|
||||
}
|
||||
}
|
||||
|
||||
public buildTheme(chatColorTheme: string | null = null, chatColorThemeInverted = false) {
|
||||
if (!this.buildChecker) {
|
||||
this._theme = new Theme(chatColorTheme, chatColorThemeInverted)
|
||||
this.buildChecker = true
|
||||
} else {
|
||||
if (
|
||||
this.theme?.chatColorTheme !== chatColorTheme ||
|
||||
this.theme?.chatColorThemeInverted !== chatColorThemeInverted
|
||||
) {
|
||||
this._theme = new Theme(chatColorTheme, chatColorThemeInverted)
|
||||
this.buildChecker = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeBuilder>(new ThemeBuilder())
|
||||
export const useThemeContext = () => useContext(ThemeContext)
|
||||
44
web/app/components/base/chat/embedded-chatbot/theme/theme.ts
Normal file
44
web/app/components/base/chat/embedded-chatbot/theme/theme.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { hexToRGBA } from './utils'
|
||||
|
||||
export type Theme = Readonly<{
|
||||
chatColorTheme: string | null
|
||||
chatColorThemeInverted: boolean
|
||||
primaryColor: string
|
||||
backgroundHeaderColorStyle: string
|
||||
headerBorderBottomStyle: string
|
||||
colorFontOnHeaderStyle: string
|
||||
colorPathOnHeader: string
|
||||
backgroundButtonDefaultColorStyle: string
|
||||
roundedBackgroundColorStyle: string
|
||||
chatBubbleColorStyle: string
|
||||
}>
|
||||
|
||||
export const createTheme = (
|
||||
chatColorTheme: string | null = null,
|
||||
chatColorThemeInverted = false,
|
||||
): Theme => {
|
||||
const hasCustomColor = chatColorTheme !== null && chatColorTheme !== ''
|
||||
const primaryColor = hasCustomColor ? chatColorTheme : '#1C64F2'
|
||||
const colorFontOnHeaderStyle = chatColorThemeInverted ? `color: ${primaryColor}` : 'color: white'
|
||||
|
||||
return Object.freeze({
|
||||
chatColorTheme,
|
||||
chatColorThemeInverted,
|
||||
primaryColor,
|
||||
backgroundHeaderColorStyle: chatColorThemeInverted
|
||||
? 'backgroundColor: #ffffff'
|
||||
: hasCustomColor
|
||||
? `backgroundColor: ${primaryColor}`
|
||||
: 'backgroundImage: linear-gradient(to right, #2563eb, #0ea5e9)',
|
||||
headerBorderBottomStyle: chatColorThemeInverted ? 'borderBottom: 1px solid #ccc' : '',
|
||||
colorFontOnHeaderStyle,
|
||||
colorPathOnHeader: chatColorThemeInverted ? primaryColor : 'text-text-primary-on-surface',
|
||||
backgroundButtonDefaultColorStyle: hasCustomColor
|
||||
? `backgroundColor: ${primaryColor}; color: color: white;`
|
||||
: 'backgroundColor: #1C64F2',
|
||||
roundedBackgroundColorStyle: hasCustomColor
|
||||
? `backgroundColor: ${hexToRGBA(primaryColor, 0.05)}`
|
||||
: 'backgroundColor: rgb(245 248 255)',
|
||||
chatBubbleColorStyle: hasCustomColor ? `backgroundColor: ${hexToRGBA(primaryColor, 0.15)}` : '',
|
||||
})
|
||||
}
|
||||
@ -19,12 +19,6 @@ vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../../../base/chat/embedded-chatbot/theme/theme-context', () => ({
|
||||
useThemeContext: () => ({
|
||||
primaryColor: '#1890ff',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/chat/embedded-chatbot/chat-wrapper', () => ({
|
||||
default: () => <div data-testid="chat-wrapper">ChatWrapper</div>,
|
||||
}))
|
||||
|
||||
@ -16,9 +16,9 @@ import ChatWrapper from '@/app/components/base/chat/embedded-chatbot/chat-wrappe
|
||||
import { EmbeddedChatbotContext } from '@/app/components/base/chat/embedded-chatbot/context'
|
||||
import { useEmbeddedChatbot } from '@/app/components/base/chat/embedded-chatbot/hooks'
|
||||
import ViewFormDropdown from '@/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown'
|
||||
import { createTheme } from '@/app/components/base/chat/embedded-chatbot/theme/theme'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { AppSourceType } from '@/service/share'
|
||||
import { useThemeContext } from '../../../base/chat/embedded-chatbot/theme/theme-context'
|
||||
|
||||
type Props = Readonly<{
|
||||
appId: string
|
||||
@ -30,8 +30,11 @@ const TryApp: FC<Props> = ({ appId, appDetail, className }) => {
|
||||
const { t } = useTranslation()
|
||||
const media = useBreakpoints()
|
||||
const isMobile = media === MediaType.mobile
|
||||
const themeBuilder = useThemeContext()
|
||||
const { removeConversationIdInfo, ...chatData } = useEmbeddedChatbot(AppSourceType.tryApp, appId)
|
||||
const theme = createTheme(
|
||||
chatData.appData?.site?.chat_color_theme ?? null,
|
||||
chatData.appData?.site?.chat_color_theme_inverted ?? false,
|
||||
)
|
||||
const currentConversationId = chatData.currentConversationId
|
||||
const inputsForms = chatData.inputsForms
|
||||
useEffect(() => {
|
||||
@ -50,7 +53,7 @@ const TryApp: FC<Props> = ({ appId, appDetail, className }) => {
|
||||
...chatData,
|
||||
disableFeedback: true,
|
||||
isMobile,
|
||||
themeBuilder,
|
||||
theme,
|
||||
} as EmbeddedChatbotContextValue
|
||||
}
|
||||
>
|
||||
|
||||
@ -44,15 +44,6 @@ vi.mock('@/hooks/use-timestamp', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/chat/embedded-chatbot/theme/theme-context', () => ({
|
||||
useThemeContext: () => ({
|
||||
buildTheme: vi.fn(),
|
||||
theme: {
|
||||
primaryColor: '#1C64F2',
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/account-state', async () => {
|
||||
const { createAccountStateModuleMock } = await import('@/test/console/state-fixture')
|
||||
return createAccountStateModuleMock(() => ({
|
||||
|
||||
Loading…
Reference in New Issue
Block a user