refactor(web): consolidate deployment state atoms (#37783)

This commit is contained in:
Stephen Zhou 2026-06-23 15:26:55 +08:00 committed by GitHub
parent cf1ebdadf5
commit 99c3d7d0f0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 2364 additions and 567 deletions

View File

@ -37,12 +37,16 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- 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 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.
- 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.
- 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.
- 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.
@ -51,7 +55,11 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- 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 scope is an optional instance-isolation tool for secondary surfaces with independent local state. Query atoms keep shared cache behavior through the shared QueryClient.
- 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.
- Keep independent dialog lifecycles separate. Avoid a single discriminated "current action dialog" atom when edit, delete, and other dialogs have their own open state, loading guard, or reset behavior.
- Route-derived stable identities that do not need instance reset or scoped isolation can be hydrated at the route or layout boundary into a feature route atom. Use scoped atoms only when stale cross-instance state or per-surface reset semantics are needed.
## Components, Props, And Types
@ -74,6 +82,7 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- Use generated enum objects and union types directly in props, comparisons, status logic, and i18n keys. Do not add local enum constants or parallel frontend enum/status layers unless they model real product state not represented by the API. Presentation-only tone maps should be keyed by the generated enum.
- Normalize or coerce only at a real boundary, such as user-entered forms, search, URL/query params, file names, DOM IDs, or legacy adapters. Preserve user-entered values when whitespace or formatting can be meaningful.
- Do not coerce nullable or optional API strings to `''` in query, derived model, or payload-building code. Keep `undefined` or `null` until the final boundary that requires a string.
- Do not use `value || undefined` for mutation payload fields where an empty string means "clear this value". Trim or normalize at the form boundary, then preserve `''` when the API contract treats it as an intentional update.
- Local UI models are fine for presentation, form state, select options, or guarded required-field refinements. Name them as UI concepts, not generated DTO mirrors.
- Required-value refinements are allowed only after same-branch filtering or early return. Prefer nullable-tolerant props for render-only data.
- When a component needs a stricter shape than a generated DTO, refine once at the API/query-to-UI boundary into a purpose-named UI type instead of hiding missing fields with generic fallback or coercion helpers.
@ -93,12 +102,17 @@ 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(...))`.
- 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.
- Avoid pass-through hooks and thin `web/service/use-*` wrappers that only rename `queryOptions()` or `mutationOptions()`. Extract a small `queryOptions` helper only when repeated call-site options justify it.
- Keep feature hooks for real orchestration, workflow state, or shared domain behavior.
- For TanStack cache data, use generated or query-derived types; do not create local wrappers for `getQueryData` or `getQueriesData`.
- For generated oRPC `queryOptions()` / `infiniteOptions()`, do not pass `skipToken` as `input`; keep a valid placeholder input shape and use `enabled` to gate missing required params because the OpenAPI codec encodes input eagerly.
- 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.
- 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`.
- Prefer `mutate(...)`; use `mutateAsync(...)` only when Promise semantics are required, and wrap awaited calls in `try/catch`.
@ -110,6 +124,7 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- Keep cohesive forms, menu bodies, and one-off helpers local unless they need their own state, reuse, or semantic boundary.
- Separate hidden secondary surfaces from the trigger's main flow. For dialogs, dropdowns, popovers, and similar branches, extract a small local component that owns the trigger, open state, and hidden content when it would obscure the parent flow.
- Preserve composability by separating behavior ownership from layout ownership. A dropdown action may own its trigger, open state, and menu content; the caller owns placement such as slots, offsets, and alignment.
- When a dialog, dropdown, or popover component already accepts controlled `open` state, mount the surface unconditionally unless unmounting is required for performance or reset semantics. Use keyed scope or local state reset for reset behavior instead of `{open && <Surface />}` wrappers.
- Avoid unnecessary DOM hierarchy. Do not add wrapper elements unless they provide layout, semantics, accessibility, state ownership, or integration with a library API; prefer fragments or styling an existing element when possible.
- Avoid shallow wrappers, hook-to-props adapter components, layout-only render-prop wrappers, children-as-pass-through composition, and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary. If a component only calls a hook, forwards props, or passes trigger/content through to one child, move the logic into that child or make the wrapper own a real surface.
@ -120,6 +135,7 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- Do not use Effects to handle user actions. Put action-specific logic in the event handler where the cause is known.
- Do not use Effects to copy one state value into another state value representing the same concept. Pick one source of truth and derive the rest during render.
- Do not reset or adjust state from props with an Effect. Prefer a `key` reset, storing a stable ID and deriving the selected object, or guarded same-component render-time adjustment when truly necessary.
- For forms initialized from query data, prefer keyed remounts or surface-entry hydration of form/field atoms over an Effect that copies query data into form state.
- Prefer framework data APIs or TanStack Query for data fetching instead of writing request Effects in components.
- If an Effect still seems necessary, first name the external system it synchronizes with. If there is no external system, remove the Effect and restructure the state or event flow.

View File

@ -1,11 +1,13 @@
import type { ReactNode } from 'react'
import { DeployDrawer } from '@/features/deployments/deploy-drawer'
import { DeploymentsRouteStateHydrator } from '@/features/deployments/route-state-hydrator'
export default function DeploymentsLayout({ children }: {
children: ReactNode
}) {
return (
<>
<DeploymentsRouteStateHydrator />
{children}
<DeployDrawer />
</>

View File

@ -0,0 +1,222 @@
import type { Getter } from 'jotai/vanilla'
import { skipToken } from '@tanstack/react-query'
import { atom, createStore } from 'jotai'
import { beforeEach, describe, expect, it, vi } from 'vitest'
type QueryOptions = {
enabled?: boolean
input?: unknown
queryKey?: readonly unknown[]
}
type QueryResult = {
data?: unknown
}
type MutationOptions = {
mutationKey?: readonly string[]
}
type MutationResult = {
isPending: boolean
mutate: ReturnType<typeof vi.fn>
mutateAsync: ReturnType<typeof vi.fn>
}
const mockQueryResults = vi.hoisted(() => ({
current: new Map<string, QueryResult>(),
}))
const mockUpdateMutation = vi.hoisted<{ current: MutationResult }>(() => ({
current: {
isPending: false,
mutate: vi.fn(),
mutateAsync: vi.fn(),
},
}))
const mockDeleteMutation = vi.hoisted<{ current: MutationResult }>(() => ({
current: {
isPending: false,
mutate: vi.fn(),
mutateAsync: vi.fn(),
},
}))
vi.mock('jotai-tanstack-query', () => ({
atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom((get) => {
const options = createOptions(get)
const queryName = String(options.queryKey?.[0] ?? 'unknown')
const queryResult = options.enabled === false
? undefined
: mockQueryResults.current.get(queryName)
return {
...options,
data: queryResult?.data,
isError: false,
isFetching: false,
isLoading: false,
isSuccess: Boolean(queryResult?.data),
}
}),
atomWithMutation: (createOptions: () => MutationOptions) => atom(() => {
const options = createOptions()
return options.mutationKey?.[0] === 'deleteAppInstance'
? mockDeleteMutation.current
: mockUpdateMutation.current
}),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
enterprise: {
appInstanceService: {
getAppInstance: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['getAppInstance', options.input],
}),
},
updateAppInstance: {
mutationOptions: () => ({ mutationKey: ['updateAppInstance'] }),
},
deleteAppInstance: {
mutationOptions: () => ({ mutationKey: ['deleteAppInstance'] }),
},
},
},
},
}))
async function loadState() {
return await import('../state')
}
async function mountedStore() {
const state = await loadState()
const store = createStore()
const unsubscribe = store.sub(state.editDeploymentFormCanSaveAtom, () => undefined)
store.set(state.deploymentActionAppInstanceIdHydrationAtom, 'app-instance-1')
return {
state,
store,
unsubscribe,
}
}
function setAppInstance(overrides: Record<string, unknown> = {}) {
mockQueryResults.current.set('getAppInstance', {
data: {
appInstance: {
id: 'app-instance-1',
displayName: 'Deployment 1',
description: 'Initial description',
...overrides,
},
},
})
}
describe('deployment action state', () => {
beforeEach(() => {
vi.clearAllMocks()
mockQueryResults.current.clear()
mockUpdateMutation.current = {
isPending: false,
mutate: vi.fn(),
mutateAsync: vi.fn(),
}
mockDeleteMutation.current = {
isPending: false,
mutate: vi.fn(),
mutateAsync: vi.fn(),
}
})
it('should fetch app instance data only while an action dialog is open', async () => {
const { state, store, unsubscribe } = await mountedStore()
expect(store.get(state.deploymentActionAppInstanceQueryAtom)).toMatchObject({
enabled: false,
input: skipToken,
})
store.set(state.editDeploymentDialogOpenAtom, true)
expect(store.get(state.deploymentActionAppInstanceQueryAtom)).toMatchObject({
enabled: true,
input: { params: { appInstanceId: 'app-instance-1' } },
})
store.set(state.editDeploymentDialogOpenAtom, false)
store.set(state.deleteDeploymentDialogOpenAtom, true)
expect(store.get(state.deploymentActionAppInstanceQueryAtom)).toMatchObject({
enabled: true,
input: { params: { appInstanceId: 'app-instance-1' } },
})
unsubscribe()
})
it('should keep an edit dialog open while update is pending', async () => {
const { state, store, unsubscribe } = await mountedStore()
mockUpdateMutation.current = {
isPending: true,
mutate: vi.fn(),
mutateAsync: vi.fn(),
}
store.set(state.editDeploymentDialogOpenAtom, true)
store.set(state.setEditDeploymentDialogOpenAtom, false)
expect(store.get(state.editDeploymentDialogOpenAtom)).toBe(true)
unsubscribe()
})
it('should submit edited deployment metadata with trimmed values', async () => {
const { state, store, unsubscribe } = await mountedStore()
const response = { appInstance: { id: 'app-instance-1' } }
setAppInstance()
mockUpdateMutation.current.mutateAsync.mockResolvedValue(response)
store.set(state.editDeploymentDialogOpenAtom, true)
store.set(state.editDeploymentNameFieldAtom, ' Deployment 2 ')
store.set(state.editDeploymentDescriptionFieldAtom, ' Updated description ')
const result = await store.set(state.submitEditDeploymentFormAtom)
expect(result).toBe(true)
expect(mockUpdateMutation.current.mutateAsync).toHaveBeenCalledWith({
params: {
appInstanceId: 'app-instance-1',
},
body: {
appInstanceId: 'app-instance-1',
displayName: 'Deployment 2',
description: 'Updated description',
},
})
unsubscribe()
})
it('should submit delete with the hydrated app instance id and caller callbacks', async () => {
const { state, store, unsubscribe } = await mountedStore()
const onSuccess = vi.fn()
store.set(state.submitDeleteDeploymentInstanceAtom, { onSuccess })
expect(mockDeleteMutation.current.mutate).toHaveBeenCalledWith(
{
params: {
appInstanceId: 'app-instance-1',
},
},
{ onSuccess },
)
unsubscribe()
})
})

View File

