Compare commits

...

2 Commits

Author SHA1 Message Date
yyh
ba12960975 refactor(web): centralize role-based route guards and fix anti-patterns (#32302)
Some checks are pending
autofix.ci / autofix (push) Waiting to run
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Waiting to run
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Waiting to run
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Waiting to run
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Waiting to run
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Blocked by required conditions
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Blocked by required conditions
Main CI Pipeline / Check Changed Files (push) Waiting to run
Main CI Pipeline / API Tests (push) Blocked by required conditions
Main CI Pipeline / Web Tests (push) Blocked by required conditions
Main CI Pipeline / Style Check (push) Waiting to run
Main CI Pipeline / VDB Tests (push) Blocked by required conditions
Main CI Pipeline / DB Migration Test (push) Blocked by required conditions
2026-02-14 17:31:37 +08:00
yyh
1f74a251f7 fix: remove explore context and migrate query to orpc contract (#32320)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-14 16:18:26 +08:00
41 changed files with 1068 additions and 924 deletions

View File

@@ -390,13 +390,13 @@ describe('App List Browsing Flow', () => {
})
})
// -- Dataset operator redirect --
describe('Dataset Operator Redirect', () => {
it('should redirect dataset operators to /datasets', () => {
// -- Dataset operator behavior --
describe('Dataset Operator Behavior', () => {
it('should not redirect at list component level for dataset operators', () => {
mockIsCurrentWorkspaceDatasetOperator = true
renderList()
expect(mockRouterReplace).toHaveBeenCalledWith('/datasets')
expect(mockRouterReplace).not.toHaveBeenCalled()
})
})

View File

@@ -9,8 +9,9 @@ import type { CreateAppModalProps } from '@/app/components/explore/create-app-mo
import type { App } from '@/models/explore'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import AppList from '@/app/components/explore/app-list'
import ExploreContext from '@/context/explore-context'
import { useAppContext } from '@/context/app-context'
import { fetchAppDetail } from '@/service/explore'
import { useMembers } from '@/service/use-common'
import { AppModeEnum } from '@/types/app'
const allCategoriesEn = 'explore.apps.allCategories:{"lng":"en"}'
@@ -57,6 +58,14 @@ vi.mock('@/service/explore', () => ({
fetchAppList: vi.fn(),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('@/service/use-common', () => ({
useMembers: vi.fn(),
}))
vi.mock('@/hooks/use-import-dsl', () => ({
useImportDSL: () => ({
handleImportDSL: mockHandleImportDSL,
@@ -126,26 +135,25 @@ const createApp = (overrides: Partial<App> = {}): App => ({
is_agent: overrides.is_agent ?? false,
})
const createContextValue = (hasEditPermission = true) => ({
controlUpdateInstalledApps: 0,
setControlUpdateInstalledApps: vi.fn(),
hasEditPermission,
installedApps: [] as never[],
setInstalledApps: vi.fn(),
isFetchingInstalledApps: false,
setIsFetchingInstalledApps: vi.fn(),
isShowTryAppPanel: false,
setShowTryAppPanel: vi.fn(),
})
const mockMemberRole = (hasEditPermission: boolean) => {
;(useAppContext as Mock).mockReturnValue({
userProfile: { id: 'user-1' },
})
;(useMembers as Mock).mockReturnValue({
data: {
accounts: [{ id: 'user-1', role: hasEditPermission ? 'admin' : 'normal' }],
},
})
}
const wrapWithContext = (hasEditPermission = true, onSuccess?: () => void) => (
<ExploreContext.Provider value={createContextValue(hasEditPermission)}>
<AppList onSuccess={onSuccess} />
</ExploreContext.Provider>
)
const renderAppList = (hasEditPermission = true, onSuccess?: () => void) => {
mockMemberRole(hasEditPermission)
return render(<AppList onSuccess={onSuccess} />)
}
const renderWithContext = (hasEditPermission = true, onSuccess?: () => void) => {
return render(wrapWithContext(hasEditPermission, onSuccess))
const appListElement = (hasEditPermission = true, onSuccess?: () => void) => {
mockMemberRole(hasEditPermission)
return <AppList onSuccess={onSuccess} />
}
describe('Explore App List Flow', () => {
@@ -165,7 +173,7 @@ describe('Explore App List Flow', () => {
describe('Browse and Filter Flow', () => {
it('should display all apps when no category filter is applied', () => {
renderWithContext()
renderAppList()
expect(screen.getByText('Writer Bot')).toBeInTheDocument()
expect(screen.getByText('Translator')).toBeInTheDocument()
@@ -174,7 +182,7 @@ describe('Explore App List Flow', () => {
it('should filter apps by selected category', () => {
mockTabValue = 'Writing'
renderWithContext()
renderAppList()
expect(screen.getByText('Writer Bot')).toBeInTheDocument()
expect(screen.queryByText('Translator')).not.toBeInTheDocument()
@@ -182,7 +190,7 @@ describe('Explore App List Flow', () => {
})
it('should filter apps by search keyword', async () => {
renderWithContext()
renderAppList()
const input = screen.getByPlaceholderText('common.operation.search')
fireEvent.change(input, { target: { value: 'trans' } })
@@ -207,7 +215,7 @@ describe('Explore App List Flow', () => {
options.onSuccess?.()
})
renderWithContext(true, onSuccess)
renderAppList(true, onSuccess)
// Step 2: Click add to workspace button - opens create modal
fireEvent.click(screen.getAllByText('explore.appCard.addToWorkspace')[0])
@@ -240,7 +248,7 @@ describe('Explore App List Flow', () => {
// Step 1: Loading state
mockIsLoading = true
mockExploreData = undefined
const { rerender } = render(wrapWithContext())
const { unmount } = render(appListElement())
expect(screen.getByRole('status')).toBeInTheDocument()
@@ -250,7 +258,8 @@ describe('Explore App List Flow', () => {
categories: ['Writing'],
allList: [createApp()],
}
rerender(wrapWithContext())
unmount()
renderAppList()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.getByText('Alpha')).toBeInTheDocument()
@@ -259,13 +268,13 @@ describe('Explore App List Flow', () => {
describe('Permission-Based Behavior', () => {
it('should hide add-to-workspace button when user has no edit permission', () => {
renderWithContext(false)
renderAppList(false)
expect(screen.queryByText('explore.appCard.addToWorkspace')).not.toBeInTheDocument()
})
it('should show add-to-workspace button when user has edit permission', () => {
renderWithContext(true)
renderAppList(true)
expect(screen.getAllByText('explore.appCard.addToWorkspace').length).toBeGreaterThan(0)
})

View File

@@ -8,20 +8,13 @@
import type { Mock } from 'vitest'
import type { InstalledApp as InstalledAppModel } from '@/models/explore'
import { render, screen, waitFor } from '@testing-library/react'
import { useContext } from 'use-context-selector'
import InstalledApp from '@/app/components/explore/installed-app'
import { useWebAppStore } from '@/context/web-app-context'
import { AccessMode } from '@/models/access-control'
import { useGetUserCanAccessApp } from '@/service/access-control'
import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams } from '@/service/use-explore'
import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams, useGetInstalledApps } from '@/service/use-explore'
import { AppModeEnum } from '@/types/app'
// Mock external dependencies
vi.mock('use-context-selector', () => ({
useContext: vi.fn(),
createContext: vi.fn(() => ({})),
}))
vi.mock('@/context/web-app-context', () => ({
useWebAppStore: vi.fn(),
}))
@@ -34,6 +27,7 @@ vi.mock('@/service/use-explore', () => ({
useGetInstalledAppAccessModeByAppId: vi.fn(),
useGetInstalledAppParams: vi.fn(),
useGetInstalledAppMeta: vi.fn(),
useGetInstalledApps: vi.fn(),
}))
vi.mock('@/app/components/share/text-generation', () => ({
@@ -86,18 +80,21 @@ describe('Installed App Flow', () => {
}
type MockOverrides = {
context?: { installedApps?: InstalledAppModel[], isFetchingInstalledApps?: boolean }
accessMode?: { isFetching?: boolean, data?: unknown, error?: unknown }
params?: { isFetching?: boolean, data?: unknown, error?: unknown }
meta?: { isFetching?: boolean, data?: unknown, error?: unknown }
installedApps?: { apps?: InstalledAppModel[], isPending?: boolean, isFetching?: boolean }
accessMode?: { isPending?: boolean, data?: unknown, error?: unknown }
params?: { isPending?: boolean, data?: unknown, error?: unknown }
meta?: { isPending?: boolean, data?: unknown, error?: unknown }
userAccess?: { data?: unknown, error?: unknown }
}
const setupDefaultMocks = (app?: InstalledAppModel, overrides: MockOverrides = {}) => {
;(useContext as Mock).mockReturnValue({
installedApps: app ? [app] : [],
isFetchingInstalledApps: false,
...overrides.context,
const installedApps = overrides.installedApps?.apps ?? (app ? [app] : [])
;(useGetInstalledApps as Mock).mockReturnValue({
data: { installed_apps: installedApps },
isPending: false,
isFetching: false,
...overrides.installedApps,
})
;(useWebAppStore as unknown as Mock).mockImplementation((selector: (state: Record<string, Mock>) => unknown) => {
@@ -111,21 +108,21 @@ describe('Installed App Flow', () => {
})
;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({
isFetching: false,
isPending: false,
data: { accessMode: AccessMode.PUBLIC },
error: null,
...overrides.accessMode,
})
;(useGetInstalledAppParams as Mock).mockReturnValue({
isFetching: false,
isPending: false,
data: mockAppParams,
error: null,
...overrides.params,
})
;(useGetInstalledAppMeta as Mock).mockReturnValue({
isFetching: false,
isPending: false,
data: { tool_icons: {} },
error: null,
...overrides.meta,
@@ -182,7 +179,7 @@ describe('Installed App Flow', () => {
describe('Data Loading Flow', () => {
it('should show loading spinner when params are being fetched', () => {
const app = createInstalledApp()
setupDefaultMocks(app, { params: { isFetching: true, data: null } })
setupDefaultMocks(app, { params: { isPending: true, data: null } })
const { container } = render(<InstalledApp id="installed-app-1" />)
@@ -190,6 +187,17 @@ describe('Installed App Flow', () => {
expect(screen.queryByTestId('chat-with-history')).not.toBeInTheDocument()
})
it('should defer 404 while installed apps are refetching without a match', () => {
setupDefaultMocks(undefined, {
installedApps: { apps: [], isPending: false, isFetching: true },
})
const { container } = render(<InstalledApp id="nonexistent" />)
expect(container.querySelector('svg.spin-animation')).toBeInTheDocument()
expect(screen.queryByText(/404/)).not.toBeInTheDocument()
})
it('should render content when all data is available', () => {
const app = createInstalledApp()
setupDefaultMocks(app)

View File

@@ -1,4 +1,3 @@
import type { IExplore } from '@/context/explore-context'
/**
* Integration test: Sidebar Lifecycle Flow
*
@@ -10,14 +9,12 @@ import type { InstalledApp } from '@/models/explore'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import Toast from '@/app/components/base/toast'
import SideBar from '@/app/components/explore/sidebar'
import ExploreContext from '@/context/explore-context'
import { MediaType } from '@/hooks/use-breakpoints'
import { AppModeEnum } from '@/types/app'
let mockMediaType: string = MediaType.pc
const mockSegments = ['apps']
const mockPush = vi.fn()
const mockRefetch = vi.fn()
const mockUninstall = vi.fn()
const mockUpdatePinStatus = vi.fn()
let mockInstalledApps: InstalledApp[] = []
@@ -40,9 +37,8 @@ vi.mock('@/hooks/use-breakpoints', () => ({
vi.mock('@/service/use-explore', () => ({
useGetInstalledApps: () => ({
isFetching: false,
isPending: false,
data: { installed_apps: mockInstalledApps },
refetch: mockRefetch,
}),
useUninstallApp: () => ({
mutateAsync: mockUninstall,
@@ -69,24 +65,8 @@ const createInstalledApp = (overrides: Partial<InstalledApp> = {}): InstalledApp
},
})
const createContextValue = (installedApps: InstalledApp[] = []): IExplore => ({
controlUpdateInstalledApps: 0,
setControlUpdateInstalledApps: vi.fn(),
hasEditPermission: true,
installedApps,
setInstalledApps: vi.fn(),
isFetchingInstalledApps: false,
setIsFetchingInstalledApps: vi.fn(),
isShowTryAppPanel: false,
setShowTryAppPanel: vi.fn(),
})
const renderSidebar = (installedApps: InstalledApp[] = []) => {
return render(
<ExploreContext.Provider value={createContextValue(installedApps)}>
<SideBar controlUpdateInstalledApps={0} />
</ExploreContext.Provider>,
)
const renderSidebar = () => {
return render(<SideBar />)
}
describe('Sidebar Lifecycle Flow', () => {
@@ -104,7 +84,7 @@ describe('Sidebar Lifecycle Flow', () => {
// Step 1: Start with an unpinned app and pin it
const unpinnedApp = createInstalledApp({ is_pinned: false })
mockInstalledApps = [unpinnedApp]
const { unmount } = renderSidebar(mockInstalledApps)
const { unmount } = renderSidebar()
fireEvent.click(screen.getByTestId('item-operation-trigger'))
fireEvent.click(await screen.findByText('explore.sidebar.action.pin'))
@@ -123,7 +103,7 @@ describe('Sidebar Lifecycle Flow', () => {
const pinnedApp = createInstalledApp({ is_pinned: true })
mockInstalledApps = [pinnedApp]
renderSidebar(mockInstalledApps)
renderSidebar()
fireEvent.click(screen.getByTestId('item-operation-trigger'))
fireEvent.click(await screen.findByText('explore.sidebar.action.unpin'))
@@ -141,7 +121,7 @@ describe('Sidebar Lifecycle Flow', () => {
mockInstalledApps = [app]
mockUninstall.mockResolvedValue(undefined)
renderSidebar(mockInstalledApps)
renderSidebar()
// Step 1: Open operation menu and click delete
fireEvent.click(screen.getByTestId('item-operation-trigger'))
@@ -167,7 +147,7 @@ describe('Sidebar Lifecycle Flow', () => {
const app = createInstalledApp()
mockInstalledApps = [app]
renderSidebar(mockInstalledApps)
renderSidebar()
// Open delete flow
fireEvent.click(screen.getByTestId('item-operation-trigger'))
@@ -188,7 +168,7 @@ describe('Sidebar Lifecycle Flow', () => {
createInstalledApp({ id: 'unpinned-1', is_pinned: false, app: { ...createInstalledApp().app, name: 'Regular App' } }),
]
const { container } = renderSidebar(mockInstalledApps)
const { container } = renderSidebar()
// Both apps are rendered
const pinnedApp = screen.getByText('Pinned App')
@@ -210,14 +190,14 @@ describe('Sidebar Lifecycle Flow', () => {
describe('Empty State', () => {
it('should show NoApps component when no apps are installed on desktop', () => {
mockMediaType = MediaType.pc
renderSidebar([])
renderSidebar()
expect(screen.getByText('explore.sidebar.noApps.title')).toBeInTheDocument()
})
it('should hide NoApps on mobile', () => {
mockMediaType = MediaType.mobile
renderSidebar([])
renderSidebar()
expect(screen.queryByText('explore.sidebar.noApps.title')).not.toBeInTheDocument()
})

View File

@@ -1,10 +1,7 @@
'use client'
import type { FC } from 'react'
import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useAppContext } from '@/context/app-context'
import useDocumentTitle from '@/hooks/use-document-title'
export type IAppDetail = {
@@ -12,16 +9,9 @@ export type IAppDetail = {
}
const AppDetail: FC<IAppDetail> = ({ children }) => {
const router = useRouter()
const { isCurrentWorkspaceDatasetOperator } = useAppContext()
const { t } = useTranslation()
useDocumentTitle(t('menus.appDetail', { ns: 'common' }))
useEffect(() => {
if (isCurrentWorkspaceDatasetOperator)
return router.replace('/datasets')
}, [isCurrentWorkspaceDatasetOperator, router])
return (
<>
{children}

View File

@@ -0,0 +1,108 @@
import type { ReactNode } from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DatasetsLayout from './layout'
const mockReplace = vi.fn()
const mockUseAppContext = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
replace: mockReplace,
}),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => mockUseAppContext(),
}))
vi.mock('@/context/external-api-panel-context', () => ({
ExternalApiPanelProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
}))
vi.mock('@/context/external-knowledge-api-context', () => ({
ExternalKnowledgeApiProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
}))
type AppContextMock = {
isCurrentWorkspaceEditor: boolean
isCurrentWorkspaceDatasetOperator: boolean
isLoadingCurrentWorkspace: boolean
currentWorkspace: {
id: string
}
}
const baseContext: AppContextMock = {
isCurrentWorkspaceEditor: true,
isCurrentWorkspaceDatasetOperator: false,
isLoadingCurrentWorkspace: false,
currentWorkspace: {
id: 'workspace-1',
},
}
const setAppContext = (overrides: Partial<AppContextMock> = {}) => {
mockUseAppContext.mockReturnValue({
...baseContext,
...overrides,
})
}
describe('DatasetsLayout', () => {
beforeEach(() => {
vi.clearAllMocks()
setAppContext()
})
it('should render loading when workspace is still loading', () => {
setAppContext({
isLoadingCurrentWorkspace: true,
currentWorkspace: { id: '' },
})
render((
<DatasetsLayout>
<div data-testid="datasets-content">datasets</div>
</DatasetsLayout>
))
expect(screen.getByRole('status')).toBeInTheDocument()
expect(screen.queryByTestId('datasets-content')).not.toBeInTheDocument()
expect(mockReplace).not.toHaveBeenCalled()
})
it('should redirect non-editor and non-dataset-operator users to /apps', async () => {
setAppContext({
isCurrentWorkspaceEditor: false,
isCurrentWorkspaceDatasetOperator: false,
})
render((
<DatasetsLayout>
<div data-testid="datasets-content">datasets</div>
</DatasetsLayout>
))
expect(screen.queryByTestId('datasets-content')).not.toBeInTheDocument()
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/apps')
})
})
it('should render children for dataset operators', () => {
setAppContext({
isCurrentWorkspaceEditor: false,
isCurrentWorkspaceDatasetOperator: true,
})
render((
<DatasetsLayout>
<div data-testid="datasets-content">datasets</div>
</DatasetsLayout>
))
expect(screen.getByTestId('datasets-content')).toBeInTheDocument()
expect(mockReplace).not.toHaveBeenCalled()
})
})

View File

@@ -10,16 +10,22 @@ import { ExternalKnowledgeApiProvider } from '@/context/external-knowledge-api-c
export default function DatasetsLayout({ children }: { children: React.ReactNode }) {
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, currentWorkspace, isLoadingCurrentWorkspace } = useAppContext()
const router = useRouter()
const shouldRedirect = !isLoadingCurrentWorkspace
&& currentWorkspace.id
&& !(isCurrentWorkspaceEditor || isCurrentWorkspaceDatasetOperator)
useEffect(() => {
if (isLoadingCurrentWorkspace || !currentWorkspace.id)
return
if (!(isCurrentWorkspaceEditor || isCurrentWorkspaceDatasetOperator))
if (shouldRedirect)
router.replace('/apps')
}, [isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace, currentWorkspace, router])
}, [shouldRedirect, router])
if (isLoadingCurrentWorkspace || !(isCurrentWorkspaceEditor || isCurrentWorkspaceDatasetOperator))
if (isLoadingCurrentWorkspace || !currentWorkspace.id)
return <Loading type="app" />
if (shouldRedirect) {
return null
}
return (
<ExternalKnowledgeApiProvider>
<ExternalApiPanelProvider>

View File

@@ -14,6 +14,7 @@ import { ModalContextProvider } from '@/context/modal-context'
import { ProviderContextProvider } from '@/context/provider-context'
import PartnerStack from '../components/billing/partner-stack'
import Splash from '../components/splash'
import RoleRouteGuard from './role-route-guard'
const Layout = ({ children }: { children: ReactNode }) => {
return (
@@ -28,7 +29,9 @@ const Layout = ({ children }: { children: ReactNode }) => {
<HeaderWrapper>
<Header />
</HeaderWrapper>
{children}
<RoleRouteGuard>
{children}
</RoleRouteGuard>
<PartnerStack />
<ReadmePanel />
<GotoAnything />

View File

@@ -0,0 +1,109 @@
import { render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import RoleRouteGuard from './role-route-guard'
const mockReplace = vi.fn()
const mockUseAppContext = vi.fn()
let mockPathname = '/apps'
vi.mock('next/navigation', () => ({
usePathname: () => mockPathname,
useRouter: () => ({
replace: mockReplace,
}),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => mockUseAppContext(),
}))
type AppContextMock = {
isCurrentWorkspaceDatasetOperator: boolean
isLoadingCurrentWorkspace: boolean
}
const baseContext: AppContextMock = {
isCurrentWorkspaceDatasetOperator: false,
isLoadingCurrentWorkspace: false,
}
const setAppContext = (overrides: Partial<AppContextMock> = {}) => {
mockUseAppContext.mockReturnValue({
...baseContext,
...overrides,
})
}
describe('RoleRouteGuard', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPathname = '/apps'
setAppContext()
})
it('should render loading while workspace is loading', () => {
setAppContext({
isLoadingCurrentWorkspace: true,
})
render((
<RoleRouteGuard>
<div data-testid="guarded-content">content</div>
</RoleRouteGuard>
))
expect(screen.getByRole('status')).toBeInTheDocument()
expect(screen.queryByTestId('guarded-content')).not.toBeInTheDocument()
expect(mockReplace).not.toHaveBeenCalled()
})
it('should redirect dataset operator on guarded routes', async () => {
setAppContext({
isCurrentWorkspaceDatasetOperator: true,
})
render((
<RoleRouteGuard>
<div data-testid="guarded-content">content</div>
</RoleRouteGuard>
))
expect(screen.queryByTestId('guarded-content')).not.toBeInTheDocument()
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/datasets')
})
})
it('should allow dataset operator on non-guarded routes', () => {
mockPathname = '/plugins'
setAppContext({
isCurrentWorkspaceDatasetOperator: true,
})
render((
<RoleRouteGuard>
<div data-testid="guarded-content">content</div>
</RoleRouteGuard>
))
expect(screen.getByTestId('guarded-content')).toBeInTheDocument()
expect(mockReplace).not.toHaveBeenCalled()
})
it('should not block non-guarded routes while workspace is loading', () => {
mockPathname = '/plugins'
setAppContext({
isLoadingCurrentWorkspace: true,
})
render((
<RoleRouteGuard>
<div data-testid="guarded-content">content</div>
</RoleRouteGuard>
))
expect(screen.getByTestId('guarded-content')).toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(mockReplace).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,33 @@
'use client'
import type { ReactNode } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import { useEffect } from 'react'
import Loading from '@/app/components/base/loading'
import { useAppContext } from '@/context/app-context'
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/explore', '/tools'] as const
const isPathUnderRoute = (pathname: string, route: string) => pathname === route || pathname.startsWith(`${route}/`)
export default function RoleRouteGuard({ children }: { children: ReactNode }) {
const { isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
const pathname = usePathname()
const router = useRouter()
const shouldGuardRoute = datasetOperatorRedirectRoutes.some(route => isPathUnderRoute(pathname, route))
const shouldRedirect = shouldGuardRoute && !isLoadingCurrentWorkspace && isCurrentWorkspaceDatasetOperator
useEffect(() => {
if (shouldRedirect)
router.replace('/datasets')
}, [shouldRedirect, router])
// Block rendering only for guarded routes to avoid permission flicker.
if (shouldGuardRoute && isLoadingCurrentWorkspace)
return <Loading type="app" />
if (shouldRedirect)
return null
return <>{children}</>
}

View File

@@ -1,24 +1,14 @@
'use client'
import type { FC } from 'react'
import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import ToolProviderList from '@/app/components/tools/provider-list'
import { useAppContext } from '@/context/app-context'
import useDocumentTitle from '@/hooks/use-document-title'
const ToolsList: FC = () => {
const router = useRouter()
const { isCurrentWorkspaceDatasetOperator } = useAppContext()
const { t } = useTranslation()
useDocumentTitle(t('menus.tools', { ns: 'common' }))
useEffect(() => {
if (isCurrentWorkspaceDatasetOperator)
return router.replace('/datasets')
}, [isCurrentWorkspaceDatasetOperator, router])
return <ToolProviderList />
}
export default React.memo(ToolsList)

View File

@@ -2,18 +2,6 @@ import type { ModelAndParameter } from '../configuration/debug/types'
import type { InputVar, Variable } from '@/app/components/workflow/types'
import type { I18nKeysByPrefix } from '@/types/i18n'
import type { PublishWorkflowParams } from '@/types/workflow'
import {
RiArrowDownSLine,
RiArrowRightSLine,
RiBuildingLine,
RiGlobalLine,
RiLockLine,
RiPlanetLine,
RiPlayCircleLine,
RiPlayList2Line,
RiTerminalBoxLine,
RiVerifiedBadgeLine,
} from '@remixicon/react'
import { useKeyPress } from 'ahooks'
import {
memo,
@@ -57,22 +45,22 @@ import SuggestedAction from './suggested-action'
type AccessModeLabel = I18nKeysByPrefix<'app', 'accessControlDialog.accessItems.'>
const ACCESS_MODE_MAP: Record<AccessMode, { label: AccessModeLabel, icon: React.ElementType }> = {
const ACCESS_MODE_MAP: Record<AccessMode, { label: AccessModeLabel, icon: string }> = {
[AccessMode.ORGANIZATION]: {
label: 'organization',
icon: RiBuildingLine,
icon: 'i-ri-building-line',
},
[AccessMode.SPECIFIC_GROUPS_MEMBERS]: {
label: 'specific',
icon: RiLockLine,
icon: 'i-ri-lock-line',
},
[AccessMode.PUBLIC]: {
label: 'anyone',
icon: RiGlobalLine,
icon: 'i-ri-global-line',
},
[AccessMode.EXTERNAL_MEMBERS]: {
label: 'external',
icon: RiVerifiedBadgeLine,
icon: 'i-ri-verified-badge-line',
},
}
@@ -82,13 +70,13 @@ const AccessModeDisplay: React.FC<{ mode?: AccessMode }> = ({ mode }) => {
if (!mode || !ACCESS_MODE_MAP[mode])
return null
const { icon: Icon, label } = ACCESS_MODE_MAP[mode]
const { icon, label } = ACCESS_MODE_MAP[mode]
return (
<>
<Icon className="h-4 w-4 shrink-0 text-text-secondary" />
<span className={`${icon} h-4 w-4 shrink-0 text-text-secondary`} />
<div className="grow truncate">
<span className="system-sm-medium text-text-secondary">{t(`accessControlDialog.accessItems.${label}`, { ns: 'app' })}</span>
<span className="text-text-secondary system-sm-medium">{t(`accessControlDialog.accessItems.${label}`, { ns: 'app' })}</span>
</div>
</>
)
@@ -225,7 +213,7 @@ const AppPublisher = ({
await openAsyncWindow(async () => {
if (!appDetail?.id)
throw new Error('App not found')
const { installed_apps }: any = await fetchInstalledAppList(appDetail?.id) || {}
const { installed_apps } = await fetchInstalledAppList(appDetail.id)
if (installed_apps?.length > 0)
return `${basePath}/explore/installed/${installed_apps[0].id}`
throw new Error('No app found in Explore')
@@ -284,19 +272,19 @@ const AppPublisher = ({
disabled={disabled}
>
{t('common.publish', { ns: 'workflow' })}
<RiArrowDownSLine className="h-4 w-4 text-components-button-primary-text" />
<span className="i-ri-arrow-down-s-line h-4 w-4 text-components-button-primary-text" />
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[11]">
<div className="w-[320px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5">
<div className="p-4 pt-3">
<div className="system-xs-medium-uppercase flex h-6 items-center text-text-tertiary">
<div className="flex h-6 items-center text-text-tertiary system-xs-medium-uppercase">
{publishedAt ? t('common.latestPublished', { ns: 'workflow' }) : t('common.currentDraftUnpublished', { ns: 'workflow' })}
</div>
{publishedAt
? (
<div className="flex items-center justify-between">
<div className="system-sm-medium flex items-center text-text-secondary">
<div className="flex items-center text-text-secondary system-sm-medium">
{t('common.publishedAt', { ns: 'workflow' })}
{' '}
{formatTimeFromNow(publishedAt)}
@@ -314,7 +302,7 @@ const AppPublisher = ({
</div>
)
: (
<div className="system-sm-medium flex items-center text-text-secondary">
<div className="flex items-center text-text-secondary system-sm-medium">
{t('common.autoSaved', { ns: 'workflow' })}
{' '}
·
@@ -377,10 +365,10 @@ const AppPublisher = ({
{systemFeatures.webapp_auth.enabled && (
<div className="p-4 pt-3">
<div className="flex h-6 items-center">
<p className="system-xs-medium text-text-tertiary">{t('publishApp.title', { ns: 'app' })}</p>
<p className="text-text-tertiary system-xs-medium">{t('publishApp.title', { ns: 'app' })}</p>
</div>
<div
className="flex h-8 cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal py-1 pl-2.5 pr-2 hover:bg-primary-50 hover:text-text-accent"
className="flex h-8 cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal py-1 pl-2.5 pr-2 hover:bg-primary-50 hover:text-text-accent"
onClick={() => {
setShowAppAccessControl(true)
}}
@@ -388,12 +376,12 @@ const AppPublisher = ({
<div className="flex grow items-center gap-x-1.5 overflow-hidden pr-1">
<AccessModeDisplay mode={appDetail?.access_mode} />
</div>
{!isAppAccessSet && <p className="system-xs-regular shrink-0 text-text-tertiary">{t('publishApp.notSet', { ns: 'app' })}</p>}
{!isAppAccessSet && <p className="shrink-0 text-text-tertiary system-xs-regular">{t('publishApp.notSet', { ns: 'app' })}</p>}
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
<RiArrowRightSLine className="h-4 w-4 text-text-quaternary" />
<span className="i-ri-arrow-right-s-line h-4 w-4 text-text-quaternary" />
</div>
</div>
{!isAppAccessSet && <p className="system-xs-regular mt-1 text-text-warning">{t('publishApp.notSetDesc', { ns: 'app' })}</p>}
{!isAppAccessSet && <p className="mt-1 text-text-warning system-xs-regular">{t('publishApp.notSetDesc', { ns: 'app' })}</p>}
</div>
)}
{
@@ -405,7 +393,7 @@ const AppPublisher = ({
className="flex-1"
disabled={disabledFunctionButton}
link={appURL}
icon={<RiPlayCircleLine className="h-4 w-4" />}
icon={<span className="i-ri-play-circle-line h-4 w-4" />}
>
{t('common.runApp', { ns: 'workflow' })}
</SuggestedAction>
@@ -417,7 +405,7 @@ const AppPublisher = ({
className="flex-1"
disabled={disabledFunctionButton}
link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`}
icon={<RiPlayList2Line className="h-4 w-4" />}
icon={<span className="i-ri-play-list-2-line h-4 w-4" />}
>
{t('common.batchRunApp', { ns: 'workflow' })}
</SuggestedAction>
@@ -443,7 +431,7 @@ const AppPublisher = ({
handleOpenInExplore()
}}
disabled={disabledFunctionButton}
icon={<RiPlanetLine className="h-4 w-4" />}
icon={<span className="i-ri-planet-line h-4 w-4" />}
>
{t('common.openInExplore', { ns: 'workflow' })}
</SuggestedAction>
@@ -453,7 +441,7 @@ const AppPublisher = ({
className="flex-1"
disabled={!publishedAt || missingStartNode}
link="./develop"
icon={<RiTerminalBoxLine className="h-4 w-4" />}
icon={<span className="i-ri-terminal-box-line h-4 w-4" />}
>
{t('common.accessAPIReference', { ns: 'workflow' })}
</SuggestedAction>

View File

@@ -368,13 +368,13 @@ describe('List', () => {
})
})
describe('Dataset Operator Redirect', () => {
it('should redirect dataset operators to datasets page', () => {
describe('Dataset Operator Behavior', () => {
it('should not trigger redirect at component level for dataset operators', () => {
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(true)
renderList()
expect(mockReplace).toHaveBeenCalledWith('/datasets')
expect(mockReplace).not.toHaveBeenCalled()
})
})

View File

@@ -248,7 +248,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
e.preventDefault()
try {
await openAsyncWindow(async () => {
const { installed_apps }: any = await fetchInstalledAppList(app.id) || {}
const { installed_apps } = await fetchInstalledAppList(app.id)
if (installed_apps?.length > 0)
return `${basePath}/explore/installed/${installed_apps[0].id}`
throw new Error('No app found in Explore')
@@ -258,21 +258,22 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
},
})
}
catch (e: any) {
Toast.notify({ type: 'error', message: `${e.message || e}` })
catch (e: unknown) {
const message = e instanceof Error ? e.message : `${e}`
Toast.notify({ type: 'error', message })
}
}
return (
<div className="relative flex w-full flex-col py-1" onMouseLeave={onMouseLeave}>
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover" onClick={onClickSettings}>
<span className="system-sm-regular text-text-secondary">{t('editApp', { ns: 'app' })}</span>
<span className="text-text-secondary system-sm-regular">{t('editApp', { ns: 'app' })}</span>
</button>
<Divider className="my-1" />
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover" onClick={onClickDuplicate}>
<span className="system-sm-regular text-text-secondary">{t('duplicate', { ns: 'app' })}</span>
<span className="text-text-secondary system-sm-regular">{t('duplicate', { ns: 'app' })}</span>
</button>
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover" onClick={onClickExport}>
<span className="system-sm-regular text-text-secondary">{t('export', { ns: 'app' })}</span>
<span className="text-text-secondary system-sm-regular">{t('export', { ns: 'app' })}</span>
</button>
{(app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT) && (
<>
@@ -293,7 +294,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
<>
<Divider className="my-1" />
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover" onClick={onClickInstalledApp}>
<span className="system-sm-regular text-text-secondary">{t('openInExplore', { ns: 'app' })}</span>
<span className="text-text-secondary system-sm-regular">{t('openInExplore', { ns: 'app' })}</span>
</button>
</>
)
@@ -301,7 +302,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
<>
<Divider className="my-1" />
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover" onClick={onClickInstalledApp}>
<span className="system-sm-regular text-text-secondary">{t('openInExplore', { ns: 'app' })}</span>
<span className="text-text-secondary system-sm-regular">{t('openInExplore', { ns: 'app' })}</span>
</button>
</>
)
@@ -323,7 +324,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
className="group mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 py-[6px] hover:bg-state-destructive-hover"
onClick={onClickDelete}
>
<span className="system-sm-regular text-text-secondary group-hover:text-text-destructive">
<span className="text-text-secondary system-sm-regular group-hover:text-text-destructive">
{t('operation.delete', { ns: 'common' })}
</span>
</button>

View File

@@ -1,6 +1,6 @@
'use client'
import type { CreateAppModalProps } from '../explore/create-app-modal'
import type { CurrentTryAppParams } from '@/context/explore-context'
import type { TryAppSelection } from '@/types/try-app'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useEducationInit } from '@/app/education-apply/hooks'
@@ -20,13 +20,13 @@ const Apps = () => {
useDocumentTitle(t('menus.apps', { ns: 'common' }))
useEducationInit()
const [currentTryAppParams, setCurrentTryAppParams] = useState<CurrentTryAppParams | undefined>(undefined)
const [currentTryAppParams, setCurrentTryAppParams] = useState<TryAppSelection | undefined>(undefined)
const currApp = currentTryAppParams?.app
const [isShowTryAppPanel, setIsShowTryAppPanel] = useState(false)
const hideTryAppPanel = useCallback(() => {
setIsShowTryAppPanel(false)
}, [])
const setShowTryAppPanel = (showTryAppPanel: boolean, params?: CurrentTryAppParams) => {
const setShowTryAppPanel = (showTryAppPanel: boolean, params?: TryAppSelection) => {
if (showTryAppPanel)
setCurrentTryAppParams(params)
else

View File

@@ -1,19 +1,8 @@
'use client'
import type { FC } from 'react'
import {
RiApps2Line,
RiDragDropLine,
RiExchange2Line,
RiFile4Line,
RiMessage3Line,
RiRobot3Line,
} from '@remixicon/react'
import { useDebounceFn } from 'ahooks'
import dynamic from 'next/dynamic'
import {
useRouter,
} from 'next/navigation'
import { parseAsString, useQueryState } from 'nuqs'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -37,16 +26,6 @@ import useAppsQueryState from './hooks/use-apps-query-state'
import { useDSLDragDrop } from './hooks/use-dsl-drag-drop'
import NewAppCard from './new-app-card'
// Define valid tabs at module scope to avoid re-creation on each render and stale closures
const validTabs = new Set<string | AppModeEnum>([
'all',
AppModeEnum.WORKFLOW,
AppModeEnum.ADVANCED_CHAT,
AppModeEnum.CHAT,
AppModeEnum.AGENT_CHAT,
AppModeEnum.COMPLETION,
])
const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), {
ssr: false,
})
@@ -62,7 +41,6 @@ const List: FC<Props> = ({
}) => {
const { t } = useTranslation()
const { systemFeatures } = useGlobalPublicStore()
const router = useRouter()
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
const [activeTab, setActiveTab] = useQueryState(
@@ -125,12 +103,12 @@ const List: FC<Props> = ({
const anchorRef = useRef<HTMLDivElement>(null)
const options = [
{ value: 'all', text: t('types.all', { ns: 'app' }), icon: <RiApps2Line className="mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), icon: <RiExchange2Line className="mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), icon: <RiMessage3Line className="mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), icon: <RiMessage3Line className="mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), icon: <RiRobot3Line className="mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), icon: <RiFile4Line className="mr-1 h-[14px] w-[14px]" /> },
{ value: 'all', text: t('types.all', { ns: 'app' }), icon: <span className="i-ri-apps-2-line mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), icon: <span className="i-ri-exchange-2-line mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), icon: <span className="i-ri-message-3-line mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), icon: <span className="i-ri-message-3-line mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), icon: <span className="i-ri-robot-3-line mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), icon: <span className="i-ri-file-4-line mr-1 h-[14px] w-[14px]" /> },
]
useEffect(() => {
@@ -140,11 +118,6 @@ const List: FC<Props> = ({
}
}, [refetch])
useEffect(() => {
if (isCurrentWorkspaceDatasetOperator)
return router.replace('/datasets')
}, [router, isCurrentWorkspaceDatasetOperator])
useEffect(() => {
if (isCurrentWorkspaceDatasetOperator)
return
@@ -272,7 +245,7 @@ const List: FC<Props> = ({
role="region"
aria-label={t('newApp.dropDSLToCreateApp', { ns: 'app' })}
>
<RiDragDropLine className="h-4 w-4" />
<span className="i-ri-drag-drop-line h-4 w-4" />
<span className="system-xs-regular">{t('newApp.dropDSLToCreateApp', { ns: 'app' })}</span>
</div>
)}

View File

@@ -232,7 +232,7 @@ describe('List', () => {
})
describe('Branch Coverage', () => {
it('should redirect normal role users to /apps', async () => {
it('should not redirect normal role users at component level', async () => {
// Re-mock useAppContext with normal role
vi.doMock('@/context/app-context', () => ({
useAppContext: () => ({
@@ -249,7 +249,7 @@ describe('List', () => {
render(<ListComponent />)
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/apps')
expect(mockReplace).not.toHaveBeenCalled()
})
})

View File

@@ -1,9 +1,8 @@
'use client'
import { useBoolean, useDebounceFn } from 'ahooks'
import { useRouter } from 'next/navigation'
// Libraries
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
@@ -28,8 +27,7 @@ import Datasets from './datasets'
const List = () => {
const { t } = useTranslation()
const { systemFeatures } = useGlobalPublicStore()
const router = useRouter()
const { currentWorkspace, isCurrentWorkspaceOwner } = useAppContext()
const { isCurrentWorkspaceOwner } = useAppContext()
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
const { showExternalApiPanel, setShowExternalApiPanel } = useExternalApiPanel()
const [includeAll, { toggle: toggleIncludeAll }] = useBoolean(false)
@@ -54,11 +52,6 @@ const List = () => {
handleTagsUpdate()
}
useEffect(() => {
if (currentWorkspace.role === 'normal')
return router.replace('/apps')
}, [currentWorkspace, router])
const isCurrentWorkspaceManager = useAppContextSelector(state => state.isCurrentWorkspaceManager)
const { data: apiBaseInfo } = useDatasetApiBaseUrl()
@@ -96,7 +89,7 @@ const List = () => {
onClick={() => setShowExternalApiPanel(true)}
>
<ApiConnectionMod className="h-4 w-4 text-components-button-secondary-text" />
<div className="system-sm-medium flex items-center justify-center gap-1 px-0.5 text-components-button-secondary-text">{t('externalAPIPanelTitle', { ns: 'dataset' })}</div>
<div className="flex items-center justify-center gap-1 px-0.5 text-components-button-secondary-text system-sm-medium">{t('externalAPIPanelTitle', { ns: 'dataset' })}</div>
</Button>
</div>
</div>

View File

@@ -1,12 +1,7 @@
import type { Mock } from 'vitest'
import type { CurrentTryAppParams } from '@/context/explore-context'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useContext } from 'use-context-selector'
import { render, screen, waitFor } from '@testing-library/react'
import { useAppContext } from '@/context/app-context'
import ExploreContext from '@/context/explore-context'
import { MediaType } from '@/hooks/use-breakpoints'
import useDocumentTitle from '@/hooks/use-document-title'
import { useMembers } from '@/service/use-common'
import Explore from '../index'
const mockReplace = vi.fn()
@@ -32,9 +27,8 @@ vi.mock('@/hooks/use-breakpoints', () => ({
vi.mock('@/service/use-explore', () => ({
useGetInstalledApps: () => ({
isFetching: false,
isPending: false,
data: mockInstalledAppsData,
refetch: vi.fn(),
}),
useUninstallApp: () => ({
mutateAsync: vi.fn(),
@@ -48,83 +42,31 @@ vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('@/service/use-common', () => ({
useMembers: vi.fn(),
}))
vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
const ContextReader = ({ triggerTryPanel }: { triggerTryPanel?: boolean }) => {
const { hasEditPermission, setShowTryAppPanel, isShowTryAppPanel, currentApp } = useContext(ExploreContext)
return (
<div>
{hasEditPermission ? 'edit-yes' : 'edit-no'}
{isShowTryAppPanel && <span data-testid="try-panel-open">open</span>}
{currentApp && <span data-testid="current-app">{currentApp.appId}</span>}
{triggerTryPanel && (
<>
<button data-testid="show-try" onClick={() => setShowTryAppPanel(true, { appId: 'test-app' } as CurrentTryAppParams)}>show</button>
<button data-testid="hide-try" onClick={() => setShowTryAppPanel(false)}>hide</button>
</>
)}
</div>
)
}
describe('Explore', () => {
beforeEach(() => {
vi.clearAllMocks()
;(useAppContext as Mock).mockReturnValue({
isCurrentWorkspaceDatasetOperator: false,
})
})
describe('Rendering', () => {
it('should render children and provide edit permission from members role', async () => {
; (useAppContext as Mock).mockReturnValue({
userProfile: { id: 'user-1' },
isCurrentWorkspaceDatasetOperator: false,
});
(useMembers as Mock).mockReturnValue({
data: {
accounts: [{ id: 'user-1', role: 'admin' }],
},
})
it('should render children', () => {
render((
<Explore>
<ContextReader />
<div>child</div>
</Explore>
))
await waitFor(() => {
expect(screen.getByText('edit-yes')).toBeInTheDocument()
})
expect(screen.getByText('child')).toBeInTheDocument()
})
})
describe('Effects', () => {
it('should set document title on render', () => {
; (useAppContext as Mock).mockReturnValue({
userProfile: { id: 'user-1' },
isCurrentWorkspaceDatasetOperator: false,
});
(useMembers as Mock).mockReturnValue({ data: { accounts: [] } })
render((
<Explore>
<div>child</div>
</Explore>
))
expect(useDocumentTitle).toHaveBeenCalledWith('common.menus.explore')
})
it('should redirect dataset operators to /datasets', async () => {
; (useAppContext as Mock).mockReturnValue({
userProfile: { id: 'user-1' },
it('should not redirect dataset operators at component level', async () => {
;(useAppContext as Mock).mockReturnValue({
isCurrentWorkspaceDatasetOperator: true,
});
(useMembers as Mock).mockReturnValue({ data: { accounts: [] } })
})
render((
<Explore>
@@ -133,72 +75,18 @@ describe('Explore', () => {
))
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/datasets')
expect(mockReplace).not.toHaveBeenCalled()
})
})
it('should skip permission check when membersData has no accounts', () => {
; (useAppContext as Mock).mockReturnValue({
userProfile: { id: 'user-1' },
isCurrentWorkspaceDatasetOperator: false,
});
(useMembers as Mock).mockReturnValue({ data: undefined })
it('should not redirect non dataset operators', () => {
render((
<Explore>
<ContextReader />
<div>child</div>
</Explore>
))
expect(screen.getByText('edit-no')).toBeInTheDocument()
})
})
describe('Context: setShowTryAppPanel', () => {
it('should set currentApp params when showing try panel', async () => {
; (useAppContext as Mock).mockReturnValue({
userProfile: { id: 'user-1' },
isCurrentWorkspaceDatasetOperator: false,
});
(useMembers as Mock).mockReturnValue({ data: { accounts: [] } })
render((
<Explore>
<ContextReader triggerTryPanel />
</Explore>
))
fireEvent.click(screen.getByTestId('show-try'))
await waitFor(() => {
expect(screen.getByTestId('try-panel-open')).toBeInTheDocument()
expect(screen.getByTestId('current-app')).toHaveTextContent('test-app')
})
})
it('should clear currentApp params when hiding try panel', async () => {
; (useAppContext as Mock).mockReturnValue({
userProfile: { id: 'user-1' },
isCurrentWorkspaceDatasetOperator: false,
});
(useMembers as Mock).mockReturnValue({ data: { accounts: [] } })
render((
<Explore>
<ContextReader triggerTryPanel />
</Explore>
))
fireEvent.click(screen.getByTestId('show-try'))
await waitFor(() => {
expect(screen.getByTestId('try-panel-open')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('hide-try'))
await waitFor(() => {
expect(screen.queryByTestId('try-panel-open')).not.toBeInTheDocument()
expect(screen.queryByTestId('current-app')).not.toBeInTheDocument()
})
expect(mockReplace).not.toHaveBeenCalled()
})
})
})

View File

@@ -2,7 +2,6 @@ import type { AppCardProps } from '../index'
import type { App } from '@/models/explore'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import ExploreContext from '@/context/explore-context'
import { AppModeEnum } from '@/types/app'
import AppCard from '../index'
@@ -41,12 +40,14 @@ const createApp = (overrides?: Partial<App>): App => ({
describe('AppCard', () => {
const onCreate = vi.fn()
const onTry = vi.fn()
const renderComponent = (props?: Partial<AppCardProps>) => {
const mergedProps: AppCardProps = {
app: createApp(),
canCreate: false,
onCreate,
onTry,
isExplore: false,
...props,
}
@@ -138,31 +139,14 @@ describe('AppCard', () => {
expect(screen.getByText('Sample App')).toBeInTheDocument()
})
it('should call setShowTryAppPanel when try button is clicked', () => {
const mockSetShowTryAppPanel = vi.fn()
it('should call onTry when try button is clicked', () => {
const app = createApp()
render(
<ExploreContext.Provider
value={{
controlUpdateInstalledApps: 0,
setControlUpdateInstalledApps: vi.fn(),
hasEditPermission: false,
installedApps: [],
setInstalledApps: vi.fn(),
isFetchingInstalledApps: false,
setIsFetchingInstalledApps: vi.fn(),
isShowTryAppPanel: false,
setShowTryAppPanel: mockSetShowTryAppPanel,
}}
>
<AppCard app={app} canCreate={true} onCreate={vi.fn()} isExplore={true} />
</ExploreContext.Provider>,
)
renderComponent({ app, canCreate: true, isExplore: true })
fireEvent.click(screen.getByText('explore.appCard.try'))
expect(mockSetShowTryAppPanel).toHaveBeenCalledWith(true, { appId: 'app-id', app })
expect(onTry).toHaveBeenCalledWith({ appId: 'app-id', app })
})
})
})

View File

@@ -1,12 +1,10 @@
'use client'
import type { App } from '@/models/explore'
import type { TryAppSelection } from '@/types/try-app'
import { PlusIcon } from '@heroicons/react/20/solid'
import { RiInformation2Line } from '@remixicon/react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useContextSelector } from 'use-context-selector'
import AppIcon from '@/app/components/base/app-icon'
import ExploreContext from '@/context/explore-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
@@ -17,25 +15,24 @@ export type AppCardProps = {
app: App
canCreate: boolean
onCreate: () => void
isExplore: boolean
onTry: (params: TryAppSelection) => void
isExplore?: boolean
}
const AppCard = ({
app,
canCreate,
onCreate,
isExplore,
onTry,
isExplore = true,
}: AppCardProps) => {
const { t } = useTranslation()
const { app: appBasicInfo } = app
const { systemFeatures } = useGlobalPublicStore()
const isTrialApp = app.can_trial && systemFeatures.enable_trial_app
const setShowTryAppPanel = useContextSelector(ExploreContext, ctx => ctx.setShowTryAppPanel)
const showTryAPPPanel = useCallback((appId: string) => {
return () => {
setShowTryAppPanel?.(true, { appId, app })
}
}, [setShowTryAppPanel, app])
const handleTryApp = () => {
onTry({ appId: app.app_id, app })
}
return (
<div className={cn('group relative col-span-1 flex cursor-pointer flex-col overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pb-2 shadow-sm transition-all duration-200 ease-in-out hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-lg')}>
@@ -67,7 +64,7 @@ const AppCard = ({
</div>
</div>
</div>
<div className="description-wrapper system-xs-regular h-[90px] px-[14px] text-text-tertiary">
<div className="description-wrapper h-[90px] px-[14px] text-text-tertiary system-xs-regular">
<div className="line-clamp-4 group-hover:line-clamp-2">
{app.description}
</div>
@@ -83,7 +80,7 @@ const AppCard = ({
</Button>
)
}
<Button className="h-7" onClick={showTryAPPPanel(app.app_id)}>
<Button className="h-7" onClick={handleTryApp}>
<RiInformation2Line className="mr-1 size-4" />
<span>{t('appCard.try', { ns: 'explore' })}</span>
</Button>

View File

@@ -1,12 +1,12 @@
import type { Mock } from 'vitest'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { CurrentTryAppParams } from '@/context/explore-context'
import type { App } from '@/models/explore'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import ExploreContext from '@/context/explore-context'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { fetchAppDetail } from '@/service/explore'
import { useMembers } from '@/service/use-common'
import { AppModeEnum } from '@/types/app'
import AppList from '../index'
@@ -29,6 +29,14 @@ vi.mock('@/service/explore', () => ({
fetchAppList: vi.fn(),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('@/service/use-common', () => ({
useMembers: vi.fn(),
}))
vi.mock('@/hooks/use-import-dsl', () => ({
useImportDSL: () => ({
handleImportDSL: mockHandleImportDSL,
@@ -111,24 +119,22 @@ const createApp = (overrides: Partial<App> = {}): App => ({
is_agent: overrides.is_agent ?? false,
})
const renderWithContext = (hasEditPermission = false, onSuccess?: () => void, searchParams?: Record<string, string>) => {
const mockMemberRole = (hasEditPermission: boolean) => {
;(useAppContext as Mock).mockReturnValue({
userProfile: { id: 'user-1' },
})
;(useMembers as Mock).mockReturnValue({
data: {
accounts: [{ id: 'user-1', role: hasEditPermission ? 'admin' : 'normal' }],
},
})
}
const renderAppList = (hasEditPermission = false, onSuccess?: () => void, searchParams?: Record<string, string>) => {
mockMemberRole(hasEditPermission)
return render(
<NuqsTestingAdapter searchParams={searchParams}>
<ExploreContext.Provider
value={{
controlUpdateInstalledApps: 0,
setControlUpdateInstalledApps: vi.fn(),
hasEditPermission,
installedApps: [],
setInstalledApps: vi.fn(),
isFetchingInstalledApps: false,
setIsFetchingInstalledApps: vi.fn(),
isShowTryAppPanel: false,
setShowTryAppPanel: vi.fn(),
}}
>
<AppList onSuccess={onSuccess} />
</ExploreContext.Provider>
<AppList onSuccess={onSuccess} />
</NuqsTestingAdapter>,
)
}
@@ -151,7 +157,7 @@ describe('AppList', () => {
mockExploreData = undefined
mockIsLoading = true
renderWithContext()
renderAppList()
expect(screen.getByRole('status')).toBeInTheDocument()
})
@@ -162,7 +168,7 @@ describe('AppList', () => {
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })],
}
renderWithContext()
renderAppList()
expect(screen.getByText('Alpha')).toBeInTheDocument()
expect(screen.getByText('Beta')).toBeInTheDocument()
@@ -176,7 +182,7 @@ describe('AppList', () => {
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })],
}
renderWithContext(false, undefined, { category: 'Writing' })
renderAppList(false, undefined, { category: 'Writing' })
expect(screen.getByText('Alpha')).toBeInTheDocument()
expect(screen.queryByText('Beta')).not.toBeInTheDocument()
@@ -189,7 +195,7 @@ describe('AppList', () => {
categories: ['Writing'],
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })],
}
renderWithContext()
renderAppList()
const input = screen.getByPlaceholderText('common.operation.search')
fireEvent.change(input, { target: { value: 'gam' } })
@@ -217,7 +223,7 @@ describe('AppList', () => {
options.onSuccess?.()
})
renderWithContext(true, onSuccess)
renderAppList(true, onSuccess)
fireEvent.click(screen.getByText('explore.appCard.addToWorkspace'))
fireEvent.click(await screen.findByTestId('confirm-create'))
@@ -241,7 +247,7 @@ describe('AppList', () => {
categories: ['Writing'],
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })],
}
renderWithContext()
renderAppList()
const input = screen.getByPlaceholderText('common.operation.search')
fireEvent.change(input, { target: { value: 'gam' } })
@@ -263,7 +269,7 @@ describe('AppList', () => {
mockIsError = true
mockExploreData = undefined
const { container } = renderWithContext()
const { container } = renderAppList()
expect(container.innerHTML).toBe('')
})
@@ -271,7 +277,7 @@ describe('AppList', () => {
it('should render nothing when data is undefined', () => {
mockExploreData = undefined
const { container } = renderWithContext()
const { container } = renderAppList()
expect(container.innerHTML).toBe('')
})
@@ -281,7 +287,7 @@ describe('AppList', () => {
categories: ['Writing'],
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })],
}
renderWithContext()
renderAppList()
const input = screen.getByPlaceholderText('common.operation.search')
fireEvent.change(input, { target: { value: 'gam' } })
@@ -304,7 +310,7 @@ describe('AppList', () => {
};
(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml' })
renderWithContext(true)
renderAppList(true)
fireEvent.click(screen.getByText('explore.appCard.addToWorkspace'))
expect(await screen.findByTestId('create-app-modal')).toBeInTheDocument()
@@ -325,7 +331,7 @@ describe('AppList', () => {
options.onSuccess?.()
})
renderWithContext(true)
renderAppList(true)
fireEvent.click(screen.getByText('explore.appCard.addToWorkspace'))
fireEvent.click(await screen.findByTestId('confirm-create'))
@@ -345,7 +351,7 @@ describe('AppList', () => {
options.onPending?.()
})
renderWithContext(true)
renderAppList(true)
fireEvent.click(screen.getByText('explore.appCard.addToWorkspace'))
fireEvent.click(await screen.findByTestId('confirm-create'))
@@ -362,70 +368,16 @@ describe('AppList', () => {
describe('TryApp Panel', () => {
it('should open create modal from try app panel', async () => {
vi.useRealTimers()
const mockSetShowTryAppPanel = vi.fn()
const app = createApp()
mockExploreData = {
categories: ['Writing'],
allList: [app],
}
render(
<NuqsTestingAdapter>
<ExploreContext.Provider
value={{
controlUpdateInstalledApps: 0,
setControlUpdateInstalledApps: vi.fn(),
hasEditPermission: true,
installedApps: [],
setInstalledApps: vi.fn(),
isFetchingInstalledApps: false,
setIsFetchingInstalledApps: vi.fn(),
isShowTryAppPanel: true,
setShowTryAppPanel: mockSetShowTryAppPanel,
currentApp: { appId: 'app-1', app },
}}
>
<AppList />
</ExploreContext.Provider>
</NuqsTestingAdapter>,
)
const createBtn = screen.getByTestId('try-app-create')
fireEvent.click(createBtn)
await waitFor(() => {
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
})
})
it('should open create modal with null currApp when appParams has no app', async () => {
vi.useRealTimers()
mockExploreData = {
categories: ['Writing'],
allList: [createApp()],
}
render(
<NuqsTestingAdapter>
<ExploreContext.Provider
value={{
controlUpdateInstalledApps: 0,
setControlUpdateInstalledApps: vi.fn(),
hasEditPermission: true,
installedApps: [],
setInstalledApps: vi.fn(),
isFetchingInstalledApps: false,
setIsFetchingInstalledApps: vi.fn(),
isShowTryAppPanel: true,
setShowTryAppPanel: vi.fn(),
currentApp: { appId: 'app-1' } as CurrentTryAppParams,
}}
>
<AppList />
</ExploreContext.Provider>
</NuqsTestingAdapter>,
)
renderAppList(true)
fireEvent.click(screen.getByText('explore.appCard.try'))
expect(screen.getByTestId('try-app-panel')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('try-app-create'))
@@ -434,33 +386,19 @@ describe('AppList', () => {
})
})
it('should render try app panel with empty appId when currentApp is undefined', () => {
it('should close try app panel when close is clicked', () => {
mockExploreData = {
categories: ['Writing'],
allList: [createApp()],
}
render(
<NuqsTestingAdapter>
<ExploreContext.Provider
value={{
controlUpdateInstalledApps: 0,
setControlUpdateInstalledApps: vi.fn(),
hasEditPermission: true,
installedApps: [],
setInstalledApps: vi.fn(),
isFetchingInstalledApps: false,
setIsFetchingInstalledApps: vi.fn(),
isShowTryAppPanel: true,
setShowTryAppPanel: vi.fn(),
}}
>
<AppList />
</ExploreContext.Provider>
</NuqsTestingAdapter>,
)
renderAppList(true)
fireEvent.click(screen.getByText('explore.appCard.try'))
expect(screen.getByTestId('try-app-panel')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('try-app-close'))
expect(screen.queryByTestId('try-app-panel')).not.toBeInTheDocument()
})
})
@@ -477,7 +415,7 @@ describe('AppList', () => {
allList: [createApp()],
}
renderWithContext()
renderAppList()
expect(screen.getByTestId('explore-banner')).toBeInTheDocument()
})

View File

@@ -2,12 +2,12 @@
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { App } from '@/models/explore'
import type { TryAppSelection } from '@/types/try-app'
import { useDebounceFn } from 'ahooks'
import { useQueryState } from 'nuqs'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext, useContextSelector } from 'use-context-selector'
import DSLConfirmModal from '@/app/components/app/create-from-dsl-modal/dsl-confirm-modal'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
@@ -16,13 +16,14 @@ import AppCard from '@/app/components/explore/app-card'
import Banner from '@/app/components/explore/banner/banner'
import Category from '@/app/components/explore/category'
import CreateAppModal from '@/app/components/explore/create-app-modal'
import ExploreContext from '@/context/explore-context'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useImportDSL } from '@/hooks/use-import-dsl'
import {
DSLImportMode,
} from '@/models/app'
import { fetchAppDetail } from '@/service/explore'
import { useMembers } from '@/service/use-common'
import { useExploreAppList } from '@/service/use-explore'
import { cn } from '@/utils/classnames'
import TryApp from '../try-app'
@@ -36,9 +37,12 @@ const Apps = ({
onSuccess,
}: AppsProps) => {
const { t } = useTranslation()
const { userProfile } = useAppContext()
const { systemFeatures } = useGlobalPublicStore()
const { hasEditPermission } = useContext(ExploreContext)
const { data: membersData } = useMembers()
const allCategoriesEn = t('apps.allCategories', { ns: 'explore', lng: 'en' })
const userAccount = membersData?.accounts?.find(account => account.id === userProfile.id)
const hasEditPermission = !!userAccount && userAccount.role !== 'normal'
const [keywords, setKeywords] = useState('')
const [searchKeywords, setSearchKeywords] = useState('')
@@ -85,8 +89,8 @@ const Apps = ({
)
}, [searchKeywords, filteredList])
const [currApp, setCurrApp] = React.useState<App | null>(null)
const [isShowCreateModal, setIsShowCreateModal] = React.useState(false)
const [currApp, setCurrApp] = useState<App | null>(null)
const [isShowCreateModal, setIsShowCreateModal] = useState(false)
const {
handleImportDSL,
@@ -96,16 +100,18 @@ const Apps = ({
} = useImportDSL()
const [showDSLConfirmModal, setShowDSLConfirmModal] = useState(false)
const isShowTryAppPanel = useContextSelector(ExploreContext, ctx => ctx.isShowTryAppPanel)
const setShowTryAppPanel = useContextSelector(ExploreContext, ctx => ctx.setShowTryAppPanel)
const [currentTryApp, setCurrentTryApp] = useState<TryAppSelection | undefined>(undefined)
const isShowTryAppPanel = !!currentTryApp
const hideTryAppPanel = useCallback(() => {
setShowTryAppPanel(false)
}, [setShowTryAppPanel])
const appParams = useContextSelector(ExploreContext, ctx => ctx.currentApp)
setCurrentTryApp(undefined)
}, [])
const handleTryApp = useCallback((params: TryAppSelection) => {
setCurrentTryApp(params)
}, [])
const handleShowFromTryApp = useCallback(() => {
setCurrApp(appParams?.app || null)
setCurrApp(currentTryApp?.app || null)
setIsShowCreateModal(true)
}, [appParams?.app])
}, [currentTryApp?.app])
const onCreate: CreateAppModalProps['onConfirm'] = async ({
name,
@@ -175,7 +181,7 @@ const Apps = ({
)}
>
<div className="flex items-center">
<div className="system-xl-semibold grow truncate text-text-primary">{!hasFilterCondition ? t('apps.title', { ns: 'explore' }) : t('apps.resultNum', { num: searchFilteredList.length, ns: 'explore' })}</div>
<div className="grow truncate text-text-primary system-xl-semibold">{!hasFilterCondition ? t('apps.title', { ns: 'explore' }) : t('apps.resultNum', { num: searchFilteredList.length, ns: 'explore' })}</div>
{hasFilterCondition && (
<>
<div className="mx-3 h-4 w-px bg-divider-regular"></div>
@@ -216,13 +222,13 @@ const Apps = ({
{searchFilteredList.map(app => (
<AppCard
key={app.app_id}
isExplore
app={app}
canCreate={hasEditPermission}
onCreate={() => {
setCurrApp(app)
setIsShowCreateModal(true)
}}
onTry={handleTryApp}
/>
))}
</nav>
@@ -255,9 +261,9 @@ const Apps = ({
{isShowTryAppPanel && (
<TryApp
appId={appParams?.appId || ''}
app={appParams?.app}
category={appParams?.app?.category}
appId={currentTryApp?.appId || ''}
app={currentTryApp?.app}
category={currentTryApp?.app?.category}
onClose={hideTryAppPanel}
onCreate={handleShowFromTryApp}
/>

View File

@@ -1,80 +1,18 @@
'use client'
import type { FC } from 'react'
import type { CurrentTryAppParams } from '@/context/explore-context'
import type { InstalledApp } from '@/models/explore'
import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Sidebar from '@/app/components/explore/sidebar'
import { useAppContext } from '@/context/app-context'
import ExploreContext from '@/context/explore-context'
import useDocumentTitle from '@/hooks/use-document-title'
import { useMembers } from '@/service/use-common'
export type IExploreProps = {
children: React.ReactNode
}
const Explore: FC<IExploreProps> = ({
const Explore = ({
children,
}: {
children: React.ReactNode
}) => {
const router = useRouter()
const [controlUpdateInstalledApps, setControlUpdateInstalledApps] = useState(0)
const { userProfile, isCurrentWorkspaceDatasetOperator } = useAppContext()
const [hasEditPermission, setHasEditPermission] = useState(false)
const [installedApps, setInstalledApps] = useState<InstalledApp[]>([])
const [isFetchingInstalledApps, setIsFetchingInstalledApps] = useState(false)
const { t } = useTranslation()
const { data: membersData } = useMembers()
useDocumentTitle(t('menus.explore', { ns: 'common' }))
useEffect(() => {
if (!membersData?.accounts)
return
const currUser = membersData.accounts.find(account => account.id === userProfile.id)
setHasEditPermission(currUser?.role !== 'normal')
}, [membersData, userProfile.id])
useEffect(() => {
if (isCurrentWorkspaceDatasetOperator)
return router.replace('/datasets')
}, [isCurrentWorkspaceDatasetOperator])
const [currentTryAppParams, setCurrentTryAppParams] = useState<CurrentTryAppParams | undefined>(undefined)
const [isShowTryAppPanel, setIsShowTryAppPanel] = useState(false)
const setShowTryAppPanel = (showTryAppPanel: boolean, params?: CurrentTryAppParams) => {
if (showTryAppPanel)
setCurrentTryAppParams(params)
else
setCurrentTryAppParams(undefined)
setIsShowTryAppPanel(showTryAppPanel)
}
return (
<div className="flex h-full overflow-hidden border-t border-divider-regular bg-background-body">
<ExploreContext.Provider
value={
{
controlUpdateInstalledApps,
setControlUpdateInstalledApps,
hasEditPermission,
installedApps,
setInstalledApps,
isFetchingInstalledApps,
setIsFetchingInstalledApps,
currentApp: currentTryAppParams,
isShowTryAppPanel,
setShowTryAppPanel,
}
}
>
<Sidebar controlUpdateInstalledApps={controlUpdateInstalledApps} />
<div className="h-full min-h-0 w-0 grow">
{children}
</div>
</ExploreContext.Provider>
<Sidebar />
<div className="h-full min-h-0 w-0 grow">
{children}
</div>
</div>
)
}

View File

@@ -1,19 +1,14 @@
import type { Mock } from 'vitest'
import type { InstalledApp as InstalledAppType } from '@/models/explore'
import { render, screen, waitFor } from '@testing-library/react'
import { useContext } from 'use-context-selector'
import { useWebAppStore } from '@/context/web-app-context'
import { AccessMode } from '@/models/access-control'
import { useGetUserCanAccessApp } from '@/service/access-control'
import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams } from '@/service/use-explore'
import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams, useGetInstalledApps } from '@/service/use-explore'
import { AppModeEnum } from '@/types/app'
import InstalledApp from '../index'
vi.mock('use-context-selector', () => ({
useContext: vi.fn(),
createContext: vi.fn(() => ({})),
}))
vi.mock('@/context/web-app-context', () => ({
useWebAppStore: vi.fn(),
}))
@@ -24,28 +19,9 @@ vi.mock('@/service/use-explore', () => ({
useGetInstalledAppAccessModeByAppId: vi.fn(),
useGetInstalledAppParams: vi.fn(),
useGetInstalledAppMeta: vi.fn(),
useGetInstalledApps: vi.fn(),
}))
/**
* Mock child components for unit testing
*
* RATIONALE FOR MOCKING:
* - TextGenerationApp: 648 lines, complex batch processing, task management, file uploads
* - ChatWithHistory: 576-line custom hook, complex conversation/history management, 30+ context values
*
* These components are too complex to test as real components. Using real components would:
* 1. Require mocking dozens of their dependencies (services, contexts, hooks)
* 2. Make tests fragile and coupled to child component implementation details
* 3. Violate the principle of testing one component in isolation
*
* For a container component like InstalledApp, its responsibility is to:
* - Correctly route to the appropriate child component based on app mode
* - Pass the correct props to child components
* - Handle loading/error states before rendering children
*
* The internal logic of ChatWithHistory and TextGenerationApp should be tested
* in their own dedicated test files.
*/
vi.mock('@/app/components/share/text-generation', () => ({
default: ({ isInstalledApp, installedAppInfo, isWorkflow }: {
isInstalledApp?: boolean
@@ -115,13 +91,29 @@ describe('InstalledApp', () => {
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,
})
}
beforeEach(() => {
vi.clearAllMocks()
;(useContext as Mock).mockReturnValue({
installedApps: [mockInstalledApp],
isFetchingInstalledApps: false,
})
setupMocks()
;(useWebAppStore as unknown as Mock).mockImplementation((
selector: (state: {
@@ -143,19 +135,19 @@ describe('InstalledApp', () => {
})
;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({
isFetching: false,
isPending: false,
data: mockWebAppAccessMode,
error: null,
})
;(useGetInstalledAppParams as Mock).mockReturnValue({
isFetching: false,
isPending: false,
data: mockAppParams,
error: null,
})
;(useGetInstalledAppMeta as Mock).mockReturnValue({
isFetching: false,
isPending: false,
data: mockAppMeta,
error: null,
})
@@ -174,7 +166,7 @@ describe('InstalledApp', () => {
it('should render loading state when fetching app params', () => {
;(useGetInstalledAppParams as Mock).mockReturnValue({
isFetching: true,
isPending: true,
data: null,
error: null,
})
@@ -186,7 +178,7 @@ describe('InstalledApp', () => {
it('should render loading state when fetching app meta', () => {
;(useGetInstalledAppMeta as Mock).mockReturnValue({
isFetching: true,
isPending: true,
data: null,
error: null,
})
@@ -198,7 +190,7 @@ describe('InstalledApp', () => {
it('should render loading state when fetching web app access mode', () => {
;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({
isFetching: true,
isPending: true,
data: null,
error: null,
})
@@ -209,10 +201,7 @@ describe('InstalledApp', () => {
})
it('should render loading state when fetching installed apps', () => {
;(useContext as Mock).mockReturnValue({
installedApps: [mockInstalledApp],
isFetchingInstalledApps: true,
})
setupMocks([mockInstalledApp], { isPending: true })
const { container } = render(<InstalledApp id="installed-app-123" />)
const svg = container.querySelector('svg.spin-animation')
@@ -220,10 +209,7 @@ describe('InstalledApp', () => {
})
it('should render app not found (404) when installedApp does not exist', () => {
;(useContext as Mock).mockReturnValue({
installedApps: [],
isFetchingInstalledApps: false,
})
setupMocks([])
render(<InstalledApp id="nonexistent-app" />)
expect(screen.getByText(/404/)).toBeInTheDocument()
@@ -234,7 +220,7 @@ describe('InstalledApp', () => {
it('should render error when app params fails to load', () => {
const error = new Error('Failed to load app params')
;(useGetInstalledAppParams as Mock).mockReturnValue({
isFetching: false,
isPending: false,
data: null,
error,
})
@@ -246,7 +232,7 @@ describe('InstalledApp', () => {
it('should render error when app meta fails to load', () => {
const error = new Error('Failed to load app meta')
;(useGetInstalledAppMeta as Mock).mockReturnValue({
isFetching: false,
isPending: false,
data: null,
error,
})
@@ -258,7 +244,7 @@ describe('InstalledApp', () => {
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({
isFetching: false,
isPending: false,
data: null,
error,
})
@@ -305,10 +291,7 @@ describe('InstalledApp', () => {
mode: AppModeEnum.ADVANCED_CHAT,
},
}
;(useContext as Mock).mockReturnValue({
installedApps: [advancedChatApp],
isFetchingInstalledApps: false,
})
setupMocks([advancedChatApp])
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByText(/Chat With History/i)).toBeInTheDocument()
@@ -323,10 +306,7 @@ describe('InstalledApp', () => {
mode: AppModeEnum.AGENT_CHAT,
},
}
;(useContext as Mock).mockReturnValue({
installedApps: [agentChatApp],
isFetchingInstalledApps: false,
})
setupMocks([agentChatApp])
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByText(/Chat With History/i)).toBeInTheDocument()
@@ -341,10 +321,7 @@ describe('InstalledApp', () => {
mode: AppModeEnum.COMPLETION,
},
}
;(useContext as Mock).mockReturnValue({
installedApps: [completionApp],
isFetchingInstalledApps: false,
})
setupMocks([completionApp])
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByText(/Text Generation App/i)).toBeInTheDocument()
@@ -359,10 +336,7 @@ describe('InstalledApp', () => {
mode: AppModeEnum.WORKFLOW,
},
}
;(useContext as Mock).mockReturnValue({
installedApps: [workflowApp],
isFetchingInstalledApps: false,
})
setupMocks([workflowApp])
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByText(/Text Generation App/i)).toBeInTheDocument()
@@ -374,10 +348,7 @@ describe('InstalledApp', () => {
it('should use id prop to find installed app', () => {
const app1 = { ...mockInstalledApp, id: 'app-1' }
const app2 = { ...mockInstalledApp, id: 'app-2' }
;(useContext as Mock).mockReturnValue({
installedApps: [app1, app2],
isFetchingInstalledApps: false,
})
setupMocks([app1, app2])
render(<InstalledApp id="app-2" />)
expect(screen.getByText(/app-2/)).toBeInTheDocument()
@@ -416,10 +387,7 @@ describe('InstalledApp', () => {
})
it('should update app info to null when installedApp is not found', async () => {
;(useContext as Mock).mockReturnValue({
installedApps: [],
isFetchingInstalledApps: false,
})
setupMocks([])
render(<InstalledApp id="nonexistent-app" />)
@@ -488,7 +456,7 @@ describe('InstalledApp', () => {
it('should not update app params when data is null', async () => {
;(useGetInstalledAppParams as Mock).mockReturnValue({
isFetching: false,
isPending: false,
data: null,
error: null,
})
@@ -504,7 +472,7 @@ describe('InstalledApp', () => {
it('should not update app meta when data is null', async () => {
;(useGetInstalledAppMeta as Mock).mockReturnValue({
isFetching: false,
isPending: false,
data: null,
error: null,
})
@@ -520,7 +488,7 @@ describe('InstalledApp', () => {
it('should not update access mode when data is null', async () => {
;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({
isFetching: false,
isPending: false,
data: null,
error: null,
})
@@ -537,10 +505,7 @@ describe('InstalledApp', () => {
describe('Edge Cases', () => {
it('should handle empty installedApps array', () => {
;(useContext as Mock).mockReturnValue({
installedApps: [],
isFetchingInstalledApps: false,
})
setupMocks([])
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByText(/404/)).toBeInTheDocument()
@@ -555,10 +520,7 @@ describe('InstalledApp', () => {
name: 'Other App',
},
}
;(useContext as Mock).mockReturnValue({
installedApps: [otherApp, mockInstalledApp],
isFetchingInstalledApps: false,
})
setupMocks([otherApp, mockInstalledApp])
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByText(/Chat With History/i)).toBeInTheDocument()
@@ -568,10 +530,7 @@ describe('InstalledApp', () => {
it('should handle rapid id prop changes', async () => {
const app1 = { ...mockInstalledApp, id: 'app-1' }
const app2 = { ...mockInstalledApp, id: 'app-2' }
;(useContext as Mock).mockReturnValue({
installedApps: [app1, app2],
isFetchingInstalledApps: false,
})
setupMocks([app1, app2])
const { rerender } = render(<InstalledApp id="app-1" />)
expect(screen.getByText(/app-1/)).toBeInTheDocument()
@@ -593,10 +552,7 @@ describe('InstalledApp', () => {
})
it('should call service hooks with null when installedApp is not found', () => {
;(useContext as Mock).mockReturnValue({
installedApps: [],
isFetchingInstalledApps: false,
})
setupMocks([])
render(<InstalledApp id="nonexistent-app" />)
@@ -613,7 +569,7 @@ describe('InstalledApp', () => {
describe('Render Priority', () => {
it('should show error before loading state', () => {
;(useGetInstalledAppParams as Mock).mockReturnValue({
isFetching: true,
isPending: true,
data: null,
error: new Error('Some error'),
})
@@ -624,7 +580,7 @@ describe('InstalledApp', () => {
it('should show error before permission check', () => {
;(useGetInstalledAppParams as Mock).mockReturnValue({
isFetching: false,
isPending: false,
data: null,
error: new Error('Params error'),
})
@@ -639,10 +595,7 @@ describe('InstalledApp', () => {
})
it('should show permission error before 404', () => {
;(useContext as Mock).mockReturnValue({
installedApps: [],
isFetchingInstalledApps: false,
})
setupMocks([])
;(useGetUserCanAccessApp as Mock).mockReturnValue({
data: { result: false },
error: null,
@@ -653,16 +606,8 @@ describe('InstalledApp', () => {
expect(screen.queryByText(/404/)).not.toBeInTheDocument()
})
it('should show loading before 404', () => {
;(useContext as Mock).mockReturnValue({
installedApps: [],
isFetchingInstalledApps: false,
})
;(useGetInstalledAppParams as Mock).mockReturnValue({
isFetching: true,
data: null,
error: null,
})
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')

View File

@@ -1,37 +1,32 @@
'use client'
import type { FC } from 'react'
import type { AccessMode } from '@/models/access-control'
import type { AppData } from '@/models/share'
import * as React from 'react'
import { useEffect } from 'react'
import { useContext } from 'use-context-selector'
import ChatWithHistory from '@/app/components/base/chat/chat-with-history'
import Loading from '@/app/components/base/loading'
import TextGenerationApp from '@/app/components/share/text-generation'
import ExploreContext from '@/context/explore-context'
import { useWebAppStore } from '@/context/web-app-context'
import { useGetUserCanAccessApp } from '@/service/access-control'
import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams } from '@/service/use-explore'
import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams, useGetInstalledApps } from '@/service/use-explore'
import { AppModeEnum } from '@/types/app'
import AppUnavailable from '../../base/app-unavailable'
export type IInstalledAppProps = {
id: string
}
const InstalledApp: FC<IInstalledAppProps> = ({
const InstalledApp = ({
id,
}: {
id: string
}) => {
const { installedApps, isFetchingInstalledApps } = useContext(ExploreContext)
const { data, isPending: isPendingInstalledApps, isFetching: isFetchingInstalledApps } = useGetInstalledApps()
const installedApp = data?.installed_apps?.find(item => item.id === id)
const updateAppInfo = useWebAppStore(s => s.updateAppInfo)
const installedApp = installedApps.find(item => item.id === id)
const updateWebAppAccessMode = useWebAppStore(s => s.updateWebAppAccessMode)
const updateAppParams = useWebAppStore(s => s.updateAppParams)
const updateWebAppMeta = useWebAppStore(s => s.updateWebAppMeta)
const updateUserCanAccessApp = useWebAppStore(s => s.updateUserCanAccessApp)
const { isFetching: isFetchingWebAppAccessMode, data: webAppAccessMode, error: webAppAccessModeError } = useGetInstalledAppAccessModeByAppId(installedApp?.id ?? null)
const { isFetching: isFetchingAppParams, data: appParams, error: appParamsError } = useGetInstalledAppParams(installedApp?.id ?? null)
const { isFetching: isFetchingAppMeta, data: appMeta, error: appMetaError } = useGetInstalledAppMeta(installedApp?.id ?? null)
const { isPending: isPendingWebAppAccessMode, data: webAppAccessMode, error: webAppAccessModeError } = useGetInstalledAppAccessModeByAppId(installedApp?.id ?? null)
const { isPending: isPendingAppParams, data: appParams, error: appParamsError } = useGetInstalledAppParams(installedApp?.id ?? null)
const { isPending: isPendingAppMeta, data: appMeta, error: appMetaError } = useGetInstalledAppMeta(installedApp?.id ?? null)
const { data: userCanAccessApp, error: useCanAccessAppError } = useGetUserCanAccessApp({ appId: installedApp?.app.id, isInstalledApp: true })
useEffect(() => {
@@ -102,7 +97,11 @@ const InstalledApp: FC<IInstalledAppProps> = ({
</div>
)
}
if (isFetchingAppParams || isFetchingAppMeta || isFetchingWebAppAccessMode || isFetchingInstalledApps) {
if (
isPendingInstalledApps
|| (!installedApp && isFetchingInstalledApps)
|| (installedApp && (isPendingAppParams || isPendingAppMeta || isPendingWebAppAccessMode))
) {
return (
<div className="flex h-full items-center justify-center">
<Loading />

View File

@@ -1,18 +1,15 @@
import type { IExplore } from '@/context/explore-context'
import type { InstalledApp } from '@/models/explore'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import Toast from '@/app/components/base/toast'
import ExploreContext from '@/context/explore-context'
import { MediaType } from '@/hooks/use-breakpoints'
import { AppModeEnum } from '@/types/app'
import SideBar from '../index'
const mockSegments = ['apps']
const mockPush = vi.fn()
const mockRefetch = vi.fn()
const mockUninstall = vi.fn()
const mockUpdatePinStatus = vi.fn()
let mockIsFetching = false
let mockIsPending = false
let mockInstalledApps: InstalledApp[] = []
let mockMediaType: string = MediaType.pc
@@ -34,9 +31,8 @@ vi.mock('@/hooks/use-breakpoints', () => ({
vi.mock('@/service/use-explore', () => ({
useGetInstalledApps: () => ({
isFetching: mockIsFetching,
isPending: mockIsPending,
data: { installed_apps: mockInstalledApps },
refetch: mockRefetch,
}),
useUninstallApp: () => ({
mutateAsync: mockUninstall,
@@ -63,28 +59,14 @@ const createInstalledApp = (overrides: Partial<InstalledApp> = {}): InstalledApp
},
})
const renderWithContext = (installedApps: InstalledApp[] = []) => {
return render(
<ExploreContext.Provider
value={{
controlUpdateInstalledApps: 0,
setControlUpdateInstalledApps: vi.fn(),
hasEditPermission: true,
installedApps,
setInstalledApps: vi.fn(),
isFetchingInstalledApps: false,
setIsFetchingInstalledApps: vi.fn(),
} as unknown as IExplore}
>
<SideBar controlUpdateInstalledApps={0} />
</ExploreContext.Provider>,
)
const renderSideBar = () => {
return render(<SideBar />)
}
describe('SideBar', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsFetching = false
mockIsPending = false
mockInstalledApps = []
mockMediaType = MediaType.pc
vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
@@ -92,31 +74,38 @@ describe('SideBar', () => {
describe('Rendering', () => {
it('should render discovery link', () => {
renderWithContext()
renderSideBar()
expect(screen.getByText('explore.sidebar.title')).toBeInTheDocument()
})
it('should render workspace items when installed apps exist', () => {
mockInstalledApps = [createInstalledApp()]
renderWithContext(mockInstalledApps)
renderSideBar()
expect(screen.getByText('explore.sidebar.webApps')).toBeInTheDocument()
expect(screen.getByText('My App')).toBeInTheDocument()
})
it('should render NoApps component when no installed apps on desktop', () => {
renderWithContext([])
renderSideBar()
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' } }),
]
renderWithContext(mockInstalledApps)
renderSideBar()
expect(screen.getByText('Alpha')).toBeInTheDocument()
expect(screen.getByText('Beta')).toBeInTheDocument()
@@ -127,27 +116,18 @@ describe('SideBar', () => {
createInstalledApp({ id: 'app-1', is_pinned: true, app: { ...createInstalledApp().app, name: 'Pinned' } }),
createInstalledApp({ id: 'app-2', is_pinned: false, app: { ...createInstalledApp().app, name: 'Unpinned' } }),
]
const { container } = renderWithContext(mockInstalledApps)
const { container } = renderSideBar()
const dividers = container.querySelectorAll('[class*="divider"], hr')
expect(dividers.length).toBeGreaterThan(0)
})
})
describe('Effects', () => {
it('should refetch installed apps on mount', () => {
mockInstalledApps = [createInstalledApp()]
renderWithContext(mockInstalledApps)
expect(mockRefetch).toHaveBeenCalledTimes(1)
})
})
describe('User Interactions', () => {
it('should uninstall app and show toast when delete is confirmed', async () => {
mockInstalledApps = [createInstalledApp()]
mockUninstall.mockResolvedValue(undefined)
renderWithContext(mockInstalledApps)
renderSideBar()
fireEvent.click(screen.getByTestId('item-operation-trigger'))
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
@@ -165,7 +145,7 @@ describe('SideBar', () => {
it('should update pin status and show toast when pin is clicked', async () => {
mockInstalledApps = [createInstalledApp({ is_pinned: false })]
mockUpdatePinStatus.mockResolvedValue(undefined)
renderWithContext(mockInstalledApps)
renderSideBar()
fireEvent.click(screen.getByTestId('item-operation-trigger'))
fireEvent.click(await screen.findByText('explore.sidebar.action.pin'))
@@ -182,7 +162,7 @@ describe('SideBar', () => {
it('should unpin an already pinned app', async () => {
mockInstalledApps = [createInstalledApp({ is_pinned: true })]
mockUpdatePinStatus.mockResolvedValue(undefined)
renderWithContext(mockInstalledApps)
renderSideBar()
fireEvent.click(screen.getByTestId('item-operation-trigger'))
fireEvent.click(await screen.findByText('explore.sidebar.action.unpin'))
@@ -194,7 +174,7 @@ describe('SideBar', () => {
it('should open and close confirm dialog for delete', async () => {
mockInstalledApps = [createInstalledApp()]
renderWithContext(mockInstalledApps)
renderSideBar()
fireEvent.click(screen.getByTestId('item-operation-trigger'))
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
@@ -212,7 +192,7 @@ describe('SideBar', () => {
describe('Edge Cases', () => {
it('should hide NoApps and app names on mobile', () => {
mockMediaType = MediaType.mobile
renderWithContext([])
renderSideBar()
expect(screen.queryByText('explore.sidebar.noApps.title')).not.toBeInTheDocument()
expect(screen.queryByText('explore.sidebar.webApps')).not.toBeInTheDocument()

View File

@@ -1,16 +1,12 @@
'use client'
import type { FC } from 'react'
import { RiAppsFill, RiExpandRightLine, RiLayoutLeft2Line } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import Link from 'next/link'
import { useSelectedLayoutSegments } from 'next/navigation'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Confirm from '@/app/components/base/confirm'
import Divider from '@/app/components/base/divider'
import ExploreContext from '@/context/explore-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/service/use-explore'
import { cn } from '@/utils/classnames'
@@ -18,19 +14,13 @@ import Toast from '../../base/toast'
import Item from './app-nav-item'
import NoApps from './no-apps'
export type IExploreSideBarProps = {
controlUpdateInstalledApps: number
}
const SideBar: FC<IExploreSideBarProps> = ({
controlUpdateInstalledApps,
}) => {
const SideBar = () => {
const { t } = useTranslation()
const segments = useSelectedLayoutSegments()
const lastSegment = segments.slice(-1)[0]
const isDiscoverySelected = lastSegment === 'apps'
const { installedApps, setInstalledApps, setIsFetchingInstalledApps } = useContext(ExploreContext)
const { isFetching: isFetchingInstalledApps, data: ret, refetch: fetchInstalledAppList } = useGetInstalledApps()
const { data, isPending } = useGetInstalledApps()
const installedApps = data?.installed_apps ?? []
const { mutateAsync: uninstallApp } = useUninstallApp()
const { mutateAsync: updatePinStatus } = useUpdateAppPinStatus()
@@ -60,22 +50,6 @@ const SideBar: FC<IExploreSideBarProps> = ({
})
}
useEffect(() => {
const installed_apps = (ret as any)?.installed_apps
if (installed_apps && installed_apps.length > 0)
setInstalledApps(installed_apps)
else
setInstalledApps([])
}, [ret, setInstalledApps])
useEffect(() => {
setIsFetchingInstalledApps(isFetchingInstalledApps)
}, [isFetchingInstalledApps, setIsFetchingInstalledApps])
useEffect(() => {
fetchInstalledAppList()
}, [controlUpdateInstalledApps, fetchInstalledAppList])
const pinnedAppsCount = installedApps.filter(({ is_pinned }) => is_pinned).length
return (
<div className={cn('relative w-fit shrink-0 cursor-pointer px-3 pt-6 sm:w-[240px]', isFold && 'sm:w-[56px]')}>
@@ -85,13 +59,13 @@ const SideBar: FC<IExploreSideBarProps> = ({
className={cn(isDiscoverySelected ? 'bg-state-base-active' : 'hover:bg-state-base-hover', 'flex h-8 items-center gap-2 rounded-lg px-1 mobile:w-fit mobile:justify-center pc:w-full pc:justify-start')}
>
<div className="flex size-6 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid">
<RiAppsFill className="size-3.5 text-components-avatar-shape-fill-stop-100" />
<span className="i-ri-apps-fill size-3.5 text-components-avatar-shape-fill-stop-100" />
</div>
{!isMobile && !isFold && <div className={cn('truncate', isDiscoverySelected ? 'system-sm-semibold text-components-menu-item-text-active' : 'system-sm-regular text-components-menu-item-text')}>{t('sidebar.title', { ns: 'explore' })}</div>}
{!isMobile && !isFold && <div className={cn('truncate', isDiscoverySelected ? 'text-components-menu-item-text-active system-sm-semibold' : 'text-components-menu-item-text system-sm-regular')}>{t('sidebar.title', { ns: 'explore' })}</div>}
</Link>
</div>
{installedApps.length === 0 && !isMobile && !isFold
{!isPending && installedApps.length === 0 && !isMobile && !isFold
&& (
<div className="mt-5">
<NoApps />
@@ -100,7 +74,7 @@ const SideBar: FC<IExploreSideBarProps> = ({
{installedApps.length > 0 && (
<div className="mt-5">
{!isMobile && !isFold && <p className="system-xs-medium-uppercase mb-1.5 break-all pl-2 uppercase text-text-tertiary mobile:px-0">{t('sidebar.webApps', { ns: 'explore' })}</p>}
{!isMobile && !isFold && <p className="mb-1.5 break-all pl-2 uppercase text-text-tertiary system-xs-medium-uppercase mobile:px-0">{t('sidebar.webApps', { ns: 'explore' })}</p>}
<div
className="space-y-0.5 overflow-y-auto overflow-x-hidden"
style={{
@@ -136,9 +110,9 @@ const SideBar: FC<IExploreSideBarProps> = ({
{!isMobile && (
<div className="absolute bottom-3 left-3 flex size-8 cursor-pointer items-center justify-center text-text-tertiary" onClick={toggleIsFold}>
{isFold
? <RiExpandRightLine className="size-4.5" />
? <span className="i-ri-expand-right-line" />
: (
<RiLayoutLeft2Line className="size-4.5" />
<span className="i-ri-layout-left-2-line" />
)}
</div>
)}

View File

@@ -1,5 +1,7 @@
import type { ImgHTMLAttributes } from 'react'
import type { TryAppInfo } from '@/service/try-app'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import AppInfo from '../index'
@@ -9,6 +11,21 @@ vi.mock('../use-get-requirements', () => ({
default: (...args: unknown[]) => mockUseGetRequirements(...args),
}))
vi.mock('next/image', () => ({
default: ({
src,
alt,
unoptimized: _unoptimized,
...rest
}: {
src: string
alt: string
unoptimized?: boolean
} & ImgHTMLAttributes<HTMLImageElement>) => (
React.createElement('img', { src, alt, ...rest })
),
}))
const createMockAppDetail = (mode: string, overrides: Partial<TryAppInfo> = {}): TryAppInfo => ({
id: 'test-app-id',
name: 'Test App Name',
@@ -312,7 +329,7 @@ describe('AppInfo', () => {
expect(screen.queryByText('explore.tryApp.requirements')).not.toBeInTheDocument()
})
it('renders requirement icons with correct background image', () => {
it('renders requirement icons with correct image src', () => {
mockUseGetRequirements.mockReturnValue({
requirements: [
{ name: 'Test Tool', iconUrl: 'https://example.com/test-icon.png' },
@@ -330,9 +347,36 @@ describe('AppInfo', () => {
/>,
)
const iconElement = container.querySelector('[style*="background-image"]')
const iconElement = container.querySelector('img[src="https://example.com/test-icon.png"]')
expect(iconElement).toBeInTheDocument()
expect(iconElement).toHaveStyle({ backgroundImage: 'url(https://example.com/test-icon.png)' })
})
it('falls back to default icon when requirement image fails to load', () => {
mockUseGetRequirements.mockReturnValue({
requirements: [
{ name: 'Broken Tool', iconUrl: 'https://example.com/broken-icon.png' },
],
})
const appDetail = createMockAppDetail('chat')
const mockOnCreate = vi.fn()
render(
<AppInfo
appId="test-app-id"
appDetail={appDetail}
onCreate={mockOnCreate}
/>,
)
const requirementRow = screen.getByText('Broken Tool').parentElement as HTMLElement
const iconImage = requirementRow.querySelector('img') as HTMLImageElement
expect(iconImage).toBeInTheDocument()
fireEvent.error(iconImage)
expect(requirementRow.querySelector('img')).not.toBeInTheDocument()
expect(requirementRow.querySelector('.i-custom-public-other-default-tool-icon')).toBeInTheDocument()
})
})

View File

@@ -400,6 +400,61 @@ describe('useGetRequirements', () => {
expect(result.current.requirements[0].iconUrl).toBe('https://marketplace.api/plugins/org/plugin/icon')
})
it('maps google model provider to gemini plugin icon URL', () => {
mockUseGetTryAppFlowPreview.mockReturnValue({ data: null })
const appDetail = createMockAppDetail('chat', {
model_config: {
model: {
provider: 'langgenius/google/google',
name: 'gemini-2.0',
mode: 'chat',
},
dataset_configs: { datasets: { datasets: [] } },
agent_mode: { tools: [] },
user_input_form: [],
},
} as unknown as Partial<TryAppInfo>)
const { result } = renderHook(() =>
useGetRequirements({ appDetail, appId: 'test-app-id' }),
)
expect(result.current.requirements[0].iconUrl).toBe('https://marketplace.api/plugins/langgenius/gemini/icon')
})
it('maps special builtin tool providers to *_tool plugin icon URL', () => {
mockUseGetTryAppFlowPreview.mockReturnValue({ data: null })
const appDetail = createMockAppDetail('agent-chat', {
model_config: {
model: {
provider: 'langgenius/openai/openai',
name: 'gpt-4',
mode: 'chat',
},
dataset_configs: { datasets: { datasets: [] } },
agent_mode: {
tools: [
{
enabled: true,
provider_id: 'langgenius/jina/jina',
tool_label: 'Jina Search',
},
],
},
user_input_form: [],
},
} as unknown as Partial<TryAppInfo>)
const { result } = renderHook(() =>
useGetRequirements({ appDetail, appId: 'test-app-id' }),
)
const toolRequirement = result.current.requirements.find(item => item.name === 'Jina Search')
expect(toolRequirement?.iconUrl).toBe('https://marketplace.api/plugins/langgenius/jina_tool/icon')
})
})
describe('hook calls', () => {

View File

@@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import type { TryAppInfo } from '@/service/try-app'
import { RiAddLine } from '@remixicon/react'
import Image from 'next/image'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { AppTypeIcon } from '@/app/components/app/type-selector'
@@ -19,6 +19,37 @@ type Props = {
}
const headerClassName = 'system-sm-semibold-uppercase text-text-secondary mb-3'
const requirementIconSize = 20
type RequirementIconProps = {
iconUrl: string
}
const RequirementIcon: FC<RequirementIconProps> = ({ iconUrl }) => {
const [failedSource, setFailedSource] = React.useState<string | null>(null)
const hasLoadError = !iconUrl || failedSource === iconUrl
if (hasLoadError) {
return (
<div className="flex size-5 items-center justify-center overflow-hidden rounded-[6px] border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge">
<div className="i-custom-public-other-default-tool-icon size-3 text-text-tertiary" />
</div>
)
}
return (
<Image
className="size-5 rounded-md object-cover shadow-xs"
src={iconUrl}
alt=""
aria-hidden="true"
width={requirementIconSize}
height={requirementIconSize}
unoptimized
onError={() => setFailedSource(iconUrl)}
/>
)
}
const AppInfo: FC<Props> = ({
appId,
@@ -62,17 +93,17 @@ const AppInfo: FC<Props> = ({
</div>
</div>
{appDetail.description && (
<div className="system-sm-regular mt-[14px] shrink-0 text-text-secondary">{appDetail.description}</div>
<div className="mt-[14px] shrink-0 text-text-secondary system-sm-regular">{appDetail.description}</div>
)}
<Button variant="primary" className="mt-3 flex w-full max-w-full" onClick={onCreate}>
<RiAddLine className="mr-1 size-4 shrink-0" />
<span className="i-ri-add-line mr-1 size-4 shrink-0" />
<span className="truncate">{t('tryApp.createFromSampleApp', { ns: 'explore' })}</span>
</Button>
{category && (
<div className="mt-6 shrink-0">
<div className={headerClassName}>{t('tryApp.category', { ns: 'explore' })}</div>
<div className="system-md-regular text-text-secondary">{category}</div>
<div className="text-text-secondary system-md-regular">{category}</div>
</div>
)}
{requirements.length > 0 && (
@@ -81,8 +112,8 @@ const AppInfo: FC<Props> = ({
<div className="space-y-0.5">
{requirements.map(item => (
<div className="flex items-center space-x-2 py-1" key={item.name}>
<div className="size-5 rounded-md bg-cover shadow-xs" style={{ backgroundImage: `url(${item.iconUrl})` }} />
<div className="system-md-regular w-0 grow truncate text-text-secondary">{item.name}</div>
<RequirementIcon iconUrl={item.iconUrl} />
<div className="w-0 grow truncate text-text-secondary system-md-regular">{item.name}</div>
</div>
))}
</div>

View File

@@ -16,8 +16,56 @@ type RequirementItem = {
name: string
iconUrl: string
}
const getIconUrl = (provider: string, tool: string) => {
return `${MARKETPLACE_API_PREFIX}/plugins/${provider}/${tool}/icon`
type ProviderType = 'model' | 'tool'
type ProviderInfo = {
organization: string
providerName: string
}
const PROVIDER_PLUGIN_ALIASES: Record<ProviderType, Record<string, string>> = {
model: {
google: 'gemini',
},
tool: {
stepfun: 'stepfun_tool',
jina: 'jina_tool',
siliconflow: 'siliconflow_tool',
gitee_ai: 'gitee_ai_tool',
},
}
const parseProviderId = (providerId: string): ProviderInfo | null => {
const segments = providerId.split('/').filter(Boolean)
if (!segments.length)
return null
if (segments.length === 1) {
return {
organization: 'langgenius',
providerName: segments[0],
}
}
return {
organization: segments[0],
providerName: segments[1],
}
}
const getPluginName = (providerName: string, type: ProviderType) => {
return PROVIDER_PLUGIN_ALIASES[type][providerName] || providerName
}
const getIconUrl = (providerId: string, type: ProviderType) => {
const parsed = parseProviderId(providerId)
if (!parsed)
return ''
const organization = encodeURIComponent(parsed.organization)
const pluginName = encodeURIComponent(getPluginName(parsed.providerName, type))
return `${MARKETPLACE_API_PREFIX}/plugins/${organization}/${pluginName}/icon`
}
const useGetRequirements = ({ appDetail, appId }: Params) => {
@@ -28,20 +76,19 @@ const useGetRequirements = ({ appDetail, appId }: Params) => {
const requirements: RequirementItem[] = []
if (isBasic) {
const modelProviderAndName = appDetail.model_config.model.provider.split('/')
const modelProvider = appDetail.model_config.model.provider
const name = appDetail.model_config.model.provider.split('/').pop() || ''
requirements.push({
name,
iconUrl: getIconUrl(modelProviderAndName[0], modelProviderAndName[1]),
iconUrl: getIconUrl(modelProvider, 'model'),
})
}
if (isAgent) {
requirements.push(...appDetail.model_config.agent_mode.tools.filter(data => (data as AgentTool).enabled).map((data) => {
const tool = data as AgentTool
const modelProviderAndName = tool.provider_id.split('/')
return {
name: tool.tool_label,
iconUrl: getIconUrl(modelProviderAndName[0], modelProviderAndName[1]),
iconUrl: getIconUrl(tool.provider_id, 'tool'),
}
}))
}
@@ -50,20 +97,18 @@ const useGetRequirements = ({ appDetail, appId }: Params) => {
const llmNodes = nodes.filter(node => node.data.type === BlockEnum.LLM)
requirements.push(...llmNodes.map((node) => {
const data = node.data as LLMNodeType
const modelProviderAndName = data.model.provider.split('/')
return {
name: data.model.name,
iconUrl: getIconUrl(modelProviderAndName[0], modelProviderAndName[1]),
iconUrl: getIconUrl(data.model.provider, 'model'),
}
}))
const toolNodes = nodes.filter(node => node.data.type === BlockEnum.Tool)
requirements.push(...toolNodes.map((node) => {
const data = node.data as ToolNodeType
const toolProviderAndName = data.provider_id.split('/')
return {
name: data.tool_label,
iconUrl: getIconUrl(toolProviderAndName[0], toolProviderAndName[1]),
iconUrl: getIconUrl(data.provider_id, 'tool'),
}
}))
}

View File

@@ -2,11 +2,12 @@
'use client'
import type { FC } from 'react'
import type { App as AppType } from '@/models/explore'
import { RiCloseLine } from '@remixicon/react'
import * as React from 'react'
import { useState } from 'react'
import AppUnavailable from '@/app/components/base/app-unavailable'
import Loading from '@/app/components/base/loading'
import Modal from '@/app/components/base/modal/index'
import { IS_CLOUD_EDITION } from '@/config'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useGetTryAppInfo } from '@/service/use-try-app'
import Button from '../../base/button'
@@ -32,15 +33,10 @@ const TryApp: FC<Props> = ({
}) => {
const { systemFeatures } = useGlobalPublicStore()
const isTrialApp = !!(app && app.can_trial && systemFeatures.enable_trial_app)
const [type, setType] = useState<TypeEnum>(() => (app && !isTrialApp ? TypeEnum.DETAIL : TypeEnum.TRY))
const { data: appDetail, isLoading } = useGetTryAppInfo(appId)
React.useEffect(() => {
if (app && !isTrialApp && type !== TypeEnum.DETAIL)
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setType(TypeEnum.DETAIL)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [app, isTrialApp])
const canUseTryTab = IS_CLOUD_EDITION && (app ? isTrialApp : true)
const [type, setType] = useState<TypeEnum>(() => (canUseTryTab ? TypeEnum.TRY : TypeEnum.DETAIL))
const activeType = canUseTryTab ? type : TypeEnum.DETAIL
const { data: appDetail, isLoading, isError, error } = useGetTryAppInfo(appId)
return (
<Modal
@@ -52,11 +48,19 @@ const TryApp: FC<Props> = ({
<div className="flex h-full items-center justify-center">
<Loading type="area" />
</div>
) : isError ? (
<div className="flex h-full items-center justify-center">
<AppUnavailable className="h-auto w-auto" isUnknownReason={!error} unknownReason={error instanceof Error ? error.message : undefined} />
</div>
) : !appDetail ? (
<div className="flex h-full items-center justify-center">
<AppUnavailable className="h-auto w-auto" isUnknownReason />
</div>
) : (
<div className="flex h-full flex-col">
<div className="flex shrink-0 justify-between pl-4">
<Tab
value={type}
value={activeType}
onChange={setType}
disableTry={app ? !isTrialApp : false}
/>
@@ -66,15 +70,15 @@ const TryApp: FC<Props> = ({
className="flex size-7 items-center justify-center rounded-[10px] p-0 text-components-button-tertiary-text"
onClick={onClose}
>
<RiCloseLine className="size-5" onClick={onClose} />
<span className="i-ri-close-line size-5" />
</Button>
</div>
{/* Main content */}
<div className="mt-2 flex h-0 grow justify-between space-x-2">
{type === TypeEnum.TRY ? <App appId={appId} appDetail={appDetail!} /> : <Preview appId={appId} appDetail={appDetail!} />}
{activeType === TypeEnum.TRY ? <App appId={appId} appDetail={appDetail} /> : <Preview appId={appId} appDetail={appDetail} />}
<AppInfo
className="w-[360px] shrink-0"
appDetail={appDetail!}
appDetail={appDetail}
appId={appId}
category={category}
onCreate={onCreate}

View File

@@ -1,11 +1,11 @@
import type { CurrentTryAppParams } from './explore-context'
import type { SetTryAppPanel, TryAppSelection } from '@/types/try-app'
import { noop } from 'es-toolkit/function'
import { createContext } from 'use-context-selector'
type Props = {
currentApp?: CurrentTryAppParams
currentApp?: TryAppSelection
isShowTryAppPanel: boolean
setShowTryAppPanel: (showTryAppPanel: boolean, params?: CurrentTryAppParams) => void
setShowTryAppPanel: SetTryAppPanel
controlHideCreateFromTemplatePanel: number
}

View File

@@ -1,36 +0,0 @@
import type { App, InstalledApp } from '@/models/explore'
import { noop } from 'es-toolkit/function'
import { createContext } from 'use-context-selector'
export type CurrentTryAppParams = {
appId: string
app: App
}
export type IExplore = {
controlUpdateInstalledApps: number
setControlUpdateInstalledApps: (controlUpdateInstalledApps: number) => void
hasEditPermission: boolean
installedApps: InstalledApp[]
setInstalledApps: (installedApps: InstalledApp[]) => void
isFetchingInstalledApps: boolean
setIsFetchingInstalledApps: (isFetchingInstalledApps: boolean) => void
currentApp?: CurrentTryAppParams
isShowTryAppPanel: boolean
setShowTryAppPanel: (showTryAppPanel: boolean, params?: CurrentTryAppParams) => void
}
const ExploreContext = createContext<IExplore>({
controlUpdateInstalledApps: 0,
setControlUpdateInstalledApps: noop,
hasEditPermission: false,
installedApps: [],
setInstalledApps: noop,
isFetchingInstalledApps: false,
setIsFetchingInstalledApps: noop,
isShowTryAppPanel: false,
setShowTryAppPanel: noop,
currentApp: undefined,
})
export default ExploreContext

View File

@@ -0,0 +1,121 @@
import type { ChatConfig } from '@/app/components/base/chat/types'
import type { AccessMode } from '@/models/access-control'
import type { Banner } from '@/models/app'
import type { App, AppCategory, InstalledApp } from '@/models/explore'
import type { AppMeta } from '@/models/share'
import type { AppModeEnum } from '@/types/app'
import { type } from '@orpc/contract'
import { base } from '../base'
export type ExploreAppsResponse = {
categories: AppCategory[]
recommended_apps: App[]
}
export type ExploreAppDetailResponse = {
id: string
name: string
icon: string
icon_background: string
mode: AppModeEnum
export_data: string
can_trial?: boolean
}
export type InstalledAppsResponse = {
installed_apps: InstalledApp[]
}
export type InstalledAppMutationResponse = {
result: string
message: string
}
export type AppAccessModeResponse = {
accessMode: AccessMode
}
export const exploreAppsContract = base
.route({
path: '/explore/apps',
method: 'GET',
})
.input(type<{ query?: { language?: string } }>())
.output(type<ExploreAppsResponse>())
export const exploreAppDetailContract = base
.route({
path: '/explore/apps/{id}',
method: 'GET',
})
.input(type<{ params: { id: string } }>())
.output(type<ExploreAppDetailResponse | null>())
export const exploreInstalledAppsContract = base
.route({
path: '/installed-apps',
method: 'GET',
})
.input(type<{ query?: { app_id?: string } }>())
.output(type<InstalledAppsResponse>())
export const exploreInstalledAppUninstallContract = base
.route({
path: '/installed-apps/{id}',
method: 'DELETE',
})
.input(type<{ params: { id: string } }>())
.output(type<unknown>())
export const exploreInstalledAppPinContract = base
.route({
path: '/installed-apps/{id}',
method: 'PATCH',
})
.input(type<{
params: { id: string }
body: {
is_pinned: boolean
}
}>())
.output(type<InstalledAppMutationResponse>())
export const exploreInstalledAppAccessModeContract = base
.route({
path: '/enterprise/webapp/app/access-mode',
method: 'GET',
})
.input(type<{ query: { appId: string } }>())
.output(type<AppAccessModeResponse>())
export const exploreInstalledAppParametersContract = base
.route({
path: '/installed-apps/{appId}/parameters',
method: 'GET',
})
.input(type<{
params: {
appId: string
}
}>())
.output(type<ChatConfig>())
export const exploreInstalledAppMetaContract = base
.route({
path: '/installed-apps/{appId}/meta',
method: 'GET',
})
.input(type<{
params: {
appId: string
}
}>())
.output(type<AppMeta>())
export const exploreBannersContract = base
.route({
path: '/explore/banners',
method: 'GET',
})
.input(type<{ query?: { language?: string } }>())
.output(type<Banner[]>())

View File

@@ -1,5 +1,16 @@
import type { InferContractRouterInputs } from '@orpc/contract'
import { bindPartnerStackContract, invoicesContract } from './console/billing'
import {
exploreAppDetailContract,
exploreAppsContract,
exploreBannersContract,
exploreInstalledAppAccessModeContract,
exploreInstalledAppMetaContract,
exploreInstalledAppParametersContract,
exploreInstalledAppPinContract,
exploreInstalledAppsContract,
exploreInstalledAppUninstallContract,
} from './console/explore'
import { systemFeaturesContract } from './console/system'
import {
triggerOAuthConfigContract,
@@ -31,6 +42,17 @@ export type MarketPlaceInputs = InferContractRouterInputs<typeof marketplaceRout
export const consoleRouterContract = {
systemFeatures: systemFeaturesContract,
explore: {
apps: exploreAppsContract,
appDetail: exploreAppDetailContract,
installedApps: exploreInstalledAppsContract,
uninstallInstalledApp: exploreInstalledAppUninstallContract,
updateInstalledApp: exploreInstalledAppPinContract,
appAccessMode: exploreInstalledAppAccessModeContract,
installedAppParameters: exploreInstalledAppParametersContract,
installedAppMeta: exploreInstalledAppMetaContract,
banners: exploreBannersContract,
},
trialApps: {
info: trialAppInfoContract,
datasets: trialAppDatasetsContract,

View File

@@ -506,14 +506,8 @@
}
},
"app/components/app/app-publisher/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 7
},
"tailwindcss/no-unnecessary-whitespace": {
"count": 1
},
"ts/no-explicit-any": {
"count": 6
"count": 5
}
},
"app/components/app/app-publisher/suggested-action.tsx": {
@@ -1233,11 +1227,8 @@
"react/no-nested-component-definitions": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 6
},
"ts/no-explicit-any": {
"count": 4
"count": 2
}
},
"app/components/apps/empty.tsx": {
@@ -1250,11 +1241,6 @@
"count": 1
}
},
"app/components/apps/list.tsx": {
"unused-imports/no-unused-vars": {
"count": 1
}
},
"app/components/apps/new-app-card.tsx": {
"ts/no-explicit-any": {
"count": 1
@@ -3882,11 +3868,6 @@
"count": 1
}
},
"app/components/datasets/list/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/datasets/list/new-dataset-card/option.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
@@ -4053,16 +4034,6 @@
"count": 1
}
},
"app/components/explore/app-card/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/explore/app-list/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/explore/banner/banner-item.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 4
@@ -4092,11 +4063,6 @@
"count": 1
}
},
"app/components/explore/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
}
},
"app/components/explore/item-operation/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
@@ -4107,24 +4073,11 @@
"count": 2
}
},
"app/components/explore/sidebar/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 3
},
"ts/no-explicit-any": {
"count": 1
}
},
"app/components/explore/sidebar/no-apps/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 3
}
},
"app/components/explore/try-app/app-info/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 3
}
},
"app/components/explore/try-app/app/chat.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1

View File

@@ -1,30 +1,44 @@
import type { AccessMode } from '@/models/access-control'
import type { Banner } from '@/models/app'
import type { App, AppCategory } from '@/models/explore'
import { del, get, patch } from './base'
import type { ChatConfig } from '@/app/components/base/chat/types'
import type { ExploreAppDetailResponse } from '@/contract/console/explore'
import type { AppMeta } from '@/models/share'
import { consoleClient } from './client'
export const fetchAppList = () => {
return get<{
categories: AppCategory[]
recommended_apps: App[]
}>('/explore/apps')
export const fetchAppList = (language?: string) => {
if (!language)
return consoleClient.explore.apps({})
return consoleClient.explore.apps({
query: { language },
})
}
// eslint-disable-next-line ts/no-explicit-any
export const fetchAppDetail = (id: string): Promise<any> => {
return get(`/explore/apps/${id}`)
export const fetchAppDetail = async (id: string): Promise<ExploreAppDetailResponse> => {
const response = await consoleClient.explore.appDetail({
params: { id },
})
if (!response)
throw new Error('Recommended app not found')
return response
}
export const fetchInstalledAppList = (app_id?: string | null) => {
return get(`/installed-apps${app_id ? `?app_id=${app_id}` : ''}`)
export const fetchInstalledAppList = (appId?: string | null) => {
if (!appId)
return consoleClient.explore.installedApps({})
return consoleClient.explore.installedApps({
query: { app_id: appId },
})
}
export const uninstallApp = (id: string) => {
return del(`/installed-apps/${id}`)
return consoleClient.explore.uninstallInstalledApp({
params: { id },
})
}
export const updatePinStatus = (id: string, isPinned: boolean) => {
return patch(`/installed-apps/${id}`, {
return consoleClient.explore.updateInstalledApp({
params: { id },
body: {
is_pinned: isPinned,
},
@@ -32,10 +46,28 @@ export const updatePinStatus = (id: string, isPinned: boolean) => {
}
export const getAppAccessModeByAppId = (appId: string) => {
return get<{ accessMode: AccessMode }>(`/enterprise/webapp/app/access-mode?appId=${appId}`)
return consoleClient.explore.appAccessMode({
query: { appId },
})
}
export const fetchBanners = (language?: string): Promise<Banner[]> => {
const url = language ? `/explore/banners?language=${language}` : '/explore/banners'
return get<Banner[]>(url)
export const fetchInstalledAppParams = (appId: string) => {
return consoleClient.explore.installedAppParameters({
params: { appId },
}) as Promise<ChatConfig>
}
export const fetchInstalledAppMeta = (appId: string) => {
return consoleClient.explore.installedAppMeta({
params: { appId },
}) as Promise<AppMeta>
}
export const fetchBanners = (language?: string) => {
if (!language)
return consoleClient.explore.banners({})
return consoleClient.explore.banners({
query: { language },
})
}

View File

@@ -3,10 +3,8 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useLocale } from '@/context/i18n'
import { AccessMode } from '@/models/access-control'
import { fetchAppList, fetchBanners, fetchInstalledAppList, getAppAccessModeByAppId, uninstallApp, updatePinStatus } from './explore'
import { AppSourceType, fetchAppMeta, fetchAppParams } from './share'
const NAME_SPACE = 'explore'
import { consoleQuery } from './client'
import { fetchAppList, fetchBanners, fetchInstalledAppList, fetchInstalledAppMeta, fetchInstalledAppParams, getAppAccessModeByAppId, uninstallApp, updatePinStatus } from './explore'
type ExploreAppListData = {
categories: AppCategory[]
@@ -15,10 +13,15 @@ type ExploreAppListData = {
export const useExploreAppList = () => {
const locale = useLocale()
const exploreAppsInput = locale
? { query: { language: locale } }
: {}
const exploreAppsLanguage = exploreAppsInput?.query?.language
return useQuery<ExploreAppListData>({
queryKey: [NAME_SPACE, 'appList', locale],
queryKey: [...consoleQuery.explore.apps.queryKey({ input: exploreAppsInput }), exploreAppsLanguage],
queryFn: async () => {
const { categories, recommended_apps } = await fetchAppList()
const { categories, recommended_apps } = await fetchAppList(exploreAppsLanguage)
return {
categories,
allList: [...recommended_apps].sort((a, b) => a.position - b.position),
@@ -29,7 +32,7 @@ export const useExploreAppList = () => {
export const useGetInstalledApps = () => {
return useQuery({
queryKey: [NAME_SPACE, 'installedApps'],
queryKey: consoleQuery.explore.installedApps.queryKey({ input: {} }),
queryFn: () => {
return fetchInstalledAppList()
},
@@ -39,10 +42,12 @@ export const useGetInstalledApps = () => {
export const useUninstallApp = () => {
const client = useQueryClient()
return useMutation({
mutationKey: [NAME_SPACE, 'uninstallApp'],
mutationKey: consoleQuery.explore.uninstallInstalledApp.mutationKey(),
mutationFn: (appId: string) => uninstallApp(appId),
onSuccess: () => {
client.invalidateQueries({ queryKey: [NAME_SPACE, 'installedApps'] })
client.invalidateQueries({
queryKey: consoleQuery.explore.installedApps.queryKey({ input: {} }),
})
},
})
}
@@ -50,62 +55,82 @@ export const useUninstallApp = () => {
export const useUpdateAppPinStatus = () => {
const client = useQueryClient()
return useMutation({
mutationKey: [NAME_SPACE, 'updateAppPinStatus'],
mutationKey: consoleQuery.explore.updateInstalledApp.mutationKey(),
mutationFn: ({ appId, isPinned }: { appId: string, isPinned: boolean }) => updatePinStatus(appId, isPinned),
onSuccess: () => {
client.invalidateQueries({ queryKey: [NAME_SPACE, 'installedApps'] })
client.invalidateQueries({
queryKey: consoleQuery.explore.installedApps.queryKey({ input: {} }),
})
},
})
}
export const useGetInstalledAppAccessModeByAppId = (appId: string | null) => {
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const appAccessModeInput = { query: { appId: appId ?? '' } }
const installedAppId = appAccessModeInput.query.appId
return useQuery({
queryKey: [NAME_SPACE, 'appAccessMode', appId, systemFeatures.webapp_auth.enabled],
queryKey: [
...consoleQuery.explore.appAccessMode.queryKey({ input: appAccessModeInput }),
systemFeatures.webapp_auth.enabled,
installedAppId,
],
queryFn: () => {
if (systemFeatures.webapp_auth.enabled === false) {
return {
accessMode: AccessMode.PUBLIC,
}
}
if (!appId || appId.length === 0)
return Promise.reject(new Error('App code is required to get access mode'))
if (!installedAppId)
return Promise.reject(new Error('App ID is required to get access mode'))
return getAppAccessModeByAppId(appId)
return getAppAccessModeByAppId(installedAppId)
},
enabled: !!appId,
enabled: !!installedAppId,
})
}
export const useGetInstalledAppParams = (appId: string | null) => {
const installedAppParamsInput = { params: { appId: appId ?? '' } }
const installedAppId = installedAppParamsInput.params.appId
return useQuery({
queryKey: [NAME_SPACE, 'appParams', appId],
queryKey: [...consoleQuery.explore.installedAppParameters.queryKey({ input: installedAppParamsInput }), installedAppId],
queryFn: () => {
if (!appId || appId.length === 0)
if (!installedAppId)
return Promise.reject(new Error('App ID is required to get app params'))
return fetchAppParams(AppSourceType.installedApp, appId)
return fetchInstalledAppParams(installedAppId)
},
enabled: !!appId,
enabled: !!installedAppId,
})
}
export const useGetInstalledAppMeta = (appId: string | null) => {
const installedAppMetaInput = { params: { appId: appId ?? '' } }
const installedAppId = installedAppMetaInput.params.appId
return useQuery({
queryKey: [NAME_SPACE, 'appMeta', appId],
queryKey: [...consoleQuery.explore.installedAppMeta.queryKey({ input: installedAppMetaInput }), installedAppId],
queryFn: () => {
if (!appId || appId.length === 0)
if (!installedAppId)
return Promise.reject(new Error('App ID is required to get app meta'))
return fetchAppMeta(AppSourceType.installedApp, appId)
return fetchInstalledAppMeta(installedAppId)
},
enabled: !!appId,
enabled: !!installedAppId,
})
}
export const useGetBanners = (locale?: string) => {
const bannersInput = locale
? { query: { language: locale } }
: {}
const bannersLanguage = bannersInput?.query?.language
return useQuery({
queryKey: [NAME_SPACE, 'banners', locale],
queryKey: [...consoleQuery.explore.banners.queryKey({ input: bannersInput }), bannersLanguage],
queryFn: () => {
return fetchBanners(locale)
return fetchBanners(bannersLanguage)
},
})
}

8
web/types/try-app.ts Normal file
View File

@@ -0,0 +1,8 @@
import type { App } from '@/models/explore'
export type TryAppSelection = {
appId: string
app: App
}
export type SetTryAppPanel = (showTryAppPanel: boolean, params?: TryAppSelection) => void