refactor(web): remove ExploreContext and migrate to derived state with oRPC contracts

Replace ExploreContext with derived permission checks using useAppContext
and useMembers, eliminating redundant state synchronization. Add oRPC
contract for explore endpoints, extract TryAppSelection type, and migrate
app-publisher icons from @remixicon/react components to CSS icon classes.
Update all related tests to reflect the new context-free architecture.
This commit is contained in:
yyh
2026-02-14 13:24:10 +08:00
parent 42b3ba92c6
commit d32e23f877
19 changed files with 376 additions and 448 deletions

View File

@ -9,8 +9,9 @@ import type { CreateAppModalProps } from '@/app/components/explore/create-app-mo
import type { App } from '@/models/explore'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import AppList from '@/app/components/explore/app-list'
import ExploreContext from '@/context/explore-context'
import { useAppContext } from '@/context/app-context'
import { fetchAppDetail } from '@/service/explore'
import { useMembers } from '@/service/use-common'
import { AppModeEnum } from '@/types/app'
const allCategoriesEn = 'explore.apps.allCategories:{"lng":"en"}'
@ -57,6 +58,14 @@ vi.mock('@/service/explore', () => ({
fetchAppList: vi.fn(),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('@/service/use-common', () => ({
useMembers: vi.fn(),
}))
vi.mock('@/hooks/use-import-dsl', () => ({
useImportDSL: () => ({
handleImportDSL: mockHandleImportDSL,
@ -126,20 +135,25 @@ const createApp = (overrides: Partial<App> = {}): App => ({
is_agent: overrides.is_agent ?? false,
})
const createContextValue = (hasEditPermission = true) => ({
hasEditPermission,
isShowTryAppPanel: false,
setShowTryAppPanel: vi.fn(),
})
const mockMemberRole = (hasEditPermission: boolean) => {
;(useAppContext as Mock).mockReturnValue({
userProfile: { id: 'user-1' },
})
;(useMembers as Mock).mockReturnValue({
data: {
accounts: [{ id: 'user-1', role: hasEditPermission ? 'admin' : 'normal' }],
},
})
}
const wrapWithContext = (hasEditPermission = true, onSuccess?: () => void) => (
<ExploreContext.Provider value={createContextValue(hasEditPermission)}>
<AppList onSuccess={onSuccess} />
</ExploreContext.Provider>
)
const renderAppList = (hasEditPermission = true, onSuccess?: () => void) => {
mockMemberRole(hasEditPermission)
return render(<AppList onSuccess={onSuccess} />)
}
const renderWithContext = (hasEditPermission = true, onSuccess?: () => void) => {
return render(wrapWithContext(hasEditPermission, onSuccess))
const appListElement = (hasEditPermission = true, onSuccess?: () => void) => {
mockMemberRole(hasEditPermission)
return <AppList onSuccess={onSuccess} />
}
describe('Explore App List Flow', () => {
@ -159,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()
@ -168,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()
@ -176,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' } })
@ -201,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])
@ -234,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()
@ -244,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()
@ -253,13 +268,13 @@ describe('Explore App List Flow', () => {
describe('Permission-Based Behavior', () => {
it('should hide add-to-workspace button when user has no edit permission', () => {
renderWithContext(false)
renderAppList(false)
expect(screen.queryByText('explore.appCard.addToWorkspace')).not.toBeInTheDocument()
})
it('should show add-to-workspace button when user has edit permission', () => {
renderWithContext(true)
renderAppList(true)
expect(screen.getAllByText('explore.appCard.addToWorkspace').length).toBeGreaterThan(0)
})

View File

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

View File

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

View File

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

View File

@ -1,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()
@ -47,83 +42,31 @@ vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('@/service/use-common', () => ({
useMembers: vi.fn(),
}))
vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
const ContextReader = ({ triggerTryPanel }: { triggerTryPanel?: boolean }) => {
const { hasEditPermission, setShowTryAppPanel, isShowTryAppPanel, currentApp } = useContext(ExploreContext)
return (
<div>
{hasEditPermission ? 'edit-yes' : 'edit-no'}
{isShowTryAppPanel && <span data-testid="try-panel-open">open</span>}
{currentApp && <span data-testid="current-app">{currentApp.appId}</span>}
{triggerTryPanel && (
<>
<button data-testid="show-try" onClick={() => setShowTryAppPanel(true, { appId: 'test-app' } as CurrentTryAppParams)}>show</button>
<button data-testid="hide-try" onClick={() => setShowTryAppPanel(false)}>hide</button>
</>
)}
</div>
)
}
describe('Explore', () => {
beforeEach(() => {
vi.clearAllMocks()
})
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((
<Explore>
<ContextReader />
</Explore>
))
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((
<Explore>
<div>child</div>
</Explore>
))
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((
<Explore>
@ -136,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((
<Explore>
<ContextReader />
<div>child</div>
</Explore>
))
expect(screen.getByText('edit-no')).toBeInTheDocument()
})
})
describe('Context: setShowTryAppPanel', () => {
it('should set currentApp params when showing try panel', async () => {
; (useAppContext as Mock).mockReturnValue({
userProfile: { id: 'user-1' },
isCurrentWorkspaceDatasetOperator: false,
});
(useMembers as Mock).mockReturnValue({ data: { accounts: [] } })
render((
<Explore>
<ContextReader triggerTryPanel />
</Explore>
))
fireEvent.click(screen.getByTestId('show-try'))
await waitFor(() => {
expect(screen.getByTestId('try-panel-open')).toBeInTheDocument()
expect(screen.getByTestId('current-app')).toHaveTextContent('test-app')
})
})
it('should clear currentApp params when hiding try panel', async () => {
; (useAppContext as Mock).mockReturnValue({
userProfile: { id: 'user-1' },
isCurrentWorkspaceDatasetOperator: false,
});
(useMembers as Mock).mockReturnValue({ data: { accounts: [] } })
render((
<Explore>
<ContextReader triggerTryPanel />
</Explore>
))
fireEvent.click(screen.getByTestId('show-try'))
await waitFor(() => {
expect(screen.getByTestId('try-panel-open')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('hide-try'))
await waitFor(() => {
expect(screen.queryByTestId('try-panel-open')).not.toBeInTheDocument()
expect(screen.queryByTestId('current-app')).not.toBeInTheDocument()
})
expect(mockReplace).not.toHaveBeenCalled()
})
})
})

View File

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

View File

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

View File

@ -1,12 +1,12 @@
import type { Mock } from 'vitest'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { CurrentTryAppParams } from '@/context/explore-context'
import type { App } from '@/models/explore'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import ExploreContext from '@/context/explore-context'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { fetchAppDetail } from '@/service/explore'
import { useMembers } from '@/service/use-common'
import { AppModeEnum } from '@/types/app'
import AppList from '../index'
@ -29,6 +29,14 @@ vi.mock('@/service/explore', () => ({
fetchAppList: vi.fn(),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('@/service/use-common', () => ({
useMembers: vi.fn(),
}))
vi.mock('@/hooks/use-import-dsl', () => ({
useImportDSL: () => ({
handleImportDSL: mockHandleImportDSL,
@ -111,18 +119,22 @@ const createApp = (overrides: Partial<App> = {}): App => ({
is_agent: overrides.is_agent ?? false,
})
const renderWithContext = (hasEditPermission = false, onSuccess?: () => void, searchParams?: Record<string, string>) => {
const mockMemberRole = (hasEditPermission: boolean) => {
;(useAppContext as Mock).mockReturnValue({
userProfile: { id: 'user-1' },
})
;(useMembers as Mock).mockReturnValue({
data: {
accounts: [{ id: 'user-1', role: hasEditPermission ? 'admin' : 'normal' }],
},
})
}
const renderAppList = (hasEditPermission = false, onSuccess?: () => void, searchParams?: Record<string, string>) => {
mockMemberRole(hasEditPermission)
return render(
<NuqsTestingAdapter searchParams={searchParams}>
<ExploreContext.Provider
value={{
hasEditPermission,
isShowTryAppPanel: false,
setShowTryAppPanel: vi.fn(),
}}
>
<AppList onSuccess={onSuccess} />
</ExploreContext.Provider>
<AppList onSuccess={onSuccess} />
</NuqsTestingAdapter>,
)
}
@ -145,7 +157,7 @@ describe('AppList', () => {
mockExploreData = undefined
mockIsLoading = true
renderWithContext()
renderAppList()
expect(screen.getByRole('status')).toBeInTheDocument()
})
@ -156,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()
@ -170,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()
@ -183,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' } })
@ -211,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'))
@ -235,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' } })
@ -257,7 +269,7 @@ describe('AppList', () => {
mockIsError = true
mockExploreData = undefined
const { container } = renderWithContext()
const { container } = renderAppList()
expect(container.innerHTML).toBe('')
})
@ -265,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('')
})
@ -275,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' } })
@ -298,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()
@ -319,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'))
@ -339,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'))
@ -356,58 +368,16 @@ describe('AppList', () => {
describe('TryApp Panel', () => {
it('should open create modal from try app panel', async () => {
vi.useRealTimers()
const mockSetShowTryAppPanel = vi.fn()
const app = createApp()
mockExploreData = {
categories: ['Writing'],
allList: [app],
}
render(
<NuqsTestingAdapter>
<ExploreContext.Provider
value={{
hasEditPermission: true,
isShowTryAppPanel: true,
setShowTryAppPanel: mockSetShowTryAppPanel,
currentApp: { appId: 'app-1', app },
}}
>
<AppList />
</ExploreContext.Provider>
</NuqsTestingAdapter>,
)
const createBtn = screen.getByTestId('try-app-create')
fireEvent.click(createBtn)
await waitFor(() => {
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
})
})
it('should open create modal with null currApp when appParams has no app', async () => {
vi.useRealTimers()
mockExploreData = {
categories: ['Writing'],
allList: [createApp()],
}
render(
<NuqsTestingAdapter>
<ExploreContext.Provider
value={{
hasEditPermission: true,
isShowTryAppPanel: true,
setShowTryAppPanel: vi.fn(),
currentApp: { appId: 'app-1' } as CurrentTryAppParams,
}}
>
<AppList />
</ExploreContext.Provider>
</NuqsTestingAdapter>,
)
renderAppList(true)
fireEvent.click(screen.getByText('explore.appCard.try'))
expect(screen.getByTestId('try-app-panel')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('try-app-create'))
@ -416,27 +386,19 @@ describe('AppList', () => {
})
})
it('should render try app panel with empty appId when currentApp is undefined', () => {
it('should close try app panel when close is clicked', () => {
mockExploreData = {
categories: ['Writing'],
allList: [createApp()],
}
render(
<NuqsTestingAdapter>
<ExploreContext.Provider
value={{
hasEditPermission: true,
isShowTryAppPanel: true,
setShowTryAppPanel: vi.fn(),
}}
>
<AppList />
</ExploreContext.Provider>
</NuqsTestingAdapter>,
)
renderAppList(true)
fireEvent.click(screen.getByText('explore.appCard.try'))
expect(screen.getByTestId('try-app-panel')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('try-app-close'))
expect(screen.queryByTestId('try-app-panel')).not.toBeInTheDocument()
})
})
@ -453,7 +415,7 @@ describe('AppList', () => {
allList: [createApp()],
}
renderWithContext()
renderAppList()
expect(screen.getByTestId('explore-banner')).toBeInTheDocument()
})

View File

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

View File

@ -1,14 +1,9 @@
'use client'
import type { CurrentTryAppParams } from '@/context/explore-context'
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'
const Explore = ({
children,
@ -16,47 +11,19 @@ const Explore = ({
children: React.ReactNode
}) => {
const router = useRouter()
const { userProfile, isCurrentWorkspaceDatasetOperator } = useAppContext()
const { t } = useTranslation()
const { data: membersData } = useMembers()
useDocumentTitle(t('menus.explore', { ns: 'common' }))
const userAccount = membersData?.accounts?.find(account => account.id === userProfile.id)
const hasEditPermission = !!userAccount && userAccount.role !== 'normal'
const { isCurrentWorkspaceDatasetOperator } = useAppContext()
useEffect(() => {
if (isCurrentWorkspaceDatasetOperator)
return router.replace('/datasets')
}, [isCurrentWorkspaceDatasetOperator])
const [currentTryAppParams, setCurrentTryAppParams] = useState<CurrentTryAppParams | undefined>(undefined)
const [isShowTryAppPanel, setIsShowTryAppPanel] = useState(false)
const setShowTryAppPanel = (showTryAppPanel: boolean, params?: CurrentTryAppParams) => {
if (showTryAppPanel)
setCurrentTryAppParams(params)
else
setCurrentTryAppParams(undefined)
setIsShowTryAppPanel(showTryAppPanel)
}
router.replace('/datasets')
}, [isCurrentWorkspaceDatasetOperator, router])
return (
<div className="flex h-full overflow-hidden border-t border-divider-regular bg-background-body">
<ExploreContext.Provider
value={
{
hasEditPermission,
currentApp: currentTryAppParams,
isShowTryAppPanel,
setShowTryAppPanel,
}
}
>
<Sidebar />
<div className="h-full min-h-0 w-0 grow">
{children}
</div>
</ExploreContext.Provider>
<Sidebar />
<div className="h-full min-h-0 w-0 grow">
{children}
</div>
</div>
)
}

View File

@ -24,9 +24,9 @@ const InstalledApp = ({
const updateAppParams = useWebAppStore(s => s.updateAppParams)
const updateWebAppMeta = useWebAppStore(s => s.updateWebAppMeta)
const updateUserCanAccessApp = useWebAppStore(s => s.updateUserCanAccessApp)
const { isLoading: isLoadingWebAppAccessMode, data: webAppAccessMode, error: webAppAccessModeError } = useGetInstalledAppAccessModeByAppId(installedApp?.id ?? null)
const { isLoading: isLoadingAppParams, data: appParams, error: appParamsError } = useGetInstalledAppParams(installedApp?.id ?? null)
const { isLoading: isLoadingAppMeta, 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(() => {
@ -97,7 +97,7 @@ const InstalledApp = ({
</div>
)
}
if (isLoadingAppParams || isLoadingAppMeta || isLoadingWebAppAccessMode || isPendingInstalledApps) {
if (isPendingInstalledApps || (installedApp && (isPendingAppParams || isPendingAppMeta || isPendingWebAppAccessMode))) {
return (
<div className="flex h-full items-center justify-center">
<Loading />

View File

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

View File

@ -1,24 +0,0 @@
import type { App } 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 = {
hasEditPermission: boolean
currentApp?: CurrentTryAppParams
isShowTryAppPanel: boolean
setShowTryAppPanel: (showTryAppPanel: boolean, params?: CurrentTryAppParams) => void
}
const ExploreContext = createContext<IExplore>({
hasEditPermission: false,
isShowTryAppPanel: false,
setShowTryAppPanel: noop,
currentApp: undefined,
})
export default ExploreContext

View File

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

View File

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

View File

@ -506,14 +506,8 @@
}
},
"app/components/app/app-publisher/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 7
},
"tailwindcss/no-unnecessary-whitespace": {
"count": 1
},
"ts/no-explicit-any": {
"count": 6
"count": 5
}
},
"app/components/app/app-publisher/suggested-action.tsx": {
@ -1233,11 +1227,8 @@
"react/no-nested-component-definitions": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 6
},
"ts/no-explicit-any": {
"count": 4
"count": 2
}
},
"app/components/apps/empty.tsx": {
@ -4053,16 +4044,6 @@
"count": 1
}
},
"app/components/explore/app-card/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/explore/app-list/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/explore/banner/banner-item.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 4

View File

@ -1,30 +1,42 @@
import type { AccessMode } from '@/models/access-control'
import type { Banner } from '@/models/app'
import type { App, AppCategory, InstalledApp } from '@/models/explore'
import { del, get, patch } from './base'
import type { ExploreAppDetailResponse } from '@/contract/console/explore'
import { consoleClient } from './client'
export const fetchAppList = () => {
return get<{
categories: AppCategory[]
recommended_apps: App[]
}>('/explore/apps')
export const fetchAppList = (language?: string) => {
if (!language)
return consoleClient.explore.apps({})
return consoleClient.explore.apps({
query: { language },
})
}
// eslint-disable-next-line ts/no-explicit-any
export const fetchAppDetail = (id: string): Promise<any> => {
return get(`/explore/apps/${id}`)
export const fetchAppDetail = async (id: string): Promise<ExploreAppDetailResponse> => {
const response = await consoleClient.explore.appDetail({
params: { id },
})
if (!response)
throw new Error('Recommended app not found')
return response
}
export const fetchInstalledAppList = (app_id?: string | null) => {
return get<{ installed_apps: InstalledApp[] }>(`/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 +44,16 @@ export const updatePinStatus = (id: string, isPinned: boolean) => {
}
export const getAppAccessModeByAppId = (appId: string) => {
return get<{ accessMode: AccessMode }>(`/enterprise/webapp/app/access-mode?appId=${appId}`)
return consoleClient.explore.appAccessMode({
query: { appId },
})
}
export const fetchBanners = (language?: string): Promise<Banner[]> => {
const url = language ? `/explore/banners?language=${language}` : '/explore/banners'
return get<Banner[]>(url)
export const fetchBanners = (language?: string) => {
if (!language)
return consoleClient.explore.banners({})
return consoleClient.explore.banners({
query: { language },
})
}

View File

@ -3,11 +3,10 @@ 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 { consoleQuery } from './client'
import { fetchAppList, fetchBanners, fetchInstalledAppList, getAppAccessModeByAppId, uninstallApp, updatePinStatus } from './explore'
import { AppSourceType, fetchAppMeta, fetchAppParams } from './share'
const NAME_SPACE = 'explore'
type ExploreAppListData = {
categories: AppCategory[]
allList: App[]
@ -15,10 +14,14 @@ type ExploreAppListData = {
export const useExploreAppList = () => {
const locale = useLocale()
const exploreAppsInput = locale
? { query: { language: locale } }
: {}
return useQuery<ExploreAppListData>({
queryKey: [NAME_SPACE, 'appList', locale],
queryKey: [...consoleQuery.explore.apps.queryKey({ input: exploreAppsInput }), locale],
queryFn: async () => {
const { categories, recommended_apps } = await fetchAppList()
const { categories, recommended_apps } = await fetchAppList(locale)
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,25 +55,33 @@ 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 ?? '' } }
return useQuery({
queryKey: [NAME_SPACE, 'appAccessMode', appId, systemFeatures.webapp_auth.enabled],
queryKey: [
...consoleQuery.explore.appAccessMode.queryKey({ input: appAccessModeInput }),
systemFeatures.webapp_auth.enabled,
appId,
],
queryFn: () => {
if (systemFeatures.webapp_auth.enabled === false) {
return {
accessMode: AccessMode.PUBLIC,
}
}
if (!appId || appId.length === 0)
if (!appId)
return Promise.reject(new Error('App code is required to get access mode'))
return getAppAccessModeByAppId(appId)
@ -79,7 +92,7 @@ export const useGetInstalledAppAccessModeByAppId = (appId: string | null) => {
export const useGetInstalledAppParams = (appId: string | null) => {
return useQuery({
queryKey: [NAME_SPACE, 'appParams', appId],
queryKey: ['explore', 'appParams', appId],
queryFn: () => {
if (!appId || appId.length === 0)
return Promise.reject(new Error('App ID is required to get app params'))
@ -91,7 +104,7 @@ export const useGetInstalledAppParams = (appId: string | null) => {
export const useGetInstalledAppMeta = (appId: string | null) => {
return useQuery({
queryKey: [NAME_SPACE, 'appMeta', appId],
queryKey: ['explore', 'appMeta', appId],
queryFn: () => {
if (!appId || appId.length === 0)
return Promise.reject(new Error('App ID is required to get app meta'))
@ -102,8 +115,12 @@ export const useGetInstalledAppMeta = (appId: string | null) => {
}
export const useGetBanners = (locale?: string) => {
const bannersInput = locale
? { query: { language: locale } }
: {}
return useQuery({
queryKey: [NAME_SPACE, 'banners', locale],
queryKey: [...consoleQuery.explore.banners.queryKey({ input: bannersInput }), locale],
queryFn: () => {
return fetchBanners(locale)
},

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

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