mirror of
https://github.com/langgenius/dify.git
synced 2026-09-24 23:22:26 +08:00
chore: paginate installed apps (#39739)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: yyh <yuanyouhuilyz@gmail.com> Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
This commit is contained in:
co-authored by
autofix-ci[bot]
yyh
yyh
parent
fd804dfcc0
commit
c1fdd6fb78
@@ -1,6 +1,6 @@
|
||||
import type { InstalledAppResponse } from '@dify/contracts/api/console/installed-apps/types.gen'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ChatConfig } from '../../types'
|
||||
import type { InstalledApp } from '@/models/explore'
|
||||
import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share'
|
||||
import { ToastHost } from '@langgenius/dify-ui/toast'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
@@ -1034,17 +1034,25 @@ describe('useChatWithHistory', () => {
|
||||
describe('installedApp mode', () => {
|
||||
it('should use installedApp source type and derive appData from installedAppInfo', async () => {
|
||||
// Arrange
|
||||
const installedAppInfo = {
|
||||
const installedAppInfo: InstalledAppResponse = {
|
||||
id: 'installed-app-id',
|
||||
app_owner_tenant_id: 'tenant-id',
|
||||
editable: true,
|
||||
is_pinned: false,
|
||||
last_used_at: null,
|
||||
uninstallable: true,
|
||||
app: {
|
||||
id: 'app-id',
|
||||
name: 'Installed App',
|
||||
description: 'Installed app description',
|
||||
mode: 'chat',
|
||||
icon_type: 'emoji',
|
||||
icon: '🤖',
|
||||
icon_background: '#fff',
|
||||
icon_url: '',
|
||||
icon_url: null,
|
||||
use_icon_as_answer_icon: false,
|
||||
},
|
||||
} as unknown as InstalledApp
|
||||
}
|
||||
mockFetchConversations.mockResolvedValue(createConversationData())
|
||||
mockFetchChatList.mockResolvedValue({ data: [] })
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { InstalledAppResponse } from '@dify/contracts/api/console/installed-apps/types.gen'
|
||||
import type { ExtraContent } from '../chat/type'
|
||||
import type { Callback, ChatConfig, ChatItem, Feedback } from '../types'
|
||||
import type { InstalledApp } from '@/models/explore'
|
||||
import type { AppData, ConversationItem } from '@/models/share'
|
||||
import type { HumanInputFilledFormData, HumanInputFormData } from '@/types/workflow'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
@@ -128,7 +128,7 @@ function getFormattedChatList(messages: any[]) {
|
||||
})
|
||||
return newChatList
|
||||
}
|
||||
export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
|
||||
export const useChatWithHistory = (installedAppInfo?: InstalledAppResponse) => {
|
||||
const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo])
|
||||
const appSourceType = isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp
|
||||
const appInfo = useWebAppStore((s) => s.appInfo)
|
||||
@@ -150,7 +150,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
|
||||
title: app.name,
|
||||
description: app.description,
|
||||
icon_type: app.icon_type,
|
||||
icon: app.icon,
|
||||
icon: app.icon ?? undefined,
|
||||
icon_background: app.icon_background,
|
||||
icon_url: app.icon_url,
|
||||
prompt_public: false,
|
||||
@@ -158,9 +158,8 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
|
||||
show_workflow_steps: true,
|
||||
use_icon_as_answer_icon: app.use_icon_as_answer_icon,
|
||||
},
|
||||
plan: 'basic',
|
||||
custom_config: null,
|
||||
} as AppData
|
||||
} satisfies AppData
|
||||
}
|
||||
return appInfo
|
||||
}, [isInstalledApp, installedAppInfo, appInfo])
|
||||
@@ -539,7 +538,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
|
||||
handleUpdateConversationList()
|
||||
},
|
||||
[
|
||||
isInstalledApp,
|
||||
appSourceType,
|
||||
appId,
|
||||
t,
|
||||
handleUpdateConversationList,
|
||||
@@ -575,7 +574,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
|
||||
setConversationRenaming(false)
|
||||
}
|
||||
},
|
||||
[isInstalledApp, appId, t, conversationRenaming, originConversationList],
|
||||
[appSourceType, appId, t, conversationRenaming, originConversationList],
|
||||
)
|
||||
const handleNewConversationCompleted = useCallback(
|
||||
(newConversationId: string) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
import type { InstalledAppResponse } from '@dify/contracts/api/console/installed-apps/types.gen'
|
||||
import type { FC } from 'react'
|
||||
import type { ChatProps } from '../chat'
|
||||
import type { InstalledApp } from '@/models/explore'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useEffect, useState } from 'react'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
@@ -87,7 +87,7 @@ const ChatWithHistory: FC<ChatWithHistoryProps> = ({ className }) => {
|
||||
}
|
||||
|
||||
type ChatWithHistoryWrapProps = {
|
||||
installedAppInfo?: InstalledApp
|
||||
installedAppInfo?: InstalledAppResponse
|
||||
className?: string
|
||||
isNewAgent?: boolean
|
||||
renderAgentContent?: ChatProps['renderAgentContent']
|
||||
|
||||
@@ -5,7 +5,6 @@ import Explore from '../index'
|
||||
|
||||
const mockReplace = vi.fn()
|
||||
const mockPush = vi.fn()
|
||||
const mockInstalledAppsData = { installed_apps: [] as const }
|
||||
type MediaTypeValue = (typeof MediaType)[keyof typeof MediaType]
|
||||
|
||||
let mockMediaType: MediaTypeValue = MediaType.pc
|
||||
@@ -28,19 +27,6 @@ vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-explore', () => ({
|
||||
useGetInstalledApps: () => ({
|
||||
isPending: false,
|
||||
data: mockInstalledAppsData,
|
||||
}),
|
||||
useUninstallApp: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
}),
|
||||
useUpdateAppPinStatus: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('Explore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
+49
-16
@@ -1,17 +1,30 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import AppNavItem from '../index'
|
||||
import type { InstalledAppResponse } from '@dify/contracts/api/console/installed-apps/types.gen'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import AppNavItem from '../app-nav-item'
|
||||
|
||||
const baseProps = {
|
||||
name: 'My App',
|
||||
id: 'app-123',
|
||||
icon_type: 'emoji' as const,
|
||||
icon: '🤖',
|
||||
icon_background: '#fff',
|
||||
icon_url: '',
|
||||
app: {
|
||||
id: 'app-123',
|
||||
app_owner_tenant_id: 'tenant-1',
|
||||
editable: true,
|
||||
is_pinned: false,
|
||||
last_used_at: null,
|
||||
uninstallable: false,
|
||||
app: {
|
||||
id: 'source-app-123',
|
||||
name: 'My App',
|
||||
description: 'Description',
|
||||
mode: 'chat',
|
||||
icon_type: 'emoji',
|
||||
icon: '🤖',
|
||||
icon_background: '#fff',
|
||||
icon_url: null,
|
||||
use_icon_as_answer_icon: false,
|
||||
},
|
||||
} satisfies InstalledAppResponse,
|
||||
isSelected: false,
|
||||
isPinned: false,
|
||||
togglePin: vi.fn(),
|
||||
uninstallable: false,
|
||||
onTogglePin: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
}
|
||||
|
||||
@@ -59,20 +72,40 @@ describe('AppNavItem', () => {
|
||||
})
|
||||
|
||||
it('should call onDelete with app id when delete action is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<AppNavItem {...baseProps} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.more' }))
|
||||
await user.click(await screen.findByText('explore.sidebar.action.delete'))
|
||||
|
||||
expect(baseProps.onDelete).toHaveBeenCalledWith('app-123')
|
||||
})
|
||||
|
||||
it('should request the next pin state', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<AppNavItem {...baseProps} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.more' }))
|
||||
await user.click(await screen.findByText('explore.sidebar.action.pin'))
|
||||
|
||||
expect(baseProps.onTogglePin).toHaveBeenCalledWith('app-123', true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should not render delete action when app is uninstallable', () => {
|
||||
render(<AppNavItem {...baseProps} uninstallable />)
|
||||
it('should not render delete action when app is uninstallable', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<AppNavItem
|
||||
{...baseProps}
|
||||
app={{
|
||||
...baseProps.app,
|
||||
uninstallable: true,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.more' }))
|
||||
|
||||
expect(screen.queryByText('explore.sidebar.action.delete')).not.toBeInTheDocument()
|
||||
})
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
import type { RefObject } from 'react'
|
||||
import { act, render } from '@testing-library/react'
|
||||
import { InfiniteScrollSentinel } from '../infinite-scroll-sentinel'
|
||||
|
||||
describe('InfiniteScrollSentinel', () => {
|
||||
it('does not observe again after a next-page request completes', () => {
|
||||
const fetchNextPage = vi.fn(() => Promise.resolve())
|
||||
const scrollRootRef: RefObject<HTMLDivElement | null> = {
|
||||
current: document.createElement('div'),
|
||||
}
|
||||
const observerConstructed = vi.fn()
|
||||
|
||||
vi.stubGlobal(
|
||||
'IntersectionObserver',
|
||||
class MockIntersectionObserver {
|
||||
private readonly callback: IntersectionObserverCallback
|
||||
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
this.callback = callback
|
||||
observerConstructed()
|
||||
}
|
||||
|
||||
observe() {
|
||||
this.callback(
|
||||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||
this as unknown as IntersectionObserver,
|
||||
)
|
||||
}
|
||||
|
||||
disconnect() {}
|
||||
unobserve() {}
|
||||
},
|
||||
)
|
||||
|
||||
const { rerender } = render(
|
||||
<InfiniteScrollSentinel
|
||||
canFetchNextPage
|
||||
fetchNextPage={fetchNextPage}
|
||||
isFetchingNextPage={false}
|
||||
scrollRootRef={scrollRootRef}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(fetchNextPage).toHaveBeenCalledOnce()
|
||||
|
||||
act(() => {
|
||||
rerender(
|
||||
<InfiniteScrollSentinel
|
||||
canFetchNextPage
|
||||
fetchNextPage={fetchNextPage}
|
||||
isFetchingNextPage
|
||||
scrollRootRef={scrollRootRef}
|
||||
/>,
|
||||
)
|
||||
})
|
||||
act(() => {
|
||||
rerender(
|
||||
<InfiniteScrollSentinel
|
||||
canFetchNextPage
|
||||
fetchNextPage={fetchNextPage}
|
||||
isFetchingNextPage={false}
|
||||
scrollRootRef={scrollRootRef}
|
||||
/>,
|
||||
)
|
||||
})
|
||||
|
||||
expect(observerConstructed).toHaveBeenCalledOnce()
|
||||
expect(fetchNextPage).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
+13
-21
@@ -1,5 +1,5 @@
|
||||
'use client'
|
||||
import type { AppIconType } from '@/types/app'
|
||||
import type { InstalledAppResponse } from '@dify/contracts/api/console/installed-apps/types.gen'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import * as React from 'react'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
@@ -9,35 +9,27 @@ import Link from '@/next/link'
|
||||
|
||||
type IAppNavItemProps = {
|
||||
variant?: 'default' | 'mainNav'
|
||||
name: string
|
||||
ariaLabel?: string
|
||||
id: string
|
||||
icon_type: AppIconType | null
|
||||
icon: string
|
||||
icon_background: string
|
||||
icon_url: string
|
||||
app: InstalledAppResponse
|
||||
isSelected: boolean
|
||||
isPinned: boolean
|
||||
togglePin: () => void
|
||||
uninstallable: boolean
|
||||
onTogglePin: (id: string, isPinned: boolean) => void
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
|
||||
export default function AppNavItem({
|
||||
variant = 'default',
|
||||
name,
|
||||
ariaLabel,
|
||||
id,
|
||||
icon_type,
|
||||
icon,
|
||||
icon_background,
|
||||
icon_url,
|
||||
app: installedApp,
|
||||
isSelected,
|
||||
isPinned,
|
||||
togglePin,
|
||||
uninstallable,
|
||||
onTogglePin,
|
||||
onDelete,
|
||||
}: IAppNavItemProps) {
|
||||
const {
|
||||
id,
|
||||
is_pinned: isPinned,
|
||||
uninstallable,
|
||||
app: { name, icon_type, icon, icon_background, icon_url },
|
||||
} = installedApp
|
||||
const url = buildInstalledAppPath(id)
|
||||
const isMainNav = variant === 'mainNav'
|
||||
|
||||
@@ -68,7 +60,7 @@ export default function AppNavItem({
|
||||
size="tiny"
|
||||
className={cn(isMainNav && 'size-5 rounded-md text-sm')}
|
||||
iconType={icon_type}
|
||||
icon={icon}
|
||||
icon={icon ?? undefined}
|
||||
background={icon_background}
|
||||
imageUrl={icon_url}
|
||||
/>
|
||||
@@ -90,7 +82,7 @@ export default function AppNavItem({
|
||||
>
|
||||
<ItemOperation
|
||||
isPinned={isPinned}
|
||||
togglePin={togglePin}
|
||||
togglePin={() => onTogglePin(id, !isPinned)}
|
||||
isShowDelete={!uninstallable && !isSelected}
|
||||
onDelete={() => onDelete(id)}
|
||||
/>
|
||||
@@ -0,0 +1,50 @@
|
||||
'use client'
|
||||
|
||||
import type { RefObject } from 'react'
|
||||
import { useEffect, useEffectEvent, useRef } from 'react'
|
||||
|
||||
type InfiniteScrollSentinelProps = {
|
||||
canFetchNextPage: boolean
|
||||
fetchNextPage: () => Promise<unknown>
|
||||
isFetchingNextPage: boolean
|
||||
scrollRootRef: RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
export const InfiniteScrollSentinel = ({
|
||||
canFetchNextPage,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
scrollRootRef,
|
||||
}: InfiniteScrollSentinelProps) => {
|
||||
const sentinelRef = useRef<HTMLDivElement>(null)
|
||||
const handleIntersection = useEffectEvent((entry: IntersectionObserverEntry) => {
|
||||
if (entry.isIntersecting && canFetchNextPage && !isFetchingNextPage) void fetchNextPage()
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const scrollRoot = scrollRootRef.current
|
||||
const sentinel = sentinelRef.current
|
||||
if (
|
||||
!canFetchNextPage ||
|
||||
!scrollRoot ||
|
||||
!sentinel ||
|
||||
typeof IntersectionObserver === 'undefined'
|
||||
)
|
||||
return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry) handleIntersection(entry)
|
||||
},
|
||||
{
|
||||
root: scrollRoot,
|
||||
rootMargin: '0px 0px 64px 0px',
|
||||
},
|
||||
)
|
||||
|
||||
observer.observe(sentinel)
|
||||
return () => observer.disconnect()
|
||||
}, [canFetchNextPage, scrollRootRef])
|
||||
|
||||
return <div ref={sentinelRef} aria-hidden className="h-px" />
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
const skeletonClassName =
|
||||
'animate-pulse rounded bg-text-quaternary opacity-20 motion-reduce:animate-none'
|
||||
|
||||
export const InstalledAppPaginationSkeleton = () => (
|
||||
<div aria-hidden className="flex h-8 items-center gap-2 px-2 py-0.5">
|
||||
<div className={`${skeletonClassName} size-5 shrink-0 rounded-md`} />
|
||||
<div className={`${skeletonClassName} h-3 w-24`} />
|
||||
</div>
|
||||
)
|
||||
@@ -1,125 +1,149 @@
|
||||
import type {
|
||||
AppMode,
|
||||
InstalledAppResponse,
|
||||
} from '@dify/contracts/api/console/installed-apps/types.gen'
|
||||
import type { Mock } from 'vitest'
|
||||
import type { InstalledApp as InstalledAppType } from '@/models/explore'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control'
|
||||
import {
|
||||
useGetInstalledAppAccessModeByAppId,
|
||||
useGetInstalledAppMeta,
|
||||
useGetInstalledAppParams,
|
||||
useGetInstalledApps,
|
||||
} from '@/service/use-explore'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import InstalledApp from '../index'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getAccessMode: vi.fn(),
|
||||
getInstalledApp: vi.fn(),
|
||||
getInstalledAppMeta: vi.fn(),
|
||||
getInstalledAppParameters: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
systemFeatures: {
|
||||
get: {
|
||||
queryKey: () => ['system-features'],
|
||||
queryOptions: () => ({
|
||||
queryKey: ['system-features'],
|
||||
queryFn: async () => ({ webapp_auth: { enabled: true } }),
|
||||
}),
|
||||
},
|
||||
},
|
||||
enterprise: {
|
||||
webAppAuth: {
|
||||
getWebAppAccessMode: {
|
||||
queryOptions: ({ input }: { input: { query: { appId: string } } }) => ({
|
||||
queryKey: ['installed-app-access-mode', input.query.appId],
|
||||
queryFn: () => mocks.getAccessMode(input.query.appId),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
installedApps: {
|
||||
byInstalledAppId: {
|
||||
get: {
|
||||
queryOptions: ({ input }: { input: { params: { installed_app_id: string } } }) => ({
|
||||
queryKey: ['installed-app', input.params.installed_app_id],
|
||||
queryFn: () => mocks.getInstalledApp(input.params.installed_app_id),
|
||||
retry: false,
|
||||
}),
|
||||
},
|
||||
meta: {
|
||||
get: {
|
||||
queryOptions: ({ input }: { input: { params: { installed_app_id: string } } }) => ({
|
||||
queryKey: ['installed-app-meta', input.params.installed_app_id],
|
||||
queryFn: () => mocks.getInstalledAppMeta(input.params.installed_app_id),
|
||||
}),
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
get: {
|
||||
queryOptions: ({ input }: { input: { params: { installed_app_id: string } } }) => ({
|
||||
queryKey: ['installed-app-parameters', input.params.installed_app_id],
|
||||
queryFn: () => mocks.getInstalledAppParameters(input.params.installed_app_id),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/context/web-app-context', () => ({
|
||||
useWebAppStore: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/access-control/use-app-access-control', () => ({
|
||||
useGetUserCanAccessApp: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/service/use-explore', () => ({
|
||||
useGetInstalledAppAccessModeByAppId: vi.fn(),
|
||||
useGetInstalledAppParams: vi.fn(),
|
||||
useGetInstalledAppMeta: vi.fn(),
|
||||
useGetInstalledApps: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/share/text-generation', () => ({
|
||||
default: ({
|
||||
isInstalledApp,
|
||||
installedAppInfo,
|
||||
isWorkflow,
|
||||
}: {
|
||||
isInstalledApp?: boolean
|
||||
installedAppInfo?: InstalledAppType
|
||||
isWorkflow?: boolean
|
||||
}) => (
|
||||
<div data-testid="text-generation-app">
|
||||
Text Generation App
|
||||
{isWorkflow && ' (Workflow)'}
|
||||
{isInstalledApp && ` - ${installedAppInfo?.id}`}
|
||||
</div>
|
||||
default: ({ isWorkflow }: { isWorkflow?: boolean }) => (
|
||||
<div>{isWorkflow ? 'Workflow App' : 'Completion App'}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/chat/chat-with-history', () => ({
|
||||
default: ({
|
||||
installedAppInfo,
|
||||
className,
|
||||
}: {
|
||||
installedAppInfo?: InstalledAppType
|
||||
className?: string
|
||||
}) => (
|
||||
<div data-testid="chat-with-history" className={className}>
|
||||
Chat With History - {installedAppInfo?.id}
|
||||
</div>
|
||||
),
|
||||
default: () => <div>Chat App</div>,
|
||||
}))
|
||||
|
||||
const createInstalledApp = (mode: AppMode = 'chat'): InstalledAppResponse => ({
|
||||
id: 'installed-app-123',
|
||||
app_owner_tenant_id: 'tenant-1',
|
||||
editable: true,
|
||||
is_pinned: false,
|
||||
last_used_at: null,
|
||||
uninstallable: true,
|
||||
app: {
|
||||
id: 'app-123',
|
||||
name: 'Test App',
|
||||
description: 'Test description',
|
||||
mode,
|
||||
icon_type: 'emoji',
|
||||
icon: '🚀',
|
||||
icon_background: '#FFFFFF',
|
||||
icon_url: null,
|
||||
use_icon_as_answer_icon: false,
|
||||
},
|
||||
})
|
||||
|
||||
describe('InstalledApp', () => {
|
||||
const mockUpdateAppInfo = vi.fn()
|
||||
const mockUpdateWebAppAccessMode = vi.fn()
|
||||
const mockUpdateAppParams = vi.fn()
|
||||
const mockUpdateWebAppMeta = vi.fn()
|
||||
const mockUpdateUserCanAccessApp = vi.fn()
|
||||
|
||||
const mockInstalledApp = {
|
||||
id: 'installed-app-123',
|
||||
app: {
|
||||
id: 'app-123',
|
||||
name: 'Test App',
|
||||
mode: AppModeEnum.CHAT,
|
||||
icon_type: 'emoji' as const,
|
||||
icon: '🚀',
|
||||
icon_background: '#FFFFFF',
|
||||
icon_url: '',
|
||||
description: 'Test description',
|
||||
use_icon_as_answer_icon: false,
|
||||
},
|
||||
uninstallable: true,
|
||||
is_pinned: false,
|
||||
}
|
||||
|
||||
const mockAppParams = {
|
||||
const updateAppInfo = vi.fn()
|
||||
const updateWebAppAccessMode = vi.fn()
|
||||
const updateAppParams = vi.fn()
|
||||
const updateWebAppMeta = vi.fn()
|
||||
const updateUserCanAccessApp = vi.fn()
|
||||
const appParams = {
|
||||
user_input_form: [],
|
||||
file_upload: { image: { enabled: false, number_limits: 0, transfer_methods: [] } },
|
||||
system_parameters: {},
|
||||
}
|
||||
|
||||
const mockAppMeta = {
|
||||
tool_icons: {},
|
||||
}
|
||||
|
||||
const mockWebAppAccessMode = {
|
||||
accessMode: AccessMode.PUBLIC,
|
||||
}
|
||||
|
||||
const mockUserCanAccessApp = {
|
||||
result: true,
|
||||
}
|
||||
|
||||
const setupMocks = (
|
||||
installedApps: InstalledAppType[] = [mockInstalledApp],
|
||||
options: {
|
||||
isPending?: boolean
|
||||
isFetching?: boolean
|
||||
} = {},
|
||||
) => {
|
||||
const { isPending = false, isFetching = false } = options
|
||||
|
||||
;(useGetInstalledApps as Mock).mockReturnValue({
|
||||
data: { installed_apps: installedApps },
|
||||
isPending,
|
||||
isFetching,
|
||||
})
|
||||
}
|
||||
const appMeta = { tool_icons: {} }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
setupMocks()
|
||||
mocks.getAccessMode.mockResolvedValue({ accessMode: AccessMode.PUBLIC })
|
||||
mocks.getInstalledApp.mockResolvedValue(createInstalledApp())
|
||||
mocks.getInstalledAppMeta.mockResolvedValue(appMeta)
|
||||
mocks.getInstalledAppParameters.mockResolvedValue({
|
||||
annotation_reply: {},
|
||||
file_upload: appParams.file_upload,
|
||||
more_like_this: {},
|
||||
opening_statement: null,
|
||||
retriever_resource: {},
|
||||
sensitive_word_avoidance: {},
|
||||
speech_to_text: {},
|
||||
suggested_questions: [],
|
||||
suggested_questions_after_answer: {},
|
||||
system_parameters: {
|
||||
audio_file_size_limit: 10,
|
||||
file_size_limit: 15,
|
||||
image_file_size_limit: 10,
|
||||
video_file_size_limit: 100,
|
||||
workflow_file_upload_limit: 10,
|
||||
},
|
||||
text_to_speech: {},
|
||||
user_input_form: [],
|
||||
})
|
||||
|
||||
;(useWebAppStore as unknown as Mock).mockImplementation(
|
||||
(
|
||||
@@ -130,492 +154,113 @@ describe('InstalledApp', () => {
|
||||
updateWebAppMeta: Mock
|
||||
updateUserCanAccessApp: Mock
|
||||
}) => unknown,
|
||||
) => {
|
||||
const state = {
|
||||
updateAppInfo: mockUpdateAppInfo,
|
||||
updateWebAppAccessMode: mockUpdateWebAppAccessMode,
|
||||
updateAppParams: mockUpdateAppParams,
|
||||
updateWebAppMeta: mockUpdateWebAppMeta,
|
||||
updateUserCanAccessApp: mockUpdateUserCanAccessApp,
|
||||
}
|
||||
return selector(state)
|
||||
},
|
||||
) =>
|
||||
selector({
|
||||
updateAppInfo,
|
||||
updateWebAppAccessMode,
|
||||
updateAppParams,
|
||||
updateWebAppMeta,
|
||||
updateUserCanAccessApp,
|
||||
}),
|
||||
)
|
||||
|
||||
;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({
|
||||
isPending: false,
|
||||
data: mockWebAppAccessMode,
|
||||
error: null,
|
||||
})
|
||||
|
||||
;(useGetInstalledAppParams as Mock).mockReturnValue({
|
||||
isPending: false,
|
||||
data: mockAppParams,
|
||||
error: null,
|
||||
})
|
||||
|
||||
;(useGetInstalledAppMeta as Mock).mockReturnValue({
|
||||
isPending: false,
|
||||
data: mockAppMeta,
|
||||
error: null,
|
||||
})
|
||||
|
||||
;(useGetUserCanAccessApp as Mock).mockReturnValue({
|
||||
data: mockUserCanAccessApp,
|
||||
data: { result: true },
|
||||
error: null,
|
||||
isPending: false,
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render loading state when fetching app params', () => {
|
||||
;(useGetInstalledAppParams as Mock).mockReturnValue({
|
||||
isPending: true,
|
||||
data: null,
|
||||
error: null,
|
||||
})
|
||||
it.each<AppMode>(['chat', 'advanced-chat', 'agent-chat'])(
|
||||
'renders chat for the supported %s mode',
|
||||
async (mode) => {
|
||||
mocks.getInstalledApp.mockResolvedValue(createInstalledApp(mode))
|
||||
|
||||
const { container } = render(<InstalledApp id="installed-app-123" />)
|
||||
const svg = container.querySelector('svg.spin-animation')
|
||||
expect(svg).toBeInTheDocument()
|
||||
renderWithConsoleQuery(<InstalledApp id="installed-app-123" />)
|
||||
|
||||
expect(await screen.findByText('Chat App')).toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
|
||||
it('renders completion and workflow surfaces explicitly', async () => {
|
||||
mocks.getInstalledApp.mockResolvedValue(createInstalledApp('completion'))
|
||||
const { rerender } = renderWithConsoleQuery(<InstalledApp id="installed-app-123" />)
|
||||
expect(await screen.findByText('Completion App')).toBeInTheDocument()
|
||||
|
||||
mocks.getInstalledApp.mockResolvedValue(createInstalledApp('workflow'))
|
||||
rerender(<InstalledApp id="installed-app-456" />)
|
||||
expect(await screen.findByText('Workflow App')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each<AppMode>(['agent', 'channel', 'rag-pipeline'])(
|
||||
'fails closed for unsupported %s mode',
|
||||
async (mode) => {
|
||||
mocks.getInstalledApp.mockResolvedValue(createInstalledApp(mode))
|
||||
|
||||
renderWithConsoleQuery(<InstalledApp id="installed-app-123" />)
|
||||
|
||||
expect(await screen.findByText('Unsupported installed app mode.')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Chat App')).not.toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
|
||||
it('starts route-owned parameter, metadata, and access queries without waiting for detail', async () => {
|
||||
mocks.getInstalledApp.mockReturnValue(new Promise(() => {}))
|
||||
|
||||
renderWithConsoleQuery(<InstalledApp id="installed-app-123" />, {
|
||||
systemFeatures: { webapp_auth: { enabled: true } },
|
||||
})
|
||||
|
||||
it('should render loading state when fetching app meta', () => {
|
||||
;(useGetInstalledAppMeta as Mock).mockReturnValue({
|
||||
isPending: true,
|
||||
data: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const { container } = render(<InstalledApp id="installed-app-123" />)
|
||||
const svg = container.querySelector('svg.spin-animation')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render loading state when fetching web app access mode', () => {
|
||||
;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({
|
||||
isPending: true,
|
||||
data: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const { container } = render(<InstalledApp id="installed-app-123" />)
|
||||
const svg = container.querySelector('svg.spin-animation')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render loading state when fetching installed apps', () => {
|
||||
setupMocks([mockInstalledApp], { isPending: true })
|
||||
|
||||
const { container } = render(<InstalledApp id="installed-app-123" />)
|
||||
const svg = container.querySelector('svg.spin-animation')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app not found (404) when installedApp does not exist', () => {
|
||||
setupMocks([])
|
||||
|
||||
render(<InstalledApp id="nonexistent-app" />)
|
||||
expect(screen.getByText(/404/)).toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(mocks.getAccessMode).toHaveBeenCalledWith('installed-app-123')
|
||||
expect(mocks.getInstalledAppParameters).toHaveBeenCalledWith('installed-app-123')
|
||||
expect(mocks.getInstalledAppMeta).toHaveBeenCalledWith('installed-app-123')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error States', () => {
|
||||
it('should render error when app params fails to load', () => {
|
||||
const error = new Error('Failed to load app params')
|
||||
;(useGetInstalledAppParams as Mock).mockReturnValue({
|
||||
isPending: false,
|
||||
data: null,
|
||||
error,
|
||||
})
|
||||
it('writes one contract-derived app snapshot into the installed-app runtime store', async () => {
|
||||
renderWithConsoleQuery(<InstalledApp id="installed-app-123" />)
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
expect(screen.getByText(/Failed to load app params/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render error when app meta fails to load', () => {
|
||||
const error = new Error('Failed to load app meta')
|
||||
;(useGetInstalledAppMeta as Mock).mockReturnValue({
|
||||
isPending: false,
|
||||
data: null,
|
||||
error,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
expect(screen.getByText(/Failed to load app meta/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render error when web app access mode fails to load', () => {
|
||||
const error = new Error('Failed to load access mode')
|
||||
;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({
|
||||
isPending: false,
|
||||
data: null,
|
||||
error,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
expect(screen.getByText(/Failed to load access mode/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render error when user access check fails', () => {
|
||||
const error = new Error('Failed to check user access')
|
||||
;(useGetUserCanAccessApp as Mock).mockReturnValue({
|
||||
data: null,
|
||||
error,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
expect(screen.getByText(/Failed to check user access/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render no permission (403) when user cannot access app', () => {
|
||||
;(useGetUserCanAccessApp as Mock).mockReturnValue({
|
||||
data: { result: false },
|
||||
error: null,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
expect(screen.getByText(/403/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/no permission/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('App Mode Rendering', () => {
|
||||
it('should render ChatWithHistory for CHAT mode', async () => {
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
expect(await screen.findByText(/Chat With History/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/Text Generation App/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render ChatWithHistory for ADVANCED_CHAT mode', () => {
|
||||
const advancedChatApp = {
|
||||
...mockInstalledApp,
|
||||
app: {
|
||||
...mockInstalledApp.app,
|
||||
mode: AppModeEnum.ADVANCED_CHAT,
|
||||
await waitFor(() =>
|
||||
expect(updateAppInfo).toHaveBeenCalledWith({
|
||||
app_id: 'installed-app-123',
|
||||
custom_config: null,
|
||||
site: {
|
||||
title: 'Test App',
|
||||
description: 'Test description',
|
||||
icon_type: 'emoji',
|
||||
icon: '🚀',
|
||||
icon_background: '#FFFFFF',
|
||||
icon_url: null,
|
||||
prompt_public: false,
|
||||
copyright: '',
|
||||
show_workflow_steps: true,
|
||||
use_icon_as_answer_icon: false,
|
||||
},
|
||||
}
|
||||
setupMocks([advancedChatApp])
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
expect(screen.getByText(/Chat With History/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/Text Generation App/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render ChatWithHistory for AGENT_CHAT mode', () => {
|
||||
const agentChatApp = {
|
||||
...mockInstalledApp,
|
||||
app: {
|
||||
...mockInstalledApp.app,
|
||||
mode: AppModeEnum.AGENT_CHAT,
|
||||
},
|
||||
}
|
||||
setupMocks([agentChatApp])
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
expect(screen.getByText(/Chat With History/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/Text Generation App/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render TextGenerationApp for COMPLETION mode', () => {
|
||||
const completionApp = {
|
||||
...mockInstalledApp,
|
||||
app: {
|
||||
...mockInstalledApp.app,
|
||||
mode: AppModeEnum.COMPLETION,
|
||||
},
|
||||
}
|
||||
setupMocks([completionApp])
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
expect(screen.getByText(/Text Generation App/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/Workflow/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render TextGenerationApp with workflow flag for WORKFLOW mode', () => {
|
||||
const workflowApp = {
|
||||
...mockInstalledApp,
|
||||
app: {
|
||||
...mockInstalledApp.app,
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
},
|
||||
}
|
||||
setupMocks([workflowApp])
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
expect(screen.getByText(/Text Generation App/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Workflow/)).toBeInTheDocument()
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should use id prop to find installed app', () => {
|
||||
const app1 = { ...mockInstalledApp, id: 'app-1' }
|
||||
const app2 = { ...mockInstalledApp, id: 'app-2' }
|
||||
setupMocks([app1, app2])
|
||||
|
||||
render(<InstalledApp id="app-2" />)
|
||||
expect(screen.getByText(/app-2/)).toBeInTheDocument()
|
||||
it('does not mount the app surface before the access query settles', async () => {
|
||||
;(useGetUserCanAccessApp as Mock).mockReturnValue({
|
||||
data: undefined,
|
||||
error: null,
|
||||
isPending: true,
|
||||
})
|
||||
|
||||
it('should handle id that does not match any installed app', () => {
|
||||
render(<InstalledApp id="nonexistent-id" />)
|
||||
expect(screen.getByText(/404/)).toBeInTheDocument()
|
||||
})
|
||||
renderWithConsoleQuery(<InstalledApp id="installed-app-123" />)
|
||||
|
||||
await waitFor(() => expect(mocks.getInstalledApp).toHaveBeenCalled())
|
||||
expect(screen.queryByText('Chat App')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('Effects', () => {
|
||||
it('should update app info when installedApp is available', async () => {
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
it('distinguishes a missing installed app from a recoverable detail failure', async () => {
|
||||
mocks.getInstalledApp.mockRejectedValue(new Response(null, { status: 404 }))
|
||||
const { unmount } = renderWithConsoleQuery(<InstalledApp id="missing-app" />)
|
||||
expect(await screen.findByText(/404/)).toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAppInfo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
app_id: 'installed-app-123',
|
||||
site: expect.objectContaining({
|
||||
title: 'Test App',
|
||||
icon_type: 'emoji',
|
||||
icon: '🚀',
|
||||
icon_background: '#FFFFFF',
|
||||
icon_url: '',
|
||||
prompt_public: false,
|
||||
copyright: '',
|
||||
show_workflow_steps: true,
|
||||
use_icon_as_answer_icon: false,
|
||||
}),
|
||||
plan: 'basic',
|
||||
custom_config: null,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should update app info to null when installedApp is not found', async () => {
|
||||
setupMocks([])
|
||||
|
||||
render(<InstalledApp id="nonexistent-app" />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAppInfo).toHaveBeenCalledWith(null)
|
||||
})
|
||||
})
|
||||
|
||||
it('should update app params when data is available', async () => {
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAppParams).toHaveBeenCalledWith(mockAppParams)
|
||||
})
|
||||
})
|
||||
|
||||
it('should update app meta when data is available', async () => {
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateWebAppMeta).toHaveBeenCalledWith(mockAppMeta)
|
||||
})
|
||||
})
|
||||
|
||||
it('should update web app access mode when data is available', async () => {
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateWebAppAccessMode).toHaveBeenCalledWith(AccessMode.PUBLIC)
|
||||
})
|
||||
})
|
||||
|
||||
it('should update user can access app when data is available', async () => {
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateUserCanAccessApp).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should update user can access app to false when result is false', async () => {
|
||||
;(useGetUserCanAccessApp as Mock).mockReturnValue({
|
||||
data: { result: false },
|
||||
error: null,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateUserCanAccessApp).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should update user can access app to false when data is null', async () => {
|
||||
;(useGetUserCanAccessApp as Mock).mockReturnValue({
|
||||
data: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateUserCanAccessApp).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not update app params when data is null', async () => {
|
||||
;(useGetInstalledAppParams as Mock).mockReturnValue({
|
||||
isPending: false,
|
||||
data: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAppInfo).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
expect(mockUpdateAppParams).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not update app meta when data is null', async () => {
|
||||
;(useGetInstalledAppMeta as Mock).mockReturnValue({
|
||||
isPending: false,
|
||||
data: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAppInfo).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
expect(mockUpdateWebAppMeta).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not update access mode when data is null', async () => {
|
||||
;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({
|
||||
isPending: false,
|
||||
data: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAppInfo).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
expect(mockUpdateWebAppAccessMode).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty installedApps array', () => {
|
||||
setupMocks([])
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
expect(screen.getByText(/404/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle multiple installed apps and find the correct one', () => {
|
||||
const otherApp = {
|
||||
...mockInstalledApp,
|
||||
id: 'other-app-id',
|
||||
app: {
|
||||
...mockInstalledApp.app,
|
||||
name: 'Other App',
|
||||
},
|
||||
}
|
||||
setupMocks([otherApp, mockInstalledApp])
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
expect(screen.getByText(/Chat With History/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/installed-app-123/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle rapid id prop changes', async () => {
|
||||
const app1 = { ...mockInstalledApp, id: 'app-1' }
|
||||
const app2 = { ...mockInstalledApp, id: 'app-2' }
|
||||
setupMocks([app1, app2])
|
||||
|
||||
const { rerender } = render(<InstalledApp id="app-1" />)
|
||||
expect(screen.getByText(/app-1/)).toBeInTheDocument()
|
||||
|
||||
rerender(<InstalledApp id="app-2" />)
|
||||
expect(screen.getByText(/app-2/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call service hooks with correct appId', () => {
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
|
||||
expect(useGetInstalledAppAccessModeByAppId).toHaveBeenCalledWith('installed-app-123')
|
||||
expect(useGetInstalledAppParams).toHaveBeenCalledWith('installed-app-123')
|
||||
expect(useGetInstalledAppMeta).toHaveBeenCalledWith('installed-app-123')
|
||||
expect(useGetUserCanAccessApp).toHaveBeenCalledWith({
|
||||
appId: 'app-123',
|
||||
isInstalledApp: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should call service hooks with null when installedApp is not found', () => {
|
||||
setupMocks([])
|
||||
|
||||
render(<InstalledApp id="nonexistent-app" />)
|
||||
|
||||
expect(useGetInstalledAppAccessModeByAppId).toHaveBeenCalledWith(null)
|
||||
expect(useGetInstalledAppParams).toHaveBeenCalledWith(null)
|
||||
expect(useGetInstalledAppMeta).toHaveBeenCalledWith(null)
|
||||
expect(useGetUserCanAccessApp).toHaveBeenCalledWith({
|
||||
appId: undefined,
|
||||
isInstalledApp: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Render Priority', () => {
|
||||
it('should show error before loading state', () => {
|
||||
;(useGetInstalledAppParams as Mock).mockReturnValue({
|
||||
isPending: true,
|
||||
data: null,
|
||||
error: new Error('Some error'),
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
expect(screen.getByText(/Some error/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show error before permission check', () => {
|
||||
;(useGetInstalledAppParams as Mock).mockReturnValue({
|
||||
isPending: false,
|
||||
data: null,
|
||||
error: new Error('Params error'),
|
||||
})
|
||||
;(useGetUserCanAccessApp as Mock).mockReturnValue({
|
||||
data: { result: false },
|
||||
error: null,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
expect(screen.getByText(/Params error/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/403/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show permission error before 404', () => {
|
||||
setupMocks([])
|
||||
;(useGetUserCanAccessApp as Mock).mockReturnValue({
|
||||
data: { result: false },
|
||||
error: null,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="nonexistent-app" />)
|
||||
expect(screen.getByText(/403/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/404/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show loading before 404 while installed apps are refetching', () => {
|
||||
setupMocks([], { isFetching: true })
|
||||
|
||||
const { container } = render(<InstalledApp id="nonexistent-app" />)
|
||||
const svg = container.querySelector('svg.spin-animation')
|
||||
expect(svg).toBeInTheDocument()
|
||||
expect(screen.queryByText(/404/)).not.toBeInTheDocument()
|
||||
})
|
||||
mocks.getInstalledApp.mockRejectedValue(new Error('Network unavailable'))
|
||||
renderWithConsoleQuery(<InstalledApp id="offline-app" />)
|
||||
expect(await screen.findByText('Network unavailable')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
'use client'
|
||||
import type { AccessMode } from '@/models/access-control'
|
||||
import type { AppMode } from '@dify/contracts/api/console/installed-apps/types.gen'
|
||||
import type { AppData } from '@/models/share'
|
||||
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import * as React from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import TextGenerationApp from '@/app/components/share/text-generation'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import { useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control'
|
||||
import {
|
||||
useGetInstalledAppAccessModeByAppId,
|
||||
useGetInstalledAppMeta,
|
||||
useGetInstalledAppParams,
|
||||
useGetInstalledApps,
|
||||
} from '@/service/use-explore'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import AppUnavailable from '../../base/app-unavailable'
|
||||
import { toInstalledAppAccessMode, toInstalledAppMeta, toInstalledAppParameters } from './runtime'
|
||||
|
||||
const ChatWithHistory = dynamic(() => import('@/app/components/base/chat/chat-with-history'), {
|
||||
ssr: false,
|
||||
@@ -34,13 +32,41 @@ const InstalledTextGenerationSurface = ({ children }: { children: React.ReactNod
|
||||
</div>
|
||||
)
|
||||
|
||||
const getInstalledAppSurface = (
|
||||
mode: AppMode,
|
||||
): 'chat' | 'completion' | 'unsupported' | 'workflow' => {
|
||||
switch (mode) {
|
||||
case 'chat':
|
||||
case 'advanced-chat':
|
||||
case 'agent-chat':
|
||||
return 'chat'
|
||||
case 'completion':
|
||||
return 'completion'
|
||||
case 'workflow':
|
||||
return 'workflow'
|
||||
case 'agent':
|
||||
case 'channel':
|
||||
case 'rag-pipeline':
|
||||
return 'unsupported'
|
||||
}
|
||||
}
|
||||
|
||||
const InstalledApp = ({ id }: { id: string }) => {
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const webappAuthEnabled = systemFeatures.webapp_auth.enabled
|
||||
const {
|
||||
data,
|
||||
isPending: isPendingInstalledApps,
|
||||
isFetching: isFetchingInstalledApps,
|
||||
} = useGetInstalledApps()
|
||||
const installedApp = data?.installed_apps?.find((item) => item.id === id)
|
||||
data: installedApp,
|
||||
isPending: isPendingInstalledApp,
|
||||
error: installedAppError,
|
||||
} = useQuery({
|
||||
...consoleQuery.installedApps.byInstalledAppId.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
installed_app_id: id,
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
const updateAppInfo = useWebAppStore((s) => s.updateAppInfo)
|
||||
const updateWebAppAccessMode = useWebAppStore((s) => s.updateWebAppAccessMode)
|
||||
const updateAppParams = useWebAppStore((s) => s.updateAppParams)
|
||||
@@ -50,18 +76,41 @@ const InstalledApp = ({ id }: { id: string }) => {
|
||||
isPending: isPendingWebAppAccessMode,
|
||||
data: webAppAccessMode,
|
||||
error: webAppAccessModeError,
|
||||
} = useGetInstalledAppAccessModeByAppId(installedApp?.id ?? null)
|
||||
} = useQuery({
|
||||
...consoleQuery.enterprise.webAppAuth.getWebAppAccessMode.queryOptions({
|
||||
input: { query: { appId: id } },
|
||||
}),
|
||||
enabled: webappAuthEnabled,
|
||||
select: toInstalledAppAccessMode,
|
||||
})
|
||||
const {
|
||||
isPending: isPendingAppParams,
|
||||
data: appParams,
|
||||
error: appParamsError,
|
||||
} = useGetInstalledAppParams(installedApp?.id ?? null)
|
||||
} = useQuery({
|
||||
...consoleQuery.installedApps.byInstalledAppId.parameters.get.queryOptions({
|
||||
input: { params: { installed_app_id: id } },
|
||||
}),
|
||||
select: toInstalledAppParameters,
|
||||
})
|
||||
const {
|
||||
isPending: isPendingAppMeta,
|
||||
data: appMeta,
|
||||
error: appMetaError,
|
||||
} = useGetInstalledAppMeta(installedApp?.id ?? null)
|
||||
const { data: userCanAccessApp, error: useCanAccessAppError } = useGetUserCanAccessApp({
|
||||
} = useQuery({
|
||||
...consoleQuery.installedApps.byInstalledAppId.meta.get.queryOptions({
|
||||
input: { params: { installed_app_id: id } },
|
||||
}),
|
||||
select: toInstalledAppMeta,
|
||||
})
|
||||
const resolvedWebAppAccessMode = webappAuthEnabled
|
||||
? webAppAccessMode?.accessMode
|
||||
: AccessMode.PUBLIC
|
||||
const {
|
||||
data: userCanAccessApp,
|
||||
error: useCanAccessAppError,
|
||||
isPending: isPendingUserCanAccessApp,
|
||||
} = useGetUserCanAccessApp({
|
||||
appId: installedApp?.app.id,
|
||||
isInstalledApp: true,
|
||||
})
|
||||
@@ -77,7 +126,7 @@ const InstalledApp = ({ id }: { id: string }) => {
|
||||
title: app.name,
|
||||
description: app.description,
|
||||
icon_type: app.icon_type,
|
||||
icon: app.icon,
|
||||
icon: app.icon ?? undefined,
|
||||
icon_background: app.icon_background,
|
||||
icon_url: app.icon_url,
|
||||
prompt_public: false,
|
||||
@@ -85,18 +134,14 @@ const InstalledApp = ({ id }: { id: string }) => {
|
||||
show_workflow_steps: true,
|
||||
use_icon_as_answer_icon: app.use_icon_as_answer_icon,
|
||||
},
|
||||
plan: 'basic',
|
||||
custom_config: null,
|
||||
} as AppData)
|
||||
} satisfies AppData)
|
||||
}
|
||||
|
||||
if (appParams) updateAppParams(appParams)
|
||||
if (appMeta) updateWebAppMeta(appMeta)
|
||||
if (webAppAccessMode)
|
||||
updateWebAppAccessMode((webAppAccessMode as { accessMode: AccessMode }).accessMode)
|
||||
updateUserCanAccessApp(
|
||||
Boolean(userCanAccessApp && (userCanAccessApp as { result: boolean })?.result),
|
||||
)
|
||||
if (resolvedWebAppAccessMode) updateWebAppAccessMode(resolvedWebAppAccessMode)
|
||||
updateUserCanAccessApp(Boolean(userCanAccessApp?.result))
|
||||
}, [
|
||||
installedApp,
|
||||
appMeta,
|
||||
@@ -106,7 +151,7 @@ const InstalledApp = ({ id }: { id: string }) => {
|
||||
updateUserCanAccessApp,
|
||||
updateWebAppMeta,
|
||||
userCanAccessApp,
|
||||
webAppAccessMode,
|
||||
resolvedWebAppAccessMode,
|
||||
updateWebAppAccessMode,
|
||||
])
|
||||
|
||||
@@ -146,6 +191,20 @@ const InstalledApp = ({ id }: { id: string }) => {
|
||||
</InstalledAppFrame>
|
||||
)
|
||||
}
|
||||
if (installedAppError) {
|
||||
const isNotFound = installedAppError instanceof Response && installedAppError.status === 404
|
||||
return (
|
||||
<InstalledAppFrame>
|
||||
<div className="flex h-full items-center justify-center">
|
||||
{isNotFound ? (
|
||||
<AppUnavailable code={404} isUnknownReason />
|
||||
) : (
|
||||
<AppUnavailable unknownReason={installedAppError.message} />
|
||||
)}
|
||||
</div>
|
||||
</InstalledAppFrame>
|
||||
)
|
||||
}
|
||||
if (userCanAccessApp && !userCanAccessApp.result) {
|
||||
return (
|
||||
<InstalledAppFrame>
|
||||
@@ -156,9 +215,11 @@ const InstalledApp = ({ id }: { id: string }) => {
|
||||
)
|
||||
}
|
||||
if (
|
||||
isPendingInstalledApps ||
|
||||
(!installedApp && isFetchingInstalledApps) ||
|
||||
(installedApp && (isPendingAppParams || isPendingAppMeta || isPendingWebAppAccessMode))
|
||||
isPendingInstalledApp ||
|
||||
isPendingAppParams ||
|
||||
isPendingAppMeta ||
|
||||
(webappAuthEnabled && isPendingWebAppAccessMode) ||
|
||||
isPendingUserCanAccessApp
|
||||
) {
|
||||
return (
|
||||
<InstalledAppFrame>
|
||||
@@ -177,23 +238,34 @@ const InstalledApp = ({ id }: { id: string }) => {
|
||||
</InstalledAppFrame>
|
||||
)
|
||||
}
|
||||
const surface = getInstalledAppSurface(installedApp.app.mode)
|
||||
|
||||
if (surface === 'unsupported') {
|
||||
return (
|
||||
<InstalledAppFrame>
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<AppUnavailable unknownReason="Unsupported installed app mode." />
|
||||
</div>
|
||||
</InstalledAppFrame>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<InstalledAppFrame>
|
||||
{installedApp?.app.mode !== AppModeEnum.COMPLETION &&
|
||||
installedApp?.app.mode !== AppModeEnum.WORKFLOW && (
|
||||
<ChatWithHistory
|
||||
installedAppInfo={installedApp}
|
||||
className={`overflow-hidden rounded-2xl shadow-md ${installedAppSurfaceClassName}`}
|
||||
/>
|
||||
)}
|
||||
{installedApp?.app.mode === AppModeEnum.COMPLETION && (
|
||||
{surface === 'chat' && (
|
||||
<ChatWithHistory
|
||||
installedAppInfo={installedApp}
|
||||
className={`overflow-hidden rounded-2xl shadow-md ${installedAppSurfaceClassName}`}
|
||||
/>
|
||||
)}
|
||||
{surface === 'completion' && (
|
||||
<InstalledTextGenerationSurface>
|
||||
<TextGenerationApp isInstalledApp installedAppInfo={installedApp} />
|
||||
<TextGenerationApp isInstalledApp />
|
||||
</InstalledTextGenerationSurface>
|
||||
)}
|
||||
{installedApp?.app.mode === AppModeEnum.WORKFLOW && (
|
||||
{surface === 'workflow' && (
|
||||
<InstalledTextGenerationSurface>
|
||||
<TextGenerationApp isWorkflow isInstalledApp installedAppInfo={installedApp} />
|
||||
<TextGenerationApp isWorkflow isInstalledApp />
|
||||
</InstalledTextGenerationSurface>
|
||||
)}
|
||||
</InstalledAppFrame>
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import type {
|
||||
ExploreAppMetaResponse,
|
||||
Parameters as InstalledAppParametersResponse,
|
||||
} from '@dify/contracts/api/console/installed-apps/types.gen'
|
||||
import type { GetWebAppAccessModeRes } from '@dify/contracts/enterprise/types.gen'
|
||||
import type { ChatConfig } from '@/app/components/base/chat/types'
|
||||
import type { AccessMode } from '@/models/access-control'
|
||||
import type { AppMeta, ToolIcon } from '@/models/share'
|
||||
import { isAccessMode } from '@/models/access-control'
|
||||
import { PromptMode } from '@/models/debug'
|
||||
import { RETRIEVE_TYPE, TtsAutoPlay } from '@/types/app'
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
|
||||
const getValue = (source: object, key: string): unknown => Reflect.get(source, key)
|
||||
|
||||
const getStringProperty = (source: object, key: string) => {
|
||||
const value = getValue(source, key)
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
const getBooleanProperty = (source: object, key: string) => {
|
||||
const value = getValue(source, key)
|
||||
return typeof value === 'boolean' ? value : false
|
||||
}
|
||||
|
||||
const isTtsAutoPlay = (value: unknown): value is TtsAutoPlay =>
|
||||
value === TtsAutoPlay.enabled || value === TtsAutoPlay.disabled
|
||||
|
||||
const isUserInputFormItem = (value: unknown): value is ChatConfig['user_input_form'][number] => {
|
||||
if (!isRecord(value)) return false
|
||||
|
||||
return [
|
||||
'text-input',
|
||||
'select',
|
||||
'paragraph',
|
||||
'number',
|
||||
'checkbox',
|
||||
'file',
|
||||
'file-list',
|
||||
'external_data_tool',
|
||||
'json_object',
|
||||
].some((key) => isRecord(getValue(value, key)))
|
||||
}
|
||||
|
||||
const isModel = (
|
||||
value: unknown,
|
||||
): value is NonNullable<ChatConfig['suggested_questions_after_answer']['model']> => isRecord(value)
|
||||
|
||||
const isAnnotationReplyConfig = (
|
||||
value: unknown,
|
||||
): value is NonNullable<ChatConfig['annotation_reply']> => isRecord(value)
|
||||
|
||||
const isFileUploadConfig = (value: unknown): value is NonNullable<ChatConfig['file_upload']> =>
|
||||
isRecord(value)
|
||||
|
||||
const defaultDatasetConfigs = (): ChatConfig['dataset_configs'] => ({
|
||||
retrieval_model: RETRIEVE_TYPE.oneWay,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
top_k: 4,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: null,
|
||||
datasets: {
|
||||
datasets: [],
|
||||
},
|
||||
})
|
||||
|
||||
const normalizeEnabledConfig = (value: unknown): { enabled: boolean } => {
|
||||
const record = isRecord(value) ? value : {}
|
||||
|
||||
return {
|
||||
...record,
|
||||
enabled: getBooleanProperty(record, 'enabled'),
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeSuggestedQuestionsAfterAnswer = (
|
||||
value: unknown,
|
||||
): ChatConfig['suggested_questions_after_answer'] => {
|
||||
const record = isRecord(value) ? value : {}
|
||||
const model = getValue(record, 'model')
|
||||
const prompt = getStringProperty(record, 'prompt')
|
||||
|
||||
return {
|
||||
enabled: getBooleanProperty(record, 'enabled'),
|
||||
...(isModel(model) ? { model } : {}),
|
||||
...(prompt ? { prompt } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeTextToSpeech = (value: unknown): ChatConfig['text_to_speech'] => {
|
||||
const record = isRecord(value) ? value : {}
|
||||
const autoPlay = getValue(record, 'autoPlay')
|
||||
const normalizedAutoPlay = isTtsAutoPlay(autoPlay) ? autoPlay : undefined
|
||||
|
||||
return {
|
||||
...record,
|
||||
enabled: getBooleanProperty(record, 'enabled'),
|
||||
voice: getStringProperty(record, 'voice') || undefined,
|
||||
language: getStringProperty(record, 'language') || undefined,
|
||||
...(normalizedAutoPlay ? { autoPlay: normalizedAutoPlay } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeSystemParameters = (
|
||||
systemParameters: InstalledAppParametersResponse['system_parameters'],
|
||||
): ChatConfig['system_parameters'] => ({
|
||||
audio_file_size_limit: systemParameters.audio_file_size_limit,
|
||||
file_size_limit: systemParameters.file_size_limit,
|
||||
image_file_size_limit: systemParameters.image_file_size_limit,
|
||||
video_file_size_limit: systemParameters.video_file_size_limit,
|
||||
workflow_file_upload_limit: systemParameters.workflow_file_upload_limit,
|
||||
})
|
||||
|
||||
export const toInstalledAppParameters = (response: InstalledAppParametersResponse): ChatConfig => ({
|
||||
opening_statement: response.opening_statement ?? '',
|
||||
suggested_questions: response.suggested_questions,
|
||||
pre_prompt: '',
|
||||
prompt_type: PromptMode.simple,
|
||||
user_input_form: response.user_input_form.filter(isUserInputFormItem),
|
||||
more_like_this: normalizeEnabledConfig(response.more_like_this),
|
||||
suggested_questions_after_answer: normalizeSuggestedQuestionsAfterAnswer(
|
||||
response.suggested_questions_after_answer,
|
||||
),
|
||||
speech_to_text: normalizeEnabledConfig(response.speech_to_text),
|
||||
text_to_speech: normalizeTextToSpeech(response.text_to_speech),
|
||||
retriever_resource: normalizeEnabledConfig(response.retriever_resource),
|
||||
sensitive_word_avoidance: normalizeEnabledConfig(response.sensitive_word_avoidance),
|
||||
...(isAnnotationReplyConfig(response.annotation_reply)
|
||||
? { annotation_reply: response.annotation_reply }
|
||||
: {}),
|
||||
agent_mode: {
|
||||
enabled: false,
|
||||
tools: [],
|
||||
},
|
||||
dataset_configs: defaultDatasetConfigs(),
|
||||
...(isFileUploadConfig(response.file_upload) ? { file_upload: response.file_upload } : {}),
|
||||
system_parameters: normalizeSystemParameters(response.system_parameters),
|
||||
})
|
||||
|
||||
export const toInstalledAppMeta = (response: ExploreAppMetaResponse): AppMeta => {
|
||||
const toolIcons: Record<string, ToolIcon> = {}
|
||||
|
||||
Object.entries(response.tool_icons ?? {}).forEach(([key, value]) => {
|
||||
toolIcons[key] = value
|
||||
})
|
||||
|
||||
return { tool_icons: toolIcons }
|
||||
}
|
||||
|
||||
export const toInstalledAppAccessMode = (
|
||||
response: GetWebAppAccessModeRes,
|
||||
): { accessMode: AccessMode } => {
|
||||
if (isAccessMode(response.accessMode)) return { accessMode: response.accessMode }
|
||||
|
||||
throw new Error('Web app access mode response returned an unsupported access mode.')
|
||||
}
|
||||
@@ -1,42 +1,73 @@
|
||||
import type { InstalledApp } from '@/models/explore'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { expectLoadingButton } from '@/test/button'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import type { InstalledAppResponse } from '@dify/contracts/api/console/installed-apps/types.gen'
|
||||
import { act, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import SideBar from '../index'
|
||||
|
||||
const { mockToastSuccess } = vi.hoisted(() => ({
|
||||
mockToastSuccess: vi.fn(),
|
||||
const mocks = vi.hoisted(() => ({
|
||||
fetchNextPage: vi.fn(),
|
||||
installedApps: [] as InstalledAppResponse[],
|
||||
hasNextPage: false,
|
||||
uninstall: vi.fn(),
|
||||
updatePinStatus: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockSegments = ['apps']
|
||||
const mockPush = vi.fn()
|
||||
const mockUninstall = vi.fn()
|
||||
const mockUpdatePinStatus = vi.fn()
|
||||
let mockIsPending = false
|
||||
let mockIsUninstallPending = false
|
||||
let mockInstalledApps: InstalledApp[] = []
|
||||
let intersectionCallback: IntersectionObserverCallback | undefined
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
usePathname: () => '/',
|
||||
useSelectedLayoutSegments: () => mockSegments,
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
useSelectedLayoutSegments: () => ['apps'],
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-explore', () => ({
|
||||
useGetInstalledApps: () => ({
|
||||
isPending: mockIsPending,
|
||||
data: { installed_apps: mockInstalledApps },
|
||||
}),
|
||||
useUninstallApp: () => ({
|
||||
mutateAsync: mockUninstall,
|
||||
isPending: mockIsUninstallPending,
|
||||
}),
|
||||
useUpdateAppPinStatus: () => ({
|
||||
mutateAsync: mockUpdatePinStatus,
|
||||
}),
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
systemFeatures: {
|
||||
get: {
|
||||
queryKey: () => ['system-features'],
|
||||
queryOptions: () => ({
|
||||
queryKey: ['system-features'],
|
||||
queryFn: () => new Promise(() => {}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
installedApps: {
|
||||
get: {
|
||||
infiniteOptions: (options: {
|
||||
getNextPageParam: (page: {
|
||||
has_more: boolean
|
||||
next_cursor: string | null
|
||||
}) => string | undefined
|
||||
initialPageParam: undefined
|
||||
input: (pageParam: string | undefined) => unknown
|
||||
select?: (data: unknown) => unknown
|
||||
}) => ({
|
||||
...options,
|
||||
queryKey: ['installed-apps'],
|
||||
queryFn: async ({ pageParam }: { pageParam: string | undefined }) => {
|
||||
if (pageParam) mocks.fetchNextPage(pageParam)
|
||||
return {
|
||||
installed_apps: pageParam ? [] : mocks.installedApps,
|
||||
has_more: pageParam ? false : mocks.hasNextPage,
|
||||
next_cursor: pageParam || !mocks.hasNextPage ? null : 'next-page',
|
||||
}
|
||||
},
|
||||
}),
|
||||
},
|
||||
byInstalledAppId: {
|
||||
delete: {
|
||||
mutationOptions: () => ({
|
||||
mutationFn: (input: unknown) => mocks.uninstall(input),
|
||||
}),
|
||||
},
|
||||
patch: {
|
||||
mutationOptions: () => ({
|
||||
mutationFn: (input: unknown) => mocks.updatePinStatus(input),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => {
|
||||
@@ -45,193 +76,122 @@ vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => {
|
||||
...actual,
|
||||
toast: {
|
||||
...actual.toast,
|
||||
success: mockToastSuccess,
|
||||
success: mocks.toastSuccess,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const createInstalledApp = (overrides: Partial<InstalledApp> = {}): InstalledApp => ({
|
||||
id: overrides.id ?? 'app-123',
|
||||
uninstallable: overrides.uninstallable ?? false,
|
||||
const createInstalledApp = (
|
||||
overrides: Partial<InstalledAppResponse> = {},
|
||||
): InstalledAppResponse => ({
|
||||
id: overrides.id ?? 'installed-app-1',
|
||||
app_owner_tenant_id: overrides.app_owner_tenant_id ?? 'tenant-1',
|
||||
editable: overrides.editable ?? true,
|
||||
is_pinned: overrides.is_pinned ?? false,
|
||||
last_used_at: overrides.last_used_at ?? null,
|
||||
uninstallable: overrides.uninstallable ?? false,
|
||||
app: {
|
||||
id: overrides.app?.id ?? 'app-basic-id',
|
||||
mode: overrides.app?.mode ?? AppModeEnum.CHAT,
|
||||
id: overrides.app?.id ?? 'app-1',
|
||||
name: overrides.app?.name ?? 'My App',
|
||||
description: overrides.app?.description ?? 'Description',
|
||||
mode: overrides.app?.mode ?? '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 ?? 'My App',
|
||||
description: overrides.app?.description ?? 'desc',
|
||||
icon_background: overrides.app?.icon_background ?? '#FFFFFF',
|
||||
icon_url: overrides.app?.icon_url ?? null,
|
||||
use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false,
|
||||
},
|
||||
})
|
||||
|
||||
const renderSideBar = () => {
|
||||
return render(<SideBar />)
|
||||
}
|
||||
const renderSideBar = () => renderWithConsoleQuery(<SideBar />)
|
||||
|
||||
describe('SideBar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsPending = false
|
||||
mockIsUninstallPending = false
|
||||
mockInstalledApps = []
|
||||
mocks.installedApps = []
|
||||
mocks.hasNextPage = false
|
||||
mocks.uninstall.mockResolvedValue(undefined)
|
||||
mocks.updatePinStatus.mockResolvedValue({ result: 'success', message: 'updated' })
|
||||
intersectionCallback = undefined
|
||||
vi.stubGlobal(
|
||||
'IntersectionObserver',
|
||||
class MockIntersectionObserver {
|
||||
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
|
||||
if (options?.root) intersectionCallback = callback
|
||||
}
|
||||
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render discovery link', () => {
|
||||
renderSideBar()
|
||||
it('renders the empty state after the installed-app query settles', async () => {
|
||||
renderSideBar()
|
||||
|
||||
expect(screen.getByText('explore.sidebar.title')).toBeInTheDocument()
|
||||
expect(await screen.findByText('explore.sidebar.noApps.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders installed apps and folds to icon-only navigation', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.installedApps = [createInstalledApp()]
|
||||
renderSideBar()
|
||||
|
||||
expect(await screen.findByRole('link', { name: 'My App' })).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'layout.sidebar.collapseSidebar' }))
|
||||
|
||||
expect(screen.getByRole('button', { name: 'layout.sidebar.expandSidebar' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'My App' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('automatically fetches the next page when the sentinel enters the scroll viewport', async () => {
|
||||
mocks.installedApps = [createInstalledApp()]
|
||||
mocks.hasNextPage = true
|
||||
renderSideBar()
|
||||
await screen.findByRole('region', { name: 'explore.sidebar.webApps' })
|
||||
|
||||
expect(intersectionCallback).toBeDefined()
|
||||
act(() => {
|
||||
intersectionCallback?.(
|
||||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
})
|
||||
|
||||
it('should render workspace items when installed apps exist', () => {
|
||||
mockInstalledApps = [createInstalledApp()]
|
||||
renderSideBar()
|
||||
await waitFor(() => expect(mocks.fetchNextPage).toHaveBeenCalledWith('next-page'))
|
||||
})
|
||||
|
||||
expect(screen.getByText('explore.sidebar.webApps')).toBeInTheDocument()
|
||||
expect(screen.getByRole('region', { name: 'explore.sidebar.webApps' })).toBeInTheDocument()
|
||||
expect(screen.getByText('My App')).toBeInTheDocument()
|
||||
})
|
||||
it('uninstalls the selected app and closes the confirmation after success', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.installedApps = [createInstalledApp()]
|
||||
renderSideBar()
|
||||
|
||||
it('should render NoApps component when no installed apps on desktop', () => {
|
||||
renderSideBar()
|
||||
await user.click(await screen.findByRole('button', { name: 'common.operation.more' }))
|
||||
await user.click(await screen.findByText('explore.sidebar.action.delete'))
|
||||
await user.click(screen.getByText('common.operation.confirm'))
|
||||
|
||||
expect(screen.getByText('explore.sidebar.noApps.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render NoApps while loading', () => {
|
||||
mockIsPending = true
|
||||
renderSideBar()
|
||||
|
||||
expect(screen.queryByText('explore.sidebar.noApps.title')).not.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' } }),
|
||||
]
|
||||
renderSideBar()
|
||||
|
||||
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 } = renderSideBar()
|
||||
|
||||
const dividers = container.querySelectorAll('[class*="divider"], hr')
|
||||
expect(dividers.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render a button for toggling the sidebar and update its accessible name', () => {
|
||||
renderSideBar()
|
||||
|
||||
const toggleButton = screen.getByRole('button', { name: 'layout.sidebar.collapseSidebar' })
|
||||
fireEvent.click(toggleButton)
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'layout.sidebar.expandSidebar' }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render icon-only content when folded', () => {
|
||||
mockInstalledApps = [createInstalledApp()]
|
||||
renderSideBar()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'layout.sidebar.collapseSidebar' }))
|
||||
|
||||
expect(screen.getByRole('link', { name: 'explore.sidebar.title' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'My App' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'layout.sidebar.expandSidebar' }),
|
||||
).toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(mocks.uninstall).toHaveBeenCalledWith({
|
||||
params: { installed_app_id: 'installed-app-1' },
|
||||
})
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('common.api.remove')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should uninstall app and show toast when delete is confirmed', async () => {
|
||||
mockInstalledApps = [createInstalledApp()]
|
||||
mockUninstall.mockResolvedValue(undefined)
|
||||
renderSideBar()
|
||||
it('updates pin state through the generated mutation input', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.installedApps = [createInstalledApp()]
|
||||
renderSideBar()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
|
||||
fireEvent.click(await screen.findByText('common.operation.confirm'))
|
||||
await user.click(await screen.findByRole('button', { name: 'common.operation.more' }))
|
||||
await user.click(await screen.findByText('explore.sidebar.action.pin'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUninstall).toHaveBeenCalledWith('app-123')
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('common.api.remove')
|
||||
})
|
||||
})
|
||||
|
||||
it('should update pin status and show toast when pin is clicked', async () => {
|
||||
mockInstalledApps = [createInstalledApp({ is_pinned: false })]
|
||||
mockUpdatePinStatus.mockResolvedValue(undefined)
|
||||
renderSideBar()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.pin'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-123', isPinned: true })
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('common.api.success')
|
||||
})
|
||||
})
|
||||
|
||||
it('should unpin an already pinned app', async () => {
|
||||
mockInstalledApps = [createInstalledApp({ is_pinned: true })]
|
||||
mockUpdatePinStatus.mockResolvedValue(undefined)
|
||||
renderSideBar()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
|
||||
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()]
|
||||
renderSideBar()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
it('should disable dialog actions while uninstall is pending', async () => {
|
||||
mockInstalledApps = [createInstalledApp()]
|
||||
mockIsUninstallPending = true
|
||||
renderSideBar()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
|
||||
|
||||
expect(screen.getByText('common.operation.cancel')).toBeDisabled()
|
||||
expectLoadingButton(screen.getByText('common.operation.confirm').closest('button'))
|
||||
})
|
||||
await waitFor(() =>
|
||||
expect(mocks.updatePinStatus).toHaveBeenCalledWith({
|
||||
params: { installed_app_id: 'installed-app-1' },
|
||||
body: { is_pinned: true },
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
'use client'
|
||||
import type {
|
||||
InstalledAppListResponse,
|
||||
InstalledAppResponse,
|
||||
} from '@dify/contracts/api/console/installed-apps/types.gen'
|
||||
import type { InfiniteData } from '@tanstack/react-query'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
@@ -9,81 +14,112 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
|
||||
import {
|
||||
ScrollAreaContent,
|
||||
ScrollAreaRoot,
|
||||
ScrollAreaScrollbar,
|
||||
ScrollAreaThumb,
|
||||
ScrollAreaViewport,
|
||||
} from '@langgenius/dify-ui/scroll-area'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { useInfiniteQuery, useMutation } from '@tanstack/react-query'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import AppNavItem from '@/app/components/explore/installed-app-navigation/app-nav-item'
|
||||
import { InfiniteScrollSentinel } from '@/app/components/explore/installed-app-navigation/infinite-scroll-sentinel'
|
||||
import { InstalledAppPaginationSkeleton } from '@/app/components/explore/installed-app-navigation/pagination-skeleton'
|
||||
import Link from '@/next/link'
|
||||
import { usePathname, useSelectedLayoutSegments } from '@/next/navigation'
|
||||
import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/service/use-explore'
|
||||
import Item from './app-nav-item'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import NoApps from './no-apps'
|
||||
|
||||
const emptyInstalledApps: InstalledAppResponse[] = []
|
||||
|
||||
const selectInstalledApps = (data: InfiniteData<InstalledAppListResponse, string | undefined>) =>
|
||||
data.pages.flatMap((page) => page.installed_apps)
|
||||
|
||||
const SideBar = () => {
|
||||
const { t } = useTranslation()
|
||||
const pathname = usePathname()
|
||||
const scrollRef = React.useRef<HTMLDivElement>(null)
|
||||
const segments = useSelectedLayoutSegments()
|
||||
const lastSegment = segments.slice(-1)[0]
|
||||
const isDiscoverySelected = pathname === '/' || lastSegment === 'apps'
|
||||
const { data, isPending } = useGetInstalledApps()
|
||||
const installedApps = data?.installed_apps ?? []
|
||||
const { mutateAsync: uninstallApp, isPending: isUninstalling } = useUninstallApp()
|
||||
const { mutateAsync: updatePinStatus } = useUpdateAppPinStatus()
|
||||
const installedAppsQuery = useInfiniteQuery(
|
||||
consoleQuery.installedApps.get.infiniteOptions({
|
||||
input: (pageParam: string | undefined) => ({
|
||||
query: {
|
||||
limit: 20,
|
||||
...(typeof pageParam === 'string' ? { cursor: pageParam } : {}),
|
||||
},
|
||||
}),
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.has_more && lastPage.next_cursor ? lastPage.next_cursor : undefined,
|
||||
initialPageParam: undefined,
|
||||
select: selectInstalledApps,
|
||||
}),
|
||||
)
|
||||
const installedApps = installedAppsQuery.data ?? emptyInstalledApps
|
||||
const uninstallAppMutation = useMutation(
|
||||
consoleQuery.installedApps.byInstalledAppId.delete.mutationOptions(),
|
||||
)
|
||||
const updatePinStatusMutation = useMutation(
|
||||
consoleQuery.installedApps.byInstalledAppId.patch.mutationOptions(),
|
||||
)
|
||||
|
||||
const [isFold, { toggle: toggleIsFold }] = useBoolean(false)
|
||||
const [isFold, setIsFold] = useState(false)
|
||||
|
||||
const [showConfirm, setShowConfirm] = useState(false)
|
||||
const [currId, setCurrId] = useState('')
|
||||
const handleDelete = async () => {
|
||||
const id = currId
|
||||
await uninstallApp(id)
|
||||
setShowConfirm(false)
|
||||
toast.success(t(($) => $['api.remove'], { ns: 'common' }))
|
||||
const [uninstallDialogAppId, setUninstallDialogAppId] = useState<string | null>(null)
|
||||
const handleDelete = () => {
|
||||
if (!uninstallDialogAppId) return
|
||||
|
||||
uninstallAppMutation.mutate(
|
||||
{
|
||||
params: { installed_app_id: uninstallDialogAppId },
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setUninstallDialogAppId(null)
|
||||
toast.success(t(($) => $['api.remove'], { ns: 'common' }))
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const handleUpdatePinStatus = async (id: string, isPinned: boolean) => {
|
||||
await updatePinStatus({ appId: id, isPinned })
|
||||
toast.success(t(($) => $['api.success'], { ns: 'common' }))
|
||||
const handleUpdatePinStatus = (id: string, isPinned: boolean) => {
|
||||
updatePinStatusMutation.mutate(
|
||||
{
|
||||
params: { installed_app_id: id },
|
||||
body: { is_pinned: isPinned },
|
||||
},
|
||||
{
|
||||
onSuccess: () => toast.success(t(($) => $['api.success'], { ns: 'common' })),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const pinnedAppsCount = installedApps.filter(({ is_pinned }) => is_pinned).length
|
||||
const webAppsLabelId = React.useId()
|
||||
const installedAppItems = installedApps.map(
|
||||
(
|
||||
{ id, is_pinned, uninstallable, app: { name, icon_type, icon, icon_url, icon_background } },
|
||||
index,
|
||||
) => (
|
||||
<React.Fragment key={id}>
|
||||
<Item
|
||||
name={name}
|
||||
icon_type={icon_type}
|
||||
icon={icon}
|
||||
icon_background={icon_background}
|
||||
icon_url={icon_url}
|
||||
id={id}
|
||||
isSelected={lastSegment?.toLowerCase() === id}
|
||||
isPinned={is_pinned}
|
||||
togglePin={() => handleUpdatePinStatus(id, !is_pinned)}
|
||||
uninstallable={uninstallable}
|
||||
onDelete={(id) => {
|
||||
setCurrId(id)
|
||||
setShowConfirm(true)
|
||||
}}
|
||||
/>
|
||||
{index === pinnedAppsCount - 1 && index !== installedApps.length - 1 && <Divider />}
|
||||
</React.Fragment>
|
||||
),
|
||||
)
|
||||
const installedAppItems = installedApps.map((installedApp, index) => (
|
||||
<React.Fragment key={installedApp.id}>
|
||||
<AppNavItem
|
||||
app={installedApp}
|
||||
isSelected={lastSegment?.toLowerCase() === installedApp.id}
|
||||
onTogglePin={handleUpdatePinStatus}
|
||||
onDelete={setUninstallDialogAppId}
|
||||
/>
|
||||
{index === pinnedAppsCount - 1 && index !== installedApps.length - 1 && <Divider />}
|
||||
</React.Fragment>
|
||||
))
|
||||
|
||||
return (
|
||||
<div
|
||||
data-folded={isFold ? 'true' : undefined}
|
||||
className={cn(
|
||||
'group/sidebar flex h-full w-fit shrink-0 cursor-pointer flex-col px-3 pt-6 sm:w-[240px]',
|
||||
isFold && 'sm:w-[56px]',
|
||||
isFold && 'sm:w-14',
|
||||
)}
|
||||
>
|
||||
<div className={cn(isDiscoverySelected ? 'text-text-accent' : 'text-text-tertiary')}>
|
||||
@@ -116,9 +152,32 @@ const SideBar = () => {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{!isPending && installedApps.length === 0 && !isFold && (
|
||||
<div className="mt-5">
|
||||
<NoApps />
|
||||
{!installedAppsQuery.isPending &&
|
||||
!installedAppsQuery.isError &&
|
||||
installedApps.length === 0 &&
|
||||
!isFold && (
|
||||
<div className="mt-5">
|
||||
<NoApps />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!installedAppsQuery.isPending && installedAppsQuery.isError && !isFold && (
|
||||
<div
|
||||
className="mt-5 flex flex-col items-start gap-1 px-2 system-xs-regular text-text-tertiary"
|
||||
role="alert"
|
||||
>
|
||||
<span>{t(($) => $['errorBoundary.title'], { ns: 'common' })}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-text-accent outline-hidden hover:underline focus-visible:underline"
|
||||
onClick={() => {
|
||||
if (installedAppsQuery.isFetchNextPageError)
|
||||
void installedAppsQuery.fetchNextPage({ cancelRefetch: false })
|
||||
else void installedAppsQuery.refetch()
|
||||
}}
|
||||
>
|
||||
{t(($) => $['operation.retry'], { ns: 'common' })}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -134,20 +193,52 @@ const SideBar = () => {
|
||||
)}
|
||||
{!isFold ? (
|
||||
<div className="min-h-0 flex-1">
|
||||
<ScrollArea
|
||||
className="h-full"
|
||||
slotClassNames={{
|
||||
viewport: 'overscroll-contain',
|
||||
content: 'space-y-0.5 pr-3',
|
||||
}}
|
||||
labelledBy={webAppsLabelId}
|
||||
>
|
||||
{installedAppItems}
|
||||
</ScrollArea>
|
||||
<ScrollAreaRoot className="h-full">
|
||||
<ScrollAreaViewport
|
||||
ref={scrollRef}
|
||||
aria-busy={installedAppsQuery.isFetchingNextPage}
|
||||
aria-labelledby={webAppsLabelId}
|
||||
className="overscroll-contain"
|
||||
role="region"
|
||||
>
|
||||
<ScrollAreaContent className="space-y-0.5 pr-3">
|
||||
{installedAppItems}
|
||||
{installedAppsQuery.isFetchingNextPage && <InstalledAppPaginationSkeleton />}
|
||||
<InfiniteScrollSentinel
|
||||
canFetchNextPage={installedAppsQuery.hasNextPage && !installedAppsQuery.error}
|
||||
fetchNextPage={() =>
|
||||
installedAppsQuery.fetchNextPage({
|
||||
cancelRefetch: false,
|
||||
})
|
||||
}
|
||||
isFetchingNextPage={installedAppsQuery.isFetchingNextPage}
|
||||
scrollRootRef={scrollRef}
|
||||
/>
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full min-h-0 flex-1 space-y-0.5 overflow-x-hidden overflow-y-auto">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
aria-busy={installedAppsQuery.isFetchingNextPage}
|
||||
className="h-full min-h-0 flex-1 space-y-0.5 overflow-x-hidden overflow-y-auto"
|
||||
>
|
||||
{installedAppItems}
|
||||
{installedAppsQuery.isFetchingNextPage && <InstalledAppPaginationSkeleton />}
|
||||
<InfiniteScrollSentinel
|
||||
canFetchNextPage={installedAppsQuery.hasNextPage && !installedAppsQuery.error}
|
||||
fetchNextPage={() =>
|
||||
installedAppsQuery.fetchNextPage({
|
||||
cancelRefetch: false,
|
||||
})
|
||||
}
|
||||
isFetchingNextPage={installedAppsQuery.isFetchingNextPage}
|
||||
scrollRootRef={scrollRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -162,7 +253,7 @@ const SideBar = () => {
|
||||
: t(($) => $['sidebar.collapseSidebar'], { ns: 'layout' })
|
||||
}
|
||||
className="flex size-8 items-center justify-center rounded-lg text-text-tertiary transition-colors hover:bg-state-base-hover focus-visible:inset-ring-1 focus-visible:inset-ring-components-input-border-hover focus-visible:outline-hidden"
|
||||
onClick={toggleIsFold}
|
||||
onClick={() => setIsFold((value) => !value)}
|
||||
>
|
||||
{isFold ? (
|
||||
<span aria-hidden="true" className="i-ri-expand-right-line" />
|
||||
@@ -172,7 +263,12 @@ const SideBar = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={showConfirm} onOpenChange={setShowConfirm}>
|
||||
<AlertDialog
|
||||
open={uninstallDialogAppId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setUninstallDialogAppId(null)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<div className="flex flex-col items-start gap-2 self-stretch px-6 pt-6 pb-4">
|
||||
<AlertDialogTitle className="w-full title-2xl-semi-bold text-text-primary">
|
||||
@@ -183,12 +279,12 @@ const SideBar = () => {
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton disabled={isUninstalling}>
|
||||
<AlertDialogCancelButton disabled={uninstallAppMutation.isPending}>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton
|
||||
loading={isUninstalling}
|
||||
disabled={isUninstalling}
|
||||
loading={uninstallAppMutation.isPending}
|
||||
disabled={uninstallAppMutation.isPending}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { InstalledAppResponse } from '@dify/contracts/api/console/installed-apps/types.gen'
|
||||
import type {
|
||||
StepByStepTourStatePatchPayload,
|
||||
StepByStepTourStateResponse,
|
||||
@@ -8,10 +9,9 @@ import type { StepByStepTourSessionState } from '@/app/components/step-by-step-t
|
||||
import type { ModalContextState } from '@/context/modal-context'
|
||||
import type { ProviderContextState } from '@/context/provider-context'
|
||||
import type { ICurrentWorkspace, IWorkspace } from '@/models/common'
|
||||
import type { InstalledApp } from '@/models/explore'
|
||||
import type { ConsoleStateFixture } from '@/test/console/state-fixture'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createStore, Provider as JotaiProvider } from 'jotai'
|
||||
import { queryClientAtom } from 'jotai-tanstack-query'
|
||||
@@ -27,7 +27,6 @@ import { useProviderContext } from '@/context/provider-context'
|
||||
import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { usePathname, useRouter } from '@/next/navigation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/service/use-explore'
|
||||
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { seedRegisteredConsoleStateFixture } from '@/test/console/state-fixture'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
@@ -39,10 +38,22 @@ const activeGradientMaskClassName = 'aria-[current=page]:dify-blue-glass-surface
|
||||
const activeStackingClassName = 'aria-[current=page]:z-1'
|
||||
const mockTrackEvent = vi.hoisted(() => vi.fn())
|
||||
|
||||
const { mockIsAgentV2Enabled, mockSwitchWorkspace, mockToastSuccess } = vi.hoisted(() => ({
|
||||
const {
|
||||
mockFetchNextInstalledAppsPage,
|
||||
mockInstalledAppsRequest,
|
||||
mockIsAgentV2Enabled,
|
||||
mockSwitchWorkspace,
|
||||
mockToastSuccess,
|
||||
mockUninstall,
|
||||
mockUpdatePinStatus,
|
||||
} = vi.hoisted(() => ({
|
||||
mockFetchNextInstalledAppsPage: vi.fn(),
|
||||
mockInstalledAppsRequest: vi.fn(),
|
||||
mockIsAgentV2Enabled: vi.fn(() => true),
|
||||
mockSwitchWorkspace: vi.fn(),
|
||||
mockToastSuccess: vi.fn(),
|
||||
mockIsAgentV2Enabled: vi.fn(() => true),
|
||||
mockUninstall: vi.fn(),
|
||||
mockUpdatePinStatus: vi.fn(),
|
||||
}))
|
||||
const mockStepByStepTour = vi.hoisted(() => {
|
||||
const stateQueryKey = ['console', 'onboarding', 'step-by-step-tour', 'state'] as const
|
||||
@@ -295,6 +306,41 @@ vi.mock('@/service/client', async (importOriginal) => {
|
||||
},
|
||||
}
|
||||
}
|
||||
if (prop === 'installedApps') {
|
||||
return {
|
||||
get: {
|
||||
infiniteOptions: (options: {
|
||||
getNextPageParam: (page: {
|
||||
has_more: boolean
|
||||
next_cursor: string | null
|
||||
}) => string | undefined
|
||||
initialPageParam: undefined
|
||||
input: (pageParam: string | undefined) => {
|
||||
query: { cursor?: string; limit: number; name?: string }
|
||||
}
|
||||
placeholderData?: unknown
|
||||
select?: (data: unknown) => unknown
|
||||
}) => ({
|
||||
...options,
|
||||
queryKey: ['installed-apps', options.input(undefined).query.name ?? ''],
|
||||
queryFn: ({ pageParam }: { pageParam: string | undefined }) =>
|
||||
mockInstalledAppsRequest(options.input(pageParam)),
|
||||
}),
|
||||
},
|
||||
byInstalledAppId: {
|
||||
delete: {
|
||||
mutationOptions: () => ({
|
||||
mutationFn: (input: unknown) => mockUninstall(input),
|
||||
}),
|
||||
},
|
||||
patch: {
|
||||
mutationOptions: () => ({
|
||||
mutationFn: (input: unknown) => mockUpdatePinStatus(input),
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return Reflect.get(target, prop, receiver)
|
||||
},
|
||||
@@ -306,12 +352,6 @@ vi.mock('@/service/client', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/use-explore', () => ({
|
||||
useGetInstalledApps: vi.fn(),
|
||||
useUninstallApp: vi.fn(),
|
||||
useUpdateAppPinStatus: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@langgenius/dify-ui/toast')>()
|
||||
return {
|
||||
@@ -348,11 +388,10 @@ vi.mock('nuqs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('nuqs')>()
|
||||
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
|
||||
})
|
||||
const mockUninstall = vi.fn()
|
||||
const mockUpdatePinStatus = vi.fn()
|
||||
let mockPathname = '/apps'
|
||||
let mockInstalledApps: InstalledApp[] = []
|
||||
let mockInstalledApps: InstalledAppResponse[] = []
|
||||
let mockInstalledAppsPending = false
|
||||
let mockInstalledAppsHasNextPage = false
|
||||
let mockWorkspaces: IWorkspace[] = []
|
||||
|
||||
const ownerWorkspacePermissionKeys = [
|
||||
@@ -373,8 +412,13 @@ const datasetOperatorWorkspacePermissionKeys = [
|
||||
'dataset.external.connect',
|
||||
]
|
||||
|
||||
const createInstalledApp = (overrides: Partial<InstalledApp> = {}): InstalledApp => ({
|
||||
const createInstalledApp = (
|
||||
overrides: Partial<InstalledAppResponse> = {},
|
||||
): InstalledAppResponse => ({
|
||||
id: overrides.id ?? 'installed-1',
|
||||
app_owner_tenant_id: overrides.app_owner_tenant_id ?? 'tenant-1',
|
||||
editable: overrides.editable ?? true,
|
||||
last_used_at: overrides.last_used_at ?? null,
|
||||
uninstallable: overrides.uninstallable ?? false,
|
||||
is_pinned: overrides.is_pinned ?? false,
|
||||
app: {
|
||||
@@ -383,7 +427,7 @@ const createInstalledApp = (overrides: Partial<InstalledApp> = {}): InstalledApp
|
||||
icon_type: overrides.app?.icon_type ?? 'emoji',
|
||||
icon: overrides.app?.icon ?? '🤖',
|
||||
icon_background: overrides.app?.icon_background ?? '#fff',
|
||||
icon_url: overrides.app?.icon_url ?? '',
|
||||
icon_url: overrides.app?.icon_url ?? null,
|
||||
name: overrides.app?.name ?? 'Alpha App',
|
||||
description: overrides.app?.description ?? '',
|
||||
use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false,
|
||||
@@ -501,6 +545,7 @@ describe('MainNav', () => {
|
||||
mockPathname = '/apps'
|
||||
mockInstalledApps = []
|
||||
mockInstalledAppsPending = false
|
||||
mockInstalledAppsHasNextPage = false
|
||||
mockWorkspaces = [
|
||||
{
|
||||
id: 'workspace-1',
|
||||
@@ -542,17 +587,28 @@ describe('MainNav', () => {
|
||||
;(useModalContext as Mock).mockReturnValue({
|
||||
setShowPricingModal: mockSetShowPricingModal,
|
||||
} as unknown as ModalContextState)
|
||||
;(useGetInstalledApps as Mock).mockImplementation(() => ({
|
||||
isPending: mockInstalledAppsPending,
|
||||
data: { installed_apps: mockInstalledApps },
|
||||
}))
|
||||
;(useUninstallApp as Mock).mockReturnValue({
|
||||
mutateAsync: mockUninstall,
|
||||
isPending: false,
|
||||
})
|
||||
;(useUpdateAppPinStatus as Mock).mockReturnValue({
|
||||
mutateAsync: mockUpdatePinStatus,
|
||||
})
|
||||
mockInstalledAppsRequest.mockImplementation(
|
||||
async ({ query }: { query: { cursor?: string; name?: string } }) => {
|
||||
if (mockInstalledAppsPending) return new Promise(() => {})
|
||||
if (query.cursor) {
|
||||
mockFetchNextInstalledAppsPage(query.cursor)
|
||||
return { installed_apps: [], has_more: false, next_cursor: null }
|
||||
}
|
||||
|
||||
const installedApps = query.name
|
||||
? mockInstalledApps.filter((installedApp) =>
|
||||
installedApp.app.name.toLowerCase().includes(query.name!.toLowerCase()),
|
||||
)
|
||||
: mockInstalledApps
|
||||
return {
|
||||
installed_apps: installedApps,
|
||||
has_more: mockInstalledAppsHasNextPage,
|
||||
next_cursor: mockInstalledAppsHasNextPage ? 'next-page' : null,
|
||||
}
|
||||
},
|
||||
)
|
||||
mockUninstall.mockResolvedValue(undefined)
|
||||
mockUpdatePinStatus.mockResolvedValue({ result: 'success', message: 'updated' })
|
||||
mockSwitchWorkspace.mockReturnValue(new Promise(() => {}))
|
||||
})
|
||||
|
||||
@@ -1212,7 +1268,8 @@ describe('MainNav', () => {
|
||||
expect(screen.queryByText('common.mainNav.workspace.inviteMembers')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters installed web apps and renders installed app navigation link', () => {
|
||||
it('searches installed web apps and renders the matching navigation link', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockInstalledApps = [
|
||||
createInstalledApp({
|
||||
id: 'installed-1',
|
||||
@@ -1226,13 +1283,15 @@ describe('MainNav', () => {
|
||||
|
||||
renderMainNav()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.search' }))
|
||||
fireEvent.change(screen.getByPlaceholderText('common.mainNav.webApps.searchPlaceholder'), {
|
||||
target: { value: 'beta' },
|
||||
})
|
||||
await user.click(await screen.findByRole('button', { name: 'common.operation.search' }))
|
||||
const searchInput = screen.getByPlaceholderText('common.mainNav.webApps.searchPlaceholder')
|
||||
await user.type(searchInput, 'beta')
|
||||
|
||||
expect(screen.queryByText('Alpha App')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Beta Tool')).toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Alpha App')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Beta Tool')).toBeInTheDocument()
|
||||
})
|
||||
expect(searchInput).toHaveFocus()
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'common.mainNav.webApps.openApp:{"name":"Beta Tool"}' }),
|
||||
).toHaveAttribute('href', '/installed/installed-2')
|
||||
@@ -1257,22 +1316,24 @@ describe('MainNav', () => {
|
||||
expect(screen.queryByText('Alpha App')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the installed web apps section when no web apps are available', () => {
|
||||
it('hides the installed web apps section when no web apps are available', async () => {
|
||||
renderMainNav()
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'explore.sidebar.webApps' }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('region', { name: 'explore.sidebar.webApps' }),
|
||||
).not.toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'explore.sidebar.webApps' }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('region', { name: 'explore.sidebar.webApps' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByText('explore.sidebar.noApps.title')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'common.operation.search' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('separates pinned and unpinned installed web apps', () => {
|
||||
it('separates pinned and unpinned installed web apps', async () => {
|
||||
mockInstalledApps = [
|
||||
createInstalledApp({
|
||||
id: 'installed-1',
|
||||
@@ -1288,12 +1349,12 @@ describe('MainNav', () => {
|
||||
|
||||
renderMainNav()
|
||||
|
||||
expect(screen.getByText('Pinned App')).toBeInTheDocument()
|
||||
expect(await screen.findByText('Pinned App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Unpinned App')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('divider')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps long installed web app names truncated in the main nav item', () => {
|
||||
it('keeps long installed web app names truncated in the main nav item', async () => {
|
||||
const longName = 'A very long installed web app name that should stay on one line and truncate'
|
||||
mockInstalledApps = [
|
||||
createInstalledApp({
|
||||
@@ -1304,39 +1365,42 @@ describe('MainNav', () => {
|
||||
|
||||
renderMainNav()
|
||||
|
||||
expect(screen.getByText(longName)).toHaveClass('truncate')
|
||||
expect(await screen.findByText(longName)).toHaveClass('truncate')
|
||||
})
|
||||
|
||||
it('virtualizes large installed web app lists', async () => {
|
||||
const offsetHeightSpy = vi
|
||||
.spyOn(HTMLElement.prototype, 'offsetHeight', 'get')
|
||||
.mockReturnValue(320)
|
||||
const offsetWidthSpy = vi
|
||||
.spyOn(HTMLElement.prototype, 'offsetWidth', 'get')
|
||||
.mockReturnValue(240)
|
||||
mockInstalledApps = Array.from({ length: 100 }, (_, index) =>
|
||||
createInstalledApp({
|
||||
id: `installed-${index}`,
|
||||
app: {
|
||||
...createInstalledApp().app,
|
||||
id: `app-${index}`,
|
||||
name: `Web App ${index}`,
|
||||
},
|
||||
}),
|
||||
it('fetches the next installed web app page when the bottom sentinel enters the viewport', async () => {
|
||||
let intersectionCallback: IntersectionObserverCallback | undefined
|
||||
vi.stubGlobal(
|
||||
'IntersectionObserver',
|
||||
class MockIntersectionObserver {
|
||||
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
|
||||
if (options?.root) intersectionCallback = callback
|
||||
}
|
||||
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
)
|
||||
mockInstalledApps = [createInstalledApp()]
|
||||
mockInstalledAppsHasNextPage = true
|
||||
renderMainNav()
|
||||
await screen.findByText('Alpha App')
|
||||
|
||||
try {
|
||||
renderMainNav()
|
||||
act(() => {
|
||||
intersectionCallback?.(
|
||||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Web App 0')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Web App 99')).not.toBeInTheDocument()
|
||||
} finally {
|
||||
offsetHeightSpy.mockRestore()
|
||||
offsetWidthSpy.mockRestore()
|
||||
}
|
||||
await waitFor(() => {
|
||||
expect(mockFetchNextInstalledAppsPage).toHaveBeenCalledWith('next-page')
|
||||
})
|
||||
})
|
||||
|
||||
it('collapses and expands installed web apps from the section arrow', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockInstalledApps = [createInstalledApp()]
|
||||
|
||||
renderMainNav()
|
||||
@@ -1345,39 +1409,45 @@ describe('MainNav', () => {
|
||||
expect(webAppsButton).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByText('Alpha App')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(webAppsButton)
|
||||
await user.click(webAppsButton)
|
||||
|
||||
expect(webAppsButton).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.queryByText('Alpha App')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(webAppsButton)
|
||||
await user.click(webAppsButton)
|
||||
|
||||
expect(webAppsButton).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByText('Alpha App')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('updates pin status and reuses the existing delete confirmation for installed web apps', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockInstalledApps = [createInstalledApp()]
|
||||
mockUninstall.mockResolvedValue(undefined)
|
||||
mockUpdatePinStatus.mockResolvedValue(undefined)
|
||||
|
||||
renderMainNav()
|
||||
|
||||
fireEvent.mouseEnter(screen.getByText('Alpha App'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.pin'))
|
||||
await user.hover(await screen.findByText('Alpha App'))
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.more' }))
|
||||
await user.click(await screen.findByText('explore.sidebar.action.pin'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'installed-1', isPinned: true })
|
||||
expect(mockUpdatePinStatus).toHaveBeenCalledWith({
|
||||
params: { installed_app_id: 'installed-1' },
|
||||
body: { is_pinned: true },
|
||||
})
|
||||
})
|
||||
|
||||
fireEvent.mouseEnter(screen.getByText('Alpha App'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
|
||||
fireEvent.click(await screen.findByText('common.operation.confirm'))
|
||||
await user.hover(screen.getByText('Alpha App'))
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.more' }))
|
||||
await user.click(await screen.findByText('explore.sidebar.action.delete'))
|
||||
await user.click(await screen.findByText('common.operation.confirm'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUninstall).toHaveBeenCalledWith('installed-1')
|
||||
expect(mockUninstall).toHaveBeenCalledWith({
|
||||
params: { installed_app_id: 'installed-1' },
|
||||
})
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('common.api.remove')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import type { InstalledApp } from '@/models/explore'
|
||||
import type {
|
||||
InstalledAppListResponse,
|
||||
InstalledAppResponse,
|
||||
} from '@dify/contracts/api/console/installed-apps/types.gen'
|
||||
import type { InfiniteData } from '@tanstack/react-query'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
@@ -19,26 +23,28 @@ import {
|
||||
ScrollAreaViewport,
|
||||
} from '@langgenius/dify-ui/scroll-area'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { keepPreviousData, useInfiniteQuery, useMutation } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { Fragment, useMemo, useRef, useState } from 'react'
|
||||
import { Fragment, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { SearchInput } from '@/app/components/base/search-input'
|
||||
import AppNavItem from '@/app/components/explore/installed-app-navigation/app-nav-item'
|
||||
import { InfiniteScrollSentinel } from '@/app/components/explore/installed-app-navigation/infinite-scroll-sentinel'
|
||||
import { InstalledAppPaginationSkeleton } from '@/app/components/explore/installed-app-navigation/pagination-skeleton'
|
||||
import { isInstalledAppPath } from '@/app/components/explore/installed-app/routes'
|
||||
import AppNavItem from '@/app/components/explore/sidebar/app-nav-item'
|
||||
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||
import { usePathname } from '@/next/navigation'
|
||||
import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/service/use-explore'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
|
||||
const appNavItemHeight = 32
|
||||
const appNavItemGap = 2
|
||||
const appNavSeparatorHeight = 17
|
||||
const virtualizationThreshold = 50
|
||||
const webAppSkeletonClassName =
|
||||
'animate-pulse rounded bg-text-quaternary opacity-20 motion-reduce:animate-none'
|
||||
const webAppSkeletonWidths = ['w-24', 'w-32', 'w-28']
|
||||
const emptyInstalledApps: InstalledAppResponse[] = []
|
||||
|
||||
const selectInstalledApps = (data: InfiniteData<InstalledAppListResponse, string | undefined>) =>
|
||||
data.pages.flatMap((page) => page.installed_apps)
|
||||
|
||||
function WebAppsHeaderSkeleton() {
|
||||
return (
|
||||
@@ -65,123 +71,95 @@ function WebAppsSkeleton() {
|
||||
)
|
||||
}
|
||||
|
||||
type WebAppListRow =
|
||||
| {
|
||||
key: string
|
||||
kind: 'app'
|
||||
app: InstalledApp
|
||||
}
|
||||
| {
|
||||
key: string
|
||||
kind: 'separator'
|
||||
}
|
||||
|
||||
const WebAppsSectionContent = () => {
|
||||
const { t } = useTranslation()
|
||||
const pathname = usePathname()
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const { data, isPending } = useGetInstalledApps()
|
||||
const installedApps = useMemo(() => data?.installed_apps ?? [], [data?.installed_apps])
|
||||
const { mutateAsync: uninstallApp, isPending: isUninstalling } = useUninstallApp()
|
||||
const { mutateAsync: updatePinStatus } = useUpdateAppPinStatus()
|
||||
const [appsExpanded, setAppsExpanded] = useState(true)
|
||||
const [searchVisible, setSearchVisible] = useState(false)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [showConfirm, setShowConfirm] = useState(false)
|
||||
const [currentId, setCurrentId] = useState('')
|
||||
const [uninstallDialogAppId, setUninstallDialogAppId] = useState<string | null>(null)
|
||||
const normalizedSearchText = searchText.trim()
|
||||
|
||||
const filteredApps = useMemo(() => {
|
||||
const normalizedSearch = searchText.trim().toLowerCase()
|
||||
if (!normalizedSearch) return installedApps
|
||||
|
||||
return installedApps.filter((item) => item.app.name.toLowerCase().includes(normalizedSearch))
|
||||
}, [installedApps, searchText])
|
||||
const webAppRows = useMemo<WebAppListRow[]>(() => {
|
||||
const pinnedAppsCount = filteredApps.filter(({ is_pinned }) => is_pinned).length
|
||||
|
||||
return filteredApps.flatMap((app, index) => {
|
||||
const rows: WebAppListRow[] = [
|
||||
{
|
||||
key: app.id,
|
||||
kind: 'app',
|
||||
app,
|
||||
const installedAppsQuery = useInfiniteQuery(
|
||||
consoleQuery.installedApps.get.infiniteOptions({
|
||||
input: (pageParam: string | undefined) => ({
|
||||
query: {
|
||||
limit: 20,
|
||||
...(typeof pageParam === 'string' ? { cursor: pageParam } : {}),
|
||||
...(normalizedSearchText ? { name: normalizedSearchText } : {}),
|
||||
},
|
||||
]
|
||||
}),
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.has_more && lastPage.next_cursor ? lastPage.next_cursor : undefined,
|
||||
initialPageParam: undefined,
|
||||
placeholderData: keepPreviousData,
|
||||
select: selectInstalledApps,
|
||||
}),
|
||||
)
|
||||
const installedApps = installedAppsQuery.data ?? emptyInstalledApps
|
||||
const uninstallAppMutation = useMutation(
|
||||
consoleQuery.installedApps.byInstalledAppId.delete.mutationOptions(),
|
||||
)
|
||||
const updatePinStatusMutation = useMutation(
|
||||
consoleQuery.installedApps.byInstalledAppId.patch.mutationOptions(),
|
||||
)
|
||||
|
||||
if (index === pinnedAppsCount - 1 && index !== filteredApps.length - 1) {
|
||||
rows.push({
|
||||
key: `${app.id}-separator`,
|
||||
kind: 'separator',
|
||||
})
|
||||
}
|
||||
const pinnedAppsCount = installedApps.filter(({ is_pinned }) => is_pinned).length
|
||||
|
||||
return rows
|
||||
})
|
||||
}, [filteredApps])
|
||||
const shouldVirtualize = webAppRows.length > virtualizationThreshold
|
||||
const handleDelete = () => {
|
||||
if (!uninstallDialogAppId) return
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: webAppRows.length,
|
||||
estimateSize: (index) =>
|
||||
webAppRows[index]?.kind === 'separator' ? appNavSeparatorHeight : appNavItemHeight,
|
||||
gap: appNavItemGap,
|
||||
getItemKey: (index) => webAppRows[index]?.key ?? index,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
overscan: 6,
|
||||
paddingEnd: 8,
|
||||
})
|
||||
const virtualRows = rowVirtualizer.getVirtualItems()
|
||||
|
||||
const handleDelete = async () => {
|
||||
await uninstallApp(currentId)
|
||||
setShowConfirm(false)
|
||||
toast.success(t(($) => $['api.remove'], { ns: 'common' }))
|
||||
uninstallAppMutation.mutate(
|
||||
{
|
||||
params: { installed_app_id: uninstallDialogAppId },
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setUninstallDialogAppId(null)
|
||||
toast.success(t(($) => $['api.remove'], { ns: 'common' }))
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const handleUpdatePinStatus = async (id: string, isPinned: boolean) => {
|
||||
await updatePinStatus({ appId: id, isPinned })
|
||||
toast.success(t(($) => $['api.success'], { ns: 'common' }))
|
||||
const handleUpdatePinStatus = (id: string, isPinned: boolean) => {
|
||||
updatePinStatusMutation.mutate(
|
||||
{
|
||||
params: { installed_app_id: id },
|
||||
body: { is_pinned: isPinned },
|
||||
},
|
||||
{
|
||||
onSuccess: () => toast.success(t(($) => $['api.success'], { ns: 'common' })),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (!isPending && installedApps.length === 0) return null
|
||||
if (
|
||||
!installedAppsQuery.isPending &&
|
||||
!installedAppsQuery.isError &&
|
||||
installedApps.length === 0 &&
|
||||
!normalizedSearchText
|
||||
)
|
||||
return null
|
||||
|
||||
const renderAppNavItem = ({
|
||||
id,
|
||||
is_pinned,
|
||||
uninstallable,
|
||||
app,
|
||||
}: (typeof filteredApps)[number]) => (
|
||||
const renderAppNavItem = (installedApp: (typeof installedApps)[number]) => (
|
||||
<AppNavItem
|
||||
key={id}
|
||||
key={installedApp.id}
|
||||
variant="mainNav"
|
||||
name={app.name}
|
||||
ariaLabel={t(($) => $['mainNav.webApps.openApp'], { ns: 'common', name: app.name })}
|
||||
icon_type={app.icon_type}
|
||||
icon={app.icon}
|
||||
icon_background={app.icon_background}
|
||||
icon_url={app.icon_url}
|
||||
id={id}
|
||||
isSelected={isInstalledAppPath(pathname, id)}
|
||||
isPinned={is_pinned}
|
||||
togglePin={() => {
|
||||
void handleUpdatePinStatus(id, !is_pinned)
|
||||
}}
|
||||
uninstallable={uninstallable}
|
||||
onDelete={(id) => {
|
||||
setCurrentId(id)
|
||||
setShowConfirm(true)
|
||||
}}
|
||||
app={installedApp}
|
||||
ariaLabel={t(($) => $['mainNav.webApps.openApp'], {
|
||||
ns: 'common',
|
||||
name: installedApp.app.name,
|
||||
})}
|
||||
isSelected={isInstalledAppPath(pathname, installedApp.id)}
|
||||
onTogglePin={handleUpdatePinStatus}
|
||||
onDelete={setUninstallDialogAppId}
|
||||
/>
|
||||
)
|
||||
const renderRow = (row: WebAppListRow) => {
|
||||
if (row.kind === 'separator') return <Divider />
|
||||
|
||||
return renderAppNavItem(row.app)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{isPending ? (
|
||||
{installedAppsQuery.isPending ? (
|
||||
<WebAppsHeaderSkeleton />
|
||||
) : (
|
||||
<div className="flex items-center justify-between py-1 pr-2 pl-2">
|
||||
@@ -210,7 +188,10 @@ const WebAppsSectionContent = () => {
|
||||
)}
|
||||
onClick={() => {
|
||||
setAppsExpanded(true)
|
||||
setSearchVisible((value) => !value)
|
||||
setSearchVisible((value) => {
|
||||
if (value) setSearchText('')
|
||||
return !value
|
||||
})
|
||||
}}
|
||||
>
|
||||
<span className="flex size-5 shrink-0 items-center justify-center">
|
||||
@@ -220,7 +201,7 @@ const WebAppsSectionContent = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isPending && appsExpanded && searchVisible && (
|
||||
{!installedAppsQuery.isPending && appsExpanded && searchVisible && (
|
||||
<div className="px-2 pb-2">
|
||||
<SearchInput
|
||||
value={searchText}
|
||||
@@ -235,50 +216,62 @@ const WebAppsSectionContent = () => {
|
||||
<ScrollAreaRoot className="relative min-h-0 flex-1 overflow-hidden overscroll-contain">
|
||||
<ScrollAreaViewport
|
||||
ref={scrollRef}
|
||||
aria-busy={isPending}
|
||||
aria-busy={installedAppsQuery.isPending || installedAppsQuery.isFetchingNextPage}
|
||||
aria-label={t(($) => $['sidebar.webApps'], { ns: 'explore' })}
|
||||
className="overflow-x-hidden"
|
||||
role="region"
|
||||
>
|
||||
<ScrollAreaContent className="w-full max-w-full min-w-0! px-2">
|
||||
{isPending && <WebAppsSkeleton />}
|
||||
{!isPending && filteredApps.length === 0 && (
|
||||
<div className="px-2 py-1 system-xs-regular">
|
||||
{t(($) => $['mainNav.webApps.noResults'], { ns: 'common' })}
|
||||
{installedAppsQuery.isPending && <WebAppsSkeleton />}
|
||||
{!installedAppsQuery.isPending && installedAppsQuery.isError && (
|
||||
<div
|
||||
className="flex flex-col items-start gap-1 px-2 py-2 system-xs-regular text-text-tertiary"
|
||||
role="alert"
|
||||
>
|
||||
<span>{t(($) => $['errorBoundary.title'], { ns: 'common' })}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-text-accent outline-hidden hover:underline focus-visible:underline"
|
||||
onClick={() => {
|
||||
if (installedAppsQuery.isFetchNextPageError)
|
||||
void installedAppsQuery.fetchNextPage({ cancelRefetch: false })
|
||||
else void installedAppsQuery.refetch()
|
||||
}}
|
||||
>
|
||||
{t(($) => $['operation.retry'], { ns: 'common' })}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isPending && webAppRows.length > 0 && !shouldVirtualize && (
|
||||
{!installedAppsQuery.isPending &&
|
||||
!installedAppsQuery.isError &&
|
||||
installedApps.length === 0 && (
|
||||
<div className="px-2 py-1 system-xs-regular">
|
||||
{t(($) => $['mainNav.webApps.noResults'], { ns: 'common' })}
|
||||
</div>
|
||||
)}
|
||||
{!installedAppsQuery.isPending && installedApps.length > 0 && (
|
||||
<div className="space-y-0.5 pb-2">
|
||||
{webAppRows.map((row) => (
|
||||
<Fragment key={row.key}>{renderRow(row)}</Fragment>
|
||||
{installedApps.map((installedApp, index) => (
|
||||
<Fragment key={installedApp.id}>
|
||||
{renderAppNavItem(installedApp)}
|
||||
{index === pinnedAppsCount - 1 && index !== installedApps.length - 1 && (
|
||||
<Divider />
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!isPending && shouldVirtualize && (
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
}}
|
||||
>
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const row = webAppRows[virtualRow.index]!
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
className="absolute top-0 left-0 w-full"
|
||||
style={{
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{renderRow(row)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{installedAppsQuery.isFetchingNextPage && <InstalledAppPaginationSkeleton />}
|
||||
<InfiniteScrollSentinel
|
||||
canFetchNextPage={installedAppsQuery.hasNextPage && !installedAppsQuery.error}
|
||||
fetchNextPage={() =>
|
||||
installedAppsQuery.fetchNextPage({
|
||||
cancelRefetch: false,
|
||||
})
|
||||
}
|
||||
isFetchingNextPage={installedAppsQuery.isFetchingNextPage}
|
||||
scrollRootRef={scrollRef}
|
||||
/>
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar>
|
||||
@@ -286,7 +279,12 @@ const WebAppsSectionContent = () => {
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
)}
|
||||
<AlertDialog open={showConfirm} onOpenChange={setShowConfirm}>
|
||||
<AlertDialog
|
||||
open={uninstallDialogAppId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setUninstallDialogAppId(null)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<div className="flex flex-col items-start gap-2 self-stretch pt-6 pr-6 pb-4 pl-6">
|
||||
<AlertDialogTitle className="w-full title-2xl-semi-bold text-text-primary">
|
||||
@@ -297,12 +295,12 @@ const WebAppsSectionContent = () => {
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton disabled={isUninstalling}>
|
||||
<AlertDialogCancelButton disabled={uninstallAppMutation.isPending}>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton
|
||||
loading={isUninstalling}
|
||||
disabled={isUninstalling}
|
||||
loading={uninstallAppMutation.isPending}
|
||||
disabled={uninstallAppMutation.isPending}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { InputValueTypes, TextGenerationRunControl, TextGenerationTranslate } from './types'
|
||||
import type { InstalledApp } from '@/models/explore'
|
||||
import type { VisionFile } from '@/types/app'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
@@ -18,7 +17,6 @@ import TextGenerationSidebar from './text-generation-sidebar'
|
||||
|
||||
type IMainProps = {
|
||||
isInstalledApp?: boolean
|
||||
installedAppInfo?: InstalledApp
|
||||
isWorkflow?: boolean
|
||||
}
|
||||
const TextGeneration: FC<IMainProps> = ({ isInstalledApp = false, isWorkflow = false }) => {
|
||||
|
||||
@@ -30,10 +30,3 @@ export type App = {
|
||||
is_agent: boolean
|
||||
can_trial: boolean
|
||||
}
|
||||
|
||||
export type InstalledApp = {
|
||||
app: AppBasicInfo
|
||||
id: string
|
||||
uninstallable: boolean
|
||||
is_pinned: boolean
|
||||
}
|
||||
|
||||
@@ -558,6 +558,51 @@ export const consoleQuery: RouterUtils<typeof consoleClient> = createTanstackQue
|
||||
},
|
||||
},
|
||||
},
|
||||
installedApps: {
|
||||
byInstalledAppId: {
|
||||
get: {
|
||||
queryOptions: {
|
||||
retry: (failureCount, error) => {
|
||||
if (error instanceof Response && error.status === 404) return false
|
||||
|
||||
return failureCount < 3
|
||||
},
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
mutationOptions: {
|
||||
onSuccess: (_response, variables, _onMutateResult, context) => {
|
||||
context.client.removeQueries({
|
||||
queryKey: consoleQuery.installedApps.byInstalledAppId.get.queryKey({
|
||||
input: {
|
||||
params: variables.params,
|
||||
},
|
||||
}),
|
||||
})
|
||||
context.client.invalidateQueries({
|
||||
queryKey: consoleQuery.installedApps.get.key(),
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
patch: {
|
||||
mutationOptions: {
|
||||
onSuccess: (_response, variables, _onMutateResult, context) => {
|
||||
context.client.invalidateQueries({
|
||||
queryKey: consoleQuery.installedApps.get.key(),
|
||||
})
|
||||
context.client.invalidateQueries({
|
||||
queryKey: consoleQuery.installedApps.byInstalledAppId.get.queryKey({
|
||||
input: {
|
||||
params: variables.params,
|
||||
},
|
||||
}),
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
agent: {
|
||||
post: {
|
||||
mutationOptions: {
|
||||
|
||||
+12
-25
@@ -1,9 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fetchAppDetail, fetchAppList, fetchInstalledAppMeta } from './explore'
|
||||
import { fetchAppDetail, fetchAppList, fetchInstalledAppList } from './explore'
|
||||
|
||||
const mockExploreAppsGet = vi.hoisted(() => vi.fn())
|
||||
const mockExploreAppDetailGet = vi.hoisted(() => vi.fn())
|
||||
const mockInstalledAppMetaGet = vi.hoisted(() => vi.fn())
|
||||
const mockInstalledAppsGet = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
consoleClient: {
|
||||
@@ -16,11 +16,7 @@ vi.mock('./client', () => ({
|
||||
},
|
||||
},
|
||||
installedApps: {
|
||||
byInstalledAppId: {
|
||||
meta: {
|
||||
get: mockInstalledAppMetaGet,
|
||||
},
|
||||
},
|
||||
get: mockInstalledAppsGet,
|
||||
},
|
||||
},
|
||||
}))
|
||||
@@ -70,26 +66,17 @@ describe('explore service normalizers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves provider-defined tool icon payload objects', async () => {
|
||||
const providerIcon = {
|
||||
type: 'custom',
|
||||
value: {
|
||||
content: 'tool',
|
||||
background: '#fff',
|
||||
},
|
||||
}
|
||||
mockInstalledAppMetaGet.mockResolvedValue({
|
||||
tool_icons: {
|
||||
builtin: '/tool.svg',
|
||||
provider: providerIcon,
|
||||
},
|
||||
it('preserves installed app pagination metadata', async () => {
|
||||
mockInstalledAppsGet.mockResolvedValue({
|
||||
installed_apps: [],
|
||||
has_more: true,
|
||||
next_cursor: 'next-page',
|
||||
})
|
||||
|
||||
await expect(fetchInstalledAppMeta('installed-app-id')).resolves.toEqual({
|
||||
tool_icons: {
|
||||
builtin: '/tool.svg',
|
||||
provider: providerIcon,
|
||||
},
|
||||
await expect(fetchInstalledAppList()).resolves.toEqual({
|
||||
installed_apps: [],
|
||||
has_more: true,
|
||||
next_cursor: 'next-page',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+7
-271
@@ -7,21 +7,9 @@ import type {
|
||||
RecommendedAppInfoResponse,
|
||||
RecommendedAppResponse,
|
||||
} from '@dify/contracts/api/console/explore/types.gen'
|
||||
import type {
|
||||
ExploreAppMetaResponse,
|
||||
InstalledAppInfoResponse,
|
||||
InstalledAppListResponse,
|
||||
Parameters as InstalledAppParametersResponse,
|
||||
InstalledAppResponse,
|
||||
} from '@dify/contracts/api/console/installed-apps/types.gen'
|
||||
import type { ChatConfig } from '@/app/components/base/chat/types'
|
||||
import type { Banner } from '@/models/app'
|
||||
import type { App, AppCategory, InstalledApp } from '@/models/explore'
|
||||
import type { AppMeta, ToolIcon } from '@/models/share'
|
||||
import type { App, AppCategory } from '@/models/explore'
|
||||
import type { AppIconType } from '@/types/app'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { PromptMode } from '@/models/debug'
|
||||
import { RETRIEVE_TYPE, TtsAutoPlay } from '@/types/app'
|
||||
import { consoleClient } from './client'
|
||||
|
||||
type ExploreAppsResponse = {
|
||||
@@ -43,16 +31,6 @@ type ExploreAppDetailResponse = {
|
||||
can_trial: boolean
|
||||
}
|
||||
|
||||
type InstalledAppsResponse = {
|
||||
installed_apps: InstalledApp[]
|
||||
}
|
||||
|
||||
type AppAccessModeResponse = {
|
||||
accessMode: AccessMode
|
||||
}
|
||||
|
||||
type InstalledAppParametersViewModel = ChatConfig
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -66,11 +44,6 @@ const getStringProperty = (source: object, key: string, fallback = '') => {
|
||||
return typeof value === 'string' ? value : fallback
|
||||
}
|
||||
|
||||
const getBooleanProperty = (source: object, key: string, fallback = false) => {
|
||||
const value = getValue(source, key)
|
||||
return typeof value === 'boolean' ? value : fallback
|
||||
}
|
||||
|
||||
const normalizeAppMode = (value: unknown) => {
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
@@ -79,40 +52,14 @@ const isAppIconType = (value: unknown): value is AppIconType => {
|
||||
return value === 'image' || value === 'emoji' || value === 'link'
|
||||
}
|
||||
|
||||
const isAccessMode = (value: unknown): value is AccessMode => {
|
||||
return (
|
||||
value === AccessMode.PUBLIC ||
|
||||
value === AccessMode.SPECIFIC_GROUPS_MEMBERS ||
|
||||
value === AccessMode.ORGANIZATION ||
|
||||
value === AccessMode.EXTERNAL_MEMBERS
|
||||
)
|
||||
}
|
||||
|
||||
const normalizeAccessMode = (value: unknown) => {
|
||||
if (isAccessMode(value)) return value
|
||||
|
||||
throw new Error('Web app access mode response returned an unsupported access mode.')
|
||||
}
|
||||
|
||||
const normalizeAppIconType = (value: unknown) => {
|
||||
return isAppIconType(value) ? value : null
|
||||
}
|
||||
|
||||
const normalizeAppBasicInfo = (
|
||||
source: RecommendedAppInfoResponse | InstalledAppInfoResponse | null | undefined,
|
||||
source: RecommendedAppInfoResponse | null | undefined,
|
||||
fallbackId: string,
|
||||
): App['app'] => {
|
||||
const description =
|
||||
source && 'description' in source && typeof source.description === 'string'
|
||||
? source.description
|
||||
: ''
|
||||
const useIconAsAnswerIcon =
|
||||
source &&
|
||||
'use_icon_as_answer_icon' in source &&
|
||||
typeof source.use_icon_as_answer_icon === 'boolean'
|
||||
? source.use_icon_as_answer_icon
|
||||
: false
|
||||
|
||||
return {
|
||||
id: source?.id ?? fallbackId,
|
||||
mode: normalizeAppMode(source?.mode),
|
||||
@@ -121,8 +68,8 @@ const normalizeAppBasicInfo = (
|
||||
icon_background: source?.icon_background ?? '',
|
||||
icon_url: source?.icon_url ?? '',
|
||||
name: source?.name ?? '',
|
||||
description,
|
||||
use_icon_as_answer_icon: useIconAsAnswerIcon,
|
||||
description: '',
|
||||
use_icon_as_answer_icon: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,23 +119,6 @@ const normalizeAppDetail = (response: RecommendedAppDetailResponse): ExploreAppD
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeInstalledApp = (installedApp: InstalledAppResponse): InstalledApp => {
|
||||
return {
|
||||
app: normalizeAppBasicInfo(installedApp.app, installedApp.app.id),
|
||||
id: installedApp.id,
|
||||
uninstallable: installedApp.uninstallable,
|
||||
is_pinned: installedApp.is_pinned,
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeInstalledAppsResponse = (
|
||||
response: InstalledAppListResponse,
|
||||
): InstalledAppsResponse => {
|
||||
return {
|
||||
installed_apps: response.installed_apps.map(normalizeInstalledApp),
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeBannerContent = (content: unknown): Banner['content'] => {
|
||||
const record = isRecord(content) ? content : {}
|
||||
|
||||
@@ -215,153 +145,6 @@ const normalizeBannersResponse = (response: BannerListResponse): Banner[] => {
|
||||
return response.map(normalizeBanner)
|
||||
}
|
||||
|
||||
const normalizeToolIcons = (value: unknown) => {
|
||||
const record = isRecord(value) ? value : {}
|
||||
const result: Record<string, ToolIcon> = {}
|
||||
|
||||
Object.entries(record).forEach(([key, item]) => {
|
||||
if (typeof item === 'string') result[key] = item
|
||||
else if (isRecord(item)) result[key] = item
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const normalizeAppMeta = (response: ExploreAppMetaResponse): AppMeta => {
|
||||
return {
|
||||
tool_icons: normalizeToolIcons(response.tool_icons),
|
||||
}
|
||||
}
|
||||
|
||||
const isTtsAutoPlay = (value: unknown): value is TtsAutoPlay => {
|
||||
return value === TtsAutoPlay.enabled || value === TtsAutoPlay.disabled
|
||||
}
|
||||
|
||||
const isUserInputFormItem = (value: unknown): value is ChatConfig['user_input_form'][number] => {
|
||||
if (!isRecord(value)) return false
|
||||
|
||||
return [
|
||||
'text-input',
|
||||
'select',
|
||||
'paragraph',
|
||||
'number',
|
||||
'checkbox',
|
||||
'file',
|
||||
'file-list',
|
||||
'external_data_tool',
|
||||
'json_object',
|
||||
].some((key) => isRecord(getValue(value, key)))
|
||||
}
|
||||
|
||||
const isModel = (
|
||||
value: unknown,
|
||||
): value is NonNullable<ChatConfig['suggested_questions_after_answer']['model']> => {
|
||||
return isRecord(value)
|
||||
}
|
||||
|
||||
const isAnnotationReplyConfig = (
|
||||
value: unknown,
|
||||
): value is NonNullable<ChatConfig['annotation_reply']> => {
|
||||
return isRecord(value)
|
||||
}
|
||||
|
||||
const isFileUploadConfig = (value: unknown): value is NonNullable<ChatConfig['file_upload']> => {
|
||||
return isRecord(value)
|
||||
}
|
||||
|
||||
const defaultDatasetConfigs = (): ChatConfig['dataset_configs'] => ({
|
||||
retrieval_model: RETRIEVE_TYPE.oneWay,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
top_k: 4,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: null,
|
||||
datasets: {
|
||||
datasets: [],
|
||||
},
|
||||
})
|
||||
|
||||
const normalizeEnabledConfig = (value: unknown): { enabled: boolean } => {
|
||||
const record = isRecord(value) ? value : {}
|
||||
|
||||
return {
|
||||
...record,
|
||||
enabled: getBooleanProperty(record, 'enabled'),
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeSuggestedQuestionsAfterAnswer = (
|
||||
value: unknown,
|
||||
): ChatConfig['suggested_questions_after_answer'] => {
|
||||
const record = isRecord(value) ? value : {}
|
||||
const model = getValue(record, 'model')
|
||||
const prompt = getStringProperty(record, 'prompt')
|
||||
|
||||
return {
|
||||
enabled: getBooleanProperty(record, 'enabled'),
|
||||
...(isModel(model) ? { model } : {}),
|
||||
...(prompt ? { prompt } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeTextToSpeech = (value: unknown): ChatConfig['text_to_speech'] => {
|
||||
const record = isRecord(value) ? value : {}
|
||||
const autoPlay = getValue(record, 'autoPlay')
|
||||
const normalizedAutoPlay = isTtsAutoPlay(autoPlay) ? autoPlay : undefined
|
||||
|
||||
return {
|
||||
...record,
|
||||
enabled: getBooleanProperty(record, 'enabled'),
|
||||
voice: getStringProperty(record, 'voice') || undefined,
|
||||
language: getStringProperty(record, 'language') || undefined,
|
||||
...(normalizedAutoPlay ? { autoPlay: normalizedAutoPlay } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeSystemParameters = (
|
||||
systemParameters: InstalledAppParametersResponse['system_parameters'],
|
||||
): ChatConfig['system_parameters'] => {
|
||||
return {
|
||||
audio_file_size_limit: systemParameters.audio_file_size_limit,
|
||||
file_size_limit: systemParameters.file_size_limit,
|
||||
image_file_size_limit: systemParameters.image_file_size_limit,
|
||||
video_file_size_limit: systemParameters.video_file_size_limit,
|
||||
workflow_file_upload_limit: systemParameters.workflow_file_upload_limit,
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeInstalledAppParametersViewModel = (
|
||||
response: InstalledAppParametersResponse,
|
||||
): InstalledAppParametersViewModel => {
|
||||
return {
|
||||
opening_statement: response.opening_statement ?? '',
|
||||
suggested_questions: response.suggested_questions,
|
||||
pre_prompt: '',
|
||||
prompt_type: PromptMode.simple,
|
||||
user_input_form: response.user_input_form.filter(isUserInputFormItem),
|
||||
more_like_this: normalizeEnabledConfig(response.more_like_this),
|
||||
suggested_questions_after_answer: normalizeSuggestedQuestionsAfterAnswer(
|
||||
response.suggested_questions_after_answer,
|
||||
),
|
||||
speech_to_text: normalizeEnabledConfig(response.speech_to_text),
|
||||
text_to_speech: normalizeTextToSpeech(response.text_to_speech),
|
||||
retriever_resource: normalizeEnabledConfig(response.retriever_resource),
|
||||
sensitive_word_avoidance: normalizeEnabledConfig(response.sensitive_word_avoidance),
|
||||
...(isAnnotationReplyConfig(response.annotation_reply)
|
||||
? { annotation_reply: response.annotation_reply }
|
||||
: {}),
|
||||
agent_mode: {
|
||||
enabled: false,
|
||||
tools: [],
|
||||
},
|
||||
dataset_configs: defaultDatasetConfigs(),
|
||||
...(isFileUploadConfig(response.file_upload) ? { file_upload: response.file_upload } : {}),
|
||||
system_parameters: normalizeSystemParameters(response.system_parameters),
|
||||
}
|
||||
}
|
||||
|
||||
export const fetchAppList = (language?: string) => {
|
||||
if (!language) return consoleClient.explore.apps.get({}).then(normalizeExploreAppsResponse)
|
||||
|
||||
@@ -392,60 +175,13 @@ export const fetchAppDetail = async (id: string): Promise<ExploreAppDetailRespon
|
||||
}
|
||||
|
||||
export const fetchInstalledAppList = (appId?: string | null) => {
|
||||
if (!appId) return consoleClient.installedApps.get({}).then(normalizeInstalledAppsResponse)
|
||||
if (!appId) return consoleClient.installedApps.get({})
|
||||
|
||||
return consoleClient.installedApps
|
||||
.get({
|
||||
query: { app_id: appId },
|
||||
})
|
||||
.then(normalizeInstalledAppsResponse)
|
||||
}
|
||||
|
||||
export const uninstallApp = (id: string) => {
|
||||
return consoleClient.installedApps.byInstalledAppId.delete({
|
||||
params: { installed_app_id: id },
|
||||
return consoleClient.installedApps.get({
|
||||
query: { app_id: appId },
|
||||
})
|
||||
}
|
||||
|
||||
export const updatePinStatus = (id: string, isPinned: boolean) => {
|
||||
return consoleClient.installedApps.byInstalledAppId.patch({
|
||||
params: { installed_app_id: id },
|
||||
body: {
|
||||
is_pinned: isPinned,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const getAppAccessModeByAppId = (appId: string) => {
|
||||
return consoleClient.enterprise.webAppAuth
|
||||
.getWebAppAccessMode({
|
||||
query: { appId },
|
||||
})
|
||||
.then(
|
||||
(response): AppAccessModeResponse => ({
|
||||
accessMode: normalizeAccessMode(
|
||||
isRecord(response) ? getValue(response, 'accessMode') : undefined,
|
||||
),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const fetchInstalledAppParams = (appId: string) => {
|
||||
return consoleClient.installedApps.byInstalledAppId.parameters
|
||||
.get({
|
||||
params: { installed_app_id: appId },
|
||||
})
|
||||
.then(normalizeInstalledAppParametersViewModel)
|
||||
}
|
||||
|
||||
export const fetchInstalledAppMeta = (appId: string) => {
|
||||
return consoleClient.installedApps.byInstalledAppId.meta
|
||||
.get({
|
||||
params: { installed_app_id: appId },
|
||||
})
|
||||
.then(normalizeAppMeta)
|
||||
}
|
||||
|
||||
export const fetchBanners = (language?: string) => {
|
||||
if (!language) return consoleClient.explore.banners.get({}).then(normalizeBannersResponse)
|
||||
|
||||
|
||||
+2
-115
@@ -1,19 +1,8 @@
|
||||
import type { App, AppCategory } from '@/models/explore'
|
||||
import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { consoleQuery } from './client'
|
||||
import {
|
||||
fetchAppList,
|
||||
fetchInstalledAppList,
|
||||
fetchInstalledAppMeta,
|
||||
fetchInstalledAppParams,
|
||||
fetchLearnDifyAppList,
|
||||
getAppAccessModeByAppId,
|
||||
uninstallApp,
|
||||
updatePinStatus,
|
||||
} from './explore'
|
||||
import { fetchAppList, fetchLearnDifyAppList } from './explore'
|
||||
|
||||
type ExploreAppListData = {
|
||||
categories: AppCategory[]
|
||||
@@ -57,105 +46,3 @@ export const useLearnDifyAppList = () => {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useGetInstalledApps = () => {
|
||||
return useQuery({
|
||||
queryKey: consoleQuery.installedApps.get.queryKey({ input: {} }),
|
||||
queryFn: () => {
|
||||
return fetchInstalledAppList()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useUninstallApp = () => {
|
||||
const client = useQueryClient()
|
||||
return useMutation({
|
||||
mutationKey: consoleQuery.installedApps.byInstalledAppId.delete.mutationKey(),
|
||||
mutationFn: (appId: string) => uninstallApp(appId),
|
||||
onSuccess: () => {
|
||||
client.invalidateQueries({
|
||||
queryKey: consoleQuery.installedApps.get.queryKey({ input: {} }),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useUpdateAppPinStatus = () => {
|
||||
const client = useQueryClient()
|
||||
return useMutation({
|
||||
mutationKey: consoleQuery.installedApps.byInstalledAppId.patch.mutationKey(),
|
||||
mutationFn: ({ appId, isPinned }: { appId: string; isPinned: boolean }) =>
|
||||
updatePinStatus(appId, isPinned),
|
||||
onSuccess: () => {
|
||||
client.invalidateQueries({
|
||||
queryKey: consoleQuery.installedApps.get.queryKey({ input: {} }),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useGetInstalledAppAccessModeByAppId = (appId: string | null) => {
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const webappAuthEnabled = systemFeatures.webapp_auth.enabled
|
||||
const appAccessModeInput = { query: { appId: appId ?? '' } }
|
||||
const installedAppId = appAccessModeInput.query.appId
|
||||
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
...consoleQuery.enterprise.webAppAuth.getWebAppAccessMode.queryKey({
|
||||
input: appAccessModeInput,
|
||||
}),
|
||||
webappAuthEnabled,
|
||||
installedAppId,
|
||||
],
|
||||
queryFn: () => {
|
||||
if (webappAuthEnabled === false) {
|
||||
return {
|
||||
accessMode: AccessMode.PUBLIC,
|
||||
}
|
||||
}
|
||||
if (!installedAppId) return Promise.reject(new Error('App ID is required to get access mode'))
|
||||
|
||||
return getAppAccessModeByAppId(installedAppId)
|
||||
},
|
||||
enabled: !!installedAppId,
|
||||
})
|
||||
}
|
||||
|
||||
export const useGetInstalledAppParams = (appId: string | null) => {
|
||||
const installedAppParamsInput = { params: { installed_app_id: appId ?? '' } }
|
||||
const installedAppId = installedAppParamsInput.params.installed_app_id
|
||||
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
...consoleQuery.installedApps.byInstalledAppId.parameters.get.queryKey({
|
||||
input: installedAppParamsInput,
|
||||
}),
|
||||
installedAppId,
|
||||
],
|
||||
queryFn: () => {
|
||||
if (!installedAppId) return Promise.reject(new Error('App ID is required to get app params'))
|
||||
return fetchInstalledAppParams(installedAppId)
|
||||
},
|
||||
enabled: !!installedAppId,
|
||||
})
|
||||
}
|
||||
|
||||
export const useGetInstalledAppMeta = (appId: string | null) => {
|
||||
const installedAppMetaInput = { params: { installed_app_id: appId ?? '' } }
|
||||
const installedAppId = installedAppMetaInput.params.installed_app_id
|
||||
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
...consoleQuery.installedApps.byInstalledAppId.meta.get.queryKey({
|
||||
input: installedAppMetaInput,
|
||||
}),
|
||||
installedAppId,
|
||||
],
|
||||
queryFn: () => {
|
||||
if (!installedAppId) return Promise.reject(new Error('App ID is required to get app meta'))
|
||||
return fetchInstalledAppMeta(installedAppId)
|
||||
},
|
||||
enabled: !!installedAppId,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user