refactor(web): simplify deployment action state (#37851)

This commit is contained in:
Stephen Zhou 2026-06-24 12:39:52 +08:00 committed by GitHub
parent 24dd7ea3a8
commit 1c1b20aa46
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 687 additions and 590 deletions

View File

@ -39,12 +39,15 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- For Jotai-backed surfaces, put shared query atoms, mutation atoms, derived state, and write actions in the feature state file when they coordinate multiple descendants. Do not create a query or mutation atom only because the surrounding feature uses Jotai. If the query or mutation does not read atom state, feed another atom, or participate in shared workflow orchestration, use `useQuery` or `useMutation` directly at the lowest owner.
- For repeated row/menu action surfaces that need reset, hydrate the stable identity at the surface entry and scope only the primitives that truly need per-instance reset, such as open flags, drafts, or selected local options.
- Keep callbacks in a parent only for workflow coordination such as form submission, shared selection, batch behavior, or navigation. Otherwise let the child or row own its action.
- Prefer uncontrolled DOM state and CSS variables before adding controlled props.
- Default to uncontrolled form and DOM state. Add controlled props or atom-backed drafts only when live cross-component reactions, multi-step persistence, or external synchronization require them.
## Feature-Scoped Jotai State
- A module's feature-local state lives in one state file for Jotai-backed features: primitive atoms, shared query atoms, derived atoms, write-only action atoms, shared mutation atoms, submission orchestration, provider exports, and optional scope configuration.
- Keep synchronous UI state local when one component owns it, even inside Jotai-backed features. Dialog open flags, menu/popover visibility, confirmation visibility, form/input drafts, and selected local options usually belong in component state.
- Do not put simple form drafts in Jotai atoms. For edit/create forms whose fields are only read at submit time, use uncontrolled `@langgenius/dify-ui/form` and `@langgenius/dify-ui/field` controls with `defaultValue`, browser/form validation, and keyed remounts for query-backed initial values.
- Promote form state to Jotai only when another component must react to in-progress field changes, the draft must survive unmount/remount within the same scoped workflow, or multiple steps/surfaces share the same editable draft before submit.
- Keep submit-time normalization, dirty checks, and payload shaping beside the form submit handler. Do not create form atoms, field atoms, or derived can-save atoms only to mirror uncontrolled form values or disable a submit button.
- In Jotai-backed feature surfaces, never hand-roll async loading, error, or in-flight guards with `useState` or `useRef`. For async work that depends on atom state, feeds derived atoms, or participates in shared submission orchestration, model the work with `atomWithQuery` or `atomWithMutation`; write atoms should only update the inputs that drive those atoms. For component-owned remote work that does not participate in atom state, use TanStack Query hooks directly.
- Row-local async state should belong to the row owner. Use `useQuery` or `useMutation` directly for row actions that do not depend on atom state and are not consumed by other atoms. Use a per-instance query or mutation atom only when the row action participates in a Jotai-backed shared workflow or needs atom-scoped reset semantics.
- Promote UI state to an atom only when siblings need the same source of truth, the value drives a query or mutation atom, a parent workflow coordinates the state, or the state intentionally persists across hidden or unmounted descendants within a scoped surface.
@ -60,6 +63,7 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- 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.
- For scoped primitives that are always hydrated by a `ScopeProvider` tuple, prefer `atomWithLazy<T>(() => { throw new Error(...) })` when consumers should see a non-null type. This keeps missing provider hydration as a runtime invariant without leaking `T | undefined` or adding pass-through "required" derived atoms only for narrowing.
- 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.
@ -106,6 +110,7 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- Consume queries directly with `useQuery(consoleQuery.xxx.queryOptions(...))` or `useQuery(marketplaceQuery.xxx.queryOptions(...))`.
- Do not promote a query or mutation to an atom just because the feature already has a state file. Use `atomWithQuery` or `atomWithMutation` only when the query/mutation reads atom state, is consumed by another atom, or is part of shared workflow orchestration.
- In `atomWithQuery` and `atomWithInfiniteQuery`, return generated `queryOptions()` or `infiniteOptions()` directly. Pass `enabled`, `retry`, `placeholderData`, `select`, and pagination options into that call instead of spreading generated options into a hand-built object.
- When prefetch and render consume the same server request, extract local query options or a query-options atom so `queryClient.prefetchQuery(...)` and `useQuery`/`atomWithQuery` share the exact generated query options.
- 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.
@ -116,6 +121,7 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- Consume mutations directly with `useMutation(consoleQuery.xxx.mutationOptions(...))` or `useMutation(marketplaceQuery.xxx.mutationOptions(...))` when the mutation is owned by one component, menu, dialog, or row and its pending/error state is not consumed by feature atoms. In Jotai-backed workflow orchestration, expose mutations from feature state with `atomWithMutation` so pending/error state stays attached to the mutation atom. For component-owned custom mutation functions, use `useMutation(mutationOptions(...))` at the owner.
- Put shared cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`; components may add UI feedback callbacks, but should not own shared invalidation rules.
- Component or atom mutation callbacks can handle local UI feedback such as toasts, closing dialogs, or navigation. They should not replace shared invalidation or add local cache patches for shared server state.
- For overlays that may open a heavier secondary surface, prefetch server data from the trigger/menu open event with `queryClient.prefetchQuery(queryOptions)` when the primitive exposes `onOpenChange`. Do not mount a hidden component or subscribe to a query only to warm the cache. Do not make an otherwise uncontrolled menu controlled only for prefetching.
- Do not use deprecated `useInvalid` or `useReset`.
- Prefer `mutate(...)`; use `mutateAsync(...)` only when Promise semantics are required, and wrap awaited calls in `try/catch`.
@ -128,6 +134,9 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- 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.
- When opening a dialog from a menu item, keep the menu and dialog as sibling surfaces. Let the menu item command open the dialog through local state or scoped atoms, and mount the dialog outside the menu popup content. Avoid wrapping menu items with dialog triggers when the menu primitive already owns item activation and dismissal behavior.
- For dialogs and alert dialogs, keep the root component responsible for `open` wiring and put query/mutation hooks inside the content component when the work should only mount after the overlay opens. Do not put closed-surface remote work in the root just because the root owns the open atom.
- Prefer uncontrolled overlay roots when the library can own their open state. Use `onOpenChange` for side effects such as prefetching, and CSS/data selectors for visual open-state styling instead of adding controlled state only for observation.
- 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.

View File

@ -0,0 +1,163 @@
import type { Getter } from 'jotai/vanilla'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ScopeProvider } from 'jotai-scope'
import { DeleteDeploymentDialog } from '../delete-dialog'
import {
deleteDeploymentDialogOpenAtom,
deploymentActionAppInstanceIdAtom,
} from '../state'
type QueryOptions = {
input?: unknown
queryKey?: readonly unknown[]
}
type QueryResult = {
data?: unknown
}
const mockQueryResults = vi.hoisted(() => ({
current: new Map<string, QueryResult>(),
}))
const useQueryMock = vi.hoisted(() =>
vi.fn((options: QueryOptions) => {
const queryName = String(options.queryKey?.[0] ?? 'unknown')
const queryResult = mockQueryResults.current.get(queryName)
return {
...options,
data: queryResult?.data,
isError: false,
isFetching: false,
isLoading: false,
isSuccess: Boolean(queryResult?.data),
}
}),
)
const deleteMutationMock = vi.hoisted(() => ({
isPending: false,
mutate: vi.fn(),
}))
const useMutationMock = vi.hoisted(() =>
vi.fn(() => ({
isPending: deleteMutationMock.isPending,
mutate: deleteMutationMock.mutate,
})),
)
const routerMock = vi.hoisted(() => ({
push: vi.fn(),
}))
const toastMock = vi.hoisted(() => ({
error: vi.fn(),
success: vi.fn(),
}))
vi.mock('jotai-tanstack-query', async () => {
const { atom } = await import('jotai')
return {
atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom((get) => {
return useQueryMock(createOptions(get))
}),
}
})
vi.mock('@tanstack/react-query', () => ({
useMutation: useMutationMock,
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: toastMock,
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => routerMock,
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
enterprise: {
appInstanceService: {
getAppInstance: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['getAppInstance', options.input],
}),
},
deleteAppInstance: {
mutationOptions: () => ({ mutationKey: ['deleteAppInstance'] }),
},
},
},
},
}))
function setAppInstance() {
mockQueryResults.current.set('getAppInstance', {
data: {
appInstance: {
id: 'app-instance-1',
displayName: 'Deployment 1',
},
},
})
}
function renderDialog({
open = true,
}: {
open?: boolean
} = {}) {
render(
<ScopeProvider
atoms={[
[deploymentActionAppInstanceIdAtom, 'app-instance-1'],
[deleteDeploymentDialogOpenAtom, open],
]}
name="DeleteDeploymentDialogTest"
>
<DeleteDeploymentDialog />
</ScopeProvider>,
)
}
describe('DeleteDeploymentDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mockQueryResults.current.clear()
deleteMutationMock.isPending = false
setAppInstance()
})
describe('Delete action', () => {
it('should not mount the query or delete mutation before the dialog is opened', () => {
renderDialog({ open: false })
expect(useQueryMock).not.toHaveBeenCalled()
expect(useMutationMock).not.toHaveBeenCalled()
})
it('should delete the deployment through the component mutation', async () => {
const user = userEvent.setup()
renderDialog()
await user.click(screen.getByRole('button', { name: 'deployments.settings.delete' }))
expect(deleteMutationMock.mutate).toHaveBeenCalledWith({
params: {
appInstanceId: 'app-instance-1',
},
}, expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
onSettled: expect.any(Function),
}))
})
})
})

View File

@ -0,0 +1,185 @@
import type { Getter } from 'jotai/vanilla'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ScopeProvider } from 'jotai-scope'
import { EditDeploymentDialog } from '../edit-dialog'
import {
deploymentActionAppInstanceIdAtom,
editDeploymentDialogOpenAtom,
} from '../state'
type QueryOptions = {
input?: unknown
queryKey?: readonly unknown[]
}
type QueryResult = {
data?: unknown
isError?: boolean
isLoading?: boolean
}
const mockQueryResults = vi.hoisted(() => ({
current: new Map<string, QueryResult>(),
}))
const useQueryMock = vi.hoisted(() =>
vi.fn((options: QueryOptions) => {
const queryName = String(options.queryKey?.[0] ?? 'unknown')
const queryResult = mockQueryResults.current.get(queryName)
return {
...options,
data: queryResult?.data,
isError: queryResult?.isError ?? false,
isFetching: false,
isLoading: queryResult?.isLoading ?? false,
isSuccess: Boolean(queryResult?.data),
}
}),
)
const updateMutationMock = vi.hoisted(() => ({
isPending: false,
mutate: vi.fn(),
}))
const useMutationMock = vi.hoisted(() =>
vi.fn(() => ({
isPending: updateMutationMock.isPending,
mutate: updateMutationMock.mutate,
})),
)
const toastMock = vi.hoisted(() => ({
error: vi.fn(),
success: vi.fn(),
}))
vi.mock('jotai-tanstack-query', async () => {
const { atom } = await import('jotai')
return {
atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom((get) => {
return useQueryMock(createOptions(get))
}),
}
})
vi.mock('@tanstack/react-query', () => ({
useMutation: useMutationMock,
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: toastMock,
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
enterprise: {
appInstanceService: {
getAppInstance: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['getAppInstance', options.input],
}),
},
updateAppInstance: {
mutationOptions: () => ({ mutationKey: ['updateAppInstance'] }),
},
deleteAppInstance: {
mutationOptions: () => ({ mutationKey: ['deleteAppInstance'] }),
},
},
},
},
}))
function setAppInstance(overrides: Record<string, unknown> = {}) {
mockQueryResults.current.set('getAppInstance', {
data: {
appInstance: {
id: 'app-instance-1',
displayName: 'Deployment 1',
description: 'Initial description',
...overrides,
},
},
})
}
function setAppInstanceLoading() {
mockQueryResults.current.set('getAppInstance', {
isLoading: true,
})
}
function renderDialog({
open = true,
}: {
open?: boolean
} = {}) {
render(
<ScopeProvider
atoms={[
[deploymentActionAppInstanceIdAtom, 'app-instance-1'],
[editDeploymentDialogOpenAtom, open],
]}
name="EditDeploymentDialogTest"
>
<EditDeploymentDialog />
</ScopeProvider>,
)
}
describe('EditDeploymentDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mockQueryResults.current.clear()
updateMutationMock.isPending = false
setAppInstance()
})
describe('Form submission', () => {
it('should not mount the query or update mutation before the dialog is opened', () => {
renderDialog({ open: false })
expect(useQueryMock).not.toHaveBeenCalled()
expect(useMutationMock).not.toHaveBeenCalled()
})
it('should create the update mutation only after the edit form is ready', () => {
setAppInstanceLoading()
renderDialog()
expect(useMutationMock).not.toHaveBeenCalled()
})
it('should submit trimmed deployment metadata through the component mutation', async () => {
const user = userEvent.setup()
renderDialog()
const dialog = screen.getByRole('dialog', { name: 'deployments.card.menu.editInfo' })
await user.clear(within(dialog).getByRole('textbox', { name: 'deployments.settings.name' }))
await user.type(within(dialog).getByRole('textbox', { name: 'deployments.settings.name' }), ' Deployment 2 ')
await user.clear(within(dialog).getByRole('textbox', { name: 'deployments.settings.description' }))
await user.type(within(dialog).getByRole('textbox', { name: 'deployments.settings.description' }), ' Updated description ')
await user.click(within(dialog).getByRole('button', { name: 'deployments.settings.save' }))
expect(updateMutationMock.mutate).toHaveBeenCalledWith({
params: {
appInstanceId: 'app-instance-1',
},
body: {
appInstanceId: 'app-instance-1',
displayName: 'Deployment 2',
description: 'Updated description',
},
}, expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}))
})
})
})

View File

@ -1,222 +0,0 @@
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,68 +10,80 @@ 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,
deploymentActionAppInstanceIdAtom,
deploymentActionAppInstanceQueryAtom,
} from './state'
export function DeleteDeploymentDialog() {
function DeleteDeploymentDialogContent() {
const { t } = useTranslation('deployments')
const router = useRouter()
const [open, setOpen] = useAtom(deleteDeploymentDialogOpenAtom)
const deleteInstance = useAtomValue(deleteDeploymentInstanceMutationAtom)
const submitDeleteInstance = useSetAtom(submitDeleteDeploymentInstanceAtom)
const displayName = useAtomValue(deploymentActionDisplayNameAtom)
const appInstanceId = useAtomValue(deploymentActionAppInstanceIdAtom)
const setOpen = useSetAtom(deleteDeploymentDialogOpenAtom)
const instanceQuery = useAtomValue(deploymentActionAppInstanceQueryAtom)
const deleteInstance = useMutation(consoleQuery.enterprise.appInstanceService.deleteAppInstance.mutationOptions())
const displayName = instanceQuery.data?.appInstance.displayName || appInstanceId
function handleDelete() {
submitDeleteInstance({
onSuccess: () => {
toast.success(t('settings.deleted'))
router.push('/deployments')
deleteInstance.mutate(
{
params: {
appInstanceId,
},
},
onError: () => {
toast.error(t('settings.deleteFailed'))
{
onSuccess: () => {
toast.success(t('settings.deleted'))
router.push('/deployments')
},
onError: () => {
toast.error(t('settings.deleteFailed'))
},
onSettled: () => {
setOpen(false)
},
},
onSettled: () => {
setOpen(false)
},
})
)
}
return (
<AlertDialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen && deleteInstance.isPending)
return
setOpen(nextOpen)
}}
>
<>
<div className="flex flex-col gap-3 px-6 pt-6 pb-2">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{t('settings.deleteConfirmTitle')}
</AlertDialogTitle>
<AlertDialogDescription className="system-sm-regular text-text-tertiary">
{t('settings.deleteConfirmDesc', { name: displayName })}
</AlertDialogDescription>
</div>
<AlertDialogActions className="pt-3">
<AlertDialogCancelButton variant="secondary" disabled={deleteInstance.isPending}>
{t('createModal.cancel')}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={deleteInstance.isPending}
onClick={handleDelete}
>
{t('settings.delete')}
</AlertDialogConfirmButton>
</AlertDialogActions>
</>
)
}
export function DeleteDeploymentDialog() {
const [open, setOpen] = useAtom(deleteDeploymentDialogOpenAtom)
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent className="w-120">
<div className="flex flex-col gap-3 px-6 pt-6 pb-2">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{t('settings.deleteConfirmTitle')}
</AlertDialogTitle>
<AlertDialogDescription className="system-sm-regular text-text-tertiary">
{t('settings.deleteConfirmDesc', { name: displayName })}
</AlertDialogDescription>
</div>
<AlertDialogActions className="pt-3">
<AlertDialogCancelButton variant="secondary" disabled={deleteInstance.isPending}>
{t('createModal.cancel')}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={deleteInstance.isPending}
onClick={handleDelete}
>
{t('settings.delete')}
</AlertDialogConfirmButton>
</AlertDialogActions>
<DeleteDeploymentDialogContent />
</AlertDialogContent>
</AlertDialog>
)

View File

@ -1,6 +1,5 @@
'use client'
import type { FormEvent } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import {
Dialog,
@ -8,26 +7,45 @@ import {
DialogContent,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { Input } from '@langgenius/dify-ui/input'
import { FieldControl, FieldError, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
import { Form } from '@langgenius/dify-ui/form'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
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'
import {
deploymentActionAppInstanceIdAtom,
deploymentActionAppInstanceQueryAtom,
editDeploymentDescriptionFieldAtom,
editDeploymentDialogOpenAtom,
editDeploymentFormAtom,
editDeploymentFormCanSaveAtom,
editDeploymentFormSavePendingAtom,
editDeploymentNameFieldAtom,
setEditDeploymentDialogOpenAtom,
submitEditDeploymentFormAtom,
updateDeploymentInstanceMutationAtom,
} from './state'
type EditDeploymentFormValues = {
name: string
description: string
}
function normalizedEditDeploymentFormValues(value: EditDeploymentFormValues) {
return {
name: value.name.trim(),
description: value.description.trim(),
}
}
function canSubmitEditDeploymentForm(initialValues: EditDeploymentFormValues, value: EditDeploymentFormValues) {
const normalizedValues = normalizedEditDeploymentFormValues(value)
return Boolean(
normalizedValues.name
&& (
normalizedValues.name !== initialValues.name
|| normalizedValues.description !== initialValues.description
),
)
}
function EditDeploymentFormSkeleton() {
return (
<div className="flex flex-col gap-4">
@ -47,124 +65,142 @@ function EditDeploymentFormSkeleton() {
)
}
function EditDeploymentForm() {
function EditDeploymentForm({
initialValues,
}: {
initialValues: EditDeploymentFormValues
}) {
const { t } = useTranslation('deployments')
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 nameLabel = t('settings.name')
const appInstanceId = useAtomValue(deploymentActionAppInstanceIdAtom)
const setOpen = useSetAtom(editDeploymentDialogOpenAtom)
const updateInstance = useMutation(consoleQuery.enterprise.appInstanceService.updateAppInstance.mutationOptions())
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
event.stopPropagation()
if (!canSave)
function handleClose() {
if (updateInstance.isPending)
return
try {
const didSubmit = await submitEditDeploymentForm()
if (!didSubmit)
return
setOpen(false)
}
toast.success(t('settings.updated'))
setOpen(false)
}
catch {
toast.error(t('settings.updateFailed'))
}
function handleSubmit(values: EditDeploymentFormValues) {
if (!canSubmitEditDeploymentForm(initialValues, values))
return
const normalizedValues = normalizedEditDeploymentFormValues(values)
updateInstance.mutate(
{
params: {
appInstanceId,
},
body: {
appInstanceId,
displayName: normalizedValues.name,
description: normalizedValues.description,
},
},
{
onSuccess: () => {
toast.success(t('settings.updated'))
setOpen(false)
},
onError: () => {
toast.error(t('settings.updateFailed'))
},
},
)
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="deployment-edit-name">
{t('settings.name')}
</label>
<Input
id="deployment-edit-name"
name="name"
type="text"
value={nameField.value}
onChange={event => setNameField(event.target.value)}
className="h-8"
/>
<>
<DialogCloseButton disabled={updateInstance.isPending} />
<Form<EditDeploymentFormValues> className="flex flex-col gap-4" onFormSubmit={handleSubmit}>
<FieldRoot name="name" className="gap-2">
<FieldLabel className="system-xs-medium-uppercase text-text-tertiary">
{nameLabel}
</FieldLabel>
<FieldControl
type="text"
required
defaultValue={initialValues.name}
className="h-8"
/>
<FieldError match="valueMissing">{t('errorMsg.fieldRequired', { ns: 'common', field: nameLabel })}</FieldError>
</FieldRoot>
<FieldRoot name="description" className="gap-2">
<FieldLabel className="system-xs-medium-uppercase text-text-tertiary">
{t('settings.description')}
</FieldLabel>
<Textarea
defaultValue={initialValues.description}
className="min-h-24"
/>
</FieldRoot>
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="secondary"
disabled={updateInstance.isPending}
onClick={handleClose}
>
{t('createModal.cancel')}
</Button>
<Button
type="submit"
variant="primary"
disabled={updateInstance.isPending}
loading={updateInstance.isPending}
>
{t('settings.save')}
</Button>
</div>
</Form>
</>
)
}
function EditDeploymentDialogContent() {
const { t } = useTranslation('deployments')
const instanceQuery = useAtomValue(deploymentActionAppInstanceQueryAtom)
const app = instanceQuery.data?.appInstance
return (
<>
{!app && <DialogCloseButton />}
<div className="border-b border-divider-subtle px-6 py-5">
<DialogTitle className="title-xl-semi-bold text-text-primary">
{t('card.menu.editInfo')}
</DialogTitle>
</div>
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="deployment-edit-description">
{t('settings.description')}
</label>
<Textarea
id="deployment-edit-description"
name="description"
value={descriptionField.value}
onValueChange={value => setDescriptionField(value)}
className="min-h-24"
/>
<div className="px-6 py-5">
{instanceQuery.isLoading
? <EditDeploymentFormSkeleton />
: instanceQuery.isError
? <div className="system-sm-regular text-text-tertiary">{t('common.loadFailed')}</div>
: app
? (
<EditDeploymentForm
key={`${app.id}-${app.displayName}-${app.description}`}
initialValues={{
name: app.displayName,
description: app.description,
}}
/>
)
: <div className="system-sm-regular text-text-tertiary">{t('detail.notFound')}</div>}
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="secondary"
disabled={savePending}
onClick={() => requestOpenChange(false)}
>
{t('createModal.cancel')}
</Button>
<Button
type="submit"
variant="primary"
disabled={!canSave}
loading={savePending}
>
{t('settings.save')}
</Button>
</div>
</form>
</>
)
}
export function EditDeploymentDialog() {
const { t } = useTranslation('deployments')
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'
const [open, setOpen] = useAtom(editDeploymentDialogOpenAtom)
return (
<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">
<DialogTitle className="title-xl-semi-bold text-text-primary">
{t('card.menu.editInfo')}
</DialogTitle>
</div>
<div className="px-6 py-5">
{instanceQuery.isLoading
? <EditDeploymentFormSkeleton />
: instanceQuery.isError
? <div className="system-sm-regular text-text-tertiary">{t('common.loadFailed')}</div>
: app
? (
<ScopeProvider
key={formKey}
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>
<EditDeploymentDialogContent />
</DialogContent>
</Dialog>
)

View File

@ -1,8 +1,37 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DeploymentActionsMenu } from './index'
vi.mock('@langgenius/dify-ui/dropdown-menu', () => import('@/__mocks__/base-ui-dropdown-menu'))
type QueryOptions = {
input?: unknown
queryKey?: readonly unknown[]
}
const editDialogMock = vi.hoisted(() => vi.fn())
const deleteDialogMock = vi.hoisted(() => vi.fn())
const prefetchQueryMock = vi.hoisted(() => vi.fn())
vi.mock('@tanstack/react-query', () => ({
useQueryClient: () => ({
prefetchQuery: prefetchQueryMock,
}),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
enterprise: {
appInstanceService: {
getAppInstance: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['getAppInstance', options.input],
}),
},
},
},
},
}))
vi.mock('./edit-dialog', async () => {
const { useAtomValue } = await import('jotai')
@ -11,6 +40,7 @@ vi.mock('./edit-dialog', async () => {
return {
EditDeploymentDialog: () => {
const open = useAtomValue(editDeploymentDialogOpenAtom)
editDialogMock({ open })
return <div data-testid="edit-dialog" data-open={String(open)} />
},
@ -24,6 +54,7 @@ vi.mock('./delete-dialog', async () => {
return {
DeleteDeploymentDialog: () => {
const open = useAtomValue(deleteDeploymentDialogOpenAtom)
deleteDialogMock({ open })
return <div data-testid="delete-dialog" data-open={String(open)} />
},
@ -31,7 +62,11 @@ vi.mock('./delete-dialog', async () => {
})
describe('DeploymentActionsMenu', () => {
it('keeps the trigger wrapper visible while the menu is open', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('keeps the trigger wrapper visible through uncontrolled menu state', () => {
const { container } = render(
<DeploymentActionsMenu
appInstanceId="app-instance-1"
@ -41,16 +76,17 @@ describe('DeploymentActionsMenu', () => {
)
const wrapper = container.querySelector('[role="presentation"]') as HTMLElement
expect(wrapper).toHaveClass('pointer-events-none', 'opacity-0')
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
expect(screen.getByText('deployments.card.menu.editInfo')).toBeInTheDocument()
expect(wrapper).toHaveClass('pointer-events-auto', 'opacity-100')
expect(wrapper).not.toHaveClass('pointer-events-none', 'opacity-0')
expect(wrapper).toHaveClass(
'pointer-events-none',
'opacity-0',
'[&:has([data-popup-open])]:pointer-events-auto',
'[&:has([data-popup-open])]:opacity-100',
)
})
it('keeps edit and delete dialog open state independent', () => {
it('prefetches the app instance when the menu opens', async () => {
const user = userEvent.setup()
render(
<DeploymentActionsMenu
appInstanceId="app-instance-1"
@ -58,43 +94,50 @@ describe('DeploymentActionsMenu', () => {
/>,
)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(screen.getByText('deployments.card.menu.editInfo'))
expect(prefetchQueryMock).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'deployments.card.moreActions' }))
await screen.findByRole('menuitem', { name: 'deployments.card.menu.editInfo' })
expect(prefetchQueryMock).toHaveBeenCalledWith(expect.objectContaining({
input: {
params: {
appInstanceId: 'app-instance-1',
},
},
queryKey: ['getAppInstance', {
params: {
appInstanceId: 'app-instance-1',
},
}],
}))
})
it('opens edit and delete dialogs from menu items', async () => {
const user = userEvent.setup()
render(
<DeploymentActionsMenu
appInstanceId="app-instance-1"
placement="bottom-end"
/>,
)
expect(screen.getByTestId('edit-dialog')).toHaveAttribute('data-open', 'false')
expect(screen.getByTestId('delete-dialog')).toHaveAttribute('data-open', 'false')
await user.click(screen.getByRole('button', { name: 'deployments.card.moreActions' }))
await user.click(await screen.findByRole('menuitem', { name: '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')
await user.click(screen.getByRole('button', { name: 'deployments.card.moreActions' }))
await user.click(await screen.findByRole('menuitem', { name: 'deployments.card.menu.delete' }))
expect(screen.getByTestId('edit-dialog')).toHaveAttribute('data-open', 'false')
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')
expect(editDialogMock).toHaveBeenLastCalledWith({ open: false })
expect(deleteDialogMock).toHaveBeenLastCalledWith({ open: true })
})
})

View File

@ -9,17 +9,18 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useSetAtom } from 'jotai'
import { useQueryClient } from '@tanstack/react-query'
import { useAtomValue, 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,
deploymentActionAppInstanceIdAtom,
deploymentActionAppInstanceQueryOptionsAtom,
deploymentActionsLocalAtoms,
editDeploymentDialogOpenAtom,
openDeleteDeploymentDialogAtom,
openEditDeploymentDialogAtom,
} from './state'
const ACTION_TRIGGER_CLASS_NAME = cn(
@ -36,37 +37,34 @@ type DeploymentActionsMenuProps = {
sideOffset?: ComponentProps<typeof DropdownMenuContent>['sideOffset']
}
type DeploymentActionsMenuContentProps = Omit<DeploymentActionsMenuProps, 'appInstanceId'>
function DeploymentActionsMenuContent({
className,
triggerClassName,
placement,
sideOffset,
}: DeploymentActionsMenuContentProps) {
}: Omit<DeploymentActionsMenuProps, 'appInstanceId'>) {
const { t } = useTranslation('deployments')
const setEditOpen = useSetAtom(editDeploymentDialogOpenAtom)
const setDeleteOpen = useSetAtom(deleteDeploymentDialogOpenAtom)
const [menuOpen, setMenuOpen] = useState(false)
const queryClient = useQueryClient()
const appInstanceQueryOptions = useAtomValue(deploymentActionAppInstanceQueryOptionsAtom)
const openEditDialog = useSetAtom(openEditDeploymentDialogAtom)
const openDeleteDialog = useSetAtom(openDeleteDeploymentDialogAtom)
function openEditDialog() {
setMenuOpen(false)
setEditOpen(true)
}
function openDeleteDialog() {
setMenuOpen(false)
setDeleteOpen(true)
function handleMenuOpenChange(open: boolean) {
if (open)
void queryClient.prefetchQuery(appInstanceQueryOptions)
}
return (
<div
role="presentation"
className={cn(className, menuOpen && 'pointer-events-auto opacity-100')}
className={cn(
className,
'[&:has([data-popup-open])]:pointer-events-auto [&:has([data-popup-open])]:opacity-100',
)}
onClick={event => event.stopPropagation()}
onKeyDown={event => event.stopPropagation()}
>
<DropdownMenu modal={false} open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenu modal={false} onOpenChange={handleMenuOpenChange}>
<DropdownMenuTrigger
aria-label={t('card.moreActions')}
className={cn(ACTION_TRIGGER_CLASS_NAME, triggerClassName)}
@ -89,7 +87,6 @@ function DeploymentActionsMenuContent({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<EditDeploymentDialog />
<DeleteDeploymentDialog />
</div>
@ -104,7 +101,7 @@ export function DeploymentActionsMenu({
<ScopeProvider
key={appInstanceId}
atoms={[
[deploymentActionAppInstanceIdHydrationAtom, appInstanceId],
[deploymentActionAppInstanceIdAtom, appInstanceId],
...deploymentActionsLocalAtoms,
]}
name="DeploymentActionsMenu"

View File

@ -1,165 +1,39 @@
'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 { atomWithQuery } from 'jotai-tanstack-query'
import { atomWithLazy } from 'jotai/utils'
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 deploymentActionAppInstanceIdAtom = atomWithLazy<string>(() => {
throw new Error('Missing deployment action app instance id.')
})
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) => {
export const deploymentActionAppInstanceQueryOptionsAtom = atom((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,
input: {
params: { appInstanceId },
},
})
})
export const submitEditDeploymentFormAtom = atom(null, async (get, set) => {
const response = await set(submitEditDeploymentInstanceAtom, get(editDeploymentFormValuesAtom))
return Boolean(response)
export const deploymentActionAppInstanceQueryAtom = atomWithQuery((get) => {
return get(deploymentActionAppInstanceQueryOptionsAtom)
})
type DeleteDeploymentInstanceMutationCallbacks = Parameters<ExtractAtomValue<typeof deleteDeploymentInstanceMutationAtom>['mutate']>[1]
export const openEditDeploymentDialogAtom = atom(null, (_get, set) => {
set(deleteDeploymentDialogOpenAtom, false)
set(editDeploymentDialogOpenAtom, true)
})
export const submitDeleteDeploymentInstanceAtom = atom(null, (get, _set, callbacks?: DeleteDeploymentInstanceMutationCallbacks) => {
const appInstanceId = get(deploymentActionAppInstanceIdAtom)
const deleteInstance = get(deleteDeploymentInstanceMutationAtom)
deleteInstance.mutate(
{
params: {
appInstanceId,
},
},
callbacks,
)
export const openDeleteDeploymentDialogAtom = atom(null, (_get, set) => {
set(editDeploymentDialogOpenAtom, false)
set(deleteDeploymentDialogOpenAtom, true)
})
export const deploymentActionsLocalAtoms = [