mirror of
https://github.com/langgenius/dify.git
synced 2026-05-05 18:08:07 +08:00
Merge branch 'sandboxed-agent-rebase' into feat/support-agent-sandbox
This commit is contained in:
@ -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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
110
web/app/components/app-sidebar/__tests__/basic.spec.tsx
Normal file
110
web/app/components/app-sidebar/__tests__/basic.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
298
web/app/components/app-sidebar/__tests__/index.spec.tsx
Normal file
298
web/app/components/app-sidebar/__tests__/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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([])
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
155
web/app/components/app-sidebar/app-info/__tests__/index.spec.tsx
Normal file
155
web/app/components/app-sidebar/app-info/__tests__/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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'),
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
132
web/app/components/app-sidebar/app-info/app-info-modals.tsx
Normal file
132
web/app/components/app-sidebar/app-info/app-info-modals.tsx
Normal 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)
|
||||
67
web/app/components/app-sidebar/app-info/app-info-trigger.tsx
Normal file
67
web/app/components/app-sidebar/app-info/app-info-trigger.tsx
Normal 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)
|
||||
17
web/app/components/app-sidebar/app-info/app-mode-labels.ts
Normal file
17
web/app/components/app-sidebar/app-info/app-mode-labels.ts
Normal 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' })
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
75
web/app/components/app-sidebar/app-info/index.tsx
Normal file
75
web/app/components/app-sidebar/app-info/index.tsx
Normal 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)
|
||||
189
web/app/components/app-sidebar/app-info/use-app-info-actions.ts
Normal file
189
web/app/components/app-sidebar/app-info/use-app-info-actions.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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 |
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}),
|
||||
@ -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'
|
||||
|
||||
@ -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 |
@ -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 = {
|
||||
|
||||
@ -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}
|
||||
@ -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<
|
||||
@ -1,11 +0,0 @@
|
||||
.sidebar {
|
||||
border-right: 1px solid #F3F4F6;
|
||||
}
|
||||
|
||||
.completionPic {
|
||||
background-image: url('./completion.png')
|
||||
}
|
||||
|
||||
.expertPic {
|
||||
background-image: url('./expert.png')
|
||||
}
|
||||
@ -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', () => {
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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',
|
||||
}))
|
||||
|
||||
@ -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',
|
||||
}))
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
@ -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={() => {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 => ({
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
@ -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 = () => {
|
||||
|
||||
@ -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]')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
})),
|
||||
|
||||
@ -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}
|
||||
|
||||
1021
web/app/components/app/configuration/debug/index.spec.tsx
Normal file
1021
web/app/components/app/configuration/debug/index.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -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} />)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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() }),
|
||||
}))
|
||||
|
||||
|
||||
@ -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' }))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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', () => ({
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
142
web/app/components/app/in-site-message/index.spec.tsx
Normal file
142
web/app/components/app/in-site-message/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
148
web/app/components/app/in-site-message/index.tsx
Normal file
148
web/app/components/app/in-site-message/index.tsx
Normal 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
|
||||
221
web/app/components/app/in-site-message/notification.spec.tsx
Normal file
221
web/app/components/app/in-site-message/notification.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
111
web/app/components/app/in-site-message/notification.tsx
Normal file
111
web/app/components/app/in-site-message/notification.tsx
Normal 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
|
||||
@ -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,
|
||||
}),
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
Reference in New Issue
Block a user