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

View File

@ -10,7 +10,7 @@ import AppList from './index'
const allCategoriesEn = 'explore.apps.allCategories:{"lng":"en"}' const allCategoriesEn = 'explore.apps.allCategories:{"lng":"en"}'
let mockTabValue = allCategoriesEn let mockTabValue = allCategoriesEn
const mockSetTab = vi.fn() 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 mockHandleImportDSL = vi.fn()
const mockHandleImportDSLConfirm = vi.fn() const mockHandleImportDSLConfirm = vi.fn()
@ -33,9 +33,9 @@ vi.mock('ahooks', async () => {
} }
}) })
vi.mock('swr', () => ({ vi.mock('@/service/use-explore', () => ({
__esModule: true, exploreAppListInitialData: { categories: [], allList: [] },
default: () => ({ data: mockSWRData }), useExploreAppList: () => ({ data: mockExploreData }),
})) }))
vi.mock('@/service/explore', () => ({ vi.mock('@/service/explore', () => ({
@ -135,14 +135,14 @@ describe('AppList', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockTabValue = allCategoriesEn mockTabValue = allCategoriesEn
mockSWRData = { categories: [], allList: [] } mockExploreData = { categories: [], allList: [] }
}) })
// Rendering: show loading when categories are not ready. // Rendering: show loading when categories are not ready.
describe('Rendering', () => { describe('Rendering', () => {
it('should render loading when categories are empty', () => { it('should render loading when categories are empty', () => {
// Arrange // Arrange
mockSWRData = { categories: [], allList: [] } mockExploreData = { categories: [], allList: [] }
// Act // Act
renderWithContext() renderWithContext()
@ -153,7 +153,7 @@ describe('AppList', () => {
it('should render app cards when data is available', () => { it('should render app cards when data is available', () => {
// Arrange // Arrange
mockSWRData = { mockExploreData = {
categories: ['Writing', 'Translate'], categories: ['Writing', 'Translate'],
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: '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', () => { it('should filter apps by selected category', () => {
// Arrange // Arrange
mockTabValue = 'Writing' mockTabValue = 'Writing'
mockSWRData = { mockExploreData = {
categories: ['Writing', 'Translate'], categories: ['Writing', 'Translate'],
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })], allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })],
} }
@ -190,7 +190,7 @@ describe('AppList', () => {
describe('User Interactions', () => { describe('User Interactions', () => {
it('should filter apps by search keywords', async () => { it('should filter apps by search keywords', async () => {
// Arrange // Arrange
mockSWRData = { mockExploreData = {
categories: ['Writing'], categories: ['Writing'],
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })], 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 () => { it('should handle create flow and confirm DSL when pending', async () => {
// Arrange // Arrange
const onSuccess = vi.fn() const onSuccess = vi.fn()
mockSWRData = { mockExploreData = {
categories: ['Writing'], categories: ['Writing'],
allList: [createApp()], allList: [createApp()],
}; };
@ -246,7 +246,7 @@ describe('AppList', () => {
describe('Edge Cases', () => { describe('Edge Cases', () => {
it('should reset search results when clear icon is clicked', async () => { it('should reset search results when clear icon is clicked', async () => {
// Arrange // Arrange
mockSWRData = { mockExploreData = {
categories: ['Writing'], categories: ['Writing'],
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })], 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 * as React from 'react'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import { useContext } from 'use-context-selector' import { useContext } from 'use-context-selector'
import DSLConfirmModal from '@/app/components/app/create-from-dsl-modal/dsl-confirm-modal' import DSLConfirmModal from '@/app/components/app/create-from-dsl-modal/dsl-confirm-modal'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
@ -20,7 +19,8 @@ import { useTabSearchParams } from '@/hooks/use-tab-searchparams'
import { import {
DSLImportMode, DSLImportMode,
} from '@/models/app' } 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 { cn } from '@/utils/classnames'
import s from './style.module.css' import s from './style.module.css'
@ -58,21 +58,8 @@ const Apps = ({
}) })
const { const {
data: { categories, allList }, data: { categories, allList } = exploreAppListInitialData,
} = useSWR( } = useExploreAppList()
['/explore/apps'],
() =>
fetchAppList().then(({ categories, recommended_apps }) => ({
categories,
allList: recommended_apps.sort((a, b) => a.position - b.position),
})),
{
fallbackData: {
categories: [],
allList: [],
},
},
)
const filteredList = allList.filter(item => currCategory === allCategoriesEn || item.category === currCategory) 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 { HeaderProps } from '@/app/components/workflow/header'
import type { App } from '@/types/app' 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 { AppModeEnum } from '@/types/app'
import WorkflowHeader from './index' import WorkflowHeader from './index'
@ -9,8 +9,47 @@ const mockSetCurrentLogItem = vi.fn()
const mockSetShowMessageLogModal = vi.fn() const mockSetShowMessageLogModal = vi.fn()
const mockResetWorkflowVersionHistory = 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 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', () => ({ vi.mock('@/app/components/app/store', () => ({
__esModule: true, __esModule: true,
useStore: (selector: (state: { appDetail?: App, setCurrentLogItem: typeof mockSetCurrentLogItem, setShowMessageLogModal: typeof mockSetShowMessageLogModal }) => unknown) => mockUseAppStoreSelector(selector), 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', () => { describe('WorkflowHeader', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
appDetail = { id: 'app-id', mode: AppModeEnum.COMPLETION } as unknown as App mockAppStore()
mockUseAppStoreSelector.mockImplementation(selector => selector({
appDetail,
setCurrentLogItem: mockSetCurrentLogItem,
setShowMessageLogModal: mockSetShowMessageLogModal,
}))
}) })
// Verifies the wrapper renders the workflow header shell. // Verifies the wrapper renders the workflow header shell.
@ -84,12 +117,7 @@ describe('WorkflowHeader', () => {
describe('Props', () => { describe('Props', () => {
it('should configure preview mode when app is in advanced chat mode', () => { it('should configure preview mode when app is in advanced chat mode', () => {
// Arrange // Arrange
appDetail = { id: 'app-id', mode: AppModeEnum.ADVANCED_CHAT } as unknown as App mockAppStore({ mode: AppModeEnum.ADVANCED_CHAT })
mockUseAppStoreSelector.mockImplementation(selector => selector({
appDetail,
setCurrentLogItem: mockSetCurrentLogItem,
setShowMessageLogModal: mockSetShowMessageLogModal,
}))
// Act // Act
render(<WorkflowHeader />) render(<WorkflowHeader />)
@ -104,12 +132,7 @@ describe('WorkflowHeader', () => {
it('should configure run mode when app is not in advanced chat mode', () => { it('should configure run mode when app is not in advanced chat mode', () => {
// Arrange // Arrange
appDetail = { id: 'app-id', mode: AppModeEnum.COMPLETION } as unknown as App mockAppStore({ mode: AppModeEnum.COMPLETION })
mockUseAppStoreSelector.mockImplementation(selector => selector({
appDetail,
setCurrentLogItem: mockSetCurrentLogItem,
setShowMessageLogModal: mockSetShowMessageLogModal,
}))
// Act // Act
render(<WorkflowHeader />) render(<WorkflowHeader />)
@ -130,7 +153,7 @@ describe('WorkflowHeader', () => {
render(<WorkflowHeader />) render(<WorkflowHeader />)
// Act // Act
screen.getByRole('button', { name: 'clear-history' }).click() fireEvent.click(screen.getByRole('button', { name: /clear-history/i }))
// Assert // Assert
expect(mockSetCurrentLogItem).toHaveBeenCalledWith() expect(mockSetCurrentLogItem).toHaveBeenCalledWith()
@ -145,7 +168,7 @@ describe('WorkflowHeader', () => {
render(<WorkflowHeader />) render(<WorkflowHeader />)
// Assert // Assert
screen.getByRole('button', { name: 'restore-settled' }).click() fireEvent.click(screen.getByRole('button', { name: /restore-settled/i }))
expect(mockResetWorkflowVersionHistory).toHaveBeenCalled() expect(mockResetWorkflowVersionHistory).toHaveBeenCalled()
}) })
}) })

View File

@ -1,6 +1,6 @@
import type { AccessMode } from '@/models/access-control' import type { AccessMode } from '@/models/access-control'
import type { App, AppCategory } from '@/models/explore' import type { App, AppCategory } from '@/models/explore'
import { del, get, patch, post } from './base' import { del, get, patch } from './base'
export const fetchAppList = () => { export const fetchAppList = () => {
return get<{ return get<{
@ -17,14 +17,6 @@ export const fetchInstalledAppList = (app_id?: string | null) => {
return get(`/installed-apps${app_id ? `?app_id=${app_id}` : ''}`) 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) => { export const uninstallApp = (id: string) => {
return del(`/installed-apps/${id}`) 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) => { export const getAppAccessModeByAppId = (appId: string) => {
return get<{ accessMode: AccessMode }>(`/enterprise/webapp/app/access-mode?appId=${appId}`) 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useGlobalPublicStore } from '@/context/global-public-context' import { useGlobalPublicStore } from '@/context/global-public-context'
import { AccessMode } from '@/models/access-control' 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' import { fetchAppMeta, fetchAppParams } from './share'
const NAME_SPACE = 'explore' 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 = () => { export const useGetInstalledApps = () => {
return useQuery({ return useQuery({
queryKey: [NAME_SPACE, 'installedApps'], queryKey: [NAME_SPACE, 'installedApps'],