Merge remote-tracking branch 'upstream/feat/hitl-form-enhancement' into feat/hitl-form-enhancement

This commit is contained in:
QuantumGhost
2026-05-13 10:03:25 +08:00
26 changed files with 816 additions and 63 deletions

View File

@@ -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,

View File

@@ -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()
})
})
})

View File

@@ -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(

View File

@@ -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()

View File

@@ -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>
)

View File

@@ -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', () => {

View File

@@ -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()

View File

@@ -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 })

View File

@@ -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()

View File

@@ -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#}}')
})
})

View File

@@ -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}
/>

View File

@@ -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}

View File

@@ -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
}

View File

@@ -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
})

View File

@@ -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>

View File

@@ -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,

View File

@@ -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)

View File

@@ -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',
})
})
})
})

View File

@@ -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}
/>

View File

@@ -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}
/>
),

View File

@@ -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))

View File

@@ -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)

View File

@@ -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:",

View File

@@ -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": "原因:",

View 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)
})
})
})

View File

@@ -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,
})
}