mirror of
https://github.com/langgenius/dify.git
synced 2026-10-16 15:35:32 +08:00
refactor(query): migrate imperative APIs to query semantics (#41746)
This commit is contained in:
@@ -12,7 +12,9 @@ Flag:
|
||||
- A page/tab-level section component becoming the data owner without needing a shared snapshot or shared loading/error/empty UI.
|
||||
- Feature code promoted to shared only because it appears once or might be reused later.
|
||||
|
||||
Accept repeated TanStack Query calls in siblings when each component independently consumes the data. Cache deduplication is not a reason to hoist by itself.
|
||||
Accept repeated TanStack Query hooks for the same key and input in Client Component siblings under one QueryClient;
|
||||
shared cache is not a reason to hoist. Separate Server Component QueryClients do not share it, so request-level
|
||||
deduplication needs an identified request-local cache or verified framework or transport owner.
|
||||
|
||||
## Component Boundaries
|
||||
|
||||
|
||||
@@ -22,10 +22,22 @@ Flag:
|
||||
- Fake fallback IDs or placeholder inputs used to force a query to run.
|
||||
- Query results copied into local state for rendering.
|
||||
- Shared query behavior such as invalidation, stale defaults, or retry rules reimplemented at call sites.
|
||||
- `prefetchQuery` treated as a hard gate or as returning data/errors to the caller.
|
||||
- Deprecated imperative reads such as `fetchQuery`, `prefetchQuery`, `ensureQueryData`, or their infinite variants when
|
||||
the current `query` or `infiniteQuery` contract applies.
|
||||
|
||||
Use `useQuery(consoleQuery.xxx.queryOptions(...))` or `useQuery(marketplaceQuery.xxx.queryOptions(...))` directly unless a feature hook performs real orchestration.
|
||||
|
||||
For imperative access, treat the choices as independent dimensions:
|
||||
|
||||
- `query` or `infiniteQuery` resolves the generated query and returns its data.
|
||||
- `staleTime` decides whether cached data satisfies this call: `0` treats it as stale, a finite value accepts a freshness
|
||||
window, `Infinity` accepts it until invalidation, and `'static'` accepts available data even after invalidation.
|
||||
- `select` projects the resolved value without replacing cached query-function data. An imperative query defaults to no
|
||||
retries when `retry` is not configured; `enabled` is observer-only, so guard before a conditional call.
|
||||
- `await` blocks the current flow, `return` transfers the Promise to the caller, and `void` discards the result without
|
||||
handling rejection. Handle rejection before discarding a potentially rejecting Promise; use `.catch(noop)` only for
|
||||
intentional silence or feedback owned elsewhere, and preserve rejection for hard gates.
|
||||
|
||||
## Mutations
|
||||
|
||||
Flag:
|
||||
@@ -44,12 +56,18 @@ Flag:
|
||||
|
||||
- Request-time auth, setup, workspace role, or tenant decisions moved into static `next.config redirects()`.
|
||||
- Dynamic role gates depending on `workspaces.current` implemented as static path redirects.
|
||||
- Authorization logic depending on soft `prefetchQuery`.
|
||||
- Authorization logic depending on an imperative query whose rejection is swallowed.
|
||||
- Removing a client fallback before server API unavailable behavior is defined.
|
||||
- Global placeholder query contracts introduced to solve a route-local Suspense issue.
|
||||
- Branding-sensitive UI reading placeholder defaults without checking pending/placeholder state.
|
||||
- A Server Component rendering or passing an imperative query result that the browser can independently revalidate,
|
||||
leaving server and client output with different owners.
|
||||
- A non-blocking Server Component query without pending-query dehydration, Next-compatible error redaction, a
|
||||
`HydrationBoundary` covering the same-key client consumer, or an explicit Suspense and SSR-content decision.
|
||||
|
||||
Separate hard gates from soft prefetches. `fetchQuery` can be a server decision boundary; `prefetchQuery` is cache warmup.
|
||||
Hard gates await `query` or `infiniteQuery` and preserve rejection; soft prefetches handle failure at the fallback owner.
|
||||
Treat Server Components as prefetch-and-dehydrate owners by default, rendering returned data only under exclusive server
|
||||
ownership or a freshness contract that prevents server/client drift.
|
||||
|
||||
## Workspace And Tenant
|
||||
|
||||
|
||||
@@ -18,7 +18,9 @@ Read this document when a component consumes generated contracts, nullable API v
|
||||
- If query input comes from atom state, keep it in `atomWithQuery`; do not unwrap the atom in a component solely to call `useQuery`.
|
||||
- For missing required input, branch the whole generated input with `skipToken`. Add `enabled` only for an independent execution condition; do not put `skipToken` inside a placeholder payload or coerce IDs to empty strings.
|
||||
- Return generated `queryOptions()`, `infiniteOptions()`, or `mutationOptions()` directly from TanStack Query atoms. Pass supported options into the generated call instead of spreading into a parallel object.
|
||||
- Share the exact options between prefetch and render when they represent the same request. Do not extract option helpers merely to reuse input construction.
|
||||
- For the same logical request, preserve key, input, operation, and result contracts. Reuse exact options only when
|
||||
transport, context, and cache policy are shared; imperative and observer freshness may differ. Do not extract a helper
|
||||
merely to reuse input construction.
|
||||
- Avoid pass-through service hooks that only rename generated options. Keep feature hooks for actual orchestration or shared domain behavior.
|
||||
|
||||
## Mutations And Cache
|
||||
@@ -31,12 +33,21 @@ Read this document when a component consumes generated contracts, nullable API v
|
||||
## Prefetch And Hidden Surfaces
|
||||
|
||||
- Prefetch expensive secondary content from the trigger or menu-open event when it benefits the visible path. Do not mount hidden subscribers solely to warm the cache.
|
||||
- `prefetchQuery` is cache warmup, not an authorization or availability gate. Use a hard fetch boundary when the server must decide whether rendering may proceed.
|
||||
- Use `query` or `infiniteQuery` for imperative access. `staleTime` defines cache acceptance; `select` projects the return
|
||||
value without replacing cached query-function data. Imperative queries default to no retries when `retry` is not
|
||||
configured, and observer-only `enabled` does not prevent an imperative call.
|
||||
- `await` blocks, `return` transfers the Promise, and `void` discards its value without handling rejection. Handle
|
||||
rejection before discarding a potentially rejecting Promise; use `.catch(noop)` only for intentional silence or
|
||||
feedback owned elsewhere. Hard server gates await the query and preserve rejection.
|
||||
|
||||
## SSR, Authentication, And Workspace
|
||||
|
||||
- Static configuration owns path-invariant routing. Request-dependent authentication, setup, role, and tenant decisions belong to SSR or runtime decision boundaries.
|
||||
- Distinguish soft SSR cache warming from authoritative decisions. Prefetched or placeholder data must not grant access or represent successful availability.
|
||||
- Treat Server Components as query prefetch-and-dehydrate owners by default. Do not render or pass an imperative query
|
||||
result when a browser observer can revalidate the same data unless ownership and freshness explicitly prevent drift.
|
||||
- Non-blocking RSC streaming requires pending-query dehydration without redacting Next.js server errors, a
|
||||
`HydrationBoundary` around the same-key client consumer, and Suspense when that content must be server-rendered.
|
||||
- Never reuse tenant-scoped state after switching workspaces. Discard it at the switch boundary or isolate it by workspace identity.
|
||||
- Do not make product or authorization decisions from bootstrap defaults. Wait for authoritative data, or render an explicit loading or error state.
|
||||
- Keep loading and Suspense behavior inside the feature that owns the request. Do not add fake global data merely to bypass that boundary.
|
||||
|
||||
@@ -14,7 +14,9 @@ Read this document when adding, moving, splitting, or refactoring React componen
|
||||
|
||||
- Put state, data access, loading, empty, error, and handlers in the lowest owner that uses them and whose mounted lifetime matches the required persistence.
|
||||
- Keep coordination in a parent only when it needs one consistent snapshot, the value must intentionally survive the local owner's unmount, or the parent coordinates submission, shared selection, batch behavior, navigation, or cross-section loading and errors.
|
||||
- Repeated TanStack Query calls in siblings are acceptable when each sibling independently consumes the data; the cache already deduplicates requests.
|
||||
- Repeated TanStack Query hooks for the same key and input in Client Component siblings under one QueryClient are
|
||||
acceptable; shared cache is not a reason to hoist. Separate Server Component QueryClients do not share it, so
|
||||
request-level deduplication needs an identified request-local cache or verified framework or transport owner.
|
||||
- Pass stable domain identity across boundaries. Do not pass raw server data together with separately derived flags for the same concept.
|
||||
- One pass-through prop layer is acceptable. Repeated forwarding means ownership should move closer to the consumer or into feature-scoped shared state.
|
||||
- Do not replace prop drilling with one large view-model hook. Move each query, derived value, and handler to the concrete owner that consumes it.
|
||||
|
||||
@@ -180,6 +180,29 @@ describe('CommonLayoutHydrationBoundary', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['workspace', 'permissions'] as const)(
|
||||
'should keep the Common shell recoverable when the %s query fails',
|
||||
async (target) => {
|
||||
const failedQueryFn =
|
||||
target === 'workspace' ? mocks.workspaceQueryFn : mocks.permissionQueryFn
|
||||
failedQueryFn.mockRejectedValue(new Error(`${target} unavailable`))
|
||||
const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary')
|
||||
|
||||
const element = await CommonLayoutHydrationBoundary({ children: <div>Common shell</div> })
|
||||
const state = (element as ReactElement<{ state: DehydratedState }>).props.state
|
||||
|
||||
expect(mocks.redirect).not.toHaveBeenCalled()
|
||||
expect(state.queries.map((query) => query.queryKey)).not.toContainEqual(
|
||||
target === 'workspace'
|
||||
? ['console', 'workspaces', 'current', 'summary', 'get']
|
||||
: [
|
||||
['console', 'workspaces', 'current', 'rbac', 'myPermissions', 'get'],
|
||||
{ type: 'query' },
|
||||
],
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
it('should redirect unauthorized users to the refresh route with the current path', async () => {
|
||||
mocks.basePath = '/workflow'
|
||||
mocks.profileQueryFn.mockRejectedValue(
|
||||
|
||||
@@ -2,14 +2,14 @@ import type { ReactElement, ReactNode } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
ensureSystemFeatures: vi.fn(),
|
||||
getSystemFeatures: vi.fn(),
|
||||
redirect: vi.fn((_href: string) => {
|
||||
throw new Error('NEXT_REDIRECT')
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/system-features/server', () => ({
|
||||
ensureSystemFeatures: () => mocks.ensureSystemFeatures(),
|
||||
getSystemFeatures: () => mocks.getSystemFeatures(),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
@@ -25,19 +25,19 @@ async function renderLayout(children: ReactNode) {
|
||||
describe('NewKnowledgeLayout', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.ensureSystemFeatures.mockResolvedValue({ knowledge_fs_enabled: true })
|
||||
mocks.getSystemFeatures.mockResolvedValue({ knowledge_fs_enabled: true })
|
||||
})
|
||||
|
||||
it('renders new knowledge routes when KnowledgeFS is enabled', async () => {
|
||||
await renderLayout(<div>New knowledge content</div>)
|
||||
|
||||
expect(mocks.ensureSystemFeatures).toHaveBeenCalledOnce()
|
||||
expect(mocks.getSystemFeatures).toHaveBeenCalledOnce()
|
||||
expect(screen.getByText('New knowledge content')).toBeInTheDocument()
|
||||
expect(mocks.redirect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('redirects to datasets when KnowledgeFS is disabled', async () => {
|
||||
mocks.ensureSystemFeatures.mockResolvedValue({ knowledge_fs_enabled: false })
|
||||
mocks.getSystemFeatures.mockResolvedValue({ knowledge_fs_enabled: false })
|
||||
const { default: Layout } = await import('../layout')
|
||||
|
||||
await expect(Layout({ children: <div>New knowledge content</div> })).rejects.toThrow(
|
||||
@@ -49,7 +49,7 @@ describe('NewKnowledgeLayout', () => {
|
||||
|
||||
it('preserves System Features failures', async () => {
|
||||
const error = new Error('System Features unavailable')
|
||||
mocks.ensureSystemFeatures.mockRejectedValue(error)
|
||||
mocks.getSystemFeatures.mockRejectedValue(error)
|
||||
const { default: Layout } = await import('../layout')
|
||||
|
||||
await expect(Layout({ children: <div>New knowledge content</div> })).rejects.toBe(error)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { ensureSystemFeatures } from '@/features/system-features/server'
|
||||
import { getSystemFeatures } from '@/features/system-features/server'
|
||||
import { redirect } from '@/next/navigation'
|
||||
|
||||
export default async function Layout({ children }: { children: ReactNode }) {
|
||||
const systemFeatures = await ensureSystemFeatures()
|
||||
const systemFeatures = await getSystemFeatures()
|
||||
|
||||
if (!systemFeatures.knowledge_fs_enabled) redirect('/datasets')
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
|
||||
import { dehydrate, HydrationBoundary, noop } from '@tanstack/react-query'
|
||||
import { getQueryClient } from '@/app/get-query-client'
|
||||
import { serverUserProfileQueryOptions } from '@/features/account-profile/server'
|
||||
import { headers } from '@/next/headers'
|
||||
@@ -63,19 +63,23 @@ export async function CommonLayoutHydrationBoundary({ children }: { children: Re
|
||||
const context = await getServerConsoleClientContext()
|
||||
|
||||
await Promise.all([
|
||||
queryClient.fetchQuery(serverUserProfileQueryOptions()),
|
||||
queryClient.prefetchQuery(
|
||||
serverConsoleQuery.workspaces.current.summary.get.queryOptions({
|
||||
context,
|
||||
retry: false,
|
||||
}),
|
||||
),
|
||||
queryClient.prefetchQuery(
|
||||
serverConsoleQuery.workspaces.current.rbac.myPermissions.get.queryOptions({
|
||||
context,
|
||||
retry: false,
|
||||
}),
|
||||
),
|
||||
queryClient.query(serverUserProfileQueryOptions()),
|
||||
queryClient
|
||||
.query(
|
||||
serverConsoleQuery.workspaces.current.summary.get.queryOptions({
|
||||
context,
|
||||
retry: false,
|
||||
}),
|
||||
)
|
||||
.catch(noop),
|
||||
queryClient
|
||||
.query(
|
||||
serverConsoleQuery.workspaces.current.rbac.myPermissions.get.queryOptions({
|
||||
context,
|
||||
retry: false,
|
||||
}),
|
||||
)
|
||||
.catch(noop),
|
||||
])
|
||||
} catch (error) {
|
||||
await handleProfileError(error)
|
||||
|
||||
@@ -9,7 +9,7 @@ describe('getQueryClient', () => {
|
||||
const queryPromise = new Promise<string>((resolve) => {
|
||||
resolveQuery = resolve
|
||||
})
|
||||
const prefetchPromise = queryClient.prefetchQuery({
|
||||
const queryExecution = queryClient.query({
|
||||
queryKey,
|
||||
queryFn: () => queryPromise,
|
||||
})
|
||||
@@ -24,7 +24,7 @@ describe('getQueryClient', () => {
|
||||
)
|
||||
|
||||
resolveQuery('ready')
|
||||
await prefetchPromise
|
||||
await queryExecution
|
||||
queryClient.removeQueries({ queryKey })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
let queryClient: QueryClient
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getSystemFeatures: vi.fn(),
|
||||
dehydrateSystemFeatures: vi.fn(() => ({ mutations: [], queries: [] })),
|
||||
getOptionalSystemFeatures: vi.fn(),
|
||||
headers: vi.fn(async () => new Headers()),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/system-features/server', () => ({
|
||||
getSystemFeaturesQueryClient: () => queryClient,
|
||||
prefetchSystemFeatures: async () => {
|
||||
const queryOptions = {
|
||||
queryKey: ['console', 'system-features'],
|
||||
queryFn: mocks.getSystemFeatures,
|
||||
retry: false,
|
||||
}
|
||||
if (!queryClient.getQueryState(queryOptions.queryKey))
|
||||
await queryClient.prefetchQuery(queryOptions)
|
||||
return queryClient.getQueryData(queryOptions.queryKey)
|
||||
},
|
||||
dehydrateSystemFeatures: mocks.dehydrateSystemFeatures,
|
||||
getOptionalSystemFeatures: mocks.getOptionalSystemFeatures,
|
||||
}))
|
||||
|
||||
vi.mock('@/env', async (importOriginal) => {
|
||||
@@ -42,11 +30,10 @@ describe('Root layout System Features bootstrap', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.headers.mockResolvedValue(new Headers())
|
||||
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
})
|
||||
|
||||
it('caches the resolved System Features for dehydration', async () => {
|
||||
mocks.getSystemFeatures.mockResolvedValue({
|
||||
it('uses optional System Features for branded metadata', async () => {
|
||||
mocks.getOptionalSystemFeatures.mockResolvedValue({
|
||||
branding: {
|
||||
application_title: 'Acme AI',
|
||||
enabled: true,
|
||||
@@ -62,19 +49,10 @@ describe('Root layout System Features bootstrap', () => {
|
||||
template: '%s - Acme AI',
|
||||
},
|
||||
})
|
||||
|
||||
expect(mocks.getSystemFeatures).toHaveBeenCalledTimes(1)
|
||||
expect(queryClient.getQueryData(['console', 'system-features'])).toEqual({
|
||||
branding: {
|
||||
application_title: 'Acme AI',
|
||||
enabled: true,
|
||||
},
|
||||
deployment_edition: 'CLOUD',
|
||||
})
|
||||
})
|
||||
|
||||
it('points the icons at the branding favicon when one is configured', async () => {
|
||||
mocks.getSystemFeatures.mockResolvedValue({
|
||||
mocks.getOptionalSystemFeatures.mockResolvedValue({
|
||||
branding: {
|
||||
application_title: 'Acme AI',
|
||||
enabled: true,
|
||||
@@ -93,7 +71,7 @@ describe('Root layout System Features bootstrap', () => {
|
||||
})
|
||||
|
||||
it('falls back to the static favicon without branding', async () => {
|
||||
mocks.getSystemFeatures.mockResolvedValue({
|
||||
mocks.getOptionalSystemFeatures.mockResolvedValue({
|
||||
branding: { enabled: false },
|
||||
deployment_edition: 'CLOUD',
|
||||
})
|
||||
@@ -105,7 +83,7 @@ describe('Root layout System Features bootstrap', () => {
|
||||
})
|
||||
|
||||
it('falls back to the static favicon when branding is enabled without one', async () => {
|
||||
mocks.getSystemFeatures.mockResolvedValue({
|
||||
mocks.getOptionalSystemFeatures.mockResolvedValue({
|
||||
branding: { application_title: 'Acme AI', enabled: true, favicon: '' },
|
||||
deployment_edition: 'CLOUD',
|
||||
})
|
||||
@@ -116,8 +94,8 @@ describe('Root layout System Features bootstrap', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the client recovery path when the server prefetch fails', async () => {
|
||||
mocks.getSystemFeatures.mockRejectedValue(new Error('system features unavailable'))
|
||||
it('renders the client recovery path when optional System Features are unavailable', async () => {
|
||||
mocks.getOptionalSystemFeatures.mockResolvedValue(undefined)
|
||||
const { default: RootLayout, generateMetadata } = await import('../layout')
|
||||
|
||||
await expect(RootLayout({ children: <div>App</div> })).resolves.toBeDefined()
|
||||
@@ -127,7 +105,5 @@ describe('Root layout System Features bootstrap', () => {
|
||||
template: '%s - Dify',
|
||||
},
|
||||
})
|
||||
|
||||
expect(queryClient.getQueryData(['console', 'system-features'])).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,8 +16,12 @@ import { AppACLPermission } from '@/utils/permission'
|
||||
import { basePath } from '@/utils/var'
|
||||
import { AppPublisher } from '../index'
|
||||
|
||||
const render = (ui: React.ReactElement) =>
|
||||
const render = (
|
||||
ui: React.ReactElement,
|
||||
queryClient?: ReturnType<typeof createConsoleQueryClient>,
|
||||
) =>
|
||||
renderWithConsoleQuery(ui, {
|
||||
queryClient,
|
||||
systemFeatures: { webapp_auth: { enabled: true } },
|
||||
})
|
||||
|
||||
@@ -1118,6 +1122,7 @@ describe('AppPublisher', () => {
|
||||
|
||||
it('should refresh the shared workflow query and store after a collaborator publishes', async () => {
|
||||
const setPublishedAt = vi.fn()
|
||||
const queryClient = createConsoleQueryClient()
|
||||
const workflowStore = {
|
||||
getState: () => ({ setPublishedAt }),
|
||||
}
|
||||
@@ -1129,11 +1134,16 @@ describe('AppPublisher', () => {
|
||||
created_at: 1_710_000_300,
|
||||
hash: 'published-hash',
|
||||
})
|
||||
queryClient.setQueryData(['workflow', 'publish', 'app-1'], {
|
||||
created_at: 1_710_000_100,
|
||||
hash: 'stale-published-hash',
|
||||
})
|
||||
|
||||
render(
|
||||
<WorkflowContext value={workflowStore as any}>
|
||||
<AppPublisher publishedAt={1_710_000_100_000} />
|
||||
</WorkflowContext>,
|
||||
queryClient,
|
||||
)
|
||||
|
||||
act(() => {
|
||||
|
||||
@@ -155,7 +155,10 @@ export function usePublishController({
|
||||
|
||||
if (supportsMultiEnvironment) refreshAppDeploymentData(queryClient, appId)
|
||||
void queryClient
|
||||
.fetchQuery(appWorkflowQueryOptions(appId))
|
||||
.query({
|
||||
...appWorkflowQueryOptions(appId),
|
||||
staleTime: 0,
|
||||
})
|
||||
.then((publishedWorkflow) => {
|
||||
workflowStore?.getState().setPublishedAt(publishedWorkflow?.created_at ?? 0)
|
||||
})
|
||||
|
||||
@@ -21,7 +21,7 @@ const { configState, mockHeadersGet, systemFeaturesQueryKey } = vi.hoisted(() =>
|
||||
}))
|
||||
|
||||
vi.mock('@/features/system-features/server', () => ({
|
||||
getCachedSystemFeatures: () => queryClient.getQueryData(systemFeaturesQueryKey),
|
||||
getOptionalSystemFeatures: async () => queryClient.getQueryData(systemFeaturesQueryKey),
|
||||
}))
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { COOKIEYES_SITE_KEY, IS_PROD, WEB_PREFIX } from '@/config'
|
||||
import { getCachedSystemFeatures } from '@/features/system-features/server'
|
||||
import { getOptionalSystemFeatures } from '@/features/system-features/server'
|
||||
import { headers } from '@/next/headers'
|
||||
import { AnalyticsDisabled } from './analytics-disabled'
|
||||
import { CloudAnalyticsLayoutBoundary } from './cloud-analytics-layout-boundary'
|
||||
import { isCloudAnalyticsRequest } from './request-boundary'
|
||||
|
||||
export async function CloudAnalytics() {
|
||||
const systemFeatures = getCachedSystemFeatures()
|
||||
const systemFeatures = await getOptionalSystemFeatures()
|
||||
|
||||
if (!systemFeatures) return null
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen'
|
||||
import type { ReactNode } from 'react'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import Zendesk from '../index'
|
||||
@@ -10,10 +9,8 @@ let mockDeploymentEdition: DeploymentEdition = 'CLOUD'
|
||||
let mockZendeskWidgetKey: string | undefined = 'test-key'
|
||||
let mockIsProd = false
|
||||
let mockNonce: string | null = 'test-nonce'
|
||||
let queryClient: QueryClient
|
||||
const systemFeaturesQueryKey = ['console', 'system-features']
|
||||
const getSystemFeatures = vi.fn()
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getOptionalSystemFeatures: vi.fn(),
|
||||
headers: vi.fn(),
|
||||
scriptProps: vi.fn(),
|
||||
}))
|
||||
@@ -37,15 +34,7 @@ vi.mock('@/config', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/features/system-features/server', () => ({
|
||||
prefetchSystemFeatures: async () => {
|
||||
const queryOptions = {
|
||||
queryKey: systemFeaturesQueryKey,
|
||||
queryFn: getSystemFeatures,
|
||||
retry: false,
|
||||
}
|
||||
await queryClient.prefetchQuery(queryOptions)
|
||||
return queryClient.getQueryData(queryOptions.queryKey)
|
||||
},
|
||||
getOptionalSystemFeatures: mocks.getOptionalSystemFeatures,
|
||||
}))
|
||||
|
||||
// Mock next/headers
|
||||
@@ -71,8 +60,7 @@ describe('Zendesk', () => {
|
||||
mockZendeskWidgetKey = 'test-key'
|
||||
mockIsProd = false
|
||||
mockNonce = 'test-nonce'
|
||||
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
getSystemFeatures.mockImplementation(async () => ({
|
||||
mocks.getOptionalSystemFeatures.mockImplementation(async () => ({
|
||||
deployment_edition: mockDeploymentEdition,
|
||||
}))
|
||||
mocks.headers.mockImplementation(async () => {
|
||||
@@ -102,11 +90,11 @@ describe('Zendesk', () => {
|
||||
const result = await renderZendesk()
|
||||
expect(result).toBeNull()
|
||||
expect(mocks.headers).not.toHaveBeenCalled()
|
||||
expect(getSystemFeatures).not.toHaveBeenCalled()
|
||||
expect(mocks.getOptionalSystemFeatures).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render nothing when System Features is unavailable', async () => {
|
||||
getSystemFeatures.mockRejectedValue(new Error('system features unavailable'))
|
||||
mocks.getOptionalSystemFeatures.mockResolvedValue(undefined)
|
||||
|
||||
const result = await renderZendesk()
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { memo } from 'react'
|
||||
import { IS_PROD, ZENDESK_WIDGET_KEY } from '@/config'
|
||||
import { prefetchSystemFeatures } from '@/features/system-features/server'
|
||||
import { getOptionalSystemFeatures } from '@/features/system-features/server'
|
||||
import { headers } from '@/next/headers'
|
||||
import { ZendeskScript } from './script'
|
||||
|
||||
const Zendesk = async () => {
|
||||
if (!ZENDESK_WIDGET_KEY) return null
|
||||
|
||||
const systemFeatures = await prefetchSystemFeatures()
|
||||
const systemFeatures = await getOptionalSystemFeatures()
|
||||
if (!systemFeatures || systemFeatures.deployment_edition !== 'CLOUD') return null
|
||||
|
||||
const nonce = IS_PROD ? ((await headers()).get('x-nonce') ?? '') : ''
|
||||
|
||||
@@ -89,7 +89,7 @@ export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateO
|
||||
// Delete flow handlers
|
||||
const detectIsUsedByApp = useCallback(async () => {
|
||||
try {
|
||||
const { is_using: isUsedByApp } = await queryClient.fetchQuery(
|
||||
const { is_using: isUsedByApp } = await queryClient.query(
|
||||
consoleQuery.datasets.byDatasetId.useCheck.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
|
||||
@@ -240,7 +240,7 @@ export const useLazyModelProviderDetail = (providerName: string) => {
|
||||
const loadProviderDetail = useCallback(async () => {
|
||||
setEnabled(true)
|
||||
try {
|
||||
const response = await queryClient.fetchQuery(modelProviderDetailsQueryOptions())
|
||||
const response = await queryClient.query(modelProviderDetailsQueryOptions())
|
||||
return response.data.find((provider) => provider.provider === providerName)
|
||||
} catch {
|
||||
return undefined
|
||||
|
||||
+47
-9
@@ -1,6 +1,8 @@
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import type { ReactElement } from 'react'
|
||||
import type { ModelProviderPluginSummary } from '../../index'
|
||||
import type { PluginDetail } from '@/app/components/plugins/types'
|
||||
import { zPostWorkspacesCurrentPluginListInstallationsIdsResponse } from '@dify/contracts/api/console/workspaces/zod.gen'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { PluginSource } from '@/app/components/plugins/types'
|
||||
@@ -143,6 +145,46 @@ const createSummary = (
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const pluginInstallationsResponse = zPostWorkspacesCurrentPluginListInstallationsIdsResponse.parse({
|
||||
plugins: [
|
||||
{
|
||||
checksum: 'checksum',
|
||||
created_at: '2026-09-03T00:00:00Z',
|
||||
declaration: {
|
||||
author: 'langgenius',
|
||||
category: 'model',
|
||||
created_at: '2026-09-03T00:00:00Z',
|
||||
description: { en_US: 'Provider plugin' },
|
||||
icon: 'icon.svg',
|
||||
label: { en_US: 'Provider Plugin' },
|
||||
meta: {},
|
||||
name: 'provider-plugin',
|
||||
plugins: {},
|
||||
resource: {},
|
||||
version: '1.0.0',
|
||||
},
|
||||
endpoints_active: 0,
|
||||
endpoints_setups: 0,
|
||||
id: 'installation-id',
|
||||
meta: {},
|
||||
plugin_id: 'langgenius/provider-plugin',
|
||||
plugin_unique_identifier: 'langgenius/provider-plugin@1.0.0',
|
||||
runtime_type: 'local',
|
||||
source: 'github',
|
||||
tenant_id: 'tenant-id',
|
||||
updated_at: '2026-09-03T00:00:00Z',
|
||||
version: '1.0.0',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const seedPluginDetail = (queryClient: QueryClient) => {
|
||||
const options = consoleQuery.workspaces.current.plugin.list.installations.ids.post.queryOptions({
|
||||
input: { body: { plugin_ids: ['langgenius/provider-plugin'] } },
|
||||
})
|
||||
queryClient.setQueryData(options.queryKey, pluginInstallationsResponse)
|
||||
}
|
||||
|
||||
describe('ProviderCardActions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -206,9 +248,7 @@ describe('ProviderCardActions', () => {
|
||||
const rendered = render(
|
||||
<ProviderCardActions summary={createSummary()} providerLabel="Provider Plugin" />,
|
||||
)
|
||||
const fetchQuery = vi
|
||||
.spyOn(rendered.queryClient, 'fetchQuery')
|
||||
.mockResolvedValue({ plugins: [{}] })
|
||||
seedPluginDetail(rendered.queryClient)
|
||||
|
||||
openActionsMenu()
|
||||
fireEvent.click(screen.getByText('plugin.detailPanel.operation.info'))
|
||||
@@ -216,7 +256,6 @@ describe('ProviderCardActions', () => {
|
||||
await waitFor(() => {
|
||||
expect(mockShowPluginInfo).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(fetchQuery).toHaveBeenCalledTimes(1)
|
||||
expect(mockNormalizeInstalledPluginDetail).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
@@ -224,7 +263,6 @@ describe('ProviderCardActions', () => {
|
||||
const rendered = render(
|
||||
<ProviderCardActions summary={createSummary()} providerLabel="Provider Plugin" />,
|
||||
)
|
||||
const fetchQuery = vi.spyOn(rendered.queryClient, 'fetchQuery')
|
||||
const invalidateQueries = vi.spyOn(rendered.queryClient, 'invalidateQueries')
|
||||
|
||||
openActionsMenu()
|
||||
@@ -234,7 +272,7 @@ describe('ProviderCardActions', () => {
|
||||
await waitFor(() => {
|
||||
expect(mockUninstallPlugin).toHaveBeenCalledWith('installation-id')
|
||||
})
|
||||
expect(fetchQuery).not.toHaveBeenCalled()
|
||||
expect(mockNormalizeInstalledPluginDetail).not.toHaveBeenCalled()
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: consoleQuery.workspaces.current.modelProviders.summary.get.key(),
|
||||
})
|
||||
@@ -298,7 +336,7 @@ describe('ProviderCardActions', () => {
|
||||
providerLabel="Provider Plugin"
|
||||
/>,
|
||||
)
|
||||
vi.spyOn(rendered.queryClient, 'fetchQuery').mockResolvedValue({ plugins: [{}] })
|
||||
seedPluginDetail(rendered.queryClient)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.detailPanel.operation.update' }))
|
||||
|
||||
@@ -331,7 +369,7 @@ describe('ProviderCardActions', () => {
|
||||
onUpdate={onUpdate}
|
||||
/>,
|
||||
)
|
||||
vi.spyOn(rendered.queryClient, 'fetchQuery').mockResolvedValue({ plugins: [{}] })
|
||||
seedPluginDetail(rendered.queryClient)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: current }))
|
||||
let updateComplete: Promise<void> | undefined
|
||||
@@ -374,7 +412,7 @@ describe('ProviderCardActions', () => {
|
||||
onUpdate={onUpdate}
|
||||
/>,
|
||||
)
|
||||
vi.spyOn(rendered.queryClient, 'fetchQuery').mockResolvedValue({ plugins: [{}] })
|
||||
seedPluginDetail(rendered.queryClient)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '1.0.0' }))
|
||||
let thrownError: unknown
|
||||
|
||||
+17
-15
@@ -296,7 +296,7 @@ describe('ModelAuthDropdown', () => {
|
||||
current_credential_usable: true,
|
||||
},
|
||||
} as unknown as ModelProviderSummaryResponse
|
||||
const rendered = render(
|
||||
render(
|
||||
<ModelAuthDropdown
|
||||
provider={providerSummary}
|
||||
state={createState({ hasCredentials: true, variant: 'api-active' })}
|
||||
@@ -304,13 +304,13 @@ describe('ModelAuthDropdown', () => {
|
||||
onChangePriority={onChangePriority}
|
||||
/>,
|
||||
)
|
||||
const fetchQuery = vi
|
||||
.spyOn(rendered.queryClient, 'fetchQuery')
|
||||
.mockImplementation(async () => {
|
||||
const response = { data: [fullProvider] }
|
||||
rendered.queryClient.setQueryData(commonQueryKeys.modelProviderDetails, response)
|
||||
return response
|
||||
})
|
||||
const fetchMock = vi.mocked(globalThis.fetch)
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ data: [fullProvider] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
const trigger = screen.getByRole('button', { name: /config/i })
|
||||
|
||||
fireEvent.click(trigger)
|
||||
@@ -318,7 +318,7 @@ describe('ModelAuthDropdown', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Key 1')).toBeInTheDocument()
|
||||
})
|
||||
expect(fetchQuery).toHaveBeenCalledTimes(1)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.click(trigger)
|
||||
await waitFor(() => {
|
||||
@@ -329,7 +329,7 @@ describe('ModelAuthDropdown', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Key 1')).toBeInTheDocument()
|
||||
})
|
||||
expect(fetchQuery).toHaveBeenCalledTimes(1)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render updated provider detail from the query cache', async () => {
|
||||
@@ -365,11 +365,13 @@ describe('ModelAuthDropdown', () => {
|
||||
onChangePriority={onChangePriority}
|
||||
/>,
|
||||
)
|
||||
vi.spyOn(rendered.queryClient, 'fetchQuery').mockImplementation(async () => {
|
||||
const response = { data: [firstProvider] }
|
||||
rendered.queryClient.setQueryData(commonQueryKeys.modelProviderDetails, response)
|
||||
return response
|
||||
})
|
||||
const fetchMock = vi.mocked(globalThis.fetch)
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ data: [firstProvider] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /config/i }))
|
||||
await waitFor(() => expect(screen.getByText('Key 1')).toBeInTheDocument())
|
||||
|
||||
+1
-1
@@ -120,7 +120,7 @@ function SummaryProviderCardActions({ summary, providerLabel, onUpdate }: Summar
|
||||
const loadDetail = async (action: DetailAction) => {
|
||||
setLoadingAction(action)
|
||||
try {
|
||||
const response = await queryClient.fetchQuery(
|
||||
const response = await queryClient.query(
|
||||
consoleQuery.workspaces.current.plugin.list.installations.ids.post.queryOptions({
|
||||
input: { body: { plugin_ids: [summary.plugin_id] } },
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { zLicenseStatus } from '@dify/contracts/api/console/system-features/zod.gen'
|
||||
import { noop } from '@tanstack/react-query'
|
||||
import { screen } from '@testing-library/react'
|
||||
import dayjs from 'dayjs'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
@@ -13,10 +14,12 @@ const renderLicenseBadge = (license?: Parameters<typeof seedSystemFeaturesLicens
|
||||
const queryClient = createConsoleQueryClient()
|
||||
if (license) seedSystemFeaturesLicense(queryClient, license)
|
||||
else {
|
||||
void queryClient.prefetchQuery({
|
||||
queryKey: consoleQuery.systemFeatures.license.get.queryOptions().queryKey,
|
||||
queryFn: () => new Promise(() => {}),
|
||||
})
|
||||
void queryClient
|
||||
.query({
|
||||
queryKey: consoleQuery.systemFeatures.license.get.queryOptions().queryKey,
|
||||
queryFn: () => new Promise(() => {}),
|
||||
})
|
||||
.catch(noop)
|
||||
}
|
||||
return renderWithConsoleQuery(<LicenseBadge />, { queryClient })
|
||||
}
|
||||
|
||||
@@ -5,7 +5,13 @@ import type { ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
noop,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
useSuspenseQuery,
|
||||
} from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { useState } from 'react'
|
||||
@@ -269,7 +275,7 @@ export function WorkspaceCard() {
|
||||
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
|
||||
const isCloudEdition = deploymentEdition === 'CLOUD'
|
||||
const prefetchWorkspaces = () => {
|
||||
void queryClient.prefetchQuery(workspacesQueryOptions)
|
||||
void queryClient.query(workspacesQueryOptions).catch(noop)
|
||||
}
|
||||
|
||||
if (currentWorkspaceQuery.isPending || !currentWorkspace?.name) {
|
||||
|
||||
@@ -12,6 +12,11 @@ const render = (ui: ReactElement) => {
|
||||
queryClient.setQueryData(commonQueryKeys.modelProviderDetails, {
|
||||
data: [{ provider: 'model-collection-id' }],
|
||||
})
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: commonQueryKeys.modelProviderDetails,
|
||||
exact: true,
|
||||
refetchType: 'none',
|
||||
})
|
||||
return renderWithConsoleQuery(ui, { queryClient })
|
||||
}
|
||||
|
||||
@@ -584,6 +589,7 @@ describe('ProviderDetail', () => {
|
||||
await waitFor(() => {
|
||||
expect(mockSetShowModelModal).toHaveBeenCalled()
|
||||
})
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -101,7 +101,10 @@ const ProviderDetail = ({ collection, onHide, onRefreshData }: Props) => {
|
||||
const summary = providers.find((item) => item.provider === collection?.id)
|
||||
if (!summary) return
|
||||
try {
|
||||
const response = await queryClient.ensureQueryData(modelProviderDetailsQueryOptions())
|
||||
const response = await queryClient.query({
|
||||
...modelProviderDetailsQueryOptions(),
|
||||
staleTime: 'static',
|
||||
})
|
||||
const provider = response.data.find((item) => item.provider === summary.provider)
|
||||
if (!provider) return
|
||||
setShowModelModal({
|
||||
|
||||
@@ -195,7 +195,7 @@ export const useWorkflowInit = () => {
|
||||
const handleFetchPreloadData = useCallback(async () => {
|
||||
const [nodesDefaultConfigsResult, publishedWorkflowResult] = await Promise.allSettled([
|
||||
fetchNodesDefaultConfigs(`/apps/${appDetail.id}/workflows/default-workflow-block-configs`),
|
||||
queryClient.fetchQuery(appWorkflowQueryOptions(appDetail.id)),
|
||||
queryClient.query(appWorkflowQueryOptions(appDetail.id)),
|
||||
])
|
||||
|
||||
if (nodesDefaultConfigsResult.status === 'fulfilled') {
|
||||
|
||||
+42
-16
@@ -1,4 +1,9 @@
|
||||
import type { SnippetWorkflowResponse } from '@dify/contracts/api/console/snippets/types.gen'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { createQueryClientWrapper } from '@/test/console/query-client'
|
||||
import { createTestQueryClient } from '@/test/query-client'
|
||||
import { NESTED_ELEMENT_Z_INDEX } from '../../../constants'
|
||||
import { useInsertSnippet } from '../use-insert-snippet'
|
||||
|
||||
@@ -38,7 +43,7 @@ type TestEdge = {
|
||||
}
|
||||
}
|
||||
|
||||
const mockFetchQuery = vi.fn()
|
||||
const mockRequest = vi.hoisted(() => vi.fn())
|
||||
const mockHandleSyncWorkflowDraft = vi.fn()
|
||||
const mockSaveStateToHistory = vi.fn()
|
||||
const mockToastError = vi.fn()
|
||||
@@ -48,10 +53,9 @@ const mockSetEdges = vi.fn()
|
||||
const mockIncrementSnippetUseCount = vi.fn()
|
||||
let mockEdges: unknown[] = [{ id: 'existing-edge', source: 'old', target: 'old-2' }]
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQueryClient: () => ({
|
||||
fetchQuery: mockFetchQuery,
|
||||
}),
|
||||
vi.mock('@/service/base', () => ({
|
||||
request: (...args: unknown[]) => mockRequest(...args),
|
||||
sseGeneratorPost: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
@@ -92,9 +96,28 @@ vi.mock('@/service/use-snippets', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
type PublishedWorkflow = Pick<SnippetWorkflowResponse, 'graph'>
|
||||
|
||||
const seedPublishedWorkflow = (queryClient: QueryClient, workflow: PublishedWorkflow) => {
|
||||
const queryOptions = consoleQuery.snippets.bySnippetId.workflows.publish.get.queryOptions({
|
||||
input: {
|
||||
params: { snippet_id: 'snippet-1' },
|
||||
},
|
||||
})
|
||||
|
||||
queryClient.setQueryData<PublishedWorkflow>(queryOptions.queryKey, workflow)
|
||||
}
|
||||
|
||||
describe('useInsertSnippet', () => {
|
||||
let queryClient: QueryClient
|
||||
const renderUseInsertSnippet = () =>
|
||||
renderHook(() => useInsertSnippet(), {
|
||||
wrapper: createQueryClientWrapper(queryClient),
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
queryClient = createTestQueryClient()
|
||||
mockEdges = [{ id: 'existing-edge', source: 'old', target: 'old-2' }]
|
||||
mockGetNodes.mockReturnValue([
|
||||
{
|
||||
@@ -105,9 +128,13 @@ describe('useInsertSnippet', () => {
|
||||
])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
describe('Insert Flow', () => {
|
||||
it('should append remapped snippet graph into current workflow graph', async () => {
|
||||
mockFetchQuery.mockResolvedValue({
|
||||
seedPublishedWorkflow(queryClient, {
|
||||
graph: {
|
||||
nodes: [
|
||||
{
|
||||
@@ -149,13 +176,12 @@ describe('useInsertSnippet', () => {
|
||||
},
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useInsertSnippet())
|
||||
const { result } = renderUseInsertSnippet()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleInsertSnippet('snippet-1')
|
||||
})
|
||||
|
||||
expect(mockFetchQuery).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetNodes).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetEdges).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -189,7 +215,7 @@ describe('useInsertSnippet', () => {
|
||||
})
|
||||
|
||||
it('should remap variable selectors that reference nodes inside the snippet', async () => {
|
||||
mockFetchQuery.mockResolvedValue({
|
||||
seedPublishedWorkflow(queryClient, {
|
||||
graph: {
|
||||
nodes: [
|
||||
{
|
||||
@@ -228,7 +254,7 @@ describe('useInsertSnippet', () => {
|
||||
},
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useInsertSnippet())
|
||||
const { result } = renderUseInsertSnippet()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleInsertSnippet('snippet-1')
|
||||
@@ -246,7 +272,7 @@ describe('useInsertSnippet', () => {
|
||||
})
|
||||
|
||||
it('should remap structural node ids inside a loop snippet', async () => {
|
||||
mockFetchQuery.mockResolvedValue({
|
||||
seedPublishedWorkflow(queryClient, {
|
||||
graph: {
|
||||
nodes: [
|
||||
{
|
||||
@@ -274,7 +300,7 @@ describe('useInsertSnippet', () => {
|
||||
},
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useInsertSnippet())
|
||||
const { result } = renderUseInsertSnippet()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleInsertSnippet('snippet-1')
|
||||
@@ -331,7 +357,7 @@ describe('useInsertSnippet', () => {
|
||||
},
|
||||
},
|
||||
]
|
||||
mockFetchQuery.mockResolvedValue({
|
||||
seedPublishedWorkflow(queryClient, {
|
||||
graph: {
|
||||
nodes: [
|
||||
{
|
||||
@@ -366,7 +392,7 @@ describe('useInsertSnippet', () => {
|
||||
},
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useInsertSnippet())
|
||||
const { result } = renderUseInsertSnippet()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleInsertSnippet('snippet-1', {
|
||||
@@ -450,9 +476,9 @@ describe('useInsertSnippet', () => {
|
||||
)
|
||||
|
||||
it('should show error toast when fetching snippet workflow fails', async () => {
|
||||
mockFetchQuery.mockRejectedValue(new Error('insert failed'))
|
||||
mockRequest.mockRejectedValue(new Error('insert failed'))
|
||||
|
||||
const { result } = renderHook(() => useInsertSnippet())
|
||||
const { result } = renderUseInsertSnippet()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleInsertSnippet('snippet-1')
|
||||
|
||||
@@ -358,7 +358,7 @@ export const useInsertSnippet = () => {
|
||||
const handleInsertSnippet = useCallback(
|
||||
async (snippetId: string, insertPayload?: SnippetInsertPayload) => {
|
||||
try {
|
||||
const workflow = await queryClient.fetchQuery(
|
||||
const workflow = await queryClient.query(
|
||||
consoleQuery.snippets.bySnippetId.workflows.publish.get.queryOptions({
|
||||
input: {
|
||||
params: { snippet_id: snippetId },
|
||||
|
||||
@@ -691,7 +691,7 @@ export const useChecklistBeforePublish = () => {
|
||||
await Promise.all(
|
||||
knowledgeBaseEmbeddingProviders.map(async (provider) => {
|
||||
try {
|
||||
const modelList = await queryClient.fetchQuery(
|
||||
const modelList = await queryClient.query(
|
||||
consoleQuery.workspaces.current.modelProviders.byProvider.models.get.queryOptions({
|
||||
input: { params: { provider } },
|
||||
}),
|
||||
|
||||
+6
-6
@@ -2,7 +2,7 @@ import type { ThemeProviderProps } from 'next-themes'
|
||||
import type { Metadata, Viewport } from '@/next'
|
||||
import { ToastHost } from '@langgenius/dify-ui/toast'
|
||||
import { TooltipProvider } from '@langgenius/dify-ui/tooltip'
|
||||
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
|
||||
import { HydrationBoundary } from '@tanstack/react-query'
|
||||
import { Provider as JotaiProvider } from 'jotai/react'
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/app'
|
||||
@@ -10,8 +10,8 @@ import { IS_PROD } from '@/config'
|
||||
import { getDatasetMap } from '@/env'
|
||||
import { SystemFeaturesBootstrapBoundary } from '@/features/system-features/bootstrap-boundary'
|
||||
import {
|
||||
getSystemFeaturesQueryClient,
|
||||
prefetchSystemFeatures,
|
||||
dehydrateSystemFeatures,
|
||||
getOptionalSystemFeatures,
|
||||
} from '@/features/system-features/server'
|
||||
import { getLocaleOnServer } from '@/i18n-config/server'
|
||||
import { headers } from '@/next/headers'
|
||||
@@ -33,7 +33,7 @@ export const viewport: Viewport = {
|
||||
}
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const systemFeatures = await prefetchSystemFeatures()
|
||||
const systemFeatures = await getOptionalSystemFeatures()
|
||||
const branding = systemFeatures?.branding
|
||||
const applicationTitle = getApplicationTitle(branding)
|
||||
const brandedFavicon = branding?.enabled ? branding.favicon : undefined
|
||||
@@ -54,9 +54,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
const [locale, requestHeaders] = await Promise.all([
|
||||
getLocaleOnServer(),
|
||||
headers(),
|
||||
prefetchSystemFeatures(),
|
||||
getOptionalSystemFeatures(),
|
||||
])
|
||||
const dehydratedState = dehydrate(getSystemFeaturesQueryClient())
|
||||
const dehydratedState = dehydrateSystemFeatures()
|
||||
const nonce = IS_PROD ? (requestHeaders.get('x-nonce') ?? undefined) : undefined
|
||||
const themeProviderProps: Omit<ThemeProviderProps, 'children'> = {
|
||||
attribute: 'data-theme',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen'
|
||||
import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { noop, QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
@@ -535,7 +535,7 @@ describe('CheckCode', () => {
|
||||
serviceBaseMocks.get
|
||||
.mockRejectedValueOnce(new Response(null, { status: 401 }))
|
||||
.mockReturnValueOnce(profileResponse)
|
||||
await queryClient.prefetchQuery(profileQueryOptions)
|
||||
await queryClient.query(profileQueryOptions).catch(noop)
|
||||
expect(queryClient.getQueryState(profileQueryKey)?.status).toBe('error')
|
||||
|
||||
render(
|
||||
|
||||
@@ -82,7 +82,7 @@ export default function CheckCode() {
|
||||
} else {
|
||||
const profileQueryOptions = userProfileQueryOptions()
|
||||
await queryClient.resetQueries({ queryKey: profileQueryOptions.queryKey })
|
||||
await queryClient.fetchQuery(profileQueryOptions)
|
||||
await queryClient.query(profileQueryOptions)
|
||||
replaceLoginRedirect(resolvePostLoginRedirect(searchParams), router.replace, basePath)
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -54,7 +54,7 @@ describe('serverUserProfileQueryOptions', () => {
|
||||
const { serverUserProfileQueryOptions } = await import('../server')
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
|
||||
const data = await queryClient.fetchQuery(serverUserProfileQueryOptions())
|
||||
const data = await queryClient.query(serverUserProfileQueryOptions())
|
||||
|
||||
expect(serverUserProfileQueryOptions().queryKey).toEqual(userProfileQueryOptions().queryKey)
|
||||
expect(data).toEqual({
|
||||
|
||||
+30
-1
@@ -594,6 +594,35 @@ describe('AgentConfigurePublishBar', () => {
|
||||
expect(onPublish).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should refresh cached workflow references before publishing', async () => {
|
||||
const { onPublish, queryClient } = renderPublishBar({
|
||||
activeConfigSnapshot,
|
||||
prompt: 'Updated system prompt',
|
||||
})
|
||||
const referencesQueryKey = ['agent-referencing-workflows', { params: { agent_id: 'agent-1' } }]
|
||||
|
||||
await waitFor(() => {
|
||||
expect(workflowReferences.fetchCount).toBe(1)
|
||||
})
|
||||
queryClient.setQueryDefaults(['agent-referencing-workflows'], { staleTime: Infinity })
|
||||
queryClient.setQueryData(referencesQueryKey, { data: [] })
|
||||
workflowReferences.data = publishedReferences
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(onPublish).not.toHaveBeenCalled()
|
||||
expect(
|
||||
await screen.findByRole('region', {
|
||||
name: /agentV2\.agentDetail\.configure\.publishImpact\.title/,
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
expect(workflowReferences.fetchCount).toBe(2)
|
||||
})
|
||||
|
||||
it('should mark non-prompt draft changes as unpublished', () => {
|
||||
renderPublishBar({
|
||||
activeConfigSnapshot,
|
||||
@@ -709,7 +738,7 @@ describe('AgentConfigurePublishBar', () => {
|
||||
name: /agentV2\.agentDetail\.configure\.publishImpact\.title/,
|
||||
})
|
||||
expect(impactDetails).toBeInTheDocument()
|
||||
expect(workflowReferences.fetchCount).toBe(1)
|
||||
expect(workflowReferences.fetchCount).toBe(2)
|
||||
expect(
|
||||
screen.getAllByRole('button', {
|
||||
name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/,
|
||||
|
||||
+60
-42
@@ -2,7 +2,7 @@ import type { AgentConfigApiContext } from '../../config-context'
|
||||
import type { AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
@@ -194,7 +194,7 @@ function renderAgentFiles({
|
||||
} = {}) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
queries: { retry: false, staleTime: Infinity },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
@@ -203,20 +203,23 @@ function renderAgentFiles({
|
||||
createSystemFeaturesFixture({ deployment_edition: 'COMMUNITY' }),
|
||||
)
|
||||
|
||||
return render(
|
||||
<QueryClientTestProvider queryClient={queryClient}>
|
||||
<AgentConfigApiContextProvider value={apiContext}>
|
||||
<AgentComposerProvider initialDraft={initialDraft}>
|
||||
<AgentOrchestrateViewingVersionContext value={viewingVersion}>
|
||||
<AgentOrchestrateReadOnlyContext value={readOnly}>
|
||||
<AgentFiles />
|
||||
<ConfigSnapshotProbe />
|
||||
</AgentOrchestrateReadOnlyContext>
|
||||
</AgentOrchestrateViewingVersionContext>
|
||||
</AgentComposerProvider>
|
||||
</AgentConfigApiContextProvider>
|
||||
</QueryClientTestProvider>,
|
||||
)
|
||||
return {
|
||||
...render(
|
||||
<QueryClientTestProvider queryClient={queryClient}>
|
||||
<AgentConfigApiContextProvider value={apiContext}>
|
||||
<AgentComposerProvider initialDraft={initialDraft}>
|
||||
<AgentOrchestrateViewingVersionContext value={viewingVersion}>
|
||||
<AgentOrchestrateReadOnlyContext value={readOnly}>
|
||||
<AgentFiles />
|
||||
<ConfigSnapshotProbe />
|
||||
</AgentOrchestrateReadOnlyContext>
|
||||
</AgentOrchestrateViewingVersionContext>
|
||||
</AgentComposerProvider>
|
||||
</AgentConfigApiContextProvider>
|
||||
</QueryClientTestProvider>,
|
||||
),
|
||||
queryClient,
|
||||
}
|
||||
}
|
||||
|
||||
describe('AgentFiles', () => {
|
||||
@@ -495,42 +498,57 @@ describe('AgentFiles', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should preview and download files through config file endpoints by name', async () => {
|
||||
it('should not expose a stale image URL while the preview refreshes', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderAgentFiles()
|
||||
let resolveDownload!: (value: { url: string }) => void
|
||||
const downloadResponse = new Promise<{ url: string }>((resolve) => {
|
||||
resolveDownload = resolve
|
||||
})
|
||||
const downloadQueryFn = vi.fn(() => downloadResponse)
|
||||
mocks.downloadQueryOptions.mockImplementation(({ input }) => ({
|
||||
queryKey: ['download-config-file', input],
|
||||
queryFn: downloadQueryFn,
|
||||
}))
|
||||
const { queryClient } = renderAgentFiles()
|
||||
queryClient.setQueryData(
|
||||
[
|
||||
'download-config-file',
|
||||
{
|
||||
params: { agent_id: 'agent-1', name: 'diagram.png' },
|
||||
query: { draft_type: 'draft', version_id: undefined },
|
||||
},
|
||||
],
|
||||
{ url: 'https://example.com/expired-diagram.png' },
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('diagram.png').closest('button')!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.previewQueryOptions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
input: expect.objectContaining({
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
name: 'diagram.png',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await waitFor(() => expect(downloadQueryFn).toHaveBeenCalledOnce())
|
||||
expect(screen.queryByRole('img', { name: 'diagram.png' })).not.toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
resolveDownload({ url: 'https://example.com/current-diagram.png' })
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.downloadQueryOptions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
input: expect.objectContaining({
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
name: 'diagram.png',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
expect(await screen.findByRole('img', { name: 'diagram.png' })).toHaveAttribute(
|
||||
'src',
|
||||
'https://example.com/current-diagram.png',
|
||||
)
|
||||
})
|
||||
|
||||
it('should download configured files from the row action by config name', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderAgentFiles()
|
||||
const { queryClient } = renderAgentFiles()
|
||||
queryClient.setQueryData(
|
||||
[
|
||||
'download-config-file',
|
||||
{
|
||||
params: { agent_id: 'agent-1', name: 'diagram.png' },
|
||||
query: { draft_type: 'draft', version_id: undefined },
|
||||
},
|
||||
],
|
||||
{ url: 'https://example.com/stale-diagram.png' },
|
||||
)
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
|
||||
+23
-12
@@ -13,6 +13,7 @@ import {
|
||||
FileTreeLabel,
|
||||
} from '@langgenius/dify-ui/file-tree'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
@@ -145,6 +146,7 @@ function AgentFileItem({
|
||||
},
|
||||
},
|
||||
}),
|
||||
staleTime: 0,
|
||||
enabled: shouldDownloadPreviewFile && !apiContext.workflow,
|
||||
})
|
||||
const workflowDownloadQuery = useQuery({
|
||||
@@ -161,6 +163,7 @@ function AgentFileItem({
|
||||
},
|
||||
},
|
||||
}),
|
||||
staleTime: 0,
|
||||
enabled: shouldDownloadPreviewFile && !!apiContext.workflow,
|
||||
})
|
||||
const downloadQuery = apiContext.workflow ? workflowDownloadQuery : agentDownloadQuery
|
||||
@@ -179,8 +182,8 @@ function AgentFileItem({
|
||||
|
||||
const fileName = getAgentFilePreviewKey(targetFile)
|
||||
if (apiContext.workflow) {
|
||||
const result = await queryClient.fetchQuery(
|
||||
consoleQuery.apps.byAppId.agent.config.files.byName.download.get.queryOptions({
|
||||
const result = await queryClient.query({
|
||||
...consoleQuery.apps.byAppId.agent.config.files.byName.download.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
app_id: apiContext.workflow.appId,
|
||||
@@ -193,13 +196,14 @@ function AgentFileItem({
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
staleTime: 0,
|
||||
})
|
||||
downloadUrl({ url: result.url, fileName: targetFile.name })
|
||||
return
|
||||
}
|
||||
|
||||
const result = await queryClient.fetchQuery(
|
||||
consoleQuery.agent.byAgentId.config.files.byName.download.get.queryOptions({
|
||||
const result = await queryClient.query({
|
||||
...consoleQuery.agent.byAgentId.config.files.byName.download.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
agent_id: apiContext.agentId,
|
||||
@@ -211,19 +215,26 @@ function AgentFileItem({
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
staleTime: 0,
|
||||
})
|
||||
downloadUrl({ url: result.url, fileName: targetFile.name })
|
||||
},
|
||||
[apiContext, queryClient],
|
||||
)
|
||||
const downloadFileAction = useCallback(
|
||||
(targetFile: AgentFileNode) => {
|
||||
void downloadFile(targetFile).catch(noop)
|
||||
},
|
||||
[downloadFile],
|
||||
)
|
||||
const handleDownload = useCallback(
|
||||
async (event: MouseEvent<HTMLButtonElement>) => {
|
||||
(event: MouseEvent<HTMLButtonElement>) => {
|
||||
if (file.isMissing) return
|
||||
|
||||
event.stopPropagation()
|
||||
await downloadFile(file)
|
||||
downloadFileAction(file)
|
||||
},
|
||||
[downloadFile, file],
|
||||
[downloadFileAction, file],
|
||||
)
|
||||
const handlePreviewOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
@@ -268,15 +279,15 @@ function AgentFileItem({
|
||||
filePreview: {
|
||||
binary: previewQuery.data?.binary,
|
||||
content: selectedPreviewFile.virtualContent ?? previewQuery.data?.text ?? undefined,
|
||||
downloadUrl: downloadQuery.data?.url,
|
||||
downloadUrl: downloadQuery.isFetching ? undefined : downloadQuery.data?.url,
|
||||
fileName: selectedPreviewFile.name,
|
||||
isDownloadError: downloadQuery.isError,
|
||||
isDownloadLoading: shouldDownloadPreviewFile && downloadQuery.isPending,
|
||||
isDownloadLoading: shouldDownloadPreviewFile && downloadQuery.isFetching,
|
||||
isError: !isVirtualPreviewFile && previewQuery.isError,
|
||||
isImage: isImagePreviewFile,
|
||||
isLoading: !isVirtualPreviewFile && previewQuery.isPending,
|
||||
},
|
||||
onDownloadFile: () => downloadFile(selectedPreviewFile),
|
||||
onDownloadFile: () => downloadFileAction(selectedPreviewFile),
|
||||
onSelectFile: (selectedFile) => setSelectedFileId(selectedFile.id),
|
||||
selectedFileId: selectedFileId ?? file.id,
|
||||
sections: [],
|
||||
|
||||
+5
-7
@@ -134,7 +134,7 @@ export function AgentConfigurePublishBar({
|
||||
},
|
||||
enabled: publishIsAvailable && !selectedVersionSnapshot,
|
||||
})
|
||||
const workflowReferencesQuery = useQuery(workflowReferencesQueryOptions)
|
||||
useQuery(workflowReferencesQueryOptions)
|
||||
const restoreVersionMutation = useMutation(
|
||||
consoleQuery.agent.byAgentId.versions.byVersionId.restore.post.mutationOptions(),
|
||||
)
|
||||
@@ -203,12 +203,10 @@ export function AgentConfigurePublishBar({
|
||||
|
||||
let referencesResponse: AgentReferencingWorkflowsResponse | undefined
|
||||
try {
|
||||
referencesResponse =
|
||||
queryClient.getQueryData<AgentReferencingWorkflowsResponse>(
|
||||
workflowReferencesQueryOptions.queryKey,
|
||||
) ??
|
||||
workflowReferencesQuery.data ??
|
||||
(await queryClient.ensureQueryData(workflowReferencesQueryOptions))
|
||||
referencesResponse = await queryClient.query({
|
||||
...workflowReferencesQueryOptions,
|
||||
staleTime: 0,
|
||||
})
|
||||
} catch {
|
||||
toast.error(tCommon(($) => $['api.actionFailed']))
|
||||
return
|
||||
|
||||
+88
-25
@@ -323,28 +323,31 @@ function renderAgentSkills({
|
||||
} = {}) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
queries: { retry: false, staleTime: Infinity },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AgentConfigApiContextProvider value={apiContext}>
|
||||
<AgentComposerProvider initialDraft={initialDraft}>
|
||||
<AgentOrchestrateViewingVersionContext value={viewingVersion}>
|
||||
<AgentOrchestrateAddActionsProvider>
|
||||
<AgentOrchestrateReadOnlyContext value={readOnly}>
|
||||
<AgentSkills />
|
||||
<ConfigSnapshotProbe />
|
||||
<PromptSkillAddProbe />
|
||||
</AgentOrchestrateReadOnlyContext>
|
||||
</AgentOrchestrateAddActionsProvider>
|
||||
</AgentOrchestrateViewingVersionContext>
|
||||
</AgentComposerProvider>
|
||||
</AgentConfigApiContextProvider>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
return {
|
||||
...render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AgentConfigApiContextProvider value={apiContext}>
|
||||
<AgentComposerProvider initialDraft={initialDraft}>
|
||||
<AgentOrchestrateViewingVersionContext value={viewingVersion}>
|
||||
<AgentOrchestrateAddActionsProvider>
|
||||
<AgentOrchestrateReadOnlyContext value={readOnly}>
|
||||
<AgentSkills />
|
||||
<ConfigSnapshotProbe />
|
||||
<PromptSkillAddProbe />
|
||||
</AgentOrchestrateReadOnlyContext>
|
||||
</AgentOrchestrateAddActionsProvider>
|
||||
</AgentOrchestrateViewingVersionContext>
|
||||
</AgentComposerProvider>
|
||||
</AgentConfigApiContextProvider>
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
queryClient,
|
||||
}
|
||||
}
|
||||
|
||||
describe('AgentSkills', () => {
|
||||
@@ -354,10 +357,11 @@ describe('AgentSkills', () => {
|
||||
mocks.fileUploadConfig.skill_file_size_limit = 64
|
||||
vi.stubGlobal('fetch', mocks.fetch)
|
||||
document.cookie = 'csrf_token=csrf-token; path=/'
|
||||
mocks.fetch.mockResolvedValue(
|
||||
new Response('downloaded skill file', {
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
}),
|
||||
mocks.fetch.mockImplementation(
|
||||
async () =>
|
||||
new Response('downloaded skill file', {
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
}),
|
||||
)
|
||||
mocks.agentSkillBindingsKey.mockImplementation((options) => {
|
||||
const { input } = options as { input: { params: { agent_id: string } } }
|
||||
@@ -1523,7 +1527,17 @@ describe('AgentSkills', () => {
|
||||
|
||||
it('should download a whole skill package from the row action', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderAgentSkills()
|
||||
const { queryClient } = renderAgentSkills()
|
||||
queryClient.setQueryData(
|
||||
[
|
||||
'download-skill',
|
||||
{
|
||||
params: { agent_id: 'agent-1', name: 'Tender Analyzer' },
|
||||
query: { draft_type: 'draft', version_id: undefined },
|
||||
},
|
||||
],
|
||||
{ url: 'https://example.com/stale.skill' },
|
||||
)
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
@@ -1682,7 +1696,30 @@ describe('AgentSkills', () => {
|
||||
|
||||
it('should download skill package members from the detail file tree', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderAgentSkills()
|
||||
const { queryClient } = renderAgentSkills()
|
||||
const downloadInput = {
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
name: 'Tender Analyzer',
|
||||
},
|
||||
query: {
|
||||
path: 'references/guide.md',
|
||||
draft_type: 'draft',
|
||||
version_id: undefined,
|
||||
},
|
||||
}
|
||||
const staleContentUrl = '/console/api/agent/agent-1/config/skills/stale/files/content'
|
||||
const currentContentUrl =
|
||||
'/console/api/agent/agent-1/config/skills/Tender%20Analyzer/files/content?path=references%2Fguide.md'
|
||||
const staleBlob = new Blob(['stale skill file'])
|
||||
queryClient.setQueryData(['download-skill-file', downloadInput], { url: staleContentUrl })
|
||||
queryClient.setQueryData(['agent-v2', 'skill-file-content', staleContentUrl], staleBlob)
|
||||
queryClient.setQueryData(['agent-v2', 'skill-file-content', currentContentUrl], staleBlob)
|
||||
mocks.fetch.mockResolvedValueOnce(
|
||||
new Response('current skill file', {
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
}),
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Tender Analyzer').closest('button')!)
|
||||
await user.click(await screen.findByText('references'))
|
||||
@@ -1722,7 +1759,7 @@ describe('AgentSkills', () => {
|
||||
fileName: 'guide.md',
|
||||
})
|
||||
const blob = mocks.downloadBlob.mock.calls[0]?.[0].data as Blob
|
||||
await expect(blob.text()).resolves.toBe('downloaded skill file')
|
||||
await expect(blob.text()).resolves.toBe('current skill file')
|
||||
expect(mocks.downloadUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -1773,15 +1810,41 @@ describe('AgentSkills', () => {
|
||||
const user = userEvent.setup()
|
||||
const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:skill-image')
|
||||
const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
let resolveImageRequest!: (response: Response) => void
|
||||
mocks.fetch.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveImageRequest = resolve
|
||||
}),
|
||||
)
|
||||
const view = renderAgentSkills()
|
||||
const currentContentUrl =
|
||||
'/console/api/agent/agent-1/config/skills/Tender%20Analyzer/files/content?path=assets%2Ficon.png'
|
||||
view.queryClient.setQueryData(
|
||||
['agent-v2', 'skill-file-content', currentContentUrl],
|
||||
new Blob(['stale skill image']),
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Tender Analyzer').closest('button')!)
|
||||
await user.click(await screen.findByText('assets'))
|
||||
await user.click(screen.getByText('icon.png').closest('button')!)
|
||||
|
||||
await waitFor(() => expect(mocks.fetch).toHaveBeenCalledOnce())
|
||||
expect(screen.queryByRole('img', { name: 'icon.png' })).not.toBeInTheDocument()
|
||||
expect(createObjectURL).not.toHaveBeenCalled()
|
||||
|
||||
resolveImageRequest(
|
||||
new Response('current skill image', {
|
||||
headers: { 'Content-Type': 'image/png' },
|
||||
}),
|
||||
)
|
||||
|
||||
const image = await screen.findByRole('img', { name: 'icon.png' })
|
||||
expect(image).toHaveAttribute('src', 'blob:skill-image')
|
||||
expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob))
|
||||
await expect((createObjectURL.mock.calls[0]?.[0] as Blob).text()).resolves.toBe(
|
||||
'current skill image',
|
||||
)
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
|
||||
+13
-7
@@ -12,6 +12,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
@@ -40,12 +41,12 @@ export function AgentSkillItem({
|
||||
const handleRemove = useCallback(() => {
|
||||
onRemove(skill.id)
|
||||
}, [onRemove, skill.id])
|
||||
const handleDownload = useCallback(async () => {
|
||||
const downloadSkill = useCallback(async () => {
|
||||
if (skill.isMissing) return
|
||||
|
||||
if (apiContext.workflow) {
|
||||
const result = await queryClient.fetchQuery(
|
||||
consoleQuery.apps.byAppId.agent.config.skills.byName.download.get.queryOptions({
|
||||
const result = await queryClient.query({
|
||||
...consoleQuery.apps.byAppId.agent.config.skills.byName.download.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
app_id: apiContext.workflow.appId,
|
||||
@@ -58,13 +59,14 @@ export function AgentSkillItem({
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
staleTime: 0,
|
||||
})
|
||||
downloadUrl({ url: result.url, fileName: skill.name })
|
||||
return
|
||||
}
|
||||
|
||||
const result = await queryClient.fetchQuery(
|
||||
consoleQuery.agent.byAgentId.config.skills.byName.download.get.queryOptions({
|
||||
const result = await queryClient.query({
|
||||
...consoleQuery.agent.byAgentId.config.skills.byName.download.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
agent_id: apiContext.agentId,
|
||||
@@ -76,9 +78,13 @@ export function AgentSkillItem({
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
staleTime: 0,
|
||||
})
|
||||
downloadUrl({ url: result.url, fileName: skill.name })
|
||||
}, [apiContext, queryClient, skill.isMissing, skill.name])
|
||||
const handleDownload = useCallback(() => {
|
||||
void downloadSkill().catch(noop)
|
||||
}, [downloadSkill])
|
||||
const handleOpenPreview = useCallback(() => {
|
||||
if (skill.isMissing) return
|
||||
|
||||
|
||||
+28
-22
@@ -41,7 +41,7 @@ const getSkillFileContentQueryOptions = (url: string) =>
|
||||
queryOptions({
|
||||
queryKey: skillFileContentQueryKey(url),
|
||||
queryFn: () => fetchSkillFileContent(url),
|
||||
staleTime: Infinity,
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
const isSkillFolder = (file: AgentConfigSkillFileResponse) => file.type === 'directory'
|
||||
@@ -281,6 +281,7 @@ export function useAgentSkillDetail({
|
||||
},
|
||||
},
|
||||
}),
|
||||
staleTime: 0,
|
||||
enabled: shouldLoadImagePreview && !apiContext.workflow,
|
||||
})
|
||||
const workflowDownloadQuery = useQuery({
|
||||
@@ -298,6 +299,7 @@ export function useAgentSkillDetail({
|
||||
},
|
||||
},
|
||||
}),
|
||||
staleTime: 0,
|
||||
enabled: shouldLoadImagePreview && !!apiContext.workflow,
|
||||
})
|
||||
const downloadQuery = apiContext.workflow ? workflowDownloadQuery : agentDownloadQuery
|
||||
@@ -334,29 +336,32 @@ export function useAgentSkillDetail({
|
||||
setDownloadActionLoadingTarget(action)
|
||||
try {
|
||||
if (apiContext.workflow) {
|
||||
const result = await queryClient.fetchQuery(
|
||||
consoleQuery.apps.byAppId.agent.config.skills.byName.files.download.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
app_id: apiContext.workflow.appId,
|
||||
name: skill.name,
|
||||
},
|
||||
query: {
|
||||
node_id: apiContext.workflow.nodeId,
|
||||
path,
|
||||
draft_type: apiContext.draftType,
|
||||
version_id: apiContext.versionId,
|
||||
const result = await queryClient.query({
|
||||
...consoleQuery.apps.byAppId.agent.config.skills.byName.files.download.get.queryOptions(
|
||||
{
|
||||
input: {
|
||||
params: {
|
||||
app_id: apiContext.workflow.appId,
|
||||
name: skill.name,
|
||||
},
|
||||
query: {
|
||||
node_id: apiContext.workflow.nodeId,
|
||||
path,
|
||||
draft_type: apiContext.draftType,
|
||||
version_id: apiContext.versionId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
const data = await queryClient.fetchQuery(getSkillFileContentQueryOptions(result.url))
|
||||
),
|
||||
staleTime: 0,
|
||||
})
|
||||
const data = await queryClient.query(getSkillFileContentQueryOptions(result.url))
|
||||
downloadBlob({ data, fileName: file.name })
|
||||
return
|
||||
}
|
||||
|
||||
const result = await queryClient.fetchQuery(
|
||||
consoleQuery.agent.byAgentId.config.skills.byName.files.download.get.queryOptions({
|
||||
const result = await queryClient.query({
|
||||
...consoleQuery.agent.byAgentId.config.skills.byName.files.download.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
agent_id: apiContext.agentId,
|
||||
@@ -369,8 +374,9 @@ export function useAgentSkillDetail({
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
const data = await queryClient.fetchQuery(getSkillFileContentQueryOptions(result.url))
|
||||
staleTime: 0,
|
||||
})
|
||||
const data = await queryClient.query(getSkillFileContentQueryOptions(result.url))
|
||||
downloadBlob({ data, fileName: file.name })
|
||||
} catch {
|
||||
toast.error(tCommon(($) => $['operation.downloadFailed']))
|
||||
@@ -400,11 +406,11 @@ export function useAgentSkillDetail({
|
||||
: (previewQuery.data?.text ?? undefined),
|
||||
downloadActionLoadingTarget,
|
||||
fileName: selectedFile?.name,
|
||||
imageData: imageContentQuery.data,
|
||||
imageData: imageContentQuery.isFetching ? undefined : imageContentQuery.data,
|
||||
isDownloadError:
|
||||
shouldLoadImagePreview && (downloadQuery.isError || imageContentQuery.isError),
|
||||
isDownloadLoading:
|
||||
shouldLoadImagePreview && (downloadQuery.isPending || imageContentQuery.isPending),
|
||||
shouldLoadImagePreview && (downloadQuery.isFetching || imageContentQuery.isFetching),
|
||||
isError: isSkillMdSelected
|
||||
? inspectQuery.isError
|
||||
: !!selectedPreviewPath && previewQuery.isError,
|
||||
|
||||
+1
-1
@@ -191,7 +191,7 @@ export function AgentPreviewChatConversation({
|
||||
handleSend,
|
||||
callbacks: {
|
||||
onGetConversationMessages: async (conversationId) => {
|
||||
return queryClient.fetchQuery({
|
||||
return queryClient.query({
|
||||
...consoleQuery.agent.byAgentId.chatMessages.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
|
||||
@@ -546,6 +546,14 @@ type RenderOptions = {
|
||||
const localeInput = { query: { language: 'en-US' } }
|
||||
const homeTemplatesQueryKey = ['console', 'explore', 'apps', 'get', localeInput]
|
||||
const exploreBannersQueryKey = ['console', 'explore', 'banners', 'get', localeInput]
|
||||
const recommendedAppQueryKey = (appId: string) => [
|
||||
'console',
|
||||
'explore',
|
||||
'apps',
|
||||
'byAppId',
|
||||
'get',
|
||||
{ params: { app_id: appId } },
|
||||
]
|
||||
|
||||
const renderHomeContent = ({
|
||||
hasEditPermission = false,
|
||||
@@ -1006,6 +1014,36 @@ describe('HomeContent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should reuse an invalidated cached template snapshot when creating an app', async () => {
|
||||
vi.useRealTimers()
|
||||
mockExploreData = {
|
||||
categories: ['Writing'],
|
||||
allList: [createApp()],
|
||||
}
|
||||
mockGetRecommendedApp.mockRejectedValue(new Error('should not fetch'))
|
||||
mockHandleImportDSL.mockResolvedValue(undefined)
|
||||
const { queryClient } = renderHomeContent({ hasEditPermission: true })
|
||||
queryClient.setQueryData(recommendedAppQueryKey('app-1'), {
|
||||
export_data: 'cached-yaml',
|
||||
mode: AppModeEnum.CHAT,
|
||||
})
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: recommendedAppQueryKey('app-1'),
|
||||
exact: true,
|
||||
refetchType: 'none',
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Alpha' }))
|
||||
fireEvent.click(await screen.findByTestId('confirm-create'))
|
||||
|
||||
await waitFor(() => expect(mockHandleImportDSL).toHaveBeenCalledTimes(1))
|
||||
expect(mockGetRecommendedApp).not.toHaveBeenCalled()
|
||||
expect(mockHandleImportDSL).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ yaml_content: 'cached-yaml' }),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
|
||||
it('should open create flow from learn dify item card click', async () => {
|
||||
vi.useRealTimers()
|
||||
const user = userEvent.setup()
|
||||
|
||||
@@ -343,11 +343,12 @@ export function HomeContent() {
|
||||
const appId = currApp?.app_id
|
||||
if (!appId) return
|
||||
|
||||
const appDetail = await queryClient.ensureQueryData(
|
||||
consoleQuery.explore.apps.byAppId.get.queryOptions({
|
||||
const appDetail = await queryClient.query({
|
||||
...consoleQuery.explore.apps.byAppId.get.queryOptions({
|
||||
input: { params: { app_id: appId } },
|
||||
}),
|
||||
)
|
||||
staleTime: 'static',
|
||||
})
|
||||
|
||||
const { export_data, mode } = appDetail
|
||||
currentCreateAppModeRef.current = mode
|
||||
|
||||
+25
-19
@@ -1,7 +1,7 @@
|
||||
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
|
||||
import { dehydrate, HydrationBoundary, noop } from '@tanstack/react-query'
|
||||
import { Suspense } from 'react'
|
||||
import { getQueryClient } from '@/app/get-query-client'
|
||||
import { ensureSystemFeatures } from '@/features/system-features/server'
|
||||
import { getOptionalSystemFeatures } from '@/features/system-features/server'
|
||||
import { getLocaleOnServer } from '@/i18n-config/server'
|
||||
import { getServerConsoleClientContext, serverConsoleQuery } from '@/service/server'
|
||||
import { HomeContent } from './home-content/home-content'
|
||||
@@ -15,27 +15,33 @@ export async function HomePage() {
|
||||
getServerConsoleClientContext(),
|
||||
])
|
||||
|
||||
void homeQueryClient.prefetchQuery(
|
||||
serverConsoleQuery.explore.apps.get.queryOptions({
|
||||
context,
|
||||
input: { query: { language: locale } },
|
||||
}),
|
||||
)
|
||||
void homeQueryClient.prefetchQuery(
|
||||
serverConsoleQuery.apps.recent.get.queryOptions({
|
||||
context,
|
||||
input: { query: { limit: 8 } },
|
||||
}),
|
||||
)
|
||||
|
||||
const enableExploreBanner = (await ensureSystemFeatures()).enable_explore_banner
|
||||
if (enableExploreBanner) {
|
||||
void homeQueryClient.prefetchQuery(
|
||||
serverConsoleQuery.explore.banners.get.queryOptions({
|
||||
void homeQueryClient
|
||||
.query(
|
||||
serverConsoleQuery.explore.apps.get.queryOptions({
|
||||
context,
|
||||
input: { query: { language: locale } },
|
||||
}),
|
||||
)
|
||||
.catch(noop)
|
||||
void homeQueryClient
|
||||
.query(
|
||||
serverConsoleQuery.apps.recent.get.queryOptions({
|
||||
context,
|
||||
input: { query: { limit: 8 } },
|
||||
}),
|
||||
)
|
||||
.catch(noop)
|
||||
|
||||
const enableExploreBanner = (await getOptionalSystemFeatures())?.enable_explore_banner ?? false
|
||||
if (enableExploreBanner) {
|
||||
void homeQueryClient
|
||||
.query(
|
||||
serverConsoleQuery.explore.banners.get.queryOptions({
|
||||
context,
|
||||
input: { query: { language: locale } },
|
||||
}),
|
||||
)
|
||||
.catch(noop)
|
||||
}
|
||||
|
||||
const dehydratedState = dehydrate(homeQueryClient)
|
||||
|
||||
@@ -491,14 +491,18 @@ function createAgentReference(
|
||||
}
|
||||
}
|
||||
|
||||
function renderSkillDetailPage({ strict = false }: { strict?: boolean } = {}) {
|
||||
const queryClient = new QueryClient({
|
||||
function renderSkillDetailPage({
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
}),
|
||||
strict = false,
|
||||
}: {
|
||||
queryClient?: QueryClient
|
||||
strict?: boolean
|
||||
} = {}) {
|
||||
return {
|
||||
...render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -3031,22 +3035,44 @@ describe('SkillDetailPage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('checks references before publishing when the cached reference count is stale', async () => {
|
||||
it('refreshes a fresh empty references cache before publishing', async () => {
|
||||
const user = userEvent.setup()
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, staleTime: Infinity },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
const referencesQueryKey = [
|
||||
'skill-references',
|
||||
{
|
||||
params: {
|
||||
skill_id: 'skill-1',
|
||||
},
|
||||
},
|
||||
]
|
||||
let remoteReferences: SkillReferenceResponse[] = []
|
||||
const referencesQueryFn = vi.fn(async () => ({ data: remoteReferences }))
|
||||
mocks.skillDetail = createSkillDetail({ reference_count: 0 })
|
||||
mocks.skillReferencesQueryOptions.mockImplementation((options) => ({
|
||||
queryKey: ['skill-references', options],
|
||||
queryFn: async () => ({
|
||||
data: [
|
||||
createAgentReference({
|
||||
display_name: 'Stale Count Agent',
|
||||
name: 'stale-count-agent',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
queryKey: ['skill-references', (options as { input: unknown }).input],
|
||||
queryFn: referencesQueryFn,
|
||||
}))
|
||||
queryClient.setQueryData(referencesQueryKey, { data: [] })
|
||||
|
||||
renderSkillDetailPage()
|
||||
renderSkillDetailPage({ queryClient })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(referencesQueryFn).toHaveBeenCalled()
|
||||
expect(queryClient.getQueryState(referencesQueryKey)?.fetchStatus).toBe('idle')
|
||||
})
|
||||
remoteReferences = [
|
||||
createAgentReference({
|
||||
display_name: 'Fresh Cache Agent',
|
||||
name: 'fresh-cache-agent',
|
||||
}),
|
||||
]
|
||||
referencesQueryFn.mockClear()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
@@ -3054,6 +3080,7 @@ describe('SkillDetailPage', () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(referencesQueryFn).toHaveBeenCalled()
|
||||
expect(
|
||||
await screen.findByRole('dialog', {
|
||||
name: 'skill.skillManagement.detail.publishReferencesTitle',
|
||||
@@ -3062,10 +3089,53 @@ describe('SkillDetailPage', () => {
|
||||
expect(
|
||||
screen.getByText('skill.skillManagement.detail.publishReferencesDescription_one:{"count":1}'),
|
||||
).toBeInTheDocument()
|
||||
expect(await screen.findByText('Stale Count Agent')).toBeInTheDocument()
|
||||
expect(await screen.findByText('Fresh Cache Agent')).toBeInTheDocument()
|
||||
expect(mocks.publishSkillMutationFn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not publish when refreshing references fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, staleTime: Infinity },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
let refreshShouldFail = false
|
||||
const referencesQueryFn = vi.fn(async () => {
|
||||
if (refreshShouldFail) throw new Error('references unavailable')
|
||||
return { data: [] }
|
||||
})
|
||||
mocks.skillDetail = createSkillDetail({ reference_count: 0 })
|
||||
mocks.skillReferencesQueryOptions.mockImplementation((options) => ({
|
||||
queryKey: ['skill-references', (options as { input: unknown }).input],
|
||||
queryFn: referencesQueryFn,
|
||||
}))
|
||||
|
||||
renderSkillDetailPage({ queryClient })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(referencesQueryFn).toHaveBeenCalled()
|
||||
expect(queryClient.isFetching()).toBe(0)
|
||||
})
|
||||
refreshShouldFail = true
|
||||
referencesQueryFn.mockClear()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.publishUpdate',
|
||||
}),
|
||||
)
|
||||
|
||||
await waitFor(() => expect(referencesQueryFn).toHaveBeenCalledOnce())
|
||||
expect(mocks.publishSkillMutationFn).not.toHaveBeenCalled()
|
||||
expect(
|
||||
screen.queryByRole('dialog', {
|
||||
name: 'skill.skillManagement.detail.publishReferencesTitle',
|
||||
}),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('cancels publishing from the referenced skill confirmation dialog', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createSkillDetail({ reference_count: 1 })
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||
@@ -986,16 +987,18 @@ export function FileEditor({
|
||||
return
|
||||
}
|
||||
|
||||
const references = await queryClient.fetchQuery(
|
||||
consoleQuery.workspaces.current.skills.bySkillId.references.get.queryOptions({
|
||||
const references = await queryClient.query({
|
||||
...consoleQuery.workspaces.current.skills.bySkillId.references.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
skill_id: skillId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
staleTime: 0,
|
||||
})
|
||||
const fetchedReferenceCount = references.data?.length ?? 0
|
||||
|
||||
if (fetchedReferenceCount > 0) {
|
||||
if (detail && detail.reference_count !== fetchedReferenceCount)
|
||||
setSkillDetailCache(queryClient, skillId, {
|
||||
@@ -1019,12 +1022,16 @@ export function FileEditor({
|
||||
skillId,
|
||||
])
|
||||
|
||||
const handlePublishAction = useCallback(() => {
|
||||
void handlePublish().catch(noop)
|
||||
}, [handlePublish])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingPublishAfterSaveRef.current || saveStatus === 'saving') return
|
||||
|
||||
pendingPublishAfterSaveRef.current = false
|
||||
void handlePublish()
|
||||
}, [handlePublish, saveStatus])
|
||||
handlePublishAction()
|
||||
}, [handlePublishAction, saveStatus])
|
||||
|
||||
const saveStateText =
|
||||
saveStatus === 'saving'
|
||||
@@ -1496,7 +1503,7 @@ export function FileEditor({
|
||||
canPublish={canPublish}
|
||||
metaLabel={publishMetaText}
|
||||
onOpenVersions={onOpenVersions}
|
||||
onPublish={handlePublish}
|
||||
onPublish={handlePublishAction}
|
||||
state={publishState}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
# System Features
|
||||
|
||||
This feature owns the deployment-wide capability query contract and application bootstrap boundary.
|
||||
This feature owns the deployment-wide capability query contract and application bootstrap boundary. It is neither a
|
||||
cross-request server cache nor a general configuration registry.
|
||||
|
||||
- `server.ts` exposes the request-scoped query client and canonical server query options; route and layout owners perform prefetching and hydration.
|
||||
- `client.ts` owns the canonical query options.
|
||||
- `bootstrap-boundary.tsx` blocks application rendering until capability data is available and owns retry UI.
|
||||
- The generated System Features operation owns the shared query identity and response contract.
|
||||
- `server.ts` owns request-scoped imperative resolution and dehydration. Under the [TanStack Query prefetching] semantics,
|
||||
`'static'` accepts any successful request-local snapshot, even after invalidation. Optional access maps an initial
|
||||
no-data failure to `undefined` without another optional attempt; required access reuses a successful snapshot, starts
|
||||
a new resolution after an earlier no-data failure, and preserves rejection for route gates. Neither path configures
|
||||
automatic retry.
|
||||
- `client.ts` owns the browser observer policy. `Infinity` keeps the hydrated bootstrap snapshot fresh without automatic
|
||||
revalidation, while still allowing explicit invalidation or refetching.
|
||||
- `bootstrap-boundary.tsx` blocks application rendering until the browser cache has capability data. When optional SSR
|
||||
access fails, this boundary owns the client recovery request and its loading, error, and retry UI.
|
||||
- `state.ts` exposes narrow derived client atoms backed by the same TanStack Query data.
|
||||
|
||||
Consumers read capabilities through this feature instead of issuing another request or copying deployment-edition state.
|
||||
Following [TanStack Query advanced SSR], server reads are limited to metadata, route gates, integration inclusion, and
|
||||
Home prefetch shape; application rendering stays client-owned through the hydrated cache. Revisit that split if the
|
||||
browser snapshot becomes automatically revalidating. Consumers must not issue a second capability request or copy
|
||||
deployment-edition state.
|
||||
|
||||
[TanStack Query advanced SSR]: https://tanstack.com/query/latest/docs/framework/react/guides/advanced-ssr
|
||||
[TanStack Query prefetching]: https://tanstack.com/query/latest/docs/framework/react/guides/prefetching
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import {
|
||||
dehydrate,
|
||||
HydrationBoundary,
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
} from '@tanstack/react-query'
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { serverConsoleQuery } from '@/service/server'
|
||||
import { createSystemFeaturesFixture } from '@/test/console/system-features'
|
||||
import { SystemFeaturesBootstrapBoundary } from '../bootstrap-boundary'
|
||||
|
||||
const queryKey = ['console', 'system-features', 'get']
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
@@ -26,24 +30,39 @@ const mocks = vi.hoisted(() => ({
|
||||
query: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
systemFeaturesQueryOptions: () => ({
|
||||
queryKey,
|
||||
queryFn: mocks.query,
|
||||
retryDelay: 0,
|
||||
staleTime: Infinity,
|
||||
}),
|
||||
vi.mock('server-only', () => ({}))
|
||||
|
||||
vi.mock('@/config/server', () => ({
|
||||
SERVER_CONSOLE_API_PREFIX: 'http://localhost/console/api',
|
||||
}))
|
||||
|
||||
vi.mock('../client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../client')>()
|
||||
|
||||
return {
|
||||
...actual,
|
||||
systemFeaturesQueryOptions: () => ({
|
||||
...actual.systemFeaturesQueryOptions(),
|
||||
queryFn: mocks.query,
|
||||
retryDelay: 0,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/full-screen-loading', () => ({
|
||||
FullScreenLoading: () => <div role="status">Loading system features</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/client', () => ({
|
||||
get isClient() {
|
||||
return mocks.isClient
|
||||
},
|
||||
}))
|
||||
vi.mock('@/utils/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/utils/client')>()
|
||||
|
||||
return {
|
||||
...actual,
|
||||
get isClient() {
|
||||
return mocks.isClient
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
function createQueryClient() {
|
||||
return new QueryClient()
|
||||
@@ -65,11 +84,23 @@ describe('SystemFeaturesBootstrapBoundary', () => {
|
||||
mocks.isClient = true
|
||||
})
|
||||
|
||||
it('renders immediately from hydrated data without a client request', () => {
|
||||
it('hydrates the server System Features query into the client boundary', () => {
|
||||
const serverQueryClient = createQueryClient()
|
||||
serverQueryClient.setQueryData(
|
||||
serverConsoleQuery.systemFeatures.get.queryKey(),
|
||||
createSystemFeaturesFixture(),
|
||||
)
|
||||
const queryClient = createQueryClient()
|
||||
queryClient.setQueryData(queryKey, createSystemFeaturesFixture())
|
||||
|
||||
renderBoundary(<div>Application</div>, queryClient)
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HydrationBoundary state={dehydrate(serverQueryClient)}>
|
||||
<SystemFeaturesBootstrapBoundary>
|
||||
<div>Application</div>
|
||||
</SystemFeaturesBootstrapBoundary>
|
||||
</HydrationBoundary>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Application')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
@@ -102,7 +133,7 @@ describe('SystemFeaturesBootstrapBoundary', () => {
|
||||
expect(mocks.query).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows a manual retry after the client retry budget is exhausted', async () => {
|
||||
it('allows a manual retry after a client query error', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.query.mockRejectedValue(new Error('system features unavailable'))
|
||||
|
||||
|
||||
@@ -2,17 +2,38 @@
|
||||
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
let queryClient: QueryClient
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
connection: vi.fn(async () => undefined),
|
||||
getQueryClient: vi.fn(),
|
||||
getSystemFeatures: vi.fn(),
|
||||
queryKey: [['console', 'systemFeatures', 'get'], { type: 'query' }] as const,
|
||||
}))
|
||||
|
||||
vi.mock('server-only', () => ({}))
|
||||
|
||||
vi.mock('react', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('react')>()
|
||||
|
||||
return {
|
||||
...actual,
|
||||
cache: (factory: () => unknown) => {
|
||||
let initialized = false
|
||||
let value: unknown
|
||||
|
||||
return () => {
|
||||
if (!initialized) {
|
||||
value = factory()
|
||||
initialized = true
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/get-query-client', () => ({
|
||||
getQueryClient: () => queryClient,
|
||||
getQueryClient: mocks.getQueryClient,
|
||||
}))
|
||||
|
||||
vi.mock('@/next/server', () => ({
|
||||
@@ -23,10 +44,11 @@ vi.mock('@/service/server', () => ({
|
||||
serverConsoleQuery: {
|
||||
systemFeatures: {
|
||||
get: {
|
||||
queryOptions: () => ({
|
||||
queryKey: ['console', 'system-features'],
|
||||
queryOptions: (options?: { staleTime?: 'static' }) => ({
|
||||
queryKey: mocks.queryKey,
|
||||
queryFn: mocks.getSystemFeatures,
|
||||
retry: false,
|
||||
...options,
|
||||
}),
|
||||
},
|
||||
},
|
||||
@@ -36,50 +58,51 @@ vi.mock('@/service/server', () => ({
|
||||
describe('System Features server requests', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
vi.resetModules()
|
||||
mocks.getQueryClient.mockImplementation(
|
||||
() => new QueryClient({ defaultOptions: { queries: { retry: false } } }),
|
||||
)
|
||||
mocks.connection.mockResolvedValue(undefined)
|
||||
mocks.getSystemFeatures.mockResolvedValue({ deployment_edition: 'CLOUD' })
|
||||
})
|
||||
|
||||
it.each(['prefetch', 'ensure'] as const)(
|
||||
'waits for the request boundary before the %s query starts',
|
||||
async (operation) => {
|
||||
let establishConnection!: () => void
|
||||
mocks.connection.mockImplementation(
|
||||
() =>
|
||||
new Promise<undefined>((resolve) => {
|
||||
establishConnection = () => resolve(undefined)
|
||||
}),
|
||||
)
|
||||
const { ensureSystemFeatures, prefetchSystemFeatures } = await import('../server')
|
||||
it('reuses a successful optional lookup for the rest of the request', async () => {
|
||||
const { dehydrateSystemFeatures, getOptionalSystemFeatures } = await import('../server')
|
||||
|
||||
const result = operation === 'prefetch' ? prefetchSystemFeatures() : ensureSystemFeatures()
|
||||
await Promise.resolve()
|
||||
await expect(getOptionalSystemFeatures()).resolves.toEqual({ deployment_edition: 'CLOUD' })
|
||||
await expect(getOptionalSystemFeatures()).resolves.toEqual({ deployment_edition: 'CLOUD' })
|
||||
|
||||
expect(mocks.getSystemFeatures).not.toHaveBeenCalled()
|
||||
|
||||
establishConnection()
|
||||
await expect(result).resolves.toEqual({ deployment_edition: 'CLOUD' })
|
||||
expect(mocks.getSystemFeatures).toHaveBeenCalledOnce()
|
||||
expect(queryClient.getQueryData(['console', 'system-features'])).toEqual({
|
||||
deployment_edition: 'CLOUD',
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps prefetch failures soft', async () => {
|
||||
mocks.getSystemFeatures.mockRejectedValue(new Error('System Features unavailable'))
|
||||
const { prefetchSystemFeatures } = await import('../server')
|
||||
|
||||
await expect(prefetchSystemFeatures()).resolves.toBeUndefined()
|
||||
expect(queryClient.getQueryData(['console', 'system-features'])).toBeUndefined()
|
||||
expect(mocks.getSystemFeatures).toHaveBeenCalledOnce()
|
||||
expect(mocks.getQueryClient).toHaveBeenCalledOnce()
|
||||
expect(dehydrateSystemFeatures().queries).toEqual([
|
||||
expect.objectContaining({
|
||||
queryKey: mocks.queryKey,
|
||||
state: expect.objectContaining({
|
||||
data: { deployment_edition: 'CLOUD' },
|
||||
status: 'success',
|
||||
}),
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves ensure failures for hard route gates', async () => {
|
||||
it('keeps optional failures soft and lets required consumers retry', async () => {
|
||||
mocks.getSystemFeatures
|
||||
.mockRejectedValueOnce(new Error('System Features unavailable'))
|
||||
.mockResolvedValueOnce({ deployment_edition: 'CLOUD' })
|
||||
const { getOptionalSystemFeatures, getSystemFeatures } = await import('../server')
|
||||
|
||||
await expect(getOptionalSystemFeatures()).resolves.toBeUndefined()
|
||||
await expect(getOptionalSystemFeatures()).resolves.toBeUndefined()
|
||||
await expect(getSystemFeatures()).resolves.toEqual({ deployment_edition: 'CLOUD' })
|
||||
|
||||
expect(mocks.getSystemFeatures).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('preserves required failures for hard route gates', async () => {
|
||||
const error = new Error('System Features unavailable')
|
||||
mocks.getSystemFeatures.mockRejectedValue(error)
|
||||
const { ensureSystemFeatures } = await import('../server')
|
||||
const { getSystemFeatures } = await import('../server')
|
||||
|
||||
await expect(ensureSystemFeatures()).rejects.toBe(error)
|
||||
await expect(getSystemFeatures()).rejects.toBe(error)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,31 +1,29 @@
|
||||
import { dehydrate } from '@tanstack/react-query'
|
||||
import { cache } from 'react'
|
||||
import { getQueryClient } from '@/app/get-query-client'
|
||||
import { connection } from '@/next/server'
|
||||
import { serverConsoleQuery } from '@/service/server'
|
||||
import 'server-only'
|
||||
|
||||
export const getSystemFeaturesQueryClient = cache(getQueryClient)
|
||||
const getRequestQueryClient = cache(getQueryClient)
|
||||
|
||||
const systemFeaturesServerQueryOptions = () => serverConsoleQuery.systemFeatures.get.queryOptions()
|
||||
const systemFeaturesServerQueryOptions = () =>
|
||||
serverConsoleQuery.systemFeatures.get.queryOptions({ staleTime: 'static' })
|
||||
|
||||
export const getCachedSystemFeatures = () => {
|
||||
const queryClient = getSystemFeaturesQueryClient()
|
||||
const queryOptions = systemFeaturesServerQueryOptions()
|
||||
return queryClient.getQueryData(queryOptions.queryKey)
|
||||
}
|
||||
|
||||
export const prefetchSystemFeatures = async () => {
|
||||
export const getOptionalSystemFeatures = async () => {
|
||||
await connection()
|
||||
const queryClient = getSystemFeaturesQueryClient()
|
||||
const queryClient = getRequestQueryClient()
|
||||
const queryOptions = systemFeaturesServerQueryOptions()
|
||||
const queryState = queryClient.getQueryState(queryOptions.queryKey)
|
||||
|
||||
if (!queryState || queryState.status === 'pending') await queryClient.prefetchQuery(queryOptions)
|
||||
if (queryState?.status === 'error' && queryState.data === undefined) return undefined
|
||||
|
||||
return queryClient.getQueryData(queryOptions.queryKey)
|
||||
return queryClient.query(queryOptions).catch(() => undefined)
|
||||
}
|
||||
|
||||
export const ensureSystemFeatures = async () => {
|
||||
export const getSystemFeatures = async () => {
|
||||
await connection()
|
||||
return getSystemFeaturesQueryClient().ensureQueryData(systemFeaturesServerQueryOptions())
|
||||
return getRequestQueryClient().query(systemFeaturesServerQueryOptions())
|
||||
}
|
||||
|
||||
export const dehydrateSystemFeatures = () => dehydrate(getRequestQueryClient())
|
||||
|
||||
@@ -584,7 +584,7 @@ describe('consoleQuery education defaults', () => {
|
||||
})
|
||||
|
||||
await expect(
|
||||
queryClient.fetchQuery(consoleQuery.account.education.get.queryOptions()),
|
||||
queryClient.query(consoleQuery.account.education.get.queryOptions()),
|
||||
).rejects.toThrow('education status failed')
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
@@ -1602,6 +1602,70 @@ describe('consoleQuery Web app access mutation defaults', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('consoleQuery App Instance mutation defaults', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('should bypass a fresh list cache while waiting for a created app instance', async () => {
|
||||
const appInstance = {
|
||||
id: 'app-instance-1',
|
||||
tenantId: 'tenant-1',
|
||||
displayName: 'Production App',
|
||||
description: '',
|
||||
createdBy: { id: 'user-1', displayName: 'Ada' },
|
||||
updatedBy: { id: 'user-1', displayName: 'Ada' },
|
||||
createdAt: '2026-09-03T00:00:00Z',
|
||||
updatedAt: '2026-09-03T00:00:00Z',
|
||||
}
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ appInstances: [], pagination: {} }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ appInstances: [appInstance], pagination: {} }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
const consoleQuery = await loadConsoleQueryWithRequest(request)
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, staleTime: Infinity },
|
||||
},
|
||||
})
|
||||
const listInput = {
|
||||
query: {
|
||||
pageNumber: 1,
|
||||
resultsPerPage: 100,
|
||||
},
|
||||
}
|
||||
const listOptions = consoleQuery.enterprise.appInstanceService.listAppInstances.queryOptions({
|
||||
input: listInput,
|
||||
})
|
||||
queryClient.setQueryData(listOptions.queryKey, {
|
||||
appInstances: [],
|
||||
pagination: {},
|
||||
})
|
||||
|
||||
const onSuccess =
|
||||
consoleQuery.enterprise.appInstanceService.createAppInstance.mutationOptions().onSuccess
|
||||
const completion = onSuccess?.(
|
||||
{ appInstance },
|
||||
{ body: { displayName: appInstance.displayName } },
|
||||
undefined,
|
||||
createMutationContext(queryClient),
|
||||
)
|
||||
await completion
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: legacy App Deploy mutations share cache behavior through oRPC defaults.
|
||||
describe('consoleQuery App Deploy mutation defaults', () => {
|
||||
afterEach(() => {
|
||||
|
||||
+11
-8
@@ -1376,16 +1376,19 @@ export const consoleQuery: RouterUtils<typeof consoleClient> = createTanstackQue
|
||||
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
|
||||
const listResponse = await context.client
|
||||
.fetchQuery(
|
||||
consoleQuery.enterprise.appInstanceService.listAppInstances.queryOptions({
|
||||
input: {
|
||||
query: {
|
||||
pageNumber: 1,
|
||||
resultsPerPage: APP_DEPLOY_SOURCE_APPS_PAGE_SIZE,
|
||||
.query({
|
||||
...consoleQuery.enterprise.appInstanceService.listAppInstances.queryOptions(
|
||||
{
|
||||
input: {
|
||||
query: {
|
||||
pageNumber: 1,
|
||||
resultsPerPage: APP_DEPLOY_SOURCE_APPS_PAGE_SIZE,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
),
|
||||
staleTime: 0,
|
||||
})
|
||||
.catch(() => undefined)
|
||||
|
||||
if (listResponse?.appInstances?.some((app) => app.id === appInstanceId)) break
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { ReactElement, ReactNode } from 'react'
|
||||
import type { UserProfileWithMeta } from '@/features/account-profile/client'
|
||||
import type { DeepPartial } from '@/test/console/system-features'
|
||||
import { zGetFeaturesResponse } from '@dify/contracts/api/console/features/zod.gen'
|
||||
import { noop } from '@tanstack/react-query'
|
||||
import { render, renderHook } from '@testing-library/react'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { ensureAccountProfileQuery, seedAccountProfileQuery } from '@/test/console/account-profile'
|
||||
@@ -143,10 +144,12 @@ const ensureSystemFeatures = (queryClient: QueryClient) => {
|
||||
}
|
||||
|
||||
const seedPendingSystemFeatures = (queryClient: QueryClient) => {
|
||||
void queryClient.prefetchQuery({
|
||||
queryKey: consoleQuery.systemFeatures.get.queryKey(),
|
||||
queryFn: () => new Promise<GetSystemFeaturesResponse>(() => {}),
|
||||
})
|
||||
void queryClient
|
||||
.query({
|
||||
queryKey: consoleQuery.systemFeatures.get.queryKey(),
|
||||
queryFn: () => new Promise<GetSystemFeaturesResponse>(() => {}),
|
||||
})
|
||||
.catch(noop)
|
||||
}
|
||||
|
||||
const seedTrialModels = (queryClient: QueryClient, trialModels: readonly string[] = []) => {
|
||||
|
||||
Reference in New Issue
Block a user