fix(web): readonly state of form content

This commit is contained in:
JzoNg 2026-05-12 18:05:33 +08:00
parent 3f372352d8
commit ac05dc9fa3
10 changed files with 203 additions and 28 deletions

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,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 (
<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: {

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

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

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

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

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

@ -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<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,
@ -72,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,
@ -140,7 +147,7 @@ const FormContent: FC<FormContentProps> = ({
workflowVariableBlock={{
show: true,
variables: availableVars || [],
getVarType: getVarType as any,
getVarType,
workflowNodesMap,
}}
editable={!readonly}
@ -148,11 +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}
unavailableVariableNames={formInputs.map(input => input.output_variable_name)}
onSave={handleInsertHITLNode(onInsert!)}
onSave={handleInsertHITLNode(onInsert)}
onCancel={onClose}
/>
),