refactor(app-publisher): streamline app publisher component and introduce custom hook for improved state management

This commit is contained in:
CodingOnStar
2026-03-30 10:45:28 +08:00
parent 4bb364411c
commit d331915340
21 changed files with 4100 additions and 452 deletions

View File

@ -0,0 +1,208 @@
import type { AppPublisherMenuContentProps } from '../menu-content.types'
import type { AppDetailResponse } from '@/models/app'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defaultSystemFeatures } from '@/types/feature'
import AppPublisher from '../index'
const mockMenuContent = vi.fn()
const mockUseAppPublisher = vi.fn()
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({
children,
open,
}: {
children: React.ReactNode
open: boolean
}) => <div data-testid="portal" data-open={String(open)}>{children}</div>,
PortalToFollowElemTrigger: ({
children,
onClick,
}: {
children: React.ReactNode
onClick?: () => void
}) => <div onClick={onClick}>{children}</div>,
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => <div data-testid="portal-content">{children}</div>,
}))
vi.mock('@/app/components/app/overview/embedded', () => ({
default: ({
isShow,
onClose,
appBaseUrl,
accessToken,
}: {
isShow: boolean
onClose: () => void
appBaseUrl?: string
accessToken?: string
}) => (
<div data-testid="embedded-modal" data-open={String(isShow)} data-app-base-url={appBaseUrl} data-access-token={accessToken}>
<button onClick={onClose}>close-embedded</button>
</div>
),
}))
vi.mock('../menu-content', () => ({
default: (props: AppPublisherMenuContentProps) => {
mockMenuContent(props)
return (
<div data-testid="menu-content">
<button onClick={props.onOpenEmbedding}>menu-open-embedding</button>
</div>
)
},
}))
vi.mock('../use-app-publisher', () => ({
useAppPublisher: (...args: unknown[]) => mockUseAppPublisher(...args),
}))
vi.mock('../../app-access-control', () => ({
default: ({
onClose,
onConfirm,
}: {
onClose: () => void
onConfirm: () => void
}) => (
<div data-testid="access-control">
<button onClick={onConfirm}>confirm-access</button>
<button onClick={onClose}>close-access</button>
</div>
),
}))
const createHookState = () => ({
accessToken: 'app-token',
appBaseURL: 'https://apps.example.com',
appDetail: {
id: 'app-1',
site: {
access_token: 'app-token',
app_base_url: 'https://apps.example.com',
},
} as AppDetailResponse | undefined,
appURL: '/apps/app-1',
closeAppAccessControl: vi.fn(),
closeEmbeddingModal: vi.fn(),
crossAxisOffset: 8,
debugWithMultipleModel: false,
disabled: false,
disabledFunctionButton: false,
disabledFunctionTooltip: undefined,
draftUpdatedAt: 5678,
embeddingModalOpen: false,
formatTimeFromNow: (time: number) => `from-now:${time}`,
handleAccessControlUpdate: vi.fn(),
handleOpenEmbedding: vi.fn(),
handleOpenInExplore: vi.fn(),
handlePublish: vi.fn(),
handlePublishToMarketplace: vi.fn(),
handleRestore: vi.fn(),
handleTrigger: vi.fn(),
hasHumanInputNode: false,
hasTriggerNode: false,
inputs: [],
isAppAccessSet: true,
isChatApp: false,
isGettingAppWhiteListSubjects: false,
isGettingUserCanAccessApp: false,
missingStartNode: false,
multipleModelConfigs: [],
onRefreshData: vi.fn(),
open: false,
outputs: [],
publishDisabled: false,
publishLoading: false,
published: false,
publishedAt: 1234,
publishingToMarketplace: false,
setOpen: vi.fn(),
showAppAccessControl: false,
showAppAccessControlModal: vi.fn(),
startNodeLimitExceeded: false,
systemFeatures: defaultSystemFeatures,
toolPublished: false,
upgradeHighlightStyle: { color: 'red' },
workflowToolAvailable: true,
workflowToolDisabled: false,
workflowToolMessage: undefined,
})
describe('AppPublisher', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseAppPublisher.mockReturnValue(createHookState())
})
it('should render the publish trigger and forward state into the menu content', () => {
render(<AppPublisher />)
const menuContentProps = mockMenuContent.mock.calls[0][0] as AppPublisherMenuContentProps
expect(screen.getByRole('button', { name: 'workflow.common.publish' })).toBeInTheDocument()
expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'false')
expect(menuContentProps).toEqual(expect.objectContaining({
appURL: '/apps/app-1',
publishedAt: 1234,
workflowToolDisabled: false,
}))
expect(screen.getByTestId('embedded-modal')).toHaveAttribute('data-app-base-url', 'https://apps.example.com')
expect(screen.getByTestId('embedded-modal')).toHaveAttribute('data-access-token', 'app-token')
})
it('should invoke the trigger handler when the publish button is clicked', () => {
const state = createHookState()
mockUseAppPublisher.mockReturnValue(state)
render(<AppPublisher />)
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.publish' }))
expect(state.handleTrigger).toHaveBeenCalledTimes(1)
})
it('should pass loading-disabled state to the publish button', () => {
const state = createHookState()
state.disabled = true
state.publishLoading = true
mockUseAppPublisher.mockReturnValue(state)
render(<AppPublisher />)
expect(screen.getByRole('button', { name: /workflow\.common\.publish/i })).toBeDisabled()
})
it('should render access control when requested and wire the overlay callbacks', () => {
const state = createHookState()
state.embeddingModalOpen = true
state.showAppAccessControl = true
mockUseAppPublisher.mockReturnValue(state)
render(<AppPublisher />)
expect(screen.getByTestId('embedded-modal')).toHaveAttribute('data-open', 'true')
expect(screen.getByTestId('access-control')).toBeInTheDocument()
fireEvent.click(screen.getByText('close-embedded'))
fireEvent.click(screen.getByText('confirm-access'))
fireEvent.click(screen.getByText('close-access'))
expect(state.closeEmbeddingModal).toHaveBeenCalledTimes(1)
expect(state.handleAccessControlUpdate).toHaveBeenCalledTimes(1)
expect(state.closeAppAccessControl).toHaveBeenCalledTimes(1)
})
it('should skip rendering access control when the app detail is absent', () => {
const state = createHookState()
state.appDetail = undefined
state.showAppAccessControl = true
mockUseAppPublisher.mockReturnValue(state)
render(<AppPublisher />)
expect(screen.queryByTestId('access-control')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,110 @@
import type { AppPublisherMenuContentProps } from '../menu-content.types'
import type { AppDetailResponse } from '@/models/app'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { toast } from '@/app/components/base/ui/toast'
import { AppModeEnum } from '@/types/app'
import MenuContentActionsSection from '../menu-content-actions-section'
const mockWorkflowToolConfigureButton = vi.fn()
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
error: vi.fn(),
},
}))
vi.mock('@/app/components/tools/workflow-tool/configure-button', () => ({
default: (props: {
detailNeedUpdate: boolean
disabled?: boolean
disabledReason?: string
handlePublish: (params?: unknown) => void
}) => {
mockWorkflowToolConfigureButton(props)
return (
<button onClick={() => props.handlePublish({ tool: true })}>
publish-workflow-tool
</button>
)
},
}))
const createAppDetail = (overrides: Partial<AppDetailResponse> = {}): AppDetailResponse => ({
description: 'Workflow description',
icon: '🤖',
icon_background: '#ffffff',
icon_type: 'emoji',
id: 'app-1',
mode: AppModeEnum.WORKFLOW,
name: 'Workflow app',
...overrides,
} as AppDetailResponse)
const createProps = (overrides: Partial<AppPublisherMenuContentProps> = {}): React.ComponentProps<typeof MenuContentActionsSection> => ({
appDetail: createAppDetail(),
appURL: '/apps/app-1',
disabledFunctionButton: false,
disabledFunctionTooltip: undefined,
hasHumanInputNode: false,
hasTriggerNode: false,
inputs: [],
missingStartNode: false,
onOpenEmbedding: vi.fn(),
onOpenInExplore: vi.fn(),
onPublish: vi.fn(),
onRefreshData: vi.fn(),
outputs: [],
published: false,
publishedAt: 1234,
toolPublished: false,
workflowToolDisabled: false,
workflowToolMessage: undefined,
...overrides,
})
describe('MenuContentActionsSection', () => {
it('should show a toast when explore is requested before publish and render the embed action for chat apps', async () => {
const user = userEvent.setup()
const onOpenEmbedding = vi.fn()
render(
<MenuContentActionsSection {...createProps({
appDetail: createAppDetail({ mode: AppModeEnum.CHAT }),
onOpenEmbedding,
publishedAt: undefined,
})}
/>,
)
await user.click(screen.getByText('workflow.common.openInExplore'))
await user.click(screen.getByText('workflow.common.embedIntoSite'))
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('notPublishedYet'))
expect(onOpenEmbedding).not.toHaveBeenCalled()
})
it('should forward workflow tool publish requests to onPublish', async () => {
const user = userEvent.setup()
const onPublish = vi.fn().mockResolvedValue(undefined)
render(
<MenuContentActionsSection {...createProps({
onPublish,
published: true,
toolPublished: true,
})}
/>,
)
await user.click(screen.getByText('publish-workflow-tool'))
expect(mockWorkflowToolConfigureButton.mock.calls[0][0]).toEqual(expect.objectContaining({
detailNeedUpdate: true,
disabled: false,
disabledReason: undefined,
}))
expect(onPublish).toHaveBeenCalledWith({ tool: true })
})
})

View File

@ -0,0 +1,70 @@
import type { ModelAndParameter } from '../../configuration/debug/types'
import type { AppPublisherMenuContentProps } from '../menu-content.types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import MenuContentPublishSection from '../menu-content-publish-section'
vi.mock('@/app/components/billing/upgrade-btn', () => ({
default: () => <div data-testid="upgrade-btn">upgrade-btn</div>,
}))
vi.mock('../../workflow/shortcuts-name', () => ({
default: () => <div data-testid="shortcuts-name">shortcuts-name</div>,
}))
vi.mock('../publish-with-multiple-model', () => ({
default: ({
multipleModelConfigs,
onSelect,
}: {
multipleModelConfigs: Array<{ id: string }>
onSelect: (item: { id: string }) => void
}) => (
<button onClick={() => onSelect(multipleModelConfigs[0])}>
publish-with-multiple-model
</button>
),
}))
const createProps = (overrides: Partial<AppPublisherMenuContentProps> = {}): React.ComponentProps<typeof MenuContentPublishSection> => ({
debugWithMultipleModel: false,
draftUpdatedAt: 5678,
formatTimeFromNow: time => `from-now:${time}`,
isChatApp: false,
multipleModelConfigs: [{
id: 'model-1',
model: 'gpt-4o',
parameters: {},
provider: 'openai',
}] satisfies ModelAndParameter[],
onPublish: vi.fn(),
onRestore: vi.fn(),
publishDisabled: false,
published: false,
publishedAt: undefined,
publishLoading: false,
startNodeLimitExceeded: false,
upgradeHighlightStyle: { color: 'red' },
...overrides,
})
describe('MenuContentPublishSection', () => {
it('should forward selected models when multiple-model publishing is enabled', async () => {
const user = userEvent.setup()
const onPublish = vi.fn()
render(
<MenuContentPublishSection
{...createProps({
debugWithMultipleModel: true,
onPublish,
})}
/>,
)
await user.click(screen.getByText('publish-with-multiple-model'))
expect(onPublish).toHaveBeenCalledWith({ id: 'model-1' })
})
})

View File

@ -0,0 +1,114 @@
import type { ModelAndParameter } from '../../configuration/debug/types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PublishWithMultipleModel from '../publish-with-multiple-model'
let mockTextGenerationModelList: Array<{
provider: string
models: Array<{
model: string
label: Record<string, string>
}>
}>
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({
children,
open,
}: {
children: React.ReactNode
open: boolean
}) => <div data-testid="portal" data-open={String(open)}>{children}</div>,
PortalToFollowElemTrigger: ({
children,
onClick,
}: {
children: React.ReactNode
onClick?: () => void
}) => <div onClick={onClick}>{children}</div>,
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => <div data-testid="portal-content">{children}</div>,
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
textGenerationModelList: mockTextGenerationModelList,
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-icon', () => ({
default: ({ modelName }: { modelName: string }) => <div data-testid={`model-icon-${modelName}`}>{modelName}</div>,
}))
const validConfig: ModelAndParameter = {
id: 'config-1',
model: 'gpt-4o',
provider: 'openai',
parameters: {},
}
describe('PublishWithMultipleModel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTextGenerationModelList = [{
provider: 'openai',
models: [{
model: 'gpt-4o',
label: {
en_US: 'GPT-4o',
},
}],
}]
})
it('should disable the button when no valid model configuration matches the provider context', async () => {
const user = userEvent.setup()
render(
<PublishWithMultipleModel
multipleModelConfigs={[{ ...validConfig, provider: 'missing-provider' }]}
onSelect={vi.fn()}
/>,
)
const trigger = screen.getByRole('button', { name: 'appDebug.operation.applyConfig' })
expect(trigger).toBeDisabled()
await user.click(trigger)
expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'false')
})
it('should open the model list and forward the selected configuration', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(
<PublishWithMultipleModel
multipleModelConfigs={[validConfig]}
onSelect={onSelect}
/>,
)
const trigger = screen.getByRole('button', { name: 'appDebug.operation.applyConfig' })
expect(trigger).not.toBeDisabled()
await user.click(trigger)
expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'true')
expect(screen.getByText('appDebug.publishAs')).toBeInTheDocument()
expect(screen.getByText('GPT-4o')).toBeInTheDocument()
expect(screen.getByTestId('model-icon-gpt-4o')).toBeInTheDocument()
await user.click(screen.getByText('GPT-4o'))
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining(validConfig))
expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'false')
})
})

View File

