From 1f74a251f7347c3ea0d29a098c0e4b4556368c4f Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Sat, 14 Feb 2026 16:18:26 +0800 Subject: [PATCH] fix: remove explore context and migrate query to orpc contract (#32320) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../explore/explore-app-list-flow.test.tsx | 63 ++++--- .../explore/installed-app-flow.test.tsx | 48 +++--- .../explore/sidebar-lifecycle-flow.test.tsx | 40 ++--- .../components/app/app-publisher/index.tsx | 56 +++---- web/app/components/apps/app-card.tsx | 19 ++- web/app/components/apps/index.tsx | 6 +- .../explore/__tests__/index.spec.tsx | 140 ++-------------- .../explore/app-card/__tests__/index.spec.tsx | 26 +-- web/app/components/explore/app-card/index.tsx | 23 ++- .../explore/app-list/__tests__/index.spec.tsx | 156 ++++++----------- web/app/components/explore/app-list/index.tsx | 40 +++-- web/app/components/explore/index.tsx | 73 ++------ .../installed-app/__tests__/index.spec.tsx | 157 ++++++------------ .../explore/installed-app/index.tsx | 29 ++-- .../explore/sidebar/__tests__/index.spec.tsx | 64 +++---- web/app/components/explore/sidebar/index.tsx | 46 ++--- .../try-app/app-info/__tests__/index.spec.tsx | 50 +++++- .../__tests__/use-get-requirements.spec.ts | 55 ++++++ .../explore/try-app/app-info/index.tsx | 43 ++++- .../try-app/app-info/use-get-requirements.ts | 65 ++++++-- web/app/components/explore/try-app/index.tsx | 32 ++-- web/context/app-list-context.ts | 6 +- web/context/explore-context.ts | 36 ---- web/contract/console/explore.ts | 121 ++++++++++++++ web/contract/router.ts | 22 +++ web/eslint-suppressions.json | 41 +---- web/service/explore.ts | 72 +++++--- web/service/use-explore.ts | 77 ++++++--- web/types/try-app.ts | 8 + 29 files changed, 787 insertions(+), 827 deletions(-) delete mode 100644 web/context/explore-context.ts create mode 100644 web/contract/console/explore.ts create mode 100644 web/types/try-app.ts diff --git a/web/__tests__/explore/explore-app-list-flow.test.tsx b/web/__tests__/explore/explore-app-list-flow.test.tsx index 1a54135420..40f2156c06 100644 --- a/web/__tests__/explore/explore-app-list-flow.test.tsx +++ b/web/__tests__/explore/explore-app-list-flow.test.tsx @@ -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 => ({ 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) => ( - - - -) +const renderAppList = (hasEditPermission = true, onSuccess?: () => void) => { + mockMemberRole(hasEditPermission) + return render() +} -const renderWithContext = (hasEditPermission = true, onSuccess?: () => void) => { - return render(wrapWithContext(hasEditPermission, onSuccess)) +const appListElement = (hasEditPermission = true, onSuccess?: () => void) => { + mockMemberRole(hasEditPermission) + return } 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) }) diff --git a/web/__tests__/explore/installed-app-flow.test.tsx b/web/__tests__/explore/installed-app-flow.test.tsx index 69dcb116aa..34bfac5cd6 100644 --- a/web/__tests__/explore/installed-app-flow.test.tsx +++ b/web/__tests__/explore/installed-app-flow.test.tsx @@ -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) => 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() @@ -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() + + 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) diff --git a/web/__tests__/explore/sidebar-lifecycle-flow.test.tsx b/web/__tests__/explore/sidebar-lifecycle-flow.test.tsx index bf4821ced4..e2c18bcc4f 100644 --- a/web/__tests__/explore/sidebar-lifecycle-flow.test.tsx +++ b/web/__tests__/explore/sidebar-lifecycle-flow.test.tsx @@ -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 }, }) -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( - - - , - ) +const renderSidebar = () => { + return render() } 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() }) diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index 1348e3111f..74d6a19cc1 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -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 = { +const ACCESS_MODE_MAP: Record = { [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 ( <> - +
- {t(`accessControlDialog.accessItems.${label}`, { ns: 'app' })} + {t(`accessControlDialog.accessItems.${label}`, { ns: 'app' })}
) @@ -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' })} - +
-
+
{publishedAt ? t('common.latestPublished', { ns: 'workflow' }) : t('common.currentDraftUnpublished', { ns: 'workflow' })}
{publishedAt ? (
-
+
{t('common.publishedAt', { ns: 'workflow' })} {' '} {formatTimeFromNow(publishedAt)} @@ -314,7 +302,7 @@ const AppPublisher = ({
) : ( -
+
{t('common.autoSaved', { ns: 'workflow' })} {' '} ยท @@ -377,10 +365,10 @@ const AppPublisher = ({ {systemFeatures.webapp_auth.enabled && (
-

{t('publishApp.title', { ns: 'app' })}

+

{t('publishApp.title', { ns: 'app' })}

{ setShowAppAccessControl(true) }} @@ -388,12 +376,12 @@ const AppPublisher = ({
- {!isAppAccessSet &&

{t('publishApp.notSet', { ns: 'app' })}

} + {!isAppAccessSet &&

{t('publishApp.notSet', { ns: 'app' })}

}
- +
- {!isAppAccessSet &&

{t('publishApp.notSetDesc', { ns: 'app' })}

} + {!isAppAccessSet &&

{t('publishApp.notSetDesc', { ns: 'app' })}

}
)} { @@ -405,7 +393,7 @@ const AppPublisher = ({ className="flex-1" disabled={disabledFunctionButton} link={appURL} - icon={} + icon={} > {t('common.runApp', { ns: 'workflow' })} @@ -417,7 +405,7 @@ const AppPublisher = ({ className="flex-1" disabled={disabledFunctionButton} link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`} - icon={} + icon={} > {t('common.batchRunApp', { ns: 'workflow' })} @@ -443,7 +431,7 @@ const AppPublisher = ({ handleOpenInExplore() }} disabled={disabledFunctionButton} - icon={} + icon={} > {t('common.openInExplore', { ns: 'workflow' })} @@ -453,7 +441,7 @@ const AppPublisher = ({ className="flex-1" disabled={!publishedAt || missingStartNode} link="./develop" - icon={} + icon={} > {t('common.accessAPIReference', { ns: 'workflow' })} diff --git a/web/app/components/apps/app-card.tsx b/web/app/components/apps/app-card.tsx index 730a39b68d..8f268da02c 100644 --- a/web/app/components/apps/app-card.tsx +++ b/web/app/components/apps/app-card.tsx @@ -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 (
{(app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT) && ( <> @@ -293,7 +294,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { <> ) @@ -301,7 +302,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { <> ) @@ -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} > - + {t('operation.delete', { ns: 'common' })} diff --git a/web/app/components/apps/index.tsx b/web/app/components/apps/index.tsx index 3be8492489..dce9de190d 100644 --- a/web/app/components/apps/index.tsx +++ b/web/app/components/apps/index.tsx @@ -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(undefined) + const [currentTryAppParams, setCurrentTryAppParams] = useState(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 diff --git a/web/app/components/explore/__tests__/index.spec.tsx b/web/app/components/explore/__tests__/index.spec.tsx index b84b168333..9f87d7afce 100644 --- a/web/app/components/explore/__tests__/index.spec.tsx +++ b/web/app/components/explore/__tests__/index.spec.tsx @@ -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 ( -
- {hasEditPermission ? 'edit-yes' : 'edit-no'} - {isShowTryAppPanel && open} - {currentApp && {currentApp.appId}} - {triggerTryPanel && ( - <> - - - - )} -
- ) -} - describe('Explore', () => { beforeEach(() => { vi.clearAllMocks() - }) - - 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' }], - }, - }) - - render(( - - - - )) - - await waitFor(() => { - expect(screen.getByText('edit-yes')).toBeInTheDocument() - }) + ;(useAppContext as Mock).mockReturnValue({ + isCurrentWorkspaceDatasetOperator: false, }) }) - 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: [] } }) - + describe('Rendering', () => { + it('should render children', () => { render((
child
)) - expect(useDocumentTitle).toHaveBeenCalledWith('common.menus.explore') + expect(screen.getByText('child')).toBeInTheDocument() }) + }) + describe('Effects', () => { it('should redirect dataset operators to /datasets', async () => { - ; (useAppContext as Mock).mockReturnValue({ - userProfile: { id: 'user-1' }, + ;(useAppContext as Mock).mockReturnValue({ isCurrentWorkspaceDatasetOperator: true, - }); - (useMembers as Mock).mockReturnValue({ data: { accounts: [] } }) + }) render(( @@ -137,68 +79,14 @@ describe('Explore', () => { }) }) - 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(( - +
child
)) - 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(( - - - - )) - - 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(( - - - - )) - - 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() }) }) }) diff --git a/web/app/components/explore/app-card/__tests__/index.spec.tsx b/web/app/components/explore/app-card/__tests__/index.spec.tsx index 8bc0fa99d2..2180980ee9 100644 --- a/web/app/components/explore/app-card/__tests__/index.spec.tsx +++ b/web/app/components/explore/app-card/__tests__/index.spec.tsx @@ -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 => ({ describe('AppCard', () => { const onCreate = vi.fn() + const onTry = vi.fn() const renderComponent = (props?: Partial) => { 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( - - - , - ) + 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 }) }) }) }) diff --git a/web/app/components/explore/app-card/index.tsx b/web/app/components/explore/app-card/index.tsx index 827c5c3a23..27437dfdbe 100644 --- a/web/app/components/explore/app-card/index.tsx +++ b/web/app/components/explore/app-card/index.tsx @@ -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 (
@@ -67,7 +64,7 @@ const AppCard = ({
-
+
{app.description}
@@ -83,7 +80,7 @@ const AppCard = ({ ) } - diff --git a/web/app/components/explore/app-list/__tests__/index.spec.tsx b/web/app/components/explore/app-list/__tests__/index.spec.tsx index 5048468b46..1a389e21ba 100644 --- a/web/app/components/explore/app-list/__tests__/index.spec.tsx +++ b/web/app/components/explore/app-list/__tests__/index.spec.tsx @@ -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 => ({ is_agent: overrides.is_agent ?? false, }) -const renderWithContext = (hasEditPermission = false, onSuccess?: () => void, searchParams?: Record) => { +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) => { + mockMemberRole(hasEditPermission) return render( - - - + , ) } @@ -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( - - - - - , - ) - - 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( - - - - - , - ) + 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( - - - - - , - ) + 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() }) diff --git a/web/app/components/explore/app-list/index.tsx b/web/app/components/explore/app-list/index.tsx index 5021185a03..d508f141b4 100644 --- a/web/app/components/explore/app-list/index.tsx +++ b/web/app/components/explore/app-list/index.tsx @@ -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(null) - const [isShowCreateModal, setIsShowCreateModal] = React.useState(false) + const [currApp, setCurrApp] = useState(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(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 = ({ )} >
-
{!hasFilterCondition ? t('apps.title', { ns: 'explore' }) : t('apps.resultNum', { num: searchFilteredList.length, ns: 'explore' })}
+
{!hasFilterCondition ? t('apps.title', { ns: 'explore' }) : t('apps.resultNum', { num: searchFilteredList.length, ns: 'explore' })}
{hasFilterCondition && ( <>
@@ -216,13 +222,13 @@ const Apps = ({ {searchFilteredList.map(app => ( { setCurrApp(app) setIsShowCreateModal(true) }} + onTry={handleTryApp} /> ))} @@ -255,9 +261,9 @@ const Apps = ({ {isShowTryAppPanel && ( diff --git a/web/app/components/explore/index.tsx b/web/app/components/explore/index.tsx index 2576ee4007..1533c6fa2a 100644 --- a/web/app/components/explore/index.tsx +++ b/web/app/components/explore/index.tsx @@ -1,80 +1,29 @@ '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 { useEffect } from 'react' 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 = ({ +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([]) - 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]) + const { isCurrentWorkspaceDatasetOperator } = useAppContext() useEffect(() => { if (isCurrentWorkspaceDatasetOperator) - return router.replace('/datasets') - }, [isCurrentWorkspaceDatasetOperator]) - - const [currentTryAppParams, setCurrentTryAppParams] = useState(undefined) - const [isShowTryAppPanel, setIsShowTryAppPanel] = useState(false) - const setShowTryAppPanel = (showTryAppPanel: boolean, params?: CurrentTryAppParams) => { - if (showTryAppPanel) - setCurrentTryAppParams(params) - else - setCurrentTryAppParams(undefined) - setIsShowTryAppPanel(showTryAppPanel) - } + router.replace('/datasets') + }, [isCurrentWorkspaceDatasetOperator, router]) return (
- - -
- {children} -
-
+ +
+ {children} +
) } diff --git a/web/app/components/explore/installed-app/__tests__/index.spec.tsx b/web/app/components/explore/installed-app/__tests__/index.spec.tsx index eca7b3139d..d95ae7d863 100644 --- a/web/app/components/explore/installed-app/__tests__/index.spec.tsx +++ b/web/app/components/explore/installed-app/__tests__/index.spec.tsx @@ -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() 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() 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() 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() 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() 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() 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() 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() @@ -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() 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() 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() 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() @@ -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() const svg = container.querySelector('svg.spin-animation') diff --git a/web/app/components/explore/installed-app/index.tsx b/web/app/components/explore/installed-app/index.tsx index 7366057445..e8eaa3dd5a 100644 --- a/web/app/components/explore/installed-app/index.tsx +++ b/web/app/components/explore/installed-app/index.tsx @@ -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 = ({ +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 = ({
) } - if (isFetchingAppParams || isFetchingAppMeta || isFetchingWebAppAccessMode || isFetchingInstalledApps) { + if ( + isPendingInstalledApps + || (!installedApp && isFetchingInstalledApps) + || (installedApp && (isPendingAppParams || isPendingAppMeta || isPendingWebAppAccessMode)) + ) { return (
diff --git a/web/app/components/explore/sidebar/__tests__/index.spec.tsx b/web/app/components/explore/sidebar/__tests__/index.spec.tsx index 2fcc48fc56..36e6ab217c 100644 --- a/web/app/components/explore/sidebar/__tests__/index.spec.tsx +++ b/web/app/components/explore/sidebar/__tests__/index.spec.tsx @@ -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 }, }) -const renderWithContext = (installedApps: InstalledApp[] = []) => { - return render( - - - , - ) +const renderSideBar = () => { + return render() } 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() diff --git a/web/app/components/explore/sidebar/index.tsx b/web/app/components/explore/sidebar/index.tsx index 3e9b664580..bafc745b01 100644 --- a/web/app/components/explore/sidebar/index.tsx +++ b/web/app/components/explore/sidebar/index.tsx @@ -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 = ({ - 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 = ({ }) } - 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 (
@@ -85,13 +59,13 @@ const SideBar: FC = ({ 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')} >
- +
- {!isMobile && !isFold &&
{t('sidebar.title', { ns: 'explore' })}
} + {!isMobile && !isFold &&
{t('sidebar.title', { ns: 'explore' })}
}
- {installedApps.length === 0 && !isMobile && !isFold + {!isPending && installedApps.length === 0 && !isMobile && !isFold && (
@@ -100,7 +74,7 @@ const SideBar: FC = ({ {installedApps.length > 0 && (
- {!isMobile && !isFold &&

{t('sidebar.webApps', { ns: 'explore' })}

} + {!isMobile && !isFold &&

{t('sidebar.webApps', { ns: 'explore' })}

}
= ({ {!isMobile && (
{isFold - ? + ? : ( - + )}
)} diff --git a/web/app/components/explore/try-app/app-info/__tests__/index.spec.tsx b/web/app/components/explore/try-app/app-info/__tests__/index.spec.tsx index a49e9379f0..f0c6a9c61e 100644 --- a/web/app/components/explore/try-app/app-info/__tests__/index.spec.tsx +++ b/web/app/components/explore/try-app/app-info/__tests__/index.spec.tsx @@ -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) => ( + React.createElement('img', { src, alt, ...rest }) + ), +})) + const createMockAppDetail = (mode: string, overrides: Partial = {}): 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( + , + ) + + 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() }) }) diff --git a/web/app/components/explore/try-app/app-info/__tests__/use-get-requirements.spec.ts b/web/app/components/explore/try-app/app-info/__tests__/use-get-requirements.spec.ts index 99f38b4310..c6c3353a57 100644 --- a/web/app/components/explore/try-app/app-info/__tests__/use-get-requirements.spec.ts +++ b/web/app/components/explore/try-app/app-info/__tests__/use-get-requirements.spec.ts @@ -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) + + 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) + + 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', () => { diff --git a/web/app/components/explore/try-app/app-info/index.tsx b/web/app/components/explore/try-app/app-info/index.tsx index eab265bd04..3ab82871d3 100644 --- a/web/app/components/explore/try-app/app-info/index.tsx +++ b/web/app/components/explore/try-app/app-info/index.tsx @@ -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 = ({ iconUrl }) => { + const [failedSource, setFailedSource] = React.useState(null) + const hasLoadError = !iconUrl || failedSource === iconUrl + + if (hasLoadError) { + return ( +
+
+
+ ) + } + + return ( + setFailedSource(iconUrl)} + /> + ) +} const AppInfo: FC = ({ appId, @@ -62,17 +93,17 @@ const AppInfo: FC = ({
{appDetail.description && ( -
{appDetail.description}
+
{appDetail.description}
)} {category && (
{t('tryApp.category', { ns: 'explore' })}
-
{category}
+
{category}
)} {requirements.length > 0 && ( @@ -81,8 +112,8 @@ const AppInfo: FC = ({
{requirements.map(item => (
-
-
{item.name}
+ +
{item.name}
))}
diff --git a/web/app/components/explore/try-app/app-info/use-get-requirements.ts b/web/app/components/explore/try-app/app-info/use-get-requirements.ts index 976989be73..6458c76037 100644 --- a/web/app/components/explore/try-app/app-info/use-get-requirements.ts +++ b/web/app/components/explore/try-app/app-info/use-get-requirements.ts @@ -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> = { + 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'), } })) } diff --git a/web/app/components/explore/try-app/index.tsx b/web/app/components/explore/try-app/index.tsx index c6f00ed08e..5e0f98f268 100644 --- a/web/app/components/explore/try-app/index.tsx +++ b/web/app/components/explore/try-app/index.tsx @@ -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 = ({ }) => { const { systemFeatures } = useGlobalPublicStore() const isTrialApp = !!(app && app.can_trial && systemFeatures.enable_trial_app) - const [type, setType] = useState(() => (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(() => (canUseTryTab ? TypeEnum.TRY : TypeEnum.DETAIL)) + const activeType = canUseTryTab ? type : TypeEnum.DETAIL + const { data: appDetail, isLoading, isError, error } = useGetTryAppInfo(appId) return ( = ({
+ ) : isError ? ( +
+ +
+ ) : !appDetail ? ( +
+ +
) : (
@@ -66,15 +70,15 @@ const TryApp: FC = ({ className="flex size-7 items-center justify-center rounded-[10px] p-0 text-components-button-tertiary-text" onClick={onClose} > - +
{/* Main content */}
- {type === TypeEnum.TRY ? : } + {activeType === TypeEnum.TRY ? : } void + setShowTryAppPanel: SetTryAppPanel controlHideCreateFromTemplatePanel: number } diff --git a/web/context/explore-context.ts b/web/context/explore-context.ts deleted file mode 100644 index 8ecaa7af19..0000000000 --- a/web/context/explore-context.ts +++ /dev/null @@ -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({ - controlUpdateInstalledApps: 0, - setControlUpdateInstalledApps: noop, - hasEditPermission: false, - installedApps: [], - setInstalledApps: noop, - isFetchingInstalledApps: false, - setIsFetchingInstalledApps: noop, - isShowTryAppPanel: false, - setShowTryAppPanel: noop, - currentApp: undefined, -}) - -export default ExploreContext diff --git a/web/contract/console/explore.ts b/web/contract/console/explore.ts new file mode 100644 index 0000000000..36749277fc --- /dev/null +++ b/web/contract/console/explore.ts @@ -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()) + +export const exploreAppDetailContract = base + .route({ + path: '/explore/apps/{id}', + method: 'GET', + }) + .input(type<{ params: { id: string } }>()) + .output(type()) + +export const exploreInstalledAppsContract = base + .route({ + path: '/installed-apps', + method: 'GET', + }) + .input(type<{ query?: { app_id?: string } }>()) + .output(type()) + +export const exploreInstalledAppUninstallContract = base + .route({ + path: '/installed-apps/{id}', + method: 'DELETE', + }) + .input(type<{ params: { id: string } }>()) + .output(type()) + +export const exploreInstalledAppPinContract = base + .route({ + path: '/installed-apps/{id}', + method: 'PATCH', + }) + .input(type<{ + params: { id: string } + body: { + is_pinned: boolean + } + }>()) + .output(type()) + +export const exploreInstalledAppAccessModeContract = base + .route({ + path: '/enterprise/webapp/app/access-mode', + method: 'GET', + }) + .input(type<{ query: { appId: string } }>()) + .output(type()) + +export const exploreInstalledAppParametersContract = base + .route({ + path: '/installed-apps/{appId}/parameters', + method: 'GET', + }) + .input(type<{ + params: { + appId: string + } + }>()) + .output(type()) + +export const exploreInstalledAppMetaContract = base + .route({ + path: '/installed-apps/{appId}/meta', + method: 'GET', + }) + .input(type<{ + params: { + appId: string + } + }>()) + .output(type()) + +export const exploreBannersContract = base + .route({ + path: '/explore/banners', + method: 'GET', + }) + .input(type<{ query?: { language?: string } }>()) + .output(type()) diff --git a/web/contract/router.ts b/web/contract/router.ts index 33499b106f..eb55cc5df7 100644 --- a/web/contract/router.ts +++ b/web/contract/router.ts @@ -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 { - 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 => { - return get(`/explore/apps/${id}`) +export const fetchAppDetail = async (id: string): Promise => { + 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 => { - const url = language ? `/explore/banners?language=${language}` : '/explore/banners' - return get(url) +export const fetchInstalledAppParams = (appId: string) => { + return consoleClient.explore.installedAppParameters({ + params: { appId }, + }) as Promise +} + +export const fetchInstalledAppMeta = (appId: string) => { + return consoleClient.explore.installedAppMeta({ + params: { appId }, + }) as Promise +} + +export const fetchBanners = (language?: string) => { + if (!language) + return consoleClient.explore.banners({}) + + return consoleClient.explore.banners({ + query: { language }, + }) } diff --git a/web/service/use-explore.ts b/web/service/use-explore.ts index a2c278f2b2..1f3c0ed6b9 100644 --- a/web/service/use-explore.ts +++ b/web/service/use-explore.ts @@ -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({ - 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) }, }) } diff --git a/web/types/try-app.ts b/web/types/try-app.ts new file mode 100644 index 0000000000..a2a598e5cf --- /dev/null +++ b/web/types/try-app.ts @@ -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