refactor(web): retire the app detail store in favour of the query cache (#44166)

This commit is contained in:
yyh
2026-10-15 12:57:57 +00:00
committed by GitHub
parent 1f3fdbc0d3
commit f7840ab213
57 changed files with 802 additions and 1228 deletions
-1
View File
@@ -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',
+2 -151
View File
@@ -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
}
}
}
}
@@ -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<typeof renderWithConsoleQuery>[0]) =>
renderWithConsoleQuery(ui, {
const render = (
ui: Parameters<typeof renderWithConsoleQuery>[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<AppDetailWithSite> = {}) =>
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', () => {
</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(
<AppDetailLayout appId="app-1">
<div>App page content</div>
</AppDetailLayout>,
)
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(
<AppDetailLayout appId="app-1">
<div>App page content</div>
</AppDetailLayout>,
{ 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(
<AppDetailLayout appId="app-1">
<div>App page content</div>
</AppDetailLayout>,
{ 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(
<AppDetailLayout appId="app-1">
<div>App page content</div>
</AppDetailLayout>,
{
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()
})
})
@@ -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<typeof useTranslatio
return t(($) => $['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<IAppDetailLayoutProps> = (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<IAppDetailLayoutProps> = (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 ? (
<div className="flex min-w-0 grow items-center justify-center bg-background-body">
<LoadingPlaceholder />
</div>
@@ -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(<Route />)
@@ -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 () => {
@@ -23,6 +23,11 @@ type SubscriptionEvent = {
let subscriptionCallback: ((event: SubscriptionEvent) => void) | null = null
vi.mock('@/next/navigation', async (importOriginal) => ({
...(await importOriginal<typeof import('@/next/navigation')>()),
useParams: () => ({}),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
@@ -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<typeof import('@/next/navigation')>()),
useParams: () => ({ appId: 'app-1' }),
}))
const toastErrorSpy = vi.spyOn(toast, 'error').mockReturnValue('toast-error')
let latestFormProps: Record<string, any> | null = null
vi.mock('../form-fields', () => ({
@@ -106,8 +114,10 @@ const createPayload = (overrides: Partial<InputVar> = {}): 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(
<DebugConfigurationContext.Provider
value={
{
@@ -119,15 +129,14 @@ const renderConfigModal = (payload: InputVar = createPayload()) =>
>
<ConfigModal isCreate isShow payload={payload} onClose={vi.fn()} onConfirm={vi.fn()} />
</DebugConfigurationContext.Provider>,
{ 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 () => {
@@ -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<typeof import('next/navigation')>('next/navigation')),
useParams: () => ({}),
vi.mock('@/next/navigation', async (importOriginal) => ({
...(await importOriginal<typeof import('@/next/navigation')>()),
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> = {}): 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', () => {
@@ -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<IConfigModalProps> = ({
const queryClient = useQueryClient()
const { type, options, max_length } = tempPayload
const modalRef = useRef<HTMLDivElement>(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(
@@ -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<typeof useConfiguration>) => {
let mockAppPermissionKeys: string[] = [AppACLPermission.Edit, AppACLPermission.ReleaseAndVersion]
const createRouteAppDetail = (overrides: Partial<AppDetailWithSite> = {}) =>
createAppDetailFixture({
id: 'app-1',
model_config: createAppModelConfigFixture({
updated_at: 1710000000,
}),
mode: AppModeEnum.CHAT,
permission_keys: mockAppPermissionKeys,
...overrides,
})
const createSeededQueryClient = (appDetail: Partial<AppDetailWithSite> = {}) => {
const queryClient = createTestQueryClient()
seedAccountProfileQuery(queryClient, { id: 'user-1' })
seedAppDetail(queryClient, createRouteAppDetail(appDetail))
return queryClient
}
const renderHook = (
callback: () => ReturnType<typeof useConfiguration>,
appDetail?: Partial<AppDetailWithSite>,
) => {
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<any>('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<string, unknown>) => 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<typeof import('@/next/navigation')>()),
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<typeof actual.consoleQuery.apps.byAppId.get.queryOptions>[0],
) => ({
...actual.consoleQuery.apps.byAppId.get.queryOptions(options),
queryFn: async () => createRouteAppDetail(),
}),
},
modelConfig: {
post: {
mutationOptions: (options: Record<string, unknown>) => ({
@@ -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 () => {
@@ -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({
@@ -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<string, unknown>) => unknown) => {
const appDetail = appDetailAvailable
? {
...mockBuiltInEnvironment.appDetail,
permission_keys: appPermissionKeys,
}
: undefined
return selector({ appDetail })
},
vi.mock('@/next/navigation', async (importOriginal) => ({
...(await importOriginal<typeof import('@/next/navigation')>()),
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(<AppDeploy />)
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 = []
@@ -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
+6 -5
View File
@@ -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 <LoadingPlaceholder className="h-full" />
const { canDeploy, canViewAccessPoint } = getAppACLCapabilities(appDetail.permission_keys, {
currentUserId,
resourceMaintainer: appDetail.maintainer,
-15
View File
@@ -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<State & Action>((set) => ({
appDetail: undefined,
setAppDetail: (appDetail) => set(() => ({ appDetail })),
}))
@@ -23,6 +23,7 @@ vi.mock('@/next/navigation', () => ({
push: routerPush,
}),
usePathname: () => '/',
useParams: () => ({}),
}))
let debouncedSearchQuery: string | undefined
@@ -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<CommandContext>(
() => ({
@@ -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<typeof useAppStore.getState>['appDetail'])
;(usePathname as Mock).mockReturnValue('/datasets')
render(
<MainNavLayout initialDetailSidebarMode="expand">
<div>content</div>
</MainNavLayout>,
)
expect(useAppStore.getState().appDetail).toBeUndefined()
})
})
-22
View File
@@ -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 = ({
>
<SkipNav>{t(($) => $['navigation.skipToMain'])}</SkipNav>
{appId && <AppDetailPrefetch appId={appId} />}
<AppDetailStoreCleanup />
{hideMainNavigation ? null : useDetailSidebar ? (
detailSidebar
) : useResponsiveNavigation ? (
@@ -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: <T,>(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<typeof import('@/next/navigation')>()),
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(<WorkflowApp />)
@@ -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<typeof import('@/next/navigation')>()),
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(
<StrictMode>
@@ -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,
@@ -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<typeof import('@/next/navigation')>()),
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(<WorkflowMain nodes={[]} edges={[]} viewport={{ x: 0, y: 0, zoom: 1 }} />)
@@ -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<Shape>
const mockUseIsChatMode = vi.fn()
let appStoreState: AppStoreState
let workflowStoreState: WorkflowStoreState
vi.mock('@/app/components/app/store', () => ({
useStore: <T,>(selector: (state: AppStoreState) => T) => selector(appStoreState),
vi.mock('@/next/navigation', async (importOriginal) => ({
...(await importOriginal<typeof import('@/next/navigation')>()),
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(<WorkflowPanel />, { 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(<WorkflowPanel />, { 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(<WorkflowPanel />, {
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(<WorkflowPanel />, {
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(<WorkflowPanel />, { initialStoreState: workflowStoreState })
renderPanel(workflowStoreState)
expect(await screen.findByTestId('record-panel')).toBeInTheDocument()
expect(screen.getByTestId('workflow-preview-panel')).toBeInTheDocument()
@@ -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<typeof import('@/service/base')>()),
request: async (url: string) => Response.json(await mockAppResponse(url)),
vi.mock('@/next/navigation', async (importOriginal) => ({
...(await importOriginal<typeof import('@/next/navigation')>()),
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(<FeaturesTrigger />)
// Act
await user.click(screen.getByRole('button', { name: 'publisher-publish' }))
// Assert
await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalled()
})
consoleErrorSpy.mockRestore()
})
})
})
@@ -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 = () => {
</Button>
)}
<AppPublisher
appId={appID!}
appId={appID}
{...{
publishedAt,
draftUpdatedAt,
@@ -1,15 +1,15 @@
import type { HeaderProps } from '@/app/components/workflow/header'
import { memo, useCallback, useMemo } from 'react'
import { useStore as useAppStore } from '@/app/components/app/store'
import Header from '@/app/components/workflow/header'
import { useStore } from '@/app/components/workflow/store'
import { useParams } from '@/next/navigation'
import { useResetWorkflowVersionHistory } from '@/service/use-workflow'
import { useIsChatMode } from '../../hooks/use-is-chat-mode'
import ChatVariableTrigger from './chat-variable-trigger'
import FeaturesTrigger from './features-trigger'
const WorkflowHeader = () => {
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 {
@@ -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<HTMLDivElement>(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,
@@ -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 && <WorkflowPanelOnLeft appId={appDetail.id} />,
left: <WorkflowPanelOnLeft appId={appId} />,
right: <WorkflowPanelOnRight />,
},
versionHistoryPanelProps,
}
}, [appDetail, versionHistoryPanelProps])
}, [appId, versionHistoryPanelProps])
return <Panel {...panelProps} />
}
@@ -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<typeof import('@/next/navigation')>()),
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)
})
@@ -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 = <Result>(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 = <Result>(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: <T>(selector: (state: typeof appStoreState) => T): T => selector(appStoreState),
vi.mock('@/next/navigation', async (importOriginal) => ({
...(await importOriginal<typeof import('@/next/navigation')>()),
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())
@@ -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: <T>(selector: (state: typeof appStoreState) => T): T => selector(appStoreState),
vi.mock('@/next/navigation', async (importOriginal) => ({
...(await importOriginal<typeof import('@/next/navigation')>()),
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<boolean> | undefined
act(() => {
@@ -148,7 +155,7 @@ describe('useWorkflowRefreshDraft — notUpdateCanvas parameter', () => {
}),
)
let firstIsCurrent = true
const { result } = renderHook(() => useWorkflowRefreshDraft())
const { result } = renderRefreshDraftHook()
let firstRefresh: Promise<boolean> | undefined
let secondRefresh: Promise<boolean> | 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 () => {
@@ -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<typeof import('@/next/navigation')>()),
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({
@@ -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<Record<string, unknown>> = []
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: <T>(selector: (state: typeof appStoreState) => T): T => selector(appStoreState),
vi.mock('@/next/navigation', async (importOriginal) => ({
...(await importOriginal<typeof import('@/next/navigation')>()),
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'])
@@ -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
}
@@ -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 = {
@@ -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) => {
@@ -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(
@@ -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,
+10 -8
View File
@@ -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,
@@ -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
// ---------------------------------------------------------------------------
@@ -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 = []
})
@@ -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<typeof import('@/next/navigation')>()),
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)
@@ -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
@@ -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<typeof import('@/next/navigation')>()),
useParams: () => ({ appId: 'app-1' }),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
@@ -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<BasePanelProps> = ({ 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<BasePanelProps> = ({ id, data, children }) => {
{!isStartPlaceholderPanel && (
<TabsPanel value={TabType.lastRun} className="flex flex-1 flex-col overflow-y-auto">
<LastRun
appId={appDetail?.id || ''}
appId={routeAppId || ''}
nodeId={id}
canSingleRun={isSupportSingleRun}
runningStatus={runningStatus}
@@ -1,10 +1,11 @@
import { QueryClient } from '@tanstack/react-query'
import { act, waitFor } from '@testing-library/react'
import { useSetAtom } from 'jotai'
import { useStore as useAppStore } from '@/app/components/app/store'
import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
import { agentComposerDraftAtom } from '@/features/agent-v2/agent-composer/store'
import { AgentScope } from '@/features/agent-v2/analytics'
import { createConsoleQueryClient, seedAppDetail } from '@/test/console/query-data'
import { createAppDetailFixture } from '@/test/fixtures/app'
import { AppModeEnum } from '@/types/app'
import { FlowType } from '@/types/common'
import { renderWorkflowHook } from '../../../__tests__/workflow-test-env'
@@ -146,68 +147,87 @@ vi.mock('@/utils/create-app-tracking', () => ({
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<typeof import('@/next/navigation')>()),
useParams: () => ({ appId: 'app-1' }),
}))
vi.mock('@/service/console', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/console')>()
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: {
@@ -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<typeof import('@/next/navigation')>()),
useParams: () => ({ appId: 'app-1' }),
}))
vi.mock('@/service/console', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/console')>()
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<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => {
@@ -495,7 +512,6 @@ function createDeferredPromise<T>() {
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 })))
@@ -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<typeof import('@/next/navigation')>()),
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(() => {
@@ -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 (
@@ -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<typeof import('@/next/navigation')>()),
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 = <Result>(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,
}))
@@ -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) {
+10 -2
View File
@@ -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
}
-2
View File
@@ -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,
@@ -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])
},
}
},
}
@@ -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)
})
})
+9
View File
@@ -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