mirror of
https://github.com/langgenius/dify.git
synced 2026-03-10 11:10:19 +08:00
refactor(web): restructure app-sidebar with component decomposition and folder organization (#32887)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
parent
d6ab36ff1e
commit
4c07bc99f7
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { NavIcon } from '@/app/components/app-sidebar/navLink'
|
||||
import type { NavIcon } from '@/app/components/app-sidebar/nav-link'
|
||||
import type { App } from '@/types/app'
|
||||
import {
|
||||
RiDashboard2Fill,
|
||||
|
||||
@ -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)')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -13,7 +13,7 @@ vi.mock('next/navigation', () => ({
|
||||
|
||||
// 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,474 +0,0 @@
|
||||
import type { Operation } from './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'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
RiEditLine,
|
||||
RiEqualizer2Line,
|
||||
RiExchange2Line,
|
||||
RiFileCopy2Line,
|
||||
RiFileDownloadLine,
|
||||
RiFileUploadLine,
|
||||
} from '@remixicon/react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import * as React from 'react'
|
||||
import { useCallback, 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 { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
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 { cn } from '@/utils/classnames'
|
||||
import { downloadBlob } from '@/utils/download'
|
||||
import AppIcon from '../base/app-icon'
|
||||
import AppOperations from './app-operations'
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
export type IAppInfoProps = {
|
||||
expand: boolean
|
||||
onlyShowDetail?: boolean
|
||||
openState?: boolean
|
||||
onDetailExpand?: (expand: boolean) => void
|
||||
}
|
||||
|
||||
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)
|
||||
const setAppDetail = useAppStore(state => state.setAppDetail)
|
||||
const invalidateAppList = useInvalidateAppList()
|
||||
const [open, setOpen] = useState(openState)
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
const [showDuplicateModal, setShowDuplicateModal] = useState(false)
|
||||
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
|
||||
const [showSwitchModal, setShowSwitchModal] = useState<boolean>(false)
|
||||
const [showImportDSLModal, setShowImportDSLModal] = useState<boolean>(false)
|
||||
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([])
|
||||
const [showExportWarning, setShowExportWarning] = useState(false)
|
||||
|
||||
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,
|
||||
})
|
||||
setShowEditModal(false)
|
||||
notify({
|
||||
type: 'success',
|
||||
message: t('editDone', { ns: 'app' }),
|
||||
})
|
||||
setAppDetail(app)
|
||||
}
|
||||
catch {
|
||||
notify({ type: 'error', message: t('editFailed', { ns: 'app' }) })
|
||||
}
|
||||
}, [appDetail, notify, setAppDetail, t])
|
||||
|
||||
const onCopy: DuplicateAppModalProps['onConfirm'] = 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,
|
||||
})
|
||||
setShowDuplicateModal(false)
|
||||
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' }) })
|
||||
}
|
||||
}
|
||||
|
||||
const onExport = 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' }) })
|
||||
}
|
||||
}
|
||||
|
||||
const exportCheck = async () => {
|
||||
if (!appDetail)
|
||||
return
|
||||
if (appDetail.mode !== AppModeEnum.WORKFLOW && appDetail.mode !== AppModeEnum.ADVANCED_CHAT) {
|
||||
onExport()
|
||||
return
|
||||
}
|
||||
|
||||
setShowExportWarning(true)
|
||||
}
|
||||
|
||||
const handleConfirmExport = async () => {
|
||||
if (!appDetail)
|
||||
return
|
||||
setShowExportWarning(false)
|
||||
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' }) })
|
||||
}
|
||||
}
|
||||
|
||||
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: any) {
|
||||
notify({
|
||||
type: 'error',
|
||||
message: `${t('appDeleteFailed', { ns: 'app' })}${'message' in e ? `: ${e.message}` : ''}`,
|
||||
})
|
||||
}
|
||||
setShowConfirmDelete(false)
|
||||
}, [appDetail, invalidateAppList, notify, onPlanInfoChanged, replace, setAppDetail, t])
|
||||
|
||||
const { isCurrentWorkspaceEditor } = useAppContext()
|
||||
|
||||
if (!appDetail)
|
||||
return null
|
||||
|
||||
const primaryOperations = [
|
||||
{
|
||||
id: 'edit',
|
||||
title: t('editApp', { ns: 'app' }),
|
||||
icon: <RiEditLine />,
|
||||
onClick: () => {
|
||||
setOpen(false)
|
||||
onDetailExpand?.(false)
|
||||
setShowEditModal(true)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'duplicate',
|
||||
title: t('duplicate', { ns: 'app' }),
|
||||
icon: <RiFileCopy2Line />,
|
||||
onClick: () => {
|
||||
setOpen(false)
|
||||
onDetailExpand?.(false)
|
||||
setShowDuplicateModal(true)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'export',
|
||||
title: t('export', { ns: 'app' }),
|
||||
icon: <RiFileDownloadLine />,
|
||||
onClick: exportCheck,
|
||||
},
|
||||
]
|
||||
|
||||
const secondaryOperations: Operation[] = [
|
||||
// Import DSL (conditional)
|
||||
...(appDetail.mode === AppModeEnum.ADVANCED_CHAT || appDetail.mode === AppModeEnum.WORKFLOW)
|
||||
? [{
|
||||
id: 'import',
|
||||
title: t('common.importDSL', { ns: 'workflow' }),
|
||||
icon: <RiFileUploadLine />,
|
||||
onClick: () => {
|
||||
setOpen(false)
|
||||
onDetailExpand?.(false)
|
||||
setShowImportDSLModal(true)
|
||||
},
|
||||
}]
|
||||
: [],
|
||||
// Divider
|
||||
{
|
||||
id: 'divider-1',
|
||||
title: '',
|
||||
icon: <></>,
|
||||
onClick: () => { /* divider has no action */ },
|
||||
type: 'divider' as const,
|
||||
},
|
||||
// Delete operation
|
||||
{
|
||||
id: 'delete',
|
||||
title: t('operation.delete', { ns: 'common' }),
|
||||
icon: <RiDeleteBinLine />,
|
||||
onClick: () => {
|
||||
setOpen(false)
|
||||
onDetailExpand?.(false)
|
||||
setShowConfirmDelete(true)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// Keep the switch operation separate as it's not part of the main operations
|
||||
const switchOperation = (appDetail.mode === AppModeEnum.COMPLETION || appDetail.mode === AppModeEnum.CHAT)
|
||||
? {
|
||||
id: 'switch',
|
||||
title: t('switch', { ns: 'app' }),
|
||||
icon: <RiExchange2Line />,
|
||||
onClick: () => {
|
||||
setOpen(false)
|
||||
onDetailExpand?.(false)
|
||||
setShowSwitchModal(true)
|
||||
},
|
||||
}
|
||||
: null
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!onlyShowDetail && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isCurrentWorkspaceEditor)
|
||||
setOpen(v => !v)
|
||||
}}
|
||||
className="block w-full"
|
||||
>
|
||||
<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="system-md-semibold truncate whitespace-nowrap text-text-secondary">{appDetail.name}</div>
|
||||
</div>
|
||||
<div className="system-2xs-medium-uppercase whitespace-nowrap text-text-tertiary">
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
<ContentDialog
|
||||
show={onlyShowDetail ? openState : open}
|
||||
onClose={() => {
|
||||
setOpen(false)
|
||||
onDetailExpand?.(false)
|
||||
}}
|
||||
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="system-md-semibold w-full truncate text-text-secondary">{appDetail.name}</div>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{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>
|
||||
</div>
|
||||
{/* description */}
|
||||
{appDetail.description && (
|
||||
<div className="system-xs-regular overflow-wrap-anywhere max-h-[105px] w-full max-w-full overflow-y-auto whitespace-normal break-words text-text-tertiary">{appDetail.description}</div>
|
||||
)}
|
||||
{/* operations */}
|
||||
<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"
|
||||
/>
|
||||
{/* Switch operation (if available) */}
|
||||
{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="system-sm-medium text-text-tertiary">{switchOperation.title}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</ContentDialog>
|
||||
{showSwitchModal && (
|
||||
<SwitchAppModal
|
||||
inAppDetail
|
||||
show={showSwitchModal}
|
||||
appDetail={appDetail}
|
||||
onClose={() => setShowSwitchModal(false)}
|
||||
onSuccess={() => setShowSwitchModal(false)}
|
||||
/>
|
||||
)}
|
||||
{showEditModal && (
|
||||
<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={showEditModal}
|
||||
onConfirm={onEdit}
|
||||
onHide={() => setShowEditModal(false)}
|
||||
/>
|
||||
)}
|
||||
{showDuplicateModal && (
|
||||
<DuplicateAppModal
|
||||
appName={appDetail.name}
|
||||
icon_type={appDetail.icon_type}
|
||||
icon={appDetail.icon}
|
||||
icon_background={appDetail.icon_background}
|
||||
icon_url={appDetail.icon_url}
|
||||
show={showDuplicateModal}
|
||||
onConfirm={onCopy}
|
||||
onHide={() => setShowDuplicateModal(false)}
|
||||
/>
|
||||
)}
|
||||
{showConfirmDelete && (
|
||||
<Confirm
|
||||
title={t('deleteAppConfirmTitle', { ns: 'app' })}
|
||||
content={t('deleteAppConfirmContent', { ns: 'app' })}
|
||||
isShow={showConfirmDelete}
|
||||
onConfirm={onConfirmDelete}
|
||||
onCancel={() => setShowConfirmDelete(false)}
|
||||
/>
|
||||
)}
|
||||
{showImportDSLModal && (
|
||||
<UpdateDSLModal
|
||||
onCancel={() => setShowImportDSLModal(false)}
|
||||
onBackup={exportCheck}
|
||||
/>
|
||||
)}
|
||||
{secretEnvList.length > 0 && (
|
||||
<DSLExportConfirmModal
|
||||
envList={secretEnvList}
|
||||
onConfirm={onExport}
|
||||
onClose={() => setSecretEnvList([])}
|
||||
/>
|
||||
)}
|
||||
{showExportWarning && (
|
||||
<Confirm
|
||||
type="info"
|
||||
isShow={showExportWarning}
|
||||
title={t('sidebar.exportWarning', { ns: 'workflow' })}
|
||||
content={t('sidebar.exportWarningDesc', { ns: 'workflow' })}
|
||||
onConfirm={handleConfirmExport}
|
||||
onCancel={() => setShowExportWarning(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(AppInfo)
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
147
web/app/components/app-sidebar/app-info/__tests__/index.spec.tsx
Normal file
147
web/app/components/app-sidebar/app-info/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
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 '..'
|
||||
|
||||
let mockIsCurrentWorkspaceEditor = true
|
||||
const mockSetPanelOpen = 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', () => ({
|
||||
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)
|
||||
122
web/app/components/app-sidebar/app-info/app-info-modals.tsx
Normal file
122
web/app/components/app-sidebar/app-info/app-info-modals.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
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 dynamic from 'next/dynamic'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
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()
|
||||
|
||||
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
|
||||
onConfirm={onConfirmDelete}
|
||||
onCancel={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
|
||||
@ -134,7 +134,7 @@ const AppOperations = ({
|
||||
tabIndex={-1}
|
||||
>
|
||||
{cloneElement(operation.icon, { className: 'h-3.5 w-3.5 text-components-button-secondary-text' })}
|
||||
<span className="system-xs-medium text-components-button-secondary-text">
|
||||
<span className="text-components-button-secondary-text system-xs-medium">
|
||||
{operation.title}
|
||||
</span>
|
||||
</Button>
|
||||
@ -147,7 +147,7 @@ const AppOperations = ({
|
||||
tabIndex={-1}
|
||||
>
|
||||
<RiMoreLine className="h-3.5 w-3.5 text-components-button-secondary-text" />
|
||||
<span className="system-xs-medium text-components-button-secondary-text">
|
||||
<span className="text-components-button-secondary-text system-xs-medium">
|
||||
{t('operation.more', { ns: 'common' })}
|
||||
</span>
|
||||
</Button>
|
||||
@ -163,7 +163,7 @@ const AppOperations = ({
|
||||
onClick={operation.onClick}
|
||||
>
|
||||
{cloneElement(operation.icon, { className: 'h-3.5 w-3.5 text-components-button-secondary-text' })}
|
||||
<span className="system-xs-medium text-components-button-secondary-text">
|
||||
<span className="text-components-button-secondary-text system-xs-medium">
|
||||
{operation.title}
|
||||
</span>
|
||||
</Button>
|
||||
@ -182,7 +182,7 @@ const AppOperations = ({
|
||||
className="gap-[1px]"
|
||||
>
|
||||
<RiMoreLine className="h-3.5 w-3.5 text-components-button-secondary-text" />
|
||||
<span className="system-xs-medium text-components-button-secondary-text">
|
||||
<span className="text-components-button-secondary-text system-xs-medium">
|
||||
{t('operation.more', { ns: 'common' })}
|
||||
</span>
|
||||
</Button>
|
||||
@ -200,7 +200,7 @@ const AppOperations = ({
|
||||
onClick={item.onClick}
|
||||
>
|
||||
{cloneElement(item.icon, { className: 'h-4 w-4 text-text-tertiary' })}
|
||||
<span className="system-md-regular text-text-secondary">{item.title}</span>
|
||||
<span className="text-text-secondary system-md-regular">{item.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
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 { useRouter } from 'next/navigation'
|
||||
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'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
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<{
|
||||
@ -97,9 +97,9 @@ const AppSidebarDropdown = ({ navigation }: Props) => {
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<div className="flex w-full">
|
||||
<div className="system-md-semibold truncate text-text-secondary">{appDetail.name}</div>
|
||||
<div className="truncate text-text-secondary system-md-semibold">{appDetail.name}</div>
|
||||
</div>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{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>
|
||||
|
||||
@ -76,7 +76,7 @@ export default function AppBasic({ icon, icon_background, name, isExternal, type
|
||||
)}
|
||||
{mode === 'expand' && (
|
||||
<div className="group w-full">
|
||||
<div className={`system-md-semibold flex flex-row items-center text-text-secondary group-hover:text-text-primary ${textStyle?.main ?? ''}`}>
|
||||
<div className={`flex flex-row items-center text-text-secondary system-md-semibold group-hover:text-text-primary ${textStyle?.main ?? ''}`}>
|
||||
<div className="min-w-0 overflow-hidden text-ellipsis break-normal">
|
||||
{name}
|
||||
</div>
|
||||
@ -95,10 +95,10 @@ export default function AppBasic({ icon, icon_background, name, isExternal, type
|
||||
)}
|
||||
</div>
|
||||
{!hideType && isExtraInLine && (
|
||||
<div className="system-2xs-medium-uppercase flex text-text-tertiary">{type}</div>
|
||||
<div className="flex text-text-tertiary system-2xs-medium-uppercase">{type}</div>
|
||||
)}
|
||||
{!hideType && !isExtraInLine && (
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{isExternal ? t('externalTag', { ns: 'dataset' }) : type}</div>
|
||||
<div className="text-text-tertiary system-2xs-medium-uppercase">{isExternal ? t('externalTag', { ns: 'dataset' }) : type}</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
|
||||
@ -64,12 +64,12 @@ const DatasetInfo: FC<DatasetInfoProps> = ({
|
||||
{expand && (
|
||||
<div className="flex flex-col gap-y-1 pb-0.5">
|
||||
<div
|
||||
className="system-md-semibold truncate text-text-secondary"
|
||||
className="truncate text-text-secondary system-md-semibold"
|
||||
title={dataset.name}
|
||||
>
|
||||
{dataset.name}
|
||||
</div>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">
|
||||
<div className="text-text-tertiary system-2xs-medium-uppercase">
|
||||
{isExternalProvider && t('externalTag', { ns: 'dataset' })}
|
||||
{!!(!isExternalProvider && isPipelinePublished && dataset.doc_form && dataset.indexing_technique) && (
|
||||
<div className="flex items-center gap-x-2">
|
||||
@ -79,7 +79,7 @@ const DatasetInfo: FC<DatasetInfoProps> = ({
|
||||
)}
|
||||
</div>
|
||||
{!!dataset.description && (
|
||||
<p className="system-xs-regular line-clamp-3 text-text-tertiary first-letter:capitalize">
|
||||
<p className="line-clamp-3 text-text-tertiary system-xs-regular first-letter:capitalize">
|
||||
{dataset.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -22,7 +22,7 @@ const MenuItem = ({
|
||||
}}
|
||||
>
|
||||
<Icon className="size-4 text-text-tertiary" />
|
||||
<span className="system-md-regular px-1 text-text-secondary">{name}</span>
|
||||
<span className="px-1 text-text-secondary system-md-regular">{name}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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<{
|
||||
@ -107,12 +107,12 @@ const DatasetSidebarDropdown = ({
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-1 pb-0.5">
|
||||
<div
|
||||
className="system-md-semibold truncate text-text-secondary"
|
||||
className="truncate text-text-secondary system-md-semibold"
|
||||
title={dataset.name}
|
||||
>
|
||||
{dataset.name}
|
||||
</div>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">
|
||||
<div className="text-text-tertiary system-2xs-medium-uppercase">
|
||||
{isExternalProvider && t('externalTag', { ns: 'dataset' })}
|
||||
{!!(!isExternalProvider && dataset.doc_form && dataset.indexing_technique) && (
|
||||
<div className="flex items-center gap-x-2">
|
||||
@ -123,7 +123,7 @@ const DatasetSidebarDropdown = ({
|
||||
</div>
|
||||
</div>
|
||||
{!!dataset.description && (
|
||||
<p className="system-xs-regular line-clamp-3 text-text-tertiary first-letter:capitalize">
|
||||
<p className="line-clamp-3 text-text-tertiary system-xs-regular first-letter:capitalize">
|
||||
{dataset.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 86 KiB |
@ -1,4 +1,4 @@
|
||||
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'
|
||||
@ -14,7 +14,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,7 +1,7 @@
|
||||
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', () => ({
|
||||
@ -10,7 +10,7 @@ vi.mock('next/navigation', () => ({
|
||||
|
||||
// Mock Next.js Link component
|
||||
vi.mock('next/link', () => ({
|
||||
default: function MockLink({ children, href, className, title }: any) {
|
||||
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}
|
||||
@ -54,7 +54,7 @@ const NavLink = ({
|
||||
key={name}
|
||||
type="button"
|
||||
disabled
|
||||
className={cn('system-sm-medium flex h-8 cursor-not-allowed items-center rounded-lg text-components-menu-item-text opacity-30 hover:bg-components-menu-item-bg-hover', 'pl-3 pr-1')}
|
||||
className={cn('flex h-8 cursor-not-allowed items-center rounded-lg text-components-menu-item-text opacity-30 system-sm-medium hover:bg-components-menu-item-bg-hover', 'pl-3 pr-1')}
|
||||
title={mode === 'collapse' ? name : ''}
|
||||
aria-disabled
|
||||
>
|
||||
@ -75,8 +75,8 @@ const NavLink = ({
|
||||
key={name}
|
||||
href={href}
|
||||
className={cn(isActive
|
||||
? 'system-sm-semibold border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only'
|
||||
: 'system-sm-medium text-components-menu-item-text hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1')}
|
||||
? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold'
|
||||
: 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1')}
|
||||
title={mode === 'collapse' ? name : ''}
|
||||
>
|
||||
{renderIcon()}
|
||||
@ -1,11 +0,0 @@
|
||||
.sidebar {
|
||||
border-right: 1px solid #F3F4F6;
|
||||
}
|
||||
|
||||
.completionPic {
|
||||
background-image: url('./completion.png')
|
||||
}
|
||||
|
||||
.expertPic {
|
||||
background-image: url('./expert.png')
|
||||
}
|
||||
@ -19,7 +19,7 @@ const TooltipContent = ({
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-x-1">
|
||||
<span className="system-xs-medium px-0.5 text-text-secondary">{expand ? t('sidebar.collapseSidebar', { ns: 'layout' }) : t('sidebar.expandSidebar', { ns: 'layout' })}</span>
|
||||
<span className="px-0.5 text-text-secondary system-xs-medium">{expand ? t('sidebar.collapseSidebar', { ns: 'layout' }) : t('sidebar.expandSidebar', { ns: 'layout' })}</span>
|
||||
<ShortcutsName keys={TOGGLE_SHORTCUT} textColor="secondary" />
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -277,30 +277,9 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/app-sidebar/app-info.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 6
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/app-sidebar/app-operations.tsx": {
|
||||
"app/components/app-sidebar/app-info/app-operations.tsx": {
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 4
|
||||
},
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"app/components/app-sidebar/app-sidebar-dropdown.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/app-sidebar/basic.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"app/components/app-sidebar/dataset-info/dropdown.tsx": {
|
||||
@ -308,54 +287,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/app-sidebar/dataset-info/index.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"app/components/app-sidebar/dataset-info/menu-item.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/app-sidebar/dataset-sidebar-dropdown.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"app/components/app-sidebar/index.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/app-sidebar/navLink.spec.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/app-sidebar/navLink.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"app/components/app-sidebar/sidebar-animation-issues.spec.tsx": {
|
||||
"no-console": {
|
||||
"count": 26
|
||||
}
|
||||
},
|
||||
"app/components/app-sidebar/text-squeeze-fix-verification.spec.tsx": {
|
||||
"no-console": {
|
||||
"count": 51
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/app-sidebar/toggle-button.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/app/annotation/add-annotation-modal/edit-item/index.tsx": {
|
||||
"react-refresh/only-export-components": {
|
||||
"count": 1
|
||||
@ -1856,11 +1792,6 @@
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"app/components/base/file-uploader/utils.spec.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/base/file-uploader/utils.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
@ -2033,11 +1964,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/input/index.spec.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/input/index.stories.tsx": {
|
||||
"no-console": {
|
||||
"count": 2
|
||||
@ -2618,11 +2544,6 @@
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"app/components/base/with-input-validation/index.spec.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/base/with-input-validation/index.stories.tsx": {
|
||||
"no-console": {
|
||||
"count": 1
|
||||
|
||||
Loading…
Reference in New Issue
Block a user