mirror of
https://github.com/langgenius/dify.git
synced 2026-05-01 16:08:04 +08:00
refactor(app-publisher): streamline app publisher component and introduce custom hook for improved state management
This commit is contained in:
208
web/app/components/app/app-publisher/__tests__/index.spec.tsx
Normal file
208
web/app/components/app/app-publisher/__tests__/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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 })
|
||||
})
|
||||
})
|
||||
@ -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' })
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
368
web/app/components/app/app-publisher/use-app-publisher.ts
Normal file
368
web/app/components/app/app-publisher/use-app-publisher.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
308
web/app/components/app/text-generate/item/use-generation-item.ts
Normal file
308
web/app/components/app/text-generate/item/use-generation-item.ts
Normal 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,
|
||||
}
|
||||
28
web/app/components/apps/__tests__/app-card-skeleton.spec.tsx
Normal file
28
web/app/components/apps/__tests__/app-card-skeleton.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user