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:
非法操作
2026-07-29 09:51:34 +00:00
committed by GitHub
co-authored by autofix-ci[bot] yyh yyh
parent fd804dfcc0
commit c1fdd6fb78
29 changed files with 2125 additions and 1804 deletions
@@ -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()
@@ -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()
})
@@ -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()
})
})
@@ -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 },
}),
)
})
})
+161 -65
View File
@@ -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 }) => {
-7
View File
@@ -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
}
+45
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
})
}