Merge branch 'sandboxed-agent-rebase' into feat/support-agent-sandbox

This commit is contained in:
Novice
2026-03-24 15:58:23 +08:00
4091 changed files with 379591 additions and 67898 deletions

View File

@ -2,13 +2,13 @@
import type { ReactNode } from 'react'
import Cookies from 'js-cookie'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { parseAsBoolean, useQueryState } from 'nuqs'
import { useCallback, useEffect, useState } from 'react'
import {
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
} from '@/app/education-apply/constants'
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
import { sendGAEvent } from '@/utils/gtag'
import { fetchSetupStatusWithCache } from '@/utils/setup-status'
import { resolvePostLoginRedirect } from '../signin/utils/post-login-redirect'
@ -26,11 +26,10 @@ export const AppInitializer = ({
// Tokens are now stored in cookies, no need to check localStorage
const pathname = usePathname()
const [init, setInit] = useState(false)
const [oauthNewUser, setOauthNewUser] = useQueryState(
const [oauthNewUser] = useQueryState(
'oauth_new_user',
parseAsBoolean.withOptions({ history: 'replace' }),
)
const isSetupFinished = useCallback(async () => {
try {
const setUpStatus = await fetchSetupStatusWithCache()
@ -69,11 +68,12 @@ export const AppInitializer = ({
...utmInfo,
})
// Clean up: remove utm_info cookie and URL params
Cookies.remove('utm_info')
setOauthNewUser(null)
}
if (oauthNewUser !== null)
router.replace(pathname)
if (action === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION)
localStorage.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes')
@ -84,7 +84,7 @@ export const AppInitializer = ({
return
}
const redirectUrl = resolvePostLoginRedirect(searchParams)
const redirectUrl = resolvePostLoginRedirect()
if (redirectUrl) {
location.replace(redirectUrl)
return
@ -96,7 +96,7 @@ export const AppInitializer = ({
router.replace('/signin')
}
})()
}, [isSetupFinished, router, pathname, searchParams, oauthNewUser, setOauthNewUser])
}, [isSetupFinished, router, pathname, searchParams, oauthNewUser])
return init ? children : null
}

View File

@ -0,0 +1,177 @@
import type { App, AppSSO } from '@/types/app'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { AppModeEnum } from '@/types/app'
import AppSidebarDropdown from '../app-sidebar-dropdown'
let mockAppDetail: (App & Partial<AppSSO>) | undefined
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) => selector({
appDetail: mockAppDetail,
}),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceEditor: true,
}),
}))
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
<div data-testid="portal-elem" data-open={open}>{children}</div>
),
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="portal-content">{children}</div>
),
}))
vi.mock('../../base/app-icon', () => ({
default: ({ size, icon }: { size: string, icon: string }) => (
<div data-testid="app-icon" data-size={size} data-icon={icon} />
),
}))
vi.mock('../../base/divider', () => ({
default: () => <hr data-testid="divider" />,
}))
vi.mock('../app-info', () => ({
default: ({ expand, onlyShowDetail, openState }: {
expand: boolean
onlyShowDetail?: boolean
openState?: boolean
}) => (
<div data-testid="app-info" data-expand={expand} data-only-detail={onlyShowDetail} data-open={openState} />
),
}))
vi.mock('../nav-link', () => ({
default: ({ name, href, mode }: { name: string, href: string, mode?: string }) => (
<a data-testid={`nav-link-${name}`} href={href} data-mode={mode}>{name}</a>
),
}))
const MockIcon = (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />
const createAppDetail = (overrides: Partial<App> = {}): App & Partial<AppSSO> => ({
id: 'app-1',
name: 'Test App',
mode: AppModeEnum.CHAT,
icon: '🤖',
icon_type: 'emoji',
icon_background: '#FFEAD5',
icon_url: '',
description: '',
use_icon_as_answer_icon: false,
...overrides,
} as App & Partial<AppSSO>)
const navigation = [
{ name: 'Overview', href: '/overview', icon: MockIcon, selectedIcon: MockIcon },
{ name: 'Logs', href: '/logs', icon: MockIcon, selectedIcon: MockIcon },
]
describe('AppSidebarDropdown', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAppDetail = createAppDetail()
})
it('should return null when appDetail is not available', () => {
mockAppDetail = undefined
const { container } = render(<AppSidebarDropdown navigation={navigation} />)
expect(container.innerHTML).toBe('')
})
it('should render trigger with app icon', () => {
render(<AppSidebarDropdown navigation={navigation} />)
const icons = screen.getAllByTestId('app-icon')
const smallIcon = icons.find(i => i.getAttribute('data-size') === 'small')
expect(smallIcon).toBeInTheDocument()
})
it('should render navigation links', () => {
render(<AppSidebarDropdown navigation={navigation} />)
expect(screen.getByTestId('nav-link-Overview')).toBeInTheDocument()
expect(screen.getByTestId('nav-link-Logs')).toBeInTheDocument()
})
it('should display app name', () => {
render(<AppSidebarDropdown navigation={navigation} />)
expect(screen.getByText('Test App')).toBeInTheDocument()
})
it('should display app mode label', () => {
render(<AppSidebarDropdown navigation={navigation} />)
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
})
it('should display mode labels for different modes', () => {
mockAppDetail = createAppDetail({ mode: AppModeEnum.ADVANCED_CHAT })
render(<AppSidebarDropdown navigation={navigation} />)
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
})
it('should render AppInfo component for detail expand', () => {
render(<AppSidebarDropdown navigation={navigation} />)
expect(screen.getByTestId('app-info')).toBeInTheDocument()
expect(screen.getByTestId('app-info')).toHaveAttribute('data-only-detail', 'true')
})
it('should toggle portal open state when trigger is clicked', async () => {
const user = userEvent.setup()
render(<AppSidebarDropdown navigation={navigation} />)
const trigger = screen.getByTestId('portal-trigger')
await user.click(trigger)
const portal = screen.getByTestId('portal-elem')
expect(portal).toHaveAttribute('data-open', 'true')
})
it('should render divider between app info and navigation', () => {
render(<AppSidebarDropdown navigation={navigation} />)
expect(screen.getByTestId('divider')).toBeInTheDocument()
})
it('should render large app icon in dropdown content', () => {
render(<AppSidebarDropdown navigation={navigation} />)
const icons = screen.getAllByTestId('app-icon')
const largeIcon = icons.find(icon => icon.getAttribute('data-size') === 'large')
expect(largeIcon).toBeInTheDocument()
})
it('should set detailExpand when clicking app info area', async () => {
const user = userEvent.setup()
render(<AppSidebarDropdown navigation={navigation} />)
const appName = screen.getByText('Test App')
const appInfoArea = appName.closest('[class*="cursor-pointer"]')
if (appInfoArea)
await user.click(appInfoArea)
})
it('should display workflow mode label', () => {
mockAppDetail = createAppDetail({ mode: AppModeEnum.WORKFLOW })
render(<AppSidebarDropdown navigation={navigation} />)
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
})
it('should display agent mode label', () => {
mockAppDetail = createAppDetail({ mode: AppModeEnum.AGENT_CHAT })
render(<AppSidebarDropdown navigation={navigation} />)
expect(screen.getByText('app.types.agent')).toBeInTheDocument()
})
it('should display completion mode label', () => {
mockAppDetail = createAppDetail({ mode: AppModeEnum.COMPLETION })
render(<AppSidebarDropdown navigation={navigation} />)
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,110 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import AppBasic from '../basic'
vi.mock('@/app/components/base/icons/src/vender/workflow', () => ({
ApiAggregate: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="api-icon" {...props} />,
WindowCursor: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="webapp-icon" {...props} />,
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ popupContent }: { popupContent: React.ReactNode }) => (
<div data-testid="tooltip">{popupContent}</div>
),
}))
vi.mock('../../base/app-icon', () => ({
default: ({ icon, background, innerIcon, className }: {
icon?: string
background?: string
innerIcon?: React.ReactNode
className?: string
}) => (
<div data-testid="app-icon" data-icon={icon} data-bg={background} className={className}>
{innerIcon}
</div>
),
}))
describe('AppBasic', () => {
describe('Icon rendering', () => {
it('should render app icon when iconType is app with valid icon and background', () => {
render(<AppBasic name="Test" type="Chat" icon="🤖" icon_background="#fff" />)
expect(screen.getByTestId('app-icon')).toBeInTheDocument()
})
it('should not render app icon when icon is empty', () => {
render(<AppBasic name="Test" type="Chat" />)
expect(screen.queryByTestId('app-icon')).not.toBeInTheDocument()
})
it('should render api icon when iconType is api', () => {
render(<AppBasic name="Test" type="API" iconType="api" />)
expect(screen.getByTestId('api-icon')).toBeInTheDocument()
})
it('should render webapp icon when iconType is webapp', () => {
render(<AppBasic name="Test" type="Webapp" iconType="webapp" />)
expect(screen.getByTestId('webapp-icon')).toBeInTheDocument()
})
it('should render dataset icon when iconType is dataset', () => {
render(<AppBasic name="Test" type="Dataset" iconType="dataset" />)
const icons = screen.getAllByTestId('app-icon')
expect(icons.length).toBeGreaterThan(0)
})
it('should render notion icon when iconType is notion', () => {
render(<AppBasic name="Test" type="Notion" iconType="notion" />)
const icons = screen.getAllByTestId('app-icon')
expect(icons.length).toBeGreaterThan(0)
})
})
describe('Expand mode', () => {
it('should show name and type in expand mode', () => {
render(<AppBasic name="My App" type="Chatbot" />)
expect(screen.getByText('My App')).toBeInTheDocument()
expect(screen.getByText('Chatbot')).toBeInTheDocument()
})
it('should hide name and type in collapse mode', () => {
render(<AppBasic name="My App" type="Chatbot" mode="collapse" />)
expect(screen.queryByText('My App')).not.toBeInTheDocument()
})
it('should show hover tip when provided', () => {
render(<AppBasic name="My App" type="Chatbot" hoverTip="Some tip" />)
expect(screen.getByTestId('tooltip')).toBeInTheDocument()
expect(screen.getByText('Some tip')).toBeInTheDocument()
})
it('should not show hover tip when not provided', () => {
render(<AppBasic name="My App" type="Chatbot" />)
expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument()
})
})
describe('Type display', () => {
it('should hide type when hideType is true', () => {
render(<AppBasic name="My App" type="Chatbot" hideType />)
expect(screen.queryByText('Chatbot')).not.toBeInTheDocument()
})
it('should show external tag when isExternal is true', () => {
render(<AppBasic name="My App" type="Dataset" isExternal />)
expect(screen.getByText('dataset.externalTag')).toBeInTheDocument()
})
it('should show type inline when isExtraInLine is true and hideType is false', () => {
render(<AppBasic name="My App" type="Chatbot" isExtraInLine />)
expect(screen.getByText('Chatbot')).toBeInTheDocument()
})
it('should apply custom text styles', () => {
render(<AppBasic name="My App" type="Chatbot" textStyle={{ main: 'text-red-500' }} />)
const nameContainer = screen.getByText('My App').parentElement
expect(nameContainer).toHaveClass('text-red-500')
})
})
})

View File

@ -0,0 +1,193 @@
import type { DataSet } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import DatasetSidebarDropdown from '../dataset-sidebar-dropdown'
let mockDataset: DataSet
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (selector: (state: { dataset: DataSet }) => unknown) =>
selector({ dataset: mockDataset }),
}))
vi.mock('@/service/knowledge/use-dataset', () => ({
useDatasetRelatedApps: () => ({ data: [] }),
}))
vi.mock('@/hooks/use-knowledge', () => ({
useKnowledge: () => ({
formatIndexingTechniqueAndMethod: () => 'method-text',
}),
}))
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
<div data-testid="portal-elem" data-open={open}>{children}</div>
),
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="portal-content">{children}</div>
),
}))
vi.mock('../../base/app-icon', () => ({
default: ({ size, icon }: { size: string, icon: string }) => (
<div data-testid="app-icon" data-size={size} data-icon={icon} />
),
}))
vi.mock('../../base/divider', () => ({
default: () => <hr data-testid="divider" />,
}))
vi.mock('../../base/effect', () => ({
default: ({ className }: { className?: string }) => <div data-testid="effect" className={className} />,
}))
vi.mock('../../datasets/extra-info', () => ({
default: ({ expand, documentCount }: {
relatedApps?: unknown[]
expand: boolean
documentCount: number
}) => (
<div data-testid="extra-info" data-expand={expand} data-doc-count={documentCount} />
),
}))
vi.mock('../dataset-info/dropdown', () => ({
default: ({ expand }: { expand: boolean }) => (
<div data-testid="dataset-dropdown" data-expand={expand} />
),
}))
vi.mock('../nav-link', () => ({
default: ({ name, href, mode, disabled }: { name: string, href: string, mode?: string, disabled?: boolean }) => (
<a data-testid={`nav-link-${name}`} href={href} data-mode={mode} data-disabled={disabled}>{name}</a>
),
}))
const MockIcon = (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />
const createDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',
description: 'A test dataset',
provider: 'internal',
icon_info: {
icon: '📙',
icon_type: 'emoji',
icon_background: '#FFF4ED',
icon_url: '',
},
doc_form: 'text_model' as DataSet['doc_form'],
indexing_technique: 'high_quality' as DataSet['indexing_technique'],
document_count: 10,
runtime_mode: 'general',
retrieval_model_dict: {
search_method: 'semantic_search' as DataSet['retrieval_model_dict']['search_method'],
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 5,
score_threshold_enabled: false,
score_threshold: 0,
},
...overrides,
} as DataSet)
const navigation = [
{ name: 'Documents', href: '/documents', icon: MockIcon, selectedIcon: MockIcon },
{ name: 'Settings', href: '/settings', icon: MockIcon, selectedIcon: MockIcon, disabled: true },
]
describe('DatasetSidebarDropdown', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDataset = createDataset()
})
it('should render trigger with dataset icon', () => {
render(<DatasetSidebarDropdown navigation={navigation} />)
const icons = screen.getAllByTestId('app-icon')
const smallIcon = icons.find(i => i.getAttribute('data-size') === 'small')
expect(smallIcon).toBeInTheDocument()
expect(smallIcon).toHaveAttribute('data-icon', '📙')
})
it('should display dataset name in dropdown content', () => {
render(<DatasetSidebarDropdown navigation={navigation} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should display dataset description', () => {
render(<DatasetSidebarDropdown navigation={navigation} />)
expect(screen.getByText('A test dataset')).toBeInTheDocument()
})
it('should not display description when empty', () => {
mockDataset = createDataset({ description: '' })
render(<DatasetSidebarDropdown navigation={navigation} />)
expect(screen.queryByText('A test dataset')).not.toBeInTheDocument()
})
it('should render navigation links', () => {
render(<DatasetSidebarDropdown navigation={navigation} />)
expect(screen.getByTestId('nav-link-Documents')).toBeInTheDocument()
expect(screen.getByTestId('nav-link-Settings')).toBeInTheDocument()
})
it('should render ExtraInfo', () => {
render(<DatasetSidebarDropdown navigation={navigation} />)
const extraInfo = screen.getByTestId('extra-info')
expect(extraInfo).toHaveAttribute('data-expand', 'true')
expect(extraInfo).toHaveAttribute('data-doc-count', '10')
})
it('should render Effect component', () => {
render(<DatasetSidebarDropdown navigation={navigation} />)
expect(screen.getByTestId('effect')).toBeInTheDocument()
})
it('should render Dropdown component with expand=true', () => {
render(<DatasetSidebarDropdown navigation={navigation} />)
expect(screen.getByTestId('dataset-dropdown')).toHaveAttribute('data-expand', 'true')
})
it('should show external tag for external provider', () => {
mockDataset = createDataset({ provider: 'external' })
render(<DatasetSidebarDropdown navigation={navigation} />)
expect(screen.getByText('dataset.externalTag')).toBeInTheDocument()
})
it('should use fallback icon info when icon_info is missing', () => {
mockDataset = createDataset({ icon_info: undefined as unknown as DataSet['icon_info'] })
render(<DatasetSidebarDropdown navigation={navigation} />)
const icons = screen.getAllByTestId('app-icon')
const fallbackIcon = icons.find(i => i.getAttribute('data-icon') === '📙')
expect(fallbackIcon).toBeInTheDocument()
})
it('should toggle dropdown open state on trigger click', async () => {
const user = userEvent.setup()
render(<DatasetSidebarDropdown navigation={navigation} />)
const trigger = screen.getByTestId('portal-trigger')
await user.click(trigger)
expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'true')
})
it('should render divider', () => {
render(<DatasetSidebarDropdown navigation={navigation} />)
expect(screen.getByTestId('divider')).toBeInTheDocument()
})
it('should render medium app icon in content area', () => {
render(<DatasetSidebarDropdown navigation={navigation} />)
const icons = screen.getAllByTestId('app-icon')
const mediumIcon = icons.find(i => i.getAttribute('data-size') === 'medium')
expect(mediumIcon).toBeInTheDocument()
})
})

View File

@ -0,0 +1,298 @@
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import AppDetailNav from '..'
let mockAppSidebarExpand = 'expand'
const mockSetAppSidebarExpand = vi.fn()
let mockPathname = '/app/123/overview'
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) => selector({
appDetail: { id: 'app-1', name: 'Test', mode: 'chat', icon: '🤖', icon_type: 'emoji', icon_background: '#fff' },
appSidebarExpand: mockAppSidebarExpand,
setAppSidebarExpand: mockSetAppSidebarExpand,
}),
}))
vi.mock('zustand/react/shallow', () => ({
useShallow: (fn: unknown) => fn,
}))
vi.mock('@/next/navigation', () => ({
usePathname: () => mockPathname,
}))
let mockIsHovering = true
let mockKeyPressCallback: ((e: { preventDefault: () => void }) => void) | null = null
vi.mock('ahooks', () => ({
useHover: () => mockIsHovering,
useKeyPress: (_key: string, cb: (e: { preventDefault: () => void }) => void) => {
mockKeyPressCallback = cb
},
}))
vi.mock('@/hooks/use-breakpoints', () => ({
default: () => 'desktop',
MediaType: { mobile: 'mobile', desktop: 'desktop' },
}))
let mockSubscriptionCallback: ((v: unknown) => void) | null = null
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
useSubscription: (cb: (v: unknown) => void) => { mockSubscriptionCallback = cb },
},
}),
}))
vi.mock('../../base/divider', () => ({
default: ({ className }: { className?: string }) => <hr data-testid="divider" className={className} />,
}))
vi.mock('@/app/components/workflow/utils', () => ({
getKeyboardKeyCodeBySystem: () => 'ctrl',
}))
vi.mock('../app-info', () => ({
default: ({ expand }: { expand: boolean }) => (
<div data-testid="app-info" data-expand={expand} />
),
}))
vi.mock('../app-sidebar-dropdown', () => ({
default: ({ navigation }: { navigation: unknown[] }) => (
<div data-testid="app-sidebar-dropdown" data-nav-count={navigation.length} />
),
}))
vi.mock('../dataset-info', () => ({
default: ({ expand }: { expand: boolean }) => (
<div data-testid="dataset-info" data-expand={expand} />
),
}))
vi.mock('../dataset-sidebar-dropdown', () => ({
default: ({ navigation }: { navigation: unknown[] }) => (
<div data-testid="dataset-sidebar-dropdown" data-nav-count={navigation.length} />
),
}))
vi.mock('../nav-link', () => ({
default: ({ name, href, mode }: { name: string, href: string, mode?: string }) => (
<a data-testid={`nav-link-${name}`} href={href} data-mode={mode}>{name}</a>
),
}))
vi.mock('../toggle-button', () => ({
default: ({ expand, handleToggle, className }: { expand: boolean, handleToggle: () => void, className?: string }) => (
<button type="button" data-testid="toggle-button" data-expand={expand} onClick={handleToggle} className={className}>
Toggle
</button>
),
}))
const MockIcon = (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />
const navigation = [
{ name: 'Overview', href: '/overview', icon: MockIcon, selectedIcon: MockIcon },
{ name: 'Logs', href: '/logs', icon: MockIcon, selectedIcon: MockIcon },
]
describe('AppDetailNav', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAppSidebarExpand = 'expand'
mockPathname = '/app/123/overview'
mockIsHovering = true
})
describe('Normal sidebar mode', () => {
it('should render AppInfo when iconType is app', () => {
render(<AppDetailNav navigation={navigation} />)
expect(screen.getByTestId('app-info')).toBeInTheDocument()
expect(screen.getByTestId('app-info')).toHaveAttribute('data-expand', 'true')
})
it('should render DatasetInfo when iconType is dataset', () => {
render(<AppDetailNav navigation={navigation} iconType="dataset" />)
expect(screen.getByTestId('dataset-info')).toBeInTheDocument()
})
it('should render navigation links', () => {
render(<AppDetailNav navigation={navigation} />)
expect(screen.getByTestId('nav-link-Overview')).toBeInTheDocument()
expect(screen.getByTestId('nav-link-Logs')).toBeInTheDocument()
})
it('should render divider', () => {
render(<AppDetailNav navigation={navigation} />)
expect(screen.getByTestId('divider')).toBeInTheDocument()
})
it('should apply expanded width class', () => {
const { container } = render(<AppDetailNav navigation={navigation} />)
const sidebar = container.firstElementChild as HTMLElement
expect(sidebar).toHaveClass('w-[216px]')
})
it('should apply collapsed width class', () => {
mockAppSidebarExpand = 'collapse'
const { container } = render(<AppDetailNav navigation={navigation} />)
const sidebar = container.firstElementChild as HTMLElement
expect(sidebar).toHaveClass('w-14')
})
it('should render extraInfo when iconType is dataset and extraInfo provided', () => {
render(
<AppDetailNav
navigation={navigation}
iconType="dataset"
extraInfo={mode => <div data-testid="extra-info" data-mode={mode} />}
/>,
)
expect(screen.getByTestId('extra-info')).toBeInTheDocument()
})
it('should not render extraInfo when iconType is app', () => {
render(
<AppDetailNav
navigation={navigation}
extraInfo={mode => <div data-testid="extra-info" data-mode={mode} />}
/>,
)
expect(screen.queryByTestId('extra-info')).not.toBeInTheDocument()
})
})
describe('Workflow canvas mode', () => {
it('should render AppSidebarDropdown when in workflow canvas with hidden header', () => {
mockPathname = '/app/123/workflow'
localStorage.setItem('workflow-canvas-maximize', 'true')
render(<AppDetailNav navigation={navigation} />)
expect(screen.getByTestId('app-sidebar-dropdown')).toBeInTheDocument()
expect(screen.queryByTestId('app-info')).not.toBeInTheDocument()
})
it('should render normal sidebar when workflow canvas is not maximized', () => {
mockPathname = '/app/123/workflow'
localStorage.setItem('workflow-canvas-maximize', 'false')
render(<AppDetailNav navigation={navigation} />)
expect(screen.queryByTestId('app-sidebar-dropdown')).not.toBeInTheDocument()
expect(screen.getByTestId('app-info')).toBeInTheDocument()
})
})
describe('Pipeline canvas mode', () => {
it('should render DatasetSidebarDropdown when in pipeline canvas with hidden header', () => {
mockPathname = '/dataset/123/pipeline'
localStorage.setItem('workflow-canvas-maximize', 'true')
render(<AppDetailNav navigation={navigation} />)
expect(screen.getByTestId('dataset-sidebar-dropdown')).toBeInTheDocument()
expect(screen.queryByTestId('app-info')).not.toBeInTheDocument()
})
})
describe('Navigation mode', () => {
it('should pass expand mode to nav links when expanded', () => {
render(<AppDetailNav navigation={navigation} />)
expect(screen.getByTestId('nav-link-Overview')).toHaveAttribute('data-mode', 'expand')
})
it('should pass collapse mode to nav links when collapsed', () => {
mockAppSidebarExpand = 'collapse'
render(<AppDetailNav navigation={navigation} />)
expect(screen.getByTestId('nav-link-Overview')).toHaveAttribute('data-mode', 'collapse')
})
})
describe('Toggle behavior', () => {
it('should call setAppSidebarExpand on toggle', async () => {
const user = userEvent.setup()
render(<AppDetailNav navigation={navigation} />)
await user.click(screen.getByTestId('toggle-button'))
expect(mockSetAppSidebarExpand).toHaveBeenCalledWith('collapse')
})
it('should toggle from collapse to expand', async () => {
const user = userEvent.setup()
mockAppSidebarExpand = 'collapse'
render(<AppDetailNav navigation={navigation} />)
await user.click(screen.getByTestId('toggle-button'))
expect(mockSetAppSidebarExpand).toHaveBeenCalledWith('expand')
})
})
describe('Sidebar persistence', () => {
it('should persist expand state to localStorage', () => {
render(<AppDetailNav navigation={navigation} />)
expect(localStorage.setItem).toHaveBeenCalledWith('app-detail-collapse-or-expand', 'expand')
})
})
describe('Disabled navigation items', () => {
it('should render disabled navigation items', () => {
const navWithDisabled = [
...navigation,
{ name: 'Disabled', href: '/disabled', icon: MockIcon, selectedIcon: MockIcon, disabled: true },
]
render(<AppDetailNav navigation={navWithDisabled} />)
expect(screen.getByTestId('nav-link-Disabled')).toBeInTheDocument()
})
})
describe('Event emitter subscription', () => {
it('should handle workflow-canvas-maximize event', () => {
mockPathname = '/app/123/workflow'
render(<AppDetailNav navigation={navigation} />)
const cb = mockSubscriptionCallback
expect(cb).not.toBeNull()
act(() => {
cb!({ type: 'workflow-canvas-maximize', payload: true })
})
})
it('should ignore non-maximize events', () => {
render(<AppDetailNav navigation={navigation} />)
const cb = mockSubscriptionCallback
act(() => {
cb!({ type: 'other-event' })
})
})
})
describe('Keyboard shortcut', () => {
it('should toggle sidebar on ctrl+b', () => {
render(<AppDetailNav navigation={navigation} />)
const cb = mockKeyPressCallback
expect(cb).not.toBeNull()
act(() => {
cb!({ preventDefault: vi.fn() })
})
expect(mockSetAppSidebarExpand).toHaveBeenCalledWith('collapse')
})
})
describe('Hover-based toggle button visibility', () => {
it('should hide toggle button when not hovering', () => {
mockIsHovering = false
render(<AppDetailNav navigation={navigation} />)
expect(screen.queryByTestId('toggle-button')).not.toBeInTheDocument()
})
})
})

