feat: app deployment v2 (#39829)

Co-authored-by: zhangx1n <zhangxin@dify.ai>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Wu Tianwei
2026-08-12 01:55:36 +00:00
committed by GitHub
co-authored by zhangx1n autofix-ci[bot]
parent ef8544b173
commit 7aba539e82
542 changed files with 29450 additions and 13824 deletions
+8 -2
View File
@@ -95,7 +95,10 @@ describe('embedded user id propagation in authentication flows', () => {
})
})
expect(setWebAppAccessTokenMock).toHaveBeenCalledWith('login-token')
expect(setWebAppPassportMock).toHaveBeenCalledWith('test-app', 'passport-token')
expect(setWebAppPassportMock).toHaveBeenCalledWith(
{ kind: 'default', code: 'test-app' },
'passport-token',
)
expect(replaceMock).toHaveBeenCalledWith('/chatbot/test-app')
})
@@ -166,7 +169,10 @@ describe('embedded user id propagation in authentication flows', () => {
})
})
expect(setWebAppAccessTokenMock).toHaveBeenCalledWith('code-token')
expect(setWebAppPassportMock).toHaveBeenCalledWith('test-app', 'passport-token')
expect(setWebAppPassportMock).toHaveBeenCalledWith(
{ kind: 'default', code: 'test-app' },
'passport-token',
)
expect(replaceMock).toHaveBeenCalledWith('/chatbot/test-app')
})
@@ -0,0 +1,5 @@
import { AppDetailSidebarSlot } from '../sidebar-page'
export default function AppAccessPointDetailSidebarSlot() {
return <AppDetailSidebarSlot />
}
@@ -0,0 +1,5 @@
import { AppDetailSidebarSlot } from '../sidebar-page'
export default function AppDeployDetailSidebarSlot() {
return <AppDetailSidebarSlot />
}
@@ -185,6 +185,59 @@ describe('AppDetailLayout', () => {
expect(useStore.getState().appDetail?.id).toBe('app-1')
})
it('should allow access point pages without app deploy or app ACL permissions', async () => {
mockPathname = '/app/app-1/access-point'
mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [] }))
render(
<AppDetailLayout appId="app-1">
<div>App page content</div>
</AppDetailLayout>,
)
await waitForAppContent()
expect(mockReplace).not.toHaveBeenCalled()
expect(useStore.getState().appDetail?.id).toBe('app-1')
})
it('should redirect deploy pages when app deploy ACL permission is missing', async () => {
mockPathname = '/app/app-1/deploy'
mockFetchAppDetailDirect.mockResolvedValue(
createAppDetail({ permission_keys: [AppACLPermission.ViewLayout] }),
)
render(
<AppDetailLayout appId="app-1">
<div>App page content</div>
</AppDetailLayout>,
)
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/app/app-1/workflow')
})
expect(screen.queryByText('App page content')).not.toBeInTheDocument()
expect(useStore.getState().appDetail).toBeUndefined()
})
it('should allow users with app deploy ACL permission to open deploy directly', async () => {
mockPathname = '/app/app-1/deploy'
mockFetchAppDetailDirect.mockResolvedValue(
createAppDetail({ permission_keys: [AppACLPermission.Deploy] }),
)
render(
<AppDetailLayout appId="app-1">
<div>App page content</div>
</AppDetailLayout>,
)
await waitForAppContent()
expect(mockReplace).not.toHaveBeenCalled()
expect(useStore.getState().appDetail?.id).toBe('app-1')
})
it('should allow users with layout access to open workflow pages directly', async () => {
mockPathname = '/app/app-1/workflow'
@@ -211,7 +264,7 @@ describe('AppDetailLayout', () => {
)
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/app/app-1/develop')
expect(mockReplace).toHaveBeenCalledWith('/app/app-1/access-point')
})
expect(screen.queryByText('App page content')).not.toBeInTheDocument()
expect(useStore.getState().appDetail).toBeUndefined()
@@ -336,7 +389,7 @@ describe('AppDetailLayout', () => {
)
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/app/app-1/develop')
expect(mockReplace).toHaveBeenCalledWith('/app/app-1/access-point')
})
expect(screen.queryByText('App page content')).not.toBeInTheDocument()
expect(useStore.getState().appDetail).toBeUndefined()
@@ -0,0 +1,11 @@
import AccessPoint from '@/app/components/app/access-point'
type AppAccessPointPageProps = {
params: Promise<{ appId: string }>
}
export default async function AppAccessPointPage({ params }: AppAccessPointPageProps) {
const { appId } = await params
return <AccessPoint appId={appId} />
}
@@ -0,0 +1,5 @@
import AppDeploy from '@/app/components/app/deploy'
export default function AppDeployPage() {
return <AppDeploy />
}
@@ -120,12 +120,15 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const isAnnotationsPath = pathname.endsWith('annotations')
const isOverviewPath = pathname.endsWith('overview')
const isAccessConfigPath = pathname.endsWith('access-config')
const isDeployPath = pathname.endsWith('deploy')
if (
(isLayoutPath && !appACLCapabilities.canAccessLayout) ||
(isLogsPath && !appACLCapabilities.canAccessLogAndAnnotation) ||
(isAnnotationsPath && !appACLCapabilities.canAccessLogAndAnnotation) ||
(isOverviewPath && !appACLCapabilities.canMonitor) ||
(isAccessConfigPath && !appACLCapabilities.canAccessConfig)
(isAccessConfigPath && !appACLCapabilities.canAccessConfig) ||
(isDeployPath &&
(routeAppDetail.mode !== AppModeEnum.WORKFLOW || !appACLCapabilities.canDeploy))
) {
router.replace(
getRedirectionPath(routeAppDetail, {
@@ -1,228 +0,0 @@
import type { App } from '@/types/app'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createAccountProfileQueryWrapper } from '@/test/console/account-profile'
import { render as renderWithConsoleState } from '@/test/console/render'
import CardView from '../card-view'
const mockAppState = vi.hoisted(() => ({
appDetail: {
id: 'app-1',
mode: 'chat',
permission_keys: [] as string[],
},
setAppDetail: vi.fn(),
}))
const mockUpdateAppSiteStatus = vi.hoisted(() => vi.fn())
const mockUpdateAppSiteConfig = vi.hoisted(() => vi.fn())
const mockUpdateAppSiteAccessToken = vi.hoisted(() => vi.fn())
const mockFetchAppDetail = vi.hoisted(() => vi.fn())
const mockInvalidateQueries = vi.hoisted(() => vi.fn())
const render = (ui: Parameters<typeof renderWithConsoleState>[0]) =>
renderWithConsoleState(ui, {
wrapper: createAccountProfileQueryWrapper({ id: 'user-1' }),
})
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }),
}
})
vi.mock('@/service/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/client')>()
return {
...actual,
consoleQuery: {
...actual.consoleQuery,
account: {
profile: {
get: {
queryKey: () => [['console', 'account', 'profile', 'get'], { type: 'query' }],
},
},
},
apps: {
get: { key: () => ['console', 'apps', 'get'] },
starred: { get: { key: () => ['console', 'apps', 'starred', 'get'] } },
recent: { get: { key: () => ['console', 'apps', 'recent', 'get'] } },
},
},
}
})
vi.mock('@/app/components/app/store', () => ({
useStore: <T,>(selector: (state: typeof mockAppState) => T): T => selector(mockAppState),
}))
vi.mock('@/service/use-workflow', () => ({
useAppWorkflow: () => ({ data: undefined }),
}))
vi.mock('@/service/apps', () => ({
fetchAppDetail: (...args: unknown[]) => mockFetchAppDetail(...args),
updateAppSiteStatus: (...args: unknown[]) => mockUpdateAppSiteStatus(...args),
updateAppSiteConfig: (...args: unknown[]) => mockUpdateAppSiteConfig(...args),
updateAppSiteAccessToken: (...args: unknown[]) => mockUpdateAppSiteAccessToken(...args),
}))
vi.mock('@/context/workspace-state', async () => {
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
return createWorkspaceStateModuleMock(() => ({
userProfile: { id: 'user-1' },
currentWorkspace: { id: 'workspace-1' },
workspacePermissionKeys: mockAppState.appDetail.permission_keys,
}))
})
vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => ({
userProfile: { id: 'user-1' },
currentWorkspace: { id: 'workspace-1' },
workspacePermissionKeys: mockAppState.appDetail.permission_keys,
}))
})
vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({
collaborationManager: {
onAppStateUpdate: vi.fn(() => vi.fn()),
},
}))
vi.mock('@/app/components/workflow/collaboration/core/websocket-manager', () => ({
webSocketClient: {
getSocket: vi.fn(() => null),
},
}))
vi.mock('@/app/components/app/overview/app-card', () => ({
default: ({
cardType,
onChangeStatus,
onGenerateCode,
onSaveSiteConfig,
}: {
cardType: string
onChangeStatus?: (value: boolean) => void
onGenerateCode?: () => void
onSaveSiteConfig?: (params: Record<string, unknown>) => void
}) => (
<div>
<button type="button" onClick={() => onChangeStatus?.(true)}>
toggle {cardType}
</button>
{onGenerateCode && (
<button type="button" onClick={() => onGenerateCode()}>
generate {cardType}
</button>
)}
{onSaveSiteConfig && (
<button type="button" onClick={() => onSaveSiteConfig({ title: 'Site title' })}>
save {cardType}
</button>
)}
</div>
),
}))
vi.mock('@/app/components/app/overview/trigger-card', () => ({
default: () => <div>trigger card</div>,
}))
vi.mock('@/app/components/tools/mcp/mcp-service-card', () => ({
default: () => <div>mcp card</div>,
}))
describe('CardView ACL edit guards', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAppState.appDetail = {
id: 'app-1',
mode: 'chat',
permission_keys: [],
}
mockUpdateAppSiteStatus.mockResolvedValue(mockAppState.appDetail as App)
mockUpdateAppSiteConfig.mockResolvedValue(mockAppState.appDetail as App)
mockUpdateAppSiteAccessToken.mockResolvedValue({ code: 'token' })
mockFetchAppDetail.mockResolvedValue({
id: 'app-1',
mode: 'chat',
permission_keys: ['app.acl.edit'],
site: {
title: 'Saved site title',
},
} as unknown as App)
})
// User-facing card actions should not mutate app settings without app ACL edit permission.
describe('Permissions', () => {
it('should not call write APIs when app ACL edit permission is missing', async () => {
const user = userEvent.setup()
render(<CardView appId="app-1" />)
await user.click(screen.getByRole('button', { name: /toggle webapp/ }))
await user.click(screen.getByRole('button', { name: /save webapp/ }))
await user.click(screen.getByRole('button', { name: /generate webapp/ }))
await user.click(screen.getByRole('button', { name: /toggle api/ }))
expect(mockUpdateAppSiteStatus).not.toHaveBeenCalled()
expect(mockUpdateAppSiteConfig).not.toHaveBeenCalled()
expect(mockUpdateAppSiteAccessToken).not.toHaveBeenCalled()
expect(mockFetchAppDetail).not.toHaveBeenCalled()
})
it('should call write APIs when app ACL edit permission is present', async () => {
const user = userEvent.setup()
mockAppState.appDetail.permission_keys = ['app.acl.edit']
render(<CardView appId="app-1" />)
await user.click(screen.getByRole('button', { name: /toggle webapp/ }))
await user.click(screen.getByRole('button', { name: /save webapp/ }))
await user.click(screen.getByRole('button', { name: /generate webapp/ }))
await user.click(screen.getByRole('button', { name: /toggle api/ }))
await waitFor(() => {
expect(mockUpdateAppSiteStatus).toHaveBeenCalledTimes(2)
})
expect(mockUpdateAppSiteStatus).toHaveBeenCalledWith({
url: '/apps/app-1/site-enable',
body: { enable_site: true },
})
expect(mockUpdateAppSiteStatus).toHaveBeenCalledWith({
url: '/apps/app-1/api-enable',
body: { enable_api: true },
})
expect(mockUpdateAppSiteConfig).toHaveBeenCalledWith({
url: '/apps/app-1/site',
body: { title: 'Site title' },
})
expect(mockUpdateAppSiteAccessToken).toHaveBeenCalledWith({
url: '/apps/app-1/site/access-token-reset',
})
expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: ['console', 'apps', 'get'],
})
expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: ['console', 'apps', 'starred', 'get'],
})
expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: ['console', 'apps', 'recent', 'get'],
})
await waitFor(() => {
expect(mockFetchAppDetail).toHaveBeenCalled()
})
expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
expect(mockAppState.setAppDetail).toHaveBeenCalledWith(
expect.objectContaining({
site: expect.objectContaining({ title: 'Saved site title' }),
}),
)
})
})
})
@@ -1,251 +0,0 @@
'use client'
import type { FC } from 'react'
import type { IAppCardProps } from '@/app/components/app/overview/app-card'
import type { BlockEnum } from '@/app/components/workflow/types'
import type { UpdateAppSiteCodeResponse } from '@/models/app'
import type { App } from '@/types/app'
import type { I18nKeysByPrefix } from '@/types/i18n'
import { toast } from '@langgenius/dify-ui/toast'
import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useCallback, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import AppCard from '@/app/components/app/overview/app-card'
import TriggerCard from '@/app/components/app/overview/trigger-card'
import { useStore as useAppStore } from '@/app/components/app/store'
import Loading from '@/app/components/base/loading'
import MCPServiceCard from '@/app/components/tools/mcp/mcp-service-card'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
import { isTriggerNode } from '@/app/components/workflow/types'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import {
fetchAppDetail,
updateAppSiteAccessToken,
updateAppSiteConfig,
updateAppSiteStatus,
} from '@/service/apps'
import { consoleQuery } from '@/service/client'
import { useAppWorkflow } from '@/service/use-workflow'
import { AppModeEnum } from '@/types/app'
import { asyncRunSafe } from '@/utils'
import { getAppACLCapabilities } from '@/utils/permission'
type ICardViewProps = {
appId: string
isInPanel?: boolean
className?: string
}
const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
const { t } = useTranslation()
const queryClient = useQueryClient()
const appDetail = useAppStore((state) => state.appDetail)
const setAppDetail = useAppStore((state) => state.setAppDetail)
const { data: currentUserId } = useSuspenseQuery({
...userProfileQueryOptions(),
select: (data) => data.profile.id,
})
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const canEditApp = useMemo(
() =>
getAppACLCapabilities(appDetail?.permission_keys, {
currentUserId,
resourceMaintainer: appDetail?.maintainer,
workspacePermissionKeys,
}).canEdit,
[appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys],
)
const isWorkflowApp = appDetail?.mode === AppModeEnum.WORKFLOW
const showMCPCard = isInPanel
const showTriggerCard = isInPanel && isWorkflowApp
const { data: currentWorkflow } = useAppWorkflow(isWorkflowApp ? appDetail.id : '')
const hasTriggerNode = useMemo<boolean | null>(() => {
if (!isWorkflowApp) return false
if (!currentWorkflow) return null
const nodes = currentWorkflow.graph?.nodes || []
return nodes.some((node) => {
const nodeType = node.data?.type as BlockEnum | undefined
return !!nodeType && isTriggerNode(nodeType)
})
}, [isWorkflowApp, currentWorkflow])
const shouldRenderAppCards = !isWorkflowApp || hasTriggerNode === false
const disableAppCards = !shouldRenderAppCards
const buildTriggerModeMessage = useCallback(
(featureName: string) => (
<div className="flex flex-col gap-1">
<div className="text-xs text-text-secondary">
{t(($) => $['overview.disableTooltip.triggerMode'], {
ns: 'appOverview',
feature: featureName,
})}
</div>
</div>
),
[t],
)
const disableWebAppTooltip = disableAppCards
? buildTriggerModeMessage(t(($) => $['overview.appInfo.title'], { ns: 'appOverview' }))
: null
const disableApiTooltip = disableAppCards
? buildTriggerModeMessage(t(($) => $['overview.apiInfo.title'], { ns: 'appOverview' }))
: null
const disableMcpTooltip = disableAppCards
? buildTriggerModeMessage(t(($) => $['mcp.server.title'], { ns: 'tools' }))
: null
const updateAppDetail = useCallback(async () => {
try {
const res = await fetchAppDetail({ url: '/apps', id: appId })
setAppDetail({ ...res })
} catch (error) {
console.error(error)
}
}, [appId, setAppDetail])
const handleCallbackResult = (
err: Error | null,
message?: I18nKeysByPrefix<'common', 'actionMsg.'>,
) => {
const type = err ? 'error' : 'success'
message ||= type === 'success' ? 'modifiedSuccessfully' : 'modifiedUnsuccessfully'
if (type === 'success') {
updateAppDetail()
// Emit collaboration event to notify other clients of app state changes
const socket = webSocketClient.getSocket(appId)
if (socket) {
socket.emit('collaboration_event', {
type: 'app_state_update',
data: { timestamp: Date.now() },
timestamp: Date.now(),
})
}
}
toast(t(($) => $[`actionMsg.${message}`], { ns: 'common' }) as string, { type })
}
// Listen for collaborative app state updates from other clients
useEffect(() => {
if (!appId) return
const unsubscribe = collaborationManager.onAppStateUpdate(async () => {
try {
// Update app detail when other clients modify app state
await updateAppDetail()
} catch (error) {
console.error('app state update failed:', error)
}
})
return unsubscribe
}, [appId, updateAppDetail])
const onChangeSiteStatus = async (value: boolean) => {
if (!canEditApp) return
const [err] = await asyncRunSafe<App>(
updateAppSiteStatus({
url: `/apps/${appId}/site-enable`,
body: { enable_site: value },
}) as Promise<App>,
)
handleCallbackResult(err)
}
const onChangeApiStatus = async (value: boolean) => {
if (!canEditApp) return
const [err] = await asyncRunSafe<App>(
updateAppSiteStatus({
url: `/apps/${appId}/api-enable`,
body: { enable_api: value },
}) as Promise<App>,
)
handleCallbackResult(err)
}
const onSaveSiteConfig: IAppCardProps['onSaveSiteConfig'] = async (params) => {
if (!canEditApp) return
const [err] = await asyncRunSafe<App>(
updateAppSiteConfig({
url: `/apps/${appId}/site`,
body: params,
}) as Promise<App>,
)
if (!err) {
void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.get.key() })
void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.starred.get.key() })
void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.recent.get.key() })
}
handleCallbackResult(err)
}
const onGenerateCode = async () => {
if (!canEditApp) return
const [err] = await asyncRunSafe<UpdateAppSiteCodeResponse>(
updateAppSiteAccessToken({
url: `/apps/${appId}/site/access-token-reset`,
}) as Promise<UpdateAppSiteCodeResponse>,
)
handleCallbackResult(err, err ? 'generatedUnsuccessfully' : 'generatedSuccessfully')
}
if (!appDetail) return <Loading />
const appCards = (
<>
<AppCard
appInfo={appDetail}
cardType="webapp"
isInPanel={isInPanel}
triggerModeDisabled={disableAppCards}
triggerModeMessage={disableWebAppTooltip}
onChangeStatus={onChangeSiteStatus}
onGenerateCode={onGenerateCode}
onSaveSiteConfig={onSaveSiteConfig}
/>
<AppCard
cardType="api"
appInfo={appDetail}
isInPanel={isInPanel}
triggerModeDisabled={disableAppCards}
triggerModeMessage={disableApiTooltip}
onChangeStatus={onChangeApiStatus}
/>
{showMCPCard && (
<MCPServiceCard
appInfo={appDetail}
triggerModeDisabled={disableAppCards}
triggerModeMessage={disableMcpTooltip}
/>
)}
</>
)
const triggerCardNode = showTriggerCard ? (
<TriggerCard appInfo={appDetail} onToggleResult={handleCallbackResult} />
) : null
return (
<div className={className || 'mb-6 grid w-full grid-cols-1 gap-6 xl:grid-cols-2'}>
{disableAppCards && triggerCardNode}
{appCards}
{!disableAppCards && triggerCardNode}
</div>
)
}
export default CardView
@@ -1,59 +1,22 @@
import type { ReactElement, ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
const mocks = vi.hoisted(() => ({
ensureQueryData: vi.fn(),
systemFeaturesQueryOptions: { queryKey: ['console', 'system-features'] },
notFound: vi.fn(() => {
throw new Error('NEXT_NOT_FOUND')
}),
}))
vi.mock('@/features/system-features/server', () => ({
getSystemFeaturesQueryClient: () => ({
ensureQueryData: mocks.ensureQueryData,
}),
systemFeaturesServerQueryOptions: () => mocks.systemFeaturesQueryOptions,
}))
vi.mock('@/features/deployments/deploy-drawer', () => ({
DeployDrawer: () => <div>Deploy drawer</div>,
}))
vi.mock('@/next/navigation', () => ({
notFound: () => mocks.notFound(),
}))
const renderDeploymentsLayout = async (children: ReactNode) => {
const { default: DeploymentsLayout } = await import('../layout')
const element = await DeploymentsLayout({ children })
render(element as ReactElement)
}
describe('DeploymentsLayout', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.ensureQueryData.mockResolvedValue({ enable_app_deploy: true })
})
it('should render deployments content and drawer when app deploy is enabled', async () => {
await renderDeploymentsLayout(<div>Deployments content</div>)
expect(mocks.ensureQueryData).toHaveBeenCalledWith(mocks.systemFeaturesQueryOptions)
expect(screen.getByText('Deployments content')).toBeInTheDocument()
expect(screen.getByText('Deploy drawer')).toBeInTheDocument()
expect(mocks.notFound).not.toHaveBeenCalled()
})
it('should trigger notFound when app deploy is disabled', async () => {
mocks.ensureQueryData.mockResolvedValue({ enable_app_deploy: false })
it('should always trigger notFound', async () => {
const { default: DeploymentsLayout } = await import('../layout')
await expect(
DeploymentsLayout({
children: <div>Deployments content</div>,
}),
).rejects.toThrow('NEXT_NOT_FOUND')
expect(() => DeploymentsLayout()).toThrow('NEXT_NOT_FOUND')
expect(mocks.notFound).toHaveBeenCalledTimes(1)
})
+2 -19
View File
@@ -1,22 +1,5 @@
import type { ReactNode } from 'react'
import { DeployDrawer } from '@/features/deployments/deploy-drawer'
import {
getSystemFeaturesQueryClient,
systemFeaturesServerQueryOptions,
} from '@/features/system-features/server'
import { notFound } from '@/next/navigation'
export default async function DeploymentsLayout({ children }: { children: ReactNode }) {
const systemFeatures = await getSystemFeaturesQueryClient().ensureQueryData(
systemFeaturesServerQueryOptions(),
)
if (!systemFeatures.enable_app_deploy) notFound()
return (
<>
{children}
<DeployDrawer />
</>
)
export default function DeploymentsLayout() {
notFound()
}
@@ -1,4 +1,5 @@
import type { AppData, AppMeta } from '@/models/share'
import type { WebAppAddress } from '@/service/webapp-address'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { webAppLogout } from '@/service/webapp-auth'
@@ -20,6 +21,7 @@ const updateAppParams = vi.fn()
const updateWebAppMeta = vi.fn()
const updateUserCanAccessApp = vi.fn()
const replace = vi.fn()
const webAppAddress: WebAppAddress = { kind: 'default', code: 'share-code' }
const mockWebAppState = {
shareCode: 'share-code',
@@ -96,6 +98,10 @@ vi.mock('@/service/webapp-auth', () => ({
webAppLogout: vi.fn(),
}))
vi.mock('@/service/webapp-address', () => ({
resolveWebAppAddress: () => webAppAddress,
}))
const resetQueryStates = () => {
appInfoQueryState.data = {
app_id: 'app-id',
@@ -173,7 +179,7 @@ describe('AuthenticatedLayout', () => {
await user.click(screen.getByRole('button', { name: 'common.userProfile.logout' }))
expect(webAppLogout).toHaveBeenCalledWith('share-code')
expect(webAppLogout).toHaveBeenCalledWith(webAppAddress)
expect(replace).toHaveBeenCalledWith('/webapp-signin?redirect_url=%2Fworkflow%2Fshare-code')
})
})
@@ -47,6 +47,7 @@ describe('Splash', () => {
vi.clearAllMocks()
webAppState.shareCode = 'share-app'
navigationMocks.pathname = '/chatbot/share-app'
window.history.replaceState({}, '', navigationMocks.pathname)
navigationMocks.searchParams = new URLSearchParams({
redirect_url: 'https://evil.example/chatbot/evil-app',
})
@@ -9,11 +9,11 @@ import { useWebAppStore } from '@/context/web-app-context'
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
import { useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control'
import { useGetWebAppInfo, useGetWebAppMeta, useGetWebAppParams } from '@/service/use-share'
import { resolveWebAppAddress } from '@/service/webapp-address'
import { webAppLogout } from '@/service/webapp-auth'
const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => {
const { t } = useTranslation()
const shareCode = useWebAppStore((s) => s.shareCode)
const updateAppInfo = useWebAppStore((s) => s.updateAppInfo)
const updateAppParams = useWebAppStore((s) => s.updateAppParams)
const updateWebAppMeta = useWebAppStore((s) => s.updateWebAppMeta)
@@ -59,10 +59,10 @@ const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => {
}, [searchParams, pathname])
const backToHome = useCallback(async () => {
await webAppLogout(shareCode!)
await webAppLogout(resolveWebAppAddress())
const url = getSigninUrl()
router.replace(url)
}, [getSigninUrl, router, shareCode])
}, [getSigninUrl, router])
if (appInfoError) {
return (
+12 -9
View File
@@ -11,6 +11,7 @@ import Loading from '@/app/components/base/loading'
import { useWebAppStore } from '@/context/web-app-context'
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
import { fetchAccessToken } from '@/service/share'
import { resolveWebAppAddress } from '@/service/webapp-address'
import {
setWebAppAccessToken,
setWebAppPassport,
@@ -45,16 +46,16 @@ function Splash({ children }: PropsWithChildren) {
const backToHome = useCallback(async () => {
const loginRedirect = resolveWebAppLoginRedirect(redirectUrl, window.location.origin)
const effectiveShareCode = loginRedirect?.appCode || shareCode
if (!effectiveShareCode || (isWebAppSigninPath(pathname) && !loginRedirect)) {
const address = loginRedirect?.address || resolveWebAppAddress()
if (!address || (isWebAppSigninPath(pathname) && !loginRedirect)) {
replaceLoginRedirect(getClientLoginFallback(), router.replace, basePath)
return
}
await webAppLogout(effectiveShareCode)
await webAppLogout(address)
const url = getSigninUrl()
router.replace(url)
}, [getSigninUrl, pathname, redirectUrl, router, shareCode])
}, [getSigninUrl, pathname, redirectUrl, router])
const [isLoading, setIsLoading] = useState(true)
const [unavailableShareCode, setUnavailableShareCode] = useState<string>()
@@ -66,8 +67,9 @@ function Splash({ children }: PropsWithChildren) {
return
}
const effectiveShareCode = loginRedirect?.appCode || shareCode
if (!effectiveShareCode) return
const address = loginRedirect?.address || resolveWebAppAddress()
if (!address) return
const effectiveShareCode = address.code
if (message) return
@@ -86,6 +88,7 @@ function Splash({ children }: PropsWithChildren) {
// if access mode is public, user login is always true, but the app login(passport) may be expired
const { userLoggedIn, appLoggedIn } = await webAppLoginStatus(
effectiveShareCode,
webAppAccessMode,
embeddedUserId || undefined,
)
if (userLoggedIn && appLoggedIn) {
@@ -100,15 +103,15 @@ function Splash({ children }: PropsWithChildren) {
appCode: effectiveShareCode,
userId: embeddedUserId || undefined,
})
setWebAppPassport(effectiveShareCode, access_token)
setWebAppPassport(address, access_token)
redirectOrFinish()
} catch (error) {
if (error instanceof Response && error.status === 404) {
setUnavailableShareCode(effectiveShareCode)
await webAppLogout(effectiveShareCode)
await webAppLogout(address)
return
}
await webAppLogout(effectiveShareCode)
await webAppLogout(address)
proceedToAuth()
}
}
+13
View File
@@ -0,0 +1,13 @@
import * as React from 'react'
import Main from '@/app/components/share/text-generation'
import AuthenticatedLayout from '../../../components/authenticated-layout'
const EnvironmentWorkflow = () => {
return (
<AuthenticatedLayout>
<Main isWorkflow />
</AuthenticatedLayout>
)
}
export default React.memo(EnvironmentWorkflow)
@@ -11,6 +11,7 @@ describe('resolveWebAppLoginRedirect', () => {
expect(result).toEqual({
appCode: 'share-app',
address: { kind: 'default', code: 'share-app' },
target: { kind: 'internal', href: '/chatbot/share-app?foo=bar#answer' },
})
})
@@ -23,6 +24,19 @@ describe('resolveWebAppLoginRedirect', () => {
expect(result?.target.href).toBe(redirectUrl)
expect(result?.appCode).toBe('share-app')
})
it('should resolve an environment workflow redirect', () => {
const result = resolveWebAppLoginRedirect(
'/env/workflow/workflow-app',
'https://self-hosted.example.com',
)
expect(result).toEqual({
appCode: 'workflow-app',
address: { kind: 'environment', code: 'workflow-app' },
target: { kind: 'internal', href: '/env/workflow/workflow-app' },
})
})
})
// Covers absolute destinations accepted by the shared login redirect policy.
@@ -35,6 +49,7 @@ describe('resolveWebAppLoginRedirect', () => {
expect(result).toEqual({
appCode: 'share-app',
address: { kind: 'default', code: 'share-app' },
target: {
kind: 'absolute',
href: 'http://self-hosted.example.com:8080/chatbot/share-app',
@@ -1,3 +1,4 @@
import { zSsoProtocol } from '@dify/contracts/api/console/system-features/zod.gen'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AccessMode } from '@/models/access-control'
@@ -6,13 +7,20 @@ import { renderWithConsoleQuery } from '@/test/console/query-data'
import WebSSOForm from '../page'
const navigationMocks = vi.hoisted(() => ({
push: vi.fn(),
replace: vi.fn(),
searchParams: new URLSearchParams(),
}))
const serviceMocks = vi.hoisted(() => ({
fetchWebOAuth2SSOUrl: vi.fn(),
fetchWebOIDCSSOUrl: vi.fn(),
fetchWebSAMLSSOUrl: vi.fn(),
}))
const webAppState = {
shareCode: 'share-app',
webAppAccessMode: AccessMode.PUBLIC,
webAppAccessMode: AccessMode.PUBLIC as AccessMode,
}
vi.mock('@/context/web-app-context', () => ({
@@ -20,14 +28,20 @@ vi.mock('@/context/web-app-context', () => ({
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ replace: navigationMocks.replace }),
useRouter: () => ({ push: navigationMocks.push, replace: navigationMocks.replace }),
useSearchParams: () => navigationMocks.searchParams,
}))
vi.mock('@/service/share', () => serviceMocks)
vi.mock('@/service/webapp-auth', () => ({
webAppLogout: vi.fn(),
}))
afterEach(() => {
window.history.replaceState({}, '', '/')
})
describe('WebSSOForm redirect security', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -51,6 +65,7 @@ describe('WebSSOForm redirect security', () => {
navigationMocks.searchParams = new URLSearchParams({
redirect_url: encodeURIComponent('/chatbot/share-app'),
})
window.history.replaceState({}, '', '/webapp-signin?redirect_url=%2Fchatbot%2Fshare-app')
renderWithConsoleQuery(<WebSSOForm />, {
systemFeatures: { webapp_auth: { enabled: true } },
@@ -58,9 +73,41 @@ describe('WebSSOForm redirect security', () => {
await user.click(await screen.findByRole('button', { name: 'share.login.backToHome' }))
expect(webAppLogout).toHaveBeenCalledWith('share-app')
expect(webAppLogout).toHaveBeenCalledWith({ kind: 'default', code: 'share-app' })
expect(navigationMocks.replace).toHaveBeenCalledWith(
'/webapp-signin?redirect_url=%2Fchatbot%2Fshare-app',
)
})
})
describe('WebSSOForm environment access modes', () => {
beforeEach(() => {
vi.clearAllMocks()
navigationMocks.searchParams = new URLSearchParams({
redirect_url: '/env/workflow/workflow-app',
})
})
afterEach(() => {
webAppState.webAppAccessMode = AccessMode.PUBLIC
})
it('should start web SSO for an sso verified environment webapp', async () => {
webAppState.webAppAccessMode = AccessMode.EXTERNAL_MEMBERS
serviceMocks.fetchWebSAMLSSOUrl.mockResolvedValue({ url: 'https://idp.example/authorize' })
renderWithConsoleQuery(<WebSSOForm />, {
systemFeatures: {
webapp_auth: { enabled: true, sso_config: { protocol: zSsoProtocol.enum.saml } },
},
})
await waitFor(() => {
expect(serviceMocks.fetchWebSAMLSSOUrl).toHaveBeenCalledWith(
'workflow-app',
'/env/workflow/workflow-app',
)
})
expect(navigationMocks.push).toHaveBeenCalledWith('https://idp.example/authorize')
})
})
@@ -66,7 +66,7 @@ export default function CheckCode() {
appCode: loginRedirect.appCode,
userId: embeddedUserId || undefined,
})
setWebAppPassport(loginRedirect.appCode, access_token)
setWebAppPassport(loginRedirect.address, access_token)
replaceLoginRedirect(loginRedirect.target, router.replace, basePath)
}
} catch (error) {
@@ -83,7 +83,7 @@ export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAut
appCode: loginRedirect.appCode,
userId: embeddedUserId || undefined,
})
setWebAppPassport(loginRedirect.appCode, access_token)
setWebAppPassport(loginRedirect.address, access_token)
replaceLoginRedirect(loginRedirect.target, router.replace, basePath)
} else {
toast.error(res.data)
@@ -1,10 +1,13 @@
import type { WebAppAddress } from '@/service/webapp-address'
import type { LoginRedirectTarget } from '@/utils/login-redirect'
import { parseWebAppAddress } from '@/service/webapp-address'
import { resolveLoginRedirectTarget } from '@/utils/login-redirect'
const INTERNAL_PATH_PARSE_BASE = 'https://login-redirect.invalid'
export type WebAppLoginRedirect = {
appCode: string
address: WebAppAddress
target: LoginRedirectTarget
}
@@ -40,10 +43,10 @@ export function resolveWebAppLoginRedirect(
const url = new URL(target.href, currentOrigin || INTERNAL_PATH_PARSE_BASE)
if (isWebAppSigninPath(url.pathname)) return null
const appCode = url.pathname.split('/').filter(Boolean).at(-1)
if (!appCode) return null
const address = parseWebAppAddress(url.pathname)
if (!address) return null
return { appCode, target }
return { appCode: address.code, address, target }
} catch {
return null
}
+3 -3
View File
@@ -9,6 +9,7 @@ import { useWebAppStore } from '@/context/web-app-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { AccessMode } from '@/models/access-control'
import { useRouter, useSearchParams } from '@/next/navigation'
import { resolveWebAppAddress } from '@/service/webapp-address'
import { webAppLogout } from '@/service/webapp-auth'
import { getClientLoginFallback } from '@/utils/login-redirect'
import { replaceLoginRedirect } from '@/utils/login-redirect.client'
@@ -44,12 +45,11 @@ function WebSSOForm() {
return `/webapp-signin?${params.toString()}`
}, [redirectUrl])
const shareCode = useWebAppStore((s) => s.shareCode)
const backToHome = useCallback(async () => {
await webAppLogout(shareCode!)
await webAppLogout(resolveWebAppAddress())
const url = getSigninUrl()
router.replace(url)
}, [getSigninUrl, router, shareCode])
}, [getSigninUrl, router])
if (!loginRedirect) {
return (
@@ -18,6 +18,7 @@ const render = (ui: Parameters<typeof renderWithConsoleQuery>[0]) =>
renderWithConsoleQuery(ui, {
systemFeatures: {
rbac_enabled: mockIsRbacEnabled,
enable_app_deploy: false,
},
})
@@ -44,7 +45,12 @@ vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => mockConsoleState.current)
})
vi.mock('@/context/workspace-state', async () => {
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
return createWorkspaceStateModuleMock(() => ({
isCurrentWorkspaceEditor: false,
}))
})
vi.mock('@/next/navigation', () => ({
usePathname: () => mockPathname,
}))
@@ -183,6 +189,58 @@ describe('AppDetailSection', () => {
).not.toBeInTheDocument()
})
it('should render access point navigation using its app route', () => {
// Act
render(<AppDetailSection />)
// Assert
expect(screen.getByRole('link', { name: 'common.appMenus.accessPoint' })).toHaveAttribute(
'href',
'/app/app-1/access-point',
)
expect(
screen.queryByRole('link', { name: 'common.appMenus.apiAccess' }),
).not.toBeInTheDocument()
})
it('should render deploy navigation with app deploy ACL regardless of the legacy workspace role', () => {
// Arrange
mockAppMode = 'workflow'
mockAppPermissionKeys = [AppACLPermission.Deploy]
// Act
render(<AppDetailSection />)
// Assert
expect(screen.getByRole('link', { name: 'common.appMenus.deploy' })).toHaveAttribute(
'href',
'/app/app-1/deploy',
)
})
it.each([
{
label: 'the app is not a workflow app',
mode: 'chat',
permissionKeys: [AppACLPermission.Deploy],
},
{
label: 'app deploy ACL permission is missing',
mode: 'workflow',
permissionKeys: [AppACLPermission.Monitor],
},
])('should hide deploy navigation when $label', ({ mode, permissionKeys }) => {
// Arrange
mockAppMode = mode
mockAppPermissionKeys = permissionKeys
// Act
render(<AppDetailSection />)
// Assert
expect(screen.queryByRole('link', { name: 'common.appMenus.deploy' })).not.toBeInTheDocument()
})
it('should render resource access navigation when app access config permission is granted', () => {
// Arrange
mockAppPermissionKeys = [AppACLPermission.AccessConfig]
@@ -1,104 +0,0 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import AppBasic from '../basic'
vi.mock('@/app/components/base/icons/src/vender/workflow', () => ({
ApiAggregate: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="api-icon" {...props} />,
WindowCursor: (props: React.SVGProps<SVGSVGElement>) => (
<svg data-testid="webapp-icon" {...props} />
),
}))
vi.mock('../../base/app-icon', () => ({
default: ({
icon,
background,
innerIcon,
className,
}: {
icon?: string
background?: string
innerIcon?: React.ReactNode
className?: string
}) => (
<div data-testid="app-icon" data-icon={icon} data-bg={background} className={className}>
{innerIcon}
</div>
),
}))
describe('AppBasic', () => {
describe('Icon rendering', () => {
it('should render app icon when iconType is app with valid icon and background', () => {
render(<AppBasic name="Test" type="Chat" icon="🤖" icon_background="#fff" />)
expect(screen.getByTestId('app-icon')).toBeInTheDocument()
})
it('should not render app icon when icon is empty', () => {
render(<AppBasic name="Test" type="Chat" />)
expect(screen.queryByTestId('app-icon')).not.toBeInTheDocument()
})
it('should render api icon when iconType is api', () => {
render(<AppBasic name="Test" type="API" iconType="api" />)
expect(screen.getByTestId('api-icon')).toBeInTheDocument()
})
it('should render webapp icon when iconType is webapp', () => {
render(<AppBasic name="Test" type="Webapp" iconType="webapp" />)
expect(screen.getByTestId('webapp-icon')).toBeInTheDocument()
})
it('should render dataset icon when iconType is dataset', () => {
render(<AppBasic name="Test" type="Dataset" iconType="dataset" />)
const icons = screen.getAllByTestId('app-icon')
expect(icons.length).toBeGreaterThan(0)
})
it('should render notion icon when iconType is notion', () => {
render(<AppBasic name="Test" type="Notion" iconType="notion" />)
const icons = screen.getAllByTestId('app-icon')
expect(icons.length).toBeGreaterThan(0)
})
})
describe('Expand mode', () => {
it('should show name and type in expand mode', () => {
render(<AppBasic name="My App" type="Chatbot" />)
expect(screen.getByText('My App')).toBeInTheDocument()
expect(screen.getByText('Chatbot')).toBeInTheDocument()
})
it('should hide name and type in collapse mode', () => {
render(<AppBasic name="My App" type="Chatbot" mode="collapse" />)
expect(screen.queryByText('My App')).not.toBeInTheDocument()
})
it('should show hover tip when provided', () => {
render(<AppBasic name="My App" type="Chatbot" hoverTip="Some tip" />)
expect(screen.getByLabelText('Some tip')).toBeInTheDocument()
})
it('should not show hover tip when not provided', () => {
render(<AppBasic name="My App" type="Chatbot" />)
expect(screen.queryByLabelText('Some tip')).not.toBeInTheDocument()
})
})
describe('Type display', () => {
it('should hide type when hideType is true', () => {
render(<AppBasic name="My App" type="Chatbot" hideType />)
expect(screen.queryByText('Chatbot')).not.toBeInTheDocument()
})
it('should show external tag when isExternal is true', () => {
render(<AppBasic name="My App" type="Dataset" isExternal />)
expect(screen.getByText('dataset.externalTag')).toBeInTheDocument()
})
it('should show type inline when isExtraInLine is true and hideType is false', () => {
render(<AppBasic name="My App" type="Chatbot" isExtraInLine />)
expect(screen.getByText('Chatbot')).toBeInTheDocument()
})
})
})
@@ -3,18 +3,6 @@
import type { ComponentProps } from 'react'
import type { NavIcon } from './nav-link'
import { cn } from '@langgenius/dify-ui/cn'
import {
RiDashboard2Fill,
RiDashboard2Line,
RiFileList3Fill,
RiFileList3Line,
RiLock2Fill,
RiLock2Line,
RiTerminalBoxFill,
RiTerminalBoxLine,
RiTerminalWindowFill,
RiTerminalWindowLine,
} from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { Fragment, useMemo } from 'react'
@@ -45,6 +33,28 @@ const AnnotationNavIcon = ({ className, ...props }: ComponentProps<typeof Annota
AnnotationNavIcon.displayName = 'Annotations'
const createClassNameNavIcon = (iconClassName: string) => {
const ClassNameNavIcon = ({ className }: ComponentProps<'svg'>) => (
<span aria-hidden className={cn(iconClassName, className)} />
)
ClassNameNavIcon.displayName = 'ClassNameNavIcon'
return ClassNameNavIcon
}
const accessPointNavIcon = createClassNameNavIcon('i-custom-vender-agent-v2-access-point')
const terminalWindowLineNavIcon = createClassNameNavIcon('i-ri-terminal-window-line')
const terminalWindowFillNavIcon = createClassNameNavIcon('i-ri-terminal-window-fill')
const instanceLineNavIcon = createClassNameNavIcon('i-ri-instance-line')
const instanceFillNavIcon = createClassNameNavIcon('i-ri-instance-fill')
const fileListLineNavIcon = createClassNameNavIcon('i-ri-file-list-3-line')
const fileListFillNavIcon = createClassNameNavIcon('i-ri-file-list-3-fill')
const dashboardLineNavIcon = createClassNameNavIcon('i-ri-dashboard-2-line')
const dashboardFillNavIcon = createClassNameNavIcon('i-ri-dashboard-2-fill')
const lockLineNavIcon = createClassNameNavIcon('i-ri-lock-2-line')
const lockFillNavIcon = createClassNameNavIcon('i-ri-lock-2-fill')
const isLogsNavItem = (item: AppDetailNavItem) => item.href.endsWith('/logs')
const isAnnotationsNavItem = (item: AppDetailNavItem) => item.href.endsWith('/annotations')
@@ -88,6 +98,7 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => {
const appId = appDetail.id
const isWorkflowApp =
appDetail.mode === AppModeEnum.WORKFLOW || appDetail.mode === AppModeEnum.ADVANCED_CHAT
const supportsAppDeploy = appDetail.mode === AppModeEnum.WORKFLOW
const supportsAnnotations =
appDetail.mode !== AppModeEnum.WORKFLOW && appDetail.mode !== AppModeEnum.COMPLETION
const appACLCapabilities = getAppACLCapabilities(appDetail.permission_keys, {
@@ -103,24 +114,34 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => {
{
name: t(($) => $['appMenus.promptEng'], { ns: 'common' }),
href: `/app/${appId}/${isWorkflowApp ? 'workflow' : 'configuration'}`,
icon: RiTerminalWindowLine,
selectedIcon: RiTerminalWindowFill,
icon: terminalWindowLineNavIcon,
selectedIcon: terminalWindowFillNavIcon,
},
]
: []),
{
name: t(($) => $['appMenus.apiAccess'], { ns: 'common' }),
href: `/app/${appId}/develop`,
icon: RiTerminalBoxLine,
selectedIcon: RiTerminalBoxFill,
name: t(($) => $['appMenus.accessPoint'], { ns: 'common' }),
href: `/app/${appId}/access-point`,
icon: accessPointNavIcon,
selectedIcon: accessPointNavIcon,
},
...(supportsAppDeploy && appACLCapabilities.canDeploy
? [
{
name: t(($) => $['appMenus.deploy'], { ns: 'common' }),
href: `/app/${appId}/deploy`,
icon: instanceLineNavIcon,
selectedIcon: instanceFillNavIcon,
},
]
: []),
...(appACLCapabilities.canAccessLogAndAnnotation
? [
{
name: t(($) => $['appMenus.logs'], { ns: 'common' }),
href: `/app/${appId}/logs`,
icon: RiFileList3Line,
selectedIcon: RiFileList3Fill,
icon: fileListLineNavIcon,
selectedIcon: fileListFillNavIcon,
},
]
: []),
@@ -139,8 +160,8 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => {
{
name: t(($) => $['appMenus.overview'], { ns: 'common' }),
href: `/app/${appId}/overview`,
icon: RiDashboard2Line,
selectedIcon: RiDashboard2Fill,
icon: dashboardLineNavIcon,
selectedIcon: dashboardFillNavIcon,
},
]
: []),
@@ -149,8 +170,8 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => {
{
name: t(($) => $['settings.resourceAccess'], { ns: 'common' }),
href: `/app/${appId}/access-config`,
icon: RiLock2Line,
selectedIcon: RiLock2Fill,
icon: lockLineNavIcon,
selectedIcon: lockFillNavIcon,
},
]
: []),
@@ -1,378 +0,0 @@
import type { App, AppSSO } from '@/types/app'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { render as renderWithConsoleState } from '@/test/console/render'
import { AppModeEnum } from '@/types/app'
import { AppACLPermission } from '@/utils/permission'
import AppInfoDetailPanel from '../app-info-detail-panel'
const mockWorkspacePermissionKeys = vi.hoisted(() => ({
value: ['app.create_and_management'] as string[],
}))
const mockConsoleState = vi.hoisted(() => ({
current: {
userProfile: { id: 'user-1' },
get workspacePermissionKeys() {
return mockWorkspacePermissionKeys.value
},
},
}))
const render = (ui: Parameters<typeof renderWithConsoleState>[0]) =>
renderWithConsoleState(ui, {
wrapper: createConsoleQueryWrapper({ accountProfile: { id: 'user-1' } }).wrapper,
})
vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => mockConsoleState.current)
})
vi.mock('../../../base/app-icon', () => ({
default: ({ size, icon }: { size: string; icon: string }) => (
<div data-testid="app-icon" data-size={size} data-icon={icon} />
),
}))
vi.mock('../app-info-detail-drawer', () => ({
AppInfoDetailDrawer: ({
open,
onClose,
children,
}: {
open: boolean
onClose: () => void
children: React.ReactNode
}) =>
open ? (
<div data-testid="app-info-detail-drawer">
<button type="button" data-testid="drawer-close" onClick={onClose}>
Close
</button>
{children}
</div>
) : null,
}))
vi.mock('@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view', () => ({
default: ({ appId }: { appId: string }) => <div data-testid="card-view" data-app-id={appId} />,
}))
vi.mock('../app-operations', () => ({
default: ({
primaryOperations,
secondaryOperations,
}: {
primaryOperations?: Array<{
id: string
title: string
onClick: () => void
disabled?: boolean
loading?: boolean
}>
secondaryOperations?: Array<{ id: string; title: string; onClick: () => void; type?: string }>
}) => (
<div data-testid="app-operations">
{primaryOperations?.map((op) => (
<button
key={op.id}
type="button"
data-testid={`op-${op.id}`}
data-loading={op.loading || undefined}
disabled={op.disabled}
onClick={op.onClick}
>
{op.title}
</button>
))}
{secondaryOperations?.map((op) =>
op.type === 'divider' ? (
<button key={op.id} type="button" data-testid={`op-${op.id}`} onClick={op.onClick}>
divider
</button>
) : (
<button key={op.id} type="button" data-testid={`op-${op.id}`} onClick={op.onClick}>
{op.title}
</button>
),
)}
</div>
),
}))
const defaultAppPermissionKeys = [
AppACLPermission.Edit,
AppACLPermission.ImportExportDSL,
AppACLPermission.Delete,
]
const createAppDetail = (overrides: Partial<App> = {}): App & Partial<AppSSO> =>
({
id: 'app-1',
name: 'Test App',
mode: AppModeEnum.CHAT,
icon: '🤖',
icon_type: 'emoji',
icon_background: '#FFEAD5',
icon_url: '',
description: 'A test description',
use_icon_as_answer_icon: false,
permission_keys: defaultAppPermissionKeys,
...overrides,
}) as App & Partial<AppSSO>
describe('AppInfoDetailPanel', () => {
const defaultProps = {
appDetail: createAppDetail(),
show: true,
onClose: vi.fn(),
openModal: vi.fn(),
isExporting: false,
exportCheck: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
mockWorkspacePermissionKeys.value = ['app.create_and_management']
})
describe('Rendering', () => {
it('should not render when show is false', () => {
render(<AppInfoDetailPanel {...defaultProps} show={false} />)
expect(screen.queryByTestId('app-info-detail-drawer')).not.toBeInTheDocument()
})
it('should render drawer when show is true', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
expect(screen.getByTestId('app-info-detail-drawer')).toBeInTheDocument()
})
it('should display app name', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
expect(screen.getByText('Test App')).toBeInTheDocument()
})
it('should display app mode label', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
})
it('should display description when available', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
expect(screen.getByText('A test description')).toBeInTheDocument()
})
it('should not display description when empty', () => {
render(
<AppInfoDetailPanel {...defaultProps} appDetail={createAppDetail({ description: '' })} />,
)
expect(screen.queryByText('A test description')).not.toBeInTheDocument()
})
it('should not display description when undefined', () => {
render(
<AppInfoDetailPanel
{...defaultProps}
appDetail={createAppDetail({ description: undefined as unknown as string })}
/>,
)
expect(screen.queryByText('A test description')).not.toBeInTheDocument()
})
it('should render CardView with correct appId', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
const cardView = screen.getByTestId('card-view')
expect(cardView).toHaveAttribute('data-app-id', 'app-1')
})
it('should render app icon with large size', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
const icon = screen.getByTestId('app-icon')
expect(icon).toHaveAttribute('data-size', 'large')
})
})
describe('Operations', () => {
it('should render edit, duplicate, and export operations', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
expect(screen.getByTestId('op-edit')).toBeInTheDocument()
expect(screen.getByTestId('op-duplicate')).toBeInTheDocument()
expect(screen.getByTestId('op-export')).toBeInTheDocument()
})
it('should call openModal with edit when edit is clicked', async () => {
const user = userEvent.setup()
render(<AppInfoDetailPanel {...defaultProps} />)
await user.click(screen.getByTestId('op-edit'))
expect(defaultProps.openModal).toHaveBeenCalledWith('edit')
})
it('should call openModal with duplicate when duplicate is clicked', async () => {
const user = userEvent.setup()
render(<AppInfoDetailPanel {...defaultProps} />)
await user.click(screen.getByTestId('op-duplicate'))
expect(defaultProps.openModal).toHaveBeenCalledWith('duplicate')
})
it('should hide duplicate operation when app.create_and_management permission is missing', () => {
mockWorkspacePermissionKeys.value = []
render(<AppInfoDetailPanel {...defaultProps} />)
expect(screen.queryByTestId('op-duplicate')).not.toBeInTheDocument()
})
it('should call exportCheck when export is clicked', async () => {
const user = userEvent.setup()
render(<AppInfoDetailPanel {...defaultProps} />)
await user.click(screen.getByTestId('op-export'))
expect(defaultProps.exportCheck).toHaveBeenCalledTimes(1)
})
it('should show the export operation as loading while export is pending', () => {
render(<AppInfoDetailPanel {...defaultProps} isExporting />)
expect(screen.getByTestId('op-export')).toHaveAttribute('data-loading', 'true')
expect(screen.getByTestId('op-export')).not.toBeDisabled()
})
it('should render delete operation', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
expect(screen.getByTestId('op-delete')).toBeInTheDocument()
})
it('should call openModal with delete when delete is clicked', async () => {
const user = userEvent.setup()
render(<AppInfoDetailPanel {...defaultProps} />)
await user.click(screen.getByTestId('op-delete'))
expect(defaultProps.openModal).toHaveBeenCalledWith('delete')
})
})
describe('Import DSL option', () => {
it('should show import DSL for advanced_chat mode', () => {
render(
<AppInfoDetailPanel
{...defaultProps}
appDetail={createAppDetail({ mode: AppModeEnum.ADVANCED_CHAT })}
/>,
)
expect(screen.getByTestId('op-import')).toBeInTheDocument()
})
it('should show import DSL for workflow mode', () => {
render(
<AppInfoDetailPanel
{...defaultProps}
appDetail={createAppDetail({ mode: AppModeEnum.WORKFLOW })}
/>,
)
expect(screen.getByTestId('op-import')).toBeInTheDocument()
})
it('should not show import DSL for chat mode', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
expect(screen.queryByTestId('op-import')).not.toBeInTheDocument()
})
it('should not show import DSL when import/export DSL permission is missing', () => {
render(
<AppInfoDetailPanel
{...defaultProps}
appDetail={createAppDetail({
mode: AppModeEnum.WORKFLOW,
permission_keys: [AppACLPermission.Edit],
})}
/>,
)
expect(screen.queryByTestId('op-import')).not.toBeInTheDocument()
})
it('should call openModal with importDSL when import is clicked', async () => {
const user = userEvent.setup()
render(
<AppInfoDetailPanel
{...defaultProps}
appDetail={createAppDetail({ mode: AppModeEnum.ADVANCED_CHAT })}
/>,
)
await user.click(screen.getByTestId('op-import'))
expect(defaultProps.openModal).toHaveBeenCalledWith('importDSL')
})
it('should render divider in secondary operations', async () => {
const user = userEvent.setup()
render(<AppInfoDetailPanel {...defaultProps} />)
const divider = screen.getByTestId('op-divider-1')
expect(divider).toBeInTheDocument()
await user.click(divider)
})
})
describe('Switch operation', () => {
it('should show switch button for chat mode', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
expect(screen.getByText('app.switch')).toBeInTheDocument()
})
it('should show switch button for completion mode', () => {
render(
<AppInfoDetailPanel
{...defaultProps}
appDetail={createAppDetail({ mode: AppModeEnum.COMPLETION })}
/>,
)
expect(screen.getByText('app.switch')).toBeInTheDocument()
})
it('should not show switch button for workflow mode', () => {
render(
<AppInfoDetailPanel
{...defaultProps}
appDetail={createAppDetail({ mode: AppModeEnum.WORKFLOW })}
/>,
)
expect(screen.queryByText('app.switch')).not.toBeInTheDocument()
})
it('should not show switch button for advanced_chat mode', () => {
render(
<AppInfoDetailPanel
{...defaultProps}
appDetail={createAppDetail({ mode: AppModeEnum.ADVANCED_CHAT })}
/>,
)
expect(screen.queryByText('app.switch')).not.toBeInTheDocument()
})
it('should call openModal with switch when switch button is clicked', async () => {
const user = userEvent.setup()
render(<AppInfoDetailPanel {...defaultProps} />)
await user.click(screen.getByText('app.switch'))
expect(defaultProps.openModal).toHaveBeenCalledWith('switch')
})
})
describe('Drawer interactions', () => {
it('should call onClose when drawer close button is clicked', async () => {
const user = userEvent.setup()
render(<AppInfoDetailPanel {...defaultProps} />)
await user.click(screen.getByTestId('drawer-close'))
expect(defaultProps.onClose).toHaveBeenCalledTimes(1)
})
})
})
@@ -1,10 +1,35 @@
import type { App, AppSSO } from '@/types/app'
import { render, screen } from '@testing-library/react'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { createAccountProfileQueryWrapper } from '@/test/console/account-profile'
import { render as renderWithConsoleState } from '@/test/console/render'
import { AppModeEnum } from '@/types/app'
import { AppACLPermission } from '@/utils/permission'
import AppInfoTrigger from '../app-info-trigger'
const mockWorkspacePermissionKeys = vi.hoisted(() => ({
value: ['app.create_and_management'] as string[],
}))
const mockConsoleState = vi.hoisted(() => ({
current: {
userProfile: { id: 'user-1' },
get workspacePermissionKeys() {
return mockWorkspacePermissionKeys.value
},
},
}))
const render = (ui: Parameters<typeof renderWithConsoleState>[0]) =>
renderWithConsoleState(ui, {
wrapper: createAccountProfileQueryWrapper({ id: 'user-1' }),
})
vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => mockConsoleState.current)
})
vi.mock('../../../base/app-icon', () => ({
default: ({
size,
@@ -19,6 +44,12 @@ vi.mock('../../../base/app-icon', () => ({
}) => <div data-testid="app-icon" data-size={size} data-icon={icon} data-bg={background} />,
}))
const defaultAppPermissionKeys = [
AppACLPermission.Edit,
AppACLPermission.ImportExportDSL,
AppACLPermission.Delete,
]
const createAppDetail = (overrides: Partial<App> = {}): App & Partial<AppSSO> =>
({
id: 'app-1',
@@ -30,83 +61,121 @@ const createAppDetail = (overrides: Partial<App> = {}): App & Partial<AppSSO> =>
icon_url: '',
description: 'A test app',
use_icon_as_answer_icon: false,
permission_keys: defaultAppPermissionKeys,
maintainer: 'user-1',
...overrides,
}) as App & Partial<AppSSO>
const createProps = (overrides: Partial<React.ComponentProps<typeof AppInfoTrigger>> = {}) => ({
appDetail: createAppDetail(),
expand: true,
openModal: vi.fn(),
isExporting: false,
exportCheck: vi.fn(),
...overrides,
})
const getOperationsTrigger = () =>
screen.getByRole('button', { name: /common\.operation\.moreActionsFor/ })
describe('AppInfoTrigger', () => {
it('should render app icon with correct size when expanded', () => {
render(<AppInfoTrigger appDetail={createAppDetail()} expand onClick={vi.fn()} />)
const icon = screen.getByTestId('app-icon')
expect(icon).toHaveAttribute('data-size', 'large')
beforeEach(() => {
vi.clearAllMocks()
mockWorkspacePermissionKeys.value = ['app.create_and_management']
})
it('should render app icon with small size when collapsed', () => {
render(<AppInfoTrigger appDetail={createAppDetail()} expand={false} onClick={vi.fn()} />)
const icon = screen.getByTestId('app-icon')
expect(icon).toHaveAttribute('data-size', 'medium')
})
it('should show app name when expanded', () => {
render(
<AppInfoTrigger
appDetail={createAppDetail({ name: 'My Chatbot' })}
expand
onClick={vi.fn()}
/>,
)
expect(screen.getByText('My Chatbot')).toBeInTheDocument()
})
it('should not show app name when collapsed', () => {
render(
<AppInfoTrigger
appDetail={createAppDetail({ name: 'My Chatbot' })}
expand={false}
onClick={vi.fn()}
/>,
)
expect(screen.queryByText('My Chatbot')).not.toBeInTheDocument()
})
it('should show app mode label when expanded', () => {
render(
<AppInfoTrigger
appDetail={createAppDetail({ mode: AppModeEnum.ADVANCED_CHAT })}
expand
onClick={vi.fn()}
/>,
)
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
})
it('should not show mode label when collapsed', () => {
render(<AppInfoTrigger appDetail={createAppDetail()} expand={false} onClick={vi.fn()} />)
expect(screen.queryByText('app.types.chatbot')).not.toBeInTheDocument()
})
it('should call onClick when button is clicked', async () => {
it('renders expanded app metadata without making the app info clickable', async () => {
const user = userEvent.setup()
const onClick = vi.fn()
render(<AppInfoTrigger appDetail={createAppDetail()} expand onClick={onClick} />)
const props = createProps({
appDetail: createAppDetail({ name: 'My Chatbot', mode: AppModeEnum.ADVANCED_CHAT }),
})
render(<AppInfoTrigger {...props} />)
await user.click(screen.getByRole('button'))
expect(screen.getByTestId('app-icon')).toHaveAttribute('data-size', 'large')
expect(screen.getByText('My Chatbot')).toBeInTheDocument()
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
expect(screen.getByText('My Chatbot').closest('button')).toBeNull()
expect(onClick).toHaveBeenCalledTimes(1)
await user.click(screen.getByTestId('app-icon'))
expect(props.openModal).not.toHaveBeenCalled()
expect(props.exportCheck).not.toHaveBeenCalled()
})
it('should show settings icon in expanded and collapsed states', () => {
const { container, rerender } = render(
<AppInfoTrigger appDetail={createAppDetail()} expand onClick={vi.fn()} />,
it('renders only the medium app icon when collapsed', () => {
render(<AppInfoTrigger {...createProps({ expand: false })} />)
expect(screen.getByTestId('app-icon')).toHaveAttribute('data-size', 'medium')
expect(screen.queryByText('Test App')).not.toBeInTheDocument()
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('shows every available chat app operation and keeps workflow conversion last', async () => {
const user = userEvent.setup()
const props = createProps()
render(<AppInfoTrigger {...props} />)
await user.click(getOperationsTrigger())
expect(screen.getAllByRole('menuitem').map((item) => item.textContent)).toEqual([
'app.editApp',
'app.duplicate',
'app.export',
'common.operation.delete',
'app.switch',
])
await user.click(screen.getByRole('menuitem', { name: 'app.switch' }))
expect(props.openModal).toHaveBeenCalledWith('switch')
})
it('shows import DSL for workflow apps without a workflow conversion operation', async () => {
const user = userEvent.setup()
const props = createProps({
appDetail: createAppDetail({ mode: AppModeEnum.WORKFLOW }),
})
render(<AppInfoTrigger {...props} />)
await user.click(getOperationsTrigger())
expect(screen.getByRole('menuitem', { name: 'workflow.common.importDSL' })).toBeInTheDocument()
expect(screen.queryByRole('menuitem', { name: 'app.switch' })).not.toBeInTheDocument()
await user.click(screen.getByRole('menuitem', { name: 'workflow.common.importDSL' }))
expect(props.openModal).toHaveBeenCalledWith('importDSL')
})
it('runs export from the menu and disables it while an export is pending', async () => {
const user = userEvent.setup()
const props = createProps({ isExporting: true })
const { rerender } = render(<AppInfoTrigger {...props} />)
await user.click(getOperationsTrigger())
expect(screen.getByRole('menuitem', { name: 'app.export' })).toHaveAttribute(
'aria-disabled',
'true',
)
expect(container.querySelector('.i-ri-equalizer-2-line')).toBeInTheDocument()
rerender(<AppInfoTrigger appDetail={createAppDetail()} expand={false} onClick={vi.fn()} />)
expect(container.querySelector('.i-ri-equalizer-2-line')).not.toBeInTheDocument()
const readyProps = createProps()
rerender(<AppInfoTrigger {...readyProps} />)
await user.click(screen.getByRole('menuitem', { name: 'app.export' }))
expect(readyProps.exportCheck).toHaveBeenCalledTimes(1)
})
it('should center the icon wrapper when collapsed', () => {
render(<AppInfoTrigger appDetail={createAppDetail()} expand={false} onClick={vi.fn()} />)
const iconWrapper = screen.getByTestId('app-icon').parentElement
expect(iconWrapper?.parentElement).toHaveClass('items-center')
it('hides the operations trigger when no operation is permitted', () => {
mockWorkspacePermissionKeys.value = []
render(
<AppInfoTrigger
{...createProps({
appDetail: createAppDetail({
maintainer: 'user-2',
permission_keys: [AppACLPermission.ViewLayout],
}),
})}
/>,
)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
})
@@ -136,39 +136,12 @@ describe('useAppInfoActions', () => {
it('should return initial state correctly', () => {
const { result } = renderHook(() => useAppInfoActions({}))
expect(result.current.appDetail).toEqual(mockAppDetail)
expect(result.current.panelOpen).toBe(false)
expect(result.current.activeModal).toBeNull()
expect(result.current.secretEnvList).toEqual([])
})
})
describe('Panel management', () => {
it('should toggle panelOpen', () => {
const { result } = renderHook(() => useAppInfoActions({}))
act(() => {
result.current.setPanelOpen(true)
})
expect(result.current.panelOpen).toBe(true)
})
it('should close panel and call onDetailExpand', () => {
const onDetailExpand = vi.fn()
const { result } = renderHook(() => useAppInfoActions({ onDetailExpand }))
act(() => {
result.current.setPanelOpen(true)
})
act(() => {
result.current.closePanel()
})
expect(result.current.panelOpen).toBe(false)
expect(onDetailExpand).toHaveBeenCalledWith(false)
})
describe('App-scoped state', () => {
it('should reset app-scoped state when resetKey changes', () => {
const { result, rerender } = renderHook(({ resetKey }) => useAppInfoActions({ resetKey }), {
initialProps: { resetKey: 'app-1' },
@@ -176,34 +149,26 @@ describe('useAppInfoActions', () => {
act(() => {
result.current.openModal('delete')
result.current.setPanelOpen(true)
})
expect(result.current.panelOpen).toBe(true)
expect(result.current.activeModal).toBe('delete')
rerender({ resetKey: 'app-2' })
expect(result.current.panelOpen).toBe(false)
expect(result.current.activeModal).toBeNull()
expect(result.current.secretEnvList).toEqual([])
})
})
describe('Modal management', () => {
it('should open modal and close panel', () => {
it('should open modal', () => {
const { result } = renderHook(() => useAppInfoActions({}))
act(() => {
result.current.setPanelOpen(true)
})
act(() => {
result.current.openModal('edit')
})
expect(result.current.activeModal).toBe('edit')
expect(result.current.panelOpen).toBe(false)
})
it('should close modal', () => {
@@ -1,41 +0,0 @@
import type { ReactNode } from 'react'
import {
Drawer,
DrawerBackdrop,
DrawerContent,
DrawerPopup,
DrawerPortal,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
type AppInfoDetailDrawerProps = {
open: boolean
onClose: () => void
children: ReactNode
}
export function AppInfoDetailDrawer({ open, onClose, children }: AppInfoDetailDrawerProps) {
return (
<Drawer
open={open}
swipeDirection="left"
onOpenChange={(nextOpen) => {
if (!nextOpen) onClose()
}}
>
<DrawerPortal>
<DrawerBackdrop className="cursor-default bg-app-detail-overlay-bg" />
<DrawerViewport>
<DrawerPopup
aria-label="App info"
className="border-divider-burn bg-app-detail-bg p-0 data-[swipe-direction=left]:top-2 data-[swipe-direction=left]:bottom-2 data-[swipe-direction=left]:left-2 data-[swipe-direction=left]:h-auto data-[swipe-direction=left]:w-113 data-[swipe-direction=left]:max-w-[calc(100vw-1rem)] data-[swipe-direction=left]:rounded-2xl data-[swipe-direction=left]:border-r"
>
<DrawerContent className="flex min-h-0 flex-1 flex-col overflow-hidden p-0 pb-0">
{children}
</DrawerContent>
</DrawerPopup>
</DrawerViewport>
</DrawerPortal>
</Drawer>
)
}
@@ -1,198 +0,0 @@
import type { Operation } from './app-operations'
import type { AppInfoModalType } from './use-app-info-actions'
import type { App, AppSSO } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import {
RiDeleteBinLine,
RiEditLine,
RiExchange2Line,
RiFileCopy2Line,
RiFileDownloadLine,
RiFileUploadLine,
} from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { AppModeEnum } from '@/types/app'
import { getAppACLCapabilities, hasPermission } from '@/utils/permission'
import AppIcon from '../../base/app-icon'
import { AppInfoDetailDrawer } from './app-info-detail-drawer'
import { getAppModeLabel } from './app-mode-labels'
import AppOperations from './app-operations'
type AppInfoDetailPanelProps = {
appDetail: App & Partial<AppSSO>
show: boolean
onClose: () => void
openModal: (modal: Exclude<AppInfoModalType, null>) => void
isExporting: boolean
exportCheck: () => void
}
const AppInfoDetailPanel = ({
appDetail,
show,
onClose,
openModal,
isExporting,
exportCheck,
}: AppInfoDetailPanelProps) => {
const { t } = useTranslation()
const { data: currentUserId } = useSuspenseQuery({
...userProfileQueryOptions(),
select: (data) => data.profile.id,
})
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const appACLCapabilities = useMemo(
() =>
getAppACLCapabilities(appDetail.permission_keys, {
currentUserId,
resourceMaintainer: appDetail.maintainer,
workspacePermissionKeys,
}),
[appDetail.maintainer, appDetail.permission_keys, currentUserId, workspacePermissionKeys],
)
const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management')
const primaryOperations = useMemo<Operation[]>(
() => [
...(appACLCapabilities.canEdit
? [
{
id: 'edit',
title: t(($) => $.editApp, { ns: 'app' }),
icon: <RiEditLine />,
onClick: () => openModal('edit'),
},
]
: []),
...(canCreateApp
? [
{
id: 'duplicate',
title: t(($) => $.duplicate, { ns: 'app' }),
icon: <RiFileCopy2Line />,
onClick: () => openModal('duplicate'),
},
]
: []),
...(appACLCapabilities.canImportExportDSL
? [
{
id: 'export',
title: t(($) => $.export, { ns: 'app' }),
icon: <RiFileDownloadLine />,
onClick: exportCheck,
loading: isExporting,
},
]
: []),
],
[appACLCapabilities, canCreateApp, t, openModal, exportCheck, isExporting],
)
const secondaryOperations = useMemo<Operation[]>(
() => [
...(appACLCapabilities.canImportExportDSL &&
(appDetail.mode === AppModeEnum.ADVANCED_CHAT || appDetail.mode === AppModeEnum.WORKFLOW)
? [
{
id: 'import',
title: t(($) => $['common.importDSL'], { ns: 'workflow' }),
icon: <RiFileUploadLine />,
onClick: () => openModal('importDSL'),
},
]
: []),
...(appACLCapabilities.canDelete
? [
{
id: 'divider-1',
title: '',
icon: <></>,
onClick: () => {},
type: 'divider' as const,
},
{
id: 'delete',
title: t(($) => $['operation.delete'], { ns: 'common' }),
icon: <RiDeleteBinLine />,
onClick: () => openModal('delete'),
},
]
: []),
],
[appACLCapabilities, appDetail.mode, t, openModal],
)
const switchOperation = useMemo(() => {
if (!appACLCapabilities.canEdit) return null
if (appDetail.mode !== AppModeEnum.COMPLETION && appDetail.mode !== AppModeEnum.CHAT)
return null
return {
id: 'switch',
title: t(($) => $.switch, { ns: 'app' }),
icon: <RiExchange2Line />,
onClick: () => openModal('switch'),
}
}, [appACLCapabilities.canEdit, appDetail.mode, t, openModal])
return (
<AppInfoDetailDrawer open={show} onClose={onClose}>
<div className="flex shrink-0 flex-col items-start justify-center gap-3 self-stretch p-4">
<div className="flex items-center gap-3 self-stretch">
<AppIcon
size="large"
iconType={appDetail.icon_type}
icon={appDetail.icon}
background={appDetail.icon_background}
imageUrl={appDetail.icon_url}
/>
<div className="flex flex-1 flex-col items-start justify-center overflow-hidden">
<h2 className="w-full truncate system-md-semibold text-text-secondary">
{appDetail.name}
</h2>
<div className="system-2xs-medium-uppercase text-text-tertiary">
{getAppModeLabel(appDetail.mode, t)}
</div>
</div>
</div>
{appDetail.description && (
<p className="overflow-wrap-anywhere max-h-26.25 w-full max-w-full overflow-y-auto system-xs-regular wrap-break-word whitespace-normal text-text-tertiary">
{appDetail.description}
</p>
)}
<AppOperations
gap={4}
primaryOperations={primaryOperations}
secondaryOperations={secondaryOperations}
/>
</div>
<CardView
appId={appDetail.id}
isInPanel={true}
className="flex flex-1 flex-col gap-2 overflow-auto px-2 py-1"
/>
{switchOperation && (
<div className="flex min-h-fit shrink-0 flex-col items-start justify-center gap-3 self-stretch pb-2">
<Button
size="medium"
variant="ghost"
onClick={switchOperation.onClick}
>
{switchOperation.icon}
<span className="system-sm-medium text-text-tertiary">{switchOperation.title}</span>
</Button>
</div>
)}
</AppInfoDetailDrawer>
)
}
export default React.memo(AppInfoDetailPanel)
@@ -12,10 +12,10 @@ import {
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Input } from '@langgenius/dify-ui/input'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { DSLExportConfirmContent } from '@/app/components/workflow/dsl-export-confirm-modal'
import dynamic from '@/next/dynamic'
@@ -1,63 +1,157 @@
import type { Operation } from './app-operations'
import type { AppInfoModalType } from './use-app-info-actions'
import type { App, AppSSO } from '@/types/app'
import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { AppModeEnum } from '@/types/app'
import { getAppACLCapabilities, hasPermission } from '@/utils/permission'
import AppIcon from '../../base/app-icon'
import { getAppModeLabel } from './app-mode-labels'
import AppOperations from './app-operations'
type AppInfoTriggerProps = {
appDetail: App & Partial<AppSSO>
expand: boolean
onClick: () => void
openModal: (modal: Exclude<AppInfoModalType, null>) => void
isExporting: boolean
exportCheck: () => void
}
const AppInfoTrigger = ({ appDetail, expand, onClick }: AppInfoTriggerProps) => {
const AppInfoTrigger = ({
appDetail,
expand,
openModal,
isExporting,
exportCheck,
}: AppInfoTriggerProps) => {
const { t } = useTranslation()
const { data: currentUserId } = useSuspenseQuery({
...userProfileQueryOptions(),
select: (data) => data.profile.id,
})
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const modeLabel = getAppModeLabel(appDetail.mode, t)
const appACLCapabilities = getAppACLCapabilities(appDetail.permission_keys, {
currentUserId,
resourceMaintainer: appDetail.maintainer,
workspacePermissionKeys,
})
const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management')
const mainOperations: Operation[] = [
...(appACLCapabilities.canEdit
? [
{
id: 'edit',
title: t(($) => $.editApp, { ns: 'app' }),
icon: 'i-ri-edit-line',
onClick: () => openModal('edit'),
},
]
: []),
...(canCreateApp
? [
{
id: 'duplicate',
title: t(($) => $.duplicate, { ns: 'app' }),
icon: 'i-ri-file-copy-2-line',
onClick: () => openModal('duplicate'),
},
]
: []),
...(appACLCapabilities.canImportExportDSL
? [
{
id: 'export',
title: t(($) => $.export, { ns: 'app' }),
icon: 'i-ri-file-download-line',
onClick: exportCheck,
loading: isExporting,
},
]
: []),
...(appACLCapabilities.canImportExportDSL &&
(appDetail.mode === AppModeEnum.ADVANCED_CHAT || appDetail.mode === AppModeEnum.WORKFLOW)
? [
{
id: 'import',
title: t(($) => $['common.importDSL'], { ns: 'workflow' }),
icon: 'i-ri-file-upload-line',
onClick: () => openModal('importDSL'),
},
]
: []),
]
const destructiveOperations: Operation[] = appACLCapabilities.canDelete
? [
{
id: 'delete',
title: t(($) => $['operation.delete'], { ns: 'common' }),
icon: 'i-ri-delete-bin-line',
onClick: () => openModal('delete'),
variant: 'destructive',
},
]
: []
const workflowConversionOperations: Operation[] =
appACLCapabilities.canEdit &&
(appDetail.mode === AppModeEnum.COMPLETION || appDetail.mode === AppModeEnum.CHAT)
? [
{
id: 'switch',
title: t(($) => $.switch, { ns: 'app' }),
icon: 'i-ri-exchange-2-line',
onClick: () => openModal('switch'),
},
]
: []
return (
<button
type="button"
onClick={onClick}
className="block w-full"
aria-label={!expand ? `${appDetail.name} - ${modeLabel}` : undefined}
<div
className={cn(
'rounded-xl',
expand ? 'flex items-start gap-2 p-2' : 'flex items-center justify-center px-1 py-1.5',
)}
>
<div
className={cn(
'rounded-xl hover:bg-state-base-hover',
expand ? 'flex items-start gap-2 p-2' : 'flex items-center justify-center px-1 py-1.5',
)}
>
<div className="flex shrink-0 items-center">
<div>
<AppIcon
size={expand ? 'large' : 'medium'}
iconType={appDetail.icon_type}
icon={appDetail.icon}
background={appDetail.icon_background}
imageUrl={appDetail.icon_url}
<div className="flex shrink-0 items-center">
<div>
<AppIcon
size={expand ? 'large' : 'medium'}
iconType={appDetail.icon_type}
icon={appDetail.icon}
background={appDetail.icon_background}
imageUrl={appDetail.icon_url}
/>
</div>
</div>
{expand && (
<div className="flex min-w-0 flex-1 flex-col items-start justify-center gap-0.5 self-stretch">
<div className="flex w-full min-w-0 items-center gap-2 pr-1">
<div className="min-w-0 flex-1 truncate system-md-semibold text-text-secondary">
{appDetail.name}
</div>
<AppOperations
appName={appDetail.name}
operationGroups={[
mainOperations,
destructiveOperations,
workflowConversionOperations,
]}
/>
</div>
<div className="system-2xs-medium-uppercase whitespace-nowrap text-text-tertiary">
{modeLabel}
</div>
</div>
{expand && (
<>
<div className="flex min-w-0 flex-1 flex-col items-start justify-center gap-0.5 self-stretch">
<div className="flex w-full min-w-0 pr-1">
<div className="truncate system-md-semibold text-text-secondary">
{appDetail.name}
</div>
</div>
<div className="system-2xs-medium-uppercase whitespace-nowrap text-text-tertiary">
{modeLabel}
</div>
</div>
<div className="flex size-5 shrink-0 items-center justify-center rounded-md p-0.5">
<span aria-hidden className="i-ri-equalizer-2-line size-4 text-text-tertiary" />
</div>
</>
)}
</div>
</button>
)}
</div>
)
}
@@ -1,218 +1,92 @@
import type { JSX } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { RiMoreLine } from '@remixicon/react'
import { cloneElement, useEffect, useMemo, useRef, useState } from 'react'
import { Fragment } from 'react'
import { useTranslation } from 'react-i18next'
export type Operation = {
id: string
title: string
icon: JSX.Element
icon: string
onClick: () => void
disabled?: boolean
loading?: boolean
type?: 'divider'
variant?: 'default' | 'destructive'
}
type AppOperationsProps = {
gap: number
operations?: Operation[]
primaryOperations?: Operation[]
secondaryOperations?: Operation[]
appName: string
operationGroups: Operation[][]
}
const EMPTY_OPERATIONS: Operation[] = []
const AppOperations = ({
operations,
primaryOperations,
secondaryOperations,
gap,
}: AppOperationsProps) => {
const AppOperations = ({ appName, operationGroups }: AppOperationsProps) => {
const { t } = useTranslation()
const [visibleOpreations, setVisibleOperations] = useState<Operation[]>([])
const [moreOperations, setMoreOperations] = useState<Operation[]>([])
const [showMore, setShowMore] = useState(false)
const navRef = useRef<HTMLDivElement>(null)
const visibleGroups = operationGroups.filter((group) => group.length > 0)
const primaryOps = useMemo(() => {
if (operations) return operations
if (primaryOperations) return primaryOperations
return EMPTY_OPERATIONS
}, [operations, primaryOperations])
const secondaryOps = useMemo(() => {
if (operations) return EMPTY_OPERATIONS
if (secondaryOperations) return secondaryOperations
return EMPTY_OPERATIONS
}, [operations, secondaryOperations])
const inlineOperations = primaryOps.filter((operation) => operation.type !== 'divider')
useEffect(() => {
const applyState = (visible: Operation[], overflow: Operation[]) => {
const combinedMore = [...overflow, ...secondaryOps]
if (!overflow.length && combinedMore[0]?.type === 'divider') combinedMore.shift()
setVisibleOperations(visible)
setMoreOperations(combinedMore)
}
const inline = primaryOps.filter((operation) => operation.type !== 'divider')
if (!inline.length) {
applyState([], [])
return
}
const navElement = navRef.current
const moreElement = document.getElementById('more-measure')
if (!navElement || !moreElement) return
let width = 0
const containerWidth = navElement.clientWidth
const moreWidth = moreElement.clientWidth
if (containerWidth === 0 || moreWidth === 0) return
const updatedEntries: Record<string, boolean> = inline.reduce(
(pre, cur) => {
pre[cur.id] = false
return pre
},
{} as Record<string, boolean>,
)
const childrens = Array.from(navElement.children).slice(0, -1)
for (let i = 0; i < childrens.length; i++) {
const child = childrens[i] as HTMLElement
const id = child.dataset.targetid
if (!id) break
const childWidth = child.clientWidth
if (width + gap + childWidth + moreWidth <= containerWidth) {
updatedEntries[id] = true
width += gap + childWidth
} else {
if (i === childrens.length - 1 && width + childWidth <= containerWidth)
updatedEntries[id] = true
else updatedEntries[id] = false
break
}
}
const visible = inline.filter((item) => updatedEntries[item.id])
const overflow = inline.filter((item) => !updatedEntries[item.id])
applyState(visible, overflow)
}, [gap, primaryOps, secondaryOps])
const shouldShowMoreButton = moreOperations.length > 0
if (!visibleGroups.length) return null
return (
<>
<div
aria-hidden="true"
ref={navRef}
className="pointer-events-none flex h-0 items-center self-stretch overflow-hidden"
style={{ gap }}
<DropdownMenu modal={false}>
<DropdownMenuTrigger
aria-label={t(($) => $['operation.moreActionsFor'], {
ns: 'common',
name: appName,
})}
className="flex size-5 shrink-0 items-center justify-center rounded-md p-0.5 text-text-tertiary hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden data-popup-open:bg-state-base-hover"
>
{inlineOperations.map((operation) => (
<Button
key={operation.id}
data-targetid={operation.id}
size="small"
variant="secondary"
className="focus-visible:ring-inset"
disabled={operation.disabled}
loading={operation.loading}
tabIndex={-1}
>
{cloneElement(operation.icon, {
className: 'h-3.5 w-3.5 text-components-button-secondary-text',
})}
<span className="system-xs-medium text-components-button-secondary-text">
{operation.title}
</span>
</Button>
))}
<Button
id="more-measure"
size="small"
variant="secondary"
className="focus-visible:ring-inset"
tabIndex={-1}
>
<RiMoreLine className="size-3.5 text-components-button-secondary-text" />
<span className="system-xs-medium text-components-button-secondary-text">
{t(($) => $['operation.more'], { ns: 'common' })}
</span>
</Button>
</div>
<div className="flex items-center self-stretch overflow-hidden" style={{ gap }}>
{visibleOpreations.map((operation) => (
<Button
key={operation.id}
data-targetid={operation.id}
size="small"
variant="secondary"
className="focus-visible:ring-inset"
disabled={operation.disabled}
loading={operation.loading}
onClick={operation.onClick}
>
{cloneElement(operation.icon, {
className: 'h-3.5 w-3.5 text-components-button-secondary-text',
})}
<span className="system-xs-medium text-components-button-secondary-text">
{operation.title}
</span>
</Button>
))}
{shouldShowMoreButton && (
<DropdownMenu open={showMore} onOpenChange={setShowMore}>
<DropdownMenuTrigger
render={
<Button size="small" variant="secondary" className="focus-visible:ring-inset" />
}
>
<>
<RiMoreLine className="size-3.5 text-components-button-secondary-text" />
<span className="system-xs-medium text-components-button-secondary-text">
{t(($) => $['operation.more'], { ns: 'common' })}
</span>
</>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="min-w-[264px]"
>
{moreOperations.map((item) =>
item.type === 'divider' ? (
<DropdownMenuSeparator key={item.id} />
) : (
<DropdownMenuItem
key={item.id}
className="gap-x-1 px-1.5"
disabled={item.disabled}
onClick={item.onClick}
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-40">
{visibleGroups.map((group, groupIndex) => (
<Fragment key={group.map((operation) => operation.id).join('-')}>
{groupIndex > 0 && <DropdownMenuSeparator />}
<DropdownMenuGroup>
{group.map((operation) => (
<DropdownMenuItem
key={operation.id}
variant={operation.variant}
className="gap-2 px-3"
disabled={operation.disabled || operation.loading}
onClick={operation.onClick}
>
{operation.loading ? (
<span
aria-hidden
className="i-ri-loader-2-line size-4 animate-spin text-text-tertiary motion-reduce:animate-none"
/>
) : (
<span
aria-hidden
className={cn(
operation.icon,
'size-4',
operation.variant === 'destructive'
? 'text-text-destructive'
: 'text-text-tertiary',
)}
/>
)}
<span
className={cn(
'system-sm-regular',
operation.variant !== 'destructive' && 'text-text-secondary',
)}
>
{cloneElement(item.icon, { className: 'h-4 w-4 text-text-tertiary' })}
<span className="system-md-regular text-text-secondary">{item.title}</span>
</DropdownMenuItem>
),
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</>
{operation.title}
</span>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</Fragment>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -1,35 +1,15 @@
import type { AppInfoActions } from './use-app-info-actions'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import * as React from 'react'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { getAppACLCapabilities } from '@/utils/permission'
import AppInfoDetailPanel from './app-info-detail-panel'
import AppInfoModals from './app-info-modals'
import AppInfoTrigger from './app-info-trigger'
type IAppInfoProps = {
type AppInfoViewProps = {
expand: boolean
onlyShowDetail?: boolean
openState?: boolean
onDetailExpand?: (expand: boolean) => void
}
type AppInfoViewProps = Omit<IAppInfoProps, 'onDetailExpand'> & {
actions: AppInfoActions
renderDetail?: boolean
}
type AppInfoDetailLayerProps = {
actions: AppInfoActions
open?: boolean
}
const AppInfoDetailLayer = ({ actions, open = actions.panelOpen }: AppInfoDetailLayerProps) => {
export const AppInfoView = ({ expand, actions }: AppInfoViewProps) => {
const {
appDetail,
closePanel,
activeModal,
openModal,
closeModal,
@@ -48,10 +28,9 @@ const AppInfoDetailLayer = ({ actions, open = actions.panelOpen }: AppInfoDetail
return (
<>
<AppInfoDetailPanel
<AppInfoTrigger
appDetail={appDetail}
show={open}
onClose={closePanel}
expand={expand}
openModal={openModal}
isExporting={isExporting}
exportCheck={exportCheck}
@@ -73,44 +52,3 @@ const AppInfoDetailLayer = ({ actions, open = actions.panelOpen }: AppInfoDetail
</>
)
}
export const AppInfoView = ({
expand,
onlyShowDetail = false,
openState = false,
actions,
renderDetail = true,
}: AppInfoViewProps) => {
const { appDetail, panelOpen, setPanelOpen, activeModal, secretEnvList } = actions
const { data: currentUserId } = useSuspenseQuery({
...userProfileQueryOptions(),
select: (data) => data.profile.id,
})
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const appACLCapabilities = getAppACLCapabilities(appDetail?.permission_keys, {
currentUserId,
resourceMaintainer: appDetail?.maintainer,
workspacePermissionKeys,
})
if (!appDetail) return null
const detailLayerOpen = onlyShowDetail ? openState : panelOpen
const shouldRenderDetailLayer =
renderDetail && (detailLayerOpen || activeModal || secretEnvList.length > 0)
return (
<div>
{!onlyShowDetail && (
<AppInfoTrigger
appDetail={appDetail}
expand={expand}
onClick={() => {
if (appACLCapabilities.canAccessLayout) setPanelOpen((v) => !v)
}}
/>
)}
{shouldRenderDetailLayer && <AppInfoDetailLayer actions={actions} open={detailLayerOpen} />}
</div>
)
}
@@ -30,13 +30,11 @@ export type AppInfoModalType =
| null
type UseAppInfoActionsParams = {
onDetailExpand?: (expand: boolean) => void
resetKey?: string
}
type AppInfoUiState = {
resetKey?: string
panelOpen: boolean
activeModal: AppInfoModalType
secretEnvList: EnvironmentVariableItemResponse[]
}
@@ -75,7 +73,6 @@ const updateCachedAppMetadata = (cachedApp: AppDetailWithSite | undefined, app:
const createInitialUiState = (resetKey?: string): AppInfoUiState => ({
resetKey,
panelOpen: false,
activeModal: null,
secretEnvList: [],
})
@@ -88,7 +85,7 @@ const getCurrentUiState = (state: AppInfoUiState, resetKey?: string) => {
return state.resetKey === resetKey ? state : createInitialUiState(resetKey)
}
export function useAppInfoActions({ onDetailExpand, resetKey }: UseAppInfoActionsParams) {
export function useAppInfoActions({ resetKey }: UseAppInfoActionsParams) {
const { t } = useTranslation()
const { replace } = useRouter()
const queryClient = useQueryClient()
@@ -103,23 +100,9 @@ export function useAppInfoActions({ onDetailExpand, resetKey }: UseAppInfoAction
const [uiState, setUiState] = useState(() => createInitialUiState(resetKey))
const uiStateMatchesResetKey = uiState.resetKey === resetKey
const panelOpen = uiStateMatchesResetKey ? uiState.panelOpen : false
const activeModal = uiStateMatchesResetKey ? uiState.activeModal : null
const secretEnvList = uiStateMatchesResetKey ? uiState.secretEnvList : emptySecretEnvList
const setPanelOpen = useCallback<Dispatch<SetStateAction<boolean>>>(
(value) => {
setUiState((state) => {
const current = getCurrentUiState(state, resetKey)
return {
...current,
panelOpen: resolveStateAction(value, current.panelOpen),
}
})
},
[resetKey],
)
const setActiveModal = useCallback<Dispatch<SetStateAction<AppInfoModalType>>>(
(value) => {
setUiState((state) => {
@@ -146,17 +129,11 @@ export function useAppInfoActions({ onDetailExpand, resetKey }: UseAppInfoAction
[resetKey],
)
const closePanel = useCallback(() => {
setPanelOpen(false)
onDetailExpand?.(false)
}, [onDetailExpand, setPanelOpen])
const openModal = useCallback(
(modal: Exclude<AppInfoModalType, null>) => {
closePanel()
setActiveModal(modal)
},
[closePanel, setActiveModal],
[setActiveModal],
)
const closeModal = useCallback(() => {
@@ -352,9 +329,6 @@ export function useAppInfoActions({ onDetailExpand, resetKey }: UseAppInfoAction
return {
appDetail,
panelOpen,
setPanelOpen,
closePanel,
activeModal,
openModal,
closeModal,
-127
View File
@@ -1,127 +0,0 @@
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { ApiAggregate, WindowCursor } from '@/app/components/base/icons/src/vender/workflow'
import { Infotip } from '@/app/components/base/infotip'
import AppIcon from '../base/app-icon'
type IAppBasicProps = {
iconType?: 'app' | 'api' | 'dataset' | 'webapp' | 'notion'
icon?: string
icon_background?: string | null
isExternal?: boolean
name: string
type: string | React.ReactNode
hoverTip?: string
textStyle?: { main?: string; extra?: string }
isExtraInLine?: boolean
mode?: string
hideType?: boolean
}
const DatasetSvg = (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0.833497 5.13481C0.833483 4.69553 0.83347 4.31654 0.858973 4.0044C0.88589 3.67495 0.94532 3.34727 1.10598 3.03195C1.34567 2.56155 1.72812 2.17909 2.19852 1.93941C2.51384 1.77875 2.84152 1.71932 3.17097 1.6924C3.48312 1.6669 3.86209 1.66691 4.30137 1.66693L7.62238 1.66684C8.11701 1.66618 8.55199 1.66561 8.95195 1.80356C9.30227 1.92439 9.62134 2.12159 9.88607 2.38088C10.1883 2.67692 10.3823 3.06624 10.603 3.50894L11.3484 5.00008H14.3679C15.0387 5.00007 15.5924 5.00006 16.0434 5.03691C16.5118 5.07518 16.9424 5.15732 17.3468 5.36339C17.974 5.68297 18.4839 6.19291 18.8035 6.82011C19.0096 7.22456 19.0917 7.65515 19.13 8.12356C19.1668 8.57455 19.1668 9.12818 19.1668 9.79898V13.5345C19.1668 14.2053 19.1668 14.7589 19.13 15.2099C19.0917 15.6784 19.0096 16.1089 18.8035 16.5134C18.4839 17.1406 17.974 17.6505 17.3468 17.9701C16.9424 18.1762 16.5118 18.2583 16.0434 18.2966C15.5924 18.3334 15.0387 18.3334 14.3679 18.3334H5.63243C4.96163 18.3334 4.40797 18.3334 3.95698 18.2966C3.48856 18.2583 3.05798 18.1762 2.65353 17.9701C2.02632 17.6505 1.51639 17.1406 1.19681 16.5134C0.990734 16.1089 0.908597 15.6784 0.870326 15.2099C0.833478 14.7589 0.833487 14.2053 0.833497 13.5345V5.13481ZM7.51874 3.33359C8.17742 3.33359 8.30798 3.34447 8.4085 3.37914C8.52527 3.41942 8.63163 3.48515 8.71987 3.57158C8.79584 3.64598 8.86396 3.7579 9.15852 4.34704L9.48505 5.00008L2.50023 5.00008C2.50059 4.61259 2.50314 4.34771 2.5201 4.14012C2.5386 3.91374 2.57 3.82981 2.59099 3.7886C2.67089 3.6318 2.79837 3.50432 2.95517 3.42442C2.99638 3.40343 3.08031 3.37203 3.30669 3.35353C3.54281 3.33424 3.85304 3.33359 4.3335 3.33359H7.51874Z"
fill="#444CE7"
/>
</svg>
)
const NotionSvg = (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_6294_13848)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M4.287 21.9133L1.70748 18.6999C1.08685 17.9267 0.75 16.976 0.75 15.9974V4.36124C0.75 2.89548 1.92269 1.67923 3.43553 1.57594L15.3991 0.759137C16.2682 0.699797 17.1321 0.930818 17.8461 1.41353L22.0494 4.25543C22.8018 4.76414 23.25 5.59574 23.25 6.48319V19.7124C23.25 21.1468 22.0969 22.3345 20.6157 22.4256L7.3375 23.243C6.1555 23.3158 5.01299 22.8178 4.287 21.9133Z"
fill="white"
/>
<path
d="M8.43607 10.1842V10.0318C8.43607 9.64564 8.74535 9.32537 9.14397 9.29876L12.0475 9.10491L16.0628 15.0178V9.82823L15.0293 9.69046V9.6181C15.0293 9.22739 15.3456 8.90501 15.7493 8.88433L18.3912 8.74899V9.12918C18.3912 9.30765 18.2585 9.46031 18.0766 9.49108L17.4408 9.59861V18.0029L16.6429 18.2773C15.9764 18.5065 15.2343 18.2611 14.8527 17.6853L10.9545 11.803V17.4173L12.1544 17.647L12.1377 17.7583C12.0853 18.1069 11.7843 18.3705 11.4202 18.3867L8.43607 18.5195C8.39662 18.1447 8.67758 17.8093 9.06518 17.7686L9.45771 17.7273V10.2416L8.43607 10.1842Z"
fill="black"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M15.5062 2.22521L3.5426 3.04201C2.82599 3.09094 2.27051 3.66706 2.27051 4.36136V15.9975C2.27051 16.6499 2.49507 17.2837 2.90883 17.7992L5.48835 21.0126C5.90541 21.5322 6.56174 21.8183 7.24076 21.7765L20.519 20.9591C21.1995 20.9172 21.7293 20.3716 21.7293 19.7125V6.48332C21.7293 6.07557 21.5234 5.69348 21.1777 5.45975L16.9743 2.61784C16.546 2.32822 16.0277 2.1896 15.5062 2.22521ZM4.13585 4.54287C3.96946 4.41968 4.04865 4.16303 4.25768 4.14804L15.5866 3.33545C15.9476 3.30956 16.3063 3.40896 16.5982 3.61578L18.8713 5.22622C18.9576 5.28736 18.9171 5.41935 18.8102 5.42516L6.8129 6.07764C6.44983 6.09739 6.09144 5.99073 5.80276 5.77699L4.13585 4.54287ZM6.25018 8.12315C6.25018 7.7334 6.56506 7.41145 6.9677 7.38952L19.6523 6.69871C20.0447 6.67734 20.375 6.97912 20.375 7.35898V18.8141C20.375 19.2031 20.0613 19.5247 19.6594 19.5476L7.05516 20.2648C6.61845 20.2896 6.25018 19.954 6.25018 19.5312V8.12315Z"
fill="black"
/>
</g>
<defs>
<clipPath id="clip0_6294_13848">
<rect width="24" height="24" fill="white" />
</clipPath>
</defs>
</svg>
)
const ICON_MAP = {
app: <AppIcon className="border border-[rgba(0,0,0,0.05)]!" />,
api: (
<div className="rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-brand-blue-brand-500 p-1 shadow-md">
<ApiAggregate className="size-4 text-text-primary-on-surface" />
</div>
),
dataset: (
<AppIcon innerIcon={DatasetSvg} className="border-[0.5px]! border-indigo-100! bg-indigo-25!" />
),
webapp: (
<div className="rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-brand-blue-brand-500 p-1 shadow-md">
<WindowCursor className="size-4 text-text-primary-on-surface" />
</div>
),
notion: (
<AppIcon innerIcon={NotionSvg} className="border-[0.5px]! border-indigo-100! bg-white!" />
),
}
export default function AppBasic({
icon,
icon_background,
name,
isExternal,
type,
hoverTip,
textStyle,
isExtraInLine,
mode = 'expand',
iconType = 'app',
hideType,
}: IAppBasicProps) {
const { t } = useTranslation()
return (
<div className="flex grow items-center">
{icon && icon_background && iconType === 'app' && (
<div className="mr-2 shrink-0">
<AppIcon icon={icon} background={icon_background} />
</div>
)}
{iconType !== 'app' && <div className="mr-2 shrink-0">{ICON_MAP[iconType]}</div>}
{mode === 'expand' && (
<div className="group w-full">
<div
className={`flex flex-row items-center system-md-semibold text-text-secondary group-hover:text-text-primary ${textStyle?.main ?? ''}`}
>
<div className="min-w-0 overflow-hidden break-normal text-ellipsis">{name}</div>
{hoverTip && (
<Infotip aria-label={hoverTip} className="ml-1" popupClassName="w-[240px]">
{hoverTip}
</Infotip>
)}
</div>
{!hideType && isExtraInLine && (
<div className="flex system-2xs-medium-uppercase text-text-tertiary">{type}</div>
)}
{!hideType && !isExtraInLine && (
<div className="system-2xs-medium-uppercase text-text-tertiary">
{isExternal ? t(($) => $.externalTag, { ns: 'dataset' }) : type}
</div>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,49 @@
import type { AccessPointStatus } from '../shared/access-point-status'
import { screen } from '@testing-library/react'
import { render } from '@/test/console/render'
import { AccessPointCard } from '../shared/access-point-card'
describe('AccessPointCard', () => {
it('marks the card when it is the highlighted access point', () => {
render(
<AccessPointCard
title="Web App"
description="Web application access"
icon="i-ri-robot-2-line"
status="inService"
highlighted
>
Access URL
</AccessPointCard>,
)
expect(screen.getByRole('region', { name: 'Web App' })).toHaveAttribute(
'data-highlighted',
'true',
)
})
it.each<[AccessPointStatus, string, boolean]>([
['loading', 'common.loading', true],
['unsupported', 'deployments.studio.accessPoint.notSupported', false],
['unavailable', 'deployments.health.ENVIRONMENT_STATUS_FAILED', false],
])('renders the %s state independently', (status, label, busy) => {
render(
<AccessPointCard
title="Web App"
description="Web application access"
icon="i-ri-robot-2-line"
status={status}
onEnabledChange={vi.fn()}
>
Access URL
</AccessPointCard>,
)
expect(screen.getByText(label)).toBeInTheDocument()
const card = screen.getByRole('region', { name: 'Web App' })
if (busy) expect(card).toHaveAttribute('aria-busy', 'true')
else expect(card).not.toHaveAttribute('aria-busy')
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,33 @@
import { screen } from '@testing-library/react'
import { render } from '@/test/console/render'
import { AccessPointUrl } from '../shared/access-point-url'
const endpointProps = {
label: 'Access URL',
unavailableLabel: 'FAILED',
value: 'https://example.test/access',
}
describe('AccessPointUrl', () => {
it('keeps a disabled endpoint visible without marking it unavailable', () => {
render(<AccessPointUrl {...endpointProps} enabled={false} showOpen openLabel="Open" />)
expect(screen.getByText(endpointProps.value)).toBeInTheDocument()
expect(screen.queryByText(endpointProps.unavailableLabel)).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Open' })).toBeDisabled()
})
it('shows an unavailable endpoint without replacing it with a loading skeleton', () => {
render(<AccessPointUrl {...endpointProps} enabled={false} unavailable />)
expect(screen.getByText(endpointProps.unavailableLabel)).toBeInTheDocument()
expect(screen.getByText(endpointProps.value)).toBeInTheDocument()
})
it('shows loading independently from the unavailable state', () => {
render(<AccessPointUrl {...endpointProps} enabled={false} loading />)
expect(screen.queryByText(endpointProps.unavailableLabel)).not.toBeInTheDocument()
expect(screen.queryByText(endpointProps.value)).not.toBeInTheDocument()
})
})
@@ -0,0 +1,98 @@
import type { ApiKeyList } from '@dify/contracts/api/console/apps/types.gen'
import type { ReactElement } from 'react'
import type { SecretKeyScope } from '@/app/components/develop/secret-key/secret-key-modal'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { consoleQuery } from '@/service/client'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import { ApiSecretKeyButton } from '../shared/api-secret-key-button'
const appApiKeys: ApiKeyList = {
data: [
{ id: 'key-1', token: 'app-a', type: 'app', created_at: 1, last_used_at: 1 },
{ id: 'key-2', token: 'app-b', type: 'app', created_at: 2, last_used_at: 2 },
],
}
const render = (ui: ReactElement) => {
const queryClient = createConsoleQueryClient()
queryClient.setQueryData(
consoleQuery.apps.byResourceId.apiKeys.get.queryKey({
input: { params: { resource_id: 'app-1' } },
}),
appApiKeys,
)
return renderWithConsoleQuery(ui, { queryClient })
}
vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({
default: ({
canManage,
isShow,
scope,
}: {
canManage: boolean
isShow: boolean
scope: SecretKeyScope
}) =>
isShow ? (
<div role="dialog" aria-label="API key management">
{scope.type === 'dataset' ? '' : scope.appId}:
{scope.type === 'environment' ? scope.environmentId : ''}:{String(canManage)}
</div>
) : null,
}))
describe('ApiSecretKeyButton', () => {
it('shows the current API key count and opens key management', async () => {
const user = userEvent.setup()
render(<ApiSecretKeyButton appId="app-1" canManage />)
const button = screen.getByRole('button', {
name: 'appApi.apiKeyModal.apiSecretKey 2',
})
expect(button).toBeEnabled()
await user.click(button)
expect(screen.getByRole('dialog', { name: 'API key management' })).toHaveTextContent(
'app-1::true',
)
})
it('uses the environment API key count and opens environment-scoped key management', async () => {
const user = userEvent.setup()
render(<ApiSecretKeyButton appId="app-1" environmentId="staging" apiKeyCount={5} canManage />)
const button = screen.getByRole('button', {
name: 'appApi.apiKeyModal.apiSecretKey 5',
})
expect(button).toBeEnabled()
await user.click(button)
expect(screen.getByRole('dialog', { name: 'API key management' })).toHaveTextContent(
'app-1:staging:true',
)
})
it('keeps the current count visible when service access is disabled', () => {
render(<ApiSecretKeyButton appId="app-1" canManage disabled />)
expect(
screen.getByRole('button', {
name: 'appApi.apiKeyModal.apiSecretKey 2',
}),
).toBeDisabled()
})
it('keeps the current count visible without management permission', () => {
render(<ApiSecretKeyButton appId="app-1" canManage={false} />)
expect(
screen.getByRole('button', {
name: 'appApi.apiKeyModal.apiSecretKey 2',
}),
).toBeDisabled()
})
})
@@ -0,0 +1,221 @@
import { screen } from '@testing-library/react'
import { render } from '@/test/console/render'
import { BuiltInAccessPoints } from '../built-in-access-points'
const mocks = vi.hoisted(() => ({
appInfo: {
id: 'app-1',
mode: 'workflow',
enable_site: false,
enable_api: false,
permission_keys: [],
} as Record<string, unknown>,
workflow: {
data: null as Record<string, unknown> | null,
isPending: false,
},
webCard: vi.fn(),
apiCard: vi.fn(),
mcpCard: vi.fn(),
triggerCard: vi.fn(),
}))
vi.mock('react-i18next', async () => {
const { createReactI18nextMock } = await import('@/test/i18n-mock')
return createReactI18nextMock()
})
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useSuspenseQuery: () => ({
data: {
webapp_auth: { enabled: true },
},
}),
}
})
vi.mock('jotai', async (importOriginal) => {
const actual = await importOriginal<typeof import('jotai')>()
return {
...actual,
useAtomValue: () => undefined,
}
})
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({ appDetail: mocks.appInfo }),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => path,
}))
vi.mock('@/service/use-workflow', () => ({
useAppWorkflow: () => mocks.workflow,
}))
vi.mock('@/utils/permission', () => ({
getAppACLCapabilities: () => ({
canEdit: false,
canDeploy: true,
canReleaseAndVersion: false,
}),
}))
vi.mock('../shared/use-access-point-actions', () => ({
useAccessPointActions: () => ({
changeApiStatus: vi.fn(),
changeSiteStatus: vi.fn(),
handleResult: vi.fn(),
refreshAppDetail: vi.fn(),
regenerateSiteCode: vi.fn(),
saveSiteConfig: vi.fn(),
}),
}))
vi.mock('../built-in-access-points/web-app-card', () => ({
WebAppAccessPointCard: (props: Record<string, unknown>) => {
mocks.webCard(props)
return <div data-testid="web-app-card" />
},
}))
vi.mock('../built-in-access-points/service-api-card', () => ({
ServiceApiAccessPointCard: (props: Record<string, unknown>) => {
mocks.apiCard(props)
return <div data-testid="service-api-card" />
},
}))
vi.mock('../built-in-access-points/mcp-card', () => ({
MCPAccessPointCard: (props: Record<string, unknown>) => {
mocks.mcpCard(props)
return <div data-testid="mcp-card" />
},
}))
vi.mock('../built-in-access-points/trigger-card', () => ({
TriggerAccessPointCard: (props: Record<string, unknown>) => {
mocks.triggerCard(props)
return <div data-testid="trigger-card" />
},
}))
describe('BuiltInAccessPoints', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.appInfo = {
id: 'app-1',
mode: 'workflow',
enable_site: false,
enable_api: false,
permission_keys: [],
}
mocks.workflow = {
data: null,
isPending: false,
}
})
it('renders the unpublished state across all access point cards', () => {
render(<BuiltInAccessPoints appId="app-1" />)
expect(screen.getByText('deployments.studio.accessPoint.noPublishedTitle')).toBeInTheDocument()
expect(screen.getByTestId('web-app-card')).toBeInTheDocument()
expect(screen.getByTestId('service-api-card')).toBeInTheDocument()
expect(screen.getByTestId('mcp-card')).toBeInTheDocument()
expect(screen.getByTestId('trigger-card')).toBeInTheDocument()
expect(mocks.webCard).toHaveBeenCalledWith(
expect.objectContaining({ availability: 'unavailable', canDeploy: true, canEdit: false }),
)
expect(mocks.apiCard).toHaveBeenCalledWith(
expect.objectContaining({ availability: 'unavailable', canEdit: false }),
)
expect(mocks.triggerCard).toHaveBeenCalledWith(
expect.objectContaining({ availability: 'unavailable', canEdit: false }),
)
})
it('keeps Trigger unavailable when no trigger node is published', () => {
mocks.workflow = {
data: {
graph: {
nodes: [{ data: { type: 'start' } }],
},
},
isPending: false,
}
render(<BuiltInAccessPoints appId="app-1" />)
expect(
screen.queryByText('deployments.studio.accessPoint.noPublishedTitle'),
).not.toBeInTheDocument()
expect(mocks.webCard).toHaveBeenCalledWith(
expect.objectContaining({ availability: 'available', workflow: mocks.workflow.data }),
)
expect(mocks.apiCard).toHaveBeenCalledWith(
expect.objectContaining({ availability: 'available' }),
)
expect(mocks.triggerCard).toHaveBeenCalledWith(
expect.objectContaining({ availability: 'unavailable' }),
)
})
it('highlights only the targeted built-in access point card', () => {
render(<BuiltInAccessPoints appId="app-1" highlightedAccessPoint="mcp" />)
expect(mocks.webCard).toHaveBeenCalledWith(expect.objectContaining({ highlighted: false }))
expect(mocks.apiCard).toHaveBeenCalledWith(expect.objectContaining({ highlighted: false }))
expect(mocks.mcpCard).toHaveBeenCalledWith(expect.objectContaining({ highlighted: true }))
expect(mocks.triggerCard).toHaveBeenCalledWith(expect.objectContaining({ highlighted: false }))
})
it('enables Trigger and disables the other access points in trigger mode', () => {
mocks.workflow = {
data: {
graph: {
nodes: [{ data: { type: 'trigger-webhook' } }],
},
},
isPending: false,
}
render(<BuiltInAccessPoints appId="app-1" />)
expect(mocks.webCard).toHaveBeenCalledWith(
expect.objectContaining({ availability: 'unavailable' }),
)
expect(mocks.apiCard).toHaveBeenCalledWith(
expect.objectContaining({ availability: 'unavailable' }),
)
expect(mocks.mcpCard).toHaveBeenCalledWith(
expect.objectContaining({ triggerModeDisabled: true }),
)
expect(mocks.triggerCard).toHaveBeenCalledWith(
expect.objectContaining({ availability: 'available' }),
)
expect(
screen.getByText('deployments.studio.accessPoint.triggerExclusiveNotice'),
).toBeInTheDocument()
})
it('keeps all cards visible while the published workflow is loading', () => {
mocks.workflow = {
data: null,
isPending: true,
}
render(<BuiltInAccessPoints appId="app-1" />)
expect(mocks.webCard).toHaveBeenCalledWith(expect.objectContaining({ availability: 'loading' }))
expect(mocks.apiCard).toHaveBeenCalledWith(expect.objectContaining({ availability: 'loading' }))
expect(mocks.triggerCard).toHaveBeenCalledWith(
expect.objectContaining({ availability: 'loading' }),
)
})
})
@@ -0,0 +1,79 @@
import type { AccessPoint } from '@/app/components/app/deploy/access-point'
import { screen, within } from '@testing-library/react'
import { render } from '@/test/console/render'
import { DeployedEnvironmentAccessPoints } from '../deployed-environment-access-points'
const mocks = vi.hoisted(() => ({
serviceApiCard: vi.fn(),
webAppCard: vi.fn(),
}))
vi.mock('react-i18next', async () => {
const { createReactI18nextMock } = await import('@/test/i18n-mock')
return createReactI18nextMock()
})
vi.mock('../deployed-environment-access-points/environment-service-api-card', () => ({
EnvironmentServiceApiCard: (props: Record<string, unknown>) => {
mocks.serviceApiCard(props)
return <div data-testid="environment-service-api-card" />
},
}))
vi.mock('../deployed-environment-access-points/environment-web-app-card', () => ({
EnvironmentWebAppCard: (props: Record<string, unknown>) => {
mocks.webAppCard(props)
return <div data-testid="environment-web-app-card" />
},
}))
describe('DeployedEnvironmentAccessPoints', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it.each<AccessPoint>(['webApp', 'serviceApi'])(
'highlights only the targeted %s card',
(highlightedAccessPoint) => {
render(
<DeployedEnvironmentAccessPoints
appId="app-1"
environmentId="staging"
canEdit
canManage
highlightedAccessPoint={highlightedAccessPoint}
/>,
)
expect(mocks.webAppCard).toHaveBeenCalledWith(
expect.objectContaining({ highlighted: highlightedAccessPoint === 'webApp' }),
)
expect(mocks.serviceApiCard).toHaveBeenCalledWith(
expect.objectContaining({ highlighted: highlightedAccessPoint === 'serviceApi' }),
)
},
)
it('renders MCP and Trigger as unsupported without a permanent loading state', () => {
render(
<DeployedEnvironmentAccessPoints appId="app-1" environmentId="staging" canEdit canManage />,
)
const mcpCard = screen.getByRole('region', { name: /mcp\.server\.title/ })
const triggerCard = screen.getByRole('region', { name: /settings\.trigger/ })
for (const card of [mcpCard, triggerCard]) {
expect(
within(card).getByText('deployments.studio.accessPoint.notSupported'),
).toBeInTheDocument()
expect(
within(card).getByText('deployments.studio.accessPoint.unsupportedInDeployedEnvironment'),
).toBeInTheDocument()
expect(
within(card).queryByText('deployments.health.ENVIRONMENT_STATUS_FAILED'),
).not.toBeInTheDocument()
expect(card).not.toHaveAttribute('aria-busy')
expect(card.querySelector('[aria-busy="true"]')).not.toBeInTheDocument()
}
})
})
@@ -0,0 +1,205 @@
import type { ReactElement } from 'react'
import { QueryClientProvider } from '@tanstack/react-query'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AccessMode } from '@/models/access-control'
import { render } from '@/test/console/render'
import { createTestQueryClient } from '@/test/query-client'
import { EnvironmentAccessControl } from '../deployed-environment-access-points/environment-access-control'
const mocks = vi.hoisted(() => ({
getSubjects: vi.fn(),
updateAccessMode: vi.fn(),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
enterprise: {
appDeploy: {
accessService: {
getEnvironmentSite: {
queryOptions: ({
input,
}: {
input: { params: { app_id: string; environment_id: string } }
}) => ({
queryKey: ['environment-site', input.params.app_id, input.params.environment_id],
}),
},
getEnvironmentWebAppSubjects: {
queryOptions: ({
input,
}: {
input: { params: { app_id: string; environment_id: string } }
}) => ({
queryKey: ['environment-subjects', input.params.app_id, input.params.environment_id],
queryFn: () => mocks.getSubjects(input),
}),
},
updateEnvironmentWebAppAccessMode: {
mutationOptions: (options = {}) => ({
mutationFn: mocks.updateAccessMode,
...options,
}),
},
},
},
},
},
}))
vi.mock('@/features/system-features/client', () => ({
systemFeaturesQueryOptions: () => ({
queryKey: ['system-features'],
queryFn: vi.fn(),
}),
}))
vi.mock('@/service/access-control', () => ({
useSearchForWhiteListCandidates: () => ({
isLoading: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
data: { pages: [] },
}),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
},
}))
function renderAccessControl(ui: ReactElement) {
const queryClient = createTestQueryClient()
queryClient.setQueryData(['system-features'], {
webapp_auth: {
enabled: true,
allow_sso: true,
allow_email_password_login: false,
allow_email_code_login: false,
allow_public_access: true,
},
})
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>)
}
describe('EnvironmentAccessControl', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getSubjects.mockResolvedValue({
subjects: [
{
account_data: {
email: 'ada@example.com',
id: 'account-1',
name: 'Ada',
},
subject_id: 'account-1',
subject_type: 'account',
},
],
})
mocks.updateAccessMode.mockResolvedValue({
access_mode: 'private',
enabled: true,
})
})
it('should submit only subjects loaded from the environment endpoint', async () => {
const user = userEvent.setup()
const onConfirm = vi.fn()
renderAccessControl(
<EnvironmentAccessControl
appId="app-1"
environmentId="staging"
accessMode={AccessMode.SPECIFIC_GROUPS_MEMBERS}
canManage
onClose={vi.fn()}
onConfirm={onConfirm}
/>,
)
expect(await screen.findByText('Ada')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(mocks.updateAccessMode.mock.calls[0]?.[0]).toEqual({
params: {
app_id: 'app-1',
environment_id: 'staging',
},
body: {
access_mode: 'private',
subjects: [
{
subject_id: 'account-1',
subject_type: 'account',
},
],
},
})
expect(onConfirm).toHaveBeenCalledTimes(1)
})
})
it('should allow authenticated external users to access the environment Web app', async () => {
const user = userEvent.setup()
const onConfirm = vi.fn()
renderAccessControl(
<EnvironmentAccessControl
appId="app-1"
environmentId="staging"
accessMode={AccessMode.ORGANIZATION}
canManage
onClose={vi.fn()}
onConfirm={onConfirm}
/>,
)
await user.click(
screen.getByRole('radio', {
name: 'app.accessControlDialog.accessItems.external',
}),
)
await user.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(mocks.updateAccessMode.mock.calls[0]?.[0]).toEqual({
params: {
app_id: 'app-1',
environment_id: 'staging',
},
body: {
access_mode: AccessMode.EXTERNAL_MEMBERS,
},
})
expect(onConfirm).toHaveBeenCalledTimes(1)
})
})
it('should keep confirmation disabled when the environment subjects query fails', async () => {
const user = userEvent.setup()
mocks.getSubjects.mockRejectedValue(new Error('subjects unavailable'))
renderAccessControl(
<EnvironmentAccessControl
appId="app-1"
environmentId="staging"
accessMode={AccessMode.SPECIFIC_GROUPS_MEMBERS}
canManage
onClose={vi.fn()}
onConfirm={vi.fn()}
/>,
)
expect(await screen.findByRole('alert')).toHaveTextContent('common.dynamicSelect.error')
const confirmButton = screen.getByRole('button', { name: 'common.operation.confirm' })
expect(confirmButton).toBeDisabled()
await user.click(confirmButton)
expect(mocks.updateAccessMode).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,403 @@
import type { ReactElement } from 'react'
import { QueryClientProvider } from '@tanstack/react-query'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { render } from '@/test/console/render'
import { createTestQueryClient } from '@/test/query-client'
import { EnvironmentServiceApiCard } from '../deployed-environment-access-points/environment-service-api-card'
import { EnvironmentWebAppCard } from '../deployed-environment-access-points/environment-web-app-card'
const mocks = vi.hoisted(() => ({
getApi: vi.fn(),
getSite: vi.fn(),
getSubjects: vi.fn(),
resetSite: vi.fn(),
updateApi: vi.fn(),
updateSite: vi.fn(),
environmentAccessControlProps: vi.fn(),
apiKeyButtonProps: vi.fn(),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
enterprise: {
appDeploy: {
accessService: {
getEnvironmentApi: {
queryOptions: ({
input,
}: {
input: { params: { app_id: string; environment_id: string } }
}) => ({
queryKey: ['environment-api', input.params.app_id, input.params.environment_id],
queryFn: () => mocks.getApi(input),
}),
},
getEnvironmentSite: {
queryOptions: ({
input,
}: {
input: { params: { app_id: string; environment_id: string } }
}) => ({
queryKey: ['environment-site', input.params.app_id, input.params.environment_id],
queryFn: () => mocks.getSite(input),
}),
},
getEnvironmentWebAppSubjects: {
queryOptions: ({
input,
}: {
input: { params: { app_id: string; environment_id: string } }
}) => ({
queryKey: ['environment-subjects', input.params.app_id, input.params.environment_id],
queryFn: () => mocks.getSubjects(input),
}),
},
resetEnvironmentSiteAccessToken: {
mutationOptions: (options = {}) => ({
mutationFn: mocks.resetSite,
...options,
}),
},
updateEnvironmentApi: {
mutationOptions: (options = {}) => ({
mutationFn: mocks.updateApi,
...options,
}),
},
updateEnvironmentSite: {
mutationOptions: (options = {}) => ({
mutationFn: mocks.updateSite,
...options,
}),
},
},
},
},
},
}))
vi.mock('@/features/system-features/client', () => ({
systemFeaturesQueryOptions: () => ({
queryKey: ['system-features'],
queryFn: vi.fn(),
}),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.test/en${path}`,
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
appDetail: {
id: 'app-1',
icon: '🤖',
icon_background: '#FFEAD5',
icon_type: 'emoji',
icon_url: null,
mode: 'workflow',
site: {
access_token: 'built-in-code',
app_base_url: 'https://built-in.example.test',
},
},
}),
}))
vi.mock('@/app/components/base/app-icon', () => ({
default: () => <div aria-label="app-icon" />,
}))
vi.mock('@/app/components/app/access-point/shared/use-access-point-actions', () => ({
useAccessPointActions: () => ({
saveSiteConfig: vi.fn(),
}),
}))
vi.mock('@/app/components/app/overview/customize', () => ({
default: ({ api_base_url, isShow }: { api_base_url: string; isShow: boolean }) =>
isShow ? (
<div role="dialog" aria-label="environment customize">
{api_base_url}
</div>
) : null,
}))
vi.mock('@/app/components/app/overview/settings', () => ({
default: ({ isShow }: { isShow: boolean }) =>
isShow ? <div role="dialog" aria-label="environment settings" /> : null,
}))
vi.mock('../deployed-environment-access-points/environment-access-control', () => ({
EnvironmentAccessControl: (props: {
appId: string
environmentId: string
accessMode: string
canManage: boolean
}) => {
mocks.environmentAccessControlProps(props)
return <div role="dialog" aria-label="environment access mode" />
},
}))
vi.mock('@/app/components/app/access-point/shared/api-secret-key-button', () => ({
ApiSecretKeyButton: (props: {
apiKeyCount?: number
appId: string
canManage: boolean
disabled?: boolean
environmentId?: string
}) => {
mocks.apiKeyButtonProps(props)
return (
<button type="button" disabled={!props.canManage || props.disabled}>
environment-api-keys
</button>
)
},
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
},
}))
const environmentParams = {
app_id: 'app-1',
environment_id: 'staging',
}
const site = {
access_mode: 'private',
app_base_url: 'https://site.example.test',
code: 'site-code',
enabled: true,
}
const api = {
api_key_count: 3,
base_url: 'https://api.example.test/v1',
enabled: true,
}
function renderCard(ui: ReactElement) {
const queryClient = createTestQueryClient()
queryClient.setQueryData(['system-features'], {
webapp_auth: {
enabled: true,
},
})
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>)
}
describe('environment access point cards', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getApi.mockResolvedValue(api)
mocks.getSite.mockResolvedValue(site)
mocks.getSubjects.mockResolvedValue({
subjects: [
{
account_data: {
email: 'ada@example.com',
id: 'account-1',
name: 'Ada',
},
subject_id: 'account-1',
subject_type: 'account',
},
],
})
mocks.resetSite.mockResolvedValue({
...site,
code: 'regenerated-code',
})
mocks.updateApi.mockResolvedValue({
...api,
enabled: false,
})
mocks.updateSite.mockResolvedValue({
...site,
enabled: false,
})
})
it('renders the real environment Web app URL and workflow actions without Embed', async () => {
renderCard(<EnvironmentWebAppCard appId="app-1" environmentId="staging" canEdit canManage />)
expect(await screen.findByText(/env\/workflow\/site-code/)).toHaveTextContent(
'https://site.example.test/env/workflow/site-code',
)
expect(
await screen.findByRole('button', {
name: /accessControlDialog\.accessItems\.specific/,
}),
).toBeEnabled()
expect(screen.queryByRole('button', { name: /embedIntoSite/ })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /customize\.entry/ })).toBeEnabled()
expect(screen.getByRole('button', { name: /settings\.settings/ })).toBeEnabled()
})
it('renders authenticated external users as the environment Web app access mode', async () => {
mocks.getSite.mockResolvedValue({
...site,
access_mode: 'sso_verified',
})
renderCard(<EnvironmentWebAppCard appId="app-1" environmentId="staging" canEdit canManage />)
expect(
await screen.findByRole('button', {
name: /accessControlDialog\.accessItems\.external/,
}),
).toBeEnabled()
})
it('shows the environment Web app query as loading instead of failed', () => {
mocks.getSite.mockImplementation(() => new Promise(() => {}))
renderCard(<EnvironmentWebAppCard appId="app-1" environmentId="staging" canEdit canManage />)
const card = screen.getByRole('region', { name: /webApp\.title/ })
expect(card).toHaveAttribute('aria-busy', 'true')
expect(screen.getByText('common.loading')).toBeInTheDocument()
expect(
screen.queryByText('deployments.health.ENVIRONMENT_STATUS_FAILED'),
).not.toBeInTheDocument()
})
it('uses environment Site mutations for status and URL reset, and opens its access container', async () => {
const user = userEvent.setup()
renderCard(<EnvironmentWebAppCard appId="app-1" environmentId="staging" canEdit canManage />)
const accessModeButton = await screen.findByRole('button', {
name: /accessControlDialog\.accessItems\.specific/,
})
await user.click(accessModeButton)
await waitFor(() => {
expect(mocks.environmentAccessControlProps).toHaveBeenLastCalledWith(
expect.objectContaining({
appId: 'app-1',
environmentId: 'staging',
accessMode: 'private',
canManage: true,
}),
)
})
await user.click(screen.getByRole('button', { name: /regenerate/ }))
await user.click(screen.getByRole('button', { name: /operation\.confirm/ }))
await waitFor(() => {
expect(mocks.resetSite.mock.calls[0]?.[0]).toEqual({
params: environmentParams,
})
})
await user.click(screen.getByRole('switch'))
await waitFor(() => {
expect(mocks.updateSite.mock.calls[0]?.[0]).toEqual({
body: {
enabled: false,
},
params: environmentParams,
})
})
})
it('opens Customize and Settings with environment endpoint data', async () => {
const user = userEvent.setup()
renderCard(<EnvironmentWebAppCard appId="app-1" environmentId="staging" canEdit canManage />)
await screen.findByText(/env\/workflow\/site-code/)
await user.click(screen.getByRole('button', { name: /customize\.entry/ }))
expect(screen.getByRole('dialog', { name: 'environment customize' })).toHaveTextContent(
'https://api.example.test/v1',
)
await user.click(screen.getByRole('button', { name: /settings\.settings/ }))
expect(screen.getByRole('dialog', { name: 'environment settings' })).toBeInTheDocument()
})
it('renders the real Service API endpoint, environment keys entry, docs entry, and API toggle', async () => {
const user = userEvent.setup()
renderCard(<EnvironmentServiceApiCard appId="app-1" environmentId="staging" canManage />)
expect(await screen.findByText(api.base_url)).toBeInTheDocument()
expect(mocks.apiKeyButtonProps).toHaveBeenLastCalledWith(
expect.objectContaining({
apiKeyCount: 3,
appId: 'app-1',
canManage: true,
environmentId: 'staging',
}),
)
expect(screen.getByRole('button', { name: 'environment-api-keys' })).toBeInTheDocument()
const apiReferenceLink = screen.getByRole('button', { name: /apiInfo\.doc/ })
expect(apiReferenceLink).toHaveAttribute(
'href',
'https://docs.example.test/en/api-reference/guides/workflow',
)
expect(apiReferenceLink).toHaveAttribute('target', '_blank')
expect(apiReferenceLink).toHaveAttribute('rel', 'noopener noreferrer')
await user.click(screen.getByRole('switch'))
await waitFor(() => {
expect(mocks.updateApi.mock.calls[0]?.[0]).toEqual({
body: {
enabled: false,
},
params: environmentParams,
})
})
})
it('keeps environment API keys and external documentation available when the API is stopped', async () => {
mocks.getApi.mockResolvedValue({
...api,
enabled: false,
})
renderCard(<EnvironmentServiceApiCard appId="app-1" environmentId="staging" canManage />)
await screen.findByText(api.base_url)
expect(await screen.findByRole('button', { name: 'environment-api-keys' })).toBeEnabled()
const apiReferenceLink = screen.getByRole('button', { name: /apiInfo\.doc/ })
expect(apiReferenceLink).not.toHaveAttribute('aria-disabled')
expect(apiReferenceLink).toHaveAttribute(
'href',
'https://docs.example.test/en/api-reference/guides/workflow',
)
})
it('distinguishes the Service API loading and failed query states', async () => {
mocks.getApi.mockRejectedValue(new Error('API unavailable'))
renderCard(<EnvironmentServiceApiCard appId="app-1" environmentId="staging" canManage />)
const card = screen.getByRole('region', { name: /serviceApi\.title/ })
expect(card).toHaveAttribute('aria-busy', 'true')
expect(screen.getByText('common.loading')).toBeInTheDocument()
expect(
screen.queryByText('deployments.health.ENVIRONMENT_STATUS_FAILED'),
).not.toBeInTheDocument()
expect(await screen.findAllByText('deployments.health.ENVIRONMENT_STATUS_FAILED')).toHaveLength(
2,
)
expect(card).not.toHaveAttribute('aria-busy')
expect(screen.queryByText('common.loading')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'environment-api-keys' })).toBeDisabled()
expect(screen.getByRole('button', { name: /apiInfo\.doc/ })).toHaveAttribute(
'aria-disabled',
'true',
)
})
})
@@ -0,0 +1,279 @@
import type { AppEnvironment } from '@dify/contracts/enterprise-app-deploy/types.gen'
import type { ReactNode } from 'react'
import type { AccessPoint as AccessPointType } from '@/app/components/app/deploy/access-point'
import { EnvironmentStatus } from '@dify/contracts/enterprise-app-deploy/types.gen'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { consoleQuery } from '@/service/client'
import { seedAccountProfileQuery } from '@/test/console/account-profile'
import { QueryClientTestProvider } from '@/test/console/query-provider'
import { render } from '@/test/console/render'
import { createTestQueryClient } from '@/test/query-client'
import { AppACLPermission } from '@/utils/permission'
import AccessPoint from '..'
let appMode = 'workflow'
let appPermissionKeys: string[] = [AppACLPermission.Deploy]
const mockConsoleState = vi.hoisted(() => ({
userProfile: { id: 'user-1' },
workspacePermissionKeys: [] as string[],
}))
vi.mock('react-i18next', async () => {
const { createReactI18nextMock } = await import('@/test/i18n-mock')
return createReactI18nextMock({
'workflow.nodes.common.memories.builtIn': 'Built-in',
})
})
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
appDetail: {
id: 'app-1',
mode: appMode,
maintainer: 'user-2',
permission_keys: appPermissionKeys,
},
}),
}))
vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => mockConsoleState)
})
vi.mock('@/app/components/app/access-point/built-in-access-points', () => ({
BuiltInAccessPoints: ({
appId,
highlightedAccessPoint,
}: {
appId: string
highlightedAccessPoint?: AccessPointType
}) => (
<div
data-testid="built-in-access-points"
data-highlighted-access-point={highlightedAccessPoint}
>
{appId}
</div>
),
}))
vi.mock('@/app/components/app/access-point/deployed-environment-access-points', () => ({
DeployedEnvironmentAccessPoints: ({
appId,
canEdit,
canManage,
environmentId,
highlightedAccessPoint,
}: {
appId: string
canEdit: boolean
canManage: boolean
environmentId: string
highlightedAccessPoint?: AccessPointType
}) => (
<div
data-testid="deployed-environment-access-points"
data-app-id={appId}
data-can-edit={String(canEdit)}
data-can-manage={String(canManage)}
data-highlighted-access-point={highlightedAccessPoint}
>
{environmentId}
</div>
),
}))
const appEnvironments: AppEnvironment[] = [
{
id: 'staging',
display_name: 'Staging',
description: '',
status: EnvironmentStatus.ENVIRONMENT_STATUS_READY,
in_use: true,
},
{
id: 'canary',
display_name: 'Canary',
description: '',
status: EnvironmentStatus.ENVIRONMENT_STATUS_READY,
in_use: true,
},
{
id: 'qa',
display_name: 'Quality Assurance',
description: '',
status: EnvironmentStatus.ENVIRONMENT_STATUS_READY,
in_use: false,
},
]
const renderAccessPoint = ({
environments = appEnvironments,
searchParams = '',
}: {
environments?: AppEnvironment[]
searchParams?: string
} = {}) => {
const queryClient = createTestQueryClient()
seedAccountProfileQuery(queryClient, mockConsoleState.userProfile)
const queryOptions =
consoleQuery.enterprise.appDeploy.deploymentService.listAppEnvironments.queryOptions({
input: {
params: {
app_id: 'app-1',
},
},
})
queryClient.setQueryData(queryOptions.queryKey, { data: environments })
const onUrlUpdate = vi.fn()
const Wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientTestProvider queryClient={queryClient}>
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate}>
{children}
</NuqsTestingAdapter>
</QueryClientTestProvider>
)
return {
...render(<AccessPoint appId="app-1" />, { wrapper: Wrapper }),
onUrlUpdate,
}
}
describe('AccessPoint', () => {
beforeEach(() => {
appMode = 'workflow'
appPermissionKeys = [AppACLPermission.Deploy]
})
it('renders Built-in and only in-use environments from the API', () => {
renderAccessPoint()
expect(screen.getByRole('heading', { name: 'common.appMenus.accessPoint' })).toBeInTheDocument()
expect(screen.getByTestId('built-in-access-points')).toHaveTextContent('app-1')
expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual([
'Built-in',
'Staging',
'Canary',
])
expect(screen.queryByRole('tab', { name: 'Quality Assurance' })).not.toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Built-in' })).toHaveAttribute('aria-selected', 'true')
})
it('persists the selected environment in the URL', async () => {
const user = userEvent.setup()
const { onUrlUpdate } = renderAccessPoint()
await user.click(screen.getByRole('tab', { name: 'Canary' }))
expect(onUrlUpdate).toHaveBeenCalledWith(
expect.objectContaining({
queryString: '?environment=canary',
}),
)
})
it('selects the target environment and highlights its access point from the URL', () => {
renderAccessPoint({
searchParams: '?environment=canary&accessPoint=serviceApi',
})
expect(screen.getByRole('tab', { name: 'Canary' })).toHaveAttribute('aria-selected', 'true')
expect(screen.getByTestId('deployed-environment-access-points')).toHaveTextContent('canary')
expect(screen.getByTestId('deployed-environment-access-points')).toHaveAttribute(
'data-highlighted-access-point',
'serviceApi',
)
})
it('highlights a built-in access point from the URL', () => {
renderAccessPoint({
searchParams: '?environment=built-in&accessPoint=mcp',
})
expect(screen.getByRole('tab', { name: 'Built-in' })).toHaveAttribute('aria-selected', 'true')
expect(screen.getByTestId('built-in-access-points')).toHaveAttribute(
'data-highlighted-access-point',
'mcp',
)
})
it('clears the access point highlight when switching environment tabs', async () => {
const user = userEvent.setup()
const { onUrlUpdate } = renderAccessPoint({
searchParams: '?environment=canary&accessPoint=serviceApi',
})
await user.click(screen.getByRole('tab', { name: 'Staging' }))
expect(onUrlUpdate).toHaveBeenCalledWith(
expect.objectContaining({
queryString: '?environment=staging',
}),
)
expect(screen.getByTestId('deployed-environment-access-points')).not.toHaveAttribute(
'data-highlighted-access-point',
)
})
it('shows the selected deployed environment with deploy permissions', () => {
renderAccessPoint({
searchParams: '?environment=canary',
})
expect(screen.getByTestId('deployed-environment-access-points')).toHaveTextContent('canary')
expect(screen.getByTestId('deployed-environment-access-points')).toHaveAttribute(
'data-app-id',
'app-1',
)
expect(screen.getByTestId('deployed-environment-access-points')).toHaveAttribute(
'data-can-edit',
'false',
)
expect(screen.getByTestId('deployed-environment-access-points')).toHaveAttribute(
'data-can-manage',
'true',
)
expect(screen.queryByTestId('built-in-access-points')).not.toBeInTheDocument()
})
it('falls back to Built-in when the URL targets an unused environment', () => {
renderAccessPoint({
searchParams: '?environment=qa&accessPoint=mcp',
})
expect(screen.getByRole('tab', { name: 'Built-in' })).toHaveAttribute('aria-selected', 'true')
expect(screen.queryByRole('tab', { name: 'Quality Assurance' })).not.toBeInTheDocument()
expect(screen.getByTestId('built-in-access-points')).toBeInTheDocument()
expect(screen.getByTestId('built-in-access-points')).not.toHaveAttribute(
'data-highlighted-access-point',
)
expect(screen.queryByTestId('deployed-environment-access-points')).not.toBeInTheDocument()
})
it('hides environment tabs for app types without multi-environment support', () => {
appMode = 'chat'
renderAccessPoint()
expect(screen.queryByRole('tab')).not.toBeInTheDocument()
expect(screen.getByTestId('built-in-access-points')).toBeInTheDocument()
})
it('falls back to built-in access points without app deploy ACL permission', () => {
appPermissionKeys = []
renderAccessPoint({
searchParams: '?environment=canary',
})
expect(screen.queryByRole('tab')).not.toBeInTheDocument()
expect(screen.getByTestId('built-in-access-points')).toBeInTheDocument()
expect(screen.queryByTestId('deployed-environment-access-points')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,164 @@
import type { AccessPointAppInfo, PublishedWorkflow } from '../shared/utils'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BlockEnum } from '@/app/components/workflow/types'
import { render } from '@/test/console/render'
import { AppModeEnum } from '@/types/app'
import { MCPAccessPointCard } from '../built-in-access-points/mcp-card'
const mocks = vi.hoisted(() => ({
invalidateServerDetail: vi.fn(),
serverDetail: {
data: undefined as undefined | { id: string; server_code: string; status: string },
isPending: false,
},
modalProps: vi.fn(),
refreshServerCode: vi.fn(),
updateServer: vi.fn(),
}))
vi.mock('@/service/use-tools', () => ({
useInvalidateMCPServerDetail: () => mocks.invalidateServerDetail,
useMCPServerDetail: () => mocks.serverDetail,
useRefreshMCPServerCode: () => ({
isPending: false,
mutateAsync: mocks.refreshServerCode,
}),
useUpdateMCPServer: () => ({
isPending: false,
mutateAsync: mocks.updateServer,
}),
}))
vi.mock('@/app/components/tools/mcp/mcp-server-modal', () => ({
default: (props: Record<string, unknown>) => {
mocks.modalProps(props)
return <div role="dialog" aria-label="MCP server settings" />
},
}))
const appInfo = {
api_base_url: 'https://api.example.test/v1',
id: 'app-1',
mode: AppModeEnum.CHAT,
model_config: {
updated_at: 1_710_000_000,
user_input_form: [
{
'text-input': {
label: 'Question',
required: true,
variable: 'question',
},
},
],
},
} as AccessPointAppInfo
const workflowAppInfo = {
...appInfo,
mode: AppModeEnum.WORKFLOW,
model_config: null,
} as unknown as AccessPointAppInfo
const publishedWorkflow = {
graph: {
nodes: [
{
data: {
type: BlockEnum.Start,
variables: [{ label: 'Query', variable: 'query' }],
},
},
],
},
} as unknown as PublishedWorkflow
describe('MCPAccessPointCard', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.serverDetail.data = undefined
mocks.serverDetail.isPending = false
})
afterEach(() => {
vi.restoreAllMocks()
})
it('uses the provided basic app model config without refetching app detail', async () => {
const user = userEvent.setup()
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(new Response('{}', { status: 200 }))
render(
<MCPAccessPointCard
appInfo={appInfo}
canEdit
triggerModeDisabled={false}
workflow={undefined}
workflowLoading={false}
/>,
)
await user.click(screen.getByRole('button', { name: /addDescription/ }))
expect(screen.getByRole('dialog', { name: 'MCP server settings' })).toBeInTheDocument()
expect(mocks.modalProps).toHaveBeenCalledWith(
expect.objectContaining({
latestParams: [
{
label: 'Question',
required: true,
type: 'text-input',
variable: 'question',
},
],
}),
)
expect(fetchSpy).not.toHaveBeenCalled()
})
it('uses workflow inputs when the app model config is null', async () => {
const user = userEvent.setup()
render(
<MCPAccessPointCard
appInfo={workflowAppInfo}
canEdit
triggerModeDisabled={false}
workflow={publishedWorkflow}
workflowLoading={false}
/>,
)
await user.click(screen.getByRole('button', { name: /addDescription/ }))
expect(mocks.modalProps).toHaveBeenCalledWith(
expect.objectContaining({
latestParams: [{ label: 'Query', variable: 'query' }],
}),
)
})
it('shows loading without reporting an environment failure', () => {
mocks.serverDetail.isPending = true
render(
<MCPAccessPointCard
appInfo={workflowAppInfo}
canEdit
triggerModeDisabled={false}
workflow={publishedWorkflow}
workflowLoading={false}
/>,
)
const card = screen.getByRole('region', { name: /mcp\.server\.title/ })
expect(card).toHaveAttribute('aria-busy', 'true')
expect(screen.getByText('common.loading')).toBeInTheDocument()
expect(
screen.queryByText('deployments.health.ENVIRONMENT_STATUS_FAILED'),
).not.toBeInTheDocument()
})
})
@@ -0,0 +1,120 @@
import type { AccessPointAppInfo } from '../shared/utils'
import { screen } from '@testing-library/react'
import { render } from '@/test/console/render'
import { AppModeEnum } from '@/types/app'
import { ServiceApiAccessPointCard } from '../built-in-access-points/service-api-card'
const mocks = vi.hoisted(() => ({
apiSecretKeyButtonProps: vi.fn(),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.test/en${path}`,
}))
vi.mock('../shared/api-secret-key-button', () => ({
ApiSecretKeyButton: (props: { canManage: boolean; disabled?: boolean }) => {
mocks.apiSecretKeyButtonProps(props)
return (
<button type="button" disabled={!props.canManage || props.disabled}>
api-secret-keys
</button>
)
},
}))
function createAppInfo(
mode: AppModeEnum,
overrides: Partial<AccessPointAppInfo> = {},
): AccessPointAppInfo {
return {
api_base_url: 'https://api.example.test/v1',
enable_api: true,
id: 'app-1',
mode,
...overrides,
} as AccessPointAppInfo
}
describe('ServiceApiAccessPointCard', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it.each([
[AppModeEnum.ADVANCED_CHAT, '/api-reference/guides/chatflow'],
[AppModeEnum.WORKFLOW, '/api-reference/guides/workflow'],
[AppModeEnum.CHAT, '/api-reference/guides/chat'],
[AppModeEnum.AGENT_CHAT, '/api-reference/guides/chat'],
[AppModeEnum.COMPLETION, '/api-reference/guides/completion'],
])('links %s apps to the matching external API reference', (mode, path) => {
render(
<ServiceApiAccessPointCard
appInfo={createAppInfo(mode)}
availability="available"
canEdit
onChangeStatus={vi.fn().mockResolvedValue(undefined)}
/>,
)
const apiReferenceLink = screen.getByRole('button', { name: /apiInfo\.doc/ })
expect(apiReferenceLink).toHaveAttribute('href', `https://docs.example.test/en${path}`)
expect(apiReferenceLink).toHaveAttribute('target', '_blank')
expect(apiReferenceLink).toHaveAttribute('rel', 'noopener noreferrer')
})
it('shows loading without reporting an environment failure', () => {
render(
<ServiceApiAccessPointCard
appInfo={createAppInfo(AppModeEnum.WORKFLOW)}
availability="loading"
canEdit
onChangeStatus={vi.fn().mockResolvedValue(undefined)}
/>,
)
const card = screen.getByRole('region', { name: /serviceApi\.title/ })
expect(card).toHaveAttribute('aria-busy', 'true')
expect(screen.getByText('common.loading')).toBeInTheDocument()
expect(
screen.queryByText('deployments.health.ENVIRONMENT_STATUS_FAILED'),
).not.toBeInTheDocument()
})
it('keeps API keys and external documentation available when the API is stopped', () => {
render(
<ServiceApiAccessPointCard
appInfo={createAppInfo(AppModeEnum.WORKFLOW, { enable_api: false })}
availability="available"
canEdit
onChangeStatus={vi.fn().mockResolvedValue(undefined)}
/>,
)
expect(screen.getByRole('button', { name: 'api-secret-keys' })).toBeEnabled()
const apiReferenceLink = screen.getByRole('button', { name: /apiInfo\.doc/ })
expect(apiReferenceLink).not.toHaveAttribute('aria-disabled')
expect(apiReferenceLink).toHaveAttribute(
'href',
'https://docs.example.test/en/api-reference/guides/workflow',
)
})
it('disables API keys and external documentation when the access point is unavailable', () => {
render(
<ServiceApiAccessPointCard
appInfo={createAppInfo(AppModeEnum.WORKFLOW)}
availability="unavailable"
canEdit
onChangeStatus={vi.fn().mockResolvedValue(undefined)}
/>,
)
expect(screen.getByRole('button', { name: 'api-secret-keys' })).toBeDisabled()
expect(screen.getByRole('button', { name: /apiInfo\.doc/ })).toHaveAttribute(
'aria-disabled',
'true',
)
})
})
@@ -0,0 +1,148 @@
import type { AccessPointAppInfo } from '../shared/utils'
import type { AppTrigger } from '@/service/use-tools'
import { screen } from '@testing-library/react'
import { render } from '@/test/console/render'
import { AppModeEnum } from '@/types/app'
import { TriggerAccessPointCard } from '../built-in-access-points/trigger-card'
const mocks = vi.hoisted(() => ({
invalidateTriggers: vi.fn(),
setTriggerStatus: vi.fn(),
setTriggerStatuses: vi.fn(),
triggerQuery: {
data: undefined as { data: AppTrigger[] } | undefined,
isLoading: false,
},
updateTriggerStatus: vi.fn(),
}))
vi.mock('@/app/components/workflow/store/trigger-status', () => ({
useTriggerStatusStore: () => ({
setTriggerStatus: mocks.setTriggerStatus,
setTriggerStatuses: mocks.setTriggerStatuses,
}),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.test/en${path}`,
}))
vi.mock('@/service/use-tools', () => ({
useAppTriggers: () => mocks.triggerQuery,
useInvalidateAppTriggers: () => mocks.invalidateTriggers,
useUpdateTriggerStatus: () => ({
isPending: false,
mutateAsync: mocks.updateTriggerStatus,
}),
}))
vi.mock('@/service/use-triggers', () => ({
useAllTriggerPlugins: () => ({ data: [] }),
}))
vi.mock('@/app/components/workflow/block-icon', () => ({
default: () => null,
}))
const appInfo = {
id: 'app-1',
mode: AppModeEnum.WORKFLOW,
} as AccessPointAppInfo
function createTrigger(id: string, status: AppTrigger['status']): AppTrigger {
return {
id,
trigger_type: 'trigger-webhook',
title: `Trigger ${id}`,
node_id: `node-${id}`,
provider_name: 'Webhook',
icon: '',
status,
created_at: '2026-08-04T00:00:00Z',
updated_at: '2026-08-04T00:00:00Z',
}
}
function renderCard(availability: 'available' | 'loading' | 'unavailable') {
render(
<TriggerAccessPointCard
appInfo={appInfo}
availability={availability}
canEdit
onToggleResult={vi.fn()}
/>,
)
}
describe('TriggerAccessPointCard', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.triggerQuery.data = undefined
mocks.triggerQuery.isLoading = false
})
it('shows loading without reporting an environment failure', () => {
renderCard('loading')
const card = screen.getByRole('region', { name: /settings\.trigger/ })
expect(card).toHaveAttribute('aria-busy', 'true')
expect(screen.getByText('common.loading')).toBeInTheDocument()
expect(
screen.queryByText('deployments.health.ENVIRONMENT_STATUS_FAILED'),
).not.toBeInTheDocument()
})
it('shows every non-enabled trigger as disabled with its switch off', () => {
mocks.triggerQuery.data = {
data: [
createTrigger('enabled', 'enabled'),
createTrigger('disabled', 'disabled'),
createTrigger('unauthorized', 'unauthorized'),
],
}
renderCard('available')
expect(
screen.getByText(
'deployments.studio.accessPoint.triggerEnabledCount:{"enabled":1,"total":3}',
),
).toBeInTheDocument()
expect(screen.getByText('agentV2.agentDetail.access.status.inService')).toBeInTheDocument()
expect(screen.getAllByText('appOverview.overview.status.disable')).toHaveLength(2)
expect(
screen.queryByText('deployments.studio.accessPoint.triggerDisconnected'),
).not.toBeInTheDocument()
expect(
screen.queryByText('deployments.studio.accessPoint.triggerMuted'),
).not.toBeInTheDocument()
const [enabledSwitch, disabledSwitch, unauthorizedSwitch] = screen.getAllByRole('switch')
expect(enabledSwitch).toBeChecked()
expect(disabledSwitch).not.toBeChecked()
expect(unauthorizedSwitch).not.toBeChecked()
})
it('uses the overview empty-state copy and documentation interaction', () => {
renderCard('unavailable')
expect(
screen.getByText('appOverview.overview.triggerInfo.triggerStatusDescription'),
).toBeInTheDocument()
const learnLink = screen.getByRole('link', {
name: 'appOverview.overview.triggerInfo.learnAboutTriggers',
})
expect(learnLink).toHaveAttribute(
'href',
'https://docs.example.test/en/use-dify/nodes/trigger/overview',
)
expect(learnLink).toHaveAttribute('target', '_blank')
expect(learnLink).toHaveAttribute('rel', 'noopener noreferrer')
expect(
screen.queryByText('deployments.studio.accessPoint.triggerServiceModeUnavailable'),
).not.toBeInTheDocument()
expect(
screen.queryByText('deployments.studio.accessPoint.noTriggerNodes'),
).not.toBeInTheDocument()
})
})
@@ -0,0 +1,200 @@
import type { AccessPointAppInfo, PublishedWorkflow } from '../shared/utils'
import type { InputVar, Node } from '@/app/components/workflow/types'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import { AccessMode } from '@/models/access-control'
import { render } from '@/test/console/render'
import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
import { WebAppAccessPointCard } from '../built-in-access-points/web-app-card'
vi.mock('@/service/access-control/use-app-access-control', () => ({
useAppWhiteListSubjects: () => ({
data: undefined,
}),
}))
vi.mock('@/app/components/base/app-icon', () => ({
default: () => <div aria-label="app-icon" />,
}))
vi.mock('@/app/components/app/app-access-control', () => ({
default: () => null,
}))
vi.mock('@/app/components/app/overview/customize', () => ({
default: () => null,
}))
vi.mock('@/app/components/app/overview/settings', () => ({
default: () => null,
}))
vi.mock('@/app/components/app/overview/embedded', () => ({
default: ({
hiddenInputs = [],
isShow,
}: {
hiddenInputs?: Array<{ variable: string }>
isShow: boolean
}) =>
isShow ? (
<div role="dialog" aria-label="embed into site">
{hiddenInputs.map((input) => input.variable).join(',')}
</div>
) : null,
}))
function createAppInfo(mode: AppModeEnum): AccessPointAppInfo {
return {
access_mode: AccessMode.PUBLIC,
api_base_url: 'https://api.example.test/v1',
enable_site: true,
icon: '🤖',
icon_background: '#FFEAD5',
icon_type: 'emoji',
icon_url: null,
id: 'app-1',
mode,
site: {
access_token: 'site-code',
app_base_url: 'https://site.example.test',
},
} as AccessPointAppInfo
}
function renderCard(
mode: AppModeEnum,
availability: 'available' | 'loading' | 'unavailable' = 'available',
workflow?: PublishedWorkflow,
) {
render(
<WebAppAccessPointCard
appInfo={createAppInfo(mode)}
availability={availability}
canEdit
canDeploy
canManageAccess
showAccessControl
onChangeStatus={vi.fn().mockResolvedValue(undefined)}
onRefreshApp={vi.fn().mockResolvedValue(undefined)}
onRegenerate={vi.fn().mockResolvedValue(undefined)}
onSaveSiteConfig={vi.fn().mockResolvedValue(undefined)}
workflow={workflow}
/>,
)
}
const startNode: Node<{ variables: InputVar[] }> = {
id: 'start',
position: { x: 0, y: 0 },
data: {
title: 'Start',
desc: '',
type: BlockEnum.Start,
variables: [
{
variable: 'secret',
label: 'Secret',
type: InputVarType.textInput,
hide: true,
required: true,
default: '',
},
],
},
}
const workflowWithHiddenInput: NonNullable<PublishedWorkflow> = {
conversation_variables: [],
environment_variables: [],
features: {},
id: 'workflow-id',
graph: {
nodes: [startNode],
edges: [],
},
created_at: 0,
created_by: { id: 'user-id', name: 'User', email: 'user@example.com' },
hash: 'workflow-hash',
updated_at: 0,
updated_by: { id: 'user-id', name: 'User', email: 'user@example.com' },
tool_published: false,
version: '1',
marked_name: '',
marked_comment: '',
rag_pipeline_variables: [],
}
describe('WebAppAccessPointCard', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('shows the current access mode without a redundant section label', () => {
renderCard(AppModeEnum.CHAT)
expect(screen.queryByText(/publishApp\.title/)).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: /accessControlDialog\.accessItems\.anyone/ }),
).toBeEnabled()
})
it.each([AppModeEnum.WORKFLOW, AppModeEnum.COMPLETION])(
'does not offer Embed into site for %s apps',
(mode) => {
renderCard(mode)
expect(screen.queryByRole('button', { name: /embedIntoSite/ })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /customize\.entry/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /settings\.settings/ })).toBeInTheDocument()
},
)
it('keeps Embed into site for non-workflow Web apps', async () => {
const user = userEvent.setup()
renderCard(AppModeEnum.CHAT)
await user.click(screen.getByRole('button', { name: /embedIntoSite/ }))
expect(screen.getByRole('dialog', { name: 'embed into site' })).toBeInTheDocument()
})
it('passes hidden Chatflow inputs to the embed dialog', async () => {
const user = userEvent.setup()
renderCard(AppModeEnum.ADVANCED_CHAT, 'available', workflowWithHiddenInput)
await user.click(screen.getByRole('button', { name: /embedIntoSite/ }))
expect(screen.getByRole('dialog', { name: 'embed into site' })).toHaveTextContent('secret')
})
it('configures hidden workflow inputs before opening the Web App', async () => {
const user = userEvent.setup()
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
renderCard(AppModeEnum.WORKFLOW, 'available', workflowWithHiddenInput)
await user.click(screen.getByRole('button', { name: /operation\.config/ }))
await user.type(screen.getByLabelText('Secret'), 'top-secret')
await user.click(screen.getByRole('button', { name: /overview\.appInfo\.launch/ }))
await waitFor(() => {
expect(openSpy).toHaveBeenCalledWith(
`https://site.example.test${basePath}/workflow/site-code?secret=top-secret`,
'_blank',
)
})
})
it('shows loading without reporting an environment failure', () => {
renderCard(AppModeEnum.WORKFLOW, 'loading')
const card = screen.getByRole('region', { name: /webApp\.title/ })
expect(card).toHaveAttribute('aria-busy', 'true')
expect(screen.getByText('common.loading')).toBeInTheDocument()
expect(
screen.queryByText('deployments.health.ENVIRONMENT_STATUS_FAILED'),
).not.toBeInTheDocument()
})
})
@@ -0,0 +1,162 @@
'use client'
import type { AccessPoint } from '@/app/components/app/deploy/access-point'
import { Button } from '@langgenius/dify-ui/button'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import Loading from '@/app/components/base/loading'
import { useDocLink } from '@/context/i18n'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import Link from '@/next/link'
import { useAppWorkflow } from '@/service/use-workflow'
import { getAppACLCapabilities } from '@/utils/permission'
import { useAccessPointActions } from '../shared/use-access-point-actions'
import { getPublishedWorkflowState, isAdvancedApp } from '../shared/utils'
import { MCPAccessPointCard } from './mcp-card'
import { ServiceApiAccessPointCard } from './service-api-card'
import { TriggerAccessPointCard } from './trigger-card'
import { WebAppAccessPointCard } from './web-app-card'
type BuiltInAccessPointsProps = {
appId: string
highlightedAccessPoint?: AccessPoint | null
}
export function BuiltInAccessPoints({ appId, highlightedAccessPoint }: BuiltInAccessPointsProps) {
const { t } = useTranslation()
const docLink = useDocLink()
const appInfo = useAppStore((state) => state.appDetail)
const { data: currentUserId } = useSuspenseQuery({
...userProfileQueryOptions(),
select: (data) => data.profile.id,
})
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const shouldFetchWorkflow = Boolean(appInfo && isAdvancedApp(appInfo))
const { data: workflow, isPending: workflowLoading } = useAppWorkflow(
shouldFetchWorkflow ? appId : '',
)
const capabilities = useMemo(
() =>
getAppACLCapabilities(appInfo?.permission_keys, {
currentUserId,
resourceMaintainer: appInfo?.maintainer,
workspacePermissionKeys,
}),
[appInfo?.maintainer, appInfo?.permission_keys, currentUserId, workspacePermissionKeys],
)
const actions = useAccessPointActions(appId, capabilities.canEdit)
if (!appInfo) return <Loading />
const workflowState = getPublishedWorkflowState(appInfo, workflow)
const builtInLoading = workflowState.isWorkflowApp && workflowLoading
const appCardsUnavailable =
workflowState.isWorkflowApp && (workflowState.isUnpublished || workflowState.hasTriggerNode)
const appCardAvailability = builtInLoading
? 'loading'
: appCardsUnavailable
? 'unavailable'
: 'available'
const triggerAvailability = builtInLoading
? 'loading'
: workflowState.isUnpublished || !workflowState.hasTriggerNode
? 'unavailable'
: 'available'
return (
<div className="flex h-full flex-col gap-2">
{workflowState.isUnpublished && !workflowLoading && (
<div className="flex flex-col items-start gap-2 rounded-xl bg-background-section-burn p-3">
<div className="flex flex-col gap-0.5">
<span className="block system-md-semibold text-text-secondary">
{t(($) => $['studio.accessPoint.noPublishedTitle'], {
ns: 'deployments',
})}
</span>
<span className="block system-xs-regular text-text-tertiary">
{t(($) => $['studio.accessPoint.noPublishedDescription'], {
ns: 'deployments',
})}
</span>
</div>
<Button
variant="primary"
size="medium"
disabled={!capabilities.canReleaseAndVersion}
render={<Link href={`/app/${appId}/workflow`} />}
className="flex items-center gap-1"
>
{t(($) => $['studio.accessPoint.goToPublish'], { ns: 'deployments' })}
<span aria-hidden className="i-ri-arrow-right-line size-4" />
</Button>
</div>
)}
<div className="grid w-full grid-cols-1 gap-3 xl:grid-cols-2">
<WebAppAccessPointCard
appInfo={appInfo}
availability={appCardAvailability}
canEdit={capabilities.canEdit}
canDeploy={capabilities.canDeploy}
canManageAccess={capabilities.canReleaseAndVersion}
showAccessControl={systemFeatures.webapp_auth.enabled}
onChangeStatus={actions.changeSiteStatus}
onRefreshApp={actions.refreshAppDetail}
onRegenerate={actions.regenerateSiteCode}
onSaveSiteConfig={actions.saveSiteConfig}
workflow={workflow}
highlighted={highlightedAccessPoint === 'webApp'}
/>
<ServiceApiAccessPointCard
appInfo={appInfo}
availability={appCardAvailability}
canEdit={capabilities.canEdit}
onChangeStatus={actions.changeApiStatus}
highlighted={highlightedAccessPoint === 'serviceApi'}
/>
<MCPAccessPointCard
appInfo={appInfo}
canEdit={capabilities.canEdit}
workflow={workflow}
workflowLoading={workflowLoading}
triggerModeDisabled={workflowState.hasTriggerNode}
highlighted={highlightedAccessPoint === 'mcp'}
/>
{workflowState.isWorkflowApp && (
<TriggerAccessPointCard
appInfo={appInfo}
availability={triggerAvailability}
canEdit={capabilities.canEdit}
onToggleResult={actions.handleResult}
highlighted={highlightedAccessPoint === 'trigger'}
/>
)}
</div>
{workflowState.hasTriggerNode && (
<div className="mt-2 flex min-h-10 items-center gap-2 rounded-xl bg-background-section-burn px-3 py-2 system-xs-regular text-text-tertiary">
<span aria-hidden className="i-ri-information-line size-4 shrink-0" />
<span>
{t(($) => $['studio.accessPoint.triggerExclusiveNotice'], {
ns: 'deployments',
})}{' '}
<Link
href={docLink('/use-dify/nodes/start')}
target="_blank"
rel="noopener noreferrer"
className="text-text-accent hover:underline"
>
{t(($) => $['operation.learnMore'], { ns: 'common' })}
</Link>
</span>
</div>
)}
</div>
)
}
@@ -0,0 +1,223 @@
'use client'
import type { AccessPointAppInfo, PublishedWorkflow } from '../shared/utils'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import MCPServerModal from '@/app/components/tools/mcp/mcp-server-modal'
import { BlockEnum } from '@/app/components/workflow/types'
import {
useInvalidateMCPServerDetail,
useMCPServerDetail,
useRefreshMCPServerCode,
useUpdateMCPServer,
} from '@/service/use-tools'
import { AppModeEnum } from '@/types/app'
import { AccessPointCard } from '../shared/access-point-card'
import { AccessPointUrl } from '../shared/access-point-url'
import { getPublishedWorkflowNodes, isAdvancedApp } from '../shared/utils'
type MCPAccessPointCardProps = {
appInfo: AccessPointAppInfo
canEdit: boolean
highlighted?: boolean
triggerModeDisabled: boolean
workflow: PublishedWorkflow
workflowLoading: boolean
}
export function MCPAccessPointCard({
appInfo,
canEdit,
highlighted,
triggerModeDisabled,
workflow,
workflowLoading,
}: MCPAccessPointCardProps) {
const { t } = useTranslation()
const advancedApp = isAdvancedApp(appInfo)
const basicApp = !advancedApp
const workflowApp = appInfo.mode === AppModeEnum.WORKFLOW
const [showServerModal, setShowServerModal] = useState(false)
const [showRegenerate, setShowRegenerate] = useState(false)
const [pendingStatus, setPendingStatus] = useState<boolean | null>(null)
const basicConfig = appInfo.model_config
const basicAppInputForm = basicConfig?.user_input_form
const { data: detail, isPending: serverDetailLoading } = useMCPServerDetail(
appInfo.id,
Boolean(appInfo.id),
)
const { mutateAsync: updateServer, isPending: statusUpdating } = useUpdateMCPServer()
const { mutateAsync: refreshServerCode, isPending: regenerating } = useRefreshMCPServerCode()
const invalidateServerDetail = useInvalidateMCPServerDetail()
const serverPublished = Boolean(detail?.id)
const serverActivated = detail?.status === 'active'
const activated = pendingStatus ?? serverActivated
const serverUrl = serverPublished
? `${appInfo.api_base_url.replace(/\/v1$/, '')}/mcp/server/${detail?.server_code}/mcp`
: '***********'
const workflowNodes = getPublishedWorkflowNodes(workflow)
const missingStartNode =
workflowApp && !workflowNodes.some((node) => node.data.type === BlockEnum.Start)
const appUnpublished = advancedApp ? !workflow?.graph : !basicConfig?.updated_at
const loading = serverDetailLoading || (advancedApp && workflowLoading)
const unavailable = !loading && (appUnpublished || missingStartNode || triggerModeDisabled)
const basicAppInputs = useMemo(() => {
if (!basicApp || !basicAppInputForm) return []
return basicAppInputForm.map((item) => {
const [type = 'text-input'] = Object.keys(item)
const [config = {}] = Object.values(item) as object[]
return {
...config,
type,
}
})
}, [basicApp, basicAppInputForm])
const latestParams = useMemo(() => {
if (!advancedApp) return basicAppInputs
const startNode = workflowNodes.find((node) => node.data.type === BlockEnum.Start)
return (
(
startNode?.data as {
variables?: Array<{ variable: string; label: string }>
}
)?.variables ?? []
)
}, [advancedApp, basicAppInputs, workflowNodes])
const handleStatusChange = async (enabled: boolean) => {
if (!canEdit || loading || unavailable) return
if (enabled && !serverPublished) {
setShowServerModal(true)
return
}
setPendingStatus(enabled)
try {
await updateServer({
appID: appInfo.id,
id: detail?.id || '',
description: detail?.description || '',
parameters: detail?.parameters || {},
status: enabled ? 'active' : 'inactive',
})
invalidateServerDetail(appInfo.id)
} finally {
setPendingStatus(null)
}
}
const handleRegenerate = async () => {
if (!canEdit || !detail?.id) return
await refreshServerCode(appInfo.id)
invalidateServerDetail(appInfo.id)
setShowRegenerate(false)
}
const status = loading
? 'loading'
: unavailable
? 'unavailable'
: activated
? 'inService'
: 'disabled'
return (
<>
<AccessPointCard
title={t(($) => $['mcp.server.title'], { ns: 'tools' })}
description={t(($) => $['studio.accessPoint.mcpDescription'], {
ns: 'deployments',
})}
icon="i-custom-vender-integrations-mcp"
status={status}
highlighted={highlighted}
busy={statusUpdating}
switchDisabled={!canEdit}
switchLabel={t(($) => $['mcp.server.title'], { ns: 'tools' })}
onEnabledChange={loading || unavailable ? undefined : handleStatusChange}
actions={
<Button
variant="secondary"
disabled={loading || unavailable || !canEdit}
onClick={() => setShowServerModal(true)}
className="flex items-center gap-1 px-3"
>
<span aria-hidden className="i-ri-draft-line size-4" />
{serverPublished
? t(($) => $['mcp.server.edit'], { ns: 'tools' })
: t(($) => $['mcp.server.addDescription'], { ns: 'tools' })}
</Button>
}
>
<AccessPointUrl
label={t(($) => $['mcp.server.url'], { ns: 'tools' })}
value={serverUrl}
enabled={activated}
copyDisabled={!serverPublished}
loading={loading}
unavailable={unavailable}
unavailableLabel={t(($) => $['health.ENVIRONMENT_STATUS_FAILED'], {
ns: 'deployments',
})}
showRegenerate
regenerateLabel={t(($) => $['overview.appInfo.regenerate'], {
ns: 'appOverview',
})}
regenerateDisabled={!canEdit || !serverPublished}
regenerating={regenerating}
onRegenerate={() => setShowRegenerate(true)}
/>
</AccessPointCard>
{showServerModal && (
<MCPServerModal
show
appID={appInfo.id}
data={serverPublished ? detail : undefined}
latestParams={latestParams}
onHide={() => {
setShowServerModal(false)
setPendingStatus(null)
invalidateServerDetail(appInfo.id)
}}
appInfo={appInfo}
/>
)}
<AlertDialog open={showRegenerate} onOpenChange={(open) => !open && setShowRegenerate(false)}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{t(($) => $['overview.appInfo.regenerate'], { ns: 'appOverview' })}
</AlertDialogTitle>
<AlertDialogDescription className="system-md-regular text-text-tertiary">
{t(($) => $['mcp.server.reGen'], { ns: 'tools' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={() => void handleRegenerate()}>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}
@@ -0,0 +1,44 @@
'use client'
import type { AccessPointAvailability } from '../shared/access-point-status'
import type { AccessPointAppInfo } from '../shared/utils'
import { getAccessPointStatus } from '../shared/access-point-status'
import { ServiceApiCardView } from '../shared/service-api-card-view'
import { getBuiltInAccessUrls } from '../shared/utils'
type ServiceApiAccessPointCardProps = {
appInfo: AccessPointAppInfo
availability: AccessPointAvailability
canEdit: boolean
highlighted?: boolean
onChangeStatus: (enabled: boolean) => Promise<void>
}
export function ServiceApiAccessPointCard({
appInfo,
availability,
canEdit,
highlighted,
onChangeStatus,
}: ServiceApiAccessPointCardProps) {
const { api: apiUrl } = getBuiltInAccessUrls(appInfo)
const running = availability === 'available' && appInfo.enable_api
const status = getAccessPointStatus(availability, running)
return (
<ServiceApiCardView
apiKeyButtonProps={{
appId: appInfo.id,
canManage: canEdit,
disabled: availability !== 'available',
}}
apiUrl={apiUrl}
appMode={appInfo.mode}
available={availability === 'available'}
status={status}
highlighted={highlighted}
switchDisabled={!canEdit}
onEnabledChange={availability === 'available' ? onChangeStatus : undefined}
/>
)
}
@@ -0,0 +1,208 @@
'use client'
import type { AccessPointAppInfo } from '../shared/utils'
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
import type { AppTrigger } from '@/service/use-tools'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { Switch } from '@langgenius/dify-ui/switch'
import { useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import BlockIcon from '@/app/components/workflow/block-icon'
import { useTriggerStatusStore } from '@/app/components/workflow/store/trigger-status'
import { BlockEnum } from '@/app/components/workflow/types'
import { useDocLink } from '@/context/i18n'
import Link from '@/next/link'
import {
useAppTriggers,
useInvalidateAppTriggers,
useUpdateTriggerStatus,
} from '@/service/use-tools'
import { useAllTriggerPlugins } from '@/service/use-triggers'
import { canFindTool } from '@/utils'
import { AccessPointCard, AccessPointEmptyContent } from '../shared/access-point-card'
function TriggerIcon({
trigger,
triggerPlugins,
}: {
trigger: AppTrigger
triggerPlugins: TriggerWithProvider[]
}) {
const blockType =
trigger.trigger_type === 'trigger-schedule'
? BlockEnum.TriggerSchedule
: trigger.trigger_type === 'trigger-plugin'
? BlockEnum.TriggerPlugin
: BlockEnum.TriggerWebhook
const pluginTrigger =
trigger.trigger_type === 'trigger-plugin' && trigger.provider_name
? triggerPlugins.find(
(candidate) =>
canFindTool(candidate.id, trigger.provider_name!) ||
candidate.id.includes(trigger.provider_name!) ||
candidate.name === trigger.provider_name,
)
: undefined
const toolIcon = typeof pluginTrigger?.icon === 'string' ? pluginTrigger.icon : undefined
return (
<span>
<BlockIcon type={blockType} size="md" toolIcon={toolIcon} />
</span>
)
}
type TriggerAccessPointCardProps = {
appInfo: AccessPointAppInfo
availability: 'available' | 'loading' | 'unavailable'
canEdit: boolean
highlighted?: boolean
onToggleResult: (error: Error | null) => void
}
export function TriggerAccessPointCard({
appInfo,
availability,
canEdit,
highlighted,
onToggleResult,
}: TriggerAccessPointCardProps) {
const { t } = useTranslation()
const docLink = useDocLink()
const { data: response, isLoading } = useAppTriggers(appInfo.id)
const { data: triggerPlugins = [] } = useAllTriggerPlugins()
const { mutateAsync: updateTriggerStatus, isPending: statusUpdating } = useUpdateTriggerStatus()
const invalidateTriggers = useInvalidateAppTriggers()
const { setTriggerStatus, setTriggerStatuses } = useTriggerStatusStore()
const triggers = useMemo(() => response?.data ?? [], [response?.data])
const loading = availability === 'loading' || isLoading
const active = availability === 'available' && !loading
const status = loading ? 'loading' : active ? 'inService' : 'unavailable'
const enabledCount = triggers.filter((trigger) => trigger.status === 'enabled').length
useEffect(() => {
if (!triggers.length) return
setTriggerStatuses(
triggers.reduce(
(statuses, trigger) => {
statuses[trigger.node_id] = trigger.status === 'enabled' ? 'enabled' : 'disabled'
return statuses
},
{} as Record<string, 'disabled' | 'enabled'>,
),
)
}, [setTriggerStatuses, triggers])
const toggleTrigger = async (trigger: AppTrigger, enabled: boolean) => {
if (!canEdit) return
const status = enabled ? 'enabled' : 'disabled'
setTriggerStatus(trigger.node_id, status)
try {
await updateTriggerStatus({
appId: appInfo.id,
triggerId: trigger.id,
enableTrigger: enabled,
})
invalidateTriggers(appInfo.id)
onToggleResult(null)
} catch (error) {
setTriggerStatus(trigger.node_id, enabled ? 'disabled' : 'enabled')
onToggleResult(error as Error)
}
}
return (
<AccessPointCard
title={t(($) => $['settings.trigger'], { ns: 'common' })}
description={t(($) => $['studio.accessPoint.triggerDescription'], {
ns: 'deployments',
})}
icon="i-custom-vender-integrations-trigger"
status={status}
highlighted={highlighted}
showStatus={!active}
busy={statusUpdating}
>
{loading && (
<div className="flex h-full min-h-40 flex-col gap-4 px-4 py-5">
<span className="h-2 w-24 animate-pulse rounded-full bg-text-quaternary opacity-20 motion-reduce:animate-none" />
<span className="h-10 w-full animate-pulse rounded-lg bg-text-quaternary opacity-10 motion-reduce:animate-none" />
<span className="h-10 w-full animate-pulse rounded-lg bg-text-quaternary opacity-10 motion-reduce:animate-none" />
</div>
)}
{!loading && (!active || triggers.length === 0) && (
<AccessPointEmptyContent>
<span>
{t(($) => $['overview.triggerInfo.triggerStatusDescription'], {
ns: 'appOverview',
})}{' '}
<Link
href={docLink('/use-dify/nodes/trigger/overview')}
target="_blank"
rel="noopener noreferrer"
className="text-text-accent hover:underline"
>
{t(($) => $['overview.triggerInfo.learnAboutTriggers'], {
ns: 'appOverview',
})}
</Link>
</span>
</AccessPointEmptyContent>
)}
{active && triggers.length > 0 && (
<div className="flex flex-col px-4 py-3">
<div className="flex h-6 items-center system-xs-medium-uppercase text-text-secondary">
{t(($) => $['studio.accessPoint.triggerEnabledCount'], {
ns: 'deployments',
enabled: enabledCount,
total: triggers.length,
})}
</div>
<div className="mt-1 flex flex-col gap-1">
{triggers.map((trigger) => {
const enabled = trigger.status === 'enabled'
const statusLabel = enabled
? t(($) => $['agentDetail.access.status.inService'], {
ns: 'agentV2',
})
: t(($) => $['overview.status.disable'], {
ns: 'appOverview',
})
return (
<div
key={trigger.id}
className="flex min-h-11 items-center gap-3 rounded-lg px-2 py-1.5 hover:bg-state-base-hover"
>
<TriggerIcon trigger={trigger} triggerPlugins={triggerPlugins} />
<span className="w-28 shrink-0 truncate system-sm-medium text-text-secondary">
{trigger.title}
</span>
<span className="min-w-0 flex-1 truncate system-xs-regular text-text-tertiary">
{trigger.provider_name}
</span>
<span
className={`flex shrink-0 items-center gap-1 system-xs-semibold-uppercase ${
enabled ? 'text-text-success' : 'text-text-tertiary'
}`}
>
<StatusDot size="small" status={enabled ? 'success' : 'disabled'} />
{statusLabel}
</span>
<Switch
checked={enabled}
disabled={!canEdit || statusUpdating}
aria-label={trigger.title}
onCheckedChange={(nextEnabled) => void toggleTrigger(trigger, nextEnabled)}
/>
</div>
)
})}
</div>
</div>
)}
</AccessPointCard>
)
}
@@ -0,0 +1,276 @@
'use client'
import type { SelectorParam } from 'i18next'
import type { AccessPointAvailability } from '../shared/access-point-status'
import type { AccessPointAppInfo, PublishedWorkflow } from '../shared/utils'
import type { ConfigParams } from '@/app/components/app/overview/settings'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import AccessControl from '@/app/components/app/app-access-control'
import CustomizeModal from '@/app/components/app/overview/customize'
import EmbeddedModal from '@/app/components/app/overview/embedded'
import SettingsModal from '@/app/components/app/overview/settings'
import { WorkflowLaunchDialog } from '@/app/components/app/overview/workflow-launch-dialog'
import AppIcon from '@/app/components/base/app-icon'
import { AccessMode } from '@/models/access-control'
import { useAppWhiteListSubjects } from '@/service/access-control/use-app-access-control'
import { AppModeEnum } from '@/types/app'
import { AccessPointCard } from '../shared/access-point-card'
import { getAccessPointStatus } from '../shared/access-point-status'
import { AccessPointUrl } from '../shared/access-point-url'
import { getBuiltInAccessUrls, getHiddenStartInputs } from '../shared/utils'
import { WebAppAccessControlEntry } from '../shared/web-app-access-control'
const ACCESS_MODE_ICON_MAP: Record<AccessMode, string> = {
[AccessMode.ORGANIZATION]: 'i-ri-building-line',
[AccessMode.SPECIFIC_GROUPS_MEMBERS]: 'i-ri-lock-line',
[AccessMode.PUBLIC]: 'i-ri-global-line',
[AccessMode.EXTERNAL_MEMBERS]: 'i-ri-verified-badge-line',
}
const ACCESS_MODE_LABEL_MAP: Record<AccessMode, SelectorParam<'app'>> = {
[AccessMode.ORGANIZATION]: ($) => $['accessControlDialog.accessItems.organization'],
[AccessMode.SPECIFIC_GROUPS_MEMBERS]: ($) => $['accessControlDialog.accessItems.specific'],
[AccessMode.PUBLIC]: ($) => $['accessControlDialog.accessItems.anyone'],
[AccessMode.EXTERNAL_MEMBERS]: ($) => $['accessControlDialog.accessItems.external'],
}
type WebAppAccessPointCardProps = {
appInfo: AccessPointAppInfo
availability: AccessPointAvailability
canEdit: boolean
canDeploy: boolean
canManageAccess: boolean
highlighted?: boolean
showAccessControl: boolean
onChangeStatus: (enabled: boolean) => Promise<void>
onRefreshApp: () => Promise<void>
onRegenerate: () => Promise<void>
onSaveSiteConfig: (params: ConfigParams) => Promise<void>
workflow: PublishedWorkflow
}
export function WebAppAccessPointCard({
appInfo,
availability,
canEdit,
canDeploy,
canManageAccess,
highlighted,
onChangeStatus,
onRefreshApp,
onRegenerate,
onSaveSiteConfig,
showAccessControl,
workflow,
}: WebAppAccessPointCardProps) {
const { t } = useTranslation()
const [showSettings, setShowSettings] = useState(false)
const [showEmbedded, setShowEmbedded] = useState(false)
const [showCustomize, setShowCustomize] = useState(false)
const [showAccess, setShowAccess] = useState(false)
const [showRegenerate, setShowRegenerate] = useState(false)
const [showWorkflowLaunch, setShowWorkflowLaunch] = useState(false)
const [regenerating, setRegenerating] = useState(false)
const { webApp: webAppUrl } = getBuiltInAccessUrls(appInfo)
const running = availability === 'available' && appInfo.enable_site
const supportsEmbedded =
appInfo.mode !== AppModeEnum.COMPLETION && appInfo.mode !== AppModeEnum.WORKFLOW
const hiddenLaunchVariables = getHiddenStartInputs(workflow)
const accessIcon = ACCESS_MODE_ICON_MAP[appInfo.access_mode]
const accessLabel = ACCESS_MODE_LABEL_MAP[appInfo.access_mode]
const { data: accessSubjects } = useAppWhiteListSubjects(
appInfo.id,
showAccessControl &&
canManageAccess &&
appInfo.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS,
)
const accessConfigured =
!accessSubjects ||
appInfo.access_mode !== AccessMode.SPECIFIC_GROUPS_MEMBERS ||
Boolean(accessSubjects?.groups?.length || accessSubjects?.members?.length)
const handleRegenerate = async () => {
setRegenerating(true)
await onRegenerate()
setRegenerating(false)
setShowRegenerate(false)
}
const status = getAccessPointStatus(availability, running)
return (
<>
<AccessPointCard
title={t(($) => $['agentDetail.access.webApp.title'], { ns: 'agentV2' })}
description={t(($) => $['studio.accessPoint.webAppDescription'], {
ns: 'deployments',
})}
icon={
<AppIcon
size="large"
iconType={appInfo.icon_type}
icon={appInfo.icon}
background={appInfo.icon_background}
imageUrl={appInfo.icon_url}
/>
}
status={status}
highlighted={highlighted}
switchDisabled={!canEdit}
switchLabel={t(($) => $['overview.appInfo.title'], { ns: 'appOverview' })}
onEnabledChange={availability === 'available' ? onChangeStatus : undefined}
actions={
<>
{hiddenLaunchVariables.length > 0 && (
<Button
className="flex items-center gap-1 px-3"
variant="secondary"
disabled={!running}
onClick={() => setShowWorkflowLaunch(true)}
>
<span aria-hidden className="i-ri-settings-2-line size-4" />
{t(($) => $['operation.config'], { ns: 'common' })}
</Button>
)}
{supportsEmbedded && (
<Button
className="flex items-center gap-1 px-3"
variant="secondary"
disabled={!running}
onClick={() => setShowEmbedded(true)}
>
<span aria-hidden className="i-ri-window-line size-4" />
{t(($) => $['studio.accessPoint.embedIntoSite'], { ns: 'deployments' })}
</Button>
)}
<Button
className="flex items-center gap-1 px-3"
variant="secondary"
disabled={!running}
onClick={() => setShowCustomize(true)}
>
<span aria-hidden className="i-custom-vender-deploy-code-block size-4" />
{t(($) => $['overview.appInfo.customize.entry'], {
ns: 'appOverview',
})}
</Button>
<Button
className="flex items-center gap-1 px-3"
variant="secondary"
disabled={availability !== 'available' || !canEdit}
onClick={() => setShowSettings(true)}
>
<span aria-hidden className="i-ri-equalizer-2-line size-4" />
{t(($) => $['settings.settings'], { ns: 'common' })}
</Button>
</>
}
>
<AccessPointUrl
label={t(($) => $['agentDetail.access.webApp.accessUrl'], { ns: 'agentV2' })}
value={webAppUrl}
enabled={running}
loading={availability === 'loading'}
unavailable={availability === 'unavailable'}
unavailableLabel={t(($) => $['health.ENVIRONMENT_STATUS_FAILED'], {
ns: 'deployments',
})}
showOpen
showQrCode
showRegenerate
openLabel={t(($) => $['studio.accessPoint.open'], { ns: 'deployments' })}
regenerateLabel={t(($) => $['overview.appInfo.regenerate'], {
ns: 'appOverview',
})}
regenerateDisabled={!canEdit}
regenerating={regenerating}
onOpen={() => window.open(webAppUrl, '_blank')}
onRegenerate={() => setShowRegenerate(true)}
/>
{showAccessControl && (
<WebAppAccessControlEntry
accessConfigured={accessConfigured}
accessIcon={accessIcon}
accessLabel={t(accessLabel, { ns: 'app' })}
available={availability === 'available'}
disabled={!canManageAccess}
onClick={() => setShowAccess(true)}
/>
)}
</AccessPointCard>
<SettingsModal
isChat={appInfo.mode !== AppModeEnum.COMPLETION && appInfo.mode !== AppModeEnum.WORKFLOW}
canDeploy={canDeploy}
appInfo={appInfo}
isShow={showSettings}
onClose={() => setShowSettings(false)}
onSave={onSaveSiteConfig}
/>
{supportsEmbedded && (
<EmbeddedModal
siteInfo={appInfo.site}
isShow={showEmbedded}
onClose={() => setShowEmbedded(false)}
appBaseUrl={appInfo.site?.app_base_url}
accessToken={appInfo.site?.access_token}
hiddenInputs={hiddenLaunchVariables}
/>
)}
<CustomizeModal
isShow={showCustomize}
onClose={() => setShowCustomize(false)}
appId={appInfo.id}
api_base_url={appInfo.api_base_url}
mode={appInfo.mode}
/>
{showAccess && (
<AccessControl
app={appInfo}
onClose={() => setShowAccess(false)}
onConfirm={async () => {
await onRefreshApp()
setShowAccess(false)
}}
/>
)}
<WorkflowLaunchDialog
hiddenVariables={hiddenLaunchVariables}
open={showWorkflowLaunch}
targetUrl={webAppUrl}
onOpenChange={setShowWorkflowLaunch}
/>
<AlertDialog open={showRegenerate} onOpenChange={(open) => !open && setShowRegenerate(false)}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{t(($) => $['overview.appInfo.regenerate'], { ns: 'appOverview' })}
</AlertDialogTitle>
<AlertDialogDescription className="system-md-regular text-text-tertiary">
{t(($) => $['overview.appInfo.regenerateNotice'], { ns: 'appOverview' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={() => void handleRegenerate()}>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}
@@ -0,0 +1,171 @@
'use client'
import type { EnvironmentWebAppSubject } from '@dify/contracts/enterprise-app-deploy/types.gen'
import type {
AccessControlSubjects,
AccessControlSubjectsStatus,
} from '@/app/components/app/app-access-control/specific-groups-or-members'
import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AccessControlForm } from '@/app/components/app/app-access-control/access-control-form'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { AccessMode, SubjectType } from '@/models/access-control'
import { consoleQuery } from '@/service/client'
const EMPTY_SUBJECTS: AccessControlSubjects = {
groups: [],
members: [],
}
type EnvironmentAccessControlProps = {
appId: string
environmentId: string
accessMode: AccessMode
canManage: boolean
onClose: () => void
onConfirm: () => void
}
export function EnvironmentAccessControl(props: EnvironmentAccessControlProps) {
const key = `${props.appId}:${props.environmentId}`
return <EnvironmentAccessControlContainer key={key} {...props} />
}
function EnvironmentAccessControlContainer({
appId,
environmentId,
accessMode: initialAccessMode,
canManage,
onClose,
onConfirm,
}: EnvironmentAccessControlProps) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const [accessMode, setAccessMode] = useState<AccessMode>(initialAccessMode)
const [subjectsDraft, setSubjectsDraft] = useState<AccessControlSubjects>()
const params = {
app_id: appId,
environment_id: environmentId,
}
const siteQueryOptions =
consoleQuery.enterprise.appDeploy.accessService.getEnvironmentSite.queryOptions({
input: { params },
})
const subjectsQueryOptions =
consoleQuery.enterprise.appDeploy.accessService.getEnvironmentWebAppSubjects.queryOptions({
input: { params },
})
const subjectsQuery = useQuery({
...subjectsQueryOptions,
enabled: accessMode === AccessMode.SPECIFIC_GROUPS_MEMBERS,
})
const loadedSubjects = subjectsQuery.data
? normalizeEnvironmentSubjects(subjectsQuery.data.subjects)
: undefined
const subjects = subjectsDraft ?? loadedSubjects ?? EMPTY_SUBJECTS
const subjectsStatus: AccessControlSubjectsStatus =
subjectsDraft || loadedSubjects
? 'success'
: subjectsQuery.isFetching || subjectsQuery.isPending
? 'loading'
: subjectsQuery.isError
? 'error'
: 'loading'
const updateAccessModeMutation = useMutation(
consoleQuery.enterprise.appDeploy.accessService.updateEnvironmentWebAppAccessMode.mutationOptions(
{
onSuccess: (updatedSite) => {
queryClient.setQueryData(siteQueryOptions.queryKey, updatedSite)
void queryClient.invalidateQueries({ queryKey: subjectsQueryOptions.queryKey })
},
onError: () => {
toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' }))
},
},
),
)
const publicAccessDisabled = !systemFeatures.webapp_auth.allow_public_access
const externalMembersTipHidden =
systemFeatures.webapp_auth.enabled &&
(systemFeatures.webapp_auth.allow_sso ||
systemFeatures.webapp_auth.allow_email_password_login ||
systemFeatures.webapp_auth.allow_email_code_login)
const handleConfirm = async () => {
if (
!canManage ||
updateAccessModeMutation.isPending ||
(accessMode === AccessMode.SPECIFIC_GROUPS_MEMBERS && subjectsStatus !== 'success') ||
(accessMode === AccessMode.PUBLIC && publicAccessDisabled)
)
return
await updateAccessModeMutation.mutateAsync({
params,
body: {
access_mode: accessMode,
...(accessMode === AccessMode.SPECIFIC_GROUPS_MEMBERS
? {
subjects: [
...subjects.groups.map((group) => ({
subject_id: group.id,
subject_type: SubjectType.GROUP,
})),
...subjects.members.map((member) => ({
subject_id: member.id,
subject_type: SubjectType.ACCOUNT,
})),
],
}
: {}),
},
})
toast.success(t(($) => $['accessControlDialog.updateSuccess'], { ns: 'app' }))
onConfirm()
}
return (
<AccessControlForm
accessMode={accessMode}
subjects={subjects}
subjectsStatus={subjectsStatus}
updatePending={updateAccessModeMutation.isPending}
publicAccessDisabled={publicAccessDisabled}
externalMembersTipHidden={externalMembersTipHidden}
onAccessModeChange={setAccessMode}
onSubjectsChange={setSubjectsDraft}
onRetrySubjects={() => void subjectsQuery.refetch()}
onClose={onClose}
onConfirm={() => void handleConfirm()}
/>
)
}
function normalizeEnvironmentSubjects(subjects: EnvironmentWebAppSubject[]) {
const groups: AccessControlGroup[] = []
const members: AccessControlAccount[] = []
subjects.forEach((subject) => {
if (subject.subject_type === SubjectType.GROUP) {
const id = subject.subject_id || subject.group_data?.id
const name = subject.group_data?.name
const groupSize = subject.group_data?.group_size
if (id && name && groupSize !== undefined) groups.push({ id, name, groupSize })
return
}
if (subject.subject_type === SubjectType.ACCOUNT) {
const id = subject.subject_id || subject.account_data?.id
const name = subject.account_data?.name
const email = subject.account_data?.email
const avatar = subject.account_data?.avatar ?? ''
if (id && name && email) members.push({ id, name, email, avatar, avatarUrl: avatar })
}
})
return { groups, members }
}
@@ -0,0 +1,84 @@
'use client'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { consoleQuery } from '@/service/client'
import { ServiceApiCardView } from '../shared/service-api-card-view'
type EnvironmentServiceApiCardProps = {
appId: string
environmentId: string
canManage: boolean
highlighted?: boolean
}
export function EnvironmentServiceApiCard({
appId,
environmentId,
canManage,
highlighted,
}: EnvironmentServiceApiCardProps) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const appMode = useAppStore((state) => state.appDetail?.mode)
const params = {
app_id: appId,
environment_id: environmentId,
}
const apiQueryOptions =
consoleQuery.enterprise.appDeploy.accessService.getEnvironmentApi.queryOptions({
input: { params },
})
const apiQuery = useQuery(apiQueryOptions)
const api = apiQuery.data
const apiMutation = useMutation(
consoleQuery.enterprise.appDeploy.accessService.updateEnvironmentApi.mutationOptions({
onSuccess: (updatedApi) => {
queryClient.setQueryData(apiQueryOptions.queryKey, updatedApi)
toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' }))
},
onError: () => {
toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' }))
},
}),
)
const running = Boolean(apiQuery.isSuccess && api?.enabled)
const status = apiQuery.isPending
? 'loading'
: apiQuery.isError
? 'unavailable'
: running
? 'inService'
: 'disabled'
const handleEnabledChange = (enabled: boolean) => {
if (!canManage) return
apiMutation.mutate({
params,
body: { enabled },
})
}
return (
<ServiceApiCardView
apiKeyButtonProps={{
appId,
environmentId,
apiKeyCount: api?.api_key_count,
canManage,
disabled: !apiQuery.isSuccess,
}}
apiUrl={api?.base_url ?? ''}
appMode={appMode}
available={apiQuery.isSuccess}
status={status}
highlighted={highlighted}
switchDisabled={!canManage}
onEnabledChange={apiQuery.isSuccess ? handleEnabledChange : undefined}
busy={apiMutation.isPending}
/>
)
}
@@ -0,0 +1,290 @@
'use client'
import type { AccessPointAppInfo } from '../shared/utils'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import CustomizeModal from '@/app/components/app/overview/customize'
import SettingsModal from '@/app/components/app/overview/settings'
import { useStore as useAppStore } from '@/app/components/app/store'
import AppIcon from '@/app/components/base/app-icon'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { AccessMode, isAccessMode } from '@/models/access-control'
import { consoleQuery } from '@/service/client'
import { AccessPointCard } from '../shared/access-point-card'
import { AccessPointUrl } from '../shared/access-point-url'
import { useAccessPointActions } from '../shared/use-access-point-actions'
import { WebAppAccessControlEntry } from '../shared/web-app-access-control'
import { EnvironmentAccessControl } from './environment-access-control'
import { getEnvironmentWebAppUrl } from './environment-web-app-utils'
const ACCESS_MODE_ICON_MAP: Record<AccessMode, string> = {
[AccessMode.ORGANIZATION]: 'i-ri-building-line',
[AccessMode.SPECIFIC_GROUPS_MEMBERS]: 'i-ri-lock-line',
[AccessMode.EXTERNAL_MEMBERS]: 'i-ri-verified-badge-line',
[AccessMode.PUBLIC]: 'i-ri-global-line',
}
type EnvironmentWebAppCardProps = {
appId: string
environmentId: string
canEdit: boolean
canManage: boolean
highlighted?: boolean
}
export function EnvironmentWebAppCard({
appId,
environmentId,
canEdit,
canManage,
highlighted,
}: EnvironmentWebAppCardProps) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const appInfo = useAppStore((state) => state.appDetail) as AccessPointAppInfo | null
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const actions = useAccessPointActions(appId, canEdit)
const [showSettings, setShowSettings] = useState(false)
const [showCustomize, setShowCustomize] = useState(false)
const [showAccess, setShowAccess] = useState(false)
const [showRegenerate, setShowRegenerate] = useState(false)
const params = {
app_id: appId,
environment_id: environmentId,
}
const siteQueryOptions =
consoleQuery.enterprise.appDeploy.accessService.getEnvironmentSite.queryOptions({
input: { params },
})
const siteQuery = useQuery(siteQueryOptions)
const site = siteQuery.data
const siteAccessMode = site?.access_mode
const apiQuery = useQuery(
consoleQuery.enterprise.appDeploy.accessService.getEnvironmentApi.queryOptions({
input: { params },
}),
)
const accessMode = isAccessMode(siteAccessMode) ? siteAccessMode : AccessMode.ORGANIZATION
const subjectsQueryOptions =
consoleQuery.enterprise.appDeploy.accessService.getEnvironmentWebAppSubjects.queryOptions({
input: { params },
})
const subjectsQuery = useQuery({
...subjectsQueryOptions,
enabled:
siteQuery.isSuccess &&
canManage &&
(showAccess || accessMode === AccessMode.SPECIFIC_GROUPS_MEMBERS),
})
const accessConfigured =
!subjectsQuery.data ||
accessMode !== AccessMode.SPECIFIC_GROUPS_MEMBERS ||
subjectsQuery.data.subjects.length > 0
const siteMutation = useMutation(
consoleQuery.enterprise.appDeploy.accessService.updateEnvironmentSite.mutationOptions({
onSuccess: (updatedSite) => {
queryClient.setQueryData(siteQueryOptions.queryKey, updatedSite)
toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' }))
},
onError: () => {
toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' }))
},
}),
)
const resetAccessTokenMutation = useMutation(
consoleQuery.enterprise.appDeploy.accessService.resetEnvironmentSiteAccessToken.mutationOptions(
{
onSuccess: (updatedSite) => {
queryClient.setQueryData(siteQueryOptions.queryKey, updatedSite)
setShowRegenerate(false)
toast.success(t(($) => $['actionMsg.generatedSuccessfully'], { ns: 'common' }))
},
onError: () => {
toast.error(t(($) => $['actionMsg.generatedUnsuccessfully'], { ns: 'common' }))
},
},
),
)
const webAppUrl = getEnvironmentWebAppUrl(site)
const running = Boolean(siteQuery.isSuccess && site?.enabled)
const status = siteQuery.isPending
? 'loading'
: siteQuery.isError
? 'unavailable'
: running
? 'inService'
: 'disabled'
const accessLabel =
accessMode === AccessMode.ORGANIZATION
? t(($) => $['accessControlDialog.accessItems.organization'], { ns: 'app' })
: accessMode === AccessMode.SPECIFIC_GROUPS_MEMBERS
? t(($) => $['accessControlDialog.accessItems.specific'], { ns: 'app' })
: accessMode === AccessMode.EXTERNAL_MEMBERS
? t(($) => $['accessControlDialog.accessItems.external'], { ns: 'app' })
: t(($) => $['accessControlDialog.accessItems.anyone'], { ns: 'app' })
const handleEnabledChange = (enabled: boolean) => {
if (!canManage) return
siteMutation.mutate({
params,
body: { enabled },
})
}
const handleRegenerate = () => {
if (!canManage) return
resetAccessTokenMutation.mutate({ params })
}
return (
<>
<AccessPointCard
title={t(($) => $['agentDetail.access.webApp.title'], { ns: 'agentV2' })}
description={t(($) => $['studio.accessPoint.webAppDescription'], {
ns: 'deployments',
})}
icon={
appInfo ? (
<AppIcon
size="large"
iconType={appInfo.icon_type}
icon={appInfo.icon}
background={appInfo.icon_background}
imageUrl={appInfo.icon_url}
/>
) : (
'i-ri-robot-2-line'
)
}
status={status}
highlighted={highlighted}
switchDisabled={!canManage}
switchLabel={t(($) => $['overview.appInfo.title'], { ns: 'appOverview' })}
onEnabledChange={siteQuery.isSuccess ? handleEnabledChange : undefined}
busy={siteMutation.isPending}
actions={
<>
<Button
className="flex items-center gap-1 px-3"
variant="secondary"
disabled={!running || !apiQuery.isSuccess}
onClick={() => setShowCustomize(true)}
>
<span aria-hidden className="i-custom-vender-deploy-code-block size-4" />
{t(($) => $['overview.appInfo.customize.entry'], {
ns: 'appOverview',
})}
</Button>
<Button
className="flex items-center gap-1 px-3"
variant="secondary"
disabled={!appInfo || !siteQuery.isSuccess || !canEdit}
onClick={() => setShowSettings(true)}
>
<span aria-hidden className="i-ri-equalizer-2-line size-4" />
{t(($) => $['settings.settings'], { ns: 'common' })}
</Button>
</>
}
>
<AccessPointUrl
label={t(($) => $['agentDetail.access.webApp.accessUrl'], { ns: 'agentV2' })}
value={webAppUrl}
enabled={running}
loading={siteQuery.isPending}
unavailable={siteQuery.isError}
unavailableLabel={t(($) => $['health.ENVIRONMENT_STATUS_FAILED'], {
ns: 'deployments',
})}
showOpen
showQrCode
showRegenerate
openLabel={t(($) => $['studio.accessPoint.open'], { ns: 'deployments' })}
regenerateLabel={t(($) => $['overview.appInfo.regenerate'], {
ns: 'appOverview',
})}
regenerateDisabled={!canManage}
regenerating={resetAccessTokenMutation.isPending}
onOpen={() => window.open(webAppUrl, '_blank')}
onRegenerate={() => setShowRegenerate(true)}
/>
{systemFeatures.webapp_auth.enabled && (
<WebAppAccessControlEntry
accessConfigured={accessConfigured}
accessIcon={ACCESS_MODE_ICON_MAP[accessMode]}
accessLabel={accessLabel}
available={siteQuery.isSuccess}
disabled={!canManage}
onClick={() => setShowAccess(true)}
/>
)}
</AccessPointCard>
{appInfo && (
<SettingsModal
isChat={false}
canDeploy={canManage}
appInfo={appInfo}
isShow={showSettings}
onClose={() => setShowSettings(false)}
onSave={actions.saveSiteConfig}
/>
)}
<CustomizeModal
isShow={showCustomize}
onClose={() => setShowCustomize(false)}
appId={appId}
api_base_url={apiQuery.data?.base_url ?? ''}
mode={appInfo?.mode}
/>
{showAccess && (
<EnvironmentAccessControl
appId={appId}
environmentId={environmentId}
accessMode={accessMode}
canManage={canManage}
onClose={() => setShowAccess(false)}
onConfirm={() => setShowAccess(false)}
/>
)}
<AlertDialog open={showRegenerate} onOpenChange={setShowRegenerate}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{t(($) => $['overview.appInfo.regenerate'], { ns: 'appOverview' })}
</AlertDialogTitle>
<AlertDialogDescription className="system-md-regular text-text-tertiary">
{t(($) => $['overview.appInfo.regenerateNotice'], {
ns: 'appOverview',
})}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={resetAccessTokenMutation.isPending}
onClick={handleRegenerate}
>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}
@@ -0,0 +1,8 @@
import type { EnvironmentSite } from '@dify/contracts/enterprise-app-deploy/types.gen'
import { basePath } from '@/utils/var'
export function getEnvironmentWebAppUrl(site?: EnvironmentSite) {
if (!site?.app_base_url || !site.code) return ''
return `${site.app_base_url.replace(/\/$/, '')}${basePath}/env/workflow/${site.code}`
}
@@ -0,0 +1,100 @@
'use client'
import type { AccessPoint } from '@/app/components/app/deploy/access-point'
import { useTranslation } from 'react-i18next'
import { AccessPointCard, AccessPointEmptyContent } from '../shared/access-point-card'
import { EnvironmentServiceApiCard } from './environment-service-api-card'
import { EnvironmentWebAppCard } from './environment-web-app-card'
const ACCESS_POINT_CONFIG: Record<
Exclude<AccessPoint, 'serviceApi' | 'webApp'>,
{
description: 'mcp' | 'trigger'
icon: string
title: 'mcp' | 'trigger'
}
> = {
mcp: {
description: 'mcp',
icon: 'i-custom-vender-integrations-mcp',
title: 'mcp',
},
trigger: {
description: 'trigger',
icon: 'i-custom-vender-integrations-trigger',
title: 'trigger',
},
}
const UNSUPPORTED_ACCESS_POINTS = ['mcp', 'trigger'] as const
type DeployedEnvironmentAccessPointsProps = {
appId: string
environmentId: string
canEdit: boolean
canManage: boolean
highlightedAccessPoint?: AccessPoint | null
}
export function DeployedEnvironmentAccessPoints({
appId,
environmentId,
canEdit,
canManage,
highlightedAccessPoint,
}: DeployedEnvironmentAccessPointsProps) {
const { t } = useTranslation()
const title = (accessPoint: (typeof UNSUPPORTED_ACCESS_POINTS)[number]) => {
const key = ACCESS_POINT_CONFIG[accessPoint].title
if (key === 'mcp') return t(($) => $['mcp.server.title'], { ns: 'tools' })
return t(($) => $['settings.trigger'], { ns: 'common' })
}
const description = (accessPoint: (typeof UNSUPPORTED_ACCESS_POINTS)[number]) => {
const key = ACCESS_POINT_CONFIG[accessPoint].description
if (key === 'mcp')
return t(($) => $['studio.accessPoint.mcpDescription'], {
ns: 'deployments',
})
return t(($) => $['studio.accessPoint.triggerDescription'], {
ns: 'deployments',
})
}
return (
<div className="grid w-full grid-cols-1 gap-3 xl:grid-cols-2">
<EnvironmentWebAppCard
appId={appId}
environmentId={environmentId}
canEdit={canEdit}
canManage={canManage}
highlighted={highlightedAccessPoint === 'webApp'}
/>
<EnvironmentServiceApiCard
appId={appId}
environmentId={environmentId}
canManage={canManage}
highlighted={highlightedAccessPoint === 'serviceApi'}
/>
{UNSUPPORTED_ACCESS_POINTS.map((accessPoint) => {
return (
<AccessPointCard
key={accessPoint}
title={title(accessPoint)}
description={description(accessPoint)}
icon={ACCESS_POINT_CONFIG[accessPoint].icon}
status="unsupported"
highlighted={highlightedAccessPoint === accessPoint}
>
<AccessPointEmptyContent>
{t(($) => $['studio.accessPoint.unsupportedInDeployedEnvironment'], {
ns: 'deployments',
})}
</AccessPointEmptyContent>
</AccessPointCard>
)
})}
</div>
)
}
@@ -0,0 +1,151 @@
'use client'
import { Tabs, TabsList, TabsTab } from '@langgenius/dify-ui/tabs'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs'
import { useTranslation } from 'react-i18next'
import { ACCESS_POINT_ORDER } from '@/app/components/app/deploy/access-point'
import { useStore as useAppStore } from '@/app/components/app/store'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { AppModeEnum } from '@/types/app'
import { getAppACLCapabilities } from '@/utils/permission'
import { BuiltInAccessPoints } from './built-in-access-points'
import { DeployedEnvironmentAccessPoints } from './deployed-environment-access-points'
import {
AccessPointStateBoundary,
BUILT_IN_ENVIRONMENT_ID,
inUseAppEnvironmentsAtom,
} from './state'
const environmentQueryState = parseAsString
.withDefault(BUILT_IN_ENVIRONMENT_ID)
.withOptions({ clearOnDefault: true })
const accessPointQueryState = parseAsStringLiteral(ACCESS_POINT_ORDER)
const accessPointQueryStates = {
environment: environmentQueryState,
accessPoint: accessPointQueryState,
}
type AccessPointProps = {
appId: string
}
type AccessPointContentProps = AccessPointProps & {
canEdit: boolean
canManage: boolean
showEnvironmentTabs: boolean
}
function AccessPointContent({
appId,
canEdit,
canManage,
showEnvironmentTabs,
}: AccessPointContentProps) {
const { t } = useTranslation()
const environments = useAtomValue(inUseAppEnvironmentsAtom)
const [queryStates, setQueryStates] = useQueryStates(accessPointQueryStates)
const { accessPoint: highlightedAccessPoint, environment } = queryStates
const selectedEnvironment =
showEnvironmentTabs &&
(environment === BUILT_IN_ENVIRONMENT_ID ||
environments.some((candidate) => candidate.id === environment))
? environment
: BUILT_IN_ENVIRONMENT_ID
const selectedHighlightedAccessPoint =
environment === selectedEnvironment ? highlightedAccessPoint : null
return (
<main className="flex h-full min-h-0 flex-col bg-components-panel-bg">
<header className="flex shrink-0 flex-col gap-3 px-6 pt-3 pb-2">
<div className="flex flex-col gap-0.5">
<div className="flex h-6 items-center">
<h1 className="title-xl-semi-bold text-text-primary">
{t(($) => $['appMenus.accessPoint'], { ns: 'common' })}
</h1>
</div>
<p className="system-xs-regular text-text-tertiary">
{t(($) => $['studio.accessPoint.description'], { ns: 'deployments' })}
</p>
</div>
{showEnvironmentTabs && (
<Tabs
value={selectedEnvironment}
onValueChange={(environment) => void setQueryStates({ accessPoint: null, environment })}
>
<div className="overflow-x-auto">
<TabsList
aria-label={t(($) => $['studio.environments'], { ns: 'deployments' })}
className="min-w-max gap-1"
>
<TabsTab
value={BUILT_IN_ENVIRONMENT_ID}
className="h-8 rounded-lg border-b-0 px-2.5 py-0 system-sm-medium data-active:border-transparent data-active:bg-state-base-active data-active:system-sm-semibold data-active:text-text-secondary"
>
{t(($) => $['nodes.common.memories.builtIn'], { ns: 'workflow' })}
</TabsTab>
{environments.map((environment) => (
<TabsTab
key={environment.id}
value={environment.id}
className="h-8 rounded-lg border-b-0 px-2.5 py-0 system-sm-medium data-active:border-transparent data-active:bg-state-base-active data-active:system-sm-semibold data-active:text-text-secondary"
>
{environment.display_name}
</TabsTab>
))}
</TabsList>
</div>
</Tabs>
)}
</header>
<div
className="min-h-0 flex-1 overflow-y-auto px-6 py-2"
data-environment={selectedEnvironment}
>
{selectedEnvironment === BUILT_IN_ENVIRONMENT_ID ? (
<BuiltInAccessPoints
appId={appId}
highlightedAccessPoint={selectedHighlightedAccessPoint}
/>
) : (
<DeployedEnvironmentAccessPoints
appId={appId}
environmentId={selectedEnvironment}
canEdit={canEdit}
canManage={canManage}
highlightedAccessPoint={selectedHighlightedAccessPoint}
/>
)}
</div>
</main>
)
}
export default function AccessPoint({ appId }: AccessPointProps) {
const appDetail = useAppStore((state) => state.appDetail)
const { data: currentUserId } = useSuspenseQuery({
...userProfileQueryOptions(),
select: (data) => data.profile.id,
})
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const capabilities = getAppACLCapabilities(appDetail?.permission_keys, {
currentUserId,
resourceMaintainer: appDetail?.maintainer,
workspacePermissionKeys,
})
const showEnvironmentTabs = appDetail?.mode === AppModeEnum.WORKFLOW && capabilities.canDeploy
return (
<AccessPointStateBoundary appId={appId} environmentQueryEnabled={showEnvironmentTabs}>
<AccessPointContent
appId={appId}
canEdit={capabilities.canEdit}
canManage={capabilities.canDeploy}
showEnvironmentTabs={showEnvironmentTabs}
/>
</AccessPointStateBoundary>
)
}
@@ -0,0 +1,176 @@
'use client'
import type { ReactNode } from 'react'
import type { AccessPointStatus } from './access-point-status'
import { cn } from '@langgenius/dify-ui/cn'
import { StatusDot, StatusDotSkeleton } from '@langgenius/dify-ui/status-dot'
import { Switch } from '@langgenius/dify-ui/switch'
import { useId } from 'react'
import { useTranslation } from 'react-i18next'
type AccessPointCardProps = {
actions?: ReactNode
children: ReactNode
description: string
icon: ReactNode | string
status: AccessPointStatus
title: string
busy?: boolean
className?: string
highlighted?: boolean
onEnabledChange?: (enabled: boolean) => void
showStatus?: boolean
switchDisabled?: boolean
switchLabel?: string
}
export function AccessPointCard({
actions,
busy = false,
children,
className,
description,
highlighted = false,
icon,
onEnabledChange,
showStatus = true,
status,
switchDisabled = false,
switchLabel,
title,
}: AccessPointCardProps) {
const { t } = useTranslation()
const titleId = useId()
const isEnabled = status === 'inService'
const isLoading = status === 'loading'
const showSwitch = (status === 'disabled' || status === 'inService') && Boolean(onEnabledChange)
const statusLabel: Record<AccessPointStatus, string> = {
disabled: t(($) => $['overview.status.disable'], { ns: 'appOverview' }),
inService: t(($) => $['agentDetail.access.status.inService'], { ns: 'agentV2' }),
loading: t(($) => $.loading, { ns: 'common' }),
unavailable: t(($) => $['health.ENVIRONMENT_STATUS_FAILED'], { ns: 'deployments' }),
unsupported: t(($) => $['studio.accessPoint.notSupported'], { ns: 'deployments' }),
}
return (
<section
aria-labelledby={titleId}
aria-busy={isLoading || undefined}
data-highlighted={highlighted || undefined}
className={cn(
'flex min-h-68 min-w-0 flex-col gap-0.5 rounded-xl bg-background-section-burn p-1',
highlighted && 'ring-2 ring-state-accent-solid',
className,
)}
>
<header className="flex min-h-14 shrink-0 items-center gap-2.5 py-2 pr-5 pl-2">
{typeof icon === 'string' ? (
<span className="flex size-10 shrink-0 items-center justify-center rounded-[10px] border-[0.5px] border-divider-regular bg-components-panel-on-panel-item-bg text-text-secondary">
<span aria-hidden className={cn(icon, 'size-5')} />
</span>
) : (
icon
)}
<span className="min-w-0 flex-1">
<h2 id={titleId} className="truncate system-md-semibold text-text-primary">
{title}
</h2>
<span className="block truncate system-xs-regular text-text-tertiary">{description}</span>
</span>
{showStatus && (
<>
<span
aria-live="polite"
className={cn(
'flex shrink-0 items-center gap-1 system-xs-semibold-uppercase',
status === 'inService' ? 'text-text-success' : 'text-text-tertiary',
)}
>
{isLoading ? (
<StatusDotSkeleton className="animate-pulse motion-reduce:animate-none" />
) : (
<StatusDot status={status === 'inService' ? 'success' : 'disabled'} />
)}
{statusLabel[status]}
</span>
{showSwitch && (
<Switch
checked={isEnabled}
disabled={switchDisabled}
loading={busy}
aria-label={switchLabel || title}
onCheckedChange={onEnabledChange}
/>
)}
</>
)}
</header>
<div className="flex min-h-0 flex-1 flex-col rounded-[10px] bg-components-panel-on-panel-item-bg">
<div className="min-h-0 flex-1">{children}</div>
{actions !== undefined && (
<footer className="flex shrink-0 flex-wrap items-center gap-2 border-t-[0.5px] border-divider-subtle p-4">
{actions}
</footer>
)}
</div>
</section>
)
}
type AccessPointEndpointProps = {
actions?: ReactNode
label: string
unavailableLabel: string
value: string
dimmed?: boolean
loading?: boolean
unavailable?: boolean
}
export function AccessPointEndpoint({
actions,
dimmed = false,
label,
loading = false,
unavailable = false,
unavailableLabel,
value,
}: AccessPointEndpointProps) {
return (
<div aria-busy={loading || undefined} className="flex flex-col gap-1 px-4 py-3">
<div className="flex h-6 items-center system-xs-medium text-text-secondary">{label}</div>
<div className="flex h-9 min-w-0 items-center gap-0.5 rounded-lg border-[0.5px] border-divider-subtle bg-components-input-bg-normal py-1 pr-1 pl-2">
{unavailable && !loading && (
<span className="shrink-0 rounded-[5px] border border-divider-deep px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary">
{unavailableLabel}
</span>
)}
<div className="flex min-w-0 flex-1 items-center px-1">
{loading ? (
<span className="h-2 w-[42%] animate-pulse rounded-full bg-text-quaternary opacity-20 motion-reduce:animate-none" />
) : (
<span
className={cn(
'truncate system-sm-regular text-text-secondary',
(dimmed || unavailable) && 'text-text-quaternary',
)}
translate="no"
>
{value}
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-0.5">{actions}</div>
</div>
</div>
)
}
export function AccessPointEmptyContent({ children }: { children: ReactNode }) {
return (
<div className="flex h-full min-h-36 items-center justify-center px-6 py-8 text-center system-xs-regular whitespace-pre-line text-text-tertiary">
{children}
</div>
)
}
@@ -0,0 +1,11 @@
export type AccessPointAvailability = 'available' | 'loading' | 'unavailable'
export type AccessPointStatus = 'disabled' | 'inService' | 'loading' | 'unavailable' | 'unsupported'
export function getAccessPointStatus(
availability: AccessPointAvailability,
enabled: boolean,
): AccessPointStatus {
if (availability === 'loading') return 'loading'
if (availability === 'unavailable') return 'unavailable'
return enabled ? 'inService' : 'disabled'
}
@@ -0,0 +1,134 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import CopyFeedback from '@/app/components/base/copy-feedback'
import ShareQRCode from '@/app/components/base/qrcode'
import ActionButton from '../../../base/action-button'
import { AccessPointEndpoint } from './access-point-card'
type AccessPointUrlProps = {
enabled: boolean
label: string
unavailableLabel: string
value: string
copyDisabled?: boolean
loading?: boolean
unavailable?: boolean
showOpen?: boolean
showQrCode?: boolean
showRegenerate?: boolean
onOpen?: () => void
onRegenerate?: () => void
openLabel?: string
regenerateLabel?: string
regenerateDisabled?: boolean
regenerating?: boolean
}
export function AccessPointUrl({
enabled,
label,
loading = false,
copyDisabled = false,
onOpen,
onRegenerate,
openLabel,
regenerateDisabled = false,
regenerateLabel,
regenerating = false,
showOpen = false,
showQrCode = false,
showRegenerate = false,
unavailable = false,
unavailableLabel,
value,
}: AccessPointUrlProps) {
const detailsAvailable = !loading && !unavailable
const disabledActions = (
<div className="flex items-center gap-0.5">
<div className="flex cursor-not-allowed items-center justify-center p-0.5">
<span aria-hidden className="i-ri-file-copy-line size-4 text-text-disabled" />
</div>
{showQrCode && (
<div className="flex cursor-not-allowed items-center justify-center p-0.5">
<span aria-hidden className="i-ri-qr-code-line size-4 text-text-disabled" />
</div>
)}
{showRegenerate && (
<div className="flex cursor-not-allowed items-center justify-center p-0.5">
<span aria-hidden className="i-ri-loop-left-line size-4 text-text-disabled" />
</div>
)}
{showOpen && (
<>
<span className="mx-1 h-3.5 w-px bg-divider-subtle" />
<div className="flex h-6 cursor-not-allowed items-center gap-1 rounded-md border-[0.5px] border-components-button-secondary-border-disabled px-1.5 system-sm-medium text-components-button-secondary-text-disabled backdrop-blur-xs">
<span aria-hidden className="i-ri-external-link-line size-3.5" />
{openLabel}
</div>
</>
)}
</div>
)
const availableActions = (
<div className="flex items-center gap-0.5">
{copyDisabled ? (
<div className="flex cursor-not-allowed items-center justify-center p-0.5">
<span aria-hidden className="i-ri-file-copy-line size-4 text-text-disabled" />
</div>
) : (
<CopyFeedback content={value} className="size-6!" />
)}
{showQrCode &&
(copyDisabled ? (
<div className="flex cursor-not-allowed items-center justify-center p-0.5">
<span aria-hidden className="i-ri-qr-code-line size-4 text-text-disabled" />
</div>
) : (
<ShareQRCode content={value} />
))}
{showRegenerate && (
<ActionButton
className="size-6 px-0"
aria-label={regenerateLabel}
disabled={regenerateDisabled || regenerating}
onClick={onRegenerate}
>
<span
aria-hidden
className={`i-ri-loop-left-line size-4 ${regenerating ? 'animate-spin' : ''}`}
/>
</ActionButton>
)}
{showOpen && (
<>
<span className="mx-1 h-3.5 w-px bg-divider-regular" />
<Button
variant="secondary"
size="small"
className="h-6 gap-1 px-1.5"
disabled={!enabled}
onClick={onOpen}
>
<span aria-hidden className="i-ri-external-link-line size-3.5" />
{openLabel}
</Button>
</>
)}
</div>
)
return (
<AccessPointEndpoint
label={label}
value={value}
unavailableLabel={unavailableLabel}
unavailable={unavailable}
dimmed={!enabled}
loading={loading}
actions={detailsAvailable ? availableActions : disabledActions}
/>
)
}
@@ -0,0 +1,73 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { skipToken, useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import SecretKeyModal from '@/app/components/develop/secret-key/secret-key-modal'
import { consoleQuery } from '@/service/client'
type ApiSecretKeyButtonProps = {
appId: string
canManage: boolean
apiKeyCount?: number
disabled?: boolean
environmentId?: string
}
export function ApiSecretKeyButton({
appId,
canManage,
apiKeyCount: environmentApiKeyCount,
disabled = false,
environmentId,
}: ApiSecretKeyButtonProps) {
const { t } = useTranslation()
const [modalOpen, setModalOpen] = useState(false)
const isEnvironmentScope = Boolean(environmentId)
const apiKeysQuery = useQuery(
consoleQuery.apps.byResourceId.apiKeys.get.queryOptions({
input: isEnvironmentScope ? skipToken : { params: { resource_id: appId } },
}),
)
const apiKeyCount = isEnvironmentScope
? (environmentApiKeyCount ?? 0)
: (apiKeysQuery.data?.data.length ?? 0)
const buttonDisabled =
disabled ||
!canManage ||
(!isEnvironmentScope && (apiKeysQuery.isPending || apiKeysQuery.isError))
return (
<>
<Button
variant="secondary"
size="medium"
className="gap-1.5 px-3"
disabled={buttonDisabled}
onClick={() => setModalOpen(true)}
>
<span aria-hidden className="i-ri-key-2-line size-4" />
{t(($) => $['apiKeyModal.apiSecretKey'], { ns: 'appApi' })}
<span
className={cn(
'flex min-w-4 shrink-0 items-center justify-center rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase tabular-nums',
buttonDisabled ? 'text-text-disabled' : 'text-text-tertiary',
)}
>
{apiKeyCount}
</span>
</Button>
<SecretKeyModal
canManage={canManage}
isShow={modalOpen}
scope={
environmentId ? { type: 'environment', appId, environmentId } : { type: 'app', appId }
}
onClose={() => setModalOpen(false)}
/>
</>
)
}
@@ -0,0 +1,93 @@
'use client'
import type { ComponentProps } from 'react'
import type { AccessPointStatus } from './access-point-status'
import type { AppModeEnum } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { useTranslation } from 'react-i18next'
import { useDocLink } from '@/context/i18n'
import Link from '@/next/link'
import { AccessPointCard } from './access-point-card'
import { AccessPointUrl } from './access-point-url'
import { ApiSecretKeyButton } from './api-secret-key-button'
import { getAppApiReferencePath } from './utils'
type ServiceApiCardViewProps = {
apiKeyButtonProps: ComponentProps<typeof ApiSecretKeyButton>
apiUrl: string
appMode?: AppModeEnum
available: boolean
status: AccessPointStatus
switchDisabled: boolean
busy?: boolean
highlighted?: boolean
onEnabledChange?: (enabled: boolean) => void
}
export function ServiceApiCardView({
apiKeyButtonProps,
apiUrl,
appMode,
available,
busy = false,
highlighted,
onEnabledChange,
status,
switchDisabled,
}: ServiceApiCardViewProps) {
const { t } = useTranslation()
const docLink = useDocLink()
const apiReferencePath = appMode ? getAppApiReferencePath(appMode) : undefined
const apiReferenceUrl = apiReferencePath ? docLink(apiReferencePath) : undefined
return (
<AccessPointCard
title={t(($) => $['agentDetail.access.serviceApi.title'], { ns: 'agentV2' })}
description={t(($) => $['studio.accessPoint.apiDescription'], {
ns: 'deployments',
})}
icon="i-custom-vender-knowledge-api-aggregate"
status={status}
highlighted={highlighted}
switchDisabled={switchDisabled}
switchLabel={t(($) => $['overview.apiInfo.title'], { ns: 'appOverview' })}
onEnabledChange={onEnabledChange}
busy={busy}
actions={
<>
<ApiSecretKeyButton {...apiKeyButtonProps} />
<Button
variant="secondary"
disabled={!available || !apiReferenceUrl}
nativeButton={false}
render={
apiReferenceUrl ? (
<Link href={apiReferenceUrl} target="_blank" rel="noopener noreferrer" />
) : (
<span />
)
}
className="flex items-center gap-1"
>
<span aria-hidden className="i-ri-book-open-line size-4" />
{t(($) => $['overview.apiInfo.doc'], { ns: 'appOverview' })}
<span aria-hidden className="i-ri-arrow-right-up-line size-3.5" />
</Button>
</>
}
>
<AccessPointUrl
label={t(($) => $['overview.apiInfo.accessibleAddress'], {
ns: 'appOverview',
})}
value={apiUrl}
enabled={status === 'inService'}
loading={status === 'loading'}
unavailable={status === 'unavailable'}
unavailableLabel={t(($) => $['health.ENVIRONMENT_STATUS_FAILED'], {
ns: 'deployments',
})}
/>
</AccessPointCard>
)
}
@@ -0,0 +1,133 @@
'use client'
import type { ConfigParams } from '@/app/components/app/overview/settings'
import type { UpdateAppSiteCodeResponse } from '@/models/app'
import type { App } from '@/types/app'
import type { I18nKeysByPrefix } from '@/types/i18n'
import { toast } from '@langgenius/dify-ui/toast'
import { useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
import {
fetchAppDetail,
updateAppSiteAccessToken,
updateAppSiteConfig,
updateAppSiteStatus,
} from '@/service/apps'
import { consoleQuery } from '@/service/client'
import { asyncRunSafe } from '@/utils'
export function useAccessPointActions(appId: string, canEdit: boolean) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const setAppDetail = useAppStore((state) => state.setAppDetail)
const refreshAppDetail = useCallback(async () => {
try {
const appDetail = await fetchAppDetail({ url: '/apps', id: appId })
setAppDetail({ ...appDetail })
} catch (error) {
console.error('Failed to refresh app detail:', error)
}
}, [appId, setAppDetail])
const handleResult = useCallback(
(error: Error | null, message?: I18nKeysByPrefix<'common', 'actionMsg.'>) => {
const type = error ? 'error' : 'success'
const resolvedMessage = message ?? (error ? 'modifiedUnsuccessfully' : 'modifiedSuccessfully')
if (!error) {
void refreshAppDetail()
const socket = webSocketClient.getSocket(appId)
if (socket) {
const timestamp = Date.now()
socket.emit('collaboration_event', {
type: 'app_state_update',
data: { timestamp },
timestamp,
})
}
}
toast(t(($) => $[`actionMsg.${resolvedMessage}`], { ns: 'common' }) as string, {
type,
})
},
[appId, refreshAppDetail, t],
)
useEffect(() => {
if (!appId) return
return collaborationManager.onAppStateUpdate(refreshAppDetail)
}, [appId, refreshAppDetail])
const changeSiteStatus = useCallback(
async (enabled: boolean) => {
if (!canEdit) return
const [error] = await asyncRunSafe<App>(
updateAppSiteStatus({
url: `/apps/${appId}/site-enable`,
body: { enable_site: enabled },
}) as Promise<App>,
)
handleResult(error)
},
[appId, canEdit, handleResult],
)
const changeApiStatus = useCallback(
async (enabled: boolean) => {
if (!canEdit) return
const [error] = await asyncRunSafe<App>(
updateAppSiteStatus({
url: `/apps/${appId}/api-enable`,
body: { enable_api: enabled },
}) as Promise<App>,
)
handleResult(error)
},
[appId, canEdit, handleResult],
)
const saveSiteConfig = useCallback(
async (params: ConfigParams) => {
if (!canEdit) return
const [error] = await asyncRunSafe<App>(
updateAppSiteConfig({
url: `/apps/${appId}/site`,
body: params,
}) as Promise<App>,
)
if (!error) {
void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.get.key() })
void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.starred.get.key() })
void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.recent.get.key() })
}
handleResult(error)
},
[appId, canEdit, handleResult, queryClient],
)
const regenerateSiteCode = useCallback(async () => {
if (!canEdit) return
const [error] = await asyncRunSafe<UpdateAppSiteCodeResponse>(
updateAppSiteAccessToken({
url: `/apps/${appId}/site/access-token-reset`,
}) as Promise<UpdateAppSiteCodeResponse>,
)
handleResult(error, error ? 'generatedUnsuccessfully' : 'generatedSuccessfully')
}, [appId, canEdit, handleResult])
return {
changeApiStatus,
changeSiteStatus,
handleResult,
refreshAppDetail,
regenerateSiteCode,
saveSiteConfig,
}
}
@@ -0,0 +1,80 @@
import type { WorkflowResponse } from '@dify/contracts/api/console/apps/types.gen'
import type { InputVar, Node } from '@/app/components/workflow/types'
import type { AppDetailResponse } from '@/models/app'
import type { AppSSO } from '@/types/app'
import type { DocPathWithoutLang } from '@/types/doc-paths'
import { BlockEnum, isTriggerNode } from '@/app/components/workflow/types'
import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
export type AccessPointAppInfo = AppDetailResponse & Partial<AppSSO>
export type PublishedWorkflow = WorkflowResponse | null | undefined
type AppRouteMode = Exclude<AppModeEnum, 'agent'>
const EMPTY_WORKFLOW_NODES: Node[] = []
const APP_API_REFERENCE_PATHS: Record<AppRouteMode, DocPathWithoutLang> = {
'advanced-chat': '/api-reference/guides/chatflow',
'agent-chat': '/api-reference/guides/chat',
chat: '/api-reference/guides/chat',
completion: '/api-reference/guides/completion',
workflow: '/api-reference/guides/workflow',
}
export function getAppApiReferencePath(appMode: AppModeEnum) {
if (appMode === 'agent') return undefined
return APP_API_REFERENCE_PATHS[appMode]
}
export function getPublishedWorkflowState(
appInfo: AccessPointAppInfo,
workflow: PublishedWorkflow,
) {
const isWorkflowApp = appInfo.mode === AppModeEnum.WORKFLOW
const nodes = getPublishedWorkflowNodes(workflow)
const hasStartNode = nodes.some((node) => node.data.type === BlockEnum.Start)
const hasTriggerNode = nodes.some((node) => isTriggerNode(node.data.type))
return {
hasStartNode,
hasTriggerNode,
isUnpublished: isWorkflowApp && !workflow?.graph,
isWorkflowApp,
}
}
export function getPublishedWorkflowNodes(workflow: PublishedWorkflow) {
return Array.isArray(workflow?.graph?.nodes)
? (workflow.graph.nodes as Node[])
: EMPTY_WORKFLOW_NODES
}
export function getBuiltInAccessUrls(appInfo: AccessPointAppInfo) {
const appMode =
appInfo.mode === AppModeEnum.COMPLETION || appInfo.mode === AppModeEnum.WORKFLOW
? appInfo.mode
: AppModeEnum.CHAT
return {
api: appInfo.api_base_url ?? '',
webApp: `${appInfo.site?.app_base_url ?? ''}${basePath}/${appMode}/${
appInfo.site?.access_token ?? ''
}`,
}
}
export function getHiddenStartInputs(workflow: PublishedWorkflow) {
const startNode = getPublishedWorkflowNodes(workflow).find(
(node) => node.data.type === BlockEnum.Start,
)
return ((startNode?.data as { variables?: InputVar[] } | undefined)?.variables ?? []).filter(
(variable) => variable.hide === true,
)
}
export function isAdvancedApp(appInfo: AccessPointAppInfo) {
return appInfo.mode === AppModeEnum.WORKFLOW || appInfo.mode === AppModeEnum.ADVANCED_CHAT
}
@@ -0,0 +1,60 @@
'use client'
import { useTranslation } from 'react-i18next'
type WebAppAccessControlEntryProps = {
accessConfigured: boolean
accessIcon: string
accessLabel: string
available: boolean
disabled: boolean
onClick: () => void
}
export function WebAppAccessControlEntry({
accessConfigured,
accessIcon,
accessLabel,
available,
disabled,
onClick,
}: WebAppAccessControlEntryProps) {
const { t } = useTranslation()
return (
<div className="-mt-1 px-4 pb-3">
{available ? (
<button
type="button"
className="flex h-9 w-full cursor-pointer items-center gap-x-0.5 rounded-lg border-[0.5px] border-divider-subtle bg-background-section py-1 pr-2 pl-2.5 text-left outline-hidden hover:bg-state-base-hover-alt focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:hover:bg-background-section"
disabled={disabled}
onClick={onClick}
>
<div className="flex grow items-center gap-x-1.5 overflow-hidden pr-1">
<span aria-hidden className={`${accessIcon} size-4 shrink-0 text-text-secondary`} />
<div className="grow truncate">
<span className="system-sm-regular text-text-secondary">{accessLabel}</span>
</div>
</div>
{!accessConfigured && (
<span className="shrink-0 system-xs-regular text-text-tertiary">
{t(($) => $['publishApp.notSet'], { ns: 'app' })}
</span>
)}
<div className="flex size-4 shrink-0 items-center justify-center">
<span aria-hidden className="i-ri-arrow-right-s-line size-4 text-text-quaternary" />
</div>
</button>
) : (
<div className="flex h-9 w-full items-center gap-2 rounded-lg border-[0.5px] border-divider-subtle bg-background-section px-2.5">
<span aria-hidden className="i-ri-global-line size-4 shrink-0 text-text-disabled" />
<span className="h-2 w-[42%] rounded-full bg-text-quaternary opacity-10" />
<span
aria-hidden
className="ml-auto i-ri-arrow-right-s-line size-4 shrink-0 text-text-disabled"
/>
</div>
)}
</div>
)
}
@@ -0,0 +1,60 @@
'use client'
import type { ReactNode } from 'react'
import { skipToken } from '@tanstack/react-query'
import { atom } from 'jotai'
import { atomWithQuery } from 'jotai-tanstack-query'
import { selectAtom, useHydrateAtoms } from 'jotai/utils'
import { consoleQuery } from '@/service/client'
export const BUILT_IN_ENVIRONMENT_ID = 'built-in'
const accessPointAppIdAtom = atom<string | null>(null)
const environmentQueryEnabledAtom = atom(false)
export function AccessPointStateBoundary({
appId,
children,
environmentQueryEnabled,
}: {
appId: string
children: ReactNode
environmentQueryEnabled: boolean
}) {
useHydrateAtoms(
[
[accessPointAppIdAtom, appId],
[environmentQueryEnabledAtom, environmentQueryEnabled],
] as const,
{
dangerouslyForceHydrate: true,
},
)
return children
}
const appEnvironmentsQueryAtom = atomWithQuery((get) => {
const appId = get(accessPointAppIdAtom)
const enabled = get(environmentQueryEnabledAtom)
return consoleQuery.enterprise.appDeploy.deploymentService.listAppEnvironments.queryOptions({
input: appId
? {
params: {
app_id: appId,
},
}
: skipToken,
enabled,
})
})
const appEnvironmentsAtom = selectAtom(appEnvironmentsQueryAtom, (query) => query.data?.data)
export const inUseAppEnvironmentsAtom = atom(
(get) =>
get(appEnvironmentsAtom)?.filter(
(environment) => environment.in_use && environment.id !== BUILT_IN_ENVIRONMENT_ID,
) ?? [],
)
@@ -1,130 +1,7 @@
import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control'
import type { App } from '@/types/app'
import { toast } from '@langgenius/dify-ui/toast'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import useAccessControlStore from '@/context/access-control-store'
import { AccessMode, SubjectType } from '@/models/access-control'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import AccessControlDialog from '../access-control-dialog'
import AddMemberOrGroupDialog from '../add-member-or-group-pop'
import AccessControl from '../index'
import SpecificGroupsOrMembers from '../specific-groups-or-members'
const mockUseAppWhiteListSubjects = vi.fn()
const mockUseSearchForWhiteListCandidates = vi.fn()
const { mockMutateAsync } = vi.hoisted(() => ({
mockMutateAsync: vi.fn(),
}))
const intersectionObserverMocks = vi.hoisted(() => ({
callback: null as null | ((entries: Array<{ isIntersecting: boolean }>) => void),
}))
vi.mock('@/service/access-control', () => ({
useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args),
useSearchForWhiteListCandidates: (...args: unknown[]) =>
mockUseSearchForWhiteListCandidates(...args),
}))
vi.mock('@/service/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/client')>()
const webAppAuth = new Proxy(actual.consoleQuery.enterprise.webAppAuth, {
get(target, property, receiver) {
if (property === 'updateWebAppWhitelistSubjects')
return { mutationOptions: () => ({ mutationFn: mockMutateAsync }) }
return Reflect.get(target, property, receiver)
},
})
const enterprise = new Proxy(actual.consoleQuery.enterprise, {
get(target, property, receiver) {
if (property === 'webAppAuth') return webAppAuth
return Reflect.get(target, property, receiver)
},
})
return {
...actual,
consoleQuery: new Proxy(actual.consoleQuery, {
get(target, property, receiver) {
if (property === 'enterprise') return enterprise
return Reflect.get(target, property, receiver)
},
}),
}
})
vi.mock('ahooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('ahooks')>()
return {
...actual,
useDebounce: (value: unknown) => value,
}
})
const createGroup = (overrides: Partial<AccessControlGroup> = {}): AccessControlGroup =>
({
id: 'group-1',
name: 'Group One',
groupSize: 5,
...overrides,
}) as AccessControlGroup
const createMember = (overrides: Partial<AccessControlAccount> = {}): AccessControlAccount =>
({
id: 'member-1',
name: 'Member One',
email: 'member@example.com',
avatar: '',
avatarUrl: '',
...overrides,
}) as AccessControlAccount
const baseGroup = createGroup()
const baseMember = createMember()
const groupSubject: Subject = {
subjectId: baseGroup.id,
subjectType: SubjectType.GROUP,
groupData: baseGroup,
} as Subject
const memberSubject: Subject = {
subjectId: baseMember.id,
subjectType: SubjectType.ACCOUNT,
accountData: baseMember,
} as Subject
beforeAll(() => {
class MockIntersectionObserver {
constructor(callback: (entries: Array<{ isIntersecting: boolean }>) => void) {
intersectionObserverMocks.callback = callback
}
observe = vi.fn(() => undefined)
disconnect = vi.fn(() => undefined)
unobserve = vi.fn(() => undefined)
}
// @ts-expect-error test DOM typings do not guarantee IntersectionObserver here
globalThis.IntersectionObserver = MockIntersectionObserver
})
beforeEach(() => {
vi.clearAllMocks()
mockMutateAsync.mockResolvedValue(undefined)
mockUseAppWhiteListSubjects.mockReturnValue({
isPending: false,
data: {
groups: [baseGroup],
members: [baseMember],
},
})
mockUseSearchForWhiteListCandidates.mockReturnValue({
isLoading: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
data: { pages: [{ currPage: 1, subjects: [groupSubject, memberSubject], hasMore: false }] },
})
})
// AccessControlDialog renders the shared dialog primitive with a close control.
describe('AccessControlDialog', () => {
it('should render dialog content when visible', () => {
render(
@@ -145,218 +22,10 @@ describe('AccessControlDialog', () => {
</AccessControlDialog>,
)
const closeButton = screen.getByRole('button', { name: 'Close' })
fireEvent.click(closeButton)
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
await waitFor(() => {
expect(handleClose).toHaveBeenCalledTimes(1)
})
})
})
// SpecificGroupsOrMembers syncs store state with fetched data and supports removals
describe('SpecificGroupsOrMembers', () => {
it('should render collapsed view when not in specific selection mode', () => {
useAccessControlStore.setState({ currentMenu: AccessMode.ORGANIZATION })
render(<SpecificGroupsOrMembers />)
expect(screen.getByText('app.accessControlDialog.accessItems.specific')).toBeInTheDocument()
expect(screen.queryByText(baseGroup.name)).not.toBeInTheDocument()
})
it('should show loading state while pending', async () => {
useAccessControlStore.setState({
appId: 'app-1',
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
})
mockUseAppWhiteListSubjects.mockReturnValue({
isPending: true,
data: undefined,
})
const { container } = render(<SpecificGroupsOrMembers />)
await waitFor(() => {
expect(container.querySelector('.spin-animation')).toBeInTheDocument()
})
})
it('should render fetched groups and members and support removal', async () => {
useAccessControlStore.setState({
appId: 'app-1',
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
})
render(<SpecificGroupsOrMembers />)
await waitFor(() => {
expect(screen.getByText(baseGroup.name)).toBeInTheDocument()
expect(screen.getByText(baseMember.name)).toBeInTheDocument()
})
const groupRemove = screen.getAllByRole('button', { name: /operation\.remove$/ })[0]!
fireEvent.click(groupRemove)
await waitFor(() => {
expect(screen.queryByText(baseGroup.name)).not.toBeInTheDocument()
})
const memberRemove = screen.getAllByRole('button', { name: /operation\.remove$/ })[0]!
fireEvent.click(memberRemove)
await waitFor(() => {
expect(screen.queryByText(baseMember.name)).not.toBeInTheDocument()
})
})
})
// AddMemberOrGroupDialog renders search results and updates store selections
describe('AddMemberOrGroupDialog', () => {
it('should open search popover and display candidates', async () => {
const user = userEvent.setup()
render(<AddMemberOrGroupDialog />)
await user.click(screen.getByText('common.operation.add'))
expect(
screen.getByPlaceholderText(
'app.accessControlDialog.operateGroupAndMember.searchPlaceholder',
),
).toBeInTheDocument()
expect(screen.getByText(baseGroup.name)).toBeInTheDocument()
expect(screen.getByText(baseMember.name)).toBeInTheDocument()
})
it('should allow selecting members and expanding groups', async () => {
const user = userEvent.setup()
render(<AddMemberOrGroupDialog />)
await user.click(screen.getByText('common.operation.add'))
const expandButton = screen.getByText('app.accessControlDialog.operateGroupAndMember.expand')
await user.click(expandButton)
expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([baseGroup])
await user.click(screen.getByRole('option', { name: /Member One/ }))
expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember])
})
it('should update the keyword, fetch the next page, and support deselection and breadcrumb reset', async () => {
const fetchNextPage = vi.fn()
mockUseSearchForWhiteListCandidates.mockReturnValue({
isLoading: false,
isFetchingNextPage: true,
fetchNextPage,
data: { pages: [{ currPage: 1, subjects: [groupSubject, memberSubject], hasMore: true }] },
})
const user = userEvent.setup()
render(<AddMemberOrGroupDialog />)
await user.click(screen.getByText('common.operation.add'))
await user.type(
screen.getByPlaceholderText(
'app.accessControlDialog.operateGroupAndMember.searchPlaceholder',
),
'Group',
)
expect(document.querySelector('.spin-animation')).toBeInTheDocument()
const groupOption = screen.getByRole('option', { name: /Group One/ })
expect(groupOption).not.toHaveAttribute('data-selected')
fireEvent.click(groupOption)
expect(groupOption).toHaveAttribute('data-selected')
fireEvent.click(groupOption)
expect(groupOption).not.toHaveAttribute('data-selected')
const memberOption = screen.getByRole('option', { name: /Member One/ })
expect(memberOption).not.toHaveAttribute('data-selected')
fireEvent.click(memberOption)
expect(memberOption).toHaveAttribute('data-selected')
fireEvent.click(memberOption)
expect(memberOption).not.toHaveAttribute('data-selected')
fireEvent.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.expand'))
fireEvent.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.allMembers'))
expect(useAccessControlStore.getState().specificGroups).toEqual([])
expect(useAccessControlStore.getState().specificMembers).toEqual([])
expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([])
expect(fetchNextPage).not.toHaveBeenCalled()
})
it('should show empty state when no candidates are returned', async () => {
mockUseSearchForWhiteListCandidates.mockReturnValue({
isLoading: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
data: { pages: [] },
})
const user = userEvent.setup()
render(<AddMemberOrGroupDialog />)
await user.click(screen.getByText('common.operation.add'))
expect(screen.getByRole('status')).toHaveTextContent(
'app.accessControlDialog.operateGroupAndMember.noResult',
)
})
})
// AccessControl integrates dialog, selection items, and confirm flow
describe('AccessControl', () => {
it('should initialize menu from app and call update on confirm', async () => {
const onClose = vi.fn()
const onConfirm = vi.fn()
const toastSpy = vi.spyOn(toast, 'success').mockReturnValue('toast-success')
useAccessControlStore.setState({
specificGroups: [baseGroup],
specificMembers: [baseMember],
})
const app = {
id: 'app-id-1',
access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
} as App
render(<AccessControl app={app} onClose={onClose} onConfirm={onConfirm} />)
await waitFor(() => {
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.SPECIFIC_GROUPS_MEMBERS)
})
fireEvent.click(screen.getByText('common.operation.confirm'))
await waitFor(() => {
expect(mockMutateAsync.mock.calls[0]?.[0]).toEqual({
body: {
appId: app.id,
accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
subjects: [
{ subjectId: baseGroup.id, subjectType: SubjectType.GROUP },
{ subjectId: baseMember.id, subjectType: SubjectType.ACCOUNT },
],
},
})
expect(toastSpy).toHaveBeenCalledWith('app.accessControlDialog.updateSuccess')
expect(onConfirm).toHaveBeenCalled()
})
})
it('should expose the external members tip when SSO is disabled', () => {
const app = {
id: 'app-id-2',
access_mode: AccessMode.PUBLIC,
} as App
render(<AccessControl app={app} onClose={vi.fn()} />)
expect(screen.getByText('app.accessControlDialog.accessItems.external')).toBeInTheDocument()
expect(screen.getByText('app.accessControlDialog.accessItems.anyone')).toBeInTheDocument()
})
})
@@ -1,7 +1,8 @@
import type { AccessControlSubjects } from '../specific-groups-or-members'
import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import useAccessControlStore from '@/context/access-control-store'
import { useState } from 'react'
import { SubjectType } from '@/models/access-control'
import { renderWithAccountProfile as render } from '@/test/console/account-profile'
import AddMemberOrGroupDialog from '../add-member-or-group-pop'
@@ -34,6 +35,21 @@ const createMember = (overrides: Partial<AccessControlAccount> = {}): AccessCont
...overrides,
}) as AccessControlAccount
function ControlledDialog({
onChange = () => {},
}: {
onChange?: (value: AccessControlSubjects) => void
}) {
const [subjects, setSubjects] = useState<AccessControlSubjects>({ groups: [], members: [] })
const handleChange = (nextSubjects: AccessControlSubjects) => {
setSubjects(nextSubjects)
onChange(nextSubjects)
}
return <AddMemberOrGroupDialog subjects={subjects} onChange={handleChange} />
}
describe('AddMemberOrGroupDialog', () => {
const baseGroup = createGroup()
const baseMember = createMember()
@@ -65,13 +81,6 @@ describe('AddMemberOrGroupDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
useAccessControlStore.setState({
appId: 'app-1',
specificGroups: [],
specificMembers: [],
currentMenu: SubjectType.GROUP as never,
selectedGroupsForBreadcrumb: [],
})
mockUseSearchForWhiteListCandidates.mockReturnValue({
isLoading: false,
isFetchingNextPage: false,
@@ -84,7 +93,7 @@ describe('AddMemberOrGroupDialog', () => {
it('should open the search popover and display candidates', async () => {
const user = userEvent.setup()
render(<AddMemberOrGroupDialog />)
render(<ControlledDialog />)
await user.click(screen.getByText('common.operation.add'))
@@ -97,18 +106,21 @@ describe('AddMemberOrGroupDialog', () => {
expect(screen.getByText(baseMember.name)).toBeInTheDocument()
})
it('should allow expanding groups and selecting members', async () => {
it('should allow expanding groups and report selected members', async () => {
const user = userEvent.setup()
render(<AddMemberOrGroupDialog />)
const onChange = vi.fn()
render(<ControlledDialog onChange={onChange} />)
await user.click(screen.getByText('common.operation.add'))
await user.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.expand'))
expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([baseGroup])
expect(mockUseSearchForWhiteListCandidates).toHaveBeenLastCalledWith(
expect.objectContaining({ groupId: baseGroup.id }),
true,
)
await user.click(screen.getByRole('option', { name: /Member One/ }))
expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember])
expect(onChange).toHaveBeenCalledWith({ groups: [], members: [baseMember] })
})
it('should show the empty state when no candidates are returned', async () => {
@@ -120,7 +132,7 @@ describe('AddMemberOrGroupDialog', () => {
})
const user = userEvent.setup()
render(<AddMemberOrGroupDialog />)
render(<ControlledDialog />)
await user.click(screen.getByText('common.operation.add'))
@@ -130,37 +142,39 @@ describe('AddMemberOrGroupDialog', () => {
})
it('should keep breadcrumbs visible when the current group has no candidates', async () => {
useAccessControlStore.setState({
selectedGroupsForBreadcrumb: [baseGroup],
})
mockUseSearchForWhiteListCandidates.mockReturnValue({
mockUseSearchForWhiteListCandidates.mockImplementation((query: { groupId?: string }) => ({
isLoading: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
data: { pages: [{ currPage: 1, subjects: [], hasMore: false }] },
})
data: {
pages: [
{
currPage: 1,
subjects: query.groupId ? [] : [groupSubject, memberSubject],
hasMore: false,
},
],
},
}))
const user = userEvent.setup()
render(<AddMemberOrGroupDialog />)
render(<ControlledDialog />)
await user.click(screen.getByText('common.operation.add'))
await user.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.expand'))
expect(
screen.getByRole('button', {
name: 'app.accessControlDialog.operateGroupAndMember.allMembers',
}),
).toBeInTheDocument()
const allMembersButton = screen.getByRole('button', {
name: 'app.accessControlDialog.operateGroupAndMember.allMembers',
})
expect(allMembersButton).toBeInTheDocument()
expect(screen.getByText(baseGroup.name)).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent(
'app.accessControlDialog.operateGroupAndMember.noResult',
)
await user.click(
screen.getByRole('button', {
name: 'app.accessControlDialog.operateGroupAndMember.allMembers',
}),
await user.click(allMembersButton)
expect(mockUseSearchForWhiteListCandidates).toHaveBeenLastCalledWith(
expect.objectContaining({ groupId: undefined }),
true,
)
expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([])
})
})
@@ -1,9 +1,8 @@
import type { ReactElement } from 'react'
import type { App } from '@/types/app'
import { toast } from '@langgenius/dify-ui/toast'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import useAccessControlStore from '@/context/access-control-store'
import { AccessMode } from '@/models/access-control'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import AccessControl from '../index'
@@ -64,16 +63,12 @@ describe('AccessControl', () => {
allow_email_code_login: false,
allow_public_access: true,
}
useAccessControlStore.setState({
appId: '',
specificGroups: [],
specificMembers: [],
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
selectedGroupsForBreadcrumb: [],
})
mockMutateAsync.mockResolvedValue(undefined)
mockUseAppWhiteListSubjects.mockReturnValue({
isPending: false,
isFetching: false,
isError: false,
refetch: vi.fn(),
data: {
groups: [],
members: [],
@@ -87,8 +82,8 @@ describe('AccessControl', () => {
})
})
it('should initialize menu from the app and update access mode on confirm', async () => {
const onClose = vi.fn()
it('should initialize the mode from the app and update it on confirm', async () => {
const user = userEvent.setup()
const onConfirm = vi.fn()
const toastSpy = vi.spyOn(toast, 'success').mockReturnValue('toast-success')
const app = {
@@ -96,14 +91,8 @@ describe('AccessControl', () => {
access_mode: AccessMode.PUBLIC,
} as App
render(<AccessControl app={app} onClose={onClose} onConfirm={onConfirm} />)
await waitFor(() => {
expect(useAccessControlStore.getState().appId).toBe(app.id)
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.PUBLIC)
})
fireEvent.click(screen.getByText('common.operation.confirm'))
render(<AccessControl app={app} onClose={vi.fn()} onConfirm={onConfirm} />)
await user.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(mockMutateAsync.mock.calls[0]?.[0]).toEqual({
@@ -117,7 +106,77 @@ describe('AccessControl', () => {
})
})
it('should show the external-members option when SSO tip is visible', () => {
it('should submit the successfully loaded specific subjects', async () => {
const user = userEvent.setup()
mockUseAppWhiteListSubjects.mockReturnValue({
isPending: false,
isFetching: false,
isError: false,
refetch: vi.fn(),
data: {
groups: [{ id: 'group-1', name: 'Group', groupSize: 2 }],
members: [
{
id: 'member-1',
name: 'Member',
email: 'member@example.com',
avatar: '',
avatarUrl: '',
},
],
},
})
render(
<AccessControl
app={{ id: 'app-id-2', access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS }}
onClose={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(mockMutateAsync.mock.calls[0]?.[0]).toEqual({
body: {
appId: 'app-id-2',
accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
subjects: [
{ subjectId: 'group-1', subjectType: 'group' },
{ subjectId: 'member-1', subjectType: 'account' },
],
},
})
})
})
it('should disable confirmation and preserve the error when specific subjects fail to load', async () => {
const user = userEvent.setup()
const refetch = vi.fn()
mockUseAppWhiteListSubjects.mockReturnValue({
isPending: false,
isFetching: false,
isError: true,
refetch,
data: undefined,
})
render(
<AccessControl
app={{ id: 'app-id-3', access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS }}
onClose={vi.fn()}
/>,
)
expect(screen.getByRole('alert')).toHaveTextContent('common.dynamicSelect.error')
expect(screen.queryByText('app.accessControlDialog.noGroupsOrMembers')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.confirm' })).toBeDisabled()
expect(mockMutateAsync).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'common.operation.retry' }))
expect(refetch).toHaveBeenCalledTimes(1)
})
it('should show the external-members option when the SSO tip is visible', () => {
mockWebappAuth = {
enabled: false,
allow_sso: false,
@@ -128,7 +187,7 @@ describe('AccessControl', () => {
render(
<AccessControl
app={{ id: 'app-id-2', access_mode: AccessMode.PUBLIC } as App}
app={{ id: 'app-id-4', access_mode: AccessMode.PUBLIC } as App}
onClose={vi.fn()}
/>,
)
@@ -139,7 +198,7 @@ describe('AccessControl', () => {
it('should preserve an unfinished selection when the parent rerenders', async () => {
const user = userEvent.setup()
const app = { id: 'app-id-3', access_mode: AccessMode.PUBLIC } as App
const app = { id: 'app-id-5', access_mode: AccessMode.PUBLIC } as App
const { rerender } = render(<AccessControl app={app} onClose={vi.fn()} />)
const organization = screen.getByRole('radio', {
@@ -149,51 +208,31 @@ describe('AccessControl', () => {
expect(organization).toBeChecked()
rerender(<AccessControl app={{ ...app }} onClose={vi.fn()} />)
expect(organization).toBeChecked()
})
describe('public access control', () => {
it('should render the public option enabled without a tooltip when public access is allowed', () => {
render(
<AccessControl
app={{ id: 'app-id-4', access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS } as App}
onClose={vi.fn()}
/>,
)
it('should disable public access and explain why when it is disabled by the system', () => {
mockWebappAuth = {
enabled: true,
allow_sso: true,
allow_email_password_login: false,
allow_email_code_login: false,
allow_public_access: false,
}
const publicOption = screen.getByRole('radio', {
name: /app\.accessControlDialog\.accessItems\.anyone/,
})
expect(publicOption).not.toHaveAttribute('data-disabled')
expect(
screen.queryByLabelText('app.accessControlDialog.webAppPublicAccessDisabledTip'),
).not.toBeInTheDocument()
})
render(
<AccessControl
app={{ id: 'app-id-6', access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS } as App}
onClose={vi.fn()}
/>,
)
it('should render the public option disabled with a tooltip when public access is disabled', () => {
mockWebappAuth = {
enabled: true,
allow_sso: true,
allow_email_password_login: false,
allow_email_code_login: false,
allow_public_access: false,
}
render(
<AccessControl
app={{ id: 'app-id-5', access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS } as App}
onClose={vi.fn()}
/>,
)
const publicOption = screen.getByRole('radio', {
name: /app\.accessControlDialog\.accessItems\.anyone/,
})
expect(publicOption).toHaveAttribute('aria-disabled', 'true')
expect(
screen.getByLabelText('app.accessControlDialog.webAppPublicAccessDisabledTip'),
).toBeInTheDocument()
const publicOption = screen.getByRole('radio', {
name: /app\.accessControlDialog\.accessItems\.anyone/,
})
expect(publicOption).toHaveAttribute('aria-disabled', 'true')
expect(
screen.getByLabelText('app.accessControlDialog.webAppPublicAccessDisabledTip'),
).toBeInTheDocument()
})
})
@@ -1,15 +1,9 @@
import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import useAccessControlStore from '@/context/access-control-store'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AccessMode } from '@/models/access-control'
import SpecificGroupsOrMembers from '../specific-groups-or-members'
const mockUseAppWhiteListSubjects = vi.fn()
vi.mock('@/service/access-control', () => ({
useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args),
}))
vi.mock('../add-member-or-group-pop', () => ({
default: () => <div data-testid="add-member-or-group-dialog" />,
}))
@@ -35,67 +29,88 @@ const createMember = (overrides: Partial<AccessControlAccount> = {}): AccessCont
describe('SpecificGroupsOrMembers', () => {
const baseGroup = createGroup()
const baseMember = createMember()
beforeEach(() => {
vi.clearAllMocks()
useAccessControlStore.setState({
appId: '',
specificGroups: [],
specificMembers: [],
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
selectedGroupsForBreadcrumb: [],
})
mockUseAppWhiteListSubjects.mockReturnValue({
isPending: false,
data: {
groups: [baseGroup],
members: [baseMember],
},
})
})
const subjects = {
groups: [baseGroup],
members: [baseMember],
}
it('should render the collapsed row when not in specific mode', () => {
useAccessControlStore.setState({
currentMenu: AccessMode.ORGANIZATION,
})
render(<SpecificGroupsOrMembers />)
render(
<SpecificGroupsOrMembers
accessMode={AccessMode.ORGANIZATION}
subjects={subjects}
subjectsStatus="success"
onSubjectsChange={vi.fn()}
/>,
)
expect(screen.getByText('app.accessControlDialog.accessItems.specific')).toBeInTheDocument()
expect(screen.queryByTestId('add-member-or-group-dialog')).not.toBeInTheDocument()
})
it('should show loading while whitelist subjects are pending', async () => {
mockUseAppWhiteListSubjects.mockReturnValue({
isPending: true,
data: undefined,
})
it('should show loading while whitelist subjects are pending', () => {
const { container } = render(
<SpecificGroupsOrMembers
accessMode={AccessMode.SPECIFIC_GROUPS_MEMBERS}
subjects={{ groups: [], members: [] }}
subjectsStatus="loading"
onSubjectsChange={vi.fn()}
/>,
)
const { container } = render(<SpecificGroupsOrMembers />)
await waitFor(() => {
expect(container.querySelector('.spin-animation')).toBeInTheDocument()
})
expect(container.querySelector('.spin-animation')).toBeInTheDocument()
expect(screen.queryByTestId('add-member-or-group-dialog')).not.toBeInTheDocument()
})
it('should render fetched groups and members and support removal', async () => {
useAccessControlStore.setState({ appId: 'app-1' })
it('should expose the failed load and allow retry without rendering an empty selection', async () => {
const user = userEvent.setup()
const onRetrySubjects = vi.fn()
render(<SpecificGroupsOrMembers />)
render(
<SpecificGroupsOrMembers
accessMode={AccessMode.SPECIFIC_GROUPS_MEMBERS}
subjects={{ groups: [], members: [] }}
subjectsStatus="error"
onSubjectsChange={vi.fn()}
onRetrySubjects={onRetrySubjects}
/>,
)
await waitFor(() => {
expect(screen.getByText(baseGroup.name)).toBeInTheDocument()
expect(screen.getByText(baseMember.name)).toBeInTheDocument()
})
expect(screen.getByRole('alert')).toHaveTextContent('common.dynamicSelect.error')
expect(screen.queryByText('app.accessControlDialog.noGroupsOrMembers')).not.toBeInTheDocument()
expect(screen.queryByTestId('add-member-or-group-dialog')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'common.operation.retry' }))
expect(onRetrySubjects).toHaveBeenCalledTimes(1)
})
it('should render controlled groups and members and report removals', async () => {
const user = userEvent.setup()
const onSubjectsChange = vi.fn()
render(
<SpecificGroupsOrMembers
accessMode={AccessMode.SPECIFIC_GROUPS_MEMBERS}
subjects={subjects}
subjectsStatus="success"
onSubjectsChange={onSubjectsChange}
/>,
)
expect(screen.getByText(baseGroup.name)).toBeInTheDocument()
expect(screen.getByText(baseMember.name)).toBeInTheDocument()
const removeButtons = screen.getAllByRole('button', { name: /operation\.remove$/ })
const groupRemove = removeButtons[0]!
const memberRemove = removeButtons[1]!
await user.click(removeButtons[0]!)
expect(onSubjectsChange).toHaveBeenCalledWith({
groups: [],
members: [baseMember],
})
fireEvent.click(groupRemove)
expect(useAccessControlStore.getState().specificGroups).toEqual([])
fireEvent.click(memberRemove)
expect(useAccessControlStore.getState().specificMembers).toEqual([])
await user.click(removeButtons[1]!)
expect(onSubjectsChange).toHaveBeenCalledWith({
groups: [baseGroup],
members: [],
})
})
})
@@ -0,0 +1,143 @@
'use client'
import type {
AccessControlSubjects,
AccessControlSubjectsStatus,
} from './specific-groups-or-members'
import type { AccessMode } from '@/models/access-control'
import { Button } from '@langgenius/dify-ui/button'
import { DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { RadioGroup } from '@langgenius/dify-ui/radio'
import { useId } from 'react'
import { useTranslation } from 'react-i18next'
import { AccessMode as AccessModeValue } from '@/models/access-control'
import { Infotip } from '../../base/infotip'
import AccessControlDialog from './access-control-dialog'
import AccessControlItem from './access-control-item'
import SpecificGroupsOrMembers, { WebAppSSONotEnabledTip } from './specific-groups-or-members'
export type AccessControlFormProps = {
accessMode: AccessMode
subjects: AccessControlSubjects
subjectsStatus: AccessControlSubjectsStatus
updatePending: boolean
publicAccessDisabled: boolean
externalMembersTipHidden: boolean
onAccessModeChange: (accessMode: AccessMode) => void
onSubjectsChange: (subjects: AccessControlSubjects) => void
onRetrySubjects?: () => void
onClose: () => void
onConfirm: () => void
}
export function AccessControlForm({
accessMode,
subjects,
subjectsStatus,
updatePending,
publicAccessDisabled,
externalMembersTipHidden,
onAccessModeChange,
onSubjectsChange,
onRetrySubjects,
onClose,
onConfirm,
}: AccessControlFormProps) {
const accessControlOptionsLabelId = useId()
const { t } = useTranslation()
const confirmDisabled =
updatePending ||
(accessMode === AccessModeValue.PUBLIC && publicAccessDisabled) ||
(accessMode === AccessModeValue.SPECIFIC_GROUPS_MEMBERS && subjectsStatus !== 'success')
return (
<AccessControlDialog show onClose={onClose}>
<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(($) => $['accessControlDialog.title'], { ns: 'app' })}
</DialogTitle>
<DialogDescription className="mt-1 system-xs-regular text-text-tertiary">
{t(($) => $['accessControlDialog.description'], { ns: 'app' })}
</DialogDescription>
</div>
<RadioGroup<AccessMode>
value={accessMode}
onValueChange={onAccessModeChange}
className="flex flex-col items-stretch gap-y-1 px-6 pb-3"
aria-labelledby={accessControlOptionsLabelId}
>
<div className="leading-6">
<p id={accessControlOptionsLabelId} className="system-sm-medium text-text-tertiary">
{t(($) => $['accessControlDialog.accessLabel'], { ns: 'app' })}
</p>
</div>
<AccessControlItem type={AccessModeValue.ORGANIZATION}>
<div className="flex items-center p-3">
<div className="flex grow items-center gap-x-2">
<span aria-hidden="true" className="i-ri-building-line size-4 text-text-primary" />
<p className="system-sm-medium text-text-primary">
{t(($) => $['accessControlDialog.accessItems.organization'], { ns: 'app' })}
</p>
</div>
</div>
</AccessControlItem>
<AccessControlItem type={AccessModeValue.SPECIFIC_GROUPS_MEMBERS}>
<SpecificGroupsOrMembers
accessMode={accessMode}
subjects={subjects}
subjectsStatus={subjectsStatus}
onSubjectsChange={onSubjectsChange}
onRetrySubjects={onRetrySubjects}
/>
</AccessControlItem>
<AccessControlItem type={AccessModeValue.EXTERNAL_MEMBERS}>
<div className="flex items-center p-3">
<div className="flex grow items-center gap-x-2">
<span
aria-hidden="true"
className="i-ri-verified-badge-line size-4 text-text-primary"
/>
<p className="system-sm-medium text-text-primary">
{t(($) => $['accessControlDialog.accessItems.external'], { ns: 'app' })}
</p>
</div>
{!externalMembersTipHidden && <WebAppSSONotEnabledTip />}
</div>
</AccessControlItem>
<AccessControlItem type={AccessModeValue.PUBLIC} disabled={publicAccessDisabled}>
<div className="flex items-center gap-x-2 p-3">
<span aria-hidden="true" className="i-ri-global-line size-4 text-text-primary" />
<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="h-4 w-4 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 onClick={onClose}>{t(($) => $['operation.cancel'], { ns: 'common' })}</Button>
<Button
disabled={confirmDisabled}
loading={updatePending}
variant="primary"
onClick={onConfirm}
>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</Button>
</div>
</div>
</AccessControlDialog>
)
}
@@ -1,407 +0,0 @@
'use client'
import type { ComboboxChangeEventDetails } from '@langgenius/dify-ui/combobox'
import type {
AccessControlAccount,
AccessControlGroup,
Subject,
SubjectAccount,
SubjectGroup,
} from '@/models/access-control'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxInputGroup,
ComboboxItem,
ComboboxItemText,
ComboboxList,
ComboboxStatus,
ComboboxTrigger,
} from '@langgenius/dify-ui/combobox'
import { RiArrowRightSLine, RiOrganizationChart } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useDebounce } from 'ahooks'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { SubjectType } from '@/models/access-control'
import { useSearchForWhiteListCandidates } from '@/service/access-control'
import useAccessControlStore from '../../../../context/access-control-store'
import Loading from '../../base/loading'
export default function AddMemberOrGroupDialog() {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [keyword, setKeyword] = useState('')
const scrollRootRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const specificGroups = useAccessControlStore((s) => s.specificGroups)
const setSpecificGroups = useAccessControlStore((s) => s.setSpecificGroups)
const specificMembers = useAccessControlStore((s) => s.specificMembers)
const setSpecificMembers = useAccessControlStore((s) => s.setSpecificMembers)
const selectedGroupsForBreadcrumb = useAccessControlStore((s) => s.selectedGroupsForBreadcrumb)
const debouncedKeyword = useDebounce(keyword, { wait: 500 })
const lastAvailableGroup = selectedGroupsForBreadcrumb[selectedGroupsForBreadcrumb.length - 1]
const { isLoading, isFetchingNextPage, fetchNextPage, data } = useSearchForWhiteListCandidates(
{ keyword: debouncedKeyword, groupId: lastAvailableGroup?.id, resultsPerPage: 10 },
open,
)
const pages = data?.pages ?? []
const subjects = pages.flatMap((page) => page.subjects ?? [])
const selectedSubjects = [
...specificGroups.map(groupToSubject),
...specificMembers.map(memberToSubject),
]
const hasResults = pages.length > 0 && subjects.length > 0
const shouldShowBreadcrumb = hasResults || selectedGroupsForBreadcrumb.length > 0
const hasMore = pages[pages.length - 1]?.hasMore ?? false
useEffect(() => {
let observer: IntersectionObserver | undefined
if (anchorRef.current) {
observer = new IntersectionObserver(
(entries) => {
if (entries[0]!.isIntersecting && !isLoading && hasMore) fetchNextPage()
},
{ root: scrollRootRef.current, rootMargin: '20px' },
)
observer.observe(anchorRef.current)
}
return () => observer?.disconnect()
}, [isLoading, fetchNextPage, hasMore])
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) setKeyword('')
setOpen(nextOpen)
}
const handleInputValueChange = (inputValue: string, details: ComboboxChangeEventDetails) => {
if (details.reason !== 'item-press') setKeyword(inputValue)
}
const handleValueChange = (nextSubjects: Subject[]) => {
const nextGroups: AccessControlGroup[] = []
const nextMembers: AccessControlAccount[] = []
for (const subject of nextSubjects) {
if (subject.subjectType === SubjectType.GROUP)
nextGroups.push((subject as SubjectGroup).groupData)
else nextMembers.push((subject as SubjectAccount).accountData)
}
setSpecificGroups(nextGroups)
setSpecificMembers(nextMembers)
}
return (
<Combobox<Subject, true>
multiple
open={open}
value={selectedSubjects}
inputValue={keyword}
items={subjects}
itemToStringLabel={getSubjectLabel}
itemToStringValue={getSubjectValue}
isItemEqualToValue={isSameSubject}
filter={null}
onOpenChange={handleOpenChange}
onInputValueChange={handleInputValueChange}
onValueChange={handleValueChange}
>
<ComboboxTrigger
aria-label={t(($) => $['operation.add'], { ns: 'common' })}
icon={false}
size="small"
className="h-6 w-auto min-w-13 shrink-0 rounded-md border-0 bg-transparent px-2 py-0 text-xs font-medium text-components-button-secondary-accent-text hover:bg-state-accent-hover focus-visible:bg-state-accent-hover data-popup-open:bg-state-accent-hover"
>
<span className="inline-flex min-w-0 items-center justify-center gap-x-0.5 whitespace-nowrap">
<span className="i-ri-add-circle-fill size-4 shrink-0" aria-hidden="true" />
<span className="shrink-0">{t(($) => $['operation.add'], { ns: 'common' })}</span>
</span>
</ComboboxTrigger>
<ComboboxContent
placement="bottom-end"
alignOffset={300}
popupClassName="relative flex max-h-[400px] w-[400px] flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-0 shadow-lg backdrop-blur-[5px]"
>
<div ref={scrollRootRef} className="min-h-0 overflow-y-auto">
<div className="sticky top-0 z-10 bg-components-panel-bg-blur p-2 pb-0.5 backdrop-blur-[5px]">
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<span
className="mr-0.5 i-ri-search-line size-4 shrink-0 text-text-tertiary"
aria-hidden="true"
/>
<ComboboxInput
aria-label={t(
($) => $['accessControlDialog.operateGroupAndMember.searchPlaceholder'],
{ ns: 'app' },
)}
placeholder={t(
($) => $['accessControlDialog.operateGroupAndMember.searchPlaceholder'],
{ ns: 'app' },
)}
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
/>
</ComboboxInputGroup>
</div>
{isLoading ? (
<ComboboxStatus className="p-1">
<Loading />
</ComboboxStatus>
) : (
<>
{shouldShowBreadcrumb && (
<div className="flex h-7 items-center px-2 py-0.5">
<SelectedGroupsBreadCrumb />
</div>
)}
{hasResults ? (
<>
<ComboboxList<Subject> className="max-h-none p-1">
{(subject) => <SubjectItem key={getSubjectValue(subject)} subject={subject} />}
</ComboboxList>
{isFetchingNextPage && <Loading />}
<div ref={anchorRef} className="h-0" />
</>
) : (
<ComboboxEmpty className="flex h-7 items-center justify-center px-2 py-0.5">
{t(($) => $['accessControlDialog.operateGroupAndMember.noResult'], { ns: 'app' })}
</ComboboxEmpty>
)}
</>
)}
</div>
</ComboboxContent>
</Combobox>
)
}
function groupToSubject(group: AccessControlGroup): SubjectGroup {
return {
subjectId: group.id,
subjectType: SubjectType.GROUP,
groupData: group,
}
}
function memberToSubject(member: AccessControlAccount): SubjectAccount {
return {
subjectId: member.id,
subjectType: SubjectType.ACCOUNT,
accountData: member,
}
}
function getSubjectLabel(subject: Subject) {
if (subject.subjectType === SubjectType.GROUP) return (subject as SubjectGroup).groupData.name
return (subject as SubjectAccount).accountData.name
}
function getSubjectValue(subject: Subject) {
return `${subject.subjectType}:${subject.subjectId}`
}
function isSameSubject(item: Subject, value: Subject) {
return item.subjectId === value.subjectId && item.subjectType === value.subjectType
}
function SubjectItem({ subject }: { subject: Subject }) {
if (subject.subjectType === SubjectType.GROUP)
return <GroupItem group={(subject as SubjectGroup).groupData} subject={subject} />
return <MemberItem member={(subject as SubjectAccount).accountData} subject={subject} />
}
function SelectedGroupsBreadCrumb() {
const selectedGroupsForBreadcrumb = useAccessControlStore((s) => s.selectedGroupsForBreadcrumb)
const setSelectedGroupsForBreadcrumb = useAccessControlStore(
(s) => s.setSelectedGroupsForBreadcrumb,
)
const { t } = useTranslation()
const handleBreadCrumbClick = (index: number) => {
const newGroups = selectedGroupsForBreadcrumb.slice(0, index + 1)
setSelectedGroupsForBreadcrumb(newGroups)
}
const handleReset = () => {
setSelectedGroupsForBreadcrumb([])
}
const hasBreadcrumb = selectedGroupsForBreadcrumb.length > 0
return (
<div className="flex h-7 items-center gap-x-0.5 px-2 py-0.5">
{hasBreadcrumb ? (
<button
type="button"
className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={handleReset}
>
{t(($) => $['accessControlDialog.operateGroupAndMember.allMembers'], { ns: 'app' })}
</button>
) : (
<span className="system-xs-regular text-text-tertiary">
{t(($) => $['accessControlDialog.operateGroupAndMember.allMembers'], { ns: 'app' })}
</span>
)}
{selectedGroupsForBreadcrumb.map((group, index) => {
const isLastGroup = index === selectedGroupsForBreadcrumb.length - 1
return (
<div
key={index}
className="flex items-center gap-x-0.5 system-xs-regular text-text-tertiary"
>
<span>/</span>
{isLastGroup ? (
<span>{group.name}</span>
) : (
<button
type="button"
className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={() => handleBreadCrumbClick(index)}
>
{group.name}
</button>
)}
</div>
)
})}
</div>
)
}
type GroupItemProps = {
group: AccessControlGroup
subject: Subject
}
function GroupItem({ group, subject }: GroupItemProps) {
const { t } = useTranslation()
const specificGroups = useAccessControlStore((s) => s.specificGroups)
const selectedGroupsForBreadcrumb = useAccessControlStore((s) => s.selectedGroupsForBreadcrumb)
const setSelectedGroupsForBreadcrumb = useAccessControlStore(
(s) => s.setSelectedGroupsForBreadcrumb,
)
const isChecked = specificGroups.some((g) => g.id === group.id)
const handleExpandClick = () => {
setSelectedGroupsForBreadcrumb([...selectedGroupsForBreadcrumb, group])
}
return (
<div className="flex items-center gap-2 rounded-lg hover:bg-state-base-hover">
<BaseItem subject={subject}>
{(selected) => (
<>
<SelectionBox checked={selected} />
<ComboboxItemText className="flex grow items-center px-0">
<div className="mr-2 size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
<RiOrganizationChart
className="h-3.5 w-3.5 text-components-avatar-shape-fill-stop-0"
aria-hidden="true"
/>
</div>
</div>
<span className="mr-1 system-sm-medium text-text-secondary">{group.name}</span>
<span className="system-xs-regular text-text-tertiary">{group.groupSize}</span>
</ComboboxItemText>
</>
)}
</BaseItem>
<Button
size="small"
disabled={isChecked}
variant="ghost-accent"
className="mr-1 flex shrink-0 items-center justify-between py-1"
onPointerDown={(event) => event.preventDefault()}
onClick={handleExpandClick}
>
<span>
{t(($) => $['accessControlDialog.operateGroupAndMember.expand'], { ns: 'app' })}
</span>
<RiArrowRightSLine className="size-4" aria-hidden="true" />
</Button>
</div>
)
}
type MemberItemProps = {
member: AccessControlAccount
subject: Subject
}
function MemberItem({ member, subject }: MemberItemProps) {
const { data: currentUser } = useSuspenseQuery({
...userProfileQueryOptions(),
select: (data) => data.profile,
})
const { t } = useTranslation()
return (
<BaseItem subject={subject} className="pr-3">
{(selected) => (
<>
<SelectionBox checked={selected} />
<ComboboxItemText className="flex grow items-center px-0">
<div className="mr-2 size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
<Avatar size="xxs" avatar={null} name={member.name} />
</div>
</div>
<span className="mr-1 system-sm-medium text-text-secondary">{member.name}</span>
{currentUser.email === member.email && (
<span className="system-xs-regular text-text-tertiary">
({t(($) => $.you, { ns: 'common' })})
</span>
)}
</ComboboxItemText>
<span className="system-xs-regular text-text-quaternary">{member.email}</span>
</>
)}
</BaseItem>
)
}
type BaseItemProps = {
className?: string
subject: Subject
children: (selected: boolean) => React.ReactNode
}
function BaseItem({ children, className, subject }: BaseItemProps) {
return (
<ComboboxItem
value={subject}
className={cn(
'mx-0 flex min-h-8 grow grid-cols-none items-center gap-2 rounded-lg p-1 pl-2',
className,
)}
render={(props, state) => (
<div {...props} className={props.className}>
{children(state.selected)}
</div>
)}
/>
)
}
function SelectionBox({ checked }: { checked: boolean }) {
return (
<span
aria-hidden="true"
className={cn(
'flex size-4 shrink-0 items-center justify-center rounded-sm shadow-xs shadow-shadow-shadow-3',
checked
? 'bg-components-checkbox-bg text-components-checkbox-icon'
: 'border border-components-checkbox-border bg-components-checkbox-bg-unchecked',
)}
>
{checked && <span className="i-ri-check-line size-3" />}
</span>
)
}
@@ -0,0 +1,60 @@
import type { AccessControlGroup } from '@/models/access-control'
import { useTranslation } from 'react-i18next'
type SelectedGroupsBreadcrumbProps = {
groups: AccessControlGroup[]
onChange: (groups: AccessControlGroup[]) => void
}
export function SelectedGroupsBreadcrumb({ groups, onChange }: SelectedGroupsBreadcrumbProps) {
const { t } = useTranslation()
const handleBreadcrumbClick = (index: number) => {
onChange(groups.slice(0, index + 1))
}
const handleReset = () => {
onChange([])
}
const hasBreadcrumb = groups.length > 0
return (
<div className="flex h-7 items-center gap-x-0.5 px-2 py-0.5">
{hasBreadcrumb ? (
<button
type="button"
className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={handleReset}
>
{t(($) => $['accessControlDialog.operateGroupAndMember.allMembers'], { ns: 'app' })}
</button>
) : (
<span className="system-xs-regular text-text-tertiary">
{t(($) => $['accessControlDialog.operateGroupAndMember.allMembers'], { ns: 'app' })}
</span>
)}
{groups.map((group, index) => {
const isLastGroup = index === groups.length - 1
return (
<div
key={group.id}
className="flex items-center gap-x-0.5 system-xs-regular text-text-tertiary"
>
<span>/</span>
{isLastGroup ? (
<span>{group.name}</span>
) : (
<button
type="button"
className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={() => handleBreadcrumbClick(index)}
>
{group.name}
</button>
)}
</div>
)
})}
</div>
)
}
@@ -0,0 +1,226 @@
'use client'
import type { ComboboxChangeEventDetails } from '@langgenius/dify-ui/combobox'
import type { AccessControlSubjects } from '../specific-groups-or-members'
import type {
AccessControlAccount,
AccessControlGroup,
Subject,
SubjectAccount,
SubjectGroup,
} from '@/models/access-control'
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxInputGroup,
ComboboxList,
ComboboxStatus,
ComboboxTrigger,
} from '@langgenius/dify-ui/combobox'
import { useDebounce } from 'ahooks'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { SubjectType } from '@/models/access-control'
import { useSearchForWhiteListCandidates } from '@/service/access-control'
import { SelectedGroupsBreadcrumb } from './breadcrumb'
import { SubjectItem } from './subject-item'
type AddMemberOrGroupDialogProps = {
subjects: AccessControlSubjects
onChange: (subjects: AccessControlSubjects) => void
}
export default function AddMemberOrGroupDialog({
subjects: selectedAccessSubjects,
onChange,
}: AddMemberOrGroupDialogProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [keyword, setKeyword] = useState('')
const [selectedGroupsForBreadcrumb, setSelectedGroupsForBreadcrumb] = useState<
AccessControlGroup[]
>([])
const scrollRootRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const { groups: specificGroups, members: specificMembers } = selectedAccessSubjects
const debouncedKeyword = useDebounce(keyword, { wait: 500 })
const lastAvailableGroup = selectedGroupsForBreadcrumb[selectedGroupsForBreadcrumb.length - 1]
const { isLoading, isFetchingNextPage, fetchNextPage, data } = useSearchForWhiteListCandidates(
{ keyword: debouncedKeyword, groupId: lastAvailableGroup?.id, resultsPerPage: 10 },
open,
)
const pages = data?.pages ?? []
const subjects = pages.flatMap((page) => page.subjects ?? [])
const selectedSubjects = [
...specificGroups.map(groupToSubject),
...specificMembers.map(memberToSubject),
]
const hasResults = pages.length > 0 && subjects.length > 0
const shouldShowBreadcrumb = hasResults || selectedGroupsForBreadcrumb.length > 0
const hasMore = pages[pages.length - 1]?.hasMore ?? false
useEffect(() => {
let observer: IntersectionObserver | undefined
if (anchorRef.current) {
observer = new IntersectionObserver(
(entries) => {
if (entries[0]!.isIntersecting && !isLoading && hasMore) fetchNextPage()
},
{ root: scrollRootRef.current, rootMargin: '20px' },
)
observer.observe(anchorRef.current)
}
return () => observer?.disconnect()
}, [isLoading, fetchNextPage, hasMore])
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) setKeyword('')
setOpen(nextOpen)
}
const handleInputValueChange = (inputValue: string, details: ComboboxChangeEventDetails) => {
if (details.reason !== 'item-press') setKeyword(inputValue)
}
const handleValueChange = (nextSubjects: Subject[]) => {
const nextGroups: AccessControlGroup[] = []
const nextMembers: AccessControlAccount[] = []
for (const subject of nextSubjects) {
if (subject.subjectType === SubjectType.GROUP)
nextGroups.push((subject as SubjectGroup).groupData)
else nextMembers.push((subject as SubjectAccount).accountData)
}
onChange({ groups: nextGroups, members: nextMembers })
}
return (
<Combobox<Subject, true>
multiple
open={open}
value={selectedSubjects}
inputValue={keyword}
items={subjects}
itemToStringLabel={getSubjectLabel}
itemToStringValue={getSubjectValue}
isItemEqualToValue={isSameSubject}
filter={null}
onOpenChange={handleOpenChange}
onInputValueChange={handleInputValueChange}
onValueChange={handleValueChange}
>
<ComboboxTrigger
aria-label={t(($) => $['operation.add'], { ns: 'common' })}
icon={false}
size="small"
className="h-6 w-auto min-w-13 shrink-0 rounded-md border-0 bg-transparent px-2 py-0 text-xs font-medium text-components-button-secondary-accent-text hover:bg-state-accent-hover focus-visible:bg-state-accent-hover data-popup-open:bg-state-accent-hover"
>
<span className="inline-flex min-w-0 items-center justify-center gap-x-0.5 whitespace-nowrap">
<span className="i-ri-add-circle-fill size-4 shrink-0" aria-hidden="true" />
<span className="shrink-0">{t(($) => $['operation.add'], { ns: 'common' })}</span>
</span>
</ComboboxTrigger>
<ComboboxContent
placement="bottom-end"
alignOffset={300}
popupClassName="relative flex max-h-[400px] w-[400px] flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-0 shadow-lg backdrop-blur-[5px]"
>
<div ref={scrollRootRef} className="min-h-0 overflow-y-auto">
<div className="sticky top-0 z-10 bg-components-panel-bg-blur p-2 pb-0.5 backdrop-blur-[5px]">
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<span
className="mr-0.5 i-ri-search-line size-4 shrink-0 text-text-tertiary"
aria-hidden="true"
/>
<ComboboxInput
aria-label={t(
($) => $['accessControlDialog.operateGroupAndMember.searchPlaceholder'],
{ ns: 'app' },
)}
placeholder={t(
($) => $['accessControlDialog.operateGroupAndMember.searchPlaceholder'],
{ ns: 'app' },
)}
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
/>
</ComboboxInputGroup>
</div>
{isLoading ? (
<ComboboxStatus className="p-1">
<Loading />
</ComboboxStatus>
) : (
<>
{shouldShowBreadcrumb && (
<div className="flex h-7 items-center px-2 py-0.5">
<SelectedGroupsBreadcrumb
groups={selectedGroupsForBreadcrumb}
onChange={setSelectedGroupsForBreadcrumb}
/>
</div>
)}
{hasResults ? (
<>
<ComboboxList<Subject> className="max-h-none p-1">
{(subject) => (
<SubjectItem
key={getSubjectValue(subject)}
subject={subject}
selectedGroups={specificGroups}
onExpandGroup={(group) =>
setSelectedGroupsForBreadcrumb((groups) => [...groups, group])
}
/>
)}
</ComboboxList>
{isFetchingNextPage && <Loading />}
<div ref={anchorRef} className="h-0" />
</>
) : (
<ComboboxEmpty className="flex h-7 items-center justify-center px-2 py-0.5">
{t(($) => $['accessControlDialog.operateGroupAndMember.noResult'], { ns: 'app' })}
</ComboboxEmpty>
)}
</>
)}
</div>
</ComboboxContent>
</Combobox>
)
}
function groupToSubject(group: AccessControlGroup): SubjectGroup {
return {
subjectId: group.id,
subjectType: SubjectType.GROUP,
groupData: group,
}
}
function memberToSubject(member: AccessControlAccount): SubjectAccount {
return {
subjectId: member.id,
subjectType: SubjectType.ACCOUNT,
accountData: member,
}
}
function getSubjectLabel(subject: Subject) {
if (subject.subjectType === SubjectType.GROUP) return (subject as SubjectGroup).groupData.name
return (subject as SubjectAccount).accountData.name
}
function getSubjectValue(subject: Subject) {
return `${subject.subjectType}:${subject.subjectId}`
}
function isSameSubject(item: Subject, value: Subject) {
return item.subjectId === value.subjectId && item.subjectType === value.subjectType
}
@@ -0,0 +1,160 @@
'use client'
import type { ReactNode } from 'react'
import type {
AccessControlAccount,
AccessControlGroup,
Subject,
SubjectAccount,
SubjectGroup,
} from '@/models/access-control'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { ComboboxItem, ComboboxItemText } from '@langgenius/dify-ui/combobox'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { SubjectType } from '@/models/access-control'
type SubjectItemProps = {
subject: Subject
selectedGroups: AccessControlGroup[]
onExpandGroup: (group: AccessControlGroup) => void
}
export function SubjectItem({ subject, selectedGroups, onExpandGroup }: SubjectItemProps) {
if (subject.subjectType === SubjectType.GROUP)
return (
<GroupItem
group={(subject as SubjectGroup).groupData}
subject={subject}
selectedGroups={selectedGroups}
onExpand={onExpandGroup}
/>
)
return <MemberItem member={(subject as SubjectAccount).accountData} subject={subject} />
}
type GroupItemProps = {
group: AccessControlGroup
subject: Subject
selectedGroups: AccessControlGroup[]
onExpand: (group: AccessControlGroup) => void
}
function GroupItem({ group, subject, selectedGroups, onExpand }: GroupItemProps) {
const { t } = useTranslation()
const isChecked = selectedGroups.some((selectedGroup) => selectedGroup.id === group.id)
return (
<div className="flex items-center gap-2 rounded-lg hover:bg-state-base-hover">
<BaseItem subject={subject}>
{(selected) => (
<>
<SelectionBox checked={selected} />
<ComboboxItemText className="flex grow items-center px-0">
<div className="mr-2 size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
<span
aria-hidden="true"
className="i-ri-organization-chart h-3.5 w-3.5 text-components-avatar-shape-fill-stop-0"
/>
</div>
</div>
<span className="mr-1 system-sm-medium text-text-secondary">{group.name}</span>
<span className="system-xs-regular text-text-tertiary">{group.groupSize}</span>
</ComboboxItemText>
</>
)}
</BaseItem>
<Button
size="small"
disabled={isChecked}
variant="ghost-accent"
className="mr-1 flex shrink-0 items-center justify-between py-1"
onPointerDown={(event) => event.preventDefault()}
onClick={() => onExpand(group)}
>
<span>
{t(($) => $['accessControlDialog.operateGroupAndMember.expand'], { ns: 'app' })}
</span>
<span aria-hidden="true" className="i-ri-arrow-right-s-line size-4" />
</Button>
</div>
)
}
function MemberItem({ member, subject }: { member: AccessControlAccount; subject: Subject }) {
const { data: currentUser } = useSuspenseQuery({
...userProfileQueryOptions(),
select: (data) => data.profile,
})
const { t } = useTranslation()
return (
<BaseItem subject={subject} className="pr-3">
{(selected) => (
<>
<SelectionBox checked={selected} />
<ComboboxItemText className="flex grow items-center px-0">
<div className="mr-2 size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
<Avatar size="xxs" avatar={null} name={member.name} />
</div>
</div>
<span className="mr-1 system-sm-medium text-text-secondary">{member.name}</span>
{currentUser.email === member.email && (
<span className="system-xs-regular text-text-tertiary">
({t(($) => $.you, { ns: 'common' })})
</span>
)}
</ComboboxItemText>
<span className="system-xs-regular text-text-quaternary">{member.email}</span>
</>
)}
</BaseItem>
)
}
function BaseItem({
children,
className,
subject,
}: {
className?: string
subject: Subject
children: (selected: boolean) => ReactNode
}) {
return (
<ComboboxItem
value={subject}
className={cn(
'mx-0 flex min-h-8 grow grid-cols-none items-center gap-2 rounded-lg p-1 pl-2',
className,
)}
render={(props, state) => (
<div {...props} className={props.className}>
{children(state.selected)}
</div>
)}
/>
)
}
function SelectionBox({ checked }: { checked: boolean }) {
return (
<span
aria-hidden="true"
className={cn(
'flex size-4 shrink-0 items-center justify-center rounded-sm shadow-xs shadow-shadow-shadow-3',
checked
? 'bg-components-checkbox-bg text-components-checkbox-icon'
: 'border border-components-checkbox-border bg-components-checkbox-bg-unchecked',
)}
>
{checked && <span className="i-ri-check-line size-3" />}
</span>
)
}
@@ -1,21 +1,25 @@
'use client'
import type { AppPartial } from '@dify/contracts/api/console/apps/types.gen'
import type {
AccessControlSubjects,
AccessControlSubjectsStatus,
} from './specific-groups-or-members'
import type { Subject } from '@/models/access-control'
import { Button } from '@langgenius/dify-ui/button'
import { DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { RadioGroup } from '@langgenius/dify-ui/radio'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useId } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { AccessMode, isAccessMode, SubjectType } from '@/models/access-control'
import { useAppWhiteListSubjects } from '@/service/access-control'
import { consoleQuery } from '@/service/client'
import useAccessControlStore from '../../../../context/access-control-store'
import { Infotip } from '../../base/infotip'
import AccessControlDialog from './access-control-dialog'
import AccessControlItem from './access-control-item'
import SpecificGroupsOrMembers, { WebAppSSONotEnabledTip } from './specific-groups-or-members'
import { AccessControlForm } from './access-control-form'
const EMPTY_SUBJECTS: AccessControlSubjects = {
groups: [],
members: [],
}
type AccessControlProps = {
app: Pick<AppPartial, 'id' | 'access_mode'>
@@ -24,143 +28,85 @@ type AccessControlProps = {
}
export default function AccessControl(props: AccessControlProps) {
const { app, onClose, onConfirm } = props
const { id: appId } = app
const appAccessMode = isAccessMode(app.access_mode) ? app.access_mode : undefined
const accessControlOptionsLabelId = useId()
return <AppAccessControlContainer key={props.app.id} {...props} />
}
function AppAccessControlContainer({ app, onClose, onConfirm }: AccessControlProps) {
const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const setAppId = useAccessControlStore((s) => s.setAppId)
const specificGroups = useAccessControlStore((s) => s.specificGroups)
const specificMembers = useAccessControlStore((s) => s.specificMembers)
const currentMenu = useAccessControlStore((s) => s.currentMenu)
const setCurrentMenu = useAccessControlStore((s) => s.setCurrentMenu)
const hideTip =
const [accessMode, setAccessMode] = useState(
() =>
(isAccessMode(app.access_mode) ? app.access_mode : undefined) ??
AccessMode.SPECIFIC_GROUPS_MEMBERS,
)
const [subjectsDraft, setSubjectsDraft] = useState<AccessControlSubjects>()
const subjectsQuery = useAppWhiteListSubjects(
app.id,
accessMode === AccessMode.SPECIFIC_GROUPS_MEMBERS,
)
const subjects = subjectsDraft ?? subjectsQuery.data ?? EMPTY_SUBJECTS
const subjectsStatus: AccessControlSubjectsStatus =
subjectsDraft || subjectsQuery.data
? 'success'
: subjectsQuery.isFetching || subjectsQuery.isPending
? 'loading'
: subjectsQuery.isError
? 'error'
: 'loading'
const updateAccessModeMutation = useMutation(
consoleQuery.enterprise.webAppAuth.updateWebAppWhitelistSubjects.mutationOptions(),
)
const externalMembersTipHidden =
systemFeatures.webapp_auth.enabled &&
(systemFeatures.webapp_auth.allow_sso ||
systemFeatures.webapp_auth.allow_email_password_login ||
systemFeatures.webapp_auth.allow_email_code_login)
const publicAccessDisabled = !systemFeatures.webapp_auth.allow_public_access
useEffect(() => {
setAppId(appId)
setCurrentMenu(appAccessMode ?? AccessMode.SPECIFIC_GROUPS_MEMBERS)
}, [appAccessMode, appId, setAppId, setCurrentMenu])
const handleConfirm = async () => {
if (
updateAccessModeMutation.isPending ||
(accessMode === AccessMode.SPECIFIC_GROUPS_MEMBERS && subjectsStatus !== 'success') ||
(accessMode === AccessMode.PUBLIC && publicAccessDisabled)
)
return
const { isPending, mutateAsync: updateAccessMode } = useMutation(
consoleQuery.enterprise.webAppAuth.updateWebAppWhitelistSubjects.mutationOptions(),
)
const confirmDisabled = isPending || (currentMenu === AccessMode.PUBLIC && publicAccessDisabled)
const handleConfirm = useCallback(async () => {
if (confirmDisabled) return
const submitData: {
appId: string
accessMode: AccessMode
subjects?: Pick<Subject, 'subjectId' | 'subjectType'>[]
} = { appId, accessMode: currentMenu }
if (currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS) {
const subjects: Pick<Subject, 'subjectId' | 'subjectType'>[] = []
specificGroups.forEach((group) => {
subjects.push({ subjectId: group.id, subjectType: SubjectType.GROUP })
})
specificMembers.forEach((member) => {
subjects.push({
} = { accessMode }
if (accessMode === AccessMode.SPECIFIC_GROUPS_MEMBERS) {
submitData.subjects = [
...subjects.groups.map((group) => ({
subjectId: group.id,
subjectType: SubjectType.GROUP,
})),
...subjects.members.map((member) => ({
subjectId: member.id,
subjectType: SubjectType.ACCOUNT,
})
})
submitData.subjects = subjects
})),
]
}
await updateAccessMode({ body: submitData })
await updateAccessModeMutation.mutateAsync({ body: { appId: app.id, ...submitData } })
toast.success(t(($) => $['accessControlDialog.updateSuccess'], { ns: 'app' }))
onConfirm?.()
}, [
updateAccessMode,
appId,
specificGroups,
specificMembers,
t,
onConfirm,
currentMenu,
confirmDisabled,
])
}
return (
<AccessControlDialog show onClose={onClose}>
<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(($) => $['accessControlDialog.title'], { ns: 'app' })}
</DialogTitle>
<DialogDescription className="mt-1 system-xs-regular text-text-tertiary">
{t(($) => $['accessControlDialog.description'], { ns: 'app' })}
</DialogDescription>
</div>
<RadioGroup<AccessMode>
value={currentMenu}
onValueChange={setCurrentMenu}
className="flex flex-col items-stretch gap-y-1 px-6 pb-3"
aria-labelledby={accessControlOptionsLabelId}
>
<div className="leading-6">
<p id={accessControlOptionsLabelId} className="system-sm-medium text-text-tertiary">
{t(($) => $['accessControlDialog.accessLabel'], { ns: 'app' })}
</p>
</div>
<AccessControlItem type={AccessMode.ORGANIZATION}>
<div className="flex items-center p-3">
<div className="flex grow items-center gap-x-2">
<span aria-hidden className="i-ri-building-line size-4 text-text-primary" />
<p className="system-sm-medium text-text-primary">
{t(($) => $['accessControlDialog.accessItems.organization'], { ns: 'app' })}
</p>
</div>
</div>
</AccessControlItem>
<AccessControlItem type={AccessMode.SPECIFIC_GROUPS_MEMBERS}>
<SpecificGroupsOrMembers />
</AccessControlItem>
<AccessControlItem type={AccessMode.EXTERNAL_MEMBERS}>
<div className="flex items-center p-3">
<div className="flex grow items-center gap-x-2">
<span aria-hidden className="i-ri-verified-badge-line size-4 text-text-primary" />
<p className="system-sm-medium text-text-primary">
{t(($) => $['accessControlDialog.accessItems.external'], { ns: 'app' })}
</p>
</div>
{!hideTip && <WebAppSSONotEnabledTip />}
</div>
</AccessControlItem>
<AccessControlItem type={AccessMode.PUBLIC} disabled={publicAccessDisabled}>
<div className="flex items-center gap-x-2 p-3">
<span aria-hidden className="i-ri-global-line size-4 text-text-primary" />
<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="h-4 w-4 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 onClick={onClose}>{t(($) => $['operation.cancel'], { ns: 'common' })}</Button>
<Button
disabled={confirmDisabled}
loading={isPending}
variant="primary"
onClick={handleConfirm}
>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</Button>
</div>
</div>
</AccessControlDialog>
<AccessControlForm
accessMode={accessMode}
subjects={subjects}
subjectsStatus={subjectsStatus}
updatePending={updateAccessModeMutation.isPending}
publicAccessDisabled={publicAccessDisabled}
externalMembersTipHidden={externalMembersTipHidden}
onAccessModeChange={setAccessMode}
onSubjectsChange={setSubjectsDraft}
onRetrySubjects={() => void subjectsQuery.refetch()}
onClose={onClose}
onConfirm={() => void handleConfirm()}
/>
)
}
@@ -1,37 +1,42 @@
'use client'
import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { RiCloseCircleFill, RiLockLine, RiOrganizationChart } from '@remixicon/react'
import { useCallback, useEffect } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { useTranslation } from 'react-i18next'
import { AccessMode } from '@/models/access-control'
import { useAppWhiteListSubjects } from '@/service/access-control'
import useAccessControlStore from '../../../../context/access-control-store'
import { Infotip } from '../../base/infotip'
import Loading from '../../base/loading'
import AddMemberOrGroupDialog from './add-member-or-group-pop'
export default function SpecificGroupsOrMembers() {
const currentMenu = useAccessControlStore((s) => s.currentMenu)
const appId = useAccessControlStore((s) => s.appId)
const setSpecificGroups = useAccessControlStore((s) => s.setSpecificGroups)
const setSpecificMembers = useAccessControlStore((s) => s.setSpecificMembers)
export type AccessControlSubjects = {
groups: AccessControlGroup[]
members: AccessControlAccount[]
}
export type AccessControlSubjectsStatus = 'loading' | 'error' | 'success'
type SpecificGroupsOrMembersProps = {
accessMode: AccessMode
subjects: AccessControlSubjects
subjectsStatus: AccessControlSubjectsStatus
onSubjectsChange: (subjects: AccessControlSubjects) => void
onRetrySubjects?: () => void
}
export default function SpecificGroupsOrMembers({
accessMode,
subjects,
subjectsStatus,
onSubjectsChange,
onRetrySubjects,
}: SpecificGroupsOrMembersProps) {
const { t } = useTranslation()
const { isPending, data } = useAppWhiteListSubjects(
appId,
Boolean(appId) && currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS,
)
useEffect(() => {
setSpecificGroups(data?.groups ?? [])
setSpecificMembers(data?.members ?? [])
}, [data, setSpecificGroups, setSpecificMembers])
if (currentMenu !== AccessMode.SPECIFIC_GROUPS_MEMBERS) {
if (accessMode !== AccessMode.SPECIFIC_GROUPS_MEMBERS) {
return (
<div className="flex items-center p-3">
<div className="flex grow items-center gap-x-2">
<RiLockLine className="size-4 text-text-primary" />
<span aria-hidden="true" className="i-ri-lock-line size-4 text-text-primary" />
<p className="system-sm-medium text-text-primary">
{t(($) => $['accessControlDialog.accessItems.specific'], { ns: 'app' })}
</p>
@@ -44,29 +49,51 @@ export default function SpecificGroupsOrMembers() {
<div>
<div className="flex items-center gap-x-1 p-3">
<div className="flex grow items-center gap-x-1">
<RiLockLine className="size-4 text-text-primary" />
<span aria-hidden="true" className="i-ri-lock-line size-4 text-text-primary" />
<p className="system-sm-medium text-text-primary">
{t(($) => $['accessControlDialog.accessItems.specific'], { ns: 'app' })}
</p>
</div>
<div className="flex items-center gap-x-1">
<AddMemberOrGroupDialog />
</div>
{subjectsStatus === 'success' && (
<div className="flex items-center gap-x-1">
<AddMemberOrGroupDialog subjects={subjects} onChange={onSubjectsChange} />
</div>
)}
</div>
<div className="px-1 pb-1">
<div className="flex max-h-100 flex-col gap-y-2 overflow-y-auto rounded-lg bg-background-section p-2">
{isPending ? <Loading /> : <RenderGroupsAndMembers />}
{subjectsStatus === 'loading' && <Loading />}
{subjectsStatus === 'error' && (
<div role="alert" className="flex flex-col items-center gap-2 px-2 py-5">
<p className="system-xs-regular text-text-tertiary">
{t(($) => $['dynamicSelect.error'], { ns: 'common' })}
</p>
{onRetrySubjects && (
<Button size="small" onClick={onRetrySubjects}>
{t(($) => $['operation.retry'], { ns: 'common' })}
</Button>
)}
</div>
)}
{subjectsStatus === 'success' && (
<RenderGroupsAndMembers subjects={subjects} onChange={onSubjectsChange} />
)}
</div>
</div>
</div>
)
}
function RenderGroupsAndMembers() {
type RenderGroupsAndMembersProps = {
subjects: AccessControlSubjects
onChange: (subjects: AccessControlSubjects) => void
}
function RenderGroupsAndMembers({ subjects, onChange }: RenderGroupsAndMembersProps) {
const { t } = useTranslation()
const specificGroups = useAccessControlStore((s) => s.specificGroups)
const specificMembers = useAccessControlStore((s) => s.specificMembers)
if (specificGroups.length <= 0 && specificMembers.length <= 0)
const { groups, members } = subjects
if (groups.length <= 0 && members.length <= 0) {
return (
<div className="px-2 pt-5 pb-1.5">
<p className="text-center system-xs-regular text-text-tertiary">
@@ -74,28 +101,48 @@ function RenderGroupsAndMembers() {
</p>
</div>
)
}
return (
<>
<p className="sticky top-0 system-2xs-medium-uppercase text-text-tertiary">
{t(($) => $['accessControlDialog.groups'], {
ns: 'app',
count: specificGroups.length ?? 0,
count: groups.length,
})}
</p>
<div className="flex flex-row flex-wrap gap-1">
{specificGroups.map((group, index) => (
<GroupItem key={index} group={group} />
{groups.map((group) => (
<GroupItem
key={group.id}
group={group}
onRemove={() =>
onChange({
groups: groups.filter((candidate) => candidate.id !== group.id),
members,
})
}
/>
))}
</div>
<p className="sticky top-0 system-2xs-medium-uppercase text-text-tertiary">
{t(($) => $['accessControlDialog.members'], {
ns: 'app',
count: specificMembers.length ?? 0,
count: members.length,
})}
</p>
<div className="flex flex-row flex-wrap gap-1">
{specificMembers.map((member, index) => (
<MemberItem key={index} member={member} />
{members.map((member) => (
<MemberItem
key={member.id}
member={member}
onRemove={() =>
onChange({
groups,
members: members.filter((candidate) => candidate.id !== member.id),
})
}
/>
))}
</div>
</>
@@ -104,19 +151,19 @@ function RenderGroupsAndMembers() {
type GroupItemProps = {
group: AccessControlGroup
onRemove: () => void
}
function GroupItem({ group }: GroupItemProps) {
const specificGroups = useAccessControlStore((s) => s.specificGroups)
const setSpecificGroups = useAccessControlStore((s) => s.setSpecificGroups)
const handleRemoveGroup = useCallback(() => {
setSpecificGroups(specificGroups.filter((g) => g.id !== group.id))
}, [group, setSpecificGroups, specificGroups])
function GroupItem({ group, onRemove }: GroupItemProps) {
return (
<BaseItem
icon={
<RiOrganizationChart className="h-3.5 w-3.5 text-components-avatar-shape-fill-stop-0" />
<span
aria-hidden="true"
className="i-ri-organization-chart h-3.5 w-3.5 text-components-avatar-shape-fill-stop-0"
/>
}
onRemove={handleRemoveGroup}
onRemove={onRemove}
>
<p className="system-xs-regular text-text-primary">{group.name}</p>
<p className="system-xs-regular text-text-tertiary">{group.groupSize}</p>
@@ -126,18 +173,12 @@ function GroupItem({ group }: GroupItemProps) {
type MemberItemProps = {
member: AccessControlAccount
onRemove: () => void
}
function MemberItem({ member }: MemberItemProps) {
const specificMembers = useAccessControlStore((s) => s.specificMembers)
const setSpecificMembers = useAccessControlStore((s) => s.setSpecificMembers)
const handleRemoveMember = useCallback(() => {
setSpecificMembers(specificMembers.filter((m) => m.id !== member.id))
}, [member, setSpecificMembers, specificMembers])
function MemberItem({ member, onRemove }: MemberItemProps) {
return (
<BaseItem
icon={<Avatar size="xxs" avatar={null} name={member.name} />}
onRemove={handleRemoveMember}
>
<BaseItem icon={<Avatar size="xxs" avatar={null} name={member.name} />} onRemove={onRemove}>
<p className="system-xs-regular text-text-primary">{member.name}</p>
</BaseItem>
)
@@ -146,8 +187,9 @@ function MemberItem({ member }: MemberItemProps) {
type BaseItemProps = {
icon: React.ReactNode
children: React.ReactNode
onRemove?: () => void
onRemove: () => void
}
function BaseItem({ icon, onRemove, children }: BaseItemProps) {
const { t } = useTranslation()
@@ -165,7 +207,10 @@ function BaseItem({ icon, onRemove, children }: BaseItemProps) {
aria-label={t(($) => $['operation.remove'], { ns: 'common' })}
onClick={onRemove}
>
<RiCloseCircleFill className="h-3.5 w-3.5 text-text-quaternary" aria-hidden="true" />
<span
aria-hidden="true"
className="i-ri-close-circle-fill h-3.5 w-3.5 text-text-quaternary"
/>
</button>
</div>
)
@@ -0,0 +1,818 @@
import type { WorkflowResponse } from '@dify/contracts/api/console/apps/types.gen'
import type { EnvironmentDeployment } from '@dify/contracts/enterprise-app-deploy/types.gen'
import type { QueryClient } from '@tanstack/react-query'
import {
DeploymentOperationStatus,
DeploymentOperationType,
DeploymentStatus,
EnvironmentStatus,
EnvVarValueType,
OperatorType,
} from '@dify/contracts/enterprise-app-deploy/types.gen'
import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createStore, Provider, useAtomValue, useSetAtom } from 'jotai'
import { queryClientAtom } from 'jotai-tanstack-query'
import { useEffect } from 'react'
import { consoleQuery } from '@/service/client'
import {
createConsoleQueryClient,
renderWithConsoleQuery as render,
} from '@/test/console/query-data'
import { PublisherEnvironmentFlow } from '../environment-deployment-flow'
import { useRefreshAppEnvironmentsAfterPublisherDeploymentPolling } from '../hooks/use-refresh-app-environments-after-deployment-polling'
import {
appPublisherEnvironmentsAtom,
appPublisherOpenAtom,
AppPublisherStateBoundary,
publisherEnvironmentDeploymentPollingAtom,
selectedPublisherEnvironmentIdAtom,
} from '../state'
vi.mock('react-i18next', async () => {
const { createReactI18nextMock } = await import('@/test/i18n-mock')
return createReactI18nextMock({
'common.appMenus.accessPoint': 'Access Point',
'common.appMenus.deploy': 'Deploy',
'common.operation.back': 'Back',
'common.operation.cancel': 'Cancel',
'deployments.overview.chip.latest': 'Latest',
'deployments.deployDrawer.deploying': 'Deploying...',
'deployments.deployDrawer.envVars': 'Environment Variables',
'deployments.deployDrawer.runtimeCredentials': 'Credentials',
'deployments.studio.accessPoint.goToPublish': 'Go to publish',
'deployments.studio.allVersions': 'All versions',
'deployments.studio.chooseVersionToDeploy': 'Choose a version to deploy',
'deployments.studio.current': 'Current',
'deployments.studio.deployAnotherVersion': 'Deploy another version',
'deployments.studio.deployConfiguration': 'Deploy configuration',
'deployments.studio.deployLatest': 'Deploy latest',
'deployments.studio.precheck.description': 'It contains node types that are not yet supported:',
'deployments.studio.precheck.supportMessage':
'Support for these node types is coming in a future release.',
'deployments.studio.precheck.title': "This version can't be deployed to this environment",
'deployments.studio.accessPoint.noPublishedTitle': 'No published versions yet',
'deployments.studio.publisher.noPublishedDescription':
'Publish the app before deploying it to an environment.',
'deployments.studio.publisher.deployingVersion': 'Deploying: {{version}}',
'deployments.studio.publisher.notDeployedYet': 'Not deployed yet',
'deployments.versions.deployTo': 'Deploy to {{name}}',
'workflow.common.publishedBy': 'Published {{time}} by {{author}}',
})
})
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: (time: number) => `relative:${time}`,
}),
}))
vi.mock('@/service/use-tools', () => ({
useAllBuiltInTools: () => ({ data: [] }),
useAllCustomTools: () => ({ data: [] }),
useAllMCPTools: () => ({ data: [] }),
useAllWorkflowTools: () => ({ data: [] }),
}))
function publishedWorkflowVersion({
id,
name,
publishedBy = 'Alice',
}: {
id: string
name: string
publishedBy?: string
}): WorkflowResponse {
return {
conversation_variables: [],
created_at: 1_710_000_100,
created_by: {
email: `${publishedBy.toLowerCase()}@example.com`,
id: `user-${publishedBy.toLowerCase()}`,
name: publishedBy,
},
environment_variables: [],
features: {},
graph: {},
hash: `hash-${id}`,
id,
marked_comment: `${name} notes`,
marked_name: name,
rag_pipeline_variables: [],
tool_published: false,
updated_at: 1_710_000_100,
version: `2026-07-30.${id}`,
}
}
const PUBLISHED_WORKFLOW_VERSIONS = [
publishedWorkflowVersion({
id: 'workflow-version-7',
name: 'Release 7',
}),
publishedWorkflowVersion({
id: 'workflow-version-6',
name: 'Release 6',
publishedBy: 'Carol',
}),
publishedWorkflowVersion({
id: 'sprint-42',
name: 'Sprint-42',
publishedBy: 'Evan',
}),
publishedWorkflowVersion({
id: 'sprint-35',
name: 'Sprint-35',
publishedBy: 'Evan',
}),
]
const latestPublishedWorkflow = PUBLISHED_WORKFLOW_VERSIONS[0]!
const latestVersion = {
description: latestPublishedWorkflow.marked_comment || undefined,
id: latestPublishedWorkflow.id,
latest: true,
name: latestPublishedWorkflow.marked_name || latestPublishedWorkflow.version,
publishedAt: latestPublishedWorkflow.created_at * 1000,
publishedBy: latestPublishedWorkflow.created_by?.name,
}
function createDeployment({
deployed = true,
latest = false,
status = DeploymentStatus.DEPLOYMENT_STATUS_RUNNING,
}: {
deployed?: boolean
latest?: boolean
status?: NonNullable<EnvironmentDeployment['deployment']>['status']
} = {}): EnvironmentDeployment {
const currentVersion = latest
? {
id: latestVersion.id,
marked_comment: latestVersion.description ?? '',
marked_name: latestVersion.name,
version: latestVersion.name,
}
: {
id: 'sprint-42',
marked_comment: '',
marked_name: 'Sprint-42',
version: 'Sprint-42',
}
return {
access: {
enable_api: true,
enable_site: true,
},
deployment: {
current_version: deployed ? currentVersion : undefined,
deployed_at: Math.floor(Date.now() / 1000),
deployed_by: {
display_name: 'Evan',
id: 'user-1',
type: OperatorType.OPERATOR_TYPE_ACCOUNT,
},
status,
versions_behind: latest ? 0 : 1,
},
environment: {
description: '',
display_name: 'Staging',
id: 'staging',
status: EnvironmentStatus.ENVIRONMENT_STATUS_READY,
},
}
}
function createFlowQueryClient(environmentId: string, environmentInUse = false) {
const queryClient = createConsoleQueryClient()
PUBLISHED_WORKFLOW_VERSIONS.forEach((workflow) => {
const precheckQuery =
consoleQuery.enterprise.appDeploy.deploymentService.precheckWorkflowDeployment.queryOptions({
input: {
params: {
app_id: 'app-1',
workflow_id: workflow.id,
},
},
retry: false,
})
const deploymentOptionsQuery =
consoleQuery.enterprise.appDeploy.deploymentService.getWorkflowDeploymentOptions.queryOptions(
{
input: {
params: {
app_id: 'app-1',
environment_id: environmentId,
workflow_id: workflow.id,
},
},
retry: false,
},
)
queryClient.setQueryDefaults(precheckQuery.queryKey, { staleTime: Infinity })
queryClient.setQueryData(precheckQuery.queryKey, {
unsupported_nodes: [],
})
queryClient.setQueryDefaults(deploymentOptionsQuery.queryKey, { staleTime: Infinity })
queryClient.setQueryData(deploymentOptionsQuery.queryKey, {
credential_slots: [],
environment_variable_slots: [],
})
})
seedPublishedWorkflowQueries(queryClient)
const appEnvironmentsQuery =
consoleQuery.enterprise.appDeploy.deploymentService.listAppEnvironments.queryOptions({
enabled: true,
input: {
params: {
app_id: 'app-1',
},
},
})
queryClient.setQueryData(appEnvironmentsQuery.queryKey, {
data: [
{
description: '',
display_name: 'Staging',
id: 'staging',
in_use: environmentInUse,
status: EnvironmentStatus.ENVIRONMENT_STATUS_READY,
},
],
})
return queryClient
}
function seedPublishedWorkflowQueries(queryClient: QueryClient) {
const latestPublishedWorkflowQuery = consoleQuery.apps.byAppId.workflows.publish.get.queryOptions(
{
input: {
params: {
app_id: 'app-1',
},
},
},
)
const workflowVersionsQuery = consoleQuery.apps.byAppId.workflows.get.infiniteOptions({
input: (pageParam) => ({
params: {
app_id: 'app-1',
},
query: {
limit: 10,
page: Number(pageParam),
},
}),
getNextPageParam: (lastPage) => (lastPage.has_more ? lastPage.page + 1 : undefined),
initialPageParam: 1,
})
queryClient.setQueryData(latestPublishedWorkflowQuery.queryKey, latestPublishedWorkflow)
queryClient.setQueryData(workflowVersionsQuery.queryKey, {
pageParams: [1],
pages: [
{
has_more: false,
items: PUBLISHED_WORKFLOW_VERSIONS,
limit: 10,
page: 1,
},
],
})
}
function renderFlow(
deployment = createDeployment(),
{ isDeploymentError = false }: { isDeploymentError?: boolean } = {},
) {
const queryClient = createFlowQueryClient(deployment.environment.id)
return render(
<PublisherEnvironmentFlow
appId="app-1"
deployment={deployment}
environmentId={deployment.environment.id}
environmentName={deployment.environment.display_name}
environmentTabs={<div>Environment tabs</div>}
isEnvironmentInUse
isDeploymentError={isDeploymentError}
isDeploymentLoading={false}
latestVersion={latestVersion}
onGoToPublish={vi.fn()}
/>,
{ queryClient },
)
}
function PublisherPollingObserver() {
useRefreshAppEnvironmentsAfterPublisherDeploymentPolling('app-1')
useAtomValue(appPublisherEnvironmentsAtom)
const polling = useAtomValue(publisherEnvironmentDeploymentPollingAtom)
const selectEnvironment = useSetAtom(selectedPublisherEnvironmentIdAtom)
useEffect(() => {
selectEnvironment('staging')
}, [selectEnvironment])
return <div>{`Polling: ${polling?.operationId ?? 'none'}`}</div>
}
function renderFlowWithPolling(deployment = createDeployment()) {
const queryClient = createFlowQueryClient(deployment.environment.id, true)
const store = createStore()
store.set(queryClientAtom, queryClient)
store.set(appPublisherOpenAtom, true)
return render(
<Provider store={store}>
<AppPublisherStateBoundary appId="app-1" environmentQueryEnabled>
<PublisherPollingObserver />
<PublisherEnvironmentFlow
appId="app-1"
deployment={deployment}
environmentId={deployment.environment.id}
environmentName={deployment.environment.display_name}
environmentTabs={<div>Environment tabs</div>}
isEnvironmentInUse
isDeploymentError={false}
isDeploymentLoading={false}
latestVersion={latestVersion}
onGoToPublish={vi.fn()}
/>
</AppPublisherStateBoundary>
</Provider>,
{ queryClient },
)
}
function captureDeploymentRequests() {
const requests: Request[] = []
let deploymentSubmitted = false
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push(request.clone())
if (request.url.includes('/deployment:deploy')) {
deploymentSubmitted = true
return new Response(
JSON.stringify({
operation: {
id: 'operation-staging',
status: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS,
type: DeploymentOperationType.DEPLOYMENT_OPERATION_TYPE_DEPLOY,
},
}),
{
headers: { 'Content-Type': 'application/json' },
status: 200,
},
)
}
if (request.url.includes('/workflows/environment-deployments/staging')) {
const currentDeployment = createDeployment({ latest: deploymentSubmitted })
return new Response(
JSON.stringify({
environment_deployment: {
...currentDeployment,
deployment: {
...currentDeployment.deployment,
...(deploymentSubmitted && {
latest_operation: {
activity_at: 1_785_456_000,
id: 'operation-staging',
operator: {
display_name: 'Evan',
id: 'user-1',
type: OperatorType.OPERATOR_TYPE_ACCOUNT,
},
status: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_SUCCEEDED,
target_version: {
id: latestVersion.id,
marked_comment: latestVersion.description ?? '',
marked_name: latestVersion.name,
version: latestVersion.name,
},
type: DeploymentOperationType.DEPLOYMENT_OPERATION_TYPE_DEPLOY,
},
}),
},
},
}),
{
headers: { 'Content-Type': 'application/json' },
status: 200,
},
)
}
if (new URL(request.url).pathname.endsWith('/enterprise/app-deploy/apps/app-1/environments')) {
return new Response(
JSON.stringify({
data: [
{
description: '',
display_name: 'Staging',
id: 'staging',
in_use: true,
status: EnvironmentStatus.ENVIRONMENT_STATUS_READY,
},
],
}),
{
headers: { 'Content-Type': 'application/json' },
status: 200,
},
)
}
throw new Error(`Unexpected request: ${request.method} ${request.url}`)
})
return requests
}
async function expectDeploymentRequest(
requests: Request[],
workflowId: string,
environmentId = 'staging',
) {
await waitFor(() => {
expect(requests.some((request) => request.url.includes('/deployment:deploy'))).toBe(true)
})
const deployRequest = requests.find((request) => request.url.includes('/deployment:deploy'))
if (!deployRequest) throw new Error('Expected the workflow deployment request.')
expect(deployRequest.method).toBe('POST')
expect(new URL(deployRequest.url).pathname).toBe(
`/console/api/enterprise/app-deploy/apps/app-1/workflows/${workflowId}/environments/${environmentId}/deployment:deploy`,
)
expect(await deployRequest.json()).toEqual({
credentials: [],
environment_variables: [],
})
}
describe('PublisherEnvironmentFlow', () => {
it('formats deployed_at as a Unix timestamp in seconds', () => {
const deployment = createDeployment()
const deployedAt = deployment.deployment?.deployed_at
if (deployedAt === undefined) throw new Error('Expected a deployed environment fixture')
renderFlow(deployment)
expect(screen.getByText(`Published relative:${deployedAt * 1000} by Evan`)).toBeInTheDocument()
})
it('shows the publish action when an undeployed environment has no published versions', async () => {
const user = userEvent.setup()
const onGoToPublish = vi.fn()
render(
<PublisherEnvironmentFlow
appId="app-1"
environmentId="development"
environmentName="Development"
environmentTabs={<div>Environment tabs</div>}
isEnvironmentInUse={false}
isDeploymentError={false}
isDeploymentLoading={false}
latestVersion={null}
onGoToPublish={onGoToPublish}
/>,
)
expect(screen.getByText('No published versions yet')).toBeInTheDocument()
expect(
screen.getByText('Publish the app before deploying it to an environment.'),
).toBeInTheDocument()
expect(screen.queryByText('Not deployed yet')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Deploy latest' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'All versions' })).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Go to publish' }))
expect(onGoToPublish).toHaveBeenCalledOnce()
})
it('offers the latest version actions when an undeployed environment has a published version', async () => {
const user = userEvent.setup()
const queryClient = createFlowQueryClient('development')
render(
<PublisherEnvironmentFlow
appId="app-1"
environmentId="development"
environmentName="Development"
environmentTabs={<div>Environment tabs</div>}
isEnvironmentInUse={false}
isDeploymentError={false}
isDeploymentLoading={false}
latestVersion={latestVersion}
onGoToPublish={vi.fn()}
/>,
{ queryClient },
)
expect(screen.getByText('Not deployed yet')).toBeInTheDocument()
expect(
screen.queryByText('Publish the app before deploying it to an environment.'),
).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Go to publish' })).not.toBeInTheDocument()
expect(screen.getByText('Latest').parentElement).toHaveTextContent(
`Latest: ${latestVersion.name}`,
)
await user.click(screen.getByRole('button', { name: 'All versions' }))
expect(screen.getByRole('heading', { name: 'Deploy to Development' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Release 6/ })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /#5/ })).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Back' }))
await user.click(screen.getByRole('button', { name: 'Deploy latest' }))
expect(screen.getByRole('heading', { name: 'Deploy configuration' })).toBeInTheDocument()
expect(screen.getByText(latestVersion.name)).toBeInTheDocument()
})
it('opens all versions and returns to the original publisher', async () => {
const user = userEvent.setup()
renderFlow()
await user.click(screen.getByRole('button', { name: 'All versions' }))
expect(screen.getByRole('heading', { name: 'Deploy to Staging' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Back' }))
expect(screen.getByText('Environment tabs')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'All versions' })).toBeInTheDocument()
})
it.each([
DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING,
DeploymentStatus.DEPLOYMENT_STATUS_UNDEPLOYING,
])(
'disables deployment triggers but keeps environment navigation available while the status is %s',
(status) => {
renderFlow(createDeployment({ deployed: false, status }))
const deployButtonName =
status === DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING ? 'Deploying...' : 'Deploy latest'
expect(screen.getByRole('button', { name: deployButtonName })).toBeDisabled()
expect(screen.getByRole('button', { name: 'All versions' })).toBeDisabled()
expect(screen.getByRole('link', { name: 'Access Point' })).toHaveAttribute(
'href',
'/app/app-1/access-point?environment=staging',
)
expect(screen.getByRole('link', { name: 'Deploy' })).toHaveAttribute(
'href',
'/app/app-1/deploy?environment=staging',
)
},
)
it('keeps the deployment target and progress controls when a deploying status refresh fails', () => {
const deployment = createDeployment({
status: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING,
})
deployment.deployment!.latest_operation = {
activity_at: 1_785_456_000,
id: 'operation-staging',
operator: {
display_name: 'Evan',
id: 'user-1',
type: OperatorType.OPERATOR_TYPE_ACCOUNT,
},
status: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS,
target_version: {
id: latestVersion.id,
marked_comment: latestVersion.description ?? '',
marked_name: latestVersion.name,
version: latestVersion.name,
},
type: DeploymentOperationType.DEPLOYMENT_OPERATION_TYPE_DEPLOY,
}
renderFlow(deployment, { isDeploymentError: true })
expect(screen.getByRole('button', { name: 'Deploying...' })).toBeDisabled()
expect(screen.getByText(`Deploying: ${latestVersion.name}`)).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'All versions' })).toBeDisabled()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Deploy latest' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Deploy another version' })).not.toBeInTheDocument()
})
it('keeps current and latest version information when a status refresh fails outside deployment', () => {
renderFlow(createDeployment(), { isDeploymentError: true })
expect(screen.getByText('Sprint-42')).toBeInTheDocument()
expect(screen.getByText('Latest').parentElement).toHaveTextContent(
`Latest: ${latestVersion.name}`,
)
expect(screen.getByRole('button', { name: 'Deploy latest' })).toBeEnabled()
expect(screen.queryByText('Deploying...')).not.toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it.each([
DeploymentStatus.DEPLOYMENT_STATUS_UNDEPLOYED,
DeploymentStatus.DEPLOYMENT_STATUS_FAILED,
])('shows the undeployed state when terminal status %s has no current version', (status) => {
renderFlow(createDeployment({ deployed: false, status }))
expect(screen.getByText('Not deployed yet')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Deploy latest' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'All versions' })).toBeEnabled()
})
it('deploys the latest version directly and goes back to version selection', async () => {
const user = userEvent.setup()
renderFlow()
await user.click(screen.getByRole('button', { name: 'Deploy latest' }))
expect(screen.getByRole('heading', { name: 'Deploy configuration' })).toBeInTheDocument()
expect(screen.getByText(latestVersion.name)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Back' }))
expect(screen.getByRole('heading', { name: 'Deploy to Staging' })).toBeInTheDocument()
})
it('hides the environment variables section when deployment options have no slots', async () => {
const user = userEvent.setup()
renderFlow()
await user.click(screen.getByRole('button', { name: 'Deploy latest' }))
expect(screen.getByRole('button', { name: 'Deploy' })).toBeEnabled()
expect(screen.queryByRole('heading', { name: 'Environment Variables' })).not.toBeInTheDocument()
})
it('hides the credentials section when deployment options only have environment variables', async () => {
const user = userEvent.setup()
const view = renderFlow()
const deploymentOptionsQuery =
consoleQuery.enterprise.appDeploy.deploymentService.getWorkflowDeploymentOptions.queryOptions(
{
input: {
params: {
app_id: 'app-1',
environment_id: 'staging',
workflow_id: latestVersion.id,
},
},
retry: false,
},
)
view.queryClient.setQueryData(deploymentOptionsQuery.queryKey, {
credential_slots: [],
environment_variable_slots: [
{
configured_value: 'production',
description: '',
has_configured_value: true,
has_last_deployed_value: false,
key: 'ENVIRONMENT',
value_type: EnvVarValueType.ENV_VAR_VALUE_TYPE_STRING,
},
],
})
await user.click(screen.getByRole('button', { name: 'Deploy latest' }))
expect(screen.queryByRole('heading', { name: 'Credentials' })).not.toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Environment Variables' })).toBeInTheDocument()
})
it('shows unsupported node titles when the latest version fails precheck', async () => {
const user = userEvent.setup()
const view = renderFlow()
const precheckQuery =
consoleQuery.enterprise.appDeploy.deploymentService.precheckWorkflowDeployment.queryOptions({
input: {
params: {
app_id: 'app-1',
workflow_id: latestVersion.id,
},
},
retry: false,
})
view.queryClient.setQueryData(precheckQuery.queryKey, {
unsupported_nodes: [
{
id: 'knowledge-node',
title: 'Knowledge Retrieval',
type: 'knowledge-retrieval',
},
{
id: 'notion-node',
provider: {
plugin_id: 'langgenius/notion',
provider_id: 'notion',
provider_name: 'Notion',
provider_type: 'mcp',
},
title: 'Notion MCP',
type: 'tool',
},
],
})
await user.click(screen.getByRole('button', { name: 'Deploy latest' }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("This version can't be deployed to this environment")
expect(within(alert).getByText('Knowledge Retrieval')).toBeInTheDocument()
expect(within(alert).getByText('Notion MCP')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Deploy' })).toBeDisabled()
})
it('submits the latest version through the deployment API', async () => {
const user = userEvent.setup()
const requests = captureDeploymentRequests()
renderFlow()
await user.click(screen.getByRole('button', { name: 'Deploy latest' }))
await user.click(screen.getByRole('button', { name: 'Deploy' }))
await expectDeploymentRequest(requests, latestVersion.id)
expect(await screen.findByRole('button', { name: 'All versions' })).toBeInTheDocument()
})
it('polls the environment deployment after submit and refreshes environments on success', async () => {
const user = userEvent.setup()
const requests = captureDeploymentRequests()
renderFlowWithPolling()
await user.click(screen.getByRole('button', { name: 'Deploy latest' }))
await user.click(screen.getByRole('button', { name: 'Deploy' }))
await waitFor(() => {
expect(
requests.some(
(request) =>
request.method === 'GET' &&
new URL(request.url).pathname.endsWith(
'/enterprise/app-deploy/apps/app-1/workflows/environment-deployments/staging',
),
),
).toBe(true)
})
await waitFor(() => {
expect(
requests.some(
(request) =>
request.method === 'GET' &&
new URL(request.url).pathname.endsWith(
'/enterprise/app-deploy/apps/app-1/environments',
),
),
).toBe(true)
})
expect(screen.getByText('Polling: none')).toBeInTheDocument()
})
it('submits a version selected from all versions through the deployment API', async () => {
const user = userEvent.setup()
const requests = captureDeploymentRequests()
renderFlow()
await user.click(screen.getByRole('button', { name: 'All versions' }))
await user.click(screen.getByRole('button', { name: /Release 6/ }))
await user.click(screen.getByRole('button', { name: 'Deploy' }))
await expectDeploymentRequest(requests, 'workflow-version-6')
expect(await screen.findByRole('button', { name: 'All versions' })).toBeInTheDocument()
})
it('disables deploy latest and submits another selected version when already latest', async () => {
const user = userEvent.setup()
const requests = captureDeploymentRequests()
renderFlow(createDeployment({ latest: true }))
expect(screen.getByRole('button', { name: 'Deploy latest' })).toBeDisabled()
expect(screen.getByText('Latest')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'All versions' })).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Deploy another version' }))
await user.click(screen.getByRole('button', { name: /Sprint-35/ }))
expect(screen.getByRole('heading', { name: 'Deploy configuration' })).toBeInTheDocument()
expect(screen.getByText('Sprint-35')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Deploy' }))
await expectDeploymentRequest(requests, 'sprint-35')
expect(
await screen.findByRole('button', { name: 'Deploy another version' }),
).toBeInTheDocument()
})
})
@@ -0,0 +1,185 @@
import { screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { PublisherEnvironmentTabs } from '../environment-tabs'
import { BUILT_IN_ENVIRONMENT_ID } from '../state'
const environments = [
{ id: 'staging', name: 'Staging' },
{ id: 'pre-release', name: 'Pre-release' },
{ id: 'testing', name: 'Testing' },
{ id: 'demo', name: 'Demo' },
{ id: 'us-prod', name: 'US-Prod' },
] as const
vi.mock('react-i18next', async () => {
const { createReactI18nextMock } = await import('@/test/i18n-mock')
return createReactI18nextMock({
'common.operation.more': 'More',
'deployments.card.notDeployed': 'Not deployed',
'deployments.studio.environments': 'Environments',
'deployments.studio.moreEnvironments': 'More environments',
'workflow.nodes.common.memories.builtIn': 'Built-in',
})
})
function EnvironmentTabsHarness({
initialJoinedEnvironmentIds = [],
initialSelectedEnvironmentId = BUILT_IN_ENVIRONMENT_ID,
}: {
initialJoinedEnvironmentIds?: string[]
initialSelectedEnvironmentId?: string
}) {
const [joinedEnvironmentIds, setJoinedEnvironmentIds] = useState(initialJoinedEnvironmentIds)
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(initialSelectedEnvironmentId)
return (
<PublisherEnvironmentTabs
environments={environments}
joinedEnvironmentIds={joinedEnvironmentIds}
selectedEnvironmentId={selectedEnvironmentId}
onAddEnvironment={(environmentId) => {
setJoinedEnvironmentIds((currentEnvironmentIds) => [
...currentEnvironmentIds,
environmentId,
])
setSelectedEnvironmentId(environmentId)
}}
onSelectEnvironment={setSelectedEnvironmentId}
/>
)
}
describe('PublisherEnvironmentTabs', () => {
it('uses More environments as the only add entry, then adds and selects an environment', async () => {
const user = userEvent.setup()
render(<EnvironmentTabsHarness />)
const environmentGroup = screen.getByRole('group', { name: 'Environments' })
expect(within(environmentGroup).queryByRole('tab')).not.toBeInTheDocument()
expect(screen.queryByRole('tablist')).not.toBeInTheDocument()
expect(within(environmentGroup).getByRole('button', { name: 'Built-in' })).toHaveAttribute(
'aria-current',
'true',
)
await user.click(screen.getByRole('button', { name: 'More environments' }))
const menu = screen.getByRole('menu')
expect(within(menu).getByText('Not deployed')).toBeInTheDocument()
expect(
within(menu)
.getAllByRole('menuitem')
.map((item) => item.textContent),
).toEqual(['Staging', 'Pre-release', 'Testing', 'Demo', 'US-Prod'])
await user.click(within(menu).getByRole('menuitem', { name: 'Staging' }))
expect(screen.getByRole('button', { name: 'Staging' })).toHaveAttribute('aria-current', 'true')
expect(screen.getByRole('button', { name: 'Built-in' })).not.toHaveAttribute('aria-current')
expect(screen.getByRole('button', { name: 'More' })).toBeInTheDocument()
})
it('keeps fixed visible environments in place and marks a selected overflow environment', async () => {
const user = userEvent.setup()
render(
<EnvironmentTabsHarness
initialJoinedEnvironmentIds={['staging', 'pre-release', 'testing']}
/>,
)
const environmentGroup = screen.getByRole('group', { name: 'Environments' })
expect(
within(environmentGroup)
.getAllByRole('button')
.map((button) => button.textContent),
).toEqual(['Built-in', 'Staging', 'Pre-release', 'More'])
await user.click(screen.getByRole('button', { name: 'More' }))
await user.click(screen.getByRole('menuitem', { name: 'Testing' }))
expect(
within(environmentGroup)
.getAllByRole('button')
.map((button) => button.textContent),
).toEqual(['Built-in', 'Staging', 'Pre-release', 'Testing'])
expect(screen.getByRole('button', { name: 'Testing' })).toHaveAttribute('aria-current', 'true')
expect(
within(environmentGroup)
.getAllByRole('button')
.filter((button) => button.hasAttribute('aria-current'))
.map((button) => button.textContent),
).toEqual(['Testing'])
await user.click(screen.getByRole('button', { name: 'Testing' }))
const menu = screen.getByRole('menu')
expect(within(menu).queryByRole('menuitem', { name: 'Testing' })).not.toBeInTheDocument()
expect(
within(menu)
.getAllByRole('menuitem')
.map((item) => item.textContent),
).toEqual(['Demo', 'US-Prod'])
})
it('removes the not-deployed section when every environment has joined', async () => {
const user = userEvent.setup()
render(
<EnvironmentTabsHarness
initialJoinedEnvironmentIds={environments.map((environment) => environment.id)}
initialSelectedEnvironmentId="testing"
/>,
)
await user.click(screen.getByRole('button', { name: 'Testing' }))
const menu = screen.getByRole('menu')
expect(within(menu).queryByText('Not deployed')).not.toBeInTheDocument()
expect(
within(menu)
.getAllByRole('menuitem')
.map((item) => item.textContent),
).toEqual(['Demo', 'US-Prod'])
})
it('does not render More when every joined environment fits', () => {
render(
<PublisherEnvironmentTabs
environments={environments.slice(0, 2)}
joinedEnvironmentIds={['staging', 'pre-release']}
selectedEnvironmentId={BUILT_IN_ENVIRONMENT_ID}
onAddEnvironment={vi.fn()}
onSelectEnvironment={vi.fn()}
/>,
)
expect(screen.getAllByRole('button').map((button) => button.textContent)).toEqual([
'Built-in',
'Staging',
'Pre-release',
])
expect(screen.queryByRole('button', { name: 'More' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'More environments' })).not.toBeInTheDocument()
})
it('shows the full environment name in a tooltip when its button is truncated', async () => {
const user = userEvent.setup()
const longEnvironment = {
id: 'long-production',
name: 'Production environment with a very long name',
}
render(
<PublisherEnvironmentTabs
environments={[longEnvironment]}
joinedEnvironmentIds={[longEnvironment.id]}
selectedEnvironmentId={longEnvironment.id}
onAddEnvironment={vi.fn()}
onSelectEnvironment={vi.fn()}
/>,
)
await user.hover(screen.getByRole('button', { name: longEnvironment.name }))
expect(await screen.findByRole('tooltip')).toHaveTextContent(longEnvironment.name)
})
})
@@ -57,6 +57,7 @@ vi.mock('@/app/components/base/features/hooks', () => ({
}))
describe('FeaturesWrappedAppPublisher', () => {
const resetAppConfig = vi.fn()
const publishedConfig = {
modelConfig: {
more_like_this: { enabled: true },
@@ -81,7 +82,6 @@ describe('FeaturesWrappedAppPublisher', () => {
allowed_file_upload_methods: ['remote_url'],
number_limits: 5,
},
resetAppConfig: vi.fn(),
},
}
@@ -106,13 +106,18 @@ describe('FeaturesWrappedAppPublisher', () => {
})
it('should restore published features after confirmation', async () => {
render(<FeaturesWrappedAppPublisher publishedConfig={publishedConfig as any} />)
render(
<FeaturesWrappedAppPublisher
publishedConfig={publishedConfig as any}
resetAppConfig={resetAppConfig}
/>,
)
fireEvent.click(screen.getByText('restore-through-wrapper'))
fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.confirm(?=$|:)/ }))
await waitFor(() => {
expect(publishedConfig.modelConfig.resetAppConfig).toHaveBeenCalledTimes(1)
expect(resetAppConfig).toHaveBeenCalledTimes(1)
expect(mockSetFeatures).toHaveBeenCalledWith(
expect.objectContaining({
moreLikeThis: { enabled: true },
@@ -133,7 +138,12 @@ describe('FeaturesWrappedAppPublisher', () => {
})
it('should close restore confirmation without restoring when cancelled', async () => {
render(<FeaturesWrappedAppPublisher publishedConfig={publishedConfig as any} />)
render(
<FeaturesWrappedAppPublisher
publishedConfig={publishedConfig as any}
resetAppConfig={resetAppConfig}
/>,
)
fireEvent.click(screen.getByText('restore-through-wrapper'))
const dialog = screen.getByRole('alertdialog')
@@ -145,7 +155,7 @@ describe('FeaturesWrappedAppPublisher', () => {
await waitFor(() => {
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
})
expect(publishedConfig.modelConfig.resetAppConfig).not.toHaveBeenCalled()
expect(resetAppConfig).not.toHaveBeenCalled()
expect(mockSetFeatures).not.toHaveBeenCalled()
})
})
File diff suppressed because it is too large Load Diff
@@ -58,6 +58,27 @@ describe('PublishWithMultipleModel', () => {
expect(screen.queryByText(/(?:^|\.)publishAs(?=$|:)/)).not.toBeInTheDocument()
})
it('should disable the trigger when publishing is unavailable', () => {
render(
<PublishWithMultipleModel
disabled
multipleModelConfigs={[
{
id: 'config-1',
provider: 'openai',
model: 'gpt-4o',
parameters: {},
},
]}
onSelect={vi.fn()}
/>,
)
expect(
screen.getByRole('button', { name: /(?:^|\.)operation\.applyConfig(?=$|:)/ }),
).toBeDisabled()
})
it('should open matching model options and call onSelect', () => {
const handleSelect = vi.fn()
const modelConfig = {
@@ -1,65 +1,51 @@
/* oxlint-disable typescript/no-explicit-any */
import type { ReactNode } from 'react'
import { fireEvent, screen } from '@testing-library/react'
import type { VersionHistory } from '@/types/workflow'
import { fireEvent, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AccessMode } from '@/models/access-control'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { AppModeEnum } from '@/types/app'
import {
AccessModeDisplay,
PublisherAccessSection,
PublisherActionsSection,
PublisherSummarySection,
} from '../sections'
import { PublisherActionsSection } from '../built-in-publisher/actions-section'
import { PublisherSummarySection } from '../built-in-publisher/summary-section'
vi.mock('../publish-with-multiple-model', () => ({
default: ({ onSelect }: { onSelect: (item: Record<string, unknown>) => void }) => (
<button type="button" onClick={() => onSelect({ model: 'gpt-4o' })}>
default: ({
disabled,
onSelect,
}: {
disabled?: boolean
onSelect: (item: Record<string, unknown>) => void
}) => (
<button type="button" disabled={disabled} onClick={() => onSelect({ model: 'gpt-4o' })}>
publish-multiple-model
</button>
),
}))
vi.mock('../suggested-action', () => ({
default: ({
children,
onClick,
link,
disabled,
actionButton,
}: {
children: ReactNode
onClick?: () => void
link?: string
disabled?: boolean
actionButton?: { ariaLabel: string; onClick: () => void }
}) => (
<div>
<button type="button" data-link={link} disabled={disabled} onClick={onClick}>
{children}
</button>
{actionButton && (
<button
type="button"
aria-label={actionButton.ariaLabel}
disabled={disabled}
onClick={actionButton.onClick}
>
{actionButton.ariaLabel}
</button>
)}
</div>
),
}))
vi.mock('@/app/components/tools/workflow-tool/configure-button', () => ({
default: (props: Record<string, unknown>) => (
<div>
workflow-tool-configure
<span>{String(props.disabledReason || '')}</span>
</div>
),
}))
const createVersionInfo = (overrides: Partial<VersionHistory> = {}): VersionHistory => ({
id: 'workflow-version-1',
graph: {
nodes: [],
edges: [],
},
created_at: 1_710_000_000,
created_by: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
},
hash: 'hash-1',
updated_at: 1_710_000_000,
updated_by: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
},
tool_published: false,
version: '2024-03-09T16:00:00Z',
marked_name: '',
marked_comment: '',
...overrides,
})
describe('app-publisher sections', () => {
it('should render restore controls for published chat apps', () => {
@@ -84,35 +70,41 @@ describe('app-publisher sections', () => {
fireEvent.click(screen.getByText(/(?:^|\.)common\.restore(?=$|:)/))
expect(handleRestore).toHaveBeenCalled()
expect(screen.getByRole('status')).toHaveTextContent(/common\.currentDraft\b/)
})
it('should expose the access control warning and open access settings from the keyboard', async () => {
it('should disable publish and restore after publishing in the current open session', async () => {
const user = userEvent.setup()
const onClick = vi.fn()
const handleRestore = vi.fn()
render(
<PublisherAccessSection
enabled
isAppAccessSet={false}
isLoading={false}
accessMode={AccessMode.SPECIFIC_GROUPS_MEMBERS}
onClick={onClick}
<PublisherSummarySection
debugWithMultipleModel={false}
draftUpdatedAt={Date.now()}
formatTimeFromNow={() => '3 minutes ago'}
handlePublish={vi.fn()}
handleRestore={handleRestore}
isChatApp
multipleModelConfigs={[]}
publishDisabled={false}
published
publishedAt={Date.now()}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
/>,
)
expect(screen.getByText(/(?:^|\.)publishApp\.notSet(?=$|:)/)).toBeInTheDocument()
expect(screen.getByText(/(?:^|\.)publishApp\.notSetDesc(?=$|:)/)).toBeInTheDocument()
const accessButton = screen.getByRole('button', {
name: /accessControlDialog\.accessItems\.specific/,
const restoreButton = screen.getByRole('button', {
name: /(?:^|\.)common\.restore(?=$|:)/,
})
accessButton.focus()
await user.keyboard('{Enter}')
expect(onClick).toHaveBeenCalledOnce()
expect(restoreButton).toBeDisabled()
expect(screen.getByRole('button', { name: /common\.published\b/ })).toBeDisabled()
expect(screen.getByRole('status')).toHaveTextContent(/common\.upToDate\b/)
await user.click(restoreButton)
expect(handleRestore).not.toHaveBeenCalled()
})
it('should render the publish update action when the draft has not been published yet', () => {
it('should render the initial publish action when the draft has not been published yet', () => {
render(
<PublisherSummarySection
debugWithMultipleModel={false}
@@ -130,10 +122,114 @@ describe('app-publisher sections', () => {
/>,
)
expect(screen.getByText(/(?:^|\.)common\.publishUpdate(?=$|:)/)).toBeInTheDocument()
expect(screen.getByText(/(?:^|\.)common\.notPublishedYet(?=$|:)/)).toBeInTheDocument()
expect(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)).toBeInTheDocument()
expect(screen.getByText('P')).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent(/common\.currentDraft\b/)
})
it('should render multiple-model publishing', () => {
it('should expose naming and keep publishing available for an unnamed published workflow', () => {
const onEditVersion = vi.fn()
render(
<PublisherSummarySection
debugWithMultipleModel={false}
draftUpdatedAt={1_710_000_000_000}
formatTimeFromNow={() => '17 days ago'}
handlePublish={vi.fn()}
handleRestore={vi.fn()}
isChatApp={false}
isWorkflowApp
multipleModelConfigs={[]}
onEditVersion={onEditVersion}
publishDisabled={false}
published={false}
publishedAt={1_710_000_100_000}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
versionInfo={createVersionInfo({ version_number: 5 })}
/>,
)
expect(screen.getByText('# 5')).toBeInTheDocument()
expect(screen.queryByText('2024-03-09T16:00:00Z')).not.toBeInTheDocument()
const nameButton = screen.getByRole('button', {
name: /versionHistory\.nameIt\b/,
})
fireEvent.click(nameButton)
expect(onEditVersion).toHaveBeenCalledTimes(1)
const publishButton = screen.getByRole('button', { name: /common\.publishUpdate\b/ })
expect(publishButton).toBeEnabled()
expect(within(publishButton).getByText('P')).toBeInTheDocument()
expect(screen.getByText(/common\.autoSaved\b/)).toBeInTheDocument()
})
it('should show named workflow metadata and keep publish update available', () => {
const onEditVersion = vi.fn()
render(
<PublisherSummarySection
debugWithMultipleModel={false}
draftUpdatedAt={1_710_000_200_000}
formatTimeFromNow={() => '2 minutes ago'}
handlePublish={vi.fn()}
handleRestore={vi.fn()}
isChatApp={false}
isWorkflowApp
multipleModelConfigs={[]}
onEditVersion={onEditVersion}
publishDisabled={false}
published={false}
publishedAt={1_710_000_100_000}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
versionInfo={createVersionInfo({
marked_name: 'Sprint-42',
marked_comment: 'Fixed data synchronization and page loading.',
})}
/>,
)
expect(screen.getByText('Sprint-42')).toBeInTheDocument()
expect(screen.getByText('Fixed data synchronization and page loading.')).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', {
name: /versionHistory\.editVersionInfo\b/,
}),
)
expect(onEditVersion).toHaveBeenCalledTimes(1)
expect(screen.getByRole('button', { name: /common\.publishUpdate\b/ })).toBeEnabled()
expect(screen.getByText(/common\.autoSaved\b/)).toBeInTheDocument()
expect(screen.getAllByText(/2 minutes ago/)).not.toHaveLength(0)
})
it('should keep non-workflow apps free of workflow version details and saved time', () => {
render(
<PublisherSummarySection
debugWithMultipleModel={false}
draftUpdatedAt={1_710_000_200_000}
formatTimeFromNow={() => '2 minutes ago'}
handlePublish={vi.fn()}
handleRestore={vi.fn()}
isChatApp
isWorkflowApp={false}
multipleModelConfigs={[]}
publishDisabled={false}
published={false}
publishedAt={1_710_000_100_000}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
/>,
)
expect(screen.getAllByText(/common\.latestPublished\b/)).toHaveLength(1)
expect(screen.queryByText('#5')).not.toBeInTheDocument()
expect(screen.queryByText(/versionHistory\.nameIt\b/)).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /common\.publishUpdate\b/ })).toBeEnabled()
expect(screen.getByRole('status')).toHaveTextContent(/common\.currentDraft\b/)
})
it('should keep multiple-model publishing available without publish config changes', () => {
const handlePublish = vi.fn()
render(
@@ -147,7 +243,7 @@ describe('app-publisher sections', () => {
multipleModelConfigs={[{ id: '1' } as any]}
publishDisabled={false}
published={false}
publishedAt={undefined}
publishedAt={Date.now()}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
/>,
@@ -158,6 +254,27 @@ describe('app-publisher sections', () => {
expect(handlePublish).toHaveBeenCalledWith({ model: 'gpt-4o' })
})
it('should disable multiple-model publishing when publishing is unavailable', () => {
render(
<PublisherSummarySection
debugWithMultipleModel
draftUpdatedAt={Date.now()}
formatTimeFromNow={() => '1 minute ago'}
handlePublish={vi.fn()}
handleRestore={vi.fn()}
isChatApp={false}
multipleModelConfigs={[{ id: '1' } as any]}
publishDisabled
published={false}
publishedAt={Date.now()}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
/>,
)
expect(screen.getByRole('button', { name: 'publish-multiple-model' })).toBeDisabled()
})
it('should render the upgrade hint when the start node limit is exceeded', () => {
render(
<PublisherSummarySection
@@ -179,58 +296,13 @@ describe('app-publisher sections', () => {
expect(screen.getByText(/(?:^|\.)publishLimit\.startNodeDesc(?=$|:)/)).toBeInTheDocument()
})
it('should render loading access state and access mode labels when enabled', () => {
const { rerender } = render(
<PublisherAccessSection
enabled
isAppAccessSet
isLoading
accessMode={AccessMode.PUBLIC}
onClick={vi.fn()}
/>,
)
expect(document.querySelector('.spin-animation')).toBeInTheDocument()
rerender(
<PublisherAccessSection
enabled
isAppAccessSet
isLoading={false}
accessMode={AccessMode.PUBLIC}
onClick={vi.fn()}
/>,
)
expect(
screen.getByText(/(?:^|\.)accessControlDialog\.accessItems\.anyone(?=$|:)/),
).toBeInTheDocument()
expect(render(<AccessModeDisplay />).container).toBeEmptyDOMElement()
})
it('should hide access control content when enabled is false', () => {
render(
<PublisherAccessSection
enabled={false}
isAppAccessSet
isLoading={false}
accessMode={AccessMode.PUBLIC}
onClick={vi.fn()}
/>,
)
expect(screen.queryByText(/(?:^|\.)publishApp\.title(?=$|:)/)).not.toBeInTheDocument()
expect(
screen.queryByText(/(?:^|\.)accessControlDialog\.accessItems\.anyone(?=$|:)/),
).not.toBeInTheDocument()
})
it('should render workflow actions, batch run links, and workflow tool configuration', () => {
const handleOpenInExplore = vi.fn()
const handleEmbed = vi.fn()
it('should render the published workflow actions with Workflow as Tool after Marketplace', async () => {
const user = userEvent.setup()
const handleOpenRunConfig = vi.fn()
const onConfigureWorkflowTool = vi.fn()
const onPublishToMarketplace = vi.fn()
const { rerender } = render(
render(
<PublisherActionsSection
appDetail={{
id: 'workflow-app',
@@ -244,92 +316,280 @@ describe('app-publisher sections', () => {
appURL="https://example.com/app"
disabledFunctionButton={false}
disabledFunctionTooltip="disabled"
handleEmbed={handleEmbed}
handleOpenInExplore={handleOpenInExplore}
handleOpenRunConfig={handleOpenRunConfig}
handlePublish={vi.fn()}
hasHumanInputNode={false}
hasTriggerNode={false}
missingStartNode={false}
published={false}
publishedAt={Date.now()}
showBatchRunConfig
showDeployAction
showMarketplaceAction
showRunConfig
toolPublished
workflowToolAvailable={false}
workflowToolAvailable
workflowToolIsLoading={false}
workflowToolOutdated={false}
workflowToolMessage="workflow-disabled"
onConfigureWorkflowTool={vi.fn()}
onPublishToMarketplace={onPublishToMarketplace}
onConfigureWorkflowTool={onConfigureWorkflowTool}
/>,
)
expect(screen.getByText(/(?:^|\.)common\.batchRunApp(?=$|:)/)).toHaveAttribute(
'data-link',
'https://example.com/app?mode=batch',
expect(screen.getByRole('link', { name: /common\.openWebApp\b/ })).toHaveAttribute(
'href',
'https://example.com/app',
)
fireEvent.click(screen.getAllByRole('button', { name: /(?:^|\.)operation\.config(?=$|:)/ })[0]!)
fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.config(?=$|:)/ }))
expect(handleOpenRunConfig).toHaveBeenCalledWith('https://example.com/app')
fireEvent.click(screen.getAllByRole('button', { name: /(?:^|\.)operation\.config(?=$|:)/ })[1]!)
expect(handleOpenRunConfig).toHaveBeenCalledWith('https://example.com/app?mode=batch')
fireEvent.click(screen.getByText(/(?:^|\.)common\.openInExplore(?=$|:)/))
expect(handleOpenInExplore).toHaveBeenCalled()
expect(screen.getByText('workflow-tool-configure')).toBeInTheDocument()
expect(screen.getByText('workflow-disabled')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /appMenus\.accessPoint\b/ })).toHaveAttribute(
'href',
'/app/workflow-app/access-point',
)
expect(screen.getByRole('link', { name: /appMenus\.deploy\b/ })).toHaveAttribute(
'href',
'/app/workflow-app/deploy',
)
rerender(
const marketplaceAction = screen.getByRole('button', {
name: /common\.publishToMarketplace\b/,
})
const workflowToolAction = screen.getByRole('button', {
name: /common\.workflowAsTool\b/,
})
expect(
marketplaceAction.compareDocumentPosition(workflowToolAction) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
expect(screen.getByRole('status', { name: /common\.configureRequired\b/ })).toBeInTheDocument()
await user.click(marketplaceAction)
expect(onPublishToMarketplace).toHaveBeenCalledTimes(1)
await user.click(workflowToolAction)
expect(onConfigureWorkflowTool).toHaveBeenCalledTimes(1)
})
it('should expose Configure and Manage in Tools actions for a ready workflow tool', async () => {
const user = userEvent.setup()
const onConfigureWorkflowTool = vi.fn()
render(
<PublisherActionsSection
appDetail={{
id: 'chat-app',
mode: AppModeEnum.CHAT,
name: 'Chat App',
id: 'workflow-app',
mode: AppModeEnum.WORKFLOW,
name: 'Workflow App',
}}
appURL="https://example.com/app?foo=bar"
disabledFunctionButton
disabledFunctionTooltip="disabled"
handleEmbed={handleEmbed}
handleOpenInExplore={handleOpenInExplore}
handleOpenRunConfig={handleOpenRunConfig}
handlePublish={vi.fn()}
appURL="https://example.com/app"
disabledFunctionButton={false}
hasHumanInputNode={false}
hasTriggerNode={false}
missingStartNode
published={false}
publishedAt={Date.now()}
toolPublished={false}
showDeployAction
toolPublished
workflowToolAvailable
workflowToolIsLoading={false}
workflowToolOutdated={false}
onConfigureWorkflowTool={vi.fn()}
onConfigureWorkflowTool={onConfigureWorkflowTool}
/>,
)
fireEvent.click(screen.getByText(/(?:^|\.)common\.embedIntoSite(?=$|:)/))
expect(handleEmbed).toHaveBeenCalled()
expect(screen.getByText(/(?:^|\.)common\.accessAPIReference(?=$|:)/)).toBeDisabled()
expect(
screen.getByRole('status', { name: /common\.workflowAsToolReady\b/ }),
).toBeInTheDocument()
expect(screen.getByRole('link', { name: /common\.manageInTools\b/ })).toHaveAttribute(
'href',
'/integrations/tools/workflow',
)
await user.click(screen.getByRole('button', { name: /common\.configure\b/ }))
expect(onConfigureWorkflowTool).toHaveBeenCalledTimes(1)
})
it('should show the disabled reason below setup and configured workflow tool actions', () => {
const commonProps = {
appDetail: {
id: 'workflow-app',
mode: AppModeEnum.WORKFLOW,
},
appURL: 'https://example.com/app',
disabledFunctionButton: false,
hasHumanInputNode: false,
hasTriggerNode: false,
onConfigureWorkflowTool: vi.fn(),
publishedAt: Date.now(),
workflowToolAvailable: false,
workflowToolIsLoading: false,
workflowToolMessage: 'Workflow tool unavailable',
}
const { rerender } = render(<PublisherActionsSection {...commonProps} toolPublished={false} />)
const setupAction = screen.getByRole('button', { name: /common\.workflowAsTool\b/ })
const setupReason = screen.getByText('Workflow tool unavailable')
expect(setupAction).toBeDisabled()
expect(setupReason).toBeVisible()
expect(
setupAction.compareDocumentPosition(setupReason) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
rerender(<PublisherActionsSection {...commonProps} toolPublished />)
const configureAction = screen.getByRole('button', { name: /common\.configure\b/ })
const manageAction = screen.getByRole('button', { name: /common\.manageInTools\b/ })
const configuredReason = screen.getByText('Workflow tool unavailable')
expect(configureAction).toBeDisabled()
expect(manageAction).toBeDisabled()
expect(configuredReason).toBeVisible()
expect(
manageAction.compareDocumentPosition(configuredReason) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
})
it('should surface update-needed and loading states for a configured workflow tool', async () => {
const user = userEvent.setup()
const onConfigureWorkflowTool = vi.fn()
const commonProps = {
appDetail: {
id: 'workflow-app',
mode: AppModeEnum.WORKFLOW,
},
appURL: 'https://example.com/app',
disabledFunctionButton: false,
hasHumanInputNode: false,
hasTriggerNode: false,
onConfigureWorkflowTool,
publishedAt: Date.now(),
toolPublished: true,
workflowToolAvailable: true,
}
const { rerender } = render(
<PublisherActionsSection
{...commonProps}
workflowToolIsLoading={false}
workflowToolOutdated
/>,
)
expect(
screen.getByRole('status', { name: /common\.workflowAsToolUpdateNeeded\b/ }),
).toBeInTheDocument()
expect(screen.getByText(/common\.workflowAsToolTip\b/)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /common\.workflowAsToolReconfigure\b/ }))
expect(onConfigureWorkflowTool).toHaveBeenCalledTimes(1)
rerender(
<PublisherActionsSection
appDetail={{ id: 'trigger-app', mode: AppModeEnum.WORKFLOW }}
{...commonProps}
workflowToolIsLoading
workflowToolOutdated={false}
/>,
)
expect(screen.getByRole('button', { name: /common\.workflowAsTool\b/ })).toBeDisabled()
expect(screen.getByRole('status', { name: /loading\b/ })).toBeInTheDocument()
expect(screen.queryByText(/common\.workflowAsToolTip\b/)).not.toBeInTheDocument()
})
it('should keep Access Point and Deploy available for trigger workflows', () => {
render(
<PublisherActionsSection
appDetail={{
id: 'trigger-app',
mode: AppModeEnum.WORKFLOW,
}}
appURL="https://example.com/app"
disabledFunctionButton={false}
handleEmbed={handleEmbed}
handleOpenInExplore={handleOpenInExplore}
handleOpenRunConfig={handleOpenRunConfig}
handlePublish={vi.fn()}
hasHumanInputNode={false}
hasTriggerNode
missingStartNode={false}
published={false}
publishedAt={undefined}
toolPublished={false}
publishedAt={Date.now()}
showDeployAction
workflowToolAvailable
workflowToolIsLoading={false}
workflowToolOutdated={false}
onConfigureWorkflowTool={vi.fn()}
/>,
)
expect(screen.queryByText(/(?:^|\.)common\.runApp(?=$|:)/)).not.toBeInTheDocument()
expect(screen.queryByText(/(?:^|\.)common\.openWebApp(?=$|:)/)).not.toBeInTheDocument()
expect(screen.queryByText(/(?:^|\.)common\.workflowAsTool(?=$|:)/)).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: /appMenus\.accessPoint\b/ })).toHaveAttribute(
'href',
'/app/trigger-app/access-point',
)
expect(screen.getByRole('link', { name: /appMenus\.deploy\b/ })).toHaveAttribute(
'href',
'/app/trigger-app/deploy',
)
})
it('should expose unavailable quick links as disabled buttons before the first publish', () => {
render(
<PublisherActionsSection
appDetail={{ id: 'workflow-app', mode: AppModeEnum.WORKFLOW }}
appURL="https://example.com/app"
disabledFunctionButton
hasHumanInputNode={false}
hasTriggerNode={false}
publishedAt={undefined}
showDeployAction
workflowToolAvailable
workflowToolIsLoading={false}
onConfigureWorkflowTool={vi.fn()}
/>,
)
expect(screen.getByText(/(?:^|\.)common\.openWebApp(?=$|:)/).closest('button')).toBeDisabled()
expect(
screen.getByText(/(?:^|\.)appMenus\.accessPoint(?=$|:)/).closest('button'),
).toBeDisabled()
expect(screen.getByText(/(?:^|\.)appMenus\.deploy(?=$|:)/).closest('button')).toBeDisabled()
expect(
screen.getByText(/(?:^|\.)common\.workflowAsTool(?=$|:)/).closest('button'),
).toBeDisabled()
})
it('should show the disabled reason when hovering an unavailable action', async () => {
const user = userEvent.setup()
render(
<PublisherActionsSection
appDetail={{ id: 'workflow-app', mode: AppModeEnum.WORKFLOW }}
appURL="https://example.com/app"
disabledFunctionButton
disabledFunctionTooltip="Open web app unavailable"
hasHumanInputNode={false}
hasTriggerNode={false}
publishedAt={undefined}
workflowToolAvailable
workflowToolIsLoading={false}
onConfigureWorkflowTool={vi.fn()}
/>,
)
await user.hover(screen.getByRole('button', { name: /common\.openWebApp\b/ }))
expect(await screen.findByRole('tooltip')).toHaveTextContent('Open web app unavailable')
})
it('should keep an unavailable action with a tooltip keyboard focusable', async () => {
const user = userEvent.setup()
render(
<PublisherActionsSection
appDetail={{ id: 'workflow-app', mode: AppModeEnum.WORKFLOW }}
appURL="https://example.com/app"
disabledFunctionButton
disabledFunctionTooltip="Open web app unavailable"
hasHumanInputNode={false}
hasTriggerNode={false}
publishedAt={undefined}
workflowToolAvailable
workflowToolIsLoading={false}
onConfigureWorkflowTool={vi.fn()}
/>,
)
await user.tab()
const action = screen.getByRole('button', { name: /common\.openWebApp\b/ })
expect(action).toHaveFocus()
expect(action).toHaveAttribute('aria-disabled', 'true')
expect(action).toHaveAccessibleDescription('Open web app unavailable')
expect(await screen.findByRole('tooltip')).toHaveTextContent('Open web app unavailable')
})
})
@@ -0,0 +1,635 @@
import {
DeploymentOperationStatus,
DeploymentStatus,
} from '@dify/contracts/enterprise-app-deploy/types.gen'
import { QueryClientProvider } from '@tanstack/react-query'
import { screen, render as testingLibraryRender, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Provider, useAtomValue, useSetAtom } from 'jotai'
import { createQueryAtomTestStore } from '@/test/query-atom'
import { useRefreshAppEnvironmentsAfterPublisherDeploymentPolling } from '../hooks/use-refresh-app-environments-after-deployment-polling'
import {
addPublisherEnvironmentAtom,
appPublisherEnvironmentsAtom,
appPublisherOpenAtom,
AppPublisherStateBoundary,
joinedPublisherEnvironmentIdsAtom,
publisherEnvironmentDeploymentPollingAtom,
selectedEnvironmentDeploymentAtom,
selectedPublisherEnvironmentIdAtom,
startPublisherEnvironmentDeploymentPollingAtom,
} from '../state'
type QueryOptions = {
enabled?: boolean
input: unknown
refetchInterval?: (query: {
state: {
data?: {
environment_deployment: {
deployment?: {
latest_operation?: {
id: string
status: string
}
status: string
}
}
}
fetchFailureCount?: number
status?: 'error' | 'pending' | 'success'
}
}) => false | number
}
const queryMocks = vi.hoisted(() => ({
deploymentListOptions: vi.fn(),
deploymentListRequest: vi.fn(),
deploymentOptions: vi.fn(),
deploymentRequest: vi.fn(),
environmentOptions: vi.fn(),
environmentRequest: vi.fn(),
}))
vi.mock('@/service/client', async () => {
const { skipToken } = await import('@tanstack/react-query')
return {
consoleQuery: {
enterprise: {
appDeploy: {
deploymentService: {
getEnvironmentDeployment: {
queryOptions: (options: QueryOptions) => {
queryMocks.deploymentOptions(options)
const environmentId =
typeof options.input === 'object' && options.input
? (options.input as { params: { environment_id: string } }).params
.environment_id
: 'disabled'
return {
...options,
queryFn:
options.input === skipToken
? skipToken
: () => queryMocks.deploymentRequest(options.input),
queryKey: ['publisherEnvironmentDeployment', environmentId],
}
},
},
listEnvironmentDeployments: {
queryOptions: (options: QueryOptions) => {
queryMocks.deploymentListOptions(options)
return {
...options,
queryFn:
options.input === skipToken
? skipToken
: () => queryMocks.deploymentListRequest(options.input),
queryKey: ['publisherEnvironmentDeployments'],
}
},
},
listAppEnvironments: {
queryOptions: (options: QueryOptions) => {
queryMocks.environmentOptions(options)
return {
...options,
queryFn: () => queryMocks.environmentRequest(),
queryKey: ['publisherEnvironments'],
}
},
},
},
},
},
},
}
})
function StateConsumer() {
useRefreshAppEnvironmentsAfterPublisherDeploymentPolling('app-1')
const environments = useAtomValue(appPublisherEnvironmentsAtom)
const open = useAtomValue(appPublisherOpenAtom)
const joinedEnvironmentIds = useAtomValue(joinedPublisherEnvironmentIdsAtom)
const polling = useAtomValue(publisherEnvironmentDeploymentPollingAtom)
const selectedEnvironmentId = useAtomValue(selectedPublisherEnvironmentIdAtom)
const deployment = useAtomValue(selectedEnvironmentDeploymentAtom)
const addEnvironment = useSetAtom(addPublisherEnvironmentAtom)
const setOpen = useSetAtom(appPublisherOpenAtom)
const selectEnvironment = useSetAtom(selectedPublisherEnvironmentIdAtom)
const startDeploymentPolling = useSetAtom(startPublisherEnvironmentDeploymentPollingAtom)
const development = environments.find((environment) => environment.id === 'development')
return (
<>
<div>{`Environments: ${environments.map((environment) => environment.display_name).join(', ')}`}</div>
<div>{`Joined: ${joinedEnvironmentIds.join(', ')}`}</div>
<div>{`Development in use: ${String(development?.in_use)}`}</div>
<div>{`Polling: ${polling?.operationId ?? 'none'}`}</div>
<div>{`Open: ${String(open)}`}</div>
<div>{`Selected: ${selectedEnvironmentId}`}</div>
<div>
{`Deployment: ${
deployment?.deployment?.current_version?.marked_name ??
deployment?.deployment?.current_version?.version ??
'none'
}`}
</div>
<button type="button" onClick={() => addEnvironment('development')}>
Add development
</button>
<button type="button" onClick={() => selectEnvironment('staging')}>
Select staging
</button>
<button type="button" onClick={() => selectEnvironment('development')}>
Select development
</button>
<button type="button" onClick={() => setOpen(false)}>
Close publisher
</button>
<button type="button" onClick={() => setOpen(true)}>
Open publisher
</button>
<button
type="button"
onClick={() =>
startDeploymentPolling({
environmentId: 'development',
operationId: 'operation-development',
})
}
>
Start development deployment
</button>
<button
type="button"
onClick={() =>
startDeploymentPolling({
environmentId: 'staging',
operationId: 'operation-staging',
})
}
>
Start staging deployment
</button>
</>
)
}
function renderState(initialOpen = true) {
const { queryClient, store } = createQueryAtomTestStore()
store.set(appPublisherOpenAtom, initialOpen)
const state = (mounted: boolean) => (
<QueryClientProvider client={queryClient}>
<Provider store={store}>
{mounted && (
<AppPublisherStateBoundary appId="app-1" environmentQueryEnabled>
<StateConsumer />
</AppPublisherStateBoundary>
)}
</Provider>
</QueryClientProvider>
)
const rendered = testingLibraryRender(state(true))
return {
...rendered,
queryClient,
rerenderWithMounted: (mounted: boolean) => rendered.rerender(state(mounted)),
}
}
function environmentDeploymentResponse({
deploymentStatus,
operationId,
operationStatus,
}: {
deploymentStatus: string
operationId: string
operationStatus: string
}) {
return {
environment_deployment: {
access: {
enable_api: true,
enable_site: true,
},
deployment: {
current_version:
deploymentStatus === DeploymentStatus.DEPLOYMENT_STATUS_RUNNING
? {
id: 'version-development',
marked_comment: '',
marked_name: 'Release development',
version: '2026-07-31.development',
}
: undefined,
latest_operation: {
activity_at: 1_785_456_000,
id: operationId,
operator: {
display_name: 'Evan',
id: 'user-1',
type: 'OPERATOR_TYPE_ACCOUNT',
},
status: operationStatus,
type: 'DEPLOYMENT_OPERATION_TYPE_DEPLOY',
},
status: deploymentStatus,
},
environment: {
description: '',
display_name: 'Development',
id: 'development',
status: 'ENVIRONMENT_STATUS_READY',
},
},
}
}
const appEnvironments = (developmentInUse: boolean) => ({
data: [
{
description: '',
display_name: 'Staging',
id: 'staging',
in_use: true,
status: 'ENVIRONMENT_STATUS_READY',
},
{
description: '',
display_name: 'Development',
id: 'development',
in_use: developmentInUse,
status: 'ENVIRONMENT_STATUS_READY',
},
],
})
function getDeploymentQueryOptions(environmentId: string) {
return queryMocks.deploymentOptions.mock.calls
.map(([options]) => options as QueryOptions)
.reverse()
.find((options) => {
if (typeof options.input !== 'object' || !options.input) return false
return (
(options.input as { params?: { environment_id?: string } }).params?.environment_id ===
environmentId
)
})
}
function getLatestDeploymentQueryOptions() {
return queryMocks.deploymentOptions.mock.calls.at(-1)?.[0] as QueryOptions | undefined
}
function getDeploymentRequestCount(environmentId: string) {
return queryMocks.deploymentRequest.mock.calls.filter((call) => {
const input = call[0] as { params: { environment_id: string } }
return input.params.environment_id === environmentId
}).length
}
describe('app publisher environment state', () => {
beforeEach(() => {
vi.clearAllMocks()
queryMocks.deploymentListRequest.mockResolvedValue({
environment_deployments: [],
})
queryMocks.environmentRequest.mockResolvedValue(appEnvironments(false))
queryMocks.deploymentRequest.mockImplementation(
async (input: { params: { environment_id: string } }) => ({
environment_deployment: {
access: {
enable_api: true,
enable_site: true,
},
deployment: {
current_version: {
id: `version-${input.params.environment_id}`,
marked_comment: '',
marked_name: `Release ${input.params.environment_id}`,
version: `2026-07-31.${input.params.environment_id}`,
},
status: 'DEPLOYMENT_STATUS_RUNNING',
},
environment: {
description: '',
display_name: input.params.environment_id,
id: input.params.environment_id,
status: 'ENVIRONMENT_STATUS_READY',
},
},
}),
)
})
it('derives deployed tabs from the environment in_use field', async () => {
const user = userEvent.setup()
renderState()
expect(await screen.findByText('Joined: staging')).toBeInTheDocument()
expect(screen.getByText('Environments: Staging, Development')).toBeInTheDocument()
expect(queryMocks.environmentOptions).toHaveBeenCalledWith({
enabled: true,
input: {
params: {
app_id: 'app-1',
},
},
})
await user.click(screen.getByRole('button', { name: 'Add development' }))
expect(screen.getByText('Joined: staging, development')).toBeInTheDocument()
expect(screen.getByText('Selected: development')).toBeInTheDocument()
await waitFor(() => {
expect(queryMocks.deploymentListRequest).toHaveBeenCalledWith({
params: {
app_id: 'app-1',
},
})
})
expect(queryMocks.deploymentRequest).not.toHaveBeenCalled()
})
it('discovers and resumes polling a first deployment while the environment remains not in use', async () => {
const user = userEvent.setup()
const response = environmentDeploymentResponse({
deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING,
operationId: 'operation-development',
operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS,
})
queryMocks.deploymentListRequest.mockResolvedValue({
environment_deployments: [response.environment_deployment],
})
queryMocks.deploymentRequest.mockResolvedValue(response)
renderState()
expect(await screen.findByText('Development in use: false')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Add development' }))
await waitFor(() => {
expect(queryMocks.deploymentRequest).toHaveBeenCalledWith({
params: {
app_id: 'app-1',
environment_id: 'development',
},
})
})
expect(
getDeploymentQueryOptions('development')?.refetchInterval?.({
state: {
data: response,
},
}),
).toBe(3000)
})
it('stops automatic status polling while the deployment query is failing', async () => {
const user = userEvent.setup()
const response = environmentDeploymentResponse({
deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING,
operationId: 'operation-staging',
operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS,
})
renderState()
await screen.findByText('Joined: staging')
await user.click(screen.getByRole('button', { name: 'Select staging' }))
const refetchInterval = getDeploymentQueryOptions('staging')?.refetchInterval
expect(refetchInterval).toBeTypeOf('function')
expect(
refetchInterval?.({
state: {
data: response,
fetchFailureCount: 1,
status: 'success',
},
}),
).toBe(false)
expect(
refetchInterval?.({
state: {
data: response,
status: 'error',
},
}),
).toBe(false)
})
it('queries deployment details only after selecting an in-use non-built-in environment', async () => {
const user = userEvent.setup()
renderState()
await screen.findByText('Joined: staging')
expect(queryMocks.deploymentRequest).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'Select staging' }))
await waitFor(() => {
expect(queryMocks.deploymentRequest).toHaveBeenCalledWith({
params: {
app_id: 'app-1',
environment_id: 'staging',
},
})
})
expect(await screen.findByText('Deployment: Release staging')).toBeInTheDocument()
})
it('resets the selected environment to built-in when the publisher reopens', async () => {
const user = userEvent.setup()
renderState()
await screen.findByText('Joined: staging')
await user.click(screen.getByRole('button', { name: 'Select staging' }))
expect(screen.getByText('Selected: staging')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Close publisher' }))
await user.click(screen.getByRole('button', { name: 'Open publisher' }))
expect(screen.getByText('Selected: built-in')).toBeInTheDocument()
})
it('refreshes environments when a selected deployment has succeeded', async () => {
const user = userEvent.setup()
queryMocks.environmentRequest
.mockResolvedValueOnce(appEnvironments(false))
.mockResolvedValue(appEnvironments(true))
queryMocks.deploymentRequest.mockResolvedValue(
environmentDeploymentResponse({
deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING,
operationId: 'operation-development',
operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_SUCCEEDED,
}),
)
renderState()
expect(await screen.findByText('Development in use: false')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Add development' }))
expect(queryMocks.deploymentRequest).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'Start development deployment' }))
await waitFor(() => {
expect(queryMocks.deploymentRequest).toHaveBeenCalledWith({
params: {
app_id: 'app-1',
environment_id: 'development',
},
})
})
expect(await screen.findByText('Deployment: Release development')).toBeInTheDocument()
expect(await screen.findByText('Development in use: true')).toBeInTheDocument()
expect(screen.getByText('Polling: none')).toBeInTheDocument()
const deploymentQueryOptions = getDeploymentQueryOptions('development')
expect(deploymentQueryOptions?.refetchInterval).toBeTypeOf('function')
expect(
deploymentQueryOptions?.refetchInterval?.({
state: {
data: environmentDeploymentResponse({
deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING,
operationId: 'operation-development',
operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS,
}),
},
}),
).toBe(3000)
expect(
deploymentQueryOptions?.refetchInterval?.({
state: {
data: environmentDeploymentResponse({
deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING,
operationId: 'operation-development',
operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_SUCCEEDED,
}),
},
}),
).toBe(false)
expect(queryMocks.environmentRequest).toHaveBeenCalledTimes(2)
})
it('scopes polling to the selected environment and clears it when the publisher closes', async () => {
const user = userEvent.setup()
queryMocks.environmentRequest.mockResolvedValue(appEnvironments(true))
queryMocks.deploymentRequest.mockImplementation(
async (input: { params: { environment_id: string } }) =>
input.params.environment_id === 'staging'
? environmentDeploymentResponse({
deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING,
operationId: 'operation-staging',
operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS,
})
: environmentDeploymentResponse({
deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING,
operationId: 'operation-development',
operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_SUCCEEDED,
}),
)
renderState()
await screen.findByText('Joined: staging, development')
await user.click(screen.getByRole('button', { name: 'Select staging' }))
await waitFor(() => {
expect(queryMocks.deploymentRequest).toHaveBeenCalledWith({
params: {
app_id: 'app-1',
environment_id: 'staging',
},
})
})
expect(
getDeploymentQueryOptions('staging')?.refetchInterval?.({
state: {
data: environmentDeploymentResponse({
deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING,
operationId: 'operation-staging',
operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS,
}),
},
}),
).toBe(3000)
await user.click(screen.getByRole('button', { name: 'Start staging deployment' }))
expect(screen.getByText('Polling: operation-staging')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Select development' }))
expect(screen.getByText('Polling: none')).toBeInTheDocument()
await waitFor(() => {
expect(queryMocks.deploymentRequest).toHaveBeenCalledWith({
params: {
app_id: 'app-1',
environment_id: 'development',
},
})
})
expect(
getDeploymentQueryOptions('development')?.refetchInterval?.({
state: {
data: environmentDeploymentResponse({
deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING,
operationId: 'operation-development',
operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_SUCCEEDED,
}),
},
}),
).toBe(false)
const stagingRequestCount = getDeploymentRequestCount('staging')
await user.click(screen.getByRole('button', { name: 'Select staging' }))
await waitFor(() => {
const nextStagingRequestCount = getDeploymentRequestCount('staging')
expect(nextStagingRequestCount).toBeGreaterThan(stagingRequestCount)
})
await user.click(screen.getByRole('button', { name: 'Start staging deployment' }))
await user.click(screen.getByRole('button', { name: 'Close publisher' }))
await waitFor(() => {
expect(screen.getByText('Polling: none')).toBeInTheDocument()
})
await waitFor(() => {
expect(getLatestDeploymentQueryOptions()?.enabled).toBe(false)
})
})
it('resets open state and active polling when the last publisher subscriber unmounts', async () => {
const user = userEvent.setup()
queryMocks.environmentRequest.mockResolvedValue(appEnvironments(true))
queryMocks.deploymentRequest.mockResolvedValue(
environmentDeploymentResponse({
deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING,
operationId: 'operation-staging',
operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS,
}),
)
const { rerenderWithMounted } = renderState()
await screen.findByText('Open: true')
await user.click(screen.getByRole('button', { name: 'Select staging' }))
await waitFor(() => {
expect(queryMocks.deploymentRequest).toHaveBeenCalled()
})
await user.click(screen.getByRole('button', { name: 'Start staging deployment' }))
expect(screen.getByText('Polling: operation-staging')).toBeInTheDocument()
rerenderWithMounted(false)
rerenderWithMounted(true)
expect(await screen.findByText('Open: false')).toBeInTheDocument()
expect(screen.getByText('Polling: none')).toBeInTheDocument()
await waitFor(() => {
expect(getLatestDeploymentQueryOptions()?.enabled).toBe(false)
})
})
})
@@ -1,79 +1,133 @@
import type { MouseEvent as ReactMouseEvent } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import SuggestedAction from '../suggested-action'
describe('SuggestedAction', () => {
it('should render an enabled external link', () => {
render(<SuggestedAction link="https://example.com/docs">Open docs</SuggestedAction>)
it('should render an enabled external link with supporting copy', () => {
render(
<SuggestedAction
link="https://example.com/docs"
external
description="Read the documentation"
>
Open docs
</SuggestedAction>,
)
const link = screen.getByRole('link', { name: 'Open docs' })
expect(link).toHaveAttribute('href', 'https://example.com/docs')
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAccessibleDescription('Read the documentation')
expect(screen.getByText('Read the documentation')).toBeInTheDocument()
})
it('should block clicks when disabled', () => {
it('should render internal destinations without opening a new tab', () => {
render(
<SuggestedAction link="/app/app-1/deploy" description="Push versions to environments">
Deploy
</SuggestedAction>,
)
const link = screen.getByRole('link', { name: 'Deploy' })
expect(link).toHaveAttribute('href', '/app/app-1/deploy')
expect(link).not.toHaveAttribute('target')
expect(link).toHaveAccessibleDescription('Push versions to environments')
})
it('should use native disabled button semantics for unavailable links', () => {
const handleClick = vi.fn()
render(
<SuggestedAction link="https://example.com/docs" disabled onClick={handleClick}>
Disabled action
</SuggestedAction>,
)
const link = screen.getByText('Disabled action').closest('a') as HTMLAnchorElement
fireEvent.click(link)
expect(link).not.toHaveAttribute('href')
expect(handleClick).not.toHaveBeenCalled()
})
it('should forward click events when enabled', () => {
const handleClick = vi.fn((event: ReactMouseEvent<HTMLAnchorElement>) => {
event.preventDefault()
})
render(
<SuggestedAction link="https://example.com/docs" onClick={handleClick}>
Enabled action
</SuggestedAction>,
)
fireEvent.click(screen.getByRole('link', { name: 'Enabled action' }))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('should render and trigger the trailing action button when configured', () => {
const handleActionClick = vi.fn()
render(
<SuggestedAction
link="https://example.com/docs"
disabled
description="Unavailable until published"
onClick={handleClick}
>
Disabled action
</SuggestedAction>,
)
const action = screen.getByRole('button', { name: 'Disabled action' })
fireEvent.click(action)
expect(action).toBeDisabled()
expect(screen.queryByRole('link', { name: /Disabled action/ })).not.toBeInTheDocument()
expect(handleClick).not.toHaveBeenCalled()
})
it('should keep an explained disabled action in the keyboard tab order', async () => {
const user = userEvent.setup()
const handleClick = vi.fn()
render(
<SuggestedAction disabled focusableWhenDisabled onClick={handleClick}>
Disabled action with explanation
</SuggestedAction>,
)
await user.tab()
const action = screen.getByRole('button', { name: 'Disabled action with explanation' })
expect(action).toHaveFocus()
expect(action).toHaveAttribute('aria-disabled', 'true')
await user.keyboard('{Enter}')
expect(handleClick).not.toHaveBeenCalled()
})
it('should render and trigger an enabled button action', () => {
const handleClick = vi.fn()
render(
<SuggestedAction
description="Use as a tool in other apps"
endIcon={<span data-testid="configure-icon" />}
onClick={handleClick}
>
Workflow as Tool
</SuggestedAction>,
)
fireEvent.click(screen.getByRole('button', { name: 'Workflow as Tool' }))
expect(handleClick).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('configure-icon')).toBeInTheDocument()
})
it('should keep the main link separate from a trailing action button', () => {
const handleActionClick = vi.fn()
render(
<SuggestedAction
link="https://example.com/app"
external
actionButton={{
ariaLabel: 'Configure action',
icon: <span>config</span>,
onClick: handleActionClick,
}}
>
Configurable action
Open web app
</SuggestedAction>,
)
fireEvent.click(screen.getByRole('button', { name: 'Configure action' }))
expect(screen.getByRole('link', { name: 'Configurable action' })).toHaveAttribute(
expect(screen.getByRole('link', { name: 'Open web app' })).toHaveAttribute(
'href',
'https://example.com/docs',
'https://example.com/app',
)
fireEvent.click(screen.getByRole('button', { name: 'Configure action' }))
expect(handleActionClick).toHaveBeenCalledTimes(1)
})
it('should block action button clicks when disabled', () => {
it('should disable both controls when an action with a trailing button is unavailable', () => {
const handleActionClick = vi.fn()
render(
<SuggestedAction
link="https://example.com/docs"
link="https://example.com/app"
external
disabled
actionButton={{
ariaLabel: 'Configure action',
@@ -81,11 +135,15 @@ describe('SuggestedAction', () => {
onClick: handleActionClick,
}}
>
Disabled with action
Open web app
</SuggestedAction>,
)
fireEvent.click(screen.getByRole('button', { name: 'Configure action' }))
expect(screen.getByRole('button', { name: 'Open web app' })).toBeDisabled()
const actionButton = screen.getByRole('button', { name: 'Configure action' })
fireEvent.click(actionButton)
expect(actionButton).toBeDisabled()
expect(handleActionClick).not.toHaveBeenCalled()
})
})
@@ -1,14 +1,8 @@
import type { TFunction } from 'i18next'
import { AccessMode } from '@/models/access-control'
import { withSelectorKey } from '@/test/i18n-mock'
import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
import {
getDisabledFunctionTooltip,
getPublisherAppMode,
getPublisherAppUrl,
isPublisherAccessConfigured,
} from '../utils'
import { getDisabledFunctionTooltip, getPublisherAppMode, getPublisherAppUrl } from '../utils'
describe('app-publisher utils', () => {
describe('getPublisherAppMode', () => {
@@ -33,26 +27,6 @@ describe('app-publisher utils', () => {
})
})
describe('isPublisherAccessConfigured', () => {
it('should require members or groups for specific access mode', () => {
expect(
isPublisherAccessConfigured(
{ access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS },
{ groups: [], members: [] },
),
).toBe(false)
})
it('should treat public access as configured', () => {
expect(
isPublisherAccessConfigured(
{ access_mode: AccessMode.PUBLIC },
{ groups: [], members: [] },
),
).toBe(true)
})
})
describe('getDisabledFunctionTooltip', () => {
const t = withSelectorKey((key: string) => key, 'app') as unknown as TFunction
@@ -41,7 +41,7 @@ describe('VersionInfoModal', () => {
const [titleInput] = screen.getAllByRole('textbox')
fireEvent.change(titleInput!, { target: { value: 'a'.repeat(16) } })
fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)common\.publish(?=$|:)/ }))
fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.save(?=$|:)/ }))
expect(toast.error).toHaveBeenCalledWith(
expect.stringMatching(/(?:^|\.)versionHistory\.editField\.titleLengthLimit(?=$|:)/),
@@ -71,7 +71,7 @@ describe('VersionInfoModal', () => {
const [titleInput, notesInput] = screen.getAllByRole('textbox')
fireEvent.change(titleInput!, { target: { value: 'Release 2' } })
fireEvent.change(notesInput!, { target: { value: 'Updated notes' } })
fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)common\.publish(?=$|:)/ }))
fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.save(?=$|:)/ }))
expect(handlePublish).toHaveBeenCalledWith({
title: 'Release 2',
@@ -123,20 +123,20 @@ describe('VersionInfoModal', () => {
const [titleInput, notesInput] = screen.getAllByRole('textbox')
fireEvent.change(titleInput!, { target: { value: 'a'.repeat(16) } })
fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)common\.publish(?=$|:)/ }))
fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.save(?=$|:)/ }))
expect(toast.error).toHaveBeenCalledWith(
expect.stringMatching(/(?:^|\.)versionHistory\.editField\.titleLengthLimit(?=$|:)/),
)
fireEvent.change(titleInput!, { target: { value: 'Release 3' } })
fireEvent.change(notesInput!, { target: { value: 'b'.repeat(101) } })
fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)common\.publish(?=$|:)/ }))
fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.save(?=$|:)/ }))
expect(toast.error).toHaveBeenCalledWith(
expect.stringMatching(/(?:^|\.)versionHistory\.editField\.releaseNotesLengthLimit(?=$|:)/),
)
fireEvent.change(notesInput!, { target: { value: 'Stable release notes' } })
fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)common\.publish(?=$|:)/ }))
fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.save(?=$|:)/ }))
expect(handlePublish).toHaveBeenCalledWith({
title: 'Release 3',
@@ -0,0 +1,164 @@
import type { AppPublisherProps } from '../types'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useTranslation } from 'react-i18next'
import { AppModeEnum } from '@/types/app'
import SuggestedAction from '../suggested-action'
import WorkflowToolAction from '../workflow-tool-action'
type PublisherActionsSectionProps = Pick<
AppPublisherProps,
'hasHumanInputNode' | 'hasTriggerNode' | 'publishedAt' | 'toolPublished' | 'workflowToolAvailable'
> & {
appDetail:
| {
id?: string
icon?: string
icon_type?: string | null
icon_background?: string | null
description?: string
mode?: AppModeEnum
name?: string
}
| null
| undefined
appURL: string
disabledFunctionButton: boolean
disabledFunctionTooltip?: string
handleOpenRunConfig?: (url: string) => void
marketplaceActionDisabled?: boolean
publishingToMarketplace?: boolean
showDeployAction?: boolean
showMarketplaceAction?: boolean
showRunConfig?: boolean
workflowToolIsLoading: boolean
workflowToolMessage?: string
workflowToolOutdated?: boolean
onConfigureWorkflowTool: () => void
onPublishToMarketplace?: () => void
}
export function PublisherActionsSection({
appDetail,
appURL,
disabledFunctionButton,
disabledFunctionTooltip,
handleOpenRunConfig,
hasHumanInputNode = false,
hasTriggerNode = false,
marketplaceActionDisabled = false,
publishedAt,
publishingToMarketplace = false,
showDeployAction = false,
showMarketplaceAction = false,
showRunConfig = false,
toolPublished = false,
workflowToolAvailable = true,
workflowToolIsLoading,
workflowToolMessage,
workflowToolOutdated = false,
onConfigureWorkflowTool,
onPublishToMarketplace,
}: PublisherActionsSectionProps) {
const { t } = useTranslation()
const appId = appDetail?.id
const hasPublishedVersion = Boolean(publishedAt)
const showOpenWebApp = !hasTriggerNode
const showDeploy = Boolean(showDeployAction && appId)
const showWorkflowTool =
appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && !hasTriggerNode
const navigationDisabled = !hasPublishedVersion || !appId
const workflowToolDisabled =
!hasPublishedVersion || !workflowToolAvailable || (toolPublished && workflowToolIsLoading)
return (
<div className="flex flex-col border-t-[0.5px] border-t-divider-regular p-3">
{showOpenWebApp && (
<Tooltip disabled={!disabledFunctionTooltip}>
<TooltipTrigger
render={
<div
className={cn(
'flex w-full',
disabledFunctionButton && 'cursor-not-allowed *:pointer-events-none',
)}
/>
}
>
<SuggestedAction
className="flex-1"
disabled={disabledFunctionButton}
description={
disabledFunctionButton && disabledFunctionTooltip
? disabledFunctionTooltip
: t(($) => $['common.openWebAppDescription'], { ns: 'workflow' })
}
external
focusableWhenDisabled={Boolean(disabledFunctionTooltip)}
link={appURL}
icon={<span className="i-ri-planet-line size-4" />}
actionButton={
showRunConfig && handleOpenRunConfig
? {
ariaLabel: t(($) => $['operation.config'], { ns: 'common' }),
icon: <span className="i-ri-settings-2-line size-4" />,
onClick: () => handleOpenRunConfig(appURL),
}
: undefined
}
>
{t(($) => $['common.openWebApp'], { ns: 'workflow' })}
</SuggestedAction>
</TooltipTrigger>
<TooltipContent role="tooltip">{disabledFunctionTooltip}</TooltipContent>
</Tooltip>
)}
<SuggestedAction
disabled={navigationDisabled}
description={t(($) => $['common.accessPointDescription'], { ns: 'workflow' })}
link={appId ? `/app/${appId}/access-point` : undefined}
icon={<span className="i-custom-vender-agent-v2-access-point size-4" />}
>
{t(($) => $['appMenus.accessPoint'], { ns: 'common' })}
</SuggestedAction>
{showDeploy && (
<SuggestedAction
disabled={navigationDisabled}
description={t(($) => $['common.deployDescription'], { ns: 'workflow' })}
link={`/app/${appId}/deploy`}
icon={<span className="i-ri-instance-line size-4" />}
>
{t(($) => $['appMenus.deploy'], { ns: 'common' })}
</SuggestedAction>
)}
{showMarketplaceAction && (
<SuggestedAction
disabled={marketplaceActionDisabled || publishingToMarketplace || !onPublishToMarketplace}
description={t(($) => $['common.publishToMarketplaceDescription'], {
ns: 'workflow',
})}
icon={<span className="i-ri-store-2-line size-4" />}
onClick={onPublishToMarketplace}
>
{publishingToMarketplace
? t(($) => $['common.publishingToMarketplace'], { ns: 'workflow' })
: t(($) => $['common.publishToMarketplace'], { ns: 'workflow' })}
</SuggestedAction>
)}
{showWorkflowTool && (
<>
<div aria-hidden className="m-1 h-px bg-divider-subtle" />
<WorkflowToolAction
disabled={workflowToolDisabled}
isLoading={workflowToolIsLoading}
message={workflowToolMessage}
outdated={workflowToolOutdated}
published={toolPublished}
onConfigure={onConfigureWorkflowTool}
/>
</>
)}
</div>
)
}
@@ -0,0 +1,7 @@
export const upgradeHighlightStyle = {
background:
'linear-gradient(97deg, var(--components-input-border-active-prompt-1, rgba(11, 165, 236, 0.95)) -3.64%, var(--components-input-border-active-prompt-2, rgba(21, 90, 239, 0.95)) 45.14%)',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}
@@ -0,0 +1,17 @@
import type { ComponentProps } from 'react'
import { PublisherActionsSection } from './actions-section'
import { PublisherSummarySection } from './summary-section'
type BuiltInPublisherProps = {
actions: ComponentProps<typeof PublisherActionsSection>
summary: ComponentProps<typeof PublisherSummarySection>
}
export function BuiltInPublisher({ actions, summary }: BuiltInPublisherProps) {
return (
<>
<PublisherSummarySection {...summary} />
<PublisherActionsSection {...actions} />
</>
)
}
@@ -0,0 +1,248 @@
import type { WorkflowResponse } from '@dify/contracts/api/console/apps/types.gen'
import type { CSSProperties, ReactNode } from 'react'
import type { ModelAndParameter } from '../../configuration/debug/types'
import type { AppPublisherProps } from '../types'
import type { PublishWorkflowParams } from '@/types/workflow'
import { Button } from '@langgenius/dify-ui/button'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { formatForDisplay } from '@tanstack/react-hotkeys'
import { useTranslation } from 'react-i18next'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import { getWorkflowVersionName } from '@/app/components/workflow/utils/version'
import { APP_PUBLISH_HOTKEY } from '../hotkeys'
import PublishWithMultipleModel from '../publish-with-multiple-model'
import { PublisherTimelineMarker } from '../shared/timeline-marker'
type PublisherVersionInfo = Pick<
WorkflowResponse,
'created_at' | 'marked_comment' | 'marked_name' | 'version_number'
> & {
created_by?: { name: string } | null
}
type PublisherSummarySectionProps = Pick<
AppPublisherProps,
| 'debugWithMultipleModel'
| 'draftUpdatedAt'
| 'multipleModelConfigs'
| 'publishDisabled'
| 'publishedAt'
| 'startNodeLimitExceeded'
> & {
formatTimeFromNow: (value: number) => string
handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise<void>
handleRestore: () => Promise<void>
environmentTabs?: ReactNode
isChatApp: boolean
isWorkflowApp?: boolean
onEditVersion?: () => void
published: boolean
upgradeHighlightStyle: CSSProperties
versionInfo?: PublisherVersionInfo | null
}
export function PublisherSummarySection({
debugWithMultipleModel = false,
draftUpdatedAt,
environmentTabs,
formatTimeFromNow,
handlePublish,
handleRestore,
isChatApp,
isWorkflowApp = false,
multipleModelConfigs = [],
onEditVersion,
publishDisabled = false,
published,
publishedAt,
startNodeLimitExceeded = false,
upgradeHighlightStyle,
versionInfo,
}: PublisherSummarySectionProps) {
const { t } = useTranslation()
const hasPublishedVersion = Boolean(publishedAt)
const publishedTimestamp =
publishedAt || (versionInfo?.created_at ? versionInfo.created_at * 1000 : undefined)
const publisherName = versionInfo?.created_by?.name
const markedName = versionInfo?.marked_name
const markedComment = versionInfo?.marked_comment
const publishButtonDisabled = publishDisabled || published
const publishButtonLabel = published
? t(($) => $['common.published'], { ns: 'workflow' })
: hasPublishedVersion
? t(($) => $['common.publishUpdate'], { ns: 'workflow' })
: t(($) => $['common.publish'], { ns: 'workflow' })
return (
<div className="flex flex-col gap-3 p-4">
{environmentTabs}
<div className="flex items-start gap-1 px-1 py-0.5">
<PublisherTimelineMarker position="top" />
{!hasPublishedVersion ? (
<p className="min-w-0 flex-1 system-xs-regular text-text-tertiary">
{t(($) => $['common.notPublishedYet'], { ns: 'workflow' })}
</p>
) : isWorkflowApp ? (
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex min-h-4 min-w-0 items-center gap-1">
<span className="truncate system-sm-semibold text-text-secondary">
{getWorkflowVersionName(
versionInfo,
t(($) => $['versionHistory.defaultName'], { ns: 'workflow' }),
)}
</span>
<span aria-hidden className="system-xs-regular text-text-tertiary">
·
</span>
{markedName ? (
<button
type="button"
className="flex size-4 shrink-0 items-center justify-center rounded text-text-tertiary outline-hidden hover:text-text-accent focus-visible:ring-2 focus-visible:ring-state-accent-solid"
aria-label={t(($) => $['versionHistory.editVersionInfo'], { ns: 'workflow' })}
disabled={!versionInfo || !onEditVersion}
onClick={onEditVersion}
>
<span aria-hidden className="i-ri-edit-line size-3.5" />
</button>
) : (
<button
type="button"
className="flex min-w-0 items-center gap-1 rounded text-text-accent outline-hidden hover:text-text-accent-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-wait"
disabled={!versionInfo || !onEditVersion}
onClick={onEditVersion}
>
<span aria-hidden className="i-ri-edit-line size-3.5 shrink-0" />
<span className="truncate system-xs-medium">
{t(($) => $['versionHistory.nameIt'], { ns: 'workflow' })}
</span>
</button>
)}
</div>
{markedComment && (
<>
<p className="line-clamp-3 system-xs-regular wrap-break-word text-text-tertiary">
{markedComment}
</p>
<span aria-hidden className="my-1 h-px w-4 bg-divider-regular" />
</>
)}
{!!publishedTimestamp && (
<p className="system-xs-regular text-text-tertiary">
{publisherName
? t(($) => $['common.publishedBy'], {
ns: 'workflow',
time: formatTimeFromNow(publishedTimestamp),
author: publisherName,
})
: `${t(($) => $['common.publishedAt'], { ns: 'workflow' })} ${formatTimeFromNow(publishedTimestamp)}`}
</p>
)}
</div>
) : (
<div className="flex min-w-0 flex-1 items-start justify-between gap-2">
<div className="flex min-w-0 flex-col">
<p className="truncate system-sm-semibold text-text-secondary">
{t(($) => $['common.latestPublished'], { ns: 'workflow' })}
</p>
{!!publishedTimestamp && (
<p className="truncate system-xs-regular text-text-tertiary">
{publisherName
? t(($) => $['common.publishedBy'], {
ns: 'workflow',
time: formatTimeFromNow(publishedTimestamp),
author: publisherName,
})
: `${t(($) => $['common.publishedAt'], { ns: 'workflow' })} ${formatTimeFromNow(publishedTimestamp)}`}
</p>
)}
</div>
{isChatApp && (
<Button
variant="secondary"
size="small"
className="h-6 shrink-0 gap-1"
onClick={handleRestore}
disabled={published}
>
<span aria-hidden className="i-ri-reset-left-line size-3.5" />
{t(($) => $['common.restore'], { ns: 'workflow' })}
</Button>
)}
</div>
)}
</div>
<div className="flex w-full flex-col">
{debugWithMultipleModel ? (
<PublishWithMultipleModel
disabled={publishButtonDisabled}
multipleModelConfigs={multipleModelConfigs}
onSelect={(item) => handlePublish(item)}
/>
) : (
<>
<Button
variant="primary"
className="w-full"
onClick={() => handlePublish()}
disabled={publishButtonDisabled}
>
{publishDisabled ? (
publishButtonLabel
) : (
<span className="flex items-center gap-1">
<span>{publishButtonLabel}</span>
<KbdGroup aria-hidden>
{APP_PUBLISH_HOTKEY.split('+').map((key) => (
<Kbd key={key} color="white" disabled={publishButtonDisabled}>
{formatForDisplay(key)}
</Kbd>
))}
</KbdGroup>
</span>
)}
</Button>
{startNodeLimitExceeded && (
<div className="mt-3 flex flex-col items-stretch">
<p
className="text-sm/5 font-semibold text-transparent"
style={upgradeHighlightStyle}
>
<span className="block">
{t(($) => $['publishLimit.startNodeTitlePrefix'], { ns: 'workflow' })}
</span>
<span className="block">
{t(($) => $['publishLimit.startNodeTitleSuffix'], { ns: 'workflow' })}
</span>
</p>
<p className="mt-1 text-xs/4 text-text-secondary">
{t(($) => $['publishLimit.startNodeDesc'], { ns: 'workflow' })}
</p>
<UpgradeBtn isShort className="mt-2.25 mb-3 h-8 w-23.25 self-start" />
</div>
)}
</>
)}
</div>
<div className="flex items-center gap-1 py-0.5 pr-0.5 pl-1">
<PublisherTimelineMarker position="bottom" />
<p role="status" className="min-w-0 flex-1 truncate system-xs-regular text-text-tertiary">
{published ? (
isWorkflowApp ? (
t(($) => $['common.published'], { ns: 'workflow' })
) : (
t(($) => $['common.upToDate'], { ns: 'workflow' })
)
) : isWorkflowApp && Boolean(draftUpdatedAt) ? (
<>
{t(($) => $['common.autoSaved'], { ns: 'workflow' })}
{' · '}
{formatTimeFromNow(draftUpdatedAt!)}
</>
) : (
t(($) => $['common.currentDraft'], { ns: 'workflow' })
)}
</p>
</div>
</div>
)
}
@@ -0,0 +1,43 @@
import type { EnvironmentDeployment } from '@dify/contracts/enterprise-app-deploy/types.gen'
import { useTranslation } from 'react-i18next'
import SuggestedAction from '../suggested-action'
function environmentHref(path: string, appId: string, environmentId: string) {
return `/app/${appId}/${path}?environment=${encodeURIComponent(environmentId)}`
}
export function PublisherEnvironmentActionsSection({
appId,
deployment,
environmentId,
}: {
appId?: string
deployment?: EnvironmentDeployment
environmentId: string
}) {
const { t } = useTranslation()
const actionsDisabled = !appId || !deployment
const accessPointHref = appId ? environmentHref('access-point', appId, environmentId) : undefined
const deployHref = appId ? environmentHref('deploy', appId, environmentId) : undefined
return (
<div className="flex flex-col border-t-[0.5px] border-t-divider-regular p-3">
<SuggestedAction
disabled={actionsDisabled}
description={t(($) => $['common.accessPointDescription'], { ns: 'workflow' })}
link={accessPointHref}
icon={<span className="i-custom-vender-agent-v2-access-point size-4" />}
>
{t(($) => $['appMenus.accessPoint'], { ns: 'common' })}
</SuggestedAction>
<SuggestedAction
disabled={actionsDisabled}
description={t(($) => $['common.deployDescription'], { ns: 'workflow' })}
link={deployHref}
icon={<span className="i-ri-instance-line size-4" />}
>
{t(($) => $['appMenus.deploy'], { ns: 'common' })}
</SuggestedAction>
</div>
)
}
@@ -0,0 +1,101 @@
'use client'
import type { EnvironmentDeployment } from '@dify/contracts/enterprise-app-deploy/types.gen'
import type { ReactNode } from 'react'
import type { DeploymentVersion } from '@/app/components/app/deploy/version'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { EnvironmentDeploymentFlow } from '@/app/components/app/deploy/environment-deployment-flow'
import {
publisherEnvironmentDeploymentPollingAtom,
startPublisherEnvironmentDeploymentPollingAtom,
} from '../state'
import { PublisherEnvironmentActionsSection } from './actions-section'
import { PublisherEnvironmentSummarySection } from './summary-section'
type PublisherEnvironmentFlowProps = {
appId?: string
deployment?: EnvironmentDeployment
environmentId: string
environmentName: string
environmentTabs: ReactNode
isEnvironmentInUse: boolean
isDeploymentError: boolean
isDeploymentLoading: boolean
latestVersion?: DeploymentVersion | null
onGoToPublish: () => void
}
export function PublisherEnvironmentFlow({
appId,
deployment,
environmentId,
environmentName,
environmentTabs,
isEnvironmentInUse,
isDeploymentError,
isDeploymentLoading,
latestVersion,
onGoToPublish,
}: PublisherEnvironmentFlowProps) {
const { t } = useTranslation()
const deploymentPolling = useAtomValue(publisherEnvironmentDeploymentPollingAtom)
const startDeploymentPolling = useSetAtom(startPublisherEnvironmentDeploymentPollingAtom)
if (isDeploymentLoading || (isDeploymentError && !deployment)) {
return (
<div aria-busy={isDeploymentLoading} className="flex min-h-40 flex-col gap-3 p-4">
{environmentTabs}
<div
role={isDeploymentError ? 'alert' : 'status'}
className="flex flex-1 items-center justify-center gap-2 system-sm-regular text-text-tertiary"
>
{isDeploymentLoading ? (
<>
<span aria-hidden className="i-ri-loader-2-line size-4 animate-spin" />
{t(($) => $.loading, { ns: 'common' })}
</>
) : (
t(($) => $['common.loadFailed'], { ns: 'deployments' })
)}
</div>
</div>
)
}
return (
<EnvironmentDeploymentFlow
appId={appId}
deployment={deployment}
disabled={deploymentPolling?.environmentId === environmentId}
environmentId={environmentId}
environmentName={environmentName}
onDeploymentStarted={(operationId) => {
startDeploymentPolling({ environmentId, operationId })
}}
>
{({ deploymentActionsDisabled, deployVersion, showVersionSelection }) => (
<div>
<PublisherEnvironmentSummarySection
deployment={deployment}
deploymentActionsDisabled={deploymentActionsDisabled}
environmentTabs={environmentTabs}
isEnvironmentInUse={isEnvironmentInUse}
latestVersion={latestVersion}
onDeployLatest={() => {
if (latestVersion) deployVersion(latestVersion)
}}
onDeployOtherVersion={showVersionSelection}
onGoToPublish={onGoToPublish}
onShowAllVersions={showVersionSelection}
/>
<PublisherEnvironmentActionsSection
appId={appId}
deployment={deployment}
environmentId={environmentId}
/>
</div>
)}
</EnvironmentDeploymentFlow>
)
}
@@ -0,0 +1,63 @@
import type { DeploymentVersion } from '@/app/components/app/deploy/version'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import { PublisherDeployingMarker } from '../publisher-deploying-marker'
import { PublisherTimelineMarker } from '../shared/timeline-marker'
export function PublisherLatestVersionRow({
deployingVersionName,
disabled,
isDeploying,
latestVersion,
onShowAllVersions,
}: {
deployingVersionName?: string
disabled: boolean
isDeploying: boolean
latestVersion?: DeploymentVersion | null
onShowAllVersions: () => void
}) {
const { t } = useTranslation()
return (
<div className="flex items-center gap-1 py-0.5 pr-0.5 pl-1">
{isDeploying ? <PublisherDeployingMarker /> : <PublisherTimelineMarker position="bottom" />}
<p
role={isDeploying ? 'status' : undefined}
className={cn(
'min-w-0 flex-1 truncate',
isDeploying
? 'system-xs-medium text-text-accent'
: 'system-xs-regular text-text-tertiary',
)}
>
{isDeploying ? (
deployingVersionName ? (
t(($) => $['studio.publisher.deployingVersion'], {
ns: 'deployments',
version: deployingVersionName,
})
) : (
t(($) => $['deployDrawer.deploying'], { ns: 'deployments' })
)
) : (
<>
<span className="capitalize">
{t(($) => $['overview.chip.latest'], { ns: 'deployments' })}
</span>
{latestVersion ? `: ${latestVersion.name}` : ''}
</>
)}
</p>
<button
type="button"
disabled={disabled}
className="flex shrink-0 items-center gap-0.5 rounded system-xs-regular text-text-tertiary outline-hidden hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:text-text-disabled disabled:hover:text-text-disabled"
onClick={onShowAllVersions}
>
{t(($) => $['studio.allVersions'], { ns: 'deployments' })}
<span aria-hidden className="i-ri-arrow-right-s-line size-3.5" />
</button>
</div>
)
}
@@ -0,0 +1,207 @@
import type { EnvironmentDeployment } from '@dify/contracts/enterprise-app-deploy/types.gen'
import type { ReactNode } from 'react'
import type { DeploymentVersion } from '@/app/components/app/deploy/version'
import { DeploymentStatus } from '@dify/contracts/enterprise-app-deploy/types.gen'
import { Button } from '@langgenius/dify-ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useTranslation } from 'react-i18next'
import { getWorkflowVersionName } from '@/app/components/workflow/utils/version'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { PublisherTimelineMarker } from '../shared/timeline-marker'
import { PublisherLatestVersionRow } from './latest-version-row'
export function PublisherEnvironmentSummarySection({
deployment,
deploymentActionsDisabled,
environmentTabs,
isEnvironmentInUse,
latestVersion,
onDeployLatest,
onDeployOtherVersion,
onGoToPublish,
onShowAllVersions,
}: {
deployment?: EnvironmentDeployment
deploymentActionsDisabled: boolean
environmentTabs: ReactNode
isEnvironmentInUse: boolean
latestVersion?: DeploymentVersion | null
onDeployLatest: () => void
onDeployOtherVersion: () => void
onGoToPublish: () => void
onShowAllVersions: () => void
}) {
const { t } = useTranslation()
const { formatTimeFromNow } = useFormatTimeFromNow()
const deploymentState = deployment?.deployment
const deployedVersion = deploymentState?.current_version
const isDeploying = deploymentState?.status === DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING
const deployingVersion = deploymentState?.latest_operation?.target_version
const deployingVersionName = deployingVersion
? getWorkflowVersionName(
deployingVersion,
t(($) => $['versionHistory.defaultName'], { ns: 'workflow' }),
)
: undefined
const versionsBehind = deploymentState?.versions_behind
const versionsBehindLabel =
versionsBehind === undefined
? undefined
: versionsBehind === 1
? t(($) => $['studio.versionsBehind_one'], {
ns: 'deployments',
count: versionsBehind,
})
: t(($) => $['studio.versionsBehind_other'], {
ns: 'deployments',
count: versionsBehind,
})
const isLatestVersion = Boolean(
deployedVersion &&
(latestVersion
? deployedVersion.id === latestVersion.id
: deploymentState?.versions_behind === 0),
)
const publishedAt = deploymentState?.deployed_at ? deploymentState.deployed_at * 1000 : undefined
const publishedBy = deploymentState?.deployed_by?.display_name
const showNoPublishedVersionState = !isEnvironmentInUse && latestVersion === null
if (!deployedVersion) {
return (
<div className="flex flex-col gap-3 p-4">
{environmentTabs}
<div className="flex items-start gap-1 px-1 py-0.5">
<PublisherTimelineMarker position="top" />
<div className="min-w-0 flex-1">
<p className="system-xs-regular text-text-tertiary">
{showNoPublishedVersionState
? t(($) => $['studio.accessPoint.noPublishedTitle'], {
ns: 'deployments',
})
: t(($) => $['studio.publisher.notDeployedYet'], {
ns: 'deployments',
})}
</p>
{showNoPublishedVersionState && (
<p className="system-xs-regular text-text-tertiary">
{t(($) => $['studio.publisher.noPublishedDescription'], {
ns: 'deployments',
})}
</p>
)}
</div>
</div>
{showNoPublishedVersionState ? (
<Button variant="primary" className="w-full" onClick={onGoToPublish}>
{t(($) => $['studio.accessPoint.goToPublish'], { ns: 'deployments' })}
</Button>
) : (
<>
<Button
type="button"
variant="primary"
className="w-full"
disabled={deploymentActionsDisabled || !latestVersion}
onClick={onDeployLatest}
>
{isDeploying
? t(($) => $['deployDrawer.deploying'], { ns: 'deployments' })
: t(($) => $['studio.deployLatest'], { ns: 'deployments' })}
</Button>
<PublisherLatestVersionRow
deployingVersionName={deployingVersionName}
disabled={deploymentActionsDisabled}
isDeploying={isDeploying}
latestVersion={latestVersion}
onShowAllVersions={onShowAllVersions}
/>
</>
)}
</div>
)
}
return (
<div className="flex flex-col gap-3 p-4">
{environmentTabs}
<div className="flex items-start gap-1 px-1 py-0.5">
<PublisherTimelineMarker position="top" />
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex min-w-0 items-center gap-1">
<span className="truncate system-sm-semibold text-text-secondary">
{getWorkflowVersionName(
deployedVersion,
t(($) => $['versionHistory.defaultName'], { ns: 'workflow' }),
)}
</span>
{versionsBehindLabel && !isLatestVersion && (
<Tooltip>
<TooltipTrigger
render={
<span
role="status"
aria-label={versionsBehindLabel}
className="inline-flex h-4.5 shrink-0 items-center rounded-[5px] border border-util-colors-orange-orange-500 px-1 system-2xs-medium text-util-colors-orange-orange-600"
>
<span aria-hidden className="i-ri-arrow-up-line size-3" />
{versionsBehind}
</span>
}
/>
<TooltipContent role="tooltip">{versionsBehindLabel}</TooltipContent>
</Tooltip>
)}
{isLatestVersion && (
<span className="inline-flex min-w-4 shrink-0 items-center justify-center rounded-[5px] border border-text-accent bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-accent">
{t(($) => $['overview.chip.latest'], { ns: 'deployments' })}
</span>
)}
</div>
{publishedAt !== undefined && publishedBy && (
<p className="truncate system-xs-regular text-text-tertiary">
{t(($) => $['common.publishedBy'], {
ns: 'workflow',
time: formatTimeFromNow(publishedAt),
author: publishedBy,
})}
</p>
)}
</div>
</div>
<div className="flex w-full flex-col gap-1">
<Button
type="button"
variant="primary"
className="w-full"
disabled={deploymentActionsDisabled || isLatestVersion || !latestVersion}
onClick={onDeployLatest}
>
{isDeploying
? t(($) => $['deployDrawer.deploying'], { ns: 'deployments' })
: t(($) => $['studio.deployLatest'], { ns: 'deployments' })}
</Button>
{isLatestVersion && !isDeploying && (
<Button
type="button"
variant="tertiary"
className="w-full gap-1"
disabled={deploymentActionsDisabled}
onClick={onDeployOtherVersion}
>
{t(($) => $['studio.deployAnotherVersion'], { ns: 'deployments' })}
<span aria-hidden className="i-ri-arrow-right-line size-4" />
</Button>
)}
</div>
{(isDeploying || !isLatestVersion) && (
<PublisherLatestVersionRow
deployingVersionName={deployingVersionName}
disabled={deploymentActionsDisabled}
isDeploying={isDeploying}
latestVersion={latestVersion}
onShowAllVersions={onShowAllVersions}
/>
)}
</div>
)
}
@@ -0,0 +1,81 @@
import { getEnvironmentTabLayout } from '../layout'
const environmentTabWidths = {
canary: 72,
preview: 80,
production: 88,
}
describe('getEnvironmentTabLayout', () => {
it('shows every joined environment without a More trigger when the row fits', () => {
expect(
getEnvironmentTabLayout({
availableWidth: 320,
builtInWidth: 64,
environmentTabWidths,
hasUndeployedEnvironments: false,
joinedEnvironmentIds: ['canary', 'preview'],
moreEnvironmentsWidth: 120,
moreWidth: 64,
}),
).toEqual({
overflowEnvironmentIds: [],
showMore: false,
visibleEnvironmentIds: ['canary', 'preview'],
})
})
it('reserves room for adding undeployed environments', () => {
expect(
getEnvironmentTabLayout({
availableWidth: 240,
builtInWidth: 64,
environmentTabWidths,
hasUndeployedEnvironments: true,
joinedEnvironmentIds: ['canary', 'preview'],
moreEnvironmentsWidth: 120,
moreWidth: 64,
}),
).toEqual({
overflowEnvironmentIds: ['preview'],
showMore: true,
visibleEnvironmentIds: ['canary'],
})
})
it('keeps overflow environments in their joined order', () => {
expect(
getEnvironmentTabLayout({
availableWidth: 250,
builtInWidth: 64,
environmentTabWidths,
hasUndeployedEnvironments: false,
joinedEnvironmentIds: ['canary', 'preview', 'production'],
moreEnvironmentsWidth: 120,
moreWidth: 64,
}),
).toEqual({
overflowEnvironmentIds: ['preview', 'production'],
showMore: true,
visibleEnvironmentIds: ['canary'],
})
})
it('shows only the add trigger when no environment has joined', () => {
expect(
getEnvironmentTabLayout({
availableWidth: 320,
builtInWidth: 64,
environmentTabWidths,
hasUndeployedEnvironments: true,
joinedEnvironmentIds: [],
moreEnvironmentsWidth: 120,
moreWidth: 64,
}),
).toEqual({
overflowEnvironmentIds: [],
showMore: true,
visibleEnvironmentIds: [],
})
})
})
@@ -0,0 +1,39 @@
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { ENVIRONMENT_TAB_LABEL_MAX_WIDTH } from './layout'
export function EnvironmentButton({
active,
name,
textWidth,
onClick,
}: {
active: boolean
name: string
textWidth: number
onClick: () => void
}) {
const truncated = textWidth > ENVIRONMENT_TAB_LABEL_MAX_WIDTH
return (
<Tooltip disabled={!truncated}>
<TooltipTrigger
render={
<button
type="button"
aria-current={active ? 'true' : undefined}
className={cn(
'flex h-7 max-w-22 shrink-0 items-center justify-center rounded-lg px-2 py-1.5 text-center system-sm-medium text-text-tertiary outline-hidden',
'hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid',
active && 'bg-state-base-active system-sm-semibold text-text-primary',
)}
onClick={onClick}
>
<span className="min-w-0 truncate">{name}</span>
</button>
}
/>
<TooltipContent role="tooltip">{name}</TooltipContent>
</Tooltip>
)
}
@@ -0,0 +1,19 @@
import type { PublisherEnvironment } from './types'
import { DropdownMenuItem } from '@langgenius/dify-ui/dropdown-menu'
export function EnvironmentMenuItem({
environment,
onClick,
}: {
environment: PublisherEnvironment
onClick: () => void
}) {
return (
<DropdownMenuItem className="mx-0 flex gap-2 px-2 py-1.5" onClick={onClick}>
<span aria-hidden className="i-ri-instance-line size-4 shrink-0 text-text-tertiary" />
<span className="grow truncate system-md-regular text-text-secondary">
{environment.name}
</span>
</DropdownMenuItem>
)
}

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