refactor(web): manage deployment async state with atoms (#37819)

This commit is contained in:
Stephen Zhou 2026-06-23 20:50:16 +08:00 committed by GitHub
parent ed500761c8
commit d8ed874dc7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 415 additions and 150 deletions

View File

@ -44,14 +44,16 @@ Use this as the decision guide for React/TypeScript component structure. Existin
## Feature-Scoped Jotai State
- A module's feature-local state lives in one state file for Jotai-backed features: primitive atoms, query atoms, derived atoms, write-only action atoms, mutation atoms, submission orchestration, provider exports, and optional scope configuration.
- Keep state local when one component owns it, even inside Jotai-backed features. Dialog open flags, menu/popover visibility, confirmation visibility, form/input drafts, row-local pending flags, and in-flight refs usually belong in component state.
- Keep synchronous UI state local when one component owns it, even inside Jotai-backed features. Dialog open flags, menu/popover visibility, confirmation visibility, form/input drafts, and selected local options usually belong in component state.
- In Jotai-backed feature surfaces, never hand-roll async loading, error, or in-flight guards with `useState` or `useRef`. This includes remote calls and local async work such as `File.text()`, export/download preparation, parsing, and validation calls. Model the work with `atomWithQuery` or `atomWithMutation`; write atoms should only update the inputs that drive those atoms.
- Row-local async state should still come from query or mutation atoms. When a repeated row needs isolated pending/error state, create a per-instance atom with a small factory and memoize it at the row boundary. Keep only synchronous row-local UI state, such as menu open, dialog open, drafts, and selected options, in local component state.
- Promote UI state to an atom only when siblings need the same source of truth, the value drives a query or mutation atom, a parent workflow coordinates the state, or the state intentionally persists across hidden or unmounted descendants within a scoped surface.
- Reflect atom-backed surface-wide locks or invariants in every affected trigger. If only one row, menu, or dialog should be disabled, keep the pending or lock state local to that row, menu, or dialog.
- Reflect atom-backed surface-wide locks or invariants in every affected trigger. If only one row, menu, or dialog should be disabled, keep the pending or lock scope local to that row, menu, or dialog through a per-instance query/mutation atom; keep only synchronous UI locks in local component state.
- Atom order in the state file follows the dependency graph: types/constants, editable primitives, query atoms, query-data derived atoms, readiness/business derived atoms, write actions, mutation atoms, submission orchestration, provider exports.
- Derived atom names read as business facts. Write atom names read as user or workflow commands.
- UI components read and write the exact atom they use with `useAtomValue` or `useSetAtom`. Repeated workflow semantics live in named derived atoms or write atoms.
- Non-query derived atoms return a narrow value with a clear domain name; avoid pass-through aliases or bundling unrelated UI facts. Query atoms expose the TanStack Query result object so loading, error, fetch, and pagination state stay attached to the query contract.
- Write-only atoms own synchronous state transitions that update multiple primitives, reset dependent state, or advance the workflow. Async work with loading, error, caching, retry, or stale-result concerns should be modeled as query or mutation atoms, with write atoms only changing the inputs that drive them.
- Write-only atoms own synchronous state transitions that update multiple primitives, reset dependent state, or advance the workflow. Async work with loading, error, caching, retry, stale-result, or in-flight concerns should be modeled as query or mutation atoms, with write atoms only changing the inputs that drive them.
- Avoid feature hooks that aggregate form values, query results, derived state, and commands for sibling components. Prefer named derived atoms and write atoms so UI components read the exact shared fact or command they need.
- When a form library owns validation, keep submit orchestration in feature state when post-submit result or error state is shared by the surface. Avoid duplicating validation gates or request shaping in UI hooks.
- `jotai-tanstack-query` atoms use the same QueryClient as the React Query provider. Query atoms belong in feature state when atoms are the feature's local state surface.
@ -110,7 +112,7 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- For TanStack cache data, use generated or query-derived types; do not create local wrappers for `getQueryData` or `getQueriesData`.
- For generated oRPC `queryOptions()` / `infiniteOptions()`, keep returning the generated options directly. When required input is missing, use a whole-input branch such as `input: condition ? validInput : skipToken` together with `enabled: Boolean(condition)` so no request runs and no fake payload is built.
- Do not put `skipToken` inside a nested placeholder payload, such as `{ params: { appInstanceId: skipToken } }`. Do not create hand-written "missing queryOptions" objects or coerce required IDs to `''`.
- Consume mutations directly with `useMutation(consoleQuery.xxx.mutationOptions(...))` or `useMutation(marketplaceQuery.xxx.mutationOptions(...))`; use oRPC clients as `mutationFn` only for custom flows.
- Consume mutations directly with `useMutation(consoleQuery.xxx.mutationOptions(...))` or `useMutation(marketplaceQuery.xxx.mutationOptions(...))` only in independent non-Jotai component surfaces. In Jotai-backed feature surfaces, expose mutations from feature state with `atomWithMutation` so pending/error state stays attached to the mutation atom; use oRPC clients as `mutationFn` only for custom flows.
- Put shared cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`; components may add UI feedback callbacks, but should not own shared invalidation rules.
- Component or atom mutation callbacks can handle local UI feedback such as toasts, closing dialogs, or navigation. They should not replace shared invalidation or add local cache patches for shared server state.
- Do not use deprecated `useInvalid` or `useReset`.

View File

@ -123,6 +123,14 @@ async function loadState() {
return await import('../index')
}
function workflowDsl() {
return [
'app:',
' mode: workflow',
' name: Imported guide',
].join('\n')
}
describe('create deployment guide state', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -215,4 +223,24 @@ describe('create deployment guide state', () => {
expect(store.get(state.releaseNameAtom)).toBe('Initial Release')
expect(store.get(state.stepAtom)).toBe('release')
})
it('should read selected DSL file content through the file content query', async () => {
const state = await loadState()
const store = createStore()
const text = vi.fn().mockResolvedValue(workflowDsl())
const file = new File([], 'workflow.yml', { type: 'text/yaml' })
Object.defineProperty(file, 'text', { value: text })
store.set(state.selectDslFileAtom, file)
mockQueryResults.current.set('createGuideDslFileContent', {
data: workflowDsl(),
isSuccess: true,
})
expect(text).not.toHaveBeenCalled()
expect(store.get(state.dslFileAtom)).toBe(file)
expect(store.get(state.dslDefaultAppNameAtom)).toBe('Imported guide')
expect(store.get(state.isReadingDslAtom)).toBe(false)
expect(store.get(state.dslReadErrorAtom)).toBe(false)
})
})

View File

@ -10,7 +10,7 @@ import type { RuntimeCredentialBindingSelections } from '@/features/deployments/
import type { UnsupportedDslNode } from '@/features/deployments/shared/domain/error'
import type { App } from '@/types/app'
import { EnvVarValueSource as ApiEnvVarValueSource } from '@dify/contracts/enterprise/types.gen'
import { keepPreviousData, skipToken } from '@tanstack/react-query'
import { keepPreviousData, queryOptions, skipToken } from '@tanstack/react-query'
import { atom } from 'jotai'
import { atomWithInfiniteQuery, atomWithMutation, atomWithQuery } from 'jotai-tanstack-query'
import { envVarBindingSlotFromContract, envVarBindingValueType } from '@/features/deployments/components/env-var-bindings-utils'
@ -139,10 +139,41 @@ export const selectedAppAtom = atom<WorkflowSourceApp | undefined>(undefined)
// DSL primitives and derived state
export const dslFileAtom = atom<File | undefined>(undefined)
const dslContentAtom = atom('')
export const isReadingDslAtom = atom(false)
export const dslReadErrorAtom = atom(false)
const dslReadTokenAtom = atom(0)
const dslFileReadVersionAtom = atom(0)
const dslFileContentQueryAtom = atomWithQuery((get) => {
const file = get(dslFileAtom)
const fileReadVersion = get(dslFileReadVersionAtom)
return queryOptions({
queryKey: [
'createGuideDslFileContent',
fileReadVersion,
file,
file?.name ?? '',
file?.size ?? 0,
file?.lastModified ?? 0,
],
queryFn: async () => file ? await file.text() : '',
enabled: Boolean(file),
retry: false,
})
})
const dslContentAtom = atom((get) => {
return get(dslFileContentQueryAtom).data ?? ''
})
export const isReadingDslAtom = atom((get) => {
const file = get(dslFileAtom)
const dslFileContentQuery = get(dslFileContentQueryAtom)
return Boolean(file && (dslFileContentQuery.isLoading || dslFileContentQuery.isFetching))
})
export const dslReadErrorAtom = atom((get) => {
return Boolean(get(dslFileAtom) && get(dslFileContentQueryAtom).isError)
})
export const dslDefaultAppNameAtom = atom((get) => {
const dslContent = get(dslContentAtom)
@ -470,42 +501,14 @@ export const continueFromSourceAtom = atom(null, (get, set, {
})
// DSL actions
export const selectDslFileAtom = atom(null, async (get, set, dslFile?: File) => {
export const selectDslFileAtom = atom(null, (get, set, dslFile?: File) => {
set(selectedEnvironmentIdAtom, '')
set(manualBindingSelectionsAtom, {})
set(envVarValuesAtom, {})
set(submissionUnsupportedDslNodesAtom, [])
// Token guard prevents a slow read from an older file from overwriting the newest selection.
const dslReadToken = get(dslReadTokenAtom) + 1
set(dslReadTokenAtom, dslReadToken)
set(dslFileReadVersionAtom, get(dslFileReadVersionAtom) + 1)
set(dslFileAtom, dslFile)
set(dslContentAtom, '')
set(isReadingDslAtom, Boolean(dslFile))
set(dslReadErrorAtom, false)
if (!dslFile)
return
try {
const content = await dslFile.text()
if (get(dslReadTokenAtom) !== dslReadToken)
return
set(dslContentAtom, content)
set(dslReadErrorAtom, false)
}
catch {
if (get(dslReadTokenAtom) !== dslReadToken)
return
set(dslContentAtom, '')
set(dslReadErrorAtom, true)
}
finally {
if (get(dslReadTokenAtom) === dslReadToken)
set(isReadingDslAtom, false)
}
})
// Release derived state and actions
@ -914,10 +917,7 @@ export const createDeploymentGuideScopedAtoms = [
sourceSearchTextAtom,
selectedAppAtom,
dslFileAtom,
dslContentAtom,
isReadingDslAtom,
dslReadErrorAtom,
dslReadTokenAtom,
dslFileReadVersionAtom,
instanceNameAtom,
instanceDescriptionAtom,
releaseNameAtom,

View File

@ -11,6 +11,10 @@ type QueryOptions = {
refetchInterval?: (query: { state: { data?: unknown } }) => number | false
}
type MutationOptions = {
mutationKey?: readonly unknown[]
}
vi.mock('jotai-tanstack-query', () => ({
atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom(get => ({
...createOptions(get),
@ -20,6 +24,7 @@ vi.mock('jotai-tanstack-query', () => ({
isLoading: false,
isSuccess: false,
})),
atomWithMutation: (createOptions: () => MutationOptions) => atom(() => createOptions()),
}))
vi.mock('@/service/client', () => ({
@ -56,6 +61,9 @@ vi.mock('@/service/client', () => ({
queryKey: ['listEnvironmentDeployments', options.input],
}),
},
undeploy: {
mutationOptions: () => ({ mutationKey: ['undeploy'] }),
},
},
},
},
@ -123,4 +131,14 @@ describe('deployment detail state', () => {
input: { params: { app_id: 'source-app-1' } },
})
})
it('should expose deployment row mutations from state', async () => {
const state = await loadState()
const store = createStore()
const undeployDeploymentMutationAtom = state.createUndeployDeploymentMutationAtom()
expect(store.get(undeployDeploymentMutationAtom)).toMatchObject({
mutationKey: ['undeploy'],
})
})
})

View File

@ -2,14 +2,13 @@
import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen'
import { RuntimeInstanceStatus } from '@dify/contracts/enterprise/types.gen'
import { useMutation } from '@tanstack/react-query'
import { useSetAtom } from 'jotai'
import { useRef, useState } from 'react'
import { useAtomValue, useSetAtom } from 'jotai'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { openDeployDrawerAtom } from '../../deploy-drawer/state'
import { createDeploymentIdempotencyKey } from '../../shared/domain/idempotency'
import { isRuntimeDeploymentInProgress, isUndeployedDeploymentRow } from '../../shared/domain/runtime-status'
import { createUndeployDeploymentMutationAtom } from '../state'
import { DeploymentErrorDialog } from './deployment-error-dialog'
import { DeploymentActionsDropdown } from './deployment-row-actions-menu'
import { UndeployDeploymentDialog } from './undeploy-deployment-dialog'
@ -21,14 +20,13 @@ export function DeploymentRowActions({ appInstanceId, envId, row }: {
}) {
const { t } = useTranslation('deployments')
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
const undeployDeployment = useMutation(consoleQuery.enterprise.deploymentService.undeploy.mutationOptions())
const undeployDeploymentMutationAtom = useMemo(() => createUndeployDeploymentMutationAtom(), [])
const undeployDeployment = useAtomValue(undeployDeploymentMutationAtom)
const [showUndeployConfirm, setShowUndeployConfirm] = useState(false)
const [showErrorDetail, setShowErrorDetail] = useState(false)
const [isUndeploying, setIsUndeploying] = useState(false)
const undeployInFlightRef = useRef(false)
const isUndeployed = isUndeployedDeploymentRow(row)
const status = row.status
const isUndeployRequesting = undeployDeployment.isPending || isUndeploying
const isUndeployRequesting = undeployDeployment.isPending
const undeployActionDisabled = isUndeployRequesting
const isDeploymentInProgress = isRuntimeDeploymentInProgress(status)
const isDeployFailed = status === RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_FAILED
@ -43,11 +41,9 @@ export function DeploymentRowActions({ appInstanceId, envId, row }: {
}
function handleUndeploy() {
if (undeployInFlightRef.current)
if (isUndeployRequesting)
return
undeployInFlightRef.current = true
setIsUndeploying(true)
undeployDeployment.mutate(
{
params: { appInstanceId, environmentId: envId },
@ -59,8 +55,6 @@ export function DeploymentRowActions({ appInstanceId, envId, row }: {
},
{
onSettled: () => {
undeployInFlightRef.current = false
setIsUndeploying(false)
setShowUndeployConfirm(false)
},
},

View File

@ -3,14 +3,16 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { ApiKeyGenerateMenu } from '../api-key-generate-menu'
const mockMutate = vi.fn()
const mockUseMutation = vi.hoisted(() => vi.fn())
const mockMutate = vi.hoisted(() => vi.fn())
vi.mock('../state', async () => {
const { atom } = await import('jotai')
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useMutation: (...args: unknown[]) => mockUseMutation(...args),
createApiKeyMutationAtom: atom({
isPending: false,
mutate: mockMutate,
}),
}
})
@ -24,10 +26,6 @@ function createEnvironment(): Environment {
describe('ApiKeyGenerateMenu', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseMutation.mockReturnValue({
isPending: false,
mutate: mockMutate,
})
})
it('should show the required name error when submitting an empty name', () => {

View File

@ -3,13 +3,16 @@ import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AccessChannelsSection } from '../channels-section'
const mockUseMutation = vi.hoisted(() => vi.fn())
const mockToggleAccessChannel = vi.hoisted(() => vi.fn())
vi.mock('../state', async () => {
const { atom } = await import('jotai')
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useMutation: (...args: unknown[]) => mockUseMutation(...args),
updateAccessChannelsMutationAtom: atom({
isPending: false,
mutate: mockToggleAccessChannel,
}),
}
})
@ -38,10 +41,6 @@ function createEndpoint(endpointUrl: string): AccessEndpoint {
describe('AccessChannelsSection', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseMutation.mockReturnValue({
isPending: false,
mutate: vi.fn(),
})
})
it('should render channel descriptions when access channels are enabled', () => {

View File

@ -7,14 +7,16 @@ import { describe, expect, it, vi } from 'vitest'
import { EnvironmentPermissionRow } from '../permissions'
import { AccessPermissionsSection } from '../permissions-section'
const mockMutate = vi.fn()
const mockUseMutation = vi.hoisted(() => vi.fn())
const mockMutate = vi.hoisted(() => vi.fn())
vi.mock('../state', async () => {
const { atom } = await import('jotai')
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useMutation: (...args: unknown[]) => mockUseMutation(...args),
createUpdateAccessPolicyMutationAtom: () => atom({
isPending: false,
mutate: mockMutate,
}),
}
})
@ -77,10 +79,6 @@ describe('EnvironmentPermissionRow', () => {
mockMutate.mockImplementation((_variables: unknown, options?: { onError?: () => void }) => {
options?.onError?.()
})
mockUseMutation.mockReturnValue({
isPending: false,
mutate: mockMutate,
})
})
it('should keep the previous permission visible when updating the policy fails', () => {
@ -121,10 +119,6 @@ describe('EnvironmentPermissionRow', () => {
describe('AccessPermissionsSection', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseMutation.mockReturnValue({
isPending: false,
mutate: mockMutate,
})
})
it('should render permission rows without column headers', () => {

View File

@ -0,0 +1,118 @@
import type { Getter } from 'jotai'
import { skipToken } from '@tanstack/react-query'
import { atom, createStore } from 'jotai'
import { describe, expect, it, vi } from 'vitest'
import { setNextRouteStateAtom } from '@/app/components/next-route-state/atoms'
type QueryOptions = {
enabled?: boolean
input?: unknown
queryKey?: readonly unknown[]
}
type MutationOptions = {
mutationKey?: readonly unknown[]
}
vi.mock('jotai-tanstack-query', () => ({
atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom(get => ({
...createOptions(get),
data: undefined,
isError: false,
isFetching: false,
isLoading: false,
isSuccess: false,
})),
atomWithMutation: (createOptions: () => MutationOptions) => atom(() => createOptions()),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
enterprise: {
accessService: {
createApiKey: {
mutationOptions: () => ({ mutationKey: ['createApiKey'] }),
},
deleteApiKey: {
mutationOptions: () => ({ mutationKey: ['deleteApiKey'] }),
},
getAccessSettings: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['getAccessSettings', options.input],
}),
},
getDeveloperApiSettings: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['getDeveloperApiSettings', options.input],
}),
},
updateAccessChannels: {
mutationOptions: () => ({ mutationKey: ['updateAccessChannels'] }),
},
updateAccessPolicy: {
mutationOptions: () => ({ mutationKey: ['updateAccessPolicy'] }),
},
},
},
},
}))
async function loadState() {
return await import('../state')
}
function setDeploymentRoute(store: ReturnType<typeof createStore>, appInstanceId = 'app-instance-1') {
store.set(setNextRouteStateAtom, {
pathname: `/deployments/${appInstanceId}/settings/access`,
params: { appInstanceId },
})
}
describe('deployment access state', () => {
it('should gate access queries until a route app instance exists', async () => {
const state = await loadState()
const store = createStore()
expect(store.get(state.accessSettingsQueryAtom)).toMatchObject({
enabled: false,
input: skipToken,
})
expect(store.get(state.developerApiSettingsQueryAtom)).toMatchObject({
enabled: false,
input: skipToken,
})
setDeploymentRoute(store)
expect(store.get(state.accessSettingsQueryAtom)).toMatchObject({
enabled: true,
input: { params: { appInstanceId: 'app-instance-1' } },
})
expect(store.get(state.developerApiSettingsQueryAtom)).toMatchObject({
enabled: true,
input: { params: { appInstanceId: 'app-instance-1' } },
})
})
it('should expose access mutation atoms from state', async () => {
const state = await loadState()
const store = createStore()
const deleteApiKeyMutationAtom = state.createDeleteApiKeyMutationAtom()
const updateAccessPolicyMutationAtom = state.createUpdateAccessPolicyMutationAtom()
expect(store.get(state.updateAccessChannelsMutationAtom)).toMatchObject({
mutationKey: ['updateAccessChannels'],
})
expect(store.get(state.createApiKeyMutationAtom)).toMatchObject({
mutationKey: ['createApiKey'],
})
expect(store.get(deleteApiKeyMutationAtom)).toMatchObject({
mutationKey: ['deleteApiKey'],
})
expect(store.get(updateAccessPolicyMutationAtom)).toMatchObject({
mutationKey: ['updateAccessPolicy'],
})
})
})

View File

@ -23,11 +23,11 @@ import {
SelectTrigger,
} from '@langgenius/dify-ui/select'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useEffect, useId, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { generateApiTokenName } from './api-token-name'
import { createApiKeyMutationAtom } from './state'
export function ApiKeyGenerateMenu({
appInstanceId,
@ -51,7 +51,7 @@ export function ApiKeyGenerateMenu({
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string>()
const [draftName, setDraftName] = useState('')
const [nameError, setNameError] = useState(false)
const generateApiKey = useMutation(consoleQuery.enterprise.accessService.createApiKey.mutationOptions())
const generateApiKey = useAtomValue(createApiKeyMutationAtom)
const selectableEnvironments = environments
const selectedEnvironment = selectedEnvironmentId
? selectableEnvironments.find(env => env.id === selectedEnvironmentId)

View File

@ -15,10 +15,9 @@ import {
} from '@langgenius/dify-ui/alert-dialog'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useState } from 'react'
import { useAtomValue } from 'jotai'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import {
DetailTable,
DetailTableBody,
@ -32,6 +31,7 @@ import {
import {
API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES,
} from '../../table-styles'
import { createDeleteApiKeyMutationAtom } from './state'
function ApiKeyName({ apiKey }: {
apiKey: ApiKey
@ -70,7 +70,8 @@ function RevokeApiKeyButton({ apiKey }: {
}) {
const { t } = useTranslation('deployments')
const [showRevokeConfirm, setShowRevokeConfirm] = useState(false)
const revokeApiKey = useMutation(consoleQuery.enterprise.accessService.deleteApiKey.mutationOptions())
const deleteApiKeyMutationAtom = useMemo(() => createDeleteApiKeyMutationAtom(), [])
const revokeApiKey = useAtomValue(deleteApiKeyMutationAtom)
const isRevoking = revokeApiKey.isPending
const apiKeyName = apiKey.displayName

View File

@ -3,13 +3,13 @@
import type { AccessChannels, AccessEndpoint } from '@dify/contracts/enterprise/types.gen'
import type { ReactNode } from 'react'
import { Switch, SwitchSkeleton } from '@langgenius/dify-ui/switch'
import { useMutation } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { DeploymentEmptyState, DeploymentNoticeState, DeploymentStateMessage } from '../../../components/empty-state'
import { Section } from '../../common'
import { CopyPill, EndpointRow } from './common'
import { updateAccessChannelsMutationAtom } from './state'
import { getUrlOrigin } from './url'
const ACCESS_CHANNEL_SKELETON_SECTIONS = [
@ -24,7 +24,7 @@ function AccessChannelsSwitch({ appInstanceId, checked, accessChannels, disabled
disabled?: boolean
}) {
const { t } = useTranslation('deployments')
const toggleAccessChannel = useMutation(consoleQuery.enterprise.accessService.updateAccessChannels.mutationOptions())
const toggleAccessChannel = useAtomValue(updateAccessChannelsMutationAtom)
return (
<Switch

View File

@ -8,11 +8,9 @@ import type {
import type { ReactNode } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { Switch, SwitchSkeleton } from '@langgenius/dify-ui/switch'
import { useMutation } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { DeploymentEmptyState, DeploymentStateMessage } from '../../../components/empty-state'
import { DeveloperApiDocsDrawer } from './api-docs-drawer'
import { ApiKeyGenerateMenu } from './api-key-generate-menu'
@ -20,7 +18,7 @@ import { ApiKeyList } from './api-key-list'
import { CopyPill } from './common'
import { CreatedApiTokenDialog } from './developer-api-created-token-dialog'
import { DeveloperApiSkeleton } from './developer-api-skeleton'
import { developerApiSettingsQueryAtom } from './state'
import { developerApiSettingsQueryAtom, updateAccessChannelsMutationAtom } from './state'
type CreatedApiToken = {
appInstanceId: string
@ -53,7 +51,7 @@ function DeveloperApiSwitch({ appInstanceId, checked, accessChannels, disabled }
disabled?: boolean
}) {
const { t } = useTranslation('deployments')
const toggleDeveloperAPI = useMutation(consoleQuery.enterprise.accessService.updateAccessChannels.mutationOptions())
const toggleDeveloperAPI = useAtomValue(updateAccessChannelsMutationAtom)
return (
<Switch

View File

@ -11,10 +11,9 @@ import type {
} from './access-policy'
import type { AccessSubjectSelectionValue } from '@/app/components/app/app-access-control/access-subject-selector/types'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useState } from 'react'
import { useAtomValue } from 'jotai'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import {
accessControlSelectionFromSubjects,
accessModeToPermissionKey,
@ -29,6 +28,7 @@ import {
DeploymentAccessControlDialog,
PermissionSummaryButton,
} from './permission-row-components'
import { createUpdateAccessPolicyMutationAtom } from './state'
type AccessPermissionDraft = {
fingerprint: string
@ -53,7 +53,8 @@ export function EnvironmentPermissionRow({
}: EnvironmentPermissionRowProps) {
const { t } = useTranslation('deployments')
const environmentId = environment.id
const setEnvironmentAccessPolicy = useMutation(consoleQuery.enterprise.accessService.updateAccessPolicy.mutationOptions())
const updateAccessPolicyMutationAtom = useMemo(() => createUpdateAccessPolicyMutationAtom(), [])
const setEnvironmentAccessPolicy = useAtomValue(updateAccessPolicyMutationAtom)
const policy = summaryPolicy
const policyKind = accessModeToPermissionKey(policy?.mode)
const policyFingerprint = policy

View File

@ -1,7 +1,7 @@
'use client'
import { skipToken } from '@tanstack/react-query'
import { atomWithQuery } from 'jotai-tanstack-query'
import { atomWithMutation, atomWithQuery } from 'jotai-tanstack-query'
import { consoleQuery } from '@/service/client'
import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
@ -30,3 +30,23 @@ export const developerApiSettingsQueryAtom = atomWithQuery((get) => {
enabled: Boolean(appInstanceId),
})
})
export const updateAccessChannelsMutationAtom = atomWithMutation(() =>
consoleQuery.enterprise.accessService.updateAccessChannels.mutationOptions(),
)
export const createApiKeyMutationAtom = atomWithMutation(() =>
consoleQuery.enterprise.accessService.createApiKey.mutationOptions(),
)
export function createDeleteApiKeyMutationAtom() {
return atomWithMutation(() =>
consoleQuery.enterprise.accessService.deleteApiKey.mutationOptions(),
)
}
export function createUpdateAccessPolicyMutationAtom() {
return atomWithMutation(() =>
consoleQuery.enterprise.accessService.updateAccessPolicy.mutationOptions(),
)
}

View File

@ -2,7 +2,7 @@
import { skipToken } from '@tanstack/react-query'
import { atom } from 'jotai'
import { atomWithQuery } from 'jotai-tanstack-query'
import { atomWithMutation, atomWithQuery } from 'jotai-tanstack-query'
import { consoleQuery } from '@/service/client'
import { deploymentRouteAppInstanceIdAtom } from '../route-state'
import { deploymentStatusPollingInterval } from '../shared/domain/runtime-status'
@ -59,3 +59,9 @@ export const deploymentSourceAppQueryAtom = atomWithQuery((get) => {
enabled: Boolean(sourceAppId),
})
})
export function createUndeployDeploymentMutationAtom() {
return atomWithMutation(() =>
consoleQuery.enterprise.deploymentService.undeploy.mutationOptions(),
)
}

View File

@ -4,19 +4,11 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DeployReleaseMenu } from '../deploy-release-menu'
const mockUseMutation = vi.hoisted(() => vi.fn())
const mockDeleteRelease = vi.fn()
const mockDeleteRelease = vi.hoisted(() => vi.fn())
const mockExportReleaseDsl = vi.hoisted(() => vi.fn())
vi.mock('@langgenius/dify-ui/dropdown-menu', () => import('@/__mocks__/base-ui-dropdown-menu'))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useMutation: (...args: unknown[]) => mockUseMutation(...args),
}
})
vi.mock('../state', async (importOriginal) => {
const actual = await importOriginal<typeof import('../state')>()
const { atom } = await import('jotai')
@ -25,6 +17,14 @@ vi.mock('../state', async (importOriginal) => {
...actual,
deployReleaseMenuEnvironmentDeploymentsQueryAtom: atom(environmentDeploymentsErrorResult()),
deployReleaseMenuAppInstanceQueryAtom: atom(appInstanceResult()),
deleteReleaseMutationAtom: atom({
isPending: false,
mutate: mockDeleteRelease,
}),
exportReleaseDslMutationAtom: atom({
isPending: false,
mutate: mockExportReleaseDsl,
}),
}
})
@ -78,10 +78,6 @@ function appInstanceResult() {
describe('DeployReleaseMenu', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseMutation.mockReturnValue({
isPending: false,
mutate: mockDeleteRelease,
})
})
it('should disable release deletion when deployment usage cannot be checked', () => {

View File

@ -1,4 +1,6 @@
import type { Release } from '@dify/contracts/enterprise/types.gen'
import type { Getter } from 'jotai'
import { ReleaseSource } from '@dify/contracts/enterprise/types.gen'
import { skipToken } from '@tanstack/react-query'
import { atom, createStore } from 'jotai'
import { describe, expect, it, vi } from 'vitest'
@ -11,6 +13,13 @@ type QueryOptions = {
queryKey?: readonly unknown[]
}
type MutationOptions = {
mutationFn?: (variables: unknown) => Promise<unknown>
mutationKey?: readonly unknown[]
}
const mockExportReleaseDsl = vi.hoisted(() => vi.fn())
vi.mock('jotai-tanstack-query', () => ({
atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom(get => ({
...createOptions(get),
@ -20,6 +29,7 @@ vi.mock('jotai-tanstack-query', () => ({
isLoading: false,
isSuccess: false,
})),
atomWithMutation: (createOptions: () => MutationOptions) => atom(() => createOptions()),
}))
vi.mock('@/service/client', () => ({
@ -42,21 +52,48 @@ vi.mock('@/service/client', () => ({
},
},
releaseService: {
deleteRelease: {
mutationOptions: () => ({ mutationKey: ['deleteRelease'] }),
},
listReleaseSummaries: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['listReleaseSummaries', options.input],
}),
},
updateRelease: {
mutationOptions: () => ({ mutationKey: ['updateRelease'] }),
},
},
},
},
}))
vi.mock('../release-dsl-export', () => ({
exportReleaseDsl: (...args: unknown[]) => mockExportReleaseDsl(...args),
}))
async function loadState() {
return await import('../state')
}
function createRelease(): Release {
return {
id: 'release-1',
appInstanceId: 'app-instance-1',
displayName: 'Release 1',
description: '',
source: ReleaseSource.RELEASE_SOURCE_UPLOAD,
gateCommitId: 'commit-1',
requiredSlots: [],
createdBy: {
id: 'account-1',
displayName: 'Dify Admin',
},
createdAt: '2026-01-01T00:00:00.000Z',
}
}
function setDeploymentRoute(store: ReturnType<typeof createStore>, appInstanceId = 'app-instance-1') {
store.set(setNextRouteStateAtom, {
pathname: `/deployments/${appInstanceId}/overview`,
@ -147,4 +184,36 @@ describe('versions tab state', () => {
store.set(state.adjustReleaseHistoryPageAfterDeleteAtom, 1)
expect(store.get(state.releaseHistoryCurrentPageAtom)).toBe(1)
})
it('should expose release mutation atoms from state', async () => {
const state = await loadState()
const store = createStore()
expect(store.get(state.deleteReleaseMutationAtom)).toMatchObject({
mutationKey: ['deleteRelease'],
})
expect(store.get(state.updateReleaseMutationAtom)).toMatchObject({
mutationKey: ['updateRelease'],
})
})
it('should expose release DSL export as a mutation atom', async () => {
const state = await loadState()
const store = createStore()
const mutationOptions = store.get(state.exportReleaseDslMutationAtom) as unknown as MutationOptions
const release = createRelease()
await mutationOptions.mutationFn?.({
release,
releaseId: release.id,
appInstanceName: 'Deployment 1',
})
expect(mutationOptions.mutationKey).toEqual(['deployments', 'release-dsl-export'])
expect(mockExportReleaseDsl).toHaveBeenCalledWith({
release,
releaseId: release.id,
appInstanceName: 'Deployment 1',
})
})
})

View File

@ -9,11 +9,9 @@ import {
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useAtomValue, useSetAtom } from 'jotai'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { TitleTooltip } from '../../components/title-tooltip'
import { openDeployDrawerAtom } from '../../deploy-drawer/state'
import { isUndeployedDeploymentRow } from '../../shared/domain/runtime-status'
@ -24,11 +22,12 @@ import {
releaseUsageCount,
} from './deploy-release-menu-utils'
import { EditReleaseDialog } from './edit-release-dialog'
import { exportReleaseDsl } from './release-dsl-export'
import {
deleteReleaseMutationAtom,
deployReleaseMenuAppInstanceQueryAtom,
deployReleaseMenuEnvironmentDeploymentsQueryAtom,
deployReleaseMenuOpenReleaseIdAtom,
exportReleaseDslMutationAtom,
setDeployReleaseMenuOpenAtom,
} from './state'
@ -44,11 +43,11 @@ export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows, onDel
const setDeployReleaseMenuOpen = useSetAtom(setDeployReleaseMenuOpenAtom)
const [showEditDialog, setShowEditDialog] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [isExportingDsl, setIsExportingDsl] = useState(false)
const open = openReleaseMenuId === releaseId
const environmentDeploymentsQuery = useAtomValue(deployReleaseMenuEnvironmentDeploymentsQueryAtom)
const appInstanceQuery = useAtomValue(deployReleaseMenuAppInstanceQueryAtom)
const deleteRelease = useMutation(consoleQuery.enterprise.releaseService.deleteRelease.mutationOptions())
const deleteRelease = useAtomValue(deleteReleaseMutationAtom)
const exportReleaseDslMutation = useAtomValue(exportReleaseDslMutationAtom)
const environments = (environmentDeploymentsQuery.data?.environmentDeployments ?? [])
.map(row => row.environment)
@ -59,12 +58,14 @@ export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows, onDel
if (!targetRelease)
return null
const targetReleaseName = targetRelease.displayName
const release = targetRelease
const targetReleaseName = release.displayName
const deleteUsageCount = releaseUsageCount(releaseId, deploymentRows)
const isCheckingDeleteUsage = open && environmentDeploymentsQuery.isLoading
const hasDeleteUsageCheckFailed = open && environmentDeploymentsQuery.isError
const isReleaseInUse = deleteUsageCount > 0
const isDeletingRelease = deleteRelease.isPending
const isExportingDsl = exportReleaseDslMutation.isPending
const deleteDisabledReason = isCheckingDeleteUsage
? t('versions.disabledReason.checkingDeployments')
: hasDeleteUsageCheckFailed
@ -78,21 +79,21 @@ export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows, onDel
setDeployReleaseMenuOpen({ releaseId, open: nextOpen })
}
const handleExportDsl = async () => {
function handleExportDsl() {
if (isExportingDsl)
return
setIsExportingDsl(true)
try {
await exportReleaseDsl({ release: targetRelease, releaseId, appInstanceName })
handleOpenChange(false)
}
catch {
toast.error(t('versions.exportDslFailed'))
}
finally {
setIsExportingDsl(false)
}
exportReleaseDslMutation.mutate(
{ release, releaseId, appInstanceName },
{
onSuccess: () => {
handleOpenChange(false)
},
onError: () => {
toast.error(t('versions.exportDslFailed'))
},
},
)
}
function handleDeleteRelease() {
@ -123,7 +124,7 @@ export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows, onDel
environmentDeployments: environmentDeploymentsQuery.data?.environmentDeployments ?? [],
releaseRows,
releaseId,
targetRelease,
targetRelease: release,
t,
})
@ -224,14 +225,14 @@ export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows, onDel
</DropdownMenu>
<EditReleaseDialog
release={targetRelease}
release={release}
open={showEditDialog}
onOpenChange={setShowEditDialog}
/>
<DeleteReleaseDialog
open={showDeleteConfirm}
release={targetRelease}
release={release}
isDeleting={isDeletingRelease}
onOpenChange={setShowDeleteConfirm}
onConfirm={handleDeleteRelease}

View File

@ -13,10 +13,10 @@ import {
import { Input } from '@langgenius/dify-ui/input'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { updateReleaseMutationAtom } from './state'
type EditReleaseFormValues = {
name: string
@ -127,7 +127,7 @@ export function EditReleaseDialog({
onOpenChange: (open: boolean) => void
}) {
const { t } = useTranslation('deployments')
const updateRelease = useMutation(consoleQuery.enterprise.releaseService.updateRelease.mutationOptions())
const updateRelease = useAtomValue(updateReleaseMutationAtom)
const formKey = `${release.id}-${release.displayName}-${release.description}`
function handleOpenChange(nextOpen: boolean) {

View File

@ -1,12 +1,19 @@
'use client'
import type { ListReleaseSummariesResponse } from '@dify/contracts/enterprise/types.gen'
import { keepPreviousData, skipToken } from '@tanstack/react-query'
import type { ListReleaseSummariesResponse, Release } from '@dify/contracts/enterprise/types.gen'
import { keepPreviousData, mutationOptions, skipToken } from '@tanstack/react-query'
import { atom } from 'jotai'
import { atomWithQuery } from 'jotai-tanstack-query'
import { atomWithMutation, atomWithQuery } from 'jotai-tanstack-query'
import { consoleQuery } from '@/service/client'
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
import { RELEASE_HISTORY_PAGE_SIZE } from '../../shared/domain/pagination'
import { exportReleaseDsl } from './release-dsl-export'
type ExportReleaseDslInput = {
release: Release
releaseId: string
appInstanceName?: string
}
export const releaseHistoryCurrentPageAtom = atom(0)
export const deployReleaseMenuOpenReleaseIdAtom = atom<string | undefined>(undefined)
@ -58,6 +65,21 @@ export const deployReleaseMenuAppInstanceQueryAtom = atomWithQuery((get) => {
})
})
export const deleteReleaseMutationAtom = atomWithMutation(() =>
consoleQuery.enterprise.releaseService.deleteRelease.mutationOptions(),
)
export const updateReleaseMutationAtom = atomWithMutation(() =>
consoleQuery.enterprise.releaseService.updateRelease.mutationOptions(),
)
export const exportReleaseDslMutationAtom = atomWithMutation(() =>
mutationOptions({
mutationKey: ['deployments', 'release-dsl-export'],
mutationFn: (input: ExportReleaseDslInput) => exportReleaseDsl(input),
}),
)
export const setReleaseHistoryCurrentPageAtom = atom(null, (_get, set, page: number) => {
set(releaseHistoryCurrentPageAtom, Math.max(page, 0))
})