@ -0,0 +1,486 @@
import type { AppPublisherProps } from '../index'
import type { AppDetailResponse } from '@/models/app'
import type { SystemFeatures } from '@/types/feature'
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { toast } from '@/app/components/base/ui/toast'
import { WorkflowContext } from '@/app/components/workflow/context'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
import { defaultSystemFeatures } from '@/types/feature'
import { basePath } from '@/utils/var'
import { useAppPublisher } from '../use-app-publisher'
const mockTrackEvent = vi.fn()
const mockGetSocket = vi.fn()
const mockRefetch = vi.fn()
const mockFetchAppDetailDirect = vi.fn()
const mockFetchInstalledAppList = vi.fn()
const mockFetchPublishedWorkflow = vi.fn()
const mockOpenAsyncWindow = vi.fn()
const mockPublishToCreatorsPlatform = vi.fn()
const mockInvalidateAppWorkflow = vi.fn()
const mockSetAppDetail = vi.fn()
const mockSetPublishedAt = vi.fn()
const mockUnsubscribe = vi.fn()
const mockWindowOpen = vi.fn()
let capturedShortcut: ((event: { preventDefault: () => void }) => void) | undefined
let capturedPublishUpdate: ((update: { data: { action?: string } }) => void) | undefined
let mockAppDetail: AppDetailResponse | undefined
let mockSystemFeatures: SystemFeatures
let mockAccessSubjects: { groups?: Array<{ id: string }>, members?: Array<{ id: string }> } | undefined
let mockUserCanAccessApp: { result?: boolean } | undefined
let mockGetUserCanAccessAppLoading = false
let mockAppWhiteListSubjectsLoading = false
vi.mock('ahooks', () => ({
useKeyPress: (_key: string, callback: (event: { preventDefault: () => void }) => void) => {
capturedShortcut = callback
},
}))
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
error: vi.fn(),
},
}))
vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({
collaborationManager: {
onAppPublishUpdate: (callback: (update: { data: { action?: string } }) => void) => {
capturedPublishUpdate = callback
return mockUnsubscribe
},
},
}))
vi.mock('@/app/components/workflow/collaboration/core/websocket-manager', () => ({
webSocketClient: {
getSocket: (...args: unknown[]) => mockGetSocket(...args),
},
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { appDetail?: AppDetailResponse, setAppDetail: typeof mockSetAppDetail }) => unknown) => selector({
appDetail: mockAppDetail,
setAppDetail: mockSetAppDetail,
}),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector: (state: { systemFeatures: SystemFeatures }) => unknown) => selector({
systemFeatures: mockSystemFeatures,
}),
}))
vi.mock('@/hooks/use-async-window-open', () => ({
useAsyncWindowOpen: () => mockOpenAsyncWindow,
}))
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: (time: number) => `from-now:${time}`,
}),
}))
vi.mock('@/service/access-control', () => ({
useAppWhiteListSubjects: () => ({
data: mockAccessSubjects,
isLoading: mockAppWhiteListSubjectsLoading,
}),
useGetUserCanAccessApp: () => ({
data: mockUserCanAccessApp,
isLoading: mockGetUserCanAccessAppLoading,
refetch: mockRefetch,
}),
}))
vi.mock('@/service/apps', () => ({
fetchAppDetailDirect: (...args: unknown[]) => mockFetchAppDetailDirect(...args),
}))
vi.mock('@/service/client', () => ({
consoleClient: {
apps: {
publishToCreatorsPlatform: (...args: unknown[]) => mockPublishToCreatorsPlatform(...args),
},
},
}))
vi.mock('@/service/explore', () => ({
fetchInstalledAppList: (...args: unknown[]) => mockFetchInstalledAppList(...args),
}))
vi.mock('@/service/use-workflow', () => ({
useInvalidateAppWorkflow: () => mockInvalidateAppWorkflow,
}))
vi.mock('@/service/workflow', () => ({
fetchPublishedWorkflow: (...args: unknown[]) => mockFetchPublishedWorkflow(...args),
}))
const createSystemFeatures = (overrides: Partial<SystemFeatures> = {}): SystemFeatures => ({
...defaultSystemFeatures,
...overrides,
branding: {
...defaultSystemFeatures.branding,
...overrides.branding,
},
license: {
...defaultSystemFeatures.license,
...overrides.license,
},
plugin_installation_permission: {
...defaultSystemFeatures.plugin_installation_permission,
...overrides.plugin_installation_permission,
},
webapp_auth: {
...defaultSystemFeatures.webapp_auth,
...overrides.webapp_auth,
sso_config: {
...defaultSystemFeatures.webapp_auth.sso_config,
...overrides.webapp_auth?.sso_config,
},
},
})
const createAppDetail = (overrides: Partial<AppDetailResponse> = {}): AppDetailResponse => ({
access_mode: AccessMode.PUBLIC,
description: 'Workflow description',
icon: '🤖',
icon_background: '#ffffff',
icon_type: 'emoji',
id: 'app-1',
mode: AppModeEnum.WORKFLOW,
name: 'Workflow app',
site: {
app_base_url: 'https://apps.example.com',
access_token: 'app-token',
},
...overrides,
} as AppDetailResponse)
const createProps = (overrides: Partial<AppPublisherProps> = {}): AppPublisherProps => ({
crossAxisOffset: 12,
debugWithMultipleModel: false,
draftUpdatedAt: 5678,
hasHumanInputNode: false,
hasTriggerNode: false,
inputs: [],
missingStartNode: false,
multipleModelConfigs: [],
onPublish: vi.fn(),
onRefreshData: vi.fn(),
onRestore: vi.fn(),
onToggle: vi.fn(),
outputs: [],
publishDisabled: false,
publishedAt: 1234,
publishLoading: false,
startNodeLimitExceeded: false,
toolPublished: false,
workflowToolAvailable: true,
...overrides,
})
const createWrapper = () => {
const store = {
getState: () => ({
setPublishedAt: mockSetPublishedAt,
}),
} as unknown as NonNullable<React.ContextType<typeof WorkflowContext>>
return ({ children }: { children: React.ReactNode }) => (
<WorkflowContext value={store}>
{children}
</WorkflowContext>
)
}
describe('useAppPublisher', () => {
beforeEach(() => {
vi.clearAllMocks()
capturedShortcut = undefined
capturedPublishUpdate = undefined
mockAppDetail = createAppDetail()
mockSystemFeatures = createSystemFeatures()
mockAccessSubjects = {
groups: [{ id: 'group-1' }],
members: [],
}
mockUserCanAccessApp = {
result: true,
}
mockGetUserCanAccessAppLoading = false
mockAppWhiteListSubjectsLoading = false
mockGetSocket.mockReturnValue({
emit: vi.fn(),
})
mockOpenAsyncWindow.mockImplementation(async (getUrl: () => Promise<string>, options?: { onError?: (error: Error) => void }) => {
try {
return await getUrl()
}
catch (error) {
options?.onError?.(error as Error)
}
})
mockPublishToCreatorsPlatform.mockResolvedValue({
redirect_url: 'https://marketplace.example.com/app-1',
})
mockFetchInstalledAppList.mockResolvedValue({
installed_apps: [{ id: 'installed-1' }],
})
mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ name: 'Updated app' }))
mockFetchPublishedWorkflow.mockResolvedValue({
created_at: 4321,
})
window.open = mockWindowOpen as typeof window.open
})
it('should expose derived app metadata and default action state', () => {
const { result } = renderHook(() => useAppPublisher(createProps()), {
wrapper: createWrapper(),
})
expect(result.current.appURL).toBe(`https://apps.example.com${basePath}/workflow/app-token`)
expect(result.current.isChatApp).toBe(false)
expect(result.current.disabledFunctionButton).toBe(false)
expect(result.current.disabledFunctionTooltip).toBeUndefined()
expect(result.current.workflowToolDisabled).toBe(false)
expect(result.current.workflowToolMessage).toBeUndefined()
expect(result.current.formatTimeFromNow(1234)).toBe('from-now:1234')
})
it('should derive access warnings and refetch when the popover opens', async () => {
mockSystemFeatures = createSystemFeatures({
webapp_auth: {
...defaultSystemFeatures.webapp_auth,
enabled: true,
},
})
mockAppDetail = createAppDetail({
access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
})
mockAccessSubjects = {
groups: [],
members: [],
}
mockUserCanAccessApp = {
result: false,
}
const onToggle = vi.fn()
const { result } = renderHook(() => useAppPublisher(createProps({ onToggle })), {
wrapper: createWrapper(),
})
expect(result.current.isAppAccessSet).toBe(false)
expect(result.current.disabledFunctionButton).toBe(true)
expect(result.current.disabledFunctionTooltip).toBe('app.noAccessPermission')
act(() => {
result.current.handleTrigger()
})
expect(result.current.open).toBe(true)
expect(onToggle).toHaveBeenCalledWith(true)
expect(mockRefetch).toHaveBeenCalledTimes(1)
})
it('should publish through the keyboard shortcut and emit collaboration updates', async () => {
const onPublish = vi.fn().mockResolvedValue(undefined)
const emit = vi.fn()
mockGetSocket.mockReturnValue({ emit })
const { result } = renderHook(() => useAppPublisher(createProps({ onPublish })), {
wrapper: createWrapper(),
})
await act(async () => {
await capturedShortcut?.({ preventDefault: vi.fn() })
})
expect(onPublish).toHaveBeenCalledTimes(1)
expect(result.current.published).toBe(true)
expect(mockInvalidateAppWorkflow).toHaveBeenCalledWith('app-1')
expect(emit).toHaveBeenCalledWith('collaboration_event', expect.objectContaining({
type: 'app_publish_update',
data: expect.objectContaining({ action: 'published' }),
}))
expect(mockTrackEvent).toHaveBeenCalledWith('app_published_time', expect.objectContaining({
app_id: 'app-1',
app_name: 'Workflow app',
}))
})
it('should keep the menu closed when restore finishes and swallow restore failures', async () => {
const onRestore = vi.fn()
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('restore failed'))
const { result } = renderHook(() => useAppPublisher(createProps({ onRestore })), {
wrapper: createWrapper(),
})
act(() => {
result.current.handleTrigger()
})
await act(async () => {
await result.current.handleRestore()
})
expect(result.current.open).toBe(false)
act(() => {
result.current.handleTrigger()
})
await act(async () => {
await result.current.handleRestore()
})
expect(result.current.open).toBe(true)
})
it('should open the embedding modal, refresh app access, and close both overlays', async () => {
const { result } = renderHook(() => useAppPublisher(createProps()), {
wrapper: createWrapper(),
})
act(() => {
result.current.handleTrigger()
})
act(() => {
result.current.handleOpenEmbedding()
})
expect(result.current.embeddingModalOpen).toBe(true)
expect(result.current.open).toBe(false)
act(() => {
result.current.showAppAccessControlModal()
})
expect(result.current.showAppAccessControl).toBe(true)
await act(async () => {
await result.current.handleAccessControlUpdate()
})
expect(mockFetchAppDetailDirect).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
expect(mockSetAppDetail).toHaveBeenCalledWith(expect.objectContaining({ name: 'Updated app' }))
expect(result.current.showAppAccessControl).toBe(false)
act(() => {
result.current.closeEmbeddingModal()
result.current.closeAppAccessControl()
})
expect(result.current.embeddingModalOpen).toBe(false)
expect(result.current.showAppAccessControl).toBe(false)
})
it('should resolve the explore URL and surface window-open errors via toast', async () => {
const { result } = renderHook(() => useAppPublisher(createProps()), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleOpenInExplore()
})
expect(mockFetchInstalledAppList).toHaveBeenCalledWith('app-1')
mockFetchInstalledAppList.mockResolvedValueOnce({ installed_apps: [] })
await act(async () => {
await result.current.handleOpenInExplore()
})
expect(toast.error).toHaveBeenCalledWith('No app found in Explore')
})
it('should publish to marketplace and reset loading after failures', async () => {
const { result } = renderHook(() => useAppPublisher(createProps()), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handlePublishToMarketplace()
})
expect(mockPublishToCreatorsPlatform).toHaveBeenCalledWith({
params: { appId: 'app-1' },
})
expect(mockWindowOpen).toHaveBeenCalledWith('https://marketplace.example.com/app-1', '_blank')
expect(result.current.publishingToMarketplace).toBe(false)
mockPublishToCreatorsPlatform.mockRejectedValueOnce(new Error('publish failed'))
await act(async () => {
await result.current.handlePublishToMarketplace()
})
expect(toast.error).toHaveBeenCalledWith('publish failed')
expect(result.current.publishingToMarketplace).toBe(false)
})
it('should ignore marketplace publishing when the app id is missing', async () => {
mockAppDetail = createAppDetail({ id: '' })
const { result } = renderHook(() => useAppPublisher(createProps()), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handlePublishToMarketplace()
})
expect(mockPublishToCreatorsPlatform).not.toHaveBeenCalled()
})
it('should refresh published workflow timestamps from collaboration events and unsubscribe on unmount', async () => {
const { unmount } = renderHook(() => useAppPublisher(createProps()), {
wrapper: createWrapper(),
})
await act(async () => {
capturedPublishUpdate?.({ data: { action: 'published' } })
})
await waitFor(() => {
expect(mockFetchPublishedWorkflow).toHaveBeenCalledWith('/apps/app-1/workflows/publish')
})
expect(mockSetPublishedAt).toHaveBeenCalledWith(4321)
unmount()
expect(mockUnsubscribe).toHaveBeenCalledTimes(1)
})
it('should warn when publish succeeds without an app id or socket', async () => {
mockAppDetail = createAppDetail({ id: '' })
mockGetSocket.mockReturnValue(null)
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { result } = renderHook(() => useAppPublisher(createProps()), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handlePublish()
})
expect(mockInvalidateAppWorkflow).not.toHaveBeenCalled()
expect(consoleWarnSpy).toHaveBeenCalledWith('[app-publisher] missing appId, skip workflow invalidate and socket emit')
consoleWarnSpy.mockRestore()
})
})

View File

