mirror of
https://github.com/langgenius/dify.git
synced 2026-05-14 19:00:33 -04:00
Merge remote-tracking branch 'upstream/feat/hitl-form-enhancement' into feat/hitl-form-enhancement
This commit is contained in:
@@ -79,6 +79,9 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
...actual,
|
||||
useQuery: () => ({
|
||||
data: [],
|
||||
}),
|
||||
useInfiniteQuery: () => ({
|
||||
data: { pages: mockPages },
|
||||
isLoading: mockIsLoading,
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { usePathname, useRouter } from '@/next/navigation'
|
||||
import { useDatasetDetail, useDatasetRelatedApps } from '@/service/knowledge/use-dataset'
|
||||
import DatasetDetailLayout from '../layout-main'
|
||||
|
||||
const mockReplace = vi.fn()
|
||||
const mockSetAppSidebarExpand = vi.fn()
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
usePathname: vi.fn(),
|
||||
useRouter: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useDatasetDetail: vi.fn(),
|
||||
useDatasetRelatedApps: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: { setAppSidebarExpand: typeof mockSetAppSidebarExpand }) => unknown) => selector({
|
||||
setAppSidebarExpand: mockSetAppSidebarExpand,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => ({
|
||||
eventEmitter: undefined,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
default: () => 'desktop',
|
||||
MediaType: {
|
||||
mobile: 'mobile',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app-sidebar', () => ({
|
||||
default: () => <aside aria-label="dataset navigation" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/extra-info', () => ({
|
||||
default: () => <div />,
|
||||
}))
|
||||
|
||||
const mockUsePathname = vi.mocked(usePathname)
|
||||
const mockUseRouter = vi.mocked(useRouter)
|
||||
const mockUseDatasetDetail = vi.mocked(useDatasetDetail)
|
||||
const mockUseDatasetRelatedApps = vi.mocked(useDatasetRelatedApps)
|
||||
|
||||
describe('DatasetDetailLayout', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUsePathname.mockReturnValue('/datasets/dataset-1/pipeline')
|
||||
mockUseRouter.mockReturnValue({
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
push: vi.fn(),
|
||||
replace: mockReplace,
|
||||
prefetch: vi.fn(),
|
||||
})
|
||||
mockUseDatasetRelatedApps.mockReturnValue({ data: undefined } as ReturnType<typeof useDatasetRelatedApps>)
|
||||
})
|
||||
|
||||
describe('Access Errors', () => {
|
||||
it.each([403, 404])('should redirect to datasets page when dataset detail returns %s', async (status) => {
|
||||
// Arrange
|
||||
mockUseDatasetDetail.mockReturnValue({
|
||||
data: undefined,
|
||||
error: new Response(null, { status }),
|
||||
refetch: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useDatasetDetail>)
|
||||
|
||||
// Act
|
||||
render(
|
||||
<DatasetDetailLayout datasetId="dataset-1">
|
||||
<div>Pipeline content</div>
|
||||
</DatasetDetailLayout>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenCalledWith('/datasets')
|
||||
})
|
||||
expect(mockUseDatasetRelatedApps).toHaveBeenCalledWith('dataset-1', { enabled: false })
|
||||
expect(screen.queryByText('Pipeline content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should redirect when the dataset detail error exposes status without being a Response', async () => {
|
||||
// Arrange
|
||||
mockUseDatasetDetail.mockReturnValue({
|
||||
data: undefined,
|
||||
error: { status: 403 },
|
||||
refetch: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useDatasetDetail>)
|
||||
|
||||
// Act
|
||||
render(
|
||||
<DatasetDetailLayout datasetId="dataset-1">
|
||||
<div>Pipeline content</div>
|
||||
</DatasetDetailLayout>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenCalledWith('/datasets')
|
||||
})
|
||||
expect(screen.queryByText('Pipeline content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render children when dataset detail is available', () => {
|
||||
// Arrange
|
||||
mockUseDatasetDetail.mockReturnValue({
|
||||
data: {
|
||||
id: 'dataset-1',
|
||||
name: 'Dataset 1',
|
||||
provider: 'vendor',
|
||||
runtime_mode: 'rag_pipeline',
|
||||
is_published: true,
|
||||
},
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useDatasetDetail>)
|
||||
|
||||
// Act
|
||||
render(
|
||||
<DatasetDetailLayout datasetId="dataset-1">
|
||||
<div>Pipeline content</div>
|
||||
</DatasetDetailLayout>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Pipeline content')).toBeInTheDocument()
|
||||
expect(mockUseDatasetRelatedApps).toHaveBeenCalledWith('dataset-1', { enabled: true })
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -23,7 +23,7 @@ import DatasetDetailContext from '@/context/dataset-detail'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { usePathname } from '@/next/navigation'
|
||||
import { usePathname, useRouter } from '@/next/navigation'
|
||||
import { useDatasetDetail, useDatasetRelatedApps } from '@/service/knowledge/use-dataset'
|
||||
|
||||
type IAppDetailLayoutProps = {
|
||||
@@ -31,12 +31,26 @@ type IAppDetailLayoutProps = {
|
||||
datasetId: string
|
||||
}
|
||||
|
||||
const getResponseStatus = (error: unknown) => {
|
||||
if (error instanceof Response)
|
||||
return error.status
|
||||
|
||||
if (typeof error === 'object' && error && 'status' in error && typeof error.status === 'number')
|
||||
return error.status
|
||||
}
|
||||
|
||||
const shouldRedirectToDatasetList = (error: unknown) => {
|
||||
const status = getResponseStatus(error)
|
||||
return status === 403 || status === 404
|
||||
}
|
||||
|
||||
const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
const {
|
||||
children,
|
||||
datasetId,
|
||||
} = props
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const hideSideBar = pathname.endsWith('documents/create') || pathname.endsWith('documents/create-from-pipeline')
|
||||
const isPipelineCanvas = pathname.endsWith('/pipeline')
|
||||
@@ -54,8 +68,9 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
const isMobile = media === MediaType.mobile
|
||||
|
||||
const { data: datasetRes, error, refetch: mutateDatasetRes } = useDatasetDetail(datasetId)
|
||||
const shouldRedirect = shouldRedirectToDatasetList(error)
|
||||
|
||||
const { data: relatedApps } = useDatasetRelatedApps(datasetId)
|
||||
const { data: relatedApps } = useDatasetRelatedApps(datasetId, { enabled: !!datasetRes && !shouldRedirect })
|
||||
|
||||
const isButtonDisabledWithPipeline = useMemo(() => {
|
||||
if (!datasetRes)
|
||||
@@ -115,9 +130,17 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
setAppSidebarExpand(isMobile ? mode : localeMode)
|
||||
}, [isMobile, setAppSidebarExpand])
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldRedirect)
|
||||
router.replace('/datasets')
|
||||
}, [router, shouldRedirect])
|
||||
|
||||
if (!datasetRes && !error)
|
||||
return <Loading type="app" />
|
||||
|
||||
if (shouldRedirect)
|
||||
return <Loading type="app" />
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -36,6 +36,7 @@ const mocks = vi.hoisted(() => {
|
||||
})),
|
||||
parseEditorState: vi.fn(() => ({ state: 'parsed' })),
|
||||
setEditorState: vi.fn(),
|
||||
setEditable: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
update: vi.fn((fn: () => void) => fn()),
|
||||
},
|
||||
@@ -71,6 +72,7 @@ vi.mock('lexical', async (importOriginal) => {
|
||||
})),
|
||||
getAllTextNodes: () => [],
|
||||
}),
|
||||
$nodesOfType: () => [],
|
||||
TextNode: class TextNode {
|
||||
__text: string
|
||||
constructor(text = '') {
|
||||
@@ -92,9 +94,8 @@ vi.mock('@lexical/react/LexicalComposer', () => ({
|
||||
try {
|
||||
initialConfig.onError(new Error('test error'))
|
||||
}
|
||||
catch (e) {
|
||||
// ignore error
|
||||
console.error(e)
|
||||
catch {
|
||||
// Ignore the intentional throw from the mocked error boundary path.
|
||||
}
|
||||
}
|
||||
if (initialConfig?.nodes) {
|
||||
@@ -328,6 +329,20 @@ describe('PromptEditor', () => {
|
||||
expect(screen.getByTestId('lexical-composer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should sync editable changes to the lexical editor instance', async () => {
|
||||
const { rerender } = render(<PromptEditor editable={true} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.editor.setEditable).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
rerender(<PromptEditor editable={false} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.editor.setEditable).toHaveBeenLastCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should render with isSupportFileVar=true', () => {
|
||||
render(<PromptEditor isSupportFileVar={true} />)
|
||||
expect(screen.getByTestId('lexical-composer')).toBeInTheDocument()
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
import type { InitialConfigType } from '@lexical/react/LexicalComposer'
|
||||
import type {
|
||||
EditorState,
|
||||
LexicalCommand,
|
||||
} from 'lexical'
|
||||
import type { FC } from 'react'
|
||||
import type { Hotkey } from './plugins/shortcuts-popup-plugin'
|
||||
import type { Hotkey, ShortcutPopupInsertHandler } from './plugins/shortcuts-popup-plugin'
|
||||
import type {
|
||||
ContextBlockType,
|
||||
CurrentBlockType,
|
||||
@@ -97,6 +96,16 @@ const ValueSyncPlugin: FC<{ value?: string }> = ({ value }) => {
|
||||
return null
|
||||
}
|
||||
|
||||
const EditableSyncPlugin: FC<{ editable: boolean }> = ({ editable }) => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
editor.setEditable(editable)
|
||||
}, [editor, editable])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export type PromptEditorProps = {
|
||||
instanceId?: string
|
||||
compact?: boolean
|
||||
@@ -122,7 +131,7 @@ export type PromptEditorProps = {
|
||||
errorMessageBlock?: ErrorMessageBlockType
|
||||
lastRunBlock?: LastRunBlockType
|
||||
isSupportFileVar?: boolean
|
||||
shortcutPopups?: Array<{ hotkey: Hotkey, Popup: React.ComponentType<{ onClose: () => void, onInsert: (command: LexicalCommand<unknown>, params: any[]) => void }> }>
|
||||
shortcutPopups?: Array<{ hotkey: Hotkey, Popup: React.ComponentType<{ onClose: () => void, onInsert: ShortcutPopupInsertHandler }> }>
|
||||
}
|
||||
|
||||
const PromptEditor: FC<PromptEditorProps> = ({
|
||||
@@ -194,13 +203,13 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
||||
eventEmitter?.emit({
|
||||
type: UPDATE_DATASETS_EVENT_EMITTER,
|
||||
payload: contextBlock?.datasets,
|
||||
} as any)
|
||||
})
|
||||
}, [eventEmitter, contextBlock?.datasets])
|
||||
useEffect(() => {
|
||||
eventEmitter?.emit({
|
||||
type: UPDATE_HISTORY_EVENT_EMITTER,
|
||||
payload: historyBlock?.history,
|
||||
} as any)
|
||||
})
|
||||
}, [eventEmitter, historyBlock?.history])
|
||||
|
||||
const [floatingAnchorElem, setFloatingAnchorElem] = useState<HTMLDivElement | null>(null)
|
||||
@@ -243,6 +252,7 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
||||
onEditorChange={handleEditorChange}
|
||||
/>
|
||||
<ValueSyncPlugin value={value} />
|
||||
<EditableSyncPlugin editable={editable} />
|
||||
</div>
|
||||
</LexicalComposer>
|
||||
)
|
||||
|
||||
@@ -4,8 +4,10 @@ import type { FormInputItem, ParagraphFormInput } from '@/app/components/workflo
|
||||
import type { ValueSelector } from '@/app/components/workflow/types'
|
||||
|
||||
import { LexicalComposer } from '@lexical/react/LexicalComposer'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { BlockEnum, InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import HITLInputComponentUI from '../component-ui'
|
||||
import { HITLInputNode } from '../node'
|
||||
|
||||
@@ -113,6 +115,57 @@ describe('HITLInputComponentUI', () => {
|
||||
expect(screen.queryByRole('button', { name: 'common.operation.remove' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close the edit modal when readonly becomes true', async () => {
|
||||
let setReadonlyValue: ((readonly: boolean) => void) | undefined
|
||||
const Harness = () => {
|
||||
const [readonly, setReadonly] = useState(false)
|
||||
const [namespace] = useState(() => `hitl-input-test-${crypto.randomUUID()}`)
|
||||
|
||||
useEffect(() => {
|
||||
setReadonlyValue = setReadonly
|
||||
return () => {
|
||||
setReadonlyValue = undefined
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<LexicalComposer
|
||||
initialConfig={{
|
||||
namespace,
|
||||
onError: (error: Error) => {
|
||||
throw error
|
||||
},
|
||||
nodes: [HITLInputNode],
|
||||
}}
|
||||
>
|
||||
<HITLInputComponentUI
|
||||
nodeId="node-1"
|
||||
varName="customer_name"
|
||||
workflowNodesMap={createWorkflowNodesMap()}
|
||||
onChange={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
readonly={readonly}
|
||||
/>
|
||||
</LexicalComposer>
|
||||
)
|
||||
}
|
||||
|
||||
render(<Harness />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'common.operation.edit' }))
|
||||
|
||||
expect(await screen.findByRole('textbox')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
setReadonlyValue?.(true)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render select option summary for constant options', () => {
|
||||
const { getByText } = renderComponent({
|
||||
formInput: {
|
||||
@@ -212,10 +265,33 @@ describe('HITLInputComponentUI', () => {
|
||||
|
||||
expect(queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should prevent renaming to an existing variable name', async () => {
|
||||
const {
|
||||
findByRole,
|
||||
onChange,
|
||||
onRename,
|
||||
} = renderComponent({
|
||||
unavailableVariableNames: ['existing_name'],
|
||||
})
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'common.operation.edit' }))
|
||||
|
||||
const textbox = await findByRole('textbox')
|
||||
fireEvent.change(textbox, { target: { value: 'existing_name' } })
|
||||
|
||||
expect(screen.getByText('workflow.nodes.humanInput.insertInputField.variableNameDuplicated')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeDisabled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
expect(onRename).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Default formInput', () => {
|
||||
it('should pass default payload to InputField when formInput is undefined', async () => {
|
||||
it('should open an empty default editor when formInput is undefined', async () => {
|
||||
const { findByRole } = renderComponent({
|
||||
formInput: undefined,
|
||||
})
|
||||
@@ -223,10 +299,10 @@ describe('HITLInputComponentUI', () => {
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'common.operation.edit' }))
|
||||
|
||||
const textbox = await findByRole('textbox')
|
||||
const saveButton = await screen.findByRole('button', { name: 'common.operation.save' })
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(textbox).toHaveValue('customer_name')
|
||||
expect(textbox).toHaveValue('')
|
||||
expect(saveButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should render variable selector when workflowNodesMap fallback is used', () => {
|
||||
|
||||
@@ -129,6 +129,31 @@ describe('HITLInputComponent', () => {
|
||||
expect(onChange.mock.calls[0]![0][0].output_variable_name).toBe('renamed_name')
|
||||
})
|
||||
|
||||
it('should ignore rename when the target variable name already exists', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<HITLInputComponent
|
||||
nodeKey="node-key-duplicate"
|
||||
nodeId="node-duplicate"
|
||||
varName="user_name"
|
||||
formInputs={[
|
||||
createInput(),
|
||||
createInput({ output_variable_name: 'renamed_name' }),
|
||||
]}
|
||||
onChange={onChange}
|
||||
onRename={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
workflowNodesMap={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'emit-rename' }))
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update existing payload when variable name stays the same', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { LexicalEditor } from 'lexical'
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import { LexicalComposer } from '@lexical/react/LexicalComposer'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { act, render, waitFor } from '@testing-library/react'
|
||||
import {
|
||||
$nodesOfType,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
} from 'lexical'
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
BlockEnum,
|
||||
InputVarType,
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
import { CustomTextNode } from '../../custom-text/node'
|
||||
import {
|
||||
getNodeCount,
|
||||
readEditorStateValue,
|
||||
readRootTextContent,
|
||||
renderLexicalEditor,
|
||||
selectRootEnd,
|
||||
@@ -76,6 +79,12 @@ const createInsertPayload = () => ({
|
||||
onFormInputItemRemove: vi.fn(),
|
||||
})
|
||||
|
||||
const readHITLReadonlyValues = (editor: LexicalEditor): boolean[] => {
|
||||
return readEditorStateValue(editor, () => {
|
||||
return $nodesOfType(HITLInputNode).map(node => node.getReadonly())
|
||||
})
|
||||
}
|
||||
|
||||
const renderHITLInputBlock = (props?: {
|
||||
onInsert?: () => void
|
||||
onDelete?: () => void
|
||||
@@ -169,6 +178,65 @@ describe('HITLInputBlock', () => {
|
||||
expect(getNodeCount(editor, HITLInputNode)).toBe(1)
|
||||
})
|
||||
|
||||
it('should update existing and newly inserted nodes when readonly changes', async () => {
|
||||
let setReadonlyValue: ((readonly: boolean) => void) | undefined
|
||||
const ReadonlyHarness = () => {
|
||||
const [readonly, setReadonly] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setReadonlyValue = setReadonly
|
||||
return () => {
|
||||
setReadonlyValue = undefined
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<HITLInputBlock
|
||||
nodeId="node-1"
|
||||
formInputs={[createFormInput()]}
|
||||
onFormInputItemRename={vi.fn()}
|
||||
onFormInputItemRemove={vi.fn()}
|
||||
workflowNodesMap={createWorkflowNodesMap('First Node')}
|
||||
readonly={readonly}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const { getEditor } = renderLexicalEditor({
|
||||
namespace: 'hitl-input-block-readonly-update-test',
|
||||
nodes: [CustomTextNode, HITLInputNode],
|
||||
children: <ReadonlyHarness />,
|
||||
})
|
||||
|
||||
const editor = await waitForEditorReady(getEditor)
|
||||
|
||||
selectRootEnd(editor)
|
||||
act(() => {
|
||||
editor.dispatchCommand(INSERT_HITL_INPUT_BLOCK_COMMAND, createInsertPayload())
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(readHITLReadonlyValues(editor)).toEqual([false])
|
||||
})
|
||||
|
||||
act(() => {
|
||||
setReadonlyValue?.(true)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(readHITLReadonlyValues(editor)).toEqual([true])
|
||||
})
|
||||
|
||||
selectRootEnd(editor)
|
||||
act(() => {
|
||||
editor.dispatchCommand(INSERT_HITL_INPUT_BLOCK_COMMAND, createInsertPayload())
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(readHITLReadonlyValues(editor)).toEqual([true, true])
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onDelete when delete command is dispatched', async () => {
|
||||
const onDelete = vi.fn()
|
||||
const { getEditor } = renderHITLInputBlock({ onDelete })
|
||||
|
||||
@@ -116,6 +116,31 @@ describe('InputField', () => {
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should disable save and show validation error when variable name already exists', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<InputField
|
||||
nodeId="node-duplicate-name"
|
||||
isEdit={false}
|
||||
payload={createPayload()}
|
||||
unavailableVariableNames={['existing_name']}
|
||||
onChange={onChange}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
await user.clear(inputs[0]!)
|
||||
await user.type(inputs[0]!, 'existing_name')
|
||||
|
||||
expect(screen.getByText('workflow.nodes.humanInput.insertInputField.variableNameDuplicated')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i })).toBeDisabled()
|
||||
await user.keyboard('{Control>}{Enter}{/Control}')
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onChange when saving a valid payload in edit mode', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
@@ -98,6 +98,8 @@ describe('HITLInputNode', () => {
|
||||
expect(node.getConversationVariables()).toEqual(props.conversationVariables)
|
||||
expect(node.getRagVariables()).toEqual(props.ragVariables)
|
||||
expect(node.getReadonly()).toBe(true)
|
||||
node.setReadonly(false)
|
||||
expect(node.getReadonly()).toBe(false)
|
||||
expect(node.getTextContent()).toBe('{{#$output.user_name#}}')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,6 +26,7 @@ type HITLInputComponentUIProps = {
|
||||
nodeId: string
|
||||
varName: string
|
||||
formInput?: FormInputItem
|
||||
unavailableVariableNames?: string[]
|
||||
onChange: (input: FormInputItem) => void
|
||||
onRename: (payload: FormInputItem, oldName: string) => void
|
||||
onRemove: (varName: string) => void
|
||||
@@ -44,6 +45,7 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
|
||||
nodeId,
|
||||
varName,
|
||||
formInput,
|
||||
unavailableVariableNames = [],
|
||||
onChange,
|
||||
onRename,
|
||||
onRemove,
|
||||
@@ -64,6 +66,11 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
|
||||
setFalse: hideEditModal,
|
||||
}] = useBoolean(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (readonly)
|
||||
hideEditModal()
|
||||
}, [hideEditModal, readonly])
|
||||
|
||||
// Lexical delegate the click make it unable to add click by the method of react
|
||||
const editBtnRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
@@ -91,12 +98,15 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
|
||||
}, [onRemove, varName])
|
||||
|
||||
const handleChange = useCallback((newPayload: FormInputItem) => {
|
||||
if (newPayload.output_variable_name !== varName && unavailableVariableNames.includes(newPayload.output_variable_name))
|
||||
return
|
||||
|
||||
if (varName === newPayload.output_variable_name)
|
||||
onChange(newPayload)
|
||||
else
|
||||
onRename(newPayload, varName)
|
||||
hideEditModal()
|
||||
}, [hideEditModal, onChange, onRename, varName])
|
||||
}, [hideEditModal, onChange, onRename, unavailableVariableNames, varName])
|
||||
|
||||
const isDefaultValueVariable = useMemo(() => {
|
||||
return paragraphDefault?.type === 'variable'
|
||||
@@ -203,6 +213,7 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
|
||||
nodeId={nodeId}
|
||||
isEdit
|
||||
payload={formInput}
|
||||
unavailableVariableNames={unavailableVariableNames}
|
||||
onChange={handleChange}
|
||||
onCancel={hideEditModal}
|
||||
/>
|
||||
|
||||
@@ -45,8 +45,14 @@ const HITLInputComponent: FC<HITLInputComponentProps> = ({
|
||||
}) => {
|
||||
const [ref] = useSelectOrDelete(nodeKey, DELETE_HITL_INPUT_BLOCK_COMMAND)
|
||||
const payload = formInputs.find(item => item.output_variable_name === varName)
|
||||
const unavailableVariableNames = formInputs
|
||||
.map(item => item.output_variable_name)
|
||||
.filter(name => name !== varName)
|
||||
|
||||
const handleChange = useCallback((newPayload: FormInputItem) => {
|
||||
if (newPayload.output_variable_name !== varName && unavailableVariableNames.includes(newPayload.output_variable_name))
|
||||
return
|
||||
|
||||
if (!payload) {
|
||||
onChange([...formInputs, newPayload])
|
||||
return
|
||||
@@ -58,7 +64,7 @@ const HITLInputComponent: FC<HITLInputComponentProps> = ({
|
||||
return
|
||||
}
|
||||
onChange(formInputs.map(item => item.output_variable_name === varName ? newPayload : item))
|
||||
}, [formInputs, onChange, payload, varName])
|
||||
}, [formInputs, onChange, payload, unavailableVariableNames, varName])
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -69,6 +75,7 @@ const HITLInputComponent: FC<HITLInputComponentProps> = ({
|
||||
nodeId={nodeId}
|
||||
varName={varName}
|
||||
formInput={payload}
|
||||
unavailableVariableNames={unavailableVariableNames}
|
||||
onChange={handleChange}
|
||||
onRename={onRename}
|
||||
onRemove={onRemove}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { TextNode } from 'lexical'
|
||||
import type { HITLInputBlockType } from '../../types'
|
||||
import type { Var } from '@/app/components/workflow/types'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import { $applyNodeReplacement } from 'lexical'
|
||||
@@ -31,7 +32,7 @@ const HITLInputReplacementBlock = ({
|
||||
|
||||
const environmentVariables = useMemo(() => variables?.find(o => o.nodeId === 'env')?.vars || [], [variables])
|
||||
const conversationVariables = useMemo(() => variables?.find(o => o.nodeId === 'conversation')?.vars || [], [variables])
|
||||
const ragVariables = useMemo(() => variables?.reduce<any[]>((acc, curr) => {
|
||||
const ragVariables = useMemo(() => variables?.reduce<Var[]>((acc, curr) => {
|
||||
if (curr.nodeId === 'rag')
|
||||
acc.push(...curr.vars)
|
||||
else
|
||||
@@ -81,7 +82,7 @@ const HITLInputReplacementBlock = ({
|
||||
return mergeRegister(
|
||||
editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransform(textNode, getMatch, createHITLInputBlockNode)),
|
||||
)
|
||||
}, [])
|
||||
}, [editor, getMatch, createHITLInputBlockNode])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import {
|
||||
$insertNodes,
|
||||
$nodesOfType,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
createCommand,
|
||||
} from 'lexical'
|
||||
@@ -43,6 +44,14 @@ const HITLInputBlock = memo(({
|
||||
})
|
||||
}, [editor, workflowNodesMap, workflowAvailableVariables])
|
||||
|
||||
useEffect(() => {
|
||||
editor.update(() => {
|
||||
$nodesOfType(HITLInputNode).forEach((node) => {
|
||||
node.setReadonly(readonly)
|
||||
})
|
||||
})
|
||||
}, [editor, readonly])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([HITLInputNode]))
|
||||
throw new Error('HITLInputBlockPlugin: HITLInputBlock not registered on editor')
|
||||
@@ -95,7 +104,7 @@ const HITLInputBlock = memo(({
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
)
|
||||
}, [editor, onInsert, onDelete])
|
||||
}, [editor, onInsert, onDelete, workflowNodesMap, getVarType, readonly])
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
@@ -31,6 +31,7 @@ type InputFieldProps = {
|
||||
nodeId: string
|
||||
isEdit: boolean
|
||||
payload?: FormInputItem
|
||||
unavailableVariableNames?: string[]
|
||||
onChange: (newPayload: FormInputItem) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
@@ -38,6 +39,7 @@ const InputField: React.FC<InputFieldProps> = ({
|
||||
nodeId,
|
||||
isEdit,
|
||||
payload,
|
||||
unavailableVariableNames = [],
|
||||
onChange,
|
||||
onCancel,
|
||||
}) => {
|
||||
@@ -73,14 +75,24 @@ const InputField: React.FC<InputFieldProps> = ({
|
||||
|
||||
return createDefaultParagraphFormInput(tempPayload.output_variable_name)
|
||||
}, [tempPayload])
|
||||
const nameValid = useMemo(() => {
|
||||
const unavailableVariableNameSet = useMemo(() => {
|
||||
return new Set(unavailableVariableNames.map(name => name.trim()).filter(Boolean))
|
||||
}, [unavailableVariableNames])
|
||||
const variableNameError = useMemo(() => {
|
||||
const name = tempPayload.output_variable_name.trim()
|
||||
if (!name)
|
||||
return false
|
||||
return null
|
||||
if (name.includes(' '))
|
||||
return false
|
||||
return /^[a-z_]\w{0,29}$/.test(name)
|
||||
}, [tempPayload.output_variable_name])
|
||||
return 'variableNameInvalid'
|
||||
if (!/^[a-z_]\w{0,29}$/.test(name))
|
||||
return 'variableNameInvalid'
|
||||
if (unavailableVariableNameSet.has(name))
|
||||
return 'variableNameDuplicated'
|
||||
return null
|
||||
}, [tempPayload.output_variable_name, unavailableVariableNameSet])
|
||||
const nameValid = useMemo(() => {
|
||||
return !!tempPayload.output_variable_name.trim() && !variableNameError
|
||||
}, [tempPayload.output_variable_name, variableNameError])
|
||||
const handleSave = useCallback(() => {
|
||||
if (!nameValid)
|
||||
return
|
||||
@@ -223,9 +235,9 @@ const InputField: React.FC<InputFieldProps> = ({
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
{tempPayload.output_variable_name && !nameValid && (
|
||||
{tempPayload.output_variable_name && variableNameError && (
|
||||
<div className="mt-1 px-1 system-xs-regular text-text-destructive-secondary">
|
||||
{t(`${i18nPrefix}.variableNameInvalid`, { ns: 'workflow' })}
|
||||
{t(`${i18nPrefix}.${variableNameError}`, { ns: 'workflow' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -109,6 +109,11 @@ export class HITLInputNode extends DecoratorNode<React.JSX.Element> {
|
||||
return self.__readonly || false
|
||||
}
|
||||
|
||||
setReadonly(readonly?: boolean): void {
|
||||
const self = this.getWritable()
|
||||
self.__readonly = readonly
|
||||
}
|
||||
|
||||
static override clone(node: HITLInputNode): HITLInputNode {
|
||||
return new HITLInputNode(
|
||||
node.__variableName,
|
||||
|
||||
@@ -64,6 +64,7 @@ vi.mock('@/app/components/base/prompt-editor', () => ({
|
||||
vi.mock('../add-input-field', () => ({
|
||||
__esModule: true,
|
||||
default: (props: {
|
||||
unavailableVariableNames?: string[]
|
||||
onSave: (payload: {
|
||||
type: string
|
||||
output_variable_name: string
|
||||
@@ -231,6 +232,41 @@ describe('FormContent', () => {
|
||||
expect(container.firstChild).toHaveClass('pointer-events-none')
|
||||
})
|
||||
|
||||
it('should not insert a new input when the variable name already exists', () => {
|
||||
render(
|
||||
<FormContent
|
||||
nodeId="node-2"
|
||||
value="Initial content"
|
||||
onChange={onChange}
|
||||
formInputs={[{
|
||||
type: 'paragraph',
|
||||
output_variable_name: 'approval',
|
||||
default: {
|
||||
type: 'constant',
|
||||
selector: [],
|
||||
value: '',
|
||||
},
|
||||
} as never]}
|
||||
onFormInputsChange={onFormInputsChange}
|
||||
onFormInputItemRename={onFormInputItemRename}
|
||||
onFormInputItemRemove={onFormInputItemRemove}
|
||||
editorKey={1}
|
||||
isExpand={false}
|
||||
availableVars={[]}
|
||||
availableNodes={[]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockAddInputField).toHaveBeenCalledWith(expect.objectContaining({
|
||||
unavailableVariableNames: ['approval'],
|
||||
}))
|
||||
|
||||
fireEvent.click(screen.getByText('save-input'))
|
||||
|
||||
expect(mockOnInsert).not.toHaveBeenCalled()
|
||||
expect(onFormInputsChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render the mac hotkey hint when focused on macOS', () => {
|
||||
mockIsMac.mockReturnValue(true)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer'
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { HumanInputFormData } from '@/types/workflow'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
@@ -12,25 +13,54 @@ vi.mock('react-i18next', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/button', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
children?: ReactNode
|
||||
disabled?: boolean
|
||||
onClick?: () => void
|
||||
}) => (
|
||||
<button type="button" disabled={disabled} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/chat/chat/answer/human-input-content/content-item', () => ({
|
||||
__esModule: true,
|
||||
default: ({ content }: { content: string }) => <div>{content}</div>,
|
||||
default: ({
|
||||
content,
|
||||
formInputFields,
|
||||
inputs,
|
||||
onInputChange,
|
||||
}: {
|
||||
content: string
|
||||
formInputFields: FormInputItem[]
|
||||
inputs: Record<string, HumanInputFieldValue>
|
||||
onInputChange: (name: string, value: HumanInputFieldValue) => void
|
||||
}) => {
|
||||
const fieldName = /\{\{#\$output\.([^#]+)#\}\}/.exec(content)?.[1]
|
||||
if (!fieldName)
|
||||
return <div>{content}</div>
|
||||
|
||||
const field = formInputFields.find(field => field.output_variable_name === fieldName)
|
||||
if (!field)
|
||||
return null
|
||||
|
||||
if (field.type === 'select') {
|
||||
return (
|
||||
<select
|
||||
aria-label={fieldName}
|
||||
value={typeof inputs[fieldName] === 'string' ? inputs[fieldName] : ''}
|
||||
onChange={event => onInputChange(fieldName, event.target.value)}
|
||||
>
|
||||
<option value="">Select</option>
|
||||
{field.option_source.value.map(option => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'paragraph') {
|
||||
return (
|
||||
<textarea
|
||||
aria-label={fieldName}
|
||||
value={typeof inputs[fieldName] === 'string' ? inputs[fieldName] : ''}
|
||||
onChange={event => onInputChange(fieldName, event.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <div>{fieldName}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
const createFormData = (overrides: Partial<HumanInputFormData> = {}): HumanInputFormData => ({
|
||||
@@ -60,6 +90,10 @@ const createFormData = (overrides: Partial<HumanInputFormData> = {}): HumanInput
|
||||
})
|
||||
|
||||
describe('SingleRunForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders the back action as a named button and forwards clicks', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleBack = vi.fn()
|
||||
@@ -99,4 +133,99 @@ describe('SingleRunForm', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('submits updated paragraph input values', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
render(
|
||||
<SingleRunForm
|
||||
nodeName="Review"
|
||||
data={createFormData()}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.clear(screen.getByRole('textbox', { name: 'review' }))
|
||||
await user.type(screen.getByRole('textbox', { name: 'review' }), 'updated review')
|
||||
await user.click(screen.getByRole('button', { name: 'Approve' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
inputs: { review: 'updated review' },
|
||||
action: 'approve',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('uses resolved default values for variable paragraph inputs', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
render(
|
||||
<SingleRunForm
|
||||
nodeName="Review"
|
||||
data={createFormData({
|
||||
inputs: [{
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: 'review',
|
||||
default: {
|
||||
selector: ['source', 'answer'],
|
||||
type: 'variable',
|
||||
value: 'fallback review',
|
||||
},
|
||||
}],
|
||||
resolved_default_values: {
|
||||
review: 'resolved review',
|
||||
},
|
||||
})}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Approve' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
inputs: { review: 'resolved review' },
|
||||
action: 'approve',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('disables submit actions until a select input has a value', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
render(
|
||||
<SingleRunForm
|
||||
nodeName="Review"
|
||||
data={createFormData({
|
||||
form_content: 'Choose {{#$output.choice#}}',
|
||||
inputs: [{
|
||||
type: InputVarType.select,
|
||||
output_variable_name: 'choice',
|
||||
option_source: {
|
||||
selector: [],
|
||||
type: 'constant',
|
||||
value: ['approve', 'reject'],
|
||||
},
|
||||
}],
|
||||
})}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Approve' })).toBeDisabled()
|
||||
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'choice' }), 'approve')
|
||||
await user.click(screen.getByRole('button', { name: 'Approve' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
inputs: { choice: 'approve' },
|
||||
action: 'approve',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,12 +6,14 @@ import InputField from '@/app/components/base/prompt-editor/plugins/hitl-input-b
|
||||
|
||||
type Props = {
|
||||
nodeId: string
|
||||
unavailableVariableNames?: string[]
|
||||
onSave: (newPayload: FormInputItem) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const AddInputField: FC<Props> = ({
|
||||
nodeId,
|
||||
unavailableVariableNames,
|
||||
onSave,
|
||||
onCancel,
|
||||
}) => {
|
||||
@@ -19,6 +21,7 @@ const AddInputField: FC<Props> = ({
|
||||
<InputField
|
||||
nodeId={nodeId}
|
||||
isEdit={false}
|
||||
unavailableVariableNames={unavailableVariableNames}
|
||||
onChange={onSave}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
'use client'
|
||||
import type { LexicalCommand } from 'lexical'
|
||||
import type { FC } from 'react'
|
||||
import type { FormInputItem } from '../types'
|
||||
import type { ShortcutPopupInsertHandler } from '@/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin'
|
||||
import type { WorkflowNodesMap } from '@/app/components/base/prompt-editor/types'
|
||||
import type { Node, NodeOutPutVar } from '@/app/components/workflow/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import PromptEditor from '@/app/components/base/prompt-editor'
|
||||
import { INSERT_HITL_INPUT_BLOCK_COMMAND } from '@/app/components/base/prompt-editor/plugins/hitl-input-block'
|
||||
@@ -56,11 +57,20 @@ const FormContent: FC<FormContentProps> = ({
|
||||
|
||||
const getVarType = useWorkflowVariableType()
|
||||
|
||||
const [needToAddFormInput, setNeedToAddFormInput] = useState(false)
|
||||
const [newFormInputs, setNewFormInputs] = useState<FormInputItem[]>([])
|
||||
const handleInsertHITLNode = (onInsert: (command: LexicalCommand<unknown>, params: any) => void) => {
|
||||
const pendingFormInputsRef = useRef<{
|
||||
value: string
|
||||
formInputs: FormInputItem[]
|
||||
} | null>(null)
|
||||
const handleInsertHITLNode = (onInsert: ShortcutPopupInsertHandler) => {
|
||||
return (payload: FormInputItem) => {
|
||||
if (formInputs.some(input => input.output_variable_name === payload.output_variable_name))
|
||||
return
|
||||
|
||||
const newFormInputs = [...(formInputs || []), payload]
|
||||
pendingFormInputsRef.current = {
|
||||
value,
|
||||
formInputs: newFormInputs,
|
||||
}
|
||||
onInsert(INSERT_HITL_INPUT_BLOCK_COMMAND, {
|
||||
variableName: payload.output_variable_name,
|
||||
nodeId,
|
||||
@@ -69,25 +79,25 @@ const FormContent: FC<FormContentProps> = ({
|
||||
onFormInputItemRename,
|
||||
onFormInputItemRemove,
|
||||
})
|
||||
setNewFormInputs(newFormInputs)
|
||||
setNeedToAddFormInput(true)
|
||||
}
|
||||
}
|
||||
|
||||
// avoid update formInputs would overwrite the value just inserted
|
||||
useEffect(() => {
|
||||
if (needToAddFormInput) {
|
||||
onFormInputsChange(newFormInputs)
|
||||
setNeedToAddFormInput(false)
|
||||
}
|
||||
}, [value])
|
||||
const pendingFormInputs = pendingFormInputsRef.current
|
||||
if (!pendingFormInputs || pendingFormInputs.value === value)
|
||||
return
|
||||
|
||||
onFormInputsChange(pendingFormInputs.formInputs)
|
||||
pendingFormInputsRef.current = null
|
||||
}, [onFormInputsChange, value])
|
||||
|
||||
const [isFocus, {
|
||||
setTrue: setFocus,
|
||||
setFalse: setBlur,
|
||||
}] = useBoolean(false)
|
||||
|
||||
const workflowNodesMap = availableNodes.reduce((acc: any, node) => {
|
||||
const workflowNodesMap = availableNodes.reduce<WorkflowNodesMap>((acc, node) => {
|
||||
acc[node.id] = {
|
||||
title: node.data.title,
|
||||
type: node.data.type,
|
||||
@@ -137,7 +147,7 @@ const FormContent: FC<FormContentProps> = ({
|
||||
workflowVariableBlock={{
|
||||
show: true,
|
||||
variables: availableVars || [],
|
||||
getVarType: getVarType as any,
|
||||
getVarType,
|
||||
workflowNodesMap,
|
||||
}}
|
||||
editable={!readonly}
|
||||
@@ -145,10 +155,12 @@ const FormContent: FC<FormContentProps> = ({
|
||||
? []
|
||||
: [{
|
||||
hotkey: ['mod', '/'],
|
||||
// eslint-disable-next-line react/component-hook-factories, react/no-nested-component-definitions
|
||||
Popup: ({ onClose, onInsert }) => (
|
||||
<AddInputField
|
||||
nodeId={nodeId}
|
||||
onSave={handleInsertHITLNode(onInsert!)}
|
||||
unavailableVariableNames={formInputs.map(input => input.output_variable_name)}
|
||||
onSave={handleInsertHITLNode(onInsert)}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -96,6 +96,26 @@ describe('human-input/use-form-content', () => {
|
||||
expect(result.current.editorKey).toBe(1)
|
||||
})
|
||||
|
||||
it('should not rename an input to an existing variable name', () => {
|
||||
currentInputs = createPayload({
|
||||
inputs: [
|
||||
createFormInput(),
|
||||
createFormInput({ output_variable_name: 'existing_name' }),
|
||||
],
|
||||
})
|
||||
const { result } = renderHook(() => useFormContent('human-input-node', currentInputs))
|
||||
|
||||
act(() => {
|
||||
result.current.handleFormInputItemRename(createFormInput({
|
||||
output_variable_name: 'existing_name',
|
||||
}), 'old_name')
|
||||
})
|
||||
|
||||
expect(mockSetInputs).not.toHaveBeenCalled()
|
||||
expect(mockHandleOutVarRenameChange).not.toHaveBeenCalled()
|
||||
expect(result.current.editorKey).toBe(0)
|
||||
})
|
||||
|
||||
it('should remove an input placeholder and its form input metadata', () => {
|
||||
const { result } = renderHook(() => useFormContent('human-input-node', currentInputs))
|
||||
|
||||
|
||||
@@ -29,6 +29,13 @@ const useFormContent = (id: string, payload: HumanInputNodeType) => {
|
||||
|
||||
const handleFormInputItemRename = useCallback((payload: FormInputItem, oldName: string) => {
|
||||
const inputs = inputsRef.current
|
||||
if (
|
||||
oldName !== payload.output_variable_name
|
||||
&& inputs.inputs.some(item => item.output_variable_name === payload.output_variable_name)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const newInputs = produce(inputs, (draft) => {
|
||||
draft.form_content = draft.form_content.replaceAll(`{{#$output.${oldName}#}}`, `{{#$output.${payload.output_variable_name}#}}`)
|
||||
draft.inputs = draft.inputs.map(item => item.output_variable_name === oldName ? payload : item)
|
||||
|
||||
@@ -637,6 +637,7 @@
|
||||
"nodes.humanInput.insertInputField.useConstantInstead": "Use Constant Instead",
|
||||
"nodes.humanInput.insertInputField.useVarInstead": "Use Variable Instead",
|
||||
"nodes.humanInput.insertInputField.variable": "variable",
|
||||
"nodes.humanInput.insertInputField.variableNameDuplicated": "Variable name already exists",
|
||||
"nodes.humanInput.insertInputField.variableNameInvalid": "Variable name can only contain letters, numbers, and underscores, and cannot start with a number",
|
||||
"nodes.humanInput.log.backstageInputURL": "Backstage input URL:",
|
||||
"nodes.humanInput.log.reason": "Reason:",
|
||||
|
||||
@@ -637,6 +637,7 @@
|
||||
"nodes.humanInput.insertInputField.useConstantInstead": "使用常量代替",
|
||||
"nodes.humanInput.insertInputField.useVarInstead": "使用变量代替",
|
||||
"nodes.humanInput.insertInputField.variable": "变量",
|
||||
"nodes.humanInput.insertInputField.variableNameDuplicated": "变量名已存在",
|
||||
"nodes.humanInput.insertInputField.variableNameInvalid": "只能包含字母、数字和下划线,且不能以数字开头",
|
||||
"nodes.humanInput.log.backstageInputURL": "表单输入 URL:",
|
||||
"nodes.humanInput.log.reason": "原因:",
|
||||
|
||||
94
web/service/knowledge/use-dataset.spec.ts
Normal file
94
web/service/knowledge/use-dataset.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { get } from '../base'
|
||||
import { useDatasetDetail, useDatasetRelatedApps } from './use-dataset'
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
keepPreviousData: Symbol('keepPreviousData'),
|
||||
useInfiniteQuery: vi.fn(),
|
||||
useMutation: vi.fn(),
|
||||
useQuery: vi.fn(),
|
||||
useQueryClient: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../base', () => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../use-base', () => ({
|
||||
useInvalid: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseQuery = vi.mocked(useQuery)
|
||||
const mockGet = vi.mocked(get)
|
||||
|
||||
type QueryOptions = Parameters<typeof useQuery>[0]
|
||||
type RetryFn = (failureCount: number, error: unknown) => boolean
|
||||
|
||||
const getLastQueryOptions = () => {
|
||||
return mockUseQuery.mock.calls.at(-1)?.[0] as QueryOptions
|
||||
}
|
||||
|
||||
const getRetryFn = () => {
|
||||
return getLastQueryOptions().retry as RetryFn
|
||||
}
|
||||
|
||||
describe('knowledge dataset hooks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseQuery.mockReturnValue({} as ReturnType<typeof useQuery>)
|
||||
})
|
||||
|
||||
describe('useDatasetDetail', () => {
|
||||
it('should not retry forbidden or missing dataset detail errors', () => {
|
||||
// Arrange & Act
|
||||
useDatasetDetail('dataset-1')
|
||||
const retry = getRetryFn()
|
||||
|
||||
// Assert
|
||||
expect(retry(0, new Response(null, { status: 403 }))).toBe(false)
|
||||
expect(retry(0, new Response(null, { status: 404 }))).toBe(false)
|
||||
})
|
||||
|
||||
it('should retry other dataset detail errors fewer than three times', () => {
|
||||
// Arrange & Act
|
||||
useDatasetDetail('dataset-1')
|
||||
const retry = getRetryFn()
|
||||
|
||||
// Assert
|
||||
expect(retry(2, new Error('temporary failure'))).toBe(true)
|
||||
expect(retry(3, new Error('temporary failure'))).toBe(false)
|
||||
})
|
||||
|
||||
it('should fetch dataset detail without silent mode', () => {
|
||||
// Arrange
|
||||
mockGet.mockResolvedValue({ id: 'dataset-1' })
|
||||
|
||||
// Act
|
||||
useDatasetDetail('dataset-1')
|
||||
const queryFn = getLastQueryOptions().queryFn as () => unknown
|
||||
queryFn()
|
||||
|
||||
// Assert
|
||||
expect(mockGet).toHaveBeenCalledWith('/datasets/dataset-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useDatasetRelatedApps', () => {
|
||||
it('should use explicit enabled option when provided', () => {
|
||||
// Arrange & Act
|
||||
useDatasetRelatedApps('dataset-1', { enabled: false })
|
||||
|
||||
// Assert
|
||||
expect(getLastQueryOptions().enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should enable related apps query when dataset id exists and no option is provided', () => {
|
||||
// Arrange & Act
|
||||
useDatasetRelatedApps('dataset-1')
|
||||
|
||||
// Assert
|
||||
expect(getLastQueryOptions().enabled).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -110,13 +110,20 @@ export const useDatasetDetail = (datasetId: string) => {
|
||||
queryKey: [...datasetDetailQueryKeyPrefix, datasetId],
|
||||
queryFn: () => get<DataSet>(`/datasets/${datasetId}`),
|
||||
enabled: !!datasetId,
|
||||
retry: (failureCount, error) => {
|
||||
if (error instanceof Response && [403, 404].includes(error.status))
|
||||
return false
|
||||
|
||||
return failureCount < 3
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useDatasetRelatedApps = (datasetId: string) => {
|
||||
export const useDatasetRelatedApps = (datasetId: string, options?: { enabled?: boolean }) => {
|
||||
return useQuery({
|
||||
queryKey: [NAME_SPACE, 'related-apps', datasetId],
|
||||
queryFn: () => get<RelatedAppResponse>(`/datasets/${datasetId}/related-apps`),
|
||||
enabled: options?.enabled ?? !!datasetId,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user