mirror of
https://github.com/langgenius/dify.git
synced 2026-06-24 13:01:16 +08:00
refactor(web): manage deployment async state with atoms (#37819)
This commit is contained in:
parent
ed500761c8
commit
d8ed874dc7
@ -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`.
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
},
|
||||
},
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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'],
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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))
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user