@ -1,51 +1,18 @@
import type { ModelAndParameter } from '../configuration/debug/types'
import type { CollaborationUpdate } from '@/app/components/workflow/collaboration/types/collaboration'
import type { InputVar, Variable } from '@/app/components/workflow/types'
import type { InstalledApp } from '@/models/explore'
import type { PublishWorkflowParams } from '@/types/workflow'
import { useKeyPress } from 'ahooks'
import {
memo,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import EmbeddedModal from '@/app/components/app/overview/embedded'
import { useStore as useAppStore } from '@/app/components/app/store'
import { trackEvent } from '@/app/components/base/amplitude'
import Button from '@/app/components/base/button'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { toast } from '@/app/components/base/ui/toast'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
import { WorkflowContext } from '@/app/components/workflow/context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { AccessMode } from '@/models/access-control'
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control'
import { fetchAppDetailDirect } from '@/service/apps'
import { consoleClient } from '@/service/client'
import { fetchInstalledAppList } from '@/service/explore'
import { useInvalidateAppWorkflow } from '@/service/use-workflow'
import { fetchPublishedWorkflow } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
import { getKeyboardKeyCodeBySystem } from '../../workflow/utils'
import AccessControl from '../app-access-control'
import AppPublisherMenuContent from './menu-content'
type InstalledAppsResponse = {
installed_apps?: InstalledApp[]
}
import { useAppPublisher } from './use-app-publisher'
export type AppPublisherProps = {
disabled?: boolean
@ -95,219 +62,46 @@ const AppPublisher = ({
hasHumanInputNode = false,
}: AppPublisherProps) => {
const { t } = useTranslation()
const [published, setPublished] = useState(false)
const [open, setOpen] = useState(false)
const [showAppAccessControl, setShowAppAccessControl] = useState(false)
const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false)
const [publishingToMarketplace, setPublishingToMarketplace] = useState(false)
const workflowStore = useContext(WorkflowContext)
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(s => s.setAppDetail)
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const { formatTimeFromNow } = useFormatTimeFromNow()
const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {}
const appMode = (appDetail?.mode !== AppModeEnum.COMPLETION && appDetail?.mode !== AppModeEnum.WORKFLOW) ? AppModeEnum.CHAT : appDetail.mode
const appURL = `${appBaseURL}${basePath}/${appMode}/${accessToken}`
const isChatApp = [AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.COMPLETION].includes(appDetail?.mode || AppModeEnum.CHAT)
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp, refetch } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false })
const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS)
const invalidateAppWorkflow = useInvalidateAppWorkflow()
const openAsyncWindow = useAsyncWindowOpen()
const isAppAccessSet = useMemo(() => {
if (appDetail && appAccessSubjects) {
return !(appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && appAccessSubjects.groups?.length === 0 && appAccessSubjects.members?.length === 0)
}
return true
}, [appAccessSubjects, appDetail])
const noAccessPermission = useMemo(() => Boolean(
systemFeatures.webapp_auth.enabled
&& appDetail
&& appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS
&& !userCanAccessApp?.result,
), [systemFeatures, appDetail, userCanAccessApp])
const disabledFunctionButton = useMemo(() => (!publishedAt || missingStartNode || noAccessPermission), [publishedAt, missingStartNode, noAccessPermission])
const disabledFunctionTooltip = useMemo(() => {
if (!publishedAt)
return t('notPublishedYet', { ns: 'app' })
if (missingStartNode)
return t('noUserInputNode', { ns: 'app' })
if (noAccessPermission)
return t('noAccessPermission', { ns: 'app' })
}, [missingStartNode, noAccessPermission, publishedAt, t])
useEffect(() => {
if (systemFeatures.webapp_auth.enabled && open && appDetail)
refetch()
}, [open, appDetail, refetch, systemFeatures])
const handlePublish = useCallback(async (params?: ModelAndParameter | PublishWorkflowParams) => {
try {
await onPublish?.(params)
setPublished(true)
const appId = appDetail?.id
const socket = appId ? webSocketClient.getSocket(appId) : null
if (appId)
invalidateAppWorkflow(appId)
else
console.warn('[app-publisher] missing appId, skip workflow invalidate and socket emit')
if (socket) {
const timestamp = Date.now()
socket.emit('collaboration_event', {
type: 'app_publish_update',
data: {
action: 'published',
timestamp,
},
timestamp,
})
}
else if (appId) {
console.warn('[app-publisher] socket not ready, skip collaboration_event emit', { appId })
}
trackEvent('app_published_time', { action_mode: 'app', app_id: appDetail?.id, app_name: appDetail?.name })
}
catch (error) {
console.warn('[app-publisher] publish failed', error)
setPublished(false)
}
}, [appDetail, onPublish, invalidateAppWorkflow])
const handleRestore = useCallback(async () => {
try {
await onRestore?.()
setOpen(false)
}
catch { }
}, [onRestore])
const handleTrigger = useCallback(() => {
const state = !open
if (disabled) {
setOpen(false)
return
}
onToggle?.(state)
setOpen(state)
if (state)
setPublished(false)
}, [disabled, onToggle, open])
const handleOpenInExplore = useCallback(async () => {
await openAsyncWindow(async () => {
if (!appDetail?.id)
throw new Error('App not found')
const response = (await fetchInstalledAppList(appDetail?.id)) as InstalledAppsResponse
const installedApps = response?.installed_apps
if (installedApps?.length)
return `${basePath}/explore/installed/${installedApps[0].id}`
throw new Error('No app found in Explore')
}, {
onError: (err) => {
toast.error(`${err.message || err}`)
},
})
}, [appDetail?.id, openAsyncWindow])
const handleAccessControlUpdate = useCallback(async () => {
if (!appDetail)
return
try {
const res = await fetchAppDetailDirect({ url: '/apps', id: appDetail.id })
setAppDetail(res)
}
finally {
setShowAppAccessControl(false)
}
}, [appDetail, setAppDetail])
const handlePublishToMarketplace = useCallback(async () => {
if (!appDetail?.id || publishingToMarketplace)
return
setPublishingToMarketplace(true)
try {
const result = await consoleClient.apps.publishToCreatorsPlatform({
params: { appId: appDetail.id },
})
window.open(result.redirect_url, '_blank')
}
catch (error: any) {
toast.error(error.message || t('common.publishToMarketplaceFailed', { ns: 'workflow' }))
}
finally {
setPublishingToMarketplace(false)
}
}, [appDetail?.id, publishingToMarketplace, t])
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => {
e.preventDefault()
if (publishDisabled || published || publishLoading)
return
handlePublish()
}, { exactMatch: true, useCapture: true })
useEffect(() => {
const appId = appDetail?.id
if (!appId)
return
const unsubscribe = collaborationManager.onAppPublishUpdate((update: CollaborationUpdate) => {
const action = typeof update.data.action === 'string' ? update.data.action : undefined
if (action === 'published') {
invalidateAppWorkflow(appId)
fetchPublishedWorkflow(`/apps/${appId}/workflows/publish`)
.then((publishedWorkflow) => {
if (publishedWorkflow?.created_at)
workflowStore?.getState().setPublishedAt(publishedWorkflow.created_at)
})
.catch((error) => {
console.warn('[app-publisher] refresh published workflow failed', error)
})
}
})
return unsubscribe
}, [appDetail?.id, invalidateAppWorkflow, workflowStore])
const hasPublishedVersion = !!publishedAt
const workflowToolDisabled = !hasPublishedVersion || !workflowToolAvailable
const workflowToolMessage = workflowToolDisabled ? t('common.workflowAsToolDisabledHint', { ns: 'workflow' }) : undefined
const upgradeHighlightStyle = useMemo(() => ({
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',
}), [])
const state = useAppPublisher({
disabled,
publishDisabled,
publishedAt,
draftUpdatedAt,
debugWithMultipleModel,
multipleModelConfigs,
onPublish,
onRestore,
onToggle,
crossAxisOffset,
toolPublished,
inputs,
outputs,
onRefreshData,
workflowToolAvailable,
missingStartNode,
hasTriggerNode,
startNodeLimitExceeded,
publishLoading,
hasHumanInputNode,
})
return (
<>
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
open={state.open}
onOpenChange={state.setOpen}
placement="bottom-end"
offset={{
mainAxis: 4,
crossAxis: crossAxisOffset,
crossAxis: state.crossAxisOffset,
}}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<PortalToFollowElemTrigger onClick={state.handleTrigger}>
<Button
variant="primary"
className="py-2 pl-3 pr-2"
disabled={disabled || publishLoading}
loading={publishLoading}
disabled={state.disabled || state.publishLoading}
loading={state.publishLoading}
>
{t('common.publish', { ns: 'workflow' })}
<span className="i-ri-arrow-down-s-line h-4 w-4 text-components-button-primary-text" />
@ -315,55 +109,58 @@ const AppPublisher = ({
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[11]">
<AppPublisherMenuContent
publishedAt={publishedAt}
draftUpdatedAt={draftUpdatedAt}
debugWithMultipleModel={debugWithMultipleModel}
multipleModelConfigs={multipleModelConfigs}
publishDisabled={publishDisabled}
publishLoading={publishLoading}
toolPublished={toolPublished}
inputs={inputs}
outputs={outputs}
onRefreshData={onRefreshData}
workflowToolAvailable={workflowToolAvailable}
hasTriggerNode={hasTriggerNode}
missingStartNode={missingStartNode}
startNodeLimitExceeded={startNodeLimitExceeded}
hasHumanInputNode={hasHumanInputNode}
appDetail={appDetail}
appURL={appURL}
disabledFunctionButton={disabledFunctionButton}
disabledFunctionTooltip={disabledFunctionTooltip}
formatTimeFromNow={formatTimeFromNow}
isAppAccessSet={isAppAccessSet}
isChatApp={isChatApp}
isGettingAppWhiteListSubjects={isGettingAppWhiteListSubjects}
isGettingUserCanAccessApp={isGettingUserCanAccessApp}
onOpenEmbedding={() => {
setEmbeddingModalOpen(true)
handleTrigger()
}}
onOpenInExplore={handleOpenInExplore}
onPublish={handlePublish}
onPublishToMarketplace={handlePublishToMarketplace}
onRestore={handleRestore}
onShowAppAccessControl={() => setShowAppAccessControl(true)}
published={published}
publishingToMarketplace={publishingToMarketplace}
systemFeatures={systemFeatures}
upgradeHighlightStyle={upgradeHighlightStyle}
workflowToolDisabled={workflowToolDisabled}
workflowToolMessage={workflowToolMessage}
publishedAt={state.publishedAt}
draftUpdatedAt={state.draftUpdatedAt}
debugWithMultipleModel={state.debugWithMultipleModel}
multipleModelConfigs={state.multipleModelConfigs}
publishDisabled={state.publishDisabled}
publishLoading={state.publishLoading}
toolPublished={state.toolPublished}
inputs={state.inputs}
outputs={state.outputs}
onRefreshData={state.onRefreshData}
workflowToolAvailable={state.workflowToolAvailable}
hasTriggerNode={state.hasTriggerNode}
missingStartNode={state.missingStartNode}
startNodeLimitExceeded={state.startNodeLimitExceeded}
hasHumanInputNode={state.hasHumanInputNode}
appDetail={state.appDetail}
appURL={state.appURL}
disabledFunctionButton={state.disabledFunctionButton}
disabledFunctionTooltip={state.disabledFunctionTooltip}
formatTimeFromNow={state.formatTimeFromNow}
isAppAccessSet={state.isAppAccessSet}
isChatApp={state.isChatApp}
isGettingAppWhiteListSubjects={state.isGettingAppWhiteListSubjects}
isGettingUserCanAccessApp={state.isGettingUserCanAccessApp}
onOpenEmbedding={state.handleOpenEmbedding}
onOpenInExplore={state.handleOpenInExplore}
onPublish={state.handlePublish}
onPublishToMarketplace={state.handlePublishToMarketplace}
onRestore={state.handleRestore}
onShowAppAccessControl={state.showAppAccessControlModal}
published={state.published}
publishingToMarketplace={state.publishingToMarketplace}
systemFeatures={state.systemFeatures}
upgradeHighlightStyle={state.upgradeHighlightStyle}
workflowToolDisabled={state.workflowToolDisabled}
workflowToolMessage={state.workflowToolMessage}
/>
</PortalToFollowElemContent>
<EmbeddedModal
siteInfo={appDetail?.site}
isShow={embeddingModalOpen}
onClose={() => setEmbeddingModalOpen(false)}
appBaseUrl={appBaseURL}
accessToken={accessToken}
siteInfo={state.appDetail?.site}
isShow={state.embeddingModalOpen}
onClose={state.closeEmbeddingModal}
appBaseUrl={state.appBaseURL}
accessToken={state.accessToken}
/>
{showAppAccessControl && <AccessControl app={appDetail!} onConfirm={handleAccessControlUpdate} onClose={() => { setShowAppAccessControl(false) }} />}
{state.showAppAccessControl && state.appDetail && (
<AccessControl
app={state.appDetail}
onConfirm={state.handleAccessControlUpdate}
onClose={state.closeAppAccessControl}
/>
)}
</PortalToFollowElem>
</>
)

View File

@ -0,0 +1,368 @@
'use client'
import type { CSSProperties } from 'react'
import type { ModelAndParameter } from '../configuration/debug/types'
import type { AppPublisherProps } from './index'
import type { CollaborationUpdate } from '@/app/components/workflow/collaboration/types/collaboration'
import type { InstalledApp } from '@/models/explore'
import type { PublishWorkflowParams } from '@/types/workflow'
import { useKeyPress } from 'ahooks'
import { use, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { trackEvent } from '@/app/components/base/amplitude'
import { toast } from '@/app/components/base/ui/toast'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
import { WorkflowContext } from '@/app/components/workflow/context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { AccessMode } from '@/models/access-control'
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control'
import { fetchAppDetailDirect } from '@/service/apps'
import { consoleClient } from '@/service/client'
import { fetchInstalledAppList } from '@/service/explore'
import { useInvalidateAppWorkflow } from '@/service/use-workflow'
import { fetchPublishedWorkflow } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
import { getKeyboardKeyCodeBySystem } from '../../workflow/utils'
type InstalledAppsResponse = {
installed_apps?: InstalledApp[]
}
const upgradeHighlightStyle: CSSProperties = {
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',
}
export const useAppPublisher = ({
disabled = false,
publishDisabled = false,
publishedAt,
draftUpdatedAt,
debugWithMultipleModel = false,
multipleModelConfigs = [],
onPublish,
onRestore,
onToggle,
crossAxisOffset = 0,
toolPublished,
inputs,
outputs,
onRefreshData,
workflowToolAvailable = true,
missingStartNode = false,
hasTriggerNode = false,
startNodeLimitExceeded = false,
publishLoading = false,
hasHumanInputNode = false,
}: AppPublisherProps) => {
const { t } = useTranslation()
const [published, setPublished] = useState(false)
const [open, setOpen] = useState(false)
const [showAppAccessControl, setShowAppAccessControl] = useState(false)
const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false)
const [publishingToMarketplace, setPublishingToMarketplace] = useState(false)
const workflowStore = use(WorkflowContext)
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(state => state.setAppDetail)
const systemFeatures = useGlobalPublicStore(state => state.systemFeatures)
const { formatTimeFromNow } = useFormatTimeFromNow()
const openAsyncWindow = useAsyncWindowOpen()
const invalidateAppWorkflow = useInvalidateAppWorkflow()
const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {}
const appMode = appDetail?.mode !== AppModeEnum.COMPLETION && appDetail?.mode !== AppModeEnum.WORKFLOW
? AppModeEnum.CHAT
: appDetail?.mode
const appURL = `${appBaseURL}${basePath}/${appMode}/${accessToken}`
const isChatApp = [
AppModeEnum.CHAT,
AppModeEnum.AGENT_CHAT,
AppModeEnum.COMPLETION,
].includes(appDetail?.mode || AppModeEnum.CHAT)
const {
data: userCanAccessApp,
isLoading: isGettingUserCanAccessApp,
refetch,
} = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false })
const {
data: appAccessSubjects,
isLoading: isGettingAppWhiteListSubjects,
} = useAppWhiteListSubjects(
appDetail?.id,
open
&& systemFeatures.webapp_auth.enabled
&& appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS,
)
const isAppAccessSet = useMemo(() => {
if (appDetail && appAccessSubjects) {
return !(
appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS
&& appAccessSubjects.groups?.length === 0
&& appAccessSubjects.members?.length === 0
)
}
return true
}, [appAccessSubjects, appDetail])
const noAccessPermission = useMemo(() => Boolean(
systemFeatures.webapp_auth.enabled
&& appDetail
&& appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS
&& !userCanAccessApp?.result,
), [systemFeatures, appDetail, userCanAccessApp])
const disabledFunctionButton = !publishedAt || missingStartNode || noAccessPermission
const disabledFunctionTooltip = !publishedAt
? t('notPublishedYet', { ns: 'app' })
: missingStartNode
? t('noUserInputNode', { ns: 'app' })
: noAccessPermission
? t('noAccessPermission', { ns: 'app' })
: undefined
const workflowToolDisabled = !publishedAt || !workflowToolAvailable
const workflowToolMessage = workflowToolDisabled
? t('common.workflowAsToolDisabledHint', { ns: 'workflow' })
: undefined
useEffect(() => {
if (systemFeatures.webapp_auth.enabled && open && appDetail)
refetch()
}, [appDetail, open, refetch, systemFeatures])
const handlePublish = useCallback(async (params?: ModelAndParameter | PublishWorkflowParams) => {
try {
await onPublish?.(params)
setPublished(true)
const appId = appDetail?.id
const socket = appId ? webSocketClient.getSocket(appId) : null
if (appId)
invalidateAppWorkflow(appId)
else
console.warn('[app-publisher] missing appId, skip workflow invalidate and socket emit')
if (socket) {
const timestamp = Date.now()
socket.emit('collaboration_event', {
type: 'app_publish_update',
data: {
action: 'published',
timestamp,
},
timestamp,
})
}
else if (appId) {
console.warn('[app-publisher] socket not ready, skip collaboration_event emit', { appId })
}
trackEvent('app_published_time', {
action_mode: 'app',
app_id: appDetail?.id,
app_name: appDetail?.name,
})
}
catch (error) {
console.warn('[app-publisher] publish failed', error)
setPublished(false)
}
}, [appDetail, invalidateAppWorkflow, onPublish])
const handleRestore = useCallback(async () => {
try {
await onRestore?.()
setOpen(false)
}
catch {}
}, [onRestore])
const handleTrigger = useCallback(() => {
const nextOpen = !open
if (disabled) {
setOpen(false)
return
}
onToggle?.(nextOpen)
setOpen(nextOpen)
if (nextOpen)
setPublished(false)
}, [disabled, onToggle, open])
const handleOpenEmbedding = useCallback(() => {
setEmbeddingModalOpen(true)
handleTrigger()
}, [handleTrigger])
const handleOpenInExplore = useCallback(async () => {
await openAsyncWindow(async () => {
if (!appDetail?.id)
throw new Error('App not found')
const response = await fetchInstalledAppList(appDetail.id) as InstalledAppsResponse
const installedApps = response?.installed_apps
if (installedApps?.length)
return `${basePath}/explore/installed/${installedApps[0].id}`
throw new Error('No app found in Explore')
}, {
onError: (error) => {
toast.error(`${error.message || error}`)
},
})
}, [appDetail?.id, openAsyncWindow])
const handleAccessControlUpdate = useCallback(async () => {
if (!appDetail)
return
try {
const nextAppDetail = await fetchAppDetailDirect({ url: '/apps', id: appDetail.id })
setAppDetail(nextAppDetail)
}
finally {
setShowAppAccessControl(false)
}
}, [appDetail, setAppDetail])
const handlePublishToMarketplace = useCallback(async () => {
if (!appDetail?.id || publishingToMarketplace)
return
setPublishingToMarketplace(true)
try {
const result = await consoleClient.apps.publishToCreatorsPlatform({
params: { appId: appDetail.id },
})
window.open(result.redirect_url, '_blank')
}
catch (error) {
const errorMessage = typeof error === 'object'
&& error !== null
&& 'message' in error
&& typeof error.message === 'string'
? error.message
: t('common.publishToMarketplaceFailed', { ns: 'workflow' })
toast.error(errorMessage)
}
finally {
setPublishingToMarketplace(false)
}
}, [appDetail?.id, publishingToMarketplace, t])
const closeEmbeddingModal = useCallback(() => {
setEmbeddingModalOpen(false)
}, [])
const showAppAccessControlModal = useCallback(() => {
setShowAppAccessControl(true)
}, [])
const closeAppAccessControl = useCallback(() => {
setShowAppAccessControl(false)
}, [])
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (event) => {
event.preventDefault()
if (publishDisabled || published || publishLoading)
return
handlePublish()
}, { exactMatch: true, useCapture: true })
useEffect(() => {
const appId = appDetail?.id
if (!appId)
return
const unsubscribe = collaborationManager.onAppPublishUpdate((update: CollaborationUpdate) => {
const action = typeof update.data.action === 'string' ? update.data.action : undefined
if (action === 'published') {
invalidateAppWorkflow(appId)
fetchPublishedWorkflow(`/apps/${appId}/workflows/publish`)
.then((publishedWorkflow) => {
if (publishedWorkflow?.created_at)
workflowStore?.getState().setPublishedAt(publishedWorkflow.created_at)
})
.catch((error) => {
console.warn('[app-publisher] refresh published workflow failed', error)
})
}
})
return unsubscribe
}, [appDetail?.id, invalidateAppWorkflow, workflowStore])
return {
accessToken,
appBaseURL,
appDetail,
appURL,
closeAppAccessControl,
closeEmbeddingModal,
crossAxisOffset,
debugWithMultipleModel,
disabled,
disabledFunctionButton,
disabledFunctionTooltip,
draftUpdatedAt,
embeddingModalOpen,
formatTimeFromNow,
handleAccessControlUpdate,
handleOpenEmbedding,
handleOpenInExplore,
handlePublish,
handlePublishToMarketplace,
handleRestore,
handleTrigger,
hasHumanInputNode,
hasTriggerNode,
inputs,
isAppAccessSet,
isChatApp,
isGettingAppWhiteListSubjects,
isGettingUserCanAccessApp,
missingStartNode,
multipleModelConfigs,
onRefreshData,
open,
outputs,
publishDisabled,
publishLoading,
published,
publishedAt,
publishingToMarketplace,
setOpen,
showAppAccessControl,
showAppAccessControlModal,
startNodeLimitExceeded,
systemFeatures,
toolPublished,
upgradeHighlightStyle,
workflowToolAvailable,
workflowToolDisabled,
workflowToolMessage,
}
}

