diff --git a/web/app/components/base/prompt-editor/__tests__/index.spec.tsx b/web/app/components/base/prompt-editor/__tests__/index.spec.tsx
index 31e25ab19e..9c0b3cec39 100644
--- a/web/app/components/base/prompt-editor/__tests__/index.spec.tsx
+++ b/web/app/components/base/prompt-editor/__tests__/index.spec.tsx
@@ -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()
+
+ await waitFor(() => {
+ expect(mocks.editor.setEditable).toHaveBeenCalledWith(true)
+ })
+
+ rerender()
+
+ await waitFor(() => {
+ expect(mocks.editor.setEditable).toHaveBeenLastCalledWith(false)
+ })
+ })
+
it('should render with isSupportFileVar=true', () => {
render()
expect(screen.getByTestId('lexical-composer')).toBeInTheDocument()
diff --git a/web/app/components/base/prompt-editor/index.tsx b/web/app/components/base/prompt-editor/index.tsx
index 29d0d71715..e381d61a3d 100644
--- a/web/app/components/base/prompt-editor/index.tsx
+++ b/web/app/components/base/prompt-editor/index.tsx
@@ -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, params: any[]) => void }> }>
+ shortcutPopups?: Array<{ hotkey: Hotkey, Popup: React.ComponentType<{ onClose: () => void, onInsert: ShortcutPopupInsertHandler }> }>
}
const PromptEditor: FC = ({
@@ -194,13 +203,13 @@ const PromptEditor: FC = ({
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(null)
@@ -243,6 +252,7 @@ const PromptEditor: FC = ({
onEditorChange={handleEditorChange}
/>
+
)
diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/component-ui.spec.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/component-ui.spec.tsx
index 15930b9e7a..2fa310d058 100644
--- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/component-ui.spec.tsx
+++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/component-ui.spec.tsx
@@ -4,7 +4,8 @@ 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 { 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'
@@ -114,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 (
+ {
+ throw error
+ },
+ nodes: [HITLInputNode],
+ }}
+ >
+
+
+ )
+ }
+
+ render()
+
+ 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: {
diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/index.spec.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/index.spec.tsx
index 62b867d155..79b806b889 100644
--- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/index.spec.tsx
+++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/index.spec.tsx
@@ -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 (
+
+ )
+ }
+
+ const { getEditor } = renderLexicalEditor({
+ namespace: 'hitl-input-block-readonly-update-test',
+ nodes: [CustomTextNode, HITLInputNode],
+ children: ,
+ })
+
+ 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 })
diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/node.spec.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/node.spec.tsx
index f4ab8ff90a..e80277e191 100644
--- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/node.spec.tsx
+++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/node.spec.tsx
@@ -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#}}')
})
})
diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/component-ui.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/component-ui.tsx
index 3b15e347ef..089dcf9a72 100644
--- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/component-ui.tsx
+++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/component-ui.tsx
@@ -66,6 +66,11 @@ const HITLInputComponentUI: FC = ({
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(null)
useEffect(() => {
diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/hitl-input-block-replacement-block.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/hitl-input-block-replacement-block.tsx
index a63d694e2b..ebb7d9a895 100644
--- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/hitl-input-block-replacement-block.tsx
+++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/hitl-input-block-replacement-block.tsx
@@ -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((acc, curr) => {
+ const ragVariables = useMemo(() => variables?.reduce((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
}
diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/index.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/index.tsx
index 1b2af39ebe..73cbbc3ef5 100644
--- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/index.tsx
+++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/index.tsx
@@ -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
})
diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/node.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/node.tsx
index 592ecf1f6c..ff26d8d62d 100644
--- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/node.tsx
+++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/node.tsx
@@ -109,6 +109,11 @@ export class HITLInputNode extends DecoratorNode {
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,
diff --git a/web/app/components/workflow/nodes/human-input/components/form-content.tsx b/web/app/components/workflow/nodes/human-input/components/form-content.tsx
index 0946d47f4c..6293ac7f37 100644
--- a/web/app/components/workflow/nodes/human-input/components/form-content.tsx
+++ b/web/app/components/workflow/nodes/human-input/components/form-content.tsx
@@ -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,14 +57,20 @@ const FormContent: FC = ({
const getVarType = useWorkflowVariableType()
- const [needToAddFormInput, setNeedToAddFormInput] = useState(false)
- const [newFormInputs, setNewFormInputs] = useState([])
- const handleInsertHITLNode = (onInsert: (command: LexicalCommand, 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,
@@ -72,25 +79,25 @@ const FormContent: FC = ({
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((acc, node) => {
acc[node.id] = {
title: node.data.title,
type: node.data.type,
@@ -140,7 +147,7 @@ const FormContent: FC = ({
workflowVariableBlock={{
show: true,
variables: availableVars || [],
- getVarType: getVarType as any,
+ getVarType,
workflowNodesMap,
}}
editable={!readonly}
@@ -148,11 +155,12 @@ const FormContent: FC = ({
? []
: [{
hotkey: ['mod', '/'],
+ // eslint-disable-next-line react/component-hook-factories, react/no-nested-component-definitions
Popup: ({ onClose, onInsert }) => (
input.output_variable_name)}
- onSave={handleInsertHITLNode(onInsert!)}
+ onSave={handleInsertHITLNode(onInsert)}
onCancel={onClose}
/>
),