refactor(web): migrate explore app lists from useSWR to TanStack Query (#30076)

This commit is contained in:
yyh 2025-12-24 17:03:43 +08:00 committed by GitHub
parent 1e3823e605
commit 18d69775ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 91 additions and 81 deletions

View File

@ -8,7 +8,6 @@ import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import { useContext } from 'use-context-selector'
import AppTypeSelector from '@/app/components/app/type-selector'
import { trackEvent } from '@/app/components/base/amplitude'
@ -24,7 +23,8 @@ import ExploreContext from '@/context/explore-context'
import { useTabSearchParams } from '@/hooks/use-tab-searchparams'
import { DSLImportMode } from '@/models/app'
import { importDSL } from '@/service/apps'
import { fetchAppDetail, fetchAppList } from '@/service/explore'
import { fetchAppDetail } from '@/service/explore'
import { exploreAppListInitialData, useExploreAppList } from '@/service/use-explore'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
import { cn } from '@/utils/classnames'
@ -70,21 +70,8 @@ const Apps = ({
})
const {
data: { categories, allList },
} = useSWR(
['/explore/apps'],
() =>
fetchAppList().then(({ categories, recommended_apps }) => ({
categories,
allList: recommended_apps.sort((a, b) => a.position - b.position),
})),
{
fallbackData: {
categories: [],
allList: [],
},
},
)
data: { categories, allList } = exploreAppListInitialData,
} = useExploreAppList()
const filteredList = useMemo(() => {
const filteredByCategory = allList.filter((item) => {

View File

@ -10,7 +10,7 @@ import AppList from './index'
const allCategoriesEn = 'explore.apps.allCategories:{"lng":"en"}'
let mockTabValue = allCategoriesEn
const mockSetTab = vi.fn()
let mockSWRData: { categories: string[], allList: App[] } = { categories: [], allList: [] }
let mockExploreData: { categories: string[], allList: App[] } = { categories: [], allList: [] }
const mockHandleImportDSL = vi.fn()
const mockHandleImportDSLConfirm = vi.fn()
@ -33,9 +33,9 @@ vi.mock('ahooks', async () => {
}
})
vi.mock('swr', () => ({
__esModule: true,
default: () => ({ data: mockSWRData }),
vi.mock('@/service/use-explore', () => ({
exploreAppListInitialData: { categories: [], allList: [] },
useExploreAppList: () => ({ data: mockExploreData }),
}))
vi.mock('@/service/explore', () => ({
@ -135,14 +135,14 @@ describe('AppList', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTabValue = allCategoriesEn
mockSWRData = { categories: [], allList: [] }
mockExploreData = { categories: [], allList: [] }
})
// Rendering: show loading when categories are not ready.
describe('Rendering', () => {
it('should render loading when categories are empty', () => {
// Arrange
mockSWRData = { categories: [], allList: [] }
mockExploreData = { categories: [], allList: [] }
// Act
renderWithContext()
@ -153,7 +153,7 @@ describe('AppList', () => {
it('should render app cards when data is available', () => {
// Arrange
mockSWRData = {
mockExploreData = {
categories: ['Writing', 'Translate'],
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })],
}
@ -172,7 +172,7 @@ describe('AppList', () => {
it('should filter apps by selected category', () => {
// Arrange
mockTabValue = 'Writing'
mockSWRData = {
mockExploreData = {
categories: ['Writing', 'Translate'],
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })],
}
@ -190,7 +190,7 @@ describe('AppList', () => {
describe('User Interactions', () => {
it('should filter apps by search keywords', async () => {
// Arrange
mockSWRData = {
mockExploreData = {
categories: ['Writing'],
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })],
}
@ -210,7 +210,7 @@ describe('AppList', () => {
it('should handle create flow and confirm DSL when pending', async () => {
// Arrange
const onSuccess = vi.fn()
mockSWRData = {
mockExploreData = {
categories: ['Writing'],
allList: [createApp()],
};
@ -246,7 +246,7 @@ describe('AppList', () => {
describe('Edge Cases', () => {
it('should reset search results when clear icon is clicked', async () => {
// Arrange
mockSWRData = {
mockExploreData = {
categories: ['Writing'],
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })],
}

View File

@ -6,7 +6,6 @@ import { useDebounceFn } from 'ahooks'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import { useContext } from 'use-context-selector'
import DSLConfirmModal from '@/app/components/app/create-from-dsl-modal/dsl-confirm-modal'
import Input from '@/app/components/base/input'
@ -20,7 +19,8 @@ import { useTabSearchParams } from '@/hooks/use-tab-searchparams'
import {
DSLImportMode,
} from '@/models/app'
import { fetchAppDetail, fetchAppList } from '@/service/explore'
import { fetchAppDetail } from '@/service/explore'
import { exploreAppListInitialData, useExploreAppList } from '@/service/use-explore'
import { cn } from '@/utils/classnames'
import s from './style.module.css'
@ -58,21 +58,8 @@ const Apps = ({
})
const {
data: { categories, allList },
} = useSWR(
['/explore/apps'],
() =>
fetchAppList().then(({ categories, recommended_apps }) => ({
categories,
allList: recommended_apps.sort((a, b) => a.position - b.position),
})),
{
fallbackData: {
categories: [],
allList: [],
},
},
)
data: { categories, allList } = exploreAppListInitialData,
} = useExploreAppList()
const filteredList = allList.filter(item => currCategory === allCategoriesEn || item.category === currCategory)

View File

@ -1,6 +1,6 @@
import type { HeaderProps } from '@/app/components/workflow/header'
import type { App } from '@/types/app'
import { render, screen } from '@testing-library/react'
import { fireEvent, render, screen } from '@testing-library/react'
import { AppModeEnum } from '@/types/app'
import WorkflowHeader from './index'
@ -9,8 +9,47 @@ const mockSetCurrentLogItem = vi.fn()
const mockSetShowMessageLogModal = vi.fn()
const mockResetWorkflowVersionHistory = vi.fn()
const createMockApp = (overrides: Partial<App> = {}): App => ({
id: 'app-id',
name: 'Workflow App',
description: 'Workflow app description',
author_name: 'Workflow app author',
icon_type: 'emoji',
icon: 'app-icon',
icon_background: '#FFFFFF',
icon_url: null,
use_icon_as_answer_icon: false,
mode: AppModeEnum.COMPLETION,
enable_site: true,
enable_api: true,
api_rpm: 60,
api_rph: 3600,
is_demo: false,
model_config: {} as App['model_config'],
app_model_config: {} as App['app_model_config'],
created_at: 0,
updated_at: 0,
site: {
access_token: 'token',
app_base_url: 'https://example.com',
} as App['site'],
api_base_url: 'https://api.example.com',
tags: [],
access_mode: 'public_access' as App['access_mode'],
...overrides,
})
let appDetail: App
const mockAppStore = (overrides: Partial<App> = {}) => {
appDetail = createMockApp(overrides)
mockUseAppStoreSelector.mockImplementation(selector => selector({
appDetail,
setCurrentLogItem: mockSetCurrentLogItem,
setShowMessageLogModal: mockSetShowMessageLogModal,
}))
}
vi.mock('@/app/components/app/store', () => ({
__esModule: true,
useStore: (selector: (state: { appDetail?: App, setCurrentLogItem: typeof mockSetCurrentLogItem, setShowMessageLogModal: typeof mockSetShowMessageLogModal }) => unknown) => mockUseAppStoreSelector(selector),
@ -60,13 +99,7 @@ vi.mock('@/service/use-workflow', () => ({
describe('WorkflowHeader', () => {
beforeEach(() => {
vi.clearAllMocks()
appDetail = { id: 'app-id', mode: AppModeEnum.COMPLETION } as unknown as App
mockUseAppStoreSelector.mockImplementation(selector => selector({
appDetail,
setCurrentLogItem: mockSetCurrentLogItem,
setShowMessageLogModal: mockSetShowMessageLogModal,
}))
mockAppStore()
})
// Verifies the wrapper renders the workflow header shell.
@ -84,12 +117,7 @@ describe('WorkflowHeader', () => {
describe('Props', () => {
it('should configure preview mode when app is in advanced chat mode', () => {
// Arrange
appDetail = { id: 'app-id', mode: AppModeEnum.ADVANCED_CHAT } as unknown as App
mockUseAppStoreSelector.mockImplementation(selector => selector({
appDetail,
setCurrentLogItem: mockSetCurrentLogItem,
setShowMessageLogModal: mockSetShowMessageLogModal,
}))
mockAppStore({ mode: AppModeEnum.ADVANCED_CHAT })
// Act
render(<WorkflowHeader />)
@ -104,12 +132,7 @@ describe('WorkflowHeader', () => {
it('should configure run mode when app is not in advanced chat mode', () => {
// Arrange
appDetail = { id: 'app-id', mode: AppModeEnum.COMPLETION } as unknown as App
mockUseAppStoreSelector.mockImplementation(selector => selector({
appDetail,
setCurrentLogItem: mockSetCurrentLogItem,
setShowMessageLogModal: mockSetShowMessageLogModal,
}))
mockAppStore({ mode: AppModeEnum.COMPLETION })
// Act
render(<WorkflowHeader />)
@ -130,7 +153,7 @@ describe('WorkflowHeader', () => {
render(<WorkflowHeader />)
// Act
screen.getByRole('button', { name: 'clear-history' }).click()
fireEvent.click(screen.getByRole('button', { name: /clear-history/i }))
// Assert
expect(mockSetCurrentLogItem).toHaveBeenCalledWith()
@ -145,7 +168,7 @@ describe('WorkflowHeader', () => {
render(<WorkflowHeader />)
// Assert
screen.getByRole('button', { name: 'restore-settled' }).click()
fireEvent.click(screen.getByRole('button', { name: /restore-settled/i }))
expect(mockResetWorkflowVersionHistory).toHaveBeenCalled()
})
})

View File

@ -1,6 +1,6 @@
import type { AccessMode } from '@/models/access-control'
import type { App, AppCategory } from '@/models/explore'
import { del, get, patch, post } from './base'
import { del, get, patch } from './base'
export const fetchAppList = () => {
return get<{
@ -17,14 +17,6 @@ export const fetchInstalledAppList = (app_id?: string | null) => {
return get(`/installed-apps${app_id ? `?app_id=${app_id}` : ''}`)
}
export const installApp = (id: string) => {
return post('/installed-apps', {
body: {
app_id: id,
},
})
}
export const uninstallApp = (id: string) => {
return del(`/installed-apps/${id}`)
}
@ -37,10 +29,6 @@ export const updatePinStatus = (id: string, isPinned: boolean) => {
})
}
export const getToolProviders = () => {
return get('/workspaces/current/tool-providers')
}
export const getAppAccessModeByAppId = (appId: string) => {
return get<{ accessMode: AccessMode }>(`/enterprise/webapp/app/access-mode?appId=${appId}`)
}

View File

@ -1,11 +1,36 @@
import type { App, AppCategory } from '@/models/explore'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { AccessMode } from '@/models/access-control'
import { fetchInstalledAppList, getAppAccessModeByAppId, uninstallApp, updatePinStatus } from './explore'
import { fetchAppList, fetchInstalledAppList, getAppAccessModeByAppId, uninstallApp, updatePinStatus } from './explore'
import { fetchAppMeta, fetchAppParams } from './share'
const NAME_SPACE = 'explore'
type ExploreAppListData = {
categories: AppCategory[]
allList: App[]
}
export const exploreAppListInitialData: ExploreAppListData = {
categories: [],
allList: [],
}
export const useExploreAppList = () => {
return useQuery<ExploreAppListData>({
queryKey: [NAME_SPACE, 'appList'],
queryFn: async () => {
const { categories, recommended_apps } = await fetchAppList()
return {
categories,
allList: [...recommended_apps].sort((a, b) => a.position - b.position),
}
},
placeholderData: exploreAppListInitialData,
})
}
export const useGetInstalledApps = () => {
return useQuery({
queryKey: [NAME_SPACE, 'installedApps'],