View File

@ -0,0 +1,237 @@
import type { ExternalDataTool } from '@/models/common'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { toast } from '@/app/components/base/ui/toast'
import ExternalDataToolModal from '../external-data-tool-modal'
let mockLocale = 'en-US'
vi.mock('@/app/components/base/app-icon', () => ({
default: ({
icon,
onClick,
}: {
icon?: string
onClick?: () => void
}) => (
<button data-testid="app-icon" onClick={onClick}>
{icon || 'empty-icon'}
</button>
),
}))
vi.mock('@/app/components/base/emoji-picker', () => ({
default: ({
onClose,
onSelect,
}: {
onClose: () => void
onSelect: (icon: string, iconBackground: string) => void
}) => (
<div data-testid="emoji-picker">
<button onClick={() => onSelect('😎', '#000000')}>select-emoji</button>
<button onClick={onClose}>close-emoji</button>
</div>
),
}))
vi.mock('@/app/components/base/features/new-feature-panel/moderation/form-generation', () => ({
default: ({
onChange,
}: {
onChange: (value: Record<string, string>) => void
}) => <button onClick={() => onChange({ region: 'us' })}>fill-form</button>,
}))
vi.mock('@/app/components/base/ui/dialog', () => ({
Dialog: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/app/components/base/ui/select', () => ({
Select: ({
children,
onValueChange,
}: {
children: React.ReactNode
onValueChange?: (value: string) => void
}) => (
<div>
{children}
<button onClick={() => onValueChange?.('custom-tool')}>select-custom-tool</button>
<button onClick={() => onValueChange?.('api')}>select-api</button>
</div>
),
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectTrigger: ({
children,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button {...props}>{children}</button>,
SelectValue: () => <span>select-value</span>,
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
error: vi.fn(),
},
}))
vi.mock('@/app/components/header/account-setting/api-based-extension-page/selector', () => ({
default: ({ onChange }: { onChange: (value: string) => void }) => (
<button onClick={() => onChange('extension-1')}>select-api-extension</button>
),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
useLocale: () => mockLocale,
}))
vi.mock('@/service/use-common', () => ({
useCodeBasedExtensions: () => ({
data: {
data: [{
form_schema: [{
default: 'default-region',
label: {
'en-US': 'Region',
'zh-Hans': '地区',
},
required: true,
variable: 'region',
}],
label: {
'en-US': 'Custom Tool',
'zh-Hans': '自定义工具',
},
name: 'custom-tool',
}],
},
}),
}))
describe('ExternalDataToolModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockLocale = 'en-US'
})
it('should save api-based tools after validating the selected extension', async () => {
const user = userEvent.setup()
const onSave = vi.fn()
const onValidateBeforeSave = vi.fn().mockReturnValue(true)
render(
<ExternalDataToolModal
data={{} as ExternalDataTool}
onCancel={vi.fn()}
onSave={onSave}
onValidateBeforeSave={onValidateBeforeSave}
/>,
)
expect(screen.getByRole('link', { name: 'common.apiBasedExtension.link' })).toHaveAttribute(
'href',
'https://docs.example.com/use-dify/workspace/api-extension/api-extension',
)
await user.type(screen.getByPlaceholderText('appDebug.feature.tools.modal.name.placeholder'), 'Search tool')
await user.type(screen.getByPlaceholderText('appDebug.feature.tools.modal.variableName.placeholder'), 'search_tool')
await user.click(screen.getByText('select-api-extension'))
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onValidateBeforeSave).toHaveBeenCalledWith(expect.objectContaining({
config: {
api_based_extension_id: 'extension-1',
},
enabled: true,
label: 'Search tool',
type: 'api',
variable: 'search_tool',
}))
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
config: {
api_based_extension_id: 'extension-1',
},
enabled: true,
label: 'Search tool',
type: 'api',
variable: 'search_tool',
}))
})
it('should reject invalid variable names before save', async () => {
const user = userEvent.setup()
render(
<ExternalDataToolModal
data={{} as ExternalDataTool}
onCancel={vi.fn()}
onSave={vi.fn()}
/>,
)
await user.type(screen.getByPlaceholderText('appDebug.feature.tools.modal.name.placeholder'), 'Search tool')
await user.type(screen.getByPlaceholderText('appDebug.feature.tools.modal.variableName.placeholder'), 'invalid-key!')
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('notValid'))
})
it('should allow selecting emojis and saving custom providers with generated form data', async () => {
const user = userEvent.setup()
const onSave = vi.fn()
render(
<ExternalDataToolModal
data={{} as ExternalDataTool}
onCancel={vi.fn()}
onSave={onSave}
/>,
)
await user.click(screen.getByText('select-custom-tool'))
await user.type(screen.getByPlaceholderText('appDebug.feature.tools.modal.name.placeholder'), 'Custom tool')
await user.type(screen.getByPlaceholderText('appDebug.feature.tools.modal.variableName.placeholder'), 'custom_tool')
await user.click(screen.getByTestId('app-icon'))
await user.click(screen.getByText('select-emoji'))
await user.click(screen.getByText('fill-form'))
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
config: {
region: 'us',
},
enabled: true,
icon: '😎',
icon_background: '#000000',
type: 'custom-tool',
variable: 'custom_tool',
}))
})
it('should stop before saving when the caller rejects the formatted payload', async () => {
const user = userEvent.setup()
const onSave = vi.fn()
const onValidateBeforeSave = vi.fn().mockReturnValue(false)
render(
<ExternalDataToolModal
data={{} as ExternalDataTool}
onCancel={vi.fn()}
onSave={onSave}
onValidateBeforeSave={onValidateBeforeSave}
/>,
)
await user.type(screen.getByPlaceholderText('appDebug.feature.tools.modal.name.placeholder'), 'Search tool')
await user.type(screen.getByPlaceholderText('appDebug.feature.tools.modal.variableName.placeholder'), 'search_tool')
await user.click(screen.getByText('select-api-extension'))
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onValidateBeforeSave).toHaveBeenCalledTimes(1)
expect(onSave).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,190 @@
import type { ExternalDataToolProvider } from '../helpers'
import type { CodeBasedExtensionForm, ExternalDataTool } from '@/models/common'
import { describe, expect, it } from 'vitest'
import { LanguagesSupported } from '@/i18n-config/language'
import {
findExternalDataToolVariableConflict,
formatExternalDataToolForSave,
getExternalDataToolDefaultConfig,
getExternalDataToolValidationError,
getInitialExternalDataTool,
removeExternalDataTool,
upsertExternalDataTool,
} from '../helpers'
const createProviderField = (overrides: Partial<CodeBasedExtensionForm> = {}): CodeBasedExtensionForm => ({
default: 'default-region',
label: {
'en-US': 'Region',
'zh-Hans': '地区',
} as CodeBasedExtensionForm['label'],
options: [],
placeholder: '',
required: true,
type: 'text-input',
variable: 'region',
...overrides,
})
const provider: ExternalDataToolProvider = {
key: 'custom-tool',
name: 'Custom tool',
form_schema: [createProviderField()],
}
const createTool = (overrides: Partial<ExternalDataTool> = {}): ExternalDataTool => ({
config: {},
icon: '🤖',
icon_background: '#fff',
label: 'External tool',
type: 'api',
variable: 'tool_var',
...overrides,
})
describe('configuration/tools/helpers', () => {
it('should initialize new tools with the api type', () => {
expect(getInitialExternalDataTool({} as ExternalDataTool)).toEqual({
type: 'api',
})
expect(getInitialExternalDataTool(createTool({ type: 'custom-tool' }))).toEqual(createTool({ type: 'custom-tool' }))
})
it('should derive default configs from non-system providers only', () => {
expect(getExternalDataToolDefaultConfig('api', [provider])).toBeUndefined()
expect(getExternalDataToolDefaultConfig('custom-tool', [provider])).toEqual({
region: 'default-region',
})
expect(getExternalDataToolDefaultConfig('missing-provider', [provider])).toBeUndefined()
})
it('should upsert and remove external data tools by index', () => {
const first = createTool({ label: 'First' })
const second = createTool({ label: 'Second', variable: 'second_var' })
expect(upsertExternalDataTool([first], second, -1)).toEqual([first, second])
expect(upsertExternalDataTool([first, second], createTool({ label: 'Updated second', variable: 'second_var' }), 1)).toEqual([
first,
createTool({ label: 'Updated second', variable: 'second_var' }),
])
expect(removeExternalDataTool([first, second], 0)).toEqual([second])
})
it('should detect conflicts with prompt variables and other external tools', () => {
const existing = [
createTool(),
createTool({ label: 'Second', variable: 'second_var' }),
]
expect(findExternalDataToolVariableConflict(undefined, existing, [], -1)).toBeUndefined()
expect(findExternalDataToolVariableConflict('prompt_var', existing, [{ key: 'prompt_var' }], -1)).toBe('prompt_var')
expect(findExternalDataToolVariableConflict('second_var', existing, [], -1)).toBe('second_var')
expect(findExternalDataToolVariableConflict('second_var', existing, [], 1)).toBeUndefined()
})
it('should format api and custom tools for save', () => {
const apiTool = createTool({
config: {
api_based_extension_id: 'extension-1',
region: 'ignored',
},
})
const customTool = createTool({
config: {
region: 'us',
ignored: 'value',
},
type: 'custom-tool',
})
expect(formatExternalDataToolForSave(apiTool, undefined, true)).toEqual(expect.objectContaining({
enabled: true,
config: {
api_based_extension_id: 'extension-1',
},
}))
expect(formatExternalDataToolForSave(customTool, provider, false)).toEqual(expect.objectContaining({
enabled: false,
config: {
region: 'us',
},
}))
})
it('should return required and invalid validation errors', () => {
expect(getExternalDataToolValidationError({
localeData: createTool({ type: '' }),
currentProvider: undefined,
locale: 'en-US',
})).toEqual({
kind: 'required',
label: 'feature.tools.modal.toolType.title',
})
expect(getExternalDataToolValidationError({
localeData: createTool({ label: '' }),
currentProvider: undefined,
locale: 'en-US',
})).toEqual({
kind: 'required',
label: 'feature.tools.modal.name.title',
})
expect(getExternalDataToolValidationError({
localeData: createTool({ variable: '' }),
currentProvider: undefined,
locale: 'en-US',
})).toEqual({
kind: 'required',
label: 'feature.tools.modal.variableName.title',
})
expect(getExternalDataToolValidationError({
localeData: createTool({ variable: 'invalid-key!' }),
currentProvider: undefined,
locale: 'en-US',
})).toEqual({
kind: 'invalid',
label: 'feature.tools.modal.variableName.title',
})
})
it('should validate required provider fields using the current locale', () => {
expect(getExternalDataToolValidationError({
localeData: createTool({
config: {},
}),
currentProvider: undefined,
locale: LanguagesSupported[1],
})).toEqual({
kind: 'required',
label: 'API 扩展',
})
expect(getExternalDataToolValidationError({
localeData: createTool({
config: {},
type: 'custom-tool',
}),
currentProvider: provider,
locale: LanguagesSupported[1],
})).toEqual({
kind: 'required',
label: '地区',
})
expect(getExternalDataToolValidationError({
localeData: createTool({
config: {
region: 'us',
},
type: 'custom-tool',
}),
currentProvider: provider,
locale: 'en-US',
})).toBeNull()
})
})

View File

@ -0,0 +1,173 @@
import type { ExternalDataTool } from '@/models/common'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { toast } from '@/app/components/base/ui/toast'
import Tools from '../index'
const mockCopy = vi.fn()
const mockSetExternalDataToolsConfig = vi.fn()
const mockSetShowExternalDataToolModal = vi.fn()
let mockConfigContext: {
externalDataToolsConfig: ExternalDataTool[]
modelConfig: {
configs?: {
prompt_variables?: Array<{ key: string }>
}
}
setExternalDataToolsConfig: typeof mockSetExternalDataToolsConfig
}
vi.mock('copy-to-clipboard', () => ({
default: (...args: unknown[]) => mockCopy(...args),
}))
vi.mock('use-context-selector', async () => {
const actual = await vi.importActual<typeof import('use-context-selector')>('use-context-selector')
return {
...actual,
useContext: () => mockConfigContext,
}
})
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowExternalDataToolModal: mockSetShowExternalDataToolModal,
}),
}))
vi.mock('@/app/components/base/app-icon', () => ({
default: ({ icon }: { icon?: string }) => <div data-testid="app-icon">{icon || 'icon'}</div>,
}))
vi.mock('@/app/components/base/switch', () => ({
default: ({
value,
onChange,
}: {
value: boolean
onChange: (value: boolean) => void
}) => (
<button onClick={() => onChange(!value)}>
{value ? 'switch-on' : 'switch-off'}
</button>
),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
error: vi.fn(),
},
}))
vi.mock('@/app/components/base/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
TooltipTrigger: ({
render,
}: {
render: React.ReactNode
}) => <>{render}</>,
}))
vi.mock('@remixicon/react', () => ({
RiAddLine: () => <span>add-icon</span>,
RiArrowDownSLine: () => <span>arrow-icon</span>,
RiDeleteBinLine: () => <span>delete-icon</span>,
}))
vi.mock('@/app/components/base/icons/src/vender/line/general', () => ({
Settings01: () => <span>settings-icon</span>,
}))
vi.mock('@/app/components/base/icons/src/vender/solid/general', () => ({
Tool03: () => <span>tool-icon</span>,
}))
const createTool = (overrides: Partial<ExternalDataTool> = {}): ExternalDataTool => ({
config: {
api_based_extension_id: 'extension-1',
},
enabled: false,
icon: '🤖',
icon_background: '#fff',
label: 'External tool',
type: 'api',
variable: 'tool_var',
...overrides,
})
describe('configuration/tools/index', () => {
beforeEach(() => {
vi.clearAllMocks()
mockConfigContext = {
externalDataToolsConfig: [],
modelConfig: {
configs: {
prompt_variables: [],
},
},
setExternalDataToolsConfig: mockSetExternalDataToolsConfig,
}
})
it('should open the add-tool modal and reject prompt-variable conflicts before save', async () => {
const user = userEvent.setup()
mockConfigContext.modelConfig.configs!.prompt_variables = [{ key: 'prompt_var' }]
render(<Tools />)
await user.click(screen.getByText('common.operation.add'))
const modalPayload = mockSetShowExternalDataToolModal.mock.calls[0][0]
expect(modalPayload.payload).toEqual({})
expect(modalPayload.onValidateBeforeSaveCallback(createTool({ variable: 'prompt_var' }))).toBe(false)
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('prompt_var'))
})
it('should save, copy, edit, delete, and toggle configured tools', async () => {
const user = userEvent.setup()
const existingTool = createTool()
mockConfigContext.externalDataToolsConfig = [existingTool]
const { container } = render(<Tools />)
await user.click(screen.getByText('common.operation.add'))
const modalPayload = mockSetShowExternalDataToolModal.mock.calls[0][0]
modalPayload.onSaveCallback(createTool({ label: 'New tool', variable: 'new_var' }))
expect(mockSetExternalDataToolsConfig).toHaveBeenCalledWith([
existingTool,
createTool({ label: 'New tool', variable: 'new_var' }),
])
await user.click(screen.getByText('tool_var'))
expect(mockCopy).toHaveBeenCalledWith('tool_var')
expect(screen.getByText('appApi.copied')).toBeInTheDocument()
await user.click(screen.getByText('settings-icon'))
const editPayload = mockSetShowExternalDataToolModal.mock.calls[1][0]
expect(editPayload.payload).toEqual(existingTool)
expect(editPayload.onValidateBeforeSaveCallback(createTool({ variable: 'new_var' }))).toBe(true)
await user.click(screen.getByText('delete-icon'))
expect(mockSetExternalDataToolsConfig).toHaveBeenCalledWith([])
await user.click(screen.getByText('switch-off'))
expect(mockSetExternalDataToolsConfig).toHaveBeenCalledWith([
createTool({ enabled: true }),
])
await user.click(container.querySelector('.group') as HTMLElement)
expect(screen.queryByText('External tool')).not.toBeInTheDocument()
expect(screen.getByText(/appDebug\.feature\.tools\.toolsInUse/)).toBeInTheDocument()
})
})

View File

