Merge branch 'main' into jzh

This commit is contained in:
JzoNg
2026-03-20 15:33:49 +08:00
83 changed files with 2027 additions and 652 deletions

View File

@@ -1,4 +1,4 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import * as React from 'react'
import { AppModeEnum } from '@/types/app'
import AppTypeSelector, { AppTypeIcon, AppTypeLabel } from './index'
@@ -14,7 +14,7 @@ describe('AppTypeSelector', () => {
render(<AppTypeSelector value={[]} onChange={vi.fn()} />)
expect(screen.getByText('app.typeSelector.all')).toBeInTheDocument()
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
expect(screen.queryByText('app.typeSelector.workflow')).not.toBeInTheDocument()
})
})
@@ -39,24 +39,27 @@ describe('AppTypeSelector', () => {
// Covers opening/closing the dropdown and selection updates.
describe('User interactions', () => {
it('should toggle option list when clicking the trigger', () => {
it('should close option list when clicking outside', () => {
render(<AppTypeSelector value={[]} onChange={vi.fn()} />)
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
expect(screen.queryByRole('list')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('app.typeSelector.all'))
expect(screen.getByRole('tooltip')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'app.typeSelector.all' }))
expect(screen.getByRole('list')).toBeInTheDocument()
fireEvent.click(screen.getByText('app.typeSelector.all'))
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
fireEvent.pointerDown(document.body)
fireEvent.click(document.body)
return waitFor(() => {
expect(screen.queryByRole('list')).not.toBeInTheDocument()
})
})
it('should call onChange with added type when selecting an unselected item', () => {
const onChange = vi.fn()
render(<AppTypeSelector value={[]} onChange={onChange} />)
fireEvent.click(screen.getByText('app.typeSelector.all'))
fireEvent.click(within(screen.getByRole('tooltip')).getByText('app.typeSelector.workflow'))
fireEvent.click(screen.getByRole('button', { name: 'app.typeSelector.all' }))
fireEvent.click(within(screen.getByRole('list')).getByRole('button', { name: 'app.typeSelector.workflow' }))
expect(onChange).toHaveBeenCalledWith([AppModeEnum.WORKFLOW])
})
@@ -65,8 +68,8 @@ describe('AppTypeSelector', () => {
const onChange = vi.fn()
render(<AppTypeSelector value={[AppModeEnum.WORKFLOW]} onChange={onChange} />)
fireEvent.click(screen.getByText('app.typeSelector.workflow'))
fireEvent.click(within(screen.getByRole('tooltip')).getByText('app.typeSelector.workflow'))
fireEvent.click(screen.getByRole('button', { name: 'app.typeSelector.workflow' }))
fireEvent.click(within(screen.getByRole('list')).getByRole('button', { name: 'app.typeSelector.workflow' }))
expect(onChange).toHaveBeenCalledWith([])
})
@@ -75,8 +78,8 @@ describe('AppTypeSelector', () => {
const onChange = vi.fn()
render(<AppTypeSelector value={[AppModeEnum.CHAT]} onChange={onChange} />)
fireEvent.click(screen.getByText('app.typeSelector.chatbot'))
fireEvent.click(within(screen.getByRole('tooltip')).getByText('app.typeSelector.agent'))
fireEvent.click(screen.getByRole('button', { name: 'app.typeSelector.chatbot' }))
fireEvent.click(within(screen.getByRole('list')).getByRole('button', { name: 'app.typeSelector.agent' }))
expect(onChange).toHaveBeenCalledWith([AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT])
})
@@ -88,7 +91,7 @@ describe('AppTypeSelector', () => {
fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
expect(onChange).toHaveBeenCalledWith([])
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
expect(screen.queryByText('app.typeSelector.workflow')).not.toBeInTheDocument()
})
})
})

View File

@@ -4,13 +4,12 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { BubbleTextMod, ChatBot, ListSparkle, Logic } from '@/app/components/base/icons/src/vender/solid/communication'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
Popover,
PopoverContent,
PopoverTrigger,
} from '@/app/components/base/ui/popover'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
import Checkbox from '../../base/checkbox'
export type AppSelectorProps = {
value: Array<AppModeEnum>
@@ -22,43 +21,43 @@ const allTypes: AppModeEnum[] = [AppModeEnum.WORKFLOW, AppModeEnum.ADVANCED_CHAT
const AppTypeSelector = ({ value, onChange }: AppSelectorProps) => {
const [open, setOpen] = useState(false)
const { t } = useTranslation()
const triggerLabel = value.length === 0
? t('typeSelector.all', { ns: 'app' })
: value.map(type => getAppTypeLabel(type, t)).join(', ')
return (
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={4}
>
<div className="relative">
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
className="block"
>
<div className={cn(
'flex cursor-pointer items-center justify-between space-x-1 rounded-md px-2 hover:bg-state-base-hover',
<PopoverTrigger
aria-label={triggerLabel}
className={cn(
'flex cursor-pointer items-center justify-between rounded-md px-2 hover:bg-state-base-hover',
value.length > 0 && 'pr-7',
)}
>
<AppTypeSelectTrigger values={value} />
</PopoverTrigger>
{value.length > 0 && (
<button
type="button"
aria-label={t('operation.clear', { ns: 'common' })}
className="group absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2"
onClick={() => onChange([])}
>
<AppTypeSelectTrigger values={value} />
{value && value.length > 0 && (
<button
type="button"
aria-label={t('operation.clear', { ns: 'common' })}
className="group h-4 w-4"
onClick={(e) => {
e.stopPropagation()
onChange([])
}}
>
<RiCloseCircleFill
className="h-3.5 w-3.5 text-text-quaternary group-hover:text-text-tertiary"
/>
</button>
)}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[1002]">
<ul className="relative w-[240px] rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]">
<RiCloseCircleFill
className="h-3.5 w-3.5 text-text-quaternary group-hover:text-text-tertiary"
/>
</button>
)}
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="w-[240px] rounded-xl border border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]"
>
<ul className="relative w-full p-1">
{allTypes.map(mode => (
<AppTypeSelectorItem
key={mode}
@@ -73,9 +72,9 @@ const AppTypeSelector = ({ value, onChange }: AppSelectorProps) => {
/>
))}
</ul>
</PortalToFollowElemContent>
</PopoverContent>
</div>
</PortalToFollowElem>
</Popover>
)
}
@@ -173,33 +172,54 @@ type AppTypeSelectorItemProps = {
}
function AppTypeSelectorItem({ checked, type, onClick }: AppTypeSelectorItemProps) {
return (
<li className="flex cursor-pointer items-center space-x-2 rounded-lg py-1 pl-2 pr-1 hover:bg-state-base-hover" onClick={onClick}>
<Checkbox checked={checked} />
<AppTypeIcon type={type} />
<div className="grow p-1 pl-0">
<AppTypeLabel type={type} className="system-sm-medium text-components-menu-item-text" />
</div>
<li>
<button
type="button"
className="flex w-full items-center space-x-2 rounded-lg py-1 pl-2 pr-1 text-left hover:bg-state-base-hover"
aria-pressed={checked}
onClick={onClick}
>
<span
aria-hidden="true"
className={cn(
'flex h-4 w-4 shrink-0 items-center justify-center rounded-[4px] shadow-xs shadow-shadow-shadow-3',
checked
? 'bg-components-checkbox-bg text-components-checkbox-icon'
: 'border border-components-checkbox-border bg-components-checkbox-bg-unchecked',
)}
>
{checked && <span className="i-ri-check-line h-3 w-3" />}
</span>
<AppTypeIcon type={type} />
<div className="grow p-1 pl-0">
<AppTypeLabel type={type} className="system-sm-medium text-components-menu-item-text" />
</div>
</button>
</li>
)
}
function getAppTypeLabel(type: AppModeEnum, t: ReturnType<typeof useTranslation>['t']) {
if (type === AppModeEnum.CHAT)
return t('typeSelector.chatbot', { ns: 'app' })
if (type === AppModeEnum.AGENT_CHAT)
return t('typeSelector.agent', { ns: 'app' })
if (type === AppModeEnum.COMPLETION)
return t('typeSelector.completion', { ns: 'app' })
if (type === AppModeEnum.ADVANCED_CHAT)
return t('typeSelector.advanced', { ns: 'app' })
if (type === AppModeEnum.WORKFLOW)
return t('typeSelector.workflow', { ns: 'app' })
return ''
}
type AppTypeLabelProps = {
type: AppModeEnum
className?: string
}
export function AppTypeLabel({ type, className }: AppTypeLabelProps) {
const { t } = useTranslation()
let label = ''
if (type === AppModeEnum.CHAT)
label = t('typeSelector.chatbot', { ns: 'app' })
if (type === AppModeEnum.AGENT_CHAT)
label = t('typeSelector.agent', { ns: 'app' })
if (type === AppModeEnum.COMPLETION)
label = t('typeSelector.completion', { ns: 'app' })
if (type === AppModeEnum.ADVANCED_CHAT)
label = t('typeSelector.advanced', { ns: 'app' })
if (type === AppModeEnum.WORKFLOW)
label = t('typeSelector.workflow', { ns: 'app' })
return <span className={className}>{label}</span>
return <span className={className}>{getAppTypeLabel(type, t)}</span>
}

View File

@@ -13,12 +13,20 @@ vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
const { mockToastNotify } = vi.hoisted(() => ({
mockToastNotify: vi.fn(),
}))
vi.mock('@/app/components/base/toast', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/toast')>()
return {
...actual,
default: Object.assign(actual.default, {
notify: mockToastNotify,
}),
}
})
const mockCreateEmptyDataset = vi.fn()
const mockInvalidDatasetList = vi.fn()
@@ -37,6 +45,8 @@ vi.mock('@/service/knowledge/use-dataset', () => ({
describe('CreateCard', () => {
beforeEach(() => {
vi.clearAllMocks()
mockToastNotify.mockReset()
mockToastNotify.mockImplementation(() => ({ clear: vi.fn() }))
})
describe('Rendering', () => {

View File

@@ -1,8 +1,6 @@
import type { PipelineTemplate } from '@/models/pipeline'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import { ChunkingMode } from '@/models/datasets'
import EditPipelineInfo from '../edit-pipeline-info'
@@ -16,12 +14,21 @@ vi.mock('@/service/use-pipeline', () => ({
useInvalidCustomizedTemplateList: () => mockInvalidCustomizedTemplateList,
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
const { mockToastAdd } = vi.hoisted(() => ({
mockToastAdd: vi.fn(),
}))
vi.mock('@/app/components/base/ui/toast', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/ui/toast')>()
return {
...actual,
toast: {
...actual.toast,
add: mockToastAdd,
},
}
})
// Mock AppIconPicker to capture interactions
let _mockOnSelect: ((icon: { type: 'emoji' | 'image', icon?: string, background?: string, fileId?: string, url?: string }) => void) | undefined
let _mockOnClose: (() => void) | undefined
@@ -88,6 +95,7 @@ describe('EditPipelineInfo', () => {
beforeEach(() => {
vi.clearAllMocks()
mockToastAdd.mockReset()
_mockOnSelect = undefined
_mockOnClose = undefined
})
@@ -235,9 +243,9 @@ describe('EditPipelineInfo', () => {
fireEvent.click(saveButton)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: 'Please enter a name for the Knowledge Base.',
title: 'datasetPipeline.editPipelineInfoNameRequired',
})
})
})

View File

@@ -1,7 +1,6 @@
import type { PipelineTemplate } from '@/models/pipeline'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import { ChunkingMode } from '@/models/datasets'
import TemplateCard from '../index'
@@ -15,12 +14,21 @@ vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
const { mockToastAdd } = vi.hoisted(() => ({
mockToastAdd: vi.fn(),
}))
vi.mock('@/app/components/base/ui/toast', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/ui/toast')>()
return {
...actual,
toast: {
...actual.toast,
add: mockToastAdd,
},
}
})
// Mock download utilities
vi.mock('@/utils/download', () => ({
downloadBlob: vi.fn(),
@@ -174,6 +182,7 @@ describe('TemplateCard', () => {
beforeEach(() => {
vi.clearAllMocks()
mockToastAdd.mockReset()
mockIsExporting = false
_capturedOnConfirm = undefined
_capturedOnCancel = undefined
@@ -228,9 +237,9 @@ describe('TemplateCard', () => {
fireEvent.click(chooseButton)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
title: expect.any(String),
})
})
})
@@ -291,9 +300,9 @@ describe('TemplateCard', () => {
fireEvent.click(chooseButton)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'success',
message: expect.any(String),
title: expect.any(String),
})
})
})
@@ -309,9 +318,9 @@ describe('TemplateCard', () => {
fireEvent.click(chooseButton)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
title: expect.any(String),
})
})
})
@@ -458,9 +467,9 @@ describe('TemplateCard', () => {
fireEvent.click(exportButton)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'success',
message: expect.any(String),
title: expect.any(String),
})
})
})
@@ -476,9 +485,9 @@ describe('TemplateCard', () => {
fireEvent.click(exportButton)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
title: expect.any(String),
})
})
})

View File

@@ -32,16 +32,21 @@ vi.mock('@/service/base', () => ({
ssePost: mockSsePost,
}))
// Mock Toast.notify - static method that manipulates DOM, needs mocking to verify calls
const { mockToastNotify } = vi.hoisted(() => ({
mockToastNotify: vi.fn(),
// Mock toast.add because the component reports errors through the UI toast manager.
const { mockToastAdd } = vi.hoisted(() => ({
mockToastAdd: vi.fn(),
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: mockToastNotify,
},
}))
vi.mock('@/app/components/base/ui/toast', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/ui/toast')>()
return {
...actual,
toast: {
...actual.toast,
add: mockToastAdd,
},
}
})
// Mock useGetDataSourceAuth - API service hook requires mocking
const { mockUseGetDataSourceAuth } = vi.hoisted(() => ({
@@ -192,6 +197,7 @@ const createDefaultProps = (overrides?: Partial<OnlineDocumentsProps>): OnlineDo
describe('OnlineDocuments', () => {
beforeEach(() => {
vi.clearAllMocks()
mockToastAdd.mockReset()
// Reset store state
mockStoreState.documentsData = []
@@ -509,9 +515,9 @@ describe('OnlineDocuments', () => {
render(<OnlineDocuments {...props} />)
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: 'Something went wrong',
title: 'Something went wrong',
})
})
})
@@ -774,9 +780,9 @@ describe('OnlineDocuments', () => {
render(<OnlineDocuments {...props} />)
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: 'API Error Message',
title: 'API Error Message',
})
})
})
@@ -1094,9 +1100,9 @@ describe('OnlineDocuments', () => {
render(<OnlineDocuments {...props} />)
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: 'Failed to fetch documents',
title: 'Failed to fetch documents',
})
})

