diff --git a/.agents/skills/how-to-write-component/SKILL.md b/.agents/skills/how-to-write-component/SKILL.md index ee5c0d80887..2dcc0d7fc2d 100644 --- a/.agents/skills/how-to-write-component/SKILL.md +++ b/.agents/skills/how-to-write-component/SKILL.md @@ -49,6 +49,7 @@ Use this as the component decision guide for Dify web. Existing code is referenc - Use uncontrolled `@langgenius/dify-ui/form` and `@langgenius/dify-ui/field` controls for edit/create forms whose fields are read only at submit time. Initialize query-backed defaults with `defaultValue` and keyed remounts. - Promote form state to atoms only when another component must react to in-progress values, a draft must survive unmount/remount in the scoped workflow, or multiple steps share the same editable draft before submit. - Treat `useParams`, route args, and `nuqs` query state as framework-owned state. When atom logic needs those values, hydrate primitive atoms at the route or surface boundary, such as with `useHydrateAtoms(..., { dangerouslyForceHydrate: true })`; keep URL updates in the route/query-state APIs instead of write atoms. +- Within a route-owned feature, choose one source for route identity. If route params are bridged into feature atoms, use that bridge consistently for route-derived queries and actions instead of also threading the same route id through page, tab, and section props. - For async work tied to atom state, use `atomWithQuery` or `atomWithMutation`; write atoms should update only the inputs that drive those atoms. This applies to pure frontend async work as well as network requests, so do not hand-roll loading/error/in-flight state with `useState` or `useRef` for atom-orchestrated async behavior. For component-owned remote work, use `useQuery` or `useMutation` directly. - Row-local async state belongs to the row owner unless it participates in a shared Jotai workflow or needs atom-scoped reset semantics. - Leave query and mutation atoms unscoped so they keep shared QueryClient cache and invalidation behavior. Scope resettable primitives and explicit hydration tuples; scope a derived atom only when every dependency should be private to that surface. @@ -88,11 +89,13 @@ Use this as the component decision guide for Dify web. Existing code is referenc - Keep `web/contract/*` as the API shape source of truth and follow the `{ params, query?, body? }` input shape. - Consume generated queries with `useQuery(consoleQuery.xxx.queryOptions(...))` or `useQuery(marketplaceQuery.xxx.queryOptions(...))`. +- If a generated query input comes from an atom, including a route-identity bridge atom, keep the query in `atomWithQuery`; do not unwrap the atom in a component just to call `useQuery`. - Consume owner-local mutations with `useMutation(consoleQuery.xxx.mutationOptions(...))` or `useMutation(marketplaceQuery.xxx.mutationOptions(...))` when pending/error state is not consumed by feature atoms. - In `atomWithQuery`, `atomWithInfiniteQuery`, and `atomWithMutation`, return generated `queryOptions()`, `infiniteOptions()`, or `mutationOptions()` directly. Pass `enabled`, `retry`, `placeholderData`, `select`, and pagination options into the generated call instead of spreading options into a hand-built object. - For generated oRPC options with missing required input, branch the whole input with `input: condition ? validInput : skipToken` and `enabled: Boolean(condition)`. Never place `skipToken` inside a nested placeholder payload or coerce required IDs to `''`. - When prefetch and render use the same request, extract local query options or a query-options atom so `prefetchQuery` and `useQuery`/`atomWithQuery` share the exact options. - For custom query or mutation functions, wrap options with TanStack `queryOptions(...)` or `mutationOptions(...)`. +- Do not extract generated `queryOptions(...)` into a helper solely to share input construction; extract only when prefetch/render must share exact options or the helper owns real domain behavior. - Avoid pass-through hooks and thin `web/service/use-*` wrappers that only rename generated options. Keep feature hooks for real orchestration, workflow state, or shared domain behavior. - Put shared cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`. Component or atom callbacks may handle local toasts, closing dialogs, and navigation, but should not replace shared invalidation or patch shared server state locally. - For overlays that may open heavier secondary content, prefetch from the trigger/menu open event with `queryClient.prefetchQuery(queryOptions)` when `onOpenChange` is available. Do not mount hidden subscribers just to warm cache. diff --git a/web/app/(commonLayout)/deployments/[appInstanceId]/instances/page.tsx b/web/app/(commonLayout)/deployments/[appInstanceId]/instances/page.tsx index d51bcfd5898..a8cd6e93c6d 100644 --- a/web/app/(commonLayout)/deployments/[appInstanceId]/instances/page.tsx +++ b/web/app/(commonLayout)/deployments/[appInstanceId]/instances/page.tsx @@ -1,8 +1,5 @@ import { DeployTab } from '@/features/deployments/detail/deploy-tab' -export default async function InstanceDetailInstancesPage({ params }: { - params: Promise<{ appInstanceId: string }> -}) { - const { appInstanceId } = await params - return +export default function InstanceDetailInstancesPage() { + return } diff --git a/web/app/(commonLayout)/deployments/[appInstanceId]/layout.tsx b/web/app/(commonLayout)/deployments/[appInstanceId]/layout.tsx index 4e39b066201..1ad1bf7175c 100644 --- a/web/app/(commonLayout)/deployments/[appInstanceId]/layout.tsx +++ b/web/app/(commonLayout)/deployments/[appInstanceId]/layout.tsx @@ -1,14 +1,11 @@ import type { ReactNode } from 'react' import { InstanceDetail } from '@/features/deployments/detail' -export default async function InstanceDetailLayout({ children, params }: { +export default function InstanceDetailLayout({ children }: { children: ReactNode - params: Promise<{ appInstanceId: string }> }) { - const { appInstanceId } = await params - return ( - + {children} ) diff --git a/web/app/(commonLayout)/deployments/[appInstanceId]/overview/page.tsx b/web/app/(commonLayout)/deployments/[appInstanceId]/overview/page.tsx index 7362d075a29..4683811a997 100644 --- a/web/app/(commonLayout)/deployments/[appInstanceId]/overview/page.tsx +++ b/web/app/(commonLayout)/deployments/[appInstanceId]/overview/page.tsx @@ -1,8 +1,5 @@ import { OverviewTab } from '@/features/deployments/detail/overview-tab' -export default async function InstanceDetailOverviewPage({ params }: { - params: Promise<{ appInstanceId: string }> -}) { - const { appInstanceId } = await params - return +export default function InstanceDetailOverviewPage() { + return } diff --git a/web/app/(commonLayout)/deployments/[appInstanceId]/releases/page.tsx b/web/app/(commonLayout)/deployments/[appInstanceId]/releases/page.tsx index ee5e689cc65..3d77a24ae55 100644 --- a/web/app/(commonLayout)/deployments/[appInstanceId]/releases/page.tsx +++ b/web/app/(commonLayout)/deployments/[appInstanceId]/releases/page.tsx @@ -1,8 +1,5 @@ import { VersionsTab } from '@/features/deployments/detail/versions-tab' -export default async function InstanceDetailReleasesPage({ params }: { - params: Promise<{ appInstanceId: string }> -}) { - const { appInstanceId } = await params - return +export default function InstanceDetailReleasesPage() { + return } diff --git a/web/features/deployments/detail/__tests__/state.spec.ts b/web/features/deployments/detail/__tests__/state.spec.ts index 1a53b843c4c..bf2694d178c 100644 --- a/web/features/deployments/detail/__tests__/state.spec.ts +++ b/web/features/deployments/detail/__tests__/state.spec.ts @@ -24,16 +24,6 @@ vi.mock('jotai-tanstack-query', () => ({ vi.mock('@/service/client', () => ({ consoleQuery: { - apps: { - byAppId: { - get: { - queryOptions: (options: QueryOptions) => ({ - ...options, - queryKey: ['appById', options.input], - }), - }, - }, - }, enterprise: { appInstanceService: { getAppInstance: { @@ -89,18 +79,13 @@ describe('deployment detail state', () => { enabled: false, input: skipToken, }) - expect(store.get(state.deploymentSourceAppQueryAtom)).toMatchObject({ - enabled: false, - input: skipToken, - }) }) - it('should build detail query inputs from route and source identities', async () => { + it('should build detail query inputs from route identity', async () => { const state = await loadState() const store = createStore() setDeploymentRoute(store) - store.set(state.deploymentSourceAppIdAtom, 'source-app-1') expect(store.get(state.deploymentDetailAppInstanceQueryAtom)).toMatchObject({ enabled: true, @@ -117,10 +102,5 @@ describe('deployment detail state', () => { input: { params: { appInstanceId: 'app-instance-1' } }, }) expect(environmentDeploymentsQuery.refetchInterval).toEqual(expect.any(Function)) - - expect(store.get(state.deploymentSourceAppQueryAtom)).toMatchObject({ - enabled: true, - input: { params: { app_id: 'source-app-1' } }, - }) }) }) diff --git a/web/features/deployments/detail/access-tab/channels/__tests__/section.spec.tsx b/web/features/deployments/detail/access-tab/channels/__tests__/section.spec.tsx index 488ae1a7732..ab08477cc94 100644 --- a/web/features/deployments/detail/access-tab/channels/__tests__/section.spec.tsx +++ b/web/features/deployments/detail/access-tab/channels/__tests__/section.spec.tsx @@ -16,12 +16,17 @@ vi.mock('jotai', async (importOriginal) => { } }) -vi.mock('@tanstack/react-query', () => ({ - useMutation: () => ({ - isPending: false, - mutate: mockToggleAccessChannel, - }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useMutation: () => ({ + isPending: false, + mutate: mockToggleAccessChannel, + }), + } +}) vi.mock('@/service/client', () => ({ consoleQuery: { diff --git a/web/features/deployments/detail/access-tab/permissions/__tests__/permissions.spec.tsx b/web/features/deployments/detail/access-tab/permissions/__tests__/permissions.spec.tsx index 281c5f89bdd..ecdbc625769 100644 --- a/web/features/deployments/detail/access-tab/permissions/__tests__/permissions.spec.tsx +++ b/web/features/deployments/detail/access-tab/permissions/__tests__/permissions.spec.tsx @@ -20,22 +20,23 @@ vi.mock('jotai', async (importOriginal) => { } }) -vi.mock('@tanstack/react-query', () => ({ - useInfiniteQuery: () => ({ - data: { pages: [] }, - fetchNextPage: vi.fn(), - isFetchingNextPage: false, - isLoading: false, - }), - useMutation: () => ({ - isPending: false, - mutate: mockMutate, - }), - useQuery: () => ({ - data: undefined, - isPending: false, - }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useInfiniteQuery: () => ({ + data: { pages: [] }, + fetchNextPage: vi.fn(), + isFetchingNextPage: false, + isLoading: false, + }), + useMutation: () => ({ + isPending: false, + mutate: mockMutate, + }), + } +}) vi.mock('@/service/client', () => ({ consoleQuery: { diff --git a/web/features/deployments/detail/deploy-tab/deployment-environment-list.tsx b/web/features/deployments/detail/deploy-tab/deployment-environment-list.tsx index fb45e113cb7..1319a539efa 100644 --- a/web/features/deployments/detail/deploy-tab/deployment-environment-list.tsx +++ b/web/features/deployments/detail/deploy-tab/deployment-environment-list.tsx @@ -72,8 +72,7 @@ function CurrentReleaseMobileSummary({ release }: { ) } -function DeploymentEnvironmentMobileRow({ appInstanceId, row }: { - appInstanceId: string +function DeploymentEnvironmentMobileRow({ row }: { row: EnvironmentDeployment }) { const envId = row.environment.id @@ -88,15 +87,14 @@ function DeploymentEnvironmentMobileRow({ appInstanceId, row }: { {!isUndeployedDeploymentRow(row) && }
- +
) } -function DeploymentEnvironmentDesktopRows({ appInstanceId, rows }: { - appInstanceId: string +function DeploymentEnvironmentDesktopRows({ rows }: { rows: EnvironmentDeployment[] }) { return ( @@ -116,7 +114,7 @@ function DeploymentEnvironmentDesktopRows({ appInstanceId, rows }: {
- +
@@ -126,8 +124,7 @@ function DeploymentEnvironmentDesktopRows({ appInstanceId, rows }: { ) } -export function DeploymentEnvironmentList({ appInstanceId, rows }: { - appInstanceId: string +export function DeploymentEnvironmentList({ rows }: { rows: EnvironmentDeployment[] }) { const { t } = useTranslation('deployments') @@ -138,7 +135,6 @@ export function DeploymentEnvironmentList({ appInstanceId, rows }: { {rows.map(row => ( ))} @@ -154,7 +150,7 @@ export function DeploymentEnvironmentList({ appInstanceId, rows }: { - + 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 28e67dfd53d..f5c82f473a7 100644 --- a/web/features/deployments/detail/deploy-tab/deployment-row-actions.tsx +++ b/web/features/deployments/detail/deploy-tab/deployment-row-actions.tsx @@ -3,23 +3,24 @@ 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 { useAtomValue, useSetAtom } from 'jotai' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { consoleQuery } from '@/service/client' import { openDeployDrawerAtom } from '../../deploy-drawer/state' +import { deploymentRouteAppInstanceIdAtom } from '../../route-state' import { createDeploymentIdempotencyKey } from '../../shared/domain/idempotency' import { isRuntimeDeploymentInProgress, isUndeployedDeploymentRow } from '../../shared/domain/runtime-status' import { DeploymentErrorDialog } from './deployment-error-dialog' import { DeploymentActionsDropdown } from './deployment-row-actions-menu' import { UndeployDeploymentDialog } from './undeploy-deployment-dialog' -export function DeploymentRowActions({ appInstanceId, envId, row }: { - appInstanceId: string +export function DeploymentRowActions({ envId, row }: { envId: string row: EnvironmentDeployment }) { const { t } = useTranslation('deployments') + const routeAppInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom) const openDeployDrawer = useSetAtom(openDeployDrawerAtom) const undeployDeployment = useMutation(consoleQuery.enterprise.deploymentService.undeploy.mutationOptions()) const [showUndeployConfirm, setShowUndeployConfirm] = useState(false) @@ -36,6 +37,11 @@ export function DeploymentRowActions({ appInstanceId, envId, row }: { ? t('deployDrawer.deploy') : t('deployTab.deployOtherVersion') + if (!routeAppInstanceId) + return null + + const appInstanceId = routeAppInstanceId + function handleDeployAction(releaseId?: string) { openDeployDrawer({ appInstanceId, environmentId: envId, releaseId }) } diff --git a/web/features/deployments/detail/deploy-tab/index.tsx b/web/features/deployments/detail/deploy-tab/index.tsx index db5c7b656c9..4f0bdc5cb38 100644 --- a/web/features/deployments/detail/deploy-tab/index.tsx +++ b/web/features/deployments/detail/deploy-tab/index.tsx @@ -87,9 +87,7 @@ function DeploymentEnvironmentListSkeleton() { ) } -export function DeployTab({ appInstanceId }: { - appInstanceId: string -}) { +export function DeployTab() { const { t } = useTranslation('deployments') const environmentDeploymentsQuery = useAtomValue(deploymentEnvironmentDeploymentsQueryAtom) const environmentDeployments = environmentDeploymentsQuery.data @@ -109,11 +107,11 @@ export function DeployTab({ appInstanceId }: { icon="i-ri-server-line" title={t('deployTab.emptyTitle')} description={t('deployTab.emptyDescription')} - action={} + action={} /> ) : ( - + )} ) diff --git a/web/features/deployments/detail/deploy-tab/new-deployment-button.tsx b/web/features/deployments/detail/deploy-tab/new-deployment-button.tsx index 87350ec42e0..b5a06665cdb 100644 --- a/web/features/deployments/detail/deploy-tab/new-deployment-button.tsx +++ b/web/features/deployments/detail/deploy-tab/new-deployment-button.tsx @@ -4,13 +4,13 @@ import { Button } from '@langgenius/dify-ui/button' import { useAtomValue, useSetAtom } from 'jotai' import { useTranslation } from 'react-i18next' import { openDeployDrawerAtom } from '../../deploy-drawer/state' +import { deploymentRouteAppInstanceIdAtom } from '../../route-state' import { hasRuntimeInstanceDeployment } from '../../shared/domain/runtime-status' import { deploymentEnvironmentDeploymentsQueryAtom } from '../state' -export function NewDeploymentButton({ appInstanceId }: { - appInstanceId: string -}) { +export function NewDeploymentButton() { const { t } = useTranslation('deployments') + const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom) const openDeployDrawer = useSetAtom(openDeployDrawerAtom) return ( @@ -18,7 +18,12 @@ export function NewDeploymentButton({ appInstanceId }: { size="medium" variant="primary" className="gap-1.5" - onClick={() => openDeployDrawer({ appInstanceId })} + disabled={!appInstanceId} + onClick={() => { + if (!appInstanceId) + return + openDeployDrawer({ appInstanceId }) + }} >