View File

@ -143,12 +143,6 @@ describe('Sidebar Animation Issues Reproduction', () => {
expect(toggleSection).toHaveClass('px-4') // Same consistent padding
expect(toggleSection).not.toHaveClass('px-5')
expect(toggleSection).not.toHaveClass('px-6')
// THE FIX: px-4 in both states prevents position movement
console.log('✅ Issue #1 FIXED: Toggle button now has consistent padding')
console.log(' - Before: px-4 (collapsed) vs px-6 (expanded) - 8px difference')
console.log(' - After: px-4 (both states) - 0px difference')
console.log(' - Result: No button position movement during transition')
})
it('should verify sidebar width animation is working correctly', () => {
@ -164,8 +158,6 @@ describe('Sidebar Animation Issues Reproduction', () => {
// Expanded state
rerender(<MockSidebarToggleButton expand={true} onToggle={handleToggle} />)
expect(container).toHaveClass('w-[216px]')
console.log('✅ Sidebar width transition is properly configured')
})
})
@ -188,13 +180,6 @@ describe('Sidebar Animation Issues Reproduction', () => {
expect(link).toHaveClass('px-3') // 12px padding (+2px)
expect(icon).toHaveClass('mr-2') // 8px margin (+8px)
expect(screen.getByTestId('nav-text-Orchestrate')).toBeInTheDocument()
// THE BUG: Multiple simultaneous changes create squeeze effect
console.log('🐛 Issue #2 Reproduced: Text squeeze effect from multiple layout changes')
console.log(' - Link padding: px-2.5 → px-3 (+2px)')
console.log(' - Icon margin: mr-0 → mr-2 (+8px)')
console.log(' - Text appears: none → visible (abrupt)')
console.log(' - Result: Text appears with squeeze effect due to layout shifts')
})
it('should document the abrupt text rendering issue', () => {
@ -207,10 +192,6 @@ describe('Sidebar Animation Issues Reproduction', () => {
// Text suddenly appears - no transition
expect(screen.getByTestId('nav-text-API Access')).toBeInTheDocument()
console.log('🐛 Issue #2 Detail: Conditional rendering {mode === "expand" && name}')
console.log(' - Problem: Text appears/disappears abruptly without transition')
console.log(' - Should use: opacity or width transition for smooth appearance')
})
})
@ -234,13 +215,6 @@ describe('Sidebar Animation Issues Reproduction', () => {
expect(iconContainer).toHaveClass('gap-1')
expect(iconContainer).not.toHaveClass('justify-between')
expect(appIcon).toHaveAttribute('data-size', 'small')
// THE BUG: Layout mode switch causes icon to "bounce"
console.log('🐛 Issue #3 Reproduced: Icon bounce from layout mode switching')
console.log(' - Layout change: justify-between → flex-col gap-1')
console.log(' - Icon size: large (40px) → small (24px)')
console.log(' - Transition: transition-all causes excessive animation')
console.log(' - Result: Icon appears to bounce to right then back during collapse')
})
it('should identify the problematic transition-all property', () => {
@ -251,10 +225,6 @@ describe('Sidebar Animation Issues Reproduction', () => {
// The problematic broad transition
expect(computedStyle.transition).toContain('all')
console.log('🐛 Issue #3 Detail: transition-all affects ALL CSS properties')
console.log(' - Problem: Animates layout properties that should not transition')
console.log(' - Solution: Use specific transition properties instead of "all"')
})
})
@ -276,7 +246,6 @@ describe('Sidebar Animation Issues Reproduction', () => {
// Initial state verification
expect(expanded).toBe(false)
console.log('🔄 Starting interactive test - all issues will be reproduced')
// Simulate toggle click
fireEvent.click(toggleButton)
@ -287,11 +256,6 @@ describe('Sidebar Animation Issues Reproduction', () => {
<MockAppInfo expand={expanded} />
</div>,
)
console.log('✨ All three issues successfully reproduced in interactive test:')
console.log(' 1. Toggle button position movement (padding inconsistency)')
console.log(' 2. Navigation text squeeze effect (multiple layout changes)')
console.log(' 3. App icon bounce animation (layout mode switching)')
})
})
})

View File

