refactor(web): remove deprecated deployments pages (#40593)

This commit is contained in:
Wu Tianwei
2026-08-12 08:49:51 +00:00
committed by GitHub
parent 51a007e84e
commit 6899c7720f
269 changed files with 61 additions and 29187 deletions
-2
View File
@@ -10,8 +10,6 @@ const config: KnipConfig = {
workspaces: {
web: {
entry: [
// todo: Keep the deploy drawer analyzed while the deployments routes are disabled. Delete this entry when relative files are deleted.
'features/deployments/deploy-drawer/index.tsx!',
'scripts/**/*.{js,ts,mjs}',
'bin/**/*.{js,ts,mjs}',
'tsslint.config.ts',
-6
View File
@@ -1242,9 +1242,6 @@ importers:
'@tailwindcss/typography':
specifier: 'catalog:'
version: 0.5.20(tailwindcss@4.3.3)
'@tanstack/form-core':
specifier: 'catalog:'
version: 1.33.3
'@tanstack/query-core':
specifier: 'catalog:'
version: 5.101.4
@@ -1341,9 +1338,6 @@ importers:
jotai-scope:
specifier: 'catalog:'
version: 0.11.0(jotai@2.20.2(@babel/core@7.29.7(supports-color@11.0.0))(@babel/template@7.29.7)(@types/react@19.2.18)(react@19.2.8))(react@19.2.8)
jotai-tanstack-form:
specifier: workspace:*
version: link:../packages/jotai-tanstack-form
jotai-tanstack-query:
specifier: 'catalog:'
version: 0.11.0(@tanstack/query-core@5.101.4)(@tanstack/react-query@5.101.4(react@19.2.8))(jotai@2.20.2(@babel/core@7.29.7(supports-color@11.0.0))(@babel/template@7.29.7)(@types/react@19.2.18)(react@19.2.8))(react@19.2.8)
@@ -1,5 +0,0 @@
import { DeploymentDetailSidebarSlot } from '../sidebar-page'
export default function DeploymentAccessDetailSidebarSlot() {
return <DeploymentDetailSidebarSlot />
}
@@ -1,5 +0,0 @@
import { DeploymentDetailSidebarSlot } from '../sidebar-page'
export default function DeploymentApiTokensDetailSidebarSlot() {
return <DeploymentDetailSidebarSlot />
}
@@ -1,5 +0,0 @@
import { DeploymentDetailSidebarSlot } from '../sidebar-page'
export default function DeploymentInstancesDetailSidebarSlot() {
return <DeploymentDetailSidebarSlot />
}
@@ -1,5 +0,0 @@
import { DeploymentDetailSidebarSlot } from '../sidebar-page'
export default function DeploymentOverviewDetailSidebarSlot() {
return <DeploymentDetailSidebarSlot />
}
@@ -1,5 +0,0 @@
import { DeploymentDetailSidebarSlot } from './sidebar-page'
export default function DeploymentDetailPageSidebarSlot() {
return <DeploymentDetailSidebarSlot />
}
@@ -1,5 +0,0 @@
import { DeploymentDetailSidebarSlot } from '../sidebar-page'
export default function DeploymentReleasesDetailSidebarSlot() {
return <DeploymentDetailSidebarSlot />
}
@@ -1,5 +0,0 @@
import { DeploymentDetailSidebar } from '@/features/deployments/detail/detail-sidebar'
export function DeploymentDetailSidebarSlot() {
return <DeploymentDetailSidebar />
}
@@ -1,3 +0,0 @@
export default function DeploymentCreateDetailSidebarSlot() {
return null
}
@@ -1,5 +0,0 @@
import { DeploymentAccess } from '@/features/deployments/detail/access'
export default function InstanceDetailAccessPage() {
return <DeploymentAccess />
}
@@ -1,5 +0,0 @@
import { DeploymentApiTokens } from '@/features/deployments/detail/api-tokens'
export default function InstanceDetailApiTokensPage() {
return <DeploymentApiTokens />
}
@@ -1,5 +0,0 @@
import { DeploymentInstances } from '@/features/deployments/detail/instances'
export default function InstanceDetailInstancesPage() {
return <DeploymentInstances />
}
@@ -1,6 +0,0 @@
import type { ReactNode } from 'react'
import { InstanceDetail } from '@/features/deployments/detail'
export default function InstanceDetailLayout({ children }: { children: ReactNode }) {
return <InstanceDetail>{children}</InstanceDetail>
}
@@ -1,5 +0,0 @@
import { DeploymentOverview } from '@/features/deployments/detail/overview'
export default function InstanceDetailOverviewPage() {
return <DeploymentOverview />
}
@@ -1,10 +0,0 @@
import { redirect } from '@/next/navigation'
export default async function InstanceDetailPage({
params,
}: {
params: Promise<{ appInstanceId: string }>
}) {
const { appInstanceId } = await params
redirect(`/deployments/${appInstanceId}/overview`)
}
@@ -1,5 +0,0 @@
import { DeploymentReleases } from '@/features/deployments/detail/releases'
export default function InstanceDetailReleasesPage() {
return <DeploymentReleases />
}
@@ -1,23 +0,0 @@
const mocks = vi.hoisted(() => ({
notFound: vi.fn(() => {
throw new Error('NEXT_NOT_FOUND')
}),
}))
vi.mock('@/next/navigation', () => ({
notFound: () => mocks.notFound(),
}))
describe('DeploymentsLayout', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should always trigger notFound', async () => {
const { default: DeploymentsLayout } = await import('../layout')
expect(() => DeploymentsLayout()).toThrow('NEXT_NOT_FOUND')
expect(mocks.notFound).toHaveBeenCalledTimes(1)
})
})
@@ -1,12 +0,0 @@
'use client'
import { useTranslation } from 'react-i18next'
import { CreateDeploymentGuide } from '@/features/deployments/create-guide'
import useDocumentTitle from '@/hooks/use-document-title'
export default function CreateDeploymentPage() {
const { t } = useTranslation('deployments')
useDocumentTitle(t(($) => $['documentTitle.create']))
return <CreateDeploymentGuide />
}
@@ -1,5 +0,0 @@
import { notFound } from '@/next/navigation'
export default function DeploymentsLayout() {
notFound()
}
@@ -1,10 +0,0 @@
'use client'
import { useTranslation } from 'react-i18next'
import { DeploymentsList } from '@/features/deployments/list'
import useDocumentTitle from '@/hooks/use-document-title'
export default function DeploymentsPage() {
const { t } = useTranslation('deployments')
useDocumentTitle(t(($) => $['documentTitle.list']))
return <DeploymentsList />
}
+7 -10
View File
@@ -2,7 +2,6 @@ import * as React from 'react'
import Zendesk from '@/app/components/base/zendesk'
import MaintenanceNotice from '@/app/components/header/maintenance-notice'
import MainNavLayout from '@/app/components/main-nav/layout'
import { NextRouteStateBridge } from '@/app/components/next-route-state'
import { CommonLayoutGlobalMounts } from './global-mounts'
import { ConsoleContextProviders, ConsoleRuntimeProviders } from './providers'
@@ -16,15 +15,13 @@ export default async function Layout({
return (
<React.Fragment>
<ConsoleRuntimeProviders>
<NextRouteStateBridge>
<div className="flex h-full flex-col overflow-hidden">
<MaintenanceNotice />
<ConsoleContextProviders>
<MainNavLayout detailSidebar={detailSidebar}>{children}</MainNavLayout>
<CommonLayoutGlobalMounts />
</ConsoleContextProviders>
</div>
</NextRouteStateBridge>
<div className="flex h-full flex-col overflow-hidden">
<MaintenanceNotice />
<ConsoleContextProviders>
<MainNavLayout detailSidebar={detailSidebar}>{children}</MainNavLayout>
<CommonLayoutGlobalMounts />
</ConsoleContextProviders>
</div>
</ConsoleRuntimeProviders>
<Zendesk />
</React.Fragment>
@@ -720,12 +720,6 @@ describe('MainNav', () => {
).not.toBeInTheDocument()
})
it('hides deployments in primary navigation when app deploy is enabled', () => {
renderMainNav({ branding: { enabled: false }, enable_app_deploy: true })
expect(screen.queryByRole('link', { name: /common.menus.deployments/ })).not.toBeInTheDocument()
})
it('orders the Step-by-step Tour before the account and help actions', async () => {
localStorage.setItem(STEP_BY_STEP_TOUR_SHELL_MODE_STORAGE_KEY, 'collapsed')
@@ -811,7 +805,6 @@ describe('MainNav', () => {
'href',
'/marketplace',
)
expect(screen.queryByRole('link', { name: /common.menus.deployments/ })).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'explore.sidebar.webApps' }),
).not.toBeInTheDocument()
@@ -831,14 +824,13 @@ describe('MainNav', () => {
workspacePermissionKeys: ['app_library.access', 'tool.manage', 'agent.manage'],
}
renderMainNav({ branding: { enabled: false }, enable_app_deploy: true })
renderMainNav({ branding: { enabled: false } })
expect(screen.getByRole('link', { name: /common.mainNav.home/ })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /common.menus.apps/ })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /Agents/ })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /common.menus.datasets/ })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /common.mainNav.integrations/ })).toBeInTheDocument()
expect(screen.queryByRole('link', { name: /common.menus.deployments/ })).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: /common.mainNav.marketplace/ })).toBeInTheDocument()
})
@@ -892,24 +884,6 @@ describe('MainNav', () => {
expect(screen.getByRole('link', { name: /common.menus.apps/ })).toHaveAttribute('href', '/apps')
})
it.each(['/deployments', '/deployments/create'])(
'keeps global navigation on deployment collection route %s',
(pathname) => {
mockPathname = pathname
renderMainNav({ branding: { enabled: false }, enable_app_deploy: true })
expect(screen.queryByTestId('deployment-detail-top')).not.toBeInTheDocument()
expect(screen.queryByTestId('deployment-detail-section')).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }),
).toBeInTheDocument()
expect(
screen.queryByRole('link', { name: /common.menus.deployments/ }),
).not.toBeInTheDocument()
},
)
it.each([
'/datasets/create',
'/datasets/create-from-pipeline',
@@ -1,6 +1,5 @@
import type { ReactNode } from 'react'
import type { Mock } from 'vitest'
import { useSuspenseQuery } from '@tanstack/react-query'
import { fireEvent, screen } from '@testing-library/react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag'
@@ -11,7 +10,6 @@ import MainNavLayout from '../layout'
const mockConsoleState = vi.hoisted(() => ({
current: {
isCurrentWorkspaceDatasetOperator: false,
isCurrentWorkspaceEditor: true,
},
}))
@@ -24,14 +22,6 @@ vi.mock('@/app/components/header/header-wrapper', () => ({
<div data-testid="header-wrapper">{children}</div>
),
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useSuspenseQuery: vi.fn(),
}
})
vi.mock('@/context/workspace-state', async () => {
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
return createWorkspaceStateModuleMock(() => mockConsoleState.current)
@@ -65,13 +55,7 @@ describe('MainNavLayout', () => {
;(usePathname as Mock).mockReturnValue('/apps')
mockConsoleState.current = {
isCurrentWorkspaceDatasetOperator: false,
isCurrentWorkspaceEditor: true,
}
;(useSuspenseQuery as Mock).mockReturnValue({
data: {
enable_app_deploy: true,
},
})
;(isAgentV2Enabled as Mock).mockReturnValue(true)
})
@@ -196,64 +180,29 @@ describe('MainNavLayout', () => {
expect(screen.getByRole('main')).toHaveTextContent('new knowledge detail')
})
it.each([
'/datasets/create',
'/datasets/new/create',
'/datasets/dataset-1/documents/create',
'/deployments/create',
])('keeps the global main nav on collection and creation route %s', (pathname) => {
;(usePathname as Mock).mockReturnValue(pathname)
it.each(['/datasets/create', '/datasets/new/create', '/datasets/dataset-1/documents/create'])(
'keeps the global main nav on collection and creation route %s',
(pathname) => {
;(usePathname as Mock).mockReturnValue(pathname)
render(
<MainNavLayout detailSidebar={<aside aria-label="Detail sidebar">Detail sidebar</aside>}>
<div>content</div>
</MainNavLayout>,
)
render(
<MainNavLayout detailSidebar={<aside aria-label="Detail sidebar">Detail sidebar</aside>}>
<div>content</div>
</MainNavLayout>,
)
expect(screen.getByTestId('main-nav')).toBeInTheDocument()
expect(screen.queryByRole('complementary', { name: 'Detail sidebar' })).not.toBeInTheDocument()
})
expect(screen.getByTestId('main-nav')).toBeInTheDocument()
expect(
screen.queryByRole('complementary', { name: 'Detail sidebar' }),
).not.toBeInTheDocument()
},
)
it.each([
{
label: 'agent detail route for dataset operators',
pathname: '/agents/agent-1/configure',
consoleState: {
isCurrentWorkspaceDatasetOperator: true,
isCurrentWorkspaceEditor: true,
},
systemFeatures: {
enable_app_deploy: true,
},
},
{
label: 'deployment detail route for non-editor workspaces',
pathname: '/deployments/app-instance-1/overview',
consoleState: {
isCurrentWorkspaceDatasetOperator: false,
isCurrentWorkspaceEditor: false,
},
systemFeatures: {
enable_app_deploy: true,
},
},
{
label: 'deployment detail route when deployment is disabled',
pathname: '/deployments/app-instance-1/overview',
consoleState: {
isCurrentWorkspaceDatasetOperator: false,
isCurrentWorkspaceEditor: true,
},
systemFeatures: {
enable_app_deploy: false,
},
},
])('keeps the global main nav on $label', ({ pathname, consoleState, systemFeatures }) => {
;(usePathname as Mock).mockReturnValue(pathname)
mockConsoleState.current = consoleState
;(useSuspenseQuery as Mock).mockReturnValue({
data: systemFeatures,
})
it('keeps the global main nav on agent detail routes for dataset operators', () => {
;(usePathname as Mock).mockReturnValue('/agents/agent-1/configure')
mockConsoleState.current = {
isCurrentWorkspaceDatasetOperator: true,
}
render(
<MainNavLayout detailSidebar={<aside aria-label="Detail sidebar">Detail sidebar</aside>}>
+1 -8
View File
@@ -10,10 +10,7 @@ import Badge from '@/app/components/base/badge'
import { DifyLogo } from '@/app/components/base/logo/dify-logo'
import EnvNav from '@/app/components/header/env-nav'
import StepByStepTourMount from '@/app/components/step-by-step-tour/mount'
import {
isCurrentWorkspaceDatasetOperatorAtom,
isCurrentWorkspaceEditorAtom,
} from '@/context/workspace-state'
import { isCurrentWorkspaceDatasetOperatorAtom } from '@/context/workspace-state'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag'
import { useCanManageAgents } from '@/features/agent-v2/permissions'
@@ -34,7 +31,6 @@ export function MainNav({ className }: MainNavProps) {
const { t } = useTranslation()
const pathname = usePathname()
const isCurrentWorkspaceDatasetOperator = useAtomValue(isCurrentWorkspaceDatasetOperatorAtom)
const isCurrentWorkspaceEditor = useAtomValue(isCurrentWorkspaceEditorAtom)
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { data: currentEnv } = useSuspenseQuery({
...userProfileQueryOptions(),
@@ -43,7 +39,6 @@ export function MainNav({ className }: MainNavProps) {
const agentV2Enabled = isAgentV2Enabled()
const canManageAgents = useCanManageAgents()
const showEnvTag = currentEnv === 'TESTING' || currentEnv === 'DEVELOPMENT'
const canUseAppDeploy = isCurrentWorkspaceEditor && systemFeatures.enable_app_deploy
const navItems = useMemo<MainNavItem[]>(
() =>
@@ -51,7 +46,6 @@ export function MainNav({ className }: MainNavProps) {
isMainNavRouteVisible(route, {
agentV2Enabled,
canManageAgents,
canUseAppDeploy,
isCurrentWorkspaceDatasetOperator,
marketplaceEnabled: systemFeatures.enable_marketplace,
}),
@@ -65,7 +59,6 @@ export function MainNav({ className }: MainNavProps) {
[
agentV2Enabled,
canManageAgents,
canUseAppDeploy,
isCurrentWorkspaceDatasetOperator,
systemFeatures.enable_marketplace,
t,
+1 -9
View File
@@ -1,18 +1,13 @@
'use client'
import type { ReactNode } from 'react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
import { useStore as useAppStore } from '@/app/components/app/store'
import {
isCurrentWorkspaceDatasetOperatorAtom,
isCurrentWorkspaceEditorAtom,
} from '@/context/workspace-state'
import { isCurrentWorkspaceDatasetOperatorAtom } from '@/context/workspace-state'
import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { usePathname } from '@/next/navigation'
import { MainNav } from '.'
import { shouldHideMainNavigation, shouldUseDetailSidebar } from './routes'
@@ -45,12 +40,9 @@ const MainNavLayout = ({ children, detailSidebar }: MainNavLayoutProps) => {
const { t } = useTranslation('common')
const pathname = usePathname()
const isCurrentWorkspaceDatasetOperator = useAtomValue(isCurrentWorkspaceDatasetOperatorAtom)
const isCurrentWorkspaceEditor = useAtomValue(isCurrentWorkspaceEditorAtom)
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const hideMainNavigation = shouldHideMainNavigation(pathname)
const useDetailSidebar = shouldUseDetailSidebar(pathname, {
agentV2Enabled: isAgentV2Enabled(),
canUseAppDeploy: isCurrentWorkspaceEditor && systemFeatures.enable_app_deploy,
isCurrentWorkspaceDatasetOperator,
})
+2 -14
View File
@@ -4,7 +4,6 @@ type MainNavRouteVisibility = (options: MainNavRouteVisibilityOptions) => boolea
const DATASET_COLLECTION_ROUTES = new Set(['create', 'create-from-pipeline', 'connect'])
const DATASET_DOCUMENT_CREATION_ROUTES = new Set(['create', 'create-from-pipeline'])
const DEPLOYMENT_COLLECTION_ROUTES = new Set(['create'])
export type MainNavRouteConfig = {
key: string
@@ -19,14 +18,13 @@ export type MainNavRouteConfig = {
export type MainNavRouteVisibilityOptions = {
agentV2Enabled: boolean
canManageAgents: boolean
canUseAppDeploy: boolean
isCurrentWorkspaceDatasetOperator: boolean
marketplaceEnabled: boolean
}
export type DetailSidebarVisibilityOptions = Pick<
MainNavRouteVisibilityOptions,
'agentV2Enabled' | 'canUseAppDeploy' | 'isCurrentWorkspaceDatasetOperator'
'agentV2Enabled' | 'isCurrentWorkspaceDatasetOperator'
>
const VISIBLE_TO_ALL: MainNavRouteVisibility = () => true
@@ -147,14 +145,6 @@ function isAgentDetailPathname(pathname: string) {
return section === 'agents' && !!agentId
}
function isDeploymentDetailPathname(pathname: string) {
const [section, appInstanceId] = pathname.split('/').filter(Boolean)
return (
section === 'deployments' && !!appInstanceId && !DEPLOYMENT_COLLECTION_ROUTES.has(appInstanceId)
)
}
function isSnippetDetailPathname(pathname: string) {
const [section, snippetId] = pathname.split('/').filter(Boolean)
@@ -168,7 +158,5 @@ export function shouldUseDetailSidebar(pathname: string, options: DetailSidebarV
if (isAppDetailPathname(pathname)) return true
if (options.agentV2Enabled && isAgentDetailPathname(pathname)) return true
return options.canUseAppDeploy && isDeploymentDetailPathname(pathname)
return options.agentV2Enabled && isAgentDetailPathname(pathname)
}
@@ -1,50 +0,0 @@
import { atom } from 'jotai'
export type NextRouteParams = Record<string, string | string[]>
type NextRouteState = {
pathname: string
params: NextRouteParams
}
// Mirrors Next router state. NextRouteStateBridge force-hydrates this atom on
// render so feature atoms can read route state without calling router hooks.
const nextRouteStateAtom = atom<NextRouteState>({
pathname: '',
params: {},
})
function normalizedParamEntries(params: NextRouteParams) {
return Object.keys(params)
.sort()
.map((key) => {
const value = params[key]
return [key, Array.isArray(value) ? [...value] : value] as const
})
}
function normalizeNextRouteParams(params: NextRouteParams): NextRouteParams {
return Object.fromEntries(normalizedParamEntries(params)) as NextRouteParams
}
function routeParamsKey(params: NextRouteParams) {
return JSON.stringify(normalizedParamEntries(params))
}
export const nextParamsAtom = atom((get) => get(nextRouteStateAtom).params)
export const nextPathnameAtom = atom((get) => get(nextRouteStateAtom).pathname)
export const setNextRouteStateAtom = atom(null, (get, set, routeState: NextRouteState) => {
const nextParams = normalizeNextRouteParams(routeState.params)
const currentRouteState = get(nextRouteStateAtom)
if (
currentRouteState.pathname !== routeState.pathname ||
routeParamsKey(currentRouteState.params) !== routeParamsKey(nextParams)
) {
set(nextRouteStateAtom, {
pathname: routeState.pathname,
params: nextParams,
})
}
})
@@ -1,27 +0,0 @@
'use client'
import type { ReactNode } from 'react'
import type { NextRouteParams } from './atoms'
import { useHydrateAtoms } from 'jotai/utils'
import { useParams, usePathname } from '@/next/navigation'
import { setNextRouteStateAtom } from './atoms'
export function NextRouteStateBridge({ children }: { children: ReactNode }) {
const pathname = usePathname()
const params = useParams<NextRouteParams>()
useHydrateAtoms(
[
[
setNextRouteStateAtom,
{
pathname,
params,
},
],
] as const,
{ dangerouslyForceHydrate: true },
)
return children
}
+5
View File
@@ -32,6 +32,11 @@ export const isCurrentWorkspaceManagerAtom = atom((get) => {
return get(workspaceRoleFlagsAtom).isCurrentWorkspaceManager
})
/**
* Retained for future permission checks when RBAC is disabled.
*
* @public
*/
export const isCurrentWorkspaceEditorAtom = atom((get) => {
return get(workspaceRoleFlagsAtom).isCurrentWorkspaceEditor
})
-101
View File
@@ -1,101 +0,0 @@
# Deployments
Deployment app instance, release, runtime target, access, and developer API feature modules.
## Internal Module Dependency Graph
The graph shows product module dependencies inside `web/features/deployments`. It omits route-state plumbing and shared support modules.
```mermaid
flowchart TD
list["list"]
detail["detail"]
createGuide["create-guide"]
createRelease["create-release"]
deployDrawer["deploy-drawer"]
deploymentActions["deployment-actions"]
detailOverview["detail/overview"]
detailInstances["detail/instances"]
detailAccess["detail/access"]
detailApiTokens["detail/api-tokens"]
detailReleases["detail/releases"]
accessChannels["detail/access/channels"]
accessPermissions["detail/access/permissions"]
accessSubjectSelector["detail/access/permissions/access-subject-selector"]
apiTokenManagement["detail/api-tokens/api-token-management"]
apiKeys["detail/api-tokens/api-keys"]
apiDocs["detail/api-tokens/docs"]
instanceEnvironmentList["detail/instances/environment-list"]
instanceHeaderActions["detail/instances/header-actions"]
instanceRowActions["detail/instances/row-actions"]
overviewAccessSummary["detail/overview/access-summary"]
overviewEnvironmentStatus["detail/overview/environment-status"]
overviewReleaseSummary["detail/overview/release-summary"]
releaseActions["detail/releases/release-actions"]
releaseHistory["detail/releases/release-history"]
list --> createGuide
list --> createRelease
list --> deployDrawer
list --> deploymentActions
list --> detail
detail --> createRelease
detail --> deploymentActions
detail --> detailOverview
detail --> detailInstances
detail --> detailAccess
detail --> detailApiTokens
detail --> detailReleases
detail --> instanceHeaderActions
detailOverview --> overviewAccessSummary
detailOverview --> overviewEnvironmentStatus
detailOverview --> overviewReleaseSummary
overviewEnvironmentStatus --> deployDrawer
overviewReleaseSummary --> createRelease
detailInstances --> detail
detailInstances --> instanceEnvironmentList
detailInstances --> instanceHeaderActions
instanceEnvironmentList --> detailInstances
instanceEnvironmentList --> instanceRowActions
instanceHeaderActions --> deployDrawer
instanceHeaderActions --> detail
instanceRowActions --> deployDrawer
detailAccess --> accessChannels
detailAccess --> accessPermissions
accessChannels --> detailAccess
accessPermissions --> detailAccess
accessPermissions --> accessSubjectSelector
detailApiTokens --> apiTokenManagement
apiTokenManagement --> detailApiTokens
apiTokenManagement --> apiKeys
apiTokenManagement --> apiDocs
apiKeys --> detailApiTokens
detailReleases --> releaseHistory
releaseActions --> deployDrawer
releaseHistory --> detailReleases
releaseHistory --> releaseActions
```
## Internal Modules
| Module | Why this module uses it |
| -------------------- | ------------------------------------------------------------- |
| `list` | Owns the deployment app instance list surface. |
| `detail` | Owns the deployment app instance detail shell and route tabs. |
| `create-guide` | Owns the create deployment guide workflow. |
| `create-release` | Owns release creation entry points and dialog state. |
| `deploy-drawer` | Owns deployment target selection and submit workflow state. |
| `deployment-actions` | Owns app instance edit and delete action surfaces. |
## External Modules
None.
@@ -1,18 +0,0 @@
# Deployment Create Guide
Guided deployment creation workflow for selecting a source, defining the initial release, and optionally deploying to an environment.
## Internal Modules
| Module | Why this module uses it |
| -------- | ------------------------------------------------------------------------------ |
| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. |
## External Modules
| Module | Why this module uses it |
| --------------------------------------------------- | -------------------------------------------------------------------------------- |
| `app/components/app/create-from-dsl-modal/uploader` | Reuses the existing DSL file uploader for the import source step. |
| `app/components/base/app-icon` | Renders app icons consistently in source app options. |
| `app/components/base/skeleton` | Reuses the existing skeleton primitive for source and target loading states. |
| `types/app` | Uses app types and mode enums to narrow selectable source apps to workflow apps. |
@@ -1,38 +0,0 @@
'use client'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { useTranslation } from 'react-i18next'
import Link from '@/next/link'
import { useRouter } from '@/next/navigation'
import { CreateDeploymentGuideProvider } from './state/provider'
import { CreateDeploymentGuideShell } from './ui/shell'
export function CreateDeploymentGuide() {
const { t } = useTranslation('deployments')
const router = useRouter()
return (
<Dialog open onOpenChange={(open) => !open && router.push('/deployments')}>
<DialogContent
backdropClassName="bg-background-overlay-backdrop backdrop-blur-[6px]"
className="top-4 bottom-4 h-auto max-h-none w-[min(calc(100vw-2rem),1120px)] max-w-none translate-y-0 overflow-hidden border-effects-highlight bg-background-default-subtle p-0"
>
<div className="relative flex h-full min-w-0 grow flex-col overflow-hidden">
<Link
href="/deployments"
aria-label={t(($) => $['createGuide.nav.back'])}
className="absolute top-3 right-3 z-50 flex h-9 w-9 cursor-pointer items-center justify-center rounded-[10px] bg-components-button-tertiary-bg hover:bg-components-button-tertiary-bg-hover"
>
<span
aria-hidden="true"
className="i-ri-close-large-line h-3.5 w-3.5 text-components-button-tertiary-text"
/>
</Link>
<CreateDeploymentGuideProvider>
<CreateDeploymentGuideShell />
</CreateDeploymentGuideProvider>
</div>
</DialogContent>
</Dialog>
)
}
@@ -1,12 +0,0 @@
'use client'
import type { ComponentProps } from 'react'
import Link from '@/next/link'
const createDeploymentGuideHref = '/deployments/create'
type CreateDeploymentGuideLinkProps = Omit<ComponentProps<typeof Link>, 'href'>
export function CreateDeploymentGuideLink(props: CreateDeploymentGuideLinkProps) {
return <Link {...props} href={createDeploymentGuideHref} />
}
@@ -1,202 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createQueryAtomTestStore } from '@/test/query-atom'
import {
dslFileAtom,
effectiveMethodAtom,
instanceNameAtom,
methodAtom,
releaseNameAtom,
selectedAppAtom,
stepAtom,
} from '../primitives'
import {
dslDefaultAppNameAtom,
dslReadErrorAtom,
isReadingDslAtom,
sourceAppsQueryAtom,
} from '../source'
import {
continueFromSourceAtom,
selectDslFileAtom,
selectMethodAtom,
sourceCanGoNextAtom,
} from '../workflow'
type QueryOptions = {
enabled?: boolean
input?: unknown
queryKey?: readonly unknown[]
retry?: boolean
}
type InfiniteQueryOptions = QueryOptions & {
input?: (pageParam: number) => unknown
}
const queryOptionsMocks = vi.hoisted(() => ({
sourceApps: vi.fn(),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
apps: {
get: {
infiniteOptions: (options: InfiniteQueryOptions) => {
queryOptionsMocks.sourceApps(options)
return {
...options,
queryKey: ['sourceApps'],
queryFn: async () => ({ data: [], has_more: false, page: 1 }),
}
},
},
},
enterprise: {
appInstanceService: {
listAppInstances: {
infiniteOptions: (options: InfiniteQueryOptions) => ({
...options,
queryKey: ['existingInstanceNames'],
queryFn: async () => ({ appInstances: [], pagination: {} }),
}),
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['instanceNameConflict'],
queryFn: async () => ({ appInstances: [] }),
}),
},
},
environmentService: {
listEnvironments: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['environments'],
queryFn: async () => ({ environments: [] }),
}),
},
},
releaseService: {
computeDeploymentOptions: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['deploymentOptions'],
queryFn: async () => ({ options: { credentialSlots: [], envVarSlots: [] } }),
}),
},
precheckRelease: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['precheckRelease'],
queryFn: async () => ({ canCreate: false, unsupportedNodes: [] }),
}),
},
},
},
},
}))
function workflowDsl() {
return ['app:', ' mode: workflow', ' name: Imported guide'].join('\n')
}
describe('create deployment guide state', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should keep the guide on source app mode when DSL import is disabled', () => {
const { store } = createQueryAtomTestStore()
store.set(selectMethodAtom, 'importDsl')
expect(store.get(methodAtom)).toBe('bindApp')
expect(store.get(effectiveMethodAtom)).toBe('bindApp')
})
it('should keep source app loading enabled if stale state points to DSL import', () => {
const { store } = createQueryAtomTestStore()
store.set(methodAtom, 'importDsl')
store.get(sourceAppsQueryAtom)
expect(store.get(effectiveMethodAtom)).toBe('bindApp')
expect(queryOptionsMocks.sourceApps).toHaveBeenLastCalledWith(
expect.objectContaining({ enabled: true }),
)
})
it('should continue from source app mode and auto-fill unique release metadata', () => {
const { queryClient, store } = createQueryAtomTestStore()
queryClient.setQueryData(['sourceApps'], {
pages: [
{
data: [
{
id: 'source-app-1',
name: 'Customer Service',
mode: 'workflow',
},
],
},
],
pageParams: [1],
})
queryClient.setQueryData(['precheckRelease'], {
canCreate: true,
unsupportedNodes: [],
})
queryClient.setQueryData(['deploymentOptions'], {
options: {
credentialSlots: [],
envVarSlots: [],
},
})
queryClient.setQueryData(['existingInstanceNames'], {
pages: [
{
appInstances: [
{ displayName: 'Customer Service' },
{ displayName: 'Customer Service 1' },
],
},
],
pageParams: [1],
})
expect(store.get(sourceCanGoNextAtom)).toBe(true)
store.set(continueFromSourceAtom, {
defaultDslAppName: 'Imported DSL',
defaultReleaseName: 'Initial Release',
})
expect(store.get(selectedAppAtom)).toMatchObject({
id: 'source-app-1',
name: 'Customer Service',
})
expect(store.get(instanceNameAtom)).toMatch(/^Customer Service-[a-z]{4}$/)
expect(store.get(releaseNameAtom)).toBe('Initial Release')
expect(store.get(stepAtom)).toBe('release')
})
it('should read selected DSL file content through the file content query', () => {
const { queryClient, store } = createQueryAtomTestStore()
const text = vi.fn().mockResolvedValue(workflowDsl())
const file = new File([], 'workflow.yml', { type: 'text/yaml' })
Object.defineProperty(file, 'text', { value: text })
store.set(selectDslFileAtom, file)
queryClient.setQueryData(
['createGuideDslFileContent', 1, file, file.name, file.size, file.lastModified],
workflowDsl(),
)
expect(text).not.toHaveBeenCalled()
expect(store.get(dslFileAtom)).toBe(file)
expect(store.get(dslDefaultAppNameAtom)).toBe('Imported guide')
expect(store.get(isReadingDslAtom)).toBe(false)
expect(store.get(dslReadErrorAtom)).toBe(false)
})
})
@@ -1,8 +0,0 @@
import type { Environment } from '@dify/contracts/enterprise/types.gen'
export function environmentMatchesIdentifier(environment: Environment, identifier: string) {
const normalizedIdentifier = identifier.trim()
if (!normalizedIdentifier) return false
return environment.id === normalizedIdentifier || environment.displayName === normalizedIdentifier
}
@@ -1,37 +0,0 @@
'use client'
import type { GuideMethod, GuideStep, WorkflowSourceApp } from './types'
import type { EnvVarValues } from '@/features/deployments/shared/components/env-var-bindings'
import type { RuntimeCredentialBindingSelections } from '@/features/deployments/shared/components/runtime-credential-bindings-utils'
import type { UnsupportedDslNode } from '@/features/deployments/shared/domain/error'
import { atom } from 'jotai'
import { deploymentGuideMethod } from './utils'
export const stepAtom = atom<GuideStep>('source')
export const methodAtom = atom<GuideMethod>('bindApp')
export const effectiveMethodAtom = atom((get) => deploymentGuideMethod(get(methodAtom)))
export const sourceSearchTextAtom = atom('')
export const selectedAppAtom = atom<WorkflowSourceApp | undefined>(undefined)
export const dslFileAtom = atom<File | undefined>(undefined)
export const dslFileReadVersionAtom = atom(0)
export const instanceNameAtom = atom('')
export const instanceDescriptionAtom = atom('')
export const releaseNameAtom = atom('')
export const releaseDescriptionAtom = atom('')
export const autoFilledInstanceNameAtom = atom('')
export const autoFilledReleaseNameAtom = atom('')
export const selectedEnvironmentIdAtom = atom('')
export const manualBindingSelectionsAtom = atom<RuntimeCredentialBindingSelections>({})
export const envVarValuesAtom = atom<EnvVarValues>({})
export const submissionUnsupportedDslNodesAtom = atom<UnsupportedDslNode[]>([])
export const isCreatingDeploymentAtom = atom(false)
export const isCreatingReleaseOnlyAtom = atom(false)
export const isSubmittingDeploymentGuideAtom = atom(
(get) => get(isCreatingDeploymentAtom) || get(isCreatingReleaseOnlyAtom),
)
@@ -1,9 +0,0 @@
'use client'
import type { ReactNode } from 'react'
import { ScopeProvider } from 'jotai-scope'
import { createDeploymentGuideScopedAtoms } from './scoped'
export function CreateDeploymentGuideProvider({ children }: { children: ReactNode }) {
return <ScopeProvider atoms={createDeploymentGuideScopedAtoms}>{children}</ScopeProvider>
}
@@ -1,225 +0,0 @@
'use client'
import type { Getter } from 'jotai/vanilla'
import { keepPreviousData, skipToken } from '@tanstack/react-query'
import { atom } from 'jotai'
import { atomWithInfiniteQuery, atomWithQuery } from 'jotai-tanstack-query'
import { selectAtom } from 'jotai/utils'
import { encodeDslContent } from '@/features/deployments/shared/domain/dsl'
import { consoleQuery } from '@/service/client'
import {
effectiveMethodAtom,
instanceNameAtom,
submissionUnsupportedDslNodesAtom,
} from './primitives'
import { dslContentAtom, effectiveSelectedAppAtom, sourceReady } from './source'
import { DEPLOYMENT_PAGE_SIZE, getNextPageParamFromPagination } from './utils'
export const existingInstanceNamesQueryAtom = atomWithInfiniteQuery(() =>
consoleQuery.enterprise.appInstanceService.listAppInstances.infiniteOptions({
input: (pageParam) => ({
query: {
pageNumber: Number(pageParam),
resultsPerPage: DEPLOYMENT_PAGE_SIZE,
},
}),
getNextPageParam: (lastPage) => getNextPageParamFromPagination(lastPage.pagination),
initialPageParam: 1,
placeholderData: keepPreviousData,
}),
)
export const instanceNameConflictQueryAtom = atomWithQuery((get) => {
const submittedInstanceName = get(instanceNameAtom).trim()
return consoleQuery.enterprise.appInstanceService.listAppInstances.queryOptions({
input: {
query: {
pageNumber: 1,
resultsPerPage: 1,
displayName: submittedInstanceName,
},
},
enabled: Boolean(submittedInstanceName),
})
})
export const deployableEnvironmentsQueryAtom = atomWithQuery((get) => {
return consoleQuery.enterprise.environmentService.listEnvironments.queryOptions({
input: {
query: {
// The guide offers every deployable environment at once; environment
// count is capped well below the 100-per-page maximum.
pageNumber: 1,
resultsPerPage: 100,
},
},
enabled: sourceReady(get),
})
})
export const deployableEnvironmentsDataAtom = selectAtom(
deployableEnvironmentsQueryAtom,
(query) => query.data,
)
export const deployableEnvironmentsIsErrorAtom = selectAtom(
deployableEnvironmentsQueryAtom,
(query) => query.isError,
)
export const deployableEnvironmentsIsLoadingAtom = selectAtom(
deployableEnvironmentsQueryAtom,
(query) => query.isLoading,
)
export const deployableEnvironmentsIsFetchingAtom = selectAtom(
deployableEnvironmentsQueryAtom,
(query) => query.isFetching,
)
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: encodedDslContent
? {
body: {
dsl: encodedDslContent,
},
}
: skipToken,
enabled,
retry: false,
})
: consoleQuery.enterprise.releaseService.precheckRelease.queryOptions({
input: effectiveSelectedApp?.id
? {
body: {
sourceAppId: effectiveSelectedApp.id,
},
}
: skipToken,
enabled: enabled && Boolean(effectiveSelectedApp?.id),
retry: false,
})
return precheckReleaseQueryOptions
})
const precheckReleaseDataAtom = selectAtom(precheckReleaseQueryAtom, (query) => query.data)
const precheckReleaseIsSuccessAtom = selectAtom(
precheckReleaseQueryAtom,
(query) => query.isSuccess,
)
const precheckReleaseIsLoadingAtom = selectAtom(
precheckReleaseQueryAtom,
(query) => query.isLoading,
)
const precheckReleaseIsFetchingAtom = selectAtom(
precheckReleaseQueryAtom,
(query) => query.isFetching,
)
function precheckReleaseReady(get: Getter) {
const precheckRelease = get(precheckReleaseDataAtom)
return (
sourceReady(get) &&
get(precheckReleaseIsSuccessAtom) &&
Boolean(precheckRelease?.canCreate) &&
(precheckRelease?.unsupportedNodes.length ?? 0) === 0 &&
get(submissionUnsupportedDslNodesAtom).length === 0
)
}
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: encodedDslContent
? {
body: {
dsl: encodedDslContent,
},
}
: skipToken,
enabled,
retry: false,
})
: consoleQuery.enterprise.releaseService.computeDeploymentOptions.queryOptions({
input: effectiveSelectedApp?.id
? {
body: {
sourceAppId: effectiveSelectedApp.id,
},
}
: skipToken,
enabled: enabled && Boolean(effectiveSelectedApp?.id),
retry: false,
})
return deploymentOptionsQueryOptions
})
export const deploymentOptionsDataAtom = selectAtom(
deploymentOptionsQueryAtom,
(query) => query.data,
)
export const deploymentOptionsIsErrorAtom = selectAtom(
deploymentOptionsQueryAtom,
(query) => query.isError,
)
export const deploymentOptionsIsLoadingAtom = selectAtom(
deploymentOptionsQueryAtom,
(query) => query.isLoading,
)
export const deploymentOptionsIsFetchingAtom = selectAtom(
deploymentOptionsQueryAtom,
(query) => query.isFetching,
)
const deploymentOptionsIsSuccessAtom = selectAtom(
deploymentOptionsQueryAtom,
(query) => query.isSuccess,
)
export const unsupportedDslNodesAtom = atom((get) => {
const submissionUnsupportedDslNodes = get(submissionUnsupportedDslNodesAtom)
if (submissionUnsupportedDslNodes.length > 0) return submissionUnsupportedDslNodes
if (!sourceReady(get)) return []
return get(precheckReleaseDataAtom)?.unsupportedNodes ?? []
})
const precheckReleaseReadyAtom = atom((get) => {
return precheckReleaseReady(get)
})
export const deploymentOptionsReadyAtom = atom((get) => {
return sourceReady(get) && get(precheckReleaseReadyAtom) && get(deploymentOptionsIsSuccessAtom)
})
export const deploymentOptionsContentCheckedAtom = atom((get) => {
const isLoadingOptions =
get(deploymentOptionsIsLoadingAtom) ||
(get(deploymentOptionsIsFetchingAtom) && !get(deploymentOptionsDataAtom))
const isCheckingReleaseContent =
get(precheckReleaseIsLoadingAtom) ||
(get(precheckReleaseIsFetchingAtom) && !get(precheckReleaseDataAtom))
if (!sourceReady(get) || isCheckingReleaseContent || isLoadingOptions) return false
return get(precheckReleaseReadyAtom) && get(deploymentOptionsIsSuccessAtom)
})
@@ -1,91 +0,0 @@
'use client'
import { atom } from 'jotai'
import {
autoFilledInstanceNameAtom,
autoFilledReleaseNameAtom,
envVarValuesAtom,
instanceDescriptionAtom,
instanceNameAtom,
manualBindingSelectionsAtom,
releaseDescriptionAtom,
releaseNameAtom,
selectedEnvironmentIdAtom,
stepAtom,
} from './primitives'
import {
deploymentOptionsContentCheckedAtom,
existingInstanceNamesQueryAtom,
instanceNameConflictQueryAtom,
} from './queries'
import { sourceReady } from './source'
export const hasInstanceNameConflictAtom = atom((get) => {
const submittedInstanceName = get(instanceNameAtom).trim()
const instanceNameConflictQuery = get(instanceNameConflictQueryAtom)
const existingInstanceNamesQuery = get(existingInstanceNamesQueryAtom)
const existingInstanceNames =
existingInstanceNamesQuery.data?.pages.flatMap((page) =>
page.appInstances.flatMap((appInstance) => {
const name = appInstance.displayName.trim()
return name ? [name] : []
}),
) ?? []
return Boolean(
submittedInstanceName &&
(existingInstanceNames.includes(submittedInstanceName) ||
(instanceNameConflictQuery.data?.appInstances.some(
(appInstance) => appInstance.displayName.trim() === submittedInstanceName,
) ??
false)),
)
})
export const submittedReleaseReadyAtom = atom((get) => {
return Boolean(sourceReady(get) && get(instanceNameAtom).trim() && get(releaseNameAtom).trim())
})
export const releaseCanGoNextAtom = atom((get) => {
const submittedInstanceName = get(instanceNameAtom).trim()
const instanceNameConflictQuery = get(instanceNameConflictQueryAtom)
return (
Boolean(get(submittedReleaseReadyAtom)) &&
!get(hasInstanceNameConflictAtom) &&
!(Boolean(submittedInstanceName) && instanceNameConflictQuery.isLoading) &&
get(deploymentOptionsContentCheckedAtom)
)
})
export const setInstanceNameAtom = atom(null, (_get, set, value: string) => {
set(instanceNameAtom, value)
set(autoFilledInstanceNameAtom, '')
set(stepAtom, 'release')
})
export const setInstanceDescriptionAtom = atom(null, (_get, set, value: string) => {
set(instanceDescriptionAtom, value)
set(stepAtom, 'release')
})
export const setReleaseNameAtom = atom(null, (_get, set, value: string) => {
set(releaseNameAtom, value)
set(autoFilledReleaseNameAtom, '')
set(stepAtom, 'release')
})
export const setReleaseDescriptionAtom = atom(null, (_get, set, value: string) => {
set(releaseDescriptionAtom, value)
set(stepAtom, 'release')
})
export const continueFromReleaseAtom = atom(null, (get, set) => {
if (!get(releaseCanGoNextAtom)) return
set(selectedEnvironmentIdAtom, '')
set(manualBindingSelectionsAtom, {})
set(envVarValuesAtom, {})
set(stepAtom, 'target')
})
@@ -1,41 +0,0 @@
import {
autoFilledInstanceNameAtom,
autoFilledReleaseNameAtom,
dslFileAtom,
dslFileReadVersionAtom,
envVarValuesAtom,
instanceDescriptionAtom,
instanceNameAtom,
isCreatingDeploymentAtom,
isCreatingReleaseOnlyAtom,
manualBindingSelectionsAtom,
methodAtom,
releaseDescriptionAtom,
releaseNameAtom,
selectedAppAtom,
selectedEnvironmentIdAtom,
sourceSearchTextAtom,
stepAtom,
submissionUnsupportedDslNodesAtom,
} from './primitives'
export const createDeploymentGuideScopedAtoms = [
stepAtom,
methodAtom,
sourceSearchTextAtom,
selectedAppAtom,
dslFileAtom,
dslFileReadVersionAtom,
instanceNameAtom,
instanceDescriptionAtom,
releaseNameAtom,
releaseDescriptionAtom,
autoFilledInstanceNameAtom,
autoFilledReleaseNameAtom,
selectedEnvironmentIdAtom,
manualBindingSelectionsAtom,
envVarValuesAtom,
submissionUnsupportedDslNodesAtom,
isCreatingDeploymentAtom,
isCreatingReleaseOnlyAtom,
]
@@ -1,145 +0,0 @@
'use client'
import type { Getter } from 'jotai/vanilla'
import type { WorkflowSourceApp } from './types'
import { keepPreviousData, queryOptions } from '@tanstack/react-query'
import { atom } from 'jotai'
import { atomWithInfiniteQuery, atomWithQuery } from 'jotai-tanstack-query'
import { selectAtom } from 'jotai/utils'
import { dslAppName, isWorkflowDsl } from '@/features/deployments/shared/domain/dsl'
import { consoleQuery } from '@/service/client'
import { AppModeEnum } from '@/types/app'
import {
dslFileAtom,
dslFileReadVersionAtom,
effectiveMethodAtom,
selectedAppAtom,
sourceSearchTextAtom,
} from './primitives'
import { SOURCE_APPS_PAGE_SIZE } from './utils'
const dslFileContentQueryAtom = atomWithQuery((get) => {
const file = get(dslFileAtom)
const fileReadVersion = get(dslFileReadVersionAtom)
return queryOptions({
queryKey: [
'createGuideDslFileContent',
fileReadVersion,
file,
file?.name ?? '',
file?.size ?? 0,
file?.lastModified ?? 0,
],
queryFn: async () => (file ? await file.text() : ''),
enabled: Boolean(file),
retry: false,
})
})
export const dslContentAtom = atom((get) => {
return get(dslFileContentQueryAtom).data ?? ''
})
export const isReadingDslAtom = atom((get) => {
const file = get(dslFileAtom)
const dslFileContentQuery = get(dslFileContentQueryAtom)
return Boolean(file && (dslFileContentQuery.isLoading || dslFileContentQuery.isFetching))
})
export const dslReadErrorAtom = atom((get) => {
return Boolean(get(dslFileAtom) && get(dslFileContentQueryAtom).isError)
})
export const dslDefaultAppNameAtom = atom((get) => {
const dslContent = get(dslContentAtom)
return dslContent ? dslAppName(dslContent) : ''
})
export const dslUnsupportedModeAtom = atom((get) => {
const dslContent = get(dslContentAtom)
return (
get(effectiveMethodAtom) === 'importDsl' &&
Boolean(dslContent.trim()) &&
!get(isReadingDslAtom) &&
!get(dslReadErrorAtom) &&
!isWorkflowDsl(dslContent)
)
})
export const importDslReadyAtom = atom((get) => {
return (
Boolean(get(dslContentAtom).trim()) &&
!get(isReadingDslAtom) &&
!get(dslReadErrorAtom) &&
!get(dslUnsupportedModeAtom)
)
})
export const sourceAppsQueryAtom = atomWithInfiniteQuery((get) => {
const sourceSearchText = get(sourceSearchTextAtom)
return consoleQuery.apps.get.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',
})
})
const sourceAppsDataAtom = selectAtom(sourceAppsQueryAtom, (query) => query.data)
export const sourceAppsErrorAtom = selectAtom(sourceAppsQueryAtom, (query) => query.error)
export const sourceAppsFetchNextPageAtom = selectAtom(
sourceAppsQueryAtom,
(query) => query.fetchNextPage,
)
export const sourceAppsHasNextPageAtom = selectAtom(
sourceAppsQueryAtom,
(query) => query.hasNextPage,
)
export const sourceAppsIsFetchingAtom = selectAtom(sourceAppsQueryAtom, (query) => query.isFetching)
export const sourceAppsIsFetchingNextPageAtom = selectAtom(
sourceAppsQueryAtom,
(query) => query.isFetchingNextPage,
)
export const sourceAppsIsLoadingAtom = selectAtom(sourceAppsQueryAtom, (query) => query.isLoading)
export const sourceAppsIsPlaceholderDataAtom = selectAtom(
sourceAppsQueryAtom,
(query) => query.isPlaceholderData,
)
export const sourceAppsAtom = atom((get): WorkflowSourceApp[] => {
const apps = get(sourceAppsDataAtom)?.pages.flatMap((page) => page.data) ?? []
return apps.flatMap((app) =>
app.mode === AppModeEnum.WORKFLOW ? [{ ...app, mode: 'workflow' as const }] : [],
)
})
export const effectiveSelectedAppAtom = atom((get) => {
const selectedApp = get(selectedAppAtom)
if (selectedApp) return selectedApp
if (get(sourceAppsIsPlaceholderDataAtom)) return undefined
return get(sourceAppsAtom)[0]
})
export function sourceReady(get: Getter) {
const method = get(effectiveMethodAtom)
return method === 'importDsl'
? get(importDslReadyAtom)
: Boolean(get(effectiveSelectedAppAtom)?.id)
}
@@ -1,221 +0,0 @@
'use client'
import type { DeployRequest } from '@dify/contracts/enterprise/types.gen'
import { atom } from 'jotai'
import { atomWithMutation } from 'jotai-tanstack-query'
import { selectedDeploymentRuntimeCredentials } from '@/features/deployments/shared/components/runtime-credential-bindings-utils'
import { encodeDslContent, isWorkflowDsl } from '@/features/deployments/shared/domain/dsl'
import { unsupportedDslNodeError } from '@/features/deployments/shared/domain/error'
import { createDeploymentIdempotencyKey } from '@/features/deployments/shared/domain/idempotency'
import { consoleQuery } from '@/service/client'
import { environmentMatchesIdentifier } from './environment'
import {
effectiveMethodAtom,
envVarValuesAtom,
instanceDescriptionAtom,
instanceNameAtom,
isCreatingDeploymentAtom,
isCreatingReleaseOnlyAtom,
isSubmittingDeploymentGuideAtom,
releaseDescriptionAtom,
releaseNameAtom,
selectedEnvironmentIdAtom,
submissionUnsupportedDslNodesAtom,
} from './primitives'
import { deployableEnvironmentsQueryAtom, deploymentOptionsDataAtom } from './queries'
import { submittedReleaseReadyAtom } from './release'
import { dslContentAtom, effectiveSelectedAppAtom } from './source'
import {
canDeployAtom,
canSkipDeploymentAtom,
deployableEnvironmentsAtom,
deploymentTargetBindingSelectionsAtom,
deploymentTargetBindingSlotsAtom,
deploymentTargetEnvVarSlotsAtom,
requiredBindingsReadyAtom,
requiredEnvVarsReadyAtom,
} from './target'
import { envVarInput } from './utils'
const createAppInstanceMutationAtom = atomWithMutation(() =>
consoleQuery.enterprise.appInstanceService.createAppInstance.mutationOptions(),
)
const createReleaseMutationAtom = atomWithMutation(() =>
consoleQuery.enterprise.releaseService.createRelease.mutationOptions(),
)
const createInitialDeploymentMutationAtom = atomWithMutation(() =>
consoleQuery.enterprise.deploymentService.deploy.mutationOptions(),
)
export class CreateDeploymentGuideSubmissionBlockedError extends Error {
reason: 'unsupportedDslMode' | 'deployFailed'
constructor(reason: 'unsupportedDslMode' | 'deployFailed') {
super(reason)
this.reason = reason
this.name = 'CreateDeploymentGuideSubmissionBlockedError'
}
}
export const createDeploymentGuideSubmissionAtom = atom(
null,
async (
get,
set,
{
deployToEnvironment,
}: {
deployToEnvironment: boolean
},
) => {
const method = get(effectiveMethodAtom)
const dslContent = get(dslContentAtom)
const submittedInstanceName = get(instanceNameAtom).trim()
const submittedReleaseName = get(releaseNameAtom).trim()
const submittedReleaseDescription = get(releaseDescriptionAtom).trim()
if (get(isSubmittingDeploymentGuideAtom) || !get(submittedReleaseReadyAtom)) return undefined
const effectiveSelectedApp = get(effectiveSelectedAppAtom)
const deployableEnvironmentsQuery = get(deployableEnvironmentsQueryAtom)
const deploymentOptions = get(deploymentOptionsDataAtom)?.options
const envVarSlots = get(deploymentTargetEnvVarSlotsAtom)
const envVarValues = get(envVarValuesAtom)
const bindingSlots = get(deploymentTargetBindingSlotsAtom)
const bindingSelections = get(deploymentTargetBindingSelectionsAtom)
const selectedEnvironmentId = get(selectedEnvironmentIdAtom)
const effectiveSelectedEnvironmentId =
selectedEnvironmentId || get(deployableEnvironmentsAtom)[0]?.id
const selectedEnvironment = effectiveSelectedEnvironmentId
? get(deployableEnvironmentsAtom).find((env) =>
environmentMatchesIdentifier(env, effectiveSelectedEnvironmentId),
)
: undefined
if (deployToEnvironment && !selectedEnvironment && !selectedEnvironmentId.trim())
return undefined
if (method === 'bindApp' && !effectiveSelectedApp?.id) return undefined
if (method === 'importDsl' && !dslContent.trim()) return undefined
if (method === 'importDsl' && !isWorkflowDsl(dslContent))
throw new CreateDeploymentGuideSubmissionBlockedError('unsupportedDslMode')
set(submissionUnsupportedDslNodesAtom, [])
try {
if (!deployToEnvironment) {
if (!get(canSkipDeploymentAtom)) return undefined
set(isCreatingReleaseOnlyAtom, true)
try {
const createdAppInstance = await get(createAppInstanceMutationAtom).mutateAsync({
body: {
displayName: submittedInstanceName,
description: get(instanceDescriptionAtom).trim() || undefined,
},
})
const appInstanceId = createdAppInstance.appInstance.id
if (method === 'importDsl') {
await get(createReleaseMutationAtom).mutateAsync({
body: {
appInstanceId,
dsl: encodeDslContent(dslContent),
displayName: submittedReleaseName,
description: submittedReleaseDescription || undefined,
createAppInstance: false,
},
})
return appInstanceId
}
if (!effectiveSelectedApp?.id) return undefined
await get(createReleaseMutationAtom).mutateAsync({
body: {
appInstanceId,
sourceAppId: effectiveSelectedApp.id,
displayName: submittedReleaseName,
description: submittedReleaseDescription || undefined,
createAppInstance: false,
},
})
return appInstanceId
} finally {
set(isCreatingReleaseOnlyAtom, false)
}
}
if (!get(canDeployAtom)) return undefined
set(isCreatingDeploymentAtom, true)
try {
const selectedEnvironmentIdentifier = selectedEnvironmentId.trim()
const freshSelectedEnvironment =
selectedEnvironment ||
(selectedEnvironmentIdentifier
? (await deployableEnvironmentsQuery.refetch()).data?.environments.find((environment) =>
environmentMatchesIdentifier(environment, selectedEnvironmentIdentifier),
)
: undefined)
const targetEnvironmentId = freshSelectedEnvironment?.id
if (!targetEnvironmentId)
throw new CreateDeploymentGuideSubmissionBlockedError('deployFailed')
if (!get(requiredBindingsReadyAtom)) throw new Error('Missing required deployment binding.')
if (!get(requiredEnvVarsReadyAtom))
throw new Error('Missing required deployment environment variable.')
const envVars = envVarSlots.flatMap((slot) => envVarInput(slot, envVarValues[slot.key]))
const commonDeploymentRequest = {
newAppInstance: {
displayName: submittedInstanceName,
description: get(instanceDescriptionAtom).trim() || undefined,
},
environmentId: targetEnvironmentId,
releaseName: submittedReleaseName,
releaseDescription: submittedReleaseDescription || undefined,
credentials: selectedDeploymentRuntimeCredentials(bindingSlots, bindingSelections),
envVars,
idempotencyKey: createDeploymentIdempotencyKey(),
expectedDslDigest: deploymentOptions?.dslDigest,
} satisfies Omit<DeployRequest, 'dsl' | 'sourceAppId'>
const deploymentRequest =
method === 'importDsl'
? {
...commonDeploymentRequest,
dsl: encodeDslContent(dslContent),
}
: effectiveSelectedApp?.id
? {
...commonDeploymentRequest,
sourceAppId: effectiveSelectedApp.id,
}
: undefined
if (!deploymentRequest) return undefined
const response = await get(createInitialDeploymentMutationAtom).mutateAsync({
body: deploymentRequest,
})
return response.appInstance.id
} finally {
set(isCreatingDeploymentAtom, false)
}
} catch (error) {
const unsupportedError = await unsupportedDslNodeError(error)
if (unsupportedError?.nodes.length) {
set(submissionUnsupportedDslNodesAtom, unsupportedError.nodes)
return undefined
}
throw error
}
},
)
@@ -1,172 +0,0 @@
'use client'
import type {
EnvVarBindingSlot,
EnvVarValueSelection,
} from '@/features/deployments/shared/components/env-var-bindings'
import { atom } from 'jotai'
import {
envVarBindingSlotFromContract,
envVarBindingValueType,
} from '@/features/deployments/shared/components/env-var-bindings-utils'
import {
hasMissingRequiredRuntimeCredentialBinding,
runtimeCredentialSlotKey,
selectedRuntimeCredentialSelections,
} from '@/features/deployments/shared/components/runtime-credential-bindings-utils'
import { dslEnvVarSlots } from '@/features/deployments/shared/domain/dsl'
import { environmentMatchesIdentifier } from './environment'
import {
effectiveMethodAtom,
envVarValuesAtom,
manualBindingSelectionsAtom,
selectedEnvironmentIdAtom,
} from './primitives'
import {
deployableEnvironmentsDataAtom,
deploymentOptionsDataAtom,
deploymentOptionsReadyAtom,
} from './queries'
import { submittedReleaseReadyAtom } from './release'
import { dslContentAtom, sourceReady } from './source'
import { envVarSelectionReady } from './utils'
export const deployableEnvironmentsAtom = atom((get) => {
const deployableEnvironments = get(deployableEnvironmentsDataAtom)
return sourceReady(get) ? (deployableEnvironments?.environments ?? []) : []
})
const deployableEnvironmentsReadyAtom = atom((get) => {
return sourceReady(get) && Boolean(get(deployableEnvironmentsDataAtom))
})
export const effectiveSelectedEnvironmentIdAtom = atom((get) => {
return get(selectedEnvironmentIdAtom) || get(deployableEnvironmentsAtom)[0]?.id
})
export const deploymentTargetBindingSlotsAtom = atom((get) => {
const deploymentOptions = get(deploymentOptionsDataAtom)
return sourceReady(get)
? (deploymentOptions?.options?.credentialSlots?.filter((slot) =>
runtimeCredentialSlotKey(slot),
) ?? [])
: []
})
export const deploymentTargetBindingSelectionsAtom = atom((get) => {
return selectedRuntimeCredentialSelections(
get(deploymentTargetBindingSlotsAtom),
get(manualBindingSelectionsAtom),
)
})
export const requiredBindingsReadyAtom = atom((get) => {
const bindingSelections = get(deploymentTargetBindingSelectionsAtom)
return get(deploymentTargetBindingSlotsAtom).every(
(slot) =>
!hasMissingRequiredRuntimeCredentialBinding(
slot,
bindingSelections[runtimeCredentialSlotKey(slot)],
),
)
})
export const deploymentTargetEnvVarSlotsAtom = atom((get) => {
const method = get(effectiveMethodAtom)
const deploymentOptions = get(deploymentOptionsDataAtom)
const slots = sourceReady(get) ? deploymentOptions?.options?.envVarSlots : undefined
const dslContent = get(dslContentAtom)
// Deployment options own the canonical slot list; DSL metadata only enriches import-DSL defaults.
const deploymentOptionEnvVarSlots =
slots?.flatMap((slot): EnvVarBindingSlot[] => {
const bindingSlot = envVarBindingSlotFromContract(slot)
return bindingSlot ? [bindingSlot] : []
}) ?? []
const dslEnvVarMetadataSlots =
method === 'importDsl' && dslContent
? dslEnvVarSlots(dslContent).flatMap((slot) => {
const key = slot.key.trim()
if (!key) return []
return [
{
key,
...(slot.description ? { description: slot.description } : {}),
...(slot.defaultValue !== undefined
? { defaultValue: slot.defaultValue, hasDefaultValue: true }
: {}),
...(slot.valueType ? { valueType: envVarBindingValueType(slot.valueType) } : {}),
},
]
})
: []
if (dslEnvVarMetadataSlots.length === 0) return deploymentOptionEnvVarSlots
const metadataByKey = new Map(dslEnvVarMetadataSlots.map((slot) => [slot.key, slot] as const))
return deploymentOptionEnvVarSlots.map((slot) => {
const metadata = metadataByKey.get(slot.key)
if (!metadata) return slot
const nextSlot = { ...slot }
if (!nextSlot.description && metadata.description) nextSlot.description = metadata.description
if (!nextSlot.hasDefaultValue && metadata.defaultValue !== undefined) {
nextSlot.defaultValue = metadata.defaultValue
nextSlot.hasDefaultValue = true
}
if (nextSlot.valueType === 'string' && metadata.valueType)
nextSlot.valueType = metadata.valueType
return nextSlot
})
})
export const requiredEnvVarsReadyAtom = atom((get) => {
const envVarValues = get(envVarValuesAtom)
return get(deploymentTargetEnvVarSlotsAtom).every((slot) =>
envVarSelectionReady(slot, envVarValues[slot.key]),
)
})
export const canDeployAtom = atom((get) => {
const effectiveSelectedEnvironmentId = get(effectiveSelectedEnvironmentIdAtom)
const selectedEnvironment = effectiveSelectedEnvironmentId
? get(deployableEnvironmentsAtom).find((env) =>
environmentMatchesIdentifier(env, effectiveSelectedEnvironmentId),
)
: undefined
return Boolean(
selectedEnvironment?.id &&
get(deployableEnvironmentsReadyAtom) &&
get(deploymentOptionsReadyAtom) &&
get(requiredBindingsReadyAtom) &&
get(requiredEnvVarsReadyAtom) &&
get(submittedReleaseReadyAtom),
)
})
export const canSkipDeploymentAtom = atom((get) => {
return get(submittedReleaseReadyAtom) && get(deploymentOptionsReadyAtom)
})
export const selectBindingAtom = atom(null, (get, set, slot: string, value: string) => {
set(manualBindingSelectionsAtom, {
...get(manualBindingSelectionsAtom),
[slot]: value,
})
})
export const setEnvVarAtom = atom(null, (get, set, key: string, value: EnvVarValueSelection) => {
set(envVarValuesAtom, {
...get(envVarValuesAtom),
[key]: value,
})
})
@@ -1,7 +0,0 @@
import type { AppPartial, GetAppsData } from '@dify/contracts/api/console/apps/types.gen'
export type GuideMethod = 'bindApp' | 'importDsl'
export type GuideStep = 'source' | 'release' | 'target'
export type WorkflowSourceApp = AppPartial & {
mode: Extract<NonNullable<NonNullable<GetAppsData['query']>['mode']>, 'workflow'>
}
@@ -1,107 +0,0 @@
import type { EnvVarInput, Pagination } from '@dify/contracts/enterprise/types.gen'
import type { GuideMethod } from './types'
import type {
EnvVarBindingSlot,
EnvVarValueSelection,
} from '@/features/deployments/shared/components/env-var-bindings'
import { EnvVarValueSource as ApiEnvVarValueSource } from '@dify/contracts/enterprise/types.gen'
import { isDeploymentDslImportEnabled } from '@/features/deployments/shared/domain/feature-flags'
export const DEPLOYMENT_PAGE_SIZE = 100
export const SOURCE_APPS_PAGE_SIZE = 100
export function getNextPageParamFromPagination(pagination?: Pagination) {
const currentPage = pagination?.currentPage ?? 1
const totalPages = pagination?.totalPages ?? 1
return currentPage < totalPages ? currentPage + 1 : undefined
}
export function deploymentGuideMethod(method: GuideMethod): GuideMethod {
return method === 'importDsl' && !isDeploymentDslImportEnabled ? 'bindApp' : method
}
const RANDOM_SUFFIX_ALPHABET = 'abcdefghijklmnopqrstuvwxyz'
const RANDOM_SUFFIX_LENGTH = 4
const RANDOM_SUFFIX_FALLBACK_LENGTH = 6
const RANDOM_SUFFIX_MAX_ATTEMPTS = 16
function randomLetterCombination(length: number) {
const randomValues = new Uint8Array(length)
if (globalThis.crypto) {
globalThis.crypto.getRandomValues(randomValues)
} else {
randomValues.forEach((_, index) => {
randomValues[index] = Math.floor(Math.random() * 256)
})
}
return Array.from(
randomValues,
(value) => RANDOM_SUFFIX_ALPHABET[value % RANDOM_SUFFIX_ALPHABET.length],
).join('')
}
export function availableInstanceName(sourceName: string, existingNameSet: Set<string>) {
if (!existingNameSet.has(sourceName)) return sourceName
for (let attempt = 0; attempt < RANDOM_SUFFIX_MAX_ATTEMPTS; attempt++) {
const candidate = `${sourceName}-${randomLetterCombination(RANDOM_SUFFIX_LENGTH)}`
if (!existingNameSet.has(candidate)) return candidate
}
return `${sourceName}-${randomLetterCombination(RANDOM_SUFFIX_FALLBACK_LENGTH)}`
}
function envVarValueSource(slot: EnvVarBindingSlot, selection: EnvVarValueSelection | undefined) {
return (
selection?.valueSource ??
(slot.hasDefaultValue
? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT
: slot.hasLastValue
? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT
: ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LITERAL)
)
}
export function envVarSelectionReady(
slot: EnvVarBindingSlot,
selection: EnvVarValueSelection | undefined,
) {
const valueSource = envVarValueSource(slot, selection)
if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT)
return Boolean(slot.hasLastValue)
if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT)
return Boolean(slot.hasDefaultValue)
if (!selection?.value) return false
return slot.valueType !== 'number' || !Number.isNaN(Number(selection.value))
}
export function envVarInput(
slot: EnvVarBindingSlot,
selection: EnvVarValueSelection | undefined,
): EnvVarInput[] {
const valueSource = envVarValueSource(slot, selection)
if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT) {
return slot.hasLastValue ? [{ key: slot.key, valueSource }] : []
}
if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT) {
return slot.hasDefaultValue ? [{ key: slot.key, valueSource }] : []
}
if (!selection?.value || (slot.valueType === 'number' && Number.isNaN(Number(selection.value))))
return []
return [
{
key: slot.key,
value: selection.value,
valueSource,
},
]
}
@@ -1,130 +0,0 @@
'use client'
import type { WorkflowSourceApp } from './types'
import { atom } from 'jotai'
import {
autoFilledInstanceNameAtom,
autoFilledReleaseNameAtom,
dslFileAtom,
dslFileReadVersionAtom,
effectiveMethodAtom,
envVarValuesAtom,
instanceNameAtom,
manualBindingSelectionsAtom,
methodAtom,
releaseNameAtom,
selectedAppAtom,
selectedEnvironmentIdAtom,
sourceSearchTextAtom,
stepAtom,
submissionUnsupportedDslNodesAtom,
} from './primitives'
import { deploymentOptionsContentCheckedAtom, existingInstanceNamesQueryAtom } from './queries'
import { dslDefaultAppNameAtom, effectiveSelectedAppAtom, importDslReadyAtom } from './source'
import { availableInstanceName, deploymentGuideMethod } from './utils'
export const sourceCanGoNextAtom = atom((get) => {
const method = get(effectiveMethodAtom)
const effectiveSelectedApp = get(effectiveSelectedAppAtom)
const importDslReady = method === 'importDsl' && get(importDslReadyAtom)
const bindAppReady = method === 'bindApp' && Boolean(effectiveSelectedApp?.id)
return (importDslReady || bindAppReady) && get(deploymentOptionsContentCheckedAtom)
})
export const setSourceSearchTextAtom = atom(null, (get, set, value: string) => {
if (get(sourceSearchTextAtom) === value) return
set(sourceSearchTextAtom, value)
set(selectedAppAtom, undefined)
set(selectedEnvironmentIdAtom, '')
set(manualBindingSelectionsAtom, {})
set(envVarValuesAtom, {})
set(submissionUnsupportedDslNodesAtom, [])
})
export const selectSourceAppAtom = atom(null, (_get, set, app: WorkflowSourceApp) => {
set(selectedAppAtom, app)
set(selectedEnvironmentIdAtom, '')
set(manualBindingSelectionsAtom, {})
set(envVarValuesAtom, {})
set(submissionUnsupportedDslNodesAtom, [])
})
export const continueFromSourceAtom = atom(
null,
(
get,
set,
{
defaultDslAppName,
defaultReleaseName,
}: {
defaultDslAppName: string
defaultReleaseName: string
},
) => {
if (!get(sourceCanGoNextAtom)) return
const method = get(effectiveMethodAtom)
const effectiveSelectedApp = get(effectiveSelectedAppAtom)
if (method === 'bindApp' && effectiveSelectedApp) set(selectSourceAppAtom, effectiveSelectedApp)
const sourceName =
method === 'importDsl'
? get(dslDefaultAppNameAtom) || defaultDslAppName
: effectiveSelectedApp?.name
const nextInstanceName = sourceName?.trim()
if (nextInstanceName) {
const currentInstanceName = get(instanceNameAtom).trim()
const autoFilledInstanceName = get(autoFilledInstanceNameAtom)
const existingInstanceNamesQuery = get(existingInstanceNamesQueryAtom)
const existingNameSet = new Set(
existingInstanceNamesQuery.data?.pages.flatMap((page) =>
page.appInstances.flatMap((appInstance) => {
const name = appInstance.displayName.trim()
return name ? [name] : []
}),
) ?? [],
)
if (!currentInstanceName || currentInstanceName === autoFilledInstanceName) {
const nextAvailableInstanceName = availableInstanceName(nextInstanceName, existingNameSet)
set(instanceNameAtom, nextAvailableInstanceName)
set(autoFilledInstanceNameAtom, nextAvailableInstanceName)
}
}
const currentReleaseName = get(releaseNameAtom).trim()
const autoFilledReleaseName = get(autoFilledReleaseNameAtom)
if (!currentReleaseName || currentReleaseName === autoFilledReleaseName) {
set(releaseNameAtom, defaultReleaseName)
set(autoFilledReleaseNameAtom, defaultReleaseName)
}
set(stepAtom, 'release')
},
)
export const selectDslFileAtom = atom(null, (get, set, dslFile?: File) => {
set(selectedEnvironmentIdAtom, '')
set(manualBindingSelectionsAtom, {})
set(envVarValuesAtom, {})
set(submissionUnsupportedDslNodesAtom, [])
set(dslFileReadVersionAtom, get(dslFileReadVersionAtom) + 1)
set(dslFileAtom, dslFile)
})
export const selectMethodAtom = atom(
null,
(_get, set, method: Parameters<typeof deploymentGuideMethod>[0]) => {
set(methodAtom, deploymentGuideMethod(method))
set(selectedEnvironmentIdAtom, '')
set(manualBindingSelectionsAtom, {})
set(envVarValuesAtom, {})
set(submissionUnsupportedDslNodesAtom, [])
set(stepAtom, 'source')
},
)
@@ -1,178 +0,0 @@
import { act, render, screen } from '@testing-library/react'
import { Provider } from 'jotai'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SourceStepContent } from '../source-step'
const mocks = vi.hoisted(() => {
const sourceAppsQuery = {
data: { pages: [{ data: [] }] },
error: null,
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
isPlaceholderData: false,
}
return { sourceAppsQuery }
})
type ObserverRecord = {
callback: IntersectionObserverCallback
options?: IntersectionObserverInit
}
const observers: ObserverRecord[] = []
function getInfiniteScrollObserver() {
return observers.find(({ options }) => options?.rootMargin === '0px 0px 160px 0px')
}
vi.mock('@/features/deployments/create-guide/state/primitives', async () => {
const { atom } = await import('jotai')
const methodAtom = atom<'bindApp' | 'importDsl'>('bindApp')
return {
dslFileAtom: atom<File | undefined>(undefined),
effectiveMethodAtom: atom((get) => get(methodAtom)),
methodAtom,
sourceSearchTextAtom: atom(''),
}
})
vi.mock('@/features/deployments/create-guide/state/source', async () => {
const { atom } = await import('jotai')
return {
dslReadErrorAtom: atom(false),
dslUnsupportedModeAtom: atom(false),
effectiveSelectedAppAtom: atom(undefined),
isReadingDslAtom: atom(false),
sourceAppsAtom: atom(() => mocks.sourceAppsQuery.data.pages.flatMap((page) => page.data)),
sourceAppsErrorAtom: atom(() => mocks.sourceAppsQuery.error),
sourceAppsFetchNextPageAtom: atom(() => mocks.sourceAppsQuery.fetchNextPage),
sourceAppsHasNextPageAtom: atom(() => mocks.sourceAppsQuery.hasNextPage),
sourceAppsIsFetchingAtom: atom(() => mocks.sourceAppsQuery.isFetching),
sourceAppsIsFetchingNextPageAtom: atom(() => mocks.sourceAppsQuery.isFetchingNextPage),
sourceAppsIsLoadingAtom: atom(() => mocks.sourceAppsQuery.isLoading),
sourceAppsIsPlaceholderDataAtom: atom(() => mocks.sourceAppsQuery.isPlaceholderData),
sourceAppsQueryAtom: atom(mocks.sourceAppsQuery),
}
})
vi.mock('@/features/deployments/create-guide/state/workflow', async () => {
const { atom } = await import('jotai')
const { methodAtom } = await import('@/features/deployments/create-guide/state/primitives')
const emptyActionAtom = atom(null, () => undefined)
return {
continueFromSourceAtom: emptyActionAtom,
selectDslFileAtom: emptyActionAtom,
selectMethodAtom: atom(null, (_get, set, value: 'bindApp' | 'importDsl') => {
set(methodAtom, value)
}),
selectSourceAppAtom: emptyActionAtom,
setSourceSearchTextAtom: emptyActionAtom,
sourceCanGoNextAtom: atom(false),
}
})
vi.mock('@/features/deployments/create-guide/state/queries', async () => {
const { atom } = await import('jotai')
return {
unsupportedDslNodesAtom: atom([]),
}
})
describe('SourceStepContent', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.sourceAppsQuery.fetchNextPage.mockReset()
observers.length = 0
vi.stubGlobal(
'IntersectionObserver',
class MockIntersectionObserver {
readonly root = null
readonly rootMargin = ''
readonly scrollMargin = ''
readonly thresholds: ReadonlyArray<number> = []
readonly disconnect = vi.fn()
readonly observe = vi.fn()
readonly takeRecords = () => []
readonly unobserve = vi.fn()
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
observers.push({ callback, options })
}
},
)
Object.assign(mocks.sourceAppsQuery, {
data: { pages: [{ data: [] }] },
error: null,
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
isPlaceholderData: false,
})
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('should hide the import DSL option when deployment DSL import is disabled', () => {
render(
<Provider>
<SourceStepContent />
</Provider>,
)
expect(screen.getByText(/createGuide\.methods\.bindApp\.title/)).toBeInTheDocument()
expect(screen.queryByText(/createGuide\.methods\.importDsl\.title/)).not.toBeInTheDocument()
expect(
screen.queryByText(/createGuide\.methods\.importDsl\.description/),
).not.toBeInTheDocument()
expect(
screen.getByRole('textbox', { name: /createGuide\.source\.sourceApp/ }),
).toBeInTheDocument()
})
it('should use infinite scroll to load more source apps', () => {
Object.assign(mocks.sourceAppsQuery, {
data: {
pages: [
{
data: [
{
id: 'app-1',
name: 'Workflow App',
},
],
},
],
},
hasNextPage: true,
})
render(
<Provider>
<SourceStepContent />
</Provider>,
)
act(() => {
getInfiniteScrollObserver()?.callback(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
expect(mocks.sourceAppsQuery.fetchNextPage).toHaveBeenCalledWith({ cancelRefetch: false })
expect(
screen.queryByRole('button', { name: /createModal\.loadMoreApps/ }),
).not.toBeInTheDocument()
})
})
@@ -1,220 +0,0 @@
'use client'
import type { ReactNode } from 'react'
import type { GuideStep } from '@/features/deployments/create-guide/state/types'
import { cn } from '@langgenius/dify-ui/cn'
import {
ScrollArea,
ScrollAreaContent,
ScrollAreaScrollbar,
ScrollAreaThumb,
ScrollAreaViewport,
} from '@langgenius/dify-ui/scroll-area'
import { useTranslation } from 'react-i18next'
import { TitleTooltip } from '@/features/deployments/shared/components/title-tooltip'
const GUIDE_PROGRESS_STEPS: GuideStep[] = ['source', 'release', 'target']
function GuideStepIntro({ activeStep }: { activeStep: GuideStep }) {
const { t } = useTranslation('deployments')
let title: string
let description: string
if (activeStep === 'source') {
title = t(($) => $['createGuide.source.title'])
description = t(($) => $['createGuide.method.description'])
} else if (activeStep === 'release') {
title = t(($) => $['createGuide.release.title'])
description = t(($) => $['createGuide.release.description'])
} else if (activeStep === 'target') {
title = t(($) => $['createGuide.target.title'])
description = t(($) => $['createGuide.target.description'])
} else {
return null
}
return (
<div className="pb-4">
<h2 className="system-md-semibold text-text-primary">{title}</h2>
<p className="mt-1 max-w-150 system-sm-regular text-text-tertiary">{description}</p>
</div>
)
}
function GuideProgress({ activeStep }: { activeStep: GuideStep }) {
const { t } = useTranslation('deployments')
const activeIndex = GUIDE_PROGRESS_STEPS.indexOf(activeStep)
return (
<ol className="grid grid-cols-3 gap-1.5">
{GUIDE_PROGRESS_STEPS.map((step, index) => {
const isActive = step === activeStep
const isComplete = index < activeIndex
const label = t(($) => $[`createGuide.steps.${step}`])
return (
<TitleTooltip key={step} content={label}>
<li
aria-current={isActive ? 'step' : undefined}
className={cn(
'flex min-w-0 items-start gap-1.5 px-1 py-1.5 system-xs-medium sm:items-center sm:gap-2 sm:px-2',
isActive
? 'text-text-primary'
: isComplete
? 'text-text-secondary'
: 'text-text-quaternary',
)}
>
<span
aria-hidden
className={cn(
'mt-1 size-2 shrink-0 rounded-full border-[1.5px] sm:mt-0',
isActive
? 'border-text-primary bg-text-primary'
: isComplete
? 'border-text-secondary bg-text-secondary'
: 'border-text-quaternary bg-transparent',
)}
/>
<span className="line-clamp-2 min-w-0 leading-4">{label}</span>
</li>
</TitleTooltip>
)
})}
</ol>
)
}
function GuideProgressSummary({ activeStep }: { activeStep: GuideStep }) {
const { t } = useTranslation('deployments')
const activeIndex = GUIDE_PROGRESS_STEPS.indexOf(activeStep)
const activeStepNumber = activeIndex + 1
let activeStepLabel: string
if (activeStep === 'source') activeStepLabel = t(($) => $['createGuide.steps.source'])
else if (activeStep === 'release') activeStepLabel = t(($) => $['createGuide.steps.release'])
else if (activeStep === 'target') activeStepLabel = t(($) => $['createGuide.steps.target'])
else return null
if (activeIndex < 0) return null
return (
<div className="flex w-full min-w-0 flex-col gap-2">
<div className="flex min-w-0 items-baseline justify-between gap-3">
<span className="truncate system-sm-medium text-text-secondary">{activeStepLabel}</span>
<span className="shrink-0 system-xs-regular text-text-quaternary">
{activeStepNumber}/{GUIDE_PROGRESS_STEPS.length}
</span>
</div>
<div className="grid grid-cols-3 gap-1" aria-hidden="true">
{GUIDE_PROGRESS_STEPS.map((step, index) => (
<span
key={step}
className={cn(
'h-1 rounded-full',
index <= activeIndex ? 'bg-text-primary' : 'bg-divider-subtle',
)}
/>
))}
</div>
</div>
)
}
export function StepShell({
title,
description,
descriptionClassName,
hideHeader,
className,
children,
}: {
title: string
description: string
descriptionClassName?: string
hideHeader?: boolean
className?: string
children: ReactNode
}) {
return (
<section
aria-label={hideHeader ? title : undefined}
className={cn('flex min-w-0 flex-col gap-4', className)}
>
{!hideHeader && (
<div className="flex min-w-0 flex-col gap-0.5">
<h2 className="system-md-semibold text-text-primary">{title}</h2>
<p className={cn('system-sm-regular text-text-tertiary', descriptionClassName)}>
{description}
</p>
</div>
)}
{children}
</section>
)
}
export function GuideCard({
children,
actions,
contentScrollable = true,
}: {
children: ReactNode
actions: ReactNode
contentScrollable?: boolean
}) {
return (
<div className="flex min-h-0 w-full min-w-0 flex-1 flex-col">
{contentScrollable ? (
<ScrollArea className="min-h-0 flex-1">
<ScrollAreaViewport className="overscroll-contain">
<ScrollAreaContent className="min-h-full pt-0.5 pb-6">{children}</ScrollAreaContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar>
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollArea>
) : (
<div className="min-h-0 flex-1 overflow-hidden pt-0.5 pb-6">{children}</div>
)}
{actions}
</div>
)
}
export function GuideFrame({
activeStep,
children,
}: {
activeStep: GuideStep
children: ReactNode
}) {
const { t } = useTranslation('deployments')
return (
<div className="relative flex h-full min-h-0 overflow-hidden bg-background-default-subtle">
<div className="flex min-w-0 flex-1 shrink-0 justify-center overflow-hidden">
<section
aria-label={t(($) => $['createGuide.title'])}
className="flex h-full w-full max-w-210 flex-col px-5 sm:px-8 lg:px-10"
>
<div className="h-5 sm:h-8 lg:h-12" />
<div className="flex min-w-0 items-start justify-between gap-6 pt-1 pb-4">
<h1 className="title-2xl-semi-bold text-text-primary">
{t(($) => $['createGuide.title'])}
</h1>
<div className="hidden w-46 shrink-0 min-[1120px]:block">
<GuideProgressSummary activeStep={activeStep} />
</div>
</div>
<GuideStepIntro activeStep={activeStep} />
<div className="mb-6 min-[1120px]:hidden">
<GuideProgress activeStep={activeStep} />
</div>
{children}
</section>
</div>
</div>
)
}
@@ -1,222 +0,0 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { Input } from '@langgenius/dify-ui/input'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import {
effectiveMethodAtom,
instanceDescriptionAtom,
instanceNameAtom,
releaseDescriptionAtom,
releaseNameAtom,
selectedAppAtom,
stepAtom,
} from '@/features/deployments/create-guide/state/primitives'
import {
continueFromReleaseAtom,
hasInstanceNameConflictAtom,
releaseCanGoNextAtom,
setInstanceDescriptionAtom,
setInstanceNameAtom,
setReleaseDescriptionAtom,
setReleaseNameAtom,
} from '@/features/deployments/create-guide/state/release'
import { dslDefaultAppNameAtom } from '@/features/deployments/create-guide/state/source'
import { StepShell } from './layout'
const releaseTextareaClassName =
'min-h-16 w-full resize-none appearance-none rounded-md border border-transparent bg-components-input-bg-normal p-2 px-3 system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs'
export function ReleaseStepContent() {
const { t } = useTranslation('deployments')
return (
<StepShell
title={t(($) => $['createGuide.release.title'])}
description={t(($) => $['createGuide.release.description'])}
hideHeader
>
<div className="flex flex-col gap-6">
<DeploymentInfoSection />
<InitialReleaseSection />
</div>
</StepShell>
)
}
function DeploymentInfoSection() {
const { t } = useTranslation('deployments')
return (
<div className="flex flex-col gap-4">
<h3 className="system-sm-semibold text-text-primary">
{t(($) => $['createGuide.release.deployInfo'])}
</h3>
<InstanceNameField />
<InstanceDescriptionField />
</div>
)
}
function InitialReleaseSection() {
const { t } = useTranslation('deployments')
return (
<div className="flex flex-col gap-4">
<h3 className="system-sm-semibold text-text-primary">
{t(($) => $['createGuide.release.firstVersion'])}
</h3>
<ReleaseNameField />
<ReleaseDescriptionField />
</div>
)
}
function InstanceNameField() {
const { t } = useTranslation('deployments')
const instanceName = useAtomValue(instanceNameAtom)
const setInstanceName = useSetAtom(setInstanceNameAtom)
const method = useAtomValue(effectiveMethodAtom)
const selectedApp = useAtomValue(selectedAppAtom)
const dslDefaultAppName = useAtomValue(dslDefaultAppNameAtom)
const instanceNamePlaceholder =
method === 'importDsl'
? dslDefaultAppName || t(($) => $['createGuide.dsl.defaultAppName'])
: selectedApp?.name
const hasInstanceNameConflict = useAtomValue(hasInstanceNameConflictAtom)
const instanceNameError = hasInstanceNameConflict
? t(($) => $['createGuide.release.instanceNameConflict'])
: undefined
const instanceNameErrorId = 'create-guide-instance-name-error'
return (
<div className="flex flex-col gap-2">
<label
className="system-xs-medium-uppercase text-text-tertiary"
htmlFor="create-guide-instance-name"
>
{t(($) => $['createGuide.release.instanceName'])}
</label>
<Input
id="create-guide-instance-name"
value={instanceName}
onChange={(event) => setInstanceName(event.target.value)}
placeholder={instanceNamePlaceholder}
required
aria-invalid={instanceNameError ? true : undefined}
aria-describedby={instanceNameError ? instanceNameErrorId : undefined}
className="h-9"
/>
{instanceNameError && (
<div
id={instanceNameErrorId}
role="alert"
className="system-xs-regular text-text-destructive"
>
{instanceNameError}
</div>
)}
</div>
)
}
function InstanceDescriptionField() {
const { t } = useTranslation('deployments')
const instanceDescription = useAtomValue(instanceDescriptionAtom)
const setInstanceDescription = useSetAtom(setInstanceDescriptionAtom)
return (
<div className="flex flex-col gap-2">
<OptionalFieldLabel htmlFor="create-guide-instance-description">
{t(($) => $['createGuide.release.instanceDescription'])}
</OptionalFieldLabel>
<textarea
id="create-guide-instance-description"
value={instanceDescription}
onChange={(event) => setInstanceDescription(event.target.value)}
placeholder={t(($) => $['createGuide.release.instanceDescriptionPlaceholder'])}
className={releaseTextareaClassName}
/>
</div>
)
}
function ReleaseNameField() {
const { t } = useTranslation('deployments')
const releaseName = useAtomValue(releaseNameAtom)
const setReleaseName = useSetAtom(setReleaseNameAtom)
return (
<div className="flex flex-col gap-2">
<label
className="system-xs-medium-uppercase text-text-tertiary"
htmlFor="create-guide-release-name"
>
{t(($) => $['createGuide.release.releaseName'])}
</label>
<Input
id="create-guide-release-name"
value={releaseName}
onChange={(event) => setReleaseName(event.target.value)}
placeholder={t(($) => $['createGuide.release.defaultName'])}
required
className="h-9"
/>
</div>
)
}
function ReleaseDescriptionField() {
const { t } = useTranslation('deployments')
const releaseDescription = useAtomValue(releaseDescriptionAtom)
const setReleaseDescription = useSetAtom(setReleaseDescriptionAtom)
return (
<div className="flex flex-col gap-2">
<OptionalFieldLabel htmlFor="create-guide-release-description">
{t(($) => $['createGuide.release.releaseDescription'])}
</OptionalFieldLabel>
<textarea
id="create-guide-release-description"
value={releaseDescription}
onChange={(event) => setReleaseDescription(event.target.value)}
placeholder={t(($) => $['createGuide.release.releaseDescriptionPlaceholder'])}
className={releaseTextareaClassName}
/>
</div>
)
}
function OptionalFieldLabel({ children, htmlFor }: { children: string; htmlFor: string }) {
const { t } = useTranslation('deployments')
return (
<div className="flex items-center gap-1.5">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor={htmlFor}>
{children}
</label>
<span className="system-xs-regular text-text-quaternary">
{t(($) => $['versions.optional'])}
</span>
</div>
)
}
export function ReleaseActionButtons() {
const { t } = useTranslation('deployments')
const canGoNext = useAtomValue(releaseCanGoNextAtom)
const setStep = useSetAtom(stepAtom)
const continueFromRelease = useSetAtom(continueFromReleaseAtom)
return (
<>
<Button type="button" variant="secondary" onClick={() => setStep('source')}>
{t(($) => $['createGuide.actions.back'])}
</Button>
<Button type="button" variant="primary" disabled={!canGoNext} onClick={continueFromRelease}>
{t(($) => $['createGuide.actions.next'])}
</Button>
</>
)
}
@@ -1,69 +0,0 @@
'use client'
import { useAtomValue } from 'jotai'
import { stepAtom } from '@/features/deployments/create-guide/state/primitives'
import { GuideCard, GuideFrame } from './layout'
import { ReleaseActionButtons, ReleaseStepContent } from './release-step'
import { SourceActionButtons, SourceStepContent } from './source-step'
import {
TargetBackButton,
TargetDeployButton,
TargetSkipDeploymentButton,
TargetStepContent,
} from './target-step'
export function CreateDeploymentGuideShell() {
const step = useAtomValue(stepAtom)
return (
<GuideFrame activeStep={step}>
<GuideCard contentScrollable={step !== 'source'} actions={<CreateDeploymentGuideActionBar />}>
<CreateDeploymentGuideStepContent />
</GuideCard>
</GuideFrame>
)
}
function CreateDeploymentGuideStepContent() {
const step = useAtomValue(stepAtom)
if (step === 'source') {
return (
<div className="flex h-full min-h-0 flex-col gap-7 pb-4">
<SourceStepContent />
</div>
)
}
if (step === 'release') {
return (
<div className="flex h-full min-h-0 flex-col gap-7 pb-4">
<ReleaseStepContent />
</div>
)
}
return (
<div className="flex flex-col gap-7 pb-4">
<TargetStepContent />
</div>
)
}
function CreateDeploymentGuideActionBar() {
const step = useAtomValue(stepAtom)
return (
<div className="sticky bottom-0 z-10 -mx-5 mt-auto flex items-center justify-end gap-2 border-t border-divider-subtle bg-background-default-subtle/95 px-5 py-4 backdrop-blur-sm sm:-mx-8 sm:px-8 lg:-mx-10 lg:px-10">
{step === 'source' && <SourceActionButtons />}
{step === 'release' && <ReleaseActionButtons />}
{step === 'target' && (
<>
<TargetBackButton />
<TargetSkipDeploymentButton />
<TargetDeployButton />
</>
)}
</div>
)
}
@@ -1,418 +0,0 @@
'use client'
import type {
GuideMethod,
WorkflowSourceApp,
} from '@/features/deployments/create-guide/state/types'
import { zIconType } from '@dify/contracts/api/console/apps/zod.gen'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Input } from '@langgenius/dify-ui/input'
import { RadioGroup, RadioItem } from '@langgenius/dify-ui/radio'
import { useAtomValue, useSetAtom } from 'jotai'
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { Uploader } from '@/app/components/app/create-from-dsl-modal/uploader'
import AppIcon from '@/app/components/base/app-icon'
import { InfiniteScrollSentinel } from '@/app/components/base/infinite-scroll-sentinel'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import {
dslFileAtom,
effectiveMethodAtom,
sourceSearchTextAtom,
} from '@/features/deployments/create-guide/state/primitives'
import { unsupportedDslNodesAtom } from '@/features/deployments/create-guide/state/queries'
import {
dslReadErrorAtom,
dslUnsupportedModeAtom,
effectiveSelectedAppAtom,
isReadingDslAtom,
sourceAppsAtom,
sourceAppsErrorAtom,
sourceAppsFetchNextPageAtom,
sourceAppsHasNextPageAtom,
sourceAppsIsFetchingAtom,
sourceAppsIsFetchingNextPageAtom,
sourceAppsIsLoadingAtom,
sourceAppsIsPlaceholderDataAtom,
} from '@/features/deployments/create-guide/state/source'
import {
continueFromSourceAtom,
selectDslFileAtom,
selectMethodAtom,
selectSourceAppAtom,
setSourceSearchTextAtom,
sourceCanGoNextAtom,
} from '@/features/deployments/create-guide/state/workflow'
import { DeploymentStateMessage } from '@/features/deployments/shared/components/empty-state'
import { TitleTooltip } from '@/features/deployments/shared/components/title-tooltip'
import { UnsupportedDslNodesAlert } from '@/features/deployments/shared/components/unsupported-dsl-nodes-alert'
import { isDeploymentDslImportEnabled } from '@/features/deployments/shared/domain/feature-flags'
import { StepShell } from './layout'
const sourceAppSkeletonKeys = ['first-source-app', 'second-source-app', 'third-source-app']
export function SourceStepContent() {
const method = useAtomValue(effectiveMethodAtom)
const unsupportedDslNodes = useAtomValue(unsupportedDslNodesAtom)
return (
<div className="flex min-h-0 flex-1 flex-col gap-4">
<SourceMethodSection />
{method === 'bindApp' && <SourceAppSelectionSection />}
{method === 'importDsl' && <DslUploadSection />}
<UnsupportedDslNodesAlert nodes={unsupportedDslNodes} />
</div>
)
}
function SourceMethodSection() {
const { t } = useTranslation('deployments')
const method = useAtomValue(effectiveMethodAtom)
const selectMethod = useSetAtom(selectMethodAtom)
return (
<StepShell
title={t(($) => $['createGuide.steps.method'])}
description={t(($) => $['createGuide.method.description'])}
descriptionClassName="lg:hidden"
hideHeader
>
<RadioGroup<GuideMethod>
aria-label={t(($) => $['createGuide.steps.method'])}
value={method}
onValueChange={selectMethod}
className="flex flex-col items-stretch gap-2 sm:flex-row"
>
<SourceMethodCard
value="bindApp"
icon="i-ri-stack-line"
title={t(($) => $['createGuide.methods.bindApp.title'])}
description={t(($) => $['createGuide.methods.bindApp.description'])}
/>
{isDeploymentDslImportEnabled && (
<SourceMethodCard
value="importDsl"
icon="i-ri-file-code-line"
title={t(($) => $['createGuide.methods.importDsl.title'])}
description={t(($) => $['createGuide.methods.importDsl.description'])}
/>
)}
</RadioGroup>
</StepShell>
)
}
function SourceMethodCard({
value,
icon,
title,
description,
badge,
}: {
value: GuideMethod
icon: string
title: string
description: string
badge?: string
}) {
return (
<RadioItem<GuideMethod>
value={value}
nativeButton
render={<button type="button" />}
className={cn(
`relative box-content h-21 w-full cursor-pointer rounded-xl border-[0.5px] border-components-option-card-option-border bg-components-panel-on-panel-item-bg p-3 text-left shadow-xs outline-hidden hover:shadow-md focus-visible:ring-2 focus-visible:ring-state-accent-solid sm:w-60`,
'data-checked:border-components-option-card-option-selected-border data-checked:bg-components-option-card-option-selected-bg data-checked:shadow-md data-checked:inset-ring-[0.5px] data-checked:inset-ring-components-option-card-option-selected-border',
)}
>
<span className="flex size-6 shrink-0 items-center justify-center rounded-md border border-divider-subtle bg-background-default-subtle">
<span className={cn('size-4 text-text-tertiary', icon)} aria-hidden="true" />
</span>
<span className="mt-2 mb-0.5 flex min-w-0 items-center gap-1">
<span className="truncate system-sm-semibold text-text-secondary">{title}</span>
{badge && (
<span className="shrink-0 rounded-md bg-background-default-subtle px-1.5 py-0.5 system-2xs-medium-uppercase text-text-tertiary">
{badge}
</span>
)}
</span>
<span className="flex min-w-0 items-start gap-1">
<TitleTooltip content={description}>
<span className="line-clamp-2 min-w-0 grow system-xs-regular text-text-tertiary">
{description}
</span>
</TitleTooltip>
</span>
</RadioItem>
)
}
function SourceAppSelectionSection() {
const { t } = useTranslation('deployments')
return (
<StepShell
title={t(($) => $['createGuide.source.title'])}
description={t(($) => $['createGuide.source.description'])}
descriptionClassName="lg:hidden"
hideHeader
className="min-h-0 flex-1"
>
<div className="flex min-h-0 flex-1 flex-col gap-3">
<SourceSearchInput />
<SourceAppList />
</div>
</StepShell>
)
}
function SourceSearchInput() {
const { t } = useTranslation('deployments')
const sourceSearchText = useAtomValue(sourceSearchTextAtom)
const setSourceSearchText = useSetAtom(setSourceSearchTextAtom)
return (
<div className="relative">
<span
className="pointer-events-none absolute top-1/2 left-2.5 i-ri-search-line size-4 -translate-y-1/2 text-text-tertiary"
aria-hidden="true"
/>
<Input
id="create-guide-source-search"
aria-label={t(($) => $['createGuide.source.sourceApp'])}
value={sourceSearchText}
onChange={(event) => setSourceSearchText(event.target.value)}
placeholder={t(($) => $['createGuide.source.searchPlaceholder'])}
className="h-9 pr-8 pl-8"
/>
{sourceSearchText && (
<button
type="button"
aria-label={t(($) => $['createGuide.source.clearSearch'])}
onClick={() => setSourceSearchText('')}
className="absolute top-1/2 right-2.5 flex size-4 -translate-y-1/2 items-center justify-center text-text-quaternary hover:text-text-secondary"
>
<span className="i-ri-close-circle-fill size-4" aria-hidden="true" />
</button>
)}
</div>
)
}
function SourceAppList() {
const { t } = useTranslation('deployments')
const selectSourceApp = useSetAtom(selectSourceAppAtom)
const effectiveSelectedApp = useAtomValue(effectiveSelectedAppAtom)
const sourceApps = useAtomValue(sourceAppsAtom)
const sourceAppsError = useAtomValue(sourceAppsErrorAtom)
const sourceAppsFetchNextPage = useAtomValue(sourceAppsFetchNextPageAtom)
const sourceAppsHasNextPage = useAtomValue(sourceAppsHasNextPageAtom)
const sourceAppsIsFetching = useAtomValue(sourceAppsIsFetchingAtom)
const sourceAppsIsFetchingNextPage = useAtomValue(sourceAppsIsFetchingNextPageAtom)
const sourceAppsIsLoading = useAtomValue(sourceAppsIsLoadingAtom)
const sourceAppsIsPlaceholderData = useAtomValue(sourceAppsIsPlaceholderDataAtom)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const sourceAppsLoading =
sourceAppsIsLoading ||
sourceAppsIsPlaceholderData ||
(sourceAppsIsFetching && sourceApps.length === 0)
const canLoadMore = !sourceAppsIsFetching && !sourceAppsError
return (
<div
ref={scrollContainerRef}
className="min-h-0 flex-1 overflow-y-auto rounded-lg border border-divider-subtle bg-background-default"
>
{sourceAppsLoading ? (
<SourceAppSkeleton />
) : sourceApps.length === 0 ? (
<DeploymentStateMessage variant="embedded">
{t(($) => $['createGuide.source.empty'])}
</DeploymentStateMessage>
) : (
<div>
{sourceApps.map((app) => (
<SourceAppOption
key={app.id}
app={app}
selected={effectiveSelectedApp?.id === app.id}
onSelect={() => selectSourceApp(app)}
/>
))}
{sourceAppsIsFetchingNextPage && (
<div className="border-t border-divider-subtle px-3 py-2 text-center system-xs-regular text-text-tertiary">
{t(($) => $['createModal.loadingApps'])}
</div>
)}
{sourceAppsHasNextPage && (
<InfiniteScrollSentinel
canLoadMore={canLoadMore}
onLoadMore={() => {
void sourceAppsFetchNextPage({ cancelRefetch: false })
}}
preloadDistance={160}
scrollContainerRef={scrollContainerRef}
/>
)}
</div>
)}
</div>
)
}
function SourceAppSkeleton() {
return (
<div className="divide-y divide-divider-subtle">
{sourceAppSkeletonKeys.map((key) => (
<SkeletonRow key={key} className="h-14 px-3 py-2">
<SkeletonRectangle className="my-0 size-7 animate-pulse rounded-lg" />
<div className="flex min-w-0 grow flex-col gap-1">
<SkeletonRectangle className="my-0 h-3.5 w-2/3 animate-pulse" />
<SkeletonRectangle className="my-0 h-2.5 w-1/3 animate-pulse" />
</div>
</SkeletonRow>
))}
</div>
)
}
function SourceAppOption({
app,
onSelect,
selected,
}: {
app: WorkflowSourceApp
onSelect: () => void
selected: boolean
}) {
const appIconType = zIconType.safeParse(app.icon_type).data ?? null
return (
<label
className={cn(
'group flex min-h-14 cursor-pointer items-center gap-3 border-b border-b-divider-subtle px-3 py-2 transition-colors first:rounded-t-lg last:rounded-b-lg last:border-b-0',
selected
? 'bg-state-accent-hover hover:bg-state-accent-hover'
: 'bg-background-default hover:bg-state-base-hover',
)}
>
<AppIcon
className="shrink-0"
size="xs"
iconType={appIconType}
icon={app.icon ?? undefined}
background={app.icon_background}
imageUrl={app.icon_url}
/>
<span className="flex min-w-0 grow">
<span
className={cn(
'truncate system-sm-medium',
selected ? 'text-text-accent' : 'text-text-primary',
)}
>
{app.name}
</span>
</span>
<input
type="radio"
name="source-app"
checked={selected}
onChange={onSelect}
className="sr-only"
/>
<span
className={cn(
'flex size-5 shrink-0 items-center justify-center rounded-full',
selected ? 'bg-primary-600 text-text-primary-on-surface' : 'text-transparent',
)}
aria-hidden="true"
>
<span className="i-ri-check-line size-4" />
</span>
</label>
)
}
function DslUploadSection() {
const { t } = useTranslation('deployments')
const dslFile = useAtomValue(dslFileAtom)
const selectDslFile = useSetAtom(selectDslFileAtom)
return (
<StepShell
title={t(($) => $['createGuide.dsl.title'])}
description={t(($) => $['createGuide.dsl.description'])}
hideHeader
>
<div className="flex flex-col gap-4 rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-5">
<div className="flex items-start gap-3">
<span
className="mt-0.5 i-ri-upload-cloud-2-line size-5 shrink-0 text-text-tertiary"
aria-hidden="true"
/>
<div className="flex min-w-0 flex-col gap-1">
<div className="system-sm-semibold text-text-primary">
{t(($) => $['createGuide.dsl.dropTitle'])}
</div>
<div className="system-sm-regular text-text-tertiary">
{t(($) => $['createGuide.dsl.dropDescription'])}
</div>
</div>
</div>
<Uploader className="mt-0" file={dslFile} updateFile={selectDslFile} />
<DslReadStatus />
</div>
</StepShell>
)
}
function DslReadStatus() {
const { t } = useTranslation('deployments')
const isReadingDsl = useAtomValue(isReadingDslAtom)
const dslReadError = useAtomValue(dslReadErrorAtom)
const dslUnsupportedMode = useAtomValue(dslUnsupportedModeAtom)
return (
<>
{isReadingDsl && (
<div className="system-xs-regular text-text-tertiary">
{t(($) => $['createGuide.dsl.reading'])}
</div>
)}
{dslReadError && (
<div className="system-xs-regular text-text-destructive">
{t(($) => $['createGuide.dsl.readFailed'])}
</div>
)}
{dslUnsupportedMode && (
<div role="alert" className="system-xs-regular text-text-destructive">
{t(($) => $['createGuide.dsl.unsupportedMode'])}
</div>
)}
</>
)
}
export function SourceActionButtons() {
const { t } = useTranslation('deployments')
const canGoNext = useAtomValue(sourceCanGoNextAtom)
const continueFromSource = useSetAtom(continueFromSourceAtom)
return (
<Button
type="button"
variant="primary"
disabled={!canGoNext}
onClick={() =>
continueFromSource({
defaultDslAppName: t(($) => $['createGuide.dsl.defaultAppName']),
defaultReleaseName: t(($) => $['createGuide.release.defaultName']),
})
}
>
{t(($) => $['createGuide.actions.next'])}
</Button>
)
}
@@ -1,389 +0,0 @@
'use client'
import type { Environment } from '@dify/contracts/enterprise/types.gen'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RadioControl, RadioGroup, RadioItem } from '@langgenius/dify-ui/radio'
import { toast } from '@langgenius/dify-ui/toast'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import {
envVarValuesAtom,
isCreatingReleaseOnlyAtom,
isSubmittingDeploymentGuideAtom,
selectedEnvironmentIdAtom,
stepAtom,
} from '@/features/deployments/create-guide/state/primitives'
import {
deployableEnvironmentsIsErrorAtom,
deployableEnvironmentsIsFetchingAtom,
deployableEnvironmentsIsLoadingAtom,
deploymentOptionsDataAtom,
deploymentOptionsIsErrorAtom,
deploymentOptionsIsFetchingAtom,
deploymentOptionsIsLoadingAtom,
unsupportedDslNodesAtom,
} from '@/features/deployments/create-guide/state/queries'
import {
createDeploymentGuideSubmissionAtom,
CreateDeploymentGuideSubmissionBlockedError,
} from '@/features/deployments/create-guide/state/submission'
import {
canDeployAtom,
canSkipDeploymentAtom,
deployableEnvironmentsAtom,
deploymentTargetBindingSelectionsAtom,
deploymentTargetBindingSlotsAtom,
deploymentTargetEnvVarSlotsAtom,
effectiveSelectedEnvironmentIdAtom,
selectBindingAtom,
setEnvVarAtom,
} from '@/features/deployments/create-guide/state/target'
import { EnvVarBindingsPanel } from '@/features/deployments/shared/components/env-var-bindings'
import { RuntimeCredentialBindingsPanel } from '@/features/deployments/shared/components/runtime-credential-bindings'
import { TitleTooltip } from '@/features/deployments/shared/components/title-tooltip'
import { UnsupportedDslNodesAlert } from '@/features/deployments/shared/components/unsupported-dsl-nodes-alert'
import { deploymentErrorMessage } from '@/features/deployments/shared/domain/error'
import { useRouter } from '@/next/navigation'
import { StepShell } from './layout'
const targetEnvironmentSkeletonKeys = ['first-target-environment', 'second-target-environment']
const targetBindingSkeletonKeys = ['first-target-binding', 'second-target-binding']
export function TargetStepContent() {
const { t } = useTranslation('deployments')
const unsupportedDslNodes = useAtomValue(unsupportedDslNodesAtom)
return (
<StepShell
title={t(($) => $['createGuide.target.title'])}
description={t(($) => $['createGuide.target.description'])}
hideHeader
>
<div className="flex flex-col gap-6">
<TargetEnvironmentSection />
<UnsupportedDslNodesAlert nodes={unsupportedDslNodes} />
<TargetBindingSection />
<TargetEnvVarSection />
</div>
</StepShell>
)
}
function TargetEnvironmentSection() {
const { t } = useTranslation('deployments')
const environmentsIsError = useAtomValue(deployableEnvironmentsIsErrorAtom)
const environmentsIsFetching = useAtomValue(deployableEnvironmentsIsFetchingAtom)
const environmentsIsLoading = useAtomValue(deployableEnvironmentsIsLoadingAtom)
const environments = useAtomValue(deployableEnvironmentsAtom)
const effectiveSelectedEnvironmentId = useAtomValue(effectiveSelectedEnvironmentIdAtom)
const isEnvironmentLoading =
environmentsIsLoading || (environmentsIsFetching && environments.length === 0)
const selectEnvironment = useSetAtom(selectedEnvironmentIdAtom)
const hasEnvironmentOptions = environments.length > 0
return (
<div className="flex flex-col gap-3">
<div className="system-xs-medium-uppercase text-text-tertiary">
{t(($) => $['createGuide.target.environment'])}
</div>
{hasEnvironmentOptions ? (
<RadioGroup<string>
aria-label={t(($) => $['createGuide.target.environment'])}
value={effectiveSelectedEnvironmentId}
onValueChange={selectEnvironment}
className="grid grid-cols-1 items-stretch gap-3 lg:grid-cols-2"
>
{environments.map((environment) => (
<EnvironmentOptionRow key={environment.id} environment={environment} />
))}
</RadioGroup>
) : isEnvironmentLoading ? (
<TargetEnvironmentSkeleton />
) : (
<div className="rounded-lg border border-divider-subtle bg-background-default-subtle px-3 py-3 system-sm-regular text-text-quaternary">
{environmentsIsError
? t(($) => $['createGuide.target.loadEnvironmentsFailed'])
: t(($) => $['createGuide.target.noEnvironmentOptions'])}
</div>
)}
</div>
)
}
function EnvironmentOptionRow({ environment }: { environment: Environment }) {
const { t } = useTranslation('deployments')
const summary =
environment.description.trim() ||
`${t(($) => $[`mode.${environment.mode}`])} · ${t(($) => $[`backend.${environment.backend}`])}`
return (
<RadioItem<string>
value={environment.id}
nativeButton
render={<button type="button" />}
className={cn(
'group flex cursor-pointer items-center gap-3 rounded-xl border p-3 outline-hidden',
'border-components-option-card-option-border bg-components-option-card-option-bg hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
'focus-visible:ring-2 focus-visible:ring-state-accent-solid',
'data-checked:border-state-accent-solid data-checked:bg-state-accent-hover data-checked:shadow-xs',
)}
>
<RadioControl />
<span className="flex min-w-0 grow flex-col gap-1">
<span className="truncate system-sm-semibold text-text-primary group-data-checked:text-text-accent">
{environment.displayName}
</span>
<TitleTooltip content={summary}>
<span className="line-clamp-1 system-xs-regular text-text-tertiary group-data-checked:text-text-secondary">
{summary}
</span>
</TitleTooltip>
</span>
</RadioItem>
)
}
function TargetBindingSection() {
const { t } = useTranslation('deployments')
const deploymentOptions = useAtomValue(deploymentOptionsDataAtom)
const deploymentOptionsIsError = useAtomValue(deploymentOptionsIsErrorAtom)
const deploymentOptionsIsFetching = useAtomValue(deploymentOptionsIsFetchingAtom)
const deploymentOptionsIsLoading = useAtomValue(deploymentOptionsIsLoadingAtom)
const bindingSlots = useAtomValue(deploymentTargetBindingSlotsAtom)
const bindingSelections = useAtomValue(deploymentTargetBindingSelectionsAtom)
const isBindingError = deploymentOptionsIsError
const isBindingLoading =
deploymentOptionsIsLoading || (deploymentOptionsIsFetching && !deploymentOptions)
const selectBinding = useSetAtom(selectBindingAtom)
const unsupportedDslNodes = useAtomValue(unsupportedDslNodesAtom)
const shouldRender = !(isBindingError && unsupportedDslNodes.length > 0)
if (!shouldRender) return null
if (isBindingLoading || isBindingError) {
return (
<div className="overflow-hidden rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg">
<div className="flex min-w-0 flex-col gap-0.5 px-3 py-2.5">
<div className="system-xs-medium-uppercase text-text-tertiary">
{t(($) => $['createGuide.target.bindings'])}
</div>
<span className="system-xs-regular text-text-tertiary">
{t(($) => $['createGuide.target.bindingHint'])}
</span>
</div>
{isBindingLoading ? (
<TargetBindingSkeleton />
) : (
<div className="border-t border-divider-subtle px-3 py-3 system-sm-regular text-text-quaternary">
{t(($) => $['createGuide.target.loadBindingsFailed'])}
</div>
)}
</div>
)
}
return (
<RuntimeCredentialBindingsPanel
slots={bindingSlots}
selections={bindingSelections}
title={t(($) => $['createGuide.target.bindings'])}
hint={t(($) => $['createGuide.target.bindingHint'])}
noBindingRequiredLabel={t(($) => $['createGuide.target.noBindingRequired'])}
noCredentialCandidatesLabel={t(($) => $['createGuide.target.noCredentialCandidates'])}
selectCredentialLabel={t(($) => $['createGuide.target.selectCredential'])}
missingRequiredLabel={t(($) => $['createGuide.target.missingRequiredBinding'])}
bindingCountLabel={t(($) => $['createGuide.target.bindingCount'], {
count: bindingSlots.length,
})}
onChange={selectBinding}
listScrollable={false}
className="border-components-option-card-option-border bg-components-option-card-option-bg"
/>
)
}
function TargetEnvVarSection() {
const { t } = useTranslation('deployments')
const setEnvVar = useSetAtom(setEnvVarAtom)
const envVarValues = useAtomValue(envVarValuesAtom)
const deploymentOptions = useAtomValue(deploymentOptionsDataAtom)
const deploymentOptionsIsError = useAtomValue(deploymentOptionsIsErrorAtom)
const deploymentOptionsIsFetching = useAtomValue(deploymentOptionsIsFetchingAtom)
const deploymentOptionsIsLoading = useAtomValue(deploymentOptionsIsLoadingAtom)
const envVarSlots = useAtomValue(deploymentTargetEnvVarSlotsAtom)
const isBindingError = deploymentOptionsIsError
const isBindingLoading =
deploymentOptionsIsLoading || (deploymentOptionsIsFetching && !deploymentOptions)
if (isBindingLoading || isBindingError) return null
return (
<EnvVarBindingsPanel
slots={envVarSlots}
values={envVarValues}
title={t(($) => $['createGuide.target.envVars'])}
hint={t(($) => $['createGuide.target.envVarHint'])}
envVarPlaceholder={t(($) => $['createGuide.target.envVarPlaceholder'])}
literalSourceLabel={t(($) => $['createGuide.target.envVarSource.literal'])}
defaultSourceLabel={t(($) => $['createGuide.target.envVarSource.default'])}
lastDeploymentSourceLabel={t(($) => $['createGuide.target.envVarSource.lastDeployment'])}
valueTypeLabels={{
string: t(($) => $['createGuide.target.envVarType.string']),
number: t(($) => $['createGuide.target.envVarType.number']),
secret: t(($) => $['createGuide.target.envVarType.secret']),
}}
sourceAriaLabel={(key) => t(($) => $['createGuide.target.envVarSource.ariaLabel'], { key })}
envVarCountLabel={t(($) => $['createGuide.target.envVarCount'], {
count: envVarSlots.length,
})}
onChange={setEnvVar}
listScrollable={false}
className="border-components-option-card-option-border bg-components-option-card-option-bg"
/>
)
}
function TargetEnvironmentSkeleton() {
return (
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{targetEnvironmentSkeletonKeys.map((key) => (
<SkeletonRow key={key} className="h-17 rounded-xl border border-divider-subtle px-3 py-3">
<SkeletonRectangle className="my-0 size-4 animate-pulse rounded-full" />
<div className="flex min-w-0 grow flex-col gap-1.5">
<SkeletonRectangle className="my-0 h-3.5 w-1/2 animate-pulse" />
<SkeletonRectangle className="my-0 h-3 w-2/3 animate-pulse" />
</div>
</SkeletonRow>
))}
</div>
)
}
function TargetBindingSkeleton() {
return (
<div className="border-t border-divider-subtle">
{targetBindingSkeletonKeys.map((key) => (
<SkeletonRow key={key} className="h-15 px-3 py-3">
<div className="flex min-w-0 grow flex-col gap-1.5">
<SkeletonRectangle className="my-0 h-3.5 w-1/3 animate-pulse" />
<SkeletonRectangle className="my-0 h-3 w-1/2 animate-pulse" />
</div>
<SkeletonRectangle className="my-0 h-8 w-48 animate-pulse rounded-lg" />
</SkeletonRow>
))}
</div>
)
}
export function TargetBackButton() {
const { t } = useTranslation('deployments')
const setStep = useSetAtom(stepAtom)
const isSubmitting = useAtomValue(isSubmittingDeploymentGuideAtom)
return (
<Button
type="button"
variant="secondary"
onClick={() => setStep('release')}
disabled={isSubmitting}
>
{t(($) => $['createGuide.actions.back'])}
</Button>
)
}
export function TargetSkipDeploymentButton() {
const { t } = useTranslation('deployments')
const router = useRouter()
const canSkipDeployment = useAtomValue(canSkipDeploymentAtom)
const submitCreateDeploymentGuide = useSetAtom(createDeploymentGuideSubmissionAtom)
const isSubmitting = useAtomValue(isSubmittingDeploymentGuideAtom)
const isSkippingDeployment = useAtomValue(isCreatingReleaseOnlyAtom)
const label = isSkippingDeployment
? t(($) => $['createGuide.actions.creating'])
: t(($) => $['createGuide.actions.skipDeploy'])
async function handleSkipDeployment() {
if (!canSkipDeployment) return
try {
const appInstanceId = await submitCreateDeploymentGuide({ deployToEnvironment: false })
if (appInstanceId) router.push(`/deployments/${appInstanceId}/overview`)
} catch (error) {
await showSubmissionError({
error,
fallbackMessage: t(($) => $['createGuide.errors.createReleaseFailed']),
unsupportedDslModeMessage: t(($) => $['createGuide.dsl.unsupportedMode']),
})
}
}
return (
<Button
type="button"
variant="secondary"
disabled={!canSkipDeployment || isSubmitting}
onClick={handleSkipDeployment}
>
{label}
</Button>
)
}
export function TargetDeployButton() {
const { t } = useTranslation('deployments')
const router = useRouter()
const canDeploy = useAtomValue(canDeployAtom)
const submitCreateDeploymentGuide = useSetAtom(createDeploymentGuideSubmissionAtom)
const isSubmitting = useAtomValue(isSubmittingDeploymentGuideAtom)
const isSkippingDeployment = useAtomValue(isCreatingReleaseOnlyAtom)
const label =
isSubmitting && !isSkippingDeployment
? t(($) => $['createGuide.actions.deploying'])
: t(($) => $['createGuide.actions.createAndDeploy'])
async function handleDeploy() {
if (!canDeploy) return
try {
const appInstanceId = await submitCreateDeploymentGuide({ deployToEnvironment: true })
if (appInstanceId) router.push(`/deployments/${appInstanceId}/overview`)
} catch (error) {
await showSubmissionError({
error,
fallbackMessage: t(($) => $['createGuide.errors.deployFailed']),
unsupportedDslModeMessage: t(($) => $['createGuide.dsl.unsupportedMode']),
})
}
}
return (
<Button
type="button"
variant="primary"
disabled={!canDeploy || isSubmitting}
onClick={handleDeploy}
>
{label}
</Button>
)
}
async function showSubmissionError({
error,
fallbackMessage,
unsupportedDslModeMessage,
}: {
error: unknown
fallbackMessage: string
unsupportedDslModeMessage: string
}) {
if (error instanceof CreateDeploymentGuideSubmissionBlockedError) {
toast.error(error.reason === 'unsupportedDslMode' ? unsupportedDslModeMessage : fallbackMessage)
return
}
toast.error((await deploymentErrorMessage(error)) || fallbackMessage)
}
@@ -1,18 +0,0 @@
# Deployment Create Release
Release creation dialog for selecting a source app or DSL file and creating a new release for an existing deployment app instance.
## Internal Modules
| Module | Why this module uses it |
| -------- | ------------------------------------------------------------------------------ |
| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. |
## External Modules
| Module | Why this module uses it |
| --------------------------------------------------- | -------------------------------------------------------------------------------- |
| `app/components/app/create-from-dsl-modal/uploader` | Reuses the existing DSL file uploader for the DSL release source. |
| `app/components/base/app-icon` | Renders source app icons consistently in the source app picker. |
| `app/components/base/skeleton` | Reuses skeleton primitives for source app picker loading rows. |
| `types/app` | Uses app types and mode enums to narrow selectable source apps to workflow apps. |
@@ -1,82 +0,0 @@
'use client'
import type { ButtonProps } from '@langgenius/dify-ui/button'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogTrigger } from '@langgenius/dify-ui/dialog'
import { useAtomValue, useSetAtom } from 'jotai'
import { ScopeProvider } from 'jotai-scope'
import { useTranslation } from 'react-i18next'
import {
createReleaseAppInstanceIdAtom,
createReleaseDialogOpenAtom,
createReleaseLocalAtoms,
isCreatingReleaseAtom,
openCreateReleaseDialogAtom,
requestCloseCreateReleaseDialogAtom,
} from './state'
import { CreateReleaseDialogContent } from './ui/dialog'
function CreateReleaseScopedControl({
variant,
size,
label,
className,
}: {
variant: ButtonProps['variant']
size: ButtonProps['size']
label?: string
className?: string
}) {
const { t } = useTranslation('deployments')
const open = useAtomValue(createReleaseDialogOpenAtom)
const isCreatingRelease = useAtomValue(isCreatingReleaseAtom)
const openDialog = useSetAtom(openCreateReleaseDialogAtom)
const requestCloseDialog = useSetAtom(requestCloseCreateReleaseDialogAtom)
function handleDialogOpenChange(nextOpen: boolean) {
if (nextOpen) {
openDialog()
return
}
if (!isCreatingRelease) requestCloseDialog()
}
return (
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
<DialogTrigger render={<Button size={size} variant={variant} className={className} />}>
{label ?? t(($) => $['versions.createRelease'])}
</DialogTrigger>
<CreateReleaseDialogContent />
</Dialog>
)
}
export function CreateReleaseControl({
appInstanceId,
variant = 'primary',
size = 'small',
label,
className,
}: {
appInstanceId: string
variant?: ButtonProps['variant']
size?: ButtonProps['size']
label?: string
className?: string
}) {
return (
<ScopeProvider
key={appInstanceId}
atoms={[[createReleaseAppInstanceIdAtom, appInstanceId], ...createReleaseLocalAtoms]}
name="CreateRelease"
>
<CreateReleaseScopedControl
variant={variant}
size={size}
label={label}
className={className}
/>
</ScopeProvider>
)
}
@@ -1,205 +0,0 @@
import type { QueryClient } from '@tanstack/react-query'
import { skipToken } from '@tanstack/react-query'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createQueryAtomTestStore } from '@/test/query-atom'
type QueryOptions = {
enabled?: boolean
input?: unknown
}
type InfiniteQueryOptions = QueryOptions & {
input?: (pageParam: number) => unknown
}
const mockSourceAppsInfiniteOptions = vi.hoisted(() => vi.fn())
const mockPrecheckReleaseQueryOptions = vi.hoisted(() => vi.fn())
const mockCreateRelease = vi.hoisted(() => vi.fn())
vi.mock('../../../shared/domain/feature-flags', () => ({
isDeploymentDslImportEnabled: true,
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
apps: {
get: {
infiniteOptions: (options: InfiniteQueryOptions) => {
mockSourceAppsInfiniteOptions(options)
return {
...options,
queryKey: ['sourceApps', options.input],
queryFn: async () => ({
data: [],
has_more: false,
limit: 20,
page: 1,
total: 0,
}),
}
},
},
},
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) => {
mockPrecheckReleaseQueryOptions(options)
return {
...options,
queryKey: ['precheckRelease', options.input],
}
},
},
createRelease: {
mutationOptions: () => ({
mutationKey: ['createRelease'],
mutationFn: mockCreateRelease,
}),
},
},
},
},
}))
async function loadState() {
return await import('../index')
}
async function mountedStore() {
const state = await loadState()
const { queryClient, store } = createQueryAtomTestStore()
const unsubscribe = store.sub(state.createReleaseFormIsSubmittingAtom, () => undefined)
return {
queryClient,
state,
store,
unsubscribe,
}
}
function workflowDsl() {
return ['app:', ' mode: workflow', ' name: Release source'].join('\n')
}
function setDslFileContentResult(queryClient: QueryClient, file: File, fileReadVersion: number) {
queryClient.setQueryData(
['createReleaseDslFileContent', fileReadVersion, file, file.name, file.size, file.lastModified],
workflowDsl(),
)
}
function setPrecheckReleaseResult(
queryClient: QueryClient,
input: {
body: {
appInstanceId: string
dsl: string
}
},
) {
queryClient.setQueryData(['precheckRelease', input], {
gateCommitId: 'gate-commit-1',
canCreate: true,
unsupportedNodes: [],
})
}
describe('create release state with DSL import enabled', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCreateRelease.mockReset()
})
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')
store.get(state.createReleaseSourceAppsQueryAtom)
const sourceAppsQuery = mockSourceAppsInfiniteOptions.mock.lastCall?.[0] as InfiniteQueryOptions
expect(sourceAppsQuery.enabled).toBe(true)
expect(sourceAppsQuery.input?.(2)).toEqual({
query: {
page: 2,
limit: 20,
name: 'customer',
mode: 'workflow',
},
})
unsubscribe()
})
it('should skip DSL release precheck input until DSL content is ready', async () => {
const { state, store, unsubscribe } = await mountedStore()
store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1')
store.set(state.openCreateReleaseDialogAtom)
store.set(state.selectCreateReleaseSourceModeAtom, 'dsl')
store.get(state.isCheckingCreateReleaseContentAtom)
expect(mockPrecheckReleaseQueryOptions).toHaveBeenLastCalledWith(
expect.objectContaining({
enabled: false,
input: skipToken,
}),
)
unsubscribe()
})
it('should submit a DSL release with encoded workflow content', async () => {
const { queryClient, state, store, unsubscribe } = await mountedStore()
const response = {
release: {
displayName: 'Release from DSL',
},
}
const file = new File([workflowDsl()], 'workflow.yml', { type: 'text/yaml' })
mockCreateRelease.mockResolvedValue(response)
store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1')
store.set(state.openCreateReleaseDialogAtom)
store.set(state.selectCreateReleaseSourceModeAtom, 'dsl')
store.set(state.updateCreateReleaseDslFileAtom, file)
setDslFileContentResult(queryClient, file, 2)
const encodedDslContent = store.get(state.createReleaseEncodedDslContentAtom)
setPrecheckReleaseResult(queryClient, {
body: {
appInstanceId: 'app-instance-1',
dsl: encodedDslContent,
},
})
store.set(state.createReleaseNameFieldAtom, ' Release from DSL ')
store.set(state.createReleaseDescriptionFieldAtom, ' Imported workflow ')
const result = await store.set(state.submitCreateReleaseFormAtom)
expect(result).toBe(response)
expect(encodedDslContent).not.toBe('')
expect(mockCreateRelease.mock.calls[0]?.[0]).toEqual({
body: {
appInstanceId: 'app-instance-1',
displayName: 'Release from DSL',
description: 'Imported workflow',
createAppInstance: false,
dsl: encodedDslContent,
},
})
unsubscribe()
})
})
@@ -1,513 +0,0 @@
import type { QueryClient } from '@tanstack/react-query'
import type { CreateReleaseFormValues } from '../index'
import { skipToken } from '@tanstack/react-query'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { consoleQuery } from '@/service/client'
import { createQueryAtomTestStore } from '@/test/query-atom'
type QueryOptions = {
enabled?: boolean
input?: unknown
}
const mockPrecheckReleaseQueryOptions = vi.hoisted(() => vi.fn())
const mockCreateRelease = vi.hoisted(() => vi.fn())
vi.mock('@/service/client', () => ({
consoleQuery: {
apps: {
byAppId: {
get: {
queryOptions: ({ enabled, input }: QueryOptions) => ({
enabled,
input,
queryKey: ['appById', input],
}),
},
},
},
enterprise: {
releaseService: {
listReleaseSummaries: {
key: ({ input }: { input?: unknown } = {}) =>
input === undefined ? ['listReleaseSummaries'] : ['listReleaseSummaries', input],
queryOptions: ({ enabled, input }: QueryOptions) => ({
enabled,
input,
queryKey: ['listReleaseSummaries', input],
}),
},
listReleases: {
key: ({ input }: { input?: unknown } = {}) =>
input === undefined ? ['listReleases'] : ['listReleases', input],
queryOptions: ({ enabled, input }: QueryOptions) => ({
enabled,
input,
queryKey: ['listReleases', input],
}),
},
precheckRelease: {
queryOptions: ({ enabled, input }: QueryOptions) => {
mockPrecheckReleaseQueryOptions({ enabled, input })
return {
enabled,
input,
queryKey: ['precheckRelease', input],
}
},
},
createRelease: {
mutationOptions: () => ({
mutationKey: ['createRelease'],
mutationFn: mockCreateRelease,
}),
},
},
},
},
}))
async function loadState() {
return await import('../index')
}
async function mountedStore() {
const state = await loadState()
const { queryClient, store } = createQueryAtomTestStore()
const unsubscribe = store.sub(state.createReleaseFormIsSubmittingAtom, () => undefined)
return {
queryClient,
state,
store,
unsubscribe,
}
}
function sourceApp(
overrides: Partial<NonNullable<CreateReleaseFormValues['sourceApp']>> = {},
): NonNullable<CreateReleaseFormValues['sourceApp']> {
return {
id: 'source-app-1',
name: 'Source App',
mode: 'workflow',
icon_url: null,
...overrides,
}
}
function validationIssueMessage(error: unknown) {
if (!error || typeof error !== 'object' || !('message' in error)) return undefined
return typeof error.message === 'string' ? error.message : undefined
}
function hasValidationIssue(errors: unknown[], message: string) {
return errors.some((error) => validationIssueMessage(error) === message)
}
function workflowDsl() {
return ['app:', ' mode: workflow', ' name: Release source'].join('\n')
}
function setDefaultSourceApp(
queryClient: QueryClient,
appInstanceId: string,
defaultSourceApp = sourceApp({ id: 'default-source-app', name: 'Default Source App' }),
) {
queryClient.setQueryData(
[
'listReleases',
{
params: { appInstanceId },
query: {
pageNumber: 1,
resultsPerPage: 1,
},
},
],
{
releases: [
{
displayName: 'Previous Release',
sourceAppId: defaultSourceApp.id,
},
],
},
)
queryClient.setQueryData(
['appById', { params: { app_id: defaultSourceApp.id } }],
defaultSourceApp,
)
}
function setPrecheckReleaseResult(
queryClient: QueryClient,
input: {
body: {
appInstanceId: string
dsl?: string
sourceAppId?: string
}
},
overrides: {
canCreate?: boolean
matchedRelease?: unknown
unsupportedNodes?: Array<{ id?: string; type?: string }>
} = {},
) {
queryClient.setQueryData(['precheckRelease', input], {
gateCommitId: 'gate-commit-1',
canCreate: true,
unsupportedNodes: [],
...overrides,
})
}
function setCachedReleaseSummaries(
queryClient: QueryClient,
appInstanceId: string,
displayNames: string[],
) {
queryClient.setQueryData(
consoleQuery.enterprise.releaseService.listReleaseSummaries.key({
type: 'query',
input: { params: { appInstanceId } },
}),
{
releaseSummaries: displayNames.map((displayName) => ({
release: {
displayName,
},
})),
pagination: {},
},
)
}
function setDslFileContentResult(queryClient: QueryClient, file: File, fileReadVersion: number) {
queryClient.setQueryData(
['createReleaseDslFileContent', fileReadVersion, file, file.name, file.size, file.lastModified],
workflowDsl(),
)
}
describe('create release state', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCreateRelease.mockReset()
})
it('should validate release name only when submitting', async () => {
const { state, store, unsubscribe } = await mountedStore()
await store.set(state.submitCreateReleaseFormAtom)
expect(mockCreateRelease).not.toHaveBeenCalled()
expect(
hasValidationIssue(
store.get(state.createReleaseNameFieldAtom).meta?.errors ?? [],
state.RELEASE_NAME_REQUIRED_ERROR,
),
).toBe(true)
unsubscribe()
})
it('should submit after fixing release name following a submit validation error', async () => {
const { queryClient, state, store, unsubscribe } = await mountedStore()
const response = {
release: {
displayName: 'Release 1',
},
}
mockCreateRelease.mockResolvedValue(response)
store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1')
store.set(state.openCreateReleaseDialogAtom)
setDefaultSourceApp(queryClient, 'app-instance-1')
setPrecheckReleaseResult(queryClient, {
body: {
appInstanceId: 'app-instance-1',
sourceAppId: 'default-source-app',
},
})
await store.set(state.submitCreateReleaseFormAtom)
store.set(state.createReleaseNameFieldAtom, 'Release 1')
const result = await store.set(state.submitCreateReleaseFormAtom)
expect(result).toBe(response)
expect(mockCreateRelease).toHaveBeenCalledTimes(1)
unsubscribe()
})
it('should coerce DSL source mode to source app mode when DSL import is disabled', async () => {
const { state, store, unsubscribe } = await mountedStore()
store.set(state.selectCreateReleaseSourceModeAtom, 'dsl')
expect(store.get(state.createReleaseSourceModeFieldAtom).value).toBe('sourceApp')
expect(store.get(state.createReleaseSourceModeAtom)).toBe('sourceApp')
unsubscribe()
})
it('should derive default source app selection from the latest release source', async () => {
const { queryClient, state, store, unsubscribe } = await mountedStore()
store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1')
store.set(state.openCreateReleaseDialogAtom)
setDefaultSourceApp(queryClient, 'app-instance-1')
expect(store.get(state.createReleaseSelectedSourceAppAtom)).toEqual({
id: 'default-source-app',
name: 'Default Source App',
mode: 'workflow',
icon_url: null,
})
expect(store.get(state.createReleaseSelectedSourceAppAtom)?.id).toBe('default-source-app')
unsubscribe()
})
it('should derive workflow DSL read state when selecting a DSL file', async () => {
const { queryClient, state, store, unsubscribe } = await mountedStore()
const file = new File([workflowDsl()], 'workflow.yml', { type: 'text/yaml' })
store.set(state.updateCreateReleaseDslFileAtom, file)
setDslFileContentResult(queryClient, file, 1)
expect(store.get(state.createReleaseDslFileFieldAtom).value).toBe(file)
expect(store.get(state.createReleaseDslContentAtom)).toBe(workflowDsl())
expect(store.get(state.createReleaseHasDslContentAtom)).toBe(true)
expect(store.get(state.isReadingCreateReleaseDslAtom)).toBe(false)
expect(store.get(state.createReleaseIsWorkflowDslContentAtom)).toBe(true)
expect(store.get(state.createReleaseEncodedDslContentAtom)).not.toBe('')
unsubscribe()
})
it('should reset DSL state when switching back to source app mode', async () => {
const { queryClient, state, store, unsubscribe } = await mountedStore()
const file = new File([workflowDsl()], 'workflow.yml', { type: 'text/yaml' })
store.set(state.updateCreateReleaseDslFileAtom, file)
setDslFileContentResult(queryClient, file, 1)
store.set(state.selectCreateReleaseSourceModeAtom, 'sourceApp')
expect(store.get(state.createReleaseSourceModeFieldAtom).value).toBe('sourceApp')
expect(store.get(state.createReleaseDslFileFieldAtom).value).toBeUndefined()
expect(store.get(state.createReleaseDslContentAtom)).toBe('')
expect(store.get(state.createReleaseDslReadErrorAtom)).toBe(false)
expect(store.get(state.createReleaseEncodedDslContentAtom)).toBe('')
expect(store.get(state.createReleaseHasDslContentAtom)).toBe(false)
expect(store.get(state.isReadingCreateReleaseDslAtom)).toBe(false)
expect(store.get(state.createReleaseIsWorkflowDslContentAtom)).toBe(false)
unsubscribe()
})
it('should reset source app search text when opening or closing the dialog', async () => {
const { state, store, unsubscribe } = await mountedStore()
store.set(state.createReleaseSourceAppSearchTextAtom, 'customer')
store.set(state.openCreateReleaseDialogAtom)
expect(store.get(state.createReleaseSourceAppSearchTextAtom)).toBe('')
store.set(state.createReleaseSourceAppSearchTextAtom, 'support')
store.set(state.closeCreateReleaseDialogAtom)
expect(store.get(state.createReleaseSourceAppSearchTextAtom)).toBe('')
unsubscribe()
})
it('should skip release content precheck input until source content is ready', async () => {
const { state, store, unsubscribe } = await mountedStore()
store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1')
store.set(state.openCreateReleaseDialogAtom)
store.get(state.isCheckingCreateReleaseContentAtom)
expect(mockPrecheckReleaseQueryOptions).toHaveBeenLastCalledWith(
expect.objectContaining({
enabled: false,
input: skipToken,
}),
)
unsubscribe()
})
it('should capture DSL file read failures and clear them when opening or closing the dialog', async () => {
const { state, store, unsubscribe } = await mountedStore()
const file = new File(['broken'], 'broken.yml', { type: 'text/yaml' })
vi.spyOn(file, 'text').mockRejectedValue(new Error('read failed'))
store.set(state.updateCreateReleaseDslFileAtom, file)
const unsubscribeDslReadError = store.sub(state.createReleaseDslReadErrorAtom, () => undefined)
await vi.waitFor(() => {
expect(store.get(state.createReleaseDslReadErrorAtom)).toBe(true)
})
store.set(state.openCreateReleaseDialogAtom)
expect(store.get(state.createReleaseDialogOpenAtom)).toBe(true)
expect(store.get(state.createReleaseDslReadErrorAtom)).toBe(false)
store.set(state.closeCreateReleaseDialogAtom)
expect(store.get(state.createReleaseDialogOpenAtom)).toBe(false)
unsubscribeDslReadError()
unsubscribe()
})
it('should derive content readiness from release content precheck', async () => {
const { queryClient, state, store, unsubscribe } = await mountedStore()
store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1')
store.set(state.openCreateReleaseDialogAtom)
setDefaultSourceApp(queryClient, 'app-instance-1')
setPrecheckReleaseResult(queryClient, {
body: {
appInstanceId: 'app-instance-1',
sourceAppId: 'default-source-app',
},
})
expect(store.get(state.createReleaseContentReadyAtom)).toBe(true)
store.set(state.createReleaseNameFieldAtom, 'Release 1')
expect(store.get(state.createReleaseContentReadyAtom)).toBe(true)
unsubscribe()
})
it('should detect existing release name conflicts from cached release summaries', async () => {
const { queryClient, state, store, unsubscribe } = await mountedStore()
store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1')
store.set(state.openCreateReleaseDialogAtom)
store.set(state.createReleaseNameFieldAtom, ' Release 1 ')
setCachedReleaseSummaries(queryClient, 'app-instance-1', ['Release 1'])
expect(store.get(state.createReleaseHasNameConflictAtom)).toBe(true)
unsubscribe()
})
it('should close the dialog through the close request action', async () => {
const { state, store, unsubscribe } = await mountedStore()
store.set(state.openCreateReleaseDialogAtom)
store.set(state.requestCloseCreateReleaseDialogAtom)
expect(store.get(state.createReleaseDialogOpenAtom)).toBe(false)
unsubscribe()
})
it('should expose unsupported nodes from release content precheck', async () => {
const { queryClient, state, store, unsubscribe } = await mountedStore()
store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1')
store.set(state.openCreateReleaseDialogAtom)
setDefaultSourceApp(queryClient, 'app-instance-1')
setPrecheckReleaseResult(
queryClient,
{
body: {
appInstanceId: 'app-instance-1',
sourceAppId: 'default-source-app',
},
},
{
canCreate: false,
unsupportedNodes: [{ id: 'precheck-node' }],
},
)
expect(store.get(state.createReleaseUnsupportedDslNodesAtom)).toEqual([{ id: 'precheck-node' }])
unsubscribe()
})
it('should submit source app release with the checked source and metadata', async () => {
const { queryClient, state, store, unsubscribe } = await mountedStore()
const response = {
release: {
displayName: 'Release 1',
},
}
mockCreateRelease.mockResolvedValue(response)
store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1')
store.set(state.openCreateReleaseDialogAtom)
setDefaultSourceApp(queryClient, 'app-instance-1')
setPrecheckReleaseResult(queryClient, {
body: {
appInstanceId: 'app-instance-1',
sourceAppId: 'default-source-app',
},
})
store.set(state.createReleaseNameFieldAtom, ' Release 1 ')
store.set(state.createReleaseDescriptionFieldAtom, ' Initial rollout ')
const result = await store.set(state.submitCreateReleaseFormAtom)
expect(result).toBe(response)
expect(mockCreateRelease.mock.calls[0]?.[0]).toEqual({
body: {
appInstanceId: 'app-instance-1',
sourceAppId: 'default-source-app',
displayName: 'Release 1',
description: 'Initial rollout',
createAppInstance: false,
},
})
unsubscribe()
})
it('should block release submission when release name already exists', async () => {
const { queryClient, state, store, unsubscribe } = await mountedStore()
store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1')
store.set(state.openCreateReleaseDialogAtom)
setDefaultSourceApp(queryClient, 'app-instance-1')
setPrecheckReleaseResult(queryClient, {
body: {
appInstanceId: 'app-instance-1',
sourceAppId: 'default-source-app',
},
})
setCachedReleaseSummaries(queryClient, 'app-instance-1', ['Release 1'])
store.set(state.createReleaseNameFieldAtom, 'Release 1')
const result = await store.set(state.submitCreateReleaseFormAtom)
expect(result).toBeUndefined()
expect(mockCreateRelease).not.toHaveBeenCalled()
unsubscribe()
})
it('should propagate create release submission errors', async () => {
const { queryClient, state, store, unsubscribe } = await mountedStore()
const submitError = new Error('submit failed')
mockCreateRelease.mockRejectedValue(submitError)
store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1')
store.set(state.openCreateReleaseDialogAtom)
setDefaultSourceApp(queryClient, 'app-instance-1')
setPrecheckReleaseResult(queryClient, {
body: {
appInstanceId: 'app-instance-1',
sourceAppId: 'default-source-app',
},
})
store.set(state.createReleaseNameFieldAtom, 'Release 1')
await expect(store.set(state.submitCreateReleaseFormAtom)).rejects.toThrow(submitError)
unsubscribe()
})
})
@@ -1,575 +0,0 @@
'use client'
import type { AppPartial } from '@dify/contracts/api/console/apps/types.gen'
import type {
CreateReleaseResponse,
ListReleasesResponse,
ListReleaseSummariesResponse,
} from '@dify/contracts/enterprise/types.gen'
import type { Getter } from 'jotai/vanilla'
import type { UnsupportedDslNode } from '../../shared/domain/error'
import { keepPreviousData, queryOptions, skipToken } from '@tanstack/react-query'
import { atom } from 'jotai'
import { atomWithForm, createFormAtoms } from 'jotai-tanstack-form'
import {
atomWithInfiniteQuery,
atomWithMutation,
atomWithQuery,
queryClientAtom,
} from 'jotai-tanstack-query'
import { selectAtom } from 'jotai/utils'
import * as z from 'zod'
import { consoleQuery } from '@/service/client'
import { AppModeEnum } from '@/types/app'
import { encodeDslContent, isWorkflowDsl } from '../../shared/domain/dsl'
import { isDeploymentDslImportEnabled } from '../../shared/domain/feature-flags'
export type ReleaseSourceMode = 'sourceApp' | 'dsl'
export type SourceAppPickerValue = Pick<
AppPartial,
'id' | 'name' | 'icon_type' | 'icon' | 'icon_background' | 'icon_url' | 'mode'
>
export type CreateReleaseFormValues = {
releaseSourceMode: ReleaseSourceMode
sourceApp?: SourceAppPickerValue
dslFile?: File
releaseName: string
releaseDescription: string
}
const DEFAULT_CREATE_RELEASE_FORM_VALUES: CreateReleaseFormValues = {
releaseSourceMode: 'sourceApp',
sourceApp: undefined,
dslFile: undefined,
releaseName: '',
releaseDescription: '',
}
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 ? 'sourceApp' : mode
}
const createReleaseFormSchema = z.object({
releaseSourceMode: z.union([z.literal('sourceApp'), z.literal('dsl')]),
sourceApp: z.custom<CreateReleaseFormValues['sourceApp']>().optional(),
dslFile: z.custom<CreateReleaseFormValues['dslFile']>().optional(),
releaseName: z.string().trim().min(1, RELEASE_NAME_REQUIRED_ERROR),
releaseDescription: z.string(),
})
type CreateReleaseSubmit = (
value: CreateReleaseFormValues,
) => Promise<CreateReleaseResponse | undefined> | CreateReleaseResponse | undefined
type CreateReleaseSubmitMeta = {
createRelease: CreateReleaseSubmit
}
const noopCreateRelease: CreateReleaseSubmit = () => undefined
// Form state
export const createReleaseFormAtom = atomWithForm({
defaultValues: DEFAULT_CREATE_RELEASE_FORM_VALUES,
onSubmitMeta: {
createRelease: noopCreateRelease,
},
validators: {
onSubmit: createReleaseFormSchema,
},
onSubmit: ({ value, meta }) => meta.createRelease(value),
})
const createReleaseFormAtoms = createFormAtoms(createReleaseFormAtom)
export const createReleaseFormIsSubmittingAtom = createReleaseFormAtoms.isSubmittingAtom
export const createReleaseSourceModeFieldAtom =
createReleaseFormAtoms.fieldAtom('releaseSourceMode')
export const createReleaseSourceAppFieldAtom = createReleaseFormAtoms.fieldAtom('sourceApp')
export const createReleaseDslFileFieldAtom = createReleaseFormAtoms.fieldAtom('dslFile')
export const createReleaseNameFieldAtom = createReleaseFormAtoms.fieldAtom('releaseName')
export const createReleaseDescriptionFieldAtom =
createReleaseFormAtoms.fieldAtom('releaseDescription')
// Dialog and source primitives
export const createReleaseAppInstanceIdAtom = atom<string | undefined>(undefined)
export const createReleaseDialogOpenAtom = atom(false)
const createReleaseDslFileReadVersionAtom = atom(0)
function requiredAppInstanceId(get: Getter) {
const appInstanceId = get(createReleaseAppInstanceIdAtom)
if (!appInstanceId) throw new Error('Missing create release app instance id.')
return appInstanceId
}
// Query and remote data
const latestSourceReleaseQueryAtom = atomWithQuery((get) => {
const appInstanceId = get(createReleaseAppInstanceIdAtom)
return consoleQuery.enterprise.releaseService.listReleases.queryOptions({
input: appInstanceId
? {
params: { appInstanceId },
query: {
pageNumber: 1,
resultsPerPage: DEFAULT_SOURCE_RELEASE_PAGE_SIZE,
},
}
: skipToken,
enabled: Boolean(appInstanceId && get(createReleaseDialogOpenAtom)),
})
})
function latestReleaseSourceAppId(get: Getter) {
const latestReleaseQuery = get(latestSourceReleaseQueryAtom)
return latestReleaseQuery.data?.releases[0]?.sourceAppId
}
const defaultSourceAppQueryAtom = atomWithQuery((get) => {
const latestSourceAppId = latestReleaseSourceAppId(get)
return consoleQuery.apps.byAppId.get.queryOptions({
input: latestSourceAppId
? {
params: { app_id: latestSourceAppId },
}
: skipToken,
enabled: Boolean(get(createReleaseDialogOpenAtom) && latestSourceAppId),
})
})
function defaultSourceApp(get: Getter) {
const sourceApp = get(defaultSourceAppQueryAtom).data
return sourceApp?.mode === AppModeEnum.WORKFLOW ? sourceApp : undefined
}
function submittedReleaseName(get: Getter) {
return get(createReleaseNameFieldAtom).value.trim()
}
function cachedReleaseDisplayNames(get: Getter) {
const appInstanceId = get(createReleaseAppInstanceIdAtom)
if (!appInstanceId) return []
const queryClient = get(queryClientAtom)
const releaseSummaryQueries = queryClient.getQueriesData<ListReleaseSummariesResponse>({
queryKey: consoleQuery.enterprise.releaseService.listReleaseSummaries.key({
type: 'query',
input: { params: { appInstanceId } },
}),
})
const releaseQueries = queryClient.getQueriesData<ListReleasesResponse>({
queryKey: consoleQuery.enterprise.releaseService.listReleases.key({
type: 'query',
input: { params: { appInstanceId } },
}),
})
return [
...releaseSummaryQueries.flatMap(([, data]) => {
return data?.releaseSummaries.map((summary) => summary.release.displayName) ?? []
}),
...releaseQueries.flatMap(([, data]) => {
return data?.releases.map((release) => release.displayName) ?? []
}),
]
}
export const createReleaseHasNameConflictAtom = atom((get) => {
const releaseName = submittedReleaseName(get)
if (!releaseName) return false
return cachedReleaseDisplayNames(get).some((displayName) => displayName.trim() === releaseName)
})
const createReleaseDslFileContentQueryAtom = atomWithQuery((get) => {
const file = get(createReleaseDslFileFieldAtom).value
const fileReadVersion = get(createReleaseDslFileReadVersionAtom)
return queryOptions({
queryKey: [
'createReleaseDslFileContent',
fileReadVersion,
file,
file?.name ?? '',
file?.size ?? 0,
file?.lastModified ?? 0,
],
queryFn: async () => (file ? await file.text() : ''),
enabled: Boolean(file),
retry: false,
})
})
// Source derived state
function effectiveCreateReleaseSourceMode(get: Getter) {
return deploymentReleaseSourceMode(get(createReleaseSourceModeFieldAtom).value)
}
export const createReleaseSourceModeAtom = atom((get) => {
return effectiveCreateReleaseSourceMode(get)
})
export const createReleaseSourceAppSearchTextAtom = atom('')
export const createReleaseSourceAppsQueryAtom = atomWithInfiniteQuery((get) => {
const searchText = get(createReleaseSourceAppSearchTextAtom)
return consoleQuery.apps.get.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,
),
})
})
const createReleaseSourceAppsDataAtom = selectAtom(
createReleaseSourceAppsQueryAtom,
(query) => query.data,
)
export const createReleaseSourceAppsErrorAtom = selectAtom(
createReleaseSourceAppsQueryAtom,
(query) => query.error,
)
export const createReleaseSourceAppsFetchNextPageAtom = selectAtom(
createReleaseSourceAppsQueryAtom,
(query) => query.fetchNextPage,
)
export const createReleaseSourceAppsHasNextPageAtom = selectAtom(
createReleaseSourceAppsQueryAtom,
(query) => query.hasNextPage,
)
export const createReleaseSourceAppsIsFetchingAtom = selectAtom(
createReleaseSourceAppsQueryAtom,
(query) => query.isFetching,
)
export const createReleaseSourceAppsIsFetchingNextPageAtom = selectAtom(
createReleaseSourceAppsQueryAtom,
(query) => query.isFetchingNextPage,
)
export const createReleaseSourceAppsIsLoadingAtom = selectAtom(
createReleaseSourceAppsQueryAtom,
(query) => query.isLoading,
)
export const createReleaseSourceAppsAtom = atom((get) => {
return get(createReleaseSourceAppsDataAtom)?.pages.flatMap((page) => page.data) ?? []
})
export const createReleaseDslContentAtom = atom((get) => {
return get(createReleaseDslFileContentQueryAtom).data ?? ''
})
export const createReleaseDslReadErrorAtom = atom((get) => {
return Boolean(
get(createReleaseDslFileFieldAtom).value && get(createReleaseDslFileContentQueryAtom).isError,
)
})
export const isReadingCreateReleaseDslAtom = atom((get) => {
const file = get(createReleaseDslFileFieldAtom).value
const dslFileContentQuery = get(createReleaseDslFileContentQueryAtom)
return Boolean(file && (dslFileContentQuery.isLoading || dslFileContentQuery.isFetching))
})
export const createReleaseHasDslContentAtom = atom((get) => {
return Boolean(get(createReleaseDslContentAtom).trim())
})
export const createReleaseIsWorkflowDslContentAtom = atom((get) => {
const dslContent = get(createReleaseDslContentAtom)
return get(createReleaseHasDslContentAtom) ? isWorkflowDsl(dslContent) : false
})
export const createReleaseEncodedDslContentAtom = atom((get) => {
const dslContent = get(createReleaseDslContentAtom)
return get(createReleaseHasDslContentAtom) && get(createReleaseIsWorkflowDslContentAtom)
? encodeDslContent(dslContent)
: ''
})
export const createReleaseSelectedSourceAppAtom = atom((get) => {
if (effectiveCreateReleaseSourceMode(get) !== 'sourceApp') return undefined
const fieldSourceApp = get(createReleaseSourceAppFieldAtom).value
const fallbackSourceApp = defaultSourceApp(get)
if (!isDeploymentDslImportEnabled) return fallbackSourceApp
return fieldSourceApp ?? fallbackSourceApp
})
function selectedSourceAppId(get: Getter) {
return effectiveCreateReleaseSourceMode(get) === 'sourceApp'
? get(createReleaseSelectedSourceAppAtom)?.id
: undefined
}
function hasUnsupportedDslMode(get: Getter) {
if (effectiveCreateReleaseSourceMode(get) !== 'dsl') return false
return (
get(createReleaseHasDslContentAtom) &&
!get(isReadingCreateReleaseDslAtom) &&
!get(createReleaseDslReadErrorAtom) &&
!get(createReleaseIsWorkflowDslContentAtom)
)
}
export const createReleaseHasUnsupportedDslModeAtom = atom((get) => {
return hasUnsupportedDslMode(get)
})
function canCheckReleaseSourceContent(get: Getter) {
if (effectiveCreateReleaseSourceMode(get) === 'sourceApp')
return Boolean(selectedSourceAppId(get))
if (!isDeploymentDslImportEnabled) return false
return Boolean(
get(createReleaseHasDslContentAtom) &&
!get(isReadingCreateReleaseDslAtom) &&
!get(createReleaseDslReadErrorAtom) &&
!hasUnsupportedDslMode(get),
)
}
function canCheckReleaseContent(get: Getter) {
return Boolean(
get(createReleaseAppInstanceIdAtom) &&
get(createReleaseDialogOpenAtom) &&
canCheckReleaseSourceContent(get),
)
}
// Release content check
const precheckReleaseQueryAtom = atomWithQuery((get) => {
const appInstanceId = get(createReleaseAppInstanceIdAtom)
const releaseSourceMode = effectiveCreateReleaseSourceMode(get)
const sourceAppId = selectedSourceAppId(get)
const canCheck = canCheckReleaseContent(get)
return consoleQuery.enterprise.releaseService.precheckRelease.queryOptions({
input:
canCheck && appInstanceId
? releaseSourceMode === 'dsl'
? {
body: {
appInstanceId,
dsl: get(createReleaseEncodedDslContentAtom),
},
}
: sourceAppId
? {
body: {
appInstanceId,
sourceAppId,
},
}
: skipToken
: skipToken,
enabled: canCheck,
retry: false,
})
})
export const isCheckingCreateReleaseContentAtom = atom((get) => {
const canCheck = canCheckReleaseContent(get)
const precheckReleaseQuery = get(precheckReleaseQueryAtom)
return canCheck && (precheckReleaseQuery.isLoading || precheckReleaseQuery.isFetching)
})
export const createReleaseMatchedReleaseAtom = atom((get) => {
return canCheckReleaseContent(get)
? get(precheckReleaseQueryAtom).data?.matchedRelease
: undefined
})
export const createReleaseContentCheckFailedAtom = atom((get) => {
return canCheckReleaseContent(get) && get(precheckReleaseQueryAtom).isError
})
export const createReleaseUnsupportedDslNodesAtom = atom((get): UnsupportedDslNode[] => {
return canCheckReleaseContent(get)
? (get(precheckReleaseQueryAtom).data?.unsupportedNodes ?? [])
: []
})
export const createReleaseContentReadyAtom = atom((get) => {
const canCheck = canCheckReleaseContent(get)
const precheckReleaseQuery = get(precheckReleaseQueryAtom)
return (
canCheck &&
precheckReleaseQuery.isSuccess &&
!get(isCheckingCreateReleaseContentAtom) &&
!get(createReleaseContentCheckFailedAtom) &&
Boolean(precheckReleaseQuery.data?.canCreate) &&
get(createReleaseUnsupportedDslNodesAtom).length === 0
)
})
// Actions
const resetCreateReleaseDslFileAtom = atom(null, (get, set) => {
set(createReleaseDslFileFieldAtom, undefined)
set(createReleaseDslFileReadVersionAtom, get(createReleaseDslFileReadVersionAtom) + 1)
})
const resetCreateReleaseSourceAppSearchAtom = atom(null, (_get, set) => {
set(createReleaseSourceAppSearchTextAtom, '')
})
export const openCreateReleaseDialogAtom = atom(null, (_get, set) => {
set(resetCreateReleaseDslFileAtom)
set(resetCreateReleaseSourceAppSearchAtom)
set(createReleaseDialogOpenAtom, true)
})
export const closeCreateReleaseDialogAtom = atom(null, (_get, set) => {
set(createReleaseDialogOpenAtom, false)
set(resetCreateReleaseDslFileAtom)
set(resetCreateReleaseSourceAppSearchAtom)
})
export const requestCloseCreateReleaseDialogAtom = atom(null, (get, set) => {
if (get(createReleaseFormIsSubmittingAtom)) return
set(closeCreateReleaseDialogAtom)
})
export const selectCreateReleaseSourceModeAtom = atom(
null,
(_get, set, releaseSourceMode: ReleaseSourceMode) => {
const effectiveReleaseSourceMode = deploymentReleaseSourceMode(releaseSourceMode)
set(createReleaseSourceModeFieldAtom, effectiveReleaseSourceMode)
if (effectiveReleaseSourceMode === 'sourceApp') {
set(resetCreateReleaseDslFileAtom)
return
}
set(createReleaseSourceAppFieldAtom, undefined)
},
)
export const updateCreateReleaseSourceAppAtom = atom(
null,
(_get, set, sourceApp: CreateReleaseFormValues['sourceApp']) => {
set(createReleaseSourceAppFieldAtom, sourceApp)
},
)
export const updateCreateReleaseDslFileAtom = atom(
null,
(get, set, dslFile: CreateReleaseFormValues['dslFile']) => {
set(createReleaseDslFileFieldAtom, dslFile)
set(createReleaseDslFileReadVersionAtom, get(createReleaseDslFileReadVersionAtom) + 1)
},
)
// Submission
const createReleaseMutationAtom = atomWithMutation(() =>
consoleQuery.enterprise.releaseService.createRelease.mutationOptions(),
)
export const isCreatingReleaseAtom = atom((get) => {
return get(createReleaseMutationAtom).isPending
})
export class CreateReleaseSubmissionBlockedError extends Error {
reason: 'unsupportedDslMode'
constructor(reason: 'unsupportedDslMode') {
super(reason)
this.reason = reason
this.name = 'CreateReleaseSubmissionBlockedError'
}
}
const createReleaseSubmissionAtom = atom(null, async (get, set, value: CreateReleaseFormValues) => {
const releaseSourceMode = effectiveCreateReleaseSourceMode(get)
const sourceAppId = selectedSourceAppId(get)
const submittedReleaseName = value.releaseName.trim()
if (get(isCheckingCreateReleaseContentAtom) || !submittedReleaseName) return undefined
if (get(createReleaseHasNameConflictAtom)) return undefined
if (!canCheckReleaseSourceContent(get) || !get(createReleaseContentReadyAtom)) return undefined
const appInstanceId = requiredAppInstanceId(get)
const commonCreateReleaseRequest = {
appInstanceId,
displayName: submittedReleaseName,
description: value.releaseDescription.trim() || undefined,
createAppInstance: false,
}
if (releaseSourceMode === 'dsl') {
if (!get(createReleaseIsWorkflowDslContentAtom))
throw new CreateReleaseSubmissionBlockedError('unsupportedDslMode')
return await get(createReleaseMutationAtom).mutateAsync({
body: {
...commonCreateReleaseRequest,
dsl: get(createReleaseEncodedDslContentAtom),
},
})
}
if (!sourceAppId) return undefined
return await get(createReleaseMutationAtom).mutateAsync({
body: {
...commonCreateReleaseRequest,
sourceAppId,
},
})
})
export const submitCreateReleaseFormAtom = atom(null, (get, set) => {
const form = get(createReleaseFormAtom)
let submitResponse: CreateReleaseResponse | undefined
return form.api
.handleSubmit({
createRelease: async (value) => {
const response = await set(createReleaseSubmissionAtom, value)
submitResponse = response
return response
},
} satisfies CreateReleaseSubmitMeta)
.then(() => submitResponse)
})
// Scoped atoms
export const createReleaseLocalAtoms = [
createReleaseDialogOpenAtom,
createReleaseDslFileReadVersionAtom,
createReleaseSourceAppSearchTextAtom,
] as const
@@ -1,178 +0,0 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Provider } from 'jotai'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SourceAppPicker } from '../source-app-picker'
const mocks = vi.hoisted(() => {
const sourceAppsQuery = {
data: {
pages: [
{
data: [
{
id: 'app-1',
name: 'Workflow App',
},
],
},
],
},
error: null,
fetchNextPage: vi.fn(),
hasNextPage: true,
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
}
return { sourceAppsQuery }
})
type ObserverRecord = {
callback: IntersectionObserverCallback
options?: IntersectionObserverInit
}
const observers: ObserverRecord[] = []
function getInfiniteScrollObserver() {
return observers.find(({ options }) => options?.rootMargin === '0px 0px 160px 0px')
}
vi.mock('@/features/deployments/create-release/state', async () => {
const { atom } = await import('jotai')
return {
createReleaseSourceAppsAtom: atom(() =>
mocks.sourceAppsQuery.data.pages.flatMap((page) => page.data),
),
createReleaseSourceAppsErrorAtom: atom(() => mocks.sourceAppsQuery.error),
createReleaseSourceAppsFetchNextPageAtom: atom(() => mocks.sourceAppsQuery.fetchNextPage),
createReleaseSourceAppsHasNextPageAtom: atom(() => mocks.sourceAppsQuery.hasNextPage),
createReleaseSourceAppsIsFetchingAtom: atom(() => mocks.sourceAppsQuery.isFetching),
createReleaseSourceAppsIsFetchingNextPageAtom: atom(
() => mocks.sourceAppsQuery.isFetchingNextPage,
),
createReleaseSourceAppsIsLoadingAtom: atom(() => mocks.sourceAppsQuery.isLoading),
createReleaseSourceAppSearchTextAtom: atom(''),
createReleaseSourceAppsQueryAtom: atom(mocks.sourceAppsQuery),
}
})
function renderSourceAppPicker(disabled: boolean) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return render(
<Provider>
<QueryClientProvider client={queryClient}>
<SourceAppPicker
value={{ id: 'app-1', name: 'Workflow 1', mode: 'workflow', icon_url: null }}
onChange={() => undefined}
disabled={disabled}
/>
</QueryClientProvider>
</Provider>,
)
}
describe('SourceAppPicker', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.sourceAppsQuery.fetchNextPage.mockReset()
observers.length = 0
vi.stubGlobal(
'IntersectionObserver',
class MockIntersectionObserver {
readonly root = null
readonly rootMargin = ''
readonly scrollMargin = ''
readonly thresholds: ReadonlyArray<number> = []
readonly disconnect = vi.fn()
readonly observe = vi.fn()
readonly takeRecords = () => []
readonly unobserve = vi.fn()
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
observers.push({ callback, options })
}
},
)
Object.assign(mocks.sourceAppsQuery, {
data: {
pages: [
{
data: [
{
id: 'app-1',
name: 'Workflow App',
},
],
},
],
},
error: null,
hasNextPage: true,
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
})
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('should disable the switch control when disabled', () => {
renderSourceAppPicker(true)
expect(screen.getByText('Workflow 1')).toBeInTheDocument()
expect(
screen.getByRole('combobox', { name: 'deployments.versions.sourceAppOption' }),
).toBeDisabled()
})
it('should use infinite scroll to load more apps when the picker is open', async () => {
const user = userEvent.setup()
renderSourceAppPicker(false)
expect(getInfiniteScrollObserver()).toBeUndefined()
await user.click(screen.getByRole('combobox', { name: 'deployments.versions.sourceAppOption' }))
await waitFor(() => {
expect(getInfiniteScrollObserver()).toBeDefined()
})
act(() => {
getInfiniteScrollObserver()?.callback(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
expect(mocks.sourceAppsQuery.fetchNextPage).toHaveBeenCalledWith({ cancelRefetch: false })
expect(
screen.queryByRole('button', { name: /createModal\.loadMoreApps/ }),
).not.toBeInTheDocument()
})
it('should restore the selected app by business identity', async () => {
const user = userEvent.setup()
renderSourceAppPicker(false)
await user.click(screen.getByRole('combobox', { name: 'deployments.versions.sourceAppOption' }))
expect(screen.getByRole('option', { name: /Workflow App/ })).toHaveAttribute(
'aria-selected',
'true',
)
})
})
@@ -1,61 +0,0 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import {
createReleaseContentReadyAtom,
createReleaseFormIsSubmittingAtom,
createReleaseHasNameConflictAtom,
createReleaseNameFieldAtom,
isCheckingCreateReleaseContentAtom,
requestCloseCreateReleaseDialogAtom,
} from '../state'
export function CreateReleaseActions() {
const { t } = useTranslation('deployments')
const requestCloseDialog = useSetAtom(requestCloseCreateReleaseDialogAtom)
const isSubmitting = useAtomValue(createReleaseFormIsSubmittingAtom)
const releaseContentReady = useAtomValue(createReleaseContentReadyAtom)
const isCheckingReleaseContent = useAtomValue(isCheckingCreateReleaseContentAtom)
const hasReleaseNameConflict = useAtomValue(createReleaseHasNameConflictAtom)
const releaseNameField = useAtomValue(createReleaseNameFieldAtom)
const hasReleaseName = Boolean(releaseNameField.value.trim())
return (
<div className="flex items-center justify-end gap-4 border-t border-divider-subtle bg-background-default-subtle px-6 py-4">
<div className="flex shrink-0 justify-end gap-2">
<Button
type="button"
variant="secondary"
disabled={isSubmitting}
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
requestCloseDialog()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
requestCloseDialog()
}}
>
{t(($) => $['versions.cancelCreate'])}
</Button>
<Button
type="submit"
variant="primary"
className="min-w-22"
disabled={!hasReleaseName || !releaseContentReady || hasReleaseNameConflict}
loading={isSubmitting}
>
{isSubmitting
? t(($) => $['versions.creating'])
: isCheckingReleaseContent
? t(($) => $['versions.checkingReleaseContent'])
: t(($) => $['versions.create'])}
</Button>
</div>
</div>
)
}
@@ -1,41 +0,0 @@
'use client'
import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
import { UnsupportedDslNodesAlert } from '../../shared/components/unsupported-dsl-nodes-alert'
import {
createReleaseContentCheckFailedAtom,
createReleaseMatchedReleaseAtom,
createReleaseUnsupportedDslNodesAtom,
} from '../state'
export function ReleaseContentFeedback() {
const { t } = useTranslation('deployments')
const unsupportedDslNodes = useAtomValue(createReleaseUnsupportedDslNodesAtom)
const matchedRelease = useAtomValue(createReleaseMatchedReleaseAtom)
const releaseContentCheckFailed = useAtomValue(createReleaseContentCheckFailedAtom)
return (
<>
<UnsupportedDslNodesAlert nodes={unsupportedDslNodes} />
{matchedRelease && (
<div
role="alert"
className="rounded-lg border border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 px-3 py-2 system-sm-regular text-util-colors-warning-warning-700"
>
{t(($) => $['versions.releaseAlreadyExists'], { name: matchedRelease.displayName })}
</div>
)}
{releaseContentCheckFailed && (
<div
role="alert"
className="rounded-lg border border-util-colors-red-red-200 bg-util-colors-red-red-50 px-3 py-2 system-sm-regular text-util-colors-red-red-700"
>
{t(($) => $['versions.releaseContentCheckFailed'])}
</div>
)}
</>
)
}
@@ -1,115 +0,0 @@
'use client'
import {
DialogCloseButton,
DialogContent,
DialogDescription,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { useAtomValue, useSetAtom } from 'jotai'
import { ScopeProvider } from 'jotai-scope'
import { useTranslation } from 'react-i18next'
import { deploymentErrorMessage } from '../../shared/domain/error'
import {
closeCreateReleaseDialogAtom,
createReleaseFormAtom,
createReleaseFormIsSubmittingAtom,
CreateReleaseSubmissionBlockedError,
requestCloseCreateReleaseDialogAtom,
submitCreateReleaseFormAtom,
} from '../state'
import { CreateReleaseActions } from './actions'
import { ReleaseContentFeedback } from './content-feedback'
import { ReleaseMetadataFields } from './metadata-fields'
import { ReleaseSourceSection } from './source-section'
function CreateReleaseCloseButton() {
const isSubmitting = useAtomValue(createReleaseFormIsSubmittingAtom)
const requestCloseDialog = useSetAtom(requestCloseCreateReleaseDialogAtom)
return (
<DialogCloseButton
type="button"
disabled={isSubmitting}
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
requestCloseDialog()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
requestCloseDialog()
}}
/>
)
}
export function CreateReleaseDialogContent() {
return (
<DialogContent className="top-[18dvh] w-140 max-w-[calc(100vw-32px)] translate-y-0 overflow-hidden p-0">
<ScopeProvider atoms={[createReleaseFormAtom]} name="CreateReleaseForm">
<CreateReleaseDialogSurface />
</ScopeProvider>
</DialogContent>
)
}
function CreateReleaseDialogSurface() {
const closeDialog = useSetAtom(closeCreateReleaseDialogAtom)
const submitCreateReleaseForm = useSetAtom(submitCreateReleaseFormAtom)
const { t } = useTranslation('deployments')
async function handleSubmit() {
try {
const response = await submitCreateReleaseForm()
if (!response) return
toast.success(t(($) => $['versions.createSuccess'], { name: response.release.displayName }))
closeDialog()
} catch (error) {
if (error instanceof CreateReleaseSubmissionBlockedError) {
toast.error(t(($) => $['versions.dslUnsupportedMode']))
return
}
const message = await deploymentErrorMessage(error)
toast.error(message || t(($) => $['versions.createFailed']))
}
}
return (
<>
<CreateReleaseCloseButton />
<form
noValidate
autoComplete="off"
onSubmit={(event) => {
event.preventDefault()
event.stopPropagation()
void handleSubmit()
}}
>
<div className="border-b border-divider-subtle px-6 py-5 pr-14">
<div className="min-w-0">
<DialogTitle className="title-xl-semi-bold text-text-primary">
{t(($) => $['versions.createRelease'])}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t(($) => $['versions.createReleaseDescription'])}
</DialogDescription>
</div>
</div>
<div className="flex flex-col gap-5 px-6 py-5">
<ReleaseSourceSection />
<ReleaseContentFeedback />
<ReleaseMetadataFields />
</div>
<CreateReleaseActions />
</form>
</>
)
}
@@ -1,129 +0,0 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
import { Input } from '@langgenius/dify-ui/input'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { useAtom, useAtomValue } from 'jotai'
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import {
createReleaseDescriptionFieldAtom,
createReleaseHasNameConflictAtom,
createReleaseNameFieldAtom,
RELEASE_NAME_REQUIRED_ERROR,
} from '../state'
const DESCRIPTION_MAX_LENGTH = 512
const DESCRIPTION_WARN_THRESHOLD = 460
function isValidationIssue(error: unknown): error is { message: string } {
return Boolean(
error && typeof error === 'object' && 'message' in error && typeof error.message === 'string',
)
}
function hasReleaseNameRequiredError(errors: unknown[]) {
return errors.some((error) => {
if (error === RELEASE_NAME_REQUIRED_ERROR) return true
if (Array.isArray(error))
return error.some(
(issue) => isValidationIssue(issue) && issue.message === RELEASE_NAME_REQUIRED_ERROR,
)
return isValidationIssue(error) && error.message === RELEASE_NAME_REQUIRED_ERROR
})
}
export function ReleaseMetadataFields() {
const { t } = useTranslation('deployments')
const [releaseNameField, setReleaseNameField] = useAtom(createReleaseNameFieldAtom)
const [releaseDescriptionField, setReleaseDescriptionField] = useAtom(
createReleaseDescriptionFieldAtom,
)
const hasReleaseNameConflict = useAtomValue(createReleaseHasNameConflictAtom)
const releaseNameInputRef = useRef<HTMLInputElement>(null)
const releaseNameErrors = releaseNameField.meta?.errors ?? []
const hasReleaseNameRequired = hasReleaseNameRequiredError(releaseNameErrors)
const releaseNameError = hasReleaseNameRequired
? t(($) => $['versions.releaseNameRequired'])
: hasReleaseNameConflict
? t(($) => $['versions.releaseNameConflict'])
: ''
useEffect(() => {
releaseNameInputRef.current?.focus()
}, [])
return (
<>
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="release-name">
{t(($) => $['versions.releaseNameLabel'])}
</label>
<Input
ref={releaseNameInputRef}
id="release-name"
name="releaseName"
placeholder={t(($) => $['versions.releaseNamePlaceholder'])}
maxLength={128}
autoComplete="off"
value={releaseNameField.value}
aria-invalid={Boolean(releaseNameError) || undefined}
aria-describedby={releaseNameError ? 'release-name-error' : undefined}
onChange={(event) => {
setReleaseNameField(event.target.value)
}}
className="h-9"
/>
{releaseNameError && (
<div
id="release-name-error"
role="alert"
className="system-xs-regular text-text-destructive"
>
{releaseNameError}
</div>
)}
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<label
className="system-xs-medium-uppercase text-text-tertiary"
htmlFor="release-description"
>
{t(($) => $['versions.releaseDescriptionLabel'])}
</label>
<div className="flex items-center gap-2">
<span className="system-xs-regular text-text-quaternary">
{t(($) => $['versions.optional'])}
</span>
<span
className={cn(
'system-xs-regular tabular-nums',
releaseDescriptionField.value.length >= DESCRIPTION_WARN_THRESHOLD
? 'text-util-colors-warning-warning-700'
: 'text-text-quaternary',
)}
>
{releaseDescriptionField.value.length}/{DESCRIPTION_MAX_LENGTH}
</span>
</div>
</div>
<Textarea
id="release-description"
name="releaseDescription"
placeholder={t(($) => $['versions.releaseDescriptionPlaceholder'])}
maxLength={DESCRIPTION_MAX_LENGTH}
autoComplete="off"
value={releaseDescriptionField.value}
onValueChange={(value) => {
setReleaseDescriptionField(value)
}}
className="min-h-24 resize-none"
/>
</div>
</>
)
}
@@ -1,247 +0,0 @@
'use client'
import type { AppPartial } from '@dify/contracts/api/console/apps/types.gen'
import type { SourceAppPickerValue } from '../state'
import { zIconType } from '@dify/contracts/api/console/apps/zod.gen'
import { cn } from '@langgenius/dify-ui/cn'
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxInputGroup,
ComboboxItem,
ComboboxItemText,
ComboboxList,
ComboboxTrigger,
} from '@langgenius/dify-ui/combobox'
import { useAtomValue, useSetAtom } from 'jotai'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import { InfiniteScrollSentinel } from '@/app/components/base/infinite-scroll-sentinel'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { TitleTooltip } from '../../shared/components/title-tooltip'
import {
createReleaseSourceAppsAtom,
createReleaseSourceAppSearchTextAtom,
createReleaseSourceAppsErrorAtom,
createReleaseSourceAppsFetchNextPageAtom,
createReleaseSourceAppsHasNextPageAtom,
createReleaseSourceAppsIsFetchingAtom,
createReleaseSourceAppsIsFetchingNextPageAtom,
createReleaseSourceAppsIsLoadingAtom,
} from '../state'
const SOURCE_APP_PICKER_SKELETON_KEYS = [
'first-source-app',
'second-source-app',
'third-source-app',
]
function sourceAppSearchText(app: AppPartial) {
return `${app.name} ${app.id}`.toLowerCase()
}
function isSameApp(app: AppPartial, selectedApp: AppPartial) {
return app.id === selectedApp.id
}
function SourceAppTrigger({ app }: { app?: SourceAppPickerValue }) {
const { t } = useTranslation('deployments')
const appIconType = zIconType.safeParse(app?.icon_type).data ?? null
return (
<span
className={cn(
'flex h-10 items-center gap-2 rounded-lg border border-transparent bg-components-input-bg-normal px-3 text-left',
'cursor-pointer hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
'group-data-disabled/combobox-trigger:cursor-not-allowed group-data-disabled/combobox-trigger:text-components-input-text-disabled group-data-disabled/combobox-trigger:hover:border-transparent group-data-disabled/combobox-trigger:hover:bg-components-input-bg-normal',
'group-data-popup-open/combobox-trigger:border-components-input-border-active group-data-popup-open/combobox-trigger:bg-components-input-bg-active group-data-popup-open/combobox-trigger:shadow-xs',
app && 'pl-2',
)}
>
{app && (
<AppIcon
className="shrink-0"
size="xs"
iconType={appIconType}
icon={app.icon ?? undefined}
background={app.icon_background}
imageUrl={app.icon_url}
/>
)}
<TitleTooltip content={app?.name}>
<span
className={cn(
'min-w-0 grow truncate',
app
? 'system-sm-medium text-components-input-text-filled'
: 'system-sm-regular text-components-input-text-placeholder',
)}
>
{app?.name ?? t(($) => $['createModal.appPickerPlaceholder'])}
</span>
</TitleTooltip>
<span
className={cn(
'i-ri-arrow-down-s-line size-4 shrink-0 text-text-quaternary group-hover/combobox-trigger:text-text-secondary',
'group-data-disabled/combobox-trigger:text-text-quaternary group-data-disabled/combobox-trigger:opacity-50',
'group-data-popup-open/combobox-trigger:text-text-secondary',
)}
aria-hidden="true"
/>
</span>
)
}
function SourceAppOption({ app }: { app: AppPartial }) {
const appIconType = zIconType.safeParse(app.icon_type).data ?? null
return (
<ComboboxItem value={app} className="mx-0 grid-cols-[minmax(0,1fr)] gap-3 py-1 pr-3 pl-2">
<ComboboxItemText className="flex min-w-0 items-center gap-3 px-0">
<AppIcon
className="shrink-0"
size="xs"
iconType={appIconType}
icon={app.icon ?? undefined}
background={app.icon_background}
imageUrl={app.icon_url}
/>
<TitleTooltip content={`${app.name} (${app.id})`}>
<span className="flex min-w-0 grow items-center gap-1 truncate system-sm-medium text-components-input-text-filled">
<span className="truncate">{app.name}</span>
<span className="shrink-0 text-text-tertiary">({app.id.slice(0, 8)})</span>
</span>
</TitleTooltip>
</ComboboxItemText>
</ComboboxItem>
)
}
function SourceAppPickerSkeleton() {
return (
<div className="flex flex-col gap-2 px-3 py-3">
{SOURCE_APP_PICKER_SKELETON_KEYS.map((key) => (
<SkeletonRow key={key} className="h-7 gap-3">
<SkeletonRectangle className="my-0 size-5 animate-pulse rounded-md" />
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
</SkeletonRow>
))}
</div>
)
}
export function SourceAppPicker({
value,
onChange,
disabled = false,
}: {
value?: SourceAppPickerValue
onChange: (app: AppPartial) => void
disabled?: boolean
}) {
const { t } = useTranslation('deployments')
const [isShow, setIsShow] = useState(false)
const searchText = useAtomValue(createReleaseSourceAppSearchTextAtom)
const setSearchText = useSetAtom(createReleaseSourceAppSearchTextAtom)
const apps = useAtomValue(createReleaseSourceAppsAtom)
const sourceAppsError = useAtomValue(createReleaseSourceAppsErrorAtom)
const sourceAppsFetchNextPage = useAtomValue(createReleaseSourceAppsFetchNextPageAtom)
const sourceAppsHasNextPage = useAtomValue(createReleaseSourceAppsHasNextPageAtom)
const sourceAppsIsFetching = useAtomValue(createReleaseSourceAppsIsFetchingAtom)
const sourceAppsIsFetchingNextPage = useAtomValue(createReleaseSourceAppsIsFetchingNextPageAtom)
const sourceAppsIsLoading = useAtomValue(createReleaseSourceAppsIsLoadingAtom)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const selectedApp = apps.find((app) => app.id === value?.id) ?? null
const canLoadMore = isShow && !disabled && !sourceAppsIsFetching && !sourceAppsError
return (
<Combobox<AppPartial>
items={apps}
value={selectedApp}
open={!disabled && isShow}
inputValue={searchText}
isItemEqualToValue={isSameApp}
onOpenChange={(open) => {
setIsShow(disabled ? false : open)
}}
onInputValueChange={(value) => {
if (!disabled) setSearchText(value)
}}
onValueChange={(app) => {
if (disabled) return
if (!app) return
onChange(app)
setIsShow(false)
}}
itemToStringLabel={(app) => {
if (!app) return ''
return app.name
}}
itemToStringValue={(app) => {
if (!app) return ''
return app.id
}}
filter={(app, query) => sourceAppSearchText(app).includes(query.toLowerCase())}
disabled={disabled}
>
<ComboboxTrigger
aria-label={t(($) => $['versions.sourceAppOption'])}
icon={false}
className="block h-auto w-full border-0 bg-transparent p-0 text-left hover:bg-transparent focus-visible:bg-transparent data-open:bg-transparent"
>
<SourceAppTrigger app={value} />
</ComboboxTrigger>
<ComboboxContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
>
<div className="relative flex max-h-100 min-h-20 w-89 flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
<div className="p-2 pb-1">
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<span
className="i-ri-search-line size-4 shrink-0 text-text-tertiary"
aria-hidden="true"
/>
<ComboboxInput
aria-label={t(($) => $['createModal.appSearchPlaceholder'])}
placeholder={t(($) => $['createModal.appSearchPlaceholder'])}
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
/>
</ComboboxInputGroup>
</div>
<div ref={scrollContainerRef} className="min-h-0 flex-1 overflow-y-auto p-1">
{(sourceAppsIsLoading || sourceAppsIsFetchingNextPage) && apps.length === 0 && (
<SourceAppPickerSkeleton />
)}
<ComboboxList<AppPartial> className="max-h-none p-0">
{(app) => <SourceAppOption key={app.id} app={app} />}
</ComboboxList>
{!(sourceAppsIsLoading || sourceAppsIsFetchingNextPage) && (
<ComboboxEmpty>{t(($) => $['createModal.appSearchEmpty'])}</ComboboxEmpty>
)}
{sourceAppsIsFetchingNextPage && apps.length > 0 && (
<div className="px-3 py-2 text-center system-xs-regular text-text-tertiary">
{t(($) => $['createModal.loadingApps'])}
</div>
)}
{sourceAppsHasNextPage && (
<InfiniteScrollSentinel
canLoadMore={canLoadMore}
onLoadMore={() => {
void sourceAppsFetchNextPage({ cancelRefetch: false })
}}
preloadDistance={160}
scrollContainerRef={scrollContainerRef}
/>
)}
</div>
</div>
</ComboboxContent>
</Combobox>
)
}
@@ -1,125 +0,0 @@
'use client'
import type { ReleaseSourceMode } from '../state'
import { SegmentedControl, SegmentedControlItem } from '@langgenius/dify-ui/segmented-control'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { Uploader } from '@/app/components/app/create-from-dsl-modal/uploader'
import { isDeploymentDslImportEnabled } from '../../shared/domain/feature-flags'
import {
createReleaseDslFileFieldAtom,
createReleaseDslReadErrorAtom,
createReleaseHasUnsupportedDslModeAtom,
createReleaseSelectedSourceAppAtom,
createReleaseSourceModeAtom,
isReadingCreateReleaseDslAtom,
selectCreateReleaseSourceModeAtom,
updateCreateReleaseDslFileAtom,
updateCreateReleaseSourceAppAtom,
} from '../state'
import { SourceAppPicker } from './source-app-picker'
function selectedReleaseSourceMode(value: readonly ReleaseSourceMode[] | undefined) {
return value?.[0]
}
export function ReleaseSourceSection() {
const { t } = useTranslation('deployments')
const releaseSourceMode = useAtomValue(createReleaseSourceModeAtom)
const selectReleaseSourceMode = useSetAtom(selectCreateReleaseSourceModeAtom)
return (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center justify-between gap-3">
<label
id="release-source-mode-label"
className="system-xs-medium-uppercase text-text-tertiary"
>
{t(($) => $['versions.releaseSourceLabel'])}
</label>
{isDeploymentDslImportEnabled && (
<SegmentedControl<ReleaseSourceMode>
aria-labelledby="release-source-mode-label"
value={[releaseSourceMode]}
onValueChange={(value) => {
const nextMode = selectedReleaseSourceMode(value)
if (!nextMode || nextMode === releaseSourceMode) return
selectReleaseSourceMode(nextMode)
}}
className="shrink-0"
>
<SegmentedControlItem value="sourceApp" className="gap-1.5">
<span className="i-ri-apps-2-line size-4 shrink-0" aria-hidden="true" />
<span>{t(($) => $['versions.sourceAppOption'])}</span>
</SegmentedControlItem>
<SegmentedControlItem value="dsl" className="gap-1.5">
<span className="i-ri-upload-cloud-2-line size-4 shrink-0" aria-hidden="true" />
<span>{t(($) => $['versions.manualDslOption'])}</span>
</SegmentedControlItem>
</SegmentedControl>
)}
</div>
<div className="min-h-12">
{releaseSourceMode === 'sourceApp' ? <SourceAppField /> : <DslFileField />}
</div>
</div>
)
}
function SourceAppField() {
const sourceApp = useAtomValue(createReleaseSelectedSourceAppAtom)
const updateSourceApp = useSetAtom(updateCreateReleaseSourceAppAtom)
const sourceAppLocked = !isDeploymentDslImportEnabled
return (
<div className="flex min-h-12 items-center">
<SourceAppPicker value={sourceApp} onChange={updateSourceApp} disabled={sourceAppLocked} />
</div>
)
}
function DslFileField() {
const { t } = useTranslation('deployments')
const dslFileField = useAtomValue(createReleaseDslFileFieldAtom)
const isReadingDsl = useAtomValue(isReadingCreateReleaseDslAtom)
const dslReadError = useAtomValue(createReleaseDslReadErrorAtom)
const updateDslFile = useSetAtom(updateCreateReleaseDslFileAtom)
return (
<div className="flex min-h-12 flex-col gap-2">
<Uploader
file={dslFileField.value}
updateFile={(file) => {
void updateDslFile(file)
}}
className="mt-0"
/>
{isReadingDsl && (
<div role="status" className="system-xs-regular text-text-tertiary">
{t(($) => $['versions.dslReading'])}
</div>
)}
{dslReadError && (
<div role="alert" className="system-xs-regular text-util-colors-red-red-600">
{t(($) => $['versions.dslReadFailed'])}
</div>
)}
<DslUnsupportedModeError />
</div>
)
}
function DslUnsupportedModeError() {
const { t } = useTranslation('deployments')
const hasUnsupportedDslMode = useAtomValue(createReleaseHasUnsupportedDslModeAtom)
if (!hasUnsupportedDslMode) return null
return (
<div role="alert" className="system-xs-regular text-util-colors-red-red-600">
{t(($) => $['versions.dslUnsupportedMode'])}
</div>
)
}
@@ -1,15 +0,0 @@
# Deployment Deploy Drawer
Drawer workflow for choosing a release, selecting an environment, binding runtime credentials, and starting a deployment.
## Internal Modules
| Module | Why this module uses it |
| -------- | ------------------------------------------------------------------------------ |
| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. |
## External Modules
| Module | Why this module uses it |
| ------------------------------ | ------------------------------------------------------------ |
| `app/components/base/skeleton` | Reuses skeleton primitives for drawer form loading sections. |
@@ -1,64 +0,0 @@
'use client'
import {
Drawer,
DrawerBackdrop,
DrawerCloseButton,
DrawerContent,
DrawerPopup,
DrawerPortal,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import {
closeDeployDrawerAtom,
deployDrawerAppInstanceIdAtom,
deployDrawerEnvironmentIdAtom,
deployDrawerOpenAtom,
deployDrawerReleaseIdAtom,
} from './state'
import { DeployForm } from './ui/form'
export function DeployDrawer() {
const { t } = useTranslation('deployments')
const open = useAtomValue(deployDrawerOpenAtom)
const drawerAppInstanceId = useAtomValue(deployDrawerAppInstanceIdAtom)
const drawerEnvironmentId = useAtomValue(deployDrawerEnvironmentIdAtom)
const drawerReleaseId = useAtomValue(deployDrawerReleaseIdAtom)
const closeDeployDrawer = useSetAtom(closeDeployDrawerAtom)
const formKey = `${drawerAppInstanceId ?? 'none'}-${drawerEnvironmentId ?? 'any'}-${drawerReleaseId ?? 'new'}-${open ? '1' : '0'}`
return (
<Drawer
open={open}
modal
swipeDirection="right"
onOpenChange={(next) => !next && closeDeployDrawer()}
>
<DrawerPortal>
<DrawerBackdrop />
<DrawerViewport>
<DrawerPopup className="data-[swipe-direction=right]:w-160 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"
/>
<DrawerContent className="flex min-h-0 flex-1 flex-col bg-components-panel-bg p-0 pb-0">
{!drawerAppInstanceId ? (
<div className="p-6 text-text-tertiary">{t(($) => $['deployDrawer.notFound'])}</div>
) : (
<DeployForm
key={formKey}
appInstanceId={drawerAppInstanceId}
lockedEnvId={drawerEnvironmentId}
presetReleaseId={drawerReleaseId}
/>
)}
</DrawerContent>
</DrawerPopup>
</DrawerViewport>
</DrawerPortal>
</Drawer>
)
}
@@ -1,438 +0,0 @@
import type {
CredentialCandidate,
CredentialSlot,
Environment,
EnvironmentDeployment,
EnvVarSlot,
Release,
} from '@dify/contracts/enterprise/types.gen'
import type { QueryClient } from '@tanstack/react-query'
import {
EnvVarValueSource,
EnvVarValueType,
PluginCategory,
RuntimeInstanceStatus,
} from '@dify/contracts/enterprise/types.gen'
import { skipToken } from '@tanstack/react-query'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createQueryAtomTestStore } from '@/test/query-atom'
type DeploymentOptions = {
credentialSlots: CredentialSlot[]
envVarSlots: EnvVarSlot[]
}
const mockReleaseDeploymentViewQueryOptions = vi.hoisted(() => vi.fn())
const mockPromoteMutation = vi.hoisted(() => vi.fn())
const mockRollbackMutation = vi.hoisted(() => vi.fn())
vi.mock('@/service/client', () => ({
consoleQuery: {
enterprise: {
releaseService: {
computeReleaseDeploymentView: {
queryOptions: ({ enabled, input }: { enabled: boolean; input: unknown }) => {
mockReleaseDeploymentViewQueryOptions({ enabled, input })
return {
enabled,
input,
queryKey: ['computeReleaseDeploymentView', input],
queryFn: async () => undefined,
}
},
},
computeDeploymentOptions: {
queryOptions: ({ enabled, input }: { enabled: boolean; input: unknown }) => ({
enabled,
input,
queryKey: ['computeDeploymentOptions', input],
queryFn: async () => undefined,
}),
},
},
deploymentService: {
promote: {
mutationOptions: () => ({
mutationKey: ['promote'],
mutationFn: mockPromoteMutation,
}),
},
rollback: {
mutationOptions: () => ({
mutationKey: ['rollback'],
mutationFn: mockRollbackMutation,
}),
},
},
},
},
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
error: vi.fn(),
},
}))
vi.mock('../../shared/domain/idempotency', () => ({
createDeploymentIdempotencyKey: () => 'deploy-idempotency-key',
}))
async function loadState() {
return await import('../index')
}
function environment(overrides: Partial<Environment> = {}): Environment {
return {
id: 'environment-1',
displayName: 'Production',
description: '',
mode: 'ENVIRONMENT_MODE_SHARED',
backend: 'RUNTIME_BACKEND_K8S',
status: 'ENVIRONMENT_STATUS_READY',
statusMessage: '',
cpuCount: 1,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...overrides,
} as Environment
}
function release(overrides: Partial<Release> = {}): Release {
return {
id: 'release-1',
appInstanceId: 'app-instance-1',
displayName: 'Release 1',
description: '',
sourceAppId: 'source-app-1',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
createdBy: {
id: 'account-1',
displayName: 'Owner',
},
...overrides,
} as Release
}
function credentialCandidate(overrides: Partial<CredentialCandidate> = {}): CredentialCandidate {
return {
credentialId: 'credential-1',
providerId: 'langgenius/openai',
category: PluginCategory.PLUGIN_CATEGORY_MODEL,
displayName: 'Primary key',
fromEnterprise: false,
...overrides,
}
}
function credentialSlot(overrides: Partial<CredentialSlot> = {}): CredentialSlot {
return {
providerId: 'langgenius/openai',
category: PluginCategory.PLUGIN_CATEGORY_MODEL,
candidates: [
credentialCandidate({ credentialId: 'credential-1' }),
credentialCandidate({ credentialId: 'credential-2', displayName: 'Backup key' }),
],
lastCredentialId: '',
...overrides,
}
}
function envVarSlot(overrides: Partial<EnvVarSlot> = {}): EnvVarSlot {
return {
key: 'API_KEY',
valueType: EnvVarValueType.ENV_VAR_VALUE_TYPE_STRING,
description: '',
...overrides,
}
}
function runtimeRow(overrides: Partial<EnvironmentDeployment> = {}): EnvironmentDeployment {
return {
environment: environment(),
status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_READY,
currentRelease: release({ id: 'release-1', createdAt: '2026-01-01T00:00:00.000Z' }),
desiredRelease: release({ id: 'release-1', createdAt: '2026-01-01T00:00:00.000Z' }),
...overrides,
} as EnvironmentDeployment
}
function deployConfig() {
return {
appInstanceId: 'app-instance-1',
environments: [
environment({ id: 'environment-1', displayName: 'Production' }),
environment({ id: 'environment-2', displayName: 'Staging' }),
],
releases: [
release({ id: 'release-2', displayName: 'Release 2', createdAt: '2026-01-02T00:00:00.000Z' }),
release({ id: 'release-1', displayName: 'Release 1', createdAt: '2026-01-01T00:00:00.000Z' }),
],
runtimeRows: [runtimeRow()],
defaultReleaseId: 'release-2',
}
}
function setQueryOptions(
queryClient: QueryClient,
options: DeploymentOptions,
releaseId = 'release-2',
environmentId = 'environment-1',
) {
queryClient.setQueryData(
[
'computeDeploymentOptions',
{
body: {
releaseId,
environmentId,
},
},
],
{
options,
},
)
}
describe('deploy drawer state', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should open and close the deploy drawer with route identity', async () => {
const state = await loadState()
const { store } = createQueryAtomTestStore()
store.set(state.openDeployDrawerAtom, {
appInstanceId: 'app-instance-1',
environmentId: 'environment-1',
releaseId: 'release-2',
})
expect(store.get(state.deployDrawerOpenAtom)).toBe(true)
expect(store.get(state.deployDrawerAppInstanceIdAtom)).toBe('app-instance-1')
expect(store.get(state.deployDrawerEnvironmentIdAtom)).toBe('environment-1')
expect(store.get(state.deployDrawerReleaseIdAtom)).toBe('release-2')
store.set(state.closeDeployDrawerAtom)
expect(store.get(state.deployDrawerOpenAtom)).toBe(false)
expect(store.get(state.deployDrawerAppInstanceIdAtom)).toBeUndefined()
expect(store.get(state.deployDrawerEnvironmentIdAtom)).toBeUndefined()
expect(store.get(state.deployDrawerReleaseIdAtom)).toBeUndefined()
})
it('should disable release deployment view query with skipToken until form app instance exists', async () => {
const state = await loadState()
const { store } = createQueryAtomTestStore()
store.get(state.releaseDeploymentViewQueryAtom)
expect(mockReleaseDeploymentViewQueryOptions).toHaveBeenLastCalledWith({
enabled: false,
input: skipToken,
})
store.set(state.deployFormAppInstanceIdAtom, 'app-instance-1')
store.get(state.releaseDeploymentViewQueryAtom)
expect(mockReleaseDeploymentViewQueryOptions).toHaveBeenLastCalledWith({
enabled: true,
input: { params: { appInstanceId: 'app-instance-1' } },
})
})
it('should derive default environment and release selections from config', async () => {
const state = await loadState()
const { store } = createQueryAtomTestStore()
store.set(state.deployReadyFormConfigAtom, {
...deployConfig(),
lockedEnvId: 'environment-2',
presetReleaseId: 'release-1',
releaseEmptyLabel: 'No releases',
})
expect(store.get(state.deploySelectedEnvironmentIdAtom)).toBe('environment-2')
expect(store.get(state.deployLockedEnvironmentIdAtom)).toBe('environment-2')
expect(store.get(state.deployLockedEnvironmentAtom)?.displayName).toBe('Staging')
expect(store.get(state.deploySelectedReleaseIdAtom)).toBe('release-1')
expect(store.get(state.deployDisplayedReleaseAtom)?.id).toBe('release-1')
expect(store.get(state.deployIsExistingReleaseAtom)).toBe(true)
expect(store.get(state.deployReleaseRowsAtom)).toHaveLength(2)
expect(store.get(state.deployEnvironmentRowsAtom)).toHaveLength(2)
expect(store.get(state.deployReleaseEmptyLabelAtom)).toBe('No releases')
expect(store.get(state.deployHasSelectedEnvironmentAtom)).toBe(true)
})
it('should reset bindings, env vars, and validation state when target changes', async () => {
const state = await loadState()
const { queryClient, store } = createQueryAtomTestStore()
const slot = credentialSlot()
store.set(state.deployReadyFormConfigAtom, deployConfig())
setQueryOptions(queryClient, {
credentialSlots: [slot],
envVarSlots: [envVarSlot()],
})
store.set(
state.selectDeployBindingAtom,
'langgenius/openai:PLUGIN_CATEGORY_MODEL',
'credential-1',
)
store.set(state.setDeployEnvVarAtom, 'API_KEY', {
value: 'secret',
valueSource: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_LITERAL,
})
store.set(state.showDeployValidationErrorsAtom)
expect(store.get(state.deploySelectedBindingsAtom)).toEqual({
'langgenius/openai:PLUGIN_CATEGORY_MODEL': 'credential-1',
})
expect(store.get(state.deployEnvVarValuesAtom)).toEqual({
API_KEY: {
value: 'secret',
valueSource: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_LITERAL,
},
})
expect(store.get(state.deployShowValidationErrorsAtom)).toBe(true)
store.set(state.selectDeployEnvironmentAtom, 'environment-2')
expect(store.get(state.deploySelectedEnvironmentIdAtom)).toBe('environment-2')
expect(store.get(state.deploySelectedBindingsAtom)).toEqual({})
expect(store.get(state.deployEnvVarValuesAtom)).toEqual({})
expect(store.get(state.deployShowValidationErrorsAtom)).toBe(false)
store.set(state.selectDeployReleaseAtom, 'release-1')
expect(store.get(state.deploySelectedReleaseIdAtom)).toBe('release-1')
})
it('should derive binding and env var readiness from deployment options', async () => {
const state = await loadState()
const { queryClient, store } = createQueryAtomTestStore()
const slot = credentialSlot({
lastCredentialId: 'credential-2',
})
store.set(state.deployReadyFormConfigAtom, deployConfig())
setQueryOptions(queryClient, {
credentialSlots: [slot],
envVarSlots: [
envVarSlot({
key: 'PORT',
valueType: EnvVarValueType.ENV_VAR_VALUE_TYPE_NUMBER,
defaultValue: '8080',
}),
],
})
expect(store.get(state.deployBindingSlotsAtom)).toEqual([slot])
expect(store.get(state.deployEnvVarSlotsAtom)).toMatchObject([
{
key: 'PORT',
valueType: 'number',
hasDefaultValue: true,
hasLastValue: false,
},
])
expect(store.get(state.deploySelectedBindingsAtom)).toEqual({
'langgenius/openai:PLUGIN_CATEGORY_MODEL': 'credential-2',
})
expect(store.get(state.canAttemptDeployAtom)).toBe(true)
expect(store.get(state.canSubmitDeployAtom)).toBe(true)
})
it('should block submit until required literal env vars are valid', async () => {
const state = await loadState()
const { queryClient, store } = createQueryAtomTestStore()
store.set(state.deployReadyFormConfigAtom, deployConfig())
setQueryOptions(queryClient, {
credentialSlots: [
credentialSlot({
candidates: [credentialCandidate({ credentialId: 'credential-1' })],
}),
],
envVarSlots: [
envVarSlot({
key: 'PORT',
valueType: EnvVarValueType.ENV_VAR_VALUE_TYPE_NUMBER,
}),
],
})
expect(store.get(state.canAttemptDeployAtom)).toBe(true)
expect(store.get(state.canSubmitDeployAtom)).toBe(false)
store.set(state.setDeployEnvVarAtom, 'PORT', {
value: 'abc',
valueSource: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_LITERAL,
})
expect(store.get(state.canSubmitDeployAtom)).toBe(false)
store.set(state.setDeployEnvVarAtom, 'PORT', {
value: '3000',
valueSource: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_LITERAL,
})
expect(store.get(state.canSubmitDeployAtom)).toBe(true)
})
it('should submit a promote deployment with selected credentials and env vars', async () => {
const state = await loadState()
const { queryClient, store } = createQueryAtomTestStore()
store.set(state.deployReadyFormConfigAtom, deployConfig())
setQueryOptions(queryClient, {
credentialSlots: [
credentialSlot({
candidates: [credentialCandidate({ credentialId: 'credential-1' })],
}),
],
envVarSlots: [
envVarSlot({
key: 'API_KEY',
valueType: EnvVarValueType.ENV_VAR_VALUE_TYPE_SECRET,
}),
],
})
store.set(state.setDeployEnvVarAtom, 'API_KEY', {
value: 'secret',
valueSource: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_LITERAL,
})
store.set(state.deployReleaseSubmissionAtom, {
deployFailedMessage: 'Deploy failed',
})
await vi.waitFor(() => {
expect(mockPromoteMutation).toHaveBeenCalledWith(
{
params: {
appInstanceId: 'app-instance-1',
environmentId: 'environment-1',
},
body: {
appInstanceId: 'app-instance-1',
environmentId: 'environment-1',
releaseId: 'release-2',
credentials: [
{
providerId: 'langgenius/openai',
category: PluginCategory.PLUGIN_CATEGORY_MODEL,
credentialId: 'credential-1',
},
],
envVars: [
{
key: 'API_KEY',
value: 'secret',
valueSource: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_LITERAL,
},
],
idempotencyKey: expect.any(String),
},
},
expect.any(Object),
)
})
expect(store.get(state.deployDrawerOpenAtom)).toBe(false)
})
})
@@ -1,521 +0,0 @@
'use client'
import type {
Environment,
EnvironmentDeployment,
EnvVarInput,
Release,
} from '@dify/contracts/enterprise/types.gen'
import type { Getter } from 'jotai'
import type {
EnvVarBindingSlot,
EnvVarValues,
EnvVarValueSelection,
} from '../../shared/components/env-var-bindings'
import type { RuntimeCredentialBindingSelections } from '../../shared/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 { selectAtom } from 'jotai/utils'
import { consoleQuery } from '@/service/client'
import { envVarBindingSlotFromContract } from '../../shared/components/env-var-bindings-utils'
import {
hasMissingRequiredRuntimeCredentialBinding,
runtimeCredentialSlotKey,
selectedDeploymentRuntimeCredentials,
selectedRuntimeCredentialSelections,
} from '../../shared/components/runtime-credential-bindings-utils'
import { createDeploymentIdempotencyKey } from '../../shared/domain/idempotency'
import { releaseDeploymentAction } from '../../shared/domain/release-action'
type OpenDeployDrawerParams = {
appInstanceId: string
environmentId?: string
releaseId?: string
}
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<string | undefined>(undefined)
export const openDeployDrawerAtom = atom(null, (_get, set, params: OpenDeployDrawerParams) => {
set(deployDrawerAppInstanceIdAtom, params.appInstanceId)
set(deployDrawerEnvironmentIdAtom, params.environmentId)
set(deployDrawerReleaseIdAtom, params.releaseId)
set(deployDrawerOpenAtom, true)
})
export const closeDeployDrawerAtom = atom(null, (_get, set) => {
set(deployDrawerOpenAtom, false)
set(deployDrawerAppInstanceIdAtom, undefined)
set(deployDrawerEnvironmentIdAtom, undefined)
set(deployDrawerReleaseIdAtom, undefined)
})
export type DeployReadyFormConfig = {
appInstanceId: string
environments: Environment[]
releases: Release[]
runtimeRows: EnvironmentDeployment[]
defaultReleaseId?: string
lockedEnvId?: string
presetReleaseId?: string
releaseEmptyLabel?: string
}
export const deployReadyFormConfigAtom = atom<DeployReadyFormConfig | undefined>(undefined)
export const releaseDeploymentViewQueryAtom = atomWithQuery((get) => {
const appInstanceId = get(deployFormAppInstanceIdAtom)
return consoleQuery.enterprise.releaseService.computeReleaseDeploymentView.queryOptions({
input: appInstanceId
? {
params: { appInstanceId },
}
: skipToken,
enabled: Boolean(appInstanceId),
})
})
export const releaseDeploymentViewAtom = selectAtom(
releaseDeploymentViewQueryAtom,
(query) => query.data,
)
export const releaseDeploymentViewIsLoadingAtom = selectAtom(
releaseDeploymentViewQueryAtom,
(query) => query.isLoading,
)
export const releaseDeploymentViewIsErrorAtom = selectAtom(
releaseDeploymentViewQueryAtom,
(query) => query.isError,
)
const selectedEnvIdAtom = atom<string | undefined>(undefined)
const selectedReleaseIdAtom = atom<string | undefined>(undefined)
const manualBindingsAtom = atom<RuntimeCredentialBindingSelections>({})
export const deployEnvVarValuesAtom = atom<EnvVarValues>({})
const showValidationErrorsAtom = atom(false)
export const deployReadyFormLocalAtoms = [
selectedEnvIdAtom,
selectedReleaseIdAtom,
manualBindingsAtom,
deployEnvVarValuesAtom,
showValidationErrorsAtom,
] as const
function formConfig(get: Getter) {
const config = get(deployReadyFormConfigAtom)
if (!config) throw new Error('Missing deploy ready form config.')
return config
}
const resetDeployBindingsAtom = atom(null, (_get, set) => {
set(manualBindingsAtom, {})
set(deployEnvVarValuesAtom, {})
set(showValidationErrorsAtom, false)
})
export const selectDeployEnvironmentAtom = atom(null, (_get, set, environmentId: string) => {
set(selectedEnvIdAtom, environmentId)
set(resetDeployBindingsAtom)
})
export const selectDeployReleaseAtom = atom(null, (_get, set, releaseId: string) => {
set(selectedReleaseIdAtom, releaseId)
set(resetDeployBindingsAtom)
})
export const showDeployValidationErrorsAtom = atom(null, (_get, set) => {
set(showValidationErrorsAtom, true)
})
export const deployShowValidationErrorsAtom = atom((get) => {
return get(showValidationErrorsAtom)
})
const deployPresetReleaseAtom = atom((get) => {
const config = formConfig(get)
return config.presetReleaseId
? config.releases.find((r) => r.id === config.presetReleaseId)
: undefined
})
export const deployDisplayedReleaseAtom = atom((get): Release | undefined => {
return get(deployPresetReleaseAtom)
})
export const deployIsExistingReleaseAtom = atom((get) => {
const config = formConfig(get)
return Boolean(config.presetReleaseId)
})
export const deployReleaseRowsAtom = atom((get) => {
return formConfig(get).releases
})
export const deployReleaseEmptyLabelAtom = atom((get) => {
return formConfig(get).releaseEmptyLabel
})
export const deployEnvironmentRowsAtom = atom((get) => {
return formConfig(get).environments
})
export const deployLockedEnvironmentIdAtom = atom((get) => {
return formConfig(get).lockedEnvId
})
export const deploySelectedEnvironmentIdAtom = atom((get) => {
const config = formConfig(get)
return get(selectedEnvIdAtom) ?? config.lockedEnvId ?? config.environments[0]?.id
})
const deploySelectedEnvironmentAtom = atom((get) => {
const config = formConfig(get)
const selectedEnvironmentId = get(deploySelectedEnvironmentIdAtom)
return selectedEnvironmentId
? config.environments.find((env) => env.id === selectedEnvironmentId)
: undefined
})
export const deploySelectedReleaseIdAtom = atom((get) => {
const config = formConfig(get)
const displayedRelease = get(deployDisplayedReleaseAtom)
return get(selectedReleaseIdAtom) ?? displayedRelease?.id ?? config.defaultReleaseId
})
const deploySelectedReleaseAtom = atom((get) => {
const config = formConfig(get)
const selectedReleaseId = get(deploySelectedReleaseIdAtom)
return selectedReleaseId
? config.releases.find((release) => release.id === selectedReleaseId)
: undefined
})
const deployTargetReleaseAtom = atom((get) => {
return get(deployDisplayedReleaseAtom) ?? get(deploySelectedReleaseAtom)
})
export const deployLockedEnvironmentAtom = atom((get) => {
const config = formConfig(get)
return config.lockedEnvId
? config.environments.find((environment) => environment.id === config.lockedEnvId)
: undefined
})
export const deployTargetReleaseIdAtom = atom((get) => {
const targetRelease = get(deployTargetReleaseAtom)
return targetRelease?.id ?? get(deploySelectedReleaseIdAtom)
})
export const deployHasSelectedEnvironmentAtom = atom((get) => {
return Boolean(get(deploySelectedEnvironmentAtom))
})
const releaseDeploymentOptionsQueryAtom = atomWithQuery((get) => {
const hasSelectedEnvironment = get(deployHasSelectedEnvironmentAtom)
const releaseId = get(deployTargetReleaseIdAtom)
const selectedEnvironmentId = get(deploySelectedEnvironmentIdAtom)
const hasRequiredInput = Boolean(releaseId && selectedEnvironmentId)
return consoleQuery.enterprise.releaseService.computeDeploymentOptions.queryOptions({
input:
releaseId && selectedEnvironmentId
? {
body: {
releaseId,
environmentId: selectedEnvironmentId,
},
}
: skipToken,
enabled: hasRequiredInput && hasSelectedEnvironment,
retry: false,
})
})
const releaseDeploymentOptionsAtom = selectAtom(
releaseDeploymentOptionsQueryAtom,
(query) => query.data,
)
const releaseDeploymentOptionsIsLoadingAtom = selectAtom(
releaseDeploymentOptionsQueryAtom,
(query) => query.isLoading,
)
const releaseDeploymentOptionsIsFetchingAtom = selectAtom(
releaseDeploymentOptionsQueryAtom,
(query) => query.isFetching,
)
const releaseDeploymentOptionsIsErrorAtom = selectAtom(
releaseDeploymentOptionsQueryAtom,
(query) => query.isError,
)
export const deployBindingSlotsAtom = atom((get) => {
const deploymentOptions = get(releaseDeploymentOptionsAtom)
return (
deploymentOptions?.options.credentialSlots.filter((slot) => runtimeCredentialSlotKey(slot)) ??
[]
)
})
export const deployEnvVarSlotsAtom = atom((get): EnvVarBindingSlot[] => {
const deploymentOptions = get(releaseDeploymentOptionsAtom)
return (
deploymentOptions?.options.envVarSlots.flatMap((slot): EnvVarBindingSlot[] => {
const bindingSlot = envVarBindingSlotFromContract(slot)
return bindingSlot ? [bindingSlot] : []
}) ?? []
)
})
export const deployIsBindingOptionsLoadingAtom = atom((get) => {
const releaseId = get(deployTargetReleaseIdAtom)
return Boolean(
releaseId &&
get(deployHasSelectedEnvironmentAtom) &&
(get(releaseDeploymentOptionsIsLoadingAtom) || get(releaseDeploymentOptionsIsFetchingAtom)),
)
})
export const deployHasBindingOptionsErrorAtom = atom((get) => {
return get(releaseDeploymentOptionsIsErrorAtom)
})
const deployIsBindingOptionsReadyAtom = atom((get) => {
const releaseId = get(deployTargetReleaseIdAtom)
return Boolean(
releaseId &&
get(deployHasSelectedEnvironmentAtom) &&
get(releaseDeploymentOptionsAtom) &&
!get(deployIsBindingOptionsLoadingAtom) &&
!get(deployHasBindingOptionsErrorAtom),
)
})
function deployEnvVarValueSource(
slot: EnvVarBindingSlot,
selection: EnvVarValueSelection | undefined,
) {
return (
selection?.valueSource ??
(slot.hasLastValue
? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT
: slot.hasDefaultValue
? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT
: ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LITERAL)
)
}
function deployEnvVarInput(
slot: EnvVarBindingSlot,
selection: EnvVarValueSelection | undefined,
): EnvVarInput[] {
const valueSource = deployEnvVarValueSource(slot, selection)
if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT) {
return slot.hasLastValue ? [{ key: slot.key, valueSource }] : []
}
if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT) {
return slot.hasDefaultValue ? [{ key: slot.key, valueSource }] : []
}
if (!selection?.value || (slot.valueType === 'number' && Number.isNaN(Number(selection.value))))
return []
return [
{
key: slot.key,
value: selection.value,
valueSource,
},
]
}
function deployEnvVarSelectionReady(
slot: EnvVarBindingSlot,
selection: EnvVarValueSelection | undefined,
) {
const valueSource = deployEnvVarValueSource(slot, selection)
if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT)
return Boolean(slot.hasLastValue)
if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT)
return Boolean(slot.hasDefaultValue)
if (!selection?.value) return false
return slot.valueType !== 'number' || !Number.isNaN(Number(selection.value))
}
export const deploySelectedBindingsAtom = atom((get) => {
return selectedRuntimeCredentialSelections(get(deployBindingSlotsAtom), get(manualBindingsAtom))
})
const deployDeploymentCredentialsAtom = atom((get) => {
return selectedDeploymentRuntimeCredentials(
get(deployBindingSlotsAtom),
get(deploySelectedBindingsAtom),
)
})
const deployDeploymentEnvVarsAtom = atom((get) => {
const envVarValues = get(deployEnvVarValuesAtom)
return get(deployEnvVarSlotsAtom).flatMap((slot) =>
deployEnvVarInput(slot, envVarValues[slot.key]),
)
})
const deployRequiredBindingsReadyAtom = atom((get) => {
const selectedBindings = get(deploySelectedBindingsAtom)
return get(deployBindingSlotsAtom).every(
(slot) =>
!hasMissingRequiredRuntimeCredentialBinding(
slot,
selectedBindings[runtimeCredentialSlotKey(slot)],
),
)
})
const deployRequiredEnvVarsReadyAtom = atom((get) => {
const envVarValues = get(deployEnvVarValuesAtom)
return get(deployEnvVarSlotsAtom).every((slot) =>
deployEnvVarSelectionReady(slot, envVarValues[slot.key]),
)
})
export const selectDeployBindingAtom = atom(null, (get, set, slot: string, value: string) => {
set(manualBindingsAtom, {
...get(manualBindingsAtom),
[slot]: value,
})
})
export const setDeployEnvVarAtom = atom(
null,
(get, set, key: string, value: EnvVarValueSelection) => {
set(deployEnvVarValuesAtom, {
...get(deployEnvVarValuesAtom),
[key]: value,
})
},
)
const promoteReleaseMutationAtom = atomWithMutation(() =>
consoleQuery.enterprise.deploymentService.promote.mutationOptions(),
)
const rollbackReleaseMutationAtom = atomWithMutation(() =>
consoleQuery.enterprise.deploymentService.rollback.mutationOptions(),
)
export const isDeployReleaseSubmittingAtom = atom((get) => {
return get(promoteReleaseMutationAtom).isPending || get(rollbackReleaseMutationAtom).isPending
})
export const canAttemptDeployAtom = atom((get) => {
return Boolean(
get(deploySelectedEnvironmentIdAtom) &&
get(deploySelectedEnvironmentAtom) &&
get(deployTargetReleaseIdAtom) &&
get(deployIsBindingOptionsReadyAtom) &&
!get(isDeployReleaseSubmittingAtom),
)
})
export const canSubmitDeployAtom = atom((get) => {
return Boolean(
get(canAttemptDeployAtom) &&
get(deployRequiredBindingsReadyAtom) &&
get(deployRequiredEnvVarsReadyAtom),
)
})
export const deployReleaseSubmissionAtom = atom(
null,
(
get,
set,
{
deployFailedMessage,
}: {
deployFailedMessage: string
},
) => {
const config = formConfig(get)
const selectedEnvironmentId = get(deploySelectedEnvironmentIdAtom)
const targetRelease = get(deployTargetReleaseAtom)
const targetReleaseId = get(deployTargetReleaseIdAtom)
if (!targetReleaseId || !selectedEnvironmentId) return
const idempotencyKey = createDeploymentIdempotencyKey()
const currentRelease = config.runtimeRows.find(
(row) => row.environment.id === selectedEnvironmentId,
)?.currentRelease
const action = releaseDeploymentAction({
targetRelease,
currentRelease,
releaseRows: config.releases,
isExistingRelease: true,
})
const mutationOptions = {
onSuccess: () => {
set(closeDeployDrawerAtom)
},
onError: () => {
toast.error(deployFailedMessage)
},
}
if (action === 'rollback') {
get(rollbackReleaseMutationAtom).mutate(
{
params: {
appInstanceId: config.appInstanceId,
environmentId: selectedEnvironmentId,
},
body: {
appInstanceId: config.appInstanceId,
environmentId: selectedEnvironmentId,
targetReleaseId,
idempotencyKey,
},
},
mutationOptions,
)
return
}
const deploymentEnvVars = get(deployDeploymentEnvVarsAtom)
get(promoteReleaseMutationAtom).mutate(
{
params: {
appInstanceId: config.appInstanceId,
environmentId: selectedEnvironmentId,
},
body: {
appInstanceId: config.appInstanceId,
environmentId: selectedEnvironmentId,
releaseId: targetReleaseId,
credentials: get(deployDeploymentCredentialsAtom),
envVars: deploymentEnvVars.length > 0 ? deploymentEnvVars : undefined,
idempotencyKey,
},
},
mutationOptions,
)
},
)
@@ -1,26 +0,0 @@
import type { EnvironmentDeployment, Release } from '@dify/contracts/enterprise/types.gen'
export function currentReleaseIdForEnvironment(
rows: EnvironmentDeployment[],
targetEnvironmentId?: string,
) {
if (!targetEnvironmentId) return undefined
return rows.find((row) => row.environment.id === targetEnvironmentId)?.currentRelease?.id
}
export function selectableDeployReleases({
releases,
lockedEnvId,
currentReleaseId,
presetReleaseId,
}: {
releases: Release[]
lockedEnvId?: string
currentReleaseId?: string
presetReleaseId?: string
}) {
if (!lockedEnvId || presetReleaseId || !currentReleaseId) return releases
return releases.filter((release) => release.id !== currentReleaseId)
}
@@ -1,195 +0,0 @@
'use client'
import type { CredentialSlot, Environment } from '@dify/contracts/enterprise/types.gen'
import type { RuntimeCredentialBindingSelections } from '../../shared/components/runtime-credential-bindings-utils'
import { DrawerDescription, DrawerTitle } from '@langgenius/dify-ui/drawer'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skeleton'
import { DeploymentStateMessage } from '../../shared/components/empty-state'
import { RuntimeCredentialBindingsPanel } from '../../shared/components/runtime-credential-bindings'
import { formatDate, releaseCommit } from '../../shared/domain/release'
import {
deployDisplayedReleaseAtom,
deployEnvironmentRowsAtom,
deployIsExistingReleaseAtom,
deployLockedEnvironmentAtom,
deployLockedEnvironmentIdAtom,
deployReleaseEmptyLabelAtom,
deployReleaseRowsAtom,
deploySelectedEnvironmentIdAtom,
deploySelectedReleaseIdAtom,
selectDeployEnvironmentAtom,
selectDeployReleaseAtom,
} from '../state'
import { DeploymentSelect, EnvironmentRow, Field } from './select'
export const DEPLOY_DRAWER_BINDING_LIST_CLASS_NAME = 'max-h-none overflow-visible'
function environmentOptionLabel(
env: Environment,
t: ReturnType<typeof useTranslation<'deployments'>>['t'],
) {
const description = env.description.trim()
if (description) return `${env.displayName} · ${description}`
return `${env.displayName} · ${t(($) => $[`mode.${env.mode}`])} · ${t(($) => $[`backend.${env.backend}`])}`
}
export function BindingOptionsPanel({
slots,
selections,
isLoading,
hasError,
bindingCountLabel,
showMissingRequired,
onChange,
}: {
slots: CredentialSlot[]
selections: RuntimeCredentialBindingSelections
isLoading: boolean
hasError: boolean
bindingCountLabel: string
showMissingRequired: boolean
onChange: (slot: string, value: string) => void
}) {
const { t } = useTranslation('deployments')
if (isLoading) {
return (
<div className="rounded-xl border border-divider-subtle bg-background-default-subtle px-3 py-4">
<SkeletonContainer className="gap-2">
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
<SkeletonRectangle className="h-3 w-2/3 animate-pulse" />
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
</SkeletonContainer>
</div>
)
}
if (hasError) {
return (
<div className="rounded-xl border border-divider-subtle bg-background-default-subtle px-3 py-4 system-sm-regular text-text-destructive">
{t(($) => $['deployDrawer.bindingOptionsFailed'])}
</div>
)
}
return (
<RuntimeCredentialBindingsPanel
slots={slots}
selections={selections}
title={t(($) => $['deployDrawer.runtimeCredentials'])}
hint={t(($) => $['deployDrawer.bindingSelectionHint'])}
noBindingRequiredLabel={t(($) => $['deployDrawer.noBindingRequired'])}
noCredentialCandidatesLabel={t(($) => $['deployDrawer.noCredentialCandidates'])}
selectCredentialLabel={t(($) => $['deployDrawer.selectCredential'])}
missingRequiredLabel={t(($) => $['deployDrawer.missingRequiredBinding'])}
bindingCountLabel={bindingCountLabel}
showMissingRequired={showMissingRequired}
listClassName={DEPLOY_DRAWER_BINDING_LIST_CLASS_NAME}
onChange={onChange}
/>
)
}
export function DeployFormHeader() {
const { t } = useTranslation('deployments')
return (
<div className="shrink-0 border-b border-divider-subtle px-6 py-5 pr-14">
<DrawerTitle className="title-xl-semi-bold text-text-primary">
{t(($) => $['deployDrawer.title'])}
</DrawerTitle>
<DrawerDescription className="mt-1 system-sm-regular text-text-tertiary">
{t(($) => $['deployDrawer.description'])}
</DrawerDescription>
</div>
)
}
export function ReleaseField() {
const { t } = useTranslation('deployments')
const displayedRelease = useAtomValue(deployDisplayedReleaseAtom)
const emptyLabel = useAtomValue(deployReleaseEmptyLabelAtom)
const isExistingRelease = useAtomValue(deployIsExistingReleaseAtom)
const releases = useAtomValue(deployReleaseRowsAtom)
const selectedReleaseId = useAtomValue(deploySelectedReleaseIdAtom)
const selectRelease = useSetAtom(selectDeployReleaseAtom)
return (
<Field label={t(($) => $['deployDrawer.releaseLabel'])}>
{isExistingRelease && displayedRelease ? (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between rounded-lg border border-components-panel-border bg-components-panel-bg-blur px-3 py-2">
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 font-mono system-sm-semibold text-text-primary">
{displayedRelease.displayName}
</span>
<span className="shrink-0 system-xs-regular text-text-tertiary">·</span>
<span className="shrink-0 font-mono system-xs-regular text-text-tertiary">
{releaseCommit(displayedRelease)}
</span>
</div>
<span className="shrink-0 system-xs-regular text-text-quaternary">
{formatDate(displayedRelease.createdAt)}
</span>
</div>
<span className="system-xs-regular text-text-tertiary">
{t(($) => $['deployDrawer.existingReleaseHint'])}
</span>
</div>
) : releases.length === 0 ? (
<DeploymentStateMessage variant="compact">
{emptyLabel ?? t(($) => $['deployDrawer.noReleaseAvailable'])}
</DeploymentStateMessage>
) : (
<DeploymentSelect
value={selectedReleaseId}
onChange={selectRelease}
options={releases.map((release) => ({
value: release.id,
label: `${release.displayName} · ${releaseCommit(release)}`,
}))}
ariaLabel={t(($) => $['deployDrawer.releaseLabel'])}
placeholder={t(($) => $['deployDrawer.selectRelease'])}
/>
)}
</Field>
)
}
export function EnvironmentField() {
const { t } = useTranslation('deployments')
const environments = useAtomValue(deployEnvironmentRowsAtom)
const lockedEnv = useAtomValue(deployLockedEnvironmentAtom)
const lockedEnvId = useAtomValue(deployLockedEnvironmentIdAtom)
const selectedEnvironmentId = useAtomValue(deploySelectedEnvironmentIdAtom)
const selectEnvironment = useSetAtom(selectDeployEnvironmentAtom)
return (
<Field
label={t(($) => $['deployDrawer.targetEnv'])}
hint={lockedEnvId ? t(($) => $['deployDrawer.lockedHint']) : undefined}
>
{lockedEnv ? (
<EnvironmentRow env={lockedEnv} />
) : environments.length === 0 ? (
<DeploymentStateMessage variant="compact">
{t(($) => $['deployDrawer.noNewEnvironmentAvailable'])}
</DeploymentStateMessage>
) : (
<DeploymentSelect
value={selectedEnvironmentId}
onChange={selectEnvironment}
options={environments.map((env) => ({
value: env.id,
label: environmentOptionLabel(env, t),
}))}
ariaLabel={t(($) => $['deployDrawer.targetEnv'])}
placeholder={t(($) => $['deployDrawer.selectEnv'])}
/>
)}
</Field>
)
}
@@ -1,42 +0,0 @@
'use client'
import { SkeletonContainer, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
const DEPLOY_FORM_FIELD_SKELETON_KEYS = ['environment', 'release']
export function DeployFormSkeleton() {
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="shrink-0 border-b border-divider-subtle px-6 py-5 pr-14">
<SkeletonContainer className="gap-2">
<SkeletonRectangle className="h-5 w-44 animate-pulse" />
<SkeletonRectangle className="h-3 w-72 animate-pulse" />
</SkeletonContainer>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
<div className="flex flex-col gap-5">
{DEPLOY_FORM_FIELD_SKELETON_KEYS.map((key) => (
<SkeletonContainer key={key} className="gap-2">
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
<SkeletonRectangle className="my-0 h-9 w-full animate-pulse rounded-lg" />
</SkeletonContainer>
))}
<div className="rounded-xl border border-divider-subtle bg-background-default-subtle px-3 py-4">
<SkeletonContainer className="gap-2">
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
<SkeletonRectangle className="h-3 w-2/3 animate-pulse" />
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
</SkeletonContainer>
</div>
</div>
</div>
<SkeletonRow className="shrink-0 justify-end border-t border-divider-subtle bg-background-default-subtle px-6 py-4">
<SkeletonRectangle className="my-0 h-8 w-18 animate-pulse rounded-lg" />
<SkeletonRectangle className="my-0 h-8 w-22 animate-pulse rounded-lg" />
</SkeletonRow>
</div>
)
}
@@ -1,285 +0,0 @@
'use client'
import type {
Environment,
EnvironmentDeployment,
Release,
} from '@dify/contracts/enterprise/types.gen'
import { Button } from '@langgenius/dify-ui/button'
import { useAtomValue, useSetAtom } from 'jotai'
import { ScopeProvider } from 'jotai-scope'
import { useTranslation } from 'react-i18next'
import { EnvVarBindingsPanel } from '../../shared/components/env-var-bindings'
import { isAvailableDeploymentTarget } from '../../shared/domain/runtime-status'
import {
canAttemptDeployAtom,
canSubmitDeployAtom,
closeDeployDrawerAtom,
deployBindingSlotsAtom,
deployEnvVarSlotsAtom,
deployEnvVarValuesAtom,
deployFormAppInstanceIdAtom,
deployHasBindingOptionsErrorAtom,
deployHasSelectedEnvironmentAtom,
deployIsBindingOptionsLoadingAtom,
deployReadyFormConfigAtom,
deployReadyFormLocalAtoms,
deployReleaseSubmissionAtom,
deploySelectedBindingsAtom,
deployShowValidationErrorsAtom,
deployTargetReleaseIdAtom,
isDeployReleaseSubmittingAtom,
releaseDeploymentViewAtom,
releaseDeploymentViewIsErrorAtom,
releaseDeploymentViewIsLoadingAtom,
selectDeployBindingAtom,
setDeployEnvVarAtom,
showDeployValidationErrorsAtom,
} from '../state'
import { currentReleaseIdForEnvironment, selectableDeployReleases } from '../state/release-options'
import {
BindingOptionsPanel,
DEPLOY_DRAWER_BINDING_LIST_CLASS_NAME,
DeployFormHeader,
EnvironmentField,
ReleaseField,
} from './form-sections'
import { DeployFormSkeleton } from './form-skeleton'
type DeployFormProps = {
appInstanceId: string
lockedEnvId?: string
presetReleaseId?: string
}
type DeployReadyFormProps = DeployFormProps & {
environments: Environment[]
releases: Release[]
runtimeRows: EnvironmentDeployment[]
defaultReleaseId?: string
releaseEmptyLabel?: string
}
function DeployRuntimeCredentialBindingsSection() {
const { t } = useTranslation('deployments')
const bindingSlots = useAtomValue(deployBindingSlotsAtom)
const selectedBindings = useAtomValue(deploySelectedBindingsAtom)
const isBindingOptionsLoading = useAtomValue(deployIsBindingOptionsLoadingAtom)
const hasBindingOptionsError = useAtomValue(deployHasBindingOptionsErrorAtom)
const showValidationErrors = useAtomValue(deployShowValidationErrorsAtom)
const selectBinding = useSetAtom(selectDeployBindingAtom)
return (
<BindingOptionsPanel
slots={bindingSlots}
selections={selectedBindings}
isLoading={isBindingOptionsLoading}
hasError={hasBindingOptionsError}
bindingCountLabel={t(($) => $['deployDrawer.bindingCount'], { count: bindingSlots.length })}
showMissingRequired={showValidationErrors}
onChange={selectBinding}
/>
)
}
function DeployEnvVarBindingsSection() {
const { t } = useTranslation('deployments')
const envVarSlots = useAtomValue(deployEnvVarSlotsAtom)
const envVarValues = useAtomValue(deployEnvVarValuesAtom)
const isBindingOptionsLoading = useAtomValue(deployIsBindingOptionsLoadingAtom)
const hasBindingOptionsError = useAtomValue(deployHasBindingOptionsErrorAtom)
const showValidationErrors = useAtomValue(deployShowValidationErrorsAtom)
const setDeployEnvVar = useSetAtom(setDeployEnvVarAtom)
if (isBindingOptionsLoading || hasBindingOptionsError) return null
return (
<EnvVarBindingsPanel
slots={envVarSlots}
values={envVarValues}
title={t(($) => $['deployDrawer.envVars'])}
hint={t(($) => $['deployDrawer.envVarHint'])}
envVarPlaceholder={t(($) => $['deployDrawer.envVarPlaceholder'])}
literalSourceLabel={t(($) => $['deployDrawer.envVarSource.literal'])}
defaultSourceLabel={t(($) => $['deployDrawer.envVarSource.default'])}
lastDeploymentSourceLabel={t(($) => $['deployDrawer.envVarSource.lastDeployment'])}
valueTypeLabels={{
string: t(($) => $['deployDrawer.envVarType.string']),
number: t(($) => $['deployDrawer.envVarType.number']),
secret: t(($) => $['deployDrawer.envVarType.secret']),
}}
sourceAriaLabel={(key) => t(($) => $['deployDrawer.envVarSource.ariaLabel'], { key })}
defaultSourcePriority="lastDeployment"
envVarCountLabel={t(($) => $['deployDrawer.envVarCount'], { count: envVarSlots.length })}
missingRequiredLabel={t(($) => $['deployDrawer.missingRequiredEnvVar'])}
listClassName={DEPLOY_DRAWER_BINDING_LIST_CLASS_NAME}
showMissingRequired={showValidationErrors}
onChange={setDeployEnvVar}
/>
)
}
function DeployBindingsSection() {
const targetReleaseId = useAtomValue(deployTargetReleaseIdAtom)
const hasSelectedEnvironment = useAtomValue(deployHasSelectedEnvironmentAtom)
if (!targetReleaseId || !hasSelectedEnvironment) return null
return (
<>
<DeployRuntimeCredentialBindingsSection />
<DeployEnvVarBindingsSection />
</>
)
}
function DeployFormBody() {
return (
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
<div className="flex flex-col gap-5">
<ReleaseField />
<EnvironmentField />
<DeployBindingsSection />
</div>
</div>
)
}
function DeployFooter() {
const { t } = useTranslation('deployments')
const closeDeployDrawer = useSetAtom(closeDeployDrawerAtom)
const showValidationErrors = useSetAtom(showDeployValidationErrorsAtom)
const submitDeployRelease = useSetAtom(deployReleaseSubmissionAtom)
const canAttemptDeploy = useAtomValue(canAttemptDeployAtom)
const canDeploy = useAtomValue(canSubmitDeployAtom)
const isSubmitting = useAtomValue(isDeployReleaseSubmittingAtom)
const submitLabel = isSubmitting
? t(($) => $['deployDrawer.deploying'])
: t(($) => $['deployDrawer.deploy'])
function handleDeploy() {
showValidationErrors()
if (!canDeploy) return
submitDeployRelease({
deployFailedMessage: t(($) => $['deployDrawer.deployFailed']),
})
}
return (
<div className="flex shrink-0 justify-end gap-2 border-t border-divider-subtle bg-background-default-subtle px-6 py-4">
<Button type="button" variant="secondary" onClick={closeDeployDrawer}>
{t(($) => $['deployDrawer.cancel'])}
</Button>
<Button variant="primary" disabled={!canAttemptDeploy} onClick={handleDeploy}>
{submitLabel}
</Button>
</div>
)
}
function deployReadyFormStoreKey({
appInstanceId,
environments,
releases,
runtimeRows,
defaultReleaseId,
lockedEnvId,
presetReleaseId,
}: DeployReadyFormProps) {
return [
appInstanceId,
lockedEnvId ?? 'any',
presetReleaseId ?? 'new',
defaultReleaseId ?? 'none',
environments.map((env) => env.id).join(','),
releases.map((release) => release.id).join(','),
runtimeRows.map((row) => `${row.environment.id}:${row.currentRelease?.id ?? 'none'}`).join(','),
].join('|')
}
function DeployReadyForm(config: DeployReadyFormProps) {
return (
<ScopeProvider
atoms={[[deployReadyFormConfigAtom, config], ...deployReadyFormLocalAtoms]}
name="DeployReadyForm"
>
<div className="flex min-h-0 flex-1 flex-col">
<DeployFormHeader />
<DeployFormBody />
<DeployFooter />
</div>
</ScopeProvider>
)
}
function DeployFormContent({ appInstanceId, lockedEnvId, presetReleaseId }: DeployFormProps) {
const { t } = useTranslation('deployments')
const deploymentView = useAtomValue(releaseDeploymentViewAtom)
const isLoading = useAtomValue(releaseDeploymentViewIsLoadingAtom)
const isError = useAtomValue(releaseDeploymentViewIsErrorAtom)
if (isLoading) {
return <DeployFormSkeleton />
}
if (isError) {
return (
<div className="p-4 system-sm-regular text-text-destructive">
{t(($) => $['common.loadFailed'])}
</div>
)
}
if (!deploymentView) {
return (
<div className="p-4 system-sm-regular text-text-destructive">
{t(($) => $['common.loadFailed'])}
</div>
)
}
const runtimeRows = deploymentView.environmentDeployments
const environments = runtimeRows
.filter((row) => lockedEnvId || isAvailableDeploymentTarget(row))
.map((row) => row.environment)
const releaseRows = deploymentView.releases
const currentReleaseId = currentReleaseIdForEnvironment(runtimeRows, lockedEnvId)
const releases = selectableDeployReleases({
releases: releaseRows,
lockedEnvId,
currentReleaseId,
presetReleaseId,
})
const defaultReleaseId = releases[0]?.id
const releaseEmptyLabel =
lockedEnvId && !presetReleaseId && currentReleaseId
? t(($) => $['deployDrawer.noOtherReleaseAvailable'])
: undefined
const readyFormConfig = {
appInstanceId,
environments,
releases,
runtimeRows,
defaultReleaseId,
releaseEmptyLabel,
lockedEnvId,
presetReleaseId,
}
const formKey = deployReadyFormStoreKey(readyFormConfig)
return <DeployReadyForm key={formKey} {...readyFormConfig} />
}
export function DeployForm(props: DeployFormProps) {
return (
<ScopeProvider
key={props.appInstanceId}
atoms={[[deployFormAppInstanceIdAtom, props.appInstanceId]]}
name="DeployForm"
>
<DeployFormContent {...props} />
</ScopeProvider>
)
}
@@ -1,145 +0,0 @@
'use client'
import type { Environment, EnvironmentStatus } from '@dify/contracts/enterprise/types.gen'
import { EnvironmentStatus as EnvironmentStatusEnum } from '@dify/contracts/enterprise/types.gen'
import { cn } from '@langgenius/dify-ui/cn'
import {
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectTrigger,
} from '@langgenius/dify-ui/select'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useTranslation } from 'react-i18next'
import { TitleTooltip } from '../../shared/components/title-tooltip'
import { ModeBadge } from './status-badge'
export function Field({
label,
hint,
children,
}: {
label: string
hint?: string
children: React.ReactNode
}) {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<div className="system-xs-medium-uppercase text-text-tertiary">{label}</div>
{hint && <span className="system-xs-regular text-text-quaternary">{hint}</span>}
</div>
{children}
</div>
)
}
type SelectOption = {
value?: string
label: string
disabled?: boolean
disabledReason?: string
}
type SelectProps = {
value?: string
onChange: (value: string) => void
options: SelectOption[]
ariaLabel?: string
placeholder?: string
}
export function DeploymentSelect({
value,
onChange,
options,
ariaLabel,
placeholder,
}: SelectProps) {
const { t } = useTranslation('deployments')
const selectedOption = options.find((option) => option.value === value)
return (
<Select
value={value ?? null}
onValueChange={(next) => {
if (!next) return
onChange(next)
}}
disabled={options.length === 0}
>
<SelectTrigger
aria-label={ariaLabel ?? placeholder ?? t(($) => $['deployDrawer.defaultSelect'])}
className={cn(
'h-8 min-w-0 px-2 text-left system-sm-medium',
!selectedOption && 'text-text-quaternary',
)}
>
{selectedOption?.label ?? placeholder ?? t(($) => $['deployDrawer.defaultSelect'])}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{options.map((opt) =>
opt.value ? (
<SelectItem key={opt.value} value={opt.value} disabled={opt.disabled}>
<TitleTooltip content={opt.disabled ? opt.disabledReason : undefined}>
<SelectItemText>{opt.label}</SelectItemText>
</TitleTooltip>
<SelectItemIndicator />
</SelectItem>
) : null,
)}
</SelectContent>
</Select>
)
}
function EnvironmentHealthDot({ status }: { status: EnvironmentStatus }) {
const { t } = useTranslation('deployments')
const label = t(($) => $[`health.${status}`])
const isReady = status === EnvironmentStatusEnum.ENVIRONMENT_STATUS_READY
return (
<Tooltip>
<TooltipTrigger
render={
<span
aria-label={label}
className={cn(
'flex size-4 shrink-0 items-center justify-center rounded-full',
isReady ? 'bg-util-colors-green-green-50' : 'bg-util-colors-warning-warning-50',
)}
>
<span
aria-hidden
className={cn(
'size-1.5 rounded-full',
isReady ? 'bg-util-colors-green-green-500' : 'bg-util-colors-warning-warning-500',
)}
/>
</span>
}
/>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
)
}
export function EnvironmentRow({ env }: { env: Environment }) {
const { t } = useTranslation('deployments')
const summary = env.description.trim() || t(($) => $[`backend.${env.backend}`])
return (
<div className="flex items-center justify-between gap-3 rounded-lg border border-components-panel-border bg-components-panel-bg-blur px-3 py-2">
<div className="flex min-w-0 flex-col gap-1">
<div className="flex min-w-0 items-center gap-2">
<EnvironmentHealthDot status={env.status} />
<span className="truncate system-sm-semibold text-text-primary">{env.displayName}</span>
<ModeBadge mode={env.mode} />
</div>
<span className="line-clamp-1 system-xs-regular text-text-tertiary">{summary}</span>
</div>
</div>
)
}
@@ -1,17 +0,0 @@
'use client'
import type { EnvironmentMode } from '@dify/contracts/enterprise/types.gen'
import { EnvironmentMode as EnvironmentModeEnum } from '@dify/contracts/enterprise/types.gen'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
const baseBadge =
'inline-flex items-center gap-1 rounded-md border px-2 py-0.5 system-xs-medium whitespace-nowrap'
export function ModeBadge({ mode, className }: { mode: EnvironmentMode; className?: string }) {
const { t } = useTranslation('deployments')
const style =
mode === EnvironmentModeEnum.ENVIRONMENT_MODE_SHARED
? 'border-util-colors-green-green-200 bg-util-colors-green-green-50 text-util-colors-green-green-700'
: 'border-util-colors-blue-blue-200 bg-util-colors-blue-blue-50 text-util-colors-blue-blue-700'
return <span className={cn(baseBadge, style, className)}>{t(($) => $[`mode.${mode}`])}</span>
}
@@ -1,11 +0,0 @@
# Deployment Actions
Action menu and edit/delete dialogs for deployment app instances.
## Internal Modules
None.
## External Modules
None.
@@ -1,117 +0,0 @@
import type { DeploymentActionAppInstance } from '../types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ScopeProvider } from 'jotai-scope'
import { DeleteDeploymentDialog } from '../delete-dialog'
import { deleteDeploymentDialogOpenAtom, deploymentActionAppInstanceAtom } from '../state'
const deleteMutationMock = vi.hoisted(() => ({
isPending: false,
mutate: vi.fn(),
}))
const useMutationMock = vi.hoisted(() =>
vi.fn(() => ({
isPending: deleteMutationMock.isPending,
mutate: deleteMutationMock.mutate,
})),
)
const routerMock = vi.hoisted(() => ({
push: vi.fn(),
}))
const toastMock = vi.hoisted(() => ({
error: vi.fn(),
success: vi.fn(),
}))
vi.mock('@tanstack/react-query', () => ({
useMutation: useMutationMock,
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: toastMock,
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => routerMock,
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
enterprise: {
appInstanceService: {
deleteAppInstance: {
mutationOptions: () => ({ mutationKey: ['deleteAppInstance'] }),
},
},
},
},
}))
function createAppInstance(
overrides: Partial<DeploymentActionAppInstance> = {},
): DeploymentActionAppInstance {
return {
id: 'app-instance-1',
displayName: 'Deployment 1',
description: 'Initial description',
...overrides,
}
}
function renderDialog({
appInstance = createAppInstance(),
open = true,
}: {
appInstance?: DeploymentActionAppInstance
open?: boolean
} = {}) {
render(
<ScopeProvider
atoms={[
[deploymentActionAppInstanceAtom, appInstance],
[deleteDeploymentDialogOpenAtom, open],
]}
name="DeleteDeploymentDialogTest"
>
<DeleteDeploymentDialog />
</ScopeProvider>,
)
}
describe('DeleteDeploymentDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
deleteMutationMock.isPending = false
})
describe('Delete action', () => {
it('should not mount the delete mutation before the dialog is opened', () => {
renderDialog({ open: false })
expect(useMutationMock).not.toHaveBeenCalled()
})
it('should delete the deployment through the component mutation', async () => {
const user = userEvent.setup()
renderDialog()
await user.click(screen.getByRole('button', { name: 'deployments.settings.delete' }))
expect(deleteMutationMock.mutate).toHaveBeenCalledWith(
{
params: {
appInstanceId: 'app-instance-1',
},
},
expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
onSettled: expect.any(Function),
}),
)
})
})
})
@@ -1,138 +0,0 @@
import type { DeploymentActionAppInstance } from '../types'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ScopeProvider } from 'jotai-scope'
import { EditDeploymentDialog } from '../edit-dialog'
import { deploymentActionAppInstanceAtom, editDeploymentDialogOpenAtom } from '../state'
const updateMutationMock = vi.hoisted(() => ({
isPending: false,
mutate: vi.fn(),
}))
const useMutationMock = vi.hoisted(() =>
vi.fn(() => ({
isPending: updateMutationMock.isPending,
mutate: updateMutationMock.mutate,
})),
)
const toastMock = vi.hoisted(() => ({
error: vi.fn(),
success: vi.fn(),
}))
vi.mock('@tanstack/react-query', () => ({
useMutation: useMutationMock,
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: toastMock,
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
enterprise: {
appInstanceService: {
updateAppInstance: {
mutationOptions: () => ({ mutationKey: ['updateAppInstance'] }),
},
},
},
},
}))
function createAppInstance(
overrides: Partial<DeploymentActionAppInstance> = {},
): DeploymentActionAppInstance {
return {
id: 'app-instance-1',
displayName: 'Deployment 1',
description: 'Initial description',
...overrides,
}
}
function renderDialog({
appInstance = createAppInstance(),
open = true,
}: {
appInstance?: DeploymentActionAppInstance
open?: boolean
} = {}) {
render(
<ScopeProvider
atoms={[
[deploymentActionAppInstanceAtom, appInstance],
[editDeploymentDialogOpenAtom, open],
]}
name="EditDeploymentDialogTest"
>
<EditDeploymentDialog />
</ScopeProvider>,
)
}
describe('EditDeploymentDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
updateMutationMock.isPending = false
})
describe('Form submission', () => {
it('should not mount the update mutation before the dialog is opened', () => {
renderDialog({ open: false })
expect(useMutationMock).not.toHaveBeenCalled()
})
it('should render form values from the passed app instance', () => {
renderDialog()
const dialog = screen.getByRole('dialog', { name: 'deployments.card.menu.editInfo' })
expect(
within(dialog).getByRole('textbox', { name: 'deployments.settings.name' }),
).toHaveValue('Deployment 1')
expect(
within(dialog).getByRole('textbox', { name: 'deployments.settings.description' }),
).toHaveValue('Initial description')
})
it('should submit trimmed deployment metadata through the component mutation', async () => {
const user = userEvent.setup()
renderDialog()
const dialog = screen.getByRole('dialog', { name: 'deployments.card.menu.editInfo' })
await user.clear(within(dialog).getByRole('textbox', { name: 'deployments.settings.name' }))
await user.type(
within(dialog).getByRole('textbox', { name: 'deployments.settings.name' }),
' Deployment 2 ',
)
await user.clear(
within(dialog).getByRole('textbox', { name: 'deployments.settings.description' }),
)
await user.type(
within(dialog).getByRole('textbox', { name: 'deployments.settings.description' }),
' Updated description ',
)
await user.click(within(dialog).getByRole('button', { name: 'deployments.settings.save' }))
expect(updateMutationMock.mutate).toHaveBeenCalledWith(
{
params: {
appInstanceId: 'app-instance-1',
},
body: {
appInstanceId: 'app-instance-1',
displayName: 'Deployment 2',
description: 'Updated description',
},
},
expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}),
)
})
})
})
@@ -1,84 +0,0 @@
'use client'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
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, deploymentActionAppInstanceAtom } from './state'
function DeleteDeploymentDialogContent() {
const { t } = useTranslation('deployments')
const router = useRouter()
const appInstance = useAtomValue(deploymentActionAppInstanceAtom)
const setOpen = useSetAtom(deleteDeploymentDialogOpenAtom)
const deleteInstance = useMutation(
consoleQuery.enterprise.appInstanceService.deleteAppInstance.mutationOptions(),
)
const displayName = appInstance.displayName || appInstance.id
function handleDelete() {
deleteInstance.mutate(
{
params: {
appInstanceId: appInstance.id,
},
},
{
onSuccess: () => {
toast.success(t(($) => $['settings.deleted']))
router.push('/deployments')
},
onError: () => {
toast.error(t(($) => $['settings.deleteFailed']))
},
onSettled: () => {
setOpen(false)
},
},
)
}
return (
<>
<div className="flex flex-col gap-3 px-6 pt-6 pb-2">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{t(($) => $['settings.deleteConfirmTitle'])}
</AlertDialogTitle>
<AlertDialogDescription className="system-sm-regular text-text-tertiary">
{t(($) => $['settings.deleteConfirmDesc'], { name: displayName })}
</AlertDialogDescription>
</div>
<AlertDialogActions className="pt-3">
<AlertDialogCancelButton variant="secondary" disabled={deleteInstance.isPending}>
{t(($) => $['createModal.cancel'])}
</AlertDialogCancelButton>
<AlertDialogConfirmButton loading={deleteInstance.isPending} onClick={handleDelete}>
{t(($) => $['settings.delete'])}
</AlertDialogConfirmButton>
</AlertDialogActions>
</>
)
}
export function DeleteDeploymentDialog() {
const [open, setOpen] = useAtom(deleteDeploymentDialogOpenAtom)
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent className="w-120">
<DeleteDeploymentDialogContent />
</AlertDialogContent>
</AlertDialog>
)
}
@@ -1,160 +0,0 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Field, FieldControl, FieldError, FieldLabel } from '@langgenius/dify-ui/field'
import { Form } from '@langgenius/dify-ui/form'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { deploymentActionAppInstanceAtom, editDeploymentDialogOpenAtom } from './state'
type EditDeploymentFormValues = {
name: string
description: string
}
function normalizedEditDeploymentFormValues(value: EditDeploymentFormValues) {
return {
name: value.name.trim(),
description: value.description.trim(),
}
}
function canSubmitEditDeploymentForm(
initialValues: EditDeploymentFormValues,
value: EditDeploymentFormValues,
) {
const normalizedValues = normalizedEditDeploymentFormValues(value)
return Boolean(
normalizedValues.name &&
(normalizedValues.name !== initialValues.name ||
normalizedValues.description !== initialValues.description),
)
}
function EditDeploymentForm() {
const { t } = useTranslation('deployments')
const nameLabel = t(($) => $['settings.name'])
const appInstance = useAtomValue(deploymentActionAppInstanceAtom)
const appInstanceId = appInstance.id
const initialValues = {
name: appInstance.displayName,
description: appInstance.description,
}
const setOpen = useSetAtom(editDeploymentDialogOpenAtom)
const updateInstance = useMutation(
consoleQuery.enterprise.appInstanceService.updateAppInstance.mutationOptions(),
)
function handleClose() {
if (updateInstance.isPending) return
setOpen(false)
}
function handleSubmit(values: EditDeploymentFormValues) {
if (!canSubmitEditDeploymentForm(initialValues, values)) return
const normalizedValues = normalizedEditDeploymentFormValues(values)
updateInstance.mutate(
{
params: {
appInstanceId,
},
body: {
appInstanceId,
displayName: normalizedValues.name,
description: normalizedValues.description,
},
},
{
onSuccess: () => {
toast.success(t(($) => $['settings.updated']))
setOpen(false)
},
onError: () => {
toast.error(t(($) => $['settings.updateFailed']))
},
},
)
}
return (
<>
<DialogCloseButton disabled={updateInstance.isPending} />
<Form<EditDeploymentFormValues> className="flex flex-col gap-4" onFormSubmit={handleSubmit}>
<Field name="name" className="gap-2">
<FieldLabel className="system-xs-medium-uppercase text-text-tertiary">
{nameLabel}
</FieldLabel>
<FieldControl type="text" required defaultValue={initialValues.name} className="h-8" />
<FieldError match="valueMissing">
{t(($) => $['errorMsg.fieldRequired'], { ns: 'common', field: nameLabel })}
</FieldError>
</Field>
<Field name="description" className="gap-2">
<FieldLabel className="system-xs-medium-uppercase text-text-tertiary">
{t(($) => $['settings.description'])}
</FieldLabel>
<Textarea defaultValue={initialValues.description} className="min-h-24" />
</Field>
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="secondary"
disabled={updateInstance.isPending}
onClick={handleClose}
>
{t(($) => $['createModal.cancel'])}
</Button>
<Button
type="submit"
variant="primary"
disabled={updateInstance.isPending}
loading={updateInstance.isPending}
>
{t(($) => $['settings.save'])}
</Button>
</div>
</Form>
</>
)
}
function EditDeploymentDialogContent() {
const { t } = useTranslation('deployments')
const appInstance = useAtomValue(deploymentActionAppInstanceAtom)
return (
<>
<div className="border-b border-divider-subtle px-6 py-5">
<DialogTitle className="title-xl-semi-bold text-text-primary">
{t(($) => $['card.menu.editInfo'])}
</DialogTitle>
</div>
<div className="px-6 py-5">
<EditDeploymentForm
key={`${appInstance.id}-${appInstance.displayName}-${appInstance.description}`}
/>
</div>
</>
)
}
export function EditDeploymentDialog() {
const [open, setOpen] = useAtom(editDeploymentDialogOpenAtom)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="w-120 max-w-[calc(100vw-32px)] p-0">
<EditDeploymentDialogContent />
</DialogContent>
</Dialog>
)
}
@@ -1,100 +0,0 @@
'use client'
import type { DropdownMenuContentProps } from '@langgenius/dify-ui/dropdown-menu'
import type { DeploymentActionAppInstance } from './types'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useSetAtom } from 'jotai'
import { ScopeProvider } from 'jotai-scope'
import { useTranslation } from 'react-i18next'
import { DeleteDeploymentDialog } from './delete-dialog'
import { EditDeploymentDialog } from './edit-dialog'
import {
deploymentActionAppInstanceAtom,
deploymentActionsLocalAtoms,
openDeleteDeploymentDialogAtom,
openEditDeploymentDialogAtom,
} 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',
'hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid',
'data-popup-open:bg-state-base-hover data-popup-open:text-text-secondary',
)
type DeploymentActionsMenuProps = {
appInstance: DeploymentActionAppInstance
className?: string
triggerClassName?: string
placement: DropdownMenuContentProps['placement']
sideOffset?: DropdownMenuContentProps['sideOffset']
}
function DeploymentActionsMenuContent({
className,
triggerClassName,
placement,
sideOffset,
}: Omit<DeploymentActionsMenuProps, 'appInstance'>) {
const { t } = useTranslation('deployments')
const openEditDialog = useSetAtom(openEditDeploymentDialogAtom)
const openDeleteDialog = useSetAtom(openDeleteDeploymentDialogAtom)
return (
<div
role="presentation"
className={cn(
className,
'has-data-popup-open:pointer-events-auto has-data-popup-open:opacity-100',
)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
aria-label={t(($) => $['card.moreActions'])}
className={cn(ACTION_TRIGGER_CLASS_NAME, triggerClassName)}
>
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
<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 />
<DeleteDeploymentDialog />
</div>
)
}
export function DeploymentActionsMenu({ appInstance, ...props }: DeploymentActionsMenuProps) {
return (
<ScopeProvider
key={appInstance.id}
atoms={[[deploymentActionAppInstanceAtom, appInstance], ...deploymentActionsLocalAtoms]}
name="DeploymentActionsMenu"
>
<DeploymentActionsMenuContent {...props} />
</ScopeProvider>
)
}
@@ -1,27 +0,0 @@
'use client'
import type { DeploymentActionAppInstance } from './types'
import { atom } from 'jotai'
import { atomWithLazy } from 'jotai/utils'
export const deploymentActionAppInstanceAtom = atomWithLazy<DeploymentActionAppInstance>(() => {
throw new Error('Missing deployment action app instance.')
})
export const editDeploymentDialogOpenAtom = atom(false)
export const deleteDeploymentDialogOpenAtom = atom(false)
export const openEditDeploymentDialogAtom = atom(null, (_get, set) => {
set(deleteDeploymentDialogOpenAtom, false)
set(editDeploymentDialogOpenAtom, true)
})
export const openDeleteDeploymentDialogAtom = atom(null, (_get, set) => {
set(editDeploymentDialogOpenAtom, false)
set(deleteDeploymentDialogOpenAtom, true)
})
export const deploymentActionsLocalAtoms = [
editDeploymentDialogOpenAtom,
deleteDeploymentDialogOpenAtom,
] as const
@@ -1,3 +0,0 @@
import type { AppInstance } from '@dify/contracts/enterprise/types.gen'
export type DeploymentActionAppInstance = Pick<AppInstance, 'id' | 'displayName' | 'description'>
-20
View File
@@ -1,20 +0,0 @@
# Deployment Detail Shell
Detail shell for the deployment app instance routes, including sidebar navigation, route headers, and route-level action slots.
## Internal Modules
| Module | Why this module uses it |
| --------------------------------- | ------------------------------------------------------------------------------ |
| `create-release` | Opens release creation from the detail header. |
| `deployment-actions` | Reuses app instance action menu behavior in the detail sidebar. |
| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. |
| `route-state` | Reads route identity used by detail query and tab atoms. |
| `detail/api-tokens` | Renders the developer API header switch for the API tokens route. |
| `detail/instances/header-actions` | Renders the new deployment header action for the instances route. |
## External Modules
| Module | Why this module uses it |
| ------------------------------------ | ----------------------------------------------------------------- |
| `app/components/goto-anything/atoms` | Opens the global search command from the detail sidebar shortcut. |
@@ -1,84 +0,0 @@
import type { Mock } from 'vitest'
import { render, screen } from '@testing-library/react'
import { Provider as JotaiProvider } from 'jotai'
import { NextRouteStateBridge } from '@/app/components/next-route-state'
import { useParams, usePathname } from '@/next/navigation'
import { InstanceDetail } from '..'
import { DeploymentDetailTop } from '../deployment-sidebar'
vi.mock('@/next/navigation', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/next/navigation')>()
return {
...actual,
useParams: vi.fn(),
usePathname: vi.fn(),
}
})
vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
vi.mock('../api-tokens/developer-api-header-switch', () => ({
DeveloperApiHeaderSwitch: () => null,
}))
vi.mock('../instances/header-actions/new-deployment-button', () => ({
NewDeploymentHeaderAction: () => null,
}))
vi.mock('../../create-release', () => ({
CreateReleaseControl: () => null,
}))
function renderInstanceDetail() {
;(usePathname as Mock).mockReturnValue('/deployments/app-instance-1/overview')
;(useParams as Mock).mockReturnValue({
appInstanceId: 'app-instance-1',
})
return render(
<JotaiProvider>
<NextRouteStateBridge>
<InstanceDetail>
<div>Deployment detail content</div>
</InstanceDetail>
</NextRouteStateBridge>
</JotaiProvider>,
)
}
describe('InstanceDetail', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render detail content without owning navigation landmarks', () => {
renderInstanceDetail()
expect(screen.getByText('Deployment detail content')).toBeInTheDocument()
expect(screen.queryByRole('main')).not.toBeInTheDocument()
expect(screen.queryByRole('complementary', { name: 'Detail sidebar' })).not.toBeInTheDocument()
})
})
describe('DeploymentDetailTop', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('links the combined home control to home', () => {
render(
<JotaiProvider>
<DeploymentDetailTop />
</JotaiProvider>,
)
expect(screen.getByRole('link', { name: 'common.mainNav.home' })).toHaveAttribute('href', '/')
expect(screen.getByRole('link', { name: 'common.menus.deployments' })).toHaveAttribute(
'href',
'/deployments',
)
expect(screen.queryByRole('button', { name: 'common.operation.back' })).not.toBeInTheDocument()
})
})
@@ -1,162 +0,0 @@
import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen'
import { RuntimeInstanceStatus } from '@dify/contracts/enterprise/types.gen'
import { skipToken } from '@tanstack/react-query'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { setNextRouteStateAtom } from '@/app/components/next-route-state/atoms'
import { createQueryAtomTestStore } from '@/test/query-atom'
type QueryOptions = {
enabled?: boolean
input?: unknown
queryKey?: readonly unknown[]
refetchInterval?: (query: { state: { data?: unknown } }) => number | false
}
const mockGetAppInstanceQueryOptions = vi.hoisted(() => vi.fn())
const mockListEnvironmentDeploymentsQueryOptions = vi.hoisted(() => vi.fn())
vi.mock('@/service/client', () => ({
consoleQuery: {
enterprise: {
appInstanceService: {
getAppInstance: {
queryOptions: (options: QueryOptions) => {
mockGetAppInstanceQueryOptions(options)
return {
...options,
queryKey: ['getAppInstance', options.input],
queryFn: async () => undefined,
}
},
},
},
deploymentService: {
listEnvironmentDeployments: {
queryOptions: (options: QueryOptions) => {
mockListEnvironmentDeploymentsQueryOptions(options)
return {
...options,
queryKey: ['listEnvironmentDeployments', options.input],
queryFn: async () => undefined,
}
},
},
},
},
},
}))
async function loadState() {
return await import('../state')
}
function setDeploymentRoute(
store: ReturnType<typeof createQueryAtomTestStore>['store'],
appInstanceId = 'app-instance-1',
tab = 'overview',
) {
store.set(setNextRouteStateAtom, {
pathname: `/deployments/${appInstanceId}/${tab}`,
params: { appInstanceId },
})
}
function deploymentRow(id: string, overrides: Partial<EnvironmentDeployment> = {}) {
return {
environment: {
id,
displayName: id,
},
status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_READY,
currentRelease: {
id: `release-${id}`,
},
desiredRelease: undefined,
currentDeployment: {
id: `deployment-${id}`,
},
...overrides,
} as EnvironmentDeployment
}
describe('deployment detail state', () => {
beforeEach(() => {
mockGetAppInstanceQueryOptions.mockClear()
mockListEnvironmentDeploymentsQueryOptions.mockClear()
})
it('should disable detail queries with skipToken until a route app instance exists', async () => {
const state = await loadState()
const { store } = createQueryAtomTestStore()
store.get(state.deploymentDetailAppInstanceQueryAtom)
expect(mockGetAppInstanceQueryOptions).toHaveBeenLastCalledWith({
enabled: false,
input: skipToken,
})
store.get(state.deploymentEnvironmentDeploymentsQueryAtom)
expect(mockListEnvironmentDeploymentsQueryOptions).toHaveBeenLastCalledWith(
expect.objectContaining({
enabled: false,
input: skipToken,
}),
)
})
it('should build detail query inputs from route identity', async () => {
const state = await loadState()
const { store } = createQueryAtomTestStore()
setDeploymentRoute(store)
store.get(state.deploymentDetailAppInstanceQueryAtom)
expect(mockGetAppInstanceQueryOptions).toHaveBeenLastCalledWith({
enabled: true,
input: { params: { appInstanceId: 'app-instance-1' } },
})
store.get(state.deploymentEnvironmentDeploymentsQueryAtom)
expect(mockListEnvironmentDeploymentsQueryOptions).toHaveBeenLastCalledWith(
expect.objectContaining({
enabled: true,
input: { params: { appInstanceId: 'app-instance-1' } },
refetchInterval: expect.any(Function),
}),
)
})
it('should derive active detail tab from route pathname', async () => {
const state = await loadState()
const { store } = createQueryAtomTestStore()
setDeploymentRoute(store, 'app-instance-1', 'releases')
expect(store.get(state.deploymentDetailActiveTabAtom)).toBe('releases')
setDeploymentRoute(store, 'app-instance-1', 'unknown')
expect(store.get(state.deploymentDetailActiveTabAtom)).toBe('overview')
})
it('should derive runtime instance rows from environment deployments', async () => {
const state = await loadState()
const { queryClient, store } = createQueryAtomTestStore()
setDeploymentRoute(store)
queryClient.setQueryData(
['listEnvironmentDeployments', { params: { appInstanceId: 'app-instance-1' } }],
{
environmentDeployments: [
deploymentRow('running'),
deploymentRow('undeployed', {
status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_UNSPECIFIED,
currentRelease: undefined,
desiredRelease: undefined,
currentDeployment: undefined,
}),
],
},
)
expect(
store.get(state.deploymentRuntimeInstanceRowsAtom).map((row) => row.environment.id),
).toEqual(['running'])
})
})
@@ -1,15 +0,0 @@
# Deployment Access
Access route composition for webapp channels and environment permission sections.
## Internal Modules
| Module | Why this module uses it |
| --------------------------- | ------------------------------------------------------------------ |
| `route-state` | Reads the route app instance identity for access settings queries. |
| `detail/access/channels` | Renders access channel endpoint settings. |
| `detail/access/permissions` | Renders environment access policy settings. |
## External Modules
None.
@@ -1,70 +0,0 @@
import { skipToken } from '@tanstack/react-query'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { setNextRouteStateAtom } from '@/app/components/next-route-state/atoms'
import { createQueryAtomTestStore } from '@/test/query-atom'
type QueryOptions = {
enabled?: boolean
input?: unknown
queryKey?: readonly unknown[]
}
const mockAccessSettingsQueryOptions = vi.hoisted(() => vi.fn())
vi.mock('@/service/client', () => ({
consoleQuery: {
enterprise: {
accessService: {
getAccessSettings: {
queryOptions: (options: QueryOptions) => {
mockAccessSettingsQueryOptions(options)
return {
...options,
queryKey: ['getAccessSettings', options.input],
queryFn: async () => undefined,
}
},
},
},
},
},
}))
async function loadState() {
return await import('../state')
}
function setDeploymentRoute(
store: ReturnType<typeof createQueryAtomTestStore>['store'],
appInstanceId = 'app-instance-1',
) {
store.set(setNextRouteStateAtom, {
pathname: `/deployments/${appInstanceId}/access`,
params: { appInstanceId },
})
}
describe('deployment access state', () => {
beforeEach(() => {
mockAccessSettingsQueryOptions.mockClear()
})
it('should gate access queries until a route app instance exists', async () => {
const state = await loadState()
const { store } = createQueryAtomTestStore()
store.get(state.accessSettingsQueryAtom)
expect(mockAccessSettingsQueryOptions).toHaveBeenLastCalledWith({
enabled: false,
input: skipToken,
})
setDeploymentRoute(store)
store.get(state.accessSettingsQueryAtom)
expect(mockAccessSettingsQueryOptions).toHaveBeenLastCalledWith({
enabled: true,
input: { params: { appInstanceId: 'app-instance-1' } },
})
})
})
@@ -1,17 +0,0 @@
# Deployment Access Channels
Access channels section for viewing webapp and developer API endpoints and toggling webapp access.
## Internal Modules
| Module | Why this module uses it |
| --------------- | ------------------------------------------------------------------------------ |
| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. |
| `route-state` | Reads the route app instance identity for access-channel mutations. |
| `detail/access` | Reads access route query data for channel settings. |
## External Modules
| Module | Why this module uses it |
| ------------------------------ | --------------------------------------------------------------- |
| `app/components/base/skeleton` | Reuses skeleton primitives for channel endpoint loading states. |
@@ -1,250 +0,0 @@
'use client'
import type { AccessChannels, AccessEndpoint } from '@dify/contracts/enterprise/types.gen'
import type { ReactNode } from 'react'
import { Switch, SwitchSkeleton } from '@langgenius/dify-ui/switch'
import { useMutation } 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 { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
import {
DeploymentEmptyState,
DeploymentNoticeState,
DeploymentStateMessage,
} from '../../../shared/components/empty-state'
import { CopyPill, EndpointRow } from '../../../shared/components/endpoint'
import { Section } from '../../../shared/components/section'
import {
accessSettingsAtom,
accessSettingsIsErrorAtom,
accessSettingsIsLoadingAtom,
} from '../state'
import { getUrlOrigin } from './url'
const ACCESS_CHANNEL_SKELETON_SECTIONS = [{ key: 'webapp' }, { key: 'cli' }]
function AccessChannelsSwitch({
checked,
accessChannels,
disabled,
}: {
checked: boolean
accessChannels?: AccessChannels
disabled?: boolean
}) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const toggleAccessChannel = useMutation(
consoleQuery.enterprise.accessService.updateAccessChannels.mutationOptions(),
)
return (
<Switch
aria-label={t(($) => $['access.channels.title'])}
checked={checked}
disabled={disabled || !appInstanceId}
loading={toggleAccessChannel.isPending}
onCheckedChange={(enabled) => {
if (!appInstanceId) return
toggleAccessChannel.mutate({
params: { appInstanceId },
body: {
appInstanceId,
webAppEnabled: enabled,
developerApiEnabled: accessChannels?.developerApiEnabled ?? false,
},
})
}}
/>
)
}
function AccessChannelsSkeleton() {
return (
<div className="overflow-hidden rounded-lg border border-divider-subtle bg-components-panel-bg">
{ACCESS_CHANNEL_SKELETON_SECTIONS.map((section) => (
<SkeletonRow
key={section.key}
className="flex flex-col gap-3 border-t border-divider-subtle px-4 py-4 first:border-t-0 lg:flex-row lg:items-start"
>
<div className="flex min-w-0 gap-2.5 lg:w-70">
<SkeletonRectangle className="my-0 size-7 shrink-0 animate-pulse rounded-lg" />
<div className="flex min-w-0 flex-col gap-1.5">
<SkeletonRectangle className="h-3.5 w-24 animate-pulse" />
<SkeletonRectangle className="h-3 w-40 animate-pulse" />
</div>
</div>
<SkeletonRectangle className="my-0 h-8 min-w-0 flex-1 animate-pulse rounded-lg" />
</SkeletonRow>
))}
</div>
)
}
function ChannelInfo({
icon,
title,
description,
}: {
icon: ReactNode
title: string
description?: string
}) {
return (
<div className="flex min-w-0 items-start gap-2.5">
<span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg bg-background-section-burn text-text-tertiary">
{icon}
</span>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="system-sm-medium text-text-primary">{title}</span>
</div>
{description && <div className="system-xs-regular text-text-tertiary">{description}</div>}
</div>
</div>
)
}
function ChannelRow({ info, children }: { info: ReactNode; children: ReactNode }) {
return (
<div className="flex flex-col gap-3 border-t border-divider-subtle px-4 py-4 first:border-t-0 lg:flex-row lg:items-start">
<div className="min-w-0 lg:w-70">{info}</div>
<div className="min-w-0 flex-1">{children}</div>
</div>
)
}
export function AccessChannelsSection() {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const accessSettings = useAtomValue(accessSettingsAtom)
const isLoading = useAtomValue(accessSettingsIsLoadingAtom)
const isError = useAtomValue(accessSettingsIsErrorAtom)
const accessChannels = accessSettings?.accessChannels
const webAppEndpoints: AccessEndpoint[] | undefined = accessSettings?.webAppEndpoints
const cliEndpoint: AccessEndpoint | undefined = accessSettings?.cliEndpoint
const runEnabled = accessChannels?.webAppEnabled ?? false
const webappRows =
webAppEndpoints?.flatMap((endpoint) => {
const endpointUrl = endpoint.endpointUrl
if (!endpointUrl) return []
return [
{
endpoint,
endpointUrl,
},
]
}) ?? []
const cliDomain = getUrlOrigin(cliEndpoint?.endpointUrl)
const cliDocsUrl = cliDomain ? `${cliDomain}/cli` : undefined
return (
<Section
title={t(($) => $['access.channels.title'])}
action={
isLoading ? (
<SwitchSkeleton />
) : (
<div className="flex items-center gap-2">
<span className="system-xs-medium text-text-tertiary">
{runEnabled ? t(($) => $['overview.enabled']) : t(($) => $['overview.disabled'])}
</span>
<AccessChannelsSwitch
checked={runEnabled}
accessChannels={accessChannels}
disabled={isError}
/>
</div>
)
}
>
{isLoading ? (
<AccessChannelsSkeleton />
) : isError || !appInstanceId ? (
<DeploymentStateMessage variant="section">
{t(($) => $['common.loadFailed'])}
</DeploymentStateMessage>
) : runEnabled ? (
<div className="overflow-hidden rounded-lg border border-divider-subtle bg-components-panel-bg">
<ChannelRow
info={
<ChannelInfo
icon={<span className="i-ri-global-line size-3.5" aria-hidden="true" />}
title={t(($) => $['access.runAccess.webapp'])}
description={t(($) => $['access.runAccess.webappDesc'])}
/>
}
>
{webappRows.length > 0 ? (
<div className="flex flex-col gap-1.5">
{webappRows.map(({ endpoint, endpointUrl }) => (
<EndpointRow
key={`webapp-${endpoint.environment?.id ?? endpointUrl}`}
envName={endpoint.environment?.displayName ?? '—'}
label={t(($) => $['access.runAccess.urlLabel'])}
value={endpointUrl}
openLabel={t(($) => $['access.runAccess.openWebapp'])}
/>
))}
</div>
) : (
<DeploymentNoticeState>
{t(($) => $['access.runAccess.webappEmpty'])}
</DeploymentNoticeState>
)}
</ChannelRow>
<ChannelRow
info={
<ChannelInfo
icon={<span className="i-ri-terminal-box-line size-3.5" aria-hidden="true" />}
title={t(($) => $['access.cli.title'])}
description={t(($) => $['access.cli.description'])}
/>
}
>
{cliDomain ? (
<div className="flex flex-wrap items-center gap-2">
<CopyPill
label={t(($) => $['access.cli.domain'])}
value={cliDomain}
className="min-w-0 flex-1"
/>
<a
href={cliDocsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
>
<span className="i-ri-download-cloud-2-line size-3.5" />
{t(($) => $['access.cli.install'])}
</a>
<a
href={cliDocsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
>
<span className="i-ri-book-open-line size-3.5" />
{t(($) => $['access.cli.docs'])}
</a>
</div>
) : (
<DeploymentNoticeState>{t(($) => $['access.cli.empty'])}</DeploymentNoticeState>
)}
</ChannelRow>
</div>
) : (
<DeploymentEmptyState
variant="section"
icon="i-ri-toggle-line"
title={t(($) => $['access.channels.disabled'])}
description={t(($) => $['access.channels.disabledHint'])}
/>
)}
</Section>
)
}
@@ -1,8 +0,0 @@
export function getUrlOrigin(url?: string) {
if (!url) return undefined
try {
return new URL(url).origin
} catch {
return url
}
}
@@ -1,13 +0,0 @@
'use client'
import { AccessChannelsSection } from './channels/section'
import { AccessPermissionsSection } from './permissions/section'
export function DeploymentAccess() {
return (
<div className="flex w-full max-w-240 min-w-0 flex-col gap-y-4 px-6 py-6 sm:px-20 sm:py-8">
<AccessChannelsSection />
<AccessPermissionsSection />
</div>
)
}
@@ -1,18 +0,0 @@
# Deployment Access Permissions
Environment permission section for viewing and editing access policies on deployment environments.
## Internal Modules
| Module | Why this module uses it |
| --------------------------------------------------- | ------------------------------------------------------------------------------ |
| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. |
| `route-state` | Reads the route app instance identity for access-policy mutations. |
| `detail/access` | Reads access route query data for environment access policies. |
| `detail/access/permissions/access-subject-selector` | Selects specific members and groups for access policies. |
## External Modules
| Module | Why this module uses it |
| ------------------------------ | --------------------------------------------------------- |
| `app/components/base/skeleton` | Reuses skeleton primitives for permission loading states. |
@@ -1,128 +0,0 @@
import type { ReactElement } from 'react'
import { AccessSubjectType } from '@dify/contracts/enterprise/types.gen'
import { fireEvent, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { DeploymentAccessControlDialog } from '../access-control-dialog'
const mockUseSearchAccessSubjects = vi.hoisted(() => vi.fn())
vi.mock('@/service/access-control/use-access-subjects', () => ({
useSearchAccessSubjects: (...args: unknown[]) => mockUseSearchAccessSubjects(...args),
}))
const render = (ui: ReactElement, allowPublicAccess = true) =>
renderWithConsoleQuery(ui, {
systemFeatures: { webapp_auth: { allow_public_access: allowPublicAccess } },
})
describe('DeploymentAccessControlDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseSearchAccessSubjects.mockReturnValue({
data: { pages: [] },
fetchNextPage: vi.fn(),
isFetchingNextPage: false,
isLoading: false,
})
})
it('should submit the selected deployment access subjects', () => {
const handleSubmit = vi.fn()
render(
<DeploymentAccessControlDialog
open
initialKind="specific"
initialSubjects={[
{
id: 'group-1',
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP,
name: 'Group One',
memberCount: 2,
},
{
id: 'member-1',
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT,
name: 'Member One',
},
]}
onClose={vi.fn()}
onSubmit={handleSubmit}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
expect(handleSubmit).toHaveBeenCalledWith('specific', [
{
id: 'group-1',
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP,
name: 'Group One',
memberCount: 2,
},
{
id: 'member-1',
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT,
name: 'Member One',
},
])
})
describe('public access control', () => {
it('should allow selecting the public option when public access is allowed', () => {
const handleSubmit = vi.fn()
render(
<DeploymentAccessControlDialog
open
initialKind="organization"
initialSubjects={[]}
onClose={vi.fn()}
onSubmit={handleSubmit}
/>,
true,
)
const publicOption = screen.getByRole('radio', {
name: 'app.accessControlDialog.accessItems.anyone',
})
expect(publicOption).not.toHaveAttribute('data-disabled')
fireEvent.click(publicOption)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
expect(handleSubmit).toHaveBeenCalledWith('anyone', [])
})
it('should disable the public option and show a tooltip when public access is disabled', () => {
const handleSubmit = vi.fn()
render(
<DeploymentAccessControlDialog
open
initialKind="organization"
initialSubjects={[]}
onClose={vi.fn()}
onSubmit={handleSubmit}
/>,
false,
)
const publicOption = screen.getByRole('radio', {
name: 'app.accessControlDialog.accessItems.anyone',
})
expect(publicOption).toHaveAttribute('data-disabled')
expect(
screen.getByRole('button', {
name: 'app.accessControlDialog.webAppPublicAccessDisabledTip',
}),
).toBeInTheDocument()
fireEvent.click(publicOption)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
expect(handleSubmit).toHaveBeenCalledWith('organization', [])
})
})
})
@@ -1,208 +0,0 @@
import type { AccessPolicy, Subject } from '@dify/contracts/enterprise/types.gen'
import { AccessMode, AccessSubjectType } from '@dify/contracts/enterprise/types.gen'
import { describe, expect, it } from 'vitest'
import { AccessMode as AppAccessMode, SubjectType } from '@/models/access-control'
import {
accessControlSelectionFromSubjects,
accessModeToPermissionKey,
appAccessModeToPermissionKey,
normalizeResolvedSubject,
permissionKeyToAccessMode,
permissionKeyToAppAccessMode,
policySubjects,
selectedSubjectsFromPolicy,
subjectsFromAccessControlSelection,
} from '../access-policy'
function policy(overrides: Partial<AccessPolicy>): AccessPolicy {
return {
id: 'policy-1',
appInstanceId: 'app-instance-1',
environmentId: 'environment-1',
mode: AccessMode.ACCESS_MODE_PRIVATE_ALL,
subjects: [],
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...overrides,
}
}
describe('access policy mode mapping', () => {
it('should map API access modes to deployment permission keys', () => {
expect(accessModeToPermissionKey(AccessMode.ACCESS_MODE_PRIVATE)).toBe('specific')
expect(accessModeToPermissionKey(AccessMode.ACCESS_MODE_PUBLIC)).toBe('anyone')
expect(accessModeToPermissionKey(AccessMode.ACCESS_MODE_PRIVATE_ALL)).toBe('organization')
expect(accessModeToPermissionKey()).toBe('organization')
})
it('should map permission keys to API and app access modes', () => {
expect(permissionKeyToAccessMode('organization')).toBe(AccessMode.ACCESS_MODE_PRIVATE_ALL)
expect(permissionKeyToAccessMode('specific')).toBe(AccessMode.ACCESS_MODE_PRIVATE)
expect(permissionKeyToAccessMode('anyone')).toBe(AccessMode.ACCESS_MODE_PUBLIC)
expect(permissionKeyToAppAccessMode('organization')).toBe(AppAccessMode.ORGANIZATION)
expect(permissionKeyToAppAccessMode('specific')).toBe(AppAccessMode.SPECIFIC_GROUPS_MEMBERS)
expect(permissionKeyToAppAccessMode('anyone')).toBe(AppAccessMode.PUBLIC)
expect(appAccessModeToPermissionKey(AppAccessMode.SPECIFIC_GROUPS_MEMBERS)).toBe('specific')
expect(appAccessModeToPermissionKey(AppAccessMode.PUBLIC)).toBe('anyone')
expect(appAccessModeToPermissionKey(AppAccessMode.ORGANIZATION)).toBe('organization')
})
})
describe('access policy subject conversion', () => {
it('should normalize resolved group and account subjects', () => {
expect(
normalizeResolvedSubject({
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP,
groupData: {
id: 'group-1',
name: 'Admins',
groupSize: 3,
},
}),
).toEqual({
id: 'group-1',
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP,
name: 'Admins',
memberCount: 3,
})
expect(
normalizeResolvedSubject({
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT,
accountData: {
id: 'account-1',
email: 'member@example.com',
},
}),
).toEqual({
id: 'account-1',
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT,
name: 'member@example.com',
})
})
it('should normalize resolved subjects that use app access-control subject types', () => {
expect(
normalizeResolvedSubject({
subjectId: 'group-1',
subjectType: SubjectType.GROUP,
groupData: {
id: 'group-1',
name: 'Admins',
groupSize: 3,
},
}),
).toEqual({
id: 'group-1',
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP,
name: 'Admins',
memberCount: 3,
})
expect(
normalizeResolvedSubject({
subjectId: 'account-1',
subjectType: SubjectType.ACCOUNT,
accountData: {
id: 'account-1',
name: 'Member',
email: 'member@example.com',
},
}),
).toEqual({
id: 'account-1',
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT,
name: 'Member',
})
})
it('should ignore unsupported subjects and subjects without ids', () => {
expect(
normalizeResolvedSubject({ subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP }),
).toBeUndefined()
expect(
normalizeResolvedSubject({ subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT }),
).toBeUndefined()
expect(
normalizeResolvedSubject({
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_UNSPECIFIED,
} as Subject),
).toBeUndefined()
})
it('should preserve labels when reading selected subjects from policy', () => {
expect(
selectedSubjectsFromPolicy(
policy({
subjects: [
{ subjectId: 'group-1', subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP },
{ subjectId: 'account-1', subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT },
],
}),
[
{
id: 'group-1',
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP,
name: 'Admins',
memberCount: 3,
},
],
),
).toEqual([
{
id: 'group-1',
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP,
name: 'Admins',
memberCount: 3,
},
{
id: 'account-1',
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT,
},
])
expect(selectedSubjectsFromPolicy()).toEqual([])
})
it('should convert between deployment subjects and access-control selections', () => {
const subjects = [
{
id: 'group-1',
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP,
name: 'Admins',
memberCount: 3,
},
{
id: 'account-1',
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT,
name: 'Member',
},
]
expect(policySubjects(subjects)).toEqual([
{ subjectId: 'group-1', subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP },
{ subjectId: 'account-1', subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT },
])
const selection = accessControlSelectionFromSubjects(subjects)
expect(selection.groups).toEqual([
{
id: 'group-1',
name: 'Admins',
groupSize: 3,
},
])
expect(selection.members).toEqual([
{
id: 'account-1',
name: 'Member',
email: 'Member',
avatar: '',
avatarUrl: '',
},
])
expect(subjectsFromAccessControlSelection(selection)).toEqual(subjects)
})
})
@@ -1,300 +0,0 @@
import type {
AccessPolicy,
Environment,
EnvironmentAccessPolicy,
} from '@dify/contracts/enterprise/types.gen'
import type { Atom, WritableAtom } from 'jotai'
import type { ReactNode } from 'react'
import { AccessMode, AccessSubjectType } 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 { setNextRouteStateAtom } from '@/app/components/next-route-state/atoms'
import {
accessSettingsAtom,
accessSettingsIsErrorAtom,
accessSettingsIsLoadingAtom,
} from '../../state'
import { EnvironmentPermissionRow } from '../environment-permission-row'
import { AccessPermissionsSection } from '../section'
const mockMutate = vi.hoisted(() => vi.fn())
vi.mock('../../state', async () => {
const { atom } = await import('jotai')
return {
accessSettingsAtom: atom<unknown>(undefined),
accessSettingsIsLoadingAtom: atom(false),
accessSettingsIsErrorAtom: atom(false),
}
})
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useInfiniteQuery: () => ({
data: { pages: [] },
fetchNextPage: vi.fn(),
isFetchingNextPage: false,
isLoading: false,
}),
useMutation: () => ({
isPending: false,
mutate: mockMutate,
}),
useSuspenseQuery: () => ({
data: {
webapp_auth: { allow_public_access: true },
},
}),
}
})
vi.mock('@/service/client', () => ({
consoleQuery: {
enterprise: {
accessService: {
updateAccessPolicy: {
mutationOptions: () => ({ mutationKey: ['updateAccessPolicy'] }),
},
},
},
systemFeatures: {
get: {
queryKey: () => ['console', 'systemFeatures'],
queryOptions: (options?: Record<string, unknown>) => ({
queryKey: ['console', 'systemFeatures'],
...options,
}),
},
},
},
}))
function setTestAtom<Value>(
store: ReturnType<typeof createStore>,
target: Atom<Value>,
value: Value,
) {
store.set(target as WritableAtom<Value, [Value], void>, value)
}
function renderWithAtomStore(
children: ReactNode,
accessSettings?: {
environmentPolicies: EnvironmentAccessPolicy[]
},
) {
const store = createStore()
store.set(setNextRouteStateAtom, {
pathname: '/deployments/app-instance-1/access',
params: { appInstanceId: 'app-instance-1' },
})
setTestAtom(store, accessSettingsAtom, accessSettings)
setTestAtom(store, accessSettingsIsLoadingAtom, false)
setTestAtom(store, accessSettingsIsErrorAtom, false)
return render(<JotaiProvider store={store}>{children}</JotaiProvider>)
}
function createEnvironment(overrides: Partial<Environment> = {}): Environment {
return {
id: 'environment-1',
displayName: 'Production',
...overrides,
} as Environment
}
function createAccessPolicy(): AccessPolicy {
return {
id: 'policy-1',
appInstanceId: 'app-instance-1',
environmentId: 'environment-1',
mode: AccessMode.ACCESS_MODE_PRIVATE_ALL,
subjects: [],
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
}
}
function createSpecificAccessPolicy(): AccessPolicy {
return {
...createAccessPolicy(),
mode: AccessMode.ACCESS_MODE_PRIVATE,
subjects: [
{
subjectId: 'group-1',
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP,
},
{
subjectId: 'member-1',
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT,
},
],
}
}
function createEnvironmentAccessPolicy(): EnvironmentAccessPolicy {
return {
environment: createEnvironment(),
policy: createAccessPolicy(),
resolvedSubjects: [],
}
}
describe('EnvironmentPermissionRow', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMutate.mockImplementation((_variables: unknown, options?: { onError?: () => void }) => {
options?.onError?.()
})
})
it('should keep the previous permission visible when updating the policy fails', () => {
renderWithAtomStore(
<EnvironmentPermissionRow
environment={createEnvironment()}
summaryPolicy={createAccessPolicy()}
/>,
)
fireEvent.click(
screen.getByRole('button', { name: /deployments\.access\.permissions\.editAriaLabel/ }),
)
fireEvent.click(
screen.getByRole('radio', { name: 'app.accessControlDialog.accessItems.anyone' }),
)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
expect(mockMutate).toHaveBeenCalled()
expect(screen.getByText('deployments.access.permission.organization')).toBeInTheDocument()
})
it('should show the updated policy after success', () => {
mockMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.()
})
renderWithAtomStore(
<EnvironmentPermissionRow
environment={createEnvironment()}
summaryPolicy={createAccessPolicy()}
/>,
)
fireEvent.click(
screen.getByRole('button', { name: /deployments\.access\.permissions\.editAriaLabel/ }),
)
fireEvent.click(
screen.getByRole('radio', { name: 'app.accessControlDialog.accessItems.anyone' }),
)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
expect(mockMutate).toHaveBeenCalledWith(
{
params: {
appInstanceId: 'app-instance-1',
environmentId: 'environment-1',
},
body: {
appInstanceId: 'app-instance-1',
environmentId: 'environment-1',
mode: AccessMode.ACCESS_MODE_PUBLIC,
subjects: [],
},
},
expect.objectContaining({
onError: expect.any(Function),
onSuccess: expect.any(Function),
}),
)
expect(screen.getByText('deployments.access.permission.anyone')).toBeInTheDocument()
})
it('should submit specific subjects with the deployment access subject type', () => {
mockMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.()
})
renderWithAtomStore(
<EnvironmentPermissionRow
environment={createEnvironment()}
summaryPolicy={createSpecificAccessPolicy()}
/>,
)
fireEvent.click(
screen.getByRole('button', { name: /deployments\.access\.permissions\.editAriaLabel/ }),
)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
expect(mockMutate).toHaveBeenCalledWith(
{
params: {
appInstanceId: 'app-instance-1',
environmentId: 'environment-1',
},
body: {
appInstanceId: 'app-instance-1',
environmentId: 'environment-1',
mode: AccessMode.ACCESS_MODE_PRIVATE,
subjects: [
{
subjectId: 'group-1',
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP,
},
{
subjectId: 'member-1',
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT,
},
],
},
},
expect.objectContaining({
onError: expect.any(Function),
onSuccess: expect.any(Function),
}),
)
})
it('should show specific subject counts in the access summary', () => {
renderWithAtomStore(
<EnvironmentPermissionRow
environment={createEnvironment()}
summaryPolicy={createSpecificAccessPolicy()}
/>,
)
const editButton = screen.getByRole('button', {
name: /deployments\.access\.permissions\.editAriaLabel/,
})
expect(editButton).toHaveTextContent('deployments.access.permission.specific')
expect(editButton).toHaveTextContent('deployments.access.members.groupCount:{"count":1}')
expect(editButton).toHaveTextContent('deployments.access.members.memberCount:{"count":1}')
})
})
// Access permissions render as simple environment rows without a table header.
describe('AccessPermissionsSection', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render permission rows without column headers', () => {
renderWithAtomStore(<AccessPermissionsSection />, {
environmentPolicies: [createEnvironmentAccessPolicy()],
})
expect(screen.getByText('Production')).toBeInTheDocument()
expect(
screen.queryByText('deployments.access.permissions.col.environment'),
).not.toBeInTheDocument()
expect(
screen.queryByText('deployments.access.permissions.col.permission'),
).not.toBeInTheDocument()
})
})
@@ -1,260 +0,0 @@
'use client'
import type { PropsWithChildren } from 'react'
import type { AccessPermissionKind, SelectableAccessSubject } from './access-policy'
import type { AccessSubjectSelectionValue } from './access-subject-selector/types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogDescription,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { RadioGroup, RadioItem } from '@langgenius/dify-ui/radio'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { AccessMode as AppAccessMode } from '@/models/access-control'
import {
accessControlSelectionFromSubjects,
appAccessModeToPermissionKey,
permissionKeyToAppAccessMode,
subjectsFromAccessControlSelection,
} from './access-policy'
import { AccessSubjectAddButton } from './access-subject-selector/add-button'
import { AccessSubjectSelectionList } from './access-subject-selector/selection-list'
export function DeploymentAccessControlDialog({
open,
initialKind,
initialSubjects,
saving,
onClose,
onSubmit,
}: {
open: boolean
initialKind: AccessPermissionKind
initialSubjects: SelectableAccessSubject[]
saving?: boolean
onClose: () => void
onSubmit: (kind: AccessPermissionKind, subjects: SelectableAccessSubject[]) => void
}) {
const draftKey = [
initialKind,
initialSubjects.map((subject) => `${subject.subjectType}:${subject.id}`).join(','),
].join(':')
return (
<Dialog open={open} disablePointerDismissal onOpenChange={(open) => !open && onClose()}>
<DialogContent
className={cn(
'h-auto max-h-[calc(100dvh-2rem)] min-h-80.75 w-150 max-w-none overflow-y-auto rounded-2xl border-none bg-components-panel-bg p-0 shadow-xl transition-shadow',
)}
>
<DialogCloseButton className="top-5 right-5 size-8" />
{open && (
<DeploymentAccessControlDialogBody
key={draftKey}
initialKind={initialKind}
initialSubjects={initialSubjects}
saving={saving}
onClose={onClose}
onSubmit={onSubmit}
/>
)}
</DialogContent>
</Dialog>
)
}
function DeploymentAccessControlDialogBody({
initialKind,
initialSubjects,
saving,
onClose,
onSubmit,
}: {
initialKind: AccessPermissionKind
initialSubjects: SelectableAccessSubject[]
saving?: boolean
onClose: () => void
onSubmit: (kind: AccessPermissionKind, subjects: SelectableAccessSubject[]) => void
}) {
const { t } = useTranslation('deployments')
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const [currentMenu, setCurrentMenu] = useState(() => permissionKeyToAppAccessMode(initialKind))
const [specificSelection, setSpecificSelection] = useState<AccessSubjectSelectionValue>(() =>
accessControlSelectionFromSubjects(initialSubjects),
)
const specificSelected = currentMenu === AppAccessMode.SPECIFIC_GROUPS_MEMBERS
const publicAccessDisabled = !systemFeatures.webapp_auth.allow_public_access
const selectedSubjectCount = specificSelection.groups.length + specificSelection.members.length
const specificEmpty = specificSelected && selectedSubjectCount === 0
const publicSelectedDisabled = currentMenu === AppAccessMode.PUBLIC && publicAccessDisabled
const confirmDisabled = saving || (specificSelected && specificEmpty) || publicSelectedDisabled
const handleConfirm = () => {
if (confirmDisabled) return
onSubmit(
appAccessModeToPermissionKey(currentMenu),
specificSelected ? subjectsFromAccessControlSelection(specificSelection) : [],
)
}
return (
<div className="flex flex-col gap-y-3">
<div className="pt-6 pr-14 pb-3 pl-6">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t(($) => $['access.permissions.editTitle'])}
</DialogTitle>
<DialogDescription className="mt-1 system-xs-regular text-text-tertiary">
{t(($) => $['access.permissions.editDescription'])}
</DialogDescription>
</div>
<RadioGroup<AppAccessMode>
value={currentMenu}
onValueChange={setCurrentMenu}
className="flex flex-col items-stretch gap-y-1 px-6 pb-3"
aria-labelledby="access-control-options-label"
disabled={saving}
>
<div className="leading-6">
<p id="access-control-options-label" className="system-sm-medium text-text-tertiary">
{t(($) => $['accessControlDialog.accessLabel'], { ns: 'app' })}
</p>
</div>
<AccessControlItem type={AppAccessMode.ORGANIZATION}>
<div className="flex items-center p-3">
<div className="flex grow items-center gap-x-2">
<span className="i-ri-building-line size-4 text-text-primary" aria-hidden="true" />
<p className="system-sm-medium text-text-primary">
{t(($) => $['accessControlDialog.accessItems.organization'], { ns: 'app' })}
</p>
</div>
</div>
</AccessControlItem>
<AccessControlItem type={AppAccessMode.SPECIFIC_GROUPS_MEMBERS}>
<SpecificGroupsOrMembersOption
selected={specificSelected}
selection={specificSelection}
onSelectionChange={setSpecificSelection}
/>
</AccessControlItem>
<AccessControlItem type={AppAccessMode.PUBLIC} disabled={publicAccessDisabled}>
<div className="flex items-center gap-x-2 p-3">
<span className="i-ri-global-line size-4 text-text-primary" aria-hidden="true" />
<p className="system-sm-medium text-text-primary">
{t(($) => $['accessControlDialog.accessItems.anyone'], { ns: 'app' })}
</p>
{publicAccessDisabled && (
<Infotip
aria-label={t(($) => $['accessControlDialog.webAppPublicAccessDisabledTip'], {
ns: 'app',
})}
className="size-3.5 shrink-0 text-text-warning-secondary hover:text-text-warning-secondary"
>
{t(($) => $['accessControlDialog.webAppPublicAccessDisabledTip'], { ns: 'app' })}
</Infotip>
)}
</div>
</AccessControlItem>
</RadioGroup>
<div className="flex items-center justify-end gap-x-2 p-6 pt-5">
<Button disabled={saving} onClick={onClose}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</Button>
<Button
disabled={confirmDisabled || saving}
loading={saving}
variant="primary"
onClick={handleConfirm}
>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</Button>
</div>
</div>
)
}
function AccessControlItem({
type,
children,
disabled,
}: PropsWithChildren<{
type: AppAccessMode
disabled?: boolean
}>) {
return (
<RadioItem<AppAccessMode>
value={type}
disabled={disabled}
render={<div />}
className={cn(
'cursor-pointer rounded-[10px] border-[0.5px] border-components-option-card-option-border bg-components-option-card-option-bg shadow-xs transition-colors',
'hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover',
'focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
'data-checked:border-components-option-card-option-selected-border data-checked:bg-components-option-card-option-selected-bg data-checked:inset-ring-[0.5px] data-checked:inset-ring-components-option-card-option-selected-border',
'data-disabled:cursor-not-allowed data-disabled:opacity-60 data-disabled:hover:border-components-option-card-option-border data-disabled:hover:bg-components-option-card-option-bg',
)}
>
{children}
</RadioItem>
)
}
function SpecificGroupsOrMembersOption({
selected,
selection,
onSelectionChange,
}: {
selected: boolean
selection: AccessSubjectSelectionValue
onSelectionChange: (selection: AccessSubjectSelectionValue) => void
}) {
const { t } = useTranslation()
if (!selected) {
return (
<div className="flex items-center p-3">
<div className="flex grow items-center gap-x-2">
<span className="i-ri-lock-line size-4 text-text-primary" aria-hidden="true" />
<p className="system-sm-medium text-text-primary">
{t(($) => $['accessControlDialog.accessItems.specific'], { ns: 'app' })}
</p>
</div>
</div>
)
}
return (
<div>
<div className="flex items-center gap-x-1 p-3">
<div className="flex grow items-center gap-x-1">
<span className="i-ri-lock-line size-4 text-text-primary" aria-hidden="true" />
<p className="system-sm-medium text-text-primary">
{t(($) => $['accessControlDialog.accessItems.specific'], { ns: 'app' })}
</p>
</div>
<div className="flex items-center gap-x-1">
<AccessSubjectAddButton
selectedGroups={selection.groups}
selectedMembers={selection.members}
onChange={onSelectionChange}
/>
</div>
</div>
<div className="px-1 pb-1">
<AccessSubjectSelectionList
selectedGroups={selection.groups}
selectedMembers={selection.members}
onChange={onSelectionChange}
/>
</div>
</div>
)
}
@@ -1,170 +0,0 @@
import type {
AccessPolicy,
AccessMode as AccessPolicyMode,
AccessSubject,
AccessSubjectType as AccessSubjectTypeValue,
Subject,
} from '@dify/contracts/enterprise/types.gen'
import type { AccessSubjectSelectionValue } from './access-subject-selector/types'
import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control'
import { AccessMode, AccessSubjectType } from '@dify/contracts/enterprise/types.gen'
import { AccessMode as AppAccessMode, SubjectType as AppSubjectType } from '@/models/access-control'
export type AccessPermissionKind = 'organization' | 'specific' | 'anyone'
export const permissionIcon: Record<AccessPermissionKind, string> = {
organization: 'i-ri-building-line',
specific: 'i-ri-lock-line',
anyone: 'i-ri-global-line',
}
export type SelectableAccessSubject = {
id: string
subjectType: AccessSubjectTypeValue
name?: string
memberCount?: number
}
export function accessModeToPermissionKey(mode?: AccessPolicy['mode']): AccessPermissionKind {
if (mode === AccessMode.ACCESS_MODE_PRIVATE) return 'specific'
if (mode === AccessMode.ACCESS_MODE_PUBLIC) return 'anyone'
return 'organization'
}
export function permissionKeyToAccessMode(key: AccessPermissionKind): AccessPolicyMode {
if (key === 'organization') return AccessMode.ACCESS_MODE_PRIVATE_ALL
if (key === 'specific') return AccessMode.ACCESS_MODE_PRIVATE
return AccessMode.ACCESS_MODE_PUBLIC
}
export function permissionKeyToAppAccessMode(key: AccessPermissionKind): AppAccessMode {
if (key === 'organization') return AppAccessMode.ORGANIZATION
if (key === 'specific') return AppAccessMode.SPECIFIC_GROUPS_MEMBERS
return AppAccessMode.PUBLIC
}
export function appAccessModeToPermissionKey(mode: AppAccessMode): AccessPermissionKind {
if (mode === AppAccessMode.SPECIFIC_GROUPS_MEMBERS) return 'specific'
if (mode === AppAccessMode.PUBLIC) return 'anyone'
return 'organization'
}
export function normalizeResolvedSubject(subject: Subject): SelectableAccessSubject | undefined {
const isGroupSubject =
subject.subjectType === AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP ||
subject.subjectType === AppSubjectType.GROUP
const isAccountSubject =
subject.subjectType === AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT ||
subject.subjectType === AppSubjectType.ACCOUNT
if (isGroupSubject) {
const id = subject.subjectId || subject.groupData?.id
if (!id) return undefined
return {
id,
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP,
name: subject.groupData?.name,
memberCount: subject.groupData?.groupSize,
}
}
if (isAccountSubject) {
const id = subject.subjectId || subject.accountData?.id
if (!id) return undefined
return {
id,
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT,
name: subject.accountData?.name || subject.accountData?.email,
}
}
return undefined
}
function getSubjectLabel(subject: SelectableAccessSubject) {
return subject.name || subject.id
}
export function policySubjects(subjects: SelectableAccessSubject[]): AccessSubject[] {
return subjects.map((subject) => ({
subjectId: subject.id,
subjectType: subject.subjectType,
}))
}
export function selectedSubjectsFromPolicy(
policy?: AccessPolicy,
labelSubjects: SelectableAccessSubject[] = [],
) {
if (!policy) return []
return policy.subjects.map((subject): SelectableAccessSubject => {
const matchedSubject = labelSubjects.find(
(labelSubject) =>
labelSubject.id === subject.subjectId && labelSubject.subjectType === subject.subjectType,
)
return {
id: subject.subjectId,
subjectType: subject.subjectType,
name: matchedSubject?.name,
memberCount: matchedSubject?.memberCount,
}
})
}
function selectableSubjectToGroup(subject: SelectableAccessSubject): AccessControlGroup {
return {
id: subject.id,
name: getSubjectLabel(subject),
groupSize: subject.memberCount ?? 0,
}
}
function selectableSubjectToAccount(subject: SelectableAccessSubject): AccessControlAccount {
const label = getSubjectLabel(subject)
return {
id: subject.id,
name: label,
email: label,
avatar: '',
avatarUrl: '',
}
}
export function accessControlSelectionFromSubjects(
subjects: SelectableAccessSubject[],
): AccessSubjectSelectionValue {
return {
groups: subjects
.filter((subject) => subject.subjectType === AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP)
.map(selectableSubjectToGroup),
members: subjects
.filter((subject) => subject.subjectType === AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT)
.map(selectableSubjectToAccount),
}
}
export function subjectsFromAccessControlSelection(
value: AccessSubjectSelectionValue,
): SelectableAccessSubject[] {
return [
...value.groups.map(
(group): SelectableAccessSubject => ({
id: group.id,
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP,
name: group.name,
memberCount: group.groupSize,
}),
),
...value.members.map(
(member): SelectableAccessSubject => ({
id: member.id,
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT,
name: member.name || member.email,
}),
),
]
}
@@ -1,17 +0,0 @@
# Deployment Access Subject Selector
Subject selector used by access permission dialogs to search, add, and display members or groups.
## Internal Modules
None.
## External Modules
| Module | Why this module uses it |
| -------------------------------------------- | ---------------------------------------------------------------------------------- |
| `app/components/base/loading` | Reuses the inline loading indicator while access-subject search is pending. |
| `app/components/base/skeleton` | Reuses skeleton primitives for selected subject loading rows. |
| `context/app-context` | Reads the current user id to label the current user in selectable subject options. |
| `models/access-control` | Reuses access-control subject types and group models for selector values. |
| `service/access-control/use-access-subjects` | Searches users and groups for access-policy subject selection. |
@@ -1,127 +0,0 @@
import type { AccessControlAccount, Subject } from '@/models/access-control'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SubjectType } from '@/models/access-control'
import { renderWithAccountProfile as render } from '@/test/console/account-profile'
import { AccessSubjectAddButton } from '../add-button'
const mockUseSearchAccessSubjects = vi.hoisted(() => vi.fn())
vi.mock('@/service/access-control/use-access-subjects', () => ({
useSearchAccessSubjects: (...args: unknown[]) => mockUseSearchAccessSubjects(...args),
}))
const groupSubject: Subject = {
subjectId: 'group-1',
subjectType: SubjectType.GROUP,
groupData: {
id: 'group-1',
name: 'Group One',
groupSize: 2,
},
}
const member: AccessControlAccount = {
id: 'account-1',
name: 'Member One',
email: 'member@example.com',
avatar: '',
avatarUrl: '',
}
const memberSubject: Subject = {
subjectId: member.id,
subjectType: SubjectType.ACCOUNT,
accountData: member,
}
function lastSearchParams() {
return mockUseSearchAccessSubjects.mock.calls.at(-1)?.[0] as { groupId?: string } | undefined
}
// The add menu owns transient browsing state; reopening should start from the root group list.
describe('AccessSubjectAddButton', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseSearchAccessSubjects.mockReturnValue({
data: {
pages: [
{
subjects: [groupSubject, memberSubject],
hasMore: false,
},
],
},
fetchNextPage: vi.fn(),
isFetchingNextPage: false,
isLoading: false,
})
})
it('should reset expanded group browsing when the add menu reopens', async () => {
const user = userEvent.setup()
render(<AccessSubjectAddButton selectedGroups={[]} selectedMembers={[]} onChange={vi.fn()} />)
const addButton = screen.getByRole('combobox', { name: 'common.operation.add' })
await user.click(addButton)
await user.click(
await screen.findByRole('button', {
name: 'app.accessControlDialog.operateGroupAndMember.expand',
}),
)
await waitFor(() => {
expect(lastSearchParams()).toMatchObject({ groupId: 'group-1' })
})
await user.click(addButton)
await waitFor(() => {
expect(addButton).toHaveAttribute('aria-expanded', 'false')
})
await user.click(addButton)
await waitFor(() => {
expect(lastSearchParams()?.groupId).toBeUndefined()
})
})
it('should keep group and member selected state in sync with checkbox icon DOM', async () => {
const user = userEvent.setup()
const group = groupSubject.groupData
const onChange = vi.fn()
const { rerender } = render(
<AccessSubjectAddButton selectedGroups={[group]} selectedMembers={[]} onChange={onChange} />,
)
await user.click(screen.getByRole('combobox', { name: 'common.operation.add' }))
const groupOption = await screen.findByRole('option', { name: /Group One/ })
const memberOption = screen.getByRole('option', { name: /Member One/ })
const expandButton = screen.getByRole('button', {
name: 'app.accessControlDialog.operateGroupAndMember.expand',
})
expect(groupOption).toHaveClass('mx-0', 'pl-2')
expect(memberOption).toHaveClass('mx-0', 'pl-2', 'pr-3')
expect(groupOption).toHaveAttribute('data-selected')
expect(groupOption.querySelector('.i-ri-check-line')).toBeInTheDocument()
expect(memberOption).not.toHaveAttribute('data-selected')
expect(memberOption.querySelector('.i-ri-check-line')).not.toBeInTheDocument()
expect(expandButton).toBeDisabled()
rerender(
<AccessSubjectAddButton selectedGroups={[]} selectedMembers={[member]} onChange={onChange} />,
)
await waitFor(() => {
expect(groupOption).not.toHaveAttribute('data-selected')
expect(groupOption.querySelector('.i-ri-check-line')).not.toBeInTheDocument()
expect(memberOption).toHaveAttribute('data-selected')
expect(memberOption.querySelector('.i-ri-check-line')).toBeInTheDocument()
expect(expandButton).not.toBeDisabled()
})
})
})

Some files were not shown because too many files have changed in this diff Show More