mirror of
https://github.com/langgenius/dify.git
synced 2026-05-09 12:59:18 +08:00
refactor(web): improve app component testability and raise coverage
- refactor config-modal, create-from-dsl-modal, and sub-graph for lower complexity - add missing tests for config-modal-body, sandbox-provider-page, and install-bundle flows - raise targeted worktree source coverage to 90%+ for lines and statements
This commit is contained in:
parent
edd2b040f3
commit
de62fd15bd
@ -0,0 +1,342 @@
|
||||
import type { ConfigModalState } from '../use-config-modal-state'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import ConfigModalBody from '../config-modal-body'
|
||||
|
||||
type PayloadChangeHandler = ConfigModalState['handlePayloadChange']
|
||||
type PayloadValueHandler = ReturnType<PayloadChangeHandler>
|
||||
|
||||
let selectOnValueChange: ((value: string | null) => void) | undefined
|
||||
|
||||
vi.mock('@/app/components/base/ui/select', () => ({
|
||||
Select: ({
|
||||
children,
|
||||
onValueChange,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onValueChange?: (value: string | null) => void
|
||||
}) => {
|
||||
selectOnValueChange = onValueChange
|
||||
return <div>{children}</div>
|
||||
},
|
||||
SelectTrigger: ({
|
||||
children,
|
||||
className,
|
||||
'aria-label': ariaLabel,
|
||||
}: {
|
||||
'children': React.ReactNode
|
||||
'className'?: string
|
||||
'aria-label'?: string
|
||||
}) => (
|
||||
<button type="button" role="combobox" className={className} aria-label={ariaLabel}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
SelectValue: ({ placeholder }: { placeholder?: string }) => <span>{placeholder}</span>,
|
||||
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SelectItem: ({
|
||||
children,
|
||||
value,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
value: string
|
||||
}) => (
|
||||
<div role="option" onClick={() => selectOnValueChange?.(value)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
|
||||
default: ({ value, onChange }: { value: string, onChange: (value: string) => void }) => (
|
||||
<div>
|
||||
<div data-testid="code-editor">{value}</div>
|
||||
<button type="button" onClick={() => onChange('{\n "type": "object"\n}')}>change json</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/file-upload-setting', () => ({
|
||||
default: ({
|
||||
isMultiple,
|
||||
onChange,
|
||||
payload,
|
||||
}: {
|
||||
isMultiple: boolean
|
||||
onChange: (payload: InputVar) => void
|
||||
payload: InputVar
|
||||
}) => (
|
||||
<div>
|
||||
<div data-testid="file-upload-setting">{String(isMultiple)}</div>
|
||||
<button type="button" onClick={() => onChange({ ...payload, max_length: 9 })}>update file setting</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/file-uploader', () => ({
|
||||
FileUploaderInAttachmentWrapper: ({
|
||||
onChange,
|
||||
value,
|
||||
}: {
|
||||
onChange: (files?: FileEntity[]) => void
|
||||
value: FileEntity[]
|
||||
}) => (
|
||||
<div data-testid="file-uploader" data-count={value.length}>
|
||||
<button type="button" onClick={() => onChange([{ id: 'file-1' } as FileEntity])}>upload one</button>
|
||||
<button type="button" onClick={() => onChange([{ id: 'file-1' }, { id: 'file-2' }] as FileEntity[])}>upload many</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createPayloadChangeMock = () => {
|
||||
const handlers = new Map<keyof InputVar, PayloadValueHandler>()
|
||||
const handlePayloadChange: PayloadChangeHandler = vi.fn((key: keyof InputVar) => {
|
||||
const existing = handlers.get(key)
|
||||
if (existing)
|
||||
return existing
|
||||
|
||||
const handler: PayloadValueHandler = vi.fn()
|
||||
handlers.set(key, handler)
|
||||
return handler
|
||||
})
|
||||
|
||||
return {
|
||||
handlePayloadChange,
|
||||
handlers,
|
||||
}
|
||||
}
|
||||
|
||||
const createInputVar = (overrides: Partial<InputVar> = {}): InputVar => ({
|
||||
type: InputVarType.textInput,
|
||||
label: 'Name',
|
||||
variable: 'name',
|
||||
required: false,
|
||||
hide: false,
|
||||
default: '',
|
||||
options: [],
|
||||
allowed_file_types: [SupportUploadFileTypes.document],
|
||||
allowed_file_extensions: ['pdf'],
|
||||
allowed_file_upload_methods: [TransferMethod.remote_url],
|
||||
max_length: 1,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createState = (overrides: Partial<ConfigModalState> = {}) => {
|
||||
const payloadChange = createPayloadChangeMock()
|
||||
|
||||
return {
|
||||
state: {
|
||||
checkboxDefaultSelectValue: 'false',
|
||||
handleConfirm: vi.fn(),
|
||||
handleJSONSchemaChange: vi.fn(),
|
||||
handlePayloadChange: payloadChange.handlePayloadChange,
|
||||
handleTypeChange: vi.fn(),
|
||||
handleVarKeyBlur: vi.fn(),
|
||||
handleVarNameChange: vi.fn(),
|
||||
isStringInput: true,
|
||||
jsonSchemaStr: '',
|
||||
modelId: 'test-model',
|
||||
modalRef: { current: null },
|
||||
selectOptions: [
|
||||
{ value: InputVarType.textInput, name: 'Text Input' },
|
||||
{ value: InputVarType.select, name: 'Select' },
|
||||
],
|
||||
setTempPayload: vi.fn(),
|
||||
tempPayload: createInputVar(),
|
||||
...overrides,
|
||||
} satisfies ConfigModalState,
|
||||
payloadHandlers: payloadChange.handlers,
|
||||
}
|
||||
}
|
||||
|
||||
describe('ConfigModalBody', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
selectOnValueChange = undefined
|
||||
})
|
||||
|
||||
// Covers the default text-input layout and footer callbacks.
|
||||
describe('text input mode', () => {
|
||||
it('should render text fields and trigger footer actions', () => {
|
||||
const { state, payloadHandlers } = createState()
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(<ConfigModalBody state={state} onClose={onClose} />)
|
||||
|
||||
expect(screen.getAllByRole('textbox')).toHaveLength(3)
|
||||
|
||||
fireEvent.change(screen.getAllByRole('textbox')[1], { target: { value: 'Display Name' } })
|
||||
fireEvent.change(screen.getAllByRole('textbox')[2], { target: { value: 'Hello world' } })
|
||||
fireEvent.click(screen.getAllByRole('checkbox')[0])
|
||||
fireEvent.click(screen.getAllByRole('checkbox')[1])
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
expect(payloadHandlers.get('label')).toHaveBeenCalledWith('Display Name')
|
||||
expect(payloadHandlers.get('default')).toHaveBeenCalledWith('Hello world')
|
||||
expect(payloadHandlers.get('required')).toHaveBeenCalledWith(true)
|
||||
expect(payloadHandlers.get('hide')).toHaveBeenCalledWith(true)
|
||||
expect(state.handleConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should send updates for paragraph and number defaults', () => {
|
||||
const paragraphState = createState({
|
||||
tempPayload: createInputVar({
|
||||
type: InputVarType.paragraph,
|
||||
default: 'Paragraph value',
|
||||
}),
|
||||
})
|
||||
|
||||
const { container, unmount } = render(<ConfigModalBody state={paragraphState.state} onClose={vi.fn()} />)
|
||||
|
||||
fireEvent.change(container.querySelector('textarea')!, { target: { value: 'Updated paragraph' } })
|
||||
expect(paragraphState.payloadHandlers.get('default')).toHaveBeenCalledWith('Updated paragraph')
|
||||
|
||||
unmount()
|
||||
|
||||
const numberState = createState({
|
||||
isStringInput: false,
|
||||
tempPayload: createInputVar({
|
||||
type: InputVarType.number,
|
||||
default: '5',
|
||||
}),
|
||||
})
|
||||
|
||||
const { container: numberContainer } = render(<ConfigModalBody state={numberState.state} onClose={vi.fn()} />)
|
||||
|
||||
fireEvent.change(numberContainer.querySelector('input[type="number"]')!, { target: { value: '12' } })
|
||||
expect(numberState.payloadHandlers.get('default')).toHaveBeenCalledWith('12')
|
||||
})
|
||||
})
|
||||
|
||||
// Covers select-specific sections and options wiring.
|
||||
describe('select mode', () => {
|
||||
it('should render select options and default selector when options exist', async () => {
|
||||
const { state, payloadHandlers } = createState({
|
||||
isStringInput: false,
|
||||
tempPayload: createInputVar({
|
||||
type: InputVarType.select,
|
||||
default: 'A',
|
||||
options: ['A', 'B'],
|
||||
}),
|
||||
})
|
||||
|
||||
render(
|
||||
<ConfigModalBody
|
||||
state={state}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('appDebug.variableConfig.options')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('A').length).toBeGreaterThan(0)
|
||||
|
||||
fireEvent.click(screen.getByRole('combobox', { name: 'appDebug.variableConfig.selectDefaultValue' }))
|
||||
fireEvent.click(screen.getByRole('option', { name: 'B' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(state.handlePayloadChange).toHaveBeenCalledWith('default')
|
||||
})
|
||||
expect(payloadHandlers.get('default')).toHaveBeenCalledWith('B')
|
||||
})
|
||||
|
||||
it('should convert checkbox selections into boolean defaults', async () => {
|
||||
const { state, payloadHandlers } = createState({
|
||||
isStringInput: false,
|
||||
tempPayload: createInputVar({
|
||||
type: InputVarType.checkbox,
|
||||
default: undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
render(<ConfigModalBody state={state} onClose={vi.fn()} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('combobox', { name: 'appDebug.variableConfig.selectDefaultValue' }))
|
||||
fireEvent.click(screen.getByRole('option', { name: 'appDebug.variableConfig.startChecked' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(state.handlePayloadChange).toHaveBeenCalledWith('default')
|
||||
})
|
||||
expect(payloadHandlers.get('default')).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Covers file and JSON branches that were split out of the original entry component.
|
||||
describe('specialized sections', () => {
|
||||
it('should handle file settings and uploader changes for file inputs', () => {
|
||||
const singleFileState = createState({
|
||||
isStringInput: false,
|
||||
tempPayload: createInputVar({
|
||||
type: InputVarType.singleFile,
|
||||
default: undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
const { unmount } = render(
|
||||
<ConfigModalBody
|
||||
state={singleFileState.state}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('update file setting'))
|
||||
fireEvent.click(screen.getByText('upload one'))
|
||||
|
||||
expect(singleFileState.state.setTempPayload).toHaveBeenCalledWith(expect.objectContaining({ max_length: 9 }))
|
||||
expect(singleFileState.payloadHandlers.get('default')).toHaveBeenCalledWith(expect.objectContaining({ id: 'file-1' }))
|
||||
|
||||
unmount()
|
||||
|
||||
const multiFileState = createState({
|
||||
isStringInput: false,
|
||||
tempPayload: createInputVar({
|
||||
type: InputVarType.multiFiles,
|
||||
default: [] as unknown as InputVar['default'],
|
||||
}),
|
||||
})
|
||||
|
||||
render(
|
||||
<ConfigModalBody
|
||||
state={multiFileState.state}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('file-upload-setting')).toHaveTextContent('true')
|
||||
expect(screen.getByTestId('file-uploader')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('upload many'))
|
||||
expect(multiFileState.payloadHandlers.get('default')).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ id: 'file-1' }),
|
||||
expect.objectContaining({ id: 'file-2' }),
|
||||
])
|
||||
})
|
||||
|
||||
it('should render the JSON schema editor and propagate edits for json object inputs', () => {
|
||||
const { state } = createState({
|
||||
isStringInput: false,
|
||||
jsonSchemaStr: '{\n "type": "object"\n}',
|
||||
tempPayload: createInputVar({
|
||||
type: InputVarType.jsonObject,
|
||||
default: undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
render(
|
||||
<ConfigModalBody
|
||||
state={state}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('code-editor')).toHaveTextContent('"type": "object"')
|
||||
fireEvent.click(screen.getByText('change json'))
|
||||
expect(state.handleJSONSchemaChange).toHaveBeenCalledWith('{\n "type": "object"\n}')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,33 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Field from '../field'
|
||||
|
||||
describe('ConfigModal Field', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Covers the required field label rendering.
|
||||
describe('Rendering', () => {
|
||||
it('should render the field title and children content', () => {
|
||||
render(
|
||||
<Field title="Field Title">
|
||||
<div>Child content</div>
|
||||
</Field>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Field Title')).toBeInTheDocument()
|
||||
expect(screen.getByText('Child content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the optional indicator when the field is optional', () => {
|
||||
render(
|
||||
<Field title="Optional Title" isOptional>
|
||||
<div>Optional content</div>
|
||||
</Field>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Optional Title')).toBeInTheDocument()
|
||||
expect(screen.getByText(/\(appDebug\.variableConfig\.optional\)/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,97 @@
|
||||
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import {
|
||||
applyTypeChange,
|
||||
CHECKBOX_DEFAULT_FALSE_VALUE,
|
||||
CHECKBOX_DEFAULT_TRUE_VALUE,
|
||||
getCheckboxDefaultSelectValue,
|
||||
isJsonSchemaEmpty,
|
||||
normalizeSelectDefaultValue,
|
||||
parseCheckboxSelectValue,
|
||||
} from '../helpers'
|
||||
|
||||
const createInputVar = (overrides = {}) => ({
|
||||
type: InputVarType.textInput,
|
||||
label: 'Name',
|
||||
variable: 'name',
|
||||
required: false,
|
||||
hide: false,
|
||||
default: '',
|
||||
options: [],
|
||||
allowed_file_types: [SupportUploadFileTypes.document],
|
||||
allowed_file_extensions: ['pdf'],
|
||||
allowed_file_upload_methods: [TransferMethod.local_file],
|
||||
max_length: 1,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('config-modal helpers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Covers checkbox default parsing for primitive and string payload values.
|
||||
describe('checkbox defaults', () => {
|
||||
it('should normalize checkbox default values to selector options', () => {
|
||||
expect(getCheckboxDefaultSelectValue(true)).toBe(CHECKBOX_DEFAULT_TRUE_VALUE)
|
||||
expect(getCheckboxDefaultSelectValue('true')).toBe(CHECKBOX_DEFAULT_TRUE_VALUE)
|
||||
expect(getCheckboxDefaultSelectValue(false)).toBe(CHECKBOX_DEFAULT_FALSE_VALUE)
|
||||
expect(getCheckboxDefaultSelectValue(undefined)).toBe(CHECKBOX_DEFAULT_FALSE_VALUE)
|
||||
})
|
||||
|
||||
it('should convert selector values back to boolean defaults', () => {
|
||||
expect(parseCheckboxSelectValue(CHECKBOX_DEFAULT_TRUE_VALUE)).toBe(true)
|
||||
expect(parseCheckboxSelectValue(CHECKBOX_DEFAULT_FALSE_VALUE)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// Covers select normalization and JSON schema blank detection.
|
||||
describe('normalization', () => {
|
||||
it('should clear empty select defaults but keep other defaults unchanged', () => {
|
||||
expect(normalizeSelectDefaultValue(createInputVar({
|
||||
type: InputVarType.select,
|
||||
default: '',
|
||||
}))).toEqual(expect.objectContaining({ default: undefined }))
|
||||
|
||||
expect(normalizeSelectDefaultValue(createInputVar({
|
||||
type: InputVarType.textInput,
|
||||
default: '',
|
||||
}))).toEqual(expect.objectContaining({ default: '' }))
|
||||
})
|
||||
|
||||
it('should detect empty JSON schema values', () => {
|
||||
expect(isJsonSchemaEmpty(undefined)).toBe(true)
|
||||
expect(isJsonSchemaEmpty(' ')).toBe(true)
|
||||
expect(isJsonSchemaEmpty('{}')).toBe(false)
|
||||
expect(isJsonSchemaEmpty({ type: 'object' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// Covers type switching behavior for select and file input payloads.
|
||||
describe('type changes', () => {
|
||||
it('should reset select defaults when switching to select type', () => {
|
||||
const nextPayload = applyTypeChange(createInputVar({
|
||||
default: 'hello',
|
||||
}), InputVarType.select)
|
||||
|
||||
expect(nextPayload.type).toBe(InputVarType.select)
|
||||
expect(nextPayload.default).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should seed upload defaults when switching to multi-file type', () => {
|
||||
const nextPayload = applyTypeChange(createInputVar({
|
||||
allowed_file_types: [],
|
||||
allowed_file_extensions: [],
|
||||
allowed_file_upload_methods: [],
|
||||
max_length: 1,
|
||||
}), InputVarType.multiFiles)
|
||||
|
||||
expect(nextPayload).toEqual(expect.objectContaining({
|
||||
type: InputVarType.multiFiles,
|
||||
allowed_file_types: [SupportUploadFileTypes.image],
|
||||
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
|
||||
max_length: 5,
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,136 @@
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import DebugConfigurationContext from '@/context/debug-configuration'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import ConfigModal from '../index'
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
type DebugConfigValue = React.ComponentProps<typeof DebugConfigurationContext.Provider>['value']
|
||||
|
||||
const createDebugConfigValue = (overrides: Partial<DebugConfigValue> = {}): DebugConfigValue => ({
|
||||
mode: AppModeEnum.CHAT,
|
||||
modelConfig: {
|
||||
model_id: 'test-model',
|
||||
},
|
||||
...overrides,
|
||||
} as DebugConfigValue)
|
||||
|
||||
const createAppDetail = (mode: AppModeEnum) => ({
|
||||
mode,
|
||||
}) as NonNullable<ReturnType<typeof useAppStore.getState>['appDetail']>
|
||||
|
||||
const createInputVar = (overrides: Partial<InputVar> = {}): InputVar => ({
|
||||
type: InputVarType.textInput,
|
||||
label: '',
|
||||
variable: '',
|
||||
required: false,
|
||||
hide: false,
|
||||
default: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const renderConfigModal = (
|
||||
props: Partial<React.ComponentProps<typeof ConfigModal>> = {},
|
||||
debugOverrides: Partial<DebugConfigValue> = {},
|
||||
) => {
|
||||
const defaultProps: React.ComponentProps<typeof ConfigModal> = {
|
||||
isCreate: true,
|
||||
isShow: true,
|
||||
onClose: vi.fn(),
|
||||
onConfirm: vi.fn(),
|
||||
payload: createInputVar(),
|
||||
supportFile: false,
|
||||
}
|
||||
|
||||
const mergedProps = {
|
||||
...defaultProps,
|
||||
...props,
|
||||
}
|
||||
|
||||
return render(
|
||||
<DebugConfigurationContext.Provider value={createDebugConfigValue(debugOverrides)}>
|
||||
<ConfigModal {...mergedProps} />
|
||||
</DebugConfigurationContext.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('ConfigModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useAppStore.setState({ appDetail: createAppDetail(AppModeEnum.CHAT) })
|
||||
})
|
||||
|
||||
// Covers the main text-input save path through the rendered modal.
|
||||
describe('Save flows', () => {
|
||||
it('should auto-fill the label and submit the edited payload for text inputs', () => {
|
||||
const onConfirm = vi.fn()
|
||||
renderConfigModal({ onConfirm })
|
||||
|
||||
const textboxes = screen.getAllByRole('textbox')
|
||||
fireEvent.change(textboxes[0], { target: { value: 'user name' } })
|
||||
fireEvent.blur(textboxes[0])
|
||||
fireEvent.change(textboxes[2], { target: { value: 'hello world' } })
|
||||
|
||||
expect(textboxes[1]).toHaveValue('user_name')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({
|
||||
variable: 'user_name',
|
||||
label: 'user_name',
|
||||
default: 'hello world',
|
||||
}), {
|
||||
type: 'changeVarName',
|
||||
payload: {
|
||||
beforeKey: '',
|
||||
afterKey: 'user_name',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should block save and show an error when label is missing', () => {
|
||||
const onConfirm = vi.fn()
|
||||
renderConfigModal({
|
||||
onConfirm,
|
||||
payload: createInputVar({
|
||||
label: '',
|
||||
variable: 'name',
|
||||
}),
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
expect(toast.error).toHaveBeenCalledWith('appDebug.variableConfig.errorMsg.labelNameRequired')
|
||||
})
|
||||
})
|
||||
|
||||
// Covers conditional sections for non-text variable types.
|
||||
describe('Conditional rendering', () => {
|
||||
it('should render select-specific sections when editing a select variable', () => {
|
||||
renderConfigModal({
|
||||
payload: createInputVar({
|
||||
type: InputVarType.select,
|
||||
label: 'Category',
|
||||
variable: 'category',
|
||||
options: ['A', 'B'],
|
||||
default: 'A',
|
||||
}),
|
||||
})
|
||||
|
||||
expect(screen.getByText('appDebug.variableConfig.options')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('A').length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,65 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import TypeSelector from '../type-select'
|
||||
|
||||
const items = [
|
||||
{ value: InputVarType.textInput, name: 'Text input' },
|
||||
{ value: InputVarType.number, name: 'Number input' },
|
||||
]
|
||||
|
||||
describe('ConfigModal TypeSelector', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Covers closed-state rendering and current selection display.
|
||||
describe('Rendering', () => {
|
||||
it('should render the current selected item label', () => {
|
||||
render(
|
||||
<TypeSelector
|
||||
value={InputVarType.textInput}
|
||||
items={items}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Text input')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Number input')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Covers dropdown interactions and readonly behavior.
|
||||
describe('User interactions', () => {
|
||||
it('should call onSelect and close the list when choosing another item', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(
|
||||
<TypeSelector
|
||||
value={InputVarType.textInput}
|
||||
items={items}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Text input'))
|
||||
fireEvent.click(screen.getByText('Number input'))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(items[1])
|
||||
expect(screen.queryByText('Number input')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep the list closed when readonly is true', () => {
|
||||
render(
|
||||
<TypeSelector
|
||||
readonly
|
||||
value={InputVarType.textInput}
|
||||
items={items}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Text input'))
|
||||
|
||||
expect(screen.queryByText('Number input')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,376 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import DebugConfigurationContext from '@/context/debug-configuration'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { useConfigModalState } from '../use-config-modal-state'
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
type DebugConfigValue = React.ComponentProps<typeof DebugConfigurationContext.Provider>['value']
|
||||
|
||||
const createDebugConfigValue = (overrides: Partial<DebugConfigValue> = {}): DebugConfigValue => ({
|
||||
mode: AppModeEnum.CHAT,
|
||||
modelConfig: {
|
||||
model_id: 'test-model',
|
||||
},
|
||||
...overrides,
|
||||
} as DebugConfigValue)
|
||||
|
||||
const createAppDetail = (mode: AppModeEnum) => ({
|
||||
mode,
|
||||
}) as NonNullable<ReturnType<typeof useAppStore.getState>['appDetail']>
|
||||
|
||||
const createInputVar = (overrides: Partial<InputVar> = {}): InputVar => ({
|
||||
type: InputVarType.textInput,
|
||||
label: 'Name',
|
||||
variable: 'name',
|
||||
required: false,
|
||||
hide: false,
|
||||
default: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const renderConfigModalHook = (
|
||||
props: Partial<Parameters<typeof useConfigModalState>[0]> = {},
|
||||
debugOverrides: Partial<DebugConfigValue> = {},
|
||||
) => {
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<DebugConfigurationContext.Provider value={createDebugConfigValue(debugOverrides)}>
|
||||
{children}
|
||||
</DebugConfigurationContext.Provider>
|
||||
)
|
||||
|
||||
return renderHook(() => useConfigModalState({
|
||||
isShow: true,
|
||||
onConfirm: vi.fn(),
|
||||
payload: createInputVar(),
|
||||
supportFile: false,
|
||||
...props,
|
||||
}), { wrapper })
|
||||
}
|
||||
|
||||
describe('useConfigModalState', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useAppStore.setState({ appDetail: createAppDetail(AppModeEnum.CHAT) })
|
||||
})
|
||||
|
||||
// Covers app-mode dependent type options.
|
||||
describe('select options', () => {
|
||||
it('should expose JSON and file types for advanced apps with file support', () => {
|
||||
useAppStore.setState({ appDetail: createAppDetail(AppModeEnum.WORKFLOW) })
|
||||
const { result } = renderConfigModalHook({
|
||||
supportFile: true,
|
||||
})
|
||||
|
||||
const optionValues = result.current.selectOptions.map(option => option.value)
|
||||
expect(optionValues).toContain(InputVarType.singleFile)
|
||||
expect(optionValues).toContain(InputVarType.multiFiles)
|
||||
expect(optionValues).toContain(InputVarType.jsonObject)
|
||||
})
|
||||
})
|
||||
|
||||
// Covers variable input normalization and label auto-fill.
|
||||
describe('variable editing', () => {
|
||||
it('should replace spaces with underscores and copy the value into label on blur', () => {
|
||||
const { result } = renderConfigModalHook({
|
||||
payload: createInputVar({
|
||||
label: '',
|
||||
variable: '',
|
||||
}),
|
||||
})
|
||||
|
||||
const input = document.createElement('input')
|
||||
input.value = 'user name'
|
||||
input.setSelectionRange(0, 0)
|
||||
|
||||
act(() => {
|
||||
result.current.handleVarNameChange({ target: input } as React.ChangeEvent<HTMLInputElement>)
|
||||
})
|
||||
|
||||
expect(input.value).toBe('user_name')
|
||||
expect(result.current.tempPayload.variable).toBe('user_name')
|
||||
|
||||
act(() => {
|
||||
result.current.handleVarKeyBlur({ target: input } as React.FocusEvent<HTMLInputElement>)
|
||||
})
|
||||
|
||||
expect(result.current.tempPayload.label).toBe('user_name')
|
||||
})
|
||||
|
||||
it('should reject invalid variable names and avoid overwriting an existing label on blur', () => {
|
||||
const { result } = renderConfigModalHook({
|
||||
payload: createInputVar({
|
||||
label: 'Existing label',
|
||||
variable: 'name',
|
||||
}),
|
||||
})
|
||||
|
||||
const invalidInput = document.createElement('input')
|
||||
invalidInput.value = '1bad'
|
||||
|
||||
act(() => {
|
||||
result.current.handleVarNameChange({ target: invalidInput } as React.ChangeEvent<HTMLInputElement>)
|
||||
})
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('appDebug.varKeyError.notStartWithNumber'))
|
||||
expect(result.current.tempPayload.variable).toBe('name')
|
||||
|
||||
const blurInput = document.createElement('input')
|
||||
blurInput.value = 'updated_name'
|
||||
|
||||
act(() => {
|
||||
result.current.handleVarKeyBlur({ target: blurInput } as React.FocusEvent<HTMLInputElement>)
|
||||
})
|
||||
|
||||
expect(result.current.tempPayload.label).toBe('Existing label')
|
||||
})
|
||||
})
|
||||
|
||||
// Covers payload mutation helpers and schema parsing.
|
||||
describe('state helpers', () => {
|
||||
it('should clear invalid select defaults and normalize json schema edits', () => {
|
||||
const { result } = renderConfigModalHook({
|
||||
payload: createInputVar({
|
||||
type: InputVarType.select,
|
||||
default: 'keep-me',
|
||||
options: ['keep-me', 'next'],
|
||||
}),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handlePayloadChange('options')(['next'])
|
||||
})
|
||||
|
||||
expect(result.current.tempPayload.options).toEqual(['next'])
|
||||
expect(result.current.tempPayload.default).toBeUndefined()
|
||||
|
||||
act(() => {
|
||||
result.current.handleJSONSchemaChange('{"type":"object"}')
|
||||
})
|
||||
|
||||
expect(result.current.tempPayload.json_schema).toBe('{\n "type": "object"\n}')
|
||||
|
||||
act(() => {
|
||||
result.current.handleJSONSchemaChange(' ')
|
||||
})
|
||||
|
||||
expect(result.current.tempPayload.json_schema).toBeUndefined()
|
||||
expect(result.current.handleJSONSchemaChange('{invalid')).toBeNull()
|
||||
})
|
||||
|
||||
it('should update the payload when changing types and expose file/json options for advanced apps', () => {
|
||||
useAppStore.setState({ appDetail: createAppDetail(AppModeEnum.WORKFLOW) })
|
||||
const { result } = renderConfigModalHook({
|
||||
supportFile: true,
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleTypeChange({ value: InputVarType.singleFile, name: 'Single File' })
|
||||
})
|
||||
|
||||
expect(result.current.tempPayload.type).toBe(InputVarType.singleFile)
|
||||
expect(result.current.selectOptions.map(option => option.value)).toEqual(expect.arrayContaining([
|
||||
InputVarType.singleFile,
|
||||
InputVarType.multiFiles,
|
||||
InputVarType.jsonObject,
|
||||
]))
|
||||
})
|
||||
})
|
||||
|
||||
// Covers confirm validation and metadata generation.
|
||||
describe('confirm flows', () => {
|
||||
it('should block duplicate select options and show an error toast', () => {
|
||||
const onConfirm = vi.fn()
|
||||
const { result } = renderConfigModalHook({
|
||||
onConfirm,
|
||||
payload: createInputVar({
|
||||
type: InputVarType.select,
|
||||
options: ['first', 'first'],
|
||||
}),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleConfirm()
|
||||
})
|
||||
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
expect(toast.error).toHaveBeenCalledWith('appDebug.variableConfig.errorMsg.optionRepeat')
|
||||
})
|
||||
|
||||
it('should block missing labels, invalid variable names, and incomplete file settings', () => {
|
||||
const onConfirm = vi.fn()
|
||||
const { result, rerender } = renderConfigModalHook({
|
||||
onConfirm,
|
||||
payload: createInputVar({
|
||||
variable: '1bad',
|
||||
label: 'Valid label',
|
||||
}),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleConfirm()
|
||||
})
|
||||
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('appDebug.varKeyError.notStartWithNumber'))
|
||||
|
||||
rerender()
|
||||
|
||||
const labelless = renderConfigModalHook({
|
||||
onConfirm,
|
||||
payload: createInputVar({
|
||||
label: '',
|
||||
}),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
labelless.result.current.handleConfirm()
|
||||
})
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('appDebug.variableConfig.errorMsg.labelNameRequired')
|
||||
|
||||
const noFileTypes = renderConfigModalHook({
|
||||
onConfirm,
|
||||
payload: createInputVar({
|
||||
type: InputVarType.singleFile,
|
||||
label: 'File input',
|
||||
default: undefined,
|
||||
allowed_file_types: [],
|
||||
}),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
noFileTypes.result.current.handleConfirm()
|
||||
})
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('workflow.errorMsg.fieldRequired'))
|
||||
|
||||
const customWithoutExtensions = renderConfigModalHook({
|
||||
onConfirm,
|
||||
payload: createInputVar({
|
||||
type: InputVarType.singleFile,
|
||||
label: 'File input',
|
||||
default: undefined,
|
||||
allowed_file_types: [SupportUploadFileTypes.custom],
|
||||
allowed_file_extensions: [],
|
||||
}),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
customWithoutExtensions.result.current.handleConfirm()
|
||||
})
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('workflow.errorMsg.fieldRequired'))
|
||||
})
|
||||
|
||||
it('should validate select and json object edge cases before saving', () => {
|
||||
const onConfirm = vi.fn()
|
||||
const emptyOptions = renderConfigModalHook({
|
||||
onConfirm,
|
||||
payload: createInputVar({
|
||||
type: InputVarType.select,
|
||||
options: [],
|
||||
}),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
emptyOptions.result.current.handleConfirm()
|
||||
})
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('appDebug.variableConfig.errorMsg.atLeastOneOption')
|
||||
|
||||
const invalidJson = renderConfigModalHook({
|
||||
onConfirm,
|
||||
payload: createInputVar({
|
||||
type: InputVarType.jsonObject,
|
||||
label: 'JSON payload',
|
||||
json_schema: '{invalid}',
|
||||
}),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
invalidJson.result.current.handleConfirm()
|
||||
})
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('appDebug.variableConfig.errorMsg.jsonSchemaInvalid')
|
||||
|
||||
const nonObjectJson = renderConfigModalHook({
|
||||
onConfirm,
|
||||
payload: createInputVar({
|
||||
type: InputVarType.jsonObject,
|
||||
label: 'JSON payload',
|
||||
json_schema: '{"type":"array"}',
|
||||
}),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
nonObjectJson.result.current.handleConfirm()
|
||||
})
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('appDebug.variableConfig.errorMsg.jsonSchemaMustBeObject')
|
||||
})
|
||||
|
||||
it('should omit empty JSON schema and include rename metadata when saving', () => {
|
||||
const onConfirm = vi.fn()
|
||||
const { result } = renderConfigModalHook({
|
||||
onConfirm,
|
||||
payload: createInputVar({
|
||||
type: InputVarType.jsonObject,
|
||||
json_schema: ' ',
|
||||
variable: 'old_name',
|
||||
label: 'JSON payload',
|
||||
}),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handlePayloadChange('variable')('new_name')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleConfirm()
|
||||
})
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({
|
||||
variable: 'new_name',
|
||||
json_schema: undefined,
|
||||
}), {
|
||||
type: 'changeVarName',
|
||||
payload: {
|
||||
beforeKey: 'old_name',
|
||||
afterKey: 'new_name',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should save without rename metadata when the variable name does not change', () => {
|
||||
const onConfirm = vi.fn()
|
||||
const { result } = renderConfigModalHook({
|
||||
onConfirm,
|
||||
payload: createInputVar({
|
||||
variable: 'same_name',
|
||||
label: 'Same name',
|
||||
}),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleConfirm()
|
||||
})
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({
|
||||
variable: 'same_name',
|
||||
}), undefined)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,297 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { ConfigModalState } from './use-config-modal-state'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import type { InputVar, UploadFileSetting } from '@/app/components/workflow/types'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/app/components/base/ui/select'
|
||||
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
|
||||
import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/file-upload-setting'
|
||||
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
||||
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import ConfigSelect from '../config-select'
|
||||
import ConfigString from '../config-string'
|
||||
import ModalFoot from '../modal-foot'
|
||||
import { jsonConfigPlaceHolder } from './config'
|
||||
import Field from './field'
|
||||
import {
|
||||
CHECKBOX_DEFAULT_FALSE_VALUE,
|
||||
CHECKBOX_DEFAULT_TRUE_VALUE,
|
||||
FILE_INPUT_TYPES,
|
||||
parseCheckboxSelectValue,
|
||||
TEXT_MAX_LENGTH,
|
||||
} from './helpers'
|
||||
import TypeSelector from './type-select'
|
||||
|
||||
type Props = {
|
||||
onClose: () => void
|
||||
state: ConfigModalState
|
||||
}
|
||||
|
||||
type SelectOption = {
|
||||
value: string
|
||||
name: string
|
||||
}
|
||||
|
||||
type InlineSelectProps = {
|
||||
value: string
|
||||
items: SelectOption[]
|
||||
placeholder: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
const InlineSelect = ({
|
||||
value,
|
||||
items,
|
||||
placeholder,
|
||||
onChange,
|
||||
}: InlineSelectProps) => {
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(nextValue) => {
|
||||
if (nextValue !== null)
|
||||
onChange(nextValue)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label={placeholder} className="w-full">
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent listClassName="max-h-[140px]">
|
||||
{items.map(item => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
const EMPTY_DEFAULT_VALUE = '__empty-default__'
|
||||
|
||||
const ConfigModalBody: FC<Props> = ({
|
||||
state,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
checkboxDefaultSelectValue,
|
||||
handleConfirm,
|
||||
handleJSONSchemaChange,
|
||||
handlePayloadChange,
|
||||
handleTypeChange,
|
||||
handleVarKeyBlur,
|
||||
handleVarNameChange,
|
||||
isStringInput,
|
||||
jsonSchemaStr,
|
||||
modelId,
|
||||
modalRef,
|
||||
selectOptions,
|
||||
setTempPayload,
|
||||
tempPayload,
|
||||
} = state
|
||||
|
||||
const { type, label, max_length, options, variable } = tempPayload
|
||||
const isFileInput = FILE_INPUT_TYPES.includes(type as typeof FILE_INPUT_TYPES[number])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-8" ref={modalRef} tabIndex={-1}>
|
||||
<div className="space-y-2">
|
||||
<Field title={t('variableConfig.fieldType', { ns: 'appDebug' })}>
|
||||
<TypeSelector value={type} items={selectOptions} onSelect={handleTypeChange} />
|
||||
</Field>
|
||||
|
||||
<Field title={t('variableConfig.varName', { ns: 'appDebug' })}>
|
||||
<Input
|
||||
value={variable}
|
||||
onBlur={handleVarKeyBlur}
|
||||
onChange={handleVarNameChange}
|
||||
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field title={t('variableConfig.labelName', { ns: 'appDebug' })}>
|
||||
<Input
|
||||
value={label as string}
|
||||
onChange={event => handlePayloadChange('label')(event.target.value)}
|
||||
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{isStringInput && (
|
||||
<Field title={t('variableConfig.maxLength', { ns: 'appDebug' })}>
|
||||
<ConfigString
|
||||
maxLength={type === InputVarType.textInput ? TEXT_MAX_LENGTH : Number.POSITIVE_INFINITY}
|
||||
modelId={modelId}
|
||||
value={max_length}
|
||||
onChange={handlePayloadChange('max_length') as (value: number | undefined) => void}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{type === InputVarType.textInput && (
|
||||
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
|
||||
<Input
|
||||
value={tempPayload.default || ''}
|
||||
onChange={event => handlePayloadChange('default')(event.target.value || undefined)}
|
||||
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{type === InputVarType.paragraph && (
|
||||
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
|
||||
<Textarea
|
||||
value={String(tempPayload.default ?? '')}
|
||||
onChange={event => handlePayloadChange('default')(event.target.value || undefined)}
|
||||
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{type === InputVarType.number && (
|
||||
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
|
||||
<Input
|
||||
type="number"
|
||||
value={tempPayload.default || ''}
|
||||
onChange={event => handlePayloadChange('default')(event.target.value || undefined)}
|
||||
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{type === InputVarType.checkbox && (
|
||||
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
|
||||
<InlineSelect
|
||||
value={checkboxDefaultSelectValue}
|
||||
items={[
|
||||
{
|
||||
value: CHECKBOX_DEFAULT_TRUE_VALUE,
|
||||
name: t('variableConfig.startChecked', { ns: 'appDebug' }),
|
||||
},
|
||||
{
|
||||
value: CHECKBOX_DEFAULT_FALSE_VALUE,
|
||||
name: t('variableConfig.noDefaultSelected', { ns: 'appDebug' }),
|
||||
},
|
||||
]}
|
||||
onChange={value => handlePayloadChange('default')(parseCheckboxSelectValue(value))}
|
||||
placeholder={t('variableConfig.selectDefaultValue', { ns: 'appDebug' })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{type === InputVarType.select && (
|
||||
<>
|
||||
<Field title={t('variableConfig.options', { ns: 'appDebug' })}>
|
||||
<ConfigSelect
|
||||
options={options || []}
|
||||
onChange={handlePayloadChange('options') as (value: string[]) => void}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{options && options.length > 0 && (
|
||||
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
|
||||
<InlineSelect
|
||||
value={String(tempPayload.default || EMPTY_DEFAULT_VALUE)}
|
||||
items={[
|
||||
{ value: EMPTY_DEFAULT_VALUE, name: t('variableConfig.noDefaultValue', { ns: 'appDebug' }) },
|
||||
...options
|
||||
.filter(option => option.trim() !== '')
|
||||
.map(option => ({ value: option, name: option })),
|
||||
]}
|
||||
onChange={value => handlePayloadChange('default')(value === EMPTY_DEFAULT_VALUE ? undefined : value)}
|
||||
placeholder={t('variableConfig.selectDefaultValue', { ns: 'appDebug' })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isFileInput && (
|
||||
<>
|
||||
<FileUploadSetting
|
||||
isMultiple={type === InputVarType.multiFiles}
|
||||
onChange={payload => setTempPayload(payload as InputVar)}
|
||||
payload={tempPayload as UploadFileSetting}
|
||||
/>
|
||||
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
|
||||
<FileUploaderInAttachmentWrapper
|
||||
fileConfig={{
|
||||
allowed_file_extensions: tempPayload.allowed_file_extensions || [],
|
||||
allowed_file_types: tempPayload.allowed_file_types || [SupportUploadFileTypes.document],
|
||||
allowed_file_upload_methods: tempPayload.allowed_file_upload_methods || [TransferMethod.remote_url],
|
||||
number_limits: type === InputVarType.singleFile ? 1 : tempPayload.max_length || 5,
|
||||
}}
|
||||
onChange={(files) => {
|
||||
if (type === InputVarType.singleFile)
|
||||
handlePayloadChange('default')(files?.[0] || undefined)
|
||||
else
|
||||
handlePayloadChange('default')(files || undefined)
|
||||
}}
|
||||
value={(
|
||||
type === InputVarType.singleFile
|
||||
? (tempPayload.default ? [tempPayload.default] : [])
|
||||
: (tempPayload.default || [])
|
||||
) as unknown as FileEntity[]}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === InputVarType.jsonObject && (
|
||||
<Field isOptional title={t('variableConfig.jsonSchema', { ns: 'appDebug' })}>
|
||||
<CodeEditor
|
||||
className="h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1"
|
||||
language={CodeLanguage.json}
|
||||
noWrapper
|
||||
onChange={handleJSONSchemaChange}
|
||||
placeholder={<div className="whitespace-pre">{jsonConfigPlaceHolder}</div>}
|
||||
value={jsonSchemaStr}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<div className="!mt-5 flex h-6 items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={tempPayload.required}
|
||||
disabled={tempPayload.hide}
|
||||
onCheck={() => handlePayloadChange('required')(!tempPayload.required)}
|
||||
/>
|
||||
<span className="text-text-secondary system-sm-semibold">{t('variableConfig.required', { ns: 'appDebug' })}</span>
|
||||
</div>
|
||||
|
||||
<div className="!mt-5 flex h-6 items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={tempPayload.hide}
|
||||
disabled={tempPayload.required}
|
||||
onCheck={() => handlePayloadChange('hide')(!tempPayload.hide)}
|
||||
/>
|
||||
<span className="text-text-secondary system-sm-semibold">{t('variableConfig.hide', { ns: 'appDebug' })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalFoot
|
||||
onCancel={onClose}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ConfigModalBody)
|
||||
@ -0,0 +1,59 @@
|
||||
'use client'
|
||||
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import { produce } from 'immer'
|
||||
import { DEFAULT_FILE_UPLOAD_SETTING } from '@/app/components/workflow/constants'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
|
||||
export const TEXT_MAX_LENGTH = 256
|
||||
export const CHECKBOX_DEFAULT_TRUE_VALUE = 'true'
|
||||
export const CHECKBOX_DEFAULT_FALSE_VALUE = 'false'
|
||||
export const FILE_INPUT_TYPES = [InputVarType.singleFile, InputVarType.multiFiles] as const
|
||||
|
||||
export const getCheckboxDefaultSelectValue = (value: InputVar['default'] | boolean) => {
|
||||
if (typeof value === 'boolean')
|
||||
return value ? CHECKBOX_DEFAULT_TRUE_VALUE : CHECKBOX_DEFAULT_FALSE_VALUE
|
||||
|
||||
if (typeof value === 'string')
|
||||
return value.toLowerCase() === CHECKBOX_DEFAULT_TRUE_VALUE ? CHECKBOX_DEFAULT_TRUE_VALUE : CHECKBOX_DEFAULT_FALSE_VALUE
|
||||
|
||||
return CHECKBOX_DEFAULT_FALSE_VALUE
|
||||
}
|
||||
|
||||
export const parseCheckboxSelectValue = (value: string) => value === CHECKBOX_DEFAULT_TRUE_VALUE
|
||||
|
||||
export const normalizeSelectDefaultValue = (inputVar: InputVar) => {
|
||||
if (inputVar.type === InputVarType.select && inputVar.default === '')
|
||||
return { ...inputVar, default: undefined }
|
||||
|
||||
return inputVar
|
||||
}
|
||||
|
||||
export const isJsonSchemaEmpty = (value: InputVar['json_schema']) => {
|
||||
if (value === null || value === undefined)
|
||||
return true
|
||||
|
||||
if (typeof value !== 'string')
|
||||
return false
|
||||
|
||||
return value.trim() === ''
|
||||
}
|
||||
|
||||
export const applyTypeChange = (payload: InputVar, type: InputVarType) => {
|
||||
return produce(payload, (draft) => {
|
||||
draft.type = type
|
||||
|
||||
if (type === InputVarType.select)
|
||||
draft.default = undefined
|
||||
|
||||
if (FILE_INPUT_TYPES.includes(type as typeof FILE_INPUT_TYPES[number])) {
|
||||
Object.entries(DEFAULT_FILE_UPLOAD_SETTING).forEach(([key, value]) => {
|
||||
if (key !== 'max_length')
|
||||
Reflect.set(draft, key, value)
|
||||
})
|
||||
|
||||
if (type === InputVarType.multiFiles)
|
||||
draft.max_length = DEFAULT_FILE_UPLOAD_SETTING.max_length
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -1,56 +1,11 @@
|
||||
'use client'
|
||||
import type { ChangeEvent, FC } from 'react'
|
||||
import type { Item as SelectItem } from './type-select'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import type { InputVar, MoreInfo, UploadFileSetting } from '@/app/components/workflow/types'
|
||||
import { produce } from 'immer'
|
||||
import type { FC } from 'react'
|
||||
import type { InputVar, MoreInfo } from '@/app/components/workflow/types'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import { SimpleSelect } from '@/app/components/base/select'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { DEFAULT_FILE_UPLOAD_SETTING } from '@/app/components/workflow/constants'
|
||||
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
|
||||
import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/file-upload-setting'
|
||||
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
||||
import { ChangeType, InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
import { AppModeEnum, TransferMethod } from '@/types/app'
|
||||
import { checkKeys, getNewVarInWorkflow, replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var'
|
||||
import ConfigSelect from '../config-select'
|
||||
import ConfigString from '../config-string'
|
||||
import ModalFoot from '../modal-foot'
|
||||
import { jsonConfigPlaceHolder } from './config'
|
||||
import Field from './field'
|
||||
import TypeSelector from './type-select'
|
||||
|
||||
const TEXT_MAX_LENGTH = 256
|
||||
const CHECKBOX_DEFAULT_TRUE_VALUE = 'true'
|
||||
const CHECKBOX_DEFAULT_FALSE_VALUE = 'false'
|
||||
|
||||
const getCheckboxDefaultSelectValue = (value: InputVar['default']) => {
|
||||
if (typeof value === 'boolean')
|
||||
return value ? CHECKBOX_DEFAULT_TRUE_VALUE : CHECKBOX_DEFAULT_FALSE_VALUE
|
||||
if (typeof value === 'string')
|
||||
return value.toLowerCase() === CHECKBOX_DEFAULT_TRUE_VALUE ? CHECKBOX_DEFAULT_TRUE_VALUE : CHECKBOX_DEFAULT_FALSE_VALUE
|
||||
return CHECKBOX_DEFAULT_FALSE_VALUE
|
||||
}
|
||||
|
||||
const parseCheckboxSelectValue = (value: string) =>
|
||||
value === CHECKBOX_DEFAULT_TRUE_VALUE
|
||||
|
||||
const normalizeSelectDefaultValue = (inputVar: InputVar) => {
|
||||
if (inputVar.type === InputVarType.select && inputVar.default === '')
|
||||
return { ...inputVar, default: undefined }
|
||||
return inputVar
|
||||
}
|
||||
import ConfigModalBody from './config-modal-body'
|
||||
import { useConfigModalState } from './use-config-modal-state'
|
||||
|
||||
export type IConfigModalProps = {
|
||||
isCreate?: boolean
|
||||
@ -70,257 +25,13 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
||||
onConfirm,
|
||||
supportFile,
|
||||
}) => {
|
||||
const { modelConfig } = useContext(ConfigContext)
|
||||
const { t } = useTranslation()
|
||||
const [tempPayload, setTempPayload] = useState<InputVar>(() => normalizeSelectDefaultValue(payload || getNewVarInWorkflow('') as any))
|
||||
const { type, label, variable, options, max_length } = tempPayload
|
||||
const modalRef = useRef<HTMLDivElement>(null)
|
||||
const appDetail = useAppStore(state => state.appDetail)
|
||||
const isBasicApp = appDetail?.mode !== AppModeEnum.ADVANCED_CHAT && appDetail?.mode !== AppModeEnum.WORKFLOW
|
||||
const jsonSchemaStr = useMemo(() => {
|
||||
const isJsonObject = type === InputVarType.jsonObject
|
||||
if (!isJsonObject || !tempPayload.json_schema)
|
||||
return ''
|
||||
try {
|
||||
return tempPayload.json_schema
|
||||
}
|
||||
catch {
|
||||
return ''
|
||||
}
|
||||
}, [tempPayload.json_schema])
|
||||
useEffect(() => {
|
||||
// To fix the first input element auto focus, then directly close modal will raise error
|
||||
if (isShow)
|
||||
modalRef.current?.focus()
|
||||
}, [isShow])
|
||||
|
||||
const isStringInput = type === InputVarType.textInput || type === InputVarType.paragraph
|
||||
const checkVariableName = useCallback((value: string, canBeEmpty?: boolean) => {
|
||||
const { isValid, errorMessageKey } = checkKeys([value], canBeEmpty)
|
||||
if (!isValid) {
|
||||
toast.error(t(`varKeyError.${errorMessageKey}`, { ns: 'appDebug', key: t('variableConfig.varName', { ns: 'appDebug' }) }))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}, [t])
|
||||
const handlePayloadChange = useCallback((key: string) => {
|
||||
return (value: any) => {
|
||||
setTempPayload((prev) => {
|
||||
const newPayload = {
|
||||
...prev,
|
||||
[key]: value,
|
||||
}
|
||||
|
||||
// Clear default value if modified options no longer include current default
|
||||
if (key === 'options' && prev.default) {
|
||||
const optionsArray = Array.isArray(value) ? value : []
|
||||
if (!optionsArray.includes(prev.default))
|
||||
newPayload.default = undefined
|
||||
}
|
||||
|
||||
return newPayload
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleJSONSchemaChange = useCallback((value: string) => {
|
||||
const isEmpty = value == null || value.trim() === ''
|
||||
if (isEmpty) {
|
||||
handlePayloadChange('json_schema')(undefined)
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const v = JSON.parse(value)
|
||||
handlePayloadChange('json_schema')(JSON.stringify(v, null, 2))
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}, [handlePayloadChange])
|
||||
|
||||
const selectOptions: SelectItem[] = [
|
||||
{
|
||||
name: t('variableConfig.text-input', { ns: 'appDebug' }),
|
||||
value: InputVarType.textInput,
|
||||
},
|
||||
{
|
||||
name: t('variableConfig.paragraph', { ns: 'appDebug' }),
|
||||
value: InputVarType.paragraph,
|
||||
},
|
||||
{
|
||||
name: t('variableConfig.select', { ns: 'appDebug' }),
|
||||
value: InputVarType.select,
|
||||
},
|
||||
{
|
||||
name: t('variableConfig.number', { ns: 'appDebug' }),
|
||||
value: InputVarType.number,
|
||||
},
|
||||
{
|
||||
name: t('variableConfig.checkbox', { ns: 'appDebug' }),
|
||||
value: InputVarType.checkbox,
|
||||
},
|
||||
...(supportFile
|
||||
? [
|
||||
{
|
||||
name: t('variableConfig.single-file', { ns: 'appDebug' }),
|
||||
value: InputVarType.singleFile,
|
||||
},
|
||||
{
|
||||
name: t('variableConfig.multi-files', { ns: 'appDebug' }),
|
||||
value: InputVarType.multiFiles,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...((!isBasicApp)
|
||||
? [{
|
||||
name: t('variableConfig.json', { ns: 'appDebug' }),
|
||||
value: InputVarType.jsonObject,
|
||||
}]
|
||||
: []),
|
||||
]
|
||||
|
||||
const handleTypeChange = useCallback((item: SelectItem) => {
|
||||
const type = item.value as InputVarType
|
||||
|
||||
const newPayload = produce(tempPayload, (draft) => {
|
||||
draft.type = type
|
||||
if (type === InputVarType.select)
|
||||
draft.default = undefined
|
||||
if (([InputVarType.singleFile, InputVarType.multiFiles] as const).includes(
|
||||
type as typeof InputVarType.singleFile | typeof InputVarType.multiFiles,
|
||||
)) {
|
||||
(Object.keys(DEFAULT_FILE_UPLOAD_SETTING)).forEach((key) => {
|
||||
if (key !== 'max_length')
|
||||
(draft as any)[key] = (DEFAULT_FILE_UPLOAD_SETTING as any)[key]
|
||||
})
|
||||
if (type === InputVarType.multiFiles)
|
||||
draft.max_length = DEFAULT_FILE_UPLOAD_SETTING.max_length
|
||||
}
|
||||
})
|
||||
setTempPayload(newPayload)
|
||||
}, [tempPayload])
|
||||
|
||||
const handleVarKeyBlur = useCallback((e: any) => {
|
||||
const varName = e.target.value
|
||||
if (!checkVariableName(varName, true) || tempPayload.label)
|
||||
return
|
||||
|
||||
setTempPayload((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
label: varName,
|
||||
}
|
||||
})
|
||||
}, [checkVariableName, tempPayload.label])
|
||||
|
||||
const handleVarNameChange = useCallback((e: ChangeEvent<any>) => {
|
||||
replaceSpaceWithUnderscoreInVarNameInput(e.target)
|
||||
const value = e.target.value
|
||||
const { isValid, errorKey, errorMessageKey } = checkKeys([value], true)
|
||||
if (!isValid) {
|
||||
toast.error(t(`varKeyError.${errorMessageKey}`, { ns: 'appDebug', key: errorKey }))
|
||||
return
|
||||
}
|
||||
handlePayloadChange('variable')(e.target.value)
|
||||
}, [handlePayloadChange, t])
|
||||
|
||||
const checkboxDefaultSelectValue = useMemo(() => getCheckboxDefaultSelectValue(tempPayload.default), [tempPayload.default])
|
||||
|
||||
const isJsonSchemaEmpty = (value: InputVar['json_schema']) => {
|
||||
if (value === null || value === undefined) {
|
||||
return true
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return false
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
return trimmed === ''
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
const jsonSchemaValue = tempPayload.json_schema
|
||||
const isSchemaEmpty = isJsonSchemaEmpty(jsonSchemaValue)
|
||||
const normalizedJsonSchema = isSchemaEmpty ? undefined : jsonSchemaValue
|
||||
|
||||
// if the input type is jsonObject and the schema is empty as determined by `isJsonSchemaEmpty`,
|
||||
// remove the `json_schema` field from the payload by setting its value to `undefined`.
|
||||
const payloadToSave = tempPayload.type === InputVarType.jsonObject && isSchemaEmpty
|
||||
? { ...tempPayload, json_schema: undefined }
|
||||
: tempPayload
|
||||
|
||||
const moreInfo = tempPayload.variable === payload?.variable
|
||||
? undefined
|
||||
: {
|
||||
type: ChangeType.changeVarName,
|
||||
payload: { beforeKey: payload?.variable || '', afterKey: tempPayload.variable },
|
||||
}
|
||||
|
||||
const isVariableNameValid = checkVariableName(tempPayload.variable)
|
||||
if (!isVariableNameValid)
|
||||
return
|
||||
|
||||
if (!tempPayload.label) {
|
||||
toast.error(t('variableConfig.errorMsg.labelNameRequired', { ns: 'appDebug' }))
|
||||
return
|
||||
}
|
||||
if (isStringInput || type === InputVarType.number) {
|
||||
onConfirm(payloadToSave, moreInfo)
|
||||
}
|
||||
else if (type === InputVarType.select) {
|
||||
if (options?.length === 0) {
|
||||
toast.error(t('variableConfig.errorMsg.atLeastOneOption', { ns: 'appDebug' }))
|
||||
return
|
||||
}
|
||||
const obj: Record<string, boolean> = {}
|
||||
let hasRepeatedItem = false
|
||||
options?.forEach((o) => {
|
||||
if (obj[o]) {
|
||||
hasRepeatedItem = true
|
||||
return
|
||||
}
|
||||
obj[o] = true
|
||||
})
|
||||
if (hasRepeatedItem) {
|
||||
toast.error(t('variableConfig.errorMsg.optionRepeat', { ns: 'appDebug' }))
|
||||
return
|
||||
}
|
||||
onConfirm(payloadToSave, moreInfo)
|
||||
}
|
||||
else if (([InputVarType.singleFile, InputVarType.multiFiles] as const).includes(
|
||||
type as typeof InputVarType.singleFile | typeof InputVarType.multiFiles,
|
||||
)) {
|
||||
if (tempPayload.allowed_file_types?.length === 0) {
|
||||
const errorMessages = t('errorMsg.fieldRequired', { ns: 'workflow', field: t('variableConfig.file.supportFileTypes', { ns: 'appDebug' }) })
|
||||
toast.error(errorMessages)
|
||||
return
|
||||
}
|
||||
if (tempPayload.allowed_file_types?.includes(SupportUploadFileTypes.custom) && !tempPayload.allowed_file_extensions?.length) {
|
||||
const errorMessages = t('errorMsg.fieldRequired', { ns: 'workflow', field: t('variableConfig.file.custom.name', { ns: 'appDebug' }) })
|
||||
toast.error(errorMessages)
|
||||
return
|
||||
}
|
||||
onConfirm(payloadToSave, moreInfo)
|
||||
}
|
||||
else if (type === InputVarType.jsonObject) {
|
||||
if (!isSchemaEmpty && typeof normalizedJsonSchema === 'string') {
|
||||
try {
|
||||
const schema = JSON.parse(normalizedJsonSchema)
|
||||
if (schema?.type !== 'object') {
|
||||
toast.error(t('variableConfig.errorMsg.jsonSchemaMustBeObject', { ns: 'appDebug' }))
|
||||
return
|
||||
}
|
||||
}
|
||||
catch {
|
||||
toast.error(t('variableConfig.errorMsg.jsonSchemaInvalid', { ns: 'appDebug' }))
|
||||
return
|
||||
}
|
||||
}
|
||||
onConfirm(payloadToSave, moreInfo)
|
||||
}
|
||||
else {
|
||||
onConfirm(payloadToSave, moreInfo)
|
||||
}
|
||||
}
|
||||
const modalState = useConfigModalState({
|
||||
payload,
|
||||
isShow,
|
||||
onConfirm,
|
||||
supportFile,
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@ -328,173 +39,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
||||
isShow={isShow}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="mb-8" ref={modalRef} tabIndex={-1}>
|
||||
<div className="space-y-2">
|
||||
<Field title={t('variableConfig.fieldType', { ns: 'appDebug' })}>
|
||||
<TypeSelector value={type} items={selectOptions} onSelect={handleTypeChange} />
|
||||
</Field>
|
||||
|
||||
<Field title={t('variableConfig.varName', { ns: 'appDebug' })}>
|
||||
<Input
|
||||
value={variable}
|
||||
onChange={handleVarNameChange}
|
||||
onBlur={handleVarKeyBlur}
|
||||
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })!}
|
||||
/>
|
||||
</Field>
|
||||
<Field title={t('variableConfig.labelName', { ns: 'appDebug' })}>
|
||||
<Input
|
||||
value={label as string}
|
||||
onChange={e => handlePayloadChange('label')(e.target.value)}
|
||||
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })!}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{isStringInput && (
|
||||
<Field title={t('variableConfig.maxLength', { ns: 'appDebug' })}>
|
||||
<ConfigString maxLength={type === InputVarType.textInput ? TEXT_MAX_LENGTH : Infinity} modelId={modelConfig.model_id} value={max_length} onChange={handlePayloadChange('max_length')} />
|
||||
</Field>
|
||||
|
||||
)}
|
||||
|
||||
{/* Default value for text input */}
|
||||
{type === InputVarType.textInput && (
|
||||
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
|
||||
<Input
|
||||
value={tempPayload.default || ''}
|
||||
onChange={e => handlePayloadChange('default')(e.target.value || undefined)}
|
||||
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })!}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Default value for paragraph */}
|
||||
{type === InputVarType.paragraph && (
|
||||
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
|
||||
<Textarea
|
||||
value={String(tempPayload.default ?? '')}
|
||||
onChange={e => handlePayloadChange('default')(e.target.value || undefined)}
|
||||
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })!}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Default value for number input */}
|
||||
{type === InputVarType.number && (
|
||||
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
|
||||
<Input
|
||||
type="number"
|
||||
value={tempPayload.default || ''}
|
||||
onChange={e => handlePayloadChange('default')(e.target.value || undefined)}
|
||||
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })!}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{type === InputVarType.checkbox && (
|
||||
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
|
||||
<SimpleSelect
|
||||
className="w-full"
|
||||
optionWrapClassName="max-h-[140px] overflow-y-auto"
|
||||
items={[
|
||||
{ value: CHECKBOX_DEFAULT_TRUE_VALUE, name: t('variableConfig.startChecked', { ns: 'appDebug' }) },
|
||||
{ value: CHECKBOX_DEFAULT_FALSE_VALUE, name: t('variableConfig.noDefaultSelected', { ns: 'appDebug' }) },
|
||||
]}
|
||||
defaultValue={checkboxDefaultSelectValue}
|
||||
onSelect={item => handlePayloadChange('default')(parseCheckboxSelectValue(String(item.value)))}
|
||||
placeholder={t('variableConfig.selectDefaultValue', { ns: 'appDebug' })}
|
||||
allowSearch={false}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{type === InputVarType.select && (
|
||||
<>
|
||||
<Field title={t('variableConfig.options', { ns: 'appDebug' })}>
|
||||
<ConfigSelect options={options || []} onChange={handlePayloadChange('options')} />
|
||||
</Field>
|
||||
{options && options.length > 0 && (
|
||||
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
|
||||
<SimpleSelect
|
||||
key={`default-select-${options.join('-')}`}
|
||||
className="w-full"
|
||||
optionWrapClassName="max-h-[140px] overflow-y-auto"
|
||||
items={[
|
||||
{ value: '', name: t('variableConfig.noDefaultValue', { ns: 'appDebug' }) },
|
||||
...options.filter(opt => opt.trim() !== '').map(option => ({
|
||||
value: option,
|
||||
name: option,
|
||||
})),
|
||||
]}
|
||||
defaultValue={tempPayload.default || ''}
|
||||
onSelect={item => handlePayloadChange('default')(item.value === '' ? undefined : item.value)}
|
||||
placeholder={t('variableConfig.selectDefaultValue', { ns: 'appDebug' })}
|
||||
allowSearch={false}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{([InputVarType.singleFile, InputVarType.multiFiles] as const).includes(
|
||||
type as typeof InputVarType.singleFile | typeof InputVarType.multiFiles,
|
||||
) && (
|
||||
<>
|
||||
<FileUploadSetting
|
||||
payload={tempPayload as UploadFileSetting}
|
||||
onChange={(p: UploadFileSetting) => setTempPayload(p as InputVar)}
|
||||
isMultiple={type === InputVarType.multiFiles}
|
||||
/>
|
||||
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
|
||||
<FileUploaderInAttachmentWrapper
|
||||
value={(type === InputVarType.singleFile ? (tempPayload.default ? [tempPayload.default] : []) : (tempPayload.default || [])) as unknown as FileEntity[]}
|
||||
onChange={(files) => {
|
||||
if (type === InputVarType.singleFile)
|
||||
handlePayloadChange('default')(files?.[0] || undefined)
|
||||
else
|
||||
handlePayloadChange('default')(files || undefined)
|
||||
}}
|
||||
fileConfig={{
|
||||
allowed_file_types: tempPayload.allowed_file_types || [SupportUploadFileTypes.document],
|
||||
allowed_file_extensions: tempPayload.allowed_file_extensions || [],
|
||||
allowed_file_upload_methods: tempPayload.allowed_file_upload_methods || [TransferMethod.remote_url],
|
||||
number_limits: type === InputVarType.singleFile ? 1 : tempPayload.max_length || 5,
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === InputVarType.jsonObject && (
|
||||
<Field title={t('variableConfig.jsonSchema', { ns: 'appDebug' })} isOptional>
|
||||
<CodeEditor
|
||||
language={CodeLanguage.json}
|
||||
value={jsonSchemaStr}
|
||||
onChange={handleJSONSchemaChange}
|
||||
noWrapper
|
||||
className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1"
|
||||
placeholder={
|
||||
<div className="whitespace-pre">{jsonConfigPlaceHolder}</div>
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<div className="!mt-5 flex h-6 items-center space-x-2">
|
||||
<Checkbox checked={tempPayload.required} disabled={tempPayload.hide} onCheck={() => handlePayloadChange('required')(!tempPayload.required)} />
|
||||
<span className="text-text-secondary system-sm-semibold">{t('variableConfig.required', { ns: 'appDebug' })}</span>
|
||||
</div>
|
||||
|
||||
<div className="!mt-5 flex h-6 items-center space-x-2">
|
||||
<Checkbox checked={tempPayload.hide} disabled={tempPayload.required} onCheck={() => handlePayloadChange('hide')(!tempPayload.hide)} />
|
||||
<span className="text-text-secondary system-sm-semibold">{t('variableConfig.hide', { ns: 'appDebug' })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ModalFoot
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
<ConfigModalBody state={modalState} onClose={onClose} />
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,299 @@
|
||||
'use client'
|
||||
|
||||
import type { ChangeEvent, Dispatch, FocusEvent, RefObject, SetStateAction } from 'react'
|
||||
import type { IConfigModalProps } from './index'
|
||||
import type { Item as SelectItem } from './type-select'
|
||||
import type { InputVar, MoreInfo } from '@/app/components/workflow/types'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { ChangeType, InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { checkKeys, getNewVarInWorkflow, replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var'
|
||||
import {
|
||||
applyTypeChange,
|
||||
FILE_INPUT_TYPES,
|
||||
getCheckboxDefaultSelectValue,
|
||||
isJsonSchemaEmpty,
|
||||
normalizeSelectDefaultValue,
|
||||
} from './helpers'
|
||||
|
||||
type ConfigModalStateParams = Pick<IConfigModalProps, 'isShow' | 'onConfirm' | 'payload' | 'supportFile'>
|
||||
|
||||
type PayloadChangeHandler = (key: keyof InputVar) => (value: unknown) => void
|
||||
|
||||
export type ConfigModalState = {
|
||||
checkboxDefaultSelectValue: string
|
||||
handleConfirm: () => void
|
||||
handleJSONSchemaChange: (value: string) => null | undefined
|
||||
handlePayloadChange: PayloadChangeHandler
|
||||
handleTypeChange: (item: SelectItem) => void
|
||||
handleVarKeyBlur: (event: FocusEvent<HTMLInputElement>) => void
|
||||
handleVarNameChange: (event: ChangeEvent<HTMLInputElement>) => void
|
||||
isStringInput: boolean
|
||||
jsonSchemaStr: string
|
||||
modelId: string
|
||||
modalRef: RefObject<HTMLDivElement | null>
|
||||
selectOptions: SelectItem[]
|
||||
setTempPayload: Dispatch<SetStateAction<InputVar>>
|
||||
tempPayload: InputVar
|
||||
}
|
||||
|
||||
export const useConfigModalState = ({
|
||||
payload,
|
||||
isShow,
|
||||
onConfirm,
|
||||
supportFile,
|
||||
}: ConfigModalStateParams): ConfigModalState => {
|
||||
const { modelConfig } = useContext(ConfigContext)
|
||||
const { t } = useTranslation()
|
||||
const [tempPayload, setTempPayload] = useState<InputVar>(() => normalizeSelectDefaultValue(payload ?? getNewVarInWorkflow('')))
|
||||
const modalRef = useRef<HTMLDivElement>(null)
|
||||
const appDetail = useAppStore(state => state.appDetail)
|
||||
const isBasicApp = appDetail?.mode !== AppModeEnum.ADVANCED_CHAT && appDetail?.mode !== AppModeEnum.WORKFLOW
|
||||
const { type } = tempPayload
|
||||
|
||||
useEffect(() => {
|
||||
if (isShow)
|
||||
modalRef.current?.focus()
|
||||
}, [isShow])
|
||||
|
||||
const isStringInput = type === InputVarType.textInput || type === InputVarType.paragraph
|
||||
|
||||
const jsonSchemaStr = useMemo(() => {
|
||||
if (type !== InputVarType.jsonObject || !tempPayload.json_schema)
|
||||
return ''
|
||||
|
||||
return typeof tempPayload.json_schema === 'string'
|
||||
? tempPayload.json_schema
|
||||
: JSON.stringify(tempPayload.json_schema, null, 2)
|
||||
}, [tempPayload.json_schema, type])
|
||||
|
||||
const checkVariableName = useCallback((value: string, canBeEmpty?: boolean) => {
|
||||
const { isValid, errorMessageKey } = checkKeys([value], canBeEmpty)
|
||||
|
||||
if (!isValid) {
|
||||
toast.error(t(`varKeyError.${errorMessageKey}`, {
|
||||
ns: 'appDebug',
|
||||
key: t('variableConfig.varName', { ns: 'appDebug' }),
|
||||
}))
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}, [t])
|
||||
|
||||
const handlePayloadChange = useCallback<PayloadChangeHandler>((key: keyof InputVar) => {
|
||||
return (value: unknown) => {
|
||||
setTempPayload((previousPayload) => {
|
||||
const nextPayload = {
|
||||
...previousPayload,
|
||||
[key]: value,
|
||||
} as InputVar
|
||||
|
||||
if (key === 'options' && previousPayload.default) {
|
||||
const options = Array.isArray(value) ? value : []
|
||||
if (!options.includes(previousPayload.default))
|
||||
nextPayload.default = undefined
|
||||
}
|
||||
|
||||
return nextPayload
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleJSONSchemaChange = useCallback((value: string) => {
|
||||
if (value.trim() === '') {
|
||||
handlePayloadChange('json_schema')(undefined)
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedSchema = JSON.parse(value)
|
||||
handlePayloadChange('json_schema')(JSON.stringify(parsedSchema, null, 2))
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}, [handlePayloadChange])
|
||||
|
||||
const selectOptions = useMemo<SelectItem[]>(() => ([
|
||||
{
|
||||
name: t('variableConfig.text-input', { ns: 'appDebug' }),
|
||||
value: InputVarType.textInput,
|
||||
},
|
||||
{
|
||||
name: t('variableConfig.paragraph', { ns: 'appDebug' }),
|
||||
value: InputVarType.paragraph,
|
||||
},
|
||||
{
|
||||
name: t('variableConfig.select', { ns: 'appDebug' }),
|
||||
value: InputVarType.select,
|
||||
},
|
||||
{
|
||||
name: t('variableConfig.number', { ns: 'appDebug' }),
|
||||
value: InputVarType.number,
|
||||
},
|
||||
{
|
||||
name: t('variableConfig.checkbox', { ns: 'appDebug' }),
|
||||
value: InputVarType.checkbox,
|
||||
},
|
||||
...(supportFile
|
||||
? [
|
||||
{
|
||||
name: t('variableConfig.single-file', { ns: 'appDebug' }),
|
||||
value: InputVarType.singleFile,
|
||||
},
|
||||
{
|
||||
name: t('variableConfig.multi-files', { ns: 'appDebug' }),
|
||||
value: InputVarType.multiFiles,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!isBasicApp
|
||||
? [
|
||||
{
|
||||
name: t('variableConfig.json', { ns: 'appDebug' }),
|
||||
value: InputVarType.jsonObject,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]), [isBasicApp, supportFile, t])
|
||||
|
||||
const handleTypeChange = useCallback((item: SelectItem) => {
|
||||
setTempPayload(previousPayload => applyTypeChange(previousPayload, item.value))
|
||||
}, [])
|
||||
|
||||
const handleVarKeyBlur = useCallback((event: FocusEvent<HTMLInputElement>) => {
|
||||
const variableName = event.target.value
|
||||
|
||||
if (!checkVariableName(variableName, true) || tempPayload.label)
|
||||
return
|
||||
|
||||
setTempPayload(previousPayload => ({
|
||||
...previousPayload,
|
||||
label: variableName,
|
||||
}))
|
||||
}, [checkVariableName, tempPayload.label])
|
||||
|
||||
const handleVarNameChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||
replaceSpaceWithUnderscoreInVarNameInput(event.target)
|
||||
const value = event.target.value
|
||||
const { isValid, errorKey, errorMessageKey } = checkKeys([value], true)
|
||||
|
||||
if (!isValid) {
|
||||
toast.error(t(`varKeyError.${errorMessageKey}`, { ns: 'appDebug', key: errorKey }))
|
||||
return
|
||||
}
|
||||
|
||||
handlePayloadChange('variable')(value)
|
||||
}, [handlePayloadChange, t])
|
||||
|
||||
const checkboxDefaultSelectValue = useMemo(() => getCheckboxDefaultSelectValue(tempPayload.default), [tempPayload.default])
|
||||
|
||||
const buildMoreInfo = useCallback((): MoreInfo | undefined => {
|
||||
if (tempPayload.variable === payload?.variable)
|
||||
return undefined
|
||||
|
||||
return {
|
||||
type: ChangeType.changeVarName,
|
||||
payload: {
|
||||
beforeKey: payload?.variable || '',
|
||||
afterKey: tempPayload.variable,
|
||||
},
|
||||
}
|
||||
}, [payload?.variable, tempPayload.variable])
|
||||
|
||||
const validateBeforeConfirm = useCallback(() => {
|
||||
if (!checkVariableName(tempPayload.variable))
|
||||
return false
|
||||
|
||||
if (!tempPayload.label) {
|
||||
toast.error(t('variableConfig.errorMsg.labelNameRequired', { ns: 'appDebug' }))
|
||||
return false
|
||||
}
|
||||
|
||||
if (type === InputVarType.select) {
|
||||
if (!tempPayload.options?.length) {
|
||||
toast.error(t('variableConfig.errorMsg.atLeastOneOption', { ns: 'appDebug' }))
|
||||
return false
|
||||
}
|
||||
|
||||
const duplicatedOption = tempPayload.options.find((option, index) => tempPayload.options?.indexOf(option) !== index)
|
||||
if (duplicatedOption) {
|
||||
toast.error(t('variableConfig.errorMsg.optionRepeat', { ns: 'appDebug' }))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (FILE_INPUT_TYPES.includes(type as typeof FILE_INPUT_TYPES[number])) {
|
||||
if (!tempPayload.allowed_file_types?.length) {
|
||||
toast.error(t('errorMsg.fieldRequired', {
|
||||
ns: 'workflow',
|
||||
field: t('variableConfig.file.supportFileTypes', { ns: 'appDebug' }),
|
||||
}))
|
||||
return false
|
||||
}
|
||||
|
||||
if (tempPayload.allowed_file_types.includes(SupportUploadFileTypes.custom) && !tempPayload.allowed_file_extensions?.length) {
|
||||
toast.error(t('errorMsg.fieldRequired', {
|
||||
ns: 'workflow',
|
||||
field: t('variableConfig.file.custom.name', { ns: 'appDebug' }),
|
||||
}))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedJsonSchema = isJsonSchemaEmpty(tempPayload.json_schema)
|
||||
? undefined
|
||||
: tempPayload.json_schema
|
||||
|
||||
if (type === InputVarType.jsonObject && typeof normalizedJsonSchema === 'string') {
|
||||
try {
|
||||
const schema = JSON.parse(normalizedJsonSchema)
|
||||
|
||||
if (schema?.type !== 'object') {
|
||||
toast.error(t('variableConfig.errorMsg.jsonSchemaMustBeObject', { ns: 'appDebug' }))
|
||||
return false
|
||||
}
|
||||
}
|
||||
catch {
|
||||
toast.error(t('variableConfig.errorMsg.jsonSchemaInvalid', { ns: 'appDebug' }))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}, [checkVariableName, t, tempPayload, type])
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (!validateBeforeConfirm())
|
||||
return
|
||||
|
||||
const payloadToSave = type === InputVarType.jsonObject && isJsonSchemaEmpty(tempPayload.json_schema)
|
||||
? { ...tempPayload, json_schema: undefined }
|
||||
: tempPayload
|
||||
|
||||
onConfirm(payloadToSave, buildMoreInfo())
|
||||
}, [buildMoreInfo, onConfirm, tempPayload, type, validateBeforeConfirm])
|
||||
|
||||
return {
|
||||
checkboxDefaultSelectValue,
|
||||
handleConfirm,
|
||||
handleJSONSchemaChange,
|
||||
handlePayloadChange,
|
||||
handleTypeChange,
|
||||
handleVarKeyBlur,
|
||||
handleVarNameChange,
|
||||
isStringInput,
|
||||
jsonSchemaStr,
|
||||
modelId: modelConfig.model_id,
|
||||
modalRef,
|
||||
selectOptions,
|
||||
setTempPayload,
|
||||
tempPayload,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,233 @@
|
||||
import type { PromptItem } from '@/models/debug'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { PromptMode } from '@/models/debug'
|
||||
import { fetchPromptTemplate } from '@/service/debug'
|
||||
import { AppModeEnum, ModelModeType } from '@/types/app'
|
||||
import useAdvancedPromptConfig from '../use-advanced-prompt-config'
|
||||
|
||||
const mockFetchPromptTemplate = vi.mocked(fetchPromptTemplate)
|
||||
|
||||
vi.mock('@/service/debug', () => ({
|
||||
fetchPromptTemplate: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('useAdvancedPromptConfig', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFetchPromptTemplate.mockResolvedValue({
|
||||
chat_prompt_config: {
|
||||
prompt: [
|
||||
{ text: 'context {{#pre_prompt#}}' },
|
||||
],
|
||||
},
|
||||
completion_prompt_config: {
|
||||
prompt: {
|
||||
text: 'completion {{#pre_prompt#}}',
|
||||
},
|
||||
conversation_histories_role: {
|
||||
user_prefix: 'User',
|
||||
assistant_prefix: 'Assistant',
|
||||
},
|
||||
},
|
||||
stop: ['END'],
|
||||
} as Awaited<ReturnType<typeof fetchPromptTemplate>>)
|
||||
})
|
||||
|
||||
const renderAdvancedPromptHook = (overrides: Partial<Parameters<typeof useAdvancedPromptConfig>[0]> = {}) => {
|
||||
const setCompletionParams = vi.fn()
|
||||
const setStop = vi.fn()
|
||||
const onUserChangedPrompt = vi.fn()
|
||||
|
||||
const hook = renderHook(() => useAdvancedPromptConfig({
|
||||
appMode: AppModeEnum.CHAT,
|
||||
modelModeType: ModelModeType.chat,
|
||||
modelName: 'gpt-4o',
|
||||
promptMode: PromptMode.advanced,
|
||||
prePrompt: 'prefill',
|
||||
onUserChangedPrompt,
|
||||
hasSetDataSet: false,
|
||||
completionParams: {},
|
||||
setCompletionParams,
|
||||
setStop,
|
||||
...overrides,
|
||||
}))
|
||||
|
||||
return {
|
||||
...hook,
|
||||
onUserChangedPrompt,
|
||||
setCompletionParams,
|
||||
setStop,
|
||||
}
|
||||
}
|
||||
|
||||
// Covers prompt state setters for advanced mode.
|
||||
describe('prompt state', () => {
|
||||
it('should update the current advanced prompt and notify when the user changed it', () => {
|
||||
const prompt: PromptItem[] = [{ text: 'updated prompt' }]
|
||||
const { result, onUserChangedPrompt } = renderAdvancedPromptHook()
|
||||
|
||||
act(() => {
|
||||
result.current.setCurrentAdvancedPrompt(prompt, true)
|
||||
})
|
||||
|
||||
expect(result.current.currentAdvancedPrompt).toEqual(prompt)
|
||||
expect(onUserChangedPrompt).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should update completion prompts, history role, and block status in completion mode', () => {
|
||||
const { result } = renderAdvancedPromptHook({
|
||||
modelModeType: ModelModeType.completion,
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.setCurrentAdvancedPrompt({ text: '{{#context#}}{{#histories#}}{{#query#}}' }, true)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.setConversationHistoriesRole({
|
||||
user_prefix: 'User:',
|
||||
assistant_prefix: 'Assistant:',
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.completionPromptConfig.prompt.text).toBe('{{#context#}}{{#histories#}}{{#query#}}')
|
||||
expect(result.current.completionPromptConfig.conversation_histories_role).toEqual({
|
||||
user_prefix: 'User:',
|
||||
assistant_prefix: 'Assistant:',
|
||||
})
|
||||
expect(result.current.hasSetBlockStatus).toEqual({
|
||||
context: true,
|
||||
history: true,
|
||||
query: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should no-op advanced prompt setters when the prompt mode is simple', () => {
|
||||
const { result, onUserChangedPrompt } = renderAdvancedPromptHook({
|
||||
promptMode: PromptMode.simple,
|
||||
prePrompt: '{{#context#}}',
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.setCurrentAdvancedPrompt([{ text: 'ignored' }], true)
|
||||
})
|
||||
|
||||
expect(result.current.currentAdvancedPrompt).toEqual([])
|
||||
expect(result.current.hasSetBlockStatus).toEqual({
|
||||
context: true,
|
||||
history: false,
|
||||
query: false,
|
||||
})
|
||||
expect(onUserChangedPrompt).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Covers default prompt migration for simple-mode chat apps.
|
||||
describe('default prompt migration', () => {
|
||||
it('should fetch and hydrate the default chat prompt when upgrading from simple mode', async () => {
|
||||
const { result } = renderAdvancedPromptHook({
|
||||
promptMode: PromptMode.simple,
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.migrateToDefaultPrompt()
|
||||
})
|
||||
|
||||
expect(mockFetchPromptTemplate).toHaveBeenCalledWith({
|
||||
appMode: AppModeEnum.CHAT,
|
||||
mode: ModelModeType.chat,
|
||||
modelName: 'gpt-4o',
|
||||
hasSetDataSet: false,
|
||||
})
|
||||
expect(result.current.chatPromptConfig.prompt[0].text).toBe('context prefill')
|
||||
})
|
||||
|
||||
it('should hydrate completion prompts when upgrading a simple completion app', async () => {
|
||||
const { result, setCompletionParams } = renderAdvancedPromptHook({
|
||||
promptMode: PromptMode.simple,
|
||||
modelModeType: ModelModeType.completion,
|
||||
completionParams: { temperature: 0.7 },
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.migrateToDefaultPrompt()
|
||||
})
|
||||
|
||||
expect(result.current.completionPromptConfig.prompt.text).toBe('completion prefill')
|
||||
expect(setCompletionParams).toHaveBeenCalledWith({
|
||||
temperature: 0.7,
|
||||
stop: ['END'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should migrate to completion prompts and propagate stop words when switching modes', async () => {
|
||||
const { result, setCompletionParams, setStop } = renderAdvancedPromptHook({
|
||||
modelModeType: ModelModeType.chat,
|
||||
appMode: AppModeEnum.ADVANCED_CHAT,
|
||||
completionParams: { stop: [] },
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.migrateToDefaultPrompt(true, ModelModeType.completion)
|
||||
})
|
||||
|
||||
expect(result.current.completionPromptConfig.prompt.text).toBe('completion prefill')
|
||||
expect(setCompletionParams).toHaveBeenCalledWith({
|
||||
stop: ['END'],
|
||||
})
|
||||
expect(setStop).toHaveBeenCalledWith(['END'])
|
||||
})
|
||||
|
||||
it('should preserve existing completion prompt text and stop words when migrating advanced prompts', async () => {
|
||||
const { result, setCompletionParams, setStop } = renderAdvancedPromptHook({
|
||||
modelModeType: ModelModeType.chat,
|
||||
appMode: AppModeEnum.AGENT_CHAT,
|
||||
completionParams: { stop: ['KEEP'] },
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.setCompletionPromptConfig({
|
||||
prompt: { text: 'kept {{#pre_prompt#}}' },
|
||||
conversation_histories_role: {
|
||||
user_prefix: 'User',
|
||||
assistant_prefix: 'Assistant',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.migrateToDefaultPrompt(true, ModelModeType.completion)
|
||||
})
|
||||
|
||||
expect(result.current.completionPromptConfig.prompt.text).toBe('kept prefill')
|
||||
expect(result.current.completionPromptConfig.conversation_histories_role).toEqual({
|
||||
user_prefix: 'User',
|
||||
assistant_prefix: 'Assistant',
|
||||
})
|
||||
expect(setCompletionParams).not.toHaveBeenCalled()
|
||||
expect(setStop).toHaveBeenCalledWith(['END'])
|
||||
})
|
||||
|
||||
it('should migrate advanced prompts back to chat mode and skip migration when app mode is missing', async () => {
|
||||
const { result } = renderAdvancedPromptHook({
|
||||
appMode: undefined,
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.migrateToDefaultPrompt()
|
||||
})
|
||||
|
||||
expect(mockFetchPromptTemplate).not.toHaveBeenCalled()
|
||||
|
||||
const advancedResult = renderAdvancedPromptHook({
|
||||
modelModeType: ModelModeType.completion,
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await advancedResult.result.current.migrateToDefaultPrompt(true, ModelModeType.chat)
|
||||
})
|
||||
|
||||
expect(advancedResult.result.current.chatPromptConfig.prompt[0].text).toBe('context prefill')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,171 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import DSLConfirmModal from '../dsl-confirm-modal'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
const mockHandleCheckPluginDependencies = vi.fn()
|
||||
const mockImportAppBundle = vi.fn()
|
||||
const mockGetRedirection = vi.fn()
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceEditor: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({
|
||||
usePluginDependencies: () => ({
|
||||
handleCheckPluginDependencies: mockHandleCheckPluginDependencies,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/apps', () => ({
|
||||
importAppBundle: (...args: unknown[]) => mockImportAppBundle(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/app-redirection', () => ({
|
||||
getRedirection: (...args: unknown[]) => mockGetRedirection(...args),
|
||||
}))
|
||||
|
||||
describe('DSLConfirmModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockHandleCheckPluginDependencies.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('should call onConfirm directly for non-zip files', () => {
|
||||
const onConfirm = vi.fn()
|
||||
|
||||
render(
|
||||
<DSLConfirmModal
|
||||
file={new File(['yaml'], 'demo.yml', { type: 'text/yaml' })}
|
||||
onCancel={vi.fn()}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Confirm' }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(mockImportAppBundle).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should import zip bundles and redirect on success', async () => {
|
||||
mockImportAppBundle.mockResolvedValue({
|
||||
status: 'completed',
|
||||
app_id: 'app-1',
|
||||
app_mode: 'chat',
|
||||
})
|
||||
const onCancel = vi.fn()
|
||||
const onSuccess = vi.fn()
|
||||
|
||||
render(
|
||||
<DSLConfirmModal
|
||||
file={new File(['zip'], 'demo.zip', { type: 'application/zip' })}
|
||||
onCancel={onCancel}
|
||||
onConfirm={vi.fn()}
|
||||
onSuccess={onSuccess}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Confirm' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockImportAppBundle).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('app-1')
|
||||
expect(mockGetRedirection).toHaveBeenCalledWith(true, { id: 'app-1', mode: 'chat' }, mockPush)
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1)
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should keep the confirm button disabled when requested', () => {
|
||||
render(
|
||||
<DSLConfirmModal
|
||||
confirmDisabled
|
||||
onCancel={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'app.newApp.Confirm' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should show warning and failure toasts for zip bundle edge cases', async () => {
|
||||
mockImportAppBundle.mockResolvedValueOnce({
|
||||
status: 'completed-with-warnings',
|
||||
app_id: 'app-2',
|
||||
app_mode: 'workflow',
|
||||
}).mockResolvedValueOnce({
|
||||
status: 'failed',
|
||||
})
|
||||
|
||||
const warningModal = render(
|
||||
<DSLConfirmModal
|
||||
file={new File(['zip'], 'warning.zip', { type: 'application/zip' })}
|
||||
onCancel={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Confirm' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.warning).toHaveBeenCalledWith('app.newApp.caution', { description: 'app.newApp.appCreateDSLWarning' })
|
||||
})
|
||||
|
||||
warningModal.unmount()
|
||||
|
||||
render(
|
||||
<DSLConfirmModal
|
||||
file={new File(['zip'], 'failed.zip', { type: 'application/zip' })}
|
||||
onCancel={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Confirm' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('app.importBundleFailed')
|
||||
})
|
||||
})
|
||||
|
||||
it('should surface bundle import errors and invoke cancel actions', async () => {
|
||||
const onCancel = vi.fn()
|
||||
mockImportAppBundle.mockRejectedValue(new Error('boom'))
|
||||
|
||||
render(
|
||||
<DSLConfirmModal
|
||||
file={new File(['zip'], 'broken.zip', { type: 'application/zip' })}
|
||||
onCancel={onCancel}
|
||||
onConfirm={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Cancel' }))
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Confirm' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('boom')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,141 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import CreateFromDSLModal, { CreateFromDSLModalTab } from '../index'
|
||||
|
||||
const mockUseCreateFromDSLModal = vi.fn()
|
||||
const mockDSLConfirmModal = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/base/ui/dialog', () => ({
|
||||
Dialog: ({
|
||||
children,
|
||||
onOpenChange,
|
||||
open,
|
||||
}: {
|
||||
children: ReactNode
|
||||
onOpenChange?: (open: boolean) => void
|
||||
open: boolean
|
||||
}) => (open
|
||||
? (
|
||||
<div>
|
||||
<button type="button" onClick={() => onOpenChange?.(false)}>close dialog</button>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
: null),
|
||||
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../use-create-from-dsl-modal', () => ({
|
||||
useCreateFromDSLModal: (params: unknown) => mockUseCreateFromDSLModal(params),
|
||||
}))
|
||||
|
||||
vi.mock('../dsl-confirm-modal', () => ({
|
||||
default: ({ onCancel }: { onCancel: () => void }) => {
|
||||
mockDSLConfirmModal(onCancel)
|
||||
return <button type="button" onClick={onCancel}>close confirm modal</button>
|
||||
},
|
||||
}))
|
||||
|
||||
const baseHookState = {
|
||||
buttonDisabled: false,
|
||||
currentFile: undefined,
|
||||
currentTab: CreateFromDSLModalTab.FROM_FILE,
|
||||
docHref: 'https://docs.example.com/app-management',
|
||||
dslUrlValue: '',
|
||||
handleConfirmSuccess: vi.fn(),
|
||||
handleCreate: vi.fn(),
|
||||
handleDSLConfirm: vi.fn(),
|
||||
handleFile: vi.fn(),
|
||||
isAppsFull: false,
|
||||
isCreating: false,
|
||||
isZipFile: vi.fn(() => false),
|
||||
learnMoreLabel: 'Learn more',
|
||||
setCurrentTab: vi.fn(),
|
||||
setDslUrlValue: vi.fn(),
|
||||
setShowErrorModal: vi.fn(),
|
||||
showErrorModal: false,
|
||||
tabs: [
|
||||
{ key: CreateFromDSLModalTab.FROM_FILE, label: 'Import from file' },
|
||||
{ key: CreateFromDSLModalTab.FROM_URL, label: 'Import from URL' },
|
||||
],
|
||||
versions: undefined,
|
||||
}
|
||||
|
||||
describe('CreateFromDSLModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseCreateFromDSLModal.mockReturnValue(baseHookState)
|
||||
})
|
||||
|
||||
it('should render the file tab content by default', () => {
|
||||
render(<CreateFromDSLModal show={true} onClose={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('Import from file')).toBeInTheDocument()
|
||||
expect(screen.getByText('Learn more')).toHaveAttribute('href', 'https://docs.example.com/app-management')
|
||||
})
|
||||
|
||||
it('should render the URL input when the hook reports the URL tab', () => {
|
||||
mockUseCreateFromDSLModal.mockReturnValue({
|
||||
...baseHookState,
|
||||
currentTab: CreateFromDSLModalTab.FROM_URL,
|
||||
dslUrlValue: 'https://example.com/app.yml',
|
||||
})
|
||||
|
||||
render(<CreateFromDSLModal show={true} onClose={vi.fn()} activeTab={CreateFromDSLModalTab.FROM_URL} />)
|
||||
|
||||
expect(screen.getByDisplayValue('https://example.com/app.yml')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should forward tab and input changes to the hook setters', () => {
|
||||
render(<CreateFromDSLModal show={true} onClose={vi.fn()} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Import from URL'))
|
||||
expect(baseHookState.setCurrentTab).toHaveBeenCalledWith(CreateFromDSLModalTab.FROM_URL)
|
||||
|
||||
mockUseCreateFromDSLModal.mockReturnValue({
|
||||
...baseHookState,
|
||||
currentTab: CreateFromDSLModalTab.FROM_URL,
|
||||
})
|
||||
|
||||
render(<CreateFromDSLModal show={true} onClose={vi.fn()} activeTab={CreateFromDSLModalTab.FROM_URL} />)
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'https://example.com/next.yml' } })
|
||||
expect(baseHookState.setDslUrlValue).toHaveBeenCalledWith('https://example.com/next.yml')
|
||||
})
|
||||
|
||||
it('should call the create handler when import is clicked', () => {
|
||||
render(<CreateFromDSLModal show={true} onClose={vi.fn()} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /app\.newApp\.import/i }))
|
||||
expect(baseHookState.handleCreate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should close when the dialog requests close and when the header close button is clicked', () => {
|
||||
const onClose = vi.fn()
|
||||
const { container, rerender } = render(<CreateFromDSLModal show={true} onClose={onClose} />)
|
||||
|
||||
fireEvent.click(screen.getByText('close dialog'))
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender(<CreateFromDSLModal show={true} onClose={onClose} />)
|
||||
fireEvent.click(container.querySelector('.cursor-pointer')!)
|
||||
expect(onClose).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should render the confirm modal and wire its cancel handler back to the hook state setter', () => {
|
||||
mockUseCreateFromDSLModal.mockReturnValue({
|
||||
...baseHookState,
|
||||
showErrorModal: true,
|
||||
currentFile: new File(['dsl'], 'demo.yml', { type: 'text/yaml' }),
|
||||
versions: {
|
||||
importedVersion: '0.9.0',
|
||||
systemVersion: '1.0.0',
|
||||
},
|
||||
})
|
||||
|
||||
render(<CreateFromDSLModal show={true} onClose={vi.fn()} />)
|
||||
|
||||
fireEvent.click(screen.getByText('close confirm modal'))
|
||||
expect(baseHookState.setShowErrorModal).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,98 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import Uploader from '../uploader'
|
||||
|
||||
const toastErrorSpy = vi.spyOn(toast, 'error').mockReturnValue('toast-error')
|
||||
|
||||
describe('Uploader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the empty state and trigger the file dialog', () => {
|
||||
render(<Uploader file={undefined} updateFile={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('app.dslUploader.button')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.dslUploader.browse')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the selected file and allow removal', () => {
|
||||
const updateFile = vi.fn()
|
||||
const file = new File(['yaml'], 'demo.yml', { type: 'text/yaml' })
|
||||
|
||||
render(<Uploader file={file} updateFile={updateFile} />)
|
||||
|
||||
expect(screen.getByText('demo.yml')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(updateFile).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should reject dropping more than one file', async () => {
|
||||
const updateFile = vi.fn()
|
||||
const firstFile = new File(['one'], 'one.yml', { type: 'text/yaml' })
|
||||
const secondFile = new File(['two'], 'two.yml', { type: 'text/yaml' })
|
||||
|
||||
const { container } = render(<Uploader file={undefined} updateFile={updateFile} />)
|
||||
|
||||
const dropZone = container.querySelector('input + div')
|
||||
expect(dropZone).not.toBeNull()
|
||||
|
||||
fireEvent.drop(dropZone as Element, {
|
||||
dataTransfer: {
|
||||
files: [firstFile, secondFile],
|
||||
},
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastErrorSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(updateFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should toggle drag state and accept a single dropped file', () => {
|
||||
const updateFile = vi.fn()
|
||||
const droppedFile = new File(['content'], 'single.yml', { type: 'text/yaml' })
|
||||
const { container } = render(<Uploader file={undefined} updateFile={updateFile} />)
|
||||
|
||||
const dropZone = container.querySelector('input + div') as Element
|
||||
|
||||
fireEvent.dragEnter(dropZone)
|
||||
expect(dropZone.firstElementChild).toHaveClass('border-components-dropzone-border-accent')
|
||||
|
||||
const dragMask = dropZone.querySelector('.absolute') as Element
|
||||
fireEvent.dragLeave(dragMask)
|
||||
expect(dropZone.firstElementChild).not.toHaveClass('border-components-dropzone-border-accent')
|
||||
|
||||
fireEvent.drop(dropZone, {
|
||||
dataTransfer: {
|
||||
files: [droppedFile],
|
||||
},
|
||||
})
|
||||
|
||||
expect(updateFile).toHaveBeenCalledWith(droppedFile)
|
||||
})
|
||||
|
||||
it('should restore the selection flow on cancel and update the file from the hidden input', () => {
|
||||
const updateFile = vi.fn()
|
||||
const nextFile = new File(['yaml'], 'next.yml', { type: 'text/yaml' })
|
||||
const { container } = render(<Uploader file={undefined} updateFile={updateFile} />)
|
||||
|
||||
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
|
||||
const clickSpy = vi.spyOn(fileInput, 'click').mockImplementation(() => {})
|
||||
|
||||
fireEvent.click(screen.getByText('app.dslUploader.browse'))
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
fileInput.oncancel?.(new Event('cancel'))
|
||||
expect(updateFile).toHaveBeenCalledWith(undefined)
|
||||
|
||||
fireEvent.change(fileInput, {
|
||||
target: {
|
||||
files: [nextFile],
|
||||
},
|
||||
})
|
||||
|
||||
expect(updateFile).toHaveBeenCalledWith(nextFile)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,477 @@
|
||||
import type { DocPathWithoutLang } from '@/types/doc-paths'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { CreateFromDSLModalTab } from '../index'
|
||||
import { useCreateFromDSLModal } from '../use-create-from-dsl-modal'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
const mockTrackEvent = vi.fn()
|
||||
const mockHandleCheckPluginDependencies = vi.fn()
|
||||
const mockImportAppBundle = vi.fn()
|
||||
const mockImportDSL = vi.fn()
|
||||
const mockImportDSLConfirm = vi.fn()
|
||||
const mockGetRedirection = vi.fn()
|
||||
const mockUseKeyPress = vi.fn()
|
||||
let mockProviderContext = {
|
||||
enableBilling: true,
|
||||
plan: {
|
||||
usage: { buildApps: 0 },
|
||||
total: { buildApps: 5 },
|
||||
},
|
||||
}
|
||||
|
||||
const capturedKeyCallbacks = new Map<string | string[], () => void>()
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useKeyPress: (key: string | string[], callback: () => void) => {
|
||||
capturedKeyCallbacks.set(Array.isArray(key) ? key.join('|') : key, callback)
|
||||
mockUseKeyPress(key, callback)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({
|
||||
usePluginDependencies: () => ({
|
||||
handleCheckPluginDependencies: mockHandleCheckPluginDependencies,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceEditor: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockProviderContext,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/apps', () => ({
|
||||
importAppBundle: (...args: unknown[]) => mockImportAppBundle(...args),
|
||||
importDSL: (...args: unknown[]) => mockImportDSL(...args),
|
||||
importDSLConfirm: (...args: unknown[]) => mockImportDSLConfirm(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: Object.assign(vi.fn(), {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/app-redirection', () => ({
|
||||
getRedirection: (...args: unknown[]) => mockGetRedirection(...args),
|
||||
}))
|
||||
|
||||
class MockFileReader {
|
||||
onload: ((event: { target?: { result?: string } }) => void) | null = null
|
||||
|
||||
readAsText(file: File) {
|
||||
this.onload?.({ target: { result: `content:${file.name}` } })
|
||||
}
|
||||
}
|
||||
|
||||
const appManagementLocalizedPathMap: Record<string, DocPathWithoutLang> = {
|
||||
'zh-Hans': '/use-dify/workspace/app-management',
|
||||
}
|
||||
|
||||
describe('useCreateFromDSLModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
capturedKeyCallbacks.clear()
|
||||
vi.stubGlobal('FileReader', MockFileReader)
|
||||
mockHandleCheckPluginDependencies.mockResolvedValue(undefined)
|
||||
mockProviderContext = {
|
||||
enableBilling: true,
|
||||
plan: {
|
||||
usage: { buildApps: 0 },
|
||||
total: { buildApps: 5 },
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
it('should initialize with file tab defaults and a docs url', () => {
|
||||
const { result } = renderHook(() => useCreateFromDSLModal({
|
||||
show: true,
|
||||
onClose: vi.fn(),
|
||||
activeTab: CreateFromDSLModalTab.FROM_FILE,
|
||||
dslUrl: '',
|
||||
appManagementLocalizedPathMap,
|
||||
}))
|
||||
|
||||
expect(result.current.currentTab).toBe(CreateFromDSLModalTab.FROM_FILE)
|
||||
expect(result.current.buttonDisabled).toBe(true)
|
||||
expect(result.current.docHref).toContain('/use-dify/workspace/app-management#app-export-and-import')
|
||||
})
|
||||
|
||||
it('should read non-zip files and enable creation', async () => {
|
||||
const { result } = renderHook(() => useCreateFromDSLModal({
|
||||
show: true,
|
||||
onClose: vi.fn(),
|
||||
activeTab: CreateFromDSLModalTab.FROM_FILE,
|
||||
dslUrl: '',
|
||||
appManagementLocalizedPathMap,
|
||||
}))
|
||||
const file = new File(['yaml'], 'demo.yml', { type: 'text/yaml' })
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleFile(file)
|
||||
})
|
||||
|
||||
expect(result.current.currentFile).toBe(file)
|
||||
expect(result.current.buttonDisabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should initialize from a dropped file and clear cached content for zip uploads', async () => {
|
||||
const droppedFile = new File(['yaml'], 'seed.yml', { type: 'text/yaml' })
|
||||
const { result } = renderHook(() => useCreateFromDSLModal({
|
||||
show: true,
|
||||
onClose: vi.fn(),
|
||||
activeTab: CreateFromDSLModalTab.FROM_FILE,
|
||||
dslUrl: '',
|
||||
droppedFile,
|
||||
appManagementLocalizedPathMap,
|
||||
}))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.currentFile).toBe(droppedFile)
|
||||
})
|
||||
|
||||
const zipFile = new File(['zip'], 'bundle.zip', { type: 'application/zip' })
|
||||
mockImportAppBundle.mockResolvedValue({
|
||||
id: 'import-zip',
|
||||
status: 'completed',
|
||||
app_id: 'app-zip',
|
||||
app_mode: 'chat',
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleFile(zipFile)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.currentFile).toBe(zipFile)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCreate()
|
||||
})
|
||||
|
||||
expect(mockImportAppBundle).toHaveBeenCalledWith({ file: zipFile })
|
||||
})
|
||||
|
||||
it('should import from URL and complete the success flow', async () => {
|
||||
mockImportDSL.mockResolvedValue({
|
||||
id: 'import-1',
|
||||
status: 'completed',
|
||||
app_id: 'app-1',
|
||||
app_mode: 'chat',
|
||||
})
|
||||
const onSuccess = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
|
||||
const { result } = renderHook(() => useCreateFromDSLModal({
|
||||
show: true,
|
||||
onSuccess,
|
||||
onClose,
|
||||
activeTab: CreateFromDSLModalTab.FROM_URL,
|
||||
dslUrl: 'https://example.com/app.yml',
|
||||
appManagementLocalizedPathMap,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCreate()
|
||||
})
|
||||
|
||||
expect(mockImportDSL).toHaveBeenCalledWith({
|
||||
mode: 'yaml-url',
|
||||
yaml_url: 'https://example.com/app.yml',
|
||||
})
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('create_app_with_dsl', expect.objectContaining({
|
||||
creation_method: 'dsl_url',
|
||||
has_warnings: false,
|
||||
}))
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('app-1')
|
||||
expect(mockGetRedirection).toHaveBeenCalledWith(true, { id: 'app-1', mode: 'chat' }, mockPush)
|
||||
})
|
||||
|
||||
it('should open the confirm modal when the DSL import is pending', async () => {
|
||||
mockImportDSL.mockResolvedValue({
|
||||
id: 'import-2',
|
||||
status: 'pending',
|
||||
imported_dsl_version: '0.9.0',
|
||||
current_dsl_version: '1.0.0',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useCreateFromDSLModal({
|
||||
show: true,
|
||||
onClose: vi.fn(),
|
||||
activeTab: CreateFromDSLModalTab.FROM_URL,
|
||||
dslUrl: 'https://example.com/app.yml',
|
||||
appManagementLocalizedPathMap,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCreate()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.showErrorModal).toBe(true)
|
||||
})
|
||||
expect(result.current.versions).toEqual({
|
||||
importedVersion: '0.9.0',
|
||||
systemVersion: '1.0.0',
|
||||
})
|
||||
})
|
||||
|
||||
it('should return early when required input is missing or the import returns no response', async () => {
|
||||
const missingFile = renderHook(() => useCreateFromDSLModal({
|
||||
show: true,
|
||||
onClose: vi.fn(),
|
||||
activeTab: CreateFromDSLModalTab.FROM_FILE,
|
||||
dslUrl: '',
|
||||
appManagementLocalizedPathMap,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await missingFile.result.current.handleCreate()
|
||||
})
|
||||
|
||||
expect(mockImportDSL).not.toHaveBeenCalled()
|
||||
expect(mockImportAppBundle).not.toHaveBeenCalled()
|
||||
|
||||
const missingUrl = renderHook(() => useCreateFromDSLModal({
|
||||
show: true,
|
||||
onClose: vi.fn(),
|
||||
activeTab: CreateFromDSLModalTab.FROM_URL,
|
||||
dslUrl: '',
|
||||
appManagementLocalizedPathMap,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await missingUrl.result.current.handleCreate()
|
||||
})
|
||||
|
||||
expect(mockImportDSL).not.toHaveBeenCalled()
|
||||
|
||||
mockImportDSL.mockResolvedValue(undefined)
|
||||
const noResponse = renderHook(() => useCreateFromDSLModal({
|
||||
show: true,
|
||||
onClose: vi.fn(),
|
||||
activeTab: CreateFromDSLModalTab.FROM_URL,
|
||||
dslUrl: 'https://example.com/app.yml',
|
||||
appManagementLocalizedPathMap,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await noResponse.result.current.handleCreate()
|
||||
})
|
||||
|
||||
expect(mockImportDSL).toHaveBeenCalledWith({
|
||||
mode: 'yaml-url',
|
||||
yaml_url: 'https://example.com/app.yml',
|
||||
})
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should confirm a pending import through importDSLConfirm', async () => {
|
||||
mockImportDSLConfirm.mockResolvedValue({
|
||||
status: 'completed',
|
||||
app_id: 'app-2',
|
||||
app_mode: 'workflow',
|
||||
})
|
||||
const onSuccess = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
|
||||
const { result } = renderHook(() => useCreateFromDSLModal({
|
||||
show: true,
|
||||
onSuccess,
|
||||
onClose,
|
||||
activeTab: CreateFromDSLModalTab.FROM_URL,
|
||||
dslUrl: 'https://example.com/app.yml',
|
||||
appManagementLocalizedPathMap,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
result.current.setShowErrorModal(true)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
// Seed the pending import id by taking the pending path first.
|
||||
mockImportDSL.mockResolvedValueOnce({
|
||||
id: 'import-3',
|
||||
status: 'pending',
|
||||
imported_dsl_version: '0.9.0',
|
||||
current_dsl_version: '1.0.0',
|
||||
})
|
||||
await result.current.handleCreate()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDSLConfirm()
|
||||
})
|
||||
|
||||
expect(mockImportDSLConfirm).toHaveBeenCalledWith({
|
||||
import_id: 'import-3',
|
||||
})
|
||||
expect(onSuccess).toHaveBeenCalled()
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should guard duplicate submissions and surface failed confirmation flows', async () => {
|
||||
let resolveImport: ((value: { id: string, status: string }) => void) | undefined
|
||||
mockImportDSL.mockReturnValue(new Promise((resolve) => {
|
||||
resolveImport = resolve
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useCreateFromDSLModal({
|
||||
show: true,
|
||||
onClose: vi.fn(),
|
||||
activeTab: CreateFromDSLModalTab.FROM_URL,
|
||||
dslUrl: 'https://example.com/app.yml',
|
||||
appManagementLocalizedPathMap,
|
||||
}))
|
||||
|
||||
const firstCreate = act(async () => {
|
||||
await result.current.handleCreate()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCreate()
|
||||
})
|
||||
|
||||
expect(mockImportDSL).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveImport?.({
|
||||
id: 'import-guard',
|
||||
status: 'failed',
|
||||
})
|
||||
await firstCreate
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('app.newApp.appCreateFailed')
|
||||
expect(result.current.isCreating).toBe(false)
|
||||
|
||||
mockImportDSL.mockResolvedValueOnce({
|
||||
id: 'import-confirm',
|
||||
status: 'pending',
|
||||
imported_dsl_version: '0.9.0',
|
||||
current_dsl_version: '1.0.0',
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCreate()
|
||||
})
|
||||
|
||||
mockImportDSLConfirm.mockResolvedValueOnce({
|
||||
status: 'failed',
|
||||
}).mockRejectedValueOnce(new Error('broken'))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDSLConfirm()
|
||||
})
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('app.newApp.appCreateFailed')
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDSLConfirm()
|
||||
})
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('app.newApp.appCreateFailed')
|
||||
})
|
||||
|
||||
it('should trigger create on the keyboard shortcut and close on escape', async () => {
|
||||
mockImportDSL.mockResolvedValue({
|
||||
id: 'import-4',
|
||||
status: 'failed',
|
||||
})
|
||||
const onClose = vi.fn()
|
||||
|
||||
renderHook(() => useCreateFromDSLModal({
|
||||
show: true,
|
||||
onClose,
|
||||
activeTab: CreateFromDSLModalTab.FROM_URL,
|
||||
dslUrl: 'https://example.com/app.yml',
|
||||
appManagementLocalizedPathMap,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
capturedKeyCallbacks.get('meta.enter|ctrl.enter')?.()
|
||||
})
|
||||
|
||||
expect(mockImportDSL).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => {
|
||||
capturedKeyCallbacks.get('esc')?.()
|
||||
})
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should skip keyboard creation when hidden or when the workspace is out of app quota', async () => {
|
||||
mockProviderContext = {
|
||||
enableBilling: true,
|
||||
plan: {
|
||||
usage: { buildApps: 5 },
|
||||
total: { buildApps: 5 },
|
||||
},
|
||||
}
|
||||
|
||||
renderHook(() => useCreateFromDSLModal({
|
||||
show: false,
|
||||
onClose: vi.fn(),
|
||||
activeTab: CreateFromDSLModalTab.FROM_URL,
|
||||
dslUrl: 'https://example.com/app.yml',
|
||||
appManagementLocalizedPathMap,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
capturedKeyCallbacks.get('meta.enter|ctrl.enter')?.()
|
||||
})
|
||||
|
||||
expect(mockImportDSL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should expose manual confirm success and ignore confirm requests without an import id', async () => {
|
||||
const onSuccess = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
const { result } = renderHook(() => useCreateFromDSLModal({
|
||||
show: true,
|
||||
onSuccess,
|
||||
onClose,
|
||||
activeTab: CreateFromDSLModalTab.FROM_URL,
|
||||
dslUrl: 'https://example.com/app.yml',
|
||||
appManagementLocalizedPathMap,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDSLConfirm()
|
||||
})
|
||||
|
||||
expect(mockImportDSLConfirm).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
result.current.handleConfirmSuccess()
|
||||
})
|
||||
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -1,41 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import type { DocPathWithoutLang } from '@/types/doc-paths'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { Dialog, DialogContent } from '@/app/components/base/ui/dialog'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
|
||||
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import {
|
||||
DSLImportMode,
|
||||
DSLImportStatus,
|
||||
} from '@/models/app'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import {
|
||||
importAppBundle,
|
||||
importDSL,
|
||||
importDSLConfirm,
|
||||
} from '@/service/apps'
|
||||
import { getRedirection } from '@/utils/app-redirection'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import ShortcutsName from '../../workflow/shortcuts-name'
|
||||
import DSLConfirmModal from './dsl-confirm-modal'
|
||||
import Uploader from './uploader'
|
||||
import { useCreateFromDSLModal } from './use-create-from-dsl-modal'
|
||||
|
||||
type CreateFromDSLModalProps = {
|
||||
show: boolean
|
||||
onSuccess?: () => void
|
||||
onClose: () => void
|
||||
activeTab?: string
|
||||
activeTab?: CreateFromDSLModalTab
|
||||
dslUrl?: string
|
||||
droppedFile?: File
|
||||
}
|
||||
@ -50,207 +31,38 @@ const appManagementLocalizedPathMap = {
|
||||
'zh_Hans': '/use-dify/workspace/app-management#应用导出和导入' as DocPathWithoutLang,
|
||||
'ja-JP': '/use-dify/workspace/app-management#アプリのエクスポートとインポート' as DocPathWithoutLang,
|
||||
'ja_JP': '/use-dify/workspace/app-management#アプリのエクスポートとインポート' as DocPathWithoutLang,
|
||||
}
|
||||
} satisfies Record<string, DocPathWithoutLang>
|
||||
|
||||
const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDSLModalTab.FROM_FILE, dslUrl = '', droppedFile }: CreateFromDSLModalProps) => {
|
||||
const { push } = useRouter()
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
const [currentFile, setDSLFile] = useState<File | undefined>(droppedFile)
|
||||
const [fileContent, setFileContent] = useState<string>()
|
||||
const [currentTab, setCurrentTab] = useState(activeTab)
|
||||
const [dslUrlValue, setDslUrlValue] = useState(dslUrl)
|
||||
const [showErrorModal, setShowErrorModal] = useState(false)
|
||||
const [versions, setVersions] = useState<{ importedVersion: string, systemVersion: string }>()
|
||||
const [importId, setImportId] = useState<string>()
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const { handleCheckPluginDependencies } = usePluginDependencies()
|
||||
|
||||
const isZipFile = (file?: File) => !!file && file.name.toLowerCase().endsWith('.zip')
|
||||
|
||||
const readFile = (file: File) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = function (event) {
|
||||
const content = event.target?.result
|
||||
setFileContent(content as string)
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
const handleFile = (file?: File) => {
|
||||
setDSLFile(file)
|
||||
if (file && !isZipFile(file))
|
||||
readFile(file)
|
||||
if (!file || isZipFile(file))
|
||||
setFileContent('')
|
||||
}
|
||||
|
||||
const { isCurrentWorkspaceEditor } = useAppContext()
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const isAppsFull = (enableBilling && plan.usage.buildApps >= plan.total.buildApps)
|
||||
|
||||
const isCreatingRef = useRef(false)
|
||||
const isMountedRef = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (droppedFile)
|
||||
handleFile(droppedFile)
|
||||
}, [droppedFile])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onCreate = async (_e?: React.MouseEvent) => {
|
||||
if (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile)
|
||||
return
|
||||
if (currentTab === CreateFromDSLModalTab.FROM_URL && !dslUrlValue)
|
||||
return
|
||||
if (isCreatingRef.current)
|
||||
return
|
||||
isCreatingRef.current = true
|
||||
setIsCreating(true)
|
||||
try {
|
||||
let response
|
||||
|
||||
if (currentTab === CreateFromDSLModalTab.FROM_FILE) {
|
||||
if (isZipFile(currentFile)) {
|
||||
response = await importAppBundle({ file: currentFile! })
|
||||
}
|
||||
else {
|
||||
response = await importDSL({
|
||||
mode: DSLImportMode.YAML_CONTENT,
|
||||
yaml_content: fileContent || '',
|
||||
})
|
||||
}
|
||||
}
|
||||
if (currentTab === CreateFromDSLModalTab.FROM_URL) {
|
||||
response = await importDSL({
|
||||
mode: DSLImportMode.YAML_URL,
|
||||
yaml_url: dslUrlValue || '',
|
||||
})
|
||||
}
|
||||
|
||||
if (!response)
|
||||
return
|
||||
const { id, status, app_id, app_mode, imported_dsl_version, current_dsl_version } = response
|
||||
if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) {
|
||||
// Track app creation from DSL import
|
||||
trackEvent('create_app_with_dsl', {
|
||||
app_mode,
|
||||
creation_method: currentTab === CreateFromDSLModalTab.FROM_FILE ? 'dsl_file' : 'dsl_url',
|
||||
has_warnings: status === DSLImportStatus.COMPLETED_WITH_WARNINGS,
|
||||
})
|
||||
|
||||
if (onSuccess)
|
||||
onSuccess()
|
||||
if (onClose)
|
||||
onClose()
|
||||
|
||||
toast(t(status === DSLImportStatus.COMPLETED ? 'newApp.appCreated' : 'newApp.caution', { ns: 'app' }), { type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning', description: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('newApp.appCreateDSLWarning', { ns: 'app' }) })
|
||||
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
|
||||
if (app_id)
|
||||
await handleCheckPluginDependencies(app_id)
|
||||
getRedirection(isCurrentWorkspaceEditor, { id: app_id!, mode: app_mode }, push)
|
||||
}
|
||||
else if (status === DSLImportStatus.PENDING) {
|
||||
setVersions({
|
||||
importedVersion: imported_dsl_version ?? '',
|
||||
systemVersion: current_dsl_version ?? '',
|
||||
})
|
||||
setTimeout(() => {
|
||||
setShowErrorModal(true)
|
||||
}, 300)
|
||||
setImportId(id)
|
||||
}
|
||||
else {
|
||||
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
catch (e) {
|
||||
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
|
||||
}
|
||||
finally {
|
||||
isCreatingRef.current = false
|
||||
if (isMountedRef.current)
|
||||
setIsCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
useKeyPress(['meta.enter', 'ctrl.enter'], () => {
|
||||
if (show && !isAppsFull && ((currentTab === CreateFromDSLModalTab.FROM_FILE && currentFile) || (currentTab === CreateFromDSLModalTab.FROM_URL && dslUrlValue)))
|
||||
onCreate(undefined)
|
||||
})
|
||||
|
||||
useKeyPress('esc', () => {
|
||||
if (show && !showErrorModal)
|
||||
onClose()
|
||||
})
|
||||
|
||||
const onDSLConfirm = async () => {
|
||||
try {
|
||||
if (!importId)
|
||||
return
|
||||
const response = await importDSLConfirm({
|
||||
import_id: importId,
|
||||
})
|
||||
|
||||
const { status, app_id, app_mode } = response
|
||||
|
||||
if (status === DSLImportStatus.COMPLETED) {
|
||||
if (onSuccess)
|
||||
onSuccess()
|
||||
if (onClose)
|
||||
onClose()
|
||||
|
||||
toast.success(t('newApp.appCreated', { ns: 'app' }))
|
||||
if (app_id)
|
||||
await handleCheckPluginDependencies(app_id)
|
||||
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
|
||||
getRedirection(isCurrentWorkspaceEditor, { id: app_id!, mode: app_mode }, push)
|
||||
}
|
||||
else if (status === DSLImportStatus.FAILED) {
|
||||
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
catch (e) {
|
||||
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirmSuccess = () => {
|
||||
if (onSuccess)
|
||||
onSuccess()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
key: CreateFromDSLModalTab.FROM_FILE,
|
||||
label: t('importFromDSLFile', { ns: 'app' }),
|
||||
},
|
||||
{
|
||||
key: CreateFromDSLModalTab.FROM_URL,
|
||||
label: t('importFromDSLUrl', { ns: 'app' }),
|
||||
},
|
||||
]
|
||||
|
||||
const buttonDisabled = useMemo(() => {
|
||||
if (isAppsFull)
|
||||
return true
|
||||
if (currentTab === CreateFromDSLModalTab.FROM_FILE)
|
||||
return !currentFile
|
||||
if (currentTab === CreateFromDSLModalTab.FROM_URL)
|
||||
return !dslUrlValue
|
||||
return false
|
||||
}, [isAppsFull, currentTab, currentFile, dslUrlValue])
|
||||
const learnMoreLabel = t('importFromDSLModal.learnMore', {
|
||||
ns: 'app',
|
||||
defaultValue: t('newApp.learnMore', { ns: 'app' }),
|
||||
const {
|
||||
buttonDisabled,
|
||||
currentFile,
|
||||
currentTab,
|
||||
docHref,
|
||||
dslUrlValue,
|
||||
handleConfirmSuccess,
|
||||
handleCreate,
|
||||
handleDSLConfirm,
|
||||
handleFile,
|
||||
isAppsFull,
|
||||
isCreating,
|
||||
isZipFile,
|
||||
learnMoreLabel,
|
||||
setCurrentTab,
|
||||
setDslUrlValue,
|
||||
setShowErrorModal,
|
||||
showErrorModal,
|
||||
tabs,
|
||||
versions,
|
||||
} = useCreateFromDSLModal({
|
||||
show,
|
||||
onSuccess,
|
||||
onClose,
|
||||
activeTab,
|
||||
dslUrl,
|
||||
droppedFile,
|
||||
appManagementLocalizedPathMap,
|
||||
})
|
||||
|
||||
return (
|
||||
@ -322,7 +134,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
|
||||
<div className="flex items-center justify-between px-6 pb-6 pt-5">
|
||||
<a
|
||||
className="flex items-center gap-1 text-text-accent system-xs-regular"
|
||||
href={docLink('/use-dify/workspace/app-management#app-export-and-import', appManagementLocalizedPathMap)}
|
||||
href={docHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
@ -336,7 +148,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
|
||||
<Button
|
||||
disabled={buttonDisabled || isCreating}
|
||||
variant="primary"
|
||||
onClick={onCreate}
|
||||
onClick={handleCreate}
|
||||
className="gap-1"
|
||||
loading={isCreating}
|
||||
>
|
||||
@ -352,7 +164,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
|
||||
file={currentFile}
|
||||
versions={versions}
|
||||
onCancel={() => setShowErrorModal(false)}
|
||||
onConfirm={onDSLConfirm}
|
||||
onConfirm={handleDSLConfirm}
|
||||
onSuccess={handleConfirmSuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -0,0 +1,269 @@
|
||||
'use client'
|
||||
|
||||
import type { CreateFromDSLModalTab } from './index'
|
||||
import type { AppModeEnum } from '@/types/app'
|
||||
import type { DocPathWithoutLang } from '@/types/doc-paths'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import {
|
||||
DSLImportMode,
|
||||
DSLImportStatus,
|
||||
} from '@/models/app'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import {
|
||||
importAppBundle,
|
||||
importDSL,
|
||||
importDSLConfirm,
|
||||
} from '@/service/apps'
|
||||
import { getRedirection } from '@/utils/app-redirection'
|
||||
|
||||
type CreateFromDSLModalStateParams = {
|
||||
show: boolean
|
||||
onSuccess?: () => void
|
||||
onClose: () => void
|
||||
activeTab: CreateFromDSLModalTab
|
||||
dslUrl: string
|
||||
droppedFile?: File
|
||||
appManagementLocalizedPathMap: Record<string, DocPathWithoutLang>
|
||||
}
|
||||
|
||||
export const useCreateFromDSLModal = ({
|
||||
show,
|
||||
onSuccess,
|
||||
onClose,
|
||||
activeTab,
|
||||
dslUrl,
|
||||
droppedFile,
|
||||
appManagementLocalizedPathMap,
|
||||
}: CreateFromDSLModalStateParams) => {
|
||||
const { push } = useRouter()
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
const { handleCheckPluginDependencies } = usePluginDependencies()
|
||||
const { isCurrentWorkspaceEditor } = useAppContext()
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
|
||||
const [currentFile, setCurrentFile] = useState<File | undefined>(() => droppedFile)
|
||||
const [currentTab, setCurrentTab] = useState(activeTab)
|
||||
const [dslUrlValue, setDslUrlValue] = useState(dslUrl)
|
||||
const [showErrorModal, setShowErrorModal] = useState(false)
|
||||
const [versions, setVersions] = useState<{ importedVersion: string, systemVersion: string }>()
|
||||
const [importId, setImportId] = useState<string>()
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
|
||||
const isCreatingRef = useRef(false)
|
||||
const isMountedRef = useRef(true)
|
||||
|
||||
const isZipFile = useCallback((file?: File) => !!file && file.name.toLowerCase().endsWith('.zip'), [])
|
||||
|
||||
const readFileContent = useCallback((file: File) => {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
resolve(String(event.target?.result || ''))
|
||||
}
|
||||
reader.onerror = () => {
|
||||
reject(new Error(`Unable to read ${file.name}`))
|
||||
}
|
||||
reader.readAsText(file)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleFile = useCallback((file?: File) => {
|
||||
setCurrentFile(file)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const isAppsFull = enableBilling && plan.usage.buildApps >= plan.total.buildApps
|
||||
|
||||
const handleImportSuccess = useCallback(async (status: DSLImportStatus, appId?: string, appMode?: AppModeEnum) => {
|
||||
if (onSuccess)
|
||||
onSuccess()
|
||||
onClose()
|
||||
|
||||
toast(
|
||||
t(status === DSLImportStatus.COMPLETED ? 'newApp.appCreated' : 'newApp.caution', { ns: 'app' }),
|
||||
{
|
||||
type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning',
|
||||
description: status === DSLImportStatus.COMPLETED_WITH_WARNINGS
|
||||
? t('newApp.appCreateDSLWarning', { ns: 'app' })
|
||||
: undefined,
|
||||
},
|
||||
)
|
||||
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
|
||||
if (appId)
|
||||
await handleCheckPluginDependencies(appId)
|
||||
if (appId && appMode)
|
||||
getRedirection(isCurrentWorkspaceEditor, { id: appId, mode: appMode }, push)
|
||||
}, [handleCheckPluginDependencies, isCurrentWorkspaceEditor, onClose, onSuccess, push, t])
|
||||
|
||||
const handlePendingImport = useCallback((id?: string, importedVersion?: string | null, currentVersion?: string | null) => {
|
||||
setVersions({
|
||||
importedVersion: importedVersion ?? '',
|
||||
systemVersion: currentVersion ?? '',
|
||||
})
|
||||
setTimeout(() => {
|
||||
setShowErrorModal(true)
|
||||
}, 300)
|
||||
setImportId(id)
|
||||
}, [])
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (currentTab === 'from-file' && !currentFile)
|
||||
return
|
||||
if (currentTab === 'from-url' && !dslUrlValue)
|
||||
return
|
||||
if (isCreatingRef.current)
|
||||
return
|
||||
|
||||
isCreatingRef.current = true
|
||||
setIsCreating(true)
|
||||
|
||||
try {
|
||||
const response = currentTab === 'from-file'
|
||||
? (isZipFile(currentFile)
|
||||
? await importAppBundle({ file: currentFile! })
|
||||
: await importDSL({
|
||||
mode: DSLImportMode.YAML_CONTENT,
|
||||
yaml_content: await readFileContent(currentFile!),
|
||||
}))
|
||||
: await importDSL({
|
||||
mode: DSLImportMode.YAML_URL,
|
||||
yaml_url: dslUrlValue || '',
|
||||
})
|
||||
|
||||
if (!response)
|
||||
return
|
||||
|
||||
const { id, status, app_id, app_mode, imported_dsl_version, current_dsl_version } = response
|
||||
if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) {
|
||||
trackEvent('create_app_with_dsl', {
|
||||
app_mode,
|
||||
creation_method: currentTab === 'from-file' ? 'dsl_file' : 'dsl_url',
|
||||
has_warnings: status === DSLImportStatus.COMPLETED_WITH_WARNINGS,
|
||||
})
|
||||
await handleImportSuccess(status, app_id, app_mode as AppModeEnum | undefined)
|
||||
return
|
||||
}
|
||||
|
||||
if (status === DSLImportStatus.PENDING) {
|
||||
handlePendingImport(id, imported_dsl_version, current_dsl_version)
|
||||
return
|
||||
}
|
||||
|
||||
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
|
||||
}
|
||||
catch {
|
||||
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
|
||||
}
|
||||
finally {
|
||||
isCreatingRef.current = false
|
||||
if (isMountedRef.current)
|
||||
setIsCreating(false)
|
||||
}
|
||||
}, [currentFile, currentTab, dslUrlValue, handleImportSuccess, handlePendingImport, isZipFile, readFileContent, t])
|
||||
|
||||
useKeyPress(['meta.enter', 'ctrl.enter'], () => {
|
||||
if (!show || isAppsFull)
|
||||
return
|
||||
|
||||
const canCreate = (currentTab === 'from-file' && currentFile) || (currentTab === 'from-url' && dslUrlValue)
|
||||
if (canCreate)
|
||||
handleCreate()
|
||||
})
|
||||
|
||||
useKeyPress('esc', () => {
|
||||
if (show && !showErrorModal)
|
||||
onClose()
|
||||
})
|
||||
|
||||
const handleDSLConfirm = useCallback(async () => {
|
||||
try {
|
||||
if (!importId)
|
||||
return
|
||||
|
||||
const response = await importDSLConfirm({
|
||||
import_id: importId,
|
||||
})
|
||||
|
||||
const { status, app_id, app_mode } = response
|
||||
if (status === DSLImportStatus.COMPLETED) {
|
||||
await handleImportSuccess(status, app_id, app_mode as AppModeEnum | undefined)
|
||||
return
|
||||
}
|
||||
|
||||
if (status === DSLImportStatus.FAILED)
|
||||
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
|
||||
}
|
||||
catch {
|
||||
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
|
||||
}
|
||||
}, [handleImportSuccess, importId, t])
|
||||
|
||||
const handleConfirmSuccess = useCallback(() => {
|
||||
if (onSuccess)
|
||||
onSuccess()
|
||||
onClose()
|
||||
}, [onClose, onSuccess])
|
||||
|
||||
const tabs = useMemo<Array<{ key: CreateFromDSLModalTab, label: string }>>(() => ([
|
||||
{
|
||||
key: 'from-file' as CreateFromDSLModalTab,
|
||||
label: t('importFromDSLFile', { ns: 'app' }),
|
||||
},
|
||||
{
|
||||
key: 'from-url' as CreateFromDSLModalTab,
|
||||
label: t('importFromDSLUrl', { ns: 'app' }),
|
||||
},
|
||||
]), [t])
|
||||
|
||||
const buttonDisabled = useMemo(() => {
|
||||
if (isAppsFull)
|
||||
return true
|
||||
if (currentTab === 'from-file')
|
||||
return !currentFile
|
||||
if (currentTab === 'from-url')
|
||||
return !dslUrlValue
|
||||
return false
|
||||
}, [currentFile, currentTab, dslUrlValue, isAppsFull])
|
||||
|
||||
const learnMoreLabel = t('importFromDSLModal.learnMore', {
|
||||
ns: 'app',
|
||||
defaultValue: t('newApp.learnMore', { ns: 'app' }),
|
||||
})
|
||||
|
||||
return {
|
||||
buttonDisabled,
|
||||
currentFile,
|
||||
currentTab,
|
||||
docHref: docLink('/use-dify/workspace/app-management#app-export-and-import', appManagementLocalizedPathMap),
|
||||
dslUrlValue,
|
||||
handleConfirmSuccess,
|
||||
handleCreate,
|
||||
handleDSLConfirm,
|
||||
handleFile,
|
||||
isAppsFull,
|
||||
isCreating,
|
||||
isZipFile,
|
||||
learnMoreLabel,
|
||||
setCurrentTab,
|
||||
setDslUrlValue,
|
||||
setShowErrorModal,
|
||||
showErrorModal,
|
||||
tabs,
|
||||
versions,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,186 @@
|
||||
import type { SandboxProvider } from '@/types/sandbox-provider'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import {
|
||||
useActivateSandboxProvider,
|
||||
useDeleteSandboxProviderConfig,
|
||||
useSaveSandboxProviderConfig,
|
||||
} from '@/service/use-sandbox-provider'
|
||||
import ConfigModal from '../config-modal'
|
||||
|
||||
const mockUseSaveSandboxProviderConfig = vi.mocked(useSaveSandboxProviderConfig)
|
||||
const mockUseDeleteSandboxProviderConfig = vi.mocked(useDeleteSandboxProviderConfig)
|
||||
const mockUseActivateSandboxProvider = vi.mocked(useActivateSandboxProvider)
|
||||
|
||||
let mockFormValues = {
|
||||
isCheckValidated: true,
|
||||
values: {
|
||||
api_key: 'secret-key',
|
||||
},
|
||||
}
|
||||
|
||||
vi.mock('@/service/use-sandbox-provider', () => ({
|
||||
useSaveSandboxProviderConfig: vi.fn(),
|
||||
useDeleteSandboxProviderConfig: vi.fn(),
|
||||
useActivateSandboxProvider: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/form/components/base', () => ({
|
||||
BaseForm: React.forwardRef((_props: object, ref: React.ForwardedRef<{ getFormValues: () => typeof mockFormValues }>) => {
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
getFormValues: () => mockFormValues,
|
||||
}))
|
||||
return <div data-testid="base-form" />
|
||||
}),
|
||||
}))
|
||||
|
||||
const createProvider = (overrides: Partial<SandboxProvider> = {}): SandboxProvider => ({
|
||||
provider_type: 'e2b',
|
||||
is_system_configured: true,
|
||||
is_tenant_configured: false,
|
||||
is_active: false,
|
||||
config: {},
|
||||
config_schema: [
|
||||
{ name: 'api_key', type: 'secret' },
|
||||
],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('Sandbox ConfigModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFormValues = {
|
||||
isCheckValidated: true,
|
||||
values: {
|
||||
api_key: 'secret-key',
|
||||
},
|
||||
}
|
||||
mockUseSaveSandboxProviderConfig.mockReturnValue({
|
||||
mutateAsync: vi.fn().mockResolvedValue(undefined),
|
||||
isPending: false,
|
||||
} as unknown as ReturnType<typeof useSaveSandboxProviderConfig>)
|
||||
mockUseDeleteSandboxProviderConfig.mockReturnValue({
|
||||
mutateAsync: vi.fn().mockResolvedValue(undefined),
|
||||
isPending: false,
|
||||
} as unknown as ReturnType<typeof useDeleteSandboxProviderConfig>)
|
||||
mockUseActivateSandboxProvider.mockReturnValue({
|
||||
mutateAsync: vi.fn().mockResolvedValue(undefined),
|
||||
isPending: false,
|
||||
} as unknown as ReturnType<typeof useActivateSandboxProvider>)
|
||||
})
|
||||
|
||||
// Covers the managed-mode activation shortcut for providers with system config.
|
||||
describe('managed mode', () => {
|
||||
it('should activate the managed provider when saving in managed mode', async () => {
|
||||
const activateProvider = vi.fn().mockResolvedValue(undefined)
|
||||
const onClose = vi.fn()
|
||||
mockUseActivateSandboxProvider.mockReturnValue({
|
||||
mutateAsync: activateProvider,
|
||||
isPending: false,
|
||||
} as unknown as ReturnType<typeof useActivateSandboxProvider>)
|
||||
|
||||
render(<ConfigModal provider={createProvider()} onClose={onClose} />)
|
||||
|
||||
expect(screen.queryByTestId('base-form')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.sandboxProvider.configModal.save' }))
|
||||
|
||||
await waitFor(() => expect(activateProvider).toHaveBeenCalledWith({
|
||||
providerType: 'e2b',
|
||||
type: 'system',
|
||||
}))
|
||||
expect(toast.success).toHaveBeenCalledWith('common.api.saved')
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Covers BYOK save and revoke flows.
|
||||
describe('byok mode', () => {
|
||||
it('should switch between managed and BYOK modes for providers that support both', () => {
|
||||
render(<ConfigModal provider={createProvider()} onClose={vi.fn()} />)
|
||||
|
||||
expect(screen.queryByTestId('base-form')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('common.sandboxProvider.configModal.bringYourOwnKey'))
|
||||
expect(screen.getByTestId('base-form')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('common.sandboxProvider.configModal.managedByDify'))
|
||||
expect(screen.queryByTestId('base-form')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should save BYOK config when the form validates', async () => {
|
||||
const saveConfig = vi.fn().mockResolvedValue(undefined)
|
||||
const onClose = vi.fn()
|
||||
mockUseSaveSandboxProviderConfig.mockReturnValue({
|
||||
mutateAsync: saveConfig,
|
||||
isPending: false,
|
||||
} as unknown as ReturnType<typeof useSaveSandboxProviderConfig>)
|
||||
|
||||
render(<ConfigModal provider={createProvider({ is_system_configured: false })} onClose={onClose} />)
|
||||
|
||||
expect(screen.getByTestId('base-form')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.sandboxProvider.configModal.save' }))
|
||||
|
||||
await waitFor(() => expect(saveConfig).toHaveBeenCalledWith({
|
||||
providerType: 'e2b',
|
||||
config: { api_key: 'secret-key' },
|
||||
activate: true,
|
||||
}))
|
||||
expect(toast.success).toHaveBeenCalledWith('common.api.saved')
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not save when the form validation fails', () => {
|
||||
const saveConfig = vi.fn().mockResolvedValue(undefined)
|
||||
mockFormValues = {
|
||||
isCheckValidated: false,
|
||||
values: {
|
||||
api_key: '',
|
||||
},
|
||||
}
|
||||
mockUseSaveSandboxProviderConfig.mockReturnValue({
|
||||
mutateAsync: saveConfig,
|
||||
isPending: false,
|
||||
} as unknown as ReturnType<typeof useSaveSandboxProviderConfig>)
|
||||
|
||||
render(<ConfigModal provider={createProvider({ is_system_configured: false })} onClose={vi.fn()} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.sandboxProvider.configModal.save' }))
|
||||
expect(saveConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should revoke tenant config when revoke is available', async () => {
|
||||
const deleteConfig = vi.fn().mockResolvedValue(undefined)
|
||||
const onClose = vi.fn()
|
||||
mockUseDeleteSandboxProviderConfig.mockReturnValue({
|
||||
mutateAsync: deleteConfig,
|
||||
isPending: false,
|
||||
} as unknown as ReturnType<typeof useDeleteSandboxProviderConfig>)
|
||||
|
||||
render(
|
||||
<ConfigModal
|
||||
provider={createProvider({
|
||||
is_system_configured: false,
|
||||
is_tenant_configured: true,
|
||||
})}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.sandboxProvider.configModal.revoke' }))
|
||||
|
||||
await waitFor(() => expect(deleteConfig).toHaveBeenCalledWith('e2b'))
|
||||
expect(toast.success).toHaveBeenCalledWith('common.api.remove')
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,164 @@
|
||||
import type { SandboxProvider } from '@/types/sandbox-provider'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import {
|
||||
useActivateSandboxProvider,
|
||||
useDeleteSandboxProviderConfig,
|
||||
useSaveSandboxProviderConfig,
|
||||
} from '@/service/use-sandbox-provider'
|
||||
import SandboxProviderPage from '../index'
|
||||
|
||||
const mockUseQuery = vi.mocked(useQuery)
|
||||
const mockUseAppContext = vi.mocked(useAppContext)
|
||||
const mockUseSaveSandboxProviderConfig = vi.mocked(useSaveSandboxProviderConfig)
|
||||
const mockUseDeleteSandboxProviderConfig = vi.mocked(useDeleteSandboxProviderConfig)
|
||||
const mockUseActivateSandboxProvider = vi.mocked(useActivateSandboxProvider)
|
||||
|
||||
vi.mock('@tanstack/react-query', async () => {
|
||||
const actual = await vi.importActual<typeof import('@tanstack/react-query')>('@tanstack/react-query')
|
||||
return {
|
||||
...actual,
|
||||
useQuery: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/app-context', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/context/app-context')>('@/context/app-context')
|
||||
return {
|
||||
...actual,
|
||||
useAppContext: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/use-sandbox-provider', () => ({
|
||||
useSaveSandboxProviderConfig: vi.fn(),
|
||||
useDeleteSandboxProviderConfig: vi.fn(),
|
||||
useActivateSandboxProvider: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/form/components/base', () => ({
|
||||
BaseForm: () => <div data-testid="base-form" />,
|
||||
}))
|
||||
|
||||
const createProvider = (overrides: Partial<SandboxProvider> = {}): SandboxProvider => ({
|
||||
provider_type: 'e2b',
|
||||
is_system_configured: false,
|
||||
is_tenant_configured: true,
|
||||
is_active: false,
|
||||
config: {},
|
||||
config_schema: [{ name: 'api_key', type: 'secret' }],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('SandboxProviderPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseAppContext.mockReturnValue({
|
||||
isCurrentWorkspaceManager: true,
|
||||
isLoadingCurrentWorkspace: false,
|
||||
} as ReturnType<typeof useAppContext>)
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
} as unknown as ReturnType<typeof useQuery>)
|
||||
mockUseSaveSandboxProviderConfig.mockReturnValue({
|
||||
mutateAsync: vi.fn().mockResolvedValue(undefined),
|
||||
isPending: false,
|
||||
} as unknown as ReturnType<typeof useSaveSandboxProviderConfig>)
|
||||
mockUseDeleteSandboxProviderConfig.mockReturnValue({
|
||||
mutateAsync: vi.fn().mockResolvedValue(undefined),
|
||||
isPending: false,
|
||||
} as unknown as ReturnType<typeof useDeleteSandboxProviderConfig>)
|
||||
mockUseActivateSandboxProvider.mockReturnValue({
|
||||
mutateAsync: vi.fn().mockResolvedValue(undefined),
|
||||
isPending: false,
|
||||
} as unknown as ReturnType<typeof useActivateSandboxProvider>)
|
||||
})
|
||||
|
||||
// Covers loading fallback while sandbox providers are being fetched.
|
||||
describe('loading', () => {
|
||||
it('should render the loading state while the provider list is pending', () => {
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
} as unknown as ReturnType<typeof useQuery>)
|
||||
|
||||
const { container } = render(<SandboxProviderPage />)
|
||||
expect(container.querySelector('[role="status"]')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Covers section rendering based on provider state and user permission.
|
||||
describe('sections', () => {
|
||||
it('should render current and other providers along with the no-permission hint', () => {
|
||||
mockUseAppContext.mockReturnValue({
|
||||
isCurrentWorkspaceManager: false,
|
||||
isLoadingCurrentWorkspace: false,
|
||||
} as ReturnType<typeof useAppContext>)
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: [
|
||||
createProvider({ provider_type: 'e2b', is_active: true }),
|
||||
createProvider({ provider_type: 'docker' }),
|
||||
],
|
||||
isLoading: false,
|
||||
} as unknown as ReturnType<typeof useQuery>)
|
||||
|
||||
render(<SandboxProviderPage />)
|
||||
|
||||
expect(screen.getByText('common.sandboxProvider.currentProvider')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.sandboxProvider.otherProvider')).toBeInTheDocument()
|
||||
expect(screen.getByText('E2B')).toBeInTheDocument()
|
||||
expect(screen.getByText('Docker')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.sandboxProvider.noPermission')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Covers opening the config and switch modals from card actions.
|
||||
describe('modal triggers', () => {
|
||||
it('should open config and switch modals from provider card actions', () => {
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: [
|
||||
createProvider({ provider_type: 'e2b', is_active: true }),
|
||||
createProvider({ provider_type: 'docker' }),
|
||||
],
|
||||
isLoading: false,
|
||||
} as unknown as ReturnType<typeof useQuery>)
|
||||
|
||||
const { container } = render(<SandboxProviderPage />)
|
||||
|
||||
const buttons = container.querySelectorAll('button')
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(3)
|
||||
|
||||
fireEvent.click(buttons[0])
|
||||
expect(screen.getByText('common.sandboxProvider.configModal.title')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.sandboxProvider.configModal.cancel' }))
|
||||
expect(screen.queryByText('common.sandboxProvider.configModal.title')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('common.sandboxProvider.setAsActive'))
|
||||
expect(screen.getByText('common.sandboxProvider.switchModal.title')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.sandboxProvider.switchModal.cancel' }))
|
||||
expect(screen.queryByText('common.sandboxProvider.switchModal.title')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open the config modal from an inactive provider card', () => {
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: [
|
||||
createProvider({ provider_type: 'e2b', is_active: true }),
|
||||
createProvider({ provider_type: 'docker' }),
|
||||
],
|
||||
isLoading: false,
|
||||
} as unknown as ReturnType<typeof useQuery>)
|
||||
|
||||
const { container } = render(<SandboxProviderPage />)
|
||||
const buttons = container.querySelectorAll('button')
|
||||
|
||||
fireEvent.click(buttons[2])
|
||||
|
||||
expect(screen.getAllByText('Docker').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('common.sandboxProvider.configModal.title')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,82 @@
|
||||
import type { SandboxProvider } from '@/types/sandbox-provider'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import ProviderCard from '../provider-card'
|
||||
|
||||
const createProvider = (overrides: Partial<SandboxProvider> = {}): SandboxProvider => ({
|
||||
provider_type: 'e2b',
|
||||
is_system_configured: false,
|
||||
is_tenant_configured: true,
|
||||
is_active: false,
|
||||
config: {},
|
||||
config_schema: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('Sandbox ProviderCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Covers the current-provider presentation path.
|
||||
describe('current provider', () => {
|
||||
it('should render the connected state and config action for the active provider', () => {
|
||||
const onConfig = vi.fn()
|
||||
const { container } = render(
|
||||
<ProviderCard
|
||||
provider={createProvider({ is_active: true })}
|
||||
isCurrent
|
||||
onConfig={onConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('E2B')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.sandboxProvider.connected')).toBeInTheDocument()
|
||||
expect(screen.queryByText('common.sandboxProvider.setAsActive')).not.toBeInTheDocument()
|
||||
|
||||
const buttons = container.querySelectorAll('button')
|
||||
expect(buttons).toHaveLength(1)
|
||||
fireEvent.click(buttons[0])
|
||||
expect(onConfig).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Covers action availability for other configured providers.
|
||||
describe('other providers', () => {
|
||||
it('should render enable and config actions when the provider is configured', () => {
|
||||
const onConfig = vi.fn()
|
||||
const onEnable = vi.fn()
|
||||
const { container } = render(
|
||||
<ProviderCard
|
||||
provider={createProvider()}
|
||||
onConfig={onConfig}
|
||||
onEnable={onEnable}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.sandboxProvider.setAsActive')).toBeInTheDocument()
|
||||
|
||||
const buttons = container.querySelectorAll('button')
|
||||
expect(buttons).toHaveLength(2)
|
||||
|
||||
fireEvent.click(buttons[0])
|
||||
fireEvent.click(buttons[1])
|
||||
|
||||
expect(onEnable).toHaveBeenCalledTimes(1)
|
||||
expect(onConfig).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should hide actions when the card is disabled', () => {
|
||||
const { container } = render(
|
||||
<ProviderCard
|
||||
provider={createProvider()}
|
||||
onConfig={vi.fn()}
|
||||
onEnable={vi.fn()}
|
||||
disabled
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('common.sandboxProvider.setAsActive')).not.toBeInTheDocument()
|
||||
expect(container.querySelectorAll('button')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,54 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import ProviderIcon from '../provider-icon'
|
||||
|
||||
describe('Sandbox ProviderIcon', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Covers built-in branded render paths.
|
||||
describe('built-in providers', () => {
|
||||
it('should render docker with border wrapper and svg icon', () => {
|
||||
const { container } = render(<ProviderIcon providerType="docker" withBorder />)
|
||||
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
expect(container.firstElementChild).toHaveClass('rounded')
|
||||
})
|
||||
|
||||
it('should render ssh with the dedicated image asset', () => {
|
||||
const { getByAltText } = render(<ProviderIcon providerType="ssh" />)
|
||||
|
||||
expect(getByAltText('ssh icon')).toHaveAttribute('src', '/sandbox-providers/ssh.svg')
|
||||
})
|
||||
|
||||
it('should render local and ssh providers with bordered wrappers', () => {
|
||||
const local = render(<ProviderIcon providerType="local" withBorder size="sm" />)
|
||||
|
||||
expect(local.container.querySelector('svg')).toBeInTheDocument()
|
||||
expect(local.container.firstElementChild).toHaveClass('rounded')
|
||||
|
||||
local.unmount()
|
||||
|
||||
const ssh = render(<ProviderIcon providerType="ssh" withBorder />)
|
||||
|
||||
expect(ssh.getByAltText('ssh icon')).toHaveAttribute('src', '/sandbox-providers/ssh.svg')
|
||||
expect(ssh.container.firstElementChild).toHaveClass('rounded')
|
||||
})
|
||||
})
|
||||
|
||||
// Covers the fallback icon path for unknown providers.
|
||||
describe('fallback providers', () => {
|
||||
it('should fall back to the configured icon asset for unknown providers', () => {
|
||||
const { getByAltText } = render(<ProviderIcon providerType="unknown-provider" size="sm" />)
|
||||
|
||||
expect(getByAltText('unknown-provider icon')).toHaveAttribute('src', '/sandbox-providers/e2b.svg')
|
||||
})
|
||||
|
||||
it('should wrap fallback icons in a border when requested', () => {
|
||||
const { container, getByAltText } = render(<ProviderIcon providerType="custom-provider" withBorder />)
|
||||
|
||||
expect(getByAltText('custom-provider icon')).toHaveAttribute('src', '/sandbox-providers/e2b.svg')
|
||||
expect(container.firstElementChild).toHaveClass('rounded')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,86 @@
|
||||
import type { SandboxProvider } from '@/types/sandbox-provider'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { useActivateSandboxProvider } from '@/service/use-sandbox-provider'
|
||||
import SwitchModal from '../switch-modal'
|
||||
|
||||
const mockUseActivateSandboxProvider = vi.mocked(useActivateSandboxProvider)
|
||||
|
||||
vi.mock('@/service/use-sandbox-provider', () => ({
|
||||
useActivateSandboxProvider: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const createProvider = (overrides: Partial<SandboxProvider> = {}): SandboxProvider => ({
|
||||
provider_type: 'e2b',
|
||||
is_system_configured: false,
|
||||
is_tenant_configured: true,
|
||||
is_active: false,
|
||||
config: {},
|
||||
config_schema: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('Sandbox SwitchModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseActivateSandboxProvider.mockReturnValue({
|
||||
mutateAsync: vi.fn().mockResolvedValue(undefined),
|
||||
isPending: false,
|
||||
} as unknown as ReturnType<typeof useActivateSandboxProvider>)
|
||||
})
|
||||
|
||||
// Covers close interactions without activation.
|
||||
describe('closing', () => {
|
||||
it('should close when clicking the cancel button', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<SwitchModal provider={createProvider()} onClose={onClose} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.sandboxProvider.switchModal.cancel' }))
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Covers provider activation flows.
|
||||
describe('activation', () => {
|
||||
it('should activate the provider, show success toast, and close the modal', async () => {
|
||||
const mutateAsync = vi.fn().mockResolvedValue(undefined)
|
||||
const onClose = vi.fn()
|
||||
mockUseActivateSandboxProvider.mockReturnValue({
|
||||
mutateAsync,
|
||||
isPending: false,
|
||||
} as unknown as ReturnType<typeof useActivateSandboxProvider>)
|
||||
|
||||
render(<SwitchModal provider={createProvider()} onClose={onClose} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.sandboxProvider.switchModal.confirm' }))
|
||||
|
||||
await waitFor(() => expect(mutateAsync).toHaveBeenCalledWith({
|
||||
providerType: 'e2b',
|
||||
type: 'user',
|
||||
}))
|
||||
expect(toast.success).toHaveBeenCalledWith('common.api.success')
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should disable actions while activation is pending', () => {
|
||||
mockUseActivateSandboxProvider.mockReturnValue({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: true,
|
||||
} as unknown as ReturnType<typeof useActivateSandboxProvider>)
|
||||
|
||||
render(<SwitchModal provider={createProvider()} onClose={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'common.sandboxProvider.switchModal.cancel' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'common.sandboxProvider.switchModal.confirm' })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,153 @@
|
||||
import type { GitHubItemAndMarketPlaceDependency, Plugin, VersionProps } from '@/app/components/plugins/types'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { useUploadGitHub } from '@/service/use-plugins'
|
||||
import GitHubItem from '../github-item'
|
||||
|
||||
const mockUseUploadGitHub = vi.mocked(useUploadGitHub)
|
||||
const mockPluginManifestToCardPluginProps = vi.fn()
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useUploadGitHub: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/base/loading', () => ({
|
||||
default: () => <div>loading</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/utils', () => ({
|
||||
pluginManifestToCardPluginProps: (...args: unknown[]) => mockPluginManifestToCardPluginProps(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../loaded-item', () => ({
|
||||
default: ({ payload, checked }: { payload: Plugin, checked: boolean }) => (
|
||||
<div data-testid="loaded-item" data-plugin-id={payload.plugin_id} data-from={payload.from} data-checked={String(checked)}>
|
||||
{payload.name}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
|
||||
type: 'tool',
|
||||
org: 'plugin-org',
|
||||
name: 'Plugin Name',
|
||||
plugin_id: 'plugin-id',
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
latest_package_identifier: 'pkg-id',
|
||||
icon: 'icon.png',
|
||||
verified: false,
|
||||
label: { 'en-US': 'Plugin Name' } as Plugin['label'],
|
||||
brief: { 'en-US': 'Plugin brief' } as Plugin['brief'],
|
||||
description: { 'en-US': 'Plugin description' } as Plugin['description'],
|
||||
introduction: '',
|
||||
repository: '',
|
||||
category: 'tool' as Plugin['category'],
|
||||
install_count: 0,
|
||||
endpoint: { settings: [] },
|
||||
tags: [],
|
||||
badges: [],
|
||||
verification: { authorized_category: 'langgenius' },
|
||||
from: 'github',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const versionInfo: VersionProps = {
|
||||
hasInstalled: false,
|
||||
toInstallVersion: '0.0.1',
|
||||
}
|
||||
|
||||
const dependency: GitHubItemAndMarketPlaceDependency = {
|
||||
type: 'github',
|
||||
value: {
|
||||
repo: 'org/repo',
|
||||
release: '1.2.3',
|
||||
packages: 'tool',
|
||||
},
|
||||
}
|
||||
|
||||
describe('GitHubItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseUploadGitHub.mockReturnValue({
|
||||
data: undefined,
|
||||
error: undefined,
|
||||
} as unknown as ReturnType<typeof useUploadGitHub>)
|
||||
mockPluginManifestToCardPluginProps.mockReturnValue(createPlugin())
|
||||
})
|
||||
|
||||
// Covers request parameter normalization and loading state.
|
||||
describe('loading', () => {
|
||||
it('should request the github package using release and packages fallbacks', () => {
|
||||
render(
|
||||
<GitHubItem
|
||||
checked={false}
|
||||
onCheckedChange={vi.fn()}
|
||||
dependency={dependency}
|
||||
versionInfo={versionInfo}
|
||||
onFetchedPayload={vi.fn()}
|
||||
onFetchError={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockUseUploadGitHub).toHaveBeenCalledWith({
|
||||
repo: 'org/repo',
|
||||
version: '1.2.3',
|
||||
package: 'tool',
|
||||
})
|
||||
expect(screen.getByText('loading')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Covers success and failure side effects.
|
||||
describe('effects', () => {
|
||||
it('should transform the fetched manifest and render the loaded item', async () => {
|
||||
const onFetchedPayload = vi.fn()
|
||||
mockUseUploadGitHub.mockReturnValue({
|
||||
data: {
|
||||
manifest: { plugin_unique_identifier: 'manifest-plugin-id' },
|
||||
unique_identifier: 'github-uid',
|
||||
},
|
||||
error: undefined,
|
||||
} as unknown as ReturnType<typeof useUploadGitHub>)
|
||||
|
||||
render(
|
||||
<GitHubItem
|
||||
checked
|
||||
onCheckedChange={vi.fn()}
|
||||
dependency={dependency}
|
||||
versionInfo={versionInfo}
|
||||
onFetchedPayload={onFetchedPayload}
|
||||
onFetchError={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(onFetchedPayload).toHaveBeenCalledWith(expect.objectContaining({
|
||||
plugin_id: 'github-uid',
|
||||
})))
|
||||
expect(screen.getByTestId('loaded-item')).toHaveAttribute('data-plugin-id', 'github-uid')
|
||||
expect(screen.getByTestId('loaded-item')).toHaveAttribute('data-from', 'github')
|
||||
expect(screen.getByTestId('loaded-item')).toHaveAttribute('data-checked', 'true')
|
||||
})
|
||||
|
||||
it('should notify the parent when the github upload hook returns an error', async () => {
|
||||
const onFetchError = vi.fn()
|
||||
mockUseUploadGitHub.mockReturnValue({
|
||||
data: undefined,
|
||||
error: new Error('failed'),
|
||||
} as unknown as ReturnType<typeof useUploadGitHub>)
|
||||
|
||||
render(
|
||||
<GitHubItem
|
||||
checked={false}
|
||||
onCheckedChange={vi.fn()}
|
||||
dependency={dependency}
|
||||
versionInfo={versionInfo}
|
||||
onFetchedPayload={vi.fn()}
|
||||
onFetchError={onFetchError}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(onFetchError).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,336 @@
|
||||
import type { PackageDependency, Plugin, VersionProps } from '@/app/components/plugins/types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { MARKETPLACE_API_PREFIX } from '@/config'
|
||||
import { useUploadGitHub } from '@/service/use-plugins'
|
||||
import GitHubItem from '../github-item'
|
||||
import LoadedItem from '../loaded-item'
|
||||
import MarketPlaceItem from '../marketplace-item'
|
||||
import PackageItem from '../package-item'
|
||||
|
||||
const mockUseUploadGitHub = vi.mocked(useUploadGitHub)
|
||||
|
||||
let mockCanInstall = true
|
||||
const mockGetIconUrl = vi.fn((icon: string) => `https://icons.example/${icon}`)
|
||||
const mockPluginManifestToCardPluginProps = vi.fn()
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useUploadGitHub: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/base/use-get-icon', () => ({
|
||||
default: () => ({
|
||||
getIconUrl: mockGetIconUrl,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit', () => ({
|
||||
default: () => ({
|
||||
canInstall: mockCanInstall,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/utils', () => ({
|
||||
pluginManifestToCardPluginProps: (...args: unknown[]) => mockPluginManifestToCardPluginProps(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card', () => ({
|
||||
default: ({ payload, titleLeft, limitedInstall }: { payload: Plugin, titleLeft?: React.ReactNode, limitedInstall?: boolean }) => (
|
||||
<div
|
||||
data-testid="plugin-card"
|
||||
data-icon={payload.icon}
|
||||
data-plugin-id={payload.plugin_id}
|
||||
data-limited-install={String(!!limitedInstall)}
|
||||
>
|
||||
{titleLeft}
|
||||
<span>{payload.name}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/base/version', () => ({
|
||||
default: ({ toInstallVersion }: VersionProps) => <span data-testid="version-badge">{toInstallVersion}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/base/loading', () => ({
|
||||
default: () => <div>loading</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/base/loading-error', () => ({
|
||||
default: () => <div>loading-error</div>,
|
||||
}))
|
||||
|
||||
const versionInfo: VersionProps = {
|
||||
hasInstalled: false,
|
||||
toInstallVersion: '0.0.1',
|
||||
}
|
||||
|
||||
const createPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
|
||||
plugin_id: 'plugin-id',
|
||||
type: 'tool',
|
||||
category: 'tool' as Plugin['category'],
|
||||
name: 'plugin-name',
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
latest_package_identifier: 'package-id',
|
||||
org: 'plugin-org',
|
||||
label: { en_US: 'Plugin Name' } as Plugin['label'],
|
||||
brief: { en_US: 'Plugin brief' } as Plugin['brief'],
|
||||
description: { en_US: 'Plugin description' } as Plugin['description'],
|
||||
icon: 'icon.png',
|
||||
verified: false,
|
||||
introduction: '',
|
||||
repository: '',
|
||||
install_count: 0,
|
||||
endpoint: { settings: [] },
|
||||
tags: [],
|
||||
badges: [],
|
||||
verification: { authorized_category: 'langgenius' },
|
||||
from: 'github',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createPackageDependency = (overrides: Partial<PackageDependency> = {}): PackageDependency => ({
|
||||
type: 'package',
|
||||
value: {
|
||||
unique_identifier: 'package-uid',
|
||||
manifest: { plugin_unique_identifier: 'manifest-plugin-id' } as PackageDependency['value']['manifest'],
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('install bundle item components', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCanInstall = true
|
||||
mockUseUploadGitHub.mockReturnValue({
|
||||
data: undefined,
|
||||
error: undefined,
|
||||
} as unknown as ReturnType<typeof useUploadGitHub>)
|
||||
mockPluginManifestToCardPluginProps.mockReturnValue(createPlugin({
|
||||
plugin_id: 'manifest-plugin-id',
|
||||
from: 'package',
|
||||
}))
|
||||
})
|
||||
|
||||
// Covers the base loaded card behavior for local and marketplace items.
|
||||
describe('LoadedItem', () => {
|
||||
it('should use local icon urls and emit the payload when selected', () => {
|
||||
const payload = createPlugin()
|
||||
const onCheckedChange = vi.fn()
|
||||
|
||||
render(
|
||||
<LoadedItem
|
||||
checked={false}
|
||||
onCheckedChange={onCheckedChange}
|
||||
payload={payload}
|
||||
versionInfo={versionInfo}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('plugin-card')).toHaveAttribute('data-icon', 'https://icons.example/icon.png')
|
||||
expect(screen.getByTestId('version-badge')).toHaveTextContent('1.0.0')
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox'))
|
||||
expect(onCheckedChange).toHaveBeenCalledWith(payload)
|
||||
})
|
||||
|
||||
it('should use marketplace icon urls and disable selection when install is restricted', () => {
|
||||
mockCanInstall = false
|
||||
const payload = createPlugin({
|
||||
org: 'market-org',
|
||||
name: 'market-plugin',
|
||||
})
|
||||
|
||||
render(
|
||||
<LoadedItem
|
||||
checked
|
||||
isFromMarketPlace
|
||||
onCheckedChange={vi.fn()}
|
||||
payload={payload}
|
||||
versionInfo={versionInfo}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('plugin-card')).toHaveAttribute(
|
||||
'data-icon',
|
||||
`${MARKETPLACE_API_PREFIX}/plugins/market-org/market-plugin/icon`,
|
||||
)
|
||||
expect(screen.getByTestId('plugin-card')).toHaveAttribute('data-limited-install', 'true')
|
||||
expect(screen.getByRole('checkbox')).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
// Covers the marketplace wrapper loading state and version override behavior.
|
||||
describe('MarketPlaceItem', () => {
|
||||
it('should render loading while the marketplace payload is unavailable', () => {
|
||||
render(
|
||||
<MarketPlaceItem
|
||||
checked={false}
|
||||
onCheckedChange={vi.fn()}
|
||||
payload={undefined}
|
||||
version="2.0.0"
|
||||
versionInfo={versionInfo}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('loading')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass the marketplace version into the loaded item payload', () => {
|
||||
const onCheckedChange = vi.fn()
|
||||
render(
|
||||
<MarketPlaceItem
|
||||
checked={false}
|
||||
onCheckedChange={onCheckedChange}
|
||||
payload={createPlugin({ version: '1.0.0' })}
|
||||
version="2.0.0"
|
||||
versionInfo={versionInfo}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('version-badge')).toHaveTextContent('2.0.0')
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox'))
|
||||
expect(onCheckedChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||
version: '2.0.0',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
// Covers local package conversion and missing-manifest fallback.
|
||||
describe('PackageItem', () => {
|
||||
it('should render a loading error when the package manifest is missing', () => {
|
||||
render(
|
||||
<PackageItem
|
||||
checked={false}
|
||||
onCheckedChange={vi.fn()}
|
||||
payload={createPackageDependency({
|
||||
value: {
|
||||
unique_identifier: 'missing-manifest',
|
||||
manifest: undefined as unknown as PackageDependency['value']['manifest'],
|
||||
},
|
||||
})}
|
||||
versionInfo={versionInfo}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('loading-error')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should convert package manifests and forward the packaged plugin payload', () => {
|
||||
const onCheckedChange = vi.fn()
|
||||
render(
|
||||
<PackageItem
|
||||
checked={false}
|
||||
onCheckedChange={onCheckedChange}
|
||||
payload={createPackageDependency()}
|
||||
versionInfo={versionInfo}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockPluginManifestToCardPluginProps).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox'))
|
||||
expect(onCheckedChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||
plugin_id: 'manifest-plugin-id',
|
||||
from: 'package',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
// Covers GitHub dependency fetching success and failure flows.
|
||||
describe('GitHubItem', () => {
|
||||
it('should show loading until the GitHub package metadata arrives', () => {
|
||||
render(
|
||||
<GitHubItem
|
||||
checked={false}
|
||||
onCheckedChange={vi.fn()}
|
||||
dependency={{
|
||||
type: 'github',
|
||||
value: {
|
||||
repo: 'org/repo',
|
||||
version: '1.0.0',
|
||||
package: 'tool',
|
||||
},
|
||||
}}
|
||||
versionInfo={versionInfo}
|
||||
onFetchedPayload={vi.fn()}
|
||||
onFetchError={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('loading')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hydrate the loaded item and notify the parent after a successful fetch', async () => {
|
||||
const onFetchedPayload = vi.fn()
|
||||
const onCheckedChange = vi.fn()
|
||||
mockUseUploadGitHub.mockReturnValue({
|
||||
data: {
|
||||
manifest: { plugin_unique_identifier: 'fetched-plugin-id' },
|
||||
unique_identifier: 'github-uid',
|
||||
},
|
||||
error: undefined,
|
||||
} as unknown as ReturnType<typeof useUploadGitHub>)
|
||||
mockPluginManifestToCardPluginProps.mockReturnValue(createPlugin({
|
||||
plugin_id: 'fetched-plugin-id',
|
||||
}))
|
||||
|
||||
render(
|
||||
<GitHubItem
|
||||
checked={false}
|
||||
onCheckedChange={onCheckedChange}
|
||||
dependency={{
|
||||
type: 'github',
|
||||
value: {
|
||||
repo: 'org/repo',
|
||||
release: '1.2.3',
|
||||
packages: 'tool',
|
||||
},
|
||||
}}
|
||||
versionInfo={versionInfo}
|
||||
onFetchedPayload={onFetchedPayload}
|
||||
onFetchError={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(onFetchedPayload).toHaveBeenCalledWith(expect.objectContaining({
|
||||
plugin_id: 'github-uid',
|
||||
})))
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox'))
|
||||
expect(onCheckedChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||
plugin_id: 'github-uid',
|
||||
from: 'github',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should notify the parent when the GitHub fetch fails', async () => {
|
||||
const onFetchError = vi.fn()
|
||||
mockUseUploadGitHub.mockReturnValue({
|
||||
data: undefined,
|
||||
error: new Error('failed'),
|
||||
} as unknown as ReturnType<typeof useUploadGitHub>)
|
||||
|
||||
render(
|
||||
<GitHubItem
|
||||
checked={false}
|
||||
onCheckedChange={vi.fn()}
|
||||
dependency={{
|
||||
type: 'github',
|
||||
value: {
|
||||
repo: 'org/repo',
|
||||
version: '1.0.0',
|
||||
package: 'tool',
|
||||
},
|
||||
}}
|
||||
versionInfo={versionInfo}
|
||||
onFetchedPayload={vi.fn()}
|
||||
onFetchError={onFetchError}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(onFetchError).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,115 @@
|
||||
import type { Plugin, VersionProps } from '@/app/components/plugins/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { MARKETPLACE_API_PREFIX } from '@/config'
|
||||
import LoadedItem from '../loaded-item'
|
||||
|
||||
const mockGetIconUrl = vi.fn((icon: string) => `https://icons.example/${icon}`)
|
||||
let mockCanInstall = true
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/base/use-get-icon', () => ({
|
||||
default: () => ({
|
||||
getIconUrl: mockGetIconUrl,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit', () => ({
|
||||
default: () => ({
|
||||
canInstall: mockCanInstall,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card', () => ({
|
||||
default: ({ payload, titleLeft, limitedInstall }: { payload: Plugin, titleLeft?: React.ReactNode, limitedInstall?: boolean }) => (
|
||||
<div data-testid="plugin-card" data-icon={payload.icon} data-limited-install={String(!!limitedInstall)}>
|
||||
{titleLeft}
|
||||
<span>{payload.name}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
|
||||
type: 'tool',
|
||||
org: 'plugin-org',
|
||||
name: 'Plugin Name',
|
||||
plugin_id: 'plugin-id',
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
latest_package_identifier: 'pkg-id',
|
||||
icon: 'icon.png',
|
||||
verified: false,
|
||||
label: { 'en-US': 'Plugin Name' } as Plugin['label'],
|
||||
brief: { 'en-US': 'Plugin brief' } as Plugin['brief'],
|
||||
description: { 'en-US': 'Plugin description' } as Plugin['description'],
|
||||
introduction: '',
|
||||
repository: '',
|
||||
category: 'tool' as Plugin['category'],
|
||||
install_count: 0,
|
||||
endpoint: { settings: [] },
|
||||
tags: [],
|
||||
badges: [],
|
||||
verification: { authorized_category: 'langgenius' },
|
||||
from: 'github',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const versionInfo: VersionProps = {
|
||||
hasInstalled: false,
|
||||
toInstallVersion: '0.0.1',
|
||||
}
|
||||
|
||||
describe('LoadedItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCanInstall = true
|
||||
})
|
||||
|
||||
// Covers local icon resolution and checkbox interaction.
|
||||
describe('local payloads', () => {
|
||||
it('should resolve local icons and emit the plugin when checked', () => {
|
||||
const payload = createPlugin()
|
||||
const onCheckedChange = vi.fn()
|
||||
|
||||
render(
|
||||
<LoadedItem
|
||||
checked={false}
|
||||
onCheckedChange={onCheckedChange}
|
||||
payload={payload}
|
||||
versionInfo={versionInfo}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('plugin-card')).toHaveAttribute('data-icon', 'https://icons.example/icon.png')
|
||||
expect(screen.getAllByText('1.0.0').length).toBeGreaterThan(0)
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox'))
|
||||
expect(onCheckedChange).toHaveBeenCalledWith(payload)
|
||||
})
|
||||
})
|
||||
|
||||
// Covers marketplace icon resolution and install-limit behavior.
|
||||
describe('marketplace payloads', () => {
|
||||
it('should use marketplace icon URLs and disable selection when install is limited', () => {
|
||||
mockCanInstall = false
|
||||
|
||||
render(
|
||||
<LoadedItem
|
||||
checked
|
||||
isFromMarketPlace
|
||||
onCheckedChange={vi.fn()}
|
||||
payload={createPlugin({
|
||||
org: 'market-org',
|
||||
name: 'market-plugin',
|
||||
})}
|
||||
versionInfo={versionInfo}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('plugin-card')).toHaveAttribute(
|
||||
'data-icon',
|
||||
`${MARKETPLACE_API_PREFIX}/plugins/market-org/market-plugin/icon`,
|
||||
)
|
||||
expect(screen.getByTestId('plugin-card')).toHaveAttribute('data-limited-install', 'true')
|
||||
expect(screen.getByRole('checkbox')).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,82 @@
|
||||
import type { Plugin, VersionProps } from '@/app/components/plugins/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import MarketPlaceItem from '../marketplace-item'
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/base/loading', () => ({
|
||||
default: () => <div>loading</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../loaded-item', () => ({
|
||||
default: ({ payload, isFromMarketPlace }: { payload: Plugin, isFromMarketPlace?: boolean }) => (
|
||||
<div data-testid="loaded-item" data-version={payload.version} data-marketplace={String(!!isFromMarketPlace)}>
|
||||
{payload.name}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
|
||||
type: 'tool',
|
||||
org: 'plugin-org',
|
||||
name: 'Plugin Name',
|
||||
plugin_id: 'plugin-id',
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
latest_package_identifier: 'pkg-id',
|
||||
icon: 'icon.png',
|
||||
verified: false,
|
||||
label: { 'en-US': 'Plugin Name' } as Plugin['label'],
|
||||
brief: { 'en-US': 'Plugin brief' } as Plugin['brief'],
|
||||
description: { 'en-US': 'Plugin description' } as Plugin['description'],
|
||||
introduction: '',
|
||||
repository: '',
|
||||
category: 'tool' as Plugin['category'],
|
||||
install_count: 0,
|
||||
endpoint: { settings: [] },
|
||||
tags: [],
|
||||
badges: [],
|
||||
verification: { authorized_category: 'langgenius' },
|
||||
from: 'marketplace',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const versionInfo: VersionProps = {
|
||||
hasInstalled: false,
|
||||
toInstallVersion: '0.0.1',
|
||||
}
|
||||
|
||||
describe('MarketPlaceItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Covers the loading placeholder when payload metadata has not arrived.
|
||||
it('should render loading when the marketplace payload is missing', () => {
|
||||
render(
|
||||
<MarketPlaceItem
|
||||
checked={false}
|
||||
onCheckedChange={vi.fn()}
|
||||
payload={undefined}
|
||||
version="2.0.0"
|
||||
versionInfo={versionInfo}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('loading')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Covers payload version override and marketplace forwarding.
|
||||
it('should forward the marketplace payload with the requested version', () => {
|
||||
render(
|
||||
<MarketPlaceItem
|
||||
checked={false}
|
||||
onCheckedChange={vi.fn()}
|
||||
payload={createPlugin({ version: '1.0.0' })}
|
||||
version="2.0.0"
|
||||
versionInfo={versionInfo}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('loaded-item')).toHaveAttribute('data-version', '2.0.0')
|
||||
expect(screen.getByTestId('loaded-item')).toHaveAttribute('data-marketplace', 'true')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,106 @@
|
||||
import type { PackageDependency, Plugin, VersionProps } from '@/app/components/plugins/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import PackageItem from '../package-item'
|
||||
|
||||
const mockPluginManifestToCardPluginProps = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/base/loading-error', () => ({
|
||||
default: () => <div>loading-error</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/utils', () => ({
|
||||
pluginManifestToCardPluginProps: (...args: unknown[]) => mockPluginManifestToCardPluginProps(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../loaded-item', () => ({
|
||||
default: ({ payload, isFromMarketPlace }: { payload: Plugin, isFromMarketPlace?: boolean }) => (
|
||||
<div data-testid="loaded-item" data-plugin-id={payload.plugin_id} data-from={payload.from} data-marketplace={String(!!isFromMarketPlace)}>
|
||||
{payload.name}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
|
||||
type: 'tool',
|
||||
org: 'plugin-org',
|
||||
name: 'Plugin Name',
|
||||
plugin_id: 'plugin-id',
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
latest_package_identifier: 'pkg-id',
|
||||
icon: 'icon.png',
|
||||
verified: false,
|
||||
label: { 'en-US': 'Plugin Name' } as Plugin['label'],
|
||||
brief: { 'en-US': 'Plugin brief' } as Plugin['brief'],
|
||||
description: { 'en-US': 'Plugin description' } as Plugin['description'],
|
||||
introduction: '',
|
||||
repository: '',
|
||||
category: 'tool' as Plugin['category'],
|
||||
install_count: 0,
|
||||
endpoint: { settings: [] },
|
||||
tags: [],
|
||||
badges: [],
|
||||
verification: { authorized_category: 'langgenius' },
|
||||
from: 'package',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createPackageDependency = (overrides: Partial<PackageDependency> = {}): PackageDependency => ({
|
||||
type: 'package',
|
||||
value: {
|
||||
unique_identifier: 'package-plugin-uid',
|
||||
manifest: {
|
||||
plugin_unique_identifier: 'package-plugin-uid',
|
||||
} as PackageDependency['value']['manifest'],
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const versionInfo: VersionProps = {
|
||||
hasInstalled: false,
|
||||
toInstallVersion: '0.0.1',
|
||||
}
|
||||
|
||||
describe('PackageItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPluginManifestToCardPluginProps.mockReturnValue(createPlugin())
|
||||
})
|
||||
|
||||
// Covers the missing-manifest fallback.
|
||||
it('should render a loading error when the package manifest is missing', () => {
|
||||
render(
|
||||
<PackageItem
|
||||
checked={false}
|
||||
onCheckedChange={vi.fn()}
|
||||
payload={createPackageDependency({
|
||||
value: {
|
||||
unique_identifier: 'missing-manifest',
|
||||
manifest: undefined as unknown as PackageDependency['value']['manifest'],
|
||||
},
|
||||
})}
|
||||
versionInfo={versionInfo}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('loading-error')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Covers manifest transformation and prop forwarding into LoadedItem.
|
||||
it('should transform the package manifest and forward marketplace origin when requested', () => {
|
||||
render(
|
||||
<PackageItem
|
||||
checked
|
||||
onCheckedChange={vi.fn()}
|
||||
payload={createPackageDependency()}
|
||||
isFromMarketPlace
|
||||
versionInfo={versionInfo}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockPluginManifestToCardPluginProps).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByTestId('loaded-item')).toHaveAttribute('data-plugin-id', 'plugin-id')
|
||||
expect(screen.getByTestId('loaded-item')).toHaveAttribute('data-from', 'package')
|
||||
expect(screen.getByTestId('loaded-item')).toHaveAttribute('data-marketplace', 'true')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,117 @@
|
||||
import type { InstallStatus, Plugin } from '../../../../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { MARKETPLACE_API_PREFIX } from '@/config'
|
||||
import Installed from '../installed'
|
||||
|
||||
const mockGetIconUrl = vi.fn((icon: string) => `https://icons.example/${icon}`)
|
||||
|
||||
vi.mock('../../../base/use-get-icon', () => ({
|
||||
default: () => ({
|
||||
getIconUrl: mockGetIconUrl,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card', () => ({
|
||||
default: ({ payload, installed, installFailed, titleLeft }: { payload: Plugin, installed?: boolean, installFailed?: boolean, titleLeft?: React.ReactNode }) => (
|
||||
<div
|
||||
data-testid="plugin-card"
|
||||
data-icon={payload.icon}
|
||||
data-installed={String(!!installed)}
|
||||
data-install-failed={String(!!installFailed)}
|
||||
>
|
||||
{titleLeft}
|
||||
<span>{payload.name}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
|
||||
type: 'tool',
|
||||
org: 'plugin-org',
|
||||
name: 'Plugin Name',
|
||||
plugin_id: 'plugin-id',
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
latest_package_identifier: 'pkg-id',
|
||||
icon: 'icon.png',
|
||||
verified: false,
|
||||
label: { 'en-US': 'Plugin Name' } as Plugin['label'],
|
||||
brief: { 'en-US': 'Plugin brief' } as Plugin['brief'],
|
||||
description: { 'en-US': 'Plugin description' } as Plugin['description'],
|
||||
introduction: '',
|
||||
repository: '',
|
||||
category: 'tool' as Plugin['category'],
|
||||
install_count: 0,
|
||||
endpoint: { settings: [] },
|
||||
tags: [],
|
||||
badges: [],
|
||||
verification: { authorized_category: 'langgenius' },
|
||||
from: 'github',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('Installed', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Covers marketplace/local icon resolution and status flags.
|
||||
describe('rendering', () => {
|
||||
it('should render installed cards with the correct icon source and status flags', () => {
|
||||
const list = [
|
||||
createPlugin({ from: 'marketplace', org: 'market-org', name: 'market-plugin' }),
|
||||
createPlugin({ plugin_id: 'plugin-2', name: 'Local Plugin', from: 'github' }),
|
||||
]
|
||||
const installStatus: InstallStatus[] = [
|
||||
{ success: true, isFromMarketPlace: true },
|
||||
{ success: false, isFromMarketPlace: false },
|
||||
]
|
||||
|
||||
render(
|
||||
<Installed
|
||||
list={list}
|
||||
installStatus={installStatus}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const cards = screen.getAllByTestId('plugin-card')
|
||||
expect(cards[0]).toHaveAttribute('data-icon', `${MARKETPLACE_API_PREFIX}/plugins/market-org/market-plugin/icon`)
|
||||
expect(cards[0]).toHaveAttribute('data-installed', 'true')
|
||||
expect(cards[1]).toHaveAttribute('data-icon', 'https://icons.example/icon.png')
|
||||
expect(cards[1]).toHaveAttribute('data-install-failed', 'true')
|
||||
expect(screen.getAllByText('1.0.0').length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// Covers footer button visibility and close behavior.
|
||||
describe('actions', () => {
|
||||
it('should call onCancel when clicking close', () => {
|
||||
const onCancel = vi.fn()
|
||||
|
||||
render(
|
||||
<Installed
|
||||
list={[createPlugin()]}
|
||||
installStatus={[{ success: true, isFromMarketPlace: false }]}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should hide the footer button when isHideButton is true', () => {
|
||||
render(
|
||||
<Installed
|
||||
list={[createPlugin()]}
|
||||
installStatus={[{ success: true, isFromMarketPlace: false }]}
|
||||
onCancel={vi.fn()}
|
||||
isHideButton
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'common.operation.close' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,688 +1,304 @@
|
||||
import type { Dependency, GitHubItemAndMarketPlaceDependency, PackageDependency, Plugin, VersionInfo } from '@/app/components/plugins/types'
|
||||
import type { Dependency, PackageDependency, Plugin, VersionInfo } from '@/app/components/plugins/types'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed'
|
||||
import { pluginInstallLimit } from '@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useFetchPluginsInMarketPlaceByInfo } from '@/service/use-plugins'
|
||||
import { defaultSystemFeatures } from '@/types/feature'
|
||||
import { getPluginKey, useInstallMultiState } from '../use-install-multi-state'
|
||||
|
||||
let mockMarketplaceData: ReturnType<typeof createMarketplaceApiData> | null = null
|
||||
let mockMarketplaceError: Error | null = null
|
||||
let mockInstalledInfo: Record<string, VersionInfo> = {}
|
||||
let mockCanInstall = true
|
||||
const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore)
|
||||
const mockUseFetchPluginsInMarketPlaceByInfo = vi.mocked(useFetchPluginsInMarketPlaceByInfo)
|
||||
const mockUseCheckInstalled = vi.mocked(useCheckInstalled)
|
||||
const mockPluginInstallLimit = vi.mocked(pluginInstallLimit)
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useFetchPluginsInMarketPlaceByInfo: () => ({
|
||||
isLoading: false,
|
||||
data: mockMarketplaceData,
|
||||
error: mockMarketplaceError,
|
||||
}),
|
||||
useFetchPluginsInMarketPlaceByInfo: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({
|
||||
default: () => ({
|
||||
installedInfo: mockInstalledInfo,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: () => ({}),
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit', () => ({
|
||||
pluginInstallLimit: () => ({ canInstall: mockCanInstall }),
|
||||
pluginInstallLimit: vi.fn(),
|
||||
}))
|
||||
|
||||
const createMockPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
|
||||
type: 'plugin',
|
||||
org: 'test-org',
|
||||
name: 'Test Plugin',
|
||||
plugin_id: 'test-plugin-id',
|
||||
const createPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
|
||||
type: 'tool',
|
||||
org: 'plugin-org',
|
||||
author: 'plugin-author',
|
||||
name: 'plugin-name',
|
||||
plugin_id: 'plugin-id',
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
latest_package_identifier: 'test-pkg-id',
|
||||
latest_package_identifier: 'pkg-id',
|
||||
icon: 'icon.png',
|
||||
verified: true,
|
||||
label: { 'en-US': 'Test Plugin' },
|
||||
brief: { 'en-US': 'Brief' },
|
||||
description: { 'en-US': 'Description' },
|
||||
introduction: 'Intro',
|
||||
repository: 'https://github.com/test/plugin',
|
||||
category: PluginCategoryEnum.tool,
|
||||
install_count: 100,
|
||||
verified: false,
|
||||
label: { 'en-US': 'Plugin Name' } as Plugin['label'],
|
||||
brief: { 'en-US': 'Plugin brief' } as Plugin['brief'],
|
||||
description: { 'en-US': 'Plugin description' } as Plugin['description'],
|
||||
introduction: '',
|
||||
repository: '',
|
||||
category: 'tool' as Plugin['category'],
|
||||
install_count: 0,
|
||||
endpoint: { settings: [] },
|
||||
tags: [],
|
||||
badges: [],
|
||||
verification: { authorized_category: 'community' },
|
||||
verification: { authorized_category: 'langgenius' },
|
||||
from: 'marketplace',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createPackageDependency = (index: number) => ({
|
||||
const createPackageDependency = (overrides: Partial<PackageDependency> = {}): PackageDependency => ({
|
||||
type: 'package',
|
||||
value: {
|
||||
unique_identifier: `package-plugin-${index}-uid`,
|
||||
unique_identifier: 'package-plugin-uid',
|
||||
manifest: {
|
||||
plugin_unique_identifier: `package-plugin-${index}-uid`,
|
||||
plugin_unique_identifier: 'package-plugin-uid',
|
||||
version: '1.0.0',
|
||||
author: 'test-author',
|
||||
icon: 'icon.png',
|
||||
name: `Package Plugin ${index}`,
|
||||
category: PluginCategoryEnum.tool,
|
||||
label: { 'en-US': `Package Plugin ${index}` },
|
||||
description: { 'en-US': 'Test package plugin' },
|
||||
created_at: '2024-01-01',
|
||||
resource: {},
|
||||
plugins: [],
|
||||
verified: true,
|
||||
endpoint: { settings: [], endpoints: [] },
|
||||
model: null,
|
||||
tags: [],
|
||||
agent_strategy: null,
|
||||
meta: { version: '1.0.0' },
|
||||
trigger: {},
|
||||
},
|
||||
author: 'package-org',
|
||||
name: 'Package Plugin',
|
||||
} as PackageDependency['value']['manifest'],
|
||||
},
|
||||
} as unknown as PackageDependency)
|
||||
|
||||
const createMarketplaceDependency = (index: number): GitHubItemAndMarketPlaceDependency => ({
|
||||
type: 'marketplace',
|
||||
value: {
|
||||
marketplace_plugin_unique_identifier: `test-org/plugin-${index}:1.0.0`,
|
||||
plugin_unique_identifier: `plugin-${index}`,
|
||||
version: '1.0.0',
|
||||
},
|
||||
})
|
||||
|
||||
const createGitHubDependency = (index: number): GitHubItemAndMarketPlaceDependency => ({
|
||||
type: 'github',
|
||||
value: {
|
||||
repo: `test-org/plugin-${index}`,
|
||||
version: 'v1.0.0',
|
||||
package: `plugin-${index}.zip`,
|
||||
},
|
||||
})
|
||||
|
||||
const createMarketplaceApiData = (indexes: number[]) => ({
|
||||
data: {
|
||||
list: indexes.map(i => ({
|
||||
plugin: {
|
||||
plugin_id: `test-org/plugin-${i}`,
|
||||
org: 'test-org',
|
||||
name: `Test Plugin ${i}`,
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
},
|
||||
version: {
|
||||
unique_identifier: `plugin-${i}-uid`,
|
||||
},
|
||||
})),
|
||||
},
|
||||
})
|
||||
|
||||
const createDefaultParams = (overrides = {}) => ({
|
||||
allPlugins: [createPackageDependency(0)] as Dependency[],
|
||||
selectedPlugins: [] as Plugin[],
|
||||
onSelect: vi.fn(),
|
||||
onLoadedAllPlugin: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// ==================== getPluginKey Tests ====================
|
||||
|
||||
describe('getPluginKey', () => {
|
||||
it('should return org/name when org is available', () => {
|
||||
const plugin = createMockPlugin({ org: 'my-org', name: 'my-plugin' })
|
||||
|
||||
expect(getPluginKey(plugin)).toBe('my-org/my-plugin')
|
||||
})
|
||||
|
||||
it('should fall back to author when org is not available', () => {
|
||||
const plugin = createMockPlugin({ org: undefined, author: 'my-author', name: 'my-plugin' })
|
||||
|
||||
expect(getPluginKey(plugin)).toBe('my-author/my-plugin')
|
||||
})
|
||||
|
||||
it('should prefer org over author when both exist', () => {
|
||||
const plugin = createMockPlugin({ org: 'my-org', author: 'my-author', name: 'my-plugin' })
|
||||
|
||||
expect(getPluginKey(plugin)).toBe('my-org/my-plugin')
|
||||
})
|
||||
|
||||
it('should handle undefined plugin', () => {
|
||||
expect(getPluginKey(undefined)).toBe('undefined/undefined')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== useInstallMultiState Tests ====================
|
||||
|
||||
describe('useInstallMultiState', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockMarketplaceData = null
|
||||
mockMarketplaceError = null
|
||||
mockInstalledInfo = {}
|
||||
mockCanInstall = true
|
||||
mockUseGlobalPublicStore.mockImplementation((selector) => {
|
||||
return selector({
|
||||
systemFeatures: defaultSystemFeatures,
|
||||
setSystemFeatures: vi.fn(),
|
||||
})
|
||||
})
|
||||
mockUseFetchPluginsInMarketPlaceByInfo.mockReturnValue({
|
||||
isLoading: false,
|
||||
data: null,
|
||||
error: null,
|
||||
} as unknown as ReturnType<typeof useFetchPluginsInMarketPlaceByInfo>)
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
installedInfo: {},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useCheckInstalled>)
|
||||
mockPluginInstallLimit.mockReturnValue({ canInstall: true })
|
||||
})
|
||||
|
||||
// ==================== Initial State ====================
|
||||
describe('Initial State', () => {
|
||||
it('should initialize plugins from package dependencies', () => {
|
||||
const params = createDefaultParams()
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
expect(result.current.plugins).toHaveLength(1)
|
||||
expect(result.current.plugins[0]).toBeDefined()
|
||||
expect(result.current.plugins[0]?.plugin_id).toBe('package-plugin-0-uid')
|
||||
})
|
||||
|
||||
it('should have slots for all dependencies even when no packages exist', () => {
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [createGitHubDependency(0)] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
// Array has slots for all dependencies, but unresolved ones are undefined
|
||||
expect(result.current.plugins).toHaveLength(1)
|
||||
expect(result.current.plugins[0]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined for non-package items in mixed dependencies', () => {
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [
|
||||
createPackageDependency(0),
|
||||
createGitHubDependency(1),
|
||||
] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
expect(result.current.plugins).toHaveLength(2)
|
||||
expect(result.current.plugins[0]).toBeDefined()
|
||||
expect(result.current.plugins[1]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should start with empty errorIndexes', () => {
|
||||
const params = createDefaultParams()
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
expect(result.current.errorIndexes).toEqual([])
|
||||
// Covers the exported plugin key helper.
|
||||
describe('getPluginKey', () => {
|
||||
it('should derive keys from org/name and fall back to author/name', () => {
|
||||
expect(getPluginKey(createPlugin({ org: 'langgenius', name: 'search' }))).toBe('langgenius/search')
|
||||
expect(getPluginKey(createPlugin({ org: '', author: 'author', name: 'plugin' }))).toBe('author/plugin')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Marketplace Data Sync ====================
|
||||
describe('Marketplace Data Sync', () => {
|
||||
it('should update plugins when marketplace data loads by ID', async () => {
|
||||
mockMarketplaceData = createMarketplaceApiData([0])
|
||||
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.plugins[0]).toBeDefined()
|
||||
expect(result.current.plugins[0]?.version).toBe('1.0.0')
|
||||
})
|
||||
})
|
||||
|
||||
it('should update plugins when marketplace data loads by meta', async () => {
|
||||
mockMarketplaceData = createMarketplaceApiData([0])
|
||||
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
// The "by meta" effect sets plugin_id from version.unique_identifier
|
||||
await waitFor(() => {
|
||||
expect(result.current.plugins[0]).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('should add to errorIndexes when marketplace item not found in response', async () => {
|
||||
mockMarketplaceData = { data: { list: [] } }
|
||||
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.errorIndexes).toContain(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle multiple marketplace plugins', async () => {
|
||||
mockMarketplaceData = createMarketplaceApiData([0, 1])
|
||||
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [
|
||||
createMarketplaceDependency(0),
|
||||
createMarketplaceDependency(1),
|
||||
] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.plugins[0]).toBeDefined()
|
||||
expect(result.current.plugins[1]).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('should fall back to latest_version when marketplace plugin version is missing', async () => {
|
||||
mockMarketplaceData = {
|
||||
data: {
|
||||
list: [{
|
||||
plugin: {
|
||||
plugin_id: 'test-org/plugin-0',
|
||||
org: 'test-org',
|
||||
name: 'Test Plugin 0',
|
||||
version: '',
|
||||
latest_version: '2.0.0',
|
||||
},
|
||||
version: {
|
||||
unique_identifier: 'plugin-0-uid',
|
||||
},
|
||||
}],
|
||||
},
|
||||
}
|
||||
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.plugins[0]?.version).toBe('2.0.0')
|
||||
})
|
||||
})
|
||||
|
||||
it('should resolve marketplace dependency from organization and plugin fields', async () => {
|
||||
mockMarketplaceData = createMarketplaceApiData([0])
|
||||
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [
|
||||
{
|
||||
type: 'marketplace',
|
||||
value: {
|
||||
organization: 'test-org',
|
||||
plugin: 'plugin-0',
|
||||
version: '1.0.0',
|
||||
},
|
||||
} as GitHubItemAndMarketPlaceDependency,
|
||||
] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.plugins[0]).toBeDefined()
|
||||
expect(result.current.errorIndexes).not.toContain(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Error Handling ====================
|
||||
describe('Error Handling', () => {
|
||||
it('should mark marketplace index as error when identifier misses plugin and version parts', async () => {
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [
|
||||
{
|
||||
type: 'marketplace',
|
||||
value: {
|
||||
marketplace_plugin_unique_identifier: 'invalid-identifier',
|
||||
version: '1.0.0',
|
||||
},
|
||||
} as GitHubItemAndMarketPlaceDependency,
|
||||
] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.errorIndexes).toContain(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should mark marketplace index as error when identifier has an empty plugin segment', async () => {
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [
|
||||
{
|
||||
type: 'marketplace',
|
||||
value: {
|
||||
marketplace_plugin_unique_identifier: 'test-org/:1.0.0',
|
||||
version: '1.0.0',
|
||||
},
|
||||
} as GitHubItemAndMarketPlaceDependency,
|
||||
] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.errorIndexes).toContain(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should mark marketplace index as error when identifier is missing', async () => {
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [
|
||||
{
|
||||
type: 'marketplace',
|
||||
value: {
|
||||
version: '1.0.0',
|
||||
},
|
||||
} as GitHubItemAndMarketPlaceDependency,
|
||||
] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.errorIndexes).toContain(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should mark all marketplace indexes as errors on fetch failure', async () => {
|
||||
mockMarketplaceError = new Error('Fetch failed')
|
||||
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [
|
||||
createMarketplaceDependency(0),
|
||||
createMarketplaceDependency(1),
|
||||
] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.errorIndexes).toContain(0)
|
||||
expect(result.current.errorIndexes).toContain(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not affect non-marketplace indexes on marketplace fetch error', async () => {
|
||||
mockMarketplaceError = new Error('Fetch failed')
|
||||
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [
|
||||
createPackageDependency(0),
|
||||
createMarketplaceDependency(1),
|
||||
] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.errorIndexes).toContain(1)
|
||||
expect(result.current.errorIndexes).not.toContain(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore marketplace requests whose dsl index cannot be mapped', () => {
|
||||
const duplicatedMarketplaceDependency = createMarketplaceDependency(0)
|
||||
const allPlugins = [duplicatedMarketplaceDependency] as Dependency[]
|
||||
|
||||
allPlugins.filter = vi.fn(() => [duplicatedMarketplaceDependency, duplicatedMarketplaceDependency]) as typeof allPlugins.filter
|
||||
|
||||
const params = createDefaultParams({ allPlugins })
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
expect(result.current.plugins).toHaveLength(1)
|
||||
expect(result.current.errorIndexes).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Loaded All Data Notification ====================
|
||||
describe('Loaded All Data Notification', () => {
|
||||
it('should call onLoadedAllPlugin when all data loaded', async () => {
|
||||
const params = createDefaultParams()
|
||||
renderHook(() => useInstallMultiState(params))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(params.onLoadedAllPlugin).toHaveBeenCalledWith(mockInstalledInfo)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call onLoadedAllPlugin when not all plugins resolved', () => {
|
||||
// GitHub plugin not fetched yet → isLoadedAllData = false
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [
|
||||
createPackageDependency(0),
|
||||
createGitHubDependency(1),
|
||||
] as Dependency[],
|
||||
})
|
||||
renderHook(() => useInstallMultiState(params))
|
||||
|
||||
expect(params.onLoadedAllPlugin).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onLoadedAllPlugin after all errors are counted', async () => {
|
||||
mockMarketplaceError = new Error('Fetch failed')
|
||||
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
|
||||
})
|
||||
renderHook(() => useInstallMultiState(params))
|
||||
|
||||
// Error fills errorIndexes → isLoadedAllData becomes true
|
||||
await waitFor(() => {
|
||||
expect(params.onLoadedAllPlugin).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== handleGitHubPluginFetched ====================
|
||||
describe('handleGitHubPluginFetched', () => {
|
||||
it('should update plugin at the specified index', async () => {
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [createGitHubDependency(0)] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
const mockPlugin = createMockPlugin({ plugin_id: 'github-plugin-0' })
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleGitHubPluginFetched(0)(mockPlugin)
|
||||
})
|
||||
|
||||
expect(result.current.plugins[0]).toEqual(mockPlugin)
|
||||
})
|
||||
|
||||
it('should not affect other plugin slots', async () => {
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [
|
||||
createPackageDependency(0),
|
||||
createGitHubDependency(1),
|
||||
] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
const originalPlugin0 = result.current.plugins[0]
|
||||
const mockPlugin = createMockPlugin({ plugin_id: 'github-plugin-1' })
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleGitHubPluginFetched(1)(mockPlugin)
|
||||
})
|
||||
|
||||
expect(result.current.plugins[0]).toEqual(originalPlugin0)
|
||||
expect(result.current.plugins[1]).toEqual(mockPlugin)
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== handleGitHubPluginFetchError ====================
|
||||
describe('handleGitHubPluginFetchError', () => {
|
||||
it('should add index to errorIndexes', async () => {
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [createGitHubDependency(0)] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleGitHubPluginFetchError(0)()
|
||||
})
|
||||
|
||||
expect(result.current.errorIndexes).toContain(0)
|
||||
})
|
||||
|
||||
it('should accumulate multiple error indexes without stale closure', async () => {
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [
|
||||
createGitHubDependency(0),
|
||||
createGitHubDependency(1),
|
||||
] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleGitHubPluginFetchError(0)()
|
||||
})
|
||||
await act(async () => {
|
||||
result.current.handleGitHubPluginFetchError(1)()
|
||||
})
|
||||
|
||||
expect(result.current.errorIndexes).toContain(0)
|
||||
expect(result.current.errorIndexes).toContain(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== getVersionInfo ====================
|
||||
describe('getVersionInfo', () => {
|
||||
it('should return hasInstalled false when plugin not installed', () => {
|
||||
const params = createDefaultParams()
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
const info = result.current.getVersionInfo('unknown/plugin')
|
||||
|
||||
expect(info.hasInstalled).toBe(false)
|
||||
expect(info.installedVersion).toBeUndefined()
|
||||
expect(info.toInstallVersion).toBe('')
|
||||
})
|
||||
|
||||
it('should return hasInstalled true with version when installed', () => {
|
||||
mockInstalledInfo = {
|
||||
'test-author/Package Plugin 0': {
|
||||
installedId: 'installed-1',
|
||||
// Covers package + marketplace aggregation and parent notification when loading completes.
|
||||
describe('aggregation', () => {
|
||||
it('should merge package and marketplace plugins and notify when all install info is ready', async () => {
|
||||
const onLoadedAllPlugin = vi.fn()
|
||||
const installedInfo: Record<string, VersionInfo> = {
|
||||
'package-org/Package Plugin': {
|
||||
installedId: 'installed-id',
|
||||
installedVersion: '0.9.0',
|
||||
uniqueIdentifier: 'uid-1',
|
||||
uniqueIdentifier: 'package-plugin-uid',
|
||||
},
|
||||
}
|
||||
const params = createDefaultParams()
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
mockUseFetchPluginsInMarketPlaceByInfo.mockReturnValue({
|
||||
isLoading: false,
|
||||
data: {
|
||||
data: {
|
||||
list: [
|
||||
{
|
||||
plugin: createPlugin({
|
||||
plugin_id: 'lang/plugin-1',
|
||||
org: 'lang',
|
||||
name: 'plugin-1',
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
} as unknown as ReturnType<typeof useFetchPluginsInMarketPlaceByInfo>)
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
installedInfo,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useCheckInstalled>)
|
||||
|
||||
const info = result.current.getVersionInfo('test-author/Package Plugin 0')
|
||||
const { result } = renderHook(() => useInstallMultiState({
|
||||
allPlugins: [
|
||||
createPackageDependency(),
|
||||
{
|
||||
type: 'marketplace',
|
||||
value: {
|
||||
marketplace_plugin_unique_identifier: 'lang/plugin-1:1.0.0',
|
||||
},
|
||||
},
|
||||
] as Dependency[],
|
||||
selectedPlugins: [],
|
||||
onSelect: vi.fn(),
|
||||
onLoadedAllPlugin,
|
||||
}))
|
||||
|
||||
expect(info.hasInstalled).toBe(true)
|
||||
expect(info.installedVersion).toBe('0.9.0')
|
||||
await waitFor(() => expect(onLoadedAllPlugin).toHaveBeenCalledWith(installedInfo))
|
||||
expect(result.current.plugins[0]).toEqual(expect.objectContaining({
|
||||
plugin_id: 'package-plugin-uid',
|
||||
name: 'Package Plugin',
|
||||
}))
|
||||
expect(result.current.plugins[1]).toEqual(expect.objectContaining({
|
||||
plugin_id: 'lang/plugin-1',
|
||||
name: 'plugin-1',
|
||||
from: 'marketplace',
|
||||
}))
|
||||
expect(result.current.errorIndexes).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== handleSelect ====================
|
||||
describe('handleSelect', () => {
|
||||
it('should call onSelect with plugin, index, and installable count', async () => {
|
||||
const params = createDefaultParams()
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
// Covers github callbacks, selection bookkeeping, and installability filtering.
|
||||
describe('github state', () => {
|
||||
it('should track github payloads, errors, and installable selections', () => {
|
||||
const onSelect = vi.fn()
|
||||
mockPluginInstallLimit.mockImplementation(plugin => ({
|
||||
canInstall: plugin?.name !== 'Blocked Plugin',
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
const selectedPackage = createPlugin({
|
||||
plugin_id: 'package-plugin-uid',
|
||||
name: 'Package Plugin',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useInstallMultiState({
|
||||
allPlugins: [
|
||||
{
|
||||
type: 'github',
|
||||
value: {
|
||||
repo: 'org/repo',
|
||||
version: '1.0.0',
|
||||
package: 'tool',
|
||||
},
|
||||
},
|
||||
createPackageDependency(),
|
||||
] as Dependency[],
|
||||
selectedPlugins: [selectedPackage],
|
||||
onSelect,
|
||||
onLoadedAllPlugin: vi.fn(),
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleGitHubPluginFetched(0)(createPlugin({
|
||||
plugin_id: 'github-plugin-id',
|
||||
org: 'github-org',
|
||||
name: 'Blocked Plugin',
|
||||
from: 'github',
|
||||
}))
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleGitHubPluginFetchError(0)()
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleSelect(0)()
|
||||
})
|
||||
|
||||
expect(params.onSelect).toHaveBeenCalledWith(
|
||||
result.current.plugins[0],
|
||||
0,
|
||||
expect.any(Number),
|
||||
)
|
||||
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({
|
||||
plugin_id: 'github-plugin-id',
|
||||
}), 0, 1)
|
||||
expect(result.current.isPluginSelected(1)).toBe(true)
|
||||
expect(result.current.errorIndexes).toEqual([0])
|
||||
expect(result.current.getInstallablePlugins()).toEqual({
|
||||
selectedIndexes: [1],
|
||||
installablePlugins: [
|
||||
expect.objectContaining({
|
||||
plugin_id: 'package-plugin-uid',
|
||||
}),
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should filter installable plugins using pluginInstallLimit', async () => {
|
||||
const params = createDefaultParams({
|
||||
it('should expose empty version info and skip unloaded plugins when no package dependency exists', () => {
|
||||
const { result } = renderHook(() => useInstallMultiState({
|
||||
allPlugins: [
|
||||
createPackageDependency(0),
|
||||
createPackageDependency(1),
|
||||
{
|
||||
type: 'github',
|
||||
value: {
|
||||
repo: 'org/unloaded',
|
||||
version: '1.0.0',
|
||||
package: 'tool',
|
||||
},
|
||||
},
|
||||
] as Dependency[],
|
||||
selectedPlugins: [],
|
||||
onSelect: vi.fn(),
|
||||
onLoadedAllPlugin: vi.fn(),
|
||||
}))
|
||||
|
||||
expect(result.current.plugins).toEqual([undefined])
|
||||
expect(result.current.getVersionInfo('missing/plugin')).toEqual({
|
||||
hasInstalled: false,
|
||||
installedVersion: undefined,
|
||||
toInstallVersion: '',
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleSelect(0)()
|
||||
expect(result.current.getInstallablePlugins()).toEqual({
|
||||
selectedIndexes: [],
|
||||
installablePlugins: [],
|
||||
})
|
||||
|
||||
// mockCanInstall is true, so all 2 plugins are installable
|
||||
expect(params.onSelect).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
0,
|
||||
2,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== isPluginSelected ====================
|
||||
describe('isPluginSelected', () => {
|
||||
it('should return true when plugin is in selectedPlugins', () => {
|
||||
const selectedPlugin = createMockPlugin({ plugin_id: 'package-plugin-0-uid' })
|
||||
const params = createDefaultParams({
|
||||
selectedPlugins: [selectedPlugin],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
expect(result.current.isPluginSelected(0)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when plugin is not in selectedPlugins', () => {
|
||||
const params = createDefaultParams({ selectedPlugins: [] })
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
it('should mark malformed marketplace dependencies as errors and normalize explicit marketplace info', () => {
|
||||
mockUseFetchPluginsInMarketPlaceByInfo.mockReturnValue({
|
||||
isLoading: false,
|
||||
data: null,
|
||||
error: new Error('marketplace failed'),
|
||||
} as unknown as ReturnType<typeof useFetchPluginsInMarketPlaceByInfo>)
|
||||
|
||||
expect(result.current.isPluginSelected(0)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when plugin at index is undefined', () => {
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [createGitHubDependency(0)] as Dependency[],
|
||||
selectedPlugins: [createMockPlugin()],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
// plugins[0] is undefined (GitHub not yet fetched)
|
||||
expect(result.current.isPluginSelected(0)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== getInstallablePlugins ====================
|
||||
describe('getInstallablePlugins', () => {
|
||||
it('should return all plugins when canInstall is true', () => {
|
||||
mockCanInstall = true
|
||||
const params = createDefaultParams({
|
||||
const { result } = renderHook(() => useInstallMultiState({
|
||||
allPlugins: [
|
||||
createPackageDependency(0),
|
||||
createPackageDependency(1),
|
||||
{
|
||||
type: 'marketplace',
|
||||
value: {},
|
||||
},
|
||||
{
|
||||
type: 'marketplace',
|
||||
value: {
|
||||
marketplace_plugin_unique_identifier: 'broken',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'marketplace',
|
||||
value: {
|
||||
marketplace_plugin_unique_identifier: 'org/',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'marketplace',
|
||||
value: {
|
||||
organization: 'explicit-org',
|
||||
plugin: 'explicit-plugin',
|
||||
version: '2.0.0',
|
||||
},
|
||||
},
|
||||
] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
selectedPlugins: [],
|
||||
onSelect: vi.fn(),
|
||||
onLoadedAllPlugin: vi.fn(),
|
||||
}))
|
||||
|
||||
const { installablePlugins, selectedIndexes } = result.current.getInstallablePlugins()
|
||||
|
||||
expect(installablePlugins).toHaveLength(2)
|
||||
expect(selectedIndexes).toEqual([0, 1])
|
||||
})
|
||||
|
||||
it('should return empty arrays when canInstall is false', () => {
|
||||
mockCanInstall = false
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [createPackageDependency(0)] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
const { installablePlugins, selectedIndexes } = result.current.getInstallablePlugins()
|
||||
|
||||
expect(installablePlugins).toHaveLength(0)
|
||||
expect(selectedIndexes).toEqual([])
|
||||
})
|
||||
|
||||
it('should skip unloaded (undefined) plugins', () => {
|
||||
mockCanInstall = true
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [
|
||||
createPackageDependency(0),
|
||||
createGitHubDependency(1),
|
||||
] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
const { installablePlugins, selectedIndexes } = result.current.getInstallablePlugins()
|
||||
|
||||
// Only package plugin is loaded; GitHub not yet fetched
|
||||
expect(installablePlugins).toHaveLength(1)
|
||||
expect(selectedIndexes).toEqual([0])
|
||||
expect(mockUseFetchPluginsInMarketPlaceByInfo).toHaveBeenCalledWith([
|
||||
{
|
||||
organization: 'explicit-org',
|
||||
plugin: 'explicit-plugin',
|
||||
version: '2.0.0',
|
||||
},
|
||||
])
|
||||
expect(result.current.errorIndexes).toEqual([0, 1, 2, 3])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
143
web/app/components/sub-graph/__tests__/index.spec.tsx
Normal file
143
web/app/components/sub-graph/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
import type { SubGraphProps } from '../types'
|
||||
import type { Shape as HooksStoreShape } from '@/app/components/workflow/hooks-store'
|
||||
import type { NestedNodeConfig } from '@/app/components/workflow/nodes/_base/types'
|
||||
import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types'
|
||||
import type { Node, NodeOutPutVar, ValueSelector } from '@/app/components/workflow/types'
|
||||
import { render } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NULL_STRATEGY } from '@/app/components/workflow/nodes/_base/constants'
|
||||
import { FlowType } from '@/types/common'
|
||||
import SubGraph from '../index'
|
||||
|
||||
const mockSetParentAvailableVars = vi.fn()
|
||||
const mockSetParentAvailableNodes = vi.fn()
|
||||
const mockUseSubGraphNodes = vi.fn()
|
||||
const mockSubGraphMain = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/workflow', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <div data-testid="workflow-default-context">{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/context', () => ({
|
||||
WorkflowContextProvider: ({
|
||||
children,
|
||||
injectWorkflowStoreSliceFn,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
injectWorkflowStoreSliceFn: unknown
|
||||
}) => (
|
||||
<div data-testid="workflow-context-provider" data-has-slice={String(Boolean(injectWorkflowStoreSliceFn))}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: (selector: (state: {
|
||||
setParentAvailableVars: typeof mockSetParentAvailableVars
|
||||
setParentAvailableNodes: typeof mockSetParentAvailableNodes
|
||||
}) => unknown) => selector({
|
||||
setParentAvailableVars: mockSetParentAvailableVars,
|
||||
setParentAvailableNodes: mockSetParentAvailableNodes,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useSubGraphNodes: (...args: unknown[]) => mockUseSubGraphNodes(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../components/sub-graph-main', () => ({
|
||||
default: (props: unknown) => {
|
||||
mockSubGraphMain(props)
|
||||
return <div data-testid="sub-graph-main" />
|
||||
},
|
||||
}))
|
||||
|
||||
const nestedNodeConfig: NestedNodeConfig = {
|
||||
extractor_node_id: 'extractor-1',
|
||||
output_selector: ['extractor-1', 'output'],
|
||||
null_strategy: NULL_STRATEGY.RAISE_ERROR,
|
||||
default_value: '',
|
||||
}
|
||||
|
||||
const parentAvailableNodes = [{ id: 'node-1' }] as Node[]
|
||||
const parentAvailableVars: NodeOutPutVar[] = [{ nodeId: 'node-1', title: 'Node 1', vars: [] }]
|
||||
const configsMap: HooksStoreShape['configsMap'] = {
|
||||
flowId: 'flow-1',
|
||||
flowType: FlowType.appFlow,
|
||||
}
|
||||
const sourceVariable = ['agent-1', 'context'] as ValueSelector
|
||||
const extractorNode = {
|
||||
id: 'extractor-1',
|
||||
data: { prompt_template: { role: 'user', text: 'ignored' } },
|
||||
} as Node<LLMNodeType>
|
||||
|
||||
const assembleProps: SubGraphProps = {
|
||||
variant: 'assemble',
|
||||
title: 'Assembler',
|
||||
isOpen: true,
|
||||
toolNodeId: 'tool-node',
|
||||
paramKey: 'question',
|
||||
parentAvailableNodes,
|
||||
parentAvailableVars,
|
||||
configsMap,
|
||||
nestedNodeConfig,
|
||||
onNestedNodeConfigChange: vi.fn(),
|
||||
}
|
||||
|
||||
describe('SubGraph', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseSubGraphNodes.mockReturnValue({
|
||||
nodes: [{ id: 'render-node' }],
|
||||
edges: [{ id: 'render-edge' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('should inject the sub-graph slice and sync parent availability data', () => {
|
||||
render(<SubGraph {...assembleProps} />)
|
||||
|
||||
expect(mockSetParentAvailableVars).toHaveBeenCalledWith(assembleProps.parentAvailableVars)
|
||||
expect(mockSetParentAvailableNodes).toHaveBeenCalledWith(assembleProps.parentAvailableNodes)
|
||||
})
|
||||
|
||||
it('should render the assemble variant with the derived extractor node id', () => {
|
||||
render(<SubGraph {...assembleProps} />)
|
||||
|
||||
expect(mockSubGraphMain).toHaveBeenCalledWith(expect.objectContaining({
|
||||
variant: 'assemble',
|
||||
title: 'Assembler',
|
||||
extractorNodeId: 'tool-node_ext_question',
|
||||
nodes: [{ id: 'render-node' }],
|
||||
edges: [{ id: 'render-edge' }],
|
||||
}))
|
||||
})
|
||||
|
||||
it('should render the agent variant with forwarded nested node config props', () => {
|
||||
const onNestedNodeConfigChange = vi.fn()
|
||||
|
||||
render(
|
||||
<SubGraph
|
||||
variant="agent"
|
||||
isOpen={true}
|
||||
toolNodeId="agent-tool"
|
||||
paramKey="context"
|
||||
toolParamValue="{{#agent-1.context#}}user prompt"
|
||||
agentNodeId="agent-1"
|
||||
agentName="Agent Runner"
|
||||
sourceVariable={sourceVariable}
|
||||
nestedNodeConfig={nestedNodeConfig}
|
||||
onNestedNodeConfigChange={onNestedNodeConfigChange}
|
||||
extractorNode={extractorNode}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockSubGraphMain).toHaveBeenCalledWith(expect.objectContaining({
|
||||
variant: 'agent',
|
||||
title: 'Agent Runner',
|
||||
extractorNodeId: 'agent-tool_ext_context',
|
||||
nestedNodeConfig,
|
||||
onNestedNodeConfigChange,
|
||||
}))
|
||||
})
|
||||
})
|
||||
111
web/app/components/sub-graph/__tests__/utils.spec.ts
Normal file
111
web/app/components/sub-graph/__tests__/utils.spec.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import type { AgentSubGraphProps, AssembleSubGraphProps } from '../types'
|
||||
import type { NestedNodeConfig } from '@/app/components/workflow/nodes/_base/types'
|
||||
import type { CodeNodeType } from '@/app/components/workflow/nodes/code/types'
|
||||
import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types'
|
||||
import type { Node, ValueSelector } from '@/app/components/workflow/types'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { NULL_STRATEGY } from '@/app/components/workflow/nodes/_base/constants'
|
||||
import { BlockEnum, EditionType, PromptRole } from '@/app/components/workflow/types'
|
||||
import {
|
||||
buildSubGraphEdges,
|
||||
buildSubGraphExtractorDisplayNode,
|
||||
buildSubGraphStartNode,
|
||||
getSubGraphExtractorNodeId,
|
||||
getSubGraphPromptText,
|
||||
getSubGraphSourceTitle,
|
||||
} from '../utils'
|
||||
|
||||
const nestedNodeConfig: NestedNodeConfig = {
|
||||
extractor_node_id: 'extractor-1',
|
||||
output_selector: ['extractor-1', 'output'],
|
||||
null_strategy: NULL_STRATEGY.RAISE_ERROR,
|
||||
default_value: '',
|
||||
}
|
||||
|
||||
const sourceVariable = ['agent-1', 'context'] as ValueSelector
|
||||
const assembleExtractorNode = {
|
||||
id: 'extractor-1',
|
||||
data: { selected: true },
|
||||
} as Node<CodeNodeType>
|
||||
const agentExtractorNode = {
|
||||
id: 'extractor-1',
|
||||
data: {
|
||||
selected: true,
|
||||
prompt_template: [
|
||||
{ role: PromptRole.system, text: 'keep me' },
|
||||
{ role: PromptRole.user, text: 'replace me', edition_type: EditionType.jinja2 },
|
||||
],
|
||||
},
|
||||
} as Node<LLMNodeType>
|
||||
|
||||
const createAssembleProps = (): AssembleSubGraphProps => ({
|
||||
variant: 'assemble',
|
||||
title: 'Assembler',
|
||||
isOpen: true,
|
||||
toolNodeId: 'tool-node',
|
||||
paramKey: 'question',
|
||||
nestedNodeConfig,
|
||||
onNestedNodeConfigChange: vi.fn(),
|
||||
extractorNode: assembleExtractorNode,
|
||||
})
|
||||
|
||||
const createAgentProps = (): AgentSubGraphProps => ({
|
||||
variant: 'agent',
|
||||
isOpen: true,
|
||||
toolNodeId: 'agent-tool',
|
||||
paramKey: 'context',
|
||||
toolParamValue: '{{#agent-1.context#}}hello world',
|
||||
agentNodeId: 'agent-1',
|
||||
agentName: 'Agent Runner',
|
||||
sourceVariable,
|
||||
nestedNodeConfig,
|
||||
onNestedNodeConfigChange: vi.fn(),
|
||||
extractorNode: agentExtractorNode,
|
||||
})
|
||||
|
||||
describe('sub-graph utils', () => {
|
||||
it('should resolve titles and extractor ids from the sub-graph props', () => {
|
||||
expect(getSubGraphSourceTitle(createAssembleProps())).toBe('Assembler')
|
||||
expect(getSubGraphSourceTitle(createAgentProps())).toBe('Agent Runner')
|
||||
expect(getSubGraphExtractorNodeId(createAgentProps())).toBe('agent-tool_ext_context')
|
||||
})
|
||||
|
||||
it('should strip the injected agent context prefix from the prompt text', () => {
|
||||
expect(getSubGraphPromptText(createAgentProps())).toBe('hello world')
|
||||
expect(getSubGraphPromptText(createAssembleProps())).toBe('')
|
||||
})
|
||||
|
||||
it('should build the correct start node shape for each variant', () => {
|
||||
expect(buildSubGraphStartNode(createAssembleProps(), 'Assembler').data.iconType).toBe('assemble')
|
||||
expect(buildSubGraphStartNode(createAgentProps(), 'Agent Runner').data.iconType).toBe('agent')
|
||||
})
|
||||
|
||||
it('should apply the agent prompt text to the extractor display node', () => {
|
||||
const extractorDisplayNode = buildSubGraphExtractorDisplayNode(createAgentProps(), 'hello world')
|
||||
|
||||
expect(extractorDisplayNode?.data.selected).toBe(false)
|
||||
expect(extractorDisplayNode?.data.prompt_template).toEqual([
|
||||
{ role: PromptRole.system, text: 'keep me' },
|
||||
{ role: PromptRole.user, text: 'hello world', edition_type: EditionType.jinja2, jinja2_text: 'hello world' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should preserve the assemble extractor payload without prompt rewrites', () => {
|
||||
const extractorDisplayNode = buildSubGraphExtractorDisplayNode(createAssembleProps(), '')
|
||||
|
||||
expect(extractorDisplayNode?.data.selected).toBe(false)
|
||||
expect((extractorDisplayNode?.data as Record<string, unknown> | undefined)?.prompt_template).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should build edges targeting the correct block type for each variant', () => {
|
||||
const agentProps = createAgentProps()
|
||||
const assembleProps = createAssembleProps()
|
||||
const agentStartNode = buildSubGraphStartNode(agentProps, 'Agent Runner')
|
||||
const assembleStartNode = buildSubGraphStartNode(assembleProps, 'Assembler')
|
||||
const agentExtractor = buildSubGraphExtractorDisplayNode(agentProps, 'hello world')
|
||||
const assembleExtractor = buildSubGraphExtractorDisplayNode(assembleProps, '')
|
||||
|
||||
expect(buildSubGraphEdges(agentProps, agentStartNode, agentExtractor)[0].data.targetType).toBe(BlockEnum.LLM)
|
||||
expect(buildSubGraphEdges(assembleProps, assembleStartNode, assembleExtractor)[0].data.targetType).toBe(BlockEnum.Code)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,34 @@
|
||||
import type { Edge, Node } from '@/app/components/workflow/types'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useSubGraphNodes } from '../use-sub-graph-nodes'
|
||||
|
||||
const mockInitialNodes = vi.fn()
|
||||
const mockInitialEdges = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/workflow/utils', () => ({
|
||||
initialNodes: (...args: unknown[]) => mockInitialNodes(...args),
|
||||
initialEdges: (...args: unknown[]) => mockInitialEdges(...args),
|
||||
}))
|
||||
|
||||
describe('useSubGraphNodes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should process nodes and edges through the workflow initializers', () => {
|
||||
const nodes = [{ id: 'node-1' }] as Node[]
|
||||
const edges = [{ id: 'edge-1' }] as Edge[]
|
||||
mockInitialNodes.mockReturnValue([{ id: 'processed-node' }])
|
||||
mockInitialEdges.mockReturnValue([{ id: 'processed-edge' }])
|
||||
|
||||
const { result } = renderHook(() => useSubGraphNodes(nodes, edges))
|
||||
|
||||
expect(mockInitialNodes).toHaveBeenCalledWith(nodes, edges)
|
||||
expect(mockInitialEdges).toHaveBeenCalledWith(edges, nodes)
|
||||
expect(result.current).toEqual({
|
||||
nodes: [{ id: 'processed-node' }],
|
||||
edges: [{ id: 'processed-edge' }],
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -2,40 +2,28 @@ import type { FC } from 'react'
|
||||
import type { Viewport } from 'reactflow'
|
||||
import type { SubGraphProps } from './types'
|
||||
import type { InjectWorkflowStoreSliceFn } from '@/app/components/workflow/store'
|
||||
import type { PromptItem, PromptTemplateItem } from '@/app/components/workflow/types'
|
||||
import { memo, useEffect, useMemo } from 'react'
|
||||
import WorkflowWithDefaultContext from '@/app/components/workflow'
|
||||
import { NODE_WIDTH_X_OFFSET, START_INITIAL_POSITION } from '@/app/components/workflow/constants'
|
||||
import { WorkflowContextProvider } from '@/app/components/workflow/context'
|
||||
import { CUSTOM_SUB_GRAPH_START_NODE } from '@/app/components/workflow/nodes/sub-graph-start/constants'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import { BlockEnum, EditionType, isPromptMessageContext, PromptRole } from '@/app/components/workflow/types'
|
||||
import SubGraphMain from './components/sub-graph-main'
|
||||
import { useSubGraphNodes } from './hooks'
|
||||
import { createSubGraphSlice } from './store'
|
||||
import {
|
||||
buildSubGraphEdges,
|
||||
buildSubGraphExtractorDisplayNode,
|
||||
buildSubGraphStartNode,
|
||||
defaultViewport,
|
||||
getSubGraphExtractorNodeId,
|
||||
getSubGraphPromptText,
|
||||
getSubGraphSourceTitle,
|
||||
} from './utils'
|
||||
|
||||
const SUB_GRAPH_EDGE_GAP = 160
|
||||
const SUB_GRAPH_ENTRY_POSITION = {
|
||||
x: START_INITIAL_POSITION.x,
|
||||
y: 150,
|
||||
}
|
||||
const SUB_GRAPH_EXTRACTOR_POSITION = {
|
||||
x: SUB_GRAPH_ENTRY_POSITION.x + NODE_WIDTH_X_OFFSET - SUB_GRAPH_EDGE_GAP,
|
||||
y: SUB_GRAPH_ENTRY_POSITION.y,
|
||||
}
|
||||
|
||||
const defaultViewport: Viewport = {
|
||||
x: SUB_GRAPH_EDGE_GAP,
|
||||
y: 50,
|
||||
zoom: 1,
|
||||
}
|
||||
const resolvedDefaultViewport: Viewport = defaultViewport
|
||||
|
||||
const SubGraphContent: FC<SubGraphProps> = (props) => {
|
||||
const {
|
||||
isOpen,
|
||||
toolNodeId,
|
||||
paramKey,
|
||||
toolParamValue,
|
||||
parentAvailableNodes,
|
||||
parentAvailableVars,
|
||||
configsMap,
|
||||
@ -46,10 +34,6 @@ const SubGraphContent: FC<SubGraphProps> = (props) => {
|
||||
onPendingSingleRunHandled,
|
||||
} = props
|
||||
|
||||
const isAgentVariant = props.variant === 'agent'
|
||||
const sourceTitle = isAgentVariant ? (props.agentName || '') : (props.title || '')
|
||||
const resolvedAgentNodeId = isAgentVariant ? props.agentNodeId : ''
|
||||
|
||||
const setParentAvailableVars = useStore(state => state.setParentAvailableVars)
|
||||
const setParentAvailableNodes = useStore(state => state.setParentAvailableNodes)
|
||||
|
||||
@ -58,134 +42,10 @@ const SubGraphContent: FC<SubGraphProps> = (props) => {
|
||||
setParentAvailableNodes?.(parentAvailableNodes || [])
|
||||
}, [parentAvailableNodes, parentAvailableVars, setParentAvailableNodes, setParentAvailableVars])
|
||||
|
||||
const promptText = useMemo(() => {
|
||||
if (!isAgentVariant || !toolParamValue)
|
||||
return ''
|
||||
// Reason: escape agent id before building a regex pattern.
|
||||
const escapedAgentId = resolvedAgentNodeId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const leadingPattern = new RegExp(`^\\{\\{[@#]${escapedAgentId}\\.context[@#]\\}\\}`)
|
||||
return toolParamValue.replace(leadingPattern, '')
|
||||
}, [isAgentVariant, resolvedAgentNodeId, toolParamValue])
|
||||
|
||||
const startNode = useMemo(() => {
|
||||
if (!isAgentVariant) {
|
||||
return {
|
||||
id: 'subgraph-source',
|
||||
type: CUSTOM_SUB_GRAPH_START_NODE,
|
||||
position: SUB_GRAPH_ENTRY_POSITION,
|
||||
data: {
|
||||
type: BlockEnum.Start,
|
||||
title: sourceTitle,
|
||||
desc: '',
|
||||
selected: false,
|
||||
iconType: 'assemble',
|
||||
variables: [],
|
||||
},
|
||||
selected: false,
|
||||
selectable: false,
|
||||
draggable: false,
|
||||
connectable: false,
|
||||
focusable: false,
|
||||
deletable: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'subgraph-source',
|
||||
type: CUSTOM_SUB_GRAPH_START_NODE,
|
||||
position: SUB_GRAPH_ENTRY_POSITION,
|
||||
data: {
|
||||
type: BlockEnum.Start,
|
||||
title: sourceTitle,
|
||||
desc: '',
|
||||
selected: false,
|
||||
iconType: 'agent',
|
||||
variables: [],
|
||||
},
|
||||
selected: false,
|
||||
selectable: false,
|
||||
draggable: false,
|
||||
connectable: false,
|
||||
focusable: false,
|
||||
deletable: false,
|
||||
}
|
||||
}, [isAgentVariant, sourceTitle])
|
||||
|
||||
const extractorDisplayNode = useMemo(() => {
|
||||
if (isAgentVariant) {
|
||||
const extractorNode = props.extractorNode
|
||||
if (!extractorNode)
|
||||
return null
|
||||
|
||||
const applyPromptText = (item: PromptItem) => {
|
||||
if (item.edition_type === EditionType.jinja2) {
|
||||
return {
|
||||
...item,
|
||||
text: promptText,
|
||||
jinja2_text: promptText,
|
||||
}
|
||||
}
|
||||
return { ...item, text: promptText }
|
||||
}
|
||||
|
||||
const nextPromptTemplate = (() => {
|
||||
const template = extractorNode.data.prompt_template
|
||||
if (!Array.isArray(template))
|
||||
return applyPromptText(template as PromptItem)
|
||||
|
||||
const userIndex = template.findIndex(
|
||||
item => !isPromptMessageContext(item) && (item as PromptItem).role === PromptRole.user,
|
||||
)
|
||||
if (userIndex >= 0) {
|
||||
return template.map((item, index) => {
|
||||
if (index !== userIndex)
|
||||
return item
|
||||
return applyPromptText(item as PromptItem)
|
||||
}) as PromptTemplateItem[]
|
||||
}
|
||||
|
||||
const useJinja = template.some(
|
||||
item => !isPromptMessageContext(item) && (item as PromptItem).edition_type === EditionType.jinja2,
|
||||
)
|
||||
const defaultUserPrompt: PromptItem = useJinja
|
||||
? {
|
||||
role: PromptRole.user,
|
||||
text: promptText,
|
||||
jinja2_text: promptText,
|
||||
edition_type: EditionType.jinja2,
|
||||
}
|
||||
: { role: PromptRole.user, text: promptText }
|
||||
return [...template, defaultUserPrompt] as PromptTemplateItem[]
|
||||
})()
|
||||
|
||||
return {
|
||||
...extractorNode,
|
||||
hidden: false,
|
||||
selected: false,
|
||||
position: SUB_GRAPH_EXTRACTOR_POSITION,
|
||||
data: {
|
||||
...extractorNode.data,
|
||||
selected: false,
|
||||
prompt_template: nextPromptTemplate,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const extractorNode = props.extractorNode
|
||||
if (!extractorNode)
|
||||
return null
|
||||
|
||||
return {
|
||||
...extractorNode,
|
||||
hidden: false,
|
||||
selected: false,
|
||||
position: SUB_GRAPH_EXTRACTOR_POSITION,
|
||||
data: {
|
||||
...extractorNode.data,
|
||||
selected: false,
|
||||
},
|
||||
}
|
||||
}, [isAgentVariant, promptText, props.extractorNode])
|
||||
const sourceTitle = useMemo(() => getSubGraphSourceTitle(props), [props])
|
||||
const promptText = useMemo(() => getSubGraphPromptText(props), [props])
|
||||
const startNode = useMemo(() => buildSubGraphStartNode(props, sourceTitle), [props, sourceTitle])
|
||||
const extractorDisplayNode = useMemo(() => buildSubGraphExtractorDisplayNode(props, promptText), [props, promptText])
|
||||
|
||||
const nodesSource = useMemo(() => {
|
||||
if (!extractorDisplayNode)
|
||||
@ -194,32 +54,12 @@ const SubGraphContent: FC<SubGraphProps> = (props) => {
|
||||
return [startNode, extractorDisplayNode]
|
||||
}, [extractorDisplayNode, startNode])
|
||||
|
||||
const edgesSource = useMemo(() => {
|
||||
if (!extractorDisplayNode)
|
||||
return []
|
||||
|
||||
return [
|
||||
{
|
||||
id: `${startNode.id}-${extractorDisplayNode.id}`,
|
||||
source: startNode.id,
|
||||
sourceHandle: 'source',
|
||||
target: extractorDisplayNode.id,
|
||||
targetHandle: 'target',
|
||||
type: 'custom',
|
||||
selectable: false,
|
||||
data: {
|
||||
sourceType: BlockEnum.Start,
|
||||
targetType: isAgentVariant ? BlockEnum.LLM : BlockEnum.Code,
|
||||
_isTemp: true,
|
||||
_isSubGraphTemp: true,
|
||||
},
|
||||
},
|
||||
]
|
||||
}, [extractorDisplayNode, isAgentVariant, startNode])
|
||||
const edgesSource = useMemo(() => buildSubGraphEdges(props, startNode, extractorDisplayNode), [extractorDisplayNode, props, startNode])
|
||||
|
||||
const { nodes, edges } = useSubGraphNodes(nodesSource, edgesSource)
|
||||
const extractorNodeId = getSubGraphExtractorNodeId(props)
|
||||
|
||||
if (isAgentVariant) {
|
||||
if (props.variant === 'agent') {
|
||||
return (
|
||||
<WorkflowWithDefaultContext
|
||||
nodes={nodes}
|
||||
@ -229,9 +69,9 @@ const SubGraphContent: FC<SubGraphProps> = (props) => {
|
||||
variant="agent"
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
viewport={defaultViewport}
|
||||
viewport={resolvedDefaultViewport}
|
||||
title={sourceTitle}
|
||||
extractorNodeId={`${toolNodeId}_ext_${paramKey}`}
|
||||
extractorNodeId={extractorNodeId}
|
||||
configsMap={configsMap}
|
||||
isOpen={isOpen}
|
||||
pendingSingleRun={pendingSingleRun}
|
||||
@ -255,9 +95,9 @@ const SubGraphContent: FC<SubGraphProps> = (props) => {
|
||||
variant="assemble"
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
viewport={defaultViewport}
|
||||
viewport={resolvedDefaultViewport}
|
||||
title={sourceTitle}
|
||||
extractorNodeId={`${toolNodeId}_ext_${paramKey}`}
|
||||
extractorNodeId={extractorNodeId}
|
||||
configsMap={configsMap}
|
||||
isOpen={isOpen}
|
||||
pendingSingleRun={pendingSingleRun}
|
||||
|
||||
28
web/app/components/sub-graph/store/__tests__/index.spec.ts
Normal file
28
web/app/components/sub-graph/store/__tests__/index.spec.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import type { Node, NodeOutPutVar } from '@/app/components/workflow/types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createSubGraphSlice } from '../index'
|
||||
|
||||
describe('createSubGraphSlice', () => {
|
||||
it('should initialize and update parent availability state', () => {
|
||||
let state = {} as ReturnType<typeof createSubGraphSlice>
|
||||
const set = (updater: (current: ReturnType<typeof createSubGraphSlice>) => Partial<ReturnType<typeof createSubGraphSlice>>) => {
|
||||
state = {
|
||||
...state,
|
||||
...updater(state),
|
||||
}
|
||||
}
|
||||
|
||||
state = createSubGraphSlice(set as never, (() => state) as never, {} as never)
|
||||
|
||||
expect(state.parentAvailableVars).toEqual([])
|
||||
expect(state.parentAvailableNodes).toEqual([])
|
||||
|
||||
const parentAvailableVars: NodeOutPutVar[] = [{ nodeId: 'node-1', title: 'Node 1', vars: [] }]
|
||||
state.setParentAvailableVars(parentAvailableVars)
|
||||
expect(state.parentAvailableVars).toEqual(parentAvailableVars)
|
||||
|
||||
const parentAvailableNodes = [{ id: 'node-1' }] as Node[]
|
||||
state.setParentAvailableNodes(parentAvailableNodes)
|
||||
expect(state.parentAvailableNodes).toEqual(parentAvailableNodes)
|
||||
})
|
||||
})
|
||||
169
web/app/components/sub-graph/utils.ts
Normal file
169
web/app/components/sub-graph/utils.ts
Normal file
@ -0,0 +1,169 @@
|
||||
import type { AgentSubGraphProps, AssembleSubGraphProps, SubGraphProps } from './types'
|
||||
import type { CodeNodeType } from '@/app/components/workflow/nodes/code/types'
|
||||
import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types'
|
||||
import type { Node, PromptItem, PromptTemplateItem } from '@/app/components/workflow/types'
|
||||
import { NODE_WIDTH_X_OFFSET, START_INITIAL_POSITION } from '@/app/components/workflow/constants'
|
||||
import { CUSTOM_SUB_GRAPH_START_NODE } from '@/app/components/workflow/nodes/sub-graph-start/constants'
|
||||
import { BlockEnum, EditionType, isPromptMessageContext, PromptRole } from '@/app/components/workflow/types'
|
||||
|
||||
const SUB_GRAPH_EDGE_GAP = 160
|
||||
|
||||
export const defaultViewport = {
|
||||
x: SUB_GRAPH_EDGE_GAP,
|
||||
y: 50,
|
||||
zoom: 1,
|
||||
} as const
|
||||
|
||||
export const SUB_GRAPH_ENTRY_POSITION = {
|
||||
x: START_INITIAL_POSITION.x,
|
||||
y: 150,
|
||||
} as const
|
||||
|
||||
export const SUB_GRAPH_EXTRACTOR_POSITION = {
|
||||
x: SUB_GRAPH_ENTRY_POSITION.x + NODE_WIDTH_X_OFFSET - SUB_GRAPH_EDGE_GAP,
|
||||
y: SUB_GRAPH_ENTRY_POSITION.y,
|
||||
} as const
|
||||
|
||||
export const getSubGraphSourceTitle = (props: SubGraphProps) => (
|
||||
props.variant === 'agent' ? (props.agentName || '') : (props.title || '')
|
||||
)
|
||||
|
||||
export const getSubGraphExtractorNodeId = (props: SubGraphProps) => (
|
||||
`${props.toolNodeId}_ext_${props.paramKey}`
|
||||
)
|
||||
|
||||
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
export const getSubGraphPromptText = (props: SubGraphProps) => {
|
||||
if (props.variant !== 'agent' || !props.toolParamValue)
|
||||
return ''
|
||||
|
||||
const leadingPattern = new RegExp(`^\\{\\{[@#]${escapeRegExp(props.agentNodeId)}\\.context[@#]\\}\\}`)
|
||||
return props.toolParamValue.replace(leadingPattern, '')
|
||||
}
|
||||
|
||||
export const buildSubGraphStartNode = (props: SubGraphProps, sourceTitle: string) => ({
|
||||
id: 'subgraph-source',
|
||||
type: CUSTOM_SUB_GRAPH_START_NODE,
|
||||
position: SUB_GRAPH_ENTRY_POSITION,
|
||||
data: {
|
||||
type: BlockEnum.Start,
|
||||
title: sourceTitle,
|
||||
desc: '',
|
||||
selected: false,
|
||||
iconType: props.variant === 'agent' ? 'agent' : 'assemble',
|
||||
variables: [],
|
||||
},
|
||||
selected: false,
|
||||
selectable: false,
|
||||
draggable: false,
|
||||
connectable: false,
|
||||
focusable: false,
|
||||
deletable: false,
|
||||
})
|
||||
|
||||
const applyPromptText = (item: PromptItem, promptText: string): PromptItem => {
|
||||
if (item.edition_type === EditionType.jinja2) {
|
||||
return {
|
||||
...item,
|
||||
text: promptText,
|
||||
jinja2_text: promptText,
|
||||
}
|
||||
}
|
||||
|
||||
return { ...item, text: promptText }
|
||||
}
|
||||
|
||||
const buildExtractorPromptTemplate = (template: PromptTemplateItem | PromptTemplateItem[], promptText: string) => {
|
||||
if (!Array.isArray(template))
|
||||
return applyPromptText(template as PromptItem, promptText)
|
||||
|
||||
const userIndex = template.findIndex(
|
||||
item => !isPromptMessageContext(item) && (item as PromptItem).role === PromptRole.user,
|
||||
)
|
||||
|
||||
if (userIndex >= 0) {
|
||||
return template.map((item, index) => {
|
||||
if (index !== userIndex)
|
||||
return item
|
||||
return applyPromptText(item as PromptItem, promptText)
|
||||
}) as PromptTemplateItem[]
|
||||
}
|
||||
|
||||
const useJinja = template.some(
|
||||
item => !isPromptMessageContext(item) && (item as PromptItem).edition_type === EditionType.jinja2,
|
||||
)
|
||||
const defaultUserPrompt: PromptItem = useJinja
|
||||
? {
|
||||
role: PromptRole.user,
|
||||
text: promptText,
|
||||
jinja2_text: promptText,
|
||||
edition_type: EditionType.jinja2,
|
||||
}
|
||||
: { role: PromptRole.user, text: promptText }
|
||||
|
||||
return [...template, defaultUserPrompt] as PromptTemplateItem[]
|
||||
}
|
||||
|
||||
export function buildSubGraphExtractorDisplayNode(props: AgentSubGraphProps, promptText: string): Node<LLMNodeType> | null
|
||||
export function buildSubGraphExtractorDisplayNode(props: AssembleSubGraphProps, promptText: string): Node<CodeNodeType> | null
|
||||
export function buildSubGraphExtractorDisplayNode(props: SubGraphProps, promptText: string): Node<LLMNodeType> | Node<CodeNodeType> | null
|
||||
export function buildSubGraphExtractorDisplayNode(props: SubGraphProps, promptText: string): Node<LLMNodeType> | Node<CodeNodeType> | null {
|
||||
const extractorNode = props.extractorNode
|
||||
if (!extractorNode)
|
||||
return null
|
||||
|
||||
if (props.variant !== 'agent') {
|
||||
const assembleExtractorNode = extractorNode as Node<CodeNodeType>
|
||||
return {
|
||||
...assembleExtractorNode,
|
||||
hidden: false,
|
||||
selected: false,
|
||||
position: SUB_GRAPH_EXTRACTOR_POSITION,
|
||||
data: {
|
||||
...assembleExtractorNode.data,
|
||||
selected: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const agentExtractorNode = extractorNode as Node<LLMNodeType>
|
||||
return {
|
||||
...agentExtractorNode,
|
||||
hidden: false,
|
||||
selected: false,
|
||||
position: SUB_GRAPH_EXTRACTOR_POSITION,
|
||||
data: {
|
||||
...agentExtractorNode.data,
|
||||
selected: false,
|
||||
prompt_template: buildExtractorPromptTemplate(agentExtractorNode.data.prompt_template, promptText),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const buildSubGraphEdges = (
|
||||
props: SubGraphProps,
|
||||
startNode: ReturnType<typeof buildSubGraphStartNode>,
|
||||
extractorDisplayNode: Node<LLMNodeType> | Node<CodeNodeType> | null,
|
||||
) => {
|
||||
if (!extractorDisplayNode)
|
||||
return []
|
||||
|
||||
return [
|
||||
{
|
||||
id: `${startNode.id}-${extractorDisplayNode.id}`,
|
||||
source: startNode.id,
|
||||
sourceHandle: 'source',
|
||||
target: extractorDisplayNode.id,
|
||||
targetHandle: 'target',
|
||||
type: 'custom',
|
||||
selectable: false,
|
||||
data: {
|
||||
sourceType: BlockEnum.Start,
|
||||
targetType: props.variant === 'agent' ? BlockEnum.LLM : BlockEnum.Code,
|
||||
_isTemp: true,
|
||||
_isSubGraphTemp: true,
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@ -138,7 +138,7 @@ export const fetchPromptTemplate = ({
|
||||
modelName,
|
||||
hasSetDataSet,
|
||||
}: { appMode: AppModeEnum, mode: ModelModeType, modelName: string, hasSetDataSet: boolean }) => {
|
||||
return get<Promise<{ chat_prompt_config: ChatPromptConfig, completion_prompt_config: CompletionPromptConfig, stop: [] }>>('/app/prompt-templates', {
|
||||
return get<{ chat_prompt_config: ChatPromptConfig, completion_prompt_config: CompletionPromptConfig, stop: string[] }>('/app/prompt-templates', {
|
||||
params: {
|
||||
app_mode: appMode,
|
||||
model_mode: mode,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user