@ -7,13 +7,13 @@ import { render } from '@testing-library/react'
import * as React from 'react'
// Mock Next.js navigation
vi.mock('next/navigation', () => ({
vi.mock('@/next/navigation', () => ({
useSelectedLayoutSegment: () => 'overview',
}))
// Mock classnames utility
vi.mock('@/utils/classnames', () => ({
default: (...classes: any[]) => classes.filter(Boolean).join(' '),
default: (...classes: unknown[]) => classes.filter(Boolean).join(' '),
}))
// Simplified NavLink component to test the fix
@ -101,12 +101,6 @@ describe('Text Squeeze Fix Verification', () => {
expect(textElement).toHaveClass('whitespace-nowrap')
expect(textElement).toHaveClass('transition-all')
console.log('✅ NavLink Collapsed State:')
console.log(' - Text is in DOM but visually hidden')
console.log(' - Uses opacity-0 and w-0 for hiding')
console.log(' - Has whitespace-nowrap to prevent wrapping')
console.log(' - Has transition-all for smooth animation')
// Switch to expanded state
rerender(<TestNavLink mode="expand" />)
@ -115,13 +109,6 @@ describe('Text Squeeze Fix Verification', () => {
expect(expandedText).toHaveClass('opacity-100')
expect(expandedText).toHaveClass('w-auto')
expect(expandedText).not.toHaveClass('pointer-events-none')
console.log('✅ NavLink Expanded State:')
console.log(' - Text is visible with opacity-100')
console.log(' - Uses w-auto for natural width')
console.log(' - No layout jumps during transition')
console.log('🎯 NavLink Fix Result: Text squeeze effect ELIMINATED')
})
it('should verify smooth transition properties', () => {
@ -131,11 +118,6 @@ describe('Text Squeeze Fix Verification', () => {
expect(textElement).toHaveClass('transition-all')
expect(textElement).toHaveClass('duration-200')
expect(textElement).toHaveClass('ease-in-out')
console.log('✅ Transition Properties Verified:')
console.log(' - transition-all: Smooth property changes')
console.log(' - duration-200: 200ms transition time')
console.log(' - ease-in-out: Smooth easing function')
})
})
@ -159,11 +141,6 @@ describe('Text Squeeze Fix Verification', () => {
expect(appName).toHaveClass('whitespace-nowrap')
expect(appType).toHaveClass('whitespace-nowrap')
console.log('✅ AppInfo Collapsed State:')
console.log(' - Text container is in DOM but visually hidden')
console.log(' - App name and type elements always present')
console.log(' - Uses whitespace-nowrap to prevent wrapping')
// Switch to expanded state
rerender(<TestAppInfo expand={true} />)
@ -172,13 +149,6 @@ describe('Text Squeeze Fix Verification', () => {
expect(expandedContainer).toHaveClass('opacity-100')
expect(expandedContainer).toHaveClass('w-auto')
expect(expandedContainer).not.toHaveClass('pointer-events-none')
console.log('✅ AppInfo Expanded State:')
console.log(' - Text container is visible with opacity-100')
console.log(' - Uses w-auto for natural width')
console.log(' - No layout jumps during transition')
console.log('🎯 AppInfo Fix Result: Text squeeze effect ELIMINATED')
})
it('should verify transition properties on text container', () => {
@ -188,45 +158,11 @@ describe('Text Squeeze Fix Verification', () => {
expect(textContainer).toHaveClass('transition-all')
expect(textContainer).toHaveClass('duration-200')
expect(textContainer).toHaveClass('ease-in-out')
console.log('✅ AppInfo Transition Properties Verified:')
console.log(' - Container has smooth CSS transitions')
console.log(' - Same 200ms duration as NavLink for consistency')
})
})
describe('Fix Strategy Comparison', () => {
it('should document the fix strategy differences', () => {
console.log('\n📋 TEXT SQUEEZE FIX STRATEGY COMPARISON')
console.log('='.repeat(60))
console.log('\n❌ BEFORE (Problematic):')
console.log(' NavLink: {mode === "expand" && name}')
console.log(' AppInfo: {expand && (<div>...</div>)}')
console.log(' Problem: Conditional rendering causes abrupt appearance')
console.log(' Result: Text "squeezes" from center during layout changes')
console.log('\n✅ AFTER (Fixed):')
console.log(' NavLink: <span className="opacity-0 w-0">{name}</span>')
console.log(' AppInfo: <div className="opacity-0 w-0">...</div>')
console.log(' Solution: CSS controls visibility, element always in DOM')
console.log(' Result: Smooth opacity and width transitions')
console.log('\n🎯 KEY FIX PRINCIPLES:')
console.log(' 1. ✅ Always keep text elements in DOM')
console.log(' 2. ✅ Use opacity for show/hide transitions')
console.log(' 3. ✅ Use width (w-0/w-auto) for layout control')
console.log(' 4. ✅ Add whitespace-nowrap to prevent wrapping')
console.log(' 5. ✅ Use pointer-events-none when hidden')
console.log(' 6. ✅ Add overflow-hidden for clean hiding')
console.log('\n🚀 BENEFITS:')
console.log(' - No more abrupt text appearance')
console.log(' - Smooth 200ms transitions')
console.log(' - No layout jumps or shifts')
console.log(' - Consistent animation timing')
console.log(' - Better user experience')
// Always pass documentation test
expect(true).toBe(true)
})

View File

@ -0,0 +1,46 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import ToggleButton from '../toggle-button'
vi.mock('@/app/components/workflow/shortcuts-name', () => ({
default: ({ keys }: { keys: string[] }) => (
<span data-testid="shortcuts">{keys.join('+')}</span>
),
}))
describe('ToggleButton', () => {
it('should render collapse arrow when expanded', () => {
render(<ToggleButton expand handleToggle={vi.fn()} />)
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
})
it('should render expand arrow when collapsed', () => {
render(<ToggleButton expand={false} handleToggle={vi.fn()} />)
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
})
it('should call handleToggle when clicked', async () => {
const user = userEvent.setup()
const handleToggle = vi.fn()
render(<ToggleButton expand handleToggle={handleToggle} />)
await user.click(screen.getByRole('button'))
expect(handleToggle).toHaveBeenCalledTimes(1)
})
it('should apply custom className', () => {
render(<ToggleButton expand handleToggle={vi.fn()} className="custom-class" />)
const button = screen.getByRole('button')
expect(button).toHaveClass('custom-class')
})
it('should have rounded-full style', () => {
render(<ToggleButton expand handleToggle={vi.fn()} />)
const button = screen.getByRole('button')
expect(button).toHaveClass('rounded-full')
})
})

View File

@ -1,4 +1,4 @@
import type { Operation } from './app-operations'
import type { Operation } from './app-info/app-operations'
import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { EnvironmentVariable } from '@/app/components/workflow/types'
@ -11,22 +11,22 @@ import {
RiFileDownloadLine,
RiFileUploadLine,
} from '@remixicon/react'
import dynamic from 'next/dynamic'
import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view'
import { useStore as useAppStore } from '@/app/components/app/store'
import Button from '@/app/components/base/button'
import ContentDialog from '@/app/components/base/content-dialog'
import { ToastContext } from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import dynamic from '@/next/dynamic'
import { useRouter } from '@/next/navigation'
import { copyApp, deleteApp, exportAppBundle, exportAppConfig, fetchAppDetail, updateAppInfo } from '@/service/apps'
import { useInvalidateAppList } from '@/service/use-apps'
import { fetchWorkflowDraft } from '@/service/workflow'
@ -35,7 +35,7 @@ import { getRedirection } from '@/utils/app-redirection'
import { cn } from '@/utils/classnames'
import { downloadBlob } from '@/utils/download'
import AppIcon from '../base/app-icon'
import AppOperations from './app-operations'
import AppOperations from './app-info/app-operations'
const SwitchAppModal = dynamic(() => import('@/app/components/app/switch-app-modal'), {
ssr: false,
@ -65,7 +65,7 @@ export type IAppInfoProps = {
const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailExpand }: IAppInfoProps) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const { replace } = useRouter()
const { onPlanInfoChanged } = useProviderContext()
const appDetail = useAppStore(state => state.appDetail)
@ -117,17 +117,14 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
max_active_requests,
})
setShowEditModal(false)
notify({
type: 'success',
message: t('editDone', { ns: 'app' }),
})
toast.success(t('editDone', { ns: 'app' }))
setAppDetail(app)
emitAppMetaUpdate()
}
catch {
notify({ type: 'error', message: t('editFailed', { ns: 'app' }) })
toast.error(t('editFailed', { ns: 'app' }))
}
}, [appDetail, notify, setAppDetail, t, emitAppMetaUpdate])
}, [appDetail, setAppDetail, t, emitAppMetaUpdate])
const onCopy: DuplicateAppModalProps['onConfirm'] = async ({ name, icon_type, icon, icon_background }) => {
if (!appDetail)
@ -142,16 +139,13 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
mode: appDetail.mode,
})
setShowDuplicateModal(false)
notify({
type: 'success',
message: t('newApp.appCreated', { ns: 'app' }),
})
toast.success(t('newApp.appCreated', { ns: 'app' }))
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
onPlanInfoChanged()
getRedirection(true, newApp, replace)
}
catch {
notify({ type: 'error', message: t('newApp.appCreateFailed', { ns: 'app' }) })
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
}
}
@ -174,7 +168,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
downloadBlob({ data: file, fileName: `${appDetail.name}.yaml` })
}
catch {
notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) })
toast.error(t('exportFailed', { ns: 'app' }))
}
}
@ -205,7 +199,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
setExportSandboxed(sandboxed)
}
catch {
notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) })
toast.error(t('exportFailed', { ns: 'app' }))
}
}
@ -214,20 +208,20 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
return
try {
await deleteApp(appDetail.id)
notify({ type: 'success', message: t('appDeleted', { ns: 'app' }) })
toast.success(t('appDeleted', { ns: 'app' }))
invalidateAppList()
onPlanInfoChanged()
setAppDetail()
replace('/apps')
}
catch (e: any) {
notify({
type: 'error',
message: `${t('appDeleteFailed', { ns: 'app' })}${'message' in e ? `: ${e.message}` : ''}`,
})
catch (e: unknown) {
const suffix = typeof e === 'object' && e !== null && 'message' in e
? `: ${String((e as { message: unknown }).message)}`
: ''
toast.error(`${t('appDeleteFailed', { ns: 'app' })}${suffix}`)
}
setShowConfirmDelete(false)
}, [appDetail, invalidateAppList, notify, onPlanInfoChanged, replace, setAppDetail, t])
}, [appDetail, invalidateAppList, onPlanInfoChanged, replace, setAppDetail, t])
useEffect(() => {
if (!appDetail?.id)

View File

@ -0,0 +1,298 @@
import type { App, AppSSO } from '@/types/app'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { AppModeEnum } from '@/types/app'
import AppInfoDetailPanel from '../app-info-detail-panel'
vi.mock('../../../base/app-icon', () => ({
default: ({ size, icon }: { size: string, icon: string }) => (
<div data-testid="app-icon" data-size={size} data-icon={icon} />
),
}))
vi.mock('@/app/components/base/content-dialog', () => ({
default: ({ show, onClose, children, className }: {
show: boolean
onClose: () => void
children: React.ReactNode
className?: string
}) => (
show
? (
<div data-testid="content-dialog" className={className}>
<button type="button" data-testid="dialog-close" onClick={onClose}>Close</button>
{children}
</div>
)
: null
),
}))
vi.mock('@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view', () => ({
default: ({ appId }: { appId: string }) => (
<div data-testid="card-view" data-app-id={appId} />
),
}))
vi.mock('@/app/components/base/button', () => ({
default: ({ children, onClick, className, size, variant }: {
children: React.ReactNode
onClick?: () => void
className?: string
size?: string
variant?: string
}) => (
<button type="button" onClick={onClick} className={className} data-size={size} data-variant={variant}>
{children}
</button>
),
}))
vi.mock('../app-operations', () => ({
default: ({ primaryOperations, secondaryOperations }: {
primaryOperations?: Array<{ id: string, title: string, onClick: () => void }>
secondaryOperations?: Array<{ id: string, title: string, onClick: () => void, type?: string }>
}) => (
<div data-testid="app-operations">
{primaryOperations?.map(op => (
<button key={op.id} type="button" data-testid={`op-${op.id}`} onClick={op.onClick}>{op.title}</button>
))}
{secondaryOperations?.map(op => (
op.type === 'divider'
? <button key={op.id} type="button" data-testid={`op-${op.id}`} onClick={op.onClick}>divider</button>
: <button key={op.id} type="button" data-testid={`op-${op.id}`} onClick={op.onClick}>{op.title}</button>
))}
</div>
),
}))
const createAppDetail = (overrides: Partial<App> = {}): App & Partial<AppSSO> => ({
id: 'app-1',
name: 'Test App',
mode: AppModeEnum.CHAT,
icon: '🤖',
icon_type: 'emoji',
icon_background: '#FFEAD5',
icon_url: '',
description: 'A test description',
use_icon_as_answer_icon: false,
...overrides,
} as App & Partial<AppSSO>)
describe('AppInfoDetailPanel', () => {
const defaultProps = {
appDetail: createAppDetail(),
show: true,
onClose: vi.fn(),
openModal: vi.fn(),
exportCheck: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should not render when show is false', () => {
render(<AppInfoDetailPanel {...defaultProps} show={false} />)
expect(screen.queryByTestId('content-dialog')).not.toBeInTheDocument()
})
it('should render dialog when show is true', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
expect(screen.getByTestId('content-dialog')).toBeInTheDocument()
})
it('should display app name', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
expect(screen.getByText('Test App')).toBeInTheDocument()
})
it('should display app mode label', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
})
it('should display description when available', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
expect(screen.getByText('A test description')).toBeInTheDocument()
})
it('should not display description when empty', () => {
render(<AppInfoDetailPanel {...defaultProps} appDetail={createAppDetail({ description: '' })} />)
expect(screen.queryByText('A test description')).not.toBeInTheDocument()
})
it('should not display description when undefined', () => {
render(<AppInfoDetailPanel {...defaultProps} appDetail={createAppDetail({ description: undefined as unknown as string })} />)
expect(screen.queryByText('A test description')).not.toBeInTheDocument()
})
it('should render CardView with correct appId', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
const cardView = screen.getByTestId('card-view')
expect(cardView).toHaveAttribute('data-app-id', 'app-1')
})
it('should render app icon with large size', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
const icon = screen.getByTestId('app-icon')
expect(icon).toHaveAttribute('data-size', 'large')
})
})
describe('Operations', () => {
it('should render edit, duplicate, and export operations', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
expect(screen.getByTestId('op-edit')).toBeInTheDocument()
expect(screen.getByTestId('op-duplicate')).toBeInTheDocument()
expect(screen.getByTestId('op-export')).toBeInTheDocument()
})
it('should call openModal with edit when edit is clicked', async () => {
const user = userEvent.setup()
render(<AppInfoDetailPanel {...defaultProps} />)
await user.click(screen.getByTestId('op-edit'))
expect(defaultProps.openModal).toHaveBeenCalledWith('edit')
})
it('should call openModal with duplicate when duplicate is clicked', async () => {
const user = userEvent.setup()
render(<AppInfoDetailPanel {...defaultProps} />)
await user.click(screen.getByTestId('op-duplicate'))
expect(defaultProps.openModal).toHaveBeenCalledWith('duplicate')
})
it('should call exportCheck when export is clicked', async () => {
const user = userEvent.setup()
render(<AppInfoDetailPanel {...defaultProps} />)
await user.click(screen.getByTestId('op-export'))
expect(defaultProps.exportCheck).toHaveBeenCalledTimes(1)
})
it('should render delete operation', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
expect(screen.getByTestId('op-delete')).toBeInTheDocument()
})
it('should call openModal with delete when delete is clicked', async () => {
const user = userEvent.setup()
render(<AppInfoDetailPanel {...defaultProps} />)
await user.click(screen.getByTestId('op-delete'))
expect(defaultProps.openModal).toHaveBeenCalledWith('delete')
})
})
describe('Import DSL option', () => {
it('should show import DSL for advanced_chat mode', () => {
render(
<AppInfoDetailPanel
{...defaultProps}
appDetail={createAppDetail({ mode: AppModeEnum.ADVANCED_CHAT })}
/>,
)
expect(screen.getByTestId('op-import')).toBeInTheDocument()
})
it('should show import DSL for workflow mode', () => {
render(
<AppInfoDetailPanel
{...defaultProps}
appDetail={createAppDetail({ mode: AppModeEnum.WORKFLOW })}
/>,
)
expect(screen.getByTestId('op-import')).toBeInTheDocument()
})
it('should not show import DSL for chat mode', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
expect(screen.queryByTestId('op-import')).not.toBeInTheDocument()
})
it('should call openModal with importDSL when import is clicked', async () => {
const user = userEvent.setup()
render(
<AppInfoDetailPanel
{...defaultProps}
appDetail={createAppDetail({ mode: AppModeEnum.ADVANCED_CHAT })}
/>,
)
await user.click(screen.getByTestId('op-import'))
expect(defaultProps.openModal).toHaveBeenCalledWith('importDSL')
})
it('should render divider in secondary operations', async () => {
const user = userEvent.setup()
render(<AppInfoDetailPanel {...defaultProps} />)
const divider = screen.getByTestId('op-divider-1')
expect(divider).toBeInTheDocument()
await user.click(divider)
})
})
describe('Switch operation', () => {
it('should show switch button for chat mode', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
expect(screen.getByText('app.switch')).toBeInTheDocument()
})
it('should show switch button for completion mode', () => {
render(
<AppInfoDetailPanel
{...defaultProps}
appDetail={createAppDetail({ mode: AppModeEnum.COMPLETION })}
/>,
)
expect(screen.getByText('app.switch')).toBeInTheDocument()
})
it('should not show switch button for workflow mode', () => {
render(
<AppInfoDetailPanel
{...defaultProps}
appDetail={createAppDetail({ mode: AppModeEnum.WORKFLOW })}
/>,
)
expect(screen.queryByText('app.switch')).not.toBeInTheDocument()
})
it('should not show switch button for advanced_chat mode', () => {
render(
<AppInfoDetailPanel
{...defaultProps}
appDetail={createAppDetail({ mode: AppModeEnum.ADVANCED_CHAT })}
/>,
)
expect(screen.queryByText('app.switch')).not.toBeInTheDocument()
})
it('should call openModal with switch when switch button is clicked', async () => {
const user = userEvent.setup()
render(<AppInfoDetailPanel {...defaultProps} />)
await user.click(screen.getByText('app.switch'))
expect(defaultProps.openModal).toHaveBeenCalledWith('switch')
})
})
describe('Dialog interactions', () => {
it('should call onClose when dialog close button is clicked', async () => {
const user = userEvent.setup()
render(<AppInfoDetailPanel {...defaultProps} />)
await user.click(screen.getByTestId('dialog-close'))
expect(defaultProps.onClose).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -0,0 +1,264 @@
import type { App, AppSSO } from '@/types/app'
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { AppModeEnum } from '@/types/app'
import AppInfoModals from '../app-info-modals'
vi.mock('@/next/dynamic', () => ({
default: (loader: () => Promise<{ default: React.ComponentType }>) => {
const LazyComp = React.lazy(loader)
return function DynamicWrapper(props: Record<string, unknown>) {
return React.createElement(
React.Suspense,
{ fallback: null },
React.createElement(LazyComp, props),
)
}
},
}))
vi.mock('@/app/components/app/switch-app-modal', () => ({
default: ({ show, onClose }: { show: boolean, onClose: () => void }) => (
show ? <div data-testid="switch-modal"><button type="button" onClick={onClose}>Close Switch</button></div> : null
),
}))
vi.mock('@/app/components/explore/create-app-modal', () => ({
default: ({ show, onHide, isEditModal }: { show: boolean, onHide: () => void, isEditModal?: boolean }) => (
show ? <div data-testid={isEditModal ? 'edit-modal' : 'create-modal'}><button type="button" onClick={onHide}>Close Edit</button></div> : null
),
}))
vi.mock('@/app/components/app/duplicate-modal', () => ({
default: ({ show, onHide }: { show: boolean, onHide: () => void }) => (
show ? <div data-testid="duplicate-modal"><button type="button" onClick={onHide}>Close Dup</button></div> : null
),
}))
vi.mock('@/app/components/base/confirm', () => ({
default: ({ isShow, title, onConfirm, onCancel }: {
isShow: boolean
title: string
onConfirm: () => void
onCancel: () => void
}) => (
isShow
? (
<div data-testid="confirm-modal" data-title={title}>
<button type="button" onClick={onConfirm}>Confirm</button>
<button type="button" onClick={onCancel}>Cancel</button>
</div>
)
: null
),
}))
vi.mock('@/app/components/workflow/update-dsl-modal', () => ({
default: ({ onCancel, onBackup }: { onCancel: () => void, onBackup: () => void }) => (
<div data-testid="import-dsl-modal">
<button type="button" onClick={onCancel}>Cancel Import</button>
<button type="button" onClick={onBackup}>Backup</button>
</div>
),
}))
vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
default: ({ onConfirm, onClose }: { onConfirm: (include?: boolean) => void, onClose: () => void }) => (
<div data-testid="dsl-export-confirm-modal">
<button type="button" onClick={() => onConfirm(true)}>Export Include</button>
<button type="button" onClick={onClose}>Close Export</button>
</div>
),
}))
const createAppDetail = (overrides: Partial<App> = {}): App & Partial<AppSSO> => ({
id: 'app-1',
name: 'Test App',
mode: AppModeEnum.CHAT,
icon: '🤖',
icon_type: 'emoji',
icon_background: '#FFEAD5',
icon_url: '',
description: '',
use_icon_as_answer_icon: false,
max_active_requests: null,
...overrides,
} as App & Partial<AppSSO>)
const defaultProps = {
appDetail: createAppDetail(),
closeModal: vi.fn(),
secretEnvList: [] as never[],
setSecretEnvList: vi.fn(),
onEdit: vi.fn(),
onCopy: vi.fn(),
onExport: vi.fn(),
exportCheck: vi.fn(),
handleConfirmExport: vi.fn(),
onConfirmDelete: vi.fn(),
}
describe('AppInfoModals', () => {
beforeAll(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
beforeEach(() => {
vi.clearAllMocks()
})
it('should render nothing when activeModal is null', async () => {
await act(async () => {
render(<AppInfoModals {...defaultProps} activeModal={null} />)
})
expect(screen.queryByTestId('switch-modal')).not.toBeInTheDocument()
expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument()
})
it('should render SwitchAppModal when activeModal is switch', async () => {
await act(async () => {
render(<AppInfoModals {...defaultProps} activeModal="switch" />)
})
await waitFor(() => {
expect(screen.getByTestId('switch-modal')).toBeInTheDocument()
})
})
it('should render CreateAppModal in edit mode when activeModal is edit', async () => {
await act(async () => {
render(<AppInfoModals {...defaultProps} activeModal="edit" />)
})
await waitFor(() => {
expect(screen.getByTestId('edit-modal')).toBeInTheDocument()
})
})
it('should render DuplicateAppModal when activeModal is duplicate', async () => {
await act(async () => {
render(<AppInfoModals {...defaultProps} activeModal="duplicate" />)
})
await waitFor(() => {
expect(screen.getByTestId('duplicate-modal')).toBeInTheDocument()
})
})
it('should render Confirm for delete when activeModal is delete', async () => {
await act(async () => {
render(<AppInfoModals {...defaultProps} activeModal="delete" />)
})
await waitFor(() => {
const confirm = screen.getByTestId('confirm-modal')
expect(confirm).toBeInTheDocument()
expect(confirm).toHaveAttribute('data-title', 'app.deleteAppConfirmTitle')
})
})
it('should render UpdateDSLModal when activeModal is importDSL', async () => {
await act(async () => {
render(<AppInfoModals {...defaultProps} activeModal="importDSL" />)
})
await waitFor(() => {
expect(screen.getByTestId('import-dsl-modal')).toBeInTheDocument()
})
})
it('should render export warning Confirm when activeModal is exportWarning', async () => {
await act(async () => {
render(<AppInfoModals {...defaultProps} activeModal="exportWarning" />)
})
await waitFor(() => {
const confirm = screen.getByTestId('confirm-modal')
expect(confirm).toBeInTheDocument()
expect(confirm).toHaveAttribute('data-title', 'workflow.sidebar.exportWarning')
})
})
it('should render DSLExportConfirmModal when secretEnvList is not empty', async () => {
await act(async () => {
render(
<AppInfoModals
{...defaultProps}
activeModal={null}
secretEnvList={[{ id: 'env-1', key: 'SECRET', value: '', value_type: 'secret', name: 'Secret' } as never]}
/>,
)
})
await waitFor(() => {
expect(screen.getByTestId('dsl-export-confirm-modal')).toBeInTheDocument()
})
})
it('should not render DSLExportConfirmModal when secretEnvList is empty', async () => {
await act(async () => {
render(<AppInfoModals {...defaultProps} activeModal={null} />)
})
expect(screen.queryByTestId('dsl-export-confirm-modal')).not.toBeInTheDocument()
})
it('should call closeModal when cancel on delete modal', async () => {
const user = userEvent.setup()
await act(async () => {
render(<AppInfoModals {...defaultProps} activeModal="delete" />)
})
await waitFor(() => expect(screen.getByText('Cancel')).toBeInTheDocument())
await user.click(screen.getByText('Cancel'))
expect(defaultProps.closeModal).toHaveBeenCalledTimes(1)
})
it('should call onConfirmDelete when confirm on delete modal', async () => {
const user = userEvent.setup()
await act(async () => {
render(<AppInfoModals {...defaultProps} activeModal="delete" />)
})
await waitFor(() => expect(screen.getByText('Confirm')).toBeInTheDocument())
await user.click(screen.getByText('Confirm'))
expect(defaultProps.onConfirmDelete).toHaveBeenCalledTimes(1)
})
it('should call handleConfirmExport when confirm on export warning', async () => {
const user = userEvent.setup()
await act(async () => {
render(<AppInfoModals {...defaultProps} activeModal="exportWarning" />)
})
await waitFor(() => expect(screen.getByText('Confirm')).toBeInTheDocument())
await user.click(screen.getByText('Confirm'))
expect(defaultProps.handleConfirmExport).toHaveBeenCalledTimes(1)
})
it('should call exportCheck when backup on importDSL modal', async () => {
const user = userEvent.setup()
await act(async () => {
render(<AppInfoModals {...defaultProps} activeModal="importDSL" />)
})
await waitFor(() => expect(screen.getByText('Backup')).toBeInTheDocument())
await user.click(screen.getByText('Backup'))
expect(defaultProps.exportCheck).toHaveBeenCalledTimes(1)
})
it('should call setSecretEnvList with empty array when closing DSLExportConfirmModal', async () => {
const user = userEvent.setup()
await act(async () => {
render(
<AppInfoModals
{...defaultProps}
activeModal={null}
secretEnvList={[{ id: 'env-1', key: 'SECRET', value: '', value_type: 'secret', name: 'Secret' } as never]}
/>,
)
})
await waitFor(() => expect(screen.getByText('Close Export')).toBeInTheDocument())
await user.click(screen.getByText('Close Export'))
expect(defaultProps.setSecretEnvList).toHaveBeenCalledWith([])
})
})

View File

@ -0,0 +1,99 @@
import type { App, AppSSO } from '@/types/app'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { AppModeEnum } from '@/types/app'
import AppInfoTrigger from '../app-info-trigger'
vi.mock('../../../base/app-icon', () => ({
default: ({ size, icon, background }: {
size: string
icon: string
background: string
iconType?: string
imageUrl?: string
}) => (
<div data-testid="app-icon" data-size={size} data-icon={icon} data-bg={background} />
),
}))
const createAppDetail = (overrides: Partial<App> = {}): App & Partial<AppSSO> => ({
id: 'app-1',
name: 'Test App',
mode: AppModeEnum.CHAT,
icon: '🤖',
icon_type: 'emoji',
icon_background: '#FFEAD5',
icon_url: '',
description: 'A test app',
use_icon_as_answer_icon: false,
...overrides,
} as App & Partial<AppSSO>)
describe('AppInfoTrigger', () => {
it('should render app icon with correct size when expanded', () => {
render(<AppInfoTrigger appDetail={createAppDetail()} expand onClick={vi.fn()} />)
const icon = screen.getByTestId('app-icon')
expect(icon).toHaveAttribute('data-size', 'large')
})
it('should render app icon with small size when collapsed', () => {
render(<AppInfoTrigger appDetail={createAppDetail()} expand={false} onClick={vi.fn()} />)
const icon = screen.getByTestId('app-icon')
expect(icon).toHaveAttribute('data-size', 'small')
})
it('should show app name when expanded', () => {
render(<AppInfoTrigger appDetail={createAppDetail({ name: 'My Chatbot' })} expand onClick={vi.fn()} />)
expect(screen.getByText('My Chatbot')).toBeInTheDocument()
})
it('should not show app name when collapsed', () => {
render(<AppInfoTrigger appDetail={createAppDetail({ name: 'My Chatbot' })} expand={false} onClick={vi.fn()} />)
expect(screen.queryByText('My Chatbot')).not.toBeInTheDocument()
})
it('should show app mode label when expanded', () => {
render(<AppInfoTrigger appDetail={createAppDetail({ mode: AppModeEnum.ADVANCED_CHAT })} expand onClick={vi.fn()} />)
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
})
it('should not show mode label when collapsed', () => {
render(<AppInfoTrigger appDetail={createAppDetail()} expand={false} onClick={vi.fn()} />)
expect(screen.queryByText('app.types.chatbot')).not.toBeInTheDocument()
})
it('should call onClick when button is clicked', async () => {
const user = userEvent.setup()
const onClick = vi.fn()
render(<AppInfoTrigger appDetail={createAppDetail()} expand onClick={onClick} />)
await user.click(screen.getByRole('button'))
expect(onClick).toHaveBeenCalledTimes(1)
})
it('should show settings icon in expanded and collapsed states', () => {
const { container, rerender } = render(
<AppInfoTrigger appDetail={createAppDetail()} expand onClick={vi.fn()} />,
)
expect(container.querySelector('svg')).toBeInTheDocument()
rerender(<AppInfoTrigger appDetail={createAppDetail()} expand={false} onClick={vi.fn()} />)
expect(container.querySelector('svg')).toBeInTheDocument()
})
it('should apply ml-1 class to icon wrapper when collapsed', () => {
render(
<AppInfoTrigger appDetail={createAppDetail()} expand={false} onClick={vi.fn()} />,
)
const iconWrapper = screen.getByTestId('app-icon').parentElement
expect(iconWrapper).toHaveClass('ml-1')
})
it('should not apply ml-1 class when expanded', () => {
render(<AppInfoTrigger appDetail={createAppDetail()} expand onClick={vi.fn()} />)
const iconWrapper = screen.getByTestId('app-icon').parentElement
expect(iconWrapper).not.toHaveClass('ml-1')
})
})

View File

@ -0,0 +1,34 @@
import type { TFunction } from 'i18next'
import { AppModeEnum } from '@/types/app'
import { getAppModeLabel } from '../app-mode-labels'
describe('getAppModeLabel', () => {
const t: TFunction = ((key: string, options?: Record<string, unknown>) => {
const ns = (options?.ns as string | undefined) ?? ''
return ns ? `${ns}.${key}` : key
}) as TFunction
it('should return advanced chat label', () => {
expect(getAppModeLabel(AppModeEnum.ADVANCED_CHAT, t)).toBe('app.types.advanced')
})
it('should return agent chat label', () => {
expect(getAppModeLabel(AppModeEnum.AGENT_CHAT, t)).toBe('app.types.agent')
})
it('should return chatbot label', () => {
expect(getAppModeLabel(AppModeEnum.CHAT, t)).toBe('app.types.chatbot')
})
it('should return completion label', () => {
expect(getAppModeLabel(AppModeEnum.COMPLETION, t)).toBe('app.types.completion')
})
it('should return workflow label for unknown mode', () => {
expect(getAppModeLabel('unknown-mode', t)).toBe('app.types.workflow')
})
it('should return workflow label for workflow mode', () => {
expect(getAppModeLabel(AppModeEnum.WORKFLOW, t)).toBe('app.types.workflow')
})
})

View File

@ -0,0 +1,253 @@
import type { Operation } from '../app-operations'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import AppOperations from '../app-operations'
vi.mock('../../../base/button', () => ({
default: ({ children, onClick, className, size, variant, id, tabIndex, ...rest }: {
'children': React.ReactNode
'onClick'?: () => void
'className'?: string
'size'?: string
'variant'?: string
'id'?: string
'tabIndex'?: number
'data-targetid'?: string
}) => (
<button
type="button"
onClick={onClick}
className={className}
data-size={size}
data-variant={variant}
id={id}
tabIndex={tabIndex}
data-targetid={rest['data-targetid']}
>
{children}
</button>
),
}))
vi.mock('../../../base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
<div data-testid="portal-elem" data-open={open}>{children}</div>
),
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => (
<div data-testid="portal-content" className={className}>{children}</div>
),
}))
const createOperation = (id: string, title: string, type?: 'divider'): Operation => ({
id,
title,
icon: <svg data-testid={`icon-${id}`} />,
onClick: vi.fn(),
type,
})
function setupDomMeasurements(navWidth: number, moreWidth: number, childWidths: number[]) {
const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
configurable: true,
get(this: HTMLElement) {
if (this.getAttribute('aria-hidden') === 'true')
return navWidth
if (this.id === 'more-measure')
return moreWidth
if (this.dataset.targetid) {
const idx = Array.from(this.parentElement?.children ?? []).indexOf(this)
return childWidths[idx] ?? 50
}
return 0
},
})
return () => {
if (originalClientWidth)
Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidth)
}
}
describe('AppOperations', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering with operations prop', () => {
it('should render measurement container', () => {
const ops = [createOperation('edit', 'Edit'), createOperation('copy', 'Copy')]
const { container } = render(<AppOperations gap={4} operations={ops} />)
expect(container.querySelector('[aria-hidden="true"]')).toBeInTheDocument()
})
it('should render operation buttons in measurement container', () => {
const ops = [createOperation('edit', 'Edit'), createOperation('copy', 'Copy')]
render(<AppOperations gap={4} operations={ops} />)
const editButtons = screen.getAllByText('Edit')
expect(editButtons.length).toBeGreaterThanOrEqual(1)
})
it('should use operations as primary when provided', () => {
const ops = [createOperation('edit', 'Edit')]
const secondary = [createOperation('delete', 'Delete')]
render(<AppOperations gap={4} operations={ops} secondaryOperations={secondary} />)
const editButtons = screen.getAllByText('Edit')
expect(editButtons.length).toBeGreaterThanOrEqual(1)
})
})
describe('Rendering with primaryOperations and secondaryOperations', () => {
it('should render primary operations in measurement container', () => {
const primary = [createOperation('edit', 'Edit')]
render(<AppOperations gap={4} primaryOperations={primary} />)
const editButtons = screen.getAllByText('Edit')
expect(editButtons.length).toBeGreaterThanOrEqual(1)
})
it('should use secondary operations when provided', () => {
const primary = [createOperation('edit', 'Edit')]
const secondary = [createOperation('delete', 'Delete')]
render(<AppOperations gap={4} primaryOperations={primary} secondaryOperations={secondary} />)
const editButtons = screen.getAllByText('Edit')
expect(editButtons.length).toBeGreaterThanOrEqual(1)
})
it('should use empty operations array when neither operations nor primaryOperations provided', () => {
const { container } = render(<AppOperations gap={4} />)
expect(container).toBeInTheDocument()
})
})
describe('Overflow behavior', () => {
it('should show all operations when container is wide enough', () => {
const cleanup = setupDomMeasurements(500, 60, [80, 80])
const ops = [createOperation('edit', 'Edit'), createOperation('copy', 'Copy')]
render(<AppOperations gap={4} operations={ops} />)
cleanup()
})
it('should move operations to more menu when container is narrow', () => {
const cleanup = setupDomMeasurements(100, 60, [80, 80])
const ops = [createOperation('edit', 'Edit'), createOperation('copy', 'Copy')]
render(<AppOperations gap={4} operations={ops} />)
cleanup()
})
it('should show last item without more button if it fits alone', () => {
const cleanup = setupDomMeasurements(90, 60, [80])
const ops = [createOperation('edit', 'Edit')]
render(<AppOperations gap={4} operations={ops} />)
cleanup()
})
})
describe('More button', () => {
it('should render more button text in measurement container', () => {
const ops = [createOperation('edit', 'Edit')]
render(<AppOperations gap={4} operations={ops} />)
const moreButtons = screen.getAllByText('common.operation.more')
expect(moreButtons.length).toBeGreaterThanOrEqual(1)
})
it('should handle trigger more click', async () => {
const cleanup = setupDomMeasurements(100, 60, [80, 80])
const user = userEvent.setup()
const ops = [createOperation('edit', 'Edit'), createOperation('copy', 'Copy')]
const secondary = [createOperation('delete', 'Delete')]
render(<AppOperations gap={4} primaryOperations={ops} secondaryOperations={secondary} />)
const trigger = screen.queryByTestId('portal-trigger')
if (trigger)
await user.click(trigger)
cleanup()
})
})
describe('Visible operations click', () => {
it('should call onClick when a visible operation is clicked', async () => {
const cleanup = setupDomMeasurements(500, 60, [80, 80])
const user = userEvent.setup()
const editOp = createOperation('edit', 'Edit')
const copyOp = createOperation('copy', 'Copy')
render(<AppOperations gap={4} operations={[editOp, copyOp]} />)
const visibleButtons = screen.getAllByText('Edit')
const clickableButton = visibleButtons.find(btn => btn.closest('button')?.tabIndex !== -1)
if (clickableButton)
await user.click(clickableButton)
cleanup()
})
})
describe('Divider operations', () => {
it('should filter out divider operations from inline display', () => {
const ops = [
createOperation('edit', 'Edit'),
createOperation('div-1', '', 'divider'),
createOperation('delete', 'Delete'),
]
render(<AppOperations gap={4} operations={ops} />)
const editButtons = screen.getAllByText('Edit')
expect(editButtons.length).toBeGreaterThanOrEqual(1)
})
})
describe('Gap styling', () => {
it('should apply gap to measurement and visible containers', () => {
const ops = [createOperation('edit', 'Edit')]
const { container } = render(<AppOperations gap={8} operations={ops} />)
const hiddenContainer = container.querySelector('[aria-hidden="true"]')
expect(hiddenContainer).toHaveStyle({ gap: '8px' })
})
it('should apply gap to visible container', () => {
const ops = [createOperation('edit', 'Edit')]
const { container } = render(<AppOperations gap={4} operations={ops} />)
const containers = container.querySelectorAll('div[style]')
const visibleContainer = Array.from(containers).find(
el => el.getAttribute('aria-hidden') !== 'true',
)
if (visibleContainer)
expect(visibleContainer).toHaveStyle({ gap: '4px' })
})
})
describe('More menu content', () => {
it('should render divider items in more menu', () => {
const cleanup = setupDomMeasurements(100, 60, [80, 80])
const primary = [createOperation('edit', 'Edit'), createOperation('copy', 'Copy')]
const secondary = [
createOperation('divider-1', '', 'divider'),
createOperation('delete', 'Delete'),
]
render(<AppOperations gap={4} primaryOperations={primary} secondaryOperations={secondary} />)
cleanup()
})
})
describe('Empty inline operations', () => {
it('should handle when all operations are dividers', () => {
const ops = [createOperation('div-1', '', 'divider'), createOperation('div-2', '', 'divider')]
const { container } = render(<AppOperations gap={4} operations={ops} />)
expect(container).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,155 @@
import type { App, AppSSO } from '@/types/app'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { AppModeEnum } from '@/types/app'
import AppInfo from '../index'
let mockIsCurrentWorkspaceEditor = true
const mockSetPanelOpen = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({ replace: vi.fn() }),
}))
vi.mock('@/service/use-apps', () => ({
useInvalidateAppList: () => vi.fn(),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor,
}),
}))
vi.mock('../app-info-trigger', () => ({
default: React.memo(({ appDetail, expand, onClick }: {
appDetail: App & Partial<AppSSO>
expand: boolean
onClick: () => void
}) => (
<button type="button" data-testid="trigger" data-expand={expand} onClick={onClick}>
{appDetail.name}
</button>
)),
}))
vi.mock('../app-info-detail-panel', () => ({
default: React.memo(({ show, onClose }: { show: boolean, onClose: () => void }) => (
show ? <div data-testid="detail-panel"><button type="button" onClick={onClose}>Close Panel</button></div> : null
)),
}))
vi.mock('../app-info-modals', () => ({
default: React.memo(({ activeModal }: { activeModal: string | null }) => (
activeModal ? <div data-testid="modals" data-modal={activeModal} /> : null
)),
}))
const mockAppDetail: App & Partial<AppSSO> = {
id: 'app-1',
name: 'Test App',
mode: AppModeEnum.CHAT,
icon: '🤖',
icon_type: 'emoji',
icon_background: '#FFEAD5',
icon_url: '',
description: '',
use_icon_as_answer_icon: false,
} as App & Partial<AppSSO>
const mockUseAppInfoActions = {
appDetail: mockAppDetail,
panelOpen: false,
setPanelOpen: mockSetPanelOpen,
closePanel: vi.fn(),
activeModal: null as string | null,
openModal: vi.fn(),
closeModal: vi.fn(),
secretEnvList: [],
setSecretEnvList: vi.fn(),
onEdit: vi.fn(),
onCopy: vi.fn(),
onExport: vi.fn(),
exportCheck: vi.fn(),
handleConfirmExport: vi.fn(),
onConfirmDelete: vi.fn(),
}
vi.mock('../use-app-info-actions', () => ({
useAppInfoActions: () => mockUseAppInfoActions,
}))
describe('AppInfo', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCurrentWorkspaceEditor = true
mockUseAppInfoActions.appDetail = mockAppDetail
mockUseAppInfoActions.panelOpen = false
mockUseAppInfoActions.activeModal = null
})
it('should return null when appDetail is not available', () => {
mockUseAppInfoActions.appDetail = undefined as unknown as App & Partial<AppSSO>
const { container } = render(<AppInfo expand />)
expect(container.innerHTML).toBe('')
})
it('should render trigger when not onlyShowDetail', () => {
render(<AppInfo expand />)
expect(screen.getByTestId('trigger')).toBeInTheDocument()
})
it('should not render trigger when onlyShowDetail is true', () => {
render(<AppInfo expand onlyShowDetail />)
expect(screen.queryByTestId('trigger')).not.toBeInTheDocument()
})
it('should pass expand prop to trigger', () => {
render(<AppInfo expand />)
expect(screen.getByTestId('trigger')).toHaveAttribute('data-expand', 'true')
const { unmount } = render(<AppInfo expand={false} />)
const triggers = screen.getAllByTestId('trigger')
expect(triggers[triggers.length - 1]).toHaveAttribute('data-expand', 'false')
unmount()
})
it('should toggle panel when trigger is clicked and user is editor', async () => {
const user = userEvent.setup()
render(<AppInfo expand />)
await user.click(screen.getByTestId('trigger'))
expect(mockSetPanelOpen).toHaveBeenCalled()
const updater = mockSetPanelOpen.mock.calls[0][0] as (v: boolean) => boolean
expect(updater(false)).toBe(true)
expect(updater(true)).toBe(false)
})
it('should not toggle panel when trigger is clicked and user is not editor', async () => {
const user = userEvent.setup()
mockIsCurrentWorkspaceEditor = false
render(<AppInfo expand />)
await user.click(screen.getByTestId('trigger'))
expect(mockSetPanelOpen).not.toHaveBeenCalled()
})
it('should show detail panel based on panelOpen when not onlyShowDetail', () => {
mockUseAppInfoActions.panelOpen = true
render(<AppInfo expand />)
expect(screen.getByTestId('detail-panel')).toBeInTheDocument()
})
it('should show detail panel based on openState when onlyShowDetail', () => {
render(<AppInfo expand onlyShowDetail openState />)
expect(screen.getByTestId('detail-panel')).toBeInTheDocument()
})
it('should hide detail panel when openState is false and onlyShowDetail', () => {
render(<AppInfo expand onlyShowDetail openState={false} />)
expect(screen.queryByTestId('detail-panel')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,492 @@
import { act, renderHook } from '@testing-library/react'
import { AppModeEnum } from '@/types/app'
import { useAppInfoActions } from '../use-app-info-actions'
const mockNotify = vi.fn()
const mockReplace = vi.fn()
const mockOnPlanInfoChanged = vi.fn()
const mockInvalidateAppList = vi.fn()
const mockSetAppDetail = vi.fn()
const mockUpdateAppInfo = vi.fn()
const mockCopyApp = vi.fn()
const mockExportAppConfig = vi.fn()
const mockDeleteApp = vi.fn()
const mockFetchWorkflowDraft = vi.fn()
const mockDownloadBlob = vi.fn()
let mockAppDetail: Record<string, unknown> | undefined = {
id: 'app-1',
name: 'Test App',
mode: AppModeEnum.CHAT,
icon: '🤖',
icon_type: 'emoji',
icon_background: '#FFEAD5',
}
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ replace: mockReplace }),
}))
vi.mock('use-context-selector', () => ({
useContext: () => ({ notify: mockNotify }),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({ onPlanInfoChanged: mockOnPlanInfoChanged }),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) => selector({
appDetail: mockAppDetail,
setAppDetail: mockSetAppDetail,
}),
}))
vi.mock('@/app/components/base/toast/context', () => ({
ToastContext: {},
}))
vi.mock('@/service/use-apps', () => ({
useInvalidateAppList: () => mockInvalidateAppList,
}))
vi.mock('@/service/apps', () => ({
updateAppInfo: (...args: unknown[]) => mockUpdateAppInfo(...args),
copyApp: (...args: unknown[]) => mockCopyApp(...args),
exportAppConfig: (...args: unknown[]) => mockExportAppConfig(...args),
deleteApp: (...args: unknown[]) => mockDeleteApp(...args),
}))
vi.mock('@/service/workflow', () => ({
fetchWorkflowDraft: (...args: unknown[]) => mockFetchWorkflowDraft(...args),
}))
vi.mock('@/utils/download', () => ({
downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args),
}))
vi.mock('@/utils/app-redirection', () => ({
getRedirection: vi.fn(),
}))
vi.mock('@/config', () => ({
NEED_REFRESH_APP_LIST_KEY: 'test-refresh-key',
}))
describe('useAppInfoActions', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAppDetail = {
id: 'app-1',
name: 'Test App',
mode: AppModeEnum.CHAT,
icon: '🤖',
icon_type: 'emoji',
icon_background: '#FFEAD5',
}
})
describe('Initial state', () => {
it('should return initial state correctly', () => {
const { result } = renderHook(() => useAppInfoActions({}))
expect(result.current.appDetail).toEqual(mockAppDetail)
expect(result.current.panelOpen).toBe(false)
expect(result.current.activeModal).toBeNull()
expect(result.current.secretEnvList).toEqual([])
})
})
describe('Panel management', () => {
it('should toggle panelOpen', () => {
const { result } = renderHook(() => useAppInfoActions({}))
act(() => {
result.current.setPanelOpen(true)
})
expect(result.current.panelOpen).toBe(true)
})
it('should close panel and call onDetailExpand', () => {
const onDetailExpand = vi.fn()
const { result } = renderHook(() => useAppInfoActions({ onDetailExpand }))
act(() => {
result.current.setPanelOpen(true)
})
act(() => {
result.current.closePanel()
})
expect(result.current.panelOpen).toBe(false)
expect(onDetailExpand).toHaveBeenCalledWith(false)
})
})
describe('Modal management', () => {
it('should open modal and close panel', () => {
const { result } = renderHook(() => useAppInfoActions({}))
act(() => {
result.current.setPanelOpen(true)
})
act(() => {
result.current.openModal('edit')
})
expect(result.current.activeModal).toBe('edit')
expect(result.current.panelOpen).toBe(false)
})
it('should close modal', () => {
const { result } = renderHook(() => useAppInfoActions({}))
act(() => {
result.current.openModal('delete')
})
act(() => {
result.current.closeModal()
})
expect(result.current.activeModal).toBeNull()
})
})
describe('onEdit', () => {
it('should update app info and close modal on success', async () => {
const updatedApp = { ...mockAppDetail, name: 'Updated' }
mockUpdateAppInfo.mockResolvedValue(updatedApp)
const { result } = renderHook(() => useAppInfoActions({}))
await act(async () => {
await result.current.onEdit({
name: 'Updated',
icon_type: 'emoji',
icon: '🤖',
icon_background: '#fff',
description: '',
use_icon_as_answer_icon: false,
})
})
expect(mockUpdateAppInfo).toHaveBeenCalled()
expect(mockSetAppDetail).toHaveBeenCalledWith(updatedApp)
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'app.editDone' })
})
it('should notify error on edit failure', async () => {
mockUpdateAppInfo.mockRejectedValue(new Error('fail'))
const { result } = renderHook(() => useAppInfoActions({}))
await act(async () => {
await result.current.onEdit({
name: 'Updated',
icon_type: 'emoji',
icon: '🤖',
icon_background: '#fff',
description: '',
use_icon_as_answer_icon: false,
})
})
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'app.editFailed' })
})
it('should not call updateAppInfo when appDetail is undefined', async () => {
mockAppDetail = undefined
const { result } = renderHook(() => useAppInfoActions({}))
await act(async () => {
await result.current.onEdit({
name: 'Updated',
icon_type: 'emoji',
icon: '🤖',
icon_background: '#fff',
description: '',
use_icon_as_answer_icon: false,
})
})
expect(mockUpdateAppInfo).not.toHaveBeenCalled()
})
})
describe('onCopy', () => {
it('should copy app and redirect on success', async () => {
const newApp = { id: 'app-2', name: 'Copy', mode: 'chat' }
mockCopyApp.mockResolvedValue(newApp)
const { result } = renderHook(() => useAppInfoActions({}))
await act(async () => {
await result.current.onCopy({
name: 'Copy',
icon_type: 'emoji',
icon: '🤖',
icon_background: '#fff',
})
})
expect(mockCopyApp).toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'app.newApp.appCreated' })
expect(mockOnPlanInfoChanged).toHaveBeenCalled()
})
it('should notify error on copy failure', async () => {
mockCopyApp.mockRejectedValue(new Error('fail'))
const { result } = renderHook(() => useAppInfoActions({}))
await act(async () => {
await result.current.onCopy({
name: 'Copy',
icon_type: 'emoji',
icon: '🤖',
icon_background: '#fff',
})
})
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'app.newApp.appCreateFailed' })
})
})
describe('onCopy - early return', () => {
it('should not call copyApp when appDetail is undefined', async () => {
mockAppDetail = undefined
const { result } = renderHook(() => useAppInfoActions({}))
await act(async () => {
await result.current.onCopy({
name: 'Copy',
icon_type: 'emoji',
icon: '🤖',
icon_background: '#fff',
})
})
expect(mockCopyApp).not.toHaveBeenCalled()
})
})
describe('onExport', () => {
it('should export app config and trigger download', async () => {
mockExportAppConfig.mockResolvedValue({ data: 'yaml-content' })
const { result } = renderHook(() => useAppInfoActions({}))
await act(async () => {
await result.current.onExport(false)
})
expect(mockExportAppConfig).toHaveBeenCalledWith({ appID: 'app-1', include: false })
expect(mockDownloadBlob).toHaveBeenCalled()
})
it('should notify error on export failure', async () => {
mockExportAppConfig.mockRejectedValue(new Error('fail'))
const { result } = renderHook(() => useAppInfoActions({}))
await act(async () => {
await result.current.onExport()
})
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'app.exportFailed' })
})
})
describe('onExport - early return', () => {
it('should not export when appDetail is undefined', async () => {
mockAppDetail = undefined
const { result } = renderHook(() => useAppInfoActions({}))
await act(async () => {
await result.current.onExport()
})
expect(mockExportAppConfig).not.toHaveBeenCalled()
})
})
describe('exportCheck', () => {
it('should call onExport directly for non-workflow modes', async () => {
mockExportAppConfig.mockResolvedValue({ data: 'yaml' })
const { result } = renderHook(() => useAppInfoActions({}))
await act(async () => {
await result.current.exportCheck()
})
expect(mockExportAppConfig).toHaveBeenCalled()
})
it('should open export warning modal for workflow mode', async () => {
mockAppDetail = { ...mockAppDetail, mode: AppModeEnum.WORKFLOW }
const { result } = renderHook(() => useAppInfoActions({}))
await act(async () => {
await result.current.exportCheck()
})
expect(result.current.activeModal).toBe('exportWarning')
})
it('should open export warning modal for advanced_chat mode', async () => {
mockAppDetail = { ...mockAppDetail, mode: AppModeEnum.ADVANCED_CHAT }
const { result } = renderHook(() => useAppInfoActions({}))
await act(async () => {
await result.current.exportCheck()
})
expect(result.current.activeModal).toBe('exportWarning')
})
})
describe('exportCheck - early return', () => {
it('should not do anything when appDetail is undefined', async () => {
mockAppDetail = undefined
const { result } = renderHook(() => useAppInfoActions({}))
await act(async () => {
await result.current.exportCheck()
})
expect(mockExportAppConfig).not.toHaveBeenCalled()
})
})
describe('handleConfirmExport', () => {
it('should export directly when no secret env variables', async () => {
mockAppDetail = { ...mockAppDetail, mode: AppModeEnum.WORKFLOW }
mockFetchWorkflowDraft.mockResolvedValue({
environment_variables: [{ value_type: 'string' }],
})
mockExportAppConfig.mockResolvedValue({ data: 'yaml' })
const { result } = renderHook(() => useAppInfoActions({}))
await act(async () => {
await result.current.handleConfirmExport()
})
expect(mockExportAppConfig).toHaveBeenCalled()
})
it('should set secret env list when secret variables exist', async () => {
mockAppDetail = { ...mockAppDetail, mode: AppModeEnum.WORKFLOW }
const secretVars = [{ value_type: 'secret', key: 'API_KEY' }]
mockFetchWorkflowDraft.mockResolvedValue({
environment_variables: secretVars,
})
const { result } = renderHook(() => useAppInfoActions({}))
await act(async () => {
await result.current.handleConfirmExport()
})
expect(result.current.secretEnvList).toEqual(secretVars)
})
it('should notify error on workflow draft fetch failure', async () => {
mockFetchWorkflowDraft.mockRejectedValue(new Error('fail'))
const { result } = renderHook(() => useAppInfoActions({}))
await act(async () => {
await result.current.handleConfirmExport()
})
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'app.exportFailed' })
})
})
describe('handleConfirmExport - early return', () => {
it('should not do anything when appDetail is undefined', async () => {
mockAppDetail = undefined
const { result } = renderHook(() => useAppInfoActions({}))
await act(async () => {
await result.current.handleConfirmExport()
})
expect(mockFetchWorkflowDraft).not.toHaveBeenCalled()
})
})
describe('handleConfirmExport - with environment variables', () => {
it('should handle empty environment_variables', async () => {
mockFetchWorkflowDraft.mockResolvedValue({
environment_variables: undefined,
})
mockExportAppConfig.mockResolvedValue({ data: 'yaml' })
const { result } = renderHook(() => useAppInfoActions({}))
await act(async () => {
await result.current.handleConfirmExport()
})
expect(mockExportAppConfig).toHaveBeenCalled()
})
})
describe('onConfirmDelete', () => {
it('should delete app and redirect on success', async () => {
mockDeleteApp.mockResolvedValue({})
const { result } = renderHook(() => useAppInfoActions({}))
await act(async () => {
await result.current.onConfirmDelete()
})
expect(mockDeleteApp).toHaveBeenCalledWith('app-1')
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'app.appDeleted' })
expect(mockInvalidateAppList).toHaveBeenCalled()
expect(mockReplace).toHaveBeenCalledWith('/apps')
expect(mockSetAppDetail).toHaveBeenCalledWith()
})
it('should not delete when appDetail is undefined', async () => {
mockAppDetail = undefined
const { result } = renderHook(() => useAppInfoActions({}))
await act(async () => {
await result.current.onConfirmDelete()
})
expect(mockDeleteApp).not.toHaveBeenCalled()
})
it('should notify error on delete failure', async () => {
mockDeleteApp.mockRejectedValue({ message: 'cannot delete' })
const { result } = renderHook(() => useAppInfoActions({}))
await act(async () => {
await result.current.onConfirmDelete()
})
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: expect.stringContaining('app.appDeleteFailed'),
})
})
})
})

