diff --git a/.agents/skills/how-to-write-component/SKILL.md b/.agents/skills/how-to-write-component/SKILL.md index 8a480c8fd09..e3cd59f810f 100644 --- a/.agents/skills/how-to-write-component/SKILL.md +++ b/.agents/skills/how-to-write-component/SKILL.md @@ -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`. diff --git a/web/features/deployments/create-guide/state/__tests__/index.spec.ts b/web/features/deployments/create-guide/state/__tests__/index.spec.ts index f06773b2367..bd674b9c126 100644 --- a/web/features/deployments/create-guide/state/__tests__/index.spec.ts +++ b/web/features/deployments/create-guide/state/__tests__/index.spec.ts @@ -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) + }) }) diff --git a/web/features/deployments/create-guide/state/index.ts b/web/features/deployments/create-guide/state/index.ts index 1db093e9486..f5665e95846 100644 --- a/web/features/deployments/create-guide/state/index.ts +++ b/web/features/deployments/create-guide/state/index.ts @@ -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(undefined) // DSL primitives and derived state export const dslFileAtom = atom(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, diff --git a/web/features/deployments/detail/__tests__/state.spec.ts b/web/features/deployments/detail/__tests__/state.spec.ts index 1a53b843c4c..81e562dd7d3 100644 --- a/web/features/deployments/detail/__tests__/state.spec.ts +++ b/web/features/deployments/detail/__tests__/state.spec.ts @@ -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'], + }) + }) }) diff --git a/web/features/deployments/detail/deploy-tab/deployment-row-actions.tsx b/web/features/deployments/detail/deploy-tab/deployment-row-actions.tsx index 8e3fa0c55a3..2542076bd26 100644 --- a/web/features/deployments/detail/deploy-tab/deployment-row-actions.tsx +++ b/web/features/deployments/detail/deploy-tab/deployment-row-actions.tsx @@ -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) }, }, diff --git a/web/features/deployments/detail/settings-tab/access/__tests__/api-key-generate-menu.spec.tsx b/web/features/deployments/detail/settings-tab/access/__tests__/api-key-generate-menu.spec.tsx index 9dceb9e2aa8..ee8ef010f69 100644 --- a/web/features/deployments/detail/settings-tab/access/__tests__/api-key-generate-menu.spec.tsx +++ b/web/features/deployments/detail/settings-tab/access/__tests__/api-key-generate-menu.spec.tsx @@ -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() 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', () => { diff --git a/web/features/deployments/detail/settings-tab/access/__tests__/channels-section.spec.tsx b/web/features/deployments/detail/settings-tab/access/__tests__/channels-section.spec.tsx index 730380b4284..07863d5a74e 100644 --- a/web/features/deployments/detail/settings-tab/access/__tests__/channels-section.spec.tsx +++ b/web/features/deployments/detail/settings-tab/access/__tests__/channels-section.spec.tsx @@ -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() 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', () => { diff --git a/web/features/deployments/detail/settings-tab/access/__tests__/permissions.spec.tsx b/web/features/deployments/detail/settings-tab/access/__tests__/permissions.spec.tsx index 0f8be629b27..ebf93f1b28f 100644 --- a/web/features/deployments/detail/settings-tab/access/__tests__/permissions.spec.tsx +++ b/web/features/deployments/detail/settings-tab/access/__tests__/permissions.spec.tsx @@ -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() 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', () => { diff --git a/web/features/deployments/detail/settings-tab/access/__tests__/state.spec.ts b/web/features/deployments/detail/settings-tab/access/__tests__/state.spec.ts new file mode 100644 index 00000000000..02e4d597e75 --- /dev/null +++ b/web/features/deployments/detail/settings-tab/access/__tests__/state.spec.ts @@ -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, 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'], + }) + }) +}) diff --git a/web/features/deployments/detail/settings-tab/access/api-key-generate-menu.tsx b/web/features/deployments/detail/settings-tab/access/api-key-generate-menu.tsx index bde6eb5d667..53ec1c114d4 100644 --- a/web/features/deployments/detail/settings-tab/access/api-key-generate-menu.tsx +++ b/web/features/deployments/detail/settings-tab/access/api-key-generate-menu.tsx @@ -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() 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) diff --git a/web/features/deployments/detail/settings-tab/access/api-key-list.tsx b/web/features/deployments/detail/settings-tab/access/api-key-list.tsx index cf2a8553aee..369eaf12e8a 100644 --- a/web/features/deployments/detail/settings-tab/access/api-key-list.tsx +++ b/web/features/deployments/detail/settings-tab/access/api-key-list.tsx @@ -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 diff --git a/web/features/deployments/detail/settings-tab/access/channels-section.tsx b/web/features/deployments/detail/settings-tab/access/channels-section.tsx index d3dde7936a4..e1c5fe6cb2e 100644 --- a/web/features/deployments/detail/settings-tab/access/channels-section.tsx +++ b/web/features/deployments/detail/settings-tab/access/channels-section.tsx @@ -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 ( createUpdateAccessPolicyMutationAtom(), []) + const setEnvironmentAccessPolicy = useAtomValue(updateAccessPolicyMutationAtom) const policy = summaryPolicy const policyKind = accessModeToPermissionKey(policy?.mode) const policyFingerprint = policy diff --git a/web/features/deployments/detail/settings-tab/access/state.ts b/web/features/deployments/detail/settings-tab/access/state.ts index 57bf97c9d99..0e67da37b59 100644 --- a/web/features/deployments/detail/settings-tab/access/state.ts +++ b/web/features/deployments/detail/settings-tab/access/state.ts @@ -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(), + ) +} diff --git a/web/features/deployments/detail/state.ts b/web/features/deployments/detail/state.ts index 68bb24ab3a6..d266f55ff5b 100644 --- a/web/features/deployments/detail/state.ts +++ b/web/features/deployments/detail/state.ts @@ -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(), + ) +} diff --git a/web/features/deployments/detail/versions-tab/__tests__/deploy-release-menu.spec.tsx b/web/features/deployments/detail/versions-tab/__tests__/deploy-release-menu.spec.tsx index 88692e55360..80701a10e3b 100644 --- a/web/features/deployments/detail/versions-tab/__tests__/deploy-release-menu.spec.tsx +++ b/web/features/deployments/detail/versions-tab/__tests__/deploy-release-menu.spec.tsx @@ -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() - return { - ...actual, - useMutation: (...args: unknown[]) => mockUseMutation(...args), - } -}) - vi.mock('../state', async (importOriginal) => { const actual = await importOriginal() 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', () => { diff --git a/web/features/deployments/detail/versions-tab/__tests__/state.spec.ts b/web/features/deployments/detail/versions-tab/__tests__/state.spec.ts index 2e58fb3bb80..d5d2fb90c56 100644 --- a/web/features/deployments/detail/versions-tab/__tests__/state.spec.ts +++ b/web/features/deployments/detail/versions-tab/__tests__/state.spec.ts @@ -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 + 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, 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', + }) + }) }) diff --git a/web/features/deployments/detail/versions-tab/deploy-release-menu.tsx b/web/features/deployments/detail/versions-tab/deploy-release-menu.tsx index 332ccb2f18a..26a6067c932 100644 --- a/web/features/deployments/detail/versions-tab/deploy-release-menu.tsx +++ b/web/features/deployments/detail/versions-tab/deploy-release-menu.tsx @@ -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 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) { diff --git a/web/features/deployments/detail/versions-tab/state.ts b/web/features/deployments/detail/versions-tab/state.ts index 5cf772bc54f..96b741720a3 100644 --- a/web/features/deployments/detail/versions-tab/state.ts +++ b/web/features/deployments/detail/versions-tab/state.ts @@ -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(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)) })