mirror of
https://github.com/langgenius/dify.git
synced 2026-10-30 18:34:29 +08:00
refactor(web): remove deprecated deployments pages (#40593)
This commit is contained in:
@@ -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',
|
||||
|
||||
Generated
-6
@@ -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 />
|
||||
}
|
||||
@@ -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>}>
|
||||
|
||||
@@ -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,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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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'>
|
||||
@@ -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. |
|
||||
-128
@@ -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. |
|
||||
-127
@@ -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
Reference in New Issue
Block a user