View File

@@ -45,15 +45,20 @@ vi.mock('@/service/use-datasource', () => ({
useGetDataSourceAuth: mockUseGetDataSourceAuth,
}))
const { mockToastNotify } = vi.hoisted(() => ({
mockToastNotify: vi.fn(),
const { mockToastAdd } = vi.hoisted(() => ({
mockToastAdd: vi.fn(),
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: mockToastNotify,
},
}))
vi.mock('@/app/components/base/ui/toast', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/ui/toast')>()
return {
...actual,
toast: {
...actual.toast,
add: mockToastAdd,
},
}
})
// Note: zustand/react/shallow useShallow is imported directly (simple utility function)
@@ -231,6 +236,7 @@ const resetMockStoreState = () => {
describe('OnlineDrive', () => {
beforeEach(() => {
vi.clearAllMocks()
mockToastAdd.mockReset()
// Reset store state
resetMockStoreState()
@@ -541,9 +547,9 @@ describe('OnlineDrive', () => {
render(<OnlineDrive {...props} />)
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: errorMessage,
title: errorMessage,
})
})
})
@@ -915,9 +921,9 @@ describe('OnlineDrive', () => {
render(<OnlineDrive {...props} />)
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: errorMessage,
title: errorMessage,
})
})
})

View File

@@ -1,13 +1,26 @@
import type { MockInstance } from 'vitest'
import type { RAGPipelineVariables } from '@/models/pipeline'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { BaseFieldType } from '@/app/components/base/form/form-scenarios/base/types'
import Toast from '@/app/components/base/toast'
import { CrawlStep } from '@/models/datasets'
import { PipelineInputVarType } from '@/models/pipeline'
import Options from '../index'
const { mockToastAdd } = vi.hoisted(() => ({
mockToastAdd: vi.fn(),
}))
vi.mock('@/app/components/base/ui/toast', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/ui/toast')>()
return {
...actual,
toast: {
...actual.toast,
add: mockToastAdd,
},
}
})
// Mock useInitialData and useConfigurations hooks
const { mockUseInitialData, mockUseConfigurations } = vi.hoisted(() => ({
mockUseInitialData: vi.fn(),
@@ -116,13 +129,9 @@ const createDefaultProps = (overrides?: Partial<OptionsProps>): OptionsProps =>
})
describe('Options', () => {
let toastNotifySpy: MockInstance
beforeEach(() => {
vi.clearAllMocks()
// Spy on Toast.notify instead of mocking the entire module
toastNotifySpy = vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
mockToastAdd.mockReset()
// Reset mock form values
Object.keys(mockFormValues).forEach(key => delete mockFormValues[key])
@@ -132,10 +141,6 @@ describe('Options', () => {
mockUseConfigurations.mockReturnValue([createMockConfiguration()])
})
afterEach(() => {
toastNotifySpy.mockRestore()
})
describe('Rendering', () => {
it('should render without crashing', () => {
const props = createDefaultProps()
@@ -638,7 +643,7 @@ describe('Options', () => {
fireEvent.click(screen.getByRole('button'))
// Assert - Toast should be called with error message
expect(toastNotifySpy).toHaveBeenCalledWith(
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
@@ -660,10 +665,10 @@ describe('Options', () => {
fireEvent.click(screen.getByRole('button'))
// Assert - Toast message should contain field path
expect(toastNotifySpy).toHaveBeenCalledWith(
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
message: expect.stringContaining('email_address'),
title: expect.stringContaining('email_address'),
}),
)
})
@@ -714,8 +719,8 @@ describe('Options', () => {
fireEvent.click(screen.getByRole('button'))
// Assert - Toast should be called once (only first error)
expect(toastNotifySpy).toHaveBeenCalledTimes(1)
expect(toastNotifySpy).toHaveBeenCalledWith(
expect(mockToastAdd).toHaveBeenCalledTimes(1)
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
@@ -738,7 +743,7 @@ describe('Options', () => {
fireEvent.click(screen.getByRole('button'))
// Assert - No toast error, onSubmit called
expect(toastNotifySpy).not.toHaveBeenCalled()
expect(mockToastAdd).not.toHaveBeenCalled()
expect(mockOnSubmit).toHaveBeenCalled()
})
@@ -835,7 +840,7 @@ describe('Options', () => {
fireEvent.click(screen.getByRole('button'))
expect(mockOnSubmit).toHaveBeenCalled()
expect(toastNotifySpy).not.toHaveBeenCalled()
expect(mockToastAdd).not.toHaveBeenCalled()
})
it('should fail validation with invalid data', () => {
@@ -854,7 +859,7 @@ describe('Options', () => {
fireEvent.click(screen.getByRole('button'))
expect(mockOnSubmit).not.toHaveBeenCalled()
expect(toastNotifySpy).toHaveBeenCalled()
expect(mockToastAdd).toHaveBeenCalled()
})
it('should show error toast message when validation fails', () => {
@@ -871,10 +876,10 @@ describe('Options', () => {
fireEvent.click(screen.getByRole('button'))
expect(toastNotifySpy).toHaveBeenCalledWith(
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
message: expect.any(String),
title: expect.any(String),
}),
)
})

View File

@@ -1,13 +1,24 @@
import type { NotionPage } from '@/models/common'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import Toast from '@/app/components/base/toast'
import OnlineDocumentPreview from '../online-document-preview'
// Uses global react-i18next mock from web/vitest.setup.ts
// Spy on Toast.notify
const toastNotifySpy = vi.spyOn(Toast, 'notify')
const { mockToastAdd } = vi.hoisted(() => ({
mockToastAdd: vi.fn(),
}))
vi.mock('@/app/components/base/ui/toast', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/ui/toast')>()
return {
...actual,
toast: {
...actual.toast,
add: mockToastAdd,
},
}
})
// Mock dataset-detail context - needs mock to control return values
const mockPipelineId = vi.fn()
@@ -56,6 +67,7 @@ const defaultProps = {
describe('OnlineDocumentPreview', () => {
beforeEach(() => {
vi.clearAllMocks()
mockToastAdd.mockReset()
mockPipelineId.mockReturnValue('pipeline-123')
mockUsePreviewOnlineDocument.mockReturnValue({
mutateAsync: mockMutateAsync,
@@ -258,9 +270,9 @@ describe('OnlineDocumentPreview', () => {
render(<OnlineDocumentPreview {...defaultProps} />)
await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: errorMessage,
title: errorMessage,
})
})
})
@@ -276,9 +288,9 @@ describe('OnlineDocumentPreview', () => {
render(<OnlineDocumentPreview {...defaultProps} />)
await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: 'Network Error',
title: 'Network Error',
})
})
})

View File

@@ -3,13 +3,24 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import * as z from 'zod'
import { BaseFieldType } from '@/app/components/base/form/form-scenarios/base/types'
import Toast from '@/app/components/base/toast'
import Actions from '../actions'
import Form from '../form'
import Header from '../header'
// Spy on Toast.notify for validation tests
const toastNotifySpy = vi.spyOn(Toast, 'notify')
const { mockToastAdd } = vi.hoisted(() => ({
mockToastAdd: vi.fn(),
}))
vi.mock('@/app/components/base/ui/toast', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/ui/toast')>()
return {
...actual,
toast: {
...actual.toast,
add: mockToastAdd,
},
}
})
// Test Data Factory Functions
@@ -335,7 +346,7 @@ describe('Form', () => {
beforeEach(() => {
vi.clearAllMocks()
toastNotifySpy.mockClear()
mockToastAdd.mockReset()
})
describe('Rendering', () => {
@@ -444,9 +455,9 @@ describe('Form', () => {
// Assert - validation error should be shown
await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: '"field1" is required',
title: '"field1" is required',
})
})
})
@@ -566,9 +577,9 @@ describe('Form', () => {
fireEvent.submit(form)
await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: '"field1" is required',
title: '"field1" is required',
})
})
})
@@ -583,7 +594,7 @@ describe('Form', () => {
// Assert - wait a bit and verify onSubmit was not called
await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalled()
expect(mockToastAdd).toHaveBeenCalled()
})
expect(onSubmit).not.toHaveBeenCalled()
})

View File

@@ -2,10 +2,23 @@ import type { BaseConfiguration } from '@/app/components/base/form/form-scenario
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
import Toast from '@/app/components/base/toast'
import Form from '../form'
const { mockToastAdd } = vi.hoisted(() => ({
mockToastAdd: vi.fn(),
}))
vi.mock('@/app/components/base/ui/toast', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/ui/toast')>()
return {
...actual,
toast: {
...actual.toast,
add: mockToastAdd,
},
}
})
// Mock the Header component (sibling component, not a base component)
vi.mock('../header', () => ({
default: ({ onReset, resetDisabled, onPreview, previewDisabled }: {
@@ -44,7 +57,7 @@ const defaultProps = {
describe('Form (process-documents)', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
mockToastAdd.mockReset()
})
// Verify basic rendering of form structure
@@ -106,8 +119,11 @@ describe('Form (process-documents)', () => {
fireEvent.submit(form)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
title: '"name" Name is required',
}),
)
})
})
@@ -121,7 +137,7 @@ describe('Form (process-documents)', () => {
await waitFor(() => {
expect(defaultProps.onSubmit).toHaveBeenCalled()
})
expect(Toast.notify).not.toHaveBeenCalled()
expect(mockToastAdd).not.toHaveBeenCalled()
})
})

View File

@@ -164,7 +164,7 @@ describe('ExternalKnowledgeBaseConnector', () => {
// Verify success notification
expect(mockNotify).toHaveBeenCalledWith({
type: 'success',
title: 'External Knowledge Base Connected Successfully',
title: 'dataset.externalKnowledgeForm.connectedSuccess',
})
// Verify navigation back
@@ -206,7 +206,7 @@ describe('ExternalKnowledgeBaseConnector', () => {
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
title: 'Failed to connect External Knowledge Base',
title: 'dataset.externalKnowledgeForm.connectedFailed',
})
})
@@ -228,7 +228,7 @@ describe('ExternalKnowledgeBaseConnector', () => {
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
title: 'Failed to connect External Knowledge Base',
title: 'dataset.externalKnowledgeForm.connectedFailed',
})
})
@@ -274,7 +274,7 @@ describe('ExternalKnowledgeBaseConnector', () => {
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'success',
title: 'External Knowledge Base Connected Successfully',
title: 'dataset.externalKnowledgeForm.connectedSuccess',
})
})
})

View File

