diff --git a/lint.config.ts b/lint.config.ts index 08075af3891..d071b9e44f1 100644 --- a/lint.config.ts +++ b/lint.config.ts @@ -481,7 +481,6 @@ export const lintConfig = { 'vars-on-top': 'error', yoda: ['error', 'never'], 'unicorn/no-abusive-eslint-disable': 'error', - 'dify/no-app-store-imports': 'error', 'dify/no-file-wide-disable': 'error', 'dify/require-disable-directive-description': 'error', 'dify/require-i18n-namespace': 'error', diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 658d551bd6d..5617d5905b2 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -57,16 +57,6 @@ "count": 3 } }, - "web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx": { - "dify/no-app-store-imports": { - "count": 1 - } - }, - "web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx": { - "dify/no-app-store-imports": { - "count": 1 - } - }, "web/app/(shareLayout)/webapp-reset-password/layout.tsx": { "typescript/no-explicit-any": { "count": 1 @@ -80,11 +70,6 @@ "count": 2 } }, - "web/app/components/app/__tests__/app-detail-loading.spec.tsx": { - "dify/no-app-store-imports": { - "count": 1 - } - }, "web/app/components/app/annotation/__tests__/index.spec.tsx": { "typescript/no-explicit-any": { "count": 7 @@ -238,18 +223,10 @@ } }, "web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx": { - "dify/no-app-store-imports": { - "count": 1 - }, "typescript/no-explicit-any": { "count": 3 } }, - "web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx": { - "dify/no-app-store-imports": { - "count": 1 - } - }, "web/app/components/app/configuration/config-var/config-modal/__tests__/type-select.spec.tsx": { "typescript/no-explicit-any": { "count": 2 @@ -261,9 +238,6 @@ } }, "web/app/components/app/configuration/config-var/config-modal/index.tsx": { - "dify/no-app-store-imports": { - "count": 1 - }, "typescript/no-explicit-any": { "count": 4 } @@ -450,11 +424,6 @@ "count": 3 } }, - "web/app/components/app/configuration/hooks/configuration-lifecycle/use-configuration-app-context.ts": { - "dify/no-app-store-imports": { - "count": 1 - } - }, "web/app/components/app/configuration/prompt-value-panel/__tests__/index.spec.tsx": { "typescript/no-explicit-any": { "count": 2 @@ -470,16 +439,6 @@ "count": 2 } }, - "web/app/components/app/deploy/built-in-environment-card/index.tsx": { - "dify/no-app-store-imports": { - "count": 1 - } - }, - "web/app/components/app/deploy/index.tsx": { - "dify/no-app-store-imports": { - "count": 1 - } - }, "web/app/components/app/in-site-message/__tests__/notification.spec.tsx": { "@tanstack/query/prefer-query-options": { "count": 1 @@ -1853,11 +1812,6 @@ "count": 2 } }, - "web/app/components/goto-anything/actions/commands/use-command-context.ts": { - "dify/no-app-store-imports": { - "count": 1 - } - }, "web/app/components/header/account-setting/data-source-page-new/hooks/index.ts": { "no-barrel-files/no-barrel-files": { "count": 2 @@ -1967,21 +1921,11 @@ "count": 1 } }, - "web/app/components/main-nav/__tests__/layout.spec.tsx": { - "dify/no-app-store-imports": { - "count": 1 - } - }, "web/app/components/main-nav/components/workspace-switcher.tsx": { "jsx-a11y/no-autofocus": { "count": 1 } }, - "web/app/components/main-nav/layout.tsx": { - "dify/no-app-store-imports": { - "count": 1 - } - }, "web/app/components/plugins/install-plugin/hooks.ts": { "typescript/no-explicit-any": { "count": 4 @@ -2397,71 +2341,21 @@ "count": 4 } }, - "web/app/components/workflow-app/__tests__/provider-contract.spec.tsx": { - "dify/no-app-store-imports": { - "count": 1 - } - }, "web/app/components/workflow-app/components/__tests__/workflow-children.spec.tsx": { "eslint-react/static-components": { "count": 2 } }, - "web/app/components/workflow-app/components/__tests__/workflow-main.spec.tsx": { - "dify/no-app-store-imports": { - "count": 1 - } - }, "web/app/components/workflow-app/components/workflow-children.tsx": { "typescript/no-explicit-any": { "count": 2 } }, - "web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx": { - "dify/no-app-store-imports": { - "count": 1 - } - }, - "web/app/components/workflow-app/components/workflow-header/features-trigger.tsx": { - "dify/no-app-store-imports": { - "count": 1 - } - }, - "web/app/components/workflow-app/components/workflow-header/index.tsx": { - "dify/no-app-store-imports": { - "count": 1 - } - }, - "web/app/components/workflow-app/components/workflow-main.tsx": { - "dify/no-app-store-imports": { - "count": 1 - } - }, - "web/app/components/workflow-app/components/workflow-panel.tsx": { - "dify/no-app-store-imports": { - "count": 1 - } - }, - "web/app/components/workflow-app/hooks/use-is-chat-mode.ts": { - "dify/no-app-store-imports": { - "count": 1 - } - }, "web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts": { "no-restricted-imports": { "count": 1 } }, - "web/app/components/workflow-app/hooks/use-workflow-init.ts": { - "dify/no-app-store-imports": { - "count": 1 - } - }, - "web/app/components/workflow-app/hooks/use-workflow-refresh-draft.ts": { - "dify/no-app-store-imports": { - "count": 1 - } - }, "web/app/components/workflow-app/hooks/use-workflow-run-callbacks.ts": { "no-restricted-imports": { "count": 2 @@ -2473,26 +2367,15 @@ } }, "web/app/components/workflow-app/hooks/use-workflow-run.ts": { - "dify/no-app-store-imports": { - "count": 1 - }, "no-restricted-imports": { "count": 2 } }, "web/app/components/workflow-app/hooks/use-workflow-template.ts": { - "dify/no-app-store-imports": { - "count": 1 - }, "typescript/no-explicit-any": { "count": 2 } }, - "web/app/components/workflow-app/index.tsx": { - "dify/no-app-store-imports": { - "count": 1 - } - }, "web/app/components/workflow-app/store/workflow/workflow-slice.ts": { "typescript/no-explicit-any": { "count": 2 @@ -2590,9 +2473,6 @@ } }, "web/app/components/workflow/hooks/use-checklist.ts": { - "dify/no-app-store-imports": { - "count": 1 - }, "typescript/no-empty-object-type": { "count": 1 } @@ -2870,9 +2750,6 @@ } }, "web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx": { - "dify/no-app-store-imports": { - "count": 1 - }, "eslint-react/set-state-in-effect": { "count": 2 }, @@ -2930,15 +2807,7 @@ }, "web/app/components/workflow/nodes/agent-v2/__tests__/hooks.spec.tsx": { "@tanstack/query/prefer-query-options": { - "count": 6 - }, - "dify/no-app-store-imports": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-orchestrate-panel-content.spec.tsx": { - "dify/no-app-store-imports": { - "count": 1 + "count": 3 } }, "web/app/components/workflow/nodes/agent/__tests__/integration.spec.tsx": { @@ -3053,11 +2922,6 @@ "count": 5 } }, - "web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/test-email-sender.spec.tsx": { - "dify/no-app-store-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/human-input/components/delivery-method/method-selector.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 2 @@ -3088,9 +2952,6 @@ } }, "web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx": { - "dify/no-app-store-imports": { - "count": 1 - }, "jsx-a11y/no-autofocus": { "count": 1 } @@ -3100,11 +2961,6 @@ "count": 2 } }, - "web/app/components/workflow/nodes/human-input/hooks/use-single-run-form-params.ts": { - "dify/no-app-store-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/if-else/components/condition-list/condition-input.tsx": { "typescript/no-explicit-any": { "count": 1 @@ -4080,11 +3936,6 @@ "count": 1 } }, - "web/features/agent-v2/analytics.ts": { - "dify/no-app-store-imports": { - "count": 1 - } - }, "web/features/skills/client.ts": { "dify/require-disable-directive-description": { "count": 2 @@ -4572,4 +4423,4 @@ "count": 2 } } -} \ No newline at end of file +} diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx index 9cfacbf5f6b..12f6dc08fa0 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx @@ -2,15 +2,17 @@ import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.g import { useSuspenseQuery } from '@tanstack/react-query' import { act, screen, waitFor } from '@testing-library/react' import { Suspense } from 'react' -import { useStore } from '@/app/components/app/store' import { consoleQuery } from '@/service/console' -import { renderWithConsoleQuery } from '@/test/console/query-data' +import { + createConsoleQueryClient, + renderWithConsoleQuery, + seedAppDetail, +} from '@/test/console/query-data' import { createAppDetailFixture } from '@/test/fixtures/app' import { AppModeEnum } from '@/types/app' import { AppACLPermission } from '@/utils/permission' import AppDetailLayout from '../layout-main' -const mockReplace = vi.fn() const mockAppResponse = vi.fn() let mockPathname = '/app/app-1/workflow' let mockIsRbacEnabled = true @@ -23,15 +25,25 @@ const mockConsoleState = vi.hoisted(() => ({ })) const mockNavigation = vi.hoisted(() => ({ usePathname: vi.fn(), - useRouter: vi.fn(), + // Like Next.js, a redirect stops rendering the current route. + redirect: vi.fn((): never => { + throw new Promise(() => {}) + }), })) -const render = (ui: Parameters[0]) => - renderWithConsoleQuery(ui, { +const render = ( + ui: Parameters[0], + { cachedAppDetail }: { cachedAppDetail?: AppDetailWithSite } = {}, +) => { + const queryClient = createConsoleQueryClient() + if (cachedAppDetail) seedAppDetail(queryClient, cachedAppDetail) + return renderWithConsoleQuery(ui, { + queryClient, systemFeatures: { rbac_enabled: mockIsRbacEnabled, }, }) +} vi.mock('@/next/navigation', () => mockNavigation) @@ -45,7 +57,7 @@ vi.mock('@/context/permission-state', async () => { }) const mockUsePathname = mockNavigation.usePathname -const mockUseRouter = mockNavigation.useRouter +const mockRedirect = mockNavigation.redirect const createAppDetail = (overrides: Partial = {}) => createAppDetailFixture({ @@ -74,15 +86,11 @@ describe('AppDetailLayout', () => { mockConsoleState.userProfile = { id: 'user-1' } mockConsoleState.workspacePermissionKeys = [] mockUsePathname.mockImplementation(() => mockPathname) - mockUseRouter.mockReturnValue({ - replace: mockReplace, - }) mockAppResponse.mockResolvedValue(createAppDetail()) vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { const response = await mockAppResponse(input instanceof Request ? input.url : String(input)) return response instanceof Response ? response : Response.json(response) }) - useStore.getState().setAppDetail() }) it('shares the app detail request with the overview observer', async () => { @@ -204,9 +212,8 @@ describe('AppDetailLayout', () => { , ) - await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/apps')) + await waitFor(() => expect(mockRedirect).toHaveBeenCalledWith('/apps')) expect(screen.queryByText('App page content')).not.toBeInTheDocument() - expect(useStore.getState().appDetail).toBeUndefined() }) it('should keep app detail data when navigating between pages in the same app', async () => { @@ -219,16 +226,15 @@ describe('AppDetailLayout', () => { expect(mockAppResponse).toHaveBeenCalledWith(expect.stringMatching(/\/apps\/app-1$/)) expect(mockAppResponse).toHaveBeenCalled() - mockPathname = '/app/app-1/logs' + mockPathname = '/app/app-1/overview' rerender(
App page content
, ) - await waitForAppContent() - expect(mockAppResponse).toHaveBeenCalled() - expect(useStore.getState().appDetail?.id).toBe('app-1') + expect(screen.getByText('App page content')).toBeInTheDocument() + expect(mockAppResponse).toHaveBeenCalledTimes(1) unmount() render( @@ -239,7 +245,18 @@ describe('AppDetailLayout', () => { await waitForAppContent() expect(mockAppResponse).toHaveBeenCalled() - expect(useStore.getState().appDetail?.id).toBe('app-1') + }) + + it('renders cached app detail content on the first render without a loading frame', () => { + render( + +
App page content
+
, + { cachedAppDetail: createAppDetail() }, + ) + + expect(screen.getByText('App page content')).toBeInTheDocument() + expect(mockRedirect).not.toHaveBeenCalled() }) it('should render app detail content without owning the main skip target', async () => { @@ -267,10 +284,9 @@ describe('AppDetailLayout', () => { ) await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith('/app/app-1/workflow') + expect(mockRedirect).toHaveBeenCalledWith('/app/app-1/workflow') }) expect(screen.queryByText('App page content')).not.toBeInTheDocument() - expect(useStore.getState().appDetail).toBeUndefined() }) it('should redirect logs pages when log and annotation access is missing', async () => { @@ -286,10 +302,9 @@ describe('AppDetailLayout', () => { ) await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith('/app/app-1/overview') + expect(mockRedirect).toHaveBeenCalledWith('/app/app-1/overview') }) expect(screen.queryByText('App page content')).not.toBeInTheDocument() - expect(useStore.getState().appDetail).toBeUndefined() }) it('should allow users with log and annotation access to open logs directly', async () => { @@ -306,8 +321,7 @@ describe('AppDetailLayout', () => { await waitForAppContent() - expect(mockReplace).not.toHaveBeenCalled() - expect(useStore.getState().appDetail?.id).toBe('app-1') + expect(mockRedirect).not.toHaveBeenCalled() }) it('should allow users with Access Point view permission to open the page directly', async () => { @@ -324,8 +338,7 @@ describe('AppDetailLayout', () => { await waitForAppContent() - expect(mockReplace).not.toHaveBeenCalled() - expect(useStore.getState().appDetail?.id).toBe('app-1') + expect(mockRedirect).not.toHaveBeenCalled() }) it('should redirect access point pages when view permission is missing', async () => { @@ -339,25 +352,24 @@ describe('AppDetailLayout', () => { ) await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith('/apps') + expect(mockRedirect).toHaveBeenCalledWith('/apps') }) expect(screen.queryByText('App page content')).not.toBeInTheDocument() - expect(useStore.getState().appDetail).toBeUndefined() }) it('should keep cached Access Point content hidden while redirecting without view permission', async () => { mockPathname = '/app/app-1/access-point' - useStore.getState().setAppDetail(createAppDetail({ permission_keys: [] })) render(
App page content
, + { cachedAppDetail: createAppDetail({ permission_keys: [] }) }, ) expect(screen.queryByText('App page content')).not.toBeInTheDocument() await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith('/apps') + expect(mockRedirect).toHaveBeenCalledWith('/apps') }) }) @@ -374,10 +386,9 @@ describe('AppDetailLayout', () => { ) await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith('/app/app-1/workflow') + expect(mockRedirect).toHaveBeenCalledWith('/app/app-1/workflow') }) expect(screen.queryByText('App page content')).not.toBeInTheDocument() - expect(useStore.getState().appDetail).toBeUndefined() }) it('should allow users with app deploy ACL permission to open deploy directly', async () => { @@ -394,8 +405,7 @@ describe('AppDetailLayout', () => { await waitForAppContent() - expect(mockReplace).not.toHaveBeenCalled() - expect(useStore.getState().appDetail?.id).toBe('app-1') + expect(mockRedirect).not.toHaveBeenCalled() }) it('should allow users with layout access to open workflow pages directly', async () => { @@ -409,8 +419,7 @@ describe('AppDetailLayout', () => { await waitForAppContent() - expect(mockReplace).not.toHaveBeenCalledWith('/app/app-1/overview') - expect(useStore.getState().appDetail?.id).toBe('app-1') + expect(mockRedirect).not.toHaveBeenCalledWith('/app/app-1/overview') }) it('should redirect workflow pages when layout access is missing', async () => { @@ -424,10 +433,9 @@ describe('AppDetailLayout', () => { ) await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith('/apps') + expect(mockRedirect).toHaveBeenCalledWith('/apps') }) expect(screen.queryByText('App page content')).not.toBeInTheDocument() - expect(useStore.getState().appDetail).toBeUndefined() }) it('should redirect overview pages when monitor access is missing', async () => { @@ -443,10 +451,9 @@ describe('AppDetailLayout', () => { ) await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith('/app/app-1/workflow') + expect(mockRedirect).toHaveBeenCalledWith('/app/app-1/workflow') }) expect(screen.queryByText('App page content')).not.toBeInTheDocument() - expect(useStore.getState().appDetail).toBeUndefined() }) it('should wait for workspace permission keys before redirecting restricted pages', async () => { @@ -465,7 +472,7 @@ describe('AppDetailLayout', () => { await waitFor(() => { expect(mockAppResponse).toHaveBeenCalled() }) - expect(mockReplace).not.toHaveBeenCalled() + expect(mockRedirect).not.toHaveBeenCalled() expect(screen.queryByText('App page content')).not.toBeInTheDocument() mockConsoleState.isLoadingWorkspacePermissionKeys = false @@ -476,7 +483,7 @@ describe('AppDetailLayout', () => { ) await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith('/app/app-1/workflow') + expect(mockRedirect).toHaveBeenCalledWith('/app/app-1/workflow') }) }) @@ -494,8 +501,7 @@ describe('AppDetailLayout', () => { await waitForAppContent() - expect(mockReplace).not.toHaveBeenCalled() - expect(useStore.getState().appDetail?.id).toBe('app-1') + expect(mockRedirect).not.toHaveBeenCalled() }) it('should redirect access config pages when access config access is missing', async () => { @@ -511,10 +517,9 @@ describe('AppDetailLayout', () => { ) await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith('/app/app-1/workflow') + expect(mockRedirect).toHaveBeenCalledWith('/app/app-1/workflow') }) expect(screen.queryByText('App page content')).not.toBeInTheDocument() - expect(useStore.getState().appDetail).toBeUndefined() }) it('should allow users with access config access to open access config directly', async () => { @@ -531,8 +536,7 @@ describe('AppDetailLayout', () => { await waitForAppContent() - expect(mockReplace).not.toHaveBeenCalled() - expect(useStore.getState().appDetail?.id).toBe('app-1') + expect(mockRedirect).not.toHaveBeenCalled() }) it('should redirect Agent app access config URLs to the Agent configure page', async () => { @@ -552,31 +556,30 @@ describe('AppDetailLayout', () => { ) await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith('/agents/agent-1/configure') + expect(mockRedirect).toHaveBeenCalledWith('/agents/agent-1/configure') }) expect(screen.queryByText('App page content')).not.toBeInTheDocument() - expect(useStore.getState().appDetail).toBeUndefined() }) it('should keep Agent app access config content hidden while redirecting cached app data', async () => { mockPathname = '/app/app-1/access-config' - useStore.getState().setAppDetail( - createAppDetail({ - mode: AppModeEnum.AGENT, - bound_agent_id: 'agent-1', - permission_keys: [AppACLPermission.AccessConfig], - }), - ) render(
App page content
, + { + cachedAppDetail: createAppDetail({ + mode: AppModeEnum.AGENT, + bound_agent_id: 'agent-1', + permission_keys: [AppACLPermission.AccessConfig], + }), + }, ) expect(screen.queryByText('App page content')).not.toBeInTheDocument() await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith('/agents/agent-1/configure') + expect(mockRedirect).toHaveBeenCalledWith('/agents/agent-1/configure') }) }) @@ -596,10 +599,9 @@ describe('AppDetailLayout', () => { ) await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith('/app/app-1/access-point') + expect(mockRedirect).toHaveBeenCalledWith('/app/app-1/access-point') }) expect(screen.queryByText('App page content')).not.toBeInTheDocument() - expect(useStore.getState().appDetail).toBeUndefined() }) it('should redirect annotation pages when log and annotation access is missing', async () => { @@ -618,10 +620,9 @@ describe('AppDetailLayout', () => { ) await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith('/app/app-1/overview') + expect(mockRedirect).toHaveBeenCalledWith('/app/app-1/overview') }) expect(screen.queryByText('App page content')).not.toBeInTheDocument() - expect(useStore.getState().appDetail).toBeUndefined() }) it('should allow users with log and annotation access to open annotations directly', async () => { @@ -641,7 +642,6 @@ describe('AppDetailLayout', () => { await waitForAppContent() - expect(mockReplace).not.toHaveBeenCalled() - expect(useStore.getState().appDetail?.id).toBe('app-1') + expect(mockRedirect).not.toHaveBeenCalled() }) }) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx index 546091df0f8..f582fbe49fc 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx @@ -1,14 +1,13 @@ 'use client' +import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' import type { FC } from 'react' +import type { ResourceMaintainerPermissionOptions } from '@/utils/permission' import { cn } from '@langgenius/dify-ui/cn' import { useQuery, useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import * as React from 'react' -import { useEffect } from 'react' import { useTranslation } from 'react-i18next' -import { useShallow } from 'zustand/react/shallow' -import { useStore } from '@/app/components/app/store' import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { workspacePermissionKeysAtom, @@ -18,7 +17,7 @@ import { currentWorkspaceAtom, currentWorkspaceLoadingAtom } from '@/context/wor import { userProfileQueryOptions } from '@/features/account-profile/client' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import useDocumentTitle from '@/hooks/use-document-title' -import { usePathname, useRouter } from '@/next/navigation' +import { redirect, usePathname } from '@/next/navigation' import { consoleQuery } from '@/service/console' import { isNotFoundError } from '@/service/http-error' import { AppModeEnum } from '@/types/app' @@ -46,13 +45,42 @@ const appDetailPageTitle = (pathname: string, t: ReturnType $['menus.appDetail'], { ns: 'navigation' }) } +const getAppDetailRedirectPath = ( + appDetail: AppDetailWithSite, + pathname: string, + permissionOptions: ResourceMaintainerPermissionOptions, +) => { + const appACLCapabilities = getAppACLCapabilities(appDetail.permission_keys, permissionOptions) + const isWorkflowApp = + appDetail.mode === AppModeEnum.WORKFLOW || appDetail.mode === AppModeEnum.ADVANCED_CHAT + const isLayoutPath = pathname.endsWith('configuration') || pathname.endsWith('workflow') + const isLogsPath = pathname.endsWith('logs') + const isAnnotationsPath = pathname.endsWith('annotations') + const isOverviewPath = pathname.endsWith('overview') + const isAccessConfigPath = pathname.endsWith('access-config') + const isAccessPointPath = pathname.endsWith('access-point') + const isDeployPath = pathname.endsWith('deploy') + if ( + (isLayoutPath && !appACLCapabilities.canAccessLayout) || + (isLogsPath && !appACLCapabilities.canAccessLogAndAnnotation) || + (isAnnotationsPath && !appACLCapabilities.canAccessLogAndAnnotation) || + (isOverviewPath && !appACLCapabilities.canMonitor) || + (isAccessConfigPath && + (appDetail.mode === AppModeEnum.AGENT || !appACLCapabilities.canAccessConfig)) || + (isAccessPointPath && !appACLCapabilities.canViewAccessPoint) || + (isDeployPath && (!isWorkflowApp || !appACLCapabilities.canDeploy)) + ) + return getRedirectionPath(appDetail, permissionOptions) + + return undefined +} + const AppDetailLayout: FC = (props) => { const { children, appId, // get appId in path } = props const { t } = useTranslation(['common', 'navigation']) - const router = useRouter() const pathname = usePathname() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const isLoadingCurrentWorkspace = useAtomValue(currentWorkspaceLoadingAtom) @@ -64,133 +92,38 @@ const AppDetailLayout: FC = (props) => { }) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const isRbacEnabled = systemFeatures.rbac_enabled - const { appDetail, setAppDetail } = useStore( - useShallow((state) => ({ - appDetail: state.appDetail, - setAppDetail: state.setAppDetail, - })), - ) const pageTitle = appDetailPageTitle(pathname, t) - const { - data: appDetailRes, - error: appDetailError, - isPending: isLoadingAppDetail, - } = useQuery( + // Not useSuspenseQuery: a missing app has to stay a returned error so it can redirect to /apps. + const { data: appDetail, error: appDetailError } = useQuery( consoleQuery.apps.byAppId.get.queryOptions({ input: { params: { app_id: appId } }, }), ) - const routeAppDetail = appDetail?.id === appId ? appDetail : appDetailRes - const shouldBlockAgentResourceAccess = - routeAppDetail?.mode === AppModeEnum.AGENT && pathname.endsWith('/access-config') - const canViewAccessPoint = - routeAppDetail?.id === appId && - currentWorkspace.id && - !isLoadingCurrentWorkspace && - !isLoadingWorkspacePermissionKeys && - !isLoadingAppDetail - ? getAppACLCapabilities(routeAppDetail.permission_keys, { + const isPermissionContextReady = + !!currentWorkspace.id && !isLoadingCurrentWorkspace && !isLoadingWorkspacePermissionKeys + const redirectPath = + appDetail && isPermissionContextReady + ? getAppDetailRedirectPath(appDetail, pathname, { currentUserId, - resourceMaintainer: routeAppDetail.maintainer, + resourceMaintainer: appDetail.maintainer, workspacePermissionKeys, isRbacEnabled, - }).canViewAccessPoint - : false - const shouldBlockAccessPointAccess = pathname.endsWith('/access-point') && !canViewAccessPoint + }) + : undefined useDocumentTitle( - `${pageTitle} · ${appDetailRes?.name || t(($) => $['menus.appDetail'], { ns: 'navigation' })}`, + `${pageTitle} · ${appDetail?.name || t(($) => $['menus.appDetail'], { ns: 'navigation' })}`, ) - useEffect(() => { - if (appDetailError && !appDetailRes && isNotFoundError(appDetailError)) router.replace('/apps') - }, [appDetailError, appDetailRes, router]) - - useEffect(() => { - if ( - !routeAppDetail || - !currentWorkspace.id || - isLoadingCurrentWorkspace || - isLoadingWorkspacePermissionKeys || - isLoadingAppDetail - ) - return - if (routeAppDetail.id !== appId) return - - const appACLCapabilities = getAppACLCapabilities(routeAppDetail.permission_keys, { - currentUserId, - resourceMaintainer: routeAppDetail.maintainer, - workspacePermissionKeys, - isRbacEnabled, - }) - const isLayoutPath = pathname.endsWith('configuration') || pathname.endsWith('workflow') - const isLogsPath = pathname.endsWith('logs') - const isAnnotationsPath = pathname.endsWith('annotations') - const isOverviewPath = pathname.endsWith('overview') - const isAccessConfigPath = pathname.endsWith('access-config') - const isAccessPointPath = pathname.endsWith('access-point') - const isDeployPath = pathname.endsWith('deploy') - if ( - (isLayoutPath && !appACLCapabilities.canAccessLayout) || - (isLogsPath && !appACLCapabilities.canAccessLogAndAnnotation) || - (isAnnotationsPath && !appACLCapabilities.canAccessLogAndAnnotation) || - (isOverviewPath && !appACLCapabilities.canMonitor) || - (isAccessConfigPath && - (routeAppDetail.mode === AppModeEnum.AGENT || !appACLCapabilities.canAccessConfig)) || - (isAccessPointPath && !appACLCapabilities.canViewAccessPoint) || - (isDeployPath && - ((routeAppDetail.mode !== AppModeEnum.WORKFLOW && - routeAppDetail.mode !== AppModeEnum.ADVANCED_CHAT) || - !appACLCapabilities.canDeploy)) - ) { - router.replace( - getRedirectionPath(routeAppDetail, { - currentUserId, - resourceMaintainer: routeAppDetail.maintainer, - workspacePermissionKeys, - isRbacEnabled, - }), - ) - return - } - if ( - (routeAppDetail.mode === AppModeEnum.WORKFLOW || - routeAppDetail.mode === AppModeEnum.ADVANCED_CHAT) && - pathname.endsWith('configuration') - ) { - router.replace(`/app/${appId}/workflow`) - } else if ( - routeAppDetail.mode !== AppModeEnum.WORKFLOW && - routeAppDetail.mode !== AppModeEnum.ADVANCED_CHAT && - pathname.endsWith('workflow') - ) { - router.replace(`/app/${appId}/configuration`) - return - } - - if (appDetailRes && appDetail?.id !== appDetailRes.id) setAppDetail(appDetailRes) - }, [ - appDetail?.id, - appDetailRes, - appId, - currentUserId, - currentWorkspace.id, - isLoadingAppDetail, - isLoadingCurrentWorkspace, - isLoadingWorkspacePermissionKeys, - isRbacEnabled, - pathname, - routeAppDetail, - router, - setAppDetail, - workspacePermissionKeys, - ]) - - if (appDetailError && !appDetailRes && !isNotFoundError(appDetailError)) throw appDetailError + if (appDetailError && !appDetail) { + if (isNotFoundError(appDetailError)) redirect('/apps') + throw appDetailError + } + if (redirectPath) redirect(redirectPath) const isWorkflowPage = pathname.endsWith('/workflow') const content = - appDetail?.id !== appId || shouldBlockAgentResourceAccess || shouldBlockAccessPointAccess ? ( + !appDetail || !isPermissionContextReady ? (
diff --git a/web/app/components/app/__tests__/app-detail-loading.spec.tsx b/web/app/components/app/__tests__/app-detail-loading.spec.tsx index 5a968a7315d..288bbe9df11 100644 --- a/web/app/components/app/__tests__/app-detail-loading.spec.tsx +++ b/web/app/components/app/__tests__/app-detail-loading.spec.tsx @@ -2,7 +2,6 @@ import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.g import { act, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import AppDetailSection from '@/app/components/app-sidebar/app-detail-section' -import { useStore } from '@/app/components/app/store' import { consoleQuery } from '@/service/console' import { renderWithConsoleQuery } from '@/test/console/query-data' import { createAppDetailFixture } from '@/test/fixtures/app' @@ -55,7 +54,7 @@ describe('App sidebar route loading', () => { request.mockReset() }) - it('keeps the shell visible and consumes the in-flight prefetch without an App Store producer', async () => { + it('keeps the shell visible and consumes the in-flight prefetch from the query cache alone', async () => { const pending = deferredResponse() request.mockReturnValue(pending.promise) renderWithConsoleQuery() @@ -72,7 +71,6 @@ describe('App sidebar route loading', () => { '/app/app-1/overview', ) expect(request).toHaveBeenCalledTimes(1) - expect(useStore.getState().appDetail).toBeUndefined() }) it('can load without prefetch and retries a failed query inside the sidebar', async () => { diff --git a/web/app/components/app/configuration/config-var/__tests__/index.spec.tsx b/web/app/components/app/configuration/config-var/__tests__/index.spec.tsx index 399f7673672..03238490d00 100644 --- a/web/app/components/app/configuration/config-var/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/config-var/__tests__/index.spec.tsx @@ -23,6 +23,11 @@ type SubscriptionEvent = { let subscriptionCallback: ((event: SubscriptionEvent) => void) | null = null +vi.mock('@/next/navigation', async (importOriginal) => ({ + ...(await importOriginal()), + useParams: () => ({}), +})) + vi.mock('@/context/event-emitter', () => ({ useEventEmitterContextContext: () => ({ eventEmitter: { diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx index aa85915e72b..b56a1cf9504 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx @@ -2,14 +2,22 @@ import type { InputVar } from '@/app/components/workflow/types' import { fireEvent, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { toast } from '@/app/components/app/configuration/toast' -import { useStore } from '@/app/components/app/store' import { InputVarType } from '@/app/components/workflow/types' import DebugConfigurationContext from '@/context/debug-configuration' -import { renderWithConsoleQuery as render } from '@/test/console/query-data' +import { + createConsoleQueryClient, + renderWithConsoleQuery, + seedAppDetail, +} from '@/test/console/query-data' import { createAppDetailFixture } from '@/test/fixtures/app' import { AppModeEnum } from '@/types/app' import ConfigModal from '../index' +vi.mock('@/next/navigation', async (importOriginal) => ({ + ...(await importOriginal()), + useParams: () => ({ appId: 'app-1' }), +})) + const toastErrorSpy = vi.spyOn(toast, 'error').mockReturnValue('toast-error') let latestFormProps: Record | null = null vi.mock('../form-fields', () => ({ @@ -106,8 +114,10 @@ const createPayload = (overrides: Partial = {}): InputVar => ({ ...overrides, }) -const renderConfigModal = (payload: InputVar = createPayload()) => - render( +const renderConfigModal = (payload: InputVar = createPayload()) => { + const queryClient = createConsoleQueryClient() + seedAppDetail(queryClient, createAppDetailFixture({ id: 'app-1', mode: AppModeEnum.CHAT })) + return renderWithConsoleQuery( > , + { queryClient }, ) +} describe('ConfigModal logic', () => { beforeEach(() => { vi.clearAllMocks() latestFormProps = null - useStore.setState({ - appDetail: createAppDetailFixture({ mode: AppModeEnum.CHAT }), - }) }) it('should surface validation errors from invalid variable name callbacks', async () => { diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx index 0464c2c28d3..07d7fbf2b73 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx @@ -3,17 +3,20 @@ import { fireEvent, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' import { toast } from '@/app/components/app/configuration/toast' -import { useStore } from '@/app/components/app/store' import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types' import { commonQueryKeys } from '@/service/use-common' -import { renderWithConsoleQuery as render } from '@/test/console/query-data' +import { + createConsoleQueryClient, + renderWithConsoleQuery, + seedAppDetail, +} from '@/test/console/query-data' import { createAppDetailFixture } from '@/test/fixtures/app' import { AppModeEnum, TransferMethod } from '@/types/app' import ConfigModal from '../index' -vi.mock('next/navigation', async () => ({ - ...(await vi.importActual('next/navigation')), - useParams: () => ({}), +vi.mock('@/next/navigation', async (importOriginal) => ({ + ...(await importOriginal()), + useParams: () => ({ appId: 'app-1' }), })) vi.mock('@/service/use-common', async () => ({ @@ -32,6 +35,12 @@ vi.mock('@monaco-editor/react', async () => ({ ), })) +const render = (ui: React.ReactElement) => { + const queryClient = createConsoleQueryClient() + seedAppDetail(queryClient, createAppDetailFixture({ id: 'app-1', mode: AppModeEnum.CHAT })) + return renderWithConsoleQuery(ui, { queryClient }) +} + const toastErrorSpy = vi.spyOn(toast, 'error').mockReturnValue('toast-error') const expectDescribedByLastError = (element: HTMLElement) => { @@ -55,9 +64,6 @@ const createPayload = (overrides: Partial = {}): InputVar => ({ describe('ConfigModal', () => { beforeEach(() => { vi.clearAllMocks() - useStore.setState({ - appDetail: createAppDetailFixture({ mode: AppModeEnum.CHAT }), - }) }) it('should copy the variable name into the label when the label is empty', () => { diff --git a/web/app/components/app/configuration/config-var/config-modal/index.tsx b/web/app/components/app/configuration/config-var/config-modal/index.tsx index c1c7c32dd45..8d585984a6c 100644 --- a/web/app/components/app/configuration/config-var/config-modal/index.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/index.tsx @@ -5,14 +5,15 @@ import type { ConfigModalValidationError } from './utils' import type { InputVar, InputVarType, MoreInfo } from '@/app/components/workflow/types' import type { FileUploadConfigResponse } from '@/models/common' import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' -import { useQueryClient } from '@tanstack/react-query' +import { skipToken, useQuery, useQueryClient } from '@tanstack/react-query' import * as React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' import { toast } from '@/app/components/app/configuration/toast' -import { useStore as useAppStore } from '@/app/components/app/store' import ConfigContext from '@/context/debug-configuration' +import { useParams } from '@/next/navigation' +import { consoleQuery } from '@/service/console' import { commonQueryKeys } from '@/service/use-common' import { AppModeEnum } from '@/types/app' import { @@ -62,7 +63,12 @@ const ConfigModal: FC = ({ const queryClient = useQueryClient() const { type, options, max_length } = tempPayload const modalRef = useRef(null) - const appDetail = useAppStore((state) => state.appDetail) + const { appId } = useParams<{ appId?: string }>() + const { data: appDetail } = useQuery( + consoleQuery.apps.byAppId.get.queryOptions({ + input: appId ? { params: { app_id: appId } } : skipToken, + }), + ) const isBasicApp = appDetail?.mode !== AppModeEnum.ADVANCED_CHAT && appDetail?.mode !== AppModeEnum.WORKFLOW const jsonSchemaStr = useMemo( diff --git a/web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx b/web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx index 619ad11c3a8..4067aaece94 100644 --- a/web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx +++ b/web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx @@ -1,3 +1,4 @@ +import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' import { act, screen, waitFor } from '@testing-library/react' import Cookies from 'js-cookie' import CommonLayoutError from '@/app/(commonLayout)/error' @@ -7,6 +8,7 @@ import { consoleClient, consoleQuery } from '@/service/console' import { createHttpError } from '@/service/http-error' import { seedAccountProfileQuery } from '@/test/console/account-profile' import { createQueryClientWrapper } from '@/test/console/query-client' +import { seedAppDetail } from '@/test/console/query-data' import { renderHook as renderHookWithConsoleState } from '@/test/console/render' import { createAppDetailFixture, createAppModelConfigFixture } from '@/test/fixtures/app' import { createTestQueryClient } from '@/test/query-client' @@ -14,9 +16,31 @@ import { AppModeEnum, ModelModeType } from '@/types/app' import { AppACLPermission } from '@/utils/permission' import { useConfiguration } from '../use-configuration' -const renderHook = (callback: () => ReturnType) => { +let mockAppPermissionKeys: string[] = [AppACLPermission.Edit, AppACLPermission.ReleaseAndVersion] + +const createRouteAppDetail = (overrides: Partial = {}) => + createAppDetailFixture({ + id: 'app-1', + model_config: createAppModelConfigFixture({ + updated_at: 1710000000, + }), + mode: AppModeEnum.CHAT, + permission_keys: mockAppPermissionKeys, + ...overrides, + }) + +const createSeededQueryClient = (appDetail: Partial = {}) => { const queryClient = createTestQueryClient() seedAccountProfileQuery(queryClient, { id: 'user-1' }) + seedAppDetail(queryClient, createRouteAppDetail(appDetail)) + return queryClient +} + +const renderHook = ( + callback: () => ReturnType, + appDetail?: Partial, +) => { + const queryClient = createSeededQueryClient(appDetail) return { ...renderHookWithConsoleState(callback, { wrapper: createQueryClientWrapper(queryClient), @@ -47,7 +71,6 @@ let latestAdvancedPromptConfigOptions: AdvancedPromptConfigOptions | undefined let mockTempStopState: string[] = [] let mockCurrentModelFeatures = ['vision'] let mockCurrentModelMode = ModelModeType.chat -let mockAppPermissionKeys: string[] = [AppACLPermission.Edit, AppACLPermission.ReleaseAndVersion] vi.mock('ahooks', async () => { const actual = await vi.importActual('ahooks') @@ -87,20 +110,6 @@ vi.mock('nuqs', async (importOriginal) => { return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] } }) -vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: Record) => unknown) => - selector({ - appDetail: { - id: 'app-1', - model_config: createAppModelConfigFixture({ - updated_at: 1710000000, - }), - mode: AppModeEnum.CHAT, - permission_keys: mockAppPermissionKeys, - }, - }), -})) - vi.mock('@/service/use-common', () => ({ useFileUploadConfig: () => ({ data: undefined, @@ -115,8 +124,10 @@ vi.mock('@/hooks/use-breakpoints', () => ({ }, })) -vi.mock('@/next/navigation', () => ({ +vi.mock('@/next/navigation', async (importOriginal) => ({ + ...(await importOriginal()), usePathname: () => '/app/app-1/configuration', + useParams: () => ({ appId: 'app-1' }), })) vi.mock('@/app/components/app/configuration/debug/hooks', () => ({ @@ -183,7 +194,15 @@ vi.mock('@/service/console', async () => { account: actual.consoleQuery.account, apps: { byAppId: { - get: actual.consoleQuery.apps.byAppId.get, + get: { + queryKey: actual.consoleQuery.apps.byAppId.get.queryKey, + queryOptions: ( + options: Parameters[0], + ) => ({ + ...actual.consoleQuery.apps.byAppId.get.queryOptions(options), + queryFn: async () => createRouteAppDetail(), + }), + }, modelConfig: { post: { mutationOptions: (options: Record) => ({ @@ -287,8 +306,7 @@ describe('useConfiguration', () => { it('should leave loading through the error boundary when the app has no model configuration', async () => { mockFetchAppDetailDirect.mockResolvedValueOnce(createAppDetailFixture({ model_config: null })) - const queryClient = createTestQueryClient() - seedAccountProfileQuery(queryClient, { id: 'user-1' }) + const queryClient = createSeededQueryClient() const QueryWrapper = createQueryClientWrapper(queryClient) const report = vi.spyOn(console, 'error').mockImplementation(() => {}) try { @@ -312,8 +330,7 @@ describe('useConfiguration', () => { it('should retain unauthorized ORPCError for the redirect loading fallback', async () => { const response = createHttpError(new Response(null, { status: 401 })) mockFetchCollectionList.mockRejectedValueOnce(response) - const queryClient = createTestQueryClient() - seedAccountProfileQuery(queryClient, { id: 'user-1' }) + const queryClient = createSeededQueryClient() const QueryWrapper = createQueryClientWrapper(queryClient) const onError = vi.fn() const report = vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -353,21 +370,16 @@ describe('useConfiguration', () => { }) it('should update model parameters and publish the current configuration', async () => { - const { result, queryClient } = renderHook(() => useConfiguration()) + const { result, queryClient } = renderHook(() => useConfiguration(), { + enable_api: false, + enable_site: false, + icon_url: null, + name: 'Cached app', + }) const detailQueryKey = consoleQuery.apps.byAppId.get.queryKey({ input: { params: { app_id: 'app-1' } }, }) - queryClient.setQueryData( - detailQueryKey, - createAppDetailFixture({ - enable_api: false, - enable_site: false, - icon_url: null, - id: 'app-1', - mode: 'chat', - name: 'Cached app', - }), - ) + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') await waitFor(() => { expect(result.current.showLoading).toBe(false) @@ -399,7 +411,7 @@ describe('useConfiguration', () => { params: { app_id: 'app-1' }, }), ) - expect(queryClient.getQueryState(detailQueryKey)?.isInvalidated).toBe(true) + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: detailQueryKey }) }) it('should publish and restore the complete configuration snapshot', async () => { diff --git a/web/app/components/app/configuration/hooks/configuration-lifecycle/use-configuration-app-context.ts b/web/app/components/app/configuration/hooks/configuration-lifecycle/use-configuration-app-context.ts index 14c00c79a84..70ddb91c0aa 100644 --- a/web/app/components/app/configuration/hooks/configuration-lifecycle/use-configuration-app-context.ts +++ b/web/app/components/app/configuration/hooks/configuration-lifecycle/use-configuration-app-context.ts @@ -1,11 +1,10 @@ import { useMutation, useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useMemo } from 'react' -import { useStore as useAppStore } from '@/app/components/app/store' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { currentWorkspaceAtom, currentWorkspaceLoadingAtom } from '@/context/workspace-state' import { userProfileQueryOptions } from '@/features/account-profile/client' -import { usePathname } from '@/next/navigation' +import { useParams } from '@/next/navigation' import { consoleQuery } from '@/service/console' import { getAppACLCapabilities } from '@/utils/permission' @@ -17,19 +16,19 @@ export function useConfigurationAppContext() { select: (data) => data.profile.id, }) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - const appDetail = useAppStore((state) => state.appDetail) - const pathname = usePathname() - const matched = /\/app\/([^/]+)/.exec(pathname) - const appId = matched?.[1] || '' - const serverLatestPublishedAt = useMemo(() => appDetail?.model_config?.updated_at, [appDetail]) + const { appId } = useParams<{ appId: string }>() + const { data: appDetail } = useSuspenseQuery( + consoleQuery.apps.byAppId.get.queryOptions({ input: { params: { app_id: appId } } }), + ) + const serverLatestPublishedAt = appDetail.model_config?.updated_at const appACLCapabilities = useMemo( () => - getAppACLCapabilities(appDetail?.permission_keys, { + getAppACLCapabilities(appDetail.permission_keys, { currentUserId, - resourceMaintainer: appDetail?.maintainer, + resourceMaintainer: appDetail.maintainer, workspacePermissionKeys, }), - [appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys], + [appDetail.maintainer, appDetail.permission_keys, currentUserId, workspacePermissionKeys], ) const { mutateAsync: updateModelConfig } = useMutation( consoleQuery.apps.byAppId.modelConfig.post.mutationOptions({ diff --git a/web/app/components/app/deploy/__tests__/index.spec.tsx b/web/app/components/app/deploy/__tests__/index.spec.tsx index 83c7d07f793..37b925cfc41 100644 --- a/web/app/components/app/deploy/__tests__/index.spec.tsx +++ b/web/app/components/app/deploy/__tests__/index.spec.tsx @@ -27,7 +27,13 @@ import { appWorkflowQueryOptions, appWorkflowVersionsInfiniteQueryOptions, } from '@/service/workflow-queries' -import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' +import { + createConsoleQueryClient, + renderWithConsoleQuery, + seedAppDetail, +} from '@/test/console/query-data' +import { createAppDetailFixture } from '@/test/fixtures/app' +import { AppModeEnum } from '@/types/app' import { AppACLPermission } from '@/utils/permission' import AppDeploy from '..' import { EnvironmentTable } from '../environment-table' @@ -684,6 +690,8 @@ const mockBuiltInEnvironment = vi.hoisted(() => { } }) +let appPermissionKeys: string[] = [AppACLPermission.AccessPointView, AppACLPermission.Deploy] + function render( ui: ReactElement, { @@ -733,6 +741,14 @@ function render( ], }) seedWorkflowDeploymentConfigurationQueries(queryClient) + seedAppDetail( + queryClient, + createAppDetailFixture({ + ...mockBuiltInEnvironment.appDetail, + mode: AppModeEnum.WORKFLOW, + permission_keys: appPermissionKeys, + }), + ) return renderWithConsoleQuery(ui, { queryClient }) } @@ -752,8 +768,6 @@ function environmentTableProps( } } -let appPermissionKeys: string[] = [AppACLPermission.AccessPointView, AppACLPermission.Deploy] -let appDetailAvailable = true const mockConsoleState = vi.hoisted(() => ({ workspacePermissionKeys: [] as string[], })) @@ -828,17 +842,9 @@ vi.mock('@/service/use-tools', () => ({ }), })) -vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: Record) => unknown) => { - const appDetail = appDetailAvailable - ? { - ...mockBuiltInEnvironment.appDetail, - permission_keys: appPermissionKeys, - } - : undefined - - return selector({ appDetail }) - }, +vi.mock('@/next/navigation', async (importOriginal) => ({ + ...(await importOriginal()), + useParams: () => ({ appId: APP_ID }), })) vi.mock('@/context/permission-state', async () => { @@ -865,7 +871,6 @@ describe('AppDeploy', () => { beforeEach(() => { vi.clearAllMocks() appPermissionKeys = [AppACLPermission.AccessPointView, AppACLPermission.Deploy] - appDetailAvailable = true mockBuiltInEnvironment.appDetail.enable_api = false mockBuiltInEnvironment.appDetail.enable_site = true mockBuiltInEnvironment.mcpServerDetail.status = 'active' @@ -1127,17 +1132,6 @@ describe('AppDeploy', () => { expect(builtInEnvironment.getByText('Updated at 03-09 16:03 by --')).toBeInTheDocument() }) - it('shows loading while the app detail is unavailable', () => { - appDetailAvailable = false - - render() - - expect(screen.getByRole('progressbar', { name: 'common.loading' })).toBeInTheDocument() - expect( - screen.queryByRole('heading', { name: 'common.appMenus.deploy' }), - ).not.toBeInTheDocument() - }) - it('does not render deployment controls without app deploy ACL permission', () => { appPermissionKeys = [] diff --git a/web/app/components/app/deploy/built-in-environment-card/index.tsx b/web/app/components/app/deploy/built-in-environment-card/index.tsx index e80d4aee559..c576f94b043 100644 --- a/web/app/components/app/deploy/built-in-environment-card/index.tsx +++ b/web/app/components/app/deploy/built-in-environment-card/index.tsx @@ -3,12 +3,13 @@ import type { WorkflowVersion } from '@dify/contracts/enterprise-app-deploy/types.gen' import type { Node } from '@/app/components/workflow/types' import { RuntimeState } from '@dify/contracts/enterprise-app-deploy/types.gen' -import { useQuery } from '@tanstack/react-query' +import { useQuery, useSuspenseQuery } from '@tanstack/react-query' import { memo } from 'react' import { useTranslation } from 'react-i18next' -import { useStore as useAppStore } from '@/app/components/app/store' import { BlockEnum, isTriggerNode } from '@/app/components/workflow/types' import useTimestamp from '@/hooks/use-timestamp' +import { useParams } from '@/next/navigation' +import { consoleQuery } from '@/service/console' import { useMCPServerDetail } from '@/service/use-tools' import { appWorkflowQueryOptions } from '@/service/workflow-queries' import { AccessPointIcon } from '../shared/access-point-icon' @@ -24,10 +25,12 @@ export const BuiltInEnvironmentCard = memo( ({ canViewAccessPoint }: { canViewAccessPoint: boolean }) => { const { t } = useTranslation(['deployments']) const { formatTime } = useTimestamp() - const appDetail = useAppStore((state) => state.appDetail) - const appId = appDetail?.id ?? '' - const { data: publishedWorkflow } = useQuery(appWorkflowQueryOptions(appId || null)) - const { data: mcpServerDetail } = useMCPServerDetail(appId, Boolean(appId)) + const { appId } = useParams<{ appId: string }>() + const { data: appDetail } = useSuspenseQuery( + consoleQuery.apps.byAppId.get.queryOptions({ input: { params: { app_id: appId } } }), + ) + const { data: publishedWorkflow } = useQuery(appWorkflowQueryOptions(appId)) + const { data: mcpServerDetail } = useMCPServerDetail(appId) const publishedNodes = Array.isArray(publishedWorkflow?.graph.nodes) ? (publishedWorkflow.graph.nodes as Node[]) : [] @@ -36,9 +39,9 @@ export const BuiltInEnvironmentCard = memo( const serviceModeAvailable = Boolean(publishedWorkflow && hasStartNode && !hasTriggerNode) const activeAccessPoints = { mcp: serviceModeAvailable && mcpServerDetail?.status === 'active', - serviceApi: serviceModeAvailable && Boolean(appDetail?.enable_api), + serviceApi: serviceModeAvailable && appDetail.enable_api, trigger: Boolean(publishedWorkflow && hasTriggerNode), - webApp: serviceModeAvailable && Boolean(appDetail?.enable_site), + webApp: serviceModeAvailable && appDetail.enable_site, } const publishedBy = publishedWorkflow?.created_by?.name ?? '--' const updatedBy = publishedWorkflow?.updated_by?.name ?? publishedBy diff --git a/web/app/components/app/deploy/index.tsx b/web/app/components/app/deploy/index.tsx index 1cfc58533c3..705ed19863e 100644 --- a/web/app/components/app/deploy/index.tsx +++ b/web/app/components/app/deploy/index.tsx @@ -12,12 +12,12 @@ import dynamic from 'next/dynamic' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { useLocale } from '#i18n' -import { useStore as useAppStore } from '@/app/components/app/store' -import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { getEnterpriseDocUrl } from '@/context/i18n' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { userProfileQueryOptions } from '@/features/account-profile/client' import { getDocLanguage } from '@/i18n/language' +import { useParams } from '@/next/navigation' +import { consoleQuery } from '@/service/console' import { AppModeEnum } from '@/types/app' import { getAppACLCapabilities } from '@/utils/permission' import { BuiltInEnvironmentCard } from './built-in-environment-card' @@ -154,15 +154,16 @@ function AppDeployContent({ } export default function AppDeploy() { - const appDetail = useAppStore((state) => state.appDetail) + const { appId } = useParams<{ appId: string }>() + const { data: appDetail } = useSuspenseQuery( + consoleQuery.apps.byAppId.get.queryOptions({ input: { params: { app_id: appId } } }), + ) const { data: currentUserId } = useSuspenseQuery({ ...userProfileQueryOptions(), select: (data) => data.profile.id, }) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - if (!appDetail) return - const { canDeploy, canViewAccessPoint } = getAppACLCapabilities(appDetail.permission_keys, { currentUserId, resourceMaintainer: appDetail.maintainer, diff --git a/web/app/components/app/store.ts b/web/app/components/app/store.ts deleted file mode 100644 index d970cf69ae5..00000000000 --- a/web/app/components/app/store.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' -import { create } from 'zustand' - -type State = { - appDetail?: AppDetailWithSite -} - -type Action = { - setAppDetail: (appDetail?: AppDetailWithSite) => void -} - -export const useStore = create((set) => ({ - appDetail: undefined, - setAppDetail: (appDetail) => set(() => ({ appDetail })), -})) diff --git a/web/app/components/goto-anything/__tests__/index.spec.tsx b/web/app/components/goto-anything/__tests__/index.spec.tsx index 0cc981ecdb4..0bdeabe7d2d 100644 --- a/web/app/components/goto-anything/__tests__/index.spec.tsx +++ b/web/app/components/goto-anything/__tests__/index.spec.tsx @@ -23,6 +23,7 @@ vi.mock('@/next/navigation', () => ({ push: routerPush, }), usePathname: () => '/', + useParams: () => ({}), })) let debouncedSearchQuery: string | undefined diff --git a/web/app/components/goto-anything/actions/commands/use-command-context.ts b/web/app/components/goto-anything/actions/commands/use-command-context.ts index dc4b67d6df2..d31a584a5b0 100644 --- a/web/app/components/goto-anything/actions/commands/use-command-context.ts +++ b/web/app/components/goto-anything/actions/commands/use-command-context.ts @@ -1,13 +1,14 @@ import type { CommandContext } from './types' +import { skipToken, useQuery } from '@tanstack/react-query' import { useTheme } from 'next-themes' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { useStore as useAppStore } from '@/app/components/app/store' import { useWorkflowGeneratorStore } from '@/app/components/workflow/workflow-generator/store' import { toast } from '@/app/notifications' import { useDocLink } from '@/context/i18n' import { setLocaleOnClient } from '@/i18n/client' -import { useRouter } from '@/next/navigation' +import { useParams, useRouter } from '@/next/navigation' +import { consoleQuery } from '@/service/console' const openExternal = (url: string) => { window.open(url, '_blank', 'noopener,noreferrer') @@ -24,8 +25,17 @@ export function useCommandContext( const { setTheme } = useTheme() const { push } = useRouter() const getDocsHomeUrl = useDocLink() - const appId = useAppStore((state) => state.appDetail?.id) - const appMode = useAppStore((state) => state.appDetail?.mode) + const { appId: routeAppId } = useParams<{ appId?: string }>() + // Only the app workflow page has an app id param that names an app; other routes reuse the + // `appId` segment for different resources (for example installed apps). + const { data: appDetail } = useQuery( + consoleQuery.apps.byAppId.get.queryOptions({ + input: routeAppId ? { params: { app_id: routeAppId } } : skipToken, + enabled: isWorkflowPage, + }), + ) + const appId = appDetail?.id + const appMode = appDetail?.mode return useMemo( () => ({ diff --git a/web/app/components/main-nav/__tests__/layout.spec.tsx b/web/app/components/main-nav/__tests__/layout.spec.tsx index f1b2db1bfde..f4ab543ecaa 100644 --- a/web/app/components/main-nav/__tests__/layout.spec.tsx +++ b/web/app/components/main-nav/__tests__/layout.spec.tsx @@ -4,7 +4,6 @@ import { useSuspenseQuery } from '@tanstack/react-query' import { fireEvent, screen } from '@testing-library/react' import { createStore, Provider, useAtomValue, useSetAtom } from 'jotai' import { renderToString } from 'react-dom/server' -import { useStore as useAppStore } from '@/app/components/app/store' import { detailSidebarModeAtom, setDetailSidebarModeAtom, @@ -78,7 +77,6 @@ describe('MainNavLayout', () => { beforeEach(() => { vi.clearAllMocks() localStorage.clear() - useAppStore.getState().setAppDetail() ;(usePathname as Mock).mockReturnValue('/apps') mockConsoleState.current = { isCurrentWorkspaceDatasetOperator: false, @@ -339,19 +337,4 @@ describe('MainNavLayout', () => { expect(screen.getAllByRole('main')).toHaveLength(1) expect(screen.getByRole('main')).toHaveTextContent('detail route content') }) - - it('clears app detail state after leaving app routes', () => { - useAppStore - .getState() - .setAppDetail({ id: 'app-1' } as ReturnType['appDetail']) - ;(usePathname as Mock).mockReturnValue('/datasets') - - render( - -
content
-
, - ) - - expect(useAppStore.getState().appDetail).toBeUndefined() - }) }) diff --git a/web/app/components/main-nav/layout.tsx b/web/app/components/main-nav/layout.tsx index 97c07d2be67..7b6635b404e 100644 --- a/web/app/components/main-nav/layout.tsx +++ b/web/app/components/main-nav/layout.tsx @@ -6,12 +6,9 @@ import type { DetailSidebarMode } from '@/app/components/detail-sidebar/cookie' import { cn } from '@langgenius/dify-ui/cn' import { useAtomValue } from 'jotai' import { useHydrateAtoms } from 'jotai/utils' -import { useEffect } from 'react' import { useTranslation } from 'react-i18next' -import { useShallow } from 'zustand/react/shallow' import { AppDetailPrefetch } from '@/app/components/app/app-detail-prefetch' import { getAppIdFromPathname } from '@/app/components/app/app-detail-route' -import { useStore as useAppStore } from '@/app/components/app/store' import { detailSidebarModeAtom } from '@/app/components/detail-sidebar/state' import { isCurrentWorkspaceDatasetOperatorAtom } from '@/context/workspace-state' import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag' @@ -28,24 +25,6 @@ type MainNavLayoutProps = { initialDetailSidebarMode: DetailSidebarMode } -function AppDetailStoreCleanup() { - const pathname = usePathname() - const { hasAppDetail, setAppDetail } = useAppStore( - useShallow((state) => ({ - hasAppDetail: !!state.appDetail, - setAppDetail: state.setAppDetail, - })), - ) - - useEffect(() => { - if (pathname.startsWith('/app/') || !hasAppDetail) return - - setAppDetail() - }, [hasAppDetail, pathname, setAppDetail]) - - return null -} - const MainNavLayout = ({ children, detailSidebar, @@ -76,7 +55,6 @@ const MainNavLayout = ({ > {t(($) => $['navigation.skipToMain'])} {appId && } - {hideMainNavigation ? null : useDetailSidebar ? ( detailSidebar ) : useResponsiveNavigation ? ( diff --git a/web/app/components/workflow-app/__tests__/index.spec.tsx b/web/app/components/workflow-app/__tests__/index.spec.tsx index 2b1339bc5dc..7aa1cf62a96 100644 --- a/web/app/components/workflow-app/__tests__/index.spec.tsx +++ b/web/app/components/workflow-app/__tests__/index.spec.tsx @@ -1,8 +1,13 @@ +import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' import type { ReactElement, ReactNode } from 'react' import { screen, waitFor } from '@testing-library/react' import { useEffect } from 'react' -import { createAccountProfileQueryWrapper } from '@/test/console/account-profile' +import { createAccountProfileQueryClient } from '@/test/console/account-profile' +import { createQueryClientWrapper } from '@/test/console/query-client' +import { seedAppDetail } from '@/test/console/query-data' import { render as renderWithConsoleState } from '@/test/console/render' +import { createAppDetailFixture } from '@/test/fixtures/app' +import { AppModeEnum } from '@/types/app' import { AppACLPermission } from '@/utils/permission' import WorkflowApp from '../index' @@ -22,14 +27,7 @@ const mockInitialNodes = vi.fn() const mockInitialEdges = vi.fn() const mockGetWorkflowRunAndTraceUrl = vi.fn() -let appStoreState: { - appDetail?: { - id: string - mode: string - maintainer?: string - permission_keys?: string[] - } -} +let appDetail: AppDetailWithSite let workflowInitState: { data: { @@ -66,10 +64,13 @@ let appTriggersState: { let searchParamsValue: string | null = null -const render = (ui: ReactElement) => - renderWithConsoleState(ui, { - wrapper: createAccountProfileQueryWrapper(consoleState.userProfile), +const render = (ui: ReactElement) => { + const queryClient = createAccountProfileQueryClient(consoleState.userProfile) + seedAppDetail(queryClient, appDetail) + return renderWithConsoleState(ui, { + wrapper: createQueryClientWrapper(queryClient), }) +} const mockWorkflowStore = { setState: mockWorkflowStoreSetState, @@ -84,10 +85,6 @@ const mockWorkflowStore = { }), } -vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: typeof appStoreState) => T) => selector(appStoreState), -})) - vi.mock('@/app/components/workflow/store', () => ({ useWorkflowStore: () => mockWorkflowStore, })) @@ -117,7 +114,9 @@ vi.mock('@/context/permission-state', async () => { })) }) -vi.mock('@/next/navigation', () => ({ +vi.mock('@/next/navigation', async (importOriginal) => ({ + ...(await importOriginal()), + useParams: () => ({ appId: 'app-1' }), useSearchParams: () => ({ get: (key: string) => (key === 'replayRunId' ? searchParamsValue : null), }), @@ -228,13 +227,11 @@ describe('WorkflowApp', () => { beforeEach(() => { vi.clearAllMocks() mockIsWorkflowDataLoaded = true - appStoreState = { - appDetail: { - id: 'app-1', - mode: 'workflow', - permission_keys: [AppACLPermission.TestAndRun], - }, - } + appDetail = createAppDetailFixture({ + id: 'app-1', + mode: AppModeEnum.WORKFLOW, + permission_keys: [AppACLPermission.TestAndRun], + }) workflowInitState = { data: { graph: { @@ -360,13 +357,11 @@ describe('WorkflowApp', () => { it('should skip replay lookups when test/run permission is missing', async () => { searchParamsValue = 'run-1' - appStoreState = { - appDetail: { - id: 'app-1', - mode: 'workflow', - permission_keys: [AppACLPermission.ViewLayout], - }, - } + appDetail = createAppDetailFixture({ + id: 'app-1', + mode: AppModeEnum.WORKFLOW, + permission_keys: [AppACLPermission.ViewLayout], + }) render() diff --git a/web/app/components/workflow-app/__tests__/provider-contract.spec.tsx b/web/app/components/workflow-app/__tests__/provider-contract.spec.tsx index e57af4ad4a7..41b4e233c08 100644 --- a/web/app/components/workflow-app/__tests__/provider-contract.spec.tsx +++ b/web/app/components/workflow-app/__tests__/provider-contract.spec.tsx @@ -1,15 +1,29 @@ import type { ReactElement } from 'react' import { screen } from '@testing-library/react' import { StrictMode } from 'react' -import { useStore as useAppStore } from '@/app/components/app/store' -import { createAccountProfileQueryWrapper } from '@/test/console/account-profile' +import { createAccountProfileQueryClient } from '@/test/console/account-profile' +import { createQueryClientWrapper } from '@/test/console/query-client' +import { seedAppDetail } from '@/test/console/query-data' import { render as renderWithConsoleState } from '@/test/console/render' +import { createAppDetailFixture } from '@/test/fixtures/app' +import { AppModeEnum } from '@/types/app' import WorkflowApp from '../index' -const render = (ui: ReactElement) => - renderWithConsoleState(ui, { - wrapper: createAccountProfileQueryWrapper(), +const render = (ui: ReactElement) => { + const queryClient = createAccountProfileQueryClient() + seedAppDetail( + queryClient, + createAppDetailFixture({ + id: 'app-1', + name: 'Workflow App', + mode: AppModeEnum.WORKFLOW, + permission_keys: [], + }), + ) + return renderWithConsoleState(ui, { + wrapper: createQueryClientWrapper(queryClient), }) +} vi.mock('@/context/workspace-state', async () => { const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture') @@ -26,7 +40,9 @@ vi.mock('@/context/permission-state', async () => { })) }) -vi.mock('@/next/navigation', () => ({ +vi.mock('@/next/navigation', async (importOriginal) => ({ + ...(await importOriginal()), + useParams: () => ({ appId: 'app-1' }), useSearchParams: () => ({ get: () => null }), })) @@ -116,17 +132,6 @@ vi.mock('../components/workflow-main', async () => { }) describe('WorkflowApp provider contract', () => { - beforeEach(() => { - useAppStore.setState({ - appDetail: { - id: 'app-1', - name: 'Workflow App', - mode: 'workflow', - permission_keys: [], - } as never, - }) - }) - it('shares one workflow store between initialization and the initialized canvas', async () => { render( diff --git a/web/app/components/workflow-app/components/__tests__/workflow-children.spec.tsx b/web/app/components/workflow-app/components/__tests__/workflow-children.spec.tsx index 99fecac063d..64003955eae 100644 --- a/web/app/components/workflow-app/components/__tests__/workflow-children.spec.tsx +++ b/web/app/components/workflow-app/components/__tests__/workflow-children.spec.tsx @@ -160,6 +160,10 @@ vi.mock('../../hooks/use-available-nodes-meta-data', () => ({ }), })) +vi.mock('../../hooks/use-is-chat-mode', () => ({ + useIsChatMode: () => false, +})) + vi.mock('../../hooks/use-auto-onboarding', () => ({ useAutoOnboarding: () => ({ handleOnboardingClose: mockHandleOnboardingClose, diff --git a/web/app/components/workflow-app/components/__tests__/workflow-main.spec.tsx b/web/app/components/workflow-app/components/__tests__/workflow-main.spec.tsx index ab570b3c7d9..66bec7abc3f 100644 --- a/web/app/components/workflow-app/components/__tests__/workflow-main.spec.tsx +++ b/web/app/components/workflow-app/components/__tests__/workflow-main.spec.tsx @@ -1,13 +1,36 @@ -import type { ReactNode } from 'react' +import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' +import type { ReactElement, ReactNode } from 'react' import type { WorkflowProps } from '@/app/components/workflow' -import { act, fireEvent, screen, waitFor } from '@testing-library/react' -import { useStore as useAppStore } from '@/app/components/app/store' +import { + act, + fireEvent, + render as renderWithQueryClient, + screen, + waitFor, +} from '@testing-library/react' import { ChatVarType } from '@/app/components/workflow/panel/chat-variable-panel/type' import { BlockEnum } from '@/app/components/workflow/types' -import { renderWithAccountProfile as render } from '@/test/console/account-profile' +import { createAccountProfileQueryClient } from '@/test/console/account-profile' +import { createQueryClientWrapper } from '@/test/console/query-client' +import { seedAppDetail } from '@/test/console/query-data' +import { createAppDetailFixture } from '@/test/fixtures/app' +import { AppModeEnum } from '@/types/app' import { AppACLPermission } from '@/utils/permission' import WorkflowMain from '../workflow-main' +let appDetail: AppDetailWithSite + +const render = (ui: ReactElement) => { + const queryClient = createAccountProfileQueryClient() + seedAppDetail(queryClient, appDetail) + return renderWithQueryClient(ui, { wrapper: createQueryClientWrapper(queryClient) }) +} + +vi.mock('@/next/navigation', async (importOriginal) => ({ + ...(await importOriginal()), + useParams: () => ({ appId: 'app-1' }), +})) + const mockSetFeatures = vi.fn() const mockSetConversationVariables = vi.fn() const mockSetEnvironmentVariables = vi.fn() @@ -449,7 +472,7 @@ describe('WorkflowMain', () => { mockReplaceGraphFromReactFlow.mockReturnValue(true) hookFns.doSyncWorkflowDraft.mockResolvedValue({ hash: 'saved-hash', updatedAt: 2 }) hookFns.handleRefreshWorkflowDraft.mockResolvedValue(true) - useAppStore.setState({ appDetail: undefined }) + appDetail = createAppDetailFixture({ id: 'app-1', mode: AppModeEnum.WORKFLOW }) }) it('should render the inner workflow context with children and forwarded graph props', () => { @@ -591,8 +614,10 @@ describe('WorkflowMain', () => { }) it('disables collaboration for view-only apps', () => { - useAppStore.setState({ - appDetail: { permission_keys: [AppACLPermission.ViewLayout] } as never, + appDetail = createAppDetailFixture({ + id: 'app-1', + mode: AppModeEnum.WORKFLOW, + permission_keys: [AppACLPermission.ViewLayout], }) render() diff --git a/web/app/components/workflow-app/components/__tests__/workflow-panel.spec.tsx b/web/app/components/workflow-app/components/__tests__/workflow-panel.spec.tsx index 57a4b5f90d0..aafaa97550c 100644 --- a/web/app/components/workflow-app/components/__tests__/workflow-panel.spec.tsx +++ b/web/app/components/workflow-app/components/__tests__/workflow-panel.spec.tsx @@ -4,30 +4,35 @@ import { act, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' +import { createConsoleQueryWrapper, seedAppDetail } from '@/test/console/query-data' +import { createAppDetailFixture } from '@/test/fixtures/app' import { AppModeEnum } from '@/types/app' import WorkflowPanel from '../workflow-panel' -type AppStoreState = { - appDetail?: { - id?: string - mode?: AppModeEnum - workflow?: { - id?: string - } - } -} - type WorkflowStoreState = Partial const mockUseIsChatMode = vi.fn() -let appStoreState: AppStoreState let workflowStoreState: WorkflowStoreState -vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: AppStoreState) => T) => selector(appStoreState), +vi.mock('@/next/navigation', async (importOriginal) => ({ + ...(await importOriginal()), + useParams: () => ({ appId: 'app-123' }), })) +const renderPanel = (initialStoreState: WorkflowStoreState) => { + const { queryClient } = createConsoleQueryWrapper({ appDslVersion: '0.6.0' }) + seedAppDetail( + queryClient, + createAppDetailFixture({ + id: 'app-123', + mode: AppModeEnum.WORKFLOW, + workflow: { id: 'workflow-version-id' }, + }), + ) + return renderWorkflowComponent(, { initialStoreState, queryClient }) +} + vi.mock('@/app/components/workflow/panel', () => ({ default: ({ components, @@ -110,15 +115,6 @@ vi.mock('../../hooks/use-is-chat-mode', () => ({ describe('WorkflowPanel', () => { beforeEach(() => { vi.clearAllMocks() - appStoreState = { - appDetail: { - id: 'app-123', - mode: AppModeEnum.WORKFLOW, - workflow: { - id: 'workflow-version-id', - }, - }, - } workflowStoreState = { historyWorkflowData: undefined, showDebugAndPreviewPanel: false, @@ -129,7 +125,7 @@ describe('WorkflowPanel', () => { }) it('should configure workflow version history urls and latest version id for the panel shell', async () => { - renderWorkflowComponent(, { initialStoreState: workflowStoreState }) + renderPanel(workflowStoreState) const panel = await screen.findByTestId('panel') expect(panel).toHaveAttribute('data-version-list-url', '/apps/app-123/workflows') @@ -152,9 +148,7 @@ describe('WorkflowPanel', () => { isAnswer: true, workflow_run_id: 'run-1', } - const { store } = renderWorkflowComponent(, { - initialStoreState: { messageLogItem }, - }) + const { store } = renderPanel({ messageLogItem }) expect(await screen.findByRole('tab', { name: 'runLog.detail' })).toHaveAttribute( 'aria-selected', 'true', @@ -184,9 +178,7 @@ describe('WorkflowPanel', () => { } mockUseIsChatMode.mockReturnValue(true) - const { unmount } = renderWorkflowComponent(, { - initialStoreState: workflowStoreState, - }) + const { unmount } = renderPanel(workflowStoreState) expect(await screen.findByTestId('chat-record-panel')).toBeInTheDocument() expect(screen.getByTestId('debug-and-preview-panel')).toBeInTheDocument() @@ -197,7 +189,7 @@ describe('WorkflowPanel', () => { unmount() mockUseIsChatMode.mockReturnValue(false) - renderWorkflowComponent(, { initialStoreState: workflowStoreState }) + renderPanel(workflowStoreState) expect(await screen.findByTestId('record-panel')).toBeInTheDocument() expect(screen.getByTestId('workflow-preview-panel')).toBeInTheDocument() diff --git a/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx b/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx index 700177d403a..0879fed0d1e 100644 --- a/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx +++ b/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx @@ -4,11 +4,9 @@ import type { ReactElement } from 'react' import type { AppPublisherProps } from '@/app/components/app/app-publisher/types' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { useStore as useAppStore } from '@/app/components/app/store' import { BlockEnum, InputVarType } from '@/app/components/workflow/types' import { consoleQuery } from '@/service/console' import { createConsoleQueryWrapper, seedFeatures } from '@/test/console/query-data' -import { createAppDetailFixture } from '@/test/fixtures/app' import FeaturesTrigger from '../features-trigger' const mockUseIsChatMode = vi.fn() @@ -53,7 +51,6 @@ const mockPublishWorkflow = vi.fn() const mockUpdatePublishedWorkflow = vi.fn() const mockResetWorkflowVersionHistory = vi.fn() const mockInvalidateAppTriggers = vi.fn() -const mockAppResponse = vi.fn() const mockInvalidateQueries = vi.fn() const mockSetPublishedAt = vi.fn() const mockSetLastPublishedHasUserInput = vi.fn() @@ -229,17 +226,15 @@ vi.mock('@/service/use-tools', () => ({ useInvalidateAppTriggers: () => mockInvalidateAppTriggers, })) -vi.mock('@/service/base', async (importOriginal) => ({ - ...(await importOriginal()), - request: async (url: string) => Response.json(await mockAppResponse(url)), +vi.mock('@/next/navigation', async (importOriginal) => ({ + ...(await importOriginal()), + useParams: () => ({ appId: 'app-id' }), })) vi.mock('@/hooks/use-theme', () => ({ default: () => mockUseTheme(), })) -// Use real app store - global zustand mock will auto-reset between tests - const renderWithToast = ( ui: ReactElement, { edition = 'CLOUD', plan = 'sandbox' }: { edition?: DeploymentEdition; plan?: CloudPlan } = {}, @@ -277,9 +272,6 @@ describe('FeaturesTrigger', () => { ) mockUseNodes.mockReturnValue([]) mockUseEdges.mockReturnValue([]) - // Set up app store state - useAppStore.setState({ appDetail: createAppDetailFixture({ id: 'app-id' }) }) - mockAppResponse.mockResolvedValue(createAppDetailFixture({ id: 'app-id', name: 'Updated App' })) mockInvalidateQueries.mockResolvedValue(undefined) mockPublishWorkflow.mockResolvedValue({ created_at: '2024-01-01T00:00:00Z' }) }) @@ -634,12 +626,11 @@ describe('FeaturesTrigger', () => { type: 'success', message: 'common.api.actionSuccess', }) - expect(mockAppResponse).toHaveBeenCalledWith(expect.stringContaining('/apps/app-id')) - expect(useAppStore.getState().appDetail).toEqual( - expect.objectContaining({ - name: 'Updated App', + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: consoleQuery.apps.byAppId.get.queryKey({ + input: { params: { app_id: 'app-id' } }, }), - ) + }) }) }) @@ -799,23 +790,5 @@ describe('FeaturesTrigger', () => { expect(mockSetLastPublishedHasUserInput).not.toHaveBeenCalled() expect(mockResetWorkflowVersionHistory).not.toHaveBeenCalled() }) - - it('should log error when app detail refresh fails after publish', async () => { - // Arrange - const user = userEvent.setup() - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) - mockAppResponse.mockRejectedValueOnce(new Error('fetch failed')) - - renderWithToast() - - // Act - await user.click(screen.getByRole('button', { name: 'publisher-publish' })) - - // Assert - await waitFor(() => { - expect(consoleErrorSpy).toHaveBeenCalled() - }) - consoleErrorSpy.mockRestore() - }) }) }) diff --git a/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx b/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx index 3392522ec5c..5c0c0b9c605 100644 --- a/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx +++ b/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx @@ -13,7 +13,6 @@ import { memo, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useEdges } from 'reactflow' import { AppPublisher } from '@/app/components/app/app-publisher' -import { useStore as useAppStore } from '@/app/components/app/store' import { useFeatures } from '@/app/components/base/features/hooks' // useWorkflowRunValidation, import { useHooksStore } from '@/app/components/workflow/hooks-store' @@ -30,7 +29,8 @@ import { BlockEnum, InputVarType, isTriggerNode } from '@/app/components/workflo import { toast } from '@/app/notifications' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import useTheme from '@/hooks/use-theme' -import { consoleClient, consoleQuery } from '@/service/console' +import { useParams } from '@/next/navigation' +import { consoleQuery } from '@/service/console' import { useInvalidateAppTriggers } from '@/service/use-tools' import { useInvalidateAppWorkflow, @@ -44,9 +44,7 @@ const FeaturesTrigger = () => { const isChatMode = useIsChatMode() const workflowStore = useWorkflowStore() const queryClient = useQueryClient() - const appDetail = useAppStore((s) => s.appDetail) - const setAppDetail = useAppStore((s) => s.setAppDetail) - const appID = appDetail?.id + const { appId: appID } = useParams<{ appId: string }>() const { nodesReadOnly, getNodesReadOnly } = useNodesReadOnly() const canReleaseAndVersion = useHooksStore((s) => s.accessControl.canReleaseAndVersion) const { data: deploymentEdition } = useSuspenseQuery({ @@ -159,20 +157,6 @@ const FeaturesTrigger = () => { setShowFeaturesPanel(!showFeaturesPanel) }, [workflowStore, getNodesReadOnly]) - const updateAppDetail = useCallback(async () => { - try { - if (!appID) return - - const res = await consoleClient.apps.byAppId.get({ params: { app_id: appID } }) - setAppDetail({ ...res }) - void queryClient.invalidateQueries({ - queryKey: consoleQuery.apps.byAppId.get.queryKey({ input: { params: { app_id: appID } } }), - }) - } catch (error) { - console.error(error) - } - }, [appID, setAppDetail, queryClient]) - const { mutateAsync: publishWorkflow } = usePublishWorkflow() // const { validateBeforeRun } = useWorkflowRunValidation() const needWarningNodes = useChecklist(nodes, edges) @@ -205,9 +189,13 @@ const FeaturesTrigger = () => { if (res.warning) toast.warning(res.warning) else if (options?.showSuccessToast !== false) toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) - updatePublishedWorkflow(appID!) - updateAppDetail() - invalidateAppTriggers(appID!) + updatePublishedWorkflow(appID) + void queryClient.invalidateQueries({ + queryKey: consoleQuery.apps.byAppId.get.queryKey({ + input: { params: { app_id: appID } }, + }), + }) + invalidateAppTriggers(appID) if (rosterAgentIds.length > 0) { void queryClient.invalidateQueries({ queryKey: consoleQuery.agent.get.key(), @@ -242,7 +230,6 @@ const FeaturesTrigger = () => { appID, t, updatePublishedWorkflow, - updateAppDetail, invalidateAppTriggers, rosterAgentIds, queryClient, @@ -279,7 +266,7 @@ const FeaturesTrigger = () => { )} { - const appDetail = useAppStore((state) => state.appDetail) + const { appId } = useParams<{ appId: string }>() const setMessageLogItem = useStore((state) => state.setMessageLogItem) const resetWorkflowVersionHistory = useResetWorkflowVersionHistory() const isChatMode = useIsChatMode() @@ -22,10 +22,10 @@ const WorkflowHeader = () => { return { onClearLogAndMessageModal: handleClearLogAndMessageModal, historyUrl: isChatMode - ? `/apps/${appDetail!.id}/advanced-chat/workflow-runs` - : `/apps/${appDetail!.id}/workflow-runs`, + ? `/apps/${appId}/advanced-chat/workflow-runs` + : `/apps/${appId}/workflow-runs`, } - }, [appDetail, isChatMode, handleClearLogAndMessageModal]) + }, [appId, isChatMode, handleClearLogAndMessageModal]) const headerProps: HeaderProps = useMemo(() => { return { diff --git a/web/app/components/workflow-app/components/workflow-main.tsx b/web/app/components/workflow-app/components/workflow-main.tsx index d7799b765cb..50072878210 100644 --- a/web/app/components/workflow-app/components/workflow-main.tsx +++ b/web/app/components/workflow-app/components/workflow-main.tsx @@ -9,7 +9,6 @@ import { useAtomValue } from 'jotai' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useReactFlow } from 'reactflow' -import { useStore as useAppStore } from '@/app/components/app/store' import { useFeaturesStore } from '@/app/components/base/features/hooks' import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants' import { WorkflowWithInnerContext } from '@/app/components/workflow' @@ -21,6 +20,8 @@ import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import { SupportUploadFileTypes } from '@/app/components/workflow/types' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { userProfileQueryOptions } from '@/features/account-profile/client' +import { useParams } from '@/next/navigation' +import { consoleQuery } from '@/service/console' import { fetchWorkflowDraft } from '@/service/workflow' import { getAppACLCapabilities } from '@/utils/permission' import { useAvailableNodesMetaData } from '../hooks/use-available-nodes-meta-data' @@ -54,14 +55,17 @@ const WorkflowMain = ({ nodes, edges, viewport }: WorkflowMainProps) => { const featuresStore = useFeaturesStore() const workflowStore = useWorkflowStore() const appId = useStore((s) => s.appId) - const appDetail = useAppStore((s) => s.appDetail) + const { appId: routeAppId } = useParams<{ appId: string }>() + const { data: appDetail } = useSuspenseQuery( + consoleQuery.apps.byAppId.get.queryOptions({ input: { params: { app_id: routeAppId } } }), + ) const containerRef = useRef(null) const [collaborationGraphState, setCollaborationGraphState] = useState({ appId: null as string | null, isReady: false, }) const reactFlow = useReactFlow() - const { getWorkflowDraftGraphForCanvas } = useWorkflowDraftGraphForCanvas(appDetail?.mode) + const { getWorkflowDraftGraphForCanvas } = useWorkflowDraftGraphForCanvas(appDetail.mode) const reactFlowStore = useMemo( () => ({ @@ -81,12 +85,12 @@ const WorkflowMain = ({ nodes, edges, viewport }: WorkflowMainProps) => { const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const appACLCapabilities = useMemo( () => - getAppACLCapabilities(appDetail?.permission_keys, { + getAppACLCapabilities(appDetail.permission_keys, { currentUserId, - resourceMaintainer: appDetail?.maintainer, + resourceMaintainer: appDetail.maintainer, workspacePermissionKeys, }), - [appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys], + [appDetail.maintainer, appDetail.permission_keys, currentUserId, workspacePermissionKeys], ) const { startCursorTracking, diff --git a/web/app/components/workflow-app/components/workflow-panel.tsx b/web/app/components/workflow-app/components/workflow-panel.tsx index e266f67d3fc..bd4adefbbc8 100644 --- a/web/app/components/workflow-app/components/workflow-panel.tsx +++ b/web/app/components/workflow-app/components/workflow-panel.tsx @@ -1,10 +1,12 @@ import type { PanelProps } from '@/app/components/workflow/panel' +import { useSuspenseQuery } from '@tanstack/react-query' import dynamic from 'next/dynamic' import { memo, useMemo } from 'react' -import { useStore as useAppStore } from '@/app/components/app/store' import Panel from '@/app/components/workflow/panel' import CommentsPanel from '@/app/components/workflow/panel/comments-panel' import { useStore } from '@/app/components/workflow/store' +import { useParams } from '@/next/navigation' +import { consoleQuery } from '@/service/console' import { useIsChatMode } from '../hooks/use-is-chat-mode' const MessageLogModal = dynamic(() => import('@/app/components/base/message-log-modal'), { @@ -73,28 +75,30 @@ const WorkflowPanelOnRight = () => { ) } const WorkflowPanel = () => { - const appDetail = useAppStore((s) => s.appDetail) + const { appId } = useParams<{ appId: string }>() + const { data: appDetail } = useSuspenseQuery( + consoleQuery.apps.byAppId.get.queryOptions({ input: { params: { app_id: appId } } }), + ) const versionHistoryPanelProps = useMemo(() => { - const appId = appDetail?.id return { - appMode: appDetail?.mode, + appMode: appDetail.mode, getVersionListUrl: `/apps/${appId}/workflows`, deleteVersionUrl: (versionId: string) => `/apps/${appId}/workflows/${versionId}`, restoreVersionUrl: (versionId: string) => `/apps/${appId}/workflows/${versionId}/restore`, updateVersionUrl: (versionId: string) => `/apps/${appId}/workflows/${versionId}`, - latestVersionId: appDetail?.workflow?.id, + latestVersionId: appDetail.workflow?.id, } - }, [appDetail?.id, appDetail?.mode, appDetail?.workflow?.id]) + }, [appId, appDetail.mode, appDetail.workflow?.id]) const panelProps: PanelProps = useMemo(() => { return { components: { - left: appDetail && , + left: , right: , }, versionHistoryPanelProps, } - }, [appDetail, versionHistoryPanelProps]) + }, [appId, versionHistoryPanelProps]) return } diff --git a/web/app/components/workflow-app/hooks/__tests__/use-is-chat-mode.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-is-chat-mode.spec.ts index 76c3e6b2ce8..1c2abe40ff9 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-is-chat-mode.spec.ts +++ b/web/app/components/workflow-app/hooks/__tests__/use-is-chat-mode.spec.ts @@ -1,40 +1,32 @@ -import { renderHook } from '@testing-library/react' +import { + createConsoleQueryClient, + renderHookWithConsoleQuery, + seedAppDetail, +} from '@/test/console/query-data' +import { createAppDetailFixture } from '@/test/fixtures/app' import { AppModeEnum } from '@/types/app' import { useIsChatMode } from '../use-is-chat-mode' -const { mockStoreState } = vi.hoisted(() => ({ - mockStoreState: { - appDetail: undefined as { mode?: AppModeEnum } | undefined, - }, +vi.mock('@/next/navigation', async (importOriginal) => ({ + ...(await importOriginal()), + useParams: () => ({ appId: 'app-1' }), })) -vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: typeof mockStoreState) => unknown) => selector(mockStoreState), -})) +const renderWithAppMode = (mode: AppModeEnum) => { + const queryClient = createConsoleQueryClient() + seedAppDetail(queryClient, createAppDetailFixture({ id: 'app-1', mode })) + return renderHookWithConsoleQuery(() => useIsChatMode(), { queryClient }) +} describe('useIsChatMode', () => { - beforeEach(() => { - vi.clearAllMocks() - mockStoreState.appDetail = undefined - }) - it('should return true when the app mode is ADVANCED_CHAT', () => { - mockStoreState.appDetail = { mode: AppModeEnum.ADVANCED_CHAT } - - const { result } = renderHook(() => useIsChatMode()) + const { result } = renderWithAppMode(AppModeEnum.ADVANCED_CHAT) expect(result.current).toBe(true) }) - it('should return false when the app mode is not chat or app detail is missing', () => { - mockStoreState.appDetail = { mode: AppModeEnum.WORKFLOW } - - const { result, rerender } = renderHook(() => useIsChatMode()) - - expect(result.current).toBe(false) - - mockStoreState.appDetail = undefined - rerender() + it('should return false when the app mode is WORKFLOW', () => { + const { result } = renderWithAppMode(AppModeEnum.WORKFLOW) expect(result.current).toBe(false) }) diff --git a/web/app/components/workflow-app/hooks/__tests__/use-workflow-init.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-workflow-init.spec.ts index edf8a40b5ef..399e5c4b02e 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-workflow-init.spec.ts +++ b/web/app/components/workflow-app/hooks/__tests__/use-workflow-init.spec.ts @@ -1,9 +1,13 @@ +import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' import { waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { BlockEnum } from '@/app/components/workflow/types' import { createHttpError } from '@/service/http-error' -import { createAccountProfileQueryWrapper } from '@/test/console/account-profile' +import { createAccountProfileQueryClient } from '@/test/console/account-profile' +import { createQueryClientWrapper } from '@/test/console/query-client' +import { seedAppDetail } from '@/test/console/query-data' import { renderHook as renderHookWithConsoleState } from '@/test/console/render' +import { createAppDetailFixture } from '@/test/fixtures/app' import { AppACLPermission } from '@/utils/permission' import { useWorkflowInit } from '../use-workflow-init' @@ -19,19 +23,14 @@ const mockFetchNodesDefaultConfigs = vi.fn() const mockFetchPublishedWorkflow = vi.fn() const mockSyncWorkflowDraft = vi.fn() -const renderHook = (callback: () => Result) => - renderHookWithConsoleState(callback, { - wrapper: createAccountProfileQueryWrapper({ id: 'user-1' }), - }) +let appDetail: AppDetailWithSite -let appStoreState: { - appDetail: { - id: string - name: string - mode: string - permission_keys?: string[] - maintainer?: string - } +const renderHook = (callback: () => Result) => { + const queryClient = createAccountProfileQueryClient({ id: 'user-1' }) + seedAppDetail(queryClient, appDetail) + return renderHookWithConsoleState(callback, { + wrapper: createQueryClientWrapper(queryClient), + }) } let workflowConfigState: { @@ -49,8 +48,9 @@ vi.mock('@/app/components/workflow/store', () => ({ }), })) -vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: typeof appStoreState) => T): T => selector(appStoreState), +vi.mock('@/next/navigation', async (importOriginal) => ({ + ...(await importOriginal()), + useParams: () => ({ appId: 'app-1' }), })) vi.mock('@/context/permission-state', async () => { @@ -64,7 +64,7 @@ vi.mock('@/context/permission-state', async () => { vi.mock('../use-workflow-template', () => ({ useWorkflowTemplate: () => ({ nodes: - appStoreState.appDetail.mode === 'workflow' + appDetail.mode === 'workflow' ? [{ id: 'start-placeholder', data: { type: BlockEnum.StartPlaceholder } }] : [{ id: 'start', data: { type: BlockEnum.Start } }], edges: [], @@ -123,14 +123,12 @@ const draftResponse = { describe('useWorkflowInit', () => { beforeEach(() => { vi.clearAllMocks() - appStoreState = { - appDetail: { - id: 'app-1', - name: 'Test', - mode: 'workflow', - permission_keys: [AppACLPermission.Edit], - }, - } + appDetail = createAppDetailFixture({ + id: 'app-1', + name: 'Test', + mode: 'workflow', + permission_keys: [AppACLPermission.Edit], + }) workflowConfigState = { data: null, isLoading: false } mockWorkflowStoreGetState.mockReturnValue({ setDraftUpdatedAt: mockSetDraftUpdatedAt, @@ -204,14 +202,12 @@ describe('useWorkflowInit', () => { }) it('should keep creating the first backend draft for advanced chat apps', async () => { - appStoreState = { - appDetail: { - id: 'app-1', - name: 'Test', - mode: 'advanced-chat', - permission_keys: [AppACLPermission.Edit], - }, - } + appDetail = createAppDetailFixture({ + id: 'app-1', + name: 'Test', + mode: 'advanced-chat', + permission_keys: [AppACLPermission.Edit], + }) mockFetchWorkflowDraft .mockReset() .mockRejectedValueOnce(notExistError()) @@ -244,14 +240,12 @@ describe('useWorkflowInit', () => { }) it('should keep readonly users local when the first workflow draft does not exist', async () => { - appStoreState = { - appDetail: { - id: 'app-1', - name: 'Test', - mode: 'workflow', - permission_keys: [AppACLPermission.ViewLayout], - }, - } + appDetail = createAppDetailFixture({ + id: 'app-1', + name: 'Test', + mode: 'workflow', + permission_keys: [AppACLPermission.ViewLayout], + }) mockFetchWorkflowDraft.mockReset().mockRejectedValueOnce(notExistError()) const { result } = renderHook(() => useWorkflowInit()) diff --git a/web/app/components/workflow-app/hooks/__tests__/use-workflow-refresh-draft.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-workflow-refresh-draft.spec.ts index a7d2edd8cd7..ff8b4c48ef9 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-workflow-refresh-draft.spec.ts +++ b/web/app/components/workflow-app/hooks/__tests__/use-workflow-refresh-draft.spec.ts @@ -1,6 +1,12 @@ -import { act, renderHook, waitFor } from '@testing-library/react' +import { act, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { BlockEnum } from '@/app/components/workflow/types' +import { + createConsoleQueryClient, + renderHookWithConsoleQuery, + seedAppDetail, +} from '@/test/console/query-data' +import { createAppDetailFixture } from '@/test/fixtures/app' import { AppModeEnum } from '@/types/app' import { useWorkflowRefreshDraft } from '../use-workflow-refresh-draft' @@ -12,11 +18,7 @@ const mockSetEnvSecrets = vi.fn() const mockSetConversationVariables = vi.fn() const mockSetIsWorkflowDataLoaded = vi.fn() const mockCancel = vi.fn() -let appStoreState: { - appDetail: { - mode: string - } -} +let appMode: AppModeEnum let workflowStoreState: { appId: string @@ -36,8 +38,9 @@ vi.mock('@/app/components/workflow/store', () => ({ }), })) -vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: typeof appStoreState) => T): T => selector(appStoreState), +vi.mock('@/next/navigation', async (importOriginal) => ({ + ...(await importOriginal()), + useParams: () => ({ appId: 'app-1' }), })) vi.mock('@/app/components/workflow/hooks/use-workflow-update', () => ({ @@ -49,6 +52,12 @@ vi.mock('@/service/workflow', () => ({ fetchWorkflowDraft: (...args: unknown[]) => mockFetchWorkflowDraft(...args), })) +const renderRefreshDraftHook = () => { + const queryClient = createConsoleQueryClient() + seedAppDetail(queryClient, createAppDetailFixture({ id: 'app-1', mode: appMode })) + return renderHookWithConsoleQuery(() => useWorkflowRefreshDraft(), { queryClient }) +} + const draftResponse = { hash: 'server-hash', graph: { nodes: [{ id: 'n1' }], edges: [], viewport: { x: 1, y: 2, zoom: 1 } }, @@ -70,14 +79,12 @@ describe('useWorkflowRefreshDraft — notUpdateCanvas parameter', () => { setConversationVariables: mockSetConversationVariables, setIsWorkflowDataLoaded: mockSetIsWorkflowDataLoaded, } - appStoreState = { - appDetail: { mode: AppModeEnum.ADVANCED_CHAT }, - } + appMode = AppModeEnum.ADVANCED_CHAT mockFetchWorkflowDraft.mockResolvedValue(draftResponse) }) it('should update canvas by default (notUpdateCanvas omitted)', async () => { - const { result } = renderHook(() => useWorkflowRefreshDraft()) + const { result } = renderRefreshDraftHook() let refreshed = false await act(async () => { refreshed = await result.current.handleRefreshWorkflowDraft() @@ -87,7 +94,7 @@ describe('useWorkflowRefreshDraft — notUpdateCanvas parameter', () => { }) it('should update canvas when notUpdateCanvas=false', async () => { - const { result } = renderHook(() => useWorkflowRefreshDraft()) + const { result } = renderRefreshDraftHook() await act(async () => { result.current.handleRefreshWorkflowDraft(false) }) @@ -97,7 +104,7 @@ describe('useWorkflowRefreshDraft — notUpdateCanvas parameter', () => { it('should NOT update canvas when notUpdateCanvas=true', async () => { // This is the key change: when called from a 409 error during editing, // canvas must not be overwritten with server state. - const { result } = renderHook(() => useWorkflowRefreshDraft()) + const { result } = renderRefreshDraftHook() await act(async () => { result.current.handleRefreshWorkflowDraft(true) }) @@ -112,7 +119,7 @@ describe('useWorkflowRefreshDraft — notUpdateCanvas parameter', () => { }), ) let isCurrent = true - const { result } = renderHook(() => useWorkflowRefreshDraft()) + const { result } = renderRefreshDraftHook() let refreshPromise: Promise | undefined act(() => { @@ -148,7 +155,7 @@ describe('useWorkflowRefreshDraft — notUpdateCanvas parameter', () => { }), ) let firstIsCurrent = true - const { result } = renderHook(() => useWorkflowRefreshDraft()) + const { result } = renderRefreshDraftHook() let firstRefresh: Promise | undefined let secondRefresh: Promise | undefined @@ -177,7 +184,7 @@ describe('useWorkflowRefreshDraft — notUpdateCanvas parameter', () => { }) it('should still update hash even when notUpdateCanvas=true', async () => { - const { result } = renderHook(() => useWorkflowRefreshDraft()) + const { result } = renderRefreshDraftHook() await act(async () => { result.current.handleRefreshWorkflowDraft(true) }) @@ -204,7 +211,7 @@ describe('useWorkflowRefreshDraft — notUpdateCanvas parameter', () => { conversation_variables: [{ id: 'conversation-1' }], }) - const { result } = renderHook(() => useWorkflowRefreshDraft()) + const { result } = renderRefreshDraftHook() act(() => { result.current.handleRefreshWorkflowDraft() @@ -229,9 +236,7 @@ describe('useWorkflowRefreshDraft — notUpdateCanvas parameter', () => { }) it('should restore a local start placeholder for workflow drafts without an entry node', async () => { - appStoreState = { - appDetail: { mode: AppModeEnum.WORKFLOW }, - } + appMode = AppModeEnum.WORKFLOW mockFetchWorkflowDraft.mockResolvedValue({ hash: 'server-hash', graph: { @@ -242,7 +247,7 @@ describe('useWorkflowRefreshDraft — notUpdateCanvas parameter', () => { conversation_variables: [], }) - const { result } = renderHook(() => useWorkflowRefreshDraft()) + const { result } = renderRefreshDraftHook() act(() => { result.current.handleRefreshWorkflowDraft() @@ -277,7 +282,7 @@ describe('useWorkflowRefreshDraft — notUpdateCanvas parameter', () => { conversation_variables: [], }) - const { result } = renderHook(() => useWorkflowRefreshDraft()) + const { result } = renderRefreshDraftHook() act(() => { result.current.handleRefreshWorkflowDraft() @@ -295,7 +300,7 @@ describe('useWorkflowRefreshDraft — notUpdateCanvas parameter', () => { it('should restore loaded state when refresh fails after workflow data was already loaded', async () => { mockFetchWorkflowDraft.mockRejectedValue(new Error('refresh failed')) - const { result } = renderHook(() => useWorkflowRefreshDraft()) + const { result } = renderRefreshDraftHook() let refreshed = true await act(async () => { diff --git a/web/app/components/workflow-app/hooks/__tests__/use-workflow-run.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-workflow-run.spec.ts index 74c24c9ac83..3c57c38fc69 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-workflow-run.spec.ts +++ b/web/app/components/workflow-app/hooks/__tests__/use-workflow-run.spec.ts @@ -1,6 +1,12 @@ -import { act, renderHook } from '@testing-library/react' +import { act } from '@testing-library/react' import { TriggerType } from '@/app/components/workflow/header/test-run-menu' import { WorkflowRunningStatus } from '@/app/components/workflow/types' +import { + createConsoleQueryClient, + renderHookWithConsoleQuery, + seedAppDetail, +} from '@/test/console/query-data' +import { createAppDetailFixture } from '@/test/fixtures/app' import { useWorkflowRun } from '../use-workflow-run' type DebugAbortControllerRef = { @@ -41,13 +47,6 @@ type WorkflowRunFailedTrackingEvent = { } const mocks = vi.hoisted(() => { - const appStoreState = { - appDetail: { - id: 'app-1', - mode: 'workflow', - name: 'Workflow App', - }, - } const reactFlowStoreState = { edges: [{ id: 'edge-1' }], getNodes: vi.fn(), @@ -69,7 +68,6 @@ const mocks = vi.hoisted(() => { }) return { - appStoreState, reactFlowStoreState, workflowStoreState, workflowStoreSetState, @@ -124,16 +122,6 @@ vi.mock('reactflow', () => ({ }), })) -vi.mock('@/app/components/app/store', () => { - const useStore = Object.assign(vi.fn(), { - getState: () => mocks.appStoreState, - }) - - return { - useStore, - } -}) - vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: mocks.mockTrackEvent, })) @@ -171,7 +159,9 @@ vi.mock('@/app/components/workflow/store', () => ({ }), })) -vi.mock('@/next/navigation', () => ({ +vi.mock('@/next/navigation', async (importOriginal) => ({ + ...(await importOriginal()), + useParams: () => ({ appId: 'app-1' }), usePathname: () => '/apps/app-1/workflow', })) @@ -228,6 +218,15 @@ vi.mock('../use-workflow-run-callbacks', async (importOriginal) => { } }) +const renderWorkflowRunHook = () => { + const queryClient = createConsoleQueryClient() + seedAppDetail( + queryClient, + createAppDetailFixture({ id: 'app-1', mode: 'workflow', name: 'Workflow App' }), + ) + return renderHookWithConsoleQuery(() => useWorkflowRun(), { queryClient }) +} + const createWorkflowStoreState = () => ({ backupDraft: undefined, environmentVariables: [{ id: 'env-current', value: 'secret' }], @@ -290,7 +289,7 @@ describe('useWorkflowRun', () => { }) it('should backup the current draft once and skip subsequent backups until it is cleared', () => { - const { result } = renderHook(() => useWorkflowRun()) + const { result } = renderWorkflowRunHook() act(() => { result.current.handleBackupDraft() @@ -317,7 +316,7 @@ describe('useWorkflowRun', () => { environmentVariables: [{ id: 'env-backup', value: 'value' }], } - const { result } = renderHook(() => useWorkflowRun()) + const { result } = renderWorkflowRunHook() act(() => { result.current.handleLoadBackupDraft() @@ -338,7 +337,7 @@ describe('useWorkflowRun', () => { }) it('should prepare the graph and dispatch a workflow run through ssePost for user-input mode', async () => { - const { result } = renderHook(() => useWorkflowRun()) + const { result } = renderWorkflowRunHook() await act(async () => { await result.current.handleRun({ inputs: { query: 'hello' } }) @@ -410,7 +409,7 @@ describe('useWorkflowRun', () => { ])( 'should dispatch $title trigger runs through the debug runner integration', async ({ params, options, expectedUrl, expectedBody, expectedNodeIds, expectedIsAll }) => { - const { result } = renderHook(() => useWorkflowRun()) + const { result } = renderWorkflowRunHook() await act(async () => { await result.current.handleRun(params, undefined, options) @@ -434,7 +433,7 @@ describe('useWorkflowRun', () => { ) it('should expose the workflow-failed tracker through the callback factory context', async () => { - const { result } = renderHook(() => useWorkflowRun()) + const { result } = renderWorkflowRunHook() await act(async () => { await result.current.handleRun({ inputs: { query: 'hello' } }) @@ -483,7 +482,7 @@ describe('useWorkflowRun', () => { }) it('should split serialized workflow data into chunks when it exceeds the amplitude string limit', async () => { - const { result } = renderHook(() => useWorkflowRun()) + const { result } = renderWorkflowRunHook() await act(async () => { await result.current.handleRun({ inputs: { query: 'hello' } }) @@ -532,7 +531,7 @@ describe('useWorkflowRun', () => { }) it('should track workflow failures when the error or workflow data is malformed', async () => { - const { result } = renderHook(() => useWorkflowRun()) + const { result } = renderWorkflowRunHook() await act(async () => { await result.current.handleRun({ inputs: { query: 'hello' } }) @@ -580,7 +579,7 @@ describe('useWorkflowRun', () => { }) it('should lazily create audio players with the correct public and private tts urls', async () => { - const { result } = renderHook(() => useWorkflowRun()) + const { result } = renderWorkflowRunHook() await act(async () => { await result.current.handleRun({ token: 'public-token' }) @@ -628,7 +627,7 @@ describe('useWorkflowRun', () => { }) it('should stop workflow runs by task id or by aborting active debug controllers', async () => { - const { result } = renderHook(() => useWorkflowRun()) + const { result } = renderWorkflowRunHook() await act(async () => { await result.current.handleRun({ inputs: { query: 'hello' } }) @@ -678,7 +677,7 @@ describe('useWorkflowRun', () => { }) it('should restore published workflow graph, features, and environment variables', () => { - const { result } = renderHook(() => useWorkflowRun()) + const { result } = renderWorkflowRunHook() act(() => { result.current.handleRestoreFromPublishedWorkflow({ @@ -725,7 +724,7 @@ describe('useWorkflowRun', () => { }) it('should restore published workflows with empty environment variables as an empty list', () => { - const { result } = renderHook(() => useWorkflowRun()) + const { result } = renderWorkflowRunHook() act(() => { result.current.handleRestoreFromPublishedWorkflow({ diff --git a/web/app/components/workflow-app/hooks/__tests__/use-workflow-template.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-workflow-template.spec.ts index bb96a226665..4f1317c8707 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-workflow-template.spec.ts +++ b/web/app/components/workflow-app/hooks/__tests__/use-workflow-template.spec.ts @@ -1,21 +1,28 @@ -import { renderHook } from '@testing-library/react' +import { + createConsoleQueryClient, + renderHookWithConsoleQuery, + seedAppDetail, +} from '@/test/console/query-data' +import { createAppDetailFixture } from '@/test/fixtures/app' import { AppModeEnum } from '@/types/app' import { useWorkflowTemplate } from '../use-workflow-template' const mockUseIsChatMode = vi.fn() let generateNewNodeCalls: Array> = [] -let appStoreState: { - appDetail: { - mode: string - } + +const renderTemplateHook = (mode: AppModeEnum) => { + const queryClient = createConsoleQueryClient() + seedAppDetail(queryClient, createAppDetailFixture({ id: 'app-1', mode })) + return renderHookWithConsoleQuery(() => useWorkflowTemplate(), { queryClient }) } vi.mock('../use-is-chat-mode', () => ({ useIsChatMode: () => mockUseIsChatMode(), })) -vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: typeof appStoreState) => T): T => selector(appStoreState), +vi.mock('@/next/navigation', async (importOriginal) => ({ + ...(await importOriginal()), + useParams: () => ({ appId: 'app-1' }), })) vi.mock('@/app/components/workflow/utils', async (importOriginal) => { @@ -43,15 +50,12 @@ describe('useWorkflowTemplate', () => { beforeEach(() => { vi.clearAllMocks() generateNewNodeCalls = [] - appStoreState = { - appDetail: { mode: AppModeEnum.WORKFLOW }, - } }) it('should return only the start placeholder template in workflow mode', () => { mockUseIsChatMode.mockReturnValue(false) - const { result } = renderHook(() => useWorkflowTemplate()) + const { result } = renderTemplateHook(AppModeEnum.WORKFLOW) expect(result.current.nodes).toHaveLength(1) expect(result.current.edges).toEqual([]) @@ -65,12 +69,9 @@ describe('useWorkflowTemplate', () => { }) it('should return the start node template for non-workflow app modes', () => { - appStoreState = { - appDetail: { mode: AppModeEnum.COMPLETION }, - } mockUseIsChatMode.mockReturnValue(false) - const { result } = renderHook(() => useWorkflowTemplate()) + const { result } = renderTemplateHook(AppModeEnum.COMPLETION) expect(result.current.nodes).toHaveLength(1) expect(result.current.edges).toEqual([]) @@ -82,12 +83,9 @@ describe('useWorkflowTemplate', () => { }) it('should build start, llm, and answer templates with linked edges in chat mode', () => { - appStoreState = { - appDetail: { mode: AppModeEnum.ADVANCED_CHAT }, - } mockUseIsChatMode.mockReturnValue(true) - const { result } = renderHook(() => useWorkflowTemplate()) + const { result } = renderTemplateHook(AppModeEnum.ADVANCED_CHAT) expect(result.current.nodes).toHaveLength(3) expect(result.current.nodes.map((node) => node.id)).toEqual(['generated-1', 'llm', 'answer']) diff --git a/web/app/components/workflow-app/hooks/use-is-chat-mode.ts b/web/app/components/workflow-app/hooks/use-is-chat-mode.ts index a76dbd69adf..d8283b3da0f 100644 --- a/web/app/components/workflow-app/hooks/use-is-chat-mode.ts +++ b/web/app/components/workflow-app/hooks/use-is-chat-mode.ts @@ -1,8 +1,16 @@ -import { useStore as useAppStore } from '@/app/components/app/store' +import { skipToken, useQuery } from '@tanstack/react-query' +import { useParams } from '@/next/navigation' +import { consoleQuery } from '@/service/console' import { AppModeEnum } from '@/types/app' export const useIsChatMode = () => { - const appDetail = useAppStore((s) => s.appDetail) + const { appId } = useParams<{ appId?: string }>() + const { data: appMode } = useQuery( + consoleQuery.apps.byAppId.get.queryOptions({ + input: appId ? { params: { app_id: appId } } : skipToken, + select: (appDetail) => appDetail.mode, + }), + ) - return appDetail?.mode === AppModeEnum.ADVANCED_CHAT + return appMode === AppModeEnum.ADVANCED_CHAT } diff --git a/web/app/components/workflow-app/hooks/use-workflow-init.ts b/web/app/components/workflow-app/hooks/use-workflow-init.ts index de08d5c7478..710be942a7d 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-init.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-init.ts @@ -5,11 +5,12 @@ import { ORPCError } from '@orpc/client' import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useCallback, useEffect, useMemo, useState } from 'react' -import { useStore as useAppStore } from '@/app/components/app/store' import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import { BlockEnum } from '@/app/components/workflow/types' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { userProfileQueryOptions } from '@/features/account-profile/client' +import { useParams } from '@/next/navigation' +import { consoleQuery } from '@/service/console' import { useWorkflowConfig } from '@/service/use-workflow' import { fetchNodesDefaultConfigs, fetchWorkflowDraft, syncWorkflowDraft } from '@/service/workflow' import { appWorkflowQueryOptions } from '@/service/workflow-queries' @@ -59,7 +60,10 @@ export const useWorkflowInit = () => { const queryClient = useQueryClient() const workflowStore = useWorkflowStore() const { nodes: nodesTemplate, edges: edgesTemplate } = useWorkflowTemplate() - const appDetail = useAppStore((state) => state.appDetail)! + const { appId } = useParams<{ appId: string }>() + const { data: appDetail } = useSuspenseQuery( + consoleQuery.apps.byAppId.get.queryOptions({ input: { params: { app_id: appId } } }), + ) const { data: currentUserId } = useSuspenseQuery({ ...userProfileQueryOptions(), select: (data) => data.profile.id, @@ -127,7 +131,7 @@ export const useWorkflowInit = () => { setSyncWorkflowDraftHash(initialData.hash) setIsLoading(false) } catch (error: unknown) { - if (error instanceof ORPCError && appDetail) { + if (error instanceof ORPCError) { if (error.code === 'draft_workflow_not_exist') { const isAdvancedChat = appDetail.mode === AppModeEnum.ADVANCED_CHAT const initialGraph = { diff --git a/web/app/components/workflow-app/hooks/use-workflow-refresh-draft.ts b/web/app/components/workflow-app/hooks/use-workflow-refresh-draft.ts index 0fc9783584e..edbe513e2c2 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-refresh-draft.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-refresh-draft.ts @@ -1,7 +1,9 @@ +import { useSuspenseQuery } from '@tanstack/react-query' import { useCallback, useRef } from 'react' -import { useStore as useAppStore } from '@/app/components/app/store' import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow-update' import { useWorkflowStore } from '@/app/components/workflow/store' +import { useParams } from '@/next/navigation' +import { consoleQuery } from '@/service/console' import { fetchWorkflowDraft } from '@/service/workflow' import { useWorkflowDraftGraphForCanvas } from './use-workflow-draft-graph-for-canvas' @@ -10,11 +12,14 @@ type RefreshWorkflowDraftOptions = { } export const useWorkflowRefreshDraft = () => { - const appDetail = useAppStore((s) => s.appDetail) + const { appId: routeAppId } = useParams<{ appId: string }>() + const { data: appDetail } = useSuspenseQuery( + consoleQuery.apps.byAppId.get.queryOptions({ input: { params: { app_id: routeAppId } } }), + ) const workflowStore = useWorkflowStore() const refreshSequenceRef = useRef(0) const { handleUpdateWorkflowCanvas } = useWorkflowUpdate() - const { getWorkflowDraftGraphForCanvas } = useWorkflowDraftGraphForCanvas(appDetail?.mode) + const { getWorkflowDraftGraphForCanvas } = useWorkflowDraftGraphForCanvas(appDetail.mode) const handleRefreshWorkflowDraft = useCallback( (notUpdateCanvas?: boolean, options?: RefreshWorkflowDraftOptions) => { diff --git a/web/app/components/workflow-app/hooks/use-workflow-run.ts b/web/app/components/workflow-app/hooks/use-workflow-run.ts index f4cecec43ff..df6be208fff 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-run.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-run.ts @@ -3,12 +3,12 @@ import type { AudioPlayer } from '@/app/components/base/audio-btn/audio' import type { Node } from '@/app/components/workflow/types' import type { IOtherOptions } from '@/service/base' import type { VersionHistory } from '@/types/workflow' +import { useSuspenseQuery } from '@tanstack/react-query' import { noop } from 'es-toolkit/function' import { produce } from 'immer' import { useCallback, useRef } from 'react' import { useReactFlow, useStoreApi } from 'reactflow' import { v4 as uuidV4 } from 'uuid' -import { useStore as useAppStore } from '@/app/components/app/store' import { trackEvent } from '@/app/components/base/amplitude' import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager' import { useFeaturesStore } from '@/app/components/base/features/hooks' @@ -17,8 +17,9 @@ import { useSetWorkflowVarsWithValue } from '@/app/components/workflow/hooks/use import { useWorkflowRunEvent } from '@/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event' import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow-update' import { useWorkflowStore } from '@/app/components/workflow/store' -import { usePathname } from '@/next/navigation' +import { useParams, usePathname } from '@/next/navigation' import { ssePost } from '@/service/base' +import { consoleQuery } from '@/service/console' import { useInvalidAllLastRun, useInvalidateWorkflowRunHistory } from '@/service/use-workflow' import { stopWorkflowRun } from '@/service/workflow' import { AppModeEnum } from '@/types/app' @@ -117,6 +118,10 @@ const useWorkflowRunBase = (doSyncWorkflowDraft: DoSyncWorkflowDraft) => { const featuresStore = useFeaturesStore() const { handleUpdateWorkflowCanvas } = useWorkflowUpdate() const pathname = usePathname() + const { appId } = useParams<{ appId: string }>() + const { data: appDetail } = useSuspenseQuery( + consoleQuery.apps.byAppId.get.queryOptions({ input: { params: { app_id: appId } } }), + ) const configsMap = useConfigsMap() const { flowId, flowType } = configsMap const invalidAllLastRun = useInvalidAllLastRun(flowType, flowId) @@ -225,13 +230,12 @@ const useWorkflowRunBase = (doSyncWorkflowDraft: DoSyncWorkflowDraft) => { ...restCallback } = callback || {} workflowStore.setState({ historyWorkflowData: undefined }) - const appDetail = useAppStore.getState().appDetail const runHistoryUrl = buildRunHistoryUrl(appDetail) const workflowContainer = document.getElementById('workflow-container') const { clientWidth, clientHeight } = workflowContainer! - const isInWorkflowDebug = appDetail?.mode === AppModeEnum.WORKFLOW + const isInWorkflowDebug = appDetail.mode === AppModeEnum.WORKFLOW const url = resolveWorkflowRunUrl(appDetail, runMode, isInWorkflowDebug) const requestBody = buildWorkflowRunRequestBody(runMode, resolvedParams, options) @@ -448,6 +452,7 @@ const useWorkflowRunBase = (doSyncWorkflowDraft: DoSyncWorkflowDraft) => { doSyncWorkflowDraft, workflowStore, pathname, + appDetail, handleWorkflowFailed, flowId, handleWorkflowStarted, @@ -496,7 +501,6 @@ const useWorkflowRunBase = (doSyncWorkflowDraft: DoSyncWorkflowDraft) => { } if (taskId) { - const appId = useAppStore.getState().appDetail?.id stopWorkflowRun(`/apps/${appId}/workflow-runs/tasks/${taskId}/stop`) setStoppedState() return @@ -523,7 +527,7 @@ const useWorkflowRunBase = (doSyncWorkflowDraft: DoSyncWorkflowDraft) => { abortControllerRef.current = null setStoppedState() }, - [workflowStore], + [appId, workflowStore], ) const handleRestoreFromPublishedWorkflow = useCallback( diff --git a/web/app/components/workflow-app/hooks/use-workflow-template.ts b/web/app/components/workflow-app/hooks/use-workflow-template.ts index 03c07502e96..8e0fa1b6337 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-template.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-template.ts @@ -1,18 +1,23 @@ import type { StartNodeType } from '@/app/components/workflow/nodes/start/types' +import { useSuspenseQuery } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' -import { useStore as useAppStore } from '@/app/components/app/store' import { NODE_WIDTH_X_OFFSET, START_INITIAL_POSITION } from '@/app/components/workflow/constants' import answerDefault from '@/app/components/workflow/nodes/answer/default' import llmDefault from '@/app/components/workflow/nodes/llm/default' import startPlaceholderDefault from '@/app/components/workflow/nodes/start-placeholder/default' import startDefault from '@/app/components/workflow/nodes/start/default' import { generateNewNode } from '@/app/components/workflow/utils' +import { useParams } from '@/next/navigation' +import { consoleQuery } from '@/service/console' import { AppModeEnum } from '@/types/app' import { useIsChatMode } from './use-is-chat-mode' export const useWorkflowTemplate = () => { const isChatMode = useIsChatMode() - const appDetail = useAppStore((s) => s.appDetail) + const { appId } = useParams<{ appId: string }>() + const { data: appDetail } = useSuspenseQuery( + consoleQuery.apps.byAppId.get.queryOptions({ input: { params: { app_id: appId } } }), + ) const { t } = useTranslation(['workflow']) const createStartNode = () => { @@ -84,7 +89,7 @@ export const useWorkflowTemplate = () => { edges: [startToLlmEdge, llmToAnswerEdge], } } - if (appDetail?.mode === AppModeEnum.WORKFLOW) { + if (appDetail.mode === AppModeEnum.WORKFLOW) { const { newNode: startPlaceholderNode } = generateNewNode({ data: { ...startPlaceholderDefault.defaultValue, diff --git a/web/app/components/workflow-app/index.tsx b/web/app/components/workflow-app/index.tsx index 9efdec89437..7a47e2c20e5 100644 --- a/web/app/components/workflow-app/index.tsx +++ b/web/app/components/workflow-app/index.tsx @@ -5,7 +5,6 @@ import type { InjectWorkflowStoreSliceFn } from '@/app/components/workflow/store import { useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useEffect, useMemo } from 'react' -import { useStore as useAppStore } from '@/app/components/app/store' import { FeaturesProvider } from '@/app/components/base/features' import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import WorkflowWithDefaultContext from '@/app/components/workflow' @@ -16,7 +15,8 @@ import { initialEdges, initialNodes } from '@/app/components/workflow/utils' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { currentWorkspaceAtom, currentWorkspaceLoadingAtom } from '@/context/workspace-state' import { userProfileQueryOptions } from '@/features/account-profile/client' -import { useSearchParams } from '@/next/navigation' +import { useParams, useSearchParams } from '@/next/navigation' +import { consoleQuery } from '@/service/console' import { fetchRunDetail } from '@/service/log' import { useAppTriggers } from '@/service/use-tools' import { AppModeEnum } from '@/types/app' @@ -40,18 +40,20 @@ const WorkflowAppWithAdditionalContext = () => { // Initialize trigger status at application level const { setTriggerStatuses } = useTriggerStatusStore() - const appDetail = useAppStore((s) => s.appDetail) + const { appId } = useParams<{ appId: string }>() + const { data: appDetail } = useSuspenseQuery( + consoleQuery.apps.byAppId.get.queryOptions({ input: { params: { app_id: appId } } }), + ) const appACLCapabilities = useMemo( () => - getAppACLCapabilities(appDetail?.permission_keys, { + getAppACLCapabilities(appDetail.permission_keys, { currentUserId, - resourceMaintainer: appDetail?.maintainer, + resourceMaintainer: appDetail.maintainer, workspacePermissionKeys, }), - [appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys], + [appDetail.maintainer, appDetail.permission_keys, currentUserId, workspacePermissionKeys], ) - const appId = appDetail?.id - const isWorkflowMode = appDetail?.mode === AppModeEnum.WORKFLOW + const isWorkflowMode = appDetail.mode === AppModeEnum.WORKFLOW const { data: triggersResponse } = useAppTriggers(isWorkflowMode ? appId : undefined, { staleTime: 5 * 60 * 1000, // 5 minutes cache refetchOnWindowFocus: false, diff --git a/web/app/components/workflow/__tests__/service-mock-factory.ts b/web/app/components/workflow/__tests__/service-mock-factory.ts index 6129fe3fe77..2705a6c5daa 100644 --- a/web/app/components/workflow/__tests__/service-mock-factory.ts +++ b/web/app/components/workflow/__tests__/service-mock-factory.ts @@ -6,34 +6,9 @@ * vi.mock('@/service/use-tools', async () => * (await import('../../__tests__/service-mock-factory')).createToolServiceMock(), * ) - * vi.mock('@/app/components/app/store', async () => - * (await import('../../__tests__/service-mock-factory')).createAppStoreMock(), - * ) * ``` */ -// --------------------------------------------------------------------------- -// App store -// --------------------------------------------------------------------------- - -type AppStoreMockData = { - appId?: string - appMode?: string -} - -export function createAppStoreMock(data?: AppStoreMockData) { - return { - useStore: { - getState: () => ({ - appDetail: { - id: data?.appId ?? 'app-test-id', - mode: data?.appMode ?? 'workflow', - }, - }), - }, - } -} - // --------------------------------------------------------------------------- // SWR service hooks // --------------------------------------------------------------------------- diff --git a/web/app/components/workflow/block-selector/__tests__/blocks.spec.tsx b/web/app/components/workflow/block-selector/__tests__/blocks.spec.tsx index 96fedff4a32..659890e3852 100644 --- a/web/app/components/workflow/block-selector/__tests__/blocks.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/blocks.spec.tsx @@ -14,7 +14,6 @@ import Blocks from '../blocks' import { BlockClassification } from '../types' const runtimeState = vi.hoisted(() => ({ - appType: 'workflow' as string | undefined, nodes: [] as Array<{ data: { type?: BlockEnum } }>, })) @@ -31,15 +30,6 @@ vi.mock('reactflow', () => ({ }), })) -vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: { appDetail: { type?: string } }) => unknown) => - selector({ - appDetail: { - type: runtimeState.appType, - }, - }), -})) - vi.mock('@/service/base', () => ({ request: (...args: unknown[]) => queryMocks.request(...args), })) @@ -125,7 +115,6 @@ const expectLastInviteOptionsRequest = () => { describe('Blocks', () => { beforeEach(() => { vi.clearAllMocks() - runtimeState.appType = 'workflow' runtimeState.nodes = [] }) diff --git a/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts b/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts index 5c70a4c1545..054a4d6d729 100644 --- a/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts @@ -1,4 +1,7 @@ -import type { AgentSoulDifyToolConfig } from '@dify/contracts/api/console/apps/types.gen' +import type { + AgentSoulDifyToolConfig, + AppDetailWithSite, +} from '@dify/contracts/api/console/apps/types.gen' import type { GetWorkspacesCurrentModelsModelTypesByModelTypeData } from '@dify/contracts/api/console/workspaces/types.gen' import type { OperationKey } from '@orpc/tanstack-query' import type { CommonNodeType, Node } from '../../types' @@ -10,6 +13,8 @@ import { screen, waitFor } from '@testing-library/react' import { createElement, Fragment } from 'react' import { CollectionType } from '@/app/components/tools/types' import { consoleQuery } from '@/service/console' +import { createAppDetailFixture } from '@/test/fixtures/app' +import { AppModeEnum } from '@/types/app' import { FlowType } from '@/types/common' import { createEdge, createNode, resetFixtureCounters } from '../../__tests__/fixtures' import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state' @@ -128,14 +133,10 @@ vi.mock('../../nodes/_base/components/variable/utils', () => ({ isSpecialVar: () => false, })) -vi.mock('@/app/components/app/store', () => { - const state = { appDetail: { mode: 'workflow' } } - return { - useStore: { - getState: () => state, - }, - } -}) +vi.mock('@/next/navigation', async (importOriginal) => ({ + ...(await importOriginal()), + useParams: () => ({ appId: 'app-1' }), +})) vi.mock('../../datasets-detail-store/store', () => ({ useDatasetsDetailStore: () => ({}), @@ -859,7 +860,12 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { 'query', { params: GetWorkspacesCurrentModelsModelTypesByModelTypeData['path'] } > + select?: (data: AppDetailWithSite) => unknown }) => { + if (options.queryKey[0].join('.') === 'console.apps.byAppId.get') { + const appDetail = createAppDetailFixture({ mode: AppModeEnum.WORKFLOW }) + return { data: options.select ? options.select(appDetail) : appDetail } + } if (options.queryKey[0].includes('modelProviders') && options.queryKey[0].includes('summary')) return { data: mockModelProviders } if (!options.queryKey[0].includes('modelTypes')) return actual.useQuery(options) diff --git a/web/app/components/workflow/hooks/use-checklist.ts b/web/app/components/workflow/hooks/use-checklist.ts index d6001f0baa7..29f1b52c606 100644 --- a/web/app/components/workflow/hooks/use-checklist.ts +++ b/web/app/components/workflow/hooks/use-checklist.ts @@ -18,12 +18,11 @@ import type { ModelItem } from '@/app/components/header/account-setting/model-pr import type { Emoji } from '@/app/components/tools/types' import type { AgentToolPublishIssue } from '@/features/agent-v2/agent-detail/configure/tool-provider-catalog' import type { DataSet } from '@/models/datasets' -import { useQueries, useQuery, useQueryClient } from '@tanstack/react-query' +import { skipToken, useQueries, useQuery, useQueryClient } from '@tanstack/react-query' import isDeepEqual from 'fast-deep-equal' import { useCallback, useEffect, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { useEdges, useStoreApi } from 'reactflow' -import { useStore as useAppStore } from '@/app/components/app/store' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { normalizeModelProviderModelsResponse } from '@/app/components/header/account-setting/model-provider-page/utils' import useNodes from '@/app/components/workflow/store/workflow/use-nodes' @@ -36,6 +35,7 @@ import { getAgentToolPublishIssues, useAgentToolPresentation, } from '@/features/agent-v2/agent-detail/configure/tool-provider-catalog' +import { useParams } from '@/next/navigation' import { consoleQuery } from '@/service/console' import { fetchDatasets } from '@/service/datasets' import { @@ -183,7 +183,13 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType? const { data: triggerPlugins } = useAllTriggerPlugins() const datasetsDetail = useDatasetsDetailStore((s) => s.datasetsDetail) const getToolIcon = useGetToolIcon() - const appMode = useAppStore.getState().appDetail?.mode + const { appId } = useParams<{ appId?: string }>() + const { data: appMode } = useQuery( + consoleQuery.apps.byAppId.get.queryOptions({ + input: appId ? { params: { app_id: appId } } : skipToken, + select: (appDetail) => appDetail.mode, + }), + ) const shouldCheckStartNode = appMode === AppModeEnum.WORKFLOW || appMode === AppModeEnum.ADVANCED_CHAT const { data: modelProviders = EMPTY_MODEL_PROVIDERS } = useQuery( @@ -651,7 +657,13 @@ export const useChecklistBeforePublish = () => { const { data: customTools } = useAllCustomTools() const { data: workflowTools } = useAllWorkflowTools() const flowType = useHooksStore((s) => s.configsMap?.flowType) - const appMode = useAppStore.getState().appDetail?.mode + const { appId } = useParams<{ appId?: string }>() + const { data: appMode } = useQuery( + consoleQuery.apps.byAppId.get.queryOptions({ + input: appId ? { params: { app_id: appId } } : skipToken, + select: (appDetail) => appDetail.mode, + }), + ) const shouldCheckStartNode = appMode === AppModeEnum.WORKFLOW || appMode === AppModeEnum.ADVANCED_CHAT diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx index ef3efff133d..5cfda71a375 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx @@ -70,11 +70,9 @@ const mockLastRunState = { getFilteredExistVarForms: vi.fn(() => []), } -vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: { appDetail: { id: string } }) => unknown) => - selector({ - appDetail: { id: 'app-1' }, - }), +vi.mock('@/next/navigation', async (importOriginal) => ({ + ...(await importOriginal()), + useParams: () => ({ appId: 'app-1' }), })) vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx index 43fb326540c..24b5391f58f 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx @@ -12,7 +12,6 @@ import { useQueryState } from 'nuqs' import * as React from 'react' import { cloneElement, memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useStore as useAppStore } from '@/app/components/app/store' import ResizeHandle from '@/app/components/base/resize-handle' import { UserAvatarList } from '@/app/components/base/user-avatar-list' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' @@ -48,6 +47,7 @@ import { isSupportCustomRunForm, } from '@/app/components/workflow/utils' import { userProfileQueryOptions } from '@/features/account-profile/client' +import { useParams } from '@/next/navigation' import { useAllBuiltInTools } from '@/service/use-tools' import { useAllTriggerPlugins } from '@/service/use-triggers' import { FlowType } from '@/types/common' @@ -260,7 +260,7 @@ const BasePanel: FC = ({ id, data, children }) => { const isChildNode = !!(data.isInIteration || data.isInLoop) const nodeMetaType = getNodeCatalogType(data) const isSupportSingleRun = canRunBySingle(data.type, isChildNode) - const appDetail = useAppStore((state) => state.appDetail) + const { appId: routeAppId } = useParams<{ appId?: string }>() const hasClickRunning = useRef(false) const [isPaused, setIsPaused] = useState(false) @@ -778,7 +778,7 @@ const BasePanel: FC = ({ id, data, children }) => { {!isStartPlaceholderPanel && ( ({ trackCreateApp: trackCreateAppMock, })) -vi.mock('@/service/console', () => ({ - consoleQuery: { - agent: { - byAgentId: { - get: { - queryOptions: vi.fn(), - }, - }, - }, - apps: { - byAppId: { - workflows: { - draft: { - nodes: { - byNodeId: { - agentComposer: { - get: { - queryOptions: mockAppComposerQueryOptions, - queryKey: ({ - input, - }: { - input: { params: { app_id: string; node_id: string } } - }) => ['workflow-agent-composer', input.params.app_id, input.params.node_id], - }, - put: { - mutationOptions: mockComposerMutationOptions, - }, - }, - }, - }, - }, - }, - }, - }, - snippets: { - bySnippetId: { - workflows: { - draft: { - nodes: { - byNodeId: { - agentComposer: { - get: { - queryOptions: mockSnippetComposerQueryOptions, - queryKey: ({ - input, - }: { - input: { params: { snippet_id: string; node_id: string } } - }) => ['snippet-agent-composer', input.params.snippet_id, input.params.node_id], - }, - put: { - mutationOptions: mockSnippetComposerMutationOptions, - }, - }, - }, - }, - }, - }, - }, - }, - }, +vi.mock('@/next/navigation', async (importOriginal) => ({ + ...(await importOriginal()), + useParams: () => ({ appId: 'app-1' }), })) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + return { + consoleQuery: { + agent: { + byAgentId: { + get: { + queryOptions: vi.fn(), + }, + }, + }, + apps: { + byAppId: { + get: actual.consoleQuery.apps.byAppId.get, + workflows: { + draft: { + nodes: { + byNodeId: { + agentComposer: { + get: { + queryOptions: mockAppComposerQueryOptions, + queryKey: ({ + input, + }: { + input: { params: { app_id: string; node_id: string } } + }) => ['workflow-agent-composer', input.params.app_id, input.params.node_id], + }, + put: { + mutationOptions: mockComposerMutationOptions, + }, + }, + }, + }, + }, + }, + }, + }, + snippets: { + bySnippetId: { + workflows: { + draft: { + nodes: { + byNodeId: { + agentComposer: { + get: { + queryOptions: mockSnippetComposerQueryOptions, + queryKey: ({ + input, + }: { + input: { params: { snippet_id: string; node_id: string } } + }) => [ + 'snippet-agent-composer', + input.params.snippet_id, + input.params.node_id, + ], + }, + put: { + mutationOptions: mockSnippetComposerMutationOptions, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } +}) + +const createInlineAgentBindingQueryClient = (appMode: AppModeEnum = AppModeEnum.WORKFLOW) => { + const queryClient = createConsoleQueryClient() + seedAppDetail(queryClient, createAppDetailFixture({ id: 'app-1', mode: appMode })) + return queryClient +} + describe('useWorkflowInlineAgentDetail', () => { beforeEach(() => { vi.clearAllMocks() @@ -323,7 +343,6 @@ describe('useWorkflowInlineAgentDetail', () => { describe('useCreateInlineAgentBinding', () => { beforeEach(() => { vi.clearAllMocks() - useAppStore.getState().setAppDetail({ mode: AppModeEnum.WORKFLOW } as never) mockDefaultModel.value = { model: 'gpt-4o-mini', model_type: 'llm', @@ -339,13 +358,7 @@ describe('useCreateInlineAgentBinding', () => { it('creates inline agent with the default text generation model', async () => { const onSuccess = vi.fn() - const queryClient = new QueryClient({ - defaultOptions: { - mutations: { - retry: false, - }, - }, - }) + const queryClient = createInlineAgentBindingQueryClient() const { result } = renderWorkflowHook(() => useCreateInlineAgentBinding(), { queryClient, hooksStoreProps: { @@ -419,14 +432,7 @@ describe('useCreateInlineAgentBinding', () => { }) it('tracks inline agent creation with the chatflow scope', async () => { - useAppStore.getState().setAppDetail({ mode: AppModeEnum.ADVANCED_CHAT } as never) - const queryClient = new QueryClient({ - defaultOptions: { - mutations: { - retry: false, - }, - }, - }) + const queryClient = createInlineAgentBindingQueryClient(AppModeEnum.ADVANCED_CHAT) const { result } = renderWorkflowHook(() => useCreateInlineAgentBinding(), { queryClient, hooksStoreProps: { @@ -453,13 +459,7 @@ describe('useCreateInlineAgentBinding', () => { it('creates inline agent through the snippet composer API', async () => { const onSuccess = vi.fn() - const queryClient = new QueryClient({ - defaultOptions: { - mutations: { - retry: false, - }, - }, - }) + const queryClient = createInlineAgentBindingQueryClient() const { result } = renderWorkflowHook(() => useCreateInlineAgentBinding(), { queryClient, hooksStoreProps: { @@ -512,13 +512,7 @@ describe('useCreateInlineAgentBinding', () => { it('creates inline agent with a model-less initial soul before the default model loads', async () => { mockDefaultModel.value = undefined as never const onSuccess = vi.fn() - const queryClient = new QueryClient({ - defaultOptions: { - mutations: { - retry: false, - }, - }, - }) + const queryClient = createInlineAgentBindingQueryClient() const { result } = renderWorkflowHook(() => useCreateInlineAgentBinding(), { queryClient, hooksStoreProps: { @@ -578,13 +572,7 @@ describe('useCreateInlineAgentBinding', () => { }), ) const onSuccess = vi.fn() - const queryClient = new QueryClient({ - defaultOptions: { - mutations: { - retry: false, - }, - }, - }) + const queryClient = createInlineAgentBindingQueryClient() const { result, unmount } = renderWorkflowHook(() => useCreateInlineAgentBinding(), { queryClient, hooksStoreProps: { diff --git a/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-orchestrate-panel-content.spec.tsx b/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-orchestrate-panel-content.spec.tsx index 2bc015c0ee3..34965ca8289 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-orchestrate-panel-content.spec.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-orchestrate-panel-content.spec.tsx @@ -6,10 +6,14 @@ import type { ReactNode, Ref } from 'react' import type { AgentBuildDraftChangeSummary } from '@/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-changes-context' import { act, fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { useStore as useAppStore } from '@/app/components/app/store' import { AgentScope } from '@/features/agent-v2/analytics' import { createHttpError } from '@/service/http-error' -import { renderWithConsoleQuery as render } from '@/test/console/query-data' +import { + createConsoleQueryClient, + renderWithConsoleQuery, + seedAppDetail, +} from '@/test/console/query-data' +import { createAppDetailFixture } from '@/test/fixtures/app' import { AppModeEnum } from '@/types/app' import { FlowType } from '@/types/common' import { WorkflowInlineAgentConfigureWorkspace } from '../agent-orchestrate-panel-content' @@ -204,7 +208,13 @@ vi.mock('@/app/components/workflow/nodes/agent-v2/agent-soul-config', () => ({ }), })) -vi.mock('@/service/console', async () => { +vi.mock('@/next/navigation', async (importOriginal) => ({ + ...(await importOriginal()), + useParams: () => ({ appId: 'app-1' }), +})) + +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() const { createSystemFeaturesFixture } = await import('@/test/console/system-features') return { consoleClient: { @@ -360,6 +370,7 @@ vi.mock('@/service/console', async () => { }, apps: { byAppId: { + get: actual.consoleQuery.apps.byAppId.get, workflowRuns: { byWorkflowRunId: { agentNodes: { @@ -483,6 +494,12 @@ function createInlineComposerState({ } as WorkflowAgentComposerResponse } +const render: typeof renderWithConsoleQuery = (ui, options = {}) => { + const queryClient = createConsoleQueryClient() + seedAppDetail(queryClient, createAppDetailFixture({ id: 'app-1', mode: AppModeEnum.WORKFLOW })) + return renderWithConsoleQuery(ui, { queryClient, ...options }) +} + function createDeferredPromise() { let resolve!: (value: T) => void const promise = new Promise((resolvePromise) => { @@ -495,7 +512,6 @@ function createDeferredPromise() { describe('WorkflowInlineAgentConfigureWorkspace', () => { beforeEach(() => { vi.clearAllMocks() - useAppStore.getState().setAppDetail({ mode: AppModeEnum.WORKFLOW } as never) mocks.completeBuildConversation = undefined permission.canCreateAgents = true mocks.loadBuildDraft.mockRejectedValue(createHttpError(new Response(null, { status: 404 }))) diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/test-email-sender.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/test-email-sender.spec.tsx index 084d89435af..4d660cca00a 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/test-email-sender.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/test-email-sender.spec.tsx @@ -10,7 +10,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useState } from 'react' -import { useStore as useAppStore } from '@/app/components/app/store' import { HooksStoreContext } from '@/app/components/workflow/hooks-store/provider' import { createHooksStore } from '@/app/components/workflow/hooks-store/store' import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' @@ -18,9 +17,13 @@ import { BlockEnum, InputVarType, VarType } from '@/app/components/workflow/type import { toast } from '@/app/notifications' import { seedAccountProfileQuery } from '@/test/console/account-profile' import { render } from '@/test/console/render' -import { createAppDetailFixture } from '@/test/fixtures/app' import EmailSenderModal from '../test-email-sender' +vi.mock('@/next/navigation', async (importOriginal) => ({ + ...(await importOriginal()), + useParams: () => ({ appId: 'app-1' }), +})) + vi.mock('@/app/notifications', async (importOriginal) => ({ ...(await importOriginal()), toast: { @@ -180,9 +183,6 @@ const createDynamicSelectInput = (): SelectFormInput => ({ describe('human-input/delivery-method/test-email-sender', () => { beforeEach(() => { vi.clearAllMocks() - useAppStore.setState({ - appDetail: createAppDetailFixture({ name: 'Workflow App' }), - }) }) afterEach(() => { diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx index 3b481871179..cfb44e60f14 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx @@ -11,7 +11,6 @@ import { noop, unionBy } from 'es-toolkit/compat' import { useAtomValue } from 'jotai' import { memo, useCallback, useMemo, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' -import { useStore as useAppStore } from '@/app/components/app/store' import { getInputVars as doGetInputVars } from '@/app/components/base/prompt-editor/constants' import FormItem from '@/app/components/workflow/nodes/_base/components/before-run-form/form-item' import { formatValue } from '@/app/components/workflow/nodes/_base/components/before-run-form/helpers' @@ -25,6 +24,7 @@ import { InputVarType, VarType } from '@/app/components/workflow/types' import { toast } from '@/app/notifications' import { currentWorkspaceAtom } from '@/context/workspace-state' import { userProfileQueryOptions } from '@/features/account-profile/client' +import { useParams } from '@/next/navigation' import { useMembers } from '@/service/use-common' import { useTestEmailSender } from '@/service/use-workflow' import { getHumanInputFormDependencySelectors, isOutput } from '../../utils' @@ -135,7 +135,7 @@ const EmailSenderContent = ({ select: (data) => data.profile.email, }) const currentWorkspace = useAtomValue(currentWorkspaceAtom) - const appDetail = useAppStore((state) => state.appDetail) + const { appId } = useParams<{ appId?: string }>() const { mutateAsync: testEmailSender } = useTestEmailSender() const debugEnabled = !!config?.debug_mode @@ -236,7 +236,7 @@ const EmailSenderContent = ({ setSendingEmail(true) try { await testEmailSender({ - appID: appDetail?.id || '', + appID: appId || '', nodeID: nodeId, deliveryID: deliveryId, inputs: formattedValues, @@ -245,16 +245,7 @@ const EmailSenderContent = ({ } finally { setSendingEmail(false) } - }, [ - confirmChecked, - generatedInputs, - inputs, - testEmailSender, - appDetail?.id, - nodeId, - deliveryId, - t, - ]) + }, [confirmChecked, generatedInputs, inputs, testEmailSender, appId, nodeId, deliveryId, t]) if (done) { return ( diff --git a/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-single-run-form-params.spec.ts b/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-single-run-form-params.spec.ts index 9df3b3eb498..7ce96ce9c7b 100644 --- a/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-single-run-form-params.spec.ts +++ b/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-single-run-form-params.spec.ts @@ -2,14 +2,20 @@ import type { HumanInputNodeType } from '../../types' import type { FileEntity } from '@/app/components/base/file-uploader/types' import type { InputVar } from '@/app/components/workflow/types' import type { HumanInputFormData } from '@/types/workflow' -import { act, renderHook } from '@testing-library/react' +import { act } from '@testing-library/react' import { BlockEnum, InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types' +import { + createConsoleQueryClient, + renderHookWithConsoleQuery, + seedAppDetail, +} from '@/test/console/query-data' +import { createAppDetailFixture } from '@/test/fixtures/app' import { withSelectorKey } from '@/test/i18n-mock' import { AppModeEnum, TransferMethod } from '@/types/app' import useSingleRunFormParams from '../use-single-run-form-params' const mockUseTranslation = vi.hoisted(() => vi.fn()) -const mockUseAppStore = vi.hoisted(() => vi.fn()) +const mockUseParams = vi.hoisted(() => vi.fn()) const mockFetchHumanInputNodeStepRunForm = vi.hoisted(() => vi.fn()) const mockSubmitHumanInputNodeStepRunForm = vi.hoisted(() => vi.fn()) const mockUseNodeCrud = vi.hoisted(() => vi.fn()) @@ -18,9 +24,9 @@ vi.mock('react-i18next', () => ({ useTranslation: () => mockUseTranslation(), })) -vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: { appDetail?: { id?: string; mode?: AppModeEnum } }) => unknown) => - mockUseAppStore(selector), +vi.mock('@/next/navigation', async (importOriginal) => ({ + ...(await importOriginal()), + useParams: () => mockUseParams(), })) vi.mock('@/service/workflow', () => ({ @@ -86,7 +92,13 @@ describe('human-input/hooks/use-single-run-form-params', () => { const mockSetRunInputData = vi.fn() const getInputVars = vi.fn() let currentInputs = createPayload() - let appDetail: { id?: string; mode?: AppModeEnum } | undefined + let appDetail: { id: string; mode: AppModeEnum } | undefined + + const renderHook = (callback: () => Result) => { + const queryClient = createConsoleQueryClient() + if (appDetail) seedAppDetail(queryClient, createAppDetailFixture(appDetail)) + return renderHookWithConsoleQuery(callback, { queryClient }) + } beforeEach(() => { vi.clearAllMocks() @@ -99,10 +111,7 @@ describe('human-input/hooks/use-single-run-form-params', () => { mockUseTranslation.mockReturnValue({ t: withSelectorKey((key: string) => key), }) - mockUseAppStore.mockImplementation( - (selector: (state: { appDetail?: { id?: string; mode?: AppModeEnum } }) => unknown) => - selector({ appDetail }), - ) + mockUseParams.mockImplementation(() => ({ appId: appDetail?.id })) mockUseNodeCrud.mockImplementation(() => ({ inputs: currentInputs, })) diff --git a/web/app/components/workflow/nodes/human-input/hooks/use-single-run-form-params.ts b/web/app/components/workflow/nodes/human-input/hooks/use-single-run-form-params.ts index ffdb91d621a..b99fb570e87 100644 --- a/web/app/components/workflow/nodes/human-input/hooks/use-single-run-form-params.ts +++ b/web/app/components/workflow/nodes/human-input/hooks/use-single-run-form-params.ts @@ -3,10 +3,12 @@ import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answe import type { Props as FormProps } from '@/app/components/workflow/nodes/_base/components/before-run-form/form' import type { InputVar } from '@/app/components/workflow/types' import type { HumanInputFormData } from '@/types/workflow' +import { skipToken, useQuery } from '@tanstack/react-query' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useStore as useAppStore } from '@/app/components/app/store' import { getProcessedHumanInputFormInputs } from '@/app/components/base/chat/chat/answer/human-input-content/utils' +import { useParams } from '@/next/navigation' +import { consoleQuery } from '@/service/console' import { fetchHumanInputNodeStepRunForm, submitHumanInputNodeStepRunForm } from '@/service/workflow' import { AppModeEnum } from '@/types/app' import useNodeCrud from '../../_base/hooks/use-node-crud' @@ -68,9 +70,14 @@ const useSingleRunFormParams = ({ .filter((arr) => arr.length > 0) } - const appDetail = useAppStore((s) => s.appDetail) - const appId = appDetail?.id - const isWorkflowMode = appDetail?.mode === AppModeEnum.WORKFLOW + const { appId } = useParams<{ appId?: string }>() + const { data: appMode } = useQuery( + consoleQuery.apps.byAppId.get.queryOptions({ + input: appId ? { params: { app_id: appId } } : skipToken, + select: (appDetail) => appDetail.mode, + }), + ) + const isWorkflowMode = appMode === AppModeEnum.WORKFLOW const fetchURL = useMemo(() => { if (!appId) return '' if (!isWorkflowMode) { diff --git a/web/features/agent-v2/analytics.ts b/web/features/agent-v2/analytics.ts index 8dc1d68e1f3..36137a0f151 100644 --- a/web/features/agent-v2/analytics.ts +++ b/web/features/agent-v2/analytics.ts @@ -1,5 +1,7 @@ -import { useStore as useAppStore } from '@/app/components/app/store' +import { skipToken, useQuery } from '@tanstack/react-query' import { trackEvent } from '@/app/components/base/amplitude' +import { useParams } from '@/next/navigation' +import { consoleQuery } from '@/service/console' import { AppModeEnum } from '@/types/app' export type AgentScope = (typeof AgentScope)[keyof typeof AgentScope] @@ -10,7 +12,13 @@ export const AgentScope = { } as const export const useInlineAgentScope = () => { - const appMode = useAppStore((state) => state.appDetail?.mode) + const { appId } = useParams<{ appId?: string }>() + const { data: appMode } = useQuery( + consoleQuery.apps.byAppId.get.queryOptions({ + input: appId ? { params: { app_id: appId } } : skipToken, + select: (appDetail) => appDetail.mode, + }), + ) return appMode === AppModeEnum.ADVANCED_CHAT ? AgentScope.InChatflow : AgentScope.InWorkflow } diff --git a/web/plugins/eslint/index.js b/web/plugins/eslint/index.js index c60f76916a8..6ede72704e2 100644 --- a/web/plugins/eslint/index.js +++ b/web/plugins/eslint/index.js @@ -1,6 +1,5 @@ import consistentPlaceholders from './rules/consistent-placeholders.js' import i18nFlatKey from './rules/i18n-flat-key.js' -import noAppStoreImports from './rules/no-app-store-imports.js' import noExtraKeys from './rules/no-extra-keys.js' import noFileWideDisable from './rules/no-file-wide-disable.js' import preferTailwindIcons from './rules/prefer-tailwind-icons.js' @@ -18,7 +17,6 @@ const plugin = { rules: { 'consistent-placeholders': consistentPlaceholders, 'i18n-flat-key': i18nFlatKey, - 'no-app-store-imports': noAppStoreImports, 'no-extra-keys': noExtraKeys, 'no-file-wide-disable': noFileWideDisable, 'prefer-tailwind-icons': preferTailwindIcons, diff --git a/web/plugins/eslint/rules/no-app-store-imports.js b/web/plugins/eslint/rules/no-app-store-imports.js deleted file mode 100644 index dbfe8798f10..00000000000 --- a/web/plugins/eslint/rules/no-app-store-imports.js +++ /dev/null @@ -1,75 +0,0 @@ -import path from 'node:path' - -const webRoot = path.resolve(import.meta.dirname, '../../..') -const appStorePath = path.join(webRoot, 'app/components/app/store') - -function isAppStore(source, filename) { - if (typeof source !== 'string') return false - const modulePath = source.replace(/[?#].*$/u, '') - let resolved - if (modulePath.startsWith('@/') || modulePath.startsWith('~@/')) { - resolved = path.resolve(webRoot, modulePath.replace(/^~?@\//u, '')) - } else if (modulePath.startsWith('.') || path.isAbsolute(modulePath)) { - resolved = path.resolve(path.dirname(filename), modulePath) - } else { - return false - } - return resolved.replace(/\.(?:[cm]?[jt]s|[jt]sx)$/u, '') === appStorePath -} - -function isUnshadowedRequire(sourceCode, node) { - if (node.callee.type !== 'Identifier' || node.callee.name !== 'require') return false - let scope = sourceCode.getScope(node) - while (scope) { - const variable = scope.set.get('require') - if (variable) return variable.defs.length === 0 - scope = scope.upper - } - return true -} - -/** @type {import('eslint').Rule.RuleModule} */ -export default { - meta: { - type: 'problem', - docs: { - description: 'Disallow imports and re-exports of the legacy app detail store.', - }, - schema: [], - messages: { - legacyAppStore: - 'Do not import the legacy app store. Read app details through generated consoleQuery APIs keyed by appId, and keep UI state in the owning feature.', - }, - }, - create(context) { - function checkSource(source) { - if (source && isAppStore(source.value, context.filename)) { - context.report({ node: source, messageId: 'legacyAppStore' }) - } - } - - return { - ImportDeclaration(node) { - checkSource(node.source) - }, - ExportNamedDeclaration(node) { - checkSource(node.source) - }, - ExportAllDeclaration(node) { - checkSource(node.source) - }, - ImportExpression(node) { - checkSource(node.source) - }, - TSImportType(node) { - checkSource(node.source) - }, - TSExternalModuleReference(node) { - checkSource(node.expression) - }, - CallExpression(node) { - if (isUnshadowedRequire(context.sourceCode, node)) checkSource(node.arguments[0]) - }, - } - }, -} diff --git a/web/plugins/eslint/rules/no-app-store-imports.test.js b/web/plugins/eslint/rules/no-app-store-imports.test.js deleted file mode 100644 index d133a140dcc..00000000000 --- a/web/plugins/eslint/rules/no-app-store-imports.test.js +++ /dev/null @@ -1,135 +0,0 @@ -import { spawnSync } from 'node:child_process' -import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' -import { createRequire } from 'node:module' -import { tmpdir } from 'node:os' -import { dirname, join, relative, resolve } from 'node:path' -import { expect, it } from 'vite-plus/test' - -const appStore = resolve(import.meta.dirname, '../../../app/components/app/store') -const message = - 'Do not import the legacy app store. Read app details through generated consoleQuery APIs keyed by appId, and keep UI state in the owning feature.' - -function withFixture(run) { - const require = createRequire(import.meta.url) - const viteRequire = createRequire(require.resolve('vite-plus/package.json')) - const oxlintBin = join(dirname(dirname(viteRequire.resolve('oxlint'))), 'bin', 'oxlint') - const directory = realpathSync(mkdtempSync(join(tmpdir(), 'dify-app-store-imports-'))) - try { - writeFileSync( - join(directory, 'plugin.mjs'), - `export { default } from ${JSON.stringify(require.resolve('../index.js'))};`, - ) - writeFileSync( - join(directory, '.oxlintrc.json'), - JSON.stringify({ - categories: { correctness: 'off' }, - jsPlugins: ['./plugin.mjs'], - rules: { 'dify/no-app-store-imports': 'error' }, - }), - ) - function lint(...args) { - const result = spawnSync( - process.execPath, - [oxlintBin, '--config', '.oxlintrc.json', '--format', 'json', ...args], - { cwd: directory, encoding: 'utf8' }, - ) - expect(result.error).toBeUndefined() - expect([0, 1], result.stderr + result.stdout).toContain(result.status) - return { status: result.status, ...JSON.parse(result.stdout) } - } - return run({ directory, lint }) - } finally { - rmSync(directory, { recursive: true, force: true }) - } -} - -it('rejects value, type, namespace, and side-effect imports through both aliases', () => { - withFixture(({ directory, lint }) => { - writeFileSync( - join(directory, 'fixture.tsx'), - [ - "import { useStore as renamed } from '@/app/components/app/store'", - "import * as store from '~@/app/components/app/store.ts'", - "import type { useStore } from '@/app/components/app/store'", - "import '@/app/components/app/store'", - "import '@/app/components/app/store.ts?legacy'", - "export * from '~@/app/components/app/store#legacy'", - ].join('\n'), - ) - const result = lint('fixture.tsx') - expect(result.status).toBe(1) - expect( - result.diagnostics.map(({ code, message: diagnostic, labels }) => ({ - code, - message: diagnostic, - line: labels[0].span.line, - })), - ).toEqual( - [1, 2, 3, 4, 5, 6].map((line) => ({ - code: 'dify(no-app-store-imports)', - message, - line, - })), - ) - }) -}) - -it('rejects resolved relative paths, re-exports, dynamic imports, and require forms', () => { - withFixture(({ directory, lint }) => { - const source = JSON.stringify(relative(directory, appStore)) - writeFileSync( - join(directory, 'fixture.ts'), - [ - `import { useStore } from ${source}`, - `export { useStore as store } from ${source}`, - `export * from ${source}`, - `export type { useStore as StoreType } from ${source}`, - `const lazy = import(${source})`, - `const required = require(${source})`, - `import store = require(${source})`, - `type Store = typeof import(${source})`, - `import ${JSON.stringify(`${appStore}.js`)}`, - `const variant = import(${JSON.stringify(`${relative(directory, appStore)}.ts?legacy#source`)})`, - ].join('\n'), - ) - const result = lint('fixture.ts') - expect(result.status).toBe(1) - expect(result.diagnostics.map(({ labels }) => labels[0].span.line)).toEqual([ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, - ]) - }) -}) - -it('allows other feature stores, computed sources, mocks, and shadowed require', () => { - withFixture(({ directory, lint }) => { - writeFileSync( - join(directory, 'fixture.ts'), - [ - "import { useStore } from '@/app/components/workflow-app/store'", - "export * from '@/app/components/workflow/store'", - "import './store'", - "import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'", - "vi.mock('@/app/components/app/store', () => ({}))", - "function local(require: Function) { require('@/app/components/app/store') }", - 'function dynamic(source: string) { return import(source) }', - ].join('\n'), - ) - expect(lint('fixture.ts').diagnostics).toEqual([]) - }) -}) - -it('baselines existing imports while rejecting a new file and an increased import count', () => { - withFixture(({ directory, lint }) => { - const source = "import { useStore } from '@/app/components/app/store'\n" - writeFileSync(join(directory, 'existing.ts'), source) - lint('--suppress-all', 'existing.ts') - expect(JSON.parse(readFileSync(join(directory, 'oxlint-suppressions.json'), 'utf8'))).toEqual({ - 'existing.ts': { 'dify/no-app-store-imports': { count: 1 } }, - }) - expect(lint('existing.ts').diagnostics).toEqual([]) - writeFileSync(join(directory, 'added.ts'), source) - expect(lint('added.ts').status).toBe(1) - writeFileSync(join(directory, 'existing.ts'), `${source}import '@/app/components/app/store'`) - expect(lint('existing.ts').status).toBe(1) - }) -}) diff --git a/web/test/console/query-data.ts b/web/test/console/query-data.ts index 1c7258959dc..fbb53a6c53a 100644 --- a/web/test/console/query-data.ts +++ b/web/test/console/query-data.ts @@ -2,6 +2,7 @@ import type { EducationStatusResponse, GetAccountProfileResponse, } from '@dify/contracts/api/console/account/types.gen' +import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' import type { GetFeaturesResponse } from '@dify/contracts/api/console/features/types.gen' import type { GetSystemFeaturesLicenseResponse, @@ -165,6 +166,14 @@ export const seedAppDslVersion = (queryClient: QueryClient, appDslVersion = '0.6 queryClient.setQueryData(getAppDslVersionQueryKey(), { app_dsl_version: appDslVersion }) } +export const seedAppDetail = (queryClient: QueryClient, appDetail: AppDetailWithSite) => { + queryClient.setQueryData( + consoleQuery.apps.byAppId.get.queryKey({ input: { params: { app_id: appDetail.id } } }), + appDetail, + ) + return appDetail +} + export type ConsoleQueryTestOptions = { /** * Partial overrides for the systemFeatures payload. When omitted, the cache