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