@@ -3,6 +3,7 @@
import type { CreateKnowledgeBaseReq } from '@/app/components/datasets/external-knowledge-base/create/declarations'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
import { toast } from '@/app/components/base/ui/toast'
import ExternalKnowledgeBaseCreate from '@/app/components/datasets/external-knowledge-base/create'
@@ -12,13 +13,14 @@ import { createExternalKnowledgeBase } from '@/service/datasets'
const ExternalKnowledgeBaseConnector = () => {
const [loading, setLoading] = useState(false)
const router = useRouter()
const { t } = useTranslation()
const handleConnect = async (formValue: CreateKnowledgeBaseReq) => {
try {
setLoading(true)
const result = await createExternalKnowledgeBase({ body: formValue })
if (result && result.id) {
toast.add({ type: 'success', title: 'External Knowledge Base Connected Successfully' })
toast.add({ type: 'success', title: t('externalKnowledgeForm.connectedSuccess', { ns: 'dataset' }) })
trackEvent('create_external_knowledge_base', {
provider: formValue.provider,
name: formValue.name,
@@ -29,7 +31,7 @@ const ExternalKnowledgeBaseConnector = () => {
}
catch (error) {
console.error('Error creating external knowledge base:', error)
toast.add({ type: 'error', title: 'Failed to connect External Knowledge Base' })
toast.add({ type: 'error', title: t('externalKnowledgeForm.connectedFailed', { ns: 'dataset' }) })
}
setLoading(false)
}

View File

@@ -1,4 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Import component after mocks
@@ -17,44 +18,73 @@ vi.mock('@/i18n-config/language', () => ({
],
}))
// Mock PortalSelect component
vi.mock('@/app/components/base/select', () => ({
PortalSelect: ({
const MockSelectContext = React.createContext<{
value: string
onValueChange: (value: string) => void
}>({
value: '',
onValueChange: () => {},
})
vi.mock('@/app/components/base/ui/select', () => ({
Select: ({
value,
items,
onSelect,
triggerClassName,
popupClassName,
popupInnerClassName,
onValueChange,
children,
}: {
value: string
items: Array<{ value: string, name: string }>
onSelect: (item: { value: string }) => void
triggerClassName?: string
popupClassName?: string
popupInnerClassName?: string
onValueChange: (value: string) => void
children: React.ReactNode
}) => (
<div
data-testid="portal-select"
data-value={value}
data-trigger-class={triggerClassName}
data-popup-class={popupClassName}
data-popup-inner-class={popupInnerClassName}
>
<span data-testid="selected-value">{value}</span>
<div data-testid="items-container">
{items.map(item => (
<button
key={item.value}
data-testid={`select-item-${item.value}`}
onClick={() => onSelect({ value: item.value })}
>
{item.name}
</button>
))}
</div>
<MockSelectContext.Provider value={{ value, onValueChange }}>
<div data-testid="select-root">{children}</div>
</MockSelectContext.Provider>
),
SelectTrigger: ({
children,
className,
'data-testid': testId,
}: {
'children': React.ReactNode
'className'?: string
'data-testid'?: string
}) => (
<button data-testid={testId ?? 'select-trigger'} data-class={className}>
{children}
</button>
),
SelectValue: () => {
const { value } = React.useContext(MockSelectContext)
return <span data-testid="selected-value">{value}</span>
},
SelectContent: ({
children,
popupClassName,
}: {
children: React.ReactNode
popupClassName?: string
}) => (
<div data-testid="select-content" data-popup-class={popupClassName}>
{children}
</div>
),
SelectItem: ({
children,
value,
}: {
children: React.ReactNode
value: string
}) => {
const { onValueChange } = React.useContext(MockSelectContext)
return (
<button
data-testid={`select-item-${value}`}
onClick={() => onValueChange(value)}
>
{children}
</button>
)
},
}))
// ==================== Test Utilities ====================
@@ -139,7 +169,7 @@ describe('TTSParamsPanel', () => {
expect(screen.getByText('appDebug.voice.voiceSettings.voice')).toBeInTheDocument()
})
it('should render two PortalSelect components', () => {
it('should render two Select components', () => {
// Arrange
const props = createDefaultProps()
@@ -147,7 +177,7 @@ describe('TTSParamsPanel', () => {
render(<TTSParamsPanel {...props} />)
// Assert
const selects = screen.getAllByTestId('portal-select')
const selects = screen.getAllByTestId('select-root')
expect(selects).toHaveLength(2)
})
@@ -159,8 +189,8 @@ describe('TTSParamsPanel', () => {
render(<TTSParamsPanel {...props} />)
// Assert
const selects = screen.getAllByTestId('portal-select')
expect(selects[0]).toHaveAttribute('data-value', 'zh-Hans')
const values = screen.getAllByTestId('selected-value')
expect(values[0]).toHaveTextContent('zh-Hans')
})
it('should render voice select with correct value', () => {
@@ -171,8 +201,8 @@ describe('TTSParamsPanel', () => {
render(<TTSParamsPanel {...props} />)
// Assert
const selects = screen.getAllByTestId('portal-select')
expect(selects[1]).toHaveAttribute('data-value', 'echo')
const values = screen.getAllByTestId('selected-value')
expect(values[1]).toHaveTextContent('echo')
})
it('should only show supported languages in language select', () => {
@@ -205,7 +235,7 @@ describe('TTSParamsPanel', () => {
// ==================== Props Testing ====================
describe('Props', () => {
it('should apply trigger className to PortalSelect', () => {
it('should apply trigger className to SelectTrigger', () => {
// Arrange
const props = createDefaultProps()
@@ -213,12 +243,11 @@ describe('TTSParamsPanel', () => {
render(<TTSParamsPanel {...props} />)
// Assert
const selects = screen.getAllByTestId('portal-select')
expect(selects[0]).toHaveAttribute('data-trigger-class', 'h-8')
expect(selects[1]).toHaveAttribute('data-trigger-class', 'h-8')
expect(screen.getByTestId('tts-language-select-trigger')).toHaveAttribute('data-class', 'w-full')
expect(screen.getByTestId('tts-voice-select-trigger')).toHaveAttribute('data-class', 'w-full')
})
it('should apply popup className to PortalSelect', () => {
it('should apply popup className to SelectContent', () => {
// Arrange
const props = createDefaultProps()
@@ -226,22 +255,9 @@ describe('TTSParamsPanel', () => {
render(<TTSParamsPanel {...props} />)
// Assert
const selects = screen.getAllByTestId('portal-select')
expect(selects[0]).toHaveAttribute('data-popup-class', 'z-[1000]')
expect(selects[1]).toHaveAttribute('data-popup-class', 'z-[1000]')
})
it('should apply popup inner className to PortalSelect', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<TTSParamsPanel {...props} />)
// Assert
const selects = screen.getAllByTestId('portal-select')
expect(selects[0]).toHaveAttribute('data-popup-inner-class', 'w-[354px]')
expect(selects[1]).toHaveAttribute('data-popup-inner-class', 'w-[354px]')
const contents = screen.getAllByTestId('select-content')
expect(contents[0]).toHaveAttribute('data-popup-class', 'w-[354px]')
expect(contents[1]).toHaveAttribute('data-popup-class', 'w-[354px]')
})
})
@@ -411,10 +427,8 @@ describe('TTSParamsPanel', () => {
render(<TTSParamsPanel {...props} />)
// Assert - no voice items (except language items)
const voiceSelects = screen.getAllByTestId('portal-select')
// Second select is voice select, should have no voice items in items-container
const voiceItemsContainer = voiceSelects[1].querySelector('[data-testid="items-container"]')
expect(voiceItemsContainer?.children).toHaveLength(0)
expect(screen.getAllByTestId('select-content')[1].children).toHaveLength(0)
expect(screen.queryByTestId('select-item-alloy')).not.toBeInTheDocument()
})
it('should handle currentModel with single voice', () => {
@@ -443,8 +457,8 @@ describe('TTSParamsPanel', () => {
render(<TTSParamsPanel {...props} />)
// Assert
const selects = screen.getAllByTestId('portal-select')
expect(selects[0]).toHaveAttribute('data-value', '')
const values = screen.getAllByTestId('selected-value')
expect(values[0]).toHaveTextContent('')
})
it('should handle empty voice value', () => {
@@ -455,8 +469,8 @@ describe('TTSParamsPanel', () => {
render(<TTSParamsPanel {...props} />)
// Assert
const selects = screen.getAllByTestId('portal-select')
expect(selects[1]).toHaveAttribute('data-value', '')
const values = screen.getAllByTestId('selected-value')
expect(values[1]).toHaveTextContent('')
})
it('should handle many voices', () => {
@@ -514,14 +528,14 @@ describe('TTSParamsPanel', () => {
// Act
const { rerender } = render(<TTSParamsPanel {...props} />)
const selects = screen.getAllByTestId('portal-select')
expect(selects[0]).toHaveAttribute('data-value', 'en-US')
const values = screen.getAllByTestId('selected-value')
expect(values[0]).toHaveTextContent('en-US')
rerender(<TTSParamsPanel {...props} language="zh-Hans" />)
// Assert
const updatedSelects = screen.getAllByTestId('portal-select')
expect(updatedSelects[0]).toHaveAttribute('data-value', 'zh-Hans')
const updatedValues = screen.getAllByTestId('selected-value')
expect(updatedValues[0]).toHaveTextContent('zh-Hans')
})
it('should update when voice prop changes', () => {
@@ -530,14 +544,14 @@ describe('TTSParamsPanel', () => {
// Act
const { rerender } = render(<TTSParamsPanel {...props} />)
const selects = screen.getAllByTestId('portal-select')
expect(selects[1]).toHaveAttribute('data-value', 'alloy')
const values = screen.getAllByTestId('selected-value')
expect(values[1]).toHaveTextContent('alloy')
rerender(<TTSParamsPanel {...props} voice="echo" />)
// Assert
const updatedSelects = screen.getAllByTestId('portal-select')
expect(updatedSelects[1]).toHaveAttribute('data-value', 'echo')
const updatedValues = screen.getAllByTestId('selected-value')
expect(updatedValues[1]).toHaveTextContent('echo')
})
it('should update voice list when currentModel changes', () => {

View File

@@ -1,9 +1,8 @@
import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { PortalSelect } from '@/app/components/base/select'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/app/components/base/ui/select'
import { languages } from '@/i18n-config/language'
import { cn } from '@/utils/classnames'
type Props = {
currentModel: any
@@ -12,6 +11,8 @@ type Props = {
onChange: (language: string, voice: string) => void
}
const supportedLanguages = languages.filter(item => item.supported)
const TTSParamsPanel = ({
currentModel,
language,
@@ -19,11 +20,11 @@ const TTSParamsPanel = ({
onChange,
}: Props) => {
const { t } = useTranslation()
const voiceList = useMemo(() => {
const voiceList = useMemo<Array<{ label: string, value: string }>>(() => {
if (!currentModel)
return []
return currentModel.model_properties.voices.map((item: { mode: any }) => ({
...item,
return currentModel.model_properties.voices.map((item: { mode: string, name: string }) => ({
label: item.name,
value: item.mode,
}))
}, [currentModel])
@@ -39,27 +40,57 @@ const TTSParamsPanel = ({
<div className="system-sm-semibold mb-1 flex items-center py-1 text-text-secondary">
{t('voice.voiceSettings.language', { ns: 'appDebug' })}
</div>
<PortalSelect
triggerClassName="h-8"
popupClassName={cn('z-[1000]')}
popupInnerClassName={cn('w-[354px]')}
<Select
value={language}
items={languages.filter(item => item.supported)}
onSelect={item => setLanguage(item.value as string)}
/>
onValueChange={(value) => {
if (value == null)
return
setLanguage(value)
}}
>
<SelectTrigger
className="w-full"
data-testid="tts-language-select-trigger"
aria-label={t('voice.voiceSettings.language', { ns: 'appDebug' })}
>
<SelectValue />
</SelectTrigger>
<SelectContent popupClassName="w-[354px]">
{supportedLanguages.map(item => (
<SelectItem key={item.value} value={item.value}>
{item.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="mb-3">
<div className="system-sm-semibold mb-1 flex items-center py-1 text-text-secondary">
{t('voice.voiceSettings.voice', { ns: 'appDebug' })}
</div>
<PortalSelect
triggerClassName="h-8"
popupClassName={cn('z-[1000]')}
popupInnerClassName={cn('w-[354px]')}
<Select
value={voice}
items={voiceList}
onSelect={item => setVoice(item.value as string)}
/>
onValueChange={(value) => {
if (value == null)
return
setVoice(value)
}}
>
<SelectTrigger
className="w-full"
data-testid="tts-voice-select-trigger"
aria-label={t('voice.voiceSettings.voice', { ns: 'appDebug' })}
>
<SelectValue />
</SelectTrigger>
<SelectContent popupClassName="w-[354px]">
{voiceList.map(item => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)

View File

@@ -1333,12 +1333,9 @@ describe('CommonCreateModal', () => {
mockVerifyCredentials.mockImplementation((params, { onSuccess }) => {
onSuccess()
})
const builder = createMockSubscriptionBuilder()
render(<CommonCreateModal {...defaultProps} />)
await waitFor(() => {
expect(mockCreateBuilder).toHaveBeenCalled()
})
render(<CommonCreateModal {...defaultProps} builder={builder} />)
fireEvent.click(screen.getByTestId('modal-confirm'))

View File

@@ -8,6 +8,8 @@ import { usePluginInstallation } from '@/hooks/use-query-params'
import { fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/service/plugins'
import PluginPageWithContext from '../index'
let mockEnableMarketplace = true
// Mock external dependencies
vi.mock('@/service/plugins', () => ({
fetchManifestFromMarketPlace: vi.fn(),
@@ -31,7 +33,7 @@ vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn((selector) => {
const state = {
systemFeatures: {
enable_marketplace: true,
enable_marketplace: mockEnableMarketplace,
},
}
return selector(state)
@@ -138,6 +140,7 @@ const createDefaultProps = (): PluginPageProps => ({
describe('PluginPage Component', () => {
beforeEach(() => {
vi.clearAllMocks()
mockEnableMarketplace = true
// Reset to default mock values
vi.mocked(usePluginInstallation).mockReturnValue([
{ packageId: null, bundleInfo: null },
@@ -630,18 +633,7 @@ describe('PluginPage Component', () => {
})
it('should handle marketplace disabled', () => {
// Mock marketplace disabled
vi.mock('@/context/global-public-context', async () => ({
useGlobalPublicStore: vi.fn((selector) => {
const state = {
systemFeatures: {
enable_marketplace: false,
},
}
return selector(state)
}),
}))
mockEnableMarketplace = false
vi.mocked(useQueryState).mockReturnValue(['discover', vi.fn()])
render(<PluginPageWithContext {...createDefaultProps()} />)

View File

@@ -1,5 +1,6 @@
import type { EnvironmentVariable } from '@/app/components/workflow/types'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useState } from 'react'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
import Conversion from '../conversion'
@@ -347,11 +348,67 @@ vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
),
}))
vi.mock('@/app/components/base/app-icon-picker', () => ({
default: function MockAppIconPicker({ onSelect, onClose }: {
onSelect?: (payload:
| { type: 'emoji', icon: string, background: string }
| { type: 'image', fileId: string, url: string },
) => void
onClose?: () => void
}) {
const [activeTab, setActiveTab] = useState<'emoji' | 'image'>('emoji')
const [selectedEmoji, setSelectedEmoji] = useState({ icon: '😀', background: '#FFFFFF' })
return (
<div data-testid="app-icon-picker">
<button type="button" onClick={() => setActiveTab('emoji')}>iconPicker.emoji</button>
<button type="button" onClick={() => setActiveTab('image')}>iconPicker.image</button>
{activeTab === 'emoji' && (
<button
type="button"
data-testid="picker-emoji-option"
onClick={() => setSelectedEmoji({ icon: '🎯', background: '#FFAA00' })}
>
picker-emoji-option
</button>
)}
{activeTab === 'image' && <div data-testid="picker-image-panel">picker-image-panel</div>}
<button type="button" onClick={() => onClose?.()}>iconPicker.cancel</button>
<button
type="button"
onClick={() => {
if (activeTab === 'emoji') {
onSelect?.({
type: 'emoji',
icon: selectedEmoji.icon,
background: selectedEmoji.background,
})
return
}
onSelect?.({
type: 'image',
fileId: 'test-file-id',
url: 'https://example.com/icon.png',
})
}}
>
iconPicker.ok
</button>
</div>
)
},
}))
// Silence expected console.error from Dialog/Modal rendering
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
})
// Helper to find the name input in PublishAsKnowledgePipelineModal
function getNameInput() {
return screen.getByPlaceholderText('pipeline.common.publishAsPipeline.namePlaceholder')
@@ -708,10 +765,7 @@ describe('PublishAsKnowledgePipelineModal', () => {
const appIcon = getAppIcon()
fireEvent.click(appIcon)
// Click the first emoji in the grid (search full document since Dialog uses portal)
const gridEmojis = document.querySelectorAll('.grid em-emoji')
expect(gridEmojis.length).toBeGreaterThan(0)
fireEvent.click(gridEmojis[0].parentElement!.parentElement!)
fireEvent.click(screen.getByTestId('picker-emoji-option'))
// Click OK to confirm selection
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
@@ -1031,11 +1085,8 @@ describe('Integration Tests', () => {
// Open picker and select an emoji
const appIcon = getAppIcon()
fireEvent.click(appIcon)
const gridEmojis = document.querySelectorAll('.grid em-emoji')
if (gridEmojis.length > 0) {
fireEvent.click(gridEmojis[0].parentElement!.parentElement!)
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
}
fireEvent.click(screen.getByTestId('picker-emoji-option'))
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.publish/i }))

View File

@@ -62,6 +62,7 @@ const RagPipelinePanel = () => {
return {
getVersionListUrl: `/rag/pipelines/${pipelineId}/workflows`,
deleteVersionUrl: (versionId: string) => `/rag/pipelines/${pipelineId}/workflows/${versionId}`,
restoreVersionUrl: (versionId: string) => `/rag/pipelines/${pipelineId}/workflows/${versionId}/restore`,
updateVersionUrl: (versionId: string) => `/rag/pipelines/${pipelineId}/workflows/${versionId}`,
latestVersionId: '',
}

View File

@@ -231,6 +231,25 @@ describe('useNodesSyncDraft', () => {
expect(mockSyncWorkflowDraft).toHaveBeenCalled()
})
it('should not include source_workflow_id in sync payloads', async () => {
mockGetNodesReadOnly.mockReturnValue(false)
mockGetNodes.mockReturnValue([
{ id: 'node-1', data: { type: 'start' }, position: { x: 0, y: 0 } },
])
const { result } = renderHook(() => useNodesSyncDraft())
await act(async () => {
await result.current.doSyncWorkflowDraft()
})
expect(mockSyncWorkflowDraft).toHaveBeenCalledWith(expect.objectContaining({
params: expect.not.objectContaining({
source_workflow_id: expect.anything(),
}),
}))
})
it('should call onSuccess callback when sync succeeds', async () => {
mockGetNodesReadOnly.mockReturnValue(false)
mockGetNodes.mockReturnValue([
@@ -421,6 +440,21 @@ describe('useNodesSyncDraft', () => {
expect(sentParams.rag_pipeline_variables).toEqual([{ variable: 'input', type: 'text-input' }])
})
it('should not include source_workflow_id when syncing on page close', () => {
mockGetNodes.mockReturnValue([
{ id: 'node-1', data: { type: 'start' }, position: { x: 0, y: 0 } },
])
const { result } = renderHook(() => useNodesSyncDraft())
act(() => {
result.current.syncWorkflowDraftWhenPageClose()
})
const sentParams = mockPostWithKeepalive.mock.calls[0][1]
expect(sentParams.source_workflow_id).toBeUndefined()
})
it('should remove underscore-prefixed keys from edges', () => {
mockStoreGetState.mockReturnValue({
getNodes: mockGetNodes,

View File

@@ -35,6 +35,7 @@ describe('usePipelineRefreshDraft', () => {
const mockSetIsSyncingWorkflowDraft = vi.fn()
const mockSetEnvironmentVariables = vi.fn()
const mockSetEnvSecrets = vi.fn()
const mockSetRagPipelineVariables = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
@@ -45,6 +46,7 @@ describe('usePipelineRefreshDraft', () => {
setIsSyncingWorkflowDraft: mockSetIsSyncingWorkflowDraft,
setEnvironmentVariables: mockSetEnvironmentVariables,
setEnvSecrets: mockSetEnvSecrets,
setRagPipelineVariables: mockSetRagPipelineVariables,
})
mockFetchWorkflowDraft.mockResolvedValue({
@@ -55,6 +57,7 @@ describe('usePipelineRefreshDraft', () => {
},
hash: 'new-hash',
environment_variables: [],
rag_pipeline_variables: [],
})
})
@@ -116,6 +119,29 @@ describe('usePipelineRefreshDraft', () => {
})
})
it('should update rag pipeline variables after fetch', async () => {
mockFetchWorkflowDraft.mockResolvedValue({
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
hash: 'new-hash',
environment_variables: [],
rag_pipeline_variables: [{ variable: 'query', type: 'text-input' }],
})
const { result } = renderHook(() => usePipelineRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft()
})
await waitFor(() => {
expect(mockSetRagPipelineVariables).toHaveBeenCalledWith([{ variable: 'query', type: 'text-input' }])
})
})
it('should set syncing state to false after completion', async () => {
const { result } = renderHook(() => usePipelineRefreshDraft())

View File

@@ -1,3 +1,4 @@
import type { SyncDraftCallback } from '@/app/components/workflow/hooks-store'
import { produce } from 'immer'
import { useCallback } from 'react'
import { useStoreApi } from 'reactflow'
@@ -83,11 +84,7 @@ export const useNodesSyncDraft = () => {
const performSync = useCallback(async (
notRefreshWhenSyncError?: boolean,
callback?: {
onSuccess?: () => void
onError?: () => void
onSettled?: () => void
},
callback?: SyncDraftCallback,
) => {
if (getNodesReadOnly())
return

View File

@@ -16,6 +16,7 @@ export const usePipelineRefreshDraft = () => {
setIsSyncingWorkflowDraft,
setEnvironmentVariables,
setEnvSecrets,
setRagPipelineVariables,
} = workflowStore.getState()
setIsSyncingWorkflowDraft(true)
fetchWorkflowDraft(`/rag/pipelines/${pipelineId}/workflows/draft`).then((response) => {
@@ -34,6 +35,7 @@ export const usePipelineRefreshDraft = () => {
return acc
}, {} as Record<string, string>))
setEnvironmentVariables(response.environment_variables?.map(env => env.value_type === 'secret' ? { ...env, value: '[__HIDDEN__]' } : env) || [])
setRagPipelineVariables?.(response.rag_pipeline_variables || [])
}).finally(() => setIsSyncingWorkflowDraft(false))
}, [handleUpdateWorkflowCanvas, workflowStore])

View File

@@ -1,21 +1,24 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { importSchemaFromURL } from '@/service/tools'
import Toast from '../../../base/toast'
import examples from '../examples'
import GetSchema from '../get-schema'
vi.mock('@/service/tools', () => ({
importSchemaFromURL: vi.fn(),
}))
const mockToastAdd = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
add: mockToastAdd,
},
}))
const importSchemaFromURLMock = vi.mocked(importSchemaFromURL)
describe('GetSchema', () => {
const notifySpy = vi.spyOn(Toast, 'notify')
const mockOnChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
notifySpy.mockClear()
importSchemaFromURLMock.mockReset()
render(<GetSchema onChange={mockOnChange} />)
})
@@ -27,9 +30,9 @@ describe('GetSchema', () => {
fireEvent.change(input, { target: { value: 'ftp://invalid' } })
fireEvent.click(screen.getByText('common.operation.ok'))
expect(notifySpy).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: 'tools.createTool.urlError',
title: 'tools.createTool.urlError',
})
})

View File

@@ -10,8 +10,8 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import { toast } from '@/app/components/base/ui/toast'
import { importSchemaFromURL } from '@/service/tools'
import Toast from '../../base/toast'
import examples from './examples'
type Props = {
@@ -27,9 +27,9 @@ const GetSchema: FC<Props> = ({
const [isParsing, setIsParsing] = useState(false)
const handleImportFromUrl = async () => {
if (!importUrl.startsWith('http://') && !importUrl.startsWith('https://')) {
Toast.notify({
toast.add({
type: 'error',
message: t('createTool.urlError', { ns: 'tools' }),
title: t('createTool.urlError', { ns: 'tools' }),
})
return
}

View File

@@ -18,32 +18,11 @@ vi.mock('@/app/components/plugins/hooks', () => ({
}),
}))
// Mock useDebounceFn to store the function and allow manual triggering
let debouncedFn: (() => void) | null = null
vi.mock('ahooks', () => ({
useDebounceFn: (fn: () => void) => {
debouncedFn = fn
return {
run: () => {
// Schedule to run after React state updates
setTimeout(() => debouncedFn?.(), 0)
},
cancel: vi.fn(),
}
},
}))
describe('LabelFilter', () => {
const mockOnChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
debouncedFn = null
})
afterEach(() => {
vi.useRealTimers()
})
// Rendering Tests
@@ -81,36 +60,23 @@ describe('LabelFilter', () => {
const trigger = screen.getByText('common.tag.placeholder')
await act(async () => {
fireEvent.click(trigger)
vi.advanceTimersByTime(10)
})
await act(async () => fireEvent.click(trigger))
mockTags.forEach((tag) => {
expect(screen.getByText(tag.label)).toBeInTheDocument()
})
})
it('should close dropdown when trigger is clicked again', async () => {
it('should render search input when dropdown is open', async () => {
render(<LabelFilter value={[]} onChange={mockOnChange} />)
const trigger = screen.getByText('common.tag.placeholder')
const trigger = screen.getByText('common.tag.placeholder').closest('button')
expect(trigger).toBeInTheDocument()
// Open
await act(async () => {
fireEvent.click(trigger)
vi.advanceTimersByTime(10)
})
await act(async () => fireEvent.click(trigger!))
expect(screen.getByText('Agent')).toBeInTheDocument()
// Close
await act(async () => {
fireEvent.click(trigger)
vi.advanceTimersByTime(10)
})
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
})
@@ -119,17 +85,11 @@ describe('LabelFilter', () => {
it('should call onChange with selected label when clicking a label', async () => {
render(<LabelFilter value={[]} onChange={mockOnChange} />)
await act(async () => {
fireEvent.click(screen.getByText('common.tag.placeholder'))
vi.advanceTimersByTime(10)
})
await act(async () => fireEvent.click(screen.getByText('common.tag.placeholder')))
expect(screen.getByText('Agent')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByText('Agent'))
vi.advanceTimersByTime(10)
})
await act(async () => fireEvent.click(screen.getByText('Agent')))
expect(mockOnChange).toHaveBeenCalledWith(['agent'])
})
@@ -137,10 +97,7 @@ describe('LabelFilter', () => {
it('should remove label from selection when clicking already selected label', async () => {
render(<LabelFilter value={['agent']} onChange={mockOnChange} />)
await act(async () => {
fireEvent.click(screen.getByText('Agent'))
vi.advanceTimersByTime(10)
})
await act(async () => fireEvent.click(screen.getByText('Agent')))
// Find the label item in the dropdown list
const labelItems = screen.getAllByText('Agent')
@@ -149,7 +106,6 @@ describe('LabelFilter', () => {
await act(async () => {
if (dropdownItem)
fireEvent.click(dropdownItem)
vi.advanceTimersByTime(10)
})
expect(mockOnChange).toHaveBeenCalledWith([])
@@ -158,17 +114,11 @@ describe('LabelFilter', () => {
it('should add label to existing selection', async () => {
render(<LabelFilter value={['agent']} onChange={mockOnChange} />)
await act(async () => {
fireEvent.click(screen.getByText('Agent'))
vi.advanceTimersByTime(10)
})
await act(async () => fireEvent.click(screen.getByText('Agent')))
expect(screen.getByText('RAG')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByText('RAG'))
vi.advanceTimersByTime(10)
})
await act(async () => fireEvent.click(screen.getByText('RAG')))
expect(mockOnChange).toHaveBeenCalledWith(['agent', 'rag'])
})
@@ -179,8 +129,7 @@ describe('LabelFilter', () => {
it('should clear all selections when clear button is clicked', async () => {
render(<LabelFilter value={['agent', 'rag']} onChange={mockOnChange} />)
// Find and click the clear button (XCircle icon's parent)
const clearButton = document.querySelector('.group\\/clear')
const clearButton = screen.getByRole('button', { name: 'common.operation.clear' })
expect(clearButton).toBeInTheDocument()
fireEvent.click(clearButton!)
@@ -203,21 +152,16 @@ describe('LabelFilter', () => {
await act(async () => {
fireEvent.click(screen.getByText('common.tag.placeholder'))
vi.advanceTimersByTime(10)
})
expect(screen.getByRole('textbox')).toBeInTheDocument()
await act(async () => {
const searchInput = screen.getByRole('textbox')
// Filter by 'rag' which only matches 'rag' name
fireEvent.change(searchInput, { target: { value: 'rag' } })
vi.advanceTimersByTime(10)
})
// Only RAG should be visible (rag contains 'rag')
expect(screen.getByTitle('RAG')).toBeInTheDocument()
// Agent should not be in the dropdown list (agent doesn't contain 'rag')
expect(screen.queryByTitle('Agent')).not.toBeInTheDocument()
})
@@ -226,7 +170,6 @@ describe('LabelFilter', () => {
await act(async () => {
fireEvent.click(screen.getByText('common.tag.placeholder'))
vi.advanceTimersByTime(10)
})
expect(screen.getByRole('textbox')).toBeInTheDocument()
@@ -234,7 +177,6 @@ describe('LabelFilter', () => {
await act(async () => {
const searchInput = screen.getByRole('textbox')
fireEvent.change(searchInput, { target: { value: 'nonexistent' } })
vi.advanceTimersByTime(10)
})
expect(screen.getByText('common.tag.noTag')).toBeInTheDocument()
@@ -245,26 +187,21 @@ describe('LabelFilter', () => {
await act(async () => {
fireEvent.click(screen.getByText('common.tag.placeholder'))
vi.advanceTimersByTime(10)
})
expect(screen.getByRole('textbox')).toBeInTheDocument()
await act(async () => {
const searchInput = screen.getByRole('textbox')
// First filter to show only RAG
fireEvent.change(searchInput, { target: { value: 'rag' } })
vi.advanceTimersByTime(10)
})
expect(screen.getByTitle('RAG')).toBeInTheDocument()
expect(screen.queryByTitle('Agent')).not.toBeInTheDocument()
await act(async () => {
// Clear the input
const searchInput = screen.getByRole('textbox')
fireEvent.change(searchInput, { target: { value: '' } })
vi.advanceTimersByTime(10)
})
// All labels should be visible again
@@ -310,17 +247,11 @@ describe('LabelFilter', () => {
it('should call onChange with updated array', async () => {
render(<LabelFilter value={[]} onChange={mockOnChange} />)
await act(async () => {
fireEvent.click(screen.getByText('common.tag.placeholder'))
vi.advanceTimersByTime(10)
})
await act(async () => fireEvent.click(screen.getByText('common.tag.placeholder')))
expect(screen.getByText('Agent')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByText('Agent'))
vi.advanceTimersByTime(10)
})
await act(async () => fireEvent.click(screen.getByText('Agent')))
expect(mockOnChange).toHaveBeenCalledTimes(1)
expect(mockOnChange).toHaveBeenCalledWith(['agent'])

View File

@@ -1,7 +1,6 @@
import type { FC } from 'react'
import type { Label } from '@/app/components/tools/labels/constant'
import { RiArrowDownSLine } from '@remixicon/react'
import { useDebounceFn } from 'ahooks'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Tag01, Tag03 } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
@@ -9,10 +8,10 @@ import { Check } from '@/app/components/base/icons/src/vender/line/general'
import { XCircle } from '@/app/components/base/icons/src/vender/solid/general'
import Input from '@/app/components/base/input'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
Popover,
PopoverContent,
PopoverTrigger,
} from '@/app/components/base/ui/popover'
import { useTags } from '@/app/components/plugins/hooks'
import { cn } from '@/utils/classnames'
@@ -30,18 +29,10 @@ const LabelFilter: FC<LabelFilterProps> = ({
const { tags: labelList } = useTags()
const [keywords, setKeywords] = useState('')
const [searchKeywords, setSearchKeywords] = useState('')
const { run: handleSearch } = useDebounceFn(() => {
setSearchKeywords(keywords)
}, { wait: 500 })
const handleKeywordsChange = (value: string) => {
setKeywords(value)
handleSearch()
}
const filteredLabelList = useMemo(() => {
return labelList.filter(label => label.name.includes(searchKeywords))
}, [labelList, searchKeywords])
return labelList.filter(label => label.name.includes(keywords))
}, [labelList, keywords])
const currentLabel = useMemo(() => {
return labelList.find(label => label.name === value[0])
@@ -55,72 +46,70 @@ const LabelFilter: FC<LabelFilterProps> = ({
}
return (
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={4}
>
<div className="relative">
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
className="block"
>
<div className={cn(
'flex h-8 cursor-pointer select-none items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 hover:bg-components-input-bg-hover',
!open && !!value.length && 'shadow-xs',
open && !!value.length && 'shadow-xs',
<PopoverTrigger
className={cn(
'flex h-8 cursor-pointer select-none items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 text-left hover:bg-components-input-bg-hover',
!!value.length && 'pr-6 shadow-xs',
)}
>
<div className="p-[1px]">
<Tag01 className="h-3.5 w-3.5 text-text-tertiary" />
</div>
<div className="text-[13px] leading-[18px] text-text-tertiary">
{!value.length && t('tag.placeholder', { ns: 'common' })}
{!!value.length && currentLabel?.label}
</div>
{value.length > 1 && (
<div className="text-xs font-medium leading-[18px] text-text-tertiary">{`+${value.length - 1}`}</div>
)}
{!value.length && (
<div className="p-[1px]">
<RiArrowDownSLine className="h-3.5 w-3.5 text-text-tertiary" />
</div>
)}
{!!value.length && (
<div
className="group/clear cursor-pointer p-[1px]"
onClick={(e) => {
e.stopPropagation()
onChange([])
}}
>
<XCircle className="h-3.5 w-3.5 text-text-tertiary group-hover/clear:text-text-secondary" />
</div>
)}
>
<div className="p-[1px]">
<Tag01 className="h-3.5 w-3.5 text-text-tertiary" />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[1002]">
<div className="relative w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]">
<div className="min-w-0 truncate text-[13px] leading-[18px] text-text-tertiary">
{!value.length && t('tag.placeholder', { ns: 'common' })}
{!!value.length && currentLabel?.label}
</div>
{value.length > 1 && (
<div className="shrink-0 text-xs font-medium leading-[18px] text-text-tertiary">{`+${value.length - 1}`}</div>
)}
{!value.length && (
<div className="shrink-0 p-[1px]">
<RiArrowDownSLine className="h-3.5 w-3.5 text-text-tertiary" />
</div>
)}
</PopoverTrigger>
{!!value.length && (
<button
type="button"
aria-label={t('operation.clear', { ns: 'common' })}
className="group/clear absolute right-2 top-1/2 -translate-y-1/2 p-[1px]"
data-testid="label-filter-clear-button"
onClick={() => onChange([])}
>
<XCircle className="h-3.5 w-3.5 text-text-tertiary group-hover/clear:text-text-secondary" />
</button>
)}
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]"
>
<div className="relative">
<div className="p-2">
<Input
showLeftIcon
showClearIcon
value={keywords}
onChange={e => handleKeywordsChange(e.target.value)}
onClear={() => handleKeywordsChange('')}
onChange={e => setKeywords(e.target.value)}
onClear={() => setKeywords('')}
/>
</div>
<div className="p-1">
{filteredLabelList.map(label => (
<div
<button
key={label.name}
className="flex cursor-pointer select-none items-center gap-2 rounded-lg py-[6px] pl-3 pr-2 hover:bg-state-base-hover"
type="button"
className="flex w-full select-none items-center gap-2 rounded-lg py-[6px] pl-3 pr-2 text-left hover:bg-state-base-hover"
onClick={() => selectLabel(label)}
>
<div title={label.label} className="grow truncate text-sm leading-5 text-text-secondary">{label.label}</div>
{value.includes(label.name) && <Check className="h-4 w-4 shrink-0 text-text-accent" />}
</div>
</button>
))}
{!filteredLabelList.length && (
<div className="flex flex-col items-center gap-1 p-3">
@@ -130,9 +119,9 @@ const LabelFilter: FC<LabelFilterProps> = ({
)}
</div>
</div>
</PortalToFollowElemContent>
</PopoverContent>
</div>
</PortalToFollowElem>
</Popover>
)
}

View File

@@ -3,7 +3,7 @@ import type { ToolWithProvider } from '@/app/components/workflow/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MCPModal from '../modal'
// Mock the service API
@@ -48,7 +48,18 @@ vi.mock('@/service/use-plugins', () => ({
}),
}))
const mockToastAdd = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
add: mockToastAdd,
},
}))
describe('MCPModal', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
@@ -299,6 +310,10 @@ describe('MCPModal', () => {
// Wait a bit and verify onConfirm was not called
await new Promise(resolve => setTimeout(resolve, 100))
expect(onConfirm).not.toHaveBeenCalled()
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
title: 'tools.mcp.modal.invalidServerUrl',
})
})
it('should not call onConfirm with invalid server identifier', async () => {
@@ -320,6 +335,10 @@ describe('MCPModal', () => {
// Wait a bit and verify onConfirm was not called
await new Promise(resolve => setTimeout(resolve, 100))
expect(onConfirm).not.toHaveBeenCalled()
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
title: 'tools.mcp.modal.invalidServerIdentifier',
})
})
})

View File

@@ -14,7 +14,7 @@ import { Mcp } from '@/app/components/base/icons/src/vender/other'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import TabSlider from '@/app/components/base/tab-slider'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import { MCPAuthMethod } from '@/app/components/tools/types'
import { cn } from '@/utils/classnames'
import { shouldUseMcpIconForAppIcon } from '@/utils/mcp'
@@ -82,11 +82,11 @@ const MCPModalContent: FC<MCPModalContentProps> = ({
const submit = async () => {
if (!isValidUrl(state.url)) {
Toast.notify({ type: 'error', message: 'invalid server url' })
toast.add({ type: 'error', title: t('mcp.modal.invalidServerUrl', { ns: 'tools' }) })
return
}
if (!isValidServerID(state.serverIdentifier.trim())) {
Toast.notify({ type: 'error', message: 'invalid server identifier' })
toast.add({ type: 'error', title: t('mcp.modal.invalidServerIdentifier', { ns: 'tools' }) })
return
}
const formattedHeaders = state.headers.reduce((acc, item) => {

View File

@@ -70,11 +70,11 @@ vi.mock('@/app/components/tools/edit-custom-collection-modal', () => ({
},
}))
// Mock Toast
// Mock toast
const mockToastNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: (options: { type: string, message: string }) => mockToastNotify(options),
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
add: (options: { type: string, title: string }) => mockToastNotify(options),
},
}))
@@ -200,7 +200,7 @@ describe('CustomCreateCard', () => {
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'success',
message: expect.any(String),
title: expect.any(String),
})
})
})

View File

@@ -92,8 +92,9 @@ vi.mock('@/app/components/base/confirm', () => ({
: null,
}))
vi.mock('@/app/components/base/toast', () => ({
default: { notify: vi.fn() },
const mockToastAdd = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/base/ui/toast', () => ({
toast: { add: mockToastAdd },
}))
vi.mock('@/app/components/header/indicator', () => ({

View File

@@ -5,7 +5,7 @@ import {
} from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import EditCustomToolModal from '@/app/components/tools/edit-custom-collection-modal'
import { useAppContext } from '@/context/app-context'
import { createCustomCollection } from '@/service/tools'
@@ -21,9 +21,9 @@ const Contribute = ({ onRefreshData }: Props) => {
const [isShowEditCollectionToolModal, setIsShowEditCustomCollectionModal] = useState(false)
const doCreateCustomToolCollection = async (data: CustomCollectionBackend) => {
await createCustomCollection(data)
Toast.notify({
toast.add({
type: 'success',
message: t('api.actionSuccess', { ns: 'common' }),
title: t('api.actionSuccess', { ns: 'common' }),
})
setIsShowEditCustomCollectionModal(false)
onRefreshData()

View File

@@ -13,7 +13,7 @@ import Confirm from '@/app/components/base/confirm'
import Drawer from '@/app/components/base/drawer'
import { LinkExternal02, Settings01 } from '@/app/components/base/icons/src/vender/line/general'
import Loading from '@/app/components/base/loading'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import { ConfigurationMethodEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import Indicator from '@/app/components/header/indicator'
import Icon from '@/app/components/plugins/card/base/card-icon'
@@ -122,18 +122,18 @@ const ProviderDetail = ({
await getCustomProvider()
// Use fresh data from form submission to avoid race condition with collection.labels
setCustomCollection(prev => prev ? { ...prev, labels: data.labels } : null)
Toast.notify({
toast.add({
type: 'success',
message: t('api.actionSuccess', { ns: 'common' }),
title: t('api.actionSuccess', { ns: 'common' }),
})
setIsShowEditCustomCollectionModal(false)
}
const doRemoveCustomToolCollection = async () => {
await removeCustomCollection(collection?.name as string)
onRefreshData()
Toast.notify({
toast.add({
type: 'success',
message: t('api.actionSuccess', { ns: 'common' }),
title: t('api.actionSuccess', { ns: 'common' }),
})
setIsShowEditCustomCollectionModal(false)
}
@@ -161,9 +161,9 @@ const ProviderDetail = ({
const removeWorkflowToolProvider = async () => {
await deleteWorkflowTool(collection.id)
onRefreshData()
Toast.notify({
toast.add({
type: 'success',
message: t('api.actionSuccess', { ns: 'common' }),
title: t('api.actionSuccess', { ns: 'common' }),
})
setIsShowEditWorkflowToolModal(false)
}
@@ -175,9 +175,9 @@ const ProviderDetail = ({
invalidateAllWorkflowTools()
onRefreshData()
getWorkflowToolProvider()
Toast.notify({
toast.add({
type: 'success',
message: t('api.actionSuccess', { ns: 'common' }),
title: t('api.actionSuccess', { ns: 'common' }),
})
setIsShowEditWorkflowToolModal(false)
}
@@ -385,18 +385,18 @@ const ProviderDetail = ({
onCancel={() => setShowSettingAuth(false)}
onSaved={async (value) => {
await updateBuiltInToolCredential(collection.name, value)
Toast.notify({
toast.add({
type: 'success',
message: t('api.actionSuccess', { ns: 'common' }),
title: t('api.actionSuccess', { ns: 'common' }),
})
await onRefreshData()
setShowSettingAuth(false)
}}
onRemove={async () => {
await removeBuiltInToolCredential(collection.name)
Toast.notify({
toast.add({
type: 'success',
message: t('api.actionSuccess', { ns: 'common' }),
title: t('api.actionSuccess', { ns: 'common' }),
})
await onRefreshData()
setShowSettingAuth(false)

View File

@@ -110,6 +110,7 @@ const WorkflowPanel = () => {
return {
getVersionListUrl: `/apps/${appId}/workflows`,
deleteVersionUrl: (versionId: string) => `/apps/${appId}/workflows/${versionId}`,
restoreVersionUrl: (versionId: string) => `/apps/${appId}/workflows/${versionId}/restore`,
updateVersionUrl: (versionId: string) => `/apps/${appId}/workflows/${versionId}`,
latestVersionId: appDetail?.workflow?.id,
}

View File

@@ -108,4 +108,18 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () =>
expect(mockHandleRefreshWorkflowDraft).not.toHaveBeenCalled()
})
it('should not include source_workflow_id in draft sync payloads', async () => {
const { result } = renderHook(() => useNodesSyncDraft())
await act(async () => {
await result.current.doSyncWorkflowDraft(false)
})
expect(mockSyncWorkflowDraft).toHaveBeenCalledWith(expect.objectContaining({
params: expect.not.objectContaining({
source_workflow_id: expect.anything(),
}),
}))
})
})

View File

@@ -1,3 +1,4 @@
import type { SyncDraftCallback } from '@/app/components/workflow/hooks-store'
import { produce } from 'immer'
import { useCallback } from 'react'
import { useStoreApi } from 'reactflow'
@@ -91,11 +92,7 @@ export const useNodesSyncDraft = () => {
const performSync = useCallback(async (
notRefreshWhenSyncError?: boolean,
callback?: {
onSuccess?: () => void
onError?: () => void
onSettled?: () => void
},
callback?: SyncDraftCallback,
) => {
if (getNodesReadOnly())
return

View File

@@ -0,0 +1,126 @@
import type { VersionHistory } from '@/types/workflow'
import { screen } from '@testing-library/react'
import { FlowType } from '@/types/common'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import { WorkflowVersion } from '../../types'
import HeaderInRestoring from '../header-in-restoring'
const mockRestoreWorkflow = vi.fn()
const mockInvalidAllLastRun = vi.fn()
const mockHandleLoadBackupDraft = vi.fn()
const mockHandleRefreshWorkflowDraft = vi.fn()
vi.mock('@/hooks/use-theme', () => ({
default: () => ({
theme: 'light',
}),
}))
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({
formatTime: vi.fn(() => '09:30:00'),
}),
}))
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: vi.fn(() => '3 hours ago'),
}),
}))
vi.mock('@/service/use-workflow', () => ({
useInvalidAllLastRun: () => mockInvalidAllLastRun,
useRestoreWorkflow: () => ({
mutateAsync: mockRestoreWorkflow,
}),
}))
vi.mock('../../hooks', () => ({
useWorkflowRun: () => ({
handleLoadBackupDraft: mockHandleLoadBackupDraft,
}),
useWorkflowRefreshDraft: () => ({
handleRefreshWorkflowDraft: mockHandleRefreshWorkflowDraft,
}),
}))
const createVersion = (overrides: Partial<VersionHistory> = {}): VersionHistory => ({
id: 'version-1',
graph: {
nodes: [],
edges: [],
},
created_at: 1_700_000_000,
created_by: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
},
hash: 'hash-1',
updated_at: 1_700_000_100,
updated_by: {
id: 'user-2',
name: 'Bob',
email: 'bob@example.com',
},
tool_published: false,
version: 'v1',
marked_name: 'Release 1',
marked_comment: '',
...overrides,
})
describe('HeaderInRestoring', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should disable restore when the flow id is not ready yet', () => {
renderWorkflowComponent(<HeaderInRestoring />, {
initialStoreState: {
currentVersion: createVersion(),
},
hooksStoreProps: {
configsMap: undefined,
},
})
expect(screen.getByRole('button', { name: 'workflow.common.restore' })).toBeDisabled()
})
it('should enable restore when version and flow config are both ready', () => {
renderWorkflowComponent(<HeaderInRestoring />, {
initialStoreState: {
currentVersion: createVersion(),
},
hooksStoreProps: {
configsMap: {
flowId: 'app-1',
flowType: FlowType.appFlow,
fileSettings: {} as never,
},
},
})
expect(screen.getByRole('button', { name: 'workflow.common.restore' })).toBeEnabled()
})
it('should keep restore disabled for draft versions even when flow config is ready', () => {
renderWorkflowComponent(<HeaderInRestoring />, {
initialStoreState: {
currentVersion: createVersion({
version: WorkflowVersion.Draft,
}),
},
hooksStoreProps: {
configsMap: {
flowId: 'app-1',
flowType: FlowType.appFlow,
fileSettings: {} as never,
},
},
})
expect(screen.getByRole('button', { name: 'workflow.common.restore' })).toBeDisabled()
})
})

View File

@@ -5,11 +5,12 @@ import {
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import useTheme from '@/hooks/use-theme'
import { useInvalidAllLastRun } from '@/service/use-workflow'
import { useInvalidAllLastRun, useRestoreWorkflow } from '@/service/use-workflow'
import { getFlowPrefix } from '@/service/utils'
import { cn } from '@/utils/classnames'
import Toast from '../../base/toast'
import {
useNodesSyncDraft,
useWorkflowRefreshDraft,
useWorkflowRun,
} from '../hooks'
import { useHooksStore } from '../hooks-store'
@@ -42,7 +43,9 @@ const HeaderInRestoring = ({
const {
handleLoadBackupDraft,
} = useWorkflowRun()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft()
const { mutateAsync: restoreWorkflow } = useRestoreWorkflow()
const canRestore = !!currentVersion?.id && !!configsMap?.flowId && currentVersion.version !== WorkflowVersion.Draft
const handleCancelRestore = useCallback(() => {
handleLoadBackupDraft()
@@ -50,30 +53,35 @@ const HeaderInRestoring = ({
setShowWorkflowVersionHistoryPanel(false)
}, [workflowStore, handleLoadBackupDraft, setShowWorkflowVersionHistoryPanel])
const handleRestore = useCallback(() => {
const handleRestore = useCallback(async () => {
if (!canRestore)
return
setShowWorkflowVersionHistoryPanel(false)
workflowStore.setState({ isRestoring: false })
workflowStore.setState({ backupDraft: undefined })
handleSyncWorkflowDraft(true, false, {
onSuccess: () => {
Toast.notify({
type: 'success',
message: t('versionHistory.action.restoreSuccess', { ns: 'workflow' }),
})
},
onError: () => {
Toast.notify({
type: 'error',
message: t('versionHistory.action.restoreFailure', { ns: 'workflow' }),
})
},
onSettled: () => {
onRestoreSettled?.()
},
})
deleteAllInspectVars()
invalidAllLastRun()
}, [setShowWorkflowVersionHistoryPanel, workflowStore, handleSyncWorkflowDraft, deleteAllInspectVars, invalidAllLastRun, t, onRestoreSettled])
const restoreUrl = `/${getFlowPrefix(configsMap.flowType)}/${configsMap.flowId}/workflows/${currentVersion.id}/restore`
try {
await restoreWorkflow(restoreUrl)
workflowStore.setState({ isRestoring: false })
workflowStore.setState({ backupDraft: undefined })
handleRefreshWorkflowDraft()
Toast.notify({
type: 'success',
message: t('versionHistory.action.restoreSuccess', { ns: 'workflow' }),
})
deleteAllInspectVars()
invalidAllLastRun()
}
catch {
Toast.notify({
type: 'error',
message: t('versionHistory.action.restoreFailure', { ns: 'workflow' }),
})
}
finally {
onRestoreSettled?.()
}
}, [canRestore, currentVersion?.id, configsMap, setShowWorkflowVersionHistoryPanel, workflowStore, restoreWorkflow, handleRefreshWorkflowDraft, deleteAllInspectVars, invalidAllLastRun, t, onRestoreSettled])
return (
<>
@@ -83,7 +91,7 @@ const HeaderInRestoring = ({
<div className=" flex items-center justify-end gap-x-2">
<Button
onClick={handleRestore}
disabled={!currentVersion || currentVersion.version === WorkflowVersion.Draft}
disabled={!canRestore}
variant="primary"
className={cn(
'rounded-lg border border-transparent',

View File

@@ -22,14 +22,15 @@ export type AvailableNodesMetaData = {
nodes: NodeDefault[]
nodesMap?: Record<BlockEnum, NodeDefault<any>>
}
export type SyncDraftCallback = {
onSuccess?: () => void
onError?: () => void
onSettled?: () => void
}
export type CommonHooksFnMap = {
doSyncWorkflowDraft: (
notRefreshWhenSyncError?: boolean,
callback?: {
onSuccess?: () => void
onError?: () => void
onSettled?: () => void
},
callback?: SyncDraftCallback,
) => Promise<void>
syncWorkflowDraftWhenPageClose: () => void
handleRefreshWorkflowDraft: () => void

View File

@@ -1,13 +1,10 @@
import type { SyncDraftCallback } from '../hooks-store'
import { useCallback } from 'react'
import { useHooksStore } from '@/app/components/workflow/hooks-store'
import { useStore } from '../store'
import { useNodesReadOnly } from './use-workflow'
export type SyncCallback = {
onSuccess?: () => void
onError?: () => void
onSettled?: () => void
}
export type SyncCallback = SyncDraftCallback
export const useNodesSyncDraft = () => {
const { getNodesReadOnly } = useNodesReadOnly()
@@ -18,7 +15,7 @@ export const useNodesSyncDraft = () => {
const handleSyncWorkflowDraft = useCallback((
sync?: boolean,
notRefreshWhenSyncError?: boolean,
callback?: SyncCallback,
callback?: SyncDraftCallback,
) => {
if (getNodesReadOnly())
return

View File

@@ -0,0 +1,115 @@
import type { PanelProps } from '../index'
import { screen } from '@testing-library/react'
import { createNode } from '../../__tests__/fixtures'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import Panel from '../index'
const mockVersionHistoryPanel = vi.hoisted(() => vi.fn())
class MockResizeObserver implements ResizeObserver {
observe = vi.fn()
unobserve = vi.fn()
disconnect = vi.fn()
constructor(_callback: ResizeObserverCallback) {}
}
vi.mock('@/next/dynamic', () => ({
default: () => (props: { latestVersionId?: string }) => {
mockVersionHistoryPanel(props)
return <div data-testid="version-history-panel">{props.latestVersionId}</div>
},
}))
vi.mock('reactflow', async () => {
const mod = await import('../../__tests__/reactflow-mock-state')
const base = mod.createReactFlowModuleMock()
return {
...base,
useStore: vi.fn(selector => selector({
getNodes: () => mod.rfState.nodes,
})),
}
})
vi.mock('../env-panel', () => ({
default: () => <div data-testid="env-panel" />,
}))
vi.mock('../../nodes', () => ({
Panel: ({ id }: { id: string }) => <div data-testid="node-panel">{id}</div>,
}))
const versionHistoryPanelProps = {
latestVersionId: 'version-1',
restoreVersionUrl: (versionId: string) => `/workflows/${versionId}/restore`,
} satisfies NonNullable<PanelProps['versionHistoryPanelProps']>
describe('Panel', () => {
beforeEach(() => {
vi.clearAllMocks()
resetReactFlowMockState()
vi.stubGlobal('ResizeObserver', MockResizeObserver)
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('Version History Panel', () => {
it('should render the version history panel when the panel is open and props are provided', () => {
renderWorkflowComponent(
<Panel versionHistoryPanelProps={versionHistoryPanelProps} />,
{
initialStoreState: {
showWorkflowVersionHistoryPanel: true,
},
},
)
expect(screen.getByTestId('version-history-panel')).toHaveTextContent('version-1')
expect(mockVersionHistoryPanel).toHaveBeenCalledWith(expect.objectContaining({
latestVersionId: 'version-1',
}))
})
it('should not render the version history panel when the panel is open but props are missing', () => {
renderWorkflowComponent(
<Panel />,
{
initialStoreState: {
showWorkflowVersionHistoryPanel: true,
},
},
)
expect(screen.queryByTestId('version-history-panel')).not.toBeInTheDocument()
expect(mockVersionHistoryPanel).not.toHaveBeenCalled()
})
it('should not render the version history panel when the panel is closed', () => {
rfState.nodes = [
createNode({
id: 'selected-node',
data: {
selected: true,
},
}),
] as typeof rfState.nodes
renderWorkflowComponent(
<Panel versionHistoryPanelProps={versionHistoryPanelProps} />,
{
initialStoreState: {
showWorkflowVersionHistoryPanel: false,
},
},
)
expect(screen.queryByTestId('version-history-panel')).not.toBeInTheDocument()
expect(screen.getByTestId('node-panel')).toHaveTextContent('selected-node')
})
})
})

View File

@@ -140,7 +140,7 @@ const Panel: FC<PanelProps> = ({
components?.right
}
{
showWorkflowVersionHistoryPanel && (
showWorkflowVersionHistoryPanel && versionHistoryPanelProps && (
<VersionHistoryPanel {...versionHistoryPanelProps} />
)
}

View File

@@ -1,14 +1,55 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { WorkflowVersion } from '../../../types'
import type { Shape } from '../../../store'
import type { VersionHistory } from '@/types/workflow'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useEffect } from 'react'
import { VersionHistoryContextMenuOptions, WorkflowVersion } from '../../../types'
const mockHandleRestoreFromPublishedWorkflow = vi.fn()
const mockHandleLoadBackupDraft = vi.fn()
const mockHandleRefreshWorkflowDraft = vi.fn()
const mockRestoreWorkflow = vi.fn()
const mockSetCurrentVersion = vi.fn()
const mockSetShowWorkflowVersionHistoryPanel = vi.fn()
const mockWorkflowStoreSetState = vi.fn()
type MockWorkflowStoreState = {
setShowWorkflowVersionHistoryPanel: ReturnType<typeof vi.fn>
currentVersion: null
setCurrentVersion: typeof mockSetCurrentVersion
const createVersionHistory = (overrides: Partial<VersionHistory> = {}): VersionHistory => ({
id: 'version-id',
version: WorkflowVersion.Draft,
graph: { nodes: [], edges: [] },
features: {
opening_statement: '',
suggested_questions: [],
suggested_questions_after_answer: { enabled: false },
text_to_speech: { enabled: false },
speech_to_text: { enabled: false },
retriever_resource: { enabled: false },
sensitive_word_avoidance: { enabled: false },
file_upload: { image: { enabled: false } },
},
created_at: Date.now() / 1000,
created_by: { id: 'user-1', name: 'User 1', email: 'user-1@example.com' },
hash: 'test-hash',
updated_at: Date.now() / 1000,
updated_by: { id: 'user-1', name: 'User 1', email: 'user-1@example.com' },
tool_published: false,
environment_variables: [],
marked_name: '',
marked_comment: '',
...overrides,
})
let mockCurrentVersion: VersionHistory | null = null
type MockVersionStoreState = Pick<Shape, 'currentVersion' | 'setCurrentVersion' | 'setShowWorkflowVersionHistoryPanel'>
type MockRestoreConfirmModalProps = {
isOpen: boolean
versionInfo: VersionHistory
onRestore: (item: VersionHistory) => void
}
type MockVersionHistoryItemProps = {
item: VersionHistory
onClick: (item: VersionHistory) => void
handleClickMenuItem: (operation: VersionHistoryContextMenuOptions) => void
}
vi.mock('@/context/app-context', () => ({
@@ -19,52 +60,23 @@ vi.mock('@/service/use-workflow', () => ({
useDeleteWorkflow: () => ({ mutateAsync: vi.fn() }),
useInvalidAllLastRun: () => vi.fn(),
useResetWorkflowVersionHistory: () => vi.fn(),
useRestoreWorkflow: () => ({ mutateAsync: mockRestoreWorkflow }),
useUpdateWorkflow: () => ({ mutateAsync: vi.fn() }),
useWorkflowVersionHistory: () => ({
data: {
pages: [
{
items: [
{
createVersionHistory({
id: 'draft-version-id',
version: WorkflowVersion.Draft,
graph: { nodes: [], edges: [], viewport: null },
features: {
opening_statement: '',
suggested_questions: [],
suggested_questions_after_answer: { enabled: false },
text_to_speech: { enabled: false },
speech_to_text: { enabled: false },
retriever_resource: { enabled: false },
sensitive_word_avoidance: { enabled: false },
file_upload: { image: { enabled: false } },
},
created_at: Date.now() / 1000,
created_by: { id: 'user-1', name: 'User 1' },
environment_variables: [],
marked_name: '',
marked_comment: '',
},
{
}),
createVersionHistory({
id: 'published-version-id',
version: '2024-01-01T00:00:00Z',
graph: { nodes: [], edges: [], viewport: null },
features: {
opening_statement: '',
suggested_questions: [],
suggested_questions_after_answer: { enabled: false },
text_to_speech: { enabled: false },
speech_to_text: { enabled: false },
retriever_resource: { enabled: false },
sensitive_word_avoidance: { enabled: false },
file_upload: { image: { enabled: false } },
},
created_at: Date.now() / 1000,
created_by: { id: 'user-1', name: 'User 1' },
environment_variables: [],
marked_name: 'v1.0',
marked_comment: 'First release',
},
}),
],
},
],
@@ -77,7 +89,7 @@ vi.mock('@/service/use-workflow', () => ({
vi.mock('../../../hooks', () => ({
useDSL: () => ({ handleExportDSL: vi.fn() }),
useNodesSyncDraft: () => ({ handleSyncWorkflowDraft: vi.fn() }),
useWorkflowRefreshDraft: () => ({ handleRefreshWorkflowDraft: mockHandleRefreshWorkflowDraft }),
useWorkflowRun: () => ({
handleRestoreFromPublishedWorkflow: mockHandleRestoreFromPublishedWorkflow,
handleLoadBackupDraft: mockHandleLoadBackupDraft,
@@ -92,10 +104,10 @@ vi.mock('../../../hooks-store', () => ({
}))
vi.mock('../../../store', () => ({
useStore: <T,>(selector: (state: MockWorkflowStoreState) => T) => {
const state: MockWorkflowStoreState = {
setShowWorkflowVersionHistoryPanel: vi.fn(),
currentVersion: null,
useStore: <T,>(selector: (state: MockVersionStoreState) => T) => {
const state: MockVersionStoreState = {
setShowWorkflowVersionHistoryPanel: mockSetShowWorkflowVersionHistoryPanel,
currentVersion: mockCurrentVersion,
setCurrentVersion: mockSetCurrentVersion,
}
return selector(state)
@@ -103,10 +115,10 @@ vi.mock('../../../store', () => ({
useWorkflowStore: () => ({
getState: () => ({
deleteAllInspectVars: vi.fn(),
setShowWorkflowVersionHistoryPanel: vi.fn(),
setShowWorkflowVersionHistoryPanel: mockSetShowWorkflowVersionHistoryPanel,
setCurrentVersion: mockSetCurrentVersion,
}),
setState: vi.fn(),
setState: mockWorkflowStoreSetState,
}),
}))
@@ -115,16 +127,54 @@ vi.mock('../delete-confirm-modal', () => ({
}))
vi.mock('../restore-confirm-modal', () => ({
default: () => null,
default: (props: MockRestoreConfirmModalProps) => {
const MockRestoreConfirmModal = () => {
const { isOpen, versionInfo, onRestore } = props
if (!isOpen)
return null
return <button onClick={() => onRestore(versionInfo)}>confirm restore</button>
}
return <MockRestoreConfirmModal />
},
}))
vi.mock('@/app/components/app/app-publisher/version-info-modal', () => ({
default: () => null,
}))
vi.mock('../version-history-item', () => ({
default: (props: MockVersionHistoryItemProps) => {
const MockVersionHistoryItem = () => {
const { item, onClick, handleClickMenuItem } = props
useEffect(() => {
if (item.version === WorkflowVersion.Draft)
onClick(item)
}, [item, onClick])
return (
<div>
<button onClick={() => onClick(item)}>{item.marked_name || item.version}</button>
{item.version !== WorkflowVersion.Draft && (
<button onClick={() => handleClickMenuItem(VersionHistoryContextMenuOptions.restore)}>
{`restore-${item.id}`}
</button>
)}
</div>
)
}
return <MockVersionHistoryItem />
},
}))
describe('VersionHistoryPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCurrentVersion = null
})
describe('Version Click Behavior', () => {
@@ -134,10 +184,10 @@ describe('VersionHistoryPanel', () => {
render(
<VersionHistoryPanel
latestVersionId="published-version-id"
restoreVersionUrl={versionId => `/apps/app-1/workflows/${versionId}/restore`}
/>,
)
// Draft version auto-clicks on mount via useEffect in VersionHistoryItem
expect(mockHandleLoadBackupDraft).toHaveBeenCalled()
expect(mockHandleRestoreFromPublishedWorkflow).not.toHaveBeenCalled()
})
@@ -148,17 +198,72 @@ describe('VersionHistoryPanel', () => {
render(
<VersionHistoryPanel
latestVersionId="published-version-id"
restoreVersionUrl={versionId => `/apps/app-1/workflows/${versionId}/restore`}
/>,
)
// Clear mocks after initial render (draft version auto-clicks on mount)
vi.clearAllMocks()
const publishedItem = screen.getByText('v1.0')
fireEvent.click(publishedItem)
fireEvent.click(screen.getByText('v1.0'))
expect(mockHandleRestoreFromPublishedWorkflow).toHaveBeenCalled()
expect(mockHandleLoadBackupDraft).not.toHaveBeenCalled()
})
})
it('should set current version before confirming restore from context menu', async () => {
const { VersionHistoryPanel } = await import('../index')
render(
<VersionHistoryPanel
latestVersionId="published-version-id"
restoreVersionUrl={versionId => `/apps/app-1/workflows/${versionId}/restore`}
/>,
)
vi.clearAllMocks()
fireEvent.click(screen.getByText('restore-published-version-id'))
fireEvent.click(screen.getByText('confirm restore'))
await waitFor(() => {
expect(mockSetCurrentVersion).toHaveBeenCalledWith(expect.objectContaining({
id: 'published-version-id',
}))
expect(mockRestoreWorkflow).toHaveBeenCalledWith('/apps/app-1/workflows/published-version-id/restore')
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ isRestoring: false })
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ backupDraft: undefined })
expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalled()
})
})
it('should keep restore mode backup state when restore request fails', async () => {
const { VersionHistoryPanel } = await import('../index')
mockRestoreWorkflow.mockRejectedValueOnce(new Error('restore failed'))
mockCurrentVersion = createVersionHistory({
id: 'draft-version-id',
version: WorkflowVersion.Draft,
})
render(
<VersionHistoryPanel
latestVersionId="published-version-id"
restoreVersionUrl={versionId => `/apps/app-1/workflows/${versionId}/restore`}
/>,
)
vi.clearAllMocks()
fireEvent.click(screen.getByText('restore-published-version-id'))
fireEvent.click(screen.getByText('confirm restore'))
await waitFor(() => {
expect(mockRestoreWorkflow).toHaveBeenCalledWith('/apps/app-1/workflows/published-version-id/restore')
})
expect(mockWorkflowStoreSetState).not.toHaveBeenCalledWith({ isRestoring: false })
expect(mockWorkflowStoreSetState).not.toHaveBeenCalledWith({ backupDraft: undefined })
expect(mockSetCurrentVersion).not.toHaveBeenCalled()
expect(mockHandleRefreshWorkflowDraft).not.toHaveBeenCalled()
})
})

View File

@@ -9,8 +9,8 @@ import VersionInfoModal from '@/app/components/app/app-publisher/version-info-mo
import Divider from '@/app/components/base/divider'
import { toast } from '@/app/components/base/ui/toast'
import { useSelector as useAppContextSelector } from '@/context/app-context'
import { useDeleteWorkflow, useInvalidAllLastRun, useResetWorkflowVersionHistory, useUpdateWorkflow, useWorkflowVersionHistory } from '@/service/use-workflow'
import { useDSL, useNodesSyncDraft, useWorkflowRun } from '../../hooks'
import { useDeleteWorkflow, useInvalidAllLastRun, useResetWorkflowVersionHistory, useRestoreWorkflow, useUpdateWorkflow, useWorkflowVersionHistory } from '@/service/use-workflow'
import { useDSL, useWorkflowRefreshDraft, useWorkflowRun } from '../../hooks'
import { useHooksStore } from '../../hooks-store'
import { useStore, useWorkflowStore } from '../../store'
import { VersionHistoryContextMenuOptions, WorkflowVersion, WorkflowVersionFilterOptions } from '../../types'
@@ -27,12 +27,14 @@ const INITIAL_PAGE = 1
export type VersionHistoryPanelProps = {
getVersionListUrl?: string
deleteVersionUrl?: (versionId: string) => string
restoreVersionUrl: (versionId: string) => string
updateVersionUrl?: (versionId: string) => string
latestVersionId?: string
}
export const VersionHistoryPanel = ({
getVersionListUrl,
deleteVersionUrl,
restoreVersionUrl,
updateVersionUrl,
latestVersionId,
}: VersionHistoryPanelProps) => {
@@ -43,8 +45,8 @@ export const VersionHistoryPanel = ({
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [editModalOpen, setEditModalOpen] = useState(false)
const workflowStore = useWorkflowStore()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { handleRestoreFromPublishedWorkflow, handleLoadBackupDraft } = useWorkflowRun()
const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft()
const { handleExportDSL } = useDSL()
const setShowWorkflowVersionHistoryPanel = useStore(s => s.setShowWorkflowVersionHistoryPanel)
const currentVersion = useStore(s => s.currentVersion)
@@ -144,32 +146,33 @@ export const VersionHistoryPanel = ({
}, [])
const resetWorkflowVersionHistory = useResetWorkflowVersionHistory()
const { mutateAsync: restoreWorkflow } = useRestoreWorkflow()
const handleRestore = useCallback((item: VersionHistory) => {
const handleRestore = useCallback(async (item: VersionHistory) => {
setShowWorkflowVersionHistoryPanel(false)
handleRestoreFromPublishedWorkflow(item)
workflowStore.setState({ isRestoring: false })
workflowStore.setState({ backupDraft: undefined })
handleSyncWorkflowDraft(true, false, {
onSuccess: () => {
toast.add({
type: 'success',
title: t('versionHistory.action.restoreSuccess', { ns: 'workflow' }),
})
deleteAllInspectVars()
invalidAllLastRun()
},
onError: () => {
toast.add({
type: 'error',
title: t('versionHistory.action.restoreFailure', { ns: 'workflow' }),
})
},
onSettled: () => {
resetWorkflowVersionHistory()
},
})
}, [setShowWorkflowVersionHistoryPanel, handleRestoreFromPublishedWorkflow, workflowStore, handleSyncWorkflowDraft, deleteAllInspectVars, invalidAllLastRun, t, resetWorkflowVersionHistory])
try {
await restoreWorkflow(restoreVersionUrl(item.id))
setCurrentVersion(item)
workflowStore.setState({ isRestoring: false })
workflowStore.setState({ backupDraft: undefined })
handleRefreshWorkflowDraft()
toast.add({
type: 'success',
title: t('versionHistory.action.restoreSuccess', { ns: 'workflow' }),
})
deleteAllInspectVars()
invalidAllLastRun()
}
catch {
toast.add({
type: 'error',
title: t('versionHistory.action.restoreFailure', { ns: 'workflow' }),
})
}
finally {
resetWorkflowVersionHistory()
}
}, [setShowWorkflowVersionHistoryPanel, setCurrentVersion, workflowStore, restoreWorkflow, restoreVersionUrl, handleRefreshWorkflowDraft, deleteAllInspectVars, invalidAllLastRun, t, resetWorkflowVersionHistory])
const { mutateAsync: deleteWorkflow } = useDeleteWorkflow()

View File

@@ -1325,9 +1325,6 @@
}
},
"app/components/app/type-selector/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 3
}
@@ -5211,14 +5208,11 @@
}
},
"app/components/plugins/plugin-detail-panel/model-selector/tts-params-panel.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 2
},
"ts/no-explicit-any": {
"count": 2
"count": 1
}
},
"app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.tsx": {
@@ -5934,9 +5928,6 @@
}
},
"app/components/tools/edit-custom-collection-modal/get-schema.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 3
},
@@ -5975,14 +5966,6 @@
"count": 1
}
},
"app/components/tools/labels/filter.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/no-unnecessary-whitespace": {
"count": 1
}
},
"app/components/tools/labels/selector.tsx": {
"no-restricted-imports": {
"count": 1
@@ -6070,7 +6053,7 @@
},
"app/components/tools/mcp/modal.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 7
@@ -6111,16 +6094,13 @@
}
},
"app/components/tools/provider/custom-create-card.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/tools/provider/detail.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 10

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "يحدد هيكل القطعة كيفية تقسيم المستندات وفهرستها - تقديم أوضاع عامة، الأصل والطفل، والأسئلة والأجوبة - وهي فريدة لكل قاعدة معرفة.",
"documentSettings.title": "إعدادات المستند",
"editPipelineInfo": "تعديل معلومات سير العمل",
"editPipelineInfoNameRequired": "يرجى إدخال اسم لقاعدة المعرفة.",
"exportDSL.errorTip": "فشل تصدير DSL لسير العمل",
"exportDSL.successTip": "تم تصدير DSL لسير العمل بنجاح",
"inputField": "حقل الإدخال",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "Die Blockstruktur bestimmt, wie Dokumente aufgeteilt und indiziert werden, und bietet die Modi \"Allgemein\", \"Über-Eltern-Kind\" und \"Q&A\" und ist für jede Wissensdatenbank einzigartig.",
"documentSettings.title": "Dokument-Einstellungen",
"editPipelineInfo": "Bearbeiten von Pipeline-Informationen",
"editPipelineInfoNameRequired": "Bitte geben Sie einen Namen für die Wissensdatenbank ein.",
"exportDSL.errorTip": "Fehler beim Exportieren der Pipeline-DSL",
"exportDSL.successTip": "Pipeline-DSL erfolgreich exportieren",
"inputField": "Eingabefeld",

View File

@@ -77,6 +77,8 @@
"externalKnowledgeDescriptionPlaceholder": "Describe what's in this Knowledge Base (optional)",
"externalKnowledgeForm.cancel": "Cancel",
"externalKnowledgeForm.connect": "Connect",
"externalKnowledgeForm.connectedFailed": "Failed to connect External Knowledge Base",
"externalKnowledgeForm.connectedSuccess": "External Knowledge Base Connected Successfully",
"externalKnowledgeId": "External Knowledge ID",
"externalKnowledgeIdPlaceholder": "Please enter the Knowledge ID",
"externalKnowledgeName": "External Knowledge Name",

View File

@@ -126,6 +126,8 @@
"mcp.modal.headerValuePlaceholder": "e.g., Bearer token123",
"mcp.modal.headers": "Headers",
"mcp.modal.headersTip": "Additional HTTP headers to send with MCP server requests",
"mcp.modal.invalidServerIdentifier": "Please enter a valid server identifier",
"mcp.modal.invalidServerUrl": "Please enter a valid server URL",
"mcp.modal.maskedHeadersTip": "Header values are masked for security. Changes will update the actual values.",
"mcp.modal.name": "Name & Icon",
"mcp.modal.namePlaceholder": "Name your MCP server",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "La estructura de fragmentos determina cómo se dividen e indexan los documentos, ofreciendo modos General, Principal-Secundario y Preguntas y respuestas, y es única para cada base de conocimiento.",
"documentSettings.title": "Parametrizaciones de documentos",
"editPipelineInfo": "Editar información de canalización",
"editPipelineInfoNameRequired": "Por favor, ingrese un nombre para la Base de Conocimiento.",
"exportDSL.errorTip": "No se pudo exportar DSL de canalización",
"exportDSL.successTip": "Exportar DSL de canalización correctamente",
"inputField": "Campo de entrada",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "ساختار Chunk نحوه تقسیم و نمایه سازی اسناد را تعیین می کند - حالت های عمومی، والد-فرزند و پرسش و پاسخ را ارائه می دهد - و برای هر پایگاه دانش منحصر به فرد است.",
"documentSettings.title": "تنظیمات سند",
"editPipelineInfo": "ویرایش اطلاعات خط لوله",
"editPipelineInfoNameRequired": "لطفاً یک نام برای پایگاه دانش وارد کنید.",
"exportDSL.errorTip": "صادرات DSL خط لوله انجام نشد",
"exportDSL.successTip": "DSL خط لوله را با موفقیت صادر کنید",
"inputField": "فیلد ورودی",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "La structure par blocs détermine la façon dont les documents sont divisés et indexés (en proposant les modes Général, Parent-Enfant et Q&R) et est unique à chaque base de connaissances.",
"documentSettings.title": "Paramètres du document",
"editPipelineInfo": "Modifier les informations sur le pipeline",
"editPipelineInfoNameRequired": "Veuillez saisir un nom pour la Base de connaissances.",
"exportDSL.errorTip": "Echec de lexportation du DSL du pipeline",
"exportDSL.successTip": "Pipeline dexportation DSL réussi",
"inputField": "Champ de saisie",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "चंक संरचना यह निर्धारित करती है कि दस्तावेज कैसे विभाजित और अनुक्रमित होते हैं—सामान्य, माता-पिता- बच्चे, और प्रश्नोत्तर मोड प्रदान करते हुए—और यह प्रत्येक ज्ञान आधार के लिए अद्वितीय होती है।",
"documentSettings.title": "डॉक्यूमेंट सेटिंग्स",
"editPipelineInfo": "पाइपलाइन जानकारी संपादित करें",
"editPipelineInfoNameRequired": "कृपया ज्ञान आधार के लिए एक नाम दर्ज करें।",
"exportDSL.errorTip": "पाइपलाइन DSL निर्यात करने में विफल",
"exportDSL.successTip": "निर्यात पाइपलाइन DSL सफलतापूर्वक",
"inputField": "इनपुट फ़ील्ड",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "Struktur Potongan menentukan bagaimana dokumen dibagi dan diindeks—menawarkan mode Umum, Induk-Anak, dan Tanya Jawab—dan unik untuk setiap basis pengetahuan.",
"documentSettings.title": "Pengaturan Dokumen",
"editPipelineInfo": "Mengedit info alur",
"editPipelineInfoNameRequired": "Silakan masukkan nama untuk Basis Pengetahuan.",
"exportDSL.errorTip": "Gagal mengekspor DSL alur",
"exportDSL.successTip": "Ekspor DSL pipeline berhasil",
"inputField": "Bidang Masukan",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "La struttura a blocchi determina il modo in cui i documenti vengono suddivisi e indicizzati, offrendo le modalità Generale, Padre-Figlio e Domande e risposte, ed è univoca per ogni knowledge base.",
"documentSettings.title": "Impostazioni documento",
"editPipelineInfo": "Modificare le informazioni sulla pipeline",
"editPipelineInfoNameRequired": "Inserisci un nome per la Knowledge Base.",
"exportDSL.errorTip": "Impossibile esportare il DSL della pipeline",
"exportDSL.successTip": "Esporta DSL pipeline con successo",
"inputField": "Campo di input",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "チャンク構造は、ドキュメントがどのように分割され、インデックスされるかを決定します。一般、親子、Q&Aモードを提供し、各ナレッジベースにユニークです。",
"documentSettings.title": "ドキュメント設定",
"editPipelineInfo": "パイプライン情報を編集する",
"editPipelineInfoNameRequired": "ナレッジベースの名前を入力してください。",
"exportDSL.errorTip": "パイプラインDSLのエクスポートに失敗しました",
"exportDSL.successTip": "エクスポートパイプラインDSLが成功しました",
"inputField": "入力フィールド",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "청크 구조는 문서를 분할하고 인덱싱하는 방법(일반, 부모-자식 및 Q&A 모드를 제공)을 결정하며 각 기술 자료에 고유합니다.",
"documentSettings.title": "문서 설정",
"editPipelineInfo": "파이프라인 정보 편집",
"editPipelineInfoNameRequired": "기술 자료의 이름을 입력해 주세요.",
"exportDSL.errorTip": "파이프라인 DSL을 내보내지 못했습니다.",
"exportDSL.successTip": "파이프라인 DSL 내보내기 성공",
"inputField": "입력 필드",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "Chunk Structure determines how documents are split and indexed—offering General, Parent-Child, and Q&A modes—and is unique to each knowledge base.",
"documentSettings.title": "Document Settings",
"editPipelineInfo": "Edit pipeline info",
"editPipelineInfoNameRequired": "Voer een naam in voor de Kennisbank.",
"exportDSL.errorTip": "Failed to export pipeline DSL",
"exportDSL.successTip": "Export pipeline DSL successfully",
"inputField": "Input Field",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "Struktura fragmentów określa sposób dzielenia i indeksowania dokumentów — oferując tryby Ogólne, Nadrzędny-Podrzędny oraz Q&A — i jest unikatowa dla każdej bazy wiedzy.",
"documentSettings.title": "Ustawienia dokumentu",
"editPipelineInfo": "Edytowanie informacji o potoku",
"editPipelineInfoNameRequired": "Proszę podać nazwę Bazy Wiedzy.",
"exportDSL.errorTip": "Nie można wyeksportować DSL potoku",
"exportDSL.successTip": "Pomyślnie wyeksportowano potok DSL",
"inputField": "Pole wejściowe",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "A Estrutura de Partes determina como os documentos são divididos e indexados, oferecendo os modos Geral, Pai-Filho e P e Resposta, e é exclusiva para cada base de conhecimento.",
"documentSettings.title": "Configurações do documento",
"editPipelineInfo": "Editar informações do pipeline",
"editPipelineInfoNameRequired": "Por favor, insira um nome para a Base de Conhecimento.",
"exportDSL.errorTip": "Falha ao exportar DSL de pipeline",
"exportDSL.successTip": "Exportar DSL de pipeline com êxito",
"inputField": "Campo de entrada",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "Structura de bucăți determină modul în care documentele sunt împărțite și indexate - oferind modurile General, Părinte-Copil și Întrebări și răspunsuri - și este unică pentru fiecare bază de cunoștințe.",
"documentSettings.title": "Setări document",
"editPipelineInfo": "Editați informațiile despre conductă",
"editPipelineInfoNameRequired": "Vă rugăm să introduceți un nume pentru Baza de Cunoștințe.",
"exportDSL.errorTip": "Nu s-a reușit exportul DSL al conductei",
"exportDSL.successTip": "Exportați cu succes DSL",
"inputField": "Câmp de intrare",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "Структура блоков определяет порядок разделения и индексирования документов (в соответствии с режимами «Общие», «Родитель-потомок» и «Вопросы и ответы») и является уникальной для каждой базы знаний.",
"documentSettings.title": "Настройки документа",
"editPipelineInfo": "Редактирование сведений о воронке продаж",
"editPipelineInfoNameRequired": "Пожалуйста, введите название базы знаний.",
"exportDSL.errorTip": "Не удалось экспортировать DSL конвейера",
"exportDSL.successTip": "Экспорт конвейера DSL успешно",
"inputField": "Поле ввода",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "Struktura kosov določa, kako so dokumenti razdeljeni in indeksirani ponuja načine Splošno, Nadrejeno-podrejeno in Vprašanja in odgovori in je edinstvena za vsako zbirko znanja.",
"documentSettings.title": "Nastavitve dokumenta",
"editPipelineInfo": "Urejanje informacij o cevovodu",
"editPipelineInfoNameRequired": "Prosim vnesite ime za Bazo znanja.",
"exportDSL.errorTip": "Izvoz cevovoda DSL ni uspel",
"exportDSL.successTip": "Uspešno izvozite DSL",
"inputField": "Vnosno polje",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "โครงสร้างก้อนกําหนดวิธีการแยกและจัดทําดัชนีเอกสาร โดยเสนอโหมดทั่วไป ผู้ปกครอง-รอง และ Q&A และไม่ซ้ํากันสําหรับแต่ละฐานความรู้",
"documentSettings.title": "การตั้งค่าเอกสาร",
"editPipelineInfo": "แก้ไขข้อมูลไปป์ไลน์",
"editPipelineInfoNameRequired": "โปรดป้อนชื่อสำหรับฐานความรู้",
"exportDSL.errorTip": "ไม่สามารถส่งออก DSL ไปป์ไลน์ได้",
"exportDSL.successTip": "ส่งออก DSL ไปป์ไลน์สําเร็จ",
"inputField": "ฟิลด์อินพุต",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "Yığın Yapısı, belgelerin nasıl bölündüğünü ve dizine eklendiğini belirler (Genel, Üst-Alt ve Soru-Cevap modları sunar) ve her bilgi bankası için benzersizdir.",
"documentSettings.title": "Belge Ayarları",
"editPipelineInfo": "İşlem hattı bilgilerini düzenleme",
"editPipelineInfoNameRequired": "Lütfen Bilgi Bankası için bir ad girin.",
"exportDSL.errorTip": "İşlem hattı DSL'si dışarı aktarılamadı",
"exportDSL.successTip": "İşlem hattı DSL'sini başarıyla dışarı aktarın",
"inputField": "Giriş Alanı",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "Структура фрагментів визначає, як документи розділяються та індексуються (пропонуючи режими «Загальні», «Батьки-дочірні елементи» та «Запитання й відповіді»), і є унікальною для кожної бази знань.",
"documentSettings.title": "Параметри документа",
"editPipelineInfo": "Як редагувати інформацію про воронку продажів",
"editPipelineInfoNameRequired": "Будь ласка, введіть назву Бази знань.",
"exportDSL.errorTip": "Не вдалося експортувати DSL пайплайну",
"exportDSL.successTip": "Успішний експорт DSL воронки продажів",
"inputField": "Поле введення",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "Chunk Structure xác định cách các tài liệu được phân tách và lập chỉ mục — cung cấp các chế độ General, Parent-Child và Q&A — và là duy nhất cho mỗi cơ sở tri thức.",
"documentSettings.title": "Cài đặt tài liệu",
"editPipelineInfo": "Chỉnh sửa thông tin quy trình",
"editPipelineInfoNameRequired": "Vui lòng nhập tên cho Cơ sở Kiến thức.",
"exportDSL.errorTip": "Không thể xuất DSL đường ống",
"exportDSL.successTip": "Xuất DSL quy trình thành công",
"inputField": "Trường đầu vào",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "文档结构决定了文档的拆分和索引方式Dify 提供了通用、父子和问答模式,每个知识库的文档结构是唯一的。",
"documentSettings.title": "文档设置",
"editPipelineInfo": "编辑知识流水线信息",
"editPipelineInfoNameRequired": "请输入知识库的名称。",
"exportDSL.errorTip": "导出知识流水线 DSL 失败",
"exportDSL.successTip": "成功导出知识流水线 DSL",
"inputField": "输入字段",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "區塊結構會決定文件的分割和索引方式 (提供一般、父子和問答模式),而且每個知識庫都是唯一的。",
"documentSettings.title": "文件設定",
"editPipelineInfo": "編輯管線資訊",
"editPipelineInfoNameRequired": "請輸入知識庫的名稱。",
"exportDSL.errorTip": "無法匯出管線 DSL",
"exportDSL.successTip": "成功匯出管線 DSL",
"inputField": "輸入欄位",

View File

@@ -113,6 +113,13 @@ export const useDeleteWorkflow = () => {
})
}
export const useRestoreWorkflow = () => {
return useMutation({
mutationKey: [NAME_SPACE, 'restore'],
mutationFn: (url: string) => post<CommonResponse & { updated_at: number, hash: string }>(url, {}, { silent: true }),
})
}
export const usePublishWorkflow = () => {
return useMutation({
mutationKey: [NAME_SPACE, 'publish'],