@ -0,0 +1,181 @@
import type { FeedbackType } from '@/app/components/base/chat/chat/type'
import type { WorkflowProcess } from '@/app/components/base/chat/types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { AppSourceType } from '@/service/share'
import GenerationItemActionBar from '../action-bar'
vi.mock('@/app/components/base/action-button', () => ({
default: ({
children,
disabled,
onClick,
state,
}: {
children: React.ReactNode
disabled?: boolean
onClick?: () => void
state?: string
}) => (
<button data-state={state} disabled={disabled} onClick={onClick}>
{children}
</button>
),
ActionButtonState: {
Active: 'active',
Default: 'default',
Destructive: 'destructive',
Disabled: 'disabled',
},
}))
vi.mock('@/app/components/base/new-audio-button', () => ({
default: ({ id, voice }: { id: string, voice?: string }) => <div>{`audio:${id}:${voice || ''}`}</div>,
}))
vi.mock('@remixicon/react', () => ({
RiBookmark3Line: () => <span>bookmark-icon</span>,
RiClipboardLine: () => <span>copy-icon</span>,
RiFileList3Line: () => <span>log-icon</span>,
RiResetLeftLine: () => <span>retry-icon</span>,
RiSparklingLine: () => <span>more-like-this-icon</span>,
RiThumbDownLine: () => <span>thumb-down-icon</span>,
RiThumbUpLine: () => <span>thumb-up-icon</span>,
}))
const createWorkflowProcessData = (overrides: Partial<WorkflowProcess> = {}): WorkflowProcess => ({
status: WorkflowRunningStatus.Succeeded,
tracing: [],
...overrides,
})
const createProps = (overrides: Partial<React.ComponentProps<typeof GenerationItemActionBar>> = {}): React.ComponentProps<typeof GenerationItemActionBar> => ({
appSourceType: AppSourceType.webApp,
currentTab: 'RESULT',
depth: 1,
feedback: { rating: null } as FeedbackType,
isError: false,
isInWebApp: true,
isResponding: false,
isShowTextToSpeech: true,
isTryApp: false,
isWorkflow: false,
messageId: 'message-1',
moreLikeThis: true,
onCopy: vi.fn(),
onFeedback: vi.fn(),
onMoreLikeThis: vi.fn(),
onOpenLogModal: vi.fn(),
onRetry: vi.fn(),
onSave: vi.fn(),
supportFeedback: true,
voice: 'alloy',
workflowProcessData: createWorkflowProcessData({ resultText: 'done' }),
...overrides,
})
describe('GenerationItemActionBar', () => {
it('should render non-web log actions and invoke the corresponding handlers', async () => {
const user = userEvent.setup()
const onOpenLogModal = vi.fn()
const onCopy = vi.fn()
const onMoreLikeThis = vi.fn()
render(
<GenerationItemActionBar
{...createProps({
appSourceType: AppSourceType.webApp,
isInWebApp: false,
onCopy,
onMoreLikeThis,
onOpenLogModal,
})}
/>,
)
await user.click(screen.getByText('log-icon'))
await user.click(screen.getByText('more-like-this-icon'))
await user.click(screen.getByText('copy-icon'))
expect(onOpenLogModal).toHaveBeenCalledTimes(1)
expect(onMoreLikeThis).toHaveBeenCalledTimes(1)
expect(onCopy).toHaveBeenCalledTimes(1)
expect(screen.getByText('audio:message-1:alloy')).toBeInTheDocument()
})
it('should render retry, save, and feedback controls for web apps', async () => {
const user = userEvent.setup()
const onRetry = vi.fn()
const onSave = vi.fn()
const onFeedback = vi.fn()
render(
<GenerationItemActionBar
{...createProps({
isError: true,
onRetry,
onSave,
supportFeedback: false,
})}
/>,
)
await user.click(screen.getByText('retry-icon'))
await user.click(screen.getByText('bookmark-icon'))
expect(onRetry).toHaveBeenCalledTimes(1)
expect(onSave).not.toHaveBeenCalled()
render(<GenerationItemActionBar {...createProps({ onSave })} />)
await user.click(screen.getAllByText('bookmark-icon').at(-1)!)
expect(onSave).toHaveBeenCalledWith('message-1')
render(<GenerationItemActionBar {...createProps({ onFeedback })} />)
await user.click(screen.getAllByText('thumb-up-icon').at(-1)!)
await user.click(screen.getAllByText('thumb-down-icon').at(-1)!)
expect(onFeedback).toHaveBeenCalledWith({ rating: 'like' })
expect(onFeedback).toHaveBeenCalledWith({ rating: 'dislike' })
})
it('should disable more-like-this at the maximum depth and render active feedback state toggles', async () => {
const user = userEvent.setup()
const onFeedback = vi.fn()
render(
<GenerationItemActionBar
{...createProps({
depth: 3,
feedback: { rating: 'like' },
onFeedback,
})}
/>,
)
expect(screen.getByText('more-like-this-icon').closest('button')).toBeDisabled()
await user.click(screen.getByText('thumb-up-icon'))
expect(onFeedback).toHaveBeenCalledWith({ rating: null })
render(
<GenerationItemActionBar
{...createProps({
depth: 4,
feedback: { rating: 'dislike' },
onFeedback,
})}
/>,
)
await user.click(screen.getAllByText('thumb-down-icon')[0])
expect(onFeedback).toHaveBeenCalledWith({ rating: null })
expect(screen.getByText('appDebug.errorMessage.waitForResponse')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,162 @@
import type { FeedbackType } from '@/app/components/base/chat/chat/type'
import type { WorkflowProcess } from '@/app/components/base/chat/types'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { AppSourceType } from '@/service/share'
import GenerationItem from '../index'
const mockActionBar = vi.fn()
const mockUseGenerationItem = vi.fn()
vi.mock('@/app/components/base/loading', () => ({
default: ({ type }: { type?: string }) => <div>{`loading:${type || 'default'}`}</div>,
}))
vi.mock('@/app/components/base/markdown', () => ({
Markdown: ({ content }: { content: string }) => <div>{`markdown:${content}`}</div>,
}))
vi.mock('../action-bar', () => ({
default: (props: Record<string, unknown>) => {
mockActionBar(props)
return <div>{`action-bar:${String(props.messageId)}`}</div>
},
}))
vi.mock('../workflow-content', () => ({
default: ({
currentTab,
taskId,
}: {
currentTab: string
taskId?: string
}) => <div>{`workflow-content:${currentTab}:${taskId || ''}`}</div>,
}))
vi.mock('../use-generation-item', () => ({
useGenerationItem: (...args: unknown[]) => mockUseGenerationItem(...args),
}))
const createWorkflowProcessData = (overrides: Partial<WorkflowProcess> = {}): WorkflowProcess => ({
status: WorkflowRunningStatus.Succeeded,
tracing: [],
...overrides,
})
const createHookState = (overrides: Record<string, unknown> = {}) => ({
childMessageId: null,
childProps: {
appSourceType: AppSourceType.webApp,
content: 'child content',
depth: 2,
isError: false,
onRetry: vi.fn(),
siteInfo: null,
},
config: {
text_to_speech: {
voice: 'alloy',
},
},
completionRes: '',
currentTab: 'RESULT',
handleCopy: vi.fn(),
handleMoreLikeThis: vi.fn(),
handleOpenLogModal: vi.fn(),
handleSubmitHumanInputForm: vi.fn(),
isQuerying: false,
isTop: true,
isTryApp: false,
setCurrentTab: vi.fn(),
showChildItem: false,
taskLabel: 'task-1',
...overrides,
})
const createProps = (overrides: Partial<React.ComponentProps<typeof GenerationItem>> = {}): React.ComponentProps<typeof GenerationItem> => ({
appSourceType: AppSourceType.webApp,
content: 'Hello world',
feedback: { rating: null } as FeedbackType,
isError: false,
isLoading: false,
isMobile: false,
isWorkflow: false,
messageId: 'message-1',
onFeedback: vi.fn(),
onRetry: vi.fn(),
onSave: vi.fn(),
siteInfo: null,
taskId: 'task-1',
...overrides,
})
describe('GenerationItem', () => {
it('should render the loading state while waiting for a response', () => {
mockUseGenerationItem.mockReturnValue(createHookState())
render(<GenerationItem {...createProps({ isLoading: true })} />)
expect(screen.getByText('loading:area')).toBeInTheDocument()
expect(screen.queryByText(/action-bar:/)).not.toBeInTheDocument()
})
it('should render workflow content and pass the derived action-bar props', () => {
mockUseGenerationItem.mockReturnValue(createHookState({
currentTab: 'DETAIL',
}))
render(
<GenerationItem
{...createProps({
content: { raw: true },
isWorkflow: true,
taskId: 'task-9',
workflowProcessData: createWorkflowProcessData({
resultText: 'done',
}),
})}
/>,
)
expect(screen.getByText('workflow-content:DETAIL:task-9')).toBeInTheDocument()
expect(screen.queryByText(/common\.unit\.char/)).not.toBeInTheDocument()
expect(mockActionBar.mock.calls[0][0]).toEqual(expect.objectContaining({
currentTab: 'DETAIL',
isWorkflow: true,
messageId: 'message-1',
voice: 'alloy',
}))
})
it('should render markdown output, task header, char count, and nested child items', () => {
mockUseGenerationItem
.mockReturnValueOnce(createHookState({
childProps: createProps({
content: 'Child answer',
depth: 2,
messageId: 'child-1',
}),
showChildItem: true,
}))
.mockReturnValueOnce(createHookState({
childProps: createProps({
content: '',
depth: 3,
messageId: 'child-2',
}),
isTop: false,
showChildItem: false,
taskLabel: 'task-1-1',
}))
render(<GenerationItem {...createProps({ isMobile: true, taskId: 'task-1' })} />)
expect(screen.getAllByText('share.generation.execution')).toHaveLength(2)
expect(screen.getByText('task-1')).toBeInTheDocument()
expect(screen.getByText('markdown:Hello world')).toBeInTheDocument()
expect(screen.getByText(/11\s+common\.unit\.char/)).toBeInTheDocument()
expect(screen.getByText('markdown:Child answer')).toBeInTheDocument()
expect(screen.getAllByText(/action-bar:/)).toHaveLength(2)
})
})

View File

@ -0,0 +1,51 @@
import type { WorkflowProcess } from '@/app/components/base/chat/types'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import ResultTab from '../result-tab'
vi.mock('@/app/components/base/file-uploader', () => ({
FileList: ({ files }: { files: Array<{ id: string }> }) => <div>{`files:${files.length}`}</div>,
}))
vi.mock('@/app/components/base/markdown', () => ({
Markdown: ({ content }: { content: string }) => <div>{`markdown:${content}`}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
default: ({ value }: { value: unknown }) => <div>{`code-editor:${String(value)}`}</div>,
}))
describe('ResultTab', () => {
it('should render markdown text and uploaded files in result mode', () => {
const workflowProcessData = {
files: [{
list: [{ id: 'file-1' }],
varName: 'documents',
}],
resultText: 'Generated result',
} as unknown as WorkflowProcess
render(
<ResultTab
currentTab="RESULT"
content={'{"raw":true}'}
data={workflowProcessData}
/>,
)
expect(screen.getByText('markdown:Generated result')).toBeInTheDocument()
expect(screen.getByText('documents')).toBeInTheDocument()
expect(screen.getByText('files:1')).toBeInTheDocument()
})
it('should render the JSON detail view in detail mode', () => {
render(
<ResultTab
currentTab="DETAIL"
content={'{"raw":true}'}
/>,
)
expect(screen.getByText('code-editor:{"raw":true}')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,339 @@
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import type { WorkflowProcess } from '@/app/components/base/chat/types'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { SiteInfo } from '@/models/share'
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { toast } from '@/app/components/base/ui/toast'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { AppSourceType } from '@/service/share'
import { TransferMethod } from '@/types/app'
import { generationItemHelpers, useGenerationItem } from '../use-generation-item'
const mockCopy = vi.fn()
const mockFetchTextGenerationMessage = vi.fn()
const mockFetchMoreLikeThis = vi.fn()
const mockSubmitHumanInputForm = vi.fn()
const mockUpdateFeedback = vi.fn()
const mockSubmitHumanInputFormService = vi.fn()
const mockSetCurrentLogItem = vi.fn()
const mockSetShowPromptLogModal = vi.fn()
vi.mock('copy-to-clipboard', () => ({
default: (...args: unknown[]) => mockCopy(...args),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: {
setCurrentLogItem: typeof mockSetCurrentLogItem
setShowPromptLogModal: typeof mockSetShowPromptLogModal
}) => unknown) => selector({
setCurrentLogItem: mockSetCurrentLogItem,
setShowPromptLogModal: mockSetShowPromptLogModal,
}),
}))
vi.mock('@/app/components/base/chat/chat/context', () => ({
useChatContext: () => ({
config: {
text_to_speech: {
voice: 'alloy',
},
},
}),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
success: vi.fn(),
warning: vi.fn(),
},
}))
vi.mock('@/next/navigation', () => ({
useParams: () => ({
appId: 'app-1',
}),
}))
vi.mock('@/service/debug', () => ({
fetchTextGenerationMessage: (...args: unknown[]) => mockFetchTextGenerationMessage(...args),
}))
vi.mock('@/service/share', async () => {
const actual = await vi.importActual<typeof import('@/service/share')>('@/service/share')
return {
...actual,
fetchMoreLikeThis: (...args: unknown[]) => mockFetchMoreLikeThis(...args),
submitHumanInputForm: (...args: unknown[]) => mockSubmitHumanInputForm(...args),
updateFeedback: (...args: unknown[]) => mockUpdateFeedback(...args),
}
})
vi.mock('@/service/workflow', async () => {
const actual = await vi.importActual<typeof import('@/service/workflow')>('@/service/workflow')
return {
...actual,
submitHumanInputForm: (...args: unknown[]) => mockSubmitHumanInputFormService(...args),
}
})
const createSiteInfo = (overrides: Partial<SiteInfo> = {}): SiteInfo => ({
title: 'App site',
show_workflow_steps: true,
...overrides,
})
const createWorkflowProcessData = (overrides: Partial<WorkflowProcess> = {}): WorkflowProcess => ({
status: WorkflowRunningStatus.Succeeded,
tracing: [],
...overrides,
})
const createMessageFile = (overrides: Partial<FileEntity> = {}): FileEntity => ({
id: 'file-1',
name: 'file.txt',
progress: 100,
size: 1,
supportFileType: 'document',
transferMethod: TransferMethod.local_file,
type: 'text/plain',
...overrides,
})
const createProps = (overrides: Partial<Parameters<typeof useGenerationItem>[0]> = {}): Parameters<typeof useGenerationItem>[0] => ({
appSourceType: AppSourceType.webApp,
content: 'Initial content',
controlClearMoreLikeThis: 0,
depth: 1,
installedAppId: 'installed-1',
isInWebApp: true,
isLoading: false,
isMobile: false,
isShowTextToSpeech: true,
isWorkflow: false,
messageId: 'message-1',
onRetry: vi.fn(),
onSave: vi.fn(),
siteInfo: createSiteInfo(),
taskId: 'task-1',
workflowProcessData: undefined,
...overrides,
})
describe('useGenerationItem', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetchMoreLikeThis.mockResolvedValue({
answer: 'Suggested answer',
id: 'child-1',
})
mockFetchTextGenerationMessage.mockResolvedValue({
answer: 'Assistant answer',
id: 'message-1',
message: 'Original prompt',
message_files: [createMessageFile()],
})
})
it('should derive the current tab and helper flags from workflow data', async () => {
const { result, rerender } = renderHook(props => useGenerationItem(props), {
initialProps: createProps({
appSourceType: AppSourceType.tryApp,
depth: 2,
isWorkflow: true,
workflowProcessData: createWorkflowProcessData({
resultText: 'Workflow result',
}),
}),
})
expect(result.current.currentTab).toBe('RESULT')
expect(result.current.isTop).toBe(false)
expect(result.current.isTryApp).toBe(true)
expect(result.current.taskLabel).toBe('task-1-1')
expect(result.current.config?.text_to_speech?.voice).toBe('alloy')
rerender(createProps({
depth: 2,
isWorkflow: true,
workflowProcessData: createWorkflowProcessData(),
}))
await waitFor(() => {
expect(result.current.currentTab).toBe('DETAIL')
})
})
it('should request more-like-this responses and reuse the returned message for child feedback', async () => {
const { result } = renderHook(() => useGenerationItem(createProps()))
await act(async () => {
await result.current.handleMoreLikeThis()
})
expect(mockFetchMoreLikeThis).toHaveBeenCalledWith('message-1', AppSourceType.webApp, 'installed-1')
expect(result.current.completionRes).toBe('Suggested answer')
expect(result.current.childMessageId).toBe('child-1')
expect(result.current.showChildItem).toBe(true)
expect(result.current.childProps.messageId).toBe('child-1')
expect(result.current.childProps.feedback).toEqual({ rating: null })
await act(async () => {
await result.current.childProps.onFeedback?.({ rating: 'like' })
})
expect(mockUpdateFeedback).toHaveBeenCalledWith({
body: { rating: 'like' },
url: '/messages/child-1/feedbacks',
}, AppSourceType.webApp, 'installed-1')
})
it('should warn instead of requesting more-like-this when no source message is available', async () => {
const { result } = renderHook(() => useGenerationItem(createProps({ messageId: undefined })))
await act(async () => {
await result.current.handleMoreLikeThis()
})
expect(mockFetchMoreLikeThis).not.toHaveBeenCalled()
expect(toast.warning).toHaveBeenCalledWith('appDebug.errorMessage.waitForResponse')
})
it('should clear child content when the clear control changes or the parent starts loading', async () => {
const { result, rerender } = renderHook(props => useGenerationItem(props), {
initialProps: createProps(),
})
await act(async () => {
await result.current.handleMoreLikeThis()
})
expect(result.current.childMessageId).toBe('child-1')
rerender(createProps({ controlClearMoreLikeThis: 1 }))
await waitFor(() => {
expect(result.current.childMessageId).toBeNull()
expect(result.current.completionRes).toBe('')
})
await act(async () => {
await result.current.handleMoreLikeThis()
})
rerender(createProps({ isLoading: true }))
await waitFor(() => {
expect(result.current.childMessageId).toBeNull()
})
})
it('should normalize log entries and open the prompt log modal', async () => {
const { result } = renderHook(() => useGenerationItem(createProps()))
await act(async () => {
await result.current.handleOpenLogModal()
})
expect(mockFetchTextGenerationMessage).toHaveBeenCalledWith({
appId: 'app-1',
messageId: 'message-1',
})
expect(mockSetShowPromptLogModal).toHaveBeenCalledWith(true)
expect(mockSetCurrentLogItem).toHaveBeenCalledWith(expect.objectContaining({
content: 'Assistant answer',
id: 'message-1',
isAnswer: true,
log: [{
role: 'user',
text: 'Original prompt',
}],
}))
})
it('should route human input submissions to share and workflow services based on app source', async () => {
const shareHook = renderHook(() => useGenerationItem(createProps()))
await act(async () => {
await shareHook.result.current.handleSubmitHumanInputForm('token-1', {
action: 'submit',
inputs: { city: 'Paris' },
})
})
expect(mockSubmitHumanInputForm).toHaveBeenCalledWith('token-1', {
action: 'submit',
inputs: { city: 'Paris' },
})
const installedHook = renderHook(() => useGenerationItem(createProps({
appSourceType: AppSourceType.installedApp,
})))
await act(async () => {
await installedHook.result.current.handleSubmitHumanInputForm('token-2', {
action: 'confirm',
inputs: { city: 'Berlin' },
})
})
expect(mockSubmitHumanInputFormService).toHaveBeenCalledWith('token-2', {
action: 'confirm',
inputs: { city: 'Berlin' },
})
})
it('should copy workflow text directly and stringify non-string content', async () => {
const { result: workflowResult } = renderHook(() => useGenerationItem(createProps({
content: { raw: true },
isWorkflow: true,
workflowProcessData: createWorkflowProcessData({
resultText: 'Workflow text',
}),
})))
await act(async () => {
workflowResult.current.handleCopy()
})
expect(mockCopy).toHaveBeenCalledWith('Workflow text')
expect(toast.success).toHaveBeenCalledWith('common.actionMsg.copySuccessfully')
const { result: jsonResult } = renderHook(() => useGenerationItem(createProps({
content: { raw: true },
})))
await act(async () => {
jsonResult.current.handleCopy()
})
expect(mockCopy).toHaveBeenCalledWith(JSON.stringify({ raw: true }))
})
})
describe('generationItemHelpers', () => {
it('should build a normalized log item from text messages', () => {
const logItem = generationItemHelpers.buildLogItem({
answer: 'Assistant answer',
data: {
answer: 'Assistant answer',
id: 'message-1',
message: 'Original prompt',
message_files: [createMessageFile()],
},
messageId: 'message-1',
}) as IChatItem
expect(logItem.id).toBe('message-1')
expect(logItem.log).toEqual([{ role: 'user', text: 'Original prompt' }])
})
it('should compute the default tab from workflow result availability', () => {
expect(generationItemHelpers.getCurrentTab(createWorkflowProcessData({ resultText: 'done' }))).toBe('RESULT')
expect(generationItemHelpers.getCurrentTab(createWorkflowProcessData({
files: [{ id: 'file-1' }] as unknown as WorkflowProcess['files'],
}))).toBe('RESULT')
expect(generationItemHelpers.getCurrentTab(createWorkflowProcessData())).toBe('DETAIL')
})
})

View File

@ -0,0 +1,142 @@
import type { WorkflowProcess } from '@/app/components/base/chat/types'
import type { SiteInfo } from '@/models/share'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import WorkflowContent from '../workflow-content'
vi.mock('@/app/components/base/chat/chat/answer/human-input-filled-form-list', () => ({
default: ({ humanInputFilledFormDataList }: { humanInputFilledFormDataList: Array<unknown> }) => (
<div>{`filled-forms:${humanInputFilledFormDataList.length}`}</div>
),
}))
vi.mock('@/app/components/base/chat/chat/answer/human-input-form-list', () => ({
default: ({
humanInputFormDataList,
onHumanInputFormSubmit,
}: {
humanInputFormDataList: Array<unknown>
onHumanInputFormSubmit: (formToken: string, formData: { inputs: Record<string, string>, action: string }) => void
}) => (
<button onClick={() => onHumanInputFormSubmit('token-1', { action: 'submit', inputs: { city: 'Paris' } })}>
{`human-forms:${humanInputFormDataList.length}`}
</button>
),
}))
vi.mock('@/app/components/base/chat/chat/answer/workflow-process', () => ({
default: ({
readonly,
}: {
readonly: boolean
}) => <div>{`workflow-process:${String(readonly)}`}</div>,
}))
vi.mock('../result-tab', () => ({
default: ({
currentTab,
content,
}: {
currentTab: string
content: unknown
}) => <div>{`result-tab:${currentTab}:${String(content)}`}</div>,
}))
const createWorkflowProcessData = () => ({
status: WorkflowRunningStatus.Succeeded,
tracing: [],
expand: true,
files: [{ list: [{ id: 'file-1' }], varName: 'documents' }],
humanInputFilledFormDataList: [{ id: 'filled-1' }],
humanInputFormDataList: [{ id: 'form-1' }],
resultText: 'Workflow result',
}) as unknown as WorkflowProcess
const workflowSiteInfo: SiteInfo = {
title: 'App site',
show_workflow_steps: false,
}
describe('WorkflowContent', () => {
it('should render workflow metadata, result tabs, and forward human input submissions', async () => {
const user = userEvent.setup()
const onSubmitHumanInputForm = vi.fn()
const onSwitchTab = vi.fn()
render(
<WorkflowContent
content="workflow-json"
currentTab="RESULT"
isError={false}
onSubmitHumanInputForm={onSubmitHumanInputForm}
onSwitchTab={onSwitchTab}
siteInfo={workflowSiteInfo}
taskId="task-1"
workflowProcessData={createWorkflowProcessData()}
/>,
)
expect(screen.getByText('share.generation.execution')).toBeInTheDocument()
expect(screen.getByText('task-1')).toBeInTheDocument()
expect(screen.getByText('workflow-process:true')).toBeInTheDocument()
expect(screen.getByText('runLog.result')).toBeInTheDocument()
expect(screen.getByText('runLog.detail')).toBeInTheDocument()
expect(screen.getByText('human-forms:1')).toBeInTheDocument()
expect(screen.getByText('filled-forms:1')).toBeInTheDocument()
expect(screen.getByText('result-tab:RESULT:workflow-json')).toBeInTheDocument()
await user.click(screen.getByText('runLog.detail'))
await user.click(screen.getByText('human-forms:1'))
expect(onSwitchTab).toHaveBeenCalledWith('DETAIL')
expect(onSubmitHumanInputForm).toHaveBeenCalledWith('token-1', {
action: 'submit',
inputs: { city: 'Paris' },
})
})
it('should hide result tabs and content helpers when the workflow is in an error state', () => {
render(
<WorkflowContent
content="workflow-json"
currentTab="RESULT"
isError={true}
onSubmitHumanInputForm={vi.fn()}
onSwitchTab={vi.fn()}
siteInfo={null}
workflowProcessData={{
expand: false,
status: WorkflowRunningStatus.Succeeded,
tracing: [],
}}
/>,
)
expect(screen.queryByText('runLog.result')).not.toBeInTheDocument()
expect(screen.queryByText(/result-tab:/)).not.toBeInTheDocument()
expect(screen.queryByText(/workflow-process:/)).not.toBeInTheDocument()
})
it('should switch back to the result tab when the detail tab is active', async () => {
const user = userEvent.setup()
const onSwitchTab = vi.fn()
render(
<WorkflowContent
content="workflow-json"
currentTab="DETAIL"
isError={false}
onSubmitHumanInputForm={vi.fn()}
onSwitchTab={onSwitchTab}
siteInfo={null}
workflowProcessData={createWorkflowProcessData()}
/>,
)
await user.click(screen.getByText('runLog.result'))
expect(onSwitchTab).toHaveBeenCalledWith('RESULT')
})
})

View File

@ -1,32 +1,18 @@
'use client'
import type { FC } from 'react'
import type { FeedbackType, IChatItem } from '@/app/components/base/chat/chat/type'
import type { FeedbackType } from '@/app/components/base/chat/chat/type'
import type { WorkflowProcess } from '@/app/components/base/chat/types'
import type { SiteInfo } from '@/models/share'
import {
RiPlayList2Line,
RiSparklingFill,
} from '@remixicon/react'
import { useBoolean } from 'ahooks'
import copy from 'copy-to-clipboard'
import type { AppSourceType } from '@/service/share'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useChatContext } from '@/app/components/base/chat/chat/context'
import Loading from '@/app/components/base/loading'
import { Markdown } from '@/app/components/base/markdown'
import { toast } from '@/app/components/base/ui/toast'
import { useParams } from '@/next/navigation'
import { fetchTextGenerationMessage } from '@/service/debug'
import { AppSourceType, fetchMoreLikeThis, submitHumanInputForm, updateFeedback } from '@/service/share'
import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow'
import { cn } from '@/utils/classnames'
import GenerationItemActionBar from './action-bar'
import { useGenerationItem } from './use-generation-item'
import WorkflowContent from './workflow-content'
const MAX_DEPTH = 3
export type IGenerationItemProps = {
isWorkflow?: boolean
workflowProcessData?: WorkflowProcess
@ -90,142 +76,28 @@ const GenerationItem: FC<IGenerationItemProps> = ({
inSidePanel,
}) => {
const { t } = useTranslation()
const params = useParams()
const isTop = depth === 1
const isTryApp = appSourceType === AppSourceType.tryApp
const [completionRes, setCompletionRes] = useState('')
const [childMessageId, setChildMessageId] = useState<string | null>(null)
const [childFeedback, setChildFeedback] = useState<FeedbackType>({
rating: null,
})
const {
config,
} = useChatContext()
const setCurrentLogItem = useAppStore(s => s.setCurrentLogItem)
const setShowPromptLogModal = useAppStore(s => s.setShowPromptLogModal)
const handleFeedback = async (childFeedback: FeedbackType) => {
await updateFeedback({ url: `/messages/${childMessageId}/feedbacks`, body: { rating: childFeedback.rating } }, appSourceType, installedAppId)
setChildFeedback(childFeedback)
}
const [isQuerying, { setTrue: startQuerying, setFalse: stopQuerying }] = useBoolean(false)
const childProps: IGenerationItemProps = {
isInWebApp,
content: completionRes,
messageId: childMessageId,
depth: depth + 1,
moreLikeThis: true,
onFeedback: handleFeedback,
isLoading: isQuerying,
feedback: childFeedback,
onSave,
isShowTextToSpeech,
isMobile,
const state = useGenerationItem({
appSourceType,
installedAppId,
content,
controlClearMoreLikeThis,
depth,
installedAppId,
isInWebApp,
isLoading,
isMobile,
isShowTextToSpeech,
isWorkflow,
messageId,
onRetry,
onSave,
siteInfo,
taskId,
isError: false,
onRetry,
}
const handleMoreLikeThis = async () => {
if (isQuerying || !messageId) {
toast.warning(t('errorMessage.waitForResponse', { ns: 'appDebug' }))
return
}
startQuerying()
const res: any = await fetchMoreLikeThis(messageId as string, appSourceType, installedAppId)
setCompletionRes(res.answer)
setChildFeedback({
rating: null,
})
setChildMessageId(res.id)
stopQuerying()
}
useEffect(() => {
if (controlClearMoreLikeThis) {
setChildMessageId(null)
setCompletionRes('')
}
}, [controlClearMoreLikeThis])
// regeneration clear child
useEffect(() => {
if (isLoading)
setChildMessageId(null)
}, [isLoading])
const handleOpenLogModal = async () => {
const data = await fetchTextGenerationMessage({
appId: params.appId as string,
messageId: messageId!,
})
const assistantFiles = data.message_files?.filter(file => file.belongs_to === 'assistant') || []
const normalizedMessage = typeof data.message === 'string'
? { role: 'user', text: data.message }
: data.message
const baseLog = Array.isArray(normalizedMessage) ? normalizedMessage : [normalizedMessage]
const log = Array.isArray(normalizedMessage)
? [
...normalizedMessage,
...(normalizedMessage.length > 0 && normalizedMessage[normalizedMessage.length - 1].role !== 'assistant'
? [
{
role: 'assistant',
text: data.answer || '',
files: assistantFiles,
},
]
: []),
]
: baseLog
const logItem: IChatItem = {
id: data.id || messageId || '',
content: data.answer || '',
isAnswer: true,
log,
message_files: data.message_files,
}
setCurrentLogItem(logItem)
setShowPromptLogModal(true)
}
const [currentTab, setCurrentTab] = useState<string>('DETAIL')
const switchTab = async (tab: string) => {
setCurrentTab(tab)
}
useEffect(() => {
if (workflowProcessData?.resultText || !!workflowProcessData?.files?.length || (workflowProcessData?.humanInputFormDataList && workflowProcessData?.humanInputFormDataList.length > 0) || (workflowProcessData?.humanInputFilledFormDataList && workflowProcessData?.humanInputFilledFormDataList.length > 0))
switchTab('RESULT')
else
switchTab('DETAIL')
}, [workflowProcessData?.files?.length, workflowProcessData?.resultText, workflowProcessData?.humanInputFormDataList, workflowProcessData?.humanInputFilledFormDataList])
const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: { inputs: Record<string, string>, action: string }) => {
if (appSourceType === AppSourceType.installedApp)
await submitHumanInputFormService(formToken, formData)
else
await submitHumanInputForm(formToken, formData)
}, [appSourceType])
const handleCopy = useCallback(() => {
const copyContent = isWorkflow ? workflowProcessData?.resultText : content
if (typeof copyContent === 'string')
copy(copyContent)
else
copy(JSON.stringify(copyContent))
toast.success(t('actionMsg.copySuccessfully', { ns: 'common' }))
}, [content, isWorkflow, t, workflowProcessData?.resultText])
workflowProcessData,
})
return (
<>
<div className={cn('relative', !isTop && 'mt-3', className)}>
<div className={cn('relative', !state.isTop && 'mt-3', className)}>
{isLoading && (
<div className={cn('flex h-10 items-center', !inSidePanel && 'rounded-2xl border-t border-divider-subtle bg-chat-bubble-bg')}><Loading type="area" /></div>
)}
@ -238,26 +110,24 @@ const GenerationItem: FC<IGenerationItemProps> = ({
)}
>
{workflowProcessData && (
<>
<WorkflowContent
content={content}
currentTab={currentTab}
hideProcessDetail={hideProcessDetail}
isError={isError}
onSubmitHumanInputForm={handleSubmitHumanInputForm}
onSwitchTab={switchTab}
siteInfo={siteInfo}
taskId={taskId}
workflowProcessData={workflowProcessData}
/>
</>
<WorkflowContent
content={content}
currentTab={state.currentTab}
hideProcessDetail={hideProcessDetail}
isError={isError}
onSubmitHumanInputForm={state.handleSubmitHumanInputForm}
onSwitchTab={state.setCurrentTab}
siteInfo={siteInfo}
taskId={taskId}
workflowProcessData={workflowProcessData}
/>
)}
{!workflowProcessData && taskId && (
<div className={cn('sticky left-0 top-0 flex w-full items-center rounded-t-2xl bg-components-actionbar-bg p-4 pb-3 text-text-accent-secondary system-2xs-medium-uppercase', isError && 'text-text-destructive')}>
<RiPlayList2Line className="mr-1 h-3 w-3" />
<span className="i-ri-play-list-2-line mr-1 h-3 w-3" aria-hidden="true" />
<span>{t('generation.execution', { ns: 'share' })}</span>
<span className="px-1">·</span>
<span>{`${taskId}${depth > 1 ? `-${depth - 1}` : ''}`}</span>
<span>{state.taskLabel}</span>
</div>
)}
{isError && (
@ -272,7 +142,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
{/* meta data */}
<div className={cn(
'relative mt-1 h-4 px-4 text-text-quaternary system-xs-regular',
isMobile && ((childMessageId || isQuerying) && depth < MAX_DEPTH) && 'pl-10',
isMobile && state.showChildItem && 'pl-10',
)}
>
{!isWorkflow && (
@ -286,31 +156,31 @@ const GenerationItem: FC<IGenerationItemProps> = ({
<div className="absolute bottom-1 right-2 flex items-center">
<GenerationItemActionBar
appSourceType={appSourceType}
currentTab={currentTab}
currentTab={state.currentTab}
depth={depth}
feedback={feedback}
isError={isError}
isInWebApp={isInWebApp}
isResponding={isResponding}
isShowTextToSpeech={isShowTextToSpeech}
isTryApp={isTryApp}
isTryApp={state.isTryApp}
isWorkflow={isWorkflow}
messageId={messageId}
moreLikeThis={moreLikeThis}
onCopy={handleCopy}
onCopy={state.handleCopy}
onFeedback={onFeedback}
onMoreLikeThis={handleMoreLikeThis}
onOpenLogModal={handleOpenLogModal}
onMoreLikeThis={state.handleMoreLikeThis}
onOpenLogModal={state.handleOpenLogModal}
onRetry={onRetry}
onSave={onSave}
supportFeedback={supportFeedback}
voice={config?.text_to_speech?.voice}
voice={state.config?.text_to_speech?.voice}
workflowProcessData={workflowProcessData}
/>
</div>
</div>
{/* more like this elements */}
{!isTop && (
{!state.isTop && (
<div className={cn(
'absolute top-[-32px] flex h-[33px] w-4 justify-center',
isMobile ? 'left-[17px]' : 'left-[50%] translate-x-[-50%]',
@ -322,15 +192,15 @@ const GenerationItem: FC<IGenerationItemProps> = ({
isMobile ? 'top-[3.5px]' : 'top-2',
)}
>
<RiSparklingFill className="h-3 w-3 text-text-primary-on-surface" />
<span className="i-ri-sparkling-fill h-3 w-3 text-text-primary-on-surface" aria-hidden="true" />
</div>
</div>
)}
</>
)}
</div>
{((childMessageId || isQuerying) && depth < MAX_DEPTH) && (
<GenerationItem {...childProps} />
{state.showChildItem && (
<GenerationItem {...state.childProps} />
)}
</>
)

View File

@ -0,0 +1,308 @@
'use client'
import type { IGenerationItemProps } from './index'
import type { FeedbackType, IChatItem } from '@/app/components/base/chat/chat/type'
import type { WorkflowProcess } from '@/app/components/base/chat/types'
import { useBoolean } from 'ahooks'
import copy from 'copy-to-clipboard'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useChatContext } from '@/app/components/base/chat/chat/context'
import { toast } from '@/app/components/base/ui/toast'
import { useParams } from '@/next/navigation'
import { fetchTextGenerationMessage } from '@/service/debug'
import {
AppSourceType,
fetchMoreLikeThis,
submitHumanInputForm,
updateFeedback,
} from '@/service/share'
import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow'
const MAX_DEPTH = 3
const getCurrentTab = (workflowProcessData?: WorkflowProcess) => {
if (
workflowProcessData?.resultText
|| !!workflowProcessData?.files?.length
|| !!workflowProcessData?.humanInputFormDataList?.length
|| !!workflowProcessData?.humanInputFilledFormDataList?.length
) {
return 'RESULT'
}
return 'DETAIL'
}
const buildLogItem = ({
answer,
data,
messageId,
}: {
answer?: string
data: Awaited<ReturnType<typeof fetchTextGenerationMessage>>
messageId?: string | null
}): IChatItem => {
const assistantFiles = data.message_files?.filter(file => file.belongs_to === 'assistant') || []
const normalizedMessage = typeof data.message === 'string'
? { role: 'user', text: data.message }
: data.message
const baseLog = Array.isArray(normalizedMessage) ? normalizedMessage : [normalizedMessage]
const log = Array.isArray(normalizedMessage)
? [
...normalizedMessage,
...(normalizedMessage.length > 0 && normalizedMessage[normalizedMessage.length - 1].role !== 'assistant'
? [{
role: 'assistant',
text: answer || '',
files: assistantFiles,
}]
: []),
]
: baseLog
return {
id: data.id || messageId || '',
content: answer || '',
isAnswer: true,
log,
message_files: data.message_files,
}
}
type UseGenerationItemParams = Pick<
IGenerationItemProps,
| 'appSourceType'
| 'content'
| 'controlClearMoreLikeThis'
| 'depth'
| 'installedAppId'
| 'isInWebApp'
| 'isLoading'
| 'isMobile'
| 'isShowTextToSpeech'
| 'isWorkflow'
| 'messageId'
| 'onRetry'
| 'onSave'
| 'siteInfo'
| 'taskId'
| 'workflowProcessData'
>
type MoreLikeThisState = {
childFeedback: FeedbackType
childMessageId: string | null
completionRes: string
controlVersion?: number
}
type CurrentTabState = {
signature: string
value: string | null
}
type MoreLikeThisResponse = {
answer?: string
id?: string
}
const getWorkflowTabSignature = (workflowProcessData?: WorkflowProcess) => JSON.stringify({
filesLength: workflowProcessData?.files?.length ?? 0,
humanInputFilledFormDataListLength: workflowProcessData?.humanInputFilledFormDataList?.length ?? 0,
humanInputFormDataListLength: workflowProcessData?.humanInputFormDataList?.length ?? 0,
resultText: workflowProcessData?.resultText ?? '',
})
export const useGenerationItem = ({
appSourceType,
content,
controlClearMoreLikeThis,
depth = 1,
installedAppId,
isInWebApp = false,
isLoading,
isMobile,
isShowTextToSpeech,
isWorkflow,
messageId,
onRetry,
onSave,
siteInfo,
taskId,
workflowProcessData,
}: UseGenerationItemParams) => {
const { t } = useTranslation()
const params = useParams()
const { config } = useChatContext()
const setCurrentLogItem = useAppStore(state => state.setCurrentLogItem)
const setShowPromptLogModal = useAppStore(state => state.setShowPromptLogModal)
const workflowTabSignature = getWorkflowTabSignature(workflowProcessData)
const workflowDefaultTab = getCurrentTab(workflowProcessData)
const [moreLikeThisState, setMoreLikeThisState] = useState<MoreLikeThisState>(() => ({
childFeedback: {
rating: null,
},
childMessageId: null,
completionRes: '',
controlVersion: controlClearMoreLikeThis,
}))
const [currentTabState, setCurrentTabState] = useState<CurrentTabState>(() => ({
signature: workflowTabSignature,
value: null,
}))
const [isQuerying, { setTrue: startQuerying, setFalse: stopQuerying }] = useBoolean(false)
const isTop = depth === 1
const isTryApp = appSourceType === AppSourceType.tryApp
const taskLabel = taskId ? `${taskId}${depth > 1 ? `-${depth - 1}` : ''}` : ''
const isMoreLikeThisCleared = moreLikeThisState.controlVersion !== controlClearMoreLikeThis
const completionRes = isMoreLikeThisCleared ? '' : moreLikeThisState.completionRes
const childMessageId = (isLoading || isMoreLikeThisCleared) ? null : moreLikeThisState.childMessageId
const currentTab = currentTabState.signature === workflowTabSignature && currentTabState.value
? currentTabState.value
: workflowDefaultTab
const handleChildFeedback = useCallback(async (nextFeedback: FeedbackType) => {
await updateFeedback(
{
url: `/messages/${childMessageId}/feedbacks`,
body: { rating: nextFeedback.rating },
},
appSourceType,
installedAppId,
)
setMoreLikeThisState(prev => ({
...prev,
childFeedback: nextFeedback,
}))
}, [appSourceType, childMessageId, installedAppId])
const handleMoreLikeThis = useCallback(async () => {
if (isQuerying || !messageId) {
toast.warning(t('errorMessage.waitForResponse', { ns: 'appDebug' }))
return
}
startQuerying()
const response = await fetchMoreLikeThis(messageId, appSourceType, installedAppId) as MoreLikeThisResponse
setMoreLikeThisState({
childFeedback: { rating: null },
childMessageId: response.id ?? null,
completionRes: response.answer ?? '',
controlVersion: controlClearMoreLikeThis,
})
stopQuerying()
}, [appSourceType, controlClearMoreLikeThis, installedAppId, isQuerying, messageId, startQuerying, stopQuerying, t])
const handleOpenLogModal = useCallback(async () => {
const data = await fetchTextGenerationMessage({
appId: params.appId as string,
messageId: messageId!,
})
const logItem = buildLogItem({
answer: data.answer,
data,
messageId,
})
setCurrentLogItem(logItem)
setShowPromptLogModal(true)
}, [messageId, params.appId, setCurrentLogItem, setShowPromptLogModal])
const handleSubmitHumanInputForm = useCallback(async (
formToken: string,
formData: { inputs: Record<string, string>, action: string },
) => {
if (appSourceType === AppSourceType.installedApp) {
await submitHumanInputFormService(formToken, formData)
return
}
await submitHumanInputForm(formToken, formData)
}, [appSourceType])
const handleCopy = useCallback(() => {
const copyContent = isWorkflow ? workflowProcessData?.resultText : content
if (typeof copyContent === 'string')
copy(copyContent)
else
copy(JSON.stringify(copyContent))
toast.success(t('actionMsg.copySuccessfully', { ns: 'common' }))
}, [content, isWorkflow, t, workflowProcessData?.resultText])
const setCurrentTab = useCallback((tab: string) => {
setCurrentTabState({
signature: workflowTabSignature,
value: tab,
})
}, [workflowTabSignature])
const childProps: IGenerationItemProps = useMemo(() => ({
appSourceType,
content: completionRes,
controlClearMoreLikeThis,
depth: depth + 1,
feedback: moreLikeThisState.childFeedback,
installedAppId,
isError: false,
isInWebApp,
isLoading: isQuerying,
isMobile,
isShowTextToSpeech,
isWorkflow,
messageId: childMessageId,
moreLikeThis: true,
onFeedback: handleChildFeedback,
onRetry,
onSave,
siteInfo,
taskId,
}), [
appSourceType,
childMessageId,
completionRes,
controlClearMoreLikeThis,
depth,
handleChildFeedback,
installedAppId,
isInWebApp,
isMobile,
isQuerying,
isShowTextToSpeech,
isWorkflow,
moreLikeThisState.childFeedback,
onRetry,
onSave,
siteInfo,
taskId,
])
return {
childMessageId,
childProps,
config,
completionRes,
currentTab,
handleCopy,
handleMoreLikeThis,
handleOpenLogModal,
handleSubmitHumanInputForm,
isQuerying,
isTop,
isTryApp,
setCurrentTab,
showChildItem: (childMessageId || isQuerying) && depth < MAX_DEPTH,
taskLabel,
}
}
export const generationItemHelpers = {
buildLogItem,
getCurrentTab,
}

View File

@ -0,0 +1,28 @@
import { render } from '@testing-library/react'
import { AppCardSkeleton } from '../app-card-skeleton'
describe('AppCardSkeleton', () => {
it('should render six skeleton cards by default', () => {
const { container } = render(<AppCardSkeleton />)
expect(container.querySelectorAll('.bg-components-card-bg')).toHaveLength(6)
})
it('should render the configured number of skeleton cards', () => {
const { container } = render(<AppCardSkeleton count={2} />)
expect(container.querySelectorAll('.bg-components-card-bg')).toHaveLength(2)
expect(container.querySelectorAll('.animate-pulse')).toHaveLength(10)
})
it('should render nothing when count is zero', () => {
const { container } = render(<AppCardSkeleton count={0} />)
expect(container.firstChild).toBeNull()
})
it('should expose a stable display name', () => {
expect(AppCardSkeleton.displayName).toBe('AppCardSkeleton')
})
})

View File

@ -0,0 +1,329 @@
import type { MarketplaceTemplate } from '@/service/marketplace-templates'
import { skipToken } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import ImportFromMarketplaceTemplateModal from '../import-from-marketplace-template-modal'
const {
mockUseQuery,
mockTemplateDetailQueryOptions,
mockFetchMarketplaceTemplateDSL,
mockToastError,
} = vi.hoisted(() => ({
mockUseQuery: vi.fn(),
mockTemplateDetailQueryOptions: vi.fn(),
mockFetchMarketplaceTemplateDSL: vi.fn(),
mockToastError: vi.fn(),
}))
vi.mock('@tanstack/react-query', async () => {
const actual = await vi.importActual<typeof import('@tanstack/react-query')>('@tanstack/react-query')
return {
...actual,
useQuery: (options: unknown) => mockUseQuery(options),
}
})
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, string | number>) => {
if (options?.publisher)
return `${key}:${options.publisher}`
if (typeof options?.count === 'number')
return `${key}:${options.count}`
return key
},
}),
}))
vi.mock('@/config', () => ({
MARKETPLACE_API_PREFIX: 'https://marketplace.example/api/v1',
MARKETPLACE_URL_PREFIX: 'https://marketplace.example',
}))
vi.mock('@/service/client', () => ({
marketplaceQuery: {
templateDetail: {
queryOptions: (options: unknown) => mockTemplateDetailQueryOptions(options),
},
},
}))
vi.mock('@/service/marketplace-templates', async () => {
const actual = await vi.importActual<typeof import('@/service/marketplace-templates')>('@/service/marketplace-templates')
return {
...actual,
fetchMarketplaceTemplateDSL: (templateId: string) => mockFetchMarketplaceTemplateDSL(templateId),
}
})
vi.mock('@/app/components/base/app-icon', () => ({
default: (props: Record<string, string | undefined>) => React.createElement('div', {
'data-testid': 'app-icon',
'data-icon-type': props.iconType,
'data-icon': props.icon,
'data-background': props.background,
'data-image-url': props.imageUrl,
}),
}))
vi.mock('@/app/components/base/button', () => ({
default: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => React.createElement('button', props, children),
}))
vi.mock('@/app/components/base/ui/dialog', () => ({
Dialog: ({
children,
onOpenChange,
}: {
children: React.ReactNode
onOpenChange?: (open: boolean) => void
}) => React.createElement(
'div',
{ 'data-testid': 'dialog-root' },
React.createElement('button', {
'data-testid': 'dialog-close',
'onClick': () => onOpenChange?.(false),
}, 'close'),
children,
),
DialogContent: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => React.createElement('div', props, children),
DialogTitle: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => React.createElement('div', props, children),
DialogCloseButton: (props: React.ButtonHTMLAttributes<HTMLButtonElement>) => React.createElement('button', { ...props, 'data-testid': 'dialog-close-button' }, 'close'),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
error: (...args: unknown[]) => mockToastError(...args),
},
}))
const baseTemplate: MarketplaceTemplate = {
id: 'template-id',
publisher_type: 'individual',
publisher_unique_handle: 'publisher-handle',
template_name: 'Template Name',
icon: '🚀',
icon_background: '#FFEAD5',
icon_file_key: 'icon-file',
kind: 'classic',
categories: [],
deps_plugins: [],
preferred_languages: [],
overview: 'Template overview',
readme: 'Template readme',
partner_link: '',
version: '1.0.0',
status: 'published',
usage_count: 3,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
}
describe('ImportFromMarketplaceTemplateModal', () => {
const onConfirm = vi.fn()
const onClose = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockTemplateDetailQueryOptions.mockImplementation(options => options)
})
it('should request template detail for the provided template id', () => {
mockUseQuery.mockReturnValue({
data: { data: baseTemplate },
isLoading: false,
isError: false,
})
render(
<ImportFromMarketplaceTemplateModal
templateId="template-id"
onConfirm={onConfirm}
onClose={onClose}
/>,
)
expect(mockTemplateDetailQueryOptions).toHaveBeenCalledWith({
input: {
params: {
templateId: 'template-id',
},
},
})
})
it('should pass skipToken when template id is empty', () => {
mockUseQuery.mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
})
render(
<ImportFromMarketplaceTemplateModal
templateId=""
onConfirm={onConfirm}
onClose={onClose}
/>,
)
expect(mockTemplateDetailQueryOptions).toHaveBeenCalledWith({
input: skipToken,
})
})
it('should render loading state while fetching template detail', () => {
mockUseQuery.mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
})
const { container } = render(
<ImportFromMarketplaceTemplateModal
templateId="template-id"
onConfirm={onConfirm}
onClose={onClose}
/>,
)
const spinner = container.querySelector('.animate-spin')
expect(spinner).toBeInTheDocument()
expect(screen.queryByRole('link')).not.toBeInTheDocument()
})
it('should render error state and allow closing the modal', () => {
mockUseQuery.mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
})
render(
<ImportFromMarketplaceTemplateModal
templateId="template-id"
onConfirm={onConfirm}
onClose={onClose}
/>,
)
expect(screen.getByText('marketplace.template.fetchFailed')).toBeInTheDocument()
fireEvent.click(screen.getByText('newApp.Cancel'))
fireEvent.click(screen.getByTestId('dialog-close'))
expect(onClose).toHaveBeenCalledTimes(2)
})
it('should render template information and marketplace link', () => {
mockUseQuery.mockReturnValue({
data: { data: baseTemplate },
isLoading: false,
isError: false,
})
render(
<ImportFromMarketplaceTemplateModal
templateId="template-id"
onConfirm={onConfirm}
onClose={onClose}
/>,
)
expect(screen.getByText('marketplace.template.modalTitle')).toBeInTheDocument()
expect(screen.getByText('Template Name')).toBeInTheDocument()
expect(screen.getByText('marketplace.template.publishedBy:publisher-handle')).toBeInTheDocument()
expect(screen.getByText('marketplace.template.overview')).toBeInTheDocument()
expect(screen.getByText('Template overview')).toBeInTheDocument()
expect(screen.getByText('marketplace.template.usageCount:3')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'marketplace.template.viewOnMarketplace' })).toHaveAttribute(
'href',
'https://marketplace.example/templates/template-id',
)
expect(screen.getByTestId('app-icon')).toHaveAttribute('data-icon-type', 'image')
expect(screen.getByTestId('app-icon')).toHaveAttribute(
'data-image-url',
'https://marketplace.example/api/v1/templates/template-id/icon',
)
})
it('should fall back to emoji icons and hide optional sections when data is missing', () => {
mockUseQuery.mockReturnValue({
data: {
data: {
...baseTemplate,
icon_file_key: '',
overview: '',
usage_count: 0,
},
},
isLoading: false,
isError: false,
})
render(
<ImportFromMarketplaceTemplateModal
templateId="template-id"
onConfirm={onConfirm}
onClose={onClose}
/>,
)
expect(screen.getByTestId('app-icon')).toHaveAttribute('data-icon-type', 'emoji')
expect(screen.queryByText('marketplace.template.overview')).not.toBeInTheDocument()
expect(screen.queryByText('marketplace.template.usageCount:0')).not.toBeInTheDocument()
})
it('should fetch the template DSL and confirm the import', async () => {
mockUseQuery.mockReturnValue({
data: { data: baseTemplate },
isLoading: false,
isError: false,
})
mockFetchMarketplaceTemplateDSL.mockResolvedValue('yaml-content')
render(
<ImportFromMarketplaceTemplateModal
templateId="template-id"
onConfirm={onConfirm}
onClose={onClose}
/>,
)
fireEvent.click(screen.getByText('marketplace.template.importConfirm'))
await waitFor(() => {
expect(mockFetchMarketplaceTemplateDSL).toHaveBeenCalledWith('template-id')
expect(onConfirm).toHaveBeenCalledWith('yaml-content', baseTemplate)
})
})
it('should show a toast and re-enable the button when importing fails', async () => {
mockUseQuery.mockReturnValue({
data: { data: baseTemplate },
isLoading: false,
isError: false,
})
mockFetchMarketplaceTemplateDSL.mockRejectedValue(new Error('failed'))
render(
<ImportFromMarketplaceTemplateModal
templateId="template-id"
onConfirm={onConfirm}
onClose={onClose}
/>,
)
const importButton = screen.getByText('marketplace.template.importConfirm')
fireEvent.click(importButton)
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith('marketplace.template.importFailed')
})
expect(onConfirm).not.toHaveBeenCalled()
expect(screen.getByText('marketplace.template.importConfirm')).not.toBeDisabled()
})
})

