diff --git a/.agents/skills/how-to-write-component/SKILL.md b/.agents/skills/how-to-write-component/SKILL.md index e3cd59f810f..689cd227b3a 100644 --- a/.agents/skills/how-to-write-component/SKILL.md +++ b/.agents/skills/how-to-write-component/SKILL.md @@ -36,19 +36,19 @@ Use this as the decision guide for React/TypeScript component structure. Existin - Avoid prop drilling. One pass-through layer is acceptable; repeated forwarding means ownership should move down or into feature-scoped Jotai UI state. Keep server/cache state in query and API data flow. - Do not replace prop drilling with one top-level hook that returns a large view model and then thread that object through section props. Move each hook, query, derived value, and handler to the concrete section that consumes it, or use feature-scoped Jotai atoms for simple shared form/UI state when siblings need the same source of truth. - When using feature-scoped Jotai state for a form, drawer, or other secondary surface, scope the store to that surface instance when stale cross-instance state is possible. Initialize stable config at the owning boundary, then let descendants read only the atoms or purpose-named hooks they actually need. -- For Jotai-backed surfaces, put shared query atoms, mutation atoms, derived state, and write actions in the feature state file when they coordinate multiple descendants. The lowest-owner rule still applies to independent visual surfaces that do not participate in shared state. +- For Jotai-backed surfaces, put shared query atoms, mutation atoms, derived state, and write actions in the feature state file when they coordinate multiple descendants. Do not create a query or mutation atom only because the surrounding feature uses Jotai. If the query or mutation does not read atom state, feed another atom, or participate in shared workflow orchestration, use `useQuery` or `useMutation` directly at the lowest owner. - For repeated row/menu action surfaces that need reset, hydrate the stable identity at the surface entry and scope only the primitives that truly need per-instance reset, such as open flags, drafts, or selected local options. - Keep callbacks in a parent only for workflow coordination such as form submission, shared selection, batch behavior, or navigation. Otherwise let the child or row own its action. - Prefer uncontrolled DOM state and CSS variables before adding controlled props. ## 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. +- A module's feature-local state lives in one state file for Jotai-backed features: primitive atoms, shared query atoms, derived atoms, write-only action atoms, shared mutation atoms, submission orchestration, provider exports, and optional scope configuration. - 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. +- In Jotai-backed feature surfaces, never hand-roll async loading, error, or in-flight guards with `useState` or `useRef`. For async work that depends on atom state, feeds derived atoms, or participates in shared submission orchestration, model the work with `atomWithQuery` or `atomWithMutation`; write atoms should only update the inputs that drive those atoms. For component-owned remote work that does not participate in atom state, use TanStack Query hooks directly. +- Row-local async state should belong to the row owner. Use `useQuery` or `useMutation` directly for row actions that do not depend on atom state and are not consumed by other atoms. Use a per-instance query or mutation atom only when the row action participates in a Jotai-backed shared workflow or needs atom-scoped reset semantics. - 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 scope local to that row, menu, or dialog through a per-instance query/mutation atom; keep only synchronous UI locks in local component state. +- 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 with the lowest-owner query/mutation hook unless it genuinely participates in shared atom 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. @@ -56,7 +56,7 @@ Use this as the decision guide for React/TypeScript component structure. Existin - 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. +- `jotai-tanstack-query` atoms use the same QueryClient as the React Query provider. Query atoms belong in feature state only when they need atom inputs, provide data to derived atoms, or coordinate a shared Jotai-backed workflow. - Jotai scope is an optional instance-isolation tool for secondary surfaces with independent local state. Query and mutation atoms keep shared cache behavior through the shared QueryClient. - Do not put `atomWithQuery`, `atomWithInfiniteQuery`, `atomWithMutation`, or broad derived orchestration atoms in a `ScopeProvider` just to reset a surface. Scoped derived atoms implicitly scope their dependencies, which can duplicate query client access and break shared invalidation. Leave query/mutation atoms unscoped; let them read scoped primitive inputs. - Scope providers should list resettable primitive atoms and explicit hydration tuples. If a derived atom must be scoped, confirm that every dependency it implicitly scopes is meant to be private to that surface. @@ -104,6 +104,7 @@ Use this as the decision guide for React/TypeScript component structure. Existin - Keep `web/contract/*` as the single source of truth for API shape; follow existing domain/router patterns and the `{ params, query?, body? }` input shape. - Consume queries directly with `useQuery(consoleQuery.xxx.queryOptions(...))` or `useQuery(marketplaceQuery.xxx.queryOptions(...))`. +- Do not promote a query or mutation to an atom just because the feature already has a state file. Use `atomWithQuery` or `atomWithMutation` only when the query/mutation reads atom state, is consumed by another atom, or is part of shared workflow orchestration. - In `atomWithQuery` and `atomWithInfiniteQuery`, return generated `queryOptions()` or `infiniteOptions()` directly. Pass `enabled`, `retry`, `placeholderData`, `select`, and pagination options into that call instead of spreading generated options into a hand-built object. - In `atomWithMutation`, return generated `mutationOptions()` directly when using generated clients. Put request shaping and submit orchestration in write atoms; do not rebuild mutation option objects just to pass through the generated mutation function. - For custom query functions that do not come from generated clients, wrap the options object with TanStack `queryOptions(...)` so query atoms still return a query options contract. @@ -112,7 +113,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(...))` 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. +- Consume mutations directly with `useMutation(consoleQuery.xxx.mutationOptions(...))` or `useMutation(marketplaceQuery.xxx.mutationOptions(...))` when the mutation is owned by one component, menu, dialog, or row and its pending/error state is not consumed by feature atoms. In Jotai-backed workflow orchestration, expose mutations from feature state with `atomWithMutation` so pending/error state stays attached to the mutation atom. For component-owned custom mutation functions, use `useMutation(mutationOptions(...))` at the owner. - 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/detail/__tests__/state.spec.ts b/web/features/deployments/detail/__tests__/state.spec.ts index 81e562dd7d3..1a53b843c4c 100644 --- a/web/features/deployments/detail/__tests__/state.spec.ts +++ b/web/features/deployments/detail/__tests__/state.spec.ts @@ -11,10 +11,6 @@ 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), @@ -24,7 +20,6 @@ vi.mock('jotai-tanstack-query', () => ({ isLoading: false, isSuccess: false, })), - atomWithMutation: (createOptions: () => MutationOptions) => atom(() => createOptions()), })) vi.mock('@/service/client', () => ({ @@ -61,9 +56,6 @@ vi.mock('@/service/client', () => ({ queryKey: ['listEnvironmentDeployments', options.input], }), }, - undeploy: { - mutationOptions: () => ({ mutationKey: ['undeploy'] }), - }, }, }, }, @@ -131,14 +123,4 @@ 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 2542076bd26..28e67dfd53d 100644 --- a/web/features/deployments/detail/deploy-tab/deployment-row-actions.tsx +++ b/web/features/deployments/detail/deploy-tab/deployment-row-actions.tsx @@ -2,13 +2,14 @@ import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen' import { RuntimeInstanceStatus } from '@dify/contracts/enterprise/types.gen' -import { useAtomValue, useSetAtom } from 'jotai' -import { useMemo, useState } from 'react' +import { useMutation } from '@tanstack/react-query' +import { useSetAtom } from 'jotai' +import { 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' @@ -20,8 +21,7 @@ export function DeploymentRowActions({ appInstanceId, envId, row }: { }) { const { t } = useTranslation('deployments') const openDeployDrawer = useSetAtom(openDeployDrawerAtom) - const undeployDeploymentMutationAtom = useMemo(() => createUndeployDeploymentMutationAtom(), []) - const undeployDeployment = useAtomValue(undeployDeploymentMutationAtom) + const undeployDeployment = useMutation(consoleQuery.enterprise.deploymentService.undeploy.mutationOptions()) const [showUndeployConfirm, setShowUndeployConfirm] = useState(false) const [showErrorDetail, setShowErrorDetail] = useState(false) const isUndeployed = isUndeployedDeploymentRow(row) 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 ee8ef010f69..a83974fba6f 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 @@ -5,16 +5,24 @@ import { ApiKeyGenerateMenu } from '../api-key-generate-menu' const mockMutate = vi.hoisted(() => vi.fn()) -vi.mock('../state', async () => { - const { atom } = await import('jotai') +vi.mock('@tanstack/react-query', () => ({ + useMutation: () => ({ + isPending: false, + mutate: mockMutate, + }), +})) - return { - createApiKeyMutationAtom: atom({ - isPending: false, - mutate: mockMutate, - }), - } -}) +vi.mock('@/service/client', () => ({ + consoleQuery: { + enterprise: { + accessService: { + createApiKey: { + mutationOptions: () => ({ mutationKey: ['createApiKey'] }), + }, + }, + }, + }, +})) function createEnvironment(): Environment { return { 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 07863d5a74e..fb4195115a7 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 @@ -5,16 +5,24 @@ import { AccessChannelsSection } from '../channels-section' const mockToggleAccessChannel = vi.hoisted(() => vi.fn()) -vi.mock('../state', async () => { - const { atom } = await import('jotai') +vi.mock('@tanstack/react-query', () => ({ + useMutation: () => ({ + isPending: false, + mutate: mockToggleAccessChannel, + }), +})) - return { - updateAccessChannelsMutationAtom: atom({ - isPending: false, - mutate: mockToggleAccessChannel, - }), - } -}) +vi.mock('@/service/client', () => ({ + consoleQuery: { + enterprise: { + accessService: { + updateAccessChannels: { + mutationOptions: () => ({ mutationKey: ['updateAccessChannels'] }), + }, + }, + }, + }, +})) function createAccessChannels(): AccessChannels { return { 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 ebf93f1b28f..891f117efea 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 @@ -9,16 +9,24 @@ import { AccessPermissionsSection } from '../permissions-section' const mockMutate = vi.hoisted(() => vi.fn()) -vi.mock('../state', async () => { - const { atom } = await import('jotai') +vi.mock('@tanstack/react-query', () => ({ + useMutation: () => ({ + isPending: false, + mutate: mockMutate, + }), +})) - return { - createUpdateAccessPolicyMutationAtom: () => atom({ - isPending: false, - mutate: mockMutate, - }), - } -}) +vi.mock('@/service/client', () => ({ + consoleQuery: { + enterprise: { + accessService: { + updateAccessPolicy: { + mutationOptions: () => ({ mutationKey: ['updateAccessPolicy'] }), + }, + }, + }, + }, +})) function renderWithAtomStore(children: ReactNode) { return render( 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 index 02e4d597e75..77e3d9b2c22 100644 --- a/web/features/deployments/detail/settings-tab/access/__tests__/state.spec.ts +++ b/web/features/deployments/detail/settings-tab/access/__tests__/state.spec.ts @@ -10,10 +10,6 @@ type QueryOptions = { queryKey?: readonly unknown[] } -type MutationOptions = { - mutationKey?: readonly unknown[] -} - vi.mock('jotai-tanstack-query', () => ({ atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom(get => ({ ...createOptions(get), @@ -23,19 +19,12 @@ vi.mock('jotai-tanstack-query', () => ({ 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, @@ -48,12 +37,6 @@ vi.mock('@/service/client', () => ({ queryKey: ['getDeveloperApiSettings', options.input], }), }, - updateAccessChannels: { - mutationOptions: () => ({ mutationKey: ['updateAccessChannels'] }), - }, - updateAccessPolicy: { - mutationOptions: () => ({ mutationKey: ['updateAccessPolicy'] }), - }, }, }, }, @@ -95,24 +78,4 @@ describe('deployment access state', () => { 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 53ec1c114d4..bde6eb5d667 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 { useAtomValue } from 'jotai' +import { useMutation } from '@tanstack/react-query' 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 = useAtomValue(createApiKeyMutationAtom) + const generateApiKey = useMutation(consoleQuery.enterprise.accessService.createApiKey.mutationOptions()) 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 369eaf12e8a..cf2a8553aee 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,9 +15,10 @@ import { } from '@langgenius/dify-ui/alert-dialog' import { cn } from '@langgenius/dify-ui/cn' import { toast } from '@langgenius/dify-ui/toast' -import { useAtomValue } from 'jotai' -import { useMemo, useState } from 'react' +import { useMutation } from '@tanstack/react-query' +import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { consoleQuery } from '@/service/client' import { DetailTable, DetailTableBody, @@ -31,7 +32,6 @@ import { import { API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES, } from '../../table-styles' -import { createDeleteApiKeyMutationAtom } from './state' function ApiKeyName({ apiKey }: { apiKey: ApiKey @@ -70,8 +70,7 @@ function RevokeApiKeyButton({ apiKey }: { }) { const { t } = useTranslation('deployments') const [showRevokeConfirm, setShowRevokeConfirm] = useState(false) - const deleteApiKeyMutationAtom = useMemo(() => createDeleteApiKeyMutationAtom(), []) - const revokeApiKey = useAtomValue(deleteApiKeyMutationAtom) + const revokeApiKey = useMutation(consoleQuery.enterprise.accessService.deleteApiKey.mutationOptions()) 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 e1c5fe6cb2e..d3dde7936a4 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 { useAtomValue } from 'jotai' +import { useMutation } from '@tanstack/react-query' 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 = useAtomValue(updateAccessChannelsMutationAtom) + const toggleAccessChannel = useMutation(consoleQuery.enterprise.accessService.updateAccessChannels.mutationOptions()) return ( createUpdateAccessPolicyMutationAtom(), []) - const setEnvironmentAccessPolicy = useAtomValue(updateAccessPolicyMutationAtom) + const setEnvironmentAccessPolicy = useMutation(consoleQuery.enterprise.accessService.updateAccessPolicy.mutationOptions()) 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 0e67da37b59..57bf97c9d99 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 { atomWithMutation, atomWithQuery } from 'jotai-tanstack-query' +import { atomWithQuery } from 'jotai-tanstack-query' import { consoleQuery } from '@/service/client' import { deploymentRouteAppInstanceIdAtom } from '../../../route-state' @@ -30,23 +30,3 @@ 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 d266f55ff5b..68bb24ab3a6 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 { atomWithMutation, atomWithQuery } from 'jotai-tanstack-query' +import { atomWithQuery } from 'jotai-tanstack-query' import { consoleQuery } from '@/service/client' import { deploymentRouteAppInstanceIdAtom } from '../route-state' import { deploymentStatusPollingInterval } from '../shared/domain/runtime-status' @@ -59,9 +59,3 @@ 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 80701a10e3b..34bd8e0f3ea 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 @@ -9,6 +9,32 @@ 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: (options: { mutationKey?: readonly unknown[] }) => { + if (options.mutationKey?.[0] === 'deployments') + return { isPending: false, mutate: mockExportReleaseDsl } + + return { isPending: false, mutate: mockDeleteRelease } + }, + } +}) + +vi.mock('@/service/client', () => ({ + consoleQuery: { + enterprise: { + releaseService: { + deleteRelease: { + mutationOptions: () => ({ mutationKey: ['deleteRelease'] }), + }, + }, + }, + }, +})) + vi.mock('../state', async (importOriginal) => { const actual = await importOriginal() const { atom } = await import('jotai') @@ -17,14 +43,6 @@ vi.mock('../state', async (importOriginal) => { ...actual, deployReleaseMenuEnvironmentDeploymentsQueryAtom: atom(environmentDeploymentsErrorResult()), deployReleaseMenuAppInstanceQueryAtom: atom(appInstanceResult()), - deleteReleaseMutationAtom: atom({ - isPending: false, - mutate: mockDeleteRelease, - }), - exportReleaseDslMutationAtom: atom({ - isPending: false, - mutate: mockExportReleaseDsl, - }), } }) 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 d5d2fb90c56..2e58fb3bb80 100644 --- a/web/features/deployments/detail/versions-tab/__tests__/state.spec.ts +++ b/web/features/deployments/detail/versions-tab/__tests__/state.spec.ts @@ -1,6 +1,4 @@ -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' @@ -13,13 +11,6 @@ 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), @@ -29,7 +20,6 @@ vi.mock('jotai-tanstack-query', () => ({ isLoading: false, isSuccess: false, })), - atomWithMutation: (createOptions: () => MutationOptions) => atom(() => createOptions()), })) vi.mock('@/service/client', () => ({ @@ -52,48 +42,21 @@ 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`, @@ -184,36 +147,4 @@ 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 26a6067c932..01f9b354447 100644 --- a/web/features/deployments/detail/versions-tab/deploy-release-menu.tsx +++ b/web/features/deployments/detail/versions-tab/deploy-release-menu.tsx @@ -9,9 +9,11 @@ import { DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { toast } from '@langgenius/dify-ui/toast' +import { mutationOptions, 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' @@ -22,15 +24,20 @@ 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' +type ExportReleaseDslInput = { + release: Release + releaseId: string + appInstanceName?: string +} + export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows, onDeleted }: { appInstanceId: string releaseId: string @@ -46,8 +53,11 @@ export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows, onDel const open = openReleaseMenuId === releaseId const environmentDeploymentsQuery = useAtomValue(deployReleaseMenuEnvironmentDeploymentsQueryAtom) const appInstanceQuery = useAtomValue(deployReleaseMenuAppInstanceQueryAtom) - const deleteRelease = useAtomValue(deleteReleaseMutationAtom) - const exportReleaseDslMutation = useAtomValue(exportReleaseDslMutationAtom) + const deleteRelease = useMutation(consoleQuery.enterprise.releaseService.deleteRelease.mutationOptions()) + const exportReleaseDslMutation = useMutation(mutationOptions({ + mutationKey: ['deployments', 'release-dsl-export'], + mutationFn: (input: ExportReleaseDslInput) => exportReleaseDsl(input), + })) const environments = (environmentDeploymentsQuery.data?.environmentDeployments ?? []) .map(row => row.environment) diff --git a/web/features/deployments/detail/versions-tab/edit-release-dialog.tsx b/web/features/deployments/detail/versions-tab/edit-release-dialog.tsx index 899def54ccd..28ca0b6ff1d 100644 --- a/web/features/deployments/detail/versions-tab/edit-release-dialog.tsx +++ b/web/features/deployments/detail/versions-tab/edit-release-dialog.tsx @@ -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 { useAtomValue } from 'jotai' +import { useMutation } from '@tanstack/react-query' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { updateReleaseMutationAtom } from './state' +import { consoleQuery } from '@/service/client' type EditReleaseFormValues = { name: string @@ -127,7 +127,7 @@ export function EditReleaseDialog({ onOpenChange: (open: boolean) => void }) { const { t } = useTranslation('deployments') - const updateRelease = useAtomValue(updateReleaseMutationAtom) + const updateRelease = useMutation(consoleQuery.enterprise.releaseService.updateRelease.mutationOptions()) 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 96b741720a3..5cf772bc54f 100644 --- a/web/features/deployments/detail/versions-tab/state.ts +++ b/web/features/deployments/detail/versions-tab/state.ts @@ -1,19 +1,12 @@ 'use client' -import type { ListReleaseSummariesResponse, Release } from '@dify/contracts/enterprise/types.gen' -import { keepPreviousData, mutationOptions, skipToken } from '@tanstack/react-query' +import type { ListReleaseSummariesResponse } from '@dify/contracts/enterprise/types.gen' +import { keepPreviousData, skipToken } from '@tanstack/react-query' import { atom } from 'jotai' -import { atomWithMutation, atomWithQuery } from 'jotai-tanstack-query' +import { 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) @@ -65,21 +58,6 @@ 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)) }) diff --git a/web/features/deployments/list/state/index.ts b/web/features/deployments/list/state/index.ts index fd8bf674aa7..bd4aef7b5fe 100644 --- a/web/features/deployments/list/state/index.ts +++ b/web/features/deployments/list/state/index.ts @@ -5,7 +5,7 @@ import type { InfiniteData, QueryKey } from '@tanstack/react-query' import type { ReactNode } from 'react' import { keepPreviousData } from '@tanstack/react-query' import { atom } from 'jotai' -import { atomWithInfiniteQuery, atomWithQuery } from 'jotai-tanstack-query' +import { atomWithInfiniteQuery } from 'jotai-tanstack-query' import { useHydrateAtoms } from 'jotai/utils' import { parseAsString, useQueryState } from 'nuqs' import { consoleQuery } from '@/service/client' @@ -88,16 +88,3 @@ export const deploymentsListShowEmptyStateAtom = atom((get) => { export const deploymentsListHasFilterAtom = atom((get) => { return Boolean(get(deploymentsListKeywordsAtom).trim() || get(deploymentsListEnvironmentIdAtom)) }) - -export const environmentsFilterQueryAtom = atomWithQuery(() => - consoleQuery.enterprise.environmentService.listEnvironments.queryOptions({ - input: { - query: { - // The filter lists every deployable environment; environment count is - // capped well below the 100-per-page maximum. - pageNumber: 1, - resultsPerPage: 100, - }, - }, - }), -) diff --git a/web/features/deployments/list/ui/environment-filter.tsx b/web/features/deployments/list/ui/environment-filter.tsx index c8468eec57f..d99f90d39df 100644 --- a/web/features/deployments/list/ui/environment-filter.tsx +++ b/web/features/deployments/list/ui/environment-filter.tsx @@ -8,14 +8,12 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' -import { useAtomValue } from 'jotai' +import { useQuery } from '@tanstack/react-query' import { useQueryState } from 'nuqs' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { - envFilterQueryState, - environmentsFilterQueryAtom, -} from '../state' +import { consoleQuery } from '@/service/client' +import { envFilterQueryState } from '../state' type EnvironmentFilterOption = { value: string | null @@ -33,7 +31,16 @@ export function EnvironmentFilter({ className }: { const { t } = useTranslation('deployments') const [open, setOpen] = useState(false) const [envFilter, setEnvFilter] = useQueryState('env', envFilterQueryState) - const environmentsQuery = useAtomValue(environmentsFilterQueryAtom) + const environmentsQuery = useQuery(consoleQuery.enterprise.environmentService.listEnvironments.queryOptions({ + input: { + query: { + // The filter lists every deployable environment; environment count is + // capped well below the 100-per-page maximum. + pageNumber: 1, + resultsPerPage: 100, + }, + }, + })) const environmentOptions: EnvironmentFilterOption[] = environmentsQuery.data?.environments ?.map(environment => ({ value: environment.id,