mirror of
https://github.com/langgenius/dify.git
synced 2026-09-19 10:11:30 +08:00
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:
co-authored by
zhangx1n
autofix-ci[bot]
parent
ef8544b173
commit
7aba539e82
@@ -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 />
|
||||
}
|
||||
+55
-2
@@ -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, {
|
||||
|
||||
-228
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
+79
@@ -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()
|
||||
})
|
||||
})
|
||||
+403
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+171
@@ -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 }
|
||||
}
|
||||
+84
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+290
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+8
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
+48
-34
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
+70
-55
@@ -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>
|
||||
)
|
||||
}
|
||||
+63
@@ -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
Reference in New Issue
Block a user