View File

@ -0,0 +1,116 @@
import type { DebugInfo as DebugInfoType } from '../../types'
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import DebugInfo from '../debug-info'
const { mockUseDebugKey } = vi.hoisted(() => ({
mockUseDebugKey: vi.fn(),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
}))
vi.mock('@/service/use-plugins', () => ({
useDebugKey: () => mockUseDebugKey(),
}))
vi.mock('@/app/components/base/button', () => ({
default: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => React.createElement('button', props, children),
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({
children,
popupContent,
disabled,
}: {
children: React.ReactNode
popupContent: React.ReactNode
disabled?: boolean
}) => (
<div data-testid="tooltip" data-disabled={String(Boolean(disabled))}>
{children}
{!disabled && <div data-testid="tooltip-content">{popupContent}</div>}
</div>
),
}))
vi.mock('../../base/key-value-item', () => ({
default: ({
label,
value,
maskedValue,
}: {
label: string
value: string
maskedValue?: string
}) => (
<div data-testid={`key-value-${label}`}>
<span>{label}</span>
<span>{value}</span>
{maskedValue && <span>{maskedValue}</span>}
</div>
),
}))
describe('DebugInfo', () => {
const debugInfo: DebugInfoType = {
host: '127.0.0.1',
port: 8765,
key: '12345678abcdefgh87654321',
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render nothing while loading', () => {
mockUseDebugKey.mockReturnValue({
data: undefined,
isLoading: true,
})
const { container } = render(<DebugInfo />)
expect(container.firstChild).toBeNull()
})
it('should render a disabled tooltip when debug info is unavailable', () => {
mockUseDebugKey.mockReturnValue({
data: undefined,
isLoading: false,
})
render(<DebugInfo />)
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-disabled', 'true')
expect(screen.queryByTestId('tooltip-content')).not.toBeInTheDocument()
})
it('should render masked debug information and docs link when data is available', () => {
mockUseDebugKey.mockReturnValue({
data: debugInfo,
isLoading: false,
})
render(<DebugInfo />)
expect(screen.getAllByTestId('tooltip')[0]).toHaveAttribute('data-disabled', 'false')
expect(screen.getByText('debugInfo.title')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'debugInfo.viewDocs' })).toHaveAttribute(
'href',
'https://docs.example.com/develop-plugin/features-and-specs/plugin-types/remote-debug-a-plugin',
)
expect(screen.getByTestId('key-value-URL')).toHaveTextContent('127.0.0.1:8765')
expect(screen.getByTestId('key-value-Key')).toHaveTextContent('12345678abcdefgh87654321')
expect(screen.getByTestId('key-value-Key')).toHaveTextContent('12345678********87654321')
})
})

