diff --git a/web/__mocks__/provider-context.ts b/web/__mocks__/provider-context.ts index f41aab5c24e..f9eb2ffc8dd 100644 --- a/web/__mocks__/provider-context.ts +++ b/web/__mocks__/provider-context.ts @@ -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, } } diff --git a/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts b/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts index bef3bcc1e7d..e5265af1c4c 100644 --- a/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts +++ b/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts @@ -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) => 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() }) diff --git a/web/app/components/app-sidebar/app-info/use-app-info-actions.ts b/web/app/components/app-sidebar/app-info/use-app-info-actions.ts index f0dabb42820..ed8da814d36 100644 --- a/web/app/components/app-sidebar/app-info/use-app-info-actions.ts +++ b/web/app/components/app-sidebar/app-info/use-app-info-actions.ts @@ -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, diff --git a/web/app/components/app/configuration/debug/debug-with-single-model/__tests__/index.spec.tsx b/web/app/components/app/configuration/debug/debug-with-single-model/__tests__/index.spec.tsx index ecd3fd442ee..dfcccab95bd 100644 --- a/web/app/components/app/configuration/debug/debug-with-single-model/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/debug/debug-with-single-model/__tests__/index.spec.tsx @@ -125,7 +125,6 @@ function createMockProviderContext( ttsDefaultModel: null, agentThoughtDefaultModel: null, updateModelList: vi.fn(), - onPlanInfoChanged: vi.fn(), refreshModelProviders: vi.fn(), ...overrides, } as ProviderContextState diff --git a/web/app/components/app/create-app-dialog/__tests__/index.spec.tsx b/web/app/components/app/create-app-dialog/__tests__/index.spec.tsx index 6741e058c92..1fb7cd6be68 100644 --- a/web/app/components/app/create-app-dialog/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-dialog/__tests__/index.spec.tsx @@ -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 (
- {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( - , - ) + render() fireEvent.click(screen.getByRole('button', { name: 'Success' })) - expect(mockOnSuccess).toHaveBeenCalledTimes(1) expect(mockOnClose).toHaveBeenCalledTimes(1) }) @@ -143,7 +133,6 @@ describe('CreateAppTemplateDialog', () => { render( , @@ -154,12 +143,7 @@ describe('CreateAppTemplateDialog', () => { it('should handle undefined props gracefully', () => { expect(() => { render( - , + , ) }).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(), } diff --git a/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx b/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx index 6477928e437..3cf0c9f285f 100644 --- a/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx @@ -247,7 +247,7 @@ describe('Apps', () => { }) it('renders template cards when data is available', () => { - render() + render() 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() + render() 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() + render() 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() + render() expect(screen.getAllByTestId('app-card')[0]).toHaveAttribute('data-can-create', 'false') }) @@ -283,14 +283,14 @@ describe('Apps', () => { isLoading: false, }) - render() + render() 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() + render() 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() + render() 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() + render() 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() + render() 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() + render() fireEvent.click(screen.getByText('app.newApp.startFromBlank')) @@ -413,7 +413,7 @@ describe('Apps', () => { isLoading: true, }) - render() + render() expect(screen.getByRole('status'))!.toBeInTheDocument() }) @@ -424,13 +424,13 @@ describe('Apps', () => { isLoading: false, }) - render() + render() expect(screen.getByText('app.newApp.noTemplateFound'))!.toBeInTheDocument() }) it('should filter templates by category and the remaining app modes', async () => { - render() + render() fireEvent.click(screen.getByText('Cat C')) expect(screen.queryByText('Alpha')).not.toBeInTheDocument() @@ -473,7 +473,7 @@ describe('Apps', () => { isLoading: false, }) - render() + render() 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() + render() 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() + render() const searchInput = screen.getByRole('searchbox', { name: 'app.newAppFromTemplate.searchAllTemplate', diff --git a/web/app/components/app/create-app-dialog/app-list/index.tsx b/web/app/components/app/create-app-dialog/app-list/index.tsx index e0a0145dfad..cf4aa352e2b 100644 --- a/web/app/components/app/create-app-dialog/app-list/index.tsx +++ b/web/app/components/app/create-app-dialog/app-list/index.tsx @@ -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 }, diff --git a/web/app/components/app/create-app-dialog/index.tsx b/web/app/components/app/create-app-dialog/index.tsx index 73ece1e3fef..8916ebff77e 100644 --- a/web/app/components/app/create-app-dialog/index.tsx +++ b/web/app/components/app/create-app-dialog/index.tsx @@ -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} > - { - onSuccess() - onClose() - }} - /> + ) } diff --git a/web/app/components/app/create-app-modal/__tests__/index.spec.tsx b/web/app/components/app/create-app-modal/__tests__/index.spec.tsx index b8beadb4232..22b84de2b84 100644 --- a/web/app/components/app/create-app-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-modal/__tests__/index.spec.tsx @@ -125,18 +125,16 @@ const defaultPlanUsage = { const renderModal = () => { const onClose = vi.fn() - const onSuccess = vi.fn() const onCreateFromTemplate = vi.fn() render( , ) - 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', diff --git a/web/app/components/app/create-app-modal/index.tsx b/web/app/components/app/create-app-modal/index.tsx index fd0ad9311b6..9a03b7ffdfd 100644 --- a/web/app/components/app/create-app-modal/index.tsx +++ b/web/app/components/app/create-app-modal/index.tsx @@ -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 = ({ > diff --git a/web/app/components/app/overview/apikey-info-panel/__tests__/test-utils.tsx b/web/app/components/app/overview/apikey-info-panel/__tests__/test-utils.tsx index 23177654223..2fd88ee7ed2 100644 --- a/web/app/components/app/overview/apikey-info-panel/__tests__/test-utils.tsx +++ b/web/app/components/app/overview/apikey-info-panel/__tests__/test-utils.tsx @@ -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, diff --git a/web/app/components/apps/__tests__/app-card.spec.tsx b/web/app/components/apps/__tests__/app-card.spec.tsx index adb020b86b7..9304f1fc71c 100644 --- a/web/app/components/apps/__tests__/app-card.spec.tsx +++ b/web/app/components/apps/__tests__/app-card.spec.tsx @@ -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() - - 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')) diff --git a/web/app/components/apps/__tests__/list.spec.tsx b/web/app/components/apps/__tests__/list.spec.tsx index f1cf813ea31..fbc72fcf045 100644 --- a/web/app/components/apps/__tests__/list.spec.tsx +++ b/web/app/components/apps/__tests__/list.spec.tsx @@ -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( diff --git a/web/app/components/apps/app-card/interactions.tsx b/web/app/components/apps/app-card/interactions.tsx index 0521f0b0103..df7288420cf 100644 --- a/web/app/components/apps/app-card/interactions.tsx +++ b/web/app/components/apps/app-card/interactions.tsx @@ -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, diff --git a/web/app/components/apps/app-list-creation-modals.tsx b/web/app/components/apps/app-list-creation-modals.tsx index d436c595a84..be434c48b24 100644 --- a/web/app/components/apps/app-list-creation-modals.tsx +++ b/web/app/components/apps/app-list-creation-modals.tsx @@ -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' && ( - { - onClose() - onPlanInfoChanged() - }} - droppedFile={dialog.droppedFile} - /> + )} {dialog?.type === 'blank' && ( )} {dialog?.type === 'template' && ( - + )} ) diff --git a/web/app/components/datasets/settings/form/__tests__/index.spec.tsx b/web/app/components/datasets/settings/form/__tests__/index.spec.tsx index 345b3c595e0..09aebd507cc 100644 --- a/web/app/components/datasets/settings/form/__tests__/index.spec.tsx +++ b/web/app/components/datasets/settings/form/__tests__/index.spec.tsx @@ -218,7 +218,6 @@ vi.mock('@/context/provider-context', () => ({ hasSettedApiKey: true, plan: { type: 'free' }, enableBilling: false, - onPlanInfoChanged: vi.fn(), }), })) diff --git a/web/context/provider-context-provider.tsx b/web/context/provider-context-provider.tsx index 6d7c8850dbd..f8c2939e864 100644 --- a/web/context/provider-context-provider.tsx +++ b/web/context/provider-context-provider.tsx @@ -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, diff --git a/web/context/provider-context.ts b/web/context/provider-context.ts index 54f0fc22e8f..ad53a5b4f38 100644 --- a/web/context/provider-context.ts +++ b/web/context/provider-context.ts @@ -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, diff --git a/web/service/apps.ts b/web/service/apps.ts index b62905cf661..23098ccc569 100644 --- a/web/service/apps.ts +++ b/web/service/apps.ts @@ -85,28 +85,6 @@ export const updateAppInfo = ({ return put(`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 => { - return post(`apps/${appID}/copy`, { - body: { name, icon_type, icon, icon_background, mode, description }, - }) -} - export const exportAppConfig = ({ appID, include = false, diff --git a/web/service/client.spec.ts b/web/service/client.spec.ts index 9c0ca738bdb..a3de7a87198 100644 --- a/web/service/client.spec.ts +++ b/web/service/client.spec.ts @@ -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((resolve) => { + const invalidateQueries = vi + .spyOn(queryClient, 'invalidateQueries') + .mockImplementation((filters) => { + if (JSON.stringify(filters?.queryKey) === JSON.stringify(consoleQuery.features.get.key())) + return new Promise(() => {}) + + return new Promise((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 diff --git a/web/service/client.ts b/web/service/client.ts index 41ced571a8e..98913ca7061 100644 --- a/web/service/client.ts +++ b/web/service/client.ts @@ -583,6 +583,7 @@ export const consoleQuery: RouterUtils = 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 = 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 = 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 = 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 = createTanstackQue context.client.invalidateQueries({ queryKey: consoleQuery.apps.recent.get.key(), }), - ]), + ]) + }, }, }, put: { @@ -732,7 +743,12 @@ export const consoleQuery: RouterUtils = 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(),