@ -10,47 +10,37 @@ import {
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { useRouter } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import {
deleteDeploymentDialogOpenAtom,
deleteDeploymentInstanceMutationAtom,
deploymentActionDisplayNameAtom,
submitDeleteDeploymentInstanceAtom,
} from './state'
export function DeleteDeploymentDialog({
appInstanceId,
appName,
open,
onOpenChange,
}: {
appInstanceId: string
appName?: string
open: boolean
onOpenChange: (open: boolean) => void
}) {
export function DeleteDeploymentDialog() {
const { t } = useTranslation('deployments')
const router = useRouter()
const deleteInstance = useMutation(consoleQuery.enterprise.appInstanceService.deleteAppInstance.mutationOptions())
const displayName = appName || appInstanceId
const [open, setOpen] = useAtom(deleteDeploymentDialogOpenAtom)
const deleteInstance = useAtomValue(deleteDeploymentInstanceMutationAtom)
const submitDeleteInstance = useSetAtom(submitDeleteDeploymentInstanceAtom)
const displayName = useAtomValue(deploymentActionDisplayNameAtom)
function handleDelete() {
deleteInstance.mutate(
{
params: {
appInstanceId,
},
submitDeleteInstance({
onSuccess: () => {
toast.success(t('settings.deleted'))
router.push('/deployments')
},
{
onSuccess: () => {
toast.success(t('settings.deleted'))
router.push('/deployments')
},
onError: () => {
toast.error(t('settings.deleteFailed'))
},
onSettled: () => {
onOpenChange(false)
},
onError: () => {
toast.error(t('settings.deleteFailed'))
},
)
onSettled: () => {
setOpen(false)
},
})
}
return (
@ -59,7 +49,7 @@ export function DeleteDeploymentDialog({
onOpenChange={(nextOpen) => {
if (!nextOpen && deleteInstance.isPending)
return
onOpenChange(nextOpen)
setOpen(nextOpen)
}}
>
<AlertDialogContent className="w-120">

View File

@ -1,6 +1,5 @@
'use client'
import type { AppInstance } from '@dify/contracts/enterprise/types.gen'
import type { FormEvent } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import {
@ -12,16 +11,22 @@ import {
import { Input } from '@langgenius/dify-ui/input'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import { ScopeProvider } from 'jotai-scope'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
type EditDeploymentFormValues = {
name: string
description: string
}
import {
deploymentActionAppInstanceQueryAtom,
editDeploymentDescriptionFieldAtom,
editDeploymentDialogOpenAtom,
editDeploymentFormAtom,
editDeploymentFormCanSaveAtom,
editDeploymentFormSavePendingAtom,
editDeploymentNameFieldAtom,
setEditDeploymentDialogOpenAtom,
submitEditDeploymentFormAtom,
updateDeploymentInstanceMutationAtom,
} from './state'
function EditDeploymentFormSkeleton() {
return (
@ -42,35 +47,34 @@ function EditDeploymentFormSkeleton() {
)
}
function EditDeploymentForm({
app,
isSaving,
onClose,
onSubmit,
}: {
app: AppInstance
isSaving: boolean
onClose: () => void
onSubmit: (values: EditDeploymentFormValues) => void
}) {
function EditDeploymentForm() {
const { t } = useTranslation('deployments')
const initialName = app.displayName
const initialDescription = app.description
const [name, setName] = useState(initialName)
const [description, setDescription] = useState(initialDescription)
const normalizedName = name.trim()
const normalizedDescription = description.trim()
const canSave = Boolean(normalizedName && (normalizedName !== initialName || normalizedDescription !== initialDescription) && !isSaving)
const [nameField, setNameField] = useAtom(editDeploymentNameFieldAtom)
const [descriptionField, setDescriptionField] = useAtom(editDeploymentDescriptionFieldAtom)
const canSave = useAtomValue(editDeploymentFormCanSaveAtom)
const savePending = useAtomValue(editDeploymentFormSavePendingAtom)
const submitEditDeploymentForm = useSetAtom(submitEditDeploymentFormAtom)
const requestOpenChange = useSetAtom(setEditDeploymentDialogOpenAtom)
const setOpen = useSetAtom(editDeploymentDialogOpenAtom)
function handleSubmit(event: FormEvent<HTMLFormElement>) {
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
event.stopPropagation()
if (!canSave)
return
onSubmit({
name: normalizedName,
description: normalizedDescription,
})
try {
const didSubmit = await submitEditDeploymentForm()
if (!didSubmit)
return
toast.success(t('settings.updated'))
setOpen(false)
}
catch {
toast.error(t('settings.updateFailed'))
}
}
return (
@ -81,9 +85,10 @@ function EditDeploymentForm({
</label>
<Input
id="deployment-edit-name"
name="name"
type="text"
value={name}
onChange={event => setName(event.target.value)}
value={nameField.value}
onChange={event => setNameField(event.target.value)}
className="h-8"
/>
</div>
@ -93,8 +98,9 @@ function EditDeploymentForm({
</label>
<Textarea
id="deployment-edit-description"
value={description}
onValueChange={setDescription}
name="description"
value={descriptionField.value}
onValueChange={value => setDescriptionField(value)}
className="min-h-24"
/>
</div>
@ -102,8 +108,8 @@ function EditDeploymentForm({
<Button
type="button"
variant="secondary"
disabled={isSaving}
onClick={onClose}
disabled={savePending}
onClick={() => requestOpenChange(false)}
>
{t('createModal.cancel')}
</Button>
@ -111,7 +117,7 @@ function EditDeploymentForm({
type="submit"
variant="primary"
disabled={!canSave}
loading={isSaving}
loading={savePending}
>
{t('settings.save')}
</Button>
@ -120,62 +126,17 @@ function EditDeploymentForm({
)
}
export function EditDeploymentDialog({
appInstanceId,
open,
onOpenChange,
}: {
appInstanceId: string
open: boolean
onOpenChange: (open: boolean) => void
}) {
export function EditDeploymentDialog() {
const { t } = useTranslation('deployments')
const updateInstance = useMutation(consoleQuery.enterprise.appInstanceService.updateAppInstance.mutationOptions())
const instanceQuery = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstance.queryOptions({
input: {
params: { appInstanceId },
},
enabled: open,
}))
const open = useAtomValue(editDeploymentDialogOpenAtom)
const setOpen = useSetAtom(setEditDeploymentDialogOpenAtom)
const updateInstance = useAtomValue(updateDeploymentInstanceMutationAtom)
const instanceQuery = useAtomValue(deploymentActionAppInstanceQueryAtom)
const app = instanceQuery.data?.appInstance
const formKey = app ? `${app.id}-${app.displayName}-${app.description}` : 'loading'
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen && updateInstance.isPending)
return
onOpenChange(nextOpen)
}
function handleClose() {
handleOpenChange(false)
}
function handleSubmit(values: EditDeploymentFormValues) {
updateInstance.mutate(
{
params: {
appInstanceId,
},
body: {
appInstanceId,
displayName: values.name,
description: values.description || undefined,
},
},
{
onSuccess: () => {
toast.success(t('settings.updated'))
onOpenChange(false)
},
onError: () => {
toast.error(t('settings.updateFailed'))
},
},
)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="w-120 max-w-[calc(100vw-32px)] p-0">
<DialogCloseButton disabled={updateInstance.isPending} />
<div className="border-b border-divider-subtle px-6 py-5">
@ -190,13 +151,17 @@ export function EditDeploymentDialog({
? <div className="system-sm-regular text-text-tertiary">{t('common.loadFailed')}</div>
: app
? (
<EditDeploymentForm
<ScopeProvider
key={formKey}
app={app}
isSaving={updateInstance.isPending}
onClose={handleClose}
onSubmit={handleSubmit}
/>
atoms={[
editDeploymentFormAtom,
[editDeploymentNameFieldAtom, app.displayName],
[editDeploymentDescriptionFieldAtom, app.description],
]}
name="EditDeploymentForm"
>
<EditDeploymentForm />
</ScopeProvider>
)
: <div className="system-sm-regular text-text-tertiary">{t('detail.notFound')}</div>}
</div>

View File

@ -4,13 +4,31 @@ import { DeploymentActionsMenu } from './index'
vi.mock('@langgenius/dify-ui/dropdown-menu', () => import('@/__mocks__/base-ui-dropdown-menu'))
vi.mock('./edit-dialog', () => ({
EditDeploymentDialog: () => null,
}))
vi.mock('./edit-dialog', async () => {
const { useAtomValue } = await import('jotai')
const { editDeploymentDialogOpenAtom } = await import('./state')
vi.mock('./delete-dialog', () => ({
DeleteDeploymentDialog: () => null,
}))
return {
EditDeploymentDialog: () => {
const open = useAtomValue(editDeploymentDialogOpenAtom)
return <div data-testid="edit-dialog" data-open={String(open)} />
},
}
})
vi.mock('./delete-dialog', async () => {
const { useAtomValue } = await import('jotai')
const { deleteDeploymentDialogOpenAtom } = await import('./state')
return {
DeleteDeploymentDialog: () => {
const open = useAtomValue(deleteDeploymentDialogOpenAtom)
return <div data-testid="delete-dialog" data-open={String(open)} />
},
}
})
describe('DeploymentActionsMenu', () => {
it('keeps the trigger wrapper visible while the menu is open', () => {
@ -31,4 +49,52 @@ describe('DeploymentActionsMenu', () => {
expect(wrapper).toHaveClass('pointer-events-auto', 'opacity-100')
expect(wrapper).not.toHaveClass('pointer-events-none', 'opacity-0')
})
it('keeps edit and delete dialog open state independent', () => {
render(
<DeploymentActionsMenu
appInstanceId="app-instance-1"
placement="bottom-end"
/>,
)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(screen.getByText('deployments.card.menu.editInfo'))
expect(screen.getByTestId('edit-dialog')).toHaveAttribute('data-open', 'true')
expect(screen.getByTestId('delete-dialog')).toHaveAttribute('data-open', 'false')
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(screen.getByText('deployments.card.menu.delete'))
expect(screen.getByTestId('edit-dialog')).toHaveAttribute('data-open', 'true')
expect(screen.getByTestId('delete-dialog')).toHaveAttribute('data-open', 'true')
})
it('resets dialog state when the menu app instance changes', () => {
const { rerender } = render(
<DeploymentActionsMenu
appInstanceId="app-instance-1"
placement="bottom-end"
/>,
)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(screen.getByText('deployments.card.menu.editInfo'))
expect(screen.getByTestId('edit-dialog')).toHaveAttribute('data-open', 'true')
rerender(
<DeploymentActionsMenu
appInstanceId="app-instance-2"
placement="bottom-end"
/>,
)
expect(screen.getByTestId('edit-dialog')).toHaveAttribute('data-open', 'false')
rerender(
<DeploymentActionsMenu
appInstanceId="app-instance-1"
placement="bottom-end"
/>,
)
expect(screen.getByTestId('edit-dialog')).toHaveAttribute('data-open', 'false')
})
})

View File

@ -9,10 +9,18 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useSetAtom } from 'jotai'
import { ScopeProvider } from 'jotai-scope'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { DeleteDeploymentDialog } from './delete-dialog'
import { EditDeploymentDialog } from './edit-dialog'
import {
deleteDeploymentDialogOpenAtom,
deploymentActionAppInstanceIdHydrationAtom,
deploymentActionsLocalAtoms,
editDeploymentDialogOpenAtom,
} from './state'
const ACTION_TRIGGER_CLASS_NAME = cn(
'inline-flex size-8 items-center justify-center rounded-lg bg-components-panel-bg text-text-tertiary shadow-xs outline-hidden',
@ -22,25 +30,24 @@ const ACTION_TRIGGER_CLASS_NAME = cn(
type DeploymentActionsMenuProps = {
appInstanceId: string
appName?: string
className?: string
triggerClassName?: string
placement: ComponentProps<typeof DropdownMenuContent>['placement']
sideOffset?: ComponentProps<typeof DropdownMenuContent>['sideOffset']
}
export function DeploymentActionsMenu({
appInstanceId,
appName,
type DeploymentActionsMenuContentProps = Omit<DeploymentActionsMenuProps, 'appInstanceId'>
function DeploymentActionsMenuContent({
className,
triggerClassName,
placement,
sideOffset,
}: DeploymentActionsMenuProps) {
}: DeploymentActionsMenuContentProps) {
const { t } = useTranslation('deployments')
const setEditOpen = useSetAtom(editDeploymentDialogOpenAtom)
const setDeleteOpen = useSetAtom(deleteDeploymentDialogOpenAtom)
const [menuOpen, setMenuOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
function openEditDialog() {
setMenuOpen(false)
@ -66,36 +73,43 @@ export function DeploymentActionsMenu({
>
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
{menuOpen && (
<DropdownMenuContent placement={placement} sideOffset={sideOffset} popupClassName="min-w-44">
<DropdownMenuItem className="gap-2 px-3" onClick={openEditDialog}>
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">{t('card.menu.editInfo')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
className="gap-2 px-3"
onClick={openDeleteDialog}
>
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
<span className="system-sm-regular">{t('card.menu.delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
)}
<DropdownMenuContent placement={placement} sideOffset={sideOffset} popupClassName="min-w-44">
<DropdownMenuItem className="gap-2 px-3" onClick={openEditDialog}>
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">{t('card.menu.editInfo')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
className="gap-2 px-3"
onClick={openDeleteDialog}
>
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
<span className="system-sm-regular">{t('card.menu.delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<EditDeploymentDialog
appInstanceId={appInstanceId}
open={editOpen}
onOpenChange={setEditOpen}
/>
<DeleteDeploymentDialog
appInstanceId={appInstanceId}
appName={appName}
open={deleteOpen}
onOpenChange={setDeleteOpen}
/>
<EditDeploymentDialog />
<DeleteDeploymentDialog />
</div>
)
}
export function DeploymentActionsMenu({
appInstanceId,
...props
}: DeploymentActionsMenuProps) {
return (
<ScopeProvider
key={appInstanceId}
atoms={[
[deploymentActionAppInstanceIdHydrationAtom, appInstanceId],
...deploymentActionsLocalAtoms,
]}
name="DeploymentActionsMenu"
>
<DeploymentActionsMenuContent {...props} />
</ScopeProvider>
)
}

View File

@ -0,0 +1,168 @@
'use client'
import type { ExtractAtomValue } from 'jotai'
import type { Getter } from 'jotai/vanilla'
import { skipToken } from '@tanstack/react-query'
import { atom } from 'jotai'
import {
atomWithForm,
createFormAtoms,
} from 'jotai-tanstack-form'
import { atomWithMutation, atomWithQuery } from 'jotai-tanstack-query'
import { consoleQuery } from '@/service/client'
export type EditDeploymentFormValues = {
name: string
description: string
}
const DEFAULT_EDIT_DEPLOYMENT_FORM_VALUES: EditDeploymentFormValues = {
name: '',
description: '',
}
export const deploymentActionAppInstanceIdHydrationAtom = atom<string | undefined>(undefined)
export const editDeploymentDialogOpenAtom = atom(false)
export const deleteDeploymentDialogOpenAtom = atom(false)
export const editDeploymentFormAtom = atomWithForm({
defaultValues: DEFAULT_EDIT_DEPLOYMENT_FORM_VALUES,
})
const editDeploymentFormAtoms = createFormAtoms(editDeploymentFormAtom)
const editDeploymentFormValuesAtom = editDeploymentFormAtoms.valuesAtom
export const editDeploymentNameFieldAtom = editDeploymentFormAtoms.fieldAtom('name')
export const editDeploymentDescriptionFieldAtom = editDeploymentFormAtoms.fieldAtom('description')
const deploymentActionAppInstanceIdAtom = atom((get): string => {
const appInstanceId = get(deploymentActionAppInstanceIdHydrationAtom)
if (!appInstanceId)
throw new Error('Missing deployment action app instance id.')
return appInstanceId
})
function normalizedEditDeploymentFormValues(value: EditDeploymentFormValues) {
return {
name: value.name.trim(),
description: value.description.trim(),
}
}
export const deploymentActionAppInstanceQueryAtom = atomWithQuery((get) => {
const appInstanceId = get(deploymentActionAppInstanceIdAtom)
const editOpen = get(editDeploymentDialogOpenAtom)
const deleteOpen = get(deleteDeploymentDialogOpenAtom)
const enabled = editOpen || deleteOpen
return consoleQuery.enterprise.appInstanceService.getAppInstance.queryOptions({
input: enabled
? {
params: { appInstanceId },
}
: skipToken,
enabled,
})
})
export const deploymentActionDisplayNameAtom = atom((get): string => {
return get(deploymentActionAppInstanceQueryAtom).data?.appInstance.displayName || get(deploymentActionAppInstanceIdAtom)
})
function editDeploymentInitialFormValues(get: Getter): EditDeploymentFormValues | undefined {
const app = get(deploymentActionAppInstanceQueryAtom).data?.appInstance
if (!app)
return undefined
return {
name: app.displayName,
description: app.description,
}
}
function canSubmitEditDeploymentForm(get: Getter, value: EditDeploymentFormValues) {
const initialValues = editDeploymentInitialFormValues(get)
if (!initialValues)
return false
const normalizedValues = normalizedEditDeploymentFormValues(value)
return Boolean(
normalizedValues.name
&& (
normalizedValues.name !== initialValues.name
|| normalizedValues.description !== initialValues.description
),
)
}
export const updateDeploymentInstanceMutationAtom = atomWithMutation(() =>
consoleQuery.enterprise.appInstanceService.updateAppInstance.mutationOptions(),
)
export const deleteDeploymentInstanceMutationAtom = atomWithMutation(() =>
consoleQuery.enterprise.appInstanceService.deleteAppInstance.mutationOptions(),
)
export const setEditDeploymentDialogOpenAtom = atom(null, (get, set, open: boolean) => {
if (!open && get(updateDeploymentInstanceMutationAtom).isPending)
return
set(editDeploymentDialogOpenAtom, open)
})
export const editDeploymentFormSavePendingAtom = atom((get) => {
return get(updateDeploymentInstanceMutationAtom).isPending
})
export const editDeploymentFormCanSaveAtom = atom((get) => {
return canSubmitEditDeploymentForm(get, get(editDeploymentFormValuesAtom))
&& !get(editDeploymentFormSavePendingAtom)
})
const submitEditDeploymentInstanceAtom = atom(null, async (get, _set, value: EditDeploymentFormValues) => {
if (!canSubmitEditDeploymentForm(get, value))
return undefined
const appInstanceId = get(deploymentActionAppInstanceIdAtom)
const updateInstance = get(updateDeploymentInstanceMutationAtom)
const normalizedValues = normalizedEditDeploymentFormValues(value)
return await updateInstance.mutateAsync({
params: {
appInstanceId,
},
body: {
appInstanceId,
displayName: normalizedValues.name,
description: normalizedValues.description,
},
})
})
export const submitEditDeploymentFormAtom = atom(null, async (get, set) => {
const response = await set(submitEditDeploymentInstanceAtom, get(editDeploymentFormValuesAtom))
return Boolean(response)
})
type DeleteDeploymentInstanceMutationCallbacks = Parameters<ExtractAtomValue<typeof deleteDeploymentInstanceMutationAtom>['mutate']>[1]
export const submitDeleteDeploymentInstanceAtom = atom(null, (get, _set, callbacks?: DeleteDeploymentInstanceMutationCallbacks) => {
const appInstanceId = get(deploymentActionAppInstanceIdAtom)
const deleteInstance = get(deleteDeploymentInstanceMutationAtom)
deleteInstance.mutate(
{
params: {
appInstanceId,
},
},
callbacks,
)
})
export const deploymentActionsLocalAtoms = [
editDeploymentDialogOpenAtom,
deleteDeploymentDialogOpenAtom,
] as const

View File

@ -1,10 +1,38 @@
import type { Getter } from 'jotai'
import { atom, createStore } from 'jotai'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
type QueryOptions = {
enabled?: boolean
input?: unknown
queryKey?: readonly unknown[]
retry?: boolean
}
type InfiniteQueryOptions = QueryOptions & {
input?: (pageParam: number) => unknown
}
type QueryResult = {
data?: unknown
hasNextPage?: boolean
isError?: boolean
isFetching?: boolean
isFetchingNextPage?: boolean
isLoading?: boolean
isPlaceholderData?: boolean
isSuccess?: boolean
}
const mockQueryResults = vi.hoisted(() => ({
current: new Map<string, QueryResult>(),
}))
vi.mock('jotai-tanstack-query', () => ({
atomWithInfiniteQuery: (createOptions: (get: Getter) => Record<string, unknown>) => atom((get) => {
atomWithInfiniteQuery: (createOptions: (get: Getter) => InfiniteQueryOptions) => atom((get) => {
const options = createOptions(get)
const queryName = String(options.queryKey?.[0] ?? 'unknown')
const queryResult = mockQueryResults.current.get(queryName)
return {
...options,
@ -14,33 +42,80 @@ vi.mock('jotai-tanstack-query', () => ({
isFetchingNextPage: false,
isLoading: false,
isPlaceholderData: false,
isSuccess: Boolean(queryResult?.data),
fetchNextPage: vi.fn(),
...queryResult,
}
}),
atomWithMutation: () => atom(() => ({
isPending: false,
mutateAsync: vi.fn(),
})),
atomWithQuery: (createOptions: (get: Getter) => Record<string, unknown>) => atom(get => ({
...createOptions(get),
data: undefined,
isError: false,
isFetching: false,
isLoading: false,
isSuccess: false,
})),
atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom((get) => {
const options = createOptions(get)
const queryName = String(options.queryKey?.[0] ?? 'unknown')
const queryResult = options.enabled === false
? undefined
: mockQueryResults.current.get(queryName)
return {
...options,
data: undefined,
isError: false,
isFetching: false,
isLoading: false,
isSuccess: false,
...queryResult,
}
}),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
apps: {
list: {
infiniteOptions: (options: Record<string, unknown>) => ({
infiniteOptions: (options: InfiniteQueryOptions) => ({
...options,
queryKey: ['apps', 'list'],
queryKey: ['sourceApps'],
}),
},
},
enterprise: {
appInstanceService: {
listAppInstances: {
infiniteOptions: (options: InfiniteQueryOptions) => ({
...options,
queryKey: ['existingInstanceNames'],
}),
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['instanceNameConflict'],
}),
},
},
environmentService: {
listEnvironments: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['environments'],
}),
},
},
releaseService: {
computeDeploymentOptions: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['deploymentOptions'],
}),
},
precheckRelease: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['precheckRelease'],
}),
},
},
},
},
}))
@ -49,6 +124,11 @@ async function loadState() {
}
describe('create deployment guide state', () => {
beforeEach(() => {
vi.clearAllMocks()
mockQueryResults.current.clear()
})
it('should keep the guide on source app mode when DSL import is disabled', async () => {
const state = await loadState()
const store = createStore()
@ -70,4 +150,69 @@ describe('create deployment guide state', () => {
expect(store.get(state.effectiveMethodAtom)).toBe('bindApp')
expect(sourceAppsQuery.enabled).toBe(true)
})
it('should continue from source app mode and auto-fill unique release metadata', async () => {
const state = await loadState()
const store = createStore()
mockQueryResults.current.set('sourceApps', {
data: {
pages: [
{
data: [
{
id: 'source-app-1',
name: 'Customer Service',
mode: 'workflow',
},
],
},
],
},
isSuccess: true,
})
mockQueryResults.current.set('precheckRelease', {
data: {
canCreate: true,
unsupportedNodes: [],
},
isSuccess: true,
})
mockQueryResults.current.set('deploymentOptions', {
data: {
options: {
credentialSlots: [],
envVarSlots: [],
},
},
isSuccess: true,
})
mockQueryResults.current.set('existingInstanceNames', {
data: {
pages: [
{
appInstances: [
{ displayName: 'Customer Service' },
{ displayName: 'Customer Service 1' },
],
},
],
},
isSuccess: true,
})
expect(store.get(state.sourceCanGoNextAtom)).toBe(true)
store.set(state.continueFromSourceAtom, {
defaultDslAppName: 'Imported DSL',
defaultReleaseName: 'Initial Release',
})
expect(store.get(state.selectedAppAtom)).toMatchObject({
id: 'source-app-1',
name: 'Customer Service',
})
expect(store.get(state.instanceNameAtom)).toMatch(/^Customer Service-[a-z]{4}$/)
expect(store.get(state.releaseNameAtom)).toBe('Initial Release')
expect(store.get(state.stepAtom)).toBe('release')
})
})

View File

@ -10,7 +10,7 @@ import type { RuntimeCredentialBindingSelections } from '@/features/deployments/
import type { UnsupportedDslNode } from '@/features/deployments/shared/domain/error'
import type { App } from '@/types/app'
import { EnvVarValueSource as ApiEnvVarValueSource } from '@dify/contracts/enterprise/types.gen'
import { keepPreviousData } from '@tanstack/react-query'
import { keepPreviousData, 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'
@ -193,22 +193,20 @@ export const isSubmittingDeploymentGuideAtom = atom(get => (
export const sourceAppsQueryAtom = atomWithInfiniteQuery((get) => {
const sourceSearchText = get(sourceSearchTextAtom)
return {
...consoleQuery.apps.list.infiniteOptions({
input: pageParam => ({
query: {
page: Number(pageParam),
limit: SOURCE_APPS_PAGE_SIZE,
name: sourceSearchText,
mode: AppModeEnum.WORKFLOW,
},
}),
getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined,
initialPageParam: 1,
placeholderData: keepPreviousData,
return consoleQuery.apps.list.infiniteOptions({
input: pageParam => ({
query: {
page: Number(pageParam),
limit: SOURCE_APPS_PAGE_SIZE,
name: sourceSearchText,
mode: AppModeEnum.WORKFLOW,
},
}),
getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined,
initialPageParam: 1,
placeholderData: keepPreviousData,
enabled: get(effectiveMethodAtom) === 'bindApp',
}
})
})
export const effectiveSelectedAppAtom = atom((get) => {
@ -233,8 +231,8 @@ function sourceReady(get: Getter) {
: Boolean(get(effectiveSelectedAppAtom)?.id)
}
const existingInstanceNamesQueryAtom = atomWithInfiniteQuery(() => ({
...consoleQuery.enterprise.appInstanceService.listAppInstances.infiniteOptions({
const existingInstanceNamesQueryAtom = atomWithInfiniteQuery(() =>
consoleQuery.enterprise.appInstanceService.listAppInstances.infiniteOptions({
input: pageParam => ({
query: {
pageNumber: Number(pageParam),
@ -243,9 +241,9 @@ const existingInstanceNamesQueryAtom = atomWithInfiniteQuery(() => ({
}),
getNextPageParam: lastPage => getNextPageParamFromPagination(lastPage.pagination),
initialPageParam: 1,
placeholderData: keepPreviousData,
}),
placeholderData: keepPreviousData,
}))
)
const instanceNameConflictQueryAtom = atomWithQuery((get) => {
const submittedInstanceName = get(instanceNameAtom).trim()
@ -280,31 +278,35 @@ const precheckReleaseQueryAtom = atomWithQuery((get) => {
const method = get(effectiveMethodAtom)
const effectiveSelectedApp = get(effectiveSelectedAppAtom)
const dslContent = get(dslContentAtom)
const encodedDslContent = dslContent.trim() ? encodeDslContent(dslContent) : undefined
const enabled = sourceReady(get)
// PrecheckRelease takes exactly one source arm (dsl | sourceAppId).
const precheckReleaseQueryOptions = method === 'importDsl'
? consoleQuery.enterprise.releaseService.precheckRelease.queryOptions({
input: {
body: {
dsl: dslContent.trim() ? encodeDslContent(dslContent) : '',
},
},
input: encodedDslContent
? {
body: {
dsl: encodedDslContent,
},
}
: skipToken,
enabled,
retry: false,
})
: consoleQuery.enterprise.releaseService.precheckRelease.queryOptions({
input: {
body: {
sourceAppId: effectiveSelectedApp?.id ?? '',
},
},
input: effectiveSelectedApp?.id
? {
body: {
sourceAppId: effectiveSelectedApp.id,
},
}
: skipToken,
enabled: enabled && Boolean(effectiveSelectedApp?.id),
retry: false,
})
return {
...precheckReleaseQueryOptions,
retry: false,
}
return precheckReleaseQueryOptions
})
function precheckReleaseReady(get: Getter) {
@ -321,32 +323,35 @@ export const deploymentOptionsQueryAtom = atomWithQuery((get) => {
const method = get(effectiveMethodAtom)
const effectiveSelectedApp = get(effectiveSelectedAppAtom)
const dslContent = get(dslContentAtom)
const encodedDslContent = dslContent.trim() ? encodeDslContent(dslContent) : undefined
const enabled = precheckReleaseReady(get)
// ComputeDeploymentOptions takes exactly one source arm (dsl | sourceAppId | releaseId).
const deploymentOptionsQueryOptions = method === 'importDsl'
? consoleQuery.enterprise.releaseService.computeDeploymentOptions.queryOptions({
input: {
body: {
dsl: dslContent.trim() ? encodeDslContent(dslContent) : '',
},
},
input: encodedDslContent
? {
body: {
dsl: encodedDslContent,
},
}
: skipToken,
enabled,
retry: false,
})
: consoleQuery.enterprise.releaseService.computeDeploymentOptions.queryOptions({
input: {
body: {
sourceAppId: effectiveSelectedApp?.id ?? '',
},
},
input: effectiveSelectedApp?.id
? {
body: {
sourceAppId: effectiveSelectedApp.id,
},
}
: skipToken,
enabled: enabled && Boolean(effectiveSelectedApp?.id),
retry: false,
})
// oRPC encodes input before TanStack can skip work, so keep a valid input shape and gate requests with enabled.
return {
...deploymentOptionsQueryOptions,
retry: false,
}
return deploymentOptionsQueryOptions
})
// Unsupported DSL state

View File

@ -59,7 +59,7 @@ function CreateReleaseScopedControl({
>
{label ?? t('versions.createRelease')}
</DialogTrigger>
{open && <CreateReleaseDialogContent />}
<CreateReleaseDialogContent />
</Dialog>
)
}

View File

@ -0,0 +1,245 @@
import type { Getter } from 'jotai'
import { QueryClient } from '@tanstack/react-query'
import { atom, createStore } from 'jotai'
import { queryClientAtom } from 'jotai-tanstack-query'
import { beforeEach, describe, expect, it, vi } from 'vitest'
type QueryResult = {
data?: unknown
isError?: boolean
isFetching?: boolean
isLoading?: boolean
isSuccess?: boolean
}
type QueryOptions = {
enabled?: boolean
input?: unknown
queryFn?: () => unknown
queryKey?: readonly unknown[]
retry?: boolean
}
type InfiniteQueryOptions = QueryOptions & {
input?: (pageParam: number) => unknown
}
type MutationResult = {
isPending: boolean
mutateAsync: ReturnType<typeof vi.fn>
}
const mockQueryResults = vi.hoisted(() => ({
current: new Map<string, QueryResult>(),
}))
const mockCreateReleaseMutation = vi.hoisted<{ current: MutationResult }>(() => ({
current: {
isPending: false,
mutateAsync: vi.fn(),
},
}))
vi.mock('../../../shared/domain/feature-flags', () => ({
isDeploymentDslImportEnabled: true,
}))
vi.mock('jotai-tanstack-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('jotai-tanstack-query')>()
return {
...actual,
atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom((get) => {
const options = createOptions(get)
const queryKey = Array.isArray(options.queryKey) ? options.queryKey[0] : undefined
const queryName = typeof queryKey === 'string' ? queryKey : 'unknown'
const queryResult = options.enabled === false
? undefined
: mockQueryResults.current.get(queryName)
return {
...options,
data: undefined,
isError: false,
isFetching: false,
isLoading: false,
isSuccess: false,
...queryResult,
}
}),
atomWithInfiniteQuery: (createOptions: (get: Getter) => InfiniteQueryOptions) => atom((get) => {
const options = createOptions(get)
const queryKey = Array.isArray(options.queryKey) ? options.queryKey[0] : undefined
const queryName = typeof queryKey === 'string' ? queryKey : 'unknown'
const queryResult = options.enabled === false
? undefined
: mockQueryResults.current.get(queryName)
return {
...options,
data: undefined,
hasNextPage: false,
isError: false,
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
isPlaceholderData: false,
isSuccess: false,
...queryResult,
}
}),
atomWithMutation: () => atom(() => mockCreateReleaseMutation.current),
}
})
vi.mock('@/service/client', () => ({
consoleQuery: {
apps: {
list: {
infiniteOptions: (options: InfiniteQueryOptions) => ({
...options,
queryKey: ['sourceApps', options.input],
}),
},
},
enterprise: {
releaseService: {
listReleaseSummaries: {
key: ({ input }: { input?: unknown } = {}) => input === undefined ? ['listReleaseSummaries'] : ['listReleaseSummaries', input],
},
listReleases: {
key: ({ input }: { input?: unknown } = {}) => input === undefined ? ['listReleases'] : ['listReleases', input],
},
precheckRelease: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['precheckRelease', options.input],
}),
},
createRelease: {
mutationOptions: () => ({ mutationKey: ['createRelease'] }),
},
},
},
},
}))
async function loadState() {
return await import('../index')
}
async function mountedStore() {
const state = await loadState()
const store = createStore()
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
store.set(queryClientAtom, queryClient)
const unsubscribe = store.sub(state.createReleaseFormValuesAtom, () => undefined)
return {
state,
store,
unsubscribe,
}
}
function workflowDsl() {
return [
'app:',
' mode: workflow',
' name: Release source',
].join('\n')
}
function setDslFileContentResult(overrides: QueryResult = {}) {
mockQueryResults.current.set('createReleaseDslFileContent', {
data: workflowDsl(),
isSuccess: true,
...overrides,
})
}
function setPrecheckReleaseResult(overrides: QueryResult = {}) {
mockQueryResults.current.set('precheckRelease', {
data: {
gateCommitId: 'gate-commit-1',
canCreate: true,
unsupportedNodes: [],
},
isSuccess: true,
...overrides,
})
}
describe('create release state with DSL import enabled', () => {
beforeEach(() => {
vi.clearAllMocks()
mockQueryResults.current.clear()
mockCreateReleaseMutation.current = {
isPending: false,
mutateAsync: vi.fn(),
}
})
it('should enable source app search with the current search text while creating from an app', async () => {
const { state, store, unsubscribe } = await mountedStore()
store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1')
store.set(state.openCreateReleaseDialogAtom)
store.set(state.createReleaseSourceAppSearchTextAtom, 'customer')
const sourceAppsQuery = store.get(state.createReleaseSourceAppsQueryAtom) as unknown as InfiniteQueryOptions
expect(sourceAppsQuery.enabled).toBe(true)
expect(sourceAppsQuery.input?.(2)).toEqual({
query: {
page: 2,
limit: 20,
name: 'customer',
mode: 'workflow',
},
})
unsubscribe()
})
it('should submit a DSL release with encoded workflow content', async () => {
const { state, store, unsubscribe } = await mountedStore()
const response = {
release: {
displayName: 'Release from DSL',
},
}
const file = new File([workflowDsl()], 'workflow.yml', { type: 'text/yaml' })
mockCreateReleaseMutation.current.mutateAsync.mockResolvedValue(response)
store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1')
store.set(state.openCreateReleaseDialogAtom)
store.set(state.selectCreateReleaseSourceModeAtom, 'dsl')
store.set(state.updateCreateReleaseDslFileAtom, file)
setDslFileContentResult()
setPrecheckReleaseResult()
store.set(state.createReleaseNameFieldAtom, ' Release from DSL ')
store.set(state.createReleaseDescriptionFieldAtom, ' Imported workflow ')
const result = await store.set(state.submitCreateReleaseFormAtom)
const encodedDslContent = store.get(state.createReleaseEncodedDslContentAtom)
expect(result).toBe(response)
expect(encodedDslContent).not.toBe('')
expect(mockCreateReleaseMutation.current.mutateAsync).toHaveBeenCalledWith({
body: {
appInstanceId: 'app-instance-1',
displayName: 'Release from DSL',
description: 'Imported workflow',
createAppInstance: false,
dsl: encodedDslContent,
},
})
unsubscribe()
})
})

View File

@ -8,12 +8,14 @@ import type {
import type { Getter } from 'jotai/vanilla'
import type { UnsupportedDslNode } from '../../shared/domain/error'
import type { App } from '@/types/app'
import { keepPreviousData, queryOptions, skipToken } from '@tanstack/react-query'
import { atom } from 'jotai'
import {
atomWithForm,
createFormAtoms,
} from 'jotai-tanstack-form'
import {
atomWithInfiniteQuery,
atomWithMutation,
atomWithQuery,
queryClientAtom,
@ -47,6 +49,7 @@ const DEFAULT_CREATE_RELEASE_FORM_VALUES: CreateReleaseFormValues = {
export const RELEASE_NAME_REQUIRED_ERROR = 'releaseNameRequired'
const DEFAULT_SOURCE_RELEASE_PAGE_SIZE = 1
const CREATE_RELEASE_SOURCE_APP_PAGE_SIZE = 20
function deploymentReleaseSourceMode(mode: ReleaseSourceMode): ReleaseSourceMode {
return mode === 'dsl' && !isDeploymentDslImportEnabled
@ -129,13 +132,15 @@ const latestSourceReleaseQueryAtom = atomWithQuery((get) => {
const appInstanceId = get(createReleaseAppInstanceIdAtom)
return consoleQuery.enterprise.releaseService.listReleases.queryOptions({
input: {
params: { appInstanceId: appInstanceId ?? '' },
query: {
pageNumber: 1,
resultsPerPage: DEFAULT_SOURCE_RELEASE_PAGE_SIZE,
},
},
input: appInstanceId
? {
params: { appInstanceId },
query: {
pageNumber: 1,
resultsPerPage: DEFAULT_SOURCE_RELEASE_PAGE_SIZE,
},
}
: skipToken,
enabled: Boolean(appInstanceId && get(createReleaseDialogOpenAtom)),
})
})
@ -150,9 +155,11 @@ const defaultSourceAppQueryAtom = atomWithQuery((get) => {
const latestSourceAppId = latestReleaseSourceAppId(get)
return consoleQuery.apps.byAppId.get.queryOptions({
input: {
params: { app_id: latestSourceAppId ?? '' },
},
input: latestSourceAppId
? {
params: { app_id: latestSourceAppId },
}
: skipToken,
enabled: Boolean(get(createReleaseDialogOpenAtom) && latestSourceAppId),
})
})
@ -210,7 +217,7 @@ const createReleaseDslFileContentQueryAtom = atomWithQuery((get) => {
const file = get(createReleaseDslFileFieldAtom).value
const fileReadVersion = get(createReleaseDslFileReadVersionAtom)
return {
return queryOptions({
queryKey: [
'createReleaseDslFileContent',
fileReadVersion,
@ -222,7 +229,7 @@ const createReleaseDslFileContentQueryAtom = atomWithQuery((get) => {
queryFn: async () => file ? await file.text() : '',
enabled: Boolean(file),
retry: false,
}
})
})
// Source derived state
@ -234,6 +241,31 @@ export const createReleaseSourceModeAtom = atom((get) => {
return effectiveCreateReleaseSourceMode(get)
})
export const createReleaseSourceAppSearchTextAtom = atom('')
export const createReleaseSourceAppsQueryAtom = atomWithInfiniteQuery((get) => {
const searchText = get(createReleaseSourceAppSearchTextAtom)
return consoleQuery.apps.list.infiniteOptions({
input: pageParam => ({
query: {
page: Number(pageParam),
limit: CREATE_RELEASE_SOURCE_APP_PAGE_SIZE,
name: searchText,
mode: AppModeEnum.WORKFLOW,
},
}),
getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined,
initialPageParam: 1,
placeholderData: keepPreviousData,
enabled: Boolean(
get(createReleaseDialogOpenAtom)
&& effectiveCreateReleaseSourceMode(get) === 'sourceApp'
&& isDeploymentDslImportEnabled,
),
})
})
export const createReleaseDslContentAtom = atom((get) => {
return get(createReleaseDslFileContentQueryAtom).data ?? ''
})
@ -329,20 +361,27 @@ const precheckReleaseQueryAtom = atomWithQuery((get) => {
const sourceAppId = selectedSourceAppId(get)
const canCheck = canCheckReleaseContent(get)
return {
...consoleQuery.enterprise.releaseService.precheckRelease.queryOptions({
input: {
body: {
appInstanceId: appInstanceId ?? '',
...(releaseSourceMode === 'dsl'
? { dsl: get(createReleaseEncodedDslContentAtom) }
: { sourceAppId: sourceAppId ?? '' }),
},
},
enabled: canCheck,
}),
return consoleQuery.enterprise.releaseService.precheckRelease.queryOptions({
input: appInstanceId
? releaseSourceMode === 'dsl'
? {
body: {
appInstanceId,
dsl: get(createReleaseEncodedDslContentAtom),
},
}
: sourceAppId
? {
body: {
appInstanceId,
sourceAppId,
},
}
: skipToken
: skipToken,
enabled: canCheck,
retry: false,
}
})
})
export const isCheckingCreateReleaseContentAtom = atom((get) => {
@ -507,4 +546,5 @@ export const submitCreateReleaseFormAtom = atom(null, (get, set) => {
export const createReleaseLocalAtoms = [
createReleaseDialogOpenAtom,
createReleaseDslFileReadVersionAtom,
createReleaseSourceAppSearchTextAtom,
] as const

View File

@ -14,16 +14,17 @@ import {
ComboboxList,
ComboboxTrigger,
} from '@langgenius/dify-ui/combobox'
import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query'
import { useAtomValue, useSetAtom } from 'jotai'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { AppModeEnum } from '@/types/app'
import { TitleTooltip } from '../../components/title-tooltip'
import {
createReleaseSourceAppSearchTextAtom,
createReleaseSourceAppsQueryAtom,
} from '../state'
const SOURCE_APP_PAGE_SIZE = 20
const SOURCE_APP_PICKER_SKELETON_KEYS = ['first-source-app', 'second-source-app', 'third-source-app']
function sourceAppSearchText(app: App) {
@ -131,30 +132,15 @@ export function SourceAppPicker({ value, onChange, disabled = false }: {
}) {
const { t } = useTranslation('deployments')
const [isShow, setIsShow] = useState(false)
const [searchText, setSearchText] = useState('')
const searchText = useAtomValue(createReleaseSourceAppSearchTextAtom)
const setSearchText = useSetAtom(createReleaseSourceAppSearchTextAtom)
const {
data,
isLoading,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
} = useInfiniteQuery({
...consoleQuery.apps.list.infiniteOptions({
input: pageParam => ({
query: {
page: Number(pageParam),
limit: SOURCE_APP_PAGE_SIZE,
name: searchText,
mode: AppModeEnum.WORKFLOW,
},
}),
getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined,
initialPageParam: 1,
placeholderData: keepPreviousData,
}),
enabled: !disabled,
})
} = useAtomValue(createReleaseSourceAppsQueryAtom)
const apps = data?.pages.flatMap(page => page.data) ?? []

View File

@ -0,0 +1,36 @@
import { render, screen } from '@testing-library/react'
import { createStore, Provider as JotaiProvider } from 'jotai'
import { describe, expect, it, vi } from 'vitest'
import { DeployDrawer } from '../index'
import {
deployDrawerAppInstanceIdAtom,
deployDrawerOpenAtom,
} from '../state'
vi.mock('../ui/form', () => ({
DeployForm: () => <div data-testid="deploy-form" />,
}))
describe('DeployDrawer', () => {
it('uses the full-height right drawer layout', () => {
const store = createStore()
store.set(deployDrawerOpenAtom, true)
store.set(deployDrawerAppInstanceIdAtom, 'app-instance-1')
render(
<JotaiProvider store={store}>
<DeployDrawer />
</JotaiProvider>,
)
const drawer = screen.getByRole('dialog')
expect(screen.getByTestId('deploy-form')).toBeInTheDocument()
expect(drawer).toHaveClass('data-[swipe-direction=right]:w-[640px]')
expect(drawer).not.toHaveClass('data-[swipe-direction=right]:top-2')
expect(drawer).not.toHaveClass('data-[swipe-direction=right]:right-2')
expect(drawer).not.toHaveClass('data-[swipe-direction=right]:bottom-2')
expect(drawer).not.toHaveClass('data-[swipe-direction=right]:h-auto')
expect(drawer).not.toHaveClass('data-[swipe-direction=right]:rounded-xl')
})
})

View File

@ -39,7 +39,7 @@ export function DeployDrawer() {
<DrawerPortal>
<DrawerBackdrop />
<DrawerViewport>
<DrawerPopup className="data-[swipe-direction=right]:top-16 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-[640px] data-[swipe-direction=right]:max-w-[calc(100vw-1rem)] data-[swipe-direction=right]:rounded-xl data-[swipe-direction=right]:border-[0.5px]">
<DrawerPopup className="data-[swipe-direction=right]:w-[640px] data-[swipe-direction=right]:max-w-[calc(100vw-1rem)]">
<DrawerCloseButton
aria-label={t('deployDrawer.close')}
className="absolute top-4 right-5 size-6 rounded-md"

View File

@ -15,6 +15,7 @@ import type {
import type { RuntimeCredentialBindingSelections } from '../../components/runtime-credential-bindings-utils'
import { EnvVarValueSource as ApiEnvVarValueSource } from '@dify/contracts/enterprise/types.gen'
import { toast } from '@langgenius/dify-ui/toast'
import { skipToken } from '@tanstack/react-query'
import { atom } from 'jotai'
import { atomWithMutation, atomWithQuery } from 'jotai-tanstack-query'
import { consoleQuery } from '@/service/client'
@ -38,6 +39,7 @@ export const deployDrawerOpenAtom = atom(false)
export const deployDrawerAppInstanceIdAtom = atom<string | undefined>(undefined)
export const deployDrawerEnvironmentIdAtom = atom<string | undefined>(undefined)
export const deployDrawerReleaseIdAtom = atom<string | undefined>(undefined)
export const deployFormAppInstanceIdAtom = atom('')
export const openDeployDrawerAtom = atom(null, (_get, set, params: OpenDeployDrawerParams) => {
set(deployDrawerAppInstanceIdAtom, params.appInstanceId)
@ -66,6 +68,17 @@ export type DeployReadyFormConfig = {
export const deployReadyFormConfigAtom = atom<DeployReadyFormConfig | undefined>(undefined)
export const releaseDeploymentViewQueryAtom = atomWithQuery((get) => {
const appInstanceId = get(deployFormAppInstanceIdAtom)
return consoleQuery.enterprise.releaseService.computeReleaseDeploymentView.queryOptions({
input: {
params: { appInstanceId },
},
enabled: Boolean(appInstanceId),
})
})
const selectedEnvIdAtom = atom<string | undefined>(undefined)
const selectedReleaseIdAtom = atom<string | undefined>(undefined)
const manualBindingsAtom = atom<RuntimeCredentialBindingSelections>({})
@ -192,20 +205,20 @@ const releaseDeploymentOptionsQueryAtom = atomWithQuery((get) => {
const hasSelectedEnvironment = get(deployHasSelectedEnvironmentAtom)
const releaseId = get(deployTargetReleaseIdAtom)
const selectedEnvironmentId = get(deploySelectedEnvironmentIdAtom)
const enabled = Boolean(releaseId && selectedEnvironmentId && hasSelectedEnvironment)
const hasRequiredInput = Boolean(releaseId && selectedEnvironmentId)
return {
...consoleQuery.enterprise.releaseService.computeDeploymentOptions.queryOptions({
input: {
body: {
releaseId: releaseId ?? '',
environmentId: selectedEnvironmentId ?? '',
},
},
enabled,
}),
return consoleQuery.enterprise.releaseService.computeDeploymentOptions.queryOptions({
input: releaseId && selectedEnvironmentId
? {
body: {
releaseId,
environmentId: selectedEnvironmentId,
},
}
: skipToken,
enabled: hasRequiredInput && hasSelectedEnvironment,
retry: false,
}
})
})
export const deployBindingSlotsAtom = atom((get) => {

View File

@ -6,14 +6,12 @@ import type {
Release,
} from '@dify/contracts/enterprise/types.gen'
import { Button } from '@langgenius/dify-ui/button'
import { useQuery } from '@tanstack/react-query'
import { useAtomValue, useSetAtom } from 'jotai'
import { ScopeProvider } from 'jotai-scope'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { EnvVarBindingsPanel } from '../../components/env-var-bindings'
import { isAvailableDeploymentTarget } from '../../shared/domain/runtime-status'
import { canAttemptDeployAtom, canSubmitDeployAtom, closeDeployDrawerAtom, deployBindingSlotsAtom, deployEnvVarSlotsAtom, deployEnvVarValuesAtom, deployHasBindingOptionsErrorAtom, deployHasSelectedEnvironmentAtom, deployIsBindingOptionsLoadingAtom, deployReadyFormConfigAtom, deployReadyFormLocalAtoms, deployReleaseSubmissionAtom, deploySelectedBindingsAtom, deployShowValidationErrorsAtom, deployTargetReleaseIdAtom, isDeployReleaseSubmittingAtom, selectDeployBindingAtom, setDeployEnvVarAtom, showDeployValidationErrorsAtom } from '../state'
import { canAttemptDeployAtom, canSubmitDeployAtom, closeDeployDrawerAtom, deployBindingSlotsAtom, deployEnvVarSlotsAtom, deployEnvVarValuesAtom, deployFormAppInstanceIdAtom, deployHasBindingOptionsErrorAtom, deployHasSelectedEnvironmentAtom, deployIsBindingOptionsLoadingAtom, deployReadyFormConfigAtom, deployReadyFormLocalAtoms, deployReleaseSubmissionAtom, deploySelectedBindingsAtom, deployShowValidationErrorsAtom, deployTargetReleaseIdAtom, isDeployReleaseSubmittingAtom, releaseDeploymentViewQueryAtom, selectDeployBindingAtom, setDeployEnvVarAtom, showDeployValidationErrorsAtom } from '../state'
import {
currentReleaseIdForEnvironment,
selectableDeployReleases,
@ -199,17 +197,13 @@ function DeployReadyForm(config: DeployReadyFormProps) {
)
}
export function DeployForm({
function DeployFormContent({
appInstanceId,
lockedEnvId,
presetReleaseId,
}: DeployFormProps) {
const { t } = useTranslation('deployments')
const releaseDeploymentViewQuery = useQuery(consoleQuery.enterprise.releaseService.computeReleaseDeploymentView.queryOptions({
input: {
params: { appInstanceId },
},
}))
const releaseDeploymentViewQuery = useAtomValue(releaseDeploymentViewQueryAtom)
if (releaseDeploymentViewQuery.isLoading) {
return <DeployFormSkeleton />
@ -267,3 +261,17 @@ export function DeployForm({
/>
)
}
export function DeployForm(props: DeployFormProps) {
return (
<ScopeProvider
key={props.appInstanceId}
atoms={[
[deployFormAppInstanceIdAtom, props.appInstanceId],
]}
name="DeployForm"
>
<DeployFormContent {...props} />
</ScopeProvider>
)
}

View File

@ -0,0 +1,119 @@
import type { Getter } from 'jotai'
import { skipToken } from '@tanstack/react-query'
import { atom, createStore } from 'jotai'
import { describe, expect, it, vi } from 'vitest'
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
type QueryOptions = {
enabled?: boolean
input?: unknown
queryKey?: readonly unknown[]
refetchInterval?: (query: { state: { data?: unknown } }) => number | false
}
vi.mock('jotai-tanstack-query', () => ({
atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom(get => ({
...createOptions(get),
data: undefined,
isError: false,
isFetching: false,
isLoading: false,
isSuccess: false,
})),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
apps: {
byAppId: {
get: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['appById', options.input],
}),
},
},
},
enterprise: {
appInstanceService: {
getAppInstance: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['getAppInstance', options.input],
}),
},
getAppInstanceOverview: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['getAppInstanceOverview', options.input],
}),
},
},
deploymentService: {
listEnvironmentDeployments: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['listEnvironmentDeployments', options.input],
}),
},
},
},
},
}))
async function loadState() {
return await import('../state')
}
describe('deployment detail state', () => {
it('should disable detail queries with skipToken until a route app instance exists', async () => {
const state = await loadState()
const store = createStore()
expect(store.get(state.deploymentDetailAppInstanceQueryAtom)).toMatchObject({
enabled: false,
input: skipToken,
})
expect(store.get(state.deploymentDetailOverviewQueryAtom)).toMatchObject({
enabled: false,
input: skipToken,
})
expect(store.get(state.deploymentEnvironmentDeploymentsQueryAtom)).toMatchObject({
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 () => {
const state = await loadState()
const store = createStore()
store.set(deploymentRouteAppInstanceIdAtom, 'app-instance-1')
store.set(state.deploymentSourceAppIdAtom, 'source-app-1')
expect(store.get(state.deploymentDetailAppInstanceQueryAtom)).toMatchObject({
enabled: true,
input: { params: { appInstanceId: 'app-instance-1' } },
})
expect(store.get(state.deploymentDetailOverviewQueryAtom)).toMatchObject({
enabled: true,
input: { params: { appInstanceId: 'app-instance-1' } },
})
const environmentDeploymentsQuery = store.get(state.deploymentEnvironmentDeploymentsQueryAtom) as unknown as QueryOptions
expect(environmentDeploymentsQuery).toMatchObject({
enabled: true,
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' } },
})
})
})

View File

@ -1,18 +1,14 @@
'use client'
import { useQuery } from '@tanstack/react-query'
import { consoleQuery } from '@/service/client'
import { useAtomValue } from 'jotai'
import { AccessChannelsSection } from './settings-tab/access/channels-section'
import { AccessPermissionsSection } from './settings-tab/access/permissions-section'
import { accessSettingsQueryAtom } from './settings-tab/access/state'
export function AccessTab({ appInstanceId }: {
appInstanceId: string
}) {
const accessSettingsQuery = useQuery(consoleQuery.enterprise.accessService.getAccessSettings.queryOptions({
input: {
params: { appInstanceId },
},
}))
const accessSettingsQuery = useAtomValue(accessSettingsQueryAtom)
return (
<div className="flex w-full max-w-[960px] min-w-0 flex-col gap-y-4 px-6 py-6 sm:px-20 sm:py-8">

View File

@ -1,12 +1,12 @@
'use client'
import { useQuery } 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, DeploymentStateMessage } from '../components/empty-state'
import { deploymentStatusPollingInterval, hasRuntimeInstanceDeployment } from '../shared/domain/runtime-status'
import { hasRuntimeInstanceDeployment } from '../shared/domain/runtime-status'
import { DeploymentEnvironmentList } from './deploy-tab/deployment-environment-list'
import { NewDeploymentButton } from './deploy-tab/new-deployment-button'
import { deploymentEnvironmentDeploymentsQueryAtom } from './state'
import {
DetailTable,
DetailTableBody,
@ -91,12 +91,7 @@ export function DeployTab({ appInstanceId }: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const environmentDeploymentsQuery = useQuery(consoleQuery.enterprise.deploymentService.listEnvironmentDeployments.queryOptions({
input: {
params: { appInstanceId },
},
refetchInterval: query => deploymentStatusPollingInterval(query.state.data?.environmentDeployments),
}))
const environmentDeploymentsQuery = useAtomValue(deploymentEnvironmentDeploymentsQueryAtom)
const environmentDeployments = environmentDeploymentsQuery.data
const rows = environmentDeployments?.environmentDeployments.filter(hasRuntimeInstanceDeployment) ?? []
const isLoading = environmentDeploymentsQuery.isLoading

View File

@ -22,12 +22,12 @@ export function DeploymentRowActions({ appInstanceId, envId, row }: {
const { t } = useTranslation('deployments')
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
const undeployDeployment = useMutation(consoleQuery.enterprise.deploymentService.undeploy.mutationOptions())
const isUndeployed = isUndeployedDeploymentRow(row)
const status = row.status
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 undeployActionDisabled = isUndeployRequesting
const isDeploymentInProgress = isRuntimeDeploymentInProgress(status)
@ -45,6 +45,7 @@ export function DeploymentRowActions({ appInstanceId, envId, row }: {
function handleUndeploy() {
if (undeployInFlightRef.current)
return
undeployInFlightRef.current = true
setIsUndeploying(true)
undeployDeployment.mutate(

View File

@ -1,12 +1,11 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { useQuery } from '@tanstack/react-query'
import { useSetAtom } from 'jotai'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { openDeployDrawerAtom } from '../../deploy-drawer/state'
import { deploymentStatusPollingInterval, hasRuntimeInstanceDeployment } from '../../shared/domain/runtime-status'
import { hasRuntimeInstanceDeployment } from '../../shared/domain/runtime-status'
import { deploymentEnvironmentDeploymentsQueryAtom } from '../state'
export function NewDeploymentButton({ appInstanceId }: {
appInstanceId: string
@ -30,12 +29,7 @@ export function NewDeploymentButton({ appInstanceId }: {
export function NewDeploymentHeaderAction({ appInstanceId }: {
appInstanceId: string
}) {
const environmentDeploymentsQuery = useQuery(consoleQuery.enterprise.deploymentService.listEnvironmentDeployments.queryOptions({
input: {
params: { appInstanceId },
},
refetchInterval: query => deploymentStatusPollingInterval(query.state.data?.environmentDeployments),
}))
const environmentDeploymentsQuery = useAtomValue(deploymentEnvironmentDeploymentsQueryAtom)
const rows = environmentDeploymentsQuery.data?.environmentDeployments.filter(hasRuntimeInstanceDeployment) ?? []
if (environmentDeploymentsQuery.isLoading || environmentDeploymentsQuery.isError || rows.length === 0)

View File

@ -7,7 +7,7 @@ import { cn } from '@langgenius/dify-ui/cn'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { formatForDisplay } from '@tanstack/react-hotkeys'
import { useQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
import NavLink from '@/app/components/app-sidebar/nav-link'
import ToggleButton from '@/app/components/app-sidebar/toggle-button'
@ -17,9 +17,10 @@ import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skel
import { useSetGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
import Link from '@/next/link'
import { usePathname, useRouter } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { DeploymentActionsMenu } from '../components/deployment-actions'
import { TitleTooltip } from '../components/title-tooltip'
import { deploymentRouteAppInstanceIdAtom } from '../route-state'
import { deploymentDetailAppInstanceQueryAtom } from './state'
type TabDef = {
key: InstanceDetailTabKey
@ -73,11 +74,6 @@ const DEPLOYMENT_TABS: TabDef[] = [
const SEARCH_SHORTCUT = ['Mod', 'K']
const getDeploymentIdFromPathname = (pathname: string) => {
const [section, appInstanceId] = pathname.split('/').filter(Boolean)
return section === 'deployments' ? appInstanceId : undefined
}
function DeploymentIcon({ expand }: {
expand: boolean
}) {
@ -98,11 +94,7 @@ function DeploymentDetailInstanceInfo({ appInstanceId, expand }: {
expand: boolean
}) {
const { t } = useTranslation('deployments')
const overviewQuery = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstance.queryOptions({
input: {
params: { appInstanceId },
},
}))
const overviewQuery = useAtomValue(deploymentDetailAppInstanceQueryAtom)
const app = overviewQuery.data?.appInstance
const isLoading = !app && overviewQuery.isLoading
const isUnavailable = !app || overviewQuery.isError
@ -173,7 +165,6 @@ function DeploymentDetailInstanceInfo({ appInstanceId, expand }: {
</div>
<DeploymentActionsMenu
appInstanceId={appInstanceId}
appName={instanceName}
placement="bottom-end"
sideOffset={4}
className="shrink-0"
@ -284,7 +275,7 @@ export function DeploymentDetailSection({
}) {
const { t } = useTranslation('deployments')
const pathname = usePathname()
const appInstanceId = getDeploymentIdFromPathname(pathname)
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
if (!appInstanceId)
return null

View File

@ -2,6 +2,7 @@
import type { ReactNode } from 'react'
import type { InstanceDetailTabKey } from './tabs'
import { ScopeProvider } from 'jotai-scope'
import { useTranslation } from 'react-i18next'
import useDocumentTitle from '@/hooks/use-document-title'
import Link from '@/next/link'
@ -10,6 +11,7 @@ import { CreateReleaseControl } from '../create-release'
import { NewDeploymentHeaderAction } from './deploy-tab/new-deployment-button'
import { DeveloperApiHeaderSwitch } from './settings-tab/access/developer-api-section'
import { INSTANCE_DETAIL_TAB_KEYS, isInstanceDetailTabKey } from './tabs'
import { versionsTabLocalAtoms } from './versions-tab/state'
function MobileDetailTabs({ appInstanceId, activeTab }: {
appInstanceId: string
@ -53,37 +55,45 @@ export function InstanceDetail({ appInstanceId, children }: {
useDocumentTitle(t('documentTitle.detail'))
return (
<div className="relative m-1 ml-0 flex min-h-0 flex-1 overflow-hidden rounded-lg shadow-xs">
<div className="min-w-0 grow overflow-hidden bg-components-panel-bg">
<div className="h-full min-w-0 overflow-y-auto">
<div className="flex min-h-full w-full flex-col">
<div className="flex w-full flex-col gap-y-0.5 px-4 pt-3 pb-2 sm:px-6">
<div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2">
<div className="system-xl-semibold text-text-primary">{t(`tabs.${activeTab}.name`)}</div>
{activeTab === 'api-tokens' && (
<div className="shrink-0">
<DeveloperApiHeaderSwitch appInstanceId={appInstanceId} />
</div>
)}
<ScopeProvider
key={appInstanceId}
atoms={[
...versionsTabLocalAtoms,
]}
name="DeploymentDetail"
>
<div className="relative m-1 ml-0 flex min-h-0 flex-1 overflow-hidden rounded-lg shadow-xs">
<div className="min-w-0 grow overflow-hidden bg-components-panel-bg">
<div className="h-full min-w-0 overflow-y-auto">
<div className="flex min-h-full w-full flex-col">
<div className="flex w-full flex-col gap-y-0.5 px-4 pt-3 pb-2 sm:px-6">
<div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2">
<div className="system-xl-semibold text-text-primary">{t(`tabs.${activeTab}.name`)}</div>
{activeTab === 'api-tokens' && (
<div className="shrink-0">
<DeveloperApiHeaderSwitch appInstanceId={appInstanceId} />
</div>
)}
</div>
<div className="system-sm-regular text-text-tertiary">{t(`tabs.${activeTab}.description`)}</div>
</div>
<div className="system-sm-regular text-text-tertiary">{t(`tabs.${activeTab}.description`)}</div>
{(activeTab === 'instances' || activeTab === 'releases') && (
<div className="w-full shrink-0 pt-1 sm:w-auto sm:pt-1.5 [&_button]:w-full sm:[&_button]:w-auto">
{activeTab === 'instances'
? <NewDeploymentHeaderAction appInstanceId={appInstanceId} />
: <CreateReleaseControl appInstanceId={appInstanceId} size="medium" />}
</div>
)}
</div>
{(activeTab === 'instances' || activeTab === 'releases') && (
<div className="w-full shrink-0 pt-1 sm:w-auto sm:pt-1.5 [&_button]:w-full sm:[&_button]:w-auto">
{activeTab === 'instances'
? <NewDeploymentHeaderAction appInstanceId={appInstanceId} />
: <CreateReleaseControl appInstanceId={appInstanceId} size="medium" />}
</div>
)}
</div>
<MobileDetailTabs appInstanceId={appInstanceId} activeTab={activeTab} />
{children}
</div>
<MobileDetailTabs appInstanceId={appInstanceId} activeTab={activeTab} />
{children}
</div>
</div>
</div>
</div>
</ScopeProvider>
)
}

View File

@ -1,14 +1,14 @@
'use client'
import { useQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
import Link from '@/next/link'
import { consoleQuery } from '@/service/client'
import { DeploymentStateMessage } from '../components/empty-state'
import { hasRuntimeInstanceDeployment } from '../shared/domain/runtime-status'
import { AccessStatusSection, AccessStatusSectionSkeleton, ApiTokenSummarySection, ApiTokenSummarySectionSkeleton } from './overview-tab/access-status-section'
import { EnvironmentStrip, EnvironmentStripSkeleton } from './overview-tab/environment-strip'
import { ReleaseHero, ReleaseHeroSkeleton } from './overview-tab/release-hero'
import { deploymentDetailOverviewQueryAtom } from './state'
function OverviewLayout({ children }: { children: React.ReactNode }) {
return (
@ -64,8 +64,7 @@ export function OverviewTab({ appInstanceId }: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const input = { params: { appInstanceId } }
const overviewQuery = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstanceOverview.queryOptions({ input }))
const overviewQuery = useAtomValue(deploymentDetailOverviewQueryAtom)
const overview = overviewQuery.data
if (overviewQuery.isLoading)

View File

@ -4,16 +4,20 @@ import type { Release } from '@dify/contracts/enterprise/types.gen'
import type { ReactNode } from 'react'
import { ReleaseSource } from '@dify/contracts/enterprise/types.gen'
import { cn } from '@langgenius/dify-ui/cn'
import { useQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { ScopeProvider } from 'jotai-scope'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import Link from '@/next/link'
import { consoleQuery } from '@/service/client'
import { DeploymentEmptyState } from '../../components/empty-state'
import { TitleTooltip } from '../../components/title-tooltip'
import { CreateReleaseControl } from '../../create-release'
import { formatDate, releaseCommit } from '../../shared/domain/release'
import {
deploymentSourceAppIdAtom,
deploymentSourceAppQueryAtom,
} from '../state'
import { OVERVIEW_CARD_CLASS_NAME, OVERVIEW_ICON_CLASS_NAME } from './card-styles'
type ReleaseHeroProps = {
@ -116,10 +120,6 @@ function LatestReleaseSource({ release }: {
}) {
const { t } = useTranslation('deployments')
const sourceAppId = release.sourceAppId
const sourceAppQuery = useQuery(consoleQuery.apps.byAppId.get.queryOptions({
input: { params: { app_id: sourceAppId ?? '' } },
enabled: Boolean(sourceAppId),
}))
if (!sourceAppId) {
return (
@ -129,6 +129,23 @@ function LatestReleaseSource({ release }: {
)
}
return (
<ScopeProvider
key={sourceAppId}
atoms={[
[deploymentSourceAppIdAtom, sourceAppId],
]}
name="DeploymentLatestReleaseSource"
>
<LatestReleaseSourceLink sourceAppId={sourceAppId} />
</ScopeProvider>
)
}
function LatestReleaseSourceLink({ sourceAppId }: {
sourceAppId: string
}) {
const sourceAppQuery = useAtomValue(deploymentSourceAppQueryAtom)
const sourceAppName = sourceAppQuery.data?.name
const label = sourceAppName || sourceAppId
const title = sourceAppName ? `${sourceAppName} (${sourceAppId})` : sourceAppId

View File

@ -48,4 +48,29 @@ describe('ApiKeyGenerateMenu', () => {
expect(screen.getByText('deployments.access.api.nameRequired')).toBeInTheDocument()
expect(mockMutate).not.toHaveBeenCalled()
})
it('should clear the required name error when typing a valid name', () => {
render(
<ApiKeyGenerateMenu
appInstanceId="app-instance-1"
environments={[createEnvironment()]}
onCreatedToken={vi.fn()}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'deployments.access.api.newKey' }))
const nameInput = screen.getByLabelText('deployments.access.api.nameLabel')
fireEvent.change(nameInput, {
target: { value: ' ' },
})
fireEvent.click(screen.getByRole('button', { name: 'deployments.access.api.createKey' }))
expect(screen.getByText('deployments.access.api.nameRequired')).toBeInTheDocument()
fireEvent.change(nameInput, {
target: { value: 'Production key' },
})
expect(screen.queryByText('deployments.access.api.nameRequired')).not.toBeInTheDocument()
})
})

View File

@ -1,6 +1,8 @@
import type { AccessPolicy, Environment, EnvironmentAccessPolicy } from '@dify/contracts/enterprise/types.gen'
import type { ReactNode } from 'react'
import { AccessMode, SubjectType } from '@dify/contracts/enterprise/types.gen'
import { fireEvent, render, screen } from '@testing-library/react'
import { createStore, Provider as JotaiProvider } from 'jotai'
import { describe, expect, it, vi } from 'vitest'
import { EnvironmentPermissionRow } from '../permissions'
import { AccessPermissionsSection } from '../permissions-section'
@ -16,6 +18,14 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
}
})
function renderWithAtomStore(children: ReactNode) {
return render(
<JotaiProvider store={createStore()}>
{children}
</JotaiProvider>,
)
}
function createEnvironment(overrides: Partial<Environment> = {}): Environment {
return {
id: 'environment-1',
@ -74,7 +84,7 @@ describe('EnvironmentPermissionRow', () => {
})
it('should keep the previous permission visible when updating the policy fails', () => {
render(
renderWithAtomStore(
<EnvironmentPermissionRow
appInstanceId="app-instance-1"
environment={createEnvironment()}
@ -91,7 +101,7 @@ describe('EnvironmentPermissionRow', () => {
})
it('should show specific subject counts in the access summary', () => {
render(
renderWithAtomStore(
<EnvironmentPermissionRow
appInstanceId="app-instance-1"
environment={createEnvironment()}
@ -118,7 +128,7 @@ describe('AccessPermissionsSection', () => {
})
it('should render permission rows without column headers', () => {
render(
renderWithAtomStore(
<AccessPermissionsSection
appInstanceId="app-instance-1"
environmentPolicies={[createEnvironmentAccessPolicy()]}

View File

@ -64,13 +64,6 @@ export function ApiKeyGenerateMenu({
nameInputRef.current?.focus()
}, [createDialogOpen])
function resetCreateDialog() {
setCreateDialogOpen(false)
setSelectedEnvironmentId(undefined)
setDraftName('')
setNameError(false)
}
function handleOpenCreateDialog() {
const firstEnvironment = selectableEnvironments[0]
if (!firstEnvironment)
@ -87,6 +80,19 @@ export function ApiKeyGenerateMenu({
setNameError(false)
}
function handleDraftNameChange(nextDraftName: string) {
setDraftName(nextDraftName)
if (nameError && nextDraftName.trim())
setNameError(false)
}
function resetCreateDialog() {
setCreateDialogOpen(false)
setSelectedEnvironmentId(undefined)
setDraftName('')
setNameError(false)
}
function handleDialogOpenChange(nextOpen: boolean) {
if (nextOpen || isCreating)
return
@ -175,9 +181,7 @@ export function ApiKeyGenerateMenu({
aria-describedby={nameError ? `${nameInputId}-error` : undefined}
placeholder={t('access.api.namePlaceholder')}
onChange={(event) => {
setDraftName(event.target.value)
if (nameError && event.target.value.trim())
setNameError(false)
handleDraftNameChange(event.target.value)
}}
/>
{nameError && (

View File

@ -8,8 +8,8 @@ import type {
import type { ReactNode } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { Switch, SwitchSkeleton } from '@langgenius/dify-ui/switch'
import { useMutation, useQuery } from '@tanstack/react-query'
import { atom, useAtom } from 'jotai'
import { useMutation } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
@ -20,20 +20,15 @@ import { ApiKeyList } from './api-key-list'
import { CopyPill } from './common'
import { CreatedApiTokenDialog } from './developer-api-created-token-dialog'
import { DeveloperApiSkeleton } from './developer-api-skeleton'
import { developerApiSettingsQueryAtom } from './state'
type CreatedApiToken = {
appInstanceId: string
token: string
}
const createdApiTokenAtom = atom<CreatedApiToken | undefined>(undefined)
function useDeveloperApiSettings(appInstanceId: string) {
const developerApiSettingsQuery = useQuery(consoleQuery.enterprise.accessService.getDeveloperApiSettings.queryOptions({
input: {
params: { appInstanceId },
},
}))
function useDeveloperApiSettings() {
const developerApiSettingsQuery = useAtomValue(developerApiSettingsQueryAtom)
const accessChannels = developerApiSettingsQuery.data?.accessChannels
const apiEnabled = accessChannels?.developerApiEnabled ?? false
const environments = developerApiSettingsQuery.data?.environments ?? []
@ -89,7 +84,7 @@ export function DeveloperApiHeaderSwitch({ appInstanceId }: {
accessChannels,
isLoading,
isError,
} = useDeveloperApiSettings(appInstanceId)
} = useDeveloperApiSettings()
if (isLoading)
return <SwitchSkeleton />
@ -175,7 +170,7 @@ export function DeveloperApiSection({
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const [createdApiToken, setCreatedApiToken] = useAtom(createdApiTokenAtom)
const [createdApiToken, setCreatedApiToken] = useState<CreatedApiToken>()
const {
apiEnabled,
apiUrl,
@ -183,7 +178,7 @@ export function DeveloperApiSection({
environments,
isLoading,
isError,
} = useDeveloperApiSettings(appInstanceId)
} = useDeveloperApiSettings()
const visibleCreatedApiToken = createdApiToken?.appInstanceId === appInstanceId
? createdApiToken.token
: undefined

View File

@ -30,6 +30,12 @@ import {
PermissionSummaryButton,
} from './permission-row-components'
type AccessPermissionDraft = {
fingerprint: string
kind: AccessPermissionKind
subjects: SelectableAccessSubject[]
}
type EnvironmentPermissionRowProps = {
appInstanceId: string
disabled?: boolean
@ -53,23 +59,19 @@ export function EnvironmentPermissionRow({
const policyFingerprint = policy
? `${policy.mode}:${policy.subjects.map(subject => `${subject.subjectType}:${subject.subjectId}`).join(',')}`
: 'no-policy'
const [draft, setDraft] = useState<{
fingerprint?: string
kind?: AccessPermissionKind
subjects?: SelectableAccessSubject[]
}>({})
const [draft, setDraft] = useState<AccessPermissionDraft>()
const subjectLabelCandidates = [
...(draft.subjects ?? []),
...(draft?.subjects ?? []),
...resolvedSubjects
.flatMap((subject) => {
const normalizedSubject = normalizeResolvedSubject(subject)
return normalizedSubject ? [normalizedSubject] : []
}),
]
const hasDraft = draft.fingerprint === policyFingerprint
const permissionKind = hasDraft && draft.kind ? draft.kind : policyKind
const hasDraft = draft?.fingerprint === policyFingerprint
const permissionKind = hasDraft && draft ? draft.kind : policyKind
const policySelectedSubjects = policyKind === 'specific' ? selectedSubjectsFromPolicy(policy, subjectLabelCandidates) : []
const subjects = hasDraft && draft.subjects ? draft.subjects : policySelectedSubjects
const subjects = hasDraft && draft ? draft.subjects : policySelectedSubjects
const subjectSelection = accessControlSelectionFromSubjects(subjects)
const isSaving = setEnvironmentAccessPolicy.isPending
const controlsDisabled = disabled || isSaving

View File

@ -0,0 +1,32 @@
'use client'
import { skipToken } from '@tanstack/react-query'
import { atomWithQuery } from 'jotai-tanstack-query'
import { consoleQuery } from '@/service/client'
import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
export const accessSettingsQueryAtom = atomWithQuery((get) => {
const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
return consoleQuery.enterprise.accessService.getAccessSettings.queryOptions({
input: appInstanceId
? {
params: { appInstanceId },
}
: skipToken,
enabled: Boolean(appInstanceId),
})
})
export const developerApiSettingsQueryAtom = atomWithQuery((get) => {
const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
return consoleQuery.enterprise.accessService.getDeveloperApiSettings.queryOptions({
input: appInstanceId
? {
params: { appInstanceId },
}
: skipToken,
enabled: Boolean(appInstanceId),
})
})

View File

@ -0,0 +1,61 @@
'use client'
import { skipToken } from '@tanstack/react-query'
import { atom } from 'jotai'
import { atomWithQuery } from 'jotai-tanstack-query'
import { consoleQuery } from '@/service/client'
import { deploymentRouteAppInstanceIdAtom } from '../route-state'
import { deploymentStatusPollingInterval } from '../shared/domain/runtime-status'
export const deploymentSourceAppIdAtom = atom<string | undefined>(undefined)
export const deploymentDetailAppInstanceQueryAtom = atomWithQuery((get) => {
const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
return consoleQuery.enterprise.appInstanceService.getAppInstance.queryOptions({
input: appInstanceId
? {
params: { appInstanceId },
}
: skipToken,
enabled: Boolean(appInstanceId),
})
})
export const deploymentDetailOverviewQueryAtom = atomWithQuery((get) => {
const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
return consoleQuery.enterprise.appInstanceService.getAppInstanceOverview.queryOptions({
input: appInstanceId
? {
params: { appInstanceId },
}
: skipToken,
enabled: Boolean(appInstanceId),
})
})
export const deploymentEnvironmentDeploymentsQueryAtom = atomWithQuery((get) => {
const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
return consoleQuery.enterprise.deploymentService.listEnvironmentDeployments.queryOptions({
input: appInstanceId
? {
params: { appInstanceId },
}
: skipToken,
enabled: Boolean(appInstanceId),
refetchInterval: query => deploymentStatusPollingInterval(query.state.data?.environmentDeployments),
})
})
export const deploymentSourceAppQueryAtom = atomWithQuery((get) => {
const sourceAppId = get(deploymentSourceAppIdAtom)
return consoleQuery.apps.byAppId.get.queryOptions({
input: sourceAppId
? { params: { app_id: sourceAppId } }
: skipToken,
enabled: Boolean(sourceAppId),
})
})

View File

@ -4,7 +4,6 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DeployReleaseMenu } from '../deploy-release-menu'
const mockUseQuery = vi.hoisted(() => vi.fn())
const mockUseMutation = vi.hoisted(() => vi.fn())
const mockDeleteRelease = vi.fn()
@ -14,11 +13,21 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useQuery: (...args: unknown[]) => mockUseQuery(...args),
useMutation: (...args: unknown[]) => mockUseMutation(...args),
}
})
vi.mock('../state', async (importOriginal) => {
const actual = await importOriginal<typeof import('../state')>()
const { atom } = await import('jotai')
return {
...actual,
deployReleaseMenuEnvironmentDeploymentsQueryAtom: atom(environmentDeploymentsErrorResult()),
deployReleaseMenuAppInstanceQueryAtom: atom(appInstanceResult()),
}
})
vi.mock('../edit-release-dialog', () => ({
EditReleaseDialog: () => null,
}))
@ -69,11 +78,6 @@ function appInstanceResult() {
describe('DeployReleaseMenu', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseQuery.mockImplementation(() => {
return mockUseQuery.mock.calls.length % 2 === 1
? environmentDeploymentsErrorResult()
: appInstanceResult()
})
mockUseMutation.mockReturnValue({
isPending: false,
mutate: mockDeleteRelease,

View File

@ -4,18 +4,25 @@ import { render, within } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { ReleaseHistoryRows } from '../release-history-rows'
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useQuery: () => ({ data: undefined }),
}
})
vi.mock('../deploy-release-menu', () => ({
DeployReleaseMenu: () => <button type="button">Actions</button>,
}))
vi.mock('../../state', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../state')>()
const { atom } = await import('jotai')
return {
...actual,
deploymentSourceAppIdAtom: atom<string | undefined>(undefined),
deploymentSourceAppQueryAtom: atom({
data: {
name: 'Source Workflow',
},
}),
}
})
function createReleaseRow(overrides: Partial<ReleaseWithSummaryDeployments> = {}): ReleaseWithSummaryDeployments {
return {
id: 'release-1',
@ -82,4 +89,23 @@ describe('ReleaseHistoryRows', () => {
expect(deploymentLabel).not.toHaveAttribute('data-base-ui-tooltip-trigger')
expect(container.querySelector('.shadow-status-indicator-green-shadow')).toBeInTheDocument()
})
it('should render release source app links with scoped source app state', () => {
const { container } = render(
<ReleaseHistoryRows
appInstanceId="app-instance-1"
releaseRows={[
createReleaseRow({
sourceAppId: 'source-app-1',
}),
]}
/>,
)
const table = container.querySelector('table')
const sourceLink = within(table!).getByRole('link', { name: /Source Workflow/ })
expect(sourceLink).toHaveAttribute('href', '/app/source-app-1/workflow')
expect(sourceLink).toHaveAttribute('target', '_blank')
})
})

View File

@ -0,0 +1,143 @@
import type { Getter } from 'jotai'
import { skipToken } from '@tanstack/react-query'
import { atom, createStore } from 'jotai'
import { describe, expect, it, vi } from 'vitest'
import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
type QueryOptions = {
enabled?: boolean
input?: unknown
placeholderData?: unknown
queryKey?: 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,
})),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
enterprise: {
appInstanceService: {
getAppInstance: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['getAppInstance', options.input],
}),
},
},
deploymentService: {
listEnvironmentDeployments: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['listEnvironmentDeployments', options.input],
}),
},
},
releaseService: {
listReleaseSummaries: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['listReleaseSummaries', options.input],
}),
},
},
},
},
}))
async function loadState() {
return await import('../state')
}
describe('versions tab state', () => {
it('should gate release history and menu queries until route and menu state are ready', async () => {
const state = await loadState()
const store = createStore()
expect(store.get(state.releaseHistoryQueryAtom)).toMatchObject({
enabled: false,
input: skipToken,
})
expect(store.get(state.deployReleaseMenuEnvironmentDeploymentsQueryAtom)).toMatchObject({
enabled: false,
input: skipToken,
})
expect(store.get(state.deployReleaseMenuAppInstanceQueryAtom)).toMatchObject({
enabled: false,
input: skipToken,
})
})
it('should build release history input from the current page', async () => {
const state = await loadState()
const store = createStore()
store.set(deploymentRouteAppInstanceIdAtom, 'app-instance-1')
store.set(state.setReleaseHistoryCurrentPageAtom, -1)
expect(store.get(state.releaseHistoryCurrentPageAtom)).toBe(0)
store.set(state.setReleaseHistoryCurrentPageAtom, 2)
expect(store.get(state.releaseHistoryQueryAtom)).toMatchObject({
enabled: true,
input: {
params: { appInstanceId: 'app-instance-1' },
query: {
pageNumber: 3,
},
},
})
})
it('should scope deploy menu queries to the open release id', async () => {
const state = await loadState()
const store = createStore()
store.set(deploymentRouteAppInstanceIdAtom, 'app-instance-1')
store.set(state.setDeployReleaseMenuOpenAtom, {
releaseId: 'release-1',
open: true,
})
expect(store.get(state.deployReleaseMenuOpenReleaseIdAtom)).toBe('release-1')
expect(store.get(state.deployReleaseMenuEnvironmentDeploymentsQueryAtom)).toMatchObject({
enabled: true,
input: { params: { appInstanceId: 'app-instance-1' } },
})
expect(store.get(state.deployReleaseMenuAppInstanceQueryAtom)).toMatchObject({
enabled: true,
input: { params: { appInstanceId: 'app-instance-1' } },
})
store.set(state.setDeployReleaseMenuOpenAtom, {
releaseId: 'release-2',
open: false,
})
expect(store.get(state.deployReleaseMenuOpenReleaseIdAtom)).toBe('release-1')
store.set(state.setDeployReleaseMenuOpenAtom, {
releaseId: 'release-1',
open: false,
})
expect(store.get(state.deployReleaseMenuOpenReleaseIdAtom)).toBeUndefined()
})
it('should adjust the release page only when deleting the last row on a later page', async () => {
const state = await loadState()
const store = createStore()
store.set(state.setReleaseHistoryCurrentPageAtom, 2)
store.set(state.adjustReleaseHistoryPageAfterDeleteAtom, 2)
expect(store.get(state.releaseHistoryCurrentPageAtom)).toBe(2)
store.set(state.adjustReleaseHistoryPageAfterDeleteAtom, 1)
expect(store.get(state.releaseHistoryCurrentPageAtom)).toBe(1)
})
})

View File

@ -9,8 +9,8 @@ import {
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useSetAtom } from 'jotai'
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'
@ -25,6 +25,12 @@ import {
} from './deploy-release-menu-utils'
import { EditReleaseDialog } from './edit-release-dialog'
import { exportReleaseDsl } from './release-dsl-export'
import {
deployReleaseMenuAppInstanceQueryAtom,
deployReleaseMenuEnvironmentDeploymentsQueryAtom,
deployReleaseMenuOpenReleaseIdAtom,
setDeployReleaseMenuOpenAtom,
} from './state'
export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows, onDeleted }: {
appInstanceId: string
@ -34,29 +40,21 @@ export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows, onDel
}) {
const { t } = useTranslation('deployments')
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
const [open, setOpen] = useState(false)
const openReleaseMenuId = useAtomValue(deployReleaseMenuOpenReleaseIdAtom)
const setDeployReleaseMenuOpen = useSetAtom(setDeployReleaseMenuOpenAtom)
const [showEditDialog, setShowEditDialog] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [isExportingDsl, setIsExportingDsl] = useState(false)
const environmentDeploymentsQuery = useQuery(consoleQuery.enterprise.deploymentService.listEnvironmentDeployments.queryOptions({
input: {
params: { appInstanceId },
},
enabled: open,
}))
const { data: appInstanceData } = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstance.queryOptions({
input: {
params: { appInstanceId },
},
enabled: open,
}))
const open = openReleaseMenuId === releaseId
const environmentDeploymentsQuery = useAtomValue(deployReleaseMenuEnvironmentDeploymentsQueryAtom)
const appInstanceQuery = useAtomValue(deployReleaseMenuAppInstanceQueryAtom)
const deleteRelease = useMutation(consoleQuery.enterprise.releaseService.deleteRelease.mutationOptions())
const environments = (environmentDeploymentsQuery.data?.environmentDeployments ?? [])
.map(row => row.environment)
const deploymentRows = environmentDeploymentsQuery.data?.environmentDeployments.filter(row => !isUndeployedDeploymentRow(row)) ?? []
const targetRelease = releaseRows.find(release => release.id === releaseId)
const appInstanceName = appInstanceData?.appInstance.displayName
const appInstanceName = appInstanceQuery.data?.appInstance.displayName
if (!targetRelease)
return null
@ -76,6 +74,10 @@ export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows, onDel
: undefined
const deleteActionDisabled = isDeletingRelease || isCheckingDeleteUsage || hasDeleteUsageCheckFailed || isReleaseInUse
function handleOpenChange(nextOpen: boolean) {
setDeployReleaseMenuOpen({ releaseId, open: nextOpen })
}
const handleExportDsl = async () => {
if (isExportingDsl)
return
@ -83,7 +85,7 @@ export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows, onDel
setIsExportingDsl(true)
try {
await exportReleaseDsl({ release: targetRelease, releaseId, appInstanceName })
setOpen(false)
handleOpenChange(false)
}
catch {
toast.error(t('versions.exportDslFailed'))
@ -127,7 +129,7 @@ export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows, onDel
return (
<>
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
<DropdownMenu modal={false} open={open} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger
aria-label={t('versions.moreActions')}
className={DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME}
@ -139,7 +141,7 @@ export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows, onDel
<DropdownMenuItem
className="gap-2 px-3"
onClick={() => {
setOpen(false)
handleOpenChange(false)
setShowEditDialog(true)
}}
>
@ -183,7 +185,7 @@ export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows, onDel
onClick={() => {
if (isDisabled)
return
setOpen(false)
handleOpenChange(false)
openDeployDrawer({ appInstanceId, environmentId: row.environmentId, releaseId })
}}
>
@ -209,7 +211,7 @@ export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows, onDel
onClick={() => {
if (deleteActionDisabled)
return
setOpen(false)
handleOpenChange(false)
setShowDeleteConfirm(true)
}}
>

View File

@ -152,7 +152,7 @@ export function EditReleaseDialog({
onSuccess: (data) => {
const updatedName = data.release.displayName
toast.success(t('versions.editSuccess', { name: updatedName }))
onOpenChange(false)
handleOpenChange(false)
},
onError: () => {
toast.error(t('versions.editFailed'))

View File

@ -4,16 +4,20 @@ import type { Release } from '@dify/contracts/enterprise/types.gen'
import type { ReleaseWithSummaryDeployments } from './release-deployments'
import { ReleaseSource } from '@dify/contracts/enterprise/types.gen'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { ScopeProvider } from 'jotai-scope'
import { useTranslation } from 'react-i18next'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import Link from '@/next/link'
import { consoleQuery } from '@/service/client'
import { TitleTooltip } from '../../components/title-tooltip'
import {
formatDate,
releaseCommit,
} from '../../shared/domain/release'
import {
deploymentSourceAppIdAtom,
deploymentSourceAppQueryAtom,
} from '../state'
import {
DetailTable,
DetailTableBody,
@ -77,10 +81,6 @@ function ReleaseSourceCell({ release }: {
}) {
const { t } = useTranslation('deployments')
const sourceAppId = release.sourceAppId
const sourceAppQuery = useQuery(consoleQuery.apps.byAppId.get.queryOptions({
input: { params: { app_id: sourceAppId ?? '' } },
enabled: Boolean(sourceAppId),
}))
if (!sourceAppId) {
return (
@ -90,6 +90,23 @@ function ReleaseSourceCell({ release }: {
)
}
return (
<ScopeProvider
key={sourceAppId}
atoms={[
[deploymentSourceAppIdAtom, sourceAppId],
]}
name="DeploymentReleaseSource"
>
<ReleaseSourceLink sourceAppId={sourceAppId} />
</ScopeProvider>
)
}
function ReleaseSourceLink({ sourceAppId }: {
sourceAppId: string
}) {
const sourceAppQuery = useAtomValue(deploymentSourceAppQueryAtom)
const sourceAppName = sourceAppQuery.data?.name
const label = sourceAppName || sourceAppId
const title = sourceAppName ? `${sourceAppName} (${sourceAppId})` : sourceAppId

View File

@ -1,31 +1,28 @@
'use client'
import { Pagination } from '@langgenius/dify-ui/pagination'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { DeploymentEmptyState, DeploymentStateMessage } from '../../components/empty-state'
import { RELEASE_HISTORY_PAGE_SIZE } from '../../shared/domain/pagination'
import { ReleaseHistoryRows } from './release-history-rows'
import { ReleaseHistoryTableSkeleton } from './release-history-table-skeleton'
import { releaseRowFromSummary } from './release-history-types'
import {
adjustReleaseHistoryPageAfterDeleteAtom,
releaseHistoryCurrentPageAtom,
releaseHistoryQueryAtom,
setReleaseHistoryCurrentPageAtom,
} from './state'
export function ReleaseHistoryTable({ appInstanceId }: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const [currentPage, setCurrentPage] = useState(0)
const releaseHistoryQuery = useQuery(consoleQuery.enterprise.releaseService.listReleaseSummaries.queryOptions({
input: {
params: { appInstanceId },
query: {
pageNumber: currentPage + 1,
resultsPerPage: RELEASE_HISTORY_PAGE_SIZE,
},
},
placeholderData: keepPreviousData,
}))
const currentPage = useAtomValue(releaseHistoryCurrentPageAtom)
const setCurrentPage = useSetAtom(setReleaseHistoryCurrentPageAtom)
const adjustPageAfterDelete = useSetAtom(adjustReleaseHistoryPageAfterDeleteAtom)
const releaseHistoryQuery = useAtomValue(releaseHistoryQueryAtom)
const isLoading = releaseHistoryQuery.isLoading
const hasError = releaseHistoryQuery.isError
@ -54,8 +51,7 @@ export function ReleaseHistoryTable({ appInstanceId }: {
const totalReleasePages = Math.ceil(totalReleases / RELEASE_HISTORY_PAGE_SIZE)
function handleReleaseDeleted() {
if (releaseRows.length === 1 && currentPage > 0)
setCurrentPage(page => Math.max(page - 1, 0))
adjustPageAfterDelete(releaseRows.length)
}
if (releaseRows.length === 0) {

View File

@ -0,0 +1,90 @@
'use client'
import type { ListReleaseSummariesResponse } from '@dify/contracts/enterprise/types.gen'
import { keepPreviousData, skipToken } from '@tanstack/react-query'
import { atom } from 'jotai'
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'
export const releaseHistoryCurrentPageAtom = atom(0)
export const deployReleaseMenuOpenReleaseIdAtom = atom<string | undefined>(undefined)
export const releaseHistoryQueryAtom = atomWithQuery<ListReleaseSummariesResponse>((get) => {
const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
const currentPage = get(releaseHistoryCurrentPageAtom)
return consoleQuery.enterprise.releaseService.listReleaseSummaries.queryOptions({
input: appInstanceId
? {
params: { appInstanceId },
query: {
pageNumber: currentPage + 1,
resultsPerPage: RELEASE_HISTORY_PAGE_SIZE,
},
}
: skipToken,
enabled: Boolean(appInstanceId),
placeholderData: keepPreviousData,
})
})
export const deployReleaseMenuEnvironmentDeploymentsQueryAtom = atomWithQuery((get) => {
const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
const openReleaseId = get(deployReleaseMenuOpenReleaseIdAtom)
return consoleQuery.enterprise.deploymentService.listEnvironmentDeployments.queryOptions({
input: appInstanceId
? {
params: { appInstanceId },
}
: skipToken,
enabled: Boolean(appInstanceId && openReleaseId),
})
})
export const deployReleaseMenuAppInstanceQueryAtom = atomWithQuery((get) => {
const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
const openReleaseId = get(deployReleaseMenuOpenReleaseIdAtom)
return consoleQuery.enterprise.appInstanceService.getAppInstance.queryOptions({
input: appInstanceId
? {
params: { appInstanceId },
}
: skipToken,
enabled: Boolean(appInstanceId && openReleaseId),
})
})
export const setReleaseHistoryCurrentPageAtom = atom(null, (_get, set, page: number) => {
set(releaseHistoryCurrentPageAtom, Math.max(page, 0))
})
export const adjustReleaseHistoryPageAfterDeleteAtom = atom(null, (get, set, remainingRowsOnPage: number) => {
const currentPage = get(releaseHistoryCurrentPageAtom)
if (remainingRowsOnPage === 1 && currentPage > 0)
set(releaseHistoryCurrentPageAtom, currentPage - 1)
})
export const setDeployReleaseMenuOpenAtom = atom(null, (get, set, {
releaseId,
open,
}: {
releaseId: string
open: boolean
}) => {
if (open) {
set(deployReleaseMenuOpenReleaseIdAtom, releaseId)
return
}
if (get(deployReleaseMenuOpenReleaseIdAtom) === releaseId)
set(deployReleaseMenuOpenReleaseIdAtom, undefined)
})
export const versionsTabLocalAtoms = [
releaseHistoryCurrentPageAtom,
deployReleaseMenuOpenReleaseIdAtom,
] as const

View File

@ -34,22 +34,20 @@ export const deploymentsListQueryAtom = atomWithInfiniteQuery<
const queryKeywords = get(deploymentsListKeywordsAtom).trim()
const queryEnvironmentId = get(deploymentsListEnvironmentIdAtom) ?? undefined
return {
...consoleQuery.enterprise.appInstanceService.listAppInstanceSummaries.infiniteOptions({
input: pageParam => ({
query: {
pageNumber: Number(pageParam),
resultsPerPage: SOURCE_APPS_PAGE_SIZE,
...(queryEnvironmentId ? { environmentId: queryEnvironmentId } : {}),
...(queryKeywords ? { displayName: queryKeywords } : {}),
},
}),
getNextPageParam: lastPage => getNextPageParamFromPagination(lastPage.pagination),
initialPageParam: 1,
placeholderData: keepPreviousData,
return consoleQuery.enterprise.appInstanceService.listAppInstanceSummaries.infiniteOptions({
input: pageParam => ({
query: {
pageNumber: Number(pageParam),
resultsPerPage: SOURCE_APPS_PAGE_SIZE,
...(queryEnvironmentId ? { environmentId: queryEnvironmentId } : {}),
...(queryKeywords ? { displayName: queryKeywords } : {}),
},
}),
getNextPageParam: lastPage => getNextPageParamFromPagination(lastPage.pagination),
initialPageParam: 1,
placeholderData: keepPreviousData,
refetchInterval: query => listDeploymentStatusPollingInterval(query.state.data),
}
})
})
export const deploymentsListRowsAtom = atom((get) => {

View File

@ -56,7 +56,6 @@ export function InstanceCard({ summary }: {
>
<DeploymentActionsMenu
appInstanceId={appInstanceId}
appName={appName}
placement="bottom-end"
sideOffset={4}
className="pointer-events-none absolute top-3 right-3 z-10 opacity-0 transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100"

View File

@ -0,0 +1,79 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { atom } from 'jotai'
import { describe, expect, it, vi } from 'vitest'
import { DeploymentsNav } from '../index'
const mockPush = vi.hoisted(() => vi.fn())
const mockFetchNextPage = vi.hoisted(() => vi.fn())
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}))
vi.mock('@/app/components/header/nav', () => ({
default: ({
createText,
curNav,
isLoadingMore,
navigationItems,
onCreate,
onLoadMore,
text,
}: {
createText: string
curNav?: { name: string }
isLoadingMore: boolean
navigationItems: Array<{ name: string }>
onCreate: () => void
onLoadMore: () => void
text: string
}) => (
<nav aria-label={text}>
<span>{curNav?.name}</span>
{navigationItems.map(item => (
<span key={item.name}>{item.name}</span>
))}
<button type="button" onClick={onCreate}>
{createText}
</button>
<button type="button" data-loading={String(isLoadingMore)} onClick={onLoadMore}>
load more
</button>
</nav>
),
}))
vi.mock('../state', () => ({
deploymentsNavCurrentItemAtom: atom({
id: 'app-instance-1',
name: 'Deployment 1',
}),
deploymentsNavItemsAtom: atom([
{
id: 'app-instance-1',
name: 'Deployment 1',
},
]),
deploymentsNavListQueryAtom: atom({
fetchNextPage: mockFetchNextPage,
hasNextPage: true,
isFetchingNextPage: false,
}),
}))
describe('DeploymentsNav', () => {
it('should render deployment navigation from atom state and forward nav actions', () => {
render(<DeploymentsNav />)
expect(screen.getByRole('navigation', { name: 'common.menus.deployments' })).toBeInTheDocument()
expect(screen.getAllByText('Deployment 1')).toHaveLength(2)
fireEvent.click(screen.getByRole('button', { name: 'deployments:list.createDeployment' }))
fireEvent.click(screen.getByRole('button', { name: 'load more' }))
expect(mockPush).toHaveBeenCalledWith('/deployments/create')
expect(mockFetchNextPage).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,187 @@
import type {
AppInstance,
GetAppInstanceResponse,
ListAppInstancesResponse,
} from '@dify/contracts/enterprise/types.gen'
import type { Getter } from 'jotai'
import { atom, createStore } from 'jotai'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
deploymentRouteAppInstanceIdAtom,
deploymentsRouteActiveAtom,
} from '../../route-state'
type QueryOptions = {
enabled?: boolean
input?: unknown
queryKey?: readonly unknown[]
select?: (data: GetAppInstanceResponse) => AppInstance
}
type InfiniteQueryOptions = QueryOptions & {
input?: (pageParam: number) => unknown
}
type QueryResult = {
data?: unknown
}
const mockQueryResults = vi.hoisted(() => ({
current: new Map<string, QueryResult>(),
}))
vi.mock('jotai-tanstack-query', () => ({
atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom((get) => {
const options = createOptions(get)
const queryName = String(options.queryKey?.[0] ?? 'unknown')
const queryResult = options.enabled === false
? undefined
: mockQueryResults.current.get(queryName)
const selectedData = options.select && queryResult?.data
? options.select(queryResult.data as GetAppInstanceResponse)
: queryResult?.data
return {
...options,
data: selectedData,
isError: false,
isFetching: false,
isLoading: false,
isSuccess: Boolean(selectedData),
}
}),
atomWithInfiniteQuery: (createOptions: (get: Getter) => InfiniteQueryOptions) => atom((get) => {
const options = createOptions(get)
const queryName = String(options.queryKey?.[0] ?? 'unknown')
const queryResult = options.enabled === false
? undefined
: mockQueryResults.current.get(queryName)
return {
...options,
data: queryResult?.data,
hasNextPage: false,
isError: false,
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
isSuccess: Boolean(queryResult?.data),
}
}),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
enterprise: {
appInstanceService: {
getAppInstance: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['getAppInstance', options.input],
}),
},
listAppInstances: {
infiniteOptions: (options: InfiniteQueryOptions) => ({
...options,
queryKey: ['listAppInstances'],
}),
},
},
},
},
}))
async function loadState() {
return await import('../state')
}
function appInstance(overrides: Partial<AppInstance> = {}): AppInstance {
return {
id: 'app-instance-1',
displayName: 'Deployment 1',
description: '',
sourceAppId: 'source-app-1',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...overrides,
} as AppInstance
}
function setListAppInstances(appInstances: AppInstance[]) {
mockQueryResults.current.set('listAppInstances', {
data: {
pages: [
{
appInstances,
pagination: {},
} satisfies Partial<ListAppInstancesResponse>,
],
},
})
}
describe('deployments nav state', () => {
beforeEach(() => {
vi.clearAllMocks()
mockQueryResults.current.clear()
})
it('should hide deployment nav items outside deployment routes', async () => {
const state = await loadState()
const store = createStore()
expect(store.get(state.deploymentsNavItemsAtom)).toEqual([])
expect(store.get(state.deploymentsNavCurrentItemAtom)).toBeUndefined()
})
it('should append the current route item when it is missing from the list query', async () => {
const state = await loadState()
const store = createStore()
store.set(deploymentsRouteActiveAtom, true)
store.set(deploymentRouteAppInstanceIdAtom, 'app-instance-1')
setListAppInstances([
appInstance({
id: 'app-instance-2',
displayName: 'Deployment 2',
}),
])
mockQueryResults.current.set('getAppInstance', {
data: {
appInstance: appInstance(),
},
})
expect(store.get(state.deploymentsNavItemsAtom)).toMatchObject([
{
id: 'app-instance-2',
name: 'Deployment 2',
link: '/deployments/app-instance-2/overview',
},
{
id: 'app-instance-1',
name: 'Deployment 1',
link: '/deployments/app-instance-1/overview',
},
])
expect(store.get(state.deploymentsNavCurrentItemAtom)).toMatchObject({
id: 'app-instance-1',
name: 'Deployment 1',
})
})
it('should use the route id as a fallback current item name', async () => {
const state = await loadState()
const store = createStore()
store.set(deploymentsRouteActiveAtom, true)
store.set(deploymentRouteAppInstanceIdAtom, 'app-instance-1')
setListAppInstances([])
expect(store.get(state.deploymentsNavItemsAtom)).toMatchObject([
{
id: 'app-instance-1',
name: 'app-instance-1',
link: '/deployments/app-instance-1/overview',
},
])
})
})

View File

@ -1,90 +1,21 @@
'use client'
import type { AppInstance } from '@dify/contracts/enterprise/types.gen'
import type { NavItem } from '@/app/components/header/nav/nav-selector'
import { keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
import Nav from '@/app/components/header/nav'
import { useParams, useRouter, useSelectedLayoutSegment } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { getNextPageParamFromPagination, SOURCE_APPS_PAGE_SIZE } from '../shared/domain/pagination'
function navItemFromListApp(app: AppInstance): NavItem {
const id = app.id
return {
id,
name: app.displayName,
link: `/deployments/${id}/overview`,
icon_type: 'emoji',
icon: '🚀',
icon_background: '#E0EAFF',
icon_url: null,
}
}
function navItemFromOverview(instance?: AppInstance, fallbackId?: string): NavItem | undefined {
const id = instance?.id ?? fallbackId
if (!id)
return undefined
return {
id,
name: instance ? instance.displayName : id,
link: `/deployments/${id}/overview`,
icon_type: 'emoji',
icon: '🚀',
icon_background: '#E0EAFF',
icon_url: null,
}
}
import { useRouter } from '@/next/navigation'
import {
deploymentsNavCurrentItemAtom,
deploymentsNavItemsAtom,
deploymentsNavListQueryAtom,
} from './state'
export function DeploymentsNav() {
const { t } = useTranslation()
const router = useRouter()
const selectedSegment = useSelectedLayoutSegment()
const isActive = selectedSegment === 'deployments'
const params = useParams<{ appInstanceId?: string }>()
const appInstanceId = params?.appInstanceId
const { data: currentInstance } = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstance.queryOptions({
input: {
params: {
appInstanceId: appInstanceId ?? '',
},
},
enabled: isActive && Boolean(appInstanceId),
select: data => data.appInstance,
}))
const listQuery = useInfiniteQuery({
...consoleQuery.enterprise.appInstanceService.listAppInstances.infiniteOptions({
input: pageParam => ({
query: {
pageNumber: Number(pageParam),
resultsPerPage: SOURCE_APPS_PAGE_SIZE,
},
}),
getNextPageParam: lastPage => getNextPageParamFromPagination(lastPage.pagination),
initialPageParam: 1,
placeholderData: keepPreviousData,
}),
enabled: isActive,
})
const appNavItems = listQuery.data?.pages.flatMap(page =>
page.appInstances.map(navItemFromListApp),
) ?? []
const currentNavItem = navItemFromOverview(currentInstance, appInstanceId)
const navigationItems: NavItem[] = isActive
? currentNavItem && !appNavItems.some(item => item.id === currentNavItem.id)
? [...appNavItems, currentNavItem]
: appNavItems
: []
const curNav = appInstanceId
? navigationItems.find(item => item.id === appInstanceId)
: undefined
const navigationItems = useAtomValue(deploymentsNavItemsAtom)
const curNav = useAtomValue(deploymentsNavCurrentItemAtom)
const listQuery = useAtomValue(deploymentsNavListQueryAtom)
function handleCreate() {
router.push('/deployments/create')

View File

@ -0,0 +1,112 @@
'use client'
import type {
AppInstance,
GetAppInstanceResponse,
ListAppInstancesResponse,
} from '@dify/contracts/enterprise/types.gen'
import type { InfiniteData, QueryKey } from '@tanstack/react-query'
import type { NavItem } from '@/app/components/header/nav/nav-selector'
import { keepPreviousData, skipToken } from '@tanstack/react-query'
import { atom } from 'jotai'
import { atomWithInfiniteQuery, atomWithQuery } from 'jotai-tanstack-query'
import { consoleQuery } from '@/service/client'
import {
deploymentRouteAppInstanceIdAtom,
deploymentsRouteActiveAtom,
} from '../route-state'
import { getNextPageParamFromPagination, SOURCE_APPS_PAGE_SIZE } from '../shared/domain/pagination'
function navItemFromListApp(app: AppInstance): NavItem {
const id = app.id
return {
id,
name: app.displayName,
link: `/deployments/${id}/overview`,
icon_type: 'emoji',
icon: '🚀',
icon_background: '#E0EAFF',
icon_url: null,
}
}
function navItemFromOverview(instance?: AppInstance, fallbackId?: string): NavItem | undefined {
const id = instance?.id ?? fallbackId
if (!id)
return undefined
return {
id,
name: instance ? instance.displayName : id,
link: `/deployments/${id}/overview`,
icon_type: 'emoji',
icon: '🚀',
icon_background: '#E0EAFF',
icon_url: null,
}
}
const deploymentsNavCurrentInstanceQueryAtom = atomWithQuery((get) => {
const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
const isActive = get(deploymentsRouteActiveAtom)
return consoleQuery.enterprise.appInstanceService.getAppInstance.queryOptions({
input: appInstanceId
? {
params: {
appInstanceId,
},
}
: skipToken,
enabled: isActive && Boolean(appInstanceId),
select: (data: GetAppInstanceResponse) => data.appInstance,
})
})
export const deploymentsNavListQueryAtom = atomWithInfiniteQuery<
ListAppInstancesResponse,
Error,
InfiniteData<ListAppInstancesResponse>,
QueryKey,
number
>((get) => {
const isActive = get(deploymentsRouteActiveAtom)
return consoleQuery.enterprise.appInstanceService.listAppInstances.infiniteOptions({
input: pageParam => ({
query: {
pageNumber: Number(pageParam),
resultsPerPage: SOURCE_APPS_PAGE_SIZE,
},
}),
getNextPageParam: lastPage => getNextPageParamFromPagination(lastPage.pagination),
initialPageParam: 1,
placeholderData: keepPreviousData,
enabled: isActive,
})
})
export const deploymentsNavItemsAtom = atom((get): NavItem[] => {
if (!get(deploymentsRouteActiveAtom))
return []
const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
const currentInstance = get(deploymentsNavCurrentInstanceQueryAtom).data
const appNavItems = get(deploymentsNavListQueryAtom).data?.pages.flatMap(page =>
page.appInstances.map(navItemFromListApp),
) ?? []
const currentNavItem = navItemFromOverview(currentInstance, appInstanceId)
return currentNavItem && !appNavItems.some(item => item.id === currentNavItem.id)
? [...appNavItems, currentNavItem]
: appNavItems
})
export const deploymentsNavCurrentItemAtom = atom((get) => {
const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
if (!appInstanceId)
return undefined
return get(deploymentsNavItemsAtom).find(item => item.id === appInstanceId)
})

View File

@ -0,0 +1,35 @@
'use client'
import { useSetAtom } from 'jotai'
import { useHydrateAtoms } from 'jotai/react/utils'
import { useEffect } from 'react'
import { useParams } from '@/next/navigation'
import {
deploymentRouteAppInstanceIdAtom,
deploymentsRouteActiveAtom,
} from './route-state'
function routeAppInstanceId(params?: { appInstanceId?: string | string[] }) {
return typeof params?.appInstanceId === 'string' ? params.appInstanceId : undefined
}
export function DeploymentsRouteStateHydrator() {
const params = useParams<{ appInstanceId?: string | string[] }>()
const appInstanceId = routeAppInstanceId(params)
const setDeploymentsRouteActive = useSetAtom(deploymentsRouteActiveAtom)
const setRouteAppInstanceId = useSetAtom(deploymentRouteAppInstanceIdAtom)
useHydrateAtoms([
[deploymentsRouteActiveAtom, true],
[deploymentRouteAppInstanceIdAtom, appInstanceId],
] as const, { dangerouslyForceHydrate: true })
useEffect(() => {
return () => {
setDeploymentsRouteActive(false)
setRouteAppInstanceId(undefined)
}
}, [setDeploymentsRouteActive, setRouteAppInstanceId])
return null
}

View File

@ -0,0 +1,6 @@
'use client'
import { atom } from 'jotai'
export const deploymentsRouteActiveAtom = atom(false)
export const deploymentRouteAppInstanceIdAtom = atom<string | undefined>(undefined)