View File

@ -0,0 +1,369 @@
import type { PluginDeclaration, PluginDetail } from '../../types'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { PluginCategoryEnum, PluginSource } from '../../types'
import PluginsPanel from '../plugins-panel'
const {
mockSetFilters,
mockSetCurrentPluginID,
mockLoadNextPage,
mockInvalidateInstalledPluginList,
} = vi.hoisted(() => ({
mockSetFilters: vi.fn(),
mockSetCurrentPluginID: vi.fn(),
mockLoadNextPage: vi.fn(),
mockInvalidateInstalledPluginList: vi.fn(),
}))
type PluginPageContextState = {
filters: {
categories: string[]
tags: string[]
searchQuery: string
}
setFilters: (filters: {
categories: string[]
tags: string[]
searchQuery: string
}) => void
currentPluginID: string | undefined
setCurrentPluginID: (pluginID?: string) => void
}
let mockContextState: PluginPageContextState
let mockPluginList: PluginDetail[]
let mockPluginListLoading = false
let mockPluginListFetching = false
let mockIsLastPage = true
vi.mock('ahooks', () => ({
useDebounceFn: (fn: (...args: unknown[]) => void) => ({
run: fn,
}),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en-US',
}))
vi.mock('@/i18n-config', () => ({
renderI18nObject: (value: Record<string, string> | undefined, locale: string) =>
value?.[locale] ?? value?.['en-US'] ?? '',
}))
vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: () => ({
data: { plugins: mockPluginList },
isLoading: mockPluginListLoading,
isFetching: mockPluginListFetching,
isLastPage: mockIsLastPage,
loadNextPage: mockLoadNextPage,
}),
useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList,
}))
vi.mock('../../hooks', () => ({
usePluginsWithLatestVersion: (plugins: PluginDetail[] | undefined) => plugins ?? [],
}))
vi.mock('../context', () => ({
usePluginPageContext: (selector: (value: PluginPageContextState) => unknown) => selector(mockContextState),
}))
vi.mock('../filter-management', () => ({
default: ({ onFilterChange }: {
onFilterChange: (filters: {
categories: string[]
tags: string[]
searchQuery: string
}) => void
}) => (
<button
data-testid="filter-management"
onClick={() => onFilterChange({
categories: ['tool'],
tags: ['featured'],
searchQuery: 'needle',
})}
>
filter
</button>
),
}))
vi.mock('../list', () => ({
default: ({ pluginList }: { pluginList: PluginDetail[] }) => (
<div data-testid="plugin-list">
{pluginList.map(plugin => plugin.plugin_id).join(',')}
</div>
),
}))
vi.mock('../empty', () => ({
default: () => <div data-testid="empty">empty</div>,
}))
vi.mock('@/app/components/base/loading', () => ({
default: ({ className, type }: { className?: string, type?: string }) => (
<div data-testid="loading" data-class-name={className} data-type={type}>loading</div>
),
}))
vi.mock('@/app/components/base/button', () => ({
default: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => React.createElement('button', props, children),
}))
vi.mock('@/app/components/plugins/plugin-detail-panel', () => ({
default: ({
detail,
onUpdate,
onHide,
}: {
detail?: PluginDetail
onUpdate: () => void
onHide: () => void
}) => (
<div data-testid="plugin-detail-panel">
<span data-testid="plugin-detail-id">{detail?.plugin_id ?? 'none'}</span>
<button data-testid="plugin-detail-update" onClick={onUpdate}>update</button>
<button data-testid="plugin-detail-hide" onClick={onHide}>hide</button>
</div>
),
}))
const createPluginDeclaration = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({
plugin_unique_identifier: 'plugin.unique',
version: '1.0.0',
author: 'Plugin Author',
icon: 'icon',
name: 'Declaration Name',
category: PluginCategoryEnum.tool,
label: { 'en-US': 'Label Text' } as PluginDeclaration['label'],
description: { 'en-US': 'Description Text' } as PluginDeclaration['description'],
created_at: '2024-01-01T00:00:00Z',
resource: {} as PluginDeclaration['resource'],
plugins: {} as PluginDeclaration['plugins'],
verified: false,
endpoint: { settings: [], endpoints: [] },
tool: undefined,
datasource: undefined,
model: {} as PluginDeclaration['model'],
tags: ['featured'],
agent_strategy: {} as PluginDeclaration['agent_strategy'],
meta: { version: '1.0.0' },
trigger: {} as PluginDeclaration['trigger'],
...overrides,
})
const createPlugin = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
id: 'plugin-id',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
name: 'Plugin Name',
plugin_id: 'plugin.id',
plugin_unique_identifier: 'plugin.unique',
declaration: createPluginDeclaration(),
installation_id: 'installation-id',
tenant_id: 'tenant-id',
endpoints_setups: 0,
endpoints_active: 0,
version: '1.0.0',
latest_version: '1.0.1',
latest_unique_identifier: 'plugin.unique@1.0.1',
source: PluginSource.marketplace,
status: 'active',
deprecated_reason: '',
alternative_plugin_id: '',
...overrides,
})
describe('PluginsPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockContextState = {
filters: {
categories: [],
tags: [],
searchQuery: '',
},
setFilters: mockSetFilters,
currentPluginID: undefined,
setCurrentPluginID: mockSetCurrentPluginID,
}
mockPluginList = [createPlugin()]
mockPluginListLoading = false
mockPluginListFetching = false
mockIsLastPage = true
})
it('should render loading while the plugin list is loading', () => {
mockPluginListLoading = true
render(<PluginsPanel />)
expect(screen.getByTestId('loading')).toHaveAttribute('data-type', 'app')
expect(screen.getByTestId('plugin-detail-id')).toHaveTextContent('none')
})
it('should update filters through the debounced callback', () => {
render(<PluginsPanel />)
fireEvent.click(screen.getByTestId('filter-management'))
expect(mockSetFilters).toHaveBeenCalledWith({
categories: ['tool'],
tags: ['featured'],
searchQuery: 'needle',
})
})
it('should filter plugins by category and tag', () => {
mockContextState.filters = {
categories: ['tool'],
tags: ['featured'],
searchQuery: '',
}
render(<PluginsPanel />)
expect(screen.getByTestId('plugin-list')).toHaveTextContent('plugin.id')
})
it('should filter plugins by plugin id', () => {
const plugin = createPlugin({
plugin_id: 'needle-id',
name: 'Plugin Name',
declaration: createPluginDeclaration({
name: 'Declaration Name',
label: { 'en-US': 'Label Text' } as PluginDeclaration['label'],
description: { 'en-US': 'Description Text' } as PluginDeclaration['description'],
}),
})
mockPluginList = [plugin]
mockContextState.filters = {
categories: [],
tags: [],
searchQuery: 'needle-id',
}
render(<PluginsPanel />)
expect(screen.getByTestId('plugin-list')).toHaveTextContent('needle-id')
})
it('should filter plugins by plugin name, declaration name, label, and description', () => {
const plugin = createPlugin({
plugin_id: 'plugin-id',
name: 'Needle Name',
declaration: createPluginDeclaration({
name: 'Needle Declaration',
label: { 'en-US': 'Needle Label' } as PluginDeclaration['label'],
description: { 'en-US': 'Needle Description' } as PluginDeclaration['description'],
}),
})
mockPluginList = [plugin]
const { rerender } = render(<PluginsPanel />)
mockContextState.filters = {
categories: [],
tags: [],
searchQuery: 'needle name',
}
rerender(<PluginsPanel />)
expect(screen.getByTestId('plugin-list')).toHaveTextContent('plugin-id')
mockContextState.filters = {
categories: [],
tags: [],
searchQuery: 'needle declaration',
}
rerender(<PluginsPanel />)
expect(screen.getByTestId('plugin-list')).toHaveTextContent('plugin-id')
mockContextState.filters = {
categories: [],
tags: [],
searchQuery: 'needle label',
}
rerender(<PluginsPanel />)
expect(screen.getByTestId('plugin-list')).toHaveTextContent('plugin-id')
mockContextState.filters = {
categories: [],
tags: [],
searchQuery: 'needle description',
}
rerender(<PluginsPanel />)
expect(screen.getByTestId('plugin-list')).toHaveTextContent('plugin-id')
})
it('should render empty state when no plugin matches the filters', () => {
mockContextState.filters = {
categories: ['model'],
tags: [],
searchQuery: '',
}
render(<PluginsPanel />)
expect(screen.getByTestId('empty')).toBeInTheDocument()
expect(screen.queryByTestId('plugin-list')).not.toBeInTheDocument()
})
it('should render empty state when the search query does not match any plugin field', () => {
mockContextState.filters = {
categories: [],
tags: [],
searchQuery: 'missing-value',
}
render(<PluginsPanel />)
expect(screen.getByTestId('empty')).toBeInTheDocument()
expect(screen.queryByTestId('plugin-list')).not.toBeInTheDocument()
})
it('should render a load more button and request the next page', () => {
mockIsLastPage = false
render(<PluginsPanel />)
fireEvent.click(screen.getByText('common.loadMore'))
expect(mockLoadNextPage).toHaveBeenCalledTimes(1)
})
it('should render the inline loader when fetching more items', () => {
mockIsLastPage = false
mockPluginListFetching = true
render(<PluginsPanel />)
expect(screen.getByTestId('loading')).toHaveAttribute('data-class-name', 'size-8')
expect(screen.queryByText('common.loadMore')).not.toBeInTheDocument()
})
it('should pass the selected plugin to the detail panel and handle its callbacks', () => {
mockContextState.currentPluginID = 'plugin.id'
render(<PluginsPanel />)
expect(screen.getByTestId('plugin-detail-id')).toHaveTextContent('plugin.id')
fireEvent.click(screen.getByTestId('plugin-detail-update'))
fireEvent.click(screen.getByTestId('plugin-detail-hide'))
expect(mockInvalidateInstalledPluginList).toHaveBeenCalledTimes(1)
expect(mockSetCurrentPluginID).toHaveBeenCalledWith(undefined)
})
})