mirror of
https://github.com/langgenius/dify.git
synced 2026-02-11 19:14:00 +00:00
Compare commits
2 Commits
refactor/r
...
test/integ
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99170e4970 | ||
|
|
c730fec1e4 |
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "dify-api"
|
||||
version = "1.12.1"
|
||||
version = "1.13.0"
|
||||
requires-python = ">=3.11,<3.13"
|
||||
|
||||
dependencies = [
|
||||
|
||||
2
api/uv.lock
generated
2
api/uv.lock
generated
@@ -1366,7 +1366,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "dify-api"
|
||||
version = "1.12.1"
|
||||
version = "1.13.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aliyun-log-python-sdk" },
|
||||
|
||||
@@ -21,7 +21,7 @@ services:
|
||||
|
||||
# API service
|
||||
api:
|
||||
image: langgenius/dify-api:1.12.1
|
||||
image: langgenius/dify-api:1.13.0
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -63,7 +63,7 @@ services:
|
||||
# worker service
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
image: langgenius/dify-api:1.12.1
|
||||
image: langgenius/dify-api:1.13.0
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -102,7 +102,7 @@ services:
|
||||
# worker_beat service
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
image: langgenius/dify-api:1.12.1
|
||||
image: langgenius/dify-api:1.13.0
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -132,7 +132,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.12.1
|
||||
image: langgenius/dify-web:1.13.0
|
||||
restart: always
|
||||
environment:
|
||||
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
|
||||
|
||||
@@ -715,7 +715,7 @@ services:
|
||||
|
||||
# API service
|
||||
api:
|
||||
image: langgenius/dify-api:1.12.1
|
||||
image: langgenius/dify-api:1.13.0
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -757,7 +757,7 @@ services:
|
||||
# worker service
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
image: langgenius/dify-api:1.12.1
|
||||
image: langgenius/dify-api:1.13.0
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -796,7 +796,7 @@ services:
|
||||
# worker_beat service
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
image: langgenius/dify-api:1.12.1
|
||||
image: langgenius/dify-api:1.13.0
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -826,7 +826,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.12.1
|
||||
image: langgenius/dify-web:1.13.0
|
||||
restart: always
|
||||
environment:
|
||||
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
|
||||
|
||||
303
web/__tests__/explore/explore-app-list-flow.test.tsx
Normal file
303
web/__tests__/explore/explore-app-list-flow.test.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Integration test: Explore App List Flow
|
||||
*
|
||||
* Tests the end-to-end user flow of browsing, filtering, searching,
|
||||
* and adding apps to workspace from the explore page.
|
||||
*/
|
||||
import type { Mock } from 'vitest'
|
||||
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
|
||||
import type { App } from '@/models/explore'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import AppList from '@/app/components/explore/app-list'
|
||||
import ExploreContext from '@/context/explore-context'
|
||||
import { fetchAppDetail } from '@/service/explore'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
const allCategoriesEn = 'explore.apps.allCategories:{"lng":"en"}'
|
||||
let mockTabValue = allCategoriesEn
|
||||
const mockSetTab = vi.fn()
|
||||
let mockExploreData: { categories: string[], allList: App[] } | undefined
|
||||
let mockIsLoading = false
|
||||
const mockHandleImportDSL = vi.fn()
|
||||
const mockHandleImportDSLConfirm = vi.fn()
|
||||
|
||||
vi.mock('nuqs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('nuqs')>()
|
||||
return {
|
||||
...actual,
|
||||
useQueryState: () => [mockTabValue, mockSetTab],
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('ahooks', async () => {
|
||||
const actual = await vi.importActual<typeof import('ahooks')>('ahooks')
|
||||
const React = await vi.importActual<typeof import('react')>('react')
|
||||
return {
|
||||
...actual,
|
||||
useDebounceFn: (fn: (...args: unknown[]) => void) => {
|
||||
const fnRef = React.useRef(fn)
|
||||
fnRef.current = fn
|
||||
return {
|
||||
run: () => setTimeout(() => fnRef.current(), 0),
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/use-explore', () => ({
|
||||
useExploreAppList: () => ({
|
||||
data: mockExploreData,
|
||||
isLoading: mockIsLoading,
|
||||
isError: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/explore', () => ({
|
||||
fetchAppDetail: vi.fn(),
|
||||
fetchAppList: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-import-dsl', () => ({
|
||||
useImportDSL: () => ({
|
||||
handleImportDSL: mockHandleImportDSL,
|
||||
handleImportDSLConfirm: mockHandleImportDSLConfirm,
|
||||
versions: ['v1'],
|
||||
isFetching: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/explore/create-app-modal', () => ({
|
||||
default: (props: CreateAppModalProps) => {
|
||||
if (!props.show)
|
||||
return null
|
||||
return (
|
||||
<div data-testid="create-app-modal">
|
||||
<button
|
||||
data-testid="confirm-create"
|
||||
onClick={() => props.onConfirm({
|
||||
name: 'New App',
|
||||
icon_type: 'emoji',
|
||||
icon: '🤖',
|
||||
icon_background: '#fff',
|
||||
description: 'desc',
|
||||
})}
|
||||
>
|
||||
confirm
|
||||
</button>
|
||||
<button data-testid="hide-create" onClick={props.onHide}>hide</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/create-from-dsl-modal/dsl-confirm-modal', () => ({
|
||||
default: ({ onConfirm, onCancel }: { onConfirm: () => void, onCancel: () => void }) => (
|
||||
<div data-testid="dsl-confirm-modal">
|
||||
<button data-testid="dsl-confirm" onClick={onConfirm}>confirm</button>
|
||||
<button data-testid="dsl-cancel" onClick={onCancel}>cancel</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createApp = (overrides: Partial<App> = {}): App => ({
|
||||
app: {
|
||||
id: overrides.app?.id ?? 'app-id',
|
||||
mode: overrides.app?.mode ?? AppModeEnum.CHAT,
|
||||
icon_type: overrides.app?.icon_type ?? 'emoji',
|
||||
icon: overrides.app?.icon ?? '😀',
|
||||
icon_background: overrides.app?.icon_background ?? '#fff',
|
||||
icon_url: overrides.app?.icon_url ?? '',
|
||||
name: overrides.app?.name ?? 'Alpha',
|
||||
description: overrides.app?.description ?? 'Alpha description',
|
||||
use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false,
|
||||
},
|
||||
can_trial: true,
|
||||
app_id: overrides.app_id ?? 'app-1',
|
||||
description: overrides.description ?? 'Alpha description',
|
||||
copyright: overrides.copyright ?? '',
|
||||
privacy_policy: overrides.privacy_policy ?? null,
|
||||
custom_disclaimer: overrides.custom_disclaimer ?? null,
|
||||
category: overrides.category ?? 'Writing',
|
||||
position: overrides.position ?? 1,
|
||||
is_listed: overrides.is_listed ?? true,
|
||||
install_count: overrides.install_count ?? 0,
|
||||
installed: overrides.installed ?? false,
|
||||
editable: overrides.editable ?? false,
|
||||
is_agent: overrides.is_agent ?? false,
|
||||
})
|
||||
|
||||
const renderWithContext = (hasEditPermission = true, onSuccess?: () => void) => {
|
||||
return render(
|
||||
<ExploreContext.Provider
|
||||
value={{
|
||||
controlUpdateInstalledApps: 0,
|
||||
setControlUpdateInstalledApps: vi.fn(),
|
||||
hasEditPermission,
|
||||
installedApps: [],
|
||||
setInstalledApps: vi.fn(),
|
||||
isFetchingInstalledApps: false,
|
||||
setIsFetchingInstalledApps: vi.fn(),
|
||||
isShowTryAppPanel: false,
|
||||
setShowTryAppPanel: vi.fn(),
|
||||
}}
|
||||
>
|
||||
<AppList onSuccess={onSuccess} />
|
||||
</ExploreContext.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('Explore App List Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTabValue = allCategoriesEn
|
||||
mockIsLoading = false
|
||||
mockExploreData = {
|
||||
categories: ['Writing', 'Translate', 'Programming'],
|
||||
allList: [
|
||||
createApp({ app_id: 'app-1', app: { ...createApp().app, name: 'Writer Bot' }, category: 'Writing' }),
|
||||
createApp({ app_id: 'app-2', app: { ...createApp().app, id: 'app-id-2', name: 'Translator' }, category: 'Translate' }),
|
||||
createApp({ app_id: 'app-3', app: { ...createApp().app, id: 'app-id-3', name: 'Code Helper' }, category: 'Programming' }),
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
describe('Browse and Filter Flow', () => {
|
||||
it('should display all apps when no category filter is applied', () => {
|
||||
renderWithContext()
|
||||
|
||||
expect(screen.getByText('Writer Bot')).toBeInTheDocument()
|
||||
expect(screen.getByText('Translator')).toBeInTheDocument()
|
||||
expect(screen.getByText('Code Helper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should filter apps by selected category', () => {
|
||||
mockTabValue = 'Writing'
|
||||
renderWithContext()
|
||||
|
||||
expect(screen.getByText('Writer Bot')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Translator')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Code Helper')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should filter apps by search keyword', async () => {
|
||||
renderWithContext()
|
||||
|
||||
const input = screen.getByPlaceholderText('common.operation.search')
|
||||
fireEvent.change(input, { target: { value: 'trans' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Translator')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Writer Bot')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Code Helper')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Add to Workspace Flow', () => {
|
||||
it('should complete the full add-to-workspace flow with DSL confirmation', async () => {
|
||||
// Step 1: User clicks "Add to Workspace" on an app card
|
||||
const onSuccess = vi.fn()
|
||||
;(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content' })
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void, onPending?: () => void }) => {
|
||||
options.onPending?.()
|
||||
})
|
||||
mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: () => void }) => {
|
||||
options.onSuccess?.()
|
||||
})
|
||||
|
||||
renderWithContext(true, onSuccess)
|
||||
|
||||
// Step 2: Click add to workspace button - opens create modal
|
||||
fireEvent.click(screen.getAllByText('explore.appCard.addToWorkspace')[0])
|
||||
|
||||
// Step 3: Confirm creation in modal
|
||||
fireEvent.click(await screen.findByTestId('confirm-create'))
|
||||
|
||||
// Step 4: API fetches app detail
|
||||
await waitFor(() => {
|
||||
expect(fetchAppDetail).toHaveBeenCalledWith('app-id')
|
||||
})
|
||||
|
||||
// Step 5: DSL import triggers pending confirmation
|
||||
expect(mockHandleImportDSL).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Step 6: DSL confirm modal appears and user confirms
|
||||
expect(await screen.findByTestId('dsl-confirm-modal')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('dsl-confirm'))
|
||||
|
||||
// Step 7: Flow completes successfully
|
||||
await waitFor(() => {
|
||||
expect(mockHandleImportDSLConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loading and Empty States', () => {
|
||||
it('should transition from loading to content', () => {
|
||||
// Step 1: Loading state
|
||||
mockIsLoading = true
|
||||
mockExploreData = undefined
|
||||
const { rerender } = render(
|
||||
<ExploreContext.Provider
|
||||
value={{
|
||||
controlUpdateInstalledApps: 0,
|
||||
setControlUpdateInstalledApps: vi.fn(),
|
||||
hasEditPermission: true,
|
||||
installedApps: [],
|
||||
setInstalledApps: vi.fn(),
|
||||
isFetchingInstalledApps: false,
|
||||
setIsFetchingInstalledApps: vi.fn(),
|
||||
isShowTryAppPanel: false,
|
||||
setShowTryAppPanel: vi.fn(),
|
||||
}}
|
||||
>
|
||||
<AppList />
|
||||
</ExploreContext.Provider>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
|
||||
// Step 2: Data loads
|
||||
mockIsLoading = false
|
||||
mockExploreData = {
|
||||
categories: ['Writing'],
|
||||
allList: [createApp()],
|
||||
}
|
||||
rerender(
|
||||
<ExploreContext.Provider
|
||||
value={{
|
||||
controlUpdateInstalledApps: 0,
|
||||
setControlUpdateInstalledApps: vi.fn(),
|
||||
hasEditPermission: true,
|
||||
installedApps: [],
|
||||
setInstalledApps: vi.fn(),
|
||||
isFetchingInstalledApps: false,
|
||||
setIsFetchingInstalledApps: vi.fn(),
|
||||
isShowTryAppPanel: false,
|
||||
setShowTryAppPanel: vi.fn(),
|
||||
}}
|
||||
>
|
||||
<AppList />
|
||||
</ExploreContext.Provider>,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Permission-Based Behavior', () => {
|
||||
it('should hide add-to-workspace button when user has no edit permission', () => {
|
||||
renderWithContext(false)
|
||||
|
||||
expect(screen.queryByText('explore.appCard.addToWorkspace')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show add-to-workspace button when user has edit permission', () => {
|
||||
renderWithContext(true)
|
||||
|
||||
expect(screen.getAllByText('explore.appCard.addToWorkspace').length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
290
web/__tests__/explore/installed-app-flow.test.tsx
Normal file
290
web/__tests__/explore/installed-app-flow.test.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Integration test: Installed App Flow
|
||||
*
|
||||
* Tests the end-to-end user flow of installed apps: sidebar navigation,
|
||||
* mode-based routing (Chat / Completion / Workflow), and lifecycle
|
||||
* operations (pin/unpin, delete).
|
||||
*/
|
||||
import type { Mock } from 'vitest'
|
||||
import type { InstalledApp as InstalledAppModel } from '@/models/explore'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import InstalledApp from '@/app/components/explore/installed-app'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams } from '@/service/use-explore'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
// Mock external dependencies
|
||||
vi.mock('use-context-selector', () => ({
|
||||
useContext: vi.fn(),
|
||||
createContext: vi.fn(() => ({})),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/web-app-context', () => ({
|
||||
useWebAppStore: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/access-control', () => ({
|
||||
useGetUserCanAccessApp: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-explore', () => ({
|
||||
useGetInstalledAppAccessModeByAppId: vi.fn(),
|
||||
useGetInstalledAppParams: vi.fn(),
|
||||
useGetInstalledAppMeta: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/share/text-generation', () => ({
|
||||
default: ({ isWorkflow }: { isWorkflow?: boolean }) => (
|
||||
<div data-testid="text-generation-app">
|
||||
Text Generation
|
||||
{isWorkflow && ' (Workflow)'}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/chat/chat-with-history', () => ({
|
||||
default: ({ installedAppInfo }: { installedAppInfo?: InstalledAppModel }) => (
|
||||
<div data-testid="chat-with-history">
|
||||
Chat -
|
||||
{' '}
|
||||
{installedAppInfo?.app.name}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('Installed App Flow', () => {
|
||||
const mockUpdateAppInfo = vi.fn()
|
||||
const mockUpdateWebAppAccessMode = vi.fn()
|
||||
const mockUpdateAppParams = vi.fn()
|
||||
const mockUpdateWebAppMeta = vi.fn()
|
||||
const mockUpdateUserCanAccessApp = vi.fn()
|
||||
|
||||
const createInstalledApp = (mode: AppModeEnum = AppModeEnum.CHAT): InstalledAppModel => ({
|
||||
id: 'installed-app-1',
|
||||
app: {
|
||||
id: 'real-app-id',
|
||||
name: 'Integration Test App',
|
||||
mode,
|
||||
icon_type: 'emoji',
|
||||
icon: '🧪',
|
||||
icon_background: '#FFFFFF',
|
||||
icon_url: '',
|
||||
description: 'Test app for integration',
|
||||
use_icon_as_answer_icon: false,
|
||||
},
|
||||
uninstallable: true,
|
||||
is_pinned: false,
|
||||
})
|
||||
|
||||
const mockAppParams = {
|
||||
user_input_form: [],
|
||||
file_upload: { image: { enabled: false, number_limits: 0, transfer_methods: [] } },
|
||||
system_parameters: {},
|
||||
}
|
||||
|
||||
const setupDefaultMocks = (app: InstalledAppModel) => {
|
||||
;(useContext as Mock).mockReturnValue({
|
||||
installedApps: [app],
|
||||
isFetchingInstalledApps: false,
|
||||
})
|
||||
|
||||
;(useWebAppStore as unknown as Mock).mockImplementation((selector: (state: Record<string, Mock>) => unknown) => {
|
||||
return selector({
|
||||
updateAppInfo: mockUpdateAppInfo,
|
||||
updateWebAppAccessMode: mockUpdateWebAppAccessMode,
|
||||
updateAppParams: mockUpdateAppParams,
|
||||
updateWebAppMeta: mockUpdateWebAppMeta,
|
||||
updateUserCanAccessApp: mockUpdateUserCanAccessApp,
|
||||
})
|
||||
})
|
||||
|
||||
;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({
|
||||
isFetching: false,
|
||||
data: { accessMode: AccessMode.PUBLIC },
|
||||
error: null,
|
||||
})
|
||||
|
||||
;(useGetInstalledAppParams as Mock).mockReturnValue({
|
||||
isFetching: false,
|
||||
data: mockAppParams,
|
||||
error: null,
|
||||
})
|
||||
|
||||
;(useGetInstalledAppMeta as Mock).mockReturnValue({
|
||||
isFetching: false,
|
||||
data: { tool_icons: {} },
|
||||
error: null,
|
||||
})
|
||||
|
||||
;(useGetUserCanAccessApp as Mock).mockReturnValue({
|
||||
data: { result: true },
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Mode-Based Routing', () => {
|
||||
it.each([
|
||||
[AppModeEnum.CHAT, 'chat-with-history'],
|
||||
[AppModeEnum.ADVANCED_CHAT, 'chat-with-history'],
|
||||
[AppModeEnum.AGENT_CHAT, 'chat-with-history'],
|
||||
])('should render ChatWithHistory for %s mode', (mode, testId) => {
|
||||
const app = createInstalledApp(mode)
|
||||
setupDefaultMocks(app)
|
||||
|
||||
render(<InstalledApp id="installed-app-1" />)
|
||||
|
||||
expect(screen.getByTestId(testId)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Integration Test App/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render TextGenerationApp for COMPLETION mode', () => {
|
||||
const app = createInstalledApp(AppModeEnum.COMPLETION)
|
||||
setupDefaultMocks(app)
|
||||
|
||||
render(<InstalledApp id="installed-app-1" />)
|
||||
|
||||
expect(screen.getByTestId('text-generation-app')).toBeInTheDocument()
|
||||
expect(screen.getByText('Text Generation')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/Workflow/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render TextGenerationApp with workflow flag for WORKFLOW mode', () => {
|
||||
const app = createInstalledApp(AppModeEnum.WORKFLOW)
|
||||
setupDefaultMocks(app)
|
||||
|
||||
render(<InstalledApp id="installed-app-1" />)
|
||||
|
||||
expect(screen.getByTestId('text-generation-app')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Workflow/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Data Loading Flow', () => {
|
||||
it('should show loading spinner when params are being fetched', () => {
|
||||
const app = createInstalledApp()
|
||||
setupDefaultMocks(app)
|
||||
|
||||
;(useGetInstalledAppParams as Mock).mockReturnValue({
|
||||
isFetching: true,
|
||||
data: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const { container } = render(<InstalledApp id="installed-app-1" />)
|
||||
|
||||
expect(container.querySelector('svg.spin-animation')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('chat-with-history')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render content when all data is available', () => {
|
||||
const app = createInstalledApp()
|
||||
setupDefaultMocks(app)
|
||||
|
||||
render(<InstalledApp id="installed-app-1" />)
|
||||
|
||||
expect(screen.getByTestId('chat-with-history')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Handling Flow', () => {
|
||||
it('should show error state when API fails', () => {
|
||||
const app = createInstalledApp()
|
||||
setupDefaultMocks(app)
|
||||
|
||||
;(useGetInstalledAppParams as Mock).mockReturnValue({
|
||||
isFetching: false,
|
||||
data: null,
|
||||
error: new Error('Network error'),
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-1" />)
|
||||
|
||||
expect(screen.getByText(/Network error/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show 404 when app is not found', () => {
|
||||
;(useContext as Mock).mockReturnValue({
|
||||
installedApps: [],
|
||||
isFetchingInstalledApps: false,
|
||||
})
|
||||
;(useWebAppStore as unknown as Mock).mockImplementation((selector: (state: Record<string, Mock>) => unknown) => {
|
||||
return selector({
|
||||
updateAppInfo: mockUpdateAppInfo,
|
||||
updateWebAppAccessMode: mockUpdateWebAppAccessMode,
|
||||
updateAppParams: mockUpdateAppParams,
|
||||
updateWebAppMeta: mockUpdateWebAppMeta,
|
||||
updateUserCanAccessApp: mockUpdateUserCanAccessApp,
|
||||
})
|
||||
})
|
||||
;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({
|
||||
isFetching: false,
|
||||
data: null,
|
||||
error: null,
|
||||
})
|
||||
;(useGetInstalledAppParams as Mock).mockReturnValue({
|
||||
isFetching: false,
|
||||
data: null,
|
||||
error: null,
|
||||
})
|
||||
;(useGetInstalledAppMeta as Mock).mockReturnValue({
|
||||
isFetching: false,
|
||||
data: null,
|
||||
error: null,
|
||||
})
|
||||
;(useGetUserCanAccessApp as Mock).mockReturnValue({
|
||||
data: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="nonexistent" />)
|
||||
|
||||
expect(screen.getByText(/404/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show 403 when user has no permission', () => {
|
||||
const app = createInstalledApp()
|
||||
setupDefaultMocks(app)
|
||||
|
||||
;(useGetUserCanAccessApp as Mock).mockReturnValue({
|
||||
data: { result: false },
|
||||
error: null,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-1" />)
|
||||
|
||||
expect(screen.getByText(/403/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('State Synchronization', () => {
|
||||
it('should update all stores when app data is loaded', async () => {
|
||||
const app = createInstalledApp()
|
||||
setupDefaultMocks(app)
|
||||
|
||||
render(<InstalledApp id="installed-app-1" />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAppInfo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
app_id: 'installed-app-1',
|
||||
site: expect.objectContaining({
|
||||
title: 'Integration Test App',
|
||||
icon: '🧪',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(mockUpdateAppParams).toHaveBeenCalledWith(mockAppParams)
|
||||
expect(mockUpdateWebAppMeta).toHaveBeenCalledWith({ tool_icons: {} })
|
||||
expect(mockUpdateWebAppAccessMode).toHaveBeenCalledWith(AccessMode.PUBLIC)
|
||||
expect(mockUpdateUserCanAccessApp).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
195
web/__tests__/explore/sidebar-lifecycle-flow.test.tsx
Normal file
195
web/__tests__/explore/sidebar-lifecycle-flow.test.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Integration test: Sidebar Lifecycle Flow
|
||||
*
|
||||
* Tests the sidebar interactions for installed apps lifecycle:
|
||||
* navigation, pin/unpin ordering, delete confirmation, and
|
||||
* fold/unfold behavior.
|
||||
*/
|
||||
import type { InstalledApp } from '@/models/explore'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import SideBar from '@/app/components/explore/sidebar'
|
||||
import ExploreContext from '@/context/explore-context'
|
||||
import { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
let mockMediaType: string = MediaType.pc
|
||||
const mockSegments = ['apps']
|
||||
const mockPush = vi.fn()
|
||||
const mockRefetch = vi.fn()
|
||||
const mockUninstall = vi.fn()
|
||||
const mockUpdatePinStatus = vi.fn()
|
||||
let mockInstalledApps: InstalledApp[] = []
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useSelectedLayoutSegments: () => mockSegments,
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
default: () => mockMediaType,
|
||||
MediaType: {
|
||||
mobile: 'mobile',
|
||||
tablet: 'tablet',
|
||||
pc: 'pc',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-explore', () => ({
|
||||
useGetInstalledApps: () => ({
|
||||
isFetching: false,
|
||||
data: { installed_apps: mockInstalledApps },
|
||||
refetch: mockRefetch,
|
||||
}),
|
||||
useUninstallApp: () => ({
|
||||
mutateAsync: mockUninstall,
|
||||
}),
|
||||
useUpdateAppPinStatus: () => ({
|
||||
mutateAsync: mockUpdatePinStatus,
|
||||
}),
|
||||
}))
|
||||
|
||||
const createInstalledApp = (overrides: Partial<InstalledApp> = {}): InstalledApp => ({
|
||||
id: overrides.id ?? 'app-1',
|
||||
uninstallable: overrides.uninstallable ?? false,
|
||||
is_pinned: overrides.is_pinned ?? false,
|
||||
app: {
|
||||
id: overrides.app?.id ?? 'app-basic-id',
|
||||
mode: overrides.app?.mode ?? AppModeEnum.CHAT,
|
||||
icon_type: overrides.app?.icon_type ?? 'emoji',
|
||||
icon: overrides.app?.icon ?? '🤖',
|
||||
icon_background: overrides.app?.icon_background ?? '#fff',
|
||||
icon_url: overrides.app?.icon_url ?? '',
|
||||
name: overrides.app?.name ?? 'App One',
|
||||
description: overrides.app?.description ?? 'desc',
|
||||
use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false,
|
||||
},
|
||||
})
|
||||
|
||||
const renderSidebar = (installedApps: InstalledApp[] = []) => {
|
||||
return render(
|
||||
<ExploreContext.Provider
|
||||
value={{
|
||||
controlUpdateInstalledApps: 0,
|
||||
setControlUpdateInstalledApps: vi.fn(),
|
||||
hasEditPermission: true,
|
||||
installedApps,
|
||||
setInstalledApps: vi.fn(),
|
||||
isFetchingInstalledApps: false,
|
||||
setIsFetchingInstalledApps: vi.fn(),
|
||||
} as Record<string, unknown> as ReturnType<typeof ExploreContext.Provider extends React.FC<{ value: infer V }> ? () => V : never>}
|
||||
>
|
||||
<SideBar controlUpdateInstalledApps={0} />
|
||||
</ExploreContext.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('Sidebar Lifecycle Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockMediaType = MediaType.pc
|
||||
mockInstalledApps = []
|
||||
vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
|
||||
})
|
||||
|
||||
describe('Pin / Unpin / Delete Flow', () => {
|
||||
it('should complete pin → unpin cycle for an app', async () => {
|
||||
// Step 1: Start with an unpinned app
|
||||
const app = createInstalledApp({ is_pinned: false })
|
||||
mockInstalledApps = [app]
|
||||
mockUpdatePinStatus.mockResolvedValue(undefined)
|
||||
|
||||
renderSidebar(mockInstalledApps)
|
||||
|
||||
// Step 2: Pin the app
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.pin'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-1', isPinned: true })
|
||||
expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'success',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('should complete the delete flow with confirmation', async () => {
|
||||
const app = createInstalledApp()
|
||||
mockInstalledApps = [app]
|
||||
mockUninstall.mockResolvedValue(undefined)
|
||||
|
||||
renderSidebar(mockInstalledApps)
|
||||
|
||||
// Step 1: Open operation menu and click delete
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
|
||||
|
||||
// Step 2: Confirm dialog appears
|
||||
expect(await screen.findByText('explore.sidebar.delete.title')).toBeInTheDocument()
|
||||
|
||||
// Step 3: Confirm deletion
|
||||
fireEvent.click(screen.getByText('common.operation.confirm'))
|
||||
|
||||
// Step 4: Uninstall API called and success toast shown
|
||||
await waitFor(() => {
|
||||
expect(mockUninstall).toHaveBeenCalledWith('app-1')
|
||||
expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'success',
|
||||
message: 'common.api.remove',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('should cancel deletion when user clicks cancel', async () => {
|
||||
const app = createInstalledApp()
|
||||
mockInstalledApps = [app]
|
||||
|
||||
renderSidebar(mockInstalledApps)
|
||||
|
||||
// Open delete flow
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
|
||||
|
||||
// Cancel the deletion
|
||||
fireEvent.click(await screen.findByText('common.operation.cancel'))
|
||||
|
||||
// Uninstall should not be called
|
||||
expect(mockUninstall).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Multi-App Ordering', () => {
|
||||
it('should display pinned apps before unpinned apps with divider', () => {
|
||||
mockInstalledApps = [
|
||||
createInstalledApp({ id: 'pinned-1', is_pinned: true, app: { ...createInstalledApp().app, name: 'Pinned App' } }),
|
||||
createInstalledApp({ id: 'unpinned-1', is_pinned: false, app: { ...createInstalledApp().app, name: 'Regular App' } }),
|
||||
]
|
||||
|
||||
renderSidebar(mockInstalledApps)
|
||||
|
||||
const pinnedApp = screen.getByText('Pinned App')
|
||||
const regularApp = screen.getByText('Regular App')
|
||||
|
||||
expect(pinnedApp).toBeInTheDocument()
|
||||
expect(regularApp).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Empty State', () => {
|
||||
it('should show NoApps component when no apps are installed on desktop', () => {
|
||||
mockMediaType = MediaType.pc
|
||||
renderSidebar([])
|
||||
|
||||
expect(screen.getByText('explore.sidebar.noApps.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide NoApps on mobile', () => {
|
||||
mockMediaType = MediaType.mobile
|
||||
renderSidebar([])
|
||||
|
||||
expect(screen.queryByText('explore.sidebar.noApps.title')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -96,8 +96,10 @@ const buildAppContext = (overrides: Partial<AppContextValue> = {}): AppContextVa
|
||||
isCurrentWorkspaceEditor: false,
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
mutateUserProfile: vi.fn(),
|
||||
mutateCurrentWorkspace: vi.fn(),
|
||||
langGeniusVersionInfo,
|
||||
isLoadingCurrentWorkspace: false,
|
||||
isValidatingCurrentWorkspace: false,
|
||||
}
|
||||
const useSelector: AppContextValue['useSelector'] = selector => selector({ ...base, useSelector })
|
||||
return {
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useAppContext } from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { updateCurrentWorkspace } from '@/service/common'
|
||||
import { useInvalidateCurrentWorkspace } from '@/service/use-common'
|
||||
import CustomWebAppBrand from './index'
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
@@ -16,9 +15,6 @@ vi.mock('@/app/components/base/toast', () => ({
|
||||
vi.mock('@/service/common', () => ({
|
||||
updateCurrentWorkspace: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useInvalidateCurrentWorkspace: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: vi.fn(),
|
||||
}))
|
||||
@@ -41,8 +37,6 @@ const mockUseProviderContext = vi.mocked(useProviderContext)
|
||||
const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore)
|
||||
const mockImageUpload = vi.mocked(imageUpload)
|
||||
const mockGetImageUploadErrorMessage = vi.mocked(getImageUploadErrorMessage)
|
||||
const mockInvalidateCurrentWorkspace = vi.fn()
|
||||
vi.mocked(useInvalidateCurrentWorkspace).mockReturnValue(mockInvalidateCurrentWorkspace)
|
||||
|
||||
const defaultPlanUsage = {
|
||||
buildApps: 0,
|
||||
@@ -68,6 +62,7 @@ describe('CustomWebAppBrand', () => {
|
||||
remove_webapp_brand: false,
|
||||
},
|
||||
},
|
||||
mutateCurrentWorkspace: vi.fn(),
|
||||
isCurrentWorkspaceManager: true,
|
||||
} as any)
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
@@ -97,6 +92,7 @@ describe('CustomWebAppBrand', () => {
|
||||
remove_webapp_brand: false,
|
||||
},
|
||||
},
|
||||
mutateCurrentWorkspace: vi.fn(),
|
||||
isCurrentWorkspaceManager: false,
|
||||
} as any)
|
||||
|
||||
@@ -105,7 +101,8 @@ describe('CustomWebAppBrand', () => {
|
||||
expect(fileInput).toBeDisabled()
|
||||
})
|
||||
|
||||
it('toggles remove brand switch and calls the backend + invalidate', async () => {
|
||||
it('toggles remove brand switch and calls the backend + mutate', async () => {
|
||||
const mutateMock = vi.fn()
|
||||
mockUseAppContext.mockReturnValue({
|
||||
currentWorkspace: {
|
||||
custom_config: {
|
||||
@@ -113,6 +110,7 @@ describe('CustomWebAppBrand', () => {
|
||||
remove_webapp_brand: false,
|
||||
},
|
||||
},
|
||||
mutateCurrentWorkspace: mutateMock,
|
||||
isCurrentWorkspaceManager: true,
|
||||
} as any)
|
||||
|
||||
@@ -124,7 +122,7 @@ describe('CustomWebAppBrand', () => {
|
||||
url: '/workspaces/custom-config',
|
||||
body: { remove_webapp_brand: true },
|
||||
}))
|
||||
await waitFor(() => expect(mockInvalidateCurrentWorkspace).toHaveBeenCalled())
|
||||
await waitFor(() => expect(mutateMock).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('shows cancel/apply buttons after successful upload and cancels properly', async () => {
|
||||
|
||||
@@ -24,7 +24,6 @@ import { useProviderContext } from '@/context/provider-context'
|
||||
import {
|
||||
updateCurrentWorkspace,
|
||||
} from '@/service/common'
|
||||
import { useInvalidateCurrentWorkspace } from '@/service/use-common'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
const ALLOW_FILE_EXTENSIONS = ['svg', 'png']
|
||||
@@ -35,9 +34,9 @@ const CustomWebAppBrand = () => {
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const {
|
||||
currentWorkspace,
|
||||
mutateCurrentWorkspace,
|
||||
isCurrentWorkspaceManager,
|
||||
} = useAppContext()
|
||||
const invalidateCurrentWorkspace = useInvalidateCurrentWorkspace()
|
||||
const [fileId, setFileId] = useState('')
|
||||
const [imgKey, setImgKey] = useState(() => Date.now())
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
@@ -84,7 +83,7 @@ const CustomWebAppBrand = () => {
|
||||
replace_webapp_logo: fileId,
|
||||
},
|
||||
})
|
||||
invalidateCurrentWorkspace()
|
||||
mutateCurrentWorkspace()
|
||||
setFileId('')
|
||||
setImgKey(Date.now())
|
||||
}
|
||||
@@ -97,7 +96,7 @@ const CustomWebAppBrand = () => {
|
||||
replace_webapp_logo: '',
|
||||
},
|
||||
})
|
||||
invalidateCurrentWorkspace()
|
||||
mutateCurrentWorkspace()
|
||||
}
|
||||
|
||||
const handleSwitch = async (checked: boolean) => {
|
||||
@@ -107,7 +106,7 @@ const CustomWebAppBrand = () => {
|
||||
remove_webapp_brand: checked,
|
||||
},
|
||||
})
|
||||
invalidateCurrentWorkspace()
|
||||
mutateCurrentWorkspace()
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
@@ -117,7 +116,7 @@ const CustomWebAppBrand = () => {
|
||||
|
||||
return (
|
||||
<div className="py-4">
|
||||
<div className="mb-2 flex items-center justify-between rounded-xl bg-background-section-burn p-4 text-text-primary system-md-medium">
|
||||
<div className="system-md-medium mb-2 flex items-center justify-between rounded-xl bg-background-section-burn p-4 text-text-primary">
|
||||
{t('webapp.removeBrand', { ns: 'custom' })}
|
||||
<Switch
|
||||
size="l"
|
||||
@@ -128,8 +127,8 @@ const CustomWebAppBrand = () => {
|
||||
</div>
|
||||
<div className={cn('flex h-14 items-center justify-between rounded-xl bg-background-section-burn px-4', webappBrandRemoved && 'opacity-30')}>
|
||||
<div>
|
||||
<div className="text-text-primary system-md-medium">{t('webapp.changeLogo', { ns: 'custom' })}</div>
|
||||
<div className="text-text-tertiary system-xs-regular">{t('webapp.changeLogoTip', { ns: 'custom' })}</div>
|
||||
<div className="system-md-medium text-text-primary">{t('webapp.changeLogo', { ns: 'custom' })}</div>
|
||||
<div className="system-xs-regular text-text-tertiary">{t('webapp.changeLogoTip', { ns: 'custom' })}</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{(!uploadDisabled && webappLogo && !webappBrandRemoved) && (
|
||||
@@ -205,7 +204,7 @@ const CustomWebAppBrand = () => {
|
||||
<div className="mt-2 text-xs text-[#D92D20]">{t('uploadedFail', { ns: 'custom' })}</div>
|
||||
)}
|
||||
<div className="mb-2 mt-5 flex items-center gap-2">
|
||||
<div className="shrink-0 text-text-tertiary system-xs-medium-uppercase">{t('overview.appInfo.preview', { ns: 'appOverview' })}</div>
|
||||
<div className="system-xs-medium-uppercase shrink-0 text-text-tertiary">{t('overview.appInfo.preview', { ns: 'appOverview' })}</div>
|
||||
<Divider bgStyle="gradient" className="grow" />
|
||||
</div>
|
||||
<div className="relative mb-2 flex items-center gap-3">
|
||||
@@ -216,7 +215,7 @@ const CustomWebAppBrand = () => {
|
||||
<div className={cn('inline-flex h-8 w-8 items-center justify-center rounded-lg border border-divider-regular', 'bg-components-icon-bg-blue-light-solid')}>
|
||||
<BubbleTextMod className="h-4 w-4 text-components-avatar-shape-fill-stop-100" />
|
||||
</div>
|
||||
<div className="grow text-text-secondary system-md-semibold">Chatflow App</div>
|
||||
<div className="system-md-semibold grow text-text-secondary">Chatflow App</div>
|
||||
<div className="p-1.5">
|
||||
<RiLayoutLeft2Line className="h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
@@ -247,7 +246,7 @@ const CustomWebAppBrand = () => {
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!webappBrandRemoved && (
|
||||
<>
|
||||
<div className="text-text-tertiary system-2xs-medium-uppercase">POWERED BY</div>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">POWERED BY</div>
|
||||
{
|
||||
systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
|
||||
? <img src={systemFeatures.branding.workspace_logo} alt="logo" className="block h-5 w-auto" />
|
||||
@@ -263,12 +262,12 @@ const CustomWebAppBrand = () => {
|
||||
<div className="flex w-[138px] grow flex-col justify-between p-2 pr-0">
|
||||
<div className="flex grow flex-col justify-between rounded-l-2xl border-[0.5px] border-r-0 border-components-panel-border-subtle bg-chatbot-bg pb-4 pl-[22px] pt-16">
|
||||
<div className="w-[720px] rounded-2xl border border-divider-subtle bg-chat-bubble-bg px-4 py-3">
|
||||
<div className="mb-1 text-text-primary body-md-regular">Hello! How can I assist you today?</div>
|
||||
<div className="body-md-regular mb-1 text-text-primary">Hello! How can I assist you today?</div>
|
||||
<Button size="small">
|
||||
<div className="h-2 w-[144px] rounded-sm bg-text-quaternary opacity-20"></div>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex h-[52px] w-[578px] items-center rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur pl-3.5 text-text-placeholder shadow-md backdrop-blur-sm body-lg-regular">Talk to Dify</div>
|
||||
<div className="body-lg-regular flex h-[52px] w-[578px] items-center rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur pl-3.5 text-text-placeholder shadow-md backdrop-blur-sm">Talk to Dify</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -279,14 +278,14 @@ const CustomWebAppBrand = () => {
|
||||
<div className={cn('inline-flex h-8 w-8 items-center justify-center rounded-lg border border-divider-regular', 'bg-components-icon-bg-indigo-solid')}>
|
||||
<RiExchange2Fill className="h-4 w-4 text-components-avatar-shape-fill-stop-100" />
|
||||
</div>
|
||||
<div className="grow text-text-secondary system-md-semibold">Workflow App</div>
|
||||
<div className="system-md-semibold grow text-text-secondary">Workflow App</div>
|
||||
<div className="p-1.5">
|
||||
<RiLayoutLeft2Line className="h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 shrink-0 items-center border-b-2 border-components-tab-active text-text-primary system-md-semibold-uppercase">RUN ONCE</div>
|
||||
<div className="flex h-10 grow items-center border-b-2 border-transparent text-text-tertiary system-md-semibold-uppercase">RUN BATCH</div>
|
||||
<div className="system-md-semibold-uppercase flex h-10 shrink-0 items-center border-b-2 border-components-tab-active text-text-primary">RUN ONCE</div>
|
||||
<div className="system-md-semibold-uppercase flex h-10 grow items-center border-b-2 border-transparent text-text-tertiary">RUN BATCH</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grow bg-components-panel-bg">
|
||||
@@ -294,7 +293,7 @@ const CustomWebAppBrand = () => {
|
||||
<div className="mb-1 py-2">
|
||||
<div className="h-2 w-20 rounded-sm bg-text-quaternary opacity-20"></div>
|
||||
</div>
|
||||
<div className="h-16 w-full rounded-lg bg-components-input-bg-normal"></div>
|
||||
<div className="h-16 w-full rounded-lg bg-components-input-bg-normal "></div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<Button size="small">
|
||||
@@ -309,7 +308,7 @@ const CustomWebAppBrand = () => {
|
||||
<div className="flex h-12 shrink-0 items-center gap-1.5 bg-components-panel-bg p-4 pt-3">
|
||||
{!webappBrandRemoved && (
|
||||
<>
|
||||
<div className="text-text-tertiary system-2xs-medium-uppercase">POWERED BY</div>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">POWERED BY</div>
|
||||
{
|
||||
systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
|
||||
? <img src={systemFeatures.branding.workspace_logo} alt="logo" className="block h-5 w-auto" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AppCategory } from '@/models/explore'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Category from './category'
|
||||
import Category from '../category'
|
||||
|
||||
describe('Category', () => {
|
||||
const allCategoriesEn = 'Recommended'
|
||||
@@ -19,59 +19,44 @@ describe('Category', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Rendering: basic categories and all-categories button.
|
||||
describe('Rendering', () => {
|
||||
it('should render all categories item and translated categories', () => {
|
||||
// Arrange
|
||||
renderComponent()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('explore.apps.allCategories')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.category.Writing')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render allCategoriesEn again inside the category list', () => {
|
||||
// Arrange
|
||||
renderComponent()
|
||||
|
||||
// Assert
|
||||
const recommendedItems = screen.getAllByText('explore.apps.allCategories')
|
||||
expect(recommendedItems).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Props: clicking items triggers onChange.
|
||||
describe('Props', () => {
|
||||
it('should call onChange with category value when category item is clicked', () => {
|
||||
// Arrange
|
||||
const { props } = renderComponent()
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('explore.category.Writing'))
|
||||
|
||||
// Assert
|
||||
expect(props.onChange).toHaveBeenCalledWith('Writing')
|
||||
})
|
||||
|
||||
it('should call onChange with allCategoriesEn when all categories is clicked', () => {
|
||||
// Arrange
|
||||
const { props } = renderComponent({ value: 'Writing' })
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('explore.apps.allCategories'))
|
||||
|
||||
// Assert
|
||||
expect(props.onChange).toHaveBeenCalledWith(allCategoriesEn)
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases: handle values not in the list.
|
||||
describe('Edge Cases', () => {
|
||||
it('should treat unknown value as all categories selection', () => {
|
||||
// Arrange
|
||||
renderComponent({ value: 'Unknown' })
|
||||
|
||||
// Assert
|
||||
const allCategoriesItem = screen.getByText('explore.apps.allCategories')
|
||||
expect(allCategoriesItem.className).toContain('bg-components-main-nav-nav-button-bg-active')
|
||||
})
|
||||
@@ -6,7 +6,7 @@ import ExploreContext from '@/context/explore-context'
|
||||
import { MediaType } from '@/hooks/use-breakpoints'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useMembers } from '@/service/use-common'
|
||||
import Explore from './index'
|
||||
import Explore from '../index'
|
||||
|
||||
const mockReplace = vi.fn()
|
||||
const mockPush = vi.fn()
|
||||
@@ -65,10 +65,8 @@ describe('Explore', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering: provides ExploreContext and children.
|
||||
describe('Rendering', () => {
|
||||
it('should render children and provide edit permission from members role', async () => {
|
||||
// Arrange
|
||||
; (useAppContext as Mock).mockReturnValue({
|
||||
userProfile: { id: 'user-1' },
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
@@ -79,57 +77,48 @@ describe('Explore', () => {
|
||||
},
|
||||
})
|
||||
|
||||
// Act
|
||||
render((
|
||||
<Explore>
|
||||
<ContextReader />
|
||||
</Explore>
|
||||
))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('edit-yes')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Effects: set document title and redirect dataset operators.
|
||||
describe('Effects', () => {
|
||||
it('should set document title on render', () => {
|
||||
// Arrange
|
||||
; (useAppContext as Mock).mockReturnValue({
|
||||
userProfile: { id: 'user-1' },
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
});
|
||||
(useMembers as Mock).mockReturnValue({ data: { accounts: [] } })
|
||||
|
||||
// Act
|
||||
render((
|
||||
<Explore>
|
||||
<div>child</div>
|
||||
</Explore>
|
||||
))
|
||||
|
||||
// Assert
|
||||
expect(useDocumentTitle).toHaveBeenCalledWith('common.menus.explore')
|
||||
})
|
||||
|
||||
it('should redirect dataset operators to /datasets', async () => {
|
||||
// Arrange
|
||||
; (useAppContext as Mock).mockReturnValue({
|
||||
userProfile: { id: 'user-1' },
|
||||
isCurrentWorkspaceDatasetOperator: true,
|
||||
});
|
||||
(useMembers as Mock).mockReturnValue({ data: { accounts: [] } })
|
||||
|
||||
// Act
|
||||
render((
|
||||
<Explore>
|
||||
<div>child</div>
|
||||
</Explore>
|
||||
))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenCalledWith('/datasets')
|
||||
})
|
||||
140
web/app/components/explore/app-card/__tests__/index.spec.tsx
Normal file
140
web/app/components/explore/app-card/__tests__/index.spec.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { AppCardProps } from '../index'
|
||||
import type { App } from '@/models/explore'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import AppCard from '../index'
|
||||
|
||||
vi.mock('../../../app/type-selector', () => ({
|
||||
AppTypeIcon: ({ type }: { type: string }) => <div data-testid="app-type-icon">{type}</div>,
|
||||
}))
|
||||
|
||||
const createApp = (overrides?: Partial<App>): App => ({
|
||||
can_trial: true,
|
||||
app_id: 'app-id',
|
||||
description: 'App description',
|
||||
copyright: '2024',
|
||||
privacy_policy: null,
|
||||
custom_disclaimer: null,
|
||||
category: 'Assistant',
|
||||
position: 1,
|
||||
is_listed: true,
|
||||
install_count: 0,
|
||||
installed: false,
|
||||
editable: true,
|
||||
is_agent: false,
|
||||
...overrides,
|
||||
app: {
|
||||
id: 'id-1',
|
||||
mode: AppModeEnum.CHAT,
|
||||
icon_type: null,
|
||||
icon: '🤖',
|
||||
icon_background: '#fff',
|
||||
icon_url: '',
|
||||
name: 'Sample App',
|
||||
description: 'App description',
|
||||
use_icon_as_answer_icon: false,
|
||||
...overrides?.app,
|
||||
},
|
||||
})
|
||||
|
||||
describe('AppCard', () => {
|
||||
const onCreate = vi.fn()
|
||||
|
||||
const renderComponent = (props?: Partial<AppCardProps>) => {
|
||||
const mergedProps: AppCardProps = {
|
||||
app: createApp(),
|
||||
canCreate: false,
|
||||
onCreate,
|
||||
isExplore: false,
|
||||
...props,
|
||||
}
|
||||
return render(<AppCard {...mergedProps} />)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render app name and description', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByText('Sample App')).toBeInTheDocument()
|
||||
expect(screen.getByText('App description')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[AppModeEnum.CHAT, 'APP.TYPES.CHATBOT'],
|
||||
[AppModeEnum.ADVANCED_CHAT, 'APP.TYPES.ADVANCED'],
|
||||
[AppModeEnum.AGENT_CHAT, 'APP.TYPES.AGENT'],
|
||||
[AppModeEnum.WORKFLOW, 'APP.TYPES.WORKFLOW'],
|
||||
[AppModeEnum.COMPLETION, 'APP.TYPES.COMPLETION'],
|
||||
])('should render correct mode label for %s mode', (mode, label) => {
|
||||
renderComponent({ app: createApp({ app: { ...createApp().app, mode } }) })
|
||||
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('app-type-icon')).toHaveTextContent(mode)
|
||||
})
|
||||
|
||||
it('should render description in a truncatable container', () => {
|
||||
renderComponent({ app: createApp({ description: 'Very long description text' }) })
|
||||
|
||||
const descWrapper = screen.getByText('Very long description text')
|
||||
expect(descWrapper).toHaveClass('line-clamp-4')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should show create button in explore mode and trigger action', () => {
|
||||
renderComponent({
|
||||
app: createApp({ app: { ...createApp().app, mode: AppModeEnum.WORKFLOW } }),
|
||||
canCreate: true,
|
||||
isExplore: true,
|
||||
})
|
||||
|
||||
const button = screen.getByText('explore.appCard.addToWorkspace')
|
||||
expect(button).toBeInTheDocument()
|
||||
fireEvent.click(button)
|
||||
expect(onCreate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render try button in explore mode', () => {
|
||||
renderComponent({ canCreate: true, isExplore: true })
|
||||
|
||||
expect(screen.getByText('explore.appCard.try')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should hide action buttons when not in explore mode', () => {
|
||||
renderComponent({ canCreate: true, isExplore: false })
|
||||
|
||||
expect(screen.queryByText('explore.appCard.addToWorkspace')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('explore.appCard.try')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide create button when canCreate is false', () => {
|
||||
renderComponent({ canCreate: false, isExplore: true })
|
||||
|
||||
expect(screen.queryByText('explore.appCard.addToWorkspace')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should truncate long app name with title attribute', () => {
|
||||
const longName = 'A Very Long Application Name That Should Be Truncated'
|
||||
renderComponent({ app: createApp({ app: { ...createApp().app, name: longName } }) })
|
||||
|
||||
const nameElement = screen.getByText(longName)
|
||||
expect(nameElement).toHaveAttribute('title', longName)
|
||||
expect(nameElement).toHaveClass('truncate')
|
||||
})
|
||||
|
||||
it('should render with empty description', () => {
|
||||
renderComponent({ app: createApp({ description: '' }) })
|
||||
|
||||
expect(screen.getByText('Sample App')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,87 +0,0 @@
|
||||
import type { AppCardProps } from './index'
|
||||
import type { App } from '@/models/explore'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import AppCard from './index'
|
||||
|
||||
vi.mock('../../app/type-selector', () => ({
|
||||
AppTypeIcon: ({ type }: any) => <div data-testid="app-type-icon">{type}</div>,
|
||||
}))
|
||||
|
||||
const createApp = (overrides?: Partial<App>): App => ({
|
||||
can_trial: true,
|
||||
app_id: 'app-id',
|
||||
description: 'App description',
|
||||
copyright: '2024',
|
||||
privacy_policy: null,
|
||||
custom_disclaimer: null,
|
||||
category: 'Assistant',
|
||||
position: 1,
|
||||
is_listed: true,
|
||||
install_count: 0,
|
||||
installed: false,
|
||||
editable: true,
|
||||
is_agent: false,
|
||||
...overrides,
|
||||
app: {
|
||||
id: 'id-1',
|
||||
mode: AppModeEnum.CHAT,
|
||||
icon_type: null,
|
||||
icon: '🤖',
|
||||
icon_background: '#fff',
|
||||
icon_url: '',
|
||||
name: 'Sample App',
|
||||
description: 'App description',
|
||||
use_icon_as_answer_icon: false,
|
||||
...overrides?.app,
|
||||
},
|
||||
})
|
||||
|
||||
describe('AppCard', () => {
|
||||
const onCreate = vi.fn()
|
||||
|
||||
const renderComponent = (props?: Partial<AppCardProps>) => {
|
||||
const mergedProps: AppCardProps = {
|
||||
app: createApp(),
|
||||
canCreate: false,
|
||||
onCreate,
|
||||
isExplore: false,
|
||||
...props,
|
||||
}
|
||||
return render(<AppCard {...mergedProps} />)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render app info with correct mode label when mode is CHAT', () => {
|
||||
renderComponent({ app: createApp({ app: { ...createApp().app, mode: AppModeEnum.CHAT } }) })
|
||||
|
||||
expect(screen.getByText('Sample App')).toBeInTheDocument()
|
||||
expect(screen.getByText('App description')).toBeInTheDocument()
|
||||
expect(screen.getByText('APP.TYPES.CHATBOT')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('app-type-icon')).toHaveTextContent(AppModeEnum.CHAT)
|
||||
})
|
||||
|
||||
it('should show create button in explore mode and trigger action', () => {
|
||||
renderComponent({
|
||||
app: createApp({ app: { ...createApp().app, mode: AppModeEnum.WORKFLOW } }),
|
||||
canCreate: true,
|
||||
isExplore: true,
|
||||
})
|
||||
|
||||
const button = screen.getByText('explore.appCard.addToWorkspace')
|
||||
expect(button).toBeInTheDocument()
|
||||
fireEvent.click(button)
|
||||
expect(onCreate).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByText('APP.TYPES.WORKFLOW')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide create button when not allowed', () => {
|
||||
renderComponent({ canCreate: false, isExplore: true })
|
||||
|
||||
expect(screen.queryByText('explore.appCard.addToWorkspace')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import ExploreContext from '@/context/explore-context'
|
||||
import { fetchAppDetail } from '@/service/explore'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import AppList from './index'
|
||||
import AppList from '../index'
|
||||
|
||||
const allCategoriesEn = 'explore.apps.allCategories:{"lng":"en"}'
|
||||
let mockTabValue = allCategoriesEn
|
||||
@@ -150,70 +150,55 @@ describe('AppList', () => {
|
||||
mockIsError = false
|
||||
})
|
||||
|
||||
// Rendering: show loading when categories are not ready.
|
||||
describe('Rendering', () => {
|
||||
it('should render loading when the query is loading', () => {
|
||||
// Arrange
|
||||
mockExploreData = undefined
|
||||
mockIsLoading = true
|
||||
|
||||
// Act
|
||||
renderWithContext()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app cards when data is available', () => {
|
||||
// Arrange
|
||||
mockExploreData = {
|
||||
categories: ['Writing', 'Translate'],
|
||||
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })],
|
||||
}
|
||||
|
||||
// Act
|
||||
renderWithContext()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
||||
expect(screen.getByText('Beta')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Props: category selection filters the list.
|
||||
describe('Props', () => {
|
||||
it('should filter apps by selected category', () => {
|
||||
// Arrange
|
||||
mockTabValue = 'Writing'
|
||||
mockExploreData = {
|
||||
categories: ['Writing', 'Translate'],
|
||||
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })],
|
||||
}
|
||||
|
||||
// Act
|
||||
renderWithContext()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Beta')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User interactions: search and create flow.
|
||||
describe('User Interactions', () => {
|
||||
it('should filter apps by search keywords', async () => {
|
||||
// Arrange
|
||||
mockExploreData = {
|
||||
categories: ['Writing'],
|
||||
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })],
|
||||
}
|
||||
renderWithContext()
|
||||
|
||||
// Act
|
||||
const input = screen.getByPlaceholderText('common.operation.search')
|
||||
fireEvent.change(input, { target: { value: 'gam' } })
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Alpha')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Gamma')).toBeInTheDocument()
|
||||
@@ -221,7 +206,6 @@ describe('AppList', () => {
|
||||
})
|
||||
|
||||
it('should handle create flow and confirm DSL when pending', async () => {
|
||||
// Arrange
|
||||
const onSuccess = vi.fn()
|
||||
mockExploreData = {
|
||||
categories: ['Writing'],
|
||||
@@ -235,12 +219,10 @@ describe('AppList', () => {
|
||||
options.onSuccess?.()
|
||||
})
|
||||
|
||||
// Act
|
||||
renderWithContext(true, onSuccess)
|
||||
fireEvent.click(screen.getByText('explore.appCard.addToWorkspace'))
|
||||
fireEvent.click(await screen.findByTestId('confirm-create'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(fetchAppDetail).toHaveBeenCalledWith('app-basic-id')
|
||||
})
|
||||
@@ -255,17 +237,14 @@ describe('AppList', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases: handle clearing search keywords.
|
||||
describe('Edge Cases', () => {
|
||||
it('should reset search results when clear icon is clicked', async () => {
|
||||
// Arrange
|
||||
mockExploreData = {
|
||||
categories: ['Writing'],
|
||||
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })],
|
||||
}
|
||||
renderWithContext()
|
||||
|
||||
// Act
|
||||
const input = screen.getByPlaceholderText('common.operation.search')
|
||||
fireEvent.change(input, { target: { value: 'gam' } })
|
||||
await waitFor(() => {
|
||||
@@ -274,7 +253,6 @@ describe('AppList', () => {
|
||||
|
||||
fireEvent.click(screen.getByTestId('input-clear'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
||||
expect(screen.getByText('Gamma')).toBeInTheDocument()
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Banner } from '@/models/app'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { BannerItem } from './banner-item'
|
||||
import { BannerItem } from '../banner-item'
|
||||
|
||||
const mockScrollTo = vi.fn()
|
||||
const mockSlideNodes = vi.fn()
|
||||
@@ -16,17 +16,6 @@ vi.mock('@/app/components/base/carousel', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'banner.viewMore': 'View More',
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
const createMockBanner = (overrides: Partial<Banner> = {}): Banner => ({
|
||||
id: 'banner-1',
|
||||
status: 'enabled',
|
||||
@@ -40,14 +29,11 @@ const createMockBanner = (overrides: Partial<Banner> = {}): Banner => ({
|
||||
...overrides,
|
||||
} as Banner)
|
||||
|
||||
// Mock ResizeObserver methods declared at module level and initialized
|
||||
const mockResizeObserverObserve = vi.fn()
|
||||
const mockResizeObserverDisconnect = vi.fn()
|
||||
|
||||
// Create mock class outside of describe block for proper hoisting
|
||||
class MockResizeObserver {
|
||||
constructor(_callback: ResizeObserverCallback) {
|
||||
// Store callback if needed
|
||||
}
|
||||
|
||||
observe(...args: Parameters<ResizeObserver['observe']>) {
|
||||
@@ -59,7 +45,6 @@ class MockResizeObserver {
|
||||
}
|
||||
|
||||
unobserve() {
|
||||
// No-op
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +57,6 @@ describe('BannerItem', () => {
|
||||
|
||||
vi.stubGlobal('ResizeObserver', MockResizeObserver)
|
||||
|
||||
// Mock window.innerWidth for responsive tests
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
@@ -147,7 +131,7 @@ describe('BannerItem', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('View More')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.banner.viewMore')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -257,7 +241,6 @@ describe('BannerItem', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Component should render without issues
|
||||
expect(screen.getByText('Test Banner Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -271,7 +254,6 @@ describe('BannerItem', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Component should render with isPaused
|
||||
expect(screen.getByText('Test Banner Title')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -320,7 +302,6 @@ describe('BannerItem', () => {
|
||||
})
|
||||
|
||||
it('sets maxWidth when window width is below breakpoint', () => {
|
||||
// Set window width below RESPONSIVE_BREAKPOINT (1200)
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
@@ -335,12 +316,10 @@ describe('BannerItem', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Component should render and apply responsive styles
|
||||
expect(screen.getByText('Test Banner Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies responsive styles when below breakpoint', () => {
|
||||
// Set window width below RESPONSIVE_BREAKPOINT (1200)
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
@@ -355,8 +334,7 @@ describe('BannerItem', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// The component should render even with responsive mode
|
||||
expect(screen.getByText('View More')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.banner.viewMore')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -432,8 +410,6 @@ describe('BannerItem', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// With selectedIndex=0 and 3 slides, nextIndex should be 1
|
||||
// The second indicator button should show the "next slide" state
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons).toHaveLength(3)
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import type { Banner as BannerType } from '@/models/app'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Banner from './banner'
|
||||
import Banner from '../banner'
|
||||
|
||||
const mockUseGetBanners = vi.fn()
|
||||
|
||||
@@ -53,7 +53,7 @@ vi.mock('@/app/components/base/carousel', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('./banner-item', () => ({
|
||||
vi.mock('../banner-item', () => ({
|
||||
BannerItem: ({ banner, autoplayDelay, isPaused }: {
|
||||
banner: BannerType
|
||||
autoplayDelay: number
|
||||
@@ -105,7 +105,6 @@ describe('Banner', () => {
|
||||
|
||||
render(<Banner />)
|
||||
|
||||
// Loading component renders a spinner
|
||||
const loadingWrapper = document.querySelector('[style*="min-height"]')
|
||||
expect(loadingWrapper).toBeInTheDocument()
|
||||
})
|
||||
@@ -266,7 +265,6 @@ describe('Banner', () => {
|
||||
|
||||
const carousel = screen.getByTestId('carousel')
|
||||
|
||||
// Enter and then leave
|
||||
fireEvent.mouseEnter(carousel)
|
||||
fireEvent.mouseLeave(carousel)
|
||||
|
||||
@@ -285,7 +283,6 @@ describe('Banner', () => {
|
||||
|
||||
render(<Banner />)
|
||||
|
||||
// Trigger resize event
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
})
|
||||
@@ -303,12 +300,10 @@ describe('Banner', () => {
|
||||
|
||||
render(<Banner />)
|
||||
|
||||
// Trigger resize event
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
})
|
||||
|
||||
// Wait for debounce delay (50ms)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(50)
|
||||
})
|
||||
@@ -326,31 +321,25 @@ describe('Banner', () => {
|
||||
|
||||
render(<Banner />)
|
||||
|
||||
// Trigger first resize event
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
})
|
||||
|
||||
// Wait partial time
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(30)
|
||||
})
|
||||
|
||||
// Trigger second resize event
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
})
|
||||
|
||||
// Wait another 30ms (total 60ms from second resize but only 30ms after)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(30)
|
||||
})
|
||||
|
||||
// Should still be paused (debounce resets)
|
||||
let bannerItem = screen.getByTestId('banner-item')
|
||||
expect(bannerItem).toHaveAttribute('data-is-paused', 'true')
|
||||
|
||||
// Wait remaining time
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(20)
|
||||
})
|
||||
@@ -388,7 +377,6 @@ describe('Banner', () => {
|
||||
|
||||
const { unmount } = render(<Banner />)
|
||||
|
||||
// Trigger resize to create timer
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
})
|
||||
@@ -462,10 +450,8 @@ describe('Banner', () => {
|
||||
|
||||
const { rerender } = render(<Banner />)
|
||||
|
||||
// Re-render with same props
|
||||
rerender(<Banner />)
|
||||
|
||||
// Component should still be present (memo doesn't break rendering)
|
||||
expect(screen.getByTestId('carousel')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { IndicatorButton } from './indicator-button'
|
||||
import { IndicatorButton } from '../indicator-button'
|
||||
|
||||
describe('IndicatorButton', () => {
|
||||
beforeEach(() => {
|
||||
@@ -164,7 +164,6 @@ describe('IndicatorButton', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Check for conic-gradient style which indicates progress indicator
|
||||
const progressIndicator = container.querySelector('[style*="conic-gradient"]')
|
||||
expect(progressIndicator).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -221,10 +220,8 @@ describe('IndicatorButton', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Initially no progress indicator
|
||||
expect(container.querySelector('[style*="conic-gradient"]')).not.toBeInTheDocument()
|
||||
|
||||
// Rerender with isNextSlide=true
|
||||
rerender(
|
||||
<IndicatorButton
|
||||
index={1}
|
||||
@@ -237,7 +234,6 @@ describe('IndicatorButton', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Now progress indicator should be visible
|
||||
expect(container.querySelector('[style*="conic-gradient"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -255,11 +251,9 @@ describe('IndicatorButton', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Progress indicator should be present
|
||||
const progressIndicator = container.querySelector('[style*="conic-gradient"]')
|
||||
expect(progressIndicator).toBeInTheDocument()
|
||||
|
||||
// Rerender with new resetKey - this should reset the progress animation
|
||||
rerender(
|
||||
<IndicatorButton
|
||||
index={1}
|
||||
@@ -273,7 +267,6 @@ describe('IndicatorButton', () => {
|
||||
)
|
||||
|
||||
const newProgressIndicator = container.querySelector('[style*="conic-gradient"]')
|
||||
// The progress indicator should still be present after reset
|
||||
expect(newProgressIndicator).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -293,8 +286,6 @@ describe('IndicatorButton', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// The component should still render but animation should be paused
|
||||
// requestAnimationFrame might still be called for polling but progress won't update
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
mockRequestAnimationFrame.mockRestore()
|
||||
})
|
||||
@@ -315,7 +306,6 @@ describe('IndicatorButton', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Trigger animation frame
|
||||
act(() => {
|
||||
vi.advanceTimersToNextTimer()
|
||||
})
|
||||
@@ -342,12 +332,10 @@ describe('IndicatorButton', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Trigger animation frame
|
||||
act(() => {
|
||||
vi.advanceTimersToNextTimer()
|
||||
})
|
||||
|
||||
// Change isNextSlide to false - this should cancel the animation frame
|
||||
rerender(
|
||||
<IndicatorButton
|
||||
index={1}
|
||||
@@ -368,7 +356,6 @@ describe('IndicatorButton', () => {
|
||||
const mockOnClick = vi.fn()
|
||||
const mockRequestAnimationFrame = vi.spyOn(window, 'requestAnimationFrame')
|
||||
|
||||
// Mock document.hidden to be true
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
@@ -387,10 +374,8 @@ describe('IndicatorButton', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Component should still render
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
|
||||
// Reset document.hidden
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
@@ -415,7 +400,6 @@ describe('IndicatorButton', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Progress indicator should be visible (animation running)
|
||||
expect(container.querySelector('[style*="conic-gradient"]')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,43 +1,12 @@
|
||||
import type { CreateAppModalProps } from './index'
|
||||
import type { CreateAppModalProps } from '../index'
|
||||
import type { UsagePlanInfo } from '@/app/components/billing/type'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { createMockPlan, createMockPlanTotal, createMockPlanUsage } from '@/__mocks__/provider-context'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import CreateAppModal from './index'
|
||||
import CreateAppModal from '../index'
|
||||
|
||||
let mockTranslationOverrides: Record<string, string | undefined> = {}
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
const override = mockTranslationOverrides[key]
|
||||
if (override !== undefined)
|
||||
return override
|
||||
if (options?.returnObjects)
|
||||
return [`${key}-feature-1`, `${key}-feature-2`]
|
||||
if (options) {
|
||||
const { ns, ...rest } = options
|
||||
const prefix = ns ? `${ns}.` : ''
|
||||
const suffix = Object.keys(rest).length > 0 ? `:${JSON.stringify(rest)}` : ''
|
||||
return `${prefix}${key}${suffix}`
|
||||
}
|
||||
return key
|
||||
},
|
||||
i18n: {
|
||||
language: 'en',
|
||||
changeLanguage: vi.fn(),
|
||||
},
|
||||
}),
|
||||
Trans: ({ children }: { children?: React.ReactNode }) => children,
|
||||
initReactI18next: {
|
||||
type: '3rdParty',
|
||||
init: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Avoid heavy emoji dataset initialization during unit tests.
|
||||
vi.mock('emoji-mart', () => ({
|
||||
init: vi.fn(),
|
||||
SearchIndex: { search: vi.fn().mockResolvedValue([]) },
|
||||
@@ -87,7 +56,7 @@ vi.mock('@/context/provider-context', () => ({
|
||||
|
||||
type ConfirmPayload = Parameters<CreateAppModalProps['onConfirm']>[0]
|
||||
|
||||
const setup = (overrides: Partial<CreateAppModalProps> = {}) => {
|
||||
const setup = async (overrides: Partial<CreateAppModalProps> = {}) => {
|
||||
const onConfirm = vi.fn<(payload: ConfirmPayload) => Promise<void>>().mockResolvedValue(undefined)
|
||||
const onHide = vi.fn()
|
||||
|
||||
@@ -109,7 +78,9 @@ const setup = (overrides: Partial<CreateAppModalProps> = {}) => {
|
||||
...overrides,
|
||||
}
|
||||
|
||||
render(<CreateAppModal {...props} />)
|
||||
await act(async () => {
|
||||
render(<CreateAppModal {...props} />)
|
||||
})
|
||||
return { onConfirm, onHide }
|
||||
}
|
||||
|
||||
@@ -125,25 +96,23 @@ const getAppIconTrigger = (): HTMLElement => {
|
||||
describe('CreateAppModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTranslationOverrides = {}
|
||||
mockEnableBilling = false
|
||||
mockPlanType = Plan.team
|
||||
mockUsagePlanInfo = createPlanInfo(1)
|
||||
mockTotalPlanInfo = createPlanInfo(10)
|
||||
})
|
||||
|
||||
// The title and form sections vary based on the modal mode (create vs edit).
|
||||
describe('Rendering', () => {
|
||||
it('should render create title and actions when creating', () => {
|
||||
setup({ appName: 'My App', isEditModal: false })
|
||||
it('should render create title and actions when creating', async () => {
|
||||
await setup({ appName: 'My App', isEditModal: false })
|
||||
|
||||
expect(screen.getByText('explore.appCustomize.title:{"name":"My App"}')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render edit-only fields when editing a chat app', () => {
|
||||
setup({ isEditModal: true, appMode: AppModeEnum.CHAT, max_active_requests: 5 })
|
||||
it('should render edit-only fields when editing a chat app', async () => {
|
||||
await setup({ isEditModal: true, appMode: AppModeEnum.CHAT, max_active_requests: 5 })
|
||||
|
||||
expect(screen.getByText('app.editAppTitle')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /common\.operation\.save/ })).toBeInTheDocument()
|
||||
@@ -151,65 +120,57 @@ describe('CreateAppModal', () => {
|
||||
expect((screen.getByRole('spinbutton') as HTMLInputElement).value).toBe('5')
|
||||
})
|
||||
|
||||
it.each([AppModeEnum.ADVANCED_CHAT, AppModeEnum.AGENT_CHAT])('should render answer icon switch when editing %s app', (mode) => {
|
||||
setup({ isEditModal: true, appMode: mode })
|
||||
it.each([AppModeEnum.ADVANCED_CHAT, AppModeEnum.AGENT_CHAT])('should render answer icon switch when editing %s app', async (mode) => {
|
||||
await setup({ isEditModal: true, appMode: mode })
|
||||
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render answer icon switch when editing a non-chat app', () => {
|
||||
setup({ isEditModal: true, appMode: AppModeEnum.COMPLETION })
|
||||
it('should not render answer icon switch when editing a non-chat app', async () => {
|
||||
await setup({ isEditModal: true, appMode: AppModeEnum.COMPLETION })
|
||||
|
||||
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render modal content when hidden', () => {
|
||||
setup({ show: false })
|
||||
it('should not render modal content when hidden', async () => {
|
||||
await setup({ show: false })
|
||||
|
||||
expect(screen.queryByRole('button', { name: /common\.operation\.create/ })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Disabled states prevent submission and reflect parent-driven props.
|
||||
describe('Props', () => {
|
||||
it('should disable confirm action when confirmDisabled is true', () => {
|
||||
setup({ confirmDisabled: true })
|
||||
it('should disable confirm action when confirmDisabled is true', async () => {
|
||||
await setup({ confirmDisabled: true })
|
||||
|
||||
expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable confirm action when appName is empty', () => {
|
||||
setup({ appName: ' ' })
|
||||
it('should disable confirm action when appName is empty', async () => {
|
||||
await setup({ appName: ' ' })
|
||||
|
||||
expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// Defensive coverage for falsy input values and translation edge cases.
|
||||
describe('Edge Cases', () => {
|
||||
it('should default description to empty string when appDescription is empty', () => {
|
||||
setup({ appDescription: '' })
|
||||
it('should default description to empty string when appDescription is empty', async () => {
|
||||
await setup({ appDescription: '' })
|
||||
|
||||
expect((screen.getByPlaceholderText('app.newApp.appDescriptionPlaceholder') as HTMLTextAreaElement).value).toBe('')
|
||||
})
|
||||
|
||||
it('should fall back to empty placeholders when translations return empty string', () => {
|
||||
mockTranslationOverrides = {
|
||||
'newApp.appNamePlaceholder': '',
|
||||
'newApp.appDescriptionPlaceholder': '',
|
||||
}
|
||||
it('should render i18n key placeholders when translations are available', async () => {
|
||||
await setup()
|
||||
|
||||
setup()
|
||||
|
||||
expect((screen.getByDisplayValue('Test App') as HTMLInputElement).placeholder).toBe('')
|
||||
expect((screen.getByDisplayValue('Test description') as HTMLTextAreaElement).placeholder).toBe('')
|
||||
expect((screen.getByDisplayValue('Test App') as HTMLInputElement).placeholder).toBe('app.newApp.appNamePlaceholder')
|
||||
expect((screen.getByDisplayValue('Test description') as HTMLTextAreaElement).placeholder).toBe('app.newApp.appDescriptionPlaceholder')
|
||||
})
|
||||
})
|
||||
|
||||
// The modal should close from user-initiated cancellation actions.
|
||||
describe('User Interactions', () => {
|
||||
it('should call onHide when cancel button is clicked', () => {
|
||||
const { onConfirm, onHide } = setup()
|
||||
it('should call onHide when cancel button is clicked', async () => {
|
||||
const { onConfirm, onHide } = await setup()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
@@ -217,16 +178,16 @@ describe('CreateAppModal', () => {
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onHide when pressing Escape while visible', () => {
|
||||
const { onHide } = setup()
|
||||
it('should call onHide when pressing Escape while visible', async () => {
|
||||
const { onHide } = await setup()
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape', keyCode: 27 })
|
||||
|
||||
expect(onHide).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call onHide when pressing Escape while hidden', () => {
|
||||
const { onHide } = setup({ show: false })
|
||||
it('should not call onHide when pressing Escape while hidden', async () => {
|
||||
const { onHide } = await setup({ show: false })
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape', keyCode: 27 })
|
||||
|
||||
@@ -234,34 +195,32 @@ describe('CreateAppModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// When billing limits are reached, the modal blocks app creation and shows quota guidance.
|
||||
describe('Quota Gating', () => {
|
||||
it('should show AppsFull and disable create when apps quota is reached', () => {
|
||||
it('should show AppsFull and disable create when apps quota is reached', async () => {
|
||||
mockEnableBilling = true
|
||||
mockPlanType = Plan.team
|
||||
mockUsagePlanInfo = createPlanInfo(10)
|
||||
mockTotalPlanInfo = createPlanInfo(10)
|
||||
|
||||
setup({ isEditModal: false })
|
||||
await setup({ isEditModal: false })
|
||||
|
||||
expect(screen.getByText('billing.apps.fullTip2')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should allow saving when apps quota is reached in edit mode', () => {
|
||||
it('should allow saving when apps quota is reached in edit mode', async () => {
|
||||
mockEnableBilling = true
|
||||
mockPlanType = Plan.team
|
||||
mockUsagePlanInfo = createPlanInfo(10)
|
||||
mockTotalPlanInfo = createPlanInfo(10)
|
||||
|
||||
setup({ isEditModal: true })
|
||||
await setup({ isEditModal: true })
|
||||
|
||||
expect(screen.queryByText('billing.apps.fullTip2')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /common\.operation\.save/ })).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
// Shortcut handlers are important for power users and must respect gating rules.
|
||||
describe('Keyboard Shortcuts', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
@@ -274,11 +233,11 @@ describe('CreateAppModal', () => {
|
||||
it.each([
|
||||
['meta+enter', { metaKey: true }],
|
||||
['ctrl+enter', { ctrlKey: true }],
|
||||
])('should submit when %s is pressed while visible', (_, modifier) => {
|
||||
const { onConfirm, onHide } = setup()
|
||||
])('should submit when %s is pressed while visible', async (_, modifier) => {
|
||||
const { onConfirm, onHide } = await setup()
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, ...modifier })
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
@@ -286,11 +245,11 @@ describe('CreateAppModal', () => {
|
||||
expect(onHide).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not submit when modal is hidden', () => {
|
||||
const { onConfirm, onHide } = setup({ show: false })
|
||||
it('should not submit when modal is hidden', async () => {
|
||||
const { onConfirm, onHide } = await setup({ show: false })
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true })
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
@@ -298,16 +257,16 @@ describe('CreateAppModal', () => {
|
||||
expect(onHide).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not submit when apps quota is reached in create mode', () => {
|
||||
it('should not submit when apps quota is reached in create mode', async () => {
|
||||
mockEnableBilling = true
|
||||
mockPlanType = Plan.team
|
||||
mockUsagePlanInfo = createPlanInfo(10)
|
||||
mockTotalPlanInfo = createPlanInfo(10)
|
||||
|
||||
const { onConfirm, onHide } = setup({ isEditModal: false })
|
||||
const { onConfirm, onHide } = await setup({ isEditModal: false })
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true })
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
@@ -315,16 +274,16 @@ describe('CreateAppModal', () => {
|
||||
expect(onHide).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should submit when apps quota is reached in edit mode', () => {
|
||||
it('should submit when apps quota is reached in edit mode', async () => {
|
||||
mockEnableBilling = true
|
||||
mockPlanType = Plan.team
|
||||
mockUsagePlanInfo = createPlanInfo(10)
|
||||
mockTotalPlanInfo = createPlanInfo(10)
|
||||
|
||||
const { onConfirm, onHide } = setup({ isEditModal: true })
|
||||
const { onConfirm, onHide } = await setup({ isEditModal: true })
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true })
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
@@ -332,11 +291,11 @@ describe('CreateAppModal', () => {
|
||||
expect(onHide).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not submit when name is empty', () => {
|
||||
const { onConfirm, onHide } = setup({ appName: ' ' })
|
||||
it('should not submit when name is empty', async () => {
|
||||
const { onConfirm, onHide } = await setup({ appName: ' ' })
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true })
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
@@ -345,10 +304,9 @@ describe('CreateAppModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// The app icon picker is a key user flow for customizing metadata.
|
||||
describe('App Icon Picker', () => {
|
||||
it('should open and close the picker when cancel is clicked', () => {
|
||||
setup({
|
||||
it('should open and close the picker when cancel is clicked', async () => {
|
||||
await setup({
|
||||
appIconType: 'image',
|
||||
appIcon: 'file-123',
|
||||
appIconUrl: 'https://example.com/icon.png',
|
||||
@@ -363,10 +321,10 @@ describe('CreateAppModal', () => {
|
||||
expect(screen.queryByRole('button', { name: 'app.iconPicker.cancel' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update icon payload when selecting emoji and confirming', () => {
|
||||
it('should update icon payload when selecting emoji and confirming', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { onConfirm } = setup({
|
||||
const { onConfirm } = await setup({
|
||||
appIconType: 'image',
|
||||
appIcon: 'file-123',
|
||||
appIconUrl: 'https://example.com/icon.png',
|
||||
@@ -374,7 +332,6 @@ describe('CreateAppModal', () => {
|
||||
|
||||
fireEvent.click(getAppIconTrigger())
|
||||
|
||||
// Find the emoji grid by locating the category label, then find the clickable emoji wrapper
|
||||
const categoryLabel = screen.getByText('people')
|
||||
const emojiGrid = categoryLabel.nextElementSibling
|
||||
const clickableEmojiWrapper = emojiGrid?.firstElementChild
|
||||
@@ -385,7 +342,7 @@ describe('CreateAppModal', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'app.iconPicker.ok' }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ }))
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
@@ -402,19 +359,17 @@ describe('CreateAppModal', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('should reset emoji icon to initial props when picker is cancelled', () => {
|
||||
it('should reset emoji icon to initial props when picker is cancelled', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { onConfirm } = setup({
|
||||
const { onConfirm } = await setup({
|
||||
appIconType: 'emoji',
|
||||
appIcon: '🤖',
|
||||
appIconBackground: '#FFEAD5',
|
||||
})
|
||||
|
||||
// Open picker, select a new emoji, and confirm
|
||||
fireEvent.click(getAppIconTrigger())
|
||||
|
||||
// Find the emoji grid by locating the category label, then find the clickable emoji wrapper
|
||||
const categoryLabel = screen.getByText('people')
|
||||
const emojiGrid = categoryLabel.nextElementSibling
|
||||
const clickableEmojiWrapper = emojiGrid?.firstElementChild
|
||||
@@ -426,15 +381,13 @@ describe('CreateAppModal', () => {
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'app.iconPicker.cancel' })).not.toBeInTheDocument()
|
||||
|
||||
// Open picker again and cancel - should reset to initial props
|
||||
fireEvent.click(getAppIconTrigger())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'app.iconPicker.cancel' }))
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'app.iconPicker.cancel' })).not.toBeInTheDocument()
|
||||
|
||||
// Submit and verify the payload uses the original icon (cancel reverts to props)
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ }))
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
@@ -452,7 +405,6 @@ describe('CreateAppModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Submitting uses a debounced handler and builds a payload from current form state.
|
||||
describe('Submitting', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
@@ -462,8 +414,8 @@ describe('CreateAppModal', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should call onConfirm with emoji payload and hide when create is clicked', () => {
|
||||
const { onConfirm, onHide } = setup({
|
||||
it('should call onConfirm with emoji payload and hide when create is clicked', async () => {
|
||||
const { onConfirm, onHide } = await setup({
|
||||
appName: 'My App',
|
||||
appDescription: 'My description',
|
||||
appIconType: 'emoji',
|
||||
@@ -472,7 +424,7 @@ describe('CreateAppModal', () => {
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ }))
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
@@ -491,12 +443,12 @@ describe('CreateAppModal', () => {
|
||||
expect(payload).not.toHaveProperty('max_active_requests')
|
||||
})
|
||||
|
||||
it('should include updated description when textarea is changed before submitting', () => {
|
||||
const { onConfirm } = setup({ appDescription: 'Old description' })
|
||||
it('should include updated description when textarea is changed before submitting', async () => {
|
||||
const { onConfirm } = await setup({ appDescription: 'Old description' })
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('app.newApp.appDescriptionPlaceholder'), { target: { value: 'Updated description' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ }))
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
@@ -504,8 +456,8 @@ describe('CreateAppModal', () => {
|
||||
expect(onConfirm.mock.calls[0][0]).toMatchObject({ description: 'Updated description' })
|
||||
})
|
||||
|
||||
it('should omit icon_background when submitting with image icon', () => {
|
||||
const { onConfirm } = setup({
|
||||
it('should omit icon_background when submitting with image icon', async () => {
|
||||
const { onConfirm } = await setup({
|
||||
appIconType: 'image',
|
||||
appIcon: 'file-123',
|
||||
appIconUrl: 'https://example.com/icon.png',
|
||||
@@ -513,7 +465,7 @@ describe('CreateAppModal', () => {
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ }))
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
@@ -525,8 +477,8 @@ describe('CreateAppModal', () => {
|
||||
expect(payload.icon_background).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should include max_active_requests and updated answer icon when saving', () => {
|
||||
const { onConfirm } = setup({
|
||||
it('should include max_active_requests and updated answer icon when saving', async () => {
|
||||
const { onConfirm } = await setup({
|
||||
isEditModal: true,
|
||||
appMode: AppModeEnum.CHAT,
|
||||
appUseIconAsAnswerIcon: false,
|
||||
@@ -537,7 +489,7 @@ describe('CreateAppModal', () => {
|
||||
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '12' } })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/ }))
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
@@ -548,11 +500,11 @@ describe('CreateAppModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should omit max_active_requests when input is empty', () => {
|
||||
const { onConfirm } = setup({ isEditModal: true, max_active_requests: null })
|
||||
it('should omit max_active_requests when input is empty', async () => {
|
||||
const { onConfirm } = await setup({ isEditModal: true, max_active_requests: null })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/ }))
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
@@ -560,12 +512,12 @@ describe('CreateAppModal', () => {
|
||||
expect(payload.max_active_requests).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should omit max_active_requests when input is not a number', () => {
|
||||
const { onConfirm } = setup({ isEditModal: true, max_active_requests: null })
|
||||
it('should omit max_active_requests when input is not a number', async () => {
|
||||
const { onConfirm } = await setup({ isEditModal: true, max_active_requests: null })
|
||||
|
||||
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: 'abc' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/ }))
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
@@ -573,18 +525,18 @@ describe('CreateAppModal', () => {
|
||||
expect(payload.max_active_requests).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should show toast error and not submit when name becomes empty before debounced submit runs', () => {
|
||||
const { onConfirm, onHide } = setup({ appName: 'My App' })
|
||||
it('should show toast error and not submit when name becomes empty before debounced submit runs', async () => {
|
||||
const { onConfirm, onHide } = await setup({ appName: 'My App' })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ }))
|
||||
fireEvent.change(screen.getByPlaceholderText('app.newApp.appNamePlaceholder'), { target: { value: ' ' } })
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
expect(screen.getByText('explore.appCustomize.nameRequired')).toBeInTheDocument()
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(6000)
|
||||
})
|
||||
expect(screen.queryByText('explore.appCustomize.nameRequired')).not.toBeInTheDocument()
|
||||
@@ -8,9 +8,8 @@ import { AccessMode } from '@/models/access-control'
|
||||
import { useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams } from '@/service/use-explore'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import InstalledApp from './index'
|
||||
import InstalledApp from '../index'
|
||||
|
||||
// Mock external dependencies BEFORE imports
|
||||
vi.mock('use-context-selector', () => ({
|
||||
useContext: vi.fn(),
|
||||
createContext: vi.fn(() => ({})),
|
||||
@@ -119,13 +118,11 @@ describe('InstalledApp', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Mock useContext
|
||||
;(useContext as Mock).mockReturnValue({
|
||||
installedApps: [mockInstalledApp],
|
||||
isFetchingInstalledApps: false,
|
||||
})
|
||||
|
||||
// Mock useWebAppStore
|
||||
;(useWebAppStore as unknown as Mock).mockImplementation((
|
||||
selector: (state: {
|
||||
updateAppInfo: Mock
|
||||
@@ -145,7 +142,6 @@ describe('InstalledApp', () => {
|
||||
return selector(state)
|
||||
})
|
||||
|
||||
// Mock service hooks with default success states
|
||||
;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({
|
||||
isFetching: false,
|
||||
data: mockWebAppAccessMode,
|
||||
@@ -565,7 +561,6 @@ describe('InstalledApp', () => {
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
// Should find and render the correct app
|
||||
expect(screen.getByText(/Chat With History/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/installed-app-123/)).toBeInTheDocument()
|
||||
})
|
||||
@@ -624,7 +619,6 @@ describe('InstalledApp', () => {
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
// Error should take precedence over loading
|
||||
expect(screen.getByText(/Some error/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -640,7 +634,6 @@ describe('InstalledApp', () => {
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
// Error should take precedence over permission
|
||||
expect(screen.getByText(/Params error/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/403/)).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -656,7 +649,6 @@ describe('InstalledApp', () => {
|
||||
})
|
||||
|
||||
render(<InstalledApp id="nonexistent-app" />)
|
||||
// Permission should take precedence over 404
|
||||
expect(screen.getByText(/403/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/404/)).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -673,7 +665,6 @@ describe('InstalledApp', () => {
|
||||
})
|
||||
|
||||
const { container } = render(<InstalledApp id="nonexistent-app" />)
|
||||
// Loading should take precedence over 404
|
||||
const svg = container.querySelector('svg.spin-animation')
|
||||
expect(svg).toBeInTheDocument()
|
||||
expect(screen.queryByText(/404/)).not.toBeInTheDocument()
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import ItemOperation from './index'
|
||||
import ItemOperation from '../index'
|
||||
|
||||
describe('ItemOperation', () => {
|
||||
beforeEach(() => {
|
||||
@@ -20,87 +20,65 @@ describe('ItemOperation', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Rendering: menu items show after opening.
|
||||
describe('Rendering', () => {
|
||||
it('should render pin and delete actions when menu is open', async () => {
|
||||
// Arrange
|
||||
renderComponent()
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
|
||||
// Assert
|
||||
expect(await screen.findByText('explore.sidebar.action.pin')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.sidebar.action.delete')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Props: render optional rename action and pinned label text.
|
||||
describe('Props', () => {
|
||||
it('should render rename action when isShowRenameConversation is true', async () => {
|
||||
// Arrange
|
||||
renderComponent({ isShowRenameConversation: true })
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
|
||||
// Assert
|
||||
expect(await screen.findByText('explore.sidebar.action.rename')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render unpin label when isPinned is true', async () => {
|
||||
// Arrange
|
||||
renderComponent({ isPinned: true })
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
|
||||
// Assert
|
||||
expect(await screen.findByText('explore.sidebar.action.unpin')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User interactions: clicking action items triggers callbacks.
|
||||
describe('User Interactions', () => {
|
||||
it('should call togglePin when clicking pin action', async () => {
|
||||
// Arrange
|
||||
const { props } = renderComponent()
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.pin'))
|
||||
|
||||
// Assert
|
||||
expect(props.togglePin).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onDelete when clicking delete action', async () => {
|
||||
// Arrange
|
||||
const { props } = renderComponent()
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
|
||||
|
||||
// Assert
|
||||
expect(props.onDelete).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases: menu closes after mouse leave when no hovering state remains.
|
||||
describe('Edge Cases', () => {
|
||||
it('should close the menu when mouse leaves the panel and item is not hovering', async () => {
|
||||
// Arrange
|
||||
renderComponent()
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
const pinText = await screen.findByText('explore.sidebar.action.pin')
|
||||
const menu = pinText.closest('div')?.parentElement as HTMLElement
|
||||
|
||||
// Act
|
||||
fireEvent.mouseEnter(menu)
|
||||
fireEvent.mouseLeave(menu)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('explore.sidebar.action.pin')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -5,7 +5,7 @@ import Toast from '@/app/components/base/toast'
|
||||
import ExploreContext from '@/context/explore-context'
|
||||
import { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import SideBar from './index'
|
||||
import SideBar from '../index'
|
||||
|
||||
const mockSegments = ['apps']
|
||||
const mockPush = vi.fn()
|
||||
@@ -14,6 +14,7 @@ const mockUninstall = vi.fn()
|
||||
const mockUpdatePinStatus = vi.fn()
|
||||
let mockIsFetching = false
|
||||
let mockInstalledApps: InstalledApp[] = []
|
||||
let mockMediaType: string = MediaType.pc
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useSelectedLayoutSegments: () => mockSegments,
|
||||
@@ -23,7 +24,7 @@ vi.mock('next/navigation', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
default: () => MediaType.pc,
|
||||
default: () => mockMediaType,
|
||||
MediaType: {
|
||||
mobile: 'mobile',
|
||||
tablet: 'tablet',
|
||||
@@ -85,53 +86,73 @@ describe('SideBar', () => {
|
||||
vi.clearAllMocks()
|
||||
mockIsFetching = false
|
||||
mockInstalledApps = []
|
||||
mockMediaType = MediaType.pc
|
||||
vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
|
||||
})
|
||||
|
||||
// Rendering: show discovery and workspace section.
|
||||
describe('Rendering', () => {
|
||||
it('should render workspace items when installed apps exist', () => {
|
||||
// Arrange
|
||||
mockInstalledApps = [createInstalledApp()]
|
||||
it('should render discovery link', () => {
|
||||
renderWithContext()
|
||||
|
||||
// Act
|
||||
expect(screen.getByText('explore.sidebar.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render workspace items when installed apps exist', () => {
|
||||
mockInstalledApps = [createInstalledApp()]
|
||||
renderWithContext(mockInstalledApps)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('explore.sidebar.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.sidebar.webApps')).toBeInTheDocument()
|
||||
expect(screen.getByText('My App')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Effects: refresh and sync installed apps state.
|
||||
describe('Effects', () => {
|
||||
it('should refetch installed apps on mount', () => {
|
||||
// Arrange
|
||||
mockInstalledApps = [createInstalledApp()]
|
||||
it('should render NoApps component when no installed apps on desktop', () => {
|
||||
renderWithContext([])
|
||||
|
||||
// Act
|
||||
expect(screen.getByText('explore.sidebar.noApps.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render multiple installed apps', () => {
|
||||
mockInstalledApps = [
|
||||
createInstalledApp({ id: 'app-1', app: { ...createInstalledApp().app, name: 'Alpha' } }),
|
||||
createInstalledApp({ id: 'app-2', app: { ...createInstalledApp().app, name: 'Beta' } }),
|
||||
]
|
||||
renderWithContext(mockInstalledApps)
|
||||
|
||||
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
||||
expect(screen.getByText('Beta')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render divider between pinned and unpinned apps', () => {
|
||||
mockInstalledApps = [
|
||||
createInstalledApp({ id: 'app-1', is_pinned: true, app: { ...createInstalledApp().app, name: 'Pinned' } }),
|
||||
createInstalledApp({ id: 'app-2', is_pinned: false, app: { ...createInstalledApp().app, name: 'Unpinned' } }),
|
||||
]
|
||||
const { container } = renderWithContext(mockInstalledApps)
|
||||
|
||||
const dividers = container.querySelectorAll('[class*="divider"], hr')
|
||||
expect(dividers.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Effects', () => {
|
||||
it('should refetch installed apps on mount', () => {
|
||||
mockInstalledApps = [createInstalledApp()]
|
||||
renderWithContext(mockInstalledApps)
|
||||
|
||||
// Assert
|
||||
expect(mockRefetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// User interactions: delete and pin flows.
|
||||
describe('User Interactions', () => {
|
||||
it('should uninstall app and show toast when delete is confirmed', async () => {
|
||||
// Arrange
|
||||
mockInstalledApps = [createInstalledApp()]
|
||||
mockUninstall.mockResolvedValue(undefined)
|
||||
renderWithContext(mockInstalledApps)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
|
||||
fireEvent.click(await screen.findByText('common.operation.confirm'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockUninstall).toHaveBeenCalledWith('app-123')
|
||||
expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
@@ -142,16 +163,13 @@ describe('SideBar', () => {
|
||||
})
|
||||
|
||||
it('should update pin status and show toast when pin is clicked', async () => {
|
||||
// Arrange
|
||||
mockInstalledApps = [createInstalledApp({ is_pinned: false })]
|
||||
mockUpdatePinStatus.mockResolvedValue(undefined)
|
||||
renderWithContext(mockInstalledApps)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.pin'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-123', isPinned: true })
|
||||
expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
@@ -160,5 +178,44 @@ describe('SideBar', () => {
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('should unpin an already pinned app', async () => {
|
||||
mockInstalledApps = [createInstalledApp({ is_pinned: true })]
|
||||
mockUpdatePinStatus.mockResolvedValue(undefined)
|
||||
renderWithContext(mockInstalledApps)
|
||||
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.unpin'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-123', isPinned: false })
|
||||
})
|
||||
})
|
||||
|
||||
it('should open and close confirm dialog for delete', async () => {
|
||||
mockInstalledApps = [createInstalledApp()]
|
||||
renderWithContext(mockInstalledApps)
|
||||
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
|
||||
|
||||
expect(await screen.findByText('explore.sidebar.delete.title')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('common.operation.cancel'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUninstall).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should hide NoApps and app names on mobile', () => {
|
||||
mockMediaType = MediaType.mobile
|
||||
renderWithContext([])
|
||||
|
||||
expect(screen.queryByText('explore.sidebar.noApps.title')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('explore.sidebar.webApps')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import AppNavItem from './index'
|
||||
import AppNavItem from '../index'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
|
||||
@@ -37,62 +37,46 @@ describe('AppNavItem', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering: display app name for desktop and hide for mobile.
|
||||
describe('Rendering', () => {
|
||||
it('should render name and item operation on desktop', () => {
|
||||
// Arrange
|
||||
render(<AppNavItem {...baseProps} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('My App')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('item-operation-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide name on mobile', () => {
|
||||
// Arrange
|
||||
render(<AppNavItem {...baseProps} isMobile />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('My App')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User interactions: navigation and delete flow.
|
||||
describe('User Interactions', () => {
|
||||
it('should navigate to installed app when item is clicked', () => {
|
||||
// Arrange
|
||||
render(<AppNavItem {...baseProps} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('My App'))
|
||||
|
||||
// Assert
|
||||
expect(mockPush).toHaveBeenCalledWith('/explore/installed/app-123')
|
||||
})
|
||||
|
||||
it('should call onDelete with app id when delete action is clicked', async () => {
|
||||
// Arrange
|
||||
render(<AppNavItem {...baseProps} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
|
||||
|
||||
// Assert
|
||||
expect(baseProps.onDelete).toHaveBeenCalledWith('app-123')
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases: hide delete when uninstallable or selected.
|
||||
describe('Edge Cases', () => {
|
||||
it('should not render delete action when app is uninstallable', () => {
|
||||
// Arrange
|
||||
render(<AppNavItem {...baseProps} uninstallable />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('explore.sidebar.action.delete')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Theme } from '@/types/app'
|
||||
import NoApps from '../index'
|
||||
|
||||
let mockTheme = Theme.light
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({ theme: mockTheme }),
|
||||
}))
|
||||
|
||||
describe('NoApps', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTheme = Theme.light
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render title, description and learn-more link', () => {
|
||||
render(<NoApps />)
|
||||
|
||||
expect(screen.getByText('explore.sidebar.noApps.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.sidebar.noApps.description')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.sidebar.noApps.learnMore')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render learn-more as external link with correct href', () => {
|
||||
render(<NoApps />)
|
||||
|
||||
const link = screen.getByText('explore.sidebar.noApps.learnMore')
|
||||
expect(link.tagName).toBe('A')
|
||||
expect(link).toHaveAttribute('href', 'https://docs.dify.ai/use-dify/publish/README')
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Theme', () => {
|
||||
it('should apply light theme background class in light mode', () => {
|
||||
mockTheme = Theme.light
|
||||
|
||||
const { container } = render(<NoApps />)
|
||||
const bgDiv = container.querySelector('[class*="bg-contain"]')
|
||||
|
||||
expect(bgDiv).toBeInTheDocument()
|
||||
expect(bgDiv?.className).toContain('light')
|
||||
expect(bgDiv?.className).not.toContain('dark')
|
||||
})
|
||||
|
||||
it('should apply dark theme background class in dark mode', () => {
|
||||
mockTheme = Theme.dark
|
||||
|
||||
const { container } = render(<NoApps />)
|
||||
const bgDiv = container.querySelector('[class*="bg-contain"]')
|
||||
|
||||
expect(bgDiv).toBeInTheDocument()
|
||||
expect(bgDiv?.className).toContain('dark')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,20 +1,8 @@
|
||||
import type { TryAppInfo } from '@/service/try-app'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import TryApp from './index'
|
||||
import { TypeEnum } from './tab'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'tryApp.tabHeader.try': 'Try',
|
||||
'tryApp.tabHeader.detail': 'Detail',
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
import TryApp from '../index'
|
||||
import { TypeEnum } from '../tab'
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal() as object
|
||||
@@ -30,7 +18,7 @@ vi.mock('@/service/use-try-app', () => ({
|
||||
useGetTryAppInfo: (...args: unknown[]) => mockUseGetTryAppInfo(...args),
|
||||
}))
|
||||
|
||||
vi.mock('./app', () => ({
|
||||
vi.mock('../app', () => ({
|
||||
default: ({ appId, appDetail }: { appId: string, appDetail: TryAppInfo }) => (
|
||||
<div data-testid="app-component" data-app-id={appId} data-mode={appDetail?.mode}>
|
||||
App Component
|
||||
@@ -38,7 +26,7 @@ vi.mock('./app', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./preview', () => ({
|
||||
vi.mock('../preview', () => ({
|
||||
default: ({ appId, appDetail }: { appId: string, appDetail: TryAppInfo }) => (
|
||||
<div data-testid="preview-component" data-app-id={appId} data-mode={appDetail?.mode}>
|
||||
Preview Component
|
||||
@@ -46,7 +34,7 @@ vi.mock('./preview', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./app-info', () => ({
|
||||
vi.mock('../app-info', () => ({
|
||||
default: ({
|
||||
appId,
|
||||
appDetail,
|
||||
@@ -141,8 +129,8 @@ describe('TryApp (main index.tsx)', () => {
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Try')).toBeInTheDocument()
|
||||
expect(screen.getByText('Detail')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.tryApp.tabHeader.try')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -185,7 +173,6 @@ describe('TryApp (main index.tsx)', () => {
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
// Find the close button (the one with RiCloseLine icon)
|
||||
const buttons = document.body.querySelectorAll('button')
|
||||
expect(buttons.length).toBeGreaterThan(0)
|
||||
})
|
||||
@@ -203,10 +190,10 @@ describe('TryApp (main index.tsx)', () => {
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Detail')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Detail'))
|
||||
fireEvent.click(screen.getByText('explore.tryApp.tabHeader.detail'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.body.querySelector('[data-testid="preview-component"]')).toBeInTheDocument()
|
||||
@@ -224,18 +211,16 @@ describe('TryApp (main index.tsx)', () => {
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Detail')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// First switch to Detail
|
||||
fireEvent.click(screen.getByText('Detail'))
|
||||
fireEvent.click(screen.getByText('explore.tryApp.tabHeader.detail'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.body.querySelector('[data-testid="preview-component"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Then switch back to Try
|
||||
fireEvent.click(screen.getByText('Try'))
|
||||
fireEvent.click(screen.getByText('explore.tryApp.tabHeader.try'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.body.querySelector('[data-testid="app-component"]')).toBeInTheDocument()
|
||||
@@ -256,7 +241,6 @@ describe('TryApp (main index.tsx)', () => {
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
// Find the button with close icon
|
||||
const buttons = document.body.querySelectorAll('button')
|
||||
const closeButton = Array.from(buttons).find(btn =>
|
||||
btn.querySelector('svg') || btn.className.includes('rounded-[10px]'),
|
||||
@@ -368,10 +352,10 @@ describe('TryApp (main index.tsx)', () => {
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Detail')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Detail'))
|
||||
fireEvent.click(screen.getByText('explore.tryApp.tabHeader.detail'))
|
||||
|
||||
await waitFor(() => {
|
||||
const previewComponent = document.body.querySelector('[data-testid="preview-component"]')
|
||||
@@ -1,18 +1,6 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import Tab, { TypeEnum } from './tab'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'tryApp.tabHeader.try': 'Try',
|
||||
'tryApp.tabHeader.detail': 'Detail',
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
import Tab, { TypeEnum } from '../tab'
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal() as object
|
||||
@@ -31,23 +19,23 @@ describe('Tab', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
render(<Tab value={TypeEnum.TRY} onChange={mockOnChange} />)
|
||||
|
||||
expect(screen.getByText('Try')).toBeInTheDocument()
|
||||
expect(screen.getByText('Detail')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.tryApp.tabHeader.try')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders tab with DETAIL value selected', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
render(<Tab value={TypeEnum.DETAIL} onChange={mockOnChange} />)
|
||||
|
||||
expect(screen.getByText('Try')).toBeInTheDocument()
|
||||
expect(screen.getByText('Detail')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.tryApp.tabHeader.try')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onChange when clicking a tab', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
render(<Tab value={TypeEnum.TRY} onChange={mockOnChange} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Detail'))
|
||||
fireEvent.click(screen.getByText('explore.tryApp.tabHeader.detail'))
|
||||
expect(mockOnChange).toHaveBeenCalledWith(TypeEnum.DETAIL)
|
||||
})
|
||||
|
||||
@@ -55,7 +43,7 @@ describe('Tab', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
render(<Tab value={TypeEnum.DETAIL} onChange={mockOnChange} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Try'))
|
||||
fireEvent.click(screen.getByText('explore.tryApp.tabHeader.try'))
|
||||
expect(mockOnChange).toHaveBeenCalledWith(TypeEnum.TRY)
|
||||
})
|
||||
|
||||
@@ -1,29 +1,11 @@
|
||||
import type { TryAppInfo } from '@/service/try-app'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppInfo from './index'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'types.advanced': 'Advanced',
|
||||
'types.chatbot': 'Chatbot',
|
||||
'types.agent': 'Agent',
|
||||
'types.workflow': 'Workflow',
|
||||
'types.completion': 'Completion',
|
||||
'tryApp.createFromSampleApp': 'Create from Sample',
|
||||
'tryApp.category': 'Category',
|
||||
'tryApp.requirements': 'Requirements',
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
import AppInfo from '../index'
|
||||
|
||||
const mockUseGetRequirements = vi.fn()
|
||||
|
||||
vi.mock('./use-get-requirements', () => ({
|
||||
vi.mock('../use-get-requirements', () => ({
|
||||
default: (...args: unknown[]) => mockUseGetRequirements(...args),
|
||||
}))
|
||||
|
||||
@@ -118,7 +100,7 @@ describe('AppInfo', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('ADVANCED')).toBeInTheDocument()
|
||||
expect(screen.getByText('APP.TYPES.ADVANCED')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays CHATBOT for chat mode', () => {
|
||||
@@ -133,7 +115,7 @@ describe('AppInfo', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('CHATBOT')).toBeInTheDocument()
|
||||
expect(screen.getByText('APP.TYPES.CHATBOT')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays AGENT for agent-chat mode', () => {
|
||||
@@ -148,7 +130,7 @@ describe('AppInfo', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('AGENT')).toBeInTheDocument()
|
||||
expect(screen.getByText('APP.TYPES.AGENT')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays WORKFLOW for workflow mode', () => {
|
||||
@@ -163,7 +145,7 @@ describe('AppInfo', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('WORKFLOW')).toBeInTheDocument()
|
||||
expect(screen.getByText('APP.TYPES.WORKFLOW')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays COMPLETION for completion mode', () => {
|
||||
@@ -178,7 +160,7 @@ describe('AppInfo', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('COMPLETION')).toBeInTheDocument()
|
||||
expect(screen.getByText('APP.TYPES.COMPLETION')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -214,7 +196,6 @@ describe('AppInfo', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Check that there's no element with the description class that has empty content
|
||||
const descriptionElements = container.querySelectorAll('.system-sm-regular.mt-\\[14px\\]')
|
||||
expect(descriptionElements.length).toBe(0)
|
||||
})
|
||||
@@ -233,7 +214,7 @@ describe('AppInfo', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Create from Sample')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.tryApp.createFromSampleApp')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onCreate when button is clicked', () => {
|
||||
@@ -248,7 +229,7 @@ describe('AppInfo', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Create from Sample'))
|
||||
fireEvent.click(screen.getByText('explore.tryApp.createFromSampleApp'))
|
||||
expect(mockOnCreate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -267,7 +248,7 @@ describe('AppInfo', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Category')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.tryApp.category')).toBeInTheDocument()
|
||||
expect(screen.getByText('AI Assistant')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -283,7 +264,7 @@ describe('AppInfo', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('Category')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('explore.tryApp.category')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -307,7 +288,7 @@ describe('AppInfo', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Requirements')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.tryApp.requirements')).toBeInTheDocument()
|
||||
expect(screen.getByText('OpenAI GPT-4')).toBeInTheDocument()
|
||||
expect(screen.getByText('Google Search')).toBeInTheDocument()
|
||||
})
|
||||
@@ -328,7 +309,7 @@ describe('AppInfo', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('Requirements')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('explore.tryApp.requirements')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders requirement icons with correct background image', () => {
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TryAppInfo } from '@/service/try-app'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import useGetRequirements from './use-get-requirements'
|
||||
import useGetRequirements from '../use-get-requirements'
|
||||
|
||||
const mockUseGetTryAppFlowPreview = vi.fn()
|
||||
|
||||
@@ -165,7 +165,6 @@ describe('useGetRequirements', () => {
|
||||
useGetRequirements({ appDetail, appId: 'test-app-id' }),
|
||||
)
|
||||
|
||||
// Only model provider should be included, no disabled tools
|
||||
expect(result.current.requirements).toHaveLength(1)
|
||||
expect(result.current.requirements[0].name).toBe('openai')
|
||||
})
|
||||
@@ -1,19 +1,7 @@
|
||||
import type { TryAppInfo } from '@/service/try-app'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import TryApp from './chat'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'chat.resetChat': 'Reset Chat',
|
||||
'tryApp.tryInfo': 'This is try mode info',
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
import TryApp from '../chat'
|
||||
|
||||
const mockRemoveConversationIdInfo = vi.fn()
|
||||
const mockHandleNewConversation = vi.fn()
|
||||
@@ -31,7 +19,7 @@ vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../../base/chat/embedded-chatbot/theme/theme-context', () => ({
|
||||
vi.mock('../../../../base/chat/embedded-chatbot/theme/theme-context', () => ({
|
||||
useThemeContext: () => ({
|
||||
primaryColor: '#1890ff',
|
||||
}),
|
||||
@@ -146,7 +134,7 @@ describe('TryApp (chat.tsx)', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('This is try mode info')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.tryApp.tryInfo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies className prop', () => {
|
||||
@@ -160,7 +148,6 @@ describe('TryApp (chat.tsx)', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// The component wraps with EmbeddedChatbotContext.Provider, first child is the div with className
|
||||
const innerDiv = container.querySelector('.custom-class')
|
||||
expect(innerDiv).toBeInTheDocument()
|
||||
})
|
||||
@@ -185,7 +172,6 @@ describe('TryApp (chat.tsx)', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Reset button should not be present
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -207,7 +193,6 @@ describe('TryApp (chat.tsx)', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Should have a button (the reset button)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -313,14 +298,12 @@ describe('TryApp (chat.tsx)', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Find and click the hide button on the alert
|
||||
const alertElement = screen.getByText('This is try mode info').closest('[class*="alert"]')?.parentElement
|
||||
const alertElement = screen.getByText('explore.tryApp.tryInfo').closest('[class*="alert"]')?.parentElement
|
||||
const hideButton = alertElement?.querySelector('button, [role="button"], svg')
|
||||
|
||||
if (hideButton) {
|
||||
fireEvent.click(hideButton)
|
||||
// After hiding, the alert should not be visible
|
||||
expect(screen.queryByText('This is try mode info')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('explore.tryApp.tryInfo')).not.toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,19 +1,13 @@
|
||||
import type { TryAppInfo } from '@/service/try-app'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import TryApp from './index'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
import TryApp from '../index'
|
||||
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./chat', () => ({
|
||||
vi.mock('../chat', () => ({
|
||||
default: ({ appId, appDetail, className }: { appId: string, appDetail: TryAppInfo, className: string }) => (
|
||||
<div data-testid="chat-component" data-app-id={appId} data-mode={appDetail.mode} className={className}>
|
||||
Chat Component
|
||||
@@ -21,7 +15,7 @@ vi.mock('./chat', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./text-generation', () => ({
|
||||
vi.mock('../text-generation', () => ({
|
||||
default: ({
|
||||
appId,
|
||||
className,
|
||||
@@ -1,18 +1,7 @@
|
||||
import type { AppData } from '@/models/share'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import TextGeneration from './text-generation'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'tryApp.tryInfo': 'This is a try app notice',
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
import TextGeneration from '../text-generation'
|
||||
|
||||
const mockUpdateAppInfo = vi.fn()
|
||||
const mockUpdateAppParams = vi.fn()
|
||||
@@ -156,7 +145,6 @@ describe('TextGeneration', () => {
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
// Multiple elements may have the title (header and RunOnce mock)
|
||||
const titles = screen.getAllByText('Test App Title')
|
||||
expect(titles.length).toBeGreaterThan(0)
|
||||
})
|
||||
@@ -275,7 +263,6 @@ describe('TextGeneration', () => {
|
||||
|
||||
fireEvent.click(screen.getByTestId('send-button'))
|
||||
|
||||
// The send should work without errors
|
||||
expect(screen.getByTestId('result-component')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -298,7 +285,7 @@ describe('TextGeneration', () => {
|
||||
fireEvent.click(screen.getByTestId('complete-button'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('This is a try app notice')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.tryApp.tryInfo')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -384,7 +371,6 @@ describe('TextGeneration', () => {
|
||||
|
||||
fireEvent.click(screen.getByTestId('run-start-button'))
|
||||
|
||||
// Result panel should remain visible
|
||||
expect(screen.getByTestId('result-component')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -404,10 +390,8 @@ describe('TextGeneration', () => {
|
||||
expect(screen.getByTestId('inputs-change-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Trigger input change which should call setInputs callback
|
||||
fireEvent.click(screen.getByTestId('inputs-change-button'))
|
||||
|
||||
// The component should handle the input change without errors
|
||||
expect(screen.getByTestId('run-once')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -425,7 +409,6 @@ describe('TextGeneration', () => {
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
// Mobile toggle panel should be rendered
|
||||
const togglePanel = container.querySelector('.cursor-grab')
|
||||
expect(togglePanel).toBeInTheDocument()
|
||||
})
|
||||
@@ -447,13 +430,11 @@ describe('TextGeneration', () => {
|
||||
expect(togglePanel).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click to show result panel
|
||||
const toggleParent = container.querySelector('.cursor-grab')?.parentElement
|
||||
if (toggleParent) {
|
||||
fireEvent.click(toggleParent)
|
||||
}
|
||||
|
||||
// Click again to hide result panel
|
||||
await waitFor(() => {
|
||||
const newToggleParent = container.querySelector('.cursor-grab')?.parentElement
|
||||
if (newToggleParent) {
|
||||
@@ -461,7 +442,6 @@ describe('TextGeneration', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Component should handle both show and hide without errors
|
||||
expect(screen.getByTestId('result-component')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,6 @@
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import BasicAppPreview from './basic-app-preview'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
import BasicAppPreview from '../basic-app-preview'
|
||||
|
||||
const mockUseGetTryAppInfo = vi.fn()
|
||||
const mockUseAllToolProviders = vi.fn()
|
||||
@@ -22,7 +16,7 @@ vi.mock('@/service/use-tools', () => ({
|
||||
useAllToolProviders: () => mockUseAllToolProviders(),
|
||||
}))
|
||||
|
||||
vi.mock('../../../header/account-setting/model-provider-page/hooks', () => ({
|
||||
vi.mock('../../../../header/account-setting/model-provider-page/hooks', () => ({
|
||||
useTextGenerationCurrentProviderAndModelAndModelList: (...args: unknown[]) =>
|
||||
mockUseTextGenerationCurrentProviderAndModelAndModelList(...args),
|
||||
}))
|
||||
@@ -518,7 +512,6 @@ describe('BasicAppPreview', () => {
|
||||
|
||||
render(<BasicAppPreview appId="test-app-id" />)
|
||||
|
||||
// Should still render (with default model config)
|
||||
await waitFor(() => {
|
||||
expect(mockUseGetTryAppDataSets).toHaveBeenCalled()
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import FlowAppPreview from './flow-app-preview'
|
||||
import FlowAppPreview from '../flow-app-preview'
|
||||
|
||||
const mockUseGetTryAppFlowPreview = vi.fn()
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { TryAppInfo } from '@/service/try-app'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import Preview from './index'
|
||||
import Preview from '../index'
|
||||
|
||||
vi.mock('./basic-app-preview', () => ({
|
||||
vi.mock('../basic-app-preview', () => ({
|
||||
default: ({ appId }: { appId: string }) => (
|
||||
<div data-testid="basic-app-preview" data-app-id={appId}>
|
||||
BasicAppPreview
|
||||
@@ -11,7 +11,7 @@ vi.mock('./basic-app-preview', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./flow-app-preview', () => ({
|
||||
vi.mock('../flow-app-preview', () => ({
|
||||
default: ({ appId, className }: { appId: string, className?: string }) => (
|
||||
<div data-testid="flow-app-preview" data-app-id={appId} className={className}>
|
||||
FlowAppPreview
|
||||
@@ -6,9 +6,10 @@ import {
|
||||
RiBrainLine,
|
||||
} from '@remixicon/react'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useMemo } from 'react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { cn } from '@/utils/classnames'
|
||||
@@ -33,6 +34,7 @@ const FixedModelProvider = ['langgenius/openai/openai', 'langgenius/anthropic/an
|
||||
const ModelProviderPage = ({ searchText }: Props) => {
|
||||
const debouncedSearchText = useDebounce(searchText, { wait: 500 })
|
||||
const { t } = useTranslation()
|
||||
const { mutateCurrentWorkspace, isValidatingCurrentWorkspace } = useAppContext()
|
||||
const { data: textGenerationDefaultModel, isLoading: isTextGenerationDefaultModelLoading } = useDefaultModel(ModelTypeEnum.textGeneration)
|
||||
const { data: embeddingsDefaultModel, isLoading: isEmbeddingsDefaultModelLoading } = useDefaultModel(ModelTypeEnum.textEmbedding)
|
||||
const { data: rerankDefaultModel, isLoading: isRerankDefaultModelLoading } = useDefaultModel(ModelTypeEnum.rerank)
|
||||
@@ -90,10 +92,14 @@ const ModelProviderPage = ({ searchText }: Props) => {
|
||||
return [filteredConfiguredProviders, filteredNotConfiguredProviders]
|
||||
}, [configuredProviders, debouncedSearchText, notConfiguredProviders])
|
||||
|
||||
useEffect(() => {
|
||||
mutateCurrentWorkspace()
|
||||
}, [mutateCurrentWorkspace])
|
||||
|
||||
return (
|
||||
<div className="relative -mt-2 pt-1">
|
||||
<div className={cn('mb-2 flex items-center')}>
|
||||
<div className="grow text-text-primary system-md-semibold">{t('modelProvider.models', { ns: 'common' })}</div>
|
||||
<div className="system-md-semibold grow text-text-primary">{t('modelProvider.models', { ns: 'common' })}</div>
|
||||
<div className={cn(
|
||||
'relative flex shrink-0 items-center justify-end gap-2 rounded-lg border border-transparent p-px',
|
||||
defaultModelNotConfigured && 'border-components-panel-border bg-components-panel-bg-blur pl-2 shadow-xs',
|
||||
@@ -101,7 +107,7 @@ const ModelProviderPage = ({ searchText }: Props) => {
|
||||
>
|
||||
{defaultModelNotConfigured && <div className="absolute bottom-0 left-0 right-0 top-0 opacity-40" style={{ background: 'linear-gradient(92deg, rgba(247, 144, 9, 0.25) 0%, rgba(255, 255, 255, 0.00) 100%)' }} />}
|
||||
{defaultModelNotConfigured && (
|
||||
<div className="flex items-center gap-1 text-text-primary system-xs-medium">
|
||||
<div className="system-xs-medium flex items-center gap-1 text-text-primary">
|
||||
<RiAlertFill className="h-4 w-4 text-text-warning-secondary" />
|
||||
<span className="max-w-[460px] truncate" title={t('modelProvider.notConfigured', { ns: 'common' })}>{t('modelProvider.notConfigured', { ns: 'common' })}</span>
|
||||
</div>
|
||||
@@ -117,14 +123,14 @@ const ModelProviderPage = ({ searchText }: Props) => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{IS_CLOUD_EDITION && <QuotaPanel providers={providers} />}
|
||||
{IS_CLOUD_EDITION && <QuotaPanel providers={providers} isLoading={isValidatingCurrentWorkspace} />}
|
||||
{!filteredConfiguredProviders?.length && (
|
||||
<div className="mb-2 rounded-[10px] bg-workflow-process-bg p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg backdrop-blur">
|
||||
<RiBrainLine className="h-5 w-5 text-text-primary" />
|
||||
</div>
|
||||
<div className="mt-2 text-text-secondary system-sm-medium">{t('modelProvider.emptyProviderTitle', { ns: 'common' })}</div>
|
||||
<div className="mt-1 text-text-tertiary system-xs-regular">{t('modelProvider.emptyProviderTip', { ns: 'common' })}</div>
|
||||
<div className="system-sm-medium mt-2 text-text-secondary">{t('modelProvider.emptyProviderTitle', { ns: 'common' })}</div>
|
||||
<div className="system-xs-regular mt-1 text-text-tertiary">{t('modelProvider.emptyProviderTip', { ns: 'common' })}</div>
|
||||
</div>
|
||||
)}
|
||||
{!!filteredConfiguredProviders?.length && (
|
||||
@@ -139,7 +145,7 @@ const ModelProviderPage = ({ searchText }: Props) => {
|
||||
)}
|
||||
{!!filteredNotConfiguredProviders?.length && (
|
||||
<>
|
||||
<div className="mb-2 flex items-center pt-2 text-text-primary system-md-semibold">{t('modelProvider.toBeConfigured', { ns: 'common' })}</div>
|
||||
<div className="system-md-semibold mb-2 flex items-center pt-2 text-text-primary">{t('modelProvider.toBeConfigured', { ns: 'common' })}</div>
|
||||
<div className="relative">
|
||||
{filteredNotConfiguredProviders?.map(provider => (
|
||||
<ProviderAddedCard
|
||||
|
||||
@@ -48,12 +48,14 @@ const providerKeyToPluginId: Record<ModelProviderQuotaGetPaid, string> = {
|
||||
|
||||
type QuotaPanelProps = {
|
||||
providers: ModelProvider[]
|
||||
isLoading?: boolean
|
||||
}
|
||||
const QuotaPanel: FC<QuotaPanelProps> = ({
|
||||
providers,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { currentWorkspace, isLoadingCurrentWorkspace } = useAppContext()
|
||||
const { currentWorkspace } = useAppContext()
|
||||
const { trial_models } = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const credits = Math.max((currentWorkspace.trial_credits - currentWorkspace.trial_credits_used) || 0, 0)
|
||||
const providerMap = useMemo(() => new Map(
|
||||
@@ -96,7 +98,7 @@ const QuotaPanel: FC<QuotaPanelProps> = ({
|
||||
}
|
||||
}, [providers, isShowInstallModal, hideInstallFromMarketplace])
|
||||
|
||||
if (isLoadingCurrentWorkspace) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="my-2 flex min-h-[72px] items-center justify-center rounded-xl border-[0.5px] border-components-panel-border bg-third-party-model-bg-default shadow-xs">
|
||||
<Loading />
|
||||
@@ -104,18 +106,15 @@ const QuotaPanel: FC<QuotaPanelProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
if (!currentWorkspace.id)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className={cn('my-2 min-w-[72px] shrink-0 rounded-xl border-[0.5px] pb-2.5 pl-4 pr-2.5 pt-3 shadow-xs', credits <= 0 ? 'border-state-destructive-border hover:bg-state-destructive-hover' : 'border-components-panel-border bg-third-party-model-bg-default')}>
|
||||
<div className="mb-2 flex h-4 items-center text-text-tertiary system-xs-medium-uppercase">
|
||||
<div className="system-xs-medium-uppercase mb-2 flex h-4 items-center text-text-tertiary">
|
||||
{t('modelProvider.quota', { ns: 'common' })}
|
||||
<Tooltip popupContent={t('modelProvider.card.tip', { ns: 'common', modelNames: trial_models.map(key => modelNameMap[key as keyof typeof modelNameMap]).filter(Boolean).join(', ') })} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 text-xs text-text-tertiary">
|
||||
<span className="mr-0.5 text-text-secondary system-md-semibold-uppercase">{formatNumber(credits)}</span>
|
||||
<span className="system-md-semibold-uppercase mr-0.5 text-text-secondary">{formatNumber(credits)}</span>
|
||||
<span>{t('modelProvider.credits', { ns: 'common' })}</span>
|
||||
{currentWorkspace.next_credit_reset_date
|
||||
? (
|
||||
|
||||
@@ -26,9 +26,11 @@ export type AppContextValue = {
|
||||
isCurrentWorkspaceOwner: boolean
|
||||
isCurrentWorkspaceEditor: boolean
|
||||
isCurrentWorkspaceDatasetOperator: boolean
|
||||
mutateCurrentWorkspace: VoidFunction
|
||||
langGeniusVersionInfo: LangGeniusVersionResponse
|
||||
useSelector: typeof useSelector
|
||||
isLoadingCurrentWorkspace: boolean
|
||||
isValidatingCurrentWorkspace: boolean
|
||||
}
|
||||
|
||||
const userProfilePlaceholder = {
|
||||
@@ -71,9 +73,11 @@ const AppContext = createContext<AppContextValue>({
|
||||
isCurrentWorkspaceEditor: false,
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
mutateUserProfile: noop,
|
||||
mutateCurrentWorkspace: noop,
|
||||
langGeniusVersionInfo: initialLangGeniusVersionInfo,
|
||||
useSelector,
|
||||
isLoadingCurrentWorkspace: false,
|
||||
isValidatingCurrentWorkspace: false,
|
||||
})
|
||||
|
||||
export function useSelector<T>(selector: (value: AppContextValue) => T): T {
|
||||
@@ -88,7 +92,7 @@ export const AppContextProvider: FC<AppContextProviderProps> = ({ children }) =>
|
||||
const queryClient = useQueryClient()
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const { data: userProfileResp } = useUserProfile()
|
||||
const { data: currentWorkspaceResp, isPending: isLoadingCurrentWorkspace } = useCurrentWorkspace()
|
||||
const { data: currentWorkspaceResp, isPending: isLoadingCurrentWorkspace, isFetching: isValidatingCurrentWorkspace } = useCurrentWorkspace()
|
||||
const langGeniusVersionQuery = useLangGeniusVersion(
|
||||
userProfileResp?.meta.currentVersion,
|
||||
!systemFeatures.branding.enabled,
|
||||
@@ -120,6 +124,10 @@ export const AppContextProvider: FC<AppContextProviderProps> = ({ children }) =>
|
||||
queryClient.invalidateQueries({ queryKey: ['common', 'user-profile'] })
|
||||
}, [queryClient])
|
||||
|
||||
const mutateCurrentWorkspace = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['common', 'current-workspace'] })
|
||||
}, [queryClient])
|
||||
|
||||
// #region Zendesk conversation fields
|
||||
useEffect(() => {
|
||||
if (ZENDESK_FIELD_IDS.ENVIRONMENT && langGeniusVersionInfo?.current_env) {
|
||||
@@ -191,7 +199,9 @@ export const AppContextProvider: FC<AppContextProviderProps> = ({ children }) =>
|
||||
isCurrentWorkspaceOwner,
|
||||
isCurrentWorkspaceEditor,
|
||||
isCurrentWorkspaceDatasetOperator,
|
||||
mutateCurrentWorkspace,
|
||||
isLoadingCurrentWorkspace,
|
||||
isValidatingCurrentWorkspace,
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full flex-col overflow-y-auto">
|
||||
|
||||
@@ -3076,6 +3076,12 @@
|
||||
}
|
||||
},
|
||||
"app/components/custom/custom-web-app-brand/index.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 12
|
||||
},
|
||||
"tailwindcss/no-unnecessary-whitespace": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
@@ -4512,6 +4518,11 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"app/components/header/account-setting/model-provider-page/index.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"app/components/header/account-setting/model-provider-page/install-from-marketplace.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 3
|
||||
@@ -4715,6 +4726,11 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"app/components/header/account-setting/model-provider-page/provider-added-card/quota-panel.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/header/account-setting/model-provider-page/provider-icon/index.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dify-web",
|
||||
"type": "module",
|
||||
"version": "1.12.1",
|
||||
"version": "1.13.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a",
|
||||
"imports": {
|
||||
|
||||
@@ -22,7 +22,7 @@ import type {
|
||||
UserProfileResponse,
|
||||
} from '@/models/common'
|
||||
import type { RETRIEVE_METHOD } from '@/types/app'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { IS_DEV } from '@/config'
|
||||
import { get, post } from './base'
|
||||
import { useInvalid } from './use-base'
|
||||
@@ -318,15 +318,6 @@ export const useInvalidDataSourceIntegrates = () => {
|
||||
return useInvalid(commonQueryKeys.dataSourceIntegrates)
|
||||
}
|
||||
|
||||
export const useInvalidateCurrentWorkspace = () => {
|
||||
const queryClient = useQueryClient()
|
||||
return () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: commonQueryKeys.currentWorkspace,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const usePluginProviders = () => {
|
||||
return useQuery<PluginProvider[] | null>({
|
||||
queryKey: commonQueryKeys.pluginProviders,
|
||||
|
||||
Reference in New Issue
Block a user