View File

@ -0,0 +1,151 @@
import type { Operation } from './app-operations'
import type { AppInfoModalType } from './use-app-info-actions'
import type { App, AppSSO } from '@/types/app'
import {
RiDeleteBinLine,
RiEditLine,
RiExchange2Line,
RiFileCopy2Line,
RiFileDownloadLine,
RiFileUploadLine,
} from '@remixicon/react'
import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view'
import Button from '@/app/components/base/button'
import ContentDialog from '@/app/components/base/content-dialog'
import { AppModeEnum } from '@/types/app'
import AppIcon from '../../base/app-icon'
import { getAppModeLabel } from './app-mode-labels'
import AppOperations from './app-operations'
type AppInfoDetailPanelProps = {
appDetail: App & Partial<AppSSO>
show: boolean
onClose: () => void
openModal: (modal: Exclude<AppInfoModalType, null>) => void
exportCheck: () => void
}
const AppInfoDetailPanel = ({
appDetail,
show,
onClose,
openModal,
exportCheck,
}: AppInfoDetailPanelProps) => {
const { t } = useTranslation()
const primaryOperations = useMemo<Operation[]>(() => [
{
id: 'edit',
title: t('editApp', { ns: 'app' }),
icon: <RiEditLine />,
onClick: () => openModal('edit'),
},
{
id: 'duplicate',
title: t('duplicate', { ns: 'app' }),
icon: <RiFileCopy2Line />,
onClick: () => openModal('duplicate'),
},
{
id: 'export',
title: t('export', { ns: 'app' }),
icon: <RiFileDownloadLine />,
onClick: exportCheck,
},
], [t, openModal, exportCheck])
const secondaryOperations = useMemo<Operation[]>(() => [
...(appDetail.mode === AppModeEnum.ADVANCED_CHAT || appDetail.mode === AppModeEnum.WORKFLOW)
? [{
id: 'import',
title: t('common.importDSL', { ns: 'workflow' }),
icon: <RiFileUploadLine />,
onClick: () => openModal('importDSL'),
}]
: [],
{
id: 'divider-1',
title: '',
icon: <></>,
onClick: () => {},
type: 'divider' as const,
},
{
id: 'delete',
title: t('operation.delete', { ns: 'common' }),
icon: <RiDeleteBinLine />,
onClick: () => openModal('delete'),
},
], [appDetail.mode, t, openModal])
const switchOperation = useMemo(() => {
if (appDetail.mode !== AppModeEnum.COMPLETION && appDetail.mode !== AppModeEnum.CHAT)
return null
return {
id: 'switch',
title: t('switch', { ns: 'app' }),
icon: <RiExchange2Line />,
onClick: () => openModal('switch'),
}
}, [appDetail.mode, t, openModal])
return (
<ContentDialog
show={show}
onClose={onClose}
className="absolute bottom-2 left-2 top-2 flex w-[420px] flex-col rounded-2xl !p-0"
>
<div className="flex shrink-0 flex-col items-start justify-center gap-3 self-stretch p-4">
<div className="flex items-center gap-3 self-stretch">
<AppIcon
size="large"
iconType={appDetail.icon_type}
icon={appDetail.icon}
background={appDetail.icon_background}
imageUrl={appDetail.icon_url}
/>
<div className="flex flex-1 flex-col items-start justify-center overflow-hidden">
<div className="w-full truncate text-text-secondary system-md-semibold">{appDetail.name}</div>
<div className="text-text-tertiary system-2xs-medium-uppercase">
{getAppModeLabel(appDetail.mode, t)}
</div>
</div>
</div>
{appDetail.description && (
<div className="overflow-wrap-anywhere max-h-[105px] w-full max-w-full overflow-y-auto whitespace-normal break-words text-text-tertiary system-xs-regular">
{appDetail.description}
</div>
)}
<AppOperations
gap={4}
primaryOperations={primaryOperations}
secondaryOperations={secondaryOperations}
/>
</div>
<CardView
appId={appDetail.id}
isInPanel={true}
className="flex flex-1 flex-col gap-2 overflow-auto px-2 py-1"
/>
{switchOperation && (
<div className="flex min-h-fit shrink-0 flex-col items-start justify-center gap-3 self-stretch pb-2">
<Button
size="medium"
variant="ghost"
className="gap-0.5"
onClick={switchOperation.onClick}
>
{switchOperation.icon}
<span className="text-text-tertiary system-sm-medium">{switchOperation.title}</span>
</Button>
</div>
)}
</ContentDialog>
)
}
export default React.memo(AppInfoDetailPanel)

View File

@ -0,0 +1,132 @@
import type { AppInfoModalType } from './use-app-info-actions'
import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { EnvironmentVariable } from '@/app/components/workflow/types'
import type { App, AppSSO } from '@/types/app'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import dynamic from '@/next/dynamic'
const SwitchAppModal = dynamic(() => import('@/app/components/app/switch-app-modal'), { ssr: false })
const CreateAppModal = dynamic(() => import('@/app/components/explore/create-app-modal'), { ssr: false })
const DuplicateAppModal = dynamic(() => import('@/app/components/app/duplicate-modal'), { ssr: false })
const Confirm = dynamic(() => import('@/app/components/base/confirm'), { ssr: false })
const UpdateDSLModal = dynamic(() => import('@/app/components/workflow/update-dsl-modal'), { ssr: false })
const DSLExportConfirmModal = dynamic(() => import('@/app/components/workflow/dsl-export-confirm-modal'), { ssr: false })
type AppInfoModalsProps = {
appDetail: App & Partial<AppSSO>
activeModal: AppInfoModalType
closeModal: () => void
secretEnvList: EnvironmentVariable[]
setSecretEnvList: (list: EnvironmentVariable[]) => void
onEdit: CreateAppModalProps['onConfirm']
onCopy: DuplicateAppModalProps['onConfirm']
onExport: (include?: boolean) => Promise<void>
exportCheck: () => void
handleConfirmExport: () => void
onConfirmDelete: () => void
}
const AppInfoModals = ({
appDetail,
activeModal,
closeModal,
secretEnvList,
setSecretEnvList,
onEdit,
onCopy,
onExport,
exportCheck,
handleConfirmExport,
onConfirmDelete,
}: AppInfoModalsProps) => {
const { t } = useTranslation()
const [confirmDeleteInput, setConfirmDeleteInput] = useState('')
return (
<>
{activeModal === 'switch' && (
<SwitchAppModal
inAppDetail
show
appDetail={appDetail}
onClose={closeModal}
onSuccess={closeModal}
/>
)}
{activeModal === 'edit' && (
<CreateAppModal
isEditModal
appName={appDetail.name}
appIconType={appDetail.icon_type}
appIcon={appDetail.icon}
appIconBackground={appDetail.icon_background}
appIconUrl={appDetail.icon_url}
appDescription={appDetail.description}
appMode={appDetail.mode}
appUseIconAsAnswerIcon={appDetail.use_icon_as_answer_icon}
max_active_requests={appDetail.max_active_requests ?? null}
show
onConfirm={onEdit}
onHide={closeModal}
/>
)}
{activeModal === 'duplicate' && (
<DuplicateAppModal
appName={appDetail.name}
icon_type={appDetail.icon_type}
icon={appDetail.icon}
icon_background={appDetail.icon_background}
icon_url={appDetail.icon_url}
show
onConfirm={onCopy}
onHide={closeModal}
/>
)}
{activeModal === 'delete' && (
<Confirm
title={t('deleteAppConfirmTitle', { ns: 'app' })}
content={t('deleteAppConfirmContent', { ns: 'app' })}
isShow
confirmInputLabel={t('deleteAppConfirmInputLabel', { ns: 'app', appName: appDetail.name })}
confirmInputPlaceholder={t('deleteAppConfirmInputPlaceholder', { ns: 'app' })}
confirmInputValue={confirmDeleteInput}
onConfirmInputChange={setConfirmDeleteInput}
confirmInputMatchValue={appDetail.name}
onConfirm={onConfirmDelete}
onCancel={() => {
setConfirmDeleteInput('')
closeModal()
}}
/>
)}
{activeModal === 'importDSL' && (
<UpdateDSLModal
onCancel={closeModal}
onBackup={exportCheck}
/>
)}
{activeModal === 'exportWarning' && (
<Confirm
type="info"
isShow
title={t('sidebar.exportWarning', { ns: 'workflow' })}
content={t('sidebar.exportWarningDesc', { ns: 'workflow' })}
onConfirm={handleConfirmExport}
onCancel={closeModal}
/>
)}
{secretEnvList.length > 0 && (
<DSLExportConfirmModal
envList={secretEnvList}
onConfirm={onExport}
onClose={() => setSecretEnvList([])}
/>
)}
</>
)
}
export default React.memo(AppInfoModals)

View File

@ -0,0 +1,67 @@
import type { App, AppSSO } from '@/types/app'
import { RiEqualizer2Line } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
import AppIcon from '../../base/app-icon'
import { getAppModeLabel } from './app-mode-labels'
type AppInfoTriggerProps = {
appDetail: App & Partial<AppSSO>
expand: boolean
onClick: () => void
}
const AppInfoTrigger = ({ appDetail, expand, onClick }: AppInfoTriggerProps) => {
const { t } = useTranslation()
const modeLabel = getAppModeLabel(appDetail.mode, t)
return (
<button
type="button"
onClick={onClick}
className="block w-full"
aria-label={!expand ? `${appDetail.name} - ${modeLabel}` : undefined}
>
<div className="flex flex-col gap-2 rounded-lg p-1 hover:bg-state-base-hover">
<div className="flex items-center gap-1">
<div className={cn(!expand && 'ml-1')}>
<AppIcon
size={expand ? 'large' : 'small'}
iconType={appDetail.icon_type}
icon={appDetail.icon}
background={appDetail.icon_background}
imageUrl={appDetail.icon_url}
/>
</div>
{expand && (
<div className="ml-auto flex items-center justify-center rounded-md p-0.5">
<div className="flex h-5 w-5 items-center justify-center">
<RiEqualizer2Line className="h-4 w-4 text-text-tertiary" />
</div>
</div>
)}
</div>
{!expand && (
<div className="flex items-center justify-center">
<div className="flex h-5 w-5 items-center justify-center rounded-md p-0.5">
<RiEqualizer2Line className="h-4 w-4 text-text-tertiary" />
</div>
</div>
)}
{expand && (
<div className="flex flex-col items-start gap-1">
<div className="flex w-full">
<div className="truncate whitespace-nowrap text-text-secondary system-md-semibold">{appDetail.name}</div>
</div>
<div className="whitespace-nowrap text-text-tertiary system-2xs-medium-uppercase">
{getAppModeLabel(appDetail.mode, t)}
</div>
</div>
)}
</div>
</button>
)
}
export default React.memo(AppInfoTrigger)

View File

@ -0,0 +1,17 @@
import type { TFunction } from 'i18next'
import { AppModeEnum } from '@/types/app'
export function getAppModeLabel(mode: string, t: TFunction): string {
switch (mode) {
case AppModeEnum.ADVANCED_CHAT:
return t('types.advanced', { ns: 'app' })
case AppModeEnum.AGENT_CHAT:
return t('types.agent', { ns: 'app' })
case AppModeEnum.CHAT:
return t('types.chatbot', { ns: 'app' })
case AppModeEnum.COMPLETION:
return t('types.completion', { ns: 'app' })
default:
return t('types.workflow', { ns: 'app' })
}
}

View File

@ -3,7 +3,7 @@ import { RiMoreLine } from '@remixicon/react'
import { cloneElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../base/portal-to-follow-elem'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem'
export type Operation = {
id: string

View File

@ -0,0 +1,75 @@
import * as React from 'react'
import { useAppContext } from '@/context/app-context'
import AppInfoDetailPanel from './app-info-detail-panel'
import AppInfoModals from './app-info-modals'
import AppInfoTrigger from './app-info-trigger'
import { useAppInfoActions } from './use-app-info-actions'
export type IAppInfoProps = {
expand: boolean
onlyShowDetail?: boolean
openState?: boolean
onDetailExpand?: (expand: boolean) => void
}
const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailExpand }: IAppInfoProps) => {
const { isCurrentWorkspaceEditor } = useAppContext()
const {
appDetail,
panelOpen,
setPanelOpen,
closePanel,
activeModal,
openModal,
closeModal,
secretEnvList,
setSecretEnvList,
onEdit,
onCopy,
onExport,
exportCheck,
handleConfirmExport,
onConfirmDelete,
} = useAppInfoActions({ onDetailExpand })
if (!appDetail)
return null
return (
<div>
{!onlyShowDetail && (
<AppInfoTrigger
appDetail={appDetail}
expand={expand}
onClick={() => {
if (isCurrentWorkspaceEditor)
setPanelOpen(v => !v)
}}
/>
)}
<AppInfoDetailPanel
appDetail={appDetail}
show={onlyShowDetail ? openState : panelOpen}
onClose={closePanel}
openModal={openModal}
exportCheck={exportCheck}
/>
<AppInfoModals
appDetail={appDetail}
activeModal={activeModal}
closeModal={closeModal}
secretEnvList={secretEnvList}
setSecretEnvList={setSecretEnvList}
onEdit={onEdit}
onCopy={onCopy}
onExport={onExport}
exportCheck={exportCheck}
handleConfirmExport={handleConfirmExport}
onConfirmDelete={onConfirmDelete}
/>
</div>
)
}
export default React.memo(AppInfo)

View File

@ -0,0 +1,189 @@
import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { EnvironmentVariable } from '@/app/components/workflow/types'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useStore as useAppStore } from '@/app/components/app/store'
import { ToastContext } from '@/app/components/base/toast/context'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useProviderContext } from '@/context/provider-context'
import { useRouter } from '@/next/navigation'
import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps'
import { useInvalidateAppList } from '@/service/use-apps'
import { fetchWorkflowDraft } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
import { downloadBlob } from '@/utils/download'
export type AppInfoModalType = 'edit' | 'duplicate' | 'delete' | 'switch' | 'importDSL' | 'exportWarning' | null
type UseAppInfoActionsParams = {
onDetailExpand?: (expand: boolean) => void
}
export function useAppInfoActions({ onDetailExpand }: UseAppInfoActionsParams) {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const { replace } = useRouter()
const { onPlanInfoChanged } = useProviderContext()
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(state => state.setAppDetail)
const invalidateAppList = useInvalidateAppList()
const [panelOpen, setPanelOpen] = useState(false)
const [activeModal, setActiveModal] = useState<AppInfoModalType>(null)
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([])
const closePanel = useCallback(() => {
setPanelOpen(false)
onDetailExpand?.(false)
}, [onDetailExpand])
const openModal = useCallback((modal: Exclude<AppInfoModalType, null>) => {
closePanel()
setActiveModal(modal)
}, [closePanel])
const closeModal = useCallback(() => {
setActiveModal(null)
}, [])
const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({
name,
icon_type,
icon,
icon_background,
description,
use_icon_as_answer_icon,
max_active_requests,
}) => {
if (!appDetail)
return
try {
const app = await updateAppInfo({
appID: appDetail.id,
name,
icon_type,
icon,
icon_background,
description,
use_icon_as_answer_icon,
max_active_requests,
})
closeModal()
notify({ type: 'success', message: t('editDone', { ns: 'app' }) })
setAppDetail(app)
}
catch {
notify({ type: 'error', message: t('editFailed', { ns: 'app' }) })
}
}, [appDetail, closeModal, notify, setAppDetail, t])
const onCopy: DuplicateAppModalProps['onConfirm'] = useCallback(async ({
name,
icon_type,
icon,
icon_background,
}) => {
if (!appDetail)
return
try {
const newApp = await copyApp({
appID: appDetail.id,
name,
icon_type,
icon,
icon_background,
mode: appDetail.mode,
})
closeModal()
notify({ type: 'success', message: t('newApp.appCreated', { ns: 'app' }) })
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
onPlanInfoChanged()
getRedirection(true, newApp, replace)
}
catch {
notify({ type: 'error', message: t('newApp.appCreateFailed', { ns: 'app' }) })
}
}, [appDetail, closeModal, notify, onPlanInfoChanged, replace, t])
const onExport = useCallback(async (include = false) => {
if (!appDetail)
return
try {
const { data } = await exportAppConfig({ appID: appDetail.id, include })
const file = new Blob([data], { type: 'application/yaml' })
downloadBlob({ data: file, fileName: `${appDetail.name}.yml` })
}
catch {
notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) })
}
}, [appDetail, notify, t])
const exportCheck = useCallback(async () => {
if (!appDetail)
return
if (appDetail.mode !== AppModeEnum.WORKFLOW && appDetail.mode !== AppModeEnum.ADVANCED_CHAT) {
onExport()
return
}
setActiveModal('exportWarning')
}, [appDetail, onExport])
const handleConfirmExport = useCallback(async () => {
if (!appDetail)
return
closeModal()
try {
const workflowDraft = await fetchWorkflowDraft(`/apps/${appDetail.id}/workflows/draft`)
const list = (workflowDraft.environment_variables || []).filter(env => env.value_type === 'secret')
if (list.length === 0) {
onExport()
return
}
setSecretEnvList(list)
}
catch {
notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) })
}
}, [appDetail, closeModal, notify, onExport, t])
const onConfirmDelete = useCallback(async () => {
if (!appDetail)
return
try {
await deleteApp(appDetail.id)
notify({ type: 'success', message: t('appDeleted', { ns: 'app' }) })
invalidateAppList()
onPlanInfoChanged()
setAppDetail()
replace('/apps')
}
catch (e: unknown) {
notify({
type: 'error',
message: `${t('appDeleteFailed', { ns: 'app' })}${e instanceof Error && e.message ? `: ${e.message}` : ''}`,
})
}
closeModal()
}, [appDetail, closeModal, invalidateAppList, notify, onPlanInfoChanged, replace, setAppDetail, t])
return {
appDetail,
panelOpen,
setPanelOpen,
closePanel,
activeModal,
openModal,
closeModal,
secretEnvList,
setSecretEnvList,
onEdit,
onCopy,
onExport,
exportCheck,
handleConfirmExport,
onConfirmDelete,
}
}

