mirror of
https://github.com/langgenius/dify.git
synced 2026-10-16 07:20:34 +08:00
Merge remote-tracking branch 'origin/main' into deploy/konwledge
This commit is contained in:
@@ -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
@@ -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
|
||||
@@ -1843,11 +1802,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
|
||||
@@ -1957,21 +1911,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
|
||||
@@ -2387,71 +2331,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
|
||||
@@ -2463,26 +2357,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
|
||||
@@ -2580,9 +2463,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/hooks/use-checklist.ts": {
|
||||
"dify/no-app-store-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-empty-object-type": {
|
||||
"count": 1
|
||||
}
|
||||
@@ -2860,9 +2740,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
|
||||
},
|
||||
@@ -2920,15 +2797,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": {
|
||||
@@ -3043,11 +2912,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
|
||||
@@ -3078,9 +2942,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
|
||||
}
|
||||
@@ -3090,11 +2951,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
|
||||
@@ -4070,11 +3926,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
|
||||
@@ -4557,4 +4408,4 @@
|
||||
"count": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+64
-64
@@ -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: {
|
||||
|
||||
+16
-7
@@ -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 () => {
|
||||
|
||||
+14
-8
@@ -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 () => {
|
||||
|
||||
+9
-10
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
@@ -358,19 +356,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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
+7
-34
@@ -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())
|
||||
|
||||
+29
-24
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+3
-5
@@ -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: {
|
||||
|
||||
+20
-4
@@ -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 })))
|
||||
|
||||
+5
-5
@@ -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(() => {
|
||||
|
||||
+4
-13
@@ -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 (
|
||||
|
||||
+19
-10
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user