mirror of
https://github.com/langgenius/dify.git
synced 2026-06-24 04:51:11 +08:00
refactor(web): consolidate deployment state atoms (#37783)
This commit is contained in:
parent
cf1ebdadf5
commit
99c3d7d0f0
@ -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.
|
||||
|
||||
|
||||
@ -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 />
|
||||
</>
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
168
web/features/deployments/components/deployment-actions/state.ts
Normal file
168
web/features/deployments/components/deployment-actions/state.ts
Normal 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
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -59,7 +59,7 @@ function CreateReleaseScopedControl({
|
||||
>
|
||||
{label ?? t('versions.createRelease')}
|
||||
</DialogTrigger>
|
||||
{open && <CreateReleaseDialogContent />}
|
||||
<CreateReleaseDialogContent />
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
|
||||
@ -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) ?? []
|
||||
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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"
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
119
web/features/deployments/detail/__tests__/state.spec.ts
Normal file
119
web/features/deployments/detail/__tests__/state.spec.ts
Normal 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' } },
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()]}
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
32
web/features/deployments/detail/settings-tab/access/state.ts
Normal file
32
web/features/deployments/detail/settings-tab/access/state.ts
Normal 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),
|
||||
})
|
||||
})
|
||||
61
web/features/deployments/detail/state.ts
Normal file
61
web/features/deployments/detail/state.ts
Normal 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),
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
}}
|
||||
>
|
||||
|
||||
@ -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'))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
90
web/features/deployments/detail/versions-tab/state.ts
Normal file
90
web/features/deployments/detail/versions-tab/state.ts
Normal 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
|
||||
@ -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) => {
|
||||
|
||||
@ -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"
|
||||
|
||||
79
web/features/deployments/nav/__tests__/index.spec.tsx
Normal file
79
web/features/deployments/nav/__tests__/index.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
187
web/features/deployments/nav/__tests__/state.spec.ts
Normal file
187
web/features/deployments/nav/__tests__/state.spec.ts
Normal 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',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
|
||||
112
web/features/deployments/nav/state.ts
Normal file
112
web/features/deployments/nav/state.ts
Normal 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)
|
||||
})
|
||||
35
web/features/deployments/route-state-hydrator.tsx
Normal file
35
web/features/deployments/route-state-hydrator.tsx
Normal 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
|
||||
}
|
||||
6
web/features/deployments/route-state.ts
Normal file
6
web/features/deployments/route-state.ts
Normal file
@ -0,0 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { atom } from 'jotai'
|
||||
|
||||
export const deploymentsRouteActiveAtom = atom(false)
|
||||
export const deploymentRouteAppInstanceIdAtom = atom<string | undefined>(undefined)
|
||||
Loading…
Reference in New Issue
Block a user