View File

@ -1,4 +1,4 @@
import type { NavIcon } from './navLink'
import type { NavIcon } from './nav-link'
import {
RiEqualizer2Line,
RiMenuLine,
@ -13,12 +13,12 @@ import {
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { useAppContext } from '@/context/app-context'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
import AppIcon from '../base/app-icon'
import Divider from '../base/divider'
import AppInfo from './app-info'
import NavLink from './navLink'
import { getAppModeLabel } from './app-info/app-mode-labels'
import NavLink from './nav-link'
type Props = {
navigation: Array<{
@ -99,7 +99,7 @@ const AppSidebarDropdown = ({ navigation }: Props) => {
<div className="flex w-full">
<div className="truncate text-text-secondary system-md-semibold">{appDetail.name}</div>
</div>
<div className="text-text-tertiary system-2xs-medium-uppercase">{appDetail.mode === AppModeEnum.ADVANCED_CHAT ? t('types.advanced', { ns: 'app' }) : appDetail.mode === AppModeEnum.AGENT_CHAT ? t('types.agent', { ns: 'app' }) : appDetail.mode === AppModeEnum.CHAT ? t('types.chatbot', { ns: 'app' }) : appDetail.mode === AppModeEnum.COMPLETION ? t('types.completion', { ns: 'app' }) : t('types.workflow', { ns: 'app' })}</div>
<div className="text-text-tertiary system-2xs-medium-uppercase">{getAppModeLabel(appDetail.mode, t)}</div>
</div>
</div>
</div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

View File

@ -0,0 +1,228 @@
import type { DataSet } from '@/models/datasets'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import {
ChunkingMode,
DatasetPermission,
DataSourceType,
} from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import Dropdown from '../dropdown'
let mockDataset: DataSet
let mockIsDatasetOperator = false
const mockReplace = vi.fn()
const mockInvalidDatasetList = vi.fn()
const mockInvalidDatasetDetail = vi.fn()
const mockExportPipeline = vi.fn()
const mockCheckIsUsedInApp = vi.fn()
const mockDeleteDataset = vi.fn()
const createDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Dataset Name',
indexing_status: 'completed',
icon_info: {
icon: '📙',
icon_background: '#FFF4ED',
icon_type: 'emoji',
icon_url: '',
},
description: 'Dataset description',
permission: DatasetPermission.onlyMe,
data_source_type: DataSourceType.FILE,
indexing_technique: 'high_quality' as DataSet['indexing_technique'],
created_by: 'user-1',
updated_by: 'user-1',
updated_at: 1690000000,
app_count: 0,
doc_form: ChunkingMode.text,
document_count: 1,
total_document_count: 1,
word_count: 1000,
provider: 'internal',
embedding_model: 'text-embedding-3',
embedding_model_provider: 'openai',
embedding_available: true,
retrieval_model_dict: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 5,
score_threshold_enabled: false,
score_threshold: 0,
},
retrieval_model: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 5,
score_threshold_enabled: false,
score_threshold: 0,
},
tags: [],
external_knowledge_info: {
external_knowledge_id: '',
external_knowledge_api_id: '',
external_knowledge_api_name: '',
external_knowledge_api_endpoint: '',
},
external_retrieval_model: {
top_k: 0,
score_threshold: 0,
score_threshold_enabled: false,
},
built_in_field_enabled: false,
runtime_mode: 'rag_pipeline',
enable_api: false,
is_multimodal: false,
...overrides,
})
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ replace: mockReplace }),
}))
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (selector: (state: { dataset?: DataSet }) => unknown) => selector({ dataset: mockDataset }),
}))
vi.mock('@/context/app-context', () => ({
useSelector: (selector: (state: { isCurrentWorkspaceDatasetOperator: boolean }) => unknown) =>
selector({ isCurrentWorkspaceDatasetOperator: mockIsDatasetOperator }),
}))
vi.mock('@/service/knowledge/use-dataset', () => ({
datasetDetailQueryKeyPrefix: ['dataset', 'detail'],
useInvalidDatasetList: () => mockInvalidDatasetList,
}))
vi.mock('@/service/use-base', () => ({
useInvalid: () => mockInvalidDatasetDetail,
}))
vi.mock('@/service/use-pipeline', () => ({
useExportPipelineDSL: () => ({ mutateAsync: mockExportPipeline }),
}))
vi.mock('@/service/datasets', () => ({
checkIsUsedInApp: (...args: unknown[]) => mockCheckIsUsedInApp(...args),
deleteDataset: (...args: unknown[]) => mockDeleteDataset(...args),
}))
vi.mock('@/app/components/datasets/rename-modal', () => ({
default: ({
show,
onClose,
onSuccess,
}: {
show: boolean
onClose: () => void
onSuccess?: () => void
}) => {
if (!show)
return null
return (
<div data-testid="rename-modal">
<button type="button" onClick={onSuccess}>Success</button>
<button type="button" onClick={onClose}>Close</button>
</div>
)
},
}))
vi.mock('@/app/components/base/confirm', () => ({
default: ({
isShow,
onConfirm,
onCancel,
title,
content,
}: {
isShow: boolean
onConfirm: () => void
onCancel: () => void
title: string
content: string
}) => {
if (!isShow)
return null
return (
<div data-testid="confirm-dialog">
<span>{title}</span>
<span>{content}</span>
<button type="button" onClick={onConfirm}>confirm</button>
<button type="button" onClick={onCancel}>cancel</button>
</div>
)
},
}))
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
describe('Dropdown callback coverage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDataset = createDataset({ pipeline_id: 'pipeline-1', runtime_mode: 'rag_pipeline' })
mockIsDatasetOperator = false
mockExportPipeline.mockResolvedValue({ data: 'pipeline-content' })
mockCheckIsUsedInApp.mockResolvedValue({ is_using: false })
mockDeleteDataset.mockResolvedValue({})
})
it('should call refreshDataset when rename succeeds', async () => {
const user = userEvent.setup()
render(<Dropdown expand />)
await user.click(screen.getByTestId('portal-trigger'))
await user.click(screen.getByText('common.operation.edit'))
expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
await user.click(screen.getByText('Success'))
await waitFor(() => {
expect(mockInvalidDatasetList).toHaveBeenCalled()
expect(mockInvalidDatasetDetail).toHaveBeenCalled()
})
})
it('should close rename modal when onClose is called', async () => {
const user = userEvent.setup()
render(<Dropdown expand />)
await user.click(screen.getByTestId('portal-trigger'))
await user.click(screen.getByText('common.operation.edit'))
expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
await user.click(screen.getByText('Close'))
await waitFor(() => {
expect(screen.queryByTestId('rename-modal')).not.toBeInTheDocument()
})
})
it('should close confirm dialog when cancel is clicked', async () => {
const user = userEvent.setup()
render(<Dropdown expand />)
await user.click(screen.getByTestId('portal-trigger'))
await user.click(screen.getByText('common.operation.delete'))
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
await user.click(screen.getByText('cancel'))
await waitFor(() => {
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
})
})
})

View File

@ -9,10 +9,10 @@ import {
DataSourceType,
} from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import Dropdown from './dropdown'
import DatasetInfo from './index'
import Menu from './menu'
import MenuItem from './menu-item'
import DatasetInfo from '..'
import Dropdown from '../dropdown'
import Menu from '../menu'
import MenuItem from '../menu-item'
let mockDataset: DataSet
let mockIsDatasetOperator = false
@ -90,7 +90,7 @@ const createDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
...overrides,
})
vi.mock('next/navigation', () => ({
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
replace: mockReplace,
}),

View File

@ -1,11 +1,11 @@
import type { DataSet } from '@/models/datasets'
import { RiMoreFill } from '@remixicon/react'
import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useRouter } from '@/next/navigation'
import { checkIsUsedInApp, deleteDataset } from '@/service/datasets'
import { datasetDetailQueryKeyPrefix, useInvalidDatasetList } from '@/service/knowledge/use-dataset'
import { useInvalid } from '@/service/use-base'

View File

