mirror of
https://github.com/langgenius/dify.git
synced 2026-05-13 08:57:28 +08:00
fix(web): readonly state of form content
This commit is contained in:
parent
3f372352d8
commit
ac05dc9fa3
@ -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,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: {
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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#}}')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user