refactor(web): move app quota refresh out of provider context (#41906)

This commit is contained in:
yyh 2026-09-07 06:13:20 +00:00 committed by GitHub
parent 40f0b8a2a0
commit 3be0cabf0a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 182 additions and 265 deletions

View File

@ -20,7 +20,6 @@ export const baseProviderContextValue: ProviderContextState = {
isFetchedPlanInfo: false,
enableBilling: false,
enableSkill: false,
onPlanInfoChanged: noop,
enableReplaceWebAppLogo: false,
modelLoadBalancingEnabled: false,
enableEducationPlan: false,
@ -38,7 +37,6 @@ export const createMockProviderContextValue = (
return {
...merged,
refreshModelProviders: merged.refreshModelProviders ?? noop,
onPlanInfoChanged: merged.onPlanInfoChanged ?? noop,
}
}

View File

@ -1,6 +1,7 @@
import { act, renderHook } from '@testing-library/react'
import { consoleQuery } from '@/service/client'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
import { useAppInfoActions } from '../use-app-info-actions'
const toastMocks = vi.hoisted(() => {
@ -16,7 +17,6 @@ const toastMocks = vi.hoisted(() => {
}
})
const mockReplace = vi.fn()
const mockOnPlanInfoChanged = vi.fn()
const mockInvalidateQueries = vi.fn()
const mockSetAppDetail = vi.fn()
const mockUpdateAppInfo = vi.fn()
@ -47,10 +47,6 @@ vi.mock('@/next/navigation', () => ({
useRouter: () => ({ replace: mockReplace }),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({ onPlanInfoChanged: mockOnPlanInfoChanged }),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
@ -87,6 +83,13 @@ vi.mock('@tanstack/react-query', () => ({
useSuspenseQuery: () => ({
data: { rbac_enabled: true },
}),
useMutation: ({ mutationKey }: { mutationKey: unknown }) => ({
mutateAsync:
JSON.stringify(mutationKey) ===
JSON.stringify(consoleQuery.apps.byAppId.copy.post.mutationOptions().mutationKey)
? mockCopyApp
: mockDeleteApp,
}),
useQueryClient: () => ({
invalidateQueries: mockInvalidateQueries,
setQueryData: mockSetQueryData,
@ -95,8 +98,6 @@ vi.mock('@tanstack/react-query', () => ({
vi.mock('@/service/apps', () => ({
updateAppInfo: (...args: unknown[]) => mockUpdateAppInfo(...args),
copyApp: (...args: unknown[]) => mockCopyApp(...args),
deleteApp: (...args: unknown[]) => mockDeleteApp(...args),
fetchAppDetail: (...args: unknown[]) => mockFetchAppDetail(...args),
}))
@ -306,29 +307,50 @@ describe('useAppInfoActions', () => {
})
describe('onCopy', () => {
it('should copy app and redirect on success', async () => {
const newApp = { id: 'app-2', name: 'Copy', mode: 'chat' }
mockCopyApp.mockResolvedValue(newApp)
it.each(['completed', 'pending'] as const)(
'should redirect only when the copy is completed (%s)',
async (status) => {
const newApp = { id: 'app-2', name: 'Copy', mode: 'chat' }
mockCopyApp.mockResolvedValue(
status === 'completed'
? newApp
: {
id: 'import-1',
status: 'pending',
current_dsl_version: '1.0.0',
},
)
const { result } = renderHook(() => useAppInfoActions({}))
const { result } = renderHook(() => useAppInfoActions({}))
await act(async () => {
await result.current.onCopy({
name: 'Copy',
icon_type: 'emoji',
icon: '🤖',
icon_background: '#fff',
await act(async () => {
await result.current.onCopy({
name: 'Copy',
icon_type: 'emoji',
icon: '🤖',
icon_background: '#fff',
})
})
})
expect(mockCopyApp).toHaveBeenCalled()
expect(mockInvalidateQueries).toHaveBeenCalledTimes(3)
expect(toastMocks.call).toHaveBeenCalledWith({
type: 'success',
message: 'app.newApp.appCreated',
})
expect(mockOnPlanInfoChanged).toHaveBeenCalled()
})
expect(mockCopyApp).toHaveBeenCalledWith({
params: { app_id: 'app-1' },
body: { name: 'Copy', icon_type: 'emoji', icon: '🤖', icon_background: '#fff' },
})
if (status === 'completed') {
expect(toastMocks.call).toHaveBeenCalledWith({
type: 'success',
message: 'app.newApp.appCreated',
})
expect(getRedirection).toHaveBeenCalledWith(newApp, mockReplace, { isRbacEnabled: true })
} else {
expect(toastMocks.call).toHaveBeenCalledWith({
type: 'error',
message: 'app.newApp.appCreateFailed',
})
expect(getRedirection).not.toHaveBeenCalled()
}
},
)
it('should notify error on copy failure', async () => {
mockCopyApp.mockRejectedValue(new Error('fail'))
@ -507,12 +529,11 @@ describe('useAppInfoActions', () => {
await result.current.onConfirmDelete()
})
expect(mockDeleteApp).toHaveBeenCalledWith('app-1')
expect(mockDeleteApp).toHaveBeenCalledWith({ params: { app_id: 'app-1' } })
expect(mockMarkAppDeletionStarted).toHaveBeenCalledWith('app-1')
expect(mockMarkAppDeletionSucceeded).toHaveBeenCalledWith('app-1')
expect(mockMarkAppDeletionFailed).not.toHaveBeenCalled()
expect(toastMocks.call).toHaveBeenCalledWith({ type: 'success', message: 'app.appDeleted' })
expect(mockInvalidateQueries).toHaveBeenCalledTimes(3)
expect(mockReplace).toHaveBeenCalledWith('/apps')
expect(mockSetAppDetail).toHaveBeenCalledWith()
})

View File

@ -7,12 +7,11 @@ import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-moda
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { App } from '@/types/app'
import { toast } from '@langgenius/dify-ui/toast'
import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useExportAppDsl, useExportWorkflowAppDsl } from '@/app/components/app/use-export-app-dsl'
import { useProviderContext } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useRouter } from '@/next/navigation'
import {
@ -20,7 +19,7 @@ import {
markAppDeletionStarted,
markAppDeletionSucceeded,
} from '@/service/app-deletion'
import { copyApp, deleteApp, fetchAppDetail, updateAppInfo } from '@/service/apps'
import { fetchAppDetail, updateAppInfo } from '@/service/apps'
import { consoleQuery } from '@/service/client'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
@ -94,7 +93,10 @@ export function useAppInfoActions({ resetKey }: UseAppInfoActionsParams) {
const { t } = useTranslation()
const { replace } = useRouter()
const queryClient = useQueryClient()
const { onPlanInfoChanged } = useProviderContext()
const { mutateAsync: copyApp } = useMutation(
consoleQuery.apps.byAppId.copy.post.mutationOptions(),
)
const { mutateAsync: deleteApp } = useMutation(consoleQuery.apps.byAppId.delete.mutationOptions())
const appDetail = useAppStore((state) => state.appDetail)
const setAppDetail = useAppStore((state) => state.setAppDetail)
const { exportAppDsl, isExporting: isAppDslExporting } = useExportAppDsl()
@ -251,22 +253,21 @@ export function useAppInfoActions({ resetKey }: UseAppInfoActionsParams) {
if (!appDetail) return
try {
const newApp = await copyApp({
appID: appDetail.id,
name,
icon_type,
icon,
icon_background,
mode: appDetail.mode,
params: { app_id: appDetail.id },
body: { name, icon_type, icon, icon_background },
})
if (!('mode' in newApp)) {
toast(
t(($) => $['newApp.appCreateFailed'], { ns: 'app' }),
{ type: 'error' },
)
return
}
closeModal()
toast(
t(($) => $['newApp.appCreated'], { ns: 'app' }),
{ type: 'success' },
)
void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.get.key() })
void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.starred.get.key() })
void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.recent.get.key() })
onPlanInfoChanged()
getRedirection(newApp, replace, { isRbacEnabled })
} catch {
toast(
@ -275,7 +276,7 @@ export function useAppInfoActions({ resetKey }: UseAppInfoActionsParams) {
)
}
},
[appDetail, closeModal, isRbacEnabled, onPlanInfoChanged, queryClient, replace, t],
[appDetail, closeModal, copyApp, isRbacEnabled, replace, t],
)
const onExport = useCallback(
@ -313,16 +314,12 @@ export function useAppInfoActions({ resetKey }: UseAppInfoActionsParams) {
if (!appDetail) return
markAppDeletionStarted(appDetail.id)
try {
await deleteApp(appDetail.id)
await deleteApp({ params: { app_id: appDetail.id } })
markAppDeletionSucceeded(appDetail.id)
toast(
t(($) => $.appDeleted, { ns: 'app' }),
{ type: 'success' },
)
void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.get.key() })
void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.starred.get.key() })
void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.recent.get.key() })
onPlanInfoChanged()
setAppDetail()
replace('/apps')
} catch (e: unknown) {
@ -333,7 +330,7 @@ export function useAppInfoActions({ resetKey }: UseAppInfoActionsParams) {
)
}
closeModal()
}, [appDetail, closeModal, onPlanInfoChanged, queryClient, replace, setAppDetail, t])
}, [appDetail, closeModal, deleteApp, replace, setAppDetail, t])
return {
appDetail,

View File

@ -125,7 +125,6 @@ function createMockProviderContext(
ttsDefaultModel: null,
agentThoughtDefaultModel: null,
updateModelList: vi.fn(),
onPlanInfoChanged: vi.fn(),
refreshModelProviders: vi.fn(),
...overrides,
} as ProviderContextState

View File

@ -5,14 +5,14 @@ import CreateAppTemplateDialog from '../index'
vi.mock('../app-list', () => ({
default: function MockAppList({
onCreateFromBlank,
onSuccess,
onClose,
}: {
onCreateFromBlank?: () => void
onSuccess: () => void
onClose: () => void
}) {
return (
<div role="region" aria-label="App list">
<button type="button" onClick={onSuccess}>
<button type="button" onClick={onClose}>
Success
</button>
{onCreateFromBlank && (
@ -28,7 +28,6 @@ vi.mock('../app-list', () => ({
describe('CreateAppTemplateDialog', () => {
const defaultProps = {
show: false,
onSuccess: vi.fn(),
onClose: vi.fn(),
onCreateFromBlank: vi.fn(),
}
@ -103,21 +102,12 @@ describe('CreateAppTemplateDialog', () => {
expect(screen.getByRole('button', { name: 'Success' }))!.toBeInTheDocument()
})
it('should call both onSuccess and onClose when app list success is triggered', () => {
const mockOnSuccess = vi.fn()
it('should close when an app is created from the list', () => {
const mockOnClose = vi.fn()
render(
<CreateAppTemplateDialog
{...defaultProps}
show={true}
onSuccess={mockOnSuccess}
onClose={mockOnClose}
/>,
)
render(<CreateAppTemplateDialog {...defaultProps} show={true} onClose={mockOnClose} />)
fireEvent.click(screen.getByRole('button', { name: 'Success' }))
expect(mockOnSuccess).toHaveBeenCalledTimes(1)
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
@ -143,7 +133,6 @@ describe('CreateAppTemplateDialog', () => {
render(
<CreateAppTemplateDialog
show={true}
onSuccess={vi.fn()}
onClose={vi.fn()}
// onCreateFromBlank is undefined
/>,
@ -154,12 +143,7 @@ describe('CreateAppTemplateDialog', () => {
it('should handle undefined props gracefully', () => {
expect(() => {
render(
<CreateAppTemplateDialog
show={true}
onSuccess={vi.fn()}
onClose={vi.fn()}
onCreateFromBlank={undefined}
/>,
<CreateAppTemplateDialog show={true} onClose={vi.fn()} onCreateFromBlank={undefined} />,
)
}).not.toThrow()
})
@ -193,7 +177,6 @@ describe('CreateAppTemplateDialog', () => {
it('should work with all required props only', () => {
const requiredProps = {
show: true,
onSuccess: vi.fn(),
onClose: vi.fn(),
}

View File

@ -247,7 +247,7 @@ describe('Apps', () => {
})
it('renders template cards when data is available', () => {
render(<Apps />)
render(<Apps onClose={vi.fn()} />)
expect(screen.getAllByTestId('app-card')).toHaveLength(6)
expect(screen.getByText('Alpha'))!.toBeInTheDocument()
@ -255,7 +255,7 @@ describe('Apps', () => {
})
it('opens create modal when a template card is clicked', () => {
render(<Apps />)
render(<Apps onClose={vi.fn()} />)
fireEvent.click(screen.getAllByTestId('app-card')[0]!)
expect(screen.getByTestId('create-from-template-modal'))!.toBeInTheDocument()
@ -264,7 +264,7 @@ describe('Apps', () => {
it('passes app.create_and_management permission to template cards even when user is not a workspace editor', () => {
mockWorkspacePermissionKeys = ['app.create_and_management']
render(<Apps />)
render(<Apps onClose={vi.fn()} />)
expect(screen.getAllByTestId('app-card')[0]).toHaveAttribute('data-can-create', 'true')
})
@ -272,7 +272,7 @@ describe('Apps', () => {
it('does not allow template creation when app.create_and_management permission is missing', () => {
mockWorkspacePermissionKeys = []
render(<Apps />)
render(<Apps onClose={vi.fn()} />)
expect(screen.getAllByTestId('app-card')[0]).toHaveAttribute('data-can-create', 'false')
})
@ -283,14 +283,14 @@ describe('Apps', () => {
isLoading: false,
})
render(<Apps />)
render(<Apps onClose={vi.fn()} />)
expect(screen.getByText('app.newApp.noTemplateFound'))!.toBeInTheDocument()
expect(screen.getByText('app.newApp.noTemplateFoundTip'))!.toBeInTheDocument()
})
it('filters templates by keyword and selected app type', async () => {
render(<Apps />)
render(<Apps onClose={vi.fn()} />)
fireEvent.change(screen.getByPlaceholderText('app.newAppFromTemplate.searchAllTemplate'), {
target: { value: 'Bravo' },
@ -314,9 +314,9 @@ describe('Apps', () => {
})
it('creates an app from a template and redirects after import succeeds', async () => {
const onSuccess = vi.fn()
const onClose = vi.fn()
render(<Apps onSuccess={onSuccess} />)
render(<Apps onClose={onClose} />)
fireEvent.click(screen.getAllByTestId('app-card')[0]!)
fireEvent.click(screen.getByTestId('confirm-create'))
@ -337,7 +337,7 @@ describe('Apps', () => {
templateId: 'Alpha',
})
expect(mockToastSuccess).toHaveBeenCalledWith('app.newApp.appCreated')
expect(onSuccess).toHaveBeenCalled()
expect(onClose).toHaveBeenCalledTimes(1)
expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('created-app-id')
expect(mockGetRedirection).toHaveBeenCalledWith(
{
@ -361,7 +361,7 @@ describe('Apps', () => {
app_mode: AppModeEnum.WORKFLOW,
})
render(<Apps />)
render(<Apps onClose={vi.fn()} />)
fireEvent.click(screen.getAllByTestId('app-card')[0]!)
fireEvent.click(screen.getByTestId('confirm-create'))
@ -387,7 +387,7 @@ describe('Apps', () => {
it('shows an error toast when importing the template fails', async () => {
mockImportDSL.mockRejectedValueOnce(new Error('failed'))
render(<Apps />)
render(<Apps onClose={vi.fn()} />)
fireEvent.click(screen.getAllByTestId('app-card')[0]!)
fireEvent.click(screen.getByTestId('confirm-create'))
@ -400,7 +400,7 @@ describe('Apps', () => {
it('forwards the create-from-blank action from the sidebar', () => {
const onCreateFromBlank = vi.fn()
render(<Apps onCreateFromBlank={onCreateFromBlank} />)
render(<Apps onClose={vi.fn()} onCreateFromBlank={onCreateFromBlank} />)
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
@ -413,7 +413,7 @@ describe('Apps', () => {
isLoading: true,
})
render(<Apps />)
render(<Apps onClose={vi.fn()} />)
expect(screen.getByRole('status'))!.toBeInTheDocument()
})
@ -424,13 +424,13 @@ describe('Apps', () => {
isLoading: false,
})
render(<Apps />)
render(<Apps onClose={vi.fn()} />)
expect(screen.getByText('app.newApp.noTemplateFound'))!.toBeInTheDocument()
})
it('should filter templates by category and the remaining app modes', async () => {
render(<Apps />)
render(<Apps onClose={vi.fn()} />)
fireEvent.click(screen.getByText('Cat C'))
expect(screen.queryByText('Alpha')).not.toBeInTheDocument()
@ -473,7 +473,7 @@ describe('Apps', () => {
isLoading: false,
})
render(<Apps />)
render(<Apps onClose={vi.fn()} />)
expect(screen.getByText('Cat A'))!.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'v' })).not.toBeInTheDocument()
@ -481,7 +481,7 @@ describe('Apps', () => {
})
it('should clear the search, hide the sidebar during search, and close the modal when requested', async () => {
render(<Apps />)
render(<Apps onClose={vi.fn()} />)
const searchInput = screen.getByPlaceholderText('app.newAppFromTemplate.searchAllTemplate')
fireEvent.change(searchInput, {
@ -510,7 +510,7 @@ describe('Apps', () => {
it('clears an active search immediately and returns focus to the searchbox', async () => {
const user = userEvent.setup()
render(<Apps />)
render(<Apps onClose={vi.fn()} />)
const searchInput = screen.getByRole('searchbox', {
name: 'app.newAppFromTemplate.searchAllTemplate',

View File

@ -32,7 +32,7 @@ import AppCard from '../app-card'
import Sidebar, { AppCategories, AppCategoryLabel } from './sidebar'
type AppsProps = {
onSuccess?: () => void
onClose: () => void
onCreateFromBlank?: () => void
}
@ -41,7 +41,7 @@ type AppsProps = {
// CREATE = 'create',
// }
const Apps = ({ onSuccess, onCreateFromBlank }: AppsProps) => {
const Apps = ({ onClose, onCreateFromBlank }: AppsProps) => {
const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { data: currentUserId } = useSuspenseQuery({
@ -151,7 +151,7 @@ const Apps = ({ onSuccess, onCreateFromBlank }: AppsProps) => {
setIsShowCreateModal(false)
toast.success(t(($) => $['newApp.appCreated'], { ns: 'app' }))
if (onSuccess) onSuccess()
onClose()
await handleCheckPluginDependencies(app.app_id)
getRedirection(
{ id: app.app_id, mode: app.app_mode, permission_keys: app.permission_keys },

View File

@ -5,17 +5,11 @@ import AppList from './app-list'
type CreateAppDialogProps = {
show: boolean
onSuccess: () => void
onClose: () => void
onCreateFromBlank?: () => void
}
const CreateAppTemplateDialog = ({
show,
onSuccess,
onClose,
onCreateFromBlank,
}: CreateAppDialogProps) => {
const CreateAppTemplateDialog = ({ show, onClose, onCreateFromBlank }: CreateAppDialogProps) => {
const { t } = useTranslation()
return (
@ -24,13 +18,7 @@ const CreateAppTemplateDialog = ({
title={t(($) => $['newApp.startFromTemplate'], { ns: 'app' })}
onClose={onClose}
>
<AppList
onCreateFromBlank={onCreateFromBlank}
onSuccess={() => {
onSuccess()
onClose()
}}
/>
<AppList onCreateFromBlank={onCreateFromBlank} onClose={onClose} />
</CreateAppDialogShell>
)
}

View File

@ -125,18 +125,16 @@ const defaultPlanUsage = {
const renderModal = () => {
const onClose = vi.fn()
const onSuccess = vi.fn()
const onCreateFromTemplate = vi.fn()
render(
<CreateAppModal
show
onClose={onClose}
onSuccess={onSuccess}
onCreateFromTemplate={onCreateFromTemplate}
defaultAppMode={AppModeEnum.ADVANCED_CHAT}
/>,
)
return { onClose, onSuccess, onCreateFromTemplate }
return { onClose, onCreateFromTemplate }
}
describe('CreateAppModal', () => {
@ -168,7 +166,7 @@ describe('CreateAppModal', () => {
maintainer: 'user-1',
}
mockCreateApp.mockResolvedValue(mockApp as App)
const { onClose, onSuccess } = renderModal()
const { onClose } = renderModal()
const nameInput = screen.getByPlaceholderText('app.newApp.appNamePlaceholder')
fireEvent.change(nameInput, { target: { value: 'My App' } })
@ -190,8 +188,7 @@ describe('CreateAppModal', () => {
appMode: AppModeEnum.ADVANCED_CHAT,
})
expect(mockToastSuccess).toHaveBeenCalledWith('app.newApp.appCreated')
expect(onSuccess).toHaveBeenCalled()
expect(onClose).toHaveBeenCalled()
expect(onClose).toHaveBeenCalledTimes(1)
await waitFor(() =>
expect(mockGetRedirection).toHaveBeenCalledWith(mockApp, mockPush, {
currentUserId: 'user-1',

View File

@ -34,7 +34,6 @@ import AppIconPicker from '../../base/app-icon-picker'
import { CreateAppDialogShell } from '../create-app-dialog-shell'
type CreateAppProps = {
onSuccess: () => void
onClose: () => void
onCreateFromTemplate?: () => void
defaultAppMode?: AppModeEnum
@ -50,7 +49,7 @@ const shouldExpandBeginnerAppTypes = (appMode?: AppModeEnum) => {
)
}
function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: CreateAppProps) {
function CreateApp({ onClose, onCreateFromTemplate, defaultAppMode }: CreateAppProps) {
const { t } = useTranslation()
const { push } = useRouter()
const nameInputId = useId()
@ -120,7 +119,6 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
}
toast.success(t(($) => $['newApp.appCreated'], { ns: 'app' }))
onSuccess()
onClose()
getRedirection(app, push, {
currentUserId,
@ -146,7 +144,6 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
appMode,
appIcon,
description,
onSuccess,
onClose,
push,
workspacePermissionKeys,
@ -420,7 +417,6 @@ type CreateAppDialogProps = CreateAppProps & {
const CreateAppModal = ({
show,
onClose,
onSuccess,
onCreateFromTemplate,
defaultAppMode,
}: CreateAppDialogProps) => {
@ -435,7 +431,6 @@ const CreateAppModal = ({
>
<CreateApp
onClose={onClose}
onSuccess={onSuccess}
onCreateFromTemplate={onCreateFromTemplate}
defaultAppMode={defaultAppMode}
/>

View File

@ -2,7 +2,6 @@ import type { DeploymentEdition } from '@dify/contracts/api/console/system-featu
import type { RenderOptions } from '@testing-library/react'
import type { MockedFunction } from 'vite-plus/test'
import { fireEvent, screen } from '@testing-library/react'
import { noop } from 'es-toolkit/function'
import { defaultPlan } from '@/app/components/billing/config'
import { useProviderContext as actualUseProviderContext } from '@/context/provider-context'
import { renderWithConsoleQuery } from '@/test/console/query-data'
@ -51,7 +50,6 @@ const defaultProviderContext = {
isFetchedPlanInfo: false,
enableBilling: false,
enableSkill: false,
onPlanInfoChanged: noop,
enableReplaceWebAppLogo: false,
modelLoadBalancingEnabled: false,
enableEducationPlan: false,

View File

@ -185,14 +185,6 @@ vi.mock('@/context/permission-state', async () => {
return createPermissionStateModuleMock(() => mockConsoleState)
})
// Mock provider context
const mockOnPlanInfoChanged = vi.fn()
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
onPlanInfoChanged: mockOnPlanInfoChanged,
}),
}))
// systemFeatures is seeded into the QueryClient via the local render helper.
vi.mock('@/service/apps', () => ({
@ -1163,25 +1155,6 @@ describe('AppCard', () => {
})
})
it('should call onPlanInfoChanged after successful duplication', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
fireEvent.click(screen.getByText('app.duplicate'))
})
await waitFor(() => {
expect(screen.getByTestId('duplicate-modal')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('confirm-duplicate-modal'))
await waitFor(() => {
expect(mockOnPlanInfoChanged).toHaveBeenCalled()
})
})
it('should handle copy failure', async () => {
mockCopyApp.mockRejectedValueOnce(new Error('Copy failed'))

View File

@ -131,12 +131,6 @@ vi.mock('@/context/permission-state', async () => {
workspacePermissionKeys: mockWorkspacePermissionKeys,
}))
})
const mockOnPlanInfoChanged = vi.fn()
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
onPlanInfoChanged: mockOnPlanInfoChanged,
}),
}))
vi.mock('@/service/use-common', () => ({
useMembers: () => ({
@ -288,11 +282,9 @@ vi.mock('@/next/dynamic', () => ({
return function MockCreateFromDSLModal({
show,
onClose,
onSuccess,
}: {
show: boolean
onClose: () => void
onSuccess: () => void
}) {
if (!show) return null
return React.createElement(
@ -305,7 +297,7 @@ vi.mock('@/next/dynamic', () => ({
),
React.createElement(
'button',
{ onClick: onSuccess, 'data-testid': 'success-dsl-modal' },
{ onClick: onClose, 'data-testid': 'success-dsl-modal' },
'Success',
),
)
@ -315,12 +307,10 @@ vi.mock('@/next/dynamic', () => ({
return function MockCreateAppModal({
show,
onClose,
onSuccess,
onCreateFromTemplate,
}: {
show: boolean
onClose: () => void
onSuccess: () => void
onCreateFromTemplate: () => void
}) {
if (!show) return null
@ -334,7 +324,7 @@ vi.mock('@/next/dynamic', () => ({
),
React.createElement(
'button',
{ onClick: onSuccess, 'data-testid': 'success-create-modal' },
{ onClick: onClose, 'data-testid': 'success-create-modal' },
'Success',
),
React.createElement(
@ -349,12 +339,10 @@ vi.mock('@/next/dynamic', () => ({
return function MockCreateAppTemplateDialog({
show,
onClose,
onSuccess,
onCreateFromBlank,
}: {
show: boolean
onClose: () => void
onSuccess: () => void
onCreateFromBlank: () => void
}) {
if (!show) return null
@ -368,7 +356,7 @@ vi.mock('@/next/dynamic', () => ({
),
React.createElement(
'button',
{ onClick: onSuccess, 'data-testid': 'success-template-dialog' },
{ onClick: onClose, 'data-testid': 'success-template-dialog' },
'Success',
),
React.createElement(

View File

@ -51,7 +51,6 @@ import {
useStepByStepTourControlledDropdown,
} from '@/app/components/step-by-step-tour/dropdown-menu'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { useProviderContext } from '@/context/provider-context'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
@ -276,7 +275,6 @@ export function AppCardInteractions({
})
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const isRbacEnabled = systemFeatures.rbac_enabled
const { onPlanInfoChanged } = useProviderContext()
const { push } = useRouter()
const { mutate: copyApp } = useMutation(consoleQuery.apps.byAppId.copy.post.mutationOptions())
const { mutateAsync: updateApp } = useMutation(consoleQuery.apps.byAppId.put.mutationOptions())
@ -329,7 +327,6 @@ export function AppCardInteractions({
{
onSuccess: () => {
toast.success(t(($) => $.appDeleted, { ns: 'app' }))
onPlanInfoChanged()
setActiveDialog(null)
setConfirmDeleteInput('')
},
@ -345,7 +342,7 @@ export function AppCardInteractions({
const message = error instanceof Error ? error.message : ''
toast.error(`${t(($) => $.appDeleteFailed, { ns: 'app' })}${message ? `: ${message}` : ''}`)
}
}, [app.id, deleteApp, onPlanInfoChanged, t])
}, [app.id, deleteApp, t])
const onDeleteDialogOpenChange = useCallback(
(open: boolean) => {
@ -461,7 +458,6 @@ export function AppCardInteractions({
setActiveDialog(null)
toast.success(t(($) => $['newApp.appCreated'], { ns: 'app' }))
onPlanInfoChanged()
getRedirection(newApp, push, {
currentUserId,
resourceMaintainer: newApp.maintainer ?? undefined,

View File

@ -2,7 +2,6 @@
import type { AppListUrlQuery } from './query-params'
import { zPostAppsBody } from '@dify/contracts/api/console/apps/zod.gen'
import { useProviderContext } from '@/context/provider-context'
import dynamic from '@/next/dynamic'
type AppListCategory = AppListUrlQuery['category']
@ -38,40 +37,24 @@ export function AppListCreationModals({
onOpenBlank: () => void
onOpenTemplate: () => void
}) {
const { onPlanInfoChanged } = useProviderContext()
if (!canCreateApp) return null
const defaultAppModeResult = zPostAppsBody.shape.mode.safeParse(category)
return (
<>
{dialog?.type === 'dsl' && (
<CreateFromDSLModal
show
onClose={onClose}
onSuccess={() => {
onClose()
onPlanInfoChanged()
}}
droppedFile={dialog.droppedFile}
/>
<CreateFromDSLModal show onClose={onClose} droppedFile={dialog.droppedFile} />
)}
{dialog?.type === 'blank' && (
<CreateAppModal
show
onClose={onClose}
onSuccess={onPlanInfoChanged}
onCreateFromTemplate={onOpenTemplate}
defaultAppMode={defaultAppModeResult.success ? defaultAppModeResult.data : undefined}
/>
)}
{dialog?.type === 'template' && (
<CreateAppTemplateDialog
show
onClose={onClose}
onSuccess={onPlanInfoChanged}
onCreateFromBlank={onOpenBlank}
/>
<CreateAppTemplateDialog show onClose={onClose} onCreateFromBlank={onOpenBlank} />
)}
</>
)

View File

@ -218,7 +218,6 @@ vi.mock('@/context/provider-context', () => ({
hasSettedApiKey: true,
plan: { type: 'free' },
enableBilling: false,
onPlanInfoChanged: vi.fn(),
}),
}))

View File

@ -60,11 +60,6 @@ export const ProviderContextProvider = ({ children }: ProviderContextProviderPro
queryClient.invalidateQueries({ queryKey: commonQueryKeys.modelProviderDetails }),
]).then(() => undefined)
const refreshFeatures = () =>
queryClient
.invalidateQueries({ queryKey: consoleQuery.features.get.key() })
.then(() => undefined)
// #region Zendesk conversation fields
useEffect(() => {
if (ZENDESK_FIELD_IDS.PLAN && plan.type) {
@ -99,7 +94,6 @@ export const ProviderContextProvider = ({ children }: ProviderContextProviderPro
isFetchedPlanInfo,
enableBilling,
enableSkill,
onPlanInfoChanged: refreshFeatures,
enableReplaceWebAppLogo,
modelLoadBalancingEnabled,
enableEducationPlan,

View File

@ -8,7 +8,6 @@ import type {
import type { UsagePlanInfo, UsageResetInfo } from '@/app/components/billing/type'
import type { Model } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { RETRIEVE_METHOD } from '@/types/app'
import { noop } from 'es-toolkit/function'
import { createContext, useContext, useContextSelector } from 'use-context-selector'
import { defaultPlan } from '@/app/components/billing/config'
@ -31,7 +30,6 @@ export type ProviderContextState = {
isFetchedPlanInfo: boolean
enableBilling: boolean
enableSkill: boolean
onPlanInfoChanged: () => void
enableReplaceWebAppLogo: boolean
modelLoadBalancingEnabled: boolean
enableEducationPlan: boolean
@ -55,7 +53,6 @@ export const baseProviderContextValue: ProviderContextState = {
isFetchedPlanInfo: false,
enableBilling: false,
enableSkill: false,
onPlanInfoChanged: noop,
enableReplaceWebAppLogo: false,
modelLoadBalancingEnabled: false,
enableEducationPlan: false,

View File

@ -85,28 +85,6 @@ export const updateAppInfo = ({
return put<AppDetailResponse>(`apps/${appID}`, { body })
}
export const copyApp = ({
appID,
name,
icon_type,
icon,
icon_background,
mode,
description,
}: {
appID: string
name: string
icon_type: AppIconType
icon: string
icon_background?: string | null
mode: AppModeEnum
description?: string
}): Promise<AppDetailResponse> => {
return post<AppDetailResponse>(`apps/${appID}/copy`, {
body: { name, icon_type, icon, icon_background, mode, description },
})
}
export const exportAppConfig = ({
appID,
include = false,

View File

@ -778,39 +778,51 @@ describe('consoleQuery app mutation defaults', () => {
})
})
it('should invalidate app lists without blocking a directly completed import', async () => {
const consoleQuery = await loadConsoleQuery()
const queryClient = new QueryClient()
const invalidateQueries = vi
.spyOn(queryClient, 'invalidateQueries')
.mockImplementation(() => new Promise(() => {}))
const mutationOptions = consoleQuery.apps.imports.post.mutationOptions()
it.each([undefined, 'existing-app'])(
'should refresh completed import data without blocking (overwrite: %s)',
async (appId) => {
const consoleQuery = await loadConsoleQuery()
const queryClient = new QueryClient()
const invalidateQueries = vi
.spyOn(queryClient, 'invalidateQueries')
.mockImplementation(() => new Promise(() => {}))
const mutationOptions = consoleQuery.apps.imports.post.mutationOptions()
const result = mutationOptions.onSuccess?.(
{
id: 'import-1',
status: 'completed',
app_id: 'app-1',
current_dsl_version: '',
imported_dsl_version: '',
error: '',
},
{ body: { mode: 'yaml-content', yaml_content: 'app: demo' } },
undefined,
createMutationContext(queryClient),
)
const result = mutationOptions.onSuccess?.(
{
id: 'import-1',
status: 'completed',
app_id: 'app-1',
current_dsl_version: '',
imported_dsl_version: '',
error: '',
},
{ body: { mode: 'yaml-content', yaml_content: 'app: demo', app_id: appId } },
undefined,
createMutationContext(queryClient),
)
expect(result).toBeUndefined()
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: consoleQuery.apps.get.key(),
})
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: consoleQuery.apps.starred.get.key(),
})
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: consoleQuery.apps.recent.get.key(),
})
})
if (appId) {
expect(invalidateQueries).not.toHaveBeenCalledWith({
queryKey: consoleQuery.features.get.key(),
})
} else {
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: consoleQuery.features.get.key(),
})
}
expect(result).toBeUndefined()
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: consoleQuery.apps.get.key(),
})
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: consoleQuery.apps.starred.get.key(),
})
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: consoleQuery.apps.recent.get.key(),
})
},
)
it('should keep app lists intact while an import awaits confirmation', async () => {
const consoleQuery = await loadConsoleQuery()
@ -873,16 +885,20 @@ describe('consoleQuery app mutation defaults', () => {
expect(synchronized).toBe(true)
})
it('should keep a delete mutation pending until every app list synchronizes', async () => {
it('should wait for deleted app lists to synchronize without waiting for quota', async () => {
const consoleQuery = await loadConsoleQuery()
const queryClient = new QueryClient()
const invalidationResolvers: Array<() => void> = []
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries').mockImplementation(
() =>
new Promise<void>((resolve) => {
const invalidateQueries = vi
.spyOn(queryClient, 'invalidateQueries')
.mockImplementation((filters) => {
if (JSON.stringify(filters?.queryKey) === JSON.stringify(consoleQuery.features.get.key()))
return new Promise<void>(() => {})
return new Promise<void>((resolve) => {
invalidationResolvers.push(resolve)
}),
)
})
})
const mutationOptions = consoleQuery.apps.byAppId.delete.mutationOptions()
const synchronization = mutationOptions.onSuccess?.(
@ -907,7 +923,8 @@ describe('consoleQuery app mutation defaults', () => {
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: consoleQuery.apps.recent.get.key(),
})
expect(invalidateQueries).toHaveBeenCalledTimes(3)
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: consoleQuery.features.get.key() })
expect(invalidateQueries).toHaveBeenCalledTimes(4)
invalidationResolvers.forEach((resolve) => resolve())
await synchronization

View File

@ -583,6 +583,7 @@ export const consoleQuery: RouterUtils<typeof consoleClient> = createTanstackQue
post: {
mutationOptions: {
onSuccess: (_data, _variables, _onMutateResult, context) => {
void context.client.invalidateQueries({ queryKey: consoleQuery.features.get.key() })
void context.client.invalidateQueries({ queryKey: consoleQuery.apps.get.key() })
void context.client.invalidateQueries({
queryKey: consoleQuery.apps.starred.get.key(),
@ -596,9 +597,14 @@ export const consoleQuery: RouterUtils<typeof consoleClient> = createTanstackQue
imports: {
post: {
mutationOptions: {
onSuccess: (data, _variables, _onMutateResult, context) => {
onSuccess: (data, variables, _onMutateResult, context) => {
if (data.status !== 'completed' && data.status !== 'completed-with-warnings') return
if (!variables.body.app_id) {
void context.client.invalidateQueries({
queryKey: consoleQuery.features.get.key(),
})
}
void context.client.invalidateQueries({ queryKey: consoleQuery.apps.get.key() })
void context.client.invalidateQueries({
queryKey: consoleQuery.apps.starred.get.key(),
@ -617,6 +623,9 @@ export const consoleQuery: RouterUtils<typeof consoleClient> = createTanstackQue
if (data.status !== 'completed' && data.status !== 'completed-with-warnings')
return
void context.client.invalidateQueries({
queryKey: consoleQuery.features.get.key(),
})
void context.client.invalidateQueries({
queryKey: consoleQuery.apps.get.key(),
})
@ -651,8 +660,9 @@ export const consoleQuery: RouterUtils<typeof consoleClient> = createTanstackQue
},
delete: {
mutationOptions: {
onSuccess: (_data, _variables, _onMutateResult, context) =>
Promise.all([
onSuccess: (_data, _variables, _onMutateResult, context) => {
void context.client.invalidateQueries({ queryKey: consoleQuery.features.get.key() })
return Promise.all([
context.client.invalidateQueries({ queryKey: consoleQuery.apps.get.key() }),
context.client.invalidateQueries({
queryKey: consoleQuery.apps.starred.get.key(),
@ -660,7 +670,8 @@ export const consoleQuery: RouterUtils<typeof consoleClient> = createTanstackQue
context.client.invalidateQueries({
queryKey: consoleQuery.apps.recent.get.key(),
}),
]),
])
},
},
},
put: {
@ -732,7 +743,12 @@ export const consoleQuery: RouterUtils<typeof consoleClient> = createTanstackQue
copy: {
post: {
mutationOptions: {
onSuccess: (_data, _variables, _onMutateResult, context) => {
onSuccess: (data, _variables, _onMutateResult, context) => {
if (!('mode' in data)) return
void context.client.invalidateQueries({
queryKey: consoleQuery.features.get.key(),
})
void context.client.invalidateQueries({ queryKey: consoleQuery.apps.get.key() })
void context.client.invalidateQueries({
queryKey: consoleQuery.apps.starred.get.key(),