@ -1,4 +1,4 @@
import type { NavIcon } from './navLink'
import type { NavIcon } from './nav-link'
import type { DataSet } from '@/models/datasets'
import {
RiMenuLine,
@ -21,7 +21,7 @@ import Divider from '../base/divider'
import Effect from '../base/effect'
import ExtraInfo from '../datasets/extra-info'
import Dropdown from './dataset-info/dropdown'
import NavLink from './navLink'
import NavLink from './nav-link'
type DatasetSidebarDropdownProps = {
navigation: Array<{

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

View File

@ -1,6 +1,5 @@
import type { NavIcon } from './navLink'
import type { NavIcon } from './nav-link'
import { useHover, useKeyPress } from 'ahooks'
import { usePathname } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -8,6 +7,7 @@ import { useShallow } from 'zustand/react/shallow'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { usePathname } from '@/next/navigation'
import { cn } from '@/utils/classnames'
import Divider from '../base/divider'
import Tooltip from '../base/tooltip'
@ -16,7 +16,7 @@ import AppInfo from './app-info'
import AppSidebarDropdown from './app-sidebar-dropdown'
import DatasetInfo from './dataset-info'
import DatasetSidebarDropdown from './dataset-sidebar-dropdown'
import NavLink from './navLink'
import NavLink from './nav-link'
import ToggleButton from './toggle-button'
export type IAppDetailNavProps = {

View File

@ -1,16 +1,16 @@
import type { NavLinkProps } from './navLink'
import type { NavLinkProps } from '..'
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import NavLink from './navLink'
import NavLink from '..'
// Mock Next.js navigation
vi.mock('next/navigation', () => ({
vi.mock('@/next/navigation', () => ({
useSelectedLayoutSegment: () => 'overview',
}))
// Mock Next.js Link component
vi.mock('next/link', () => ({
default: function MockLink({ children, href, className, title }: any) {
vi.mock('@/next/link', () => ({
default: function MockLink({ children, href, className, title }: { children: React.ReactNode, href: string, className?: string, title?: string }) {
return (
<a href={href} className={className} title={title} data-testid="nav-link">
{children}

View File

@ -1,8 +1,8 @@
'use client'
import type { RemixiconComponentType } from '@remixicon/react'
import Link from 'next/link'
import { useSelectedLayoutSegment } from 'next/navigation'
import * as React from 'react'
import Link from '@/next/link'
import { useSelectedLayoutSegment } from '@/next/navigation'
import { cn } from '@/utils/classnames'
export type NavIcon = React.ComponentType<

View File

@ -1,11 +0,0 @@
.sidebar {
border-right: 1px solid #F3F4F6;
}
.completionPic {
background-image: url('./completion.png')
}
.expertPic {
background-image: url('./expert.png')
}

View File

@ -1,7 +1,7 @@
import type { Props } from './csv-uploader'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { ToastContext } from '@/app/components/base/toast'
import { ToastContext } from '@/app/components/base/toast/context'
import CSVUploader from './csv-uploader'
describe('CSVUploader', () => {

View File

@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button'
import { Csv as CSVIcon } from '@/app/components/base/icons/src/public/files'
import { ToastContext } from '@/app/components/base/toast'
import { ToastContext } from '@/app/components/base/toast/context'
import { cn } from '@/utils/classnames'
export type Props = {

View File

@ -155,7 +155,7 @@ const Annotation: FC<Props> = (props) => {
<div className="text-text-primary system-sm-medium">{t('name', { ns: 'appAnnotation' })}</div>
<Switch
key={controlRefreshSwitch}
defaultValue={annotationConfig?.enabled}
value={annotationConfig?.enabled ?? false}
size="md"
onChange={async (value) => {
if (value) {

View File

@ -10,7 +10,7 @@ import { SubjectType } from '@/models/access-control'
import { useSearchForWhiteListCandidates } from '@/service/access-control'
import { cn } from '@/utils/classnames'
import useAccessControlStore from '../../../../context/access-control-store'
import Avatar from '../../base/avatar'
import { Avatar } from '../../base/avatar'
import Button from '../../base/button'
import Checkbox from '../../base/checkbox'
import Input from '../../base/input'
@ -203,7 +203,7 @@ function MemberItem({ member }: MemberItemProps) {
<div className="flex grow items-center">
<div className="mr-2 h-5 w-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
<div className="bg-access-app-icon-mask-bg flex h-full w-full items-center justify-center">
<Avatar className="h-[14px] w-[14px]" textClassName="text-[12px]" avatar={null} name={member.name} />
<Avatar size="xxs" avatar={null} name={member.name} />
</div>
</div>
<p className="mr-1 text-text-secondary system-sm-medium">{member.name}</p>

View File

@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next'
import { AccessMode } from '@/models/access-control'
import { useAppWhiteListSubjects } from '@/service/access-control'
import useAccessControlStore from '../../../../context/access-control-store'
import Avatar from '../../base/avatar'
import { Avatar } from '../../base/avatar'
import Loading from '../../base/loading'
import Tooltip from '../../base/tooltip'
import AddMemberOrGroupDialog from './add-member-or-group-pop'
@ -106,7 +106,7 @@ function MemberItem({ member }: MemberItemProps) {
}, [member, setSpecificMembers, specificMembers])
return (
<BaseItem
icon={<Avatar className="h-[14px] w-[14px]" textClassName="text-[12px]" avatar={null} name={member.name} />}
icon={<Avatar size="xxs" avatar={null} name={member.name} />}
onRemove={handleRemoveMember}
>
<p className="text-text-primary system-xs-regular">{member.name}</p>

View File

@ -5,18 +5,8 @@ import type { InstalledApp } from '@/models/explore'
import type { I18nKeysByPrefix } from '@/types/i18n'
import type { PublishWorkflowParams } from '@/types/workflow'
import {
RiArrowDownSLine,
RiArrowRightSLine,
RiBuildingLine,
RiGlobalLine,
RiLoader2Line,
RiLockLine,
RiPlanetLine,
RiPlayCircleLine,
RiPlayList2Line,
RiStore2Line,
RiTerminalBoxLine,
RiVerifiedBadgeLine,
} from '@remixicon/react'
import { useKeyPress } from 'ahooks'
import {
@ -71,22 +61,22 @@ type InstalledAppsResponse = {
installed_apps?: InstalledApp[]
}
const ACCESS_MODE_MAP: Record<AccessMode, { label: AccessModeLabel, icon: React.ElementType }> = {
const ACCESS_MODE_MAP: Record<AccessMode, { label: AccessModeLabel, icon: string }> = {
[AccessMode.ORGANIZATION]: {
label: 'organization',
icon: RiBuildingLine,
icon: 'i-ri-building-line',
},
[AccessMode.SPECIFIC_GROUPS_MEMBERS]: {
label: 'specific',
icon: RiLockLine,
icon: 'i-ri-lock-line',
},
[AccessMode.PUBLIC]: {
label: 'anyone',
icon: RiGlobalLine,
icon: 'i-ri-global-line',
},
[AccessMode.EXTERNAL_MEMBERS]: {
label: 'external',
icon: RiVerifiedBadgeLine,
icon: 'i-ri-verified-badge-line',
},
}
@ -96,11 +86,11 @@ const AccessModeDisplay: React.FC<{ mode?: AccessMode }> = ({ mode }) => {
if (!mode || !ACCESS_MODE_MAP[mode])
return null
const { icon: Icon, label } = ACCESS_MODE_MAP[mode]
const { icon, label } = ACCESS_MODE_MAP[mode]
return (
<>
<Icon className="h-4 w-4 shrink-0 text-text-secondary" />
<span className={`${icon} h-4 w-4 shrink-0 text-text-secondary`} />
<div className="grow truncate">
<span className="text-text-secondary system-sm-medium">{t(`accessControlDialog.accessItems.${label}`, { ns: 'app' })}</span>
</div>
@ -367,7 +357,7 @@ const AppPublisher = ({
loading={publishLoading}
>
{t('common.publish', { ns: 'workflow' })}
<RiArrowDownSLine className="h-4 w-4 text-components-button-primary-text" />
<span className="i-ri-arrow-down-s-line h-4 w-4 text-components-button-primary-text" />
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[11]">
@ -476,7 +466,7 @@ const AppPublisher = ({
</div>
{!isAppAccessSet && <p className="shrink-0 text-text-tertiary system-xs-regular">{t('publishApp.notSet', { ns: 'app' })}</p>}
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
<RiArrowRightSLine className="h-4 w-4 text-text-quaternary" />
<span className="i-ri-arrow-right-s-line h-4 w-4 text-text-quaternary" />
</div>
</div>
{!isAppAccessSet && <p className="mt-1 text-text-warning system-xs-regular">{t('publishApp.notSetDesc', { ns: 'app' })}</p>}
@ -491,7 +481,7 @@ const AppPublisher = ({
className="flex-1"
disabled={disabledFunctionButton}
link={appURL}
icon={<RiPlayCircleLine className="h-4 w-4" />}
icon={<span className="i-ri-play-circle-line h-4 w-4" />}
>
{t('common.runApp', { ns: 'workflow' })}
</SuggestedAction>
@ -503,7 +493,7 @@ const AppPublisher = ({
className="flex-1"
disabled={disabledFunctionButton}
link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`}
icon={<RiPlayList2Line className="h-4 w-4" />}
icon={<span className="i-ri-play-list-2-line h-4 w-4" />}
>
{t('common.batchRunApp', { ns: 'workflow' })}
</SuggestedAction>
@ -529,7 +519,7 @@ const AppPublisher = ({
handleOpenInExplore()
}}
disabled={disabledFunctionButton}
icon={<RiPlanetLine className="h-4 w-4" />}
icon={<span className="i-ri-planet-line h-4 w-4" />}
>
{t('common.openInExplore', { ns: 'workflow' })}
</SuggestedAction>
@ -539,7 +529,7 @@ const AppPublisher = ({
className="flex-1"
disabled={!publishedAt || missingStartNode}
link="./develop"
icon={<RiTerminalBoxLine className="h-4 w-4" />}
icon={<span className="i-ri-terminal-box-line h-4 w-4" />}
>
{t('common.accessAPIReference', { ns: 'workflow' })}
</SuggestedAction>

View File

@ -2,25 +2,19 @@ import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import HasNotSetAPI from './has-not-set-api'
describe('HasNotSetAPI WarningMask', () => {
it('should show default title when trial not finished', () => {
render(<HasNotSetAPI isTrailFinished={false} onSetting={vi.fn()} />)
describe('HasNotSetAPI', () => {
it('should render the empty state copy', () => {
render(<HasNotSetAPI onSetting={vi.fn()} />)
expect(screen.getByText('appDebug.notSetAPIKey.title')).toBeInTheDocument()
expect(screen.getByText('appDebug.notSetAPIKey.description')).toBeInTheDocument()
expect(screen.getByText('appDebug.noModelProviderConfigured')).toBeInTheDocument()
expect(screen.getByText('appDebug.noModelProviderConfiguredTip')).toBeInTheDocument()
})
it('should show trail finished title when flag is true', () => {
render(<HasNotSetAPI isTrailFinished onSetting={vi.fn()} />)
expect(screen.getByText('appDebug.notSetAPIKey.trailFinished')).toBeInTheDocument()
})
it('should call onSetting when primary button clicked', () => {
it('should call onSetting when manage models button is clicked', () => {
const onSetting = vi.fn()
render(<HasNotSetAPI isTrailFinished={false} onSetting={onSetting} />)
render(<HasNotSetAPI onSetting={onSetting} />)
fireEvent.click(screen.getByRole('button', { name: 'appDebug.notSetAPIKey.settingBtn' }))
fireEvent.click(screen.getByRole('button', { name: 'appDebug.manageModels' }))
expect(onSetting).toHaveBeenCalledTimes(1)
})
})

View File

@ -2,38 +2,38 @@
import type { FC } from 'react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import WarningMask from '.'
export type IHasNotSetAPIProps = {
isTrailFinished: boolean
onSetting: () => void
}
const icon = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 6.00001L14 2.00001M14 2.00001H9.99999M14 2.00001L8 8M6.66667 2H5.2C4.0799 2 3.51984 2 3.09202 2.21799C2.71569 2.40973 2.40973 2.71569 2.21799 3.09202C2 3.51984 2 4.07989 2 5.2V10.8C2 11.9201 2 12.4802 2.21799 12.908C2.40973 13.2843 2.71569 13.5903 3.09202 13.782C3.51984 14 4.07989 14 5.2 14H10.8C11.9201 14 12.4802 14 12.908 13.782C13.2843 13.5903 13.5903 13.2843 13.782 12.908C14 12.4802 14 11.9201 14 10.8V9.33333" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
const HasNotSetAPI: FC<IHasNotSetAPIProps> = ({
isTrailFinished,
onSetting,
}) => {
const { t } = useTranslation()
return (
<WarningMask
title={isTrailFinished ? t('notSetAPIKey.trailFinished', { ns: 'appDebug' }) : t('notSetAPIKey.title', { ns: 'appDebug' })}
description={t('notSetAPIKey.description', { ns: 'appDebug' })}
footer={(
<Button variant="primary" className="flex space-x-2" onClick={onSetting}>
<span>{t('notSetAPIKey.settingBtn', { ns: 'appDebug' })}</span>
{icon}
</Button>
)}
/>
<div className="flex grow flex-col items-center justify-center pb-[120px]">
<div className="flex w-full max-w-[400px] flex-col gap-2 px-4 py-4">
<div className="flex h-10 w-10 items-center justify-center rounded-[10px]">
<div className="flex h-full w-full items-center justify-center overflow-hidden rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg p-1 shadow-lg backdrop-blur-[5px]">
<span className="i-ri-brain-2-line h-5 w-5 text-text-tertiary" />
</div>
</div>
<div className="flex flex-col gap-1">
<div className="text-text-secondary system-md-semibold">{t('noModelProviderConfigured', { ns: 'appDebug' })}</div>
<div className="text-text-tertiary system-xs-regular">{t('noModelProviderConfiguredTip', { ns: 'appDebug' })}</div>
</div>
<button
type="button"
className="flex w-fit items-center gap-1 rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-2 shadow-xs backdrop-blur-[5px]"
onClick={onSetting}
>
<span className="text-components-button-secondary-accent-text system-sm-medium">{t('manageModels', { ns: 'appDebug' })}</span>
<span className="i-ri-arrow-right-line h-4 w-4 text-components-button-secondary-accent-text" />
</button>
</div>
</div>
)
}
export default React.memo(HasNotSetAPI)

View File

@ -20,7 +20,7 @@ import {
} from '@/app/components/base/icons/src/vender/line/files'
import PromptEditor from '@/app/components/base/prompt-editor'
import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '@/app/components/base/prompt-editor/plugins/variable-block'
import { useToastContext } from '@/app/components/base/toast'
import { useToastContext } from '@/app/components/base/toast/context'
import Tooltip from '@/app/components/base/tooltip'
import ConfigContext from '@/context/debug-configuration'
import { useEventEmitterContextContext } from '@/context/event-emitter'

View File

@ -17,7 +17,7 @@ import { useFeaturesStore } from '@/app/components/base/features/hooks'
import PromptEditor from '@/app/components/base/prompt-editor'
import { PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER } from '@/app/components/base/prompt-editor/plugins/update-block'
import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '@/app/components/base/prompt-editor/plugins/variable-block'
import { useToastContext } from '@/app/components/base/toast'
import { useToastContext } from '@/app/components/base/toast/context'
import Tooltip from '@/app/components/base/tooltip'
import ConfigContext from '@/context/debug-configuration'
import { useEventEmitterContextContext } from '@/context/event-emitter'

View File

@ -20,7 +20,7 @@ const Field: FC<Props> = ({
const { t } = useTranslation()
return (
<div className={cn(className)}>
<div className="leading-8 text-text-secondary system-sm-semibold">
<div className="!leading-8 text-text-secondary system-sm-semibold">
{title}
{isOptional && (
<span className="ml-1 text-text-tertiary system-xs-regular">

View File

@ -189,7 +189,9 @@ const ConfigModal: FC<IConfigModalProps> = ({
draft.type = type
if (type === InputVarType.select)
draft.default = undefined
if ([InputVarType.singleFile, InputVarType.multiFiles].includes(type)) {
if (([InputVarType.singleFile, InputVarType.multiFiles] as const).includes(
type as typeof InputVarType.singleFile | typeof InputVarType.multiFiles,
)) {
(Object.keys(DEFAULT_FILE_UPLOAD_SETTING)).forEach((key) => {
if (key !== 'max_length')
(draft as any)[key] = (DEFAULT_FILE_UPLOAD_SETTING as any)[key]
@ -290,7 +292,9 @@ const ConfigModal: FC<IConfigModalProps> = ({
}
onConfirm(payloadToSave, moreInfo)
}
else if ([InputVarType.singleFile, InputVarType.multiFiles].includes(type)) {
else if (([InputVarType.singleFile, InputVarType.multiFiles] as const).includes(
type as typeof InputVarType.singleFile | typeof InputVarType.multiFiles,
)) {
if (tempPayload.allowed_file_types?.length === 0) {
const errorMessages = t('errorMsg.fieldRequired', { ns: 'workflow', field: t('variableConfig.file.supportFileTypes', { ns: 'appDebug' }) })
Toast.notify({ type: 'error', message: errorMessages })
@ -438,7 +442,9 @@ const ConfigModal: FC<IConfigModalProps> = ({
</>
)}
{[InputVarType.singleFile, InputVarType.multiFiles].includes(type) && (
{([InputVarType.singleFile, InputVarType.multiFiles] as const).includes(
type as typeof InputVarType.singleFile | typeof InputVarType.multiFiles,
) && (
<>
<FileUploadSetting
payload={tempPayload as UploadFileSetting}

View File

@ -2,7 +2,7 @@ import type { ReactNode } from 'react'
import type { IConfigVarProps } from './index'
import type { ExternalDataTool } from '@/models/common'
import type { PromptVariable } from '@/models/debug'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import * as React from 'react'
import { vi } from 'vitest'
import Toast from '@/app/components/base/toast'
@ -237,7 +237,8 @@ describe('ConfigVar', () => {
expect(actionButtons).toHaveLength(2)
fireEvent.click(actionButtons[0])
const saveButton = await screen.findByRole('button', { name: 'common.operation.save' })
const editDialog = await screen.findByRole('dialog')
const saveButton = within(editDialog).getByRole('button', { name: 'common.operation.save' })
fireEvent.click(saveButton)
await waitFor(() => {

View File

@ -218,7 +218,7 @@ describe('ParamConfigContent', () => {
})
render(<ParamConfigContent />)
const input = screen.getByRole('spinbutton') as HTMLInputElement
const input = screen.getByRole('textbox') as HTMLInputElement
fireEvent.change(input, { target: { value: '4' } })
const updatedFile = getLatestFileConfig()

View File

@ -121,7 +121,7 @@ const ConfigVision: FC = () => {
<ParamConfig />
<div className="ml-1 mr-3 h-3.5 w-[1px] bg-divider-regular"></div>
<Switch
defaultValue={isImageEnabled}
value={isImageEnabled}
onChange={handleChange}
size="md"
/>

View File

@ -298,7 +298,7 @@ const AgentTools: FC = () => {
<div className={cn(item.isDeleted && 'opacity-50')}>
{!item.notAuthor && (
<Switch
defaultValue={item.isDeleted ? false : item.enabled}
value={item.isDeleted ? false : item.enabled}
disabled={item.isDeleted || readonly}
size="md"
onChange={(enabled) => {

View File

@ -12,7 +12,7 @@ import {
CopyCheck,
} from '@/app/components/base/icons/src/vender/line/files'
import PromptEditor from '@/app/components/base/prompt-editor'
import { useToastContext } from '@/app/components/base/toast'
import { useToastContext } from '@/app/components/base/toast/context'
import ConfigContext from '@/context/debug-configuration'
import { useModalContext } from '@/context/modal-context'
import { cn } from '@/utils/classnames'

View File

@ -298,7 +298,6 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
<div>
<ModelParameterModal
popupClassName="!w-[520px]"
portalToFollowElemContentClassName="z-[1000]"
isAdvancedMode={true}
provider={model.provider}
completionParams={model.completion_params}

View File

@ -3,8 +3,10 @@ import type { FormValue } from '@/app/components/header/account-setting/model-pr
import type { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import type { GenRes } from '@/service/debug'
import type { AppModeEnum, CompletionParams, Model, ModelModeType } from '@/types/app'
import { useSessionStorageState } from 'ahooks'
import useBoolean from 'ahooks/lib/useBoolean'
import {
useBoolean,
useSessionStorageState,
} from 'ahooks'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -207,7 +209,6 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
<div className="mb-4">
<ModelParameterModal
popupClassName="!w-[520px]"
portalToFollowElemContentClassName="z-[1000]"
isAdvancedMode={true}
provider={model.provider}
completionParams={model.completion_params}

View File

@ -69,7 +69,7 @@ const ConfigAudio: FC = () => {
<div className="flex shrink-0 items-center">
<div className="ml-1 mr-3 h-3.5 w-[1px] bg-divider-subtle"></div>
<Switch
defaultValue={isAudioEnabled}
value={isAudioEnabled}
onChange={handleChange}
size="md"
/>

View File

@ -69,7 +69,7 @@ const ConfigDocument: FC = () => {
<div className="flex shrink-0 items-center">
<div className="ml-1 mr-3 h-3.5 w-[1px] bg-divider-subtle"></div>
<Switch
defaultValue={isDocumentEnabled}
value={isDocumentEnabled}
onChange={handleChange}
size="md"
/>

View File

@ -11,7 +11,7 @@ import { RETRIEVE_METHOD } from '@/types/app'
import Item from './index'
vi.mock('../settings-modal', () => ({
default: ({ onSave, onCancel, currentDataset }: any) => (
default: ({ onSave, onCancel, currentDataset }: { currentDataset: DataSet, onCancel: () => void, onSave: (newDataset: DataSet) => void }) => (
<div>
<div>Mock settings modal</div>
<button onClick={() => onSave({ ...currentDataset, name: 'Updated dataset' })}>Save changes</button>
@ -172,12 +172,8 @@ describe('dataset-config/card-item', () => {
const [editButton] = within(card).getAllByRole('button', { hidden: true })
await user.click(editButton)
expect(screen.getByText('Mock settings modal')).toBeInTheDocument()
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeVisible()
})
await user.click(screen.getByText('Save changes'))
expect(await screen.findByText('Mock settings modal')).toBeInTheDocument()
fireEvent.click(await screen.findByText('Save changes'))
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Updated dataset' }))
@ -194,7 +190,7 @@ describe('dataset-config/card-item', () => {
const card = screen.getByText(dataset.name).closest('.group') as HTMLElement
const buttons = within(card).getAllByRole('button', { hidden: true })
const deleteButton = buttons[buttons.length - 1]
const deleteButton = buttons.at(-1)!
expect(deleteButton.className).not.toContain('action-btn-destructive')
@ -233,7 +229,7 @@ describe('dataset-config/card-item', () => {
await user.click(editButton)
expect(screen.getByText('Mock settings modal')).toBeInTheDocument()
const overlay = Array.from(document.querySelectorAll('[class]'))
const overlay = [...document.querySelectorAll('[class]')]
.find(element => element.className.toString().includes('bg-black/30'))
expect(overlay).toBeInTheDocument()

View File

@ -5,7 +5,7 @@ import * as React from 'react'
import ContextVar from './index'
// Mock external dependencies only
vi.mock('next/navigation', () => ({
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }),
usePathname: () => '/test',
}))

View File

@ -5,7 +5,7 @@ import * as React from 'react'
import VarPicker from './var-picker'
// Mock external dependencies only
vi.mock('next/navigation', () => ({
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }),
usePathname: () => '/test',
}))

View File

@ -267,7 +267,7 @@ const ConfigContent: FC<Props> = ({
canManuallyToggleRerank && (
<Switch
size="md"
defaultValue={showRerankModel}
value={showRerankModel ?? false}
onChange={handleManuallyToggleRerank}
/>
)
@ -370,7 +370,6 @@ const ConfigContent: FC<Props> = ({
<ModelParameterModal
isInWorkflow={isInWorkflow}
popupClassName="!w-[387px]"
portalToFollowElemContentClassName="!z-[1002]"
isAdvancedMode={true}
provider={model?.provider}
completionParams={model?.completion_params}

View File

@ -3,7 +3,7 @@ import type { DatasetConfigs } from '@/models/debug'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import {
useCurrentProviderAndModel,
useModelListAndDefaultModelAndCurrentProviderAndModel,
@ -75,7 +75,7 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-param
const mockedUseModelListAndDefaultModelAndCurrentProviderAndModel = useModelListAndDefaultModelAndCurrentProviderAndModel as MockedFunction<typeof useModelListAndDefaultModelAndCurrentProviderAndModel>
const mockedUseCurrentProviderAndModel = useCurrentProviderAndModel as MockedFunction<typeof useCurrentProviderAndModel>
let toastNotifySpy: MockInstance
let toastErrorSpy: MockInstance
const createDatasetConfigs = (overrides: Partial<DatasetConfigs> = {}): DatasetConfigs => {
return {
@ -140,7 +140,7 @@ describe('dataset-config/params-config', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
toastNotifySpy = vi.spyOn(Toast, 'notify').mockImplementation(() => ({}))
toastErrorSpy = vi.spyOn(toast, 'error').mockImplementation(() => '')
mockedUseModelListAndDefaultModelAndCurrentProviderAndModel.mockReturnValue({
modelList: [],
defaultModel: undefined,
@ -154,7 +154,7 @@ describe('dataset-config/params-config', () => {
})
afterEach(() => {
toastNotifySpy.mockRestore()
toastErrorSpy.mockRestore()
})
// Rendering tests (REQUIRED)
@ -180,12 +180,12 @@ describe('dataset-config/params-config', () => {
const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
const dialogScope = within(dialog)
const incrementButtons = dialogScope.getAllByRole('button', { name: 'increment' })
const incrementButtons = dialogScope.getAllByRole('button', { name: /increment/i })
await user.click(incrementButtons[0])
await waitFor(() => {
const [topKInput] = dialogScope.getAllByRole('spinbutton')
expect(topKInput).toHaveValue(5)
const [topKInput] = dialogScope.getAllByRole('textbox')
expect(topKInput).toHaveValue('5')
})
await user.click(dialogScope.getByRole('button', { name: 'common.operation.save' }))
@ -197,10 +197,10 @@ describe('dataset-config/params-config', () => {
await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
const reopenedDialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
const reopenedScope = within(reopenedDialog)
const [reopenedTopKInput] = reopenedScope.getAllByRole('spinbutton')
const [reopenedTopKInput] = reopenedScope.getAllByRole('textbox')
// Assert
expect(reopenedTopKInput).toHaveValue(5)
expect(reopenedTopKInput).toHaveValue('5')
})
it('should discard changes when cancel is clicked', async () => {
@ -213,12 +213,12 @@ describe('dataset-config/params-config', () => {
const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
const dialogScope = within(dialog)
const incrementButtons = dialogScope.getAllByRole('button', { name: 'increment' })
const incrementButtons = dialogScope.getAllByRole('button', { name: /increment/i })
await user.click(incrementButtons[0])
await waitFor(() => {
const [topKInput] = dialogScope.getAllByRole('spinbutton')
expect(topKInput).toHaveValue(5)
const [topKInput] = dialogScope.getAllByRole('textbox')
expect(topKInput).toHaveValue('5')
})
const cancelButton = await dialogScope.findByRole('button', { name: 'common.operation.cancel' })
@ -231,10 +231,10 @@ describe('dataset-config/params-config', () => {
await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
const reopenedDialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
const reopenedScope = within(reopenedDialog)
const [reopenedTopKInput] = reopenedScope.getAllByRole('spinbutton')
const [reopenedTopKInput] = reopenedScope.getAllByRole('textbox')
// Assert
expect(reopenedTopKInput).toHaveValue(4)
expect(reopenedTopKInput).toHaveValue('4')
})
it('should prevent saving when rerank model is required but invalid', async () => {
@ -254,10 +254,7 @@ describe('dataset-config/params-config', () => {
await user.click(dialogScope.getByRole('button', { name: 'common.operation.save' }))
// Assert
expect(toastNotifySpy).toHaveBeenCalledWith({
type: 'error',
message: 'appDebug.datasetConfig.rerankModelRequired',
})
expect(toastErrorSpy).toHaveBeenCalledWith('appDebug.datasetConfig.rerankModelRequired')
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})

View File

@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button'
import Modal from '@/app/components/base/modal'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useCurrentProviderAndModel, useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import {
@ -61,16 +61,12 @@ const ParamsConfig = ({
if (tempDataSetConfigs.retrieval_model === RETRIEVE_TYPE.multiWay) {
if (tempDataSetConfigs.reranking_enable
&& tempDataSetConfigs.reranking_mode === RerankingModeEnum.RerankingModel
&& !isCurrentRerankModelValid
) {
&& !isCurrentRerankModelValid) {
errMsg = t('datasetConfig.rerankModelRequired', { ns: 'appDebug' })
}
}
if (errMsg) {
Toast.notify({
type: 'error',
message: errMsg,
})
toast.error(errMsg)
}
return !errMsg
}

View File

@ -137,4 +137,31 @@ describe('SelectDataSet', () => {
expect(screen.getByRole('link', { name: 'appDebug.feature.dataSet.toCreate' })).toHaveAttribute('href', '/datasets/create')
expect(screen.getByRole('button', { name: 'common.operation.add' })).toBeDisabled()
})
it('uses selectedIds as the initial modal selection', async () => {
const datasetOne = makeDataset({
id: 'set-1',
name: 'Dataset One',
})
mockUseInfiniteDatasets.mockReturnValue({
data: { pages: [{ data: [datasetOne] }] },
isLoading: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
hasNextPage: false,
})
const onSelect = vi.fn()
await act(async () => {
render(<SelectDataSet {...baseProps} onSelect={onSelect} selectedIds={['set-1']} />)
})
expect(screen.getByText('1 appDebug.feature.dataSet.selected')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
})
expect(onSelect).toHaveBeenCalledWith([datasetOne])
})
})

View File

@ -2,9 +2,8 @@
import type { FC } from 'react'
import type { DataSet } from '@/models/datasets'
import { useInfiniteScroll } from 'ahooks'
import Link from 'next/link'
import * as React from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import Badge from '@/app/components/base/badge'
@ -14,6 +13,7 @@ import Modal from '@/app/components/base/modal'
import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import FeatureIcon from '@/app/components/header/account-setting/model-provider-page/model-selector/feature-icon'
import { useKnowledge } from '@/hooks/use-knowledge'
import Link from '@/next/link'
import { useInfiniteDatasets } from '@/service/knowledge/use-dataset'
import { cn } from '@/utils/classnames'
@ -31,17 +31,21 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({
onSelect,
}) => {
const { t } = useTranslation()
const [selected, setSelected] = useState<DataSet[]>([])
const [selectedIdsInModal, setSelectedIdsInModal] = useState(() => selectedIds)
const canSelectMulti = true
const { formatIndexingTechniqueAndMethod } = useKnowledge()
const { data, isLoading, isFetchingNextPage, fetchNextPage, hasNextPage } = useInfiniteDatasets(
{ page: 1 },
{ enabled: isShow, staleTime: 0, refetchOnMount: 'always' },
)
const pages = data?.pages || []
const datasets = useMemo(() => {
const pages = data?.pages || []
return pages.flatMap(page => page.data.filter(item => item.indexing_technique || item.provider === 'external'))
}, [pages])
}, [data])
const datasetMap = useMemo(() => new Map(datasets.map(item => [item.id, item])), [datasets])
const selected = useMemo(() => {
return selectedIdsInModal.map(id => datasetMap.get(id) || ({ id } as DataSet))
}, [datasetMap, selectedIdsInModal])
const hasNoData = !isLoading && datasets.length === 0
const listRef = useRef<HTMLDivElement>(null)
@ -61,50 +65,14 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({
},
)
const prevSelectedIdsRef = useRef<string[]>([])
const hasUserModifiedSelectionRef = useRef(false)
useEffect(() => {
if (isShow)
hasUserModifiedSelectionRef.current = false
}, [isShow])
useEffect(() => {
const prevSelectedIds = prevSelectedIdsRef.current
const idsChanged = selectedIds.length !== prevSelectedIds.length
|| selectedIds.some((id, idx) => id !== prevSelectedIds[idx])
if (!selectedIds.length && (!hasUserModifiedSelectionRef.current || idsChanged)) {
setSelected([])
prevSelectedIdsRef.current = selectedIds
hasUserModifiedSelectionRef.current = false
return
}
if (!idsChanged && hasUserModifiedSelectionRef.current)
return
setSelected((prev) => {
const prevMap = new Map(prev.map(item => [item.id, item]))
const nextSelected = selectedIds
.map(id => datasets.find(item => item.id === id) || prevMap.get(id))
.filter(Boolean) as DataSet[]
return nextSelected
})
prevSelectedIdsRef.current = selectedIds
hasUserModifiedSelectionRef.current = false
}, [datasets, selectedIds])
const toggleSelect = (dataSet: DataSet) => {
hasUserModifiedSelectionRef.current = true
const isSelected = selected.some(item => item.id === dataSet.id)
if (isSelected) {
setSelected(selected.filter(item => item.id !== dataSet.id))
}
else {
if (canSelectMulti)
setSelected([...selected, dataSet])
else
setSelected([dataSet])
}
setSelectedIdsInModal((prev) => {
const isSelected = prev.includes(dataSet.id)
if (isSelected)
return prev.filter(id => id !== dataSet.id)
return canSelectMulti ? [...prev, dataSet.id] : [dataSet.id]
})
}
const handleSelect = () => {
@ -145,7 +113,7 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({
key={item.id}
className={cn(
'flex h-10 cursor-pointer items-center rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg px-2 shadow-xs hover:border-components-panel-border hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm',
selected.some(i => i.id === item.id) && 'border-[1.5px] border-components-option-card-option-selected-border bg-state-accent-hover shadow-xs hover:border-components-option-card-option-selected-border hover:bg-state-accent-hover hover:shadow-xs',
selectedIdsInModal.includes(item.id) && 'border-[1.5px] border-components-option-card-option-selected-border bg-state-accent-hover shadow-xs hover:border-components-option-card-option-selected-border hover:bg-state-accent-hover hover:shadow-xs',
!item.embedding_available && 'hover:border-components-panel-border-subtle hover:bg-components-panel-on-panel-item-bg hover:shadow-xs',
)}
onClick={() => {

View File

@ -3,7 +3,7 @@ import type { DataSet } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ToastContext } from '@/app/components/base/toast'
import { ToastContext } from '@/app/components/base/toast/context'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'

View File

@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import { useToastContext } from '@/app/components/base/toast'
import { useToastContext } from '@/app/components/base/toast/context'
import { isReRankModelSelected } from '@/app/components/datasets/common/check-rerank-model'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import IndexMethod from '@/app/components/datasets/settings/index-method'

View File

@ -212,7 +212,7 @@ describe('RetrievalSection', () => {
currentDataset={dataset}
/>,
)
const [topKIncrement] = screen.getAllByLabelText('increment')
const [topKIncrement] = screen.getAllByRole('button', { name: /increment/i })
await userEvent.click(topKIncrement)
// Assert
@ -267,7 +267,7 @@ describe('RetrievalSection', () => {
docLink={path => path || ''}
/>,
)
const [topKIncrement] = screen.getAllByLabelText('increment')
const [topKIncrement] = screen.getAllByRole('button', { name: /increment/i })
await userEvent.click(topKIncrement)
// Assert

View File

@ -91,7 +91,7 @@ vi.mock('@/app/components/base/chat/chat', () => ({
}))
vi.mock('@/app/components/base/avatar', () => ({
default: ({ name }: { name: string }) => <div data-testid="avatar">{name}</div>,
Avatar: ({ name }: { name: string }) => <div data-testid="avatar">{name}</div>,
}))
const createModelAndParameter = (overrides: Partial<ModelAndParameter> = {}): ModelAndParameter => ({

View File

@ -7,7 +7,7 @@ import {
useCallback,
useMemo,
} from 'react'
import Avatar from '@/app/components/base/avatar'
import { Avatar } from '@/app/components/base/avatar'
import Chat from '@/app/components/base/chat/chat'
import { useChat } from '@/app/components/base/chat/chat/hooks'
import { getLastAnswer } from '@/app/components/base/chat/utils'
@ -149,7 +149,7 @@ const ChatItem: FC<ChatItemProps> = ({
suggestedQuestions={suggestedQuestions}
onSend={doSend}
showPromptLog
questionIcon={<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={40} />}
questionIcon={<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="xl" />}
allToolIcons={allToolIcons}
hideLogModal
noSpacing

View File

@ -0,0 +1,28 @@
'use client'
import type { ReactNode } from 'react'
import type { DebugWithMultipleModelContextType } from './context'
import { DebugWithMultipleModelContext } from './context'
type DebugWithMultipleModelContextProviderProps = {
children: ReactNode
} & DebugWithMultipleModelContextType
export const DebugWithMultipleModelContextProvider = ({
children,
onMultipleModelConfigsChange,
multipleModelConfigs,
onDebugWithMultipleModelChange,
checkCanSend,
}: DebugWithMultipleModelContextProviderProps) => {
return (
<DebugWithMultipleModelContext.Provider value={{
onMultipleModelConfigsChange,
multipleModelConfigs,
onDebugWithMultipleModelChange,
checkCanSend,
}}
>
{children}
</DebugWithMultipleModelContext.Provider>
)
}

View File

@ -1,10 +1,8 @@
import type { ModelAndParameter } from '../types'
import type { DebugWithMultipleModelContextType } from './context'
import { render, screen } from '@testing-library/react'
import {
DebugWithMultipleModelContextProvider,
useDebugWithMultipleModelContext,
} from './context'
import { useDebugWithMultipleModelContext } from './context'
import { DebugWithMultipleModelContextProvider } from './context-provider'
const createModelAndParameter = (overrides: Partial<ModelAndParameter> = {}): ModelAndParameter => ({
id: 'model-1',

View File

@ -10,7 +10,8 @@ export type DebugWithMultipleModelContextType = {
onDebugWithMultipleModelChange: (singleModelConfig: ModelAndParameter) => void
checkCanSend?: () => boolean
}
const DebugWithMultipleModelContext = createContext<DebugWithMultipleModelContextType>({
export const DebugWithMultipleModelContext = createContext<DebugWithMultipleModelContextType>({
multipleModelConfigs: [],
onMultipleModelConfigsChange: noop,
onDebugWithMultipleModelChange: noop,
@ -18,27 +19,4 @@ const DebugWithMultipleModelContext = createContext<DebugWithMultipleModelContex
export const useDebugWithMultipleModelContext = () => useContext(DebugWithMultipleModelContext)
type DebugWithMultipleModelContextProviderProps = {
children: React.ReactNode
} & DebugWithMultipleModelContextType
export const DebugWithMultipleModelContextProvider = ({
children,
onMultipleModelConfigsChange,
multipleModelConfigs,
onDebugWithMultipleModelChange,
checkCanSend,
}: DebugWithMultipleModelContextProviderProps) => {
return (
<DebugWithMultipleModelContext.Provider value={{
onMultipleModelConfigsChange,
multipleModelConfigs,
onDebugWithMultipleModelChange,
checkCanSend,
}}
>
{children}
</DebugWithMultipleModelContext.Provider>
)
}
export default DebugWithMultipleModelContext

View File

@ -14,10 +14,8 @@ import { useDebugConfigurationContext } from '@/context/debug-configuration'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { AppModeEnum } from '@/types/app'
import { APP_CHAT_WITH_MULTIPLE_MODEL } from '../types'
import {
DebugWithMultipleModelContextProvider,
useDebugWithMultipleModelContext,
} from './context'
import { useDebugWithMultipleModelContext } from './context'
import { DebugWithMultipleModelContextProvider } from './context-provider'
import DebugItem from './debug-item'
const DebugWithMultipleModel = () => {

View File

@ -1,13 +1,25 @@
import type { ReactNode } from 'react'
import type { ModelAndParameter } from '../types'
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type {
FormValue,
ModelProvider,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import { render, screen } from '@testing-library/react'
import { ModelStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
import {
ConfigurationMethodEnum,
CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum,
ModelStatusEnum,
ModelTypeEnum,
PreferredProviderTypeEnum,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import ModelParameterTrigger from './model-parameter-trigger'
const mockUseDebugConfigurationContext = vi.fn()
const mockUseDebugWithMultipleModelContext = vi.fn()
const mockUseLanguage = vi.fn()
const mockUseProviderContext = vi.fn()
const mockUseCredentialPanelState = vi.fn()
type RenderTriggerProps = {
open: boolean
@ -35,8 +47,12 @@ vi.mock('./context', () => ({
useDebugWithMultipleModelContext: () => mockUseDebugWithMultipleModelContext(),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useLanguage: () => mockUseLanguage(),
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => mockUseProviderContext(),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state', () => ({
useCredentialPanelState: () => mockUseCredentialPanelState(),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
@ -84,6 +100,41 @@ const createModelAndParameter = (overrides: Partial<ModelAndParameter> = {}): Mo
...overrides,
})
const createModelProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({
provider: 'openai',
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
help: {
title: { en_US: 'Help', zh_Hans: 'Help' },
url: { en_US: 'https://example.com', zh_Hans: 'https://example.com' },
},
icon_small: { en_US: '', zh_Hans: '' },
supported_model_types: [ModelTypeEnum.textGeneration],
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
provider_credential_schema: {
credential_form_schemas: [],
},
model_credential_schema: {
model: {
label: { en_US: 'Model', zh_Hans: 'Model' },
placeholder: { en_US: 'Select model', zh_Hans: 'Select model' },
},
credential_form_schemas: [],
},
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: 'cred-1',
current_credential_name: 'Primary Key',
available_credentials: [{ credential_id: 'cred-1', credential_name: 'Primary Key' }],
},
system_configuration: {
enabled: true,
current_quota_type: CurrentSystemQuotaTypeEnum.trial,
quota_configurations: [],
},
...overrides,
})
const renderComponent = (props: Partial<{ modelAndParameter: ModelAndParameter }> = {}) => {
const defaultProps = {
modelAndParameter: createModelAndParameter(),
@ -106,8 +157,19 @@ describe('ModelParameterTrigger', () => {
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
mockUseLanguage.mockReturnValue('en_US')
mockUseProviderContext.mockReturnValue(createMockProviderContextValue({
modelProviders: [createModelProvider()],
}))
mockUseCredentialPanelState.mockReturnValue({
variant: 'api-active',
priority: 'apiKey',
supportsCredits: true,
showPrioritySwitcher: true,
hasCredentials: true,
isCreditsExhausted: false,
credentialName: 'Primary Key',
credits: 10,
})
})
describe('rendering', () => {
@ -311,23 +373,66 @@ describe('ModelParameterTrigger', () => {
expect(screen.getByTestId('model-parameter-modal')).toBeInTheDocument()
})
it('should render "Select Model" text when no provider/model', () => {
renderComponent()
it('should render "Select Model" text when no provider or model is configured', () => {
renderComponent({
modelAndParameter: createModelAndParameter({
provider: '',
model: '',
}),
})
// When currentProvider and currentModel are null, shows "Select Model"
expect(screen.getByText('common.modelProvider.selectModel')).toBeInTheDocument()
})
})
describe('language context', () => {
it('should use language from useLanguage hook', () => {
mockUseLanguage.mockReturnValue('zh_Hans')
it('should render configured model id and incompatible tooltip when model is missing from the provider list', () => {
renderComponent()
// The language is used for MODEL_STATUS_TEXT tooltip
// We verify the hook is called
expect(mockUseLanguage).toHaveBeenCalled()
expect(screen.getByText('gpt-3.5-turbo')).toBeInTheDocument()
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'common.modelProvider.selector.incompatibleTip')
})
it('should render configure required tooltip for no-configure status', () => {
const { unmount } = renderComponent()
const triggerContent = capturedModalProps?.renderTrigger({
open: false,
currentProvider: { provider: 'openai' },
currentModel: { model: 'gpt-3.5-turbo', status: ModelStatusEnum.noConfigure },
})
unmount()
render(<>{triggerContent}</>)
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'common.modelProvider.selector.configureRequired')
})
it('should render disabled tooltip for disabled status', () => {
const { unmount } = renderComponent()
const triggerContent = capturedModalProps?.renderTrigger({
open: false,
currentProvider: { provider: 'openai' },
currentModel: { model: 'gpt-3.5-turbo', status: ModelStatusEnum.disabled },
})
unmount()
render(<>{triggerContent}</>)
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'common.modelProvider.selector.disabled')
})
it('should apply expanded and warning styles when the trigger is open for a non-active status', () => {
const { unmount } = renderComponent()
const triggerContent = capturedModalProps?.renderTrigger({
open: true,
currentProvider: { provider: 'openai' },
currentModel: { model: 'gpt-3.5-turbo', status: ModelStatusEnum.noConfigure },
})
unmount()
const { container } = render(<>{triggerContent}</>)
expect(container.firstChild).toHaveClass('bg-state-base-hover')
expect(container.firstChild).toHaveClass('!bg-[#FFFAEB]')
})
})

View File

@ -1,22 +1,20 @@
import type { FC } from 'react'
import type { ModelAndParameter } from '../types'
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { RiArrowDownSLine } from '@remixicon/react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/line/alertsAndFeedback'
import { CubeOutline } from '@/app/components/base/icons/src/vender/line/shapes'
import Tooltip from '@/app/components/base/tooltip'
import {
MODEL_STATUS_TEXT,
ModelStatusEnum,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
DERIVED_MODEL_STATUS_BADGE_I18N,
DERIVED_MODEL_STATUS_TOOLTIP_I18N,
deriveModelStatus,
} from '@/app/components/header/account-setting/model-provider-page/derive-model-status'
import ModelIcon from '@/app/components/header/account-setting/model-provider-page/model-icon'
import ModelName from '@/app/components/header/account-setting/model-provider-page/model-name'
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import { useCredentialPanelState } from '@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state'
import { useDebugConfigurationContext } from '@/context/debug-configuration'
import { useProviderContext } from '@/context/provider-context'
import { useDebugWithMultipleModelContext } from './context'
type ModelParameterTriggerProps = {
@ -34,8 +32,10 @@ const ModelParameterTrigger: FC<ModelParameterTriggerProps> = ({
onMultipleModelConfigsChange,
onDebugWithMultipleModelChange,
} = useDebugWithMultipleModelContext()
const language = useLanguage()
const { modelProviders } = useProviderContext()
const index = multipleModelConfigs.findIndex(v => v.id === modelAndParameter.id)
const providerMeta = modelProviders.find(provider => provider.provider === modelAndParameter.provider)
const credentialState = useCredentialPanelState(providerMeta)
const handleSelectModel = ({ modelId, provider }: { modelId: string, provider: string }) => {
const newModelConfigs = [...multipleModelConfigs]
@ -69,55 +69,77 @@ const ModelParameterTrigger: FC<ModelParameterTriggerProps> = ({
open,
currentProvider,
currentModel,
}) => (
<div
className={`
flex h-8 max-w-[200px] cursor-pointer items-center rounded-lg px-2
${open && 'bg-state-base-hover'}
${currentModel && currentModel.status !== ModelStatusEnum.active && '!bg-[#FFFAEB]'}
`}
>
{
currentProvider && (
<ModelIcon
className="mr-1 !h-4 !w-4"
provider={currentProvider}
modelName={currentModel?.model}
/>
)
}
{
!currentProvider && (
<div className="mr-1 flex h-4 w-4 items-center justify-center rounded">
<CubeOutline className="h-4 w-4 text-text-accent" />
</div>
)
}
{
currentModel && (
<ModelName
className="mr-0.5 text-text-secondary"
modelItem={currentModel}
/>
)
}
{
!currentModel && (
<div className="mr-0.5 truncate text-[13px] font-medium text-text-accent">
{t('modelProvider.selectModel', { ns: 'common' })}
</div>
)
}
<RiArrowDownSLine className={`h-3 w-3 ${(currentModel && currentProvider) ? 'text-text-tertiary' : 'text-text-accent'}`} />
{
currentModel && currentModel.status !== ModelStatusEnum.active && (
<Tooltip popupContent={MODEL_STATUS_TEXT[currentModel.status][language]}>
<AlertTriangle className="h-4 w-4 text-[#F79009]" />
</Tooltip>
)
}
</div>
)}
}) => {
const status = deriveModelStatus(
modelAndParameter.model,
modelAndParameter.provider,
providerMeta,
currentModel ?? undefined,
credentialState,
)
const iconProvider = currentProvider || providerMeta
const statusLabelKey = DERIVED_MODEL_STATUS_BADGE_I18N[status as keyof typeof DERIVED_MODEL_STATUS_BADGE_I18N]
const statusTooltipKey = DERIVED_MODEL_STATUS_TOOLTIP_I18N[status as keyof typeof DERIVED_MODEL_STATUS_TOOLTIP_I18N]
const isEmpty = status === 'empty'
const isActive = status === 'active'
return (
<div
className={`
flex h-8 max-w-[200px] cursor-pointer items-center rounded-lg px-2
${open && 'bg-state-base-hover'}
${!isEmpty && !isActive && '!bg-[#FFFAEB]'}
`}
>
{
iconProvider && !isEmpty && (
<ModelIcon
className="mr-1 !h-4 !w-4"
provider={iconProvider}
modelName={currentModel?.model || modelAndParameter.model}
/>
)
}
{
(!iconProvider || isEmpty) && (
<div className="mr-1 flex h-4 w-4 items-center justify-center rounded">
<span className="i-custom-vender-line-shapes-cube-outline h-4 w-4 text-text-accent" />
</div>
)
}
{
currentModel && (
<ModelName
className="mr-0.5 text-text-secondary"
modelItem={currentModel}
/>
)
}
{
!currentModel && !isEmpty && (
<div className="mr-0.5 truncate text-[13px] font-medium text-text-secondary">
{modelAndParameter.model}
</div>
)
}
{
isEmpty && (
<div className="mr-0.5 truncate text-[13px] font-medium text-text-accent">
{t('modelProvider.selectModel', { ns: 'common' })}
</div>
)
}
<span className={`i-ri-arrow-down-s-line h-3 w-3 ${isEmpty ? 'text-text-accent' : 'text-text-tertiary'}`} />
{
!isEmpty && !isActive && statusLabelKey && (
<Tooltip popupContent={t((statusTooltipKey || statusLabelKey) as 'modelProvider.selector.incompatible', { ns: 'common' })}>
<span className="i-custom-vender-line-alertsAndFeedback-alert-triangle h-4 w-4 text-[#F79009]" />
</Tooltip>
)
}
</div>
)
}}
/>
)
}

View File

@ -155,7 +155,7 @@ vi.mock('@/service/debug', () => ({
stopChatMessageResponding: mockStopChatMessageResponding,
}))
vi.mock('next/navigation', () => ({
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }),
usePathname: () => '/test',
useParams: () => ({}),
@ -387,7 +387,7 @@ vi.mock('@/context/event-emitter', () => ({
}))
// Mock toast context
vi.mock('@/app/components/base/toast', () => ({
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: vi.fn(() => ({
notify: vi.fn(),
})),

View File

@ -3,7 +3,7 @@ import type { ChatConfig, ChatItem, OnSend } from '@/app/components/base/chat/ty
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import { memo, useCallback, useImperativeHandle, useMemo } from 'react'
import { useStore as useAppStore } from '@/app/components/app/store'
import Avatar from '@/app/components/base/avatar'
import { Avatar } from '@/app/components/base/avatar'
import Chat from '@/app/components/base/chat/chat'
import { useChat } from '@/app/components/base/chat/chat/hooks'
import { getLastAnswer, isValidGeneratedAnswer } from '@/app/components/base/chat/utils'
@ -168,7 +168,7 @@ const DebugWithSingleModel = (
switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)}
onStopResponding={handleStop}
showPromptLog
questionIcon={<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={40} />}
questionIcon={<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="xl" />}
allToolIcons={allToolIcons}
onAnnotationEdited={handleAnnotationEdited}
onAnnotationAdded={handleAnnotationAdded}

File diff suppressed because it is too large Load Diff

View File

@ -29,11 +29,11 @@ import Button from '@/app/components/base/button'
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
import { RefreshCcw01 } from '@/app/components/base/icons/src/vender/line/arrows'
import PromptLogModal from '@/app/components/base/prompt-log-modal'
import { ToastContext } from '@/app/components/base/toast'
import { ToastContext } from '@/app/components/base/toast/context'
import TooltipPlus from '@/app/components/base/tooltip'
import { ModelFeatureEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG, IS_CE_EDITION } from '@/config'
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
import ConfigContext from '@/context/debug-configuration'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { useProviderContext } from '@/context/provider-context'
@ -505,6 +505,26 @@ const Debug: FC<IDebug> = ({
{
!debugWithMultipleModel && (
<div className="flex grow flex-col" ref={ref}>
{/* No model provider configured */}
{(!modelConfig.provider || !isAPIKeySet) && (
<HasNotSetAPIKEY onSetting={onSetting} />
)}
{/* No model selected */}
{modelConfig.provider && isAPIKeySet && !modelConfig.model_id && (
<div className="flex grow flex-col items-center justify-center pb-[120px]">
<div className="flex w-full max-w-[400px] flex-col gap-2 px-4 py-4">
<div className="flex h-10 w-10 items-center justify-center rounded-[10px]">
<div className="flex h-full w-full items-center justify-center overflow-hidden rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg p-1 shadow-lg backdrop-blur-[5px]">
<span className="i-ri-brain-2-line h-5 w-5 text-text-tertiary" />
</div>
</div>
<div className="flex flex-col gap-1">
<div className="text-text-secondary system-md-semibold">{t('noModelSelected', { ns: 'appDebug' })}</div>
<div className="text-text-tertiary system-xs-regular">{t('noModelSelectedTip', { ns: 'appDebug' })}</div>
</div>
</div>
</div>
)}
{/* Chat */}
{mode !== AppModeEnum.COMPLETION && (
<div className="h-0 grow overflow-hidden">
@ -570,7 +590,6 @@ const Debug: FC<IDebug> = ({
/>
)
}
{!isAPIKeySet && !readonly && (<HasNotSetAPIKEY isTrailFinished={!IS_CE_EDITION} onSetting={onSetting} />)}
</>
)
}

View File

@ -24,7 +24,6 @@ import { useBoolean, useGetState } from 'ahooks'
import { clone } from 'es-toolkit/object'
import { isEqual } from 'es-toolkit/predicate'
import { produce } from 'immer'
import { usePathname } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -50,7 +49,8 @@ import { FeaturesProvider } from '@/app/components/base/features'
import NewFeaturePanel from '@/app/components/base/features/new-feature-panel'
import Loading from '@/app/components/base/loading'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import Toast, { ToastContext } from '@/app/components/base/toast'
import Toast from '@/app/components/base/toast'
import { ToastContext } from '@/app/components/base/toast/context'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { ModelFeatureEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import {
@ -67,11 +67,12 @@ import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { ANNOTATION_DEFAULT, DATASET_DEFAULT, DEFAULT_AGENT_SETTING, DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
import { useAppContext } from '@/context/app-context'
import ConfigContext from '@/context/debug-configuration'
import { MittProvider } from '@/context/mitt-context'
import { MittProvider } from '@/context/mitt-context-provider'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { PromptMode } from '@/models/debug'
import { usePathname } from '@/next/navigation'
import { fetchAppDetailDirect, updateAppModelConfig } from '@/service/apps'
import { fetchDatasets } from '@/service/datasets'
import { fetchCollectionList } from '@/service/tools'
@ -110,7 +111,7 @@ const Configuration: FC = () => {
const [hasFetchedDetail, setHasFetchedDetail] = useState(false)
const isLoading = !hasFetchedDetail
const pathname = usePathname()
const matched = pathname.match(/\/app\/([^/]+)/)
const matched = /\/app\/([^/]+)/.exec(pathname)
const appId = (matched?.length && matched[1]) ? matched[1] : ''
const [mode, setMode] = useState<AppModeEnum>(AppModeEnum.CHAT)
const [publishedConfig, setPublishedConfig] = useState<PublishConfig | null>(null)

View File

@ -13,7 +13,7 @@ import FormGeneration from '@/app/components/base/features/new-feature-panel/mod
import { BookOpen01 } from '@/app/components/base/icons/src/vender/line/education'
import Modal from '@/app/components/base/modal'
import { SimpleSelect } from '@/app/components/base/select'
import { useToastContext } from '@/app/components/base/toast'
import { useToastContext } from '@/app/components/base/toast/context'
import ApiBasedExtensionSelector from '@/app/components/header/account-setting/api-based-extension-page/selector'
import { useDocLink, useLocale } from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language'

View File

@ -15,7 +15,7 @@ import {
} from '@/app/components/base/icons/src/vender/line/general'
import { Tool03 } from '@/app/components/base/icons/src/vender/solid/general'
import Switch from '@/app/components/base/switch'
import { useToastContext } from '@/app/components/base/toast'
import { useToastContext } from '@/app/components/base/toast/context'
import Tooltip from '@/app/components/base/tooltip'
import ConfigContext from '@/context/debug-configuration'
import { useModalContext } from '@/context/modal-context'
@ -179,8 +179,8 @@ const Tools = () => {
</div>
<div className="ml-2 mr-3 hidden h-3.5 w-[1px] bg-gray-200 group-hover:block" />
<Switch
size="l"
defaultValue={item.enabled}
size="lg"
value={item.enabled ?? false}
onChange={(enabled: boolean) => handleSaveExternalDataToolModal({ ...item, enabled }, index)}
/>
</div>

View File

@ -40,8 +40,8 @@ vi.mock('../app-card', () => ({
vi.mock('@/app/components/explore/create-app-modal', () => ({
default: () => <div data-testid="create-from-template-modal" />,
}))
vi.mock('@/app/components/base/toast', () => ({
default: { notify: vi.fn() },
vi.mock('@/app/components/base/ui/toast', () => ({
toast: { add: vi.fn() },
}))
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
@ -63,7 +63,7 @@ vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({
vi.mock('@/utils/app-redirection', () => ({
getRedirection: vi.fn(),
}))
vi.mock('next/navigation', () => ({
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }),
}))

View File

@ -4,7 +4,6 @@ import type { CreateAppModalProps } from '@/app/components/explore/create-app-mo
import type { App } from '@/models/explore'
import { RiRobot2Line } from '@remixicon/react'
import { useDebounceFn } from 'ahooks'
import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -14,12 +13,13 @@ import { buttonVariants } from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
import Input from '@/app/components/base/input'
import Loading from '@/app/components/base/loading'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import CreateAppModal from '@/app/components/explore/create-app-modal'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { MARKETPLACE_URL_PREFIX, NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { DSLImportMode } from '@/models/app'
import { useRouter } from '@/next/navigation'
import { importDSL } from '@/service/apps'
import { fetchAppDetail } from '@/service/explore'
import { useExploreAppList } from '@/service/use-explore'
@ -140,10 +140,7 @@ const Apps = ({
})
setIsShowCreateModal(false)
Toast.notify({
type: 'success',
message: t('newApp.appCreated', { ns: 'app' }),
})
toast.success(t('newApp.appCreated', { ns: 'app' }))
if (onSuccess)
onSuccess()
if (app.app_id)
@ -152,7 +149,7 @@ const Apps = ({
getRedirection(isCurrentWorkspaceEditor, { id: app.app_id!, mode }, push)
}
catch {
Toast.notify({ type: 'error', message: t('newApp.appCreateFailed', { ns: 'app' }) })
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
}
}

View File

@ -1,13 +1,12 @@
import type { App } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useRouter } from 'next/navigation'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { trackEvent } from '@/app/components/base/amplitude'
import { ToastContext } from '@/app/components/base/toast'
import { ToastContext } from '@/app/components/base/toast/context'
import { MARKETPLACE_URL_PREFIX, NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { useRouter } from '@/next/navigation'
import { createApp } from '@/service/apps'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
@ -23,7 +22,7 @@ vi.mock('ahooks', () => ({
useKeyPress: vi.fn(),
useHover: () => false,
}))
vi.mock('next/navigation', () => ({
vi.mock('@/next/navigation', () => ({
useRouter: vi.fn(),
}))
vi.mock('@/app/components/base/amplitude', () => ({

View File

@ -2,13 +2,11 @@
import type { AppIconSelection } from '../../base/app-icon-picker'
import type { RuntimeMode } from '@/types/app'
import { RiArrowRightLine, RiArrowRightSLine, RiCheckLine, RiExchange2Fill } from '@remixicon/react'
import { RiArrowRightLine, RiArrowRightSLine, RiExchange2Fill } from '@remixicon/react'
import { useDebounceFn, useKeyPress } from 'ahooks'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { trackEvent } from '@/app/components/base/amplitude'
import AppIcon from '@/app/components/base/app-icon'
import Badge from '@/app/components/base/badge'
@ -17,15 +15,22 @@ import Divider from '@/app/components/base/divider'
import FullScreenModal from '@/app/components/base/fullscreen-modal'
import { BubbleTextMod, ChatBot, ListSparkle, Logic } from '@/app/components/base/icons/src/vender/solid/communication'
import Input from '@/app/components/base/input'
import CustomSelect from '@/app/components/base/select/custom'
import Textarea from '@/app/components/base/textarea'
import { ToastContext } from '@/app/components/base/toast'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/app/components/base/ui/select'
import { toast } from '@/app/components/base/ui/toast'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { MARKETPLACE_URL_PREFIX, NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import useTheme from '@/hooks/use-theme'
import { useRouter } from '@/next/navigation'
import { createApp } from '@/service/apps'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
@ -51,18 +56,26 @@ type RuntimeOption = {
const marketplaceTemplatesUrl = `${MARKETPLACE_URL_PREFIX.replace(/\/$/, '')}/templates`
function isBeginnerAppMode(mode: AppModeEnum) {
return mode === AppModeEnum.CHAT || mode === AppModeEnum.AGENT_CHAT || mode === AppModeEnum.COMPLETION
}
function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: CreateAppProps) {
const { t } = useTranslation()
const { push } = useRouter()
const { notify } = useContext(ToastContext)
const [appMode, setAppMode] = useState<AppModeEnum>(defaultAppMode || AppModeEnum.ADVANCED_CHAT)
const initialAppMode = defaultAppMode || AppModeEnum.ADVANCED_CHAT
const [appMode, setAppMode] = useState<AppModeEnum>(initialAppMode)
const [appIcon, setAppIcon] = useState<AppIconSelection>({ type: 'emoji', icon: '🤖', background: '#FFEAD5' })
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [isAppTypeExpanded, setIsAppTypeExpanded] = useState(false)
const [runtimeMode, setRuntimeMode] = useState<RuntimeMode>('sandboxed')
const [isAppTypeExpanded, setIsAppTypeExpanded] = useState(() => isBeginnerAppMode(initialAppMode))
const [runtimeMode, setRuntimeMode] = useState<RuntimeMode>(() => {
if (initialAppMode !== AppModeEnum.WORKFLOW && initialAppMode !== AppModeEnum.ADVANCED_CHAT)
return 'classic'
return 'sandboxed'
})
const { plan, enableBilling } = useProviderContext()
const isAppsFull = (enableBilling && plan.usage.buildApps >= plan.total.buildApps)
@ -70,21 +83,21 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
const isCreatingRef = useRef(false)
useEffect(() => {
if (appMode === AppModeEnum.CHAT || appMode === AppModeEnum.AGENT_CHAT || appMode === AppModeEnum.COMPLETION)
const selectAppMode = useCallback((mode: AppModeEnum) => {
setAppMode(mode)
if (isBeginnerAppMode(mode))
setIsAppTypeExpanded(true)
if (appMode !== AppModeEnum.WORKFLOW && appMode !== AppModeEnum.ADVANCED_CHAT)
if (mode !== AppModeEnum.WORKFLOW && mode !== AppModeEnum.ADVANCED_CHAT)
setRuntimeMode('classic')
}, [appMode])
}, [])
const onCreate = useCallback(async () => {
if (!appMode) {
notify({ type: 'error', message: t('newApp.appTypeRequired', { ns: 'app' }) })
toast.error(t('newApp.appTypeRequired', { ns: 'app' }))
return
}
if (!name.trim()) {
notify({ type: 'error', message: t('newApp.nameNotEmpty', { ns: 'app' }) })
toast.error(t('newApp.nameNotEmpty', { ns: 'app' }))
return
}
if (isCreatingRef.current)
@ -109,20 +122,17 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
description,
})
notify({ type: 'success', message: t('newApp.appCreated', { ns: 'app' }) })
toast.success(t('newApp.appCreated', { ns: 'app' }))
onSuccess()
onClose()
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
getRedirection(isCurrentWorkspaceEditor, app, push)
}
catch (e: any) {
notify({
type: 'error',
message: e.message || t('newApp.appCreateFailed', { ns: 'app' }),
})
toast.error(e.message || t('newApp.appCreateFailed', { ns: 'app' }))
}
isCreatingRef.current = false
}, [name, notify, t, appMode, appIcon, description, onSuccess, onClose, push, isCurrentWorkspaceEditor, runtimeMode])
}, [name, t, appMode, appIcon, description, onSuccess, onClose, push, isCurrentWorkspaceEditor, runtimeMode])
const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 })
useKeyPress(['meta.enter', 'ctrl.enter'], () => {
@ -155,7 +165,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
</div>
)}
onClick={() => {
setAppMode(AppModeEnum.WORKFLOW)
selectAppMode(AppModeEnum.WORKFLOW)
}}
/>
<AppTypeCard
@ -168,7 +178,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
</div>
)}
onClick={() => {
setAppMode(AppModeEnum.ADVANCED_CHAT)
selectAppMode(AppModeEnum.ADVANCED_CHAT)
}}
/>
</div>
@ -196,7 +206,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
</div>
)}
onClick={() => {
setAppMode(AppModeEnum.CHAT)
selectAppMode(AppModeEnum.CHAT)
}}
/>
<AppTypeCard
@ -209,7 +219,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
</div>
)}
onClick={() => {
setAppMode(AppModeEnum.AGENT_CHAT)
selectAppMode(AppModeEnum.AGENT_CHAT)
}}
/>
<AppTypeCard
@ -222,7 +232,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
</div>
)}
onClick={() => {
setAppMode(AppModeEnum.COMPLETION)
selectAppMode(AppModeEnum.COMPLETION)
}}
/>
</div>
@ -283,51 +293,47 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
<div className="mb-2 text-text-secondary system-sm-semibold">
{t('newApp.runtimeLabel', { ns: 'app' })}
</div>
<CustomSelect<RuntimeOption>
options={[
{
label: t('newApp.runtimeOptionSandboxed', { ns: 'app' }),
value: 'sandboxed',
description: t('newApp.runtimeOptionSandboxedDescription', { ns: 'app' }),
recommended: true,
},
{
label: t('newApp.runtimeOptionClassic', { ns: 'app' }),
value: 'classic',
description: t('newApp.runtimeOptionClassicDescription', { ns: 'app' }),
},
]}
<Select
value={runtimeMode}
onChange={value => setRuntimeMode(value as RuntimeMode)}
triggerProps={{
className: 'px-3',
}}
popupProps={{
wrapperClassName: 'z-[60]',
className: 'w-full',
itemClassName: '!h-auto !py-2 !items-start',
}}
CustomOption={(option, selected) => (
<>
<RiCheckLine className={cn(
'mr-2 h-4 w-4 shrink-0 text-text-accent',
!selected && 'opacity-0',
)}
/>
<div className="flex flex-1 flex-col gap-0.5">
<div className="flex items-center gap-1">
<span className="text-text-secondary system-sm-semibold">{option.label}</span>
{option.recommended && (
<Badge className="!h-4 !px-1">
{t('newApp.recommended', { ns: 'app' })}
</Badge>
)}
onValueChange={value => setRuntimeMode(value as RuntimeMode)}
>
<SelectTrigger className="px-3">
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[60]" popupClassName="w-full">
{([
{
label: t('newApp.runtimeOptionSandboxed', { ns: 'app' }),
value: 'sandboxed' as const,
description: t('newApp.runtimeOptionSandboxedDescription', { ns: 'app' }),
recommended: true,
},
{
label: t('newApp.runtimeOptionClassic', { ns: 'app' }),
value: 'classic' as const,
description: t('newApp.runtimeOptionClassicDescription', { ns: 'app' }),
},
] satisfies RuntimeOption[]).map(option => (
<SelectItem
key={option.value}
value={option.value}
className="!h-auto !items-start !py-2"
>
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-1">
<span className="text-text-secondary system-sm-semibold">{option.label}</span>
{option.recommended && (
<Badge className="!h-4 !px-1">
{t('newApp.recommended', { ns: 'app' })}
</Badge>
)}
</div>
<span className="text-text-tertiary system-xs-regular">{option.description}</span>
</div>
<span className="text-text-tertiary system-xs-regular">{option.description}</span>
</div>
</>
)}
/>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
@ -473,7 +479,7 @@ function AppScreenShot({ mode, show }: { mode: AppModeEnum, show: boolean }) {
<source media="(resolution: 1x)" srcSet={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}.png`} />
<source media="(resolution: 2x)" srcSet={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}@2x.png`} />
<source media="(resolution: 3x)" srcSet={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}@3x.png`} />
<Image
<img
className={show ? '' : 'hidden'}
src={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}.png`}
alt="App Screen Shot"

View File

@ -1,13 +1,13 @@
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Modal from '@/app/components/base/modal'
import { useToastContext } from '@/app/components/base/toast'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@/app/components/base/ui/dialog'
import { toast } from '@/app/components/base/ui/toast'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { DSLImportStatus } from '@/models/app'
import { useRouter } from '@/next/navigation'
import { importAppBundle } from '@/service/apps'
import { getRedirection } from '@/utils/app-redirection'
@ -31,7 +31,7 @@ const DSLConfirmModal = ({
confirmDisabled = false,
}: DSLConfirmModalProps) => {
const { t } = useTranslation()
const { notify } = useToastContext()
const { push } = useRouter()
const { isCurrentWorkspaceEditor } = useAppContext()
const { handleCheckPluginDependencies } = usePluginDependencies()
@ -54,11 +54,10 @@ const DSLConfirmModal = ({
const { status, app_id, app_mode } = response
if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) {
notify({
type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning',
message: t(status === DSLImportStatus.COMPLETED ? 'newApp.appCreated' : 'newApp.caution', { ns: 'app' }),
children: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('newApp.appCreateDSLWarning', { ns: 'app' }),
})
if (status === DSLImportStatus.COMPLETED)
toast.success(t('newApp.appCreated', { ns: 'app' }))
else
toast.warning(t('newApp.caution', { ns: 'app' }), { description: t('newApp.appCreateDSLWarning', { ns: 'app' }) })
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
if (app_id)
await handleCheckPluginDependencies(app_id)
@ -68,12 +67,12 @@ const DSLConfirmModal = ({
onCancel()
}
else {
notify({ type: 'error', message: t('importBundleFailed', { ns: 'app' }) })
toast.error(t('importBundleFailed', { ns: 'app' }))
}
}
catch (e) {
const error = e as Error
notify({ type: 'error', message: error.message || t('importBundleFailed', { ns: 'app' }) })
toast.error(error.message || t('importBundleFailed', { ns: 'app' }))
}
finally {
setIsImporting(false)
@ -81,40 +80,41 @@ const DSLConfirmModal = ({
}
return (
<Modal
isShow
onClose={() => onCancel()}
className="w-[480px]"
>
<div className="flex flex-col items-start gap-2 self-stretch pb-4">
<div className="text-text-primary title-2xl-semi-bold">{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}</div>
<div className="flex grow flex-col text-text-secondary system-md-regular">
<div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div>
<div>{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}</div>
<br />
<div>
{t('newApp.appCreateDSLErrorPart3', { ns: 'app' })}
<span className="system-md-medium">{versions.importedVersion}</span>
</div>
<div>
{t('newApp.appCreateDSLErrorPart4', { ns: 'app' })}
<span className="system-md-medium">{versions.systemVersion}</span>
<Dialog open onOpenChange={open => !open && onCancel()}>
<DialogContent className="w-[480px]">
<DialogCloseButton />
<div className="flex flex-col items-start gap-2 self-stretch pb-4">
<DialogTitle className="text-text-primary title-2xl-semi-bold">
{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}
</DialogTitle>
<div className="flex grow flex-col text-text-secondary system-md-regular">
<div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div>
<div>{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}</div>
<br />
<div>
{t('newApp.appCreateDSLErrorPart3', { ns: 'app' })}
<span className="system-md-medium">{versions.importedVersion}</span>
</div>
<div>
{t('newApp.appCreateDSLErrorPart4', { ns: 'app' })}
<span className="system-md-medium">{versions.systemVersion}</span>
</div>
</div>
</div>
</div>
<div className="flex items-start justify-end gap-2 self-stretch pt-6">
<Button variant="secondary" onClick={() => onCancel()}>{t('newApp.Cancel', { ns: 'app' })}</Button>
<Button
variant="primary"
destructive
onClick={handleConfirm}
disabled={confirmDisabled || isImporting}
loading={isImporting}
>
{t('newApp.Confirm', { ns: 'app' })}
</Button>
</div>
</Modal>
<div className="flex items-start justify-end gap-2 self-stretch pt-6">
<Button variant="secondary" onClick={() => onCancel()}>{t('newApp.Cancel', { ns: 'app' })}</Button>
<Button
variant="primary"
destructive
onClick={handleConfirm}
disabled={confirmDisabled || isImporting}
loading={isImporting}
>
{t('newApp.Confirm', { ns: 'app' })}
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -3,7 +3,6 @@
import type { DocPathWithoutLang } from '@/types/doc-paths'
import { useKeyPress } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { useRouter } from 'next/navigation'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
@ -11,7 +10,7 @@ import { trackEvent } from '@/app/components/base/amplitude'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import { ToastContext } from '@/app/components/base/toast'
import { ToastContext } from '@/app/components/base/toast/context'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
@ -22,6 +21,7 @@ import {
DSLImportMode,
DSLImportStatus,
} from '@/models/app'
import { useRouter } from '@/next/navigation'
import {
importAppBundle,
importDSL,
@ -270,10 +270,8 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
isShow={show}
onClose={noop}
>
<div className="flex items-start justify-between pb-3 pl-6 pr-5 pt-6">
<div className="text-text-primary title-2xl-semi-bold">
{t('importApp', { ns: 'app' })}
</div>
<div className="flex items-center justify-between pb-3 pl-6 pr-5 pt-6 text-text-primary title-2xl-semi-bold">
{t('importApp', { ns: 'app' })}
<div
className="flex h-8 w-8 cursor-pointer items-center justify-center"
onClick={() => onClose()}
@ -281,9 +279,9 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
<span className="i-ri-close-line h-[18px] w-[18px] text-text-tertiary" aria-hidden="true" />
</div>
</div>
<div className="border-b border-divider-subtle px-6">
<div className="flex h-9 items-center gap-6 text-text-tertiary system-md-semibold">
{tabs.map(tab => (
<div className="flex h-9 items-center space-x-6 border-b border-divider-subtle px-6 text-text-tertiary system-md-semibold">
{
tabs.map(tab => (
<div
key={tab.key}
className={cn(
@ -297,8 +295,8 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
<div className="absolute bottom-0 h-[2px] w-full bg-util-colors-blue-brand-blue-brand-600"></div>
)}
</div>
))}
</div>
))
}
</div>
<div className="px-6 py-4">
{currentTab === CreateFromDSLModalTab.FROM_FILE && (

View File

@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import ActionButton from '@/app/components/base/action-button'
import { Yaml as YamlIcon } from '@/app/components/base/icons/src/public/files'
import { ToastContext } from '@/app/components/base/toast'
import { ToastContext } from '@/app/components/base/toast/context'
import { cn } from '@/utils/classnames'
import { formatFileSize } from '@/utils/format'

View File

@ -0,0 +1,142 @@
import type { ComponentProps } from 'react'
import type { InSiteMessageActionItem } from './index'
import { fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import InSiteMessage from './index'
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
}))
describe('InSiteMessage', () => {
const originalLocation = window.location
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('open', vi.fn())
})
afterEach(() => {
Object.defineProperty(window, 'location', {
value: originalLocation,
configurable: true,
})
vi.unstubAllGlobals()
})
const renderComponent = (actions: InSiteMessageActionItem[], props?: Partial<ComponentProps<typeof InSiteMessage>>) => {
return render(
<InSiteMessage
notificationId="test-notification-id"
title="Title\\nLine"
subtitle="Subtitle\\nLine"
main="Main content"
actions={actions}
{...props}
/>,
)
}
// Validate baseline rendering and content normalization.
describe('Rendering', () => {
it('should render title, subtitle, markdown content, and action buttons', () => {
const actions: InSiteMessageActionItem[] = [
{ action: 'close', action_name: 'dismiss', text: 'Close', type: 'default' },
{ action: 'link', action_name: 'learn_more', text: 'Learn more', type: 'primary', data: 'https://example.com' },
]
renderComponent(actions, { className: 'custom-message' })
const closeButton = screen.getByRole('button', { name: 'Close' })
const learnMoreButton = screen.getByRole('button', { name: 'Learn more' })
const panel = closeButton.closest('div.fixed')
const titleElement = panel?.querySelector('.title-3xl-bold')
const subtitleElement = panel?.querySelector('.body-md-regular')
expect(panel).toHaveClass('custom-message')
expect(titleElement).toHaveTextContent(/Title.*Line/s)
expect(subtitleElement).toHaveTextContent(/Subtitle.*Line/s)
expect(titleElement?.textContent).not.toContain('\\n')
expect(subtitleElement?.textContent).not.toContain('\\n')
expect(screen.getByText('Main content')).toBeInTheDocument()
expect(closeButton).toBeInTheDocument()
expect(learnMoreButton).toBeInTheDocument()
})
it('should fallback to default header background when headerBgUrl is empty string', () => {
const actions: InSiteMessageActionItem[] = [{ action: 'close', action_name: 'dismiss', text: 'Close', type: 'default' }]
const { container } = renderComponent(actions, { headerBgUrl: '' })
const header = container.querySelector('div[style]')
expect(header).toHaveStyle({ backgroundImage: 'url(/in-site-message/header-bg.svg)' })
})
})
// Validate action handling for close and link actions.
describe('Actions', () => {
it('should call onAction and hide component when close action is clicked', () => {
const onAction = vi.fn()
const closeAction: InSiteMessageActionItem = { action: 'close', action_name: 'dismiss', text: 'Close', type: 'default' }
renderComponent([closeAction], { onAction })
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(onAction).toHaveBeenCalledWith(closeAction)
expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument()
})
it('should open a new tab when link action data is a string', () => {
const linkAction: InSiteMessageActionItem = {
action: 'link',
action_name: 'confirm',
text: 'Open link',
type: 'primary',
data: 'https://example.com',
}
renderComponent([linkAction])
fireEvent.click(screen.getByRole('button', { name: 'Open link' }))
expect(window.open).toHaveBeenCalledWith('https://example.com', '_blank', 'noopener,noreferrer')
})
it('should navigate with location.assign when link action target is _self', () => {
const assignSpy = vi.fn()
Object.defineProperty(window, 'location', {
value: {
...originalLocation,
assign: assignSpy,
},
configurable: true,
})
const linkAction: InSiteMessageActionItem = {
action: 'link',
action_name: 'confirm',
text: 'Open self',
type: 'primary',
data: { href: 'https://example.com/self', target: '_self' },
}
renderComponent([linkAction])
fireEvent.click(screen.getByRole('button', { name: 'Open self' }))
expect(assignSpy).toHaveBeenCalledWith('https://example.com/self')
expect(window.open).not.toHaveBeenCalled()
})
it('should not trigger navigation when link data is invalid', () => {
const linkAction: InSiteMessageActionItem = {
action: 'link',
action_name: 'confirm',
text: 'Broken link',
type: 'primary',
data: { rel: 'noopener' },
}
renderComponent([linkAction])
fireEvent.click(screen.getByRole('button', { name: 'Broken link' }))
expect(window.open).not.toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,148 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { trackEvent } from '@/app/components/base/amplitude'
import Button from '@/app/components/base/button'
import { MarkdownWithDirective } from '@/app/components/base/markdown-with-directive'
import { cn } from '@/utils/classnames'
type InSiteMessageAction = 'link' | 'close'
type InSiteMessageButtonType = 'primary' | 'default'
export type InSiteMessageActionItem = {
action: InSiteMessageAction
action_name: string // for tracing and analytics
data?: unknown
text: string
type: InSiteMessageButtonType
}
type InSiteMessageProps = {
notificationId: string
actions: InSiteMessageActionItem[]
className?: string
headerBgUrl?: string
main: string
onAction?: (action: InSiteMessageActionItem) => void
subtitle: string
title: string
}
const LINE_BREAK_REGEX = /\\n/g
function normalizeLineBreaks(text: string): string {
return text.replace(LINE_BREAK_REGEX, '\n')
}
function normalizeLinkData(data: unknown): { href: string, rel?: string, target?: string } | null {
if (typeof data === 'string')
return { href: data, target: '_blank' }
if (!data || typeof data !== 'object')
return null
const candidate = data as { href?: unknown, rel?: unknown, target?: unknown }
if (typeof candidate.href !== 'string' || !candidate.href)
return null
return {
href: candidate.href,
rel: typeof candidate.rel === 'string' ? candidate.rel : undefined,
target: typeof candidate.target === 'string' ? candidate.target : '_blank',
}
}
const DEFAULT_HEADER_BG_URL = '/in-site-message/header-bg.svg'
function InSiteMessage({
notificationId,
actions,
className,
headerBgUrl = DEFAULT_HEADER_BG_URL,
main,
onAction,
subtitle,
title,
}: InSiteMessageProps) {
const [visible, setVisible] = useState(true)
const normalizedTitle = normalizeLineBreaks(title)
const normalizedSubtitle = normalizeLineBreaks(subtitle)
const headerStyle = useMemo(() => {
return {
backgroundImage: `url(${headerBgUrl || DEFAULT_HEADER_BG_URL})`,
}
}, [headerBgUrl])
useEffect(() => {
trackEvent('in_site_message_show', {
notification_id: notificationId,
})
}, [notificationId])
const handleAction = (item: InSiteMessageActionItem) => {
trackEvent('in_site_message_action', {
notification_id: notificationId,
action: item.action_name,
})
onAction?.(item)
if (item.action === 'close') {
setVisible(false)
return
}
const linkData = normalizeLinkData(item.data)
if (!linkData)
return
const target = linkData.target ?? '_blank'
if (target === '_self') {
window.location.assign(linkData.href)
return
}
window.open(linkData.href, target, linkData.rel || 'noopener,noreferrer')
}
if (!visible)
return null
return (
<div
className={cn(
'fixed bottom-3 right-3 z-50 w-[360px] overflow-hidden rounded-xl border border-components-panel-border-subtle bg-components-panel-bg shadow-2xl backdrop-blur-[5px]',
className,
)}
>
<div className="flex min-h-[128px] flex-col justify-end gap-0.5 bg-cover px-4 pb-3 pt-6 text-text-primary-on-surface" style={headerStyle}>
<div className="whitespace-pre-line title-3xl-bold">
{normalizedTitle}
</div>
<div className="whitespace-pre-line body-md-regular">
{normalizedSubtitle}
</div>
</div>
<div className="px-4 pb-2 pt-4 text-text-secondary body-md-regular">
<MarkdownWithDirective markdown={main} />
</div>
<div className="flex items-center justify-end gap-2 p-4">
{actions.map(item => (
<Button
key={`${item.type}-${item.action}-${item.text}`}
variant={item.type === 'primary' ? 'primary' : 'ghost'}
size="medium"
className={cn(item.type === 'default' && 'text-text-secondary')}
onClick={() => handleAction(item)}
>
{item.text}
</Button>
))}
</div>
</div>
)
}
export default InSiteMessage

View File

@ -0,0 +1,221 @@
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import InSiteMessageNotification from './notification'
const {
mockConfig,
mockNotification,
mockNotificationDismiss,
} = vi.hoisted(() => ({
mockConfig: {
isCloudEdition: true,
},
mockNotification: vi.fn(),
mockNotificationDismiss: vi.fn(),
}))
vi.mock(import('@/config'), async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
get IS_CLOUD_EDITION() {
return mockConfig.isCloudEdition
},
}
})
vi.mock('@/service/client', () => ({
consoleQuery: {
notification: {
queryOptions: (options?: Record<string, unknown>) => ({
queryKey: ['console', 'notification'],
queryFn: (...args: unknown[]) => mockNotification(...args),
...options,
}),
},
notificationDismiss: {
mutationOptions: (options?: Record<string, unknown>) => ({
mutationKey: ['console', 'notificationDismiss'],
mutationFn: (...args: unknown[]) => mockNotificationDismiss(...args),
...options,
}),
},
},
}))
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
})
const Wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
return Wrapper
}
describe('InSiteMessageNotification', () => {
beforeEach(() => {
vi.clearAllMocks()
mockConfig.isCloudEdition = true
vi.stubGlobal('open', vi.fn())
})
afterEach(() => {
vi.unstubAllGlobals()
})
// Validate query gating and empty state rendering.
describe('Rendering', () => {
it('should render null and skip query when not cloud edition', async () => {
mockConfig.isCloudEdition = false
const Wrapper = createWrapper()
const { container } = render(<InSiteMessageNotification />, { wrapper: Wrapper })
await waitFor(() => {
expect(mockNotification).not.toHaveBeenCalled()
})
expect(container).toBeEmptyDOMElement()
})
it('should render null when notification list is empty', async () => {
mockNotification.mockResolvedValue({ notifications: [] })
const Wrapper = createWrapper()
const { container } = render(<InSiteMessageNotification />, { wrapper: Wrapper })
await waitFor(() => {
expect(mockNotification).toHaveBeenCalledTimes(1)
})
expect(container).toBeEmptyDOMElement()
})
})
// Validate parsed-body behavior and action handling.
describe('Notification body parsing and actions', () => {
it('should render parsed main/actions and dismiss only on close action', async () => {
mockNotification.mockResolvedValue({
notifications: [
{
notification_id: 'n-1',
title: 'Update title',
subtitle: 'Update subtitle',
title_pic_url: 'https://example.com/bg.png',
body: JSON.stringify({
main: 'Parsed body main',
actions: [
{ action: 'link', data: 'https://example.com/docs', text: 'Visit docs', type: 'primary' },
{ action: 'close', text: 'Dismiss now', type: 'default' },
{ action: 'link', data: 'https://example.com/invalid', text: 100, type: 'primary' },
],
}),
},
],
})
mockNotificationDismiss.mockResolvedValue({ success: true })
const Wrapper = createWrapper()
render(<InSiteMessageNotification />, { wrapper: Wrapper })
await waitFor(() => {
expect(screen.getByText('Parsed body main')).toBeInTheDocument()
})
expect(screen.getByRole('button', { name: 'Visit docs' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Dismiss now' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Invalid' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Visit docs' }))
expect(mockNotificationDismiss).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Dismiss now' }))
await waitFor(() => {
expect(mockNotificationDismiss).toHaveBeenCalledWith(
{
body: {
notification_id: 'n-1',
},
},
expect.objectContaining({
mutationKey: ['console', 'notificationDismiss'],
}),
)
})
})
it('should fallback to raw body and default close action when body is invalid json', async () => {
mockNotification.mockResolvedValue({
notifications: [
{
notification_id: 'n-2',
title: 'Fallback title',
subtitle: 'Fallback subtitle',
title_pic_url: 'https://example.com/bg-2.png',
body: 'raw body text',
},
],
})
mockNotificationDismiss.mockResolvedValue({ success: true })
const Wrapper = createWrapper()
render(<InSiteMessageNotification />, { wrapper: Wrapper })
await waitFor(() => {
expect(screen.getByText('raw body text')).toBeInTheDocument()
})
const closeButton = screen.getByRole('button', { name: 'common.operation.close' })
fireEvent.click(closeButton)
await waitFor(() => {
expect(mockNotificationDismiss).toHaveBeenCalledWith(
{
body: {
notification_id: 'n-2',
},
},
expect.objectContaining({
mutationKey: ['console', 'notificationDismiss'],
}),
)
})
})
it('should fallback to default close action when parsed actions are all invalid', async () => {
mockNotification.mockResolvedValue({
notifications: [
{
notification_id: 'n-3',
title: 'Invalid action title',
subtitle: 'Invalid action subtitle',
title_pic_url: 'https://example.com/bg-3.png',
body: JSON.stringify({
main: 'Main from parsed body',
actions: [
{ action: 'link', type: 'primary', text: 100, data: 'https://example.com' },
],
}),
},
],
})
const Wrapper = createWrapper()
render(<InSiteMessageNotification />, { wrapper: Wrapper })
await waitFor(() => {
expect(screen.getByText('Main from parsed body')).toBeInTheDocument()
})
expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,111 @@
'use client'
import type { InSiteMessageActionItem } from './index'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { IS_CLOUD_EDITION } from '@/config'
import { consoleQuery } from '@/service/client'
import InSiteMessage from './index'
type NotificationBodyPayload = {
actions: InSiteMessageActionItem[]
main: string
}
function isValidActionItem(value: unknown): value is InSiteMessageActionItem {
if (!value || typeof value !== 'object')
return false
const candidate = value as {
action?: unknown
data?: unknown
text?: unknown
type?: unknown
}
return (
typeof candidate.text === 'string'
&& (candidate.type === 'primary' || candidate.type === 'default')
&& (candidate.action === 'link' || candidate.action === 'close')
&& (candidate.data === undefined || typeof candidate.data !== 'function')
)
}
function parseNotificationBody(body: string): NotificationBodyPayload | null {
try {
const parsed = JSON.parse(body) as {
actions?: unknown
main?: unknown
}
if (!parsed || typeof parsed !== 'object')
return null
if (typeof parsed.main !== 'string')
return null
const actions = Array.isArray(parsed.actions)
? parsed.actions.filter(isValidActionItem)
: []
return {
main: parsed.main,
actions,
}
}
catch {
return null
}
}
function InSiteMessageNotification() {
const { t } = useTranslation()
const dismissNotificationMutation = useMutation(consoleQuery.notificationDismiss.mutationOptions())
const { data } = useQuery(consoleQuery.notification.queryOptions({
enabled: IS_CLOUD_EDITION,
}))
const notification = data?.notifications?.[0]
const parsedBody = notification ? parseNotificationBody(notification.body) : null
if (!IS_CLOUD_EDITION || !notification)
return null
const fallbackActions: InSiteMessageActionItem[] = [
{
type: 'default',
action_name: 'dismiss',
text: t('operation.close', { ns: 'common' }),
action: 'close',
},
]
const actions = parsedBody?.actions?.length ? parsedBody.actions : fallbackActions
const main = parsedBody?.main ?? notification.body
const handleAction = (action: InSiteMessageActionItem) => {
if (action.action !== 'close')
return
dismissNotificationMutation.mutate({
body: {
notification_id: notification.notification_id,
},
})
}
return (
<InSiteMessage
key={notification.notification_id}
notificationId={notification.notification_id}
title={notification.title}
subtitle={notification.subtitle}
headerBgUrl={notification.title_pic_url}
main={main}
actions={actions}
onAction={handleAction}
/>
)
}
export default InSiteMessageNotification

View File

@ -7,7 +7,7 @@ import { AppModeEnum } from '@/types/app'
import LogAnnotation from './index'
const mockRouterPush = vi.fn()
vi.mock('next/navigation', () => ({
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockRouterPush,
}),

View File

@ -1,6 +1,5 @@
'use client'
import type { FC } from 'react'
import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@ -11,6 +10,7 @@ import WorkflowLog from '@/app/components/app/workflow-log'
import { PageType } from '@/app/components/base/features/new-feature-panel/annotation-reply/type'
import Loading from '@/app/components/base/loading'
import TabSlider from '@/app/components/base/tab-slider-plain'
import { useRouter } from '@/next/navigation'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'

View File

@ -1,9 +1,9 @@
'use client'
import type { FC, SVGProps } from 'react'
import type { App } from '@/types/app'
import Link from 'next/link'
import * as React from 'react'
import { Trans, useTranslation } from 'react-i18next'
import Link from '@/next/link'
import { AppModeEnum } from '@/types/app'
import { getRedirectionPath } from '@/utils/app-redirection'
import { basePath } from '@/utils/var'

View File

@ -4,13 +4,13 @@ import type { App } from '@/types/app'
import { useDebounce } from 'ahooks'
import dayjs from 'dayjs'
import { omit } from 'es-toolkit/object'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import Pagination from '@/app/components/base/pagination'
import { APP_PAGE_LIMIT } from '@/config'
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
import { useChatConversations, useCompletionConversations } from '@/service/use-log'
import { AppModeEnum } from '@/types/app'
import EmptyElement from './empty-element'

View File

@ -14,7 +14,6 @@ import timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc'
import { get } from 'es-toolkit/compat'
import { noop } from 'es-toolkit/function'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -31,13 +30,14 @@ import Drawer from '@/app/components/base/drawer'
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
import Loading from '@/app/components/base/loading'
import MessageLogModal from '@/app/components/base/message-log-modal'
import { ToastContext } from '@/app/components/base/toast'
import { ToastContext } from '@/app/components/base/toast/context'
import Tooltip from '@/app/components/base/tooltip'
import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils'
import { WorkflowContextProvider } from '@/app/components/workflow/context'
import { useAppContext } from '@/context/app-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useTimestamp from '@/hooks/use-timestamp'
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
import { fetchChatMessages, updateLogMessageAnnotations, updateLogMessageFeedbacks } from '@/service/log'
import { AppSourceType } from '@/service/share'
import { useChatConversationDetail, useCompletionConversationDetail } from '@/service/use-log'

View File

@ -14,7 +14,6 @@ import {
RiVerifiedBadgeLine,
RiWindowLine,
} from '@remixicon/react'
import { usePathname, useRouter } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -34,6 +33,7 @@ import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useDocLink } from '@/context/i18n'
import { AccessMode } from '@/models/access-control'
import { usePathname, useRouter } from '@/next/navigation'
import { useAppWhiteListSubjects } from '@/service/access-control'
import { fetchAppDetailDirect } from '@/service/apps'
import { useAppWorkflow } from '@/service/use-workflow'
@ -260,7 +260,7 @@ function AppCard({
offset={24}
>
<div>
<Switch defaultValue={runningStatus} onChange={onChangeStatus} disabled={toggleDisabled} />
<Switch value={runningStatus} onChange={onChangeStatus} disabled={toggleDisabled} />
</div>
</Tooltip>
</div>

View File

@ -323,14 +323,8 @@ describe('CustomizeModal', () => {
expect(screen.getByText('appOverview.overview.appInfo.customize.title')).toBeInTheDocument()
})
// Find the close button by navigating from the heading to the close icon
// The close icon is an SVG inside a sibling div of the title
const heading = screen.getByRole('heading', { name: /customize\.title/i })
const closeIcon = heading.parentElement!.querySelector('svg')
// Assert - closeIcon must exist for the test to be valid
expect(closeIcon).toBeInTheDocument()
fireEvent.click(closeIcon!)
const closeButton = screen.getByTestId('modal-close-button')
fireEvent.click(closeButton)
expect(onClose).toHaveBeenCalledTimes(1)
})
})

Some files were not shown because too many files have changed in this diff Show More