Merge branch 'develop' into feature/node_run
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2025-12-30 13:59:36
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-08 11:05:34
|
||||
* @Last Modified time: 2026-04-13 15:26:33
|
||||
*/
|
||||
import { forwardRef, useImperativeHandle, useState, useRef, useMemo } from 'react';
|
||||
import { Form, Input, Select, InputNumber, Button, Row, Col, Flex } from 'antd';
|
||||
@@ -136,7 +136,7 @@ const ChatVariableModal = forwardRef<ChatVariableModalRef, ChatVariableModalProp
|
||||
form.validateFields().then((values) => {
|
||||
const defaultValue = Array.isArray(values.defaultValue)
|
||||
? values.defaultValue.filter((v: any) => v !== undefined && v !== null && v !== '')
|
||||
: values.type.includes('object')
|
||||
: values.type.includes('object') && values.defaultValue
|
||||
? JSON.parse(values.defaultValue)
|
||||
: values.defaultValue;
|
||||
refresh({ ...values, defaultValue }, editIndex);
|
||||
@@ -345,15 +345,16 @@ const ChatVariableModal = forwardRef<ChatVariableModalRef, ChatVariableModalProp
|
||||
<Form.Item
|
||||
name="defaultValue"
|
||||
label={t('workflow.config.parameter-extractor.default')}
|
||||
rules={[
|
||||
(type === 'object' || type === 'array[object]') ? {
|
||||
rules={(type === 'object' || type === 'array[object]')
|
||||
? [{
|
||||
validator: (_, value) => {
|
||||
if (!value) return Promise.resolve();
|
||||
try { JSON.parse(value); return Promise.resolve(); }
|
||||
catch { return Promise.reject(t('workflow.invalidJSON')); }
|
||||
}
|
||||
} : {}
|
||||
]}
|
||||
}]
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{type === 'number'
|
||||
? <InputNumber placeholder={t('common.enter')} style={{ width: '100%' }} />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-24 17:57:08
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-07 14:05:50
|
||||
* @Last Modified time: 2026-04-14 16:33:33
|
||||
*/
|
||||
/*
|
||||
* Runtime Component
|
||||
@@ -161,8 +161,7 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({
|
||||
children: (
|
||||
<Flex gap={8} vertical>
|
||||
{/* Display error message for failed nodes */}
|
||||
|
||||
{item.error &&
|
||||
{vo.content?.error && vo.content?.error !== '' &&
|
||||
<RbAlert color="orange" className="rb:pb-0!">
|
||||
<Flex vertical className="rb:w-full!">
|
||||
<Flex align="center" justify="space-between">
|
||||
@@ -219,11 +218,11 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({
|
||||
</div>
|
||||
}
|
||||
|
||||
/** Copy value to clipboard and show success message */
|
||||
const handleCopy = (value: string) => {
|
||||
copy(value)
|
||||
message.success(t('common.copySuccess'))
|
||||
}
|
||||
/** Copy value to clipboard and show success message */
|
||||
const handleCopy = (value: string) => {
|
||||
copy(value)
|
||||
message.success(t('common.copySuccess'))
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -269,7 +268,7 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({
|
||||
</div>
|
||||
)
|
||||
: <div className="rb:mb-4">
|
||||
{item.error &&
|
||||
{item.error && item.error !== '' &&
|
||||
<RbAlert color="orange" className="rb:pb-0! rb:mb-2!"><Markdown content={item.error} /></RbAlert>
|
||||
}
|
||||
{renderChild(item.subContent)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Node } from '@antv/x6';
|
||||
|
||||
import type { WorkflowRef } from '@/views/ApplicationConfig/types'
|
||||
import { nodeLibrary } from '../../constant'
|
||||
import { isSubExprSet } from '../../utils'
|
||||
import { getToolMethods } from '@/api/tools'
|
||||
import RbDrawer from '@/components/RbDrawer'
|
||||
import { useWorkflowStore } from '@/store/workflow'
|
||||
@@ -44,14 +45,13 @@ const specialValidators: Record<string, (val: any) => boolean> = {
|
||||
// if-else.cases: every case must have at least one expression, and every expression must be fully set
|
||||
'if-else.cases': (val: any[]) => {
|
||||
if (!Array.isArray(val) || !val.length) return true
|
||||
return val.some(c => {
|
||||
if (!c?.expressions?.length) return true
|
||||
return c.expressions.some((expr: any) => {
|
||||
if (!expr?.left) return true
|
||||
if (['not_empty', 'empty'].includes(expr.operator)) return false
|
||||
return !(!!expr.left && (!!expr.right || typeof expr.right === 'boolean' || typeof expr.right === 'number'))
|
||||
})
|
||||
})
|
||||
const isExprSet = (expr: any) => {
|
||||
if (expr?.sub_variable_condition?.conditions?.length > 0) return expr.sub_variable_condition?.conditions.every(isSubExprSet)
|
||||
if (!expr.left) return false
|
||||
if (['not_empty', 'empty'].includes(expr.operator)) return true
|
||||
return !!expr.left && (!!expr.right || typeof expr.right === 'boolean' || typeof expr.right === 'number')
|
||||
}
|
||||
return val.some(c => !c?.expressions?.length || c.expressions.some((expr: any) => !isExprSet(expr)))
|
||||
},
|
||||
// question-classifier.categories: every category must have a value
|
||||
'question-classifier.categories': (val: any[]) => !Array.isArray(val) || !val.some(c => c?.class_name && String(c.class_name).trim()),
|
||||
@@ -79,7 +79,6 @@ const specialValidators: Record<string, (val: any) => boolean> = {
|
||||
}
|
||||
|
||||
function isEmpty(val: any): boolean {
|
||||
console.log('validateNode isEmpty', val, val === undefined || val === null || val === '')
|
||||
if (val === undefined || val === null || val === '') return true
|
||||
if (Array.isArray(val)) return val.length === 0
|
||||
return false
|
||||
@@ -98,7 +97,6 @@ function validateNode(type: string, config: Record<string, any>): CheckError[] {
|
||||
const specialKey = `${type}.${field}`
|
||||
const specialValidator = specialValidators[specialKey]
|
||||
const isInvalid = specialValidator ? specialValidator(val) : isEmpty(val)
|
||||
console.log('validateNode', val, specialKey, specialValidator, isEmpty(val))
|
||||
if (isInvalid) errors.push({ key: specialKey, message: '' })
|
||||
})
|
||||
|
||||
@@ -114,68 +112,13 @@ function validateNode(type: string, config: Record<string, any>): CheckError[] {
|
||||
return errors
|
||||
}
|
||||
|
||||
export async function runCheckOnGraph(
|
||||
graph: import('@antv/x6').Graph,
|
||||
t: (key: string) => string
|
||||
): Promise<NodeCheckResult[]> {
|
||||
const nodes = graph.getNodes()
|
||||
const edges = graph.getEdges()
|
||||
const targetIds = new Set<string>()
|
||||
const childTargetIds = new Set<string>()
|
||||
edges.forEach(e => {
|
||||
targetIds.add(e.getTargetCellId())
|
||||
const srcData = graph.getCellById(e.getSourceCellId())?.getData()
|
||||
const tgtData = graph.getCellById(e.getTargetCellId())?.getData()
|
||||
if (srcData?.cycle && tgtData?.cycle && srcData.cycle === tgtData.cycle) {
|
||||
childTargetIds.add(e.getTargetCellId())
|
||||
}
|
||||
})
|
||||
|
||||
const checked: NodeCheckResult[] = []
|
||||
for (const node of nodes) {
|
||||
const data = node.getData()
|
||||
if (!data || ['add-node', 'notes', 'cycle-start', 'break'].includes(data.type)) continue
|
||||
|
||||
const errors: CheckError[] = []
|
||||
const isChildNode = !!data.cycle
|
||||
const hasIncoming = isChildNode ? childTargetIds.has(node.id) : !['start', 'cycle-start'].includes(data.type) ? targetIds.has(node.id) : true
|
||||
if (!hasIncoming) errors.push({ key: 'notConnected', message: t('workflow.notConnected') })
|
||||
|
||||
const configErrors = validateNode(data.type, data.config ?? {})
|
||||
configErrors.forEach(e => {
|
||||
errors.push({ key: e.key, message: `${t(`workflow.checkListErrors.${e.key}`)} ${t('workflow.cannotBeEmpty')}`.trim() })
|
||||
})
|
||||
|
||||
if (data.type === 'tool') {
|
||||
const toolId = data.config?.tool_id?.defaultValue ?? data.config?.tool_id
|
||||
const toolParameters = data.config?.tool_parameters?.defaultValue ?? data.config?.tool_parameters ?? {}
|
||||
if (toolId) {
|
||||
try {
|
||||
const methods = await getToolMethods(toolId) as Array<{ name: string; parameters: Array<{ name: string; required: boolean }> }>
|
||||
const operation = toolParameters?.operation
|
||||
const method = operation ? methods.find(m => m.name === operation) : methods[0]
|
||||
if (method) {
|
||||
method.parameters
|
||||
.filter(p => p.required && (toolParameters[p.name] === undefined || toolParameters[p.name] === null || toolParameters[p.name] === ''))
|
||||
.forEach(p => errors.push({ key: 'tool.tool_parameters', message: `${p.name} ${t('workflow.cannotBeEmpty')}` }))
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
checked.push({ id: node.id, name: data.name || t(`workflow.${data.type}`), type: data.type, icon: nodeIconMap[data.type] ?? '', errors })
|
||||
}
|
||||
}
|
||||
return checked
|
||||
}
|
||||
|
||||
const CheckList: FC<CheckListProps> = ({ workflowRef, appId }) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const { setCheckResults, getCheckResults } = useWorkflowStore()
|
||||
const results = getCheckResults(appId)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
const toolMethodsCacheRef = useRef<Record<string, Array<{ name: string; parameters: Array<{ name: string; required: boolean }> }>>>({})
|
||||
|
||||
const runCheck = useCallback(async () => {
|
||||
const graph = workflowRef.current?.graphRef?.current
|
||||
@@ -222,9 +165,13 @@ const CheckList: FC<CheckListProps> = ({ workflowRef, appId }) => {
|
||||
if (data.type === 'tool') {
|
||||
const toolId = data.config?.tool_id?.defaultValue ?? data.config?.tool_id
|
||||
const toolParameters = data.config?.tool_parameters?.defaultValue ?? data.config?.tool_parameters ?? {}
|
||||
if (toolId) {
|
||||
|
||||
if (typeof toolId === 'string') {
|
||||
try {
|
||||
const methods = await getToolMethods(toolId) as Array<{ name: string; parameters: Array<{ name: string; required: boolean }> }>
|
||||
if (!toolMethodsCacheRef.current[toolId]) {
|
||||
toolMethodsCacheRef.current[toolId] = await getToolMethods(toolId) as Array<{ name: string; parameters: Array<{ name: string; required: boolean }> }>
|
||||
}
|
||||
const methods = toolMethodsCacheRef.current[toolId]
|
||||
const operation = toolParameters?.operation
|
||||
const method = operation ? methods.find(m => m.name === operation) : methods[0]
|
||||
if (method) {
|
||||
@@ -251,21 +198,27 @@ const CheckList: FC<CheckListProps> = ({ workflowRef, appId }) => {
|
||||
return checked
|
||||
}, [workflowRef.current?.graphRef?.current, t])
|
||||
|
||||
const scheduleCheckRef = useRef<() => void>()
|
||||
|
||||
const scheduleCheck = useCallback(() => {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(async () => {
|
||||
setCheckResults(appId, await runCheck())
|
||||
}, 500)
|
||||
}, 300)
|
||||
}, [runCheck])
|
||||
|
||||
scheduleCheckRef.current = scheduleCheck
|
||||
|
||||
useEffect(() => {
|
||||
const graph = workflowRef.current?.graphRef?.current
|
||||
console.log('graph')
|
||||
if (!graph) return
|
||||
const events = ['node:added', 'node:removed', 'node:change:data', 'edge:added', 'edge:removed']
|
||||
events.forEach(e => graph.on(e, scheduleCheck))
|
||||
scheduleCheck()
|
||||
const handler = () => scheduleCheckRef.current?.()
|
||||
const events = ['node:added', 'node:removed', 'node:change:data', 'edge:added', 'edge:removed', 'edge:connected', 'edge:changed']
|
||||
events.forEach(e => graph.on(e, handler))
|
||||
scheduleCheckRef.current?.()
|
||||
return () => {
|
||||
events.forEach(e => graph.off(e, scheduleCheck))
|
||||
events.forEach(e => graph.off(e, handler))
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
}, [workflowRef.current?.graphRef?.current])
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-04-02 15:15:36
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-07 14:48:00
|
||||
* @Last Modified time: 2026-04-16 11:34:41
|
||||
*/
|
||||
import { type FC, useEffect, useMemo } from 'react';
|
||||
import { LexicalComposer } from '@lexical/react/LexicalComposer';
|
||||
@@ -81,7 +81,7 @@ export interface Jinja2EditorProps {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
options?: Suggestion[];
|
||||
variant?: 'outlined' | 'borderless';
|
||||
variant?: 'outlined' | 'borderless' | 'filled';
|
||||
height?: number;
|
||||
size?: 'default' | 'small';
|
||||
className?: string;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2025-12-23 16:22:51
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-07 16:29:36
|
||||
* @Last Modified time: 2026-04-16 12:04:37
|
||||
*/
|
||||
import { type FC, useState, useMemo } from 'react';
|
||||
import { LexicalComposer } from '@lexical/react/LexicalComposer';
|
||||
@@ -25,7 +25,7 @@ export interface LexicalEditorProps {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
options?: Suggestion[];
|
||||
variant?: 'outlined' | 'borderless';
|
||||
variant?: 'outlined' | 'borderless' | 'filled';
|
||||
height?: number;
|
||||
fontSize?: number;
|
||||
lineHeight?: number;
|
||||
@@ -60,6 +60,7 @@ const Editor: FC<LexicalEditorProps> =({
|
||||
}) => {
|
||||
console.log('Editor value', value)
|
||||
const [_count, setCount] = useState(0);
|
||||
const [focused, setFocused] = useState(false);
|
||||
|
||||
if (language === 'jinja2') {
|
||||
return (
|
||||
@@ -90,7 +91,7 @@ const Editor: FC<LexicalEditorProps> =({
|
||||
// Calculate minimum height based on type and size
|
||||
const minheight = useMemo(() => {
|
||||
if (type === 'input') {
|
||||
return `${height ? height : size === 'small' && variant === 'borderless' ? 18 : size === 'small' ? 26 : 30}px`
|
||||
return `${height ? height : size === 'small' && ['borderless', 'filled'].includes(variant) ? 18 : size === 'small' ? 26 : 30}px`
|
||||
}
|
||||
return `${height ? height : size === 'small' ? 60 : 120}px`
|
||||
}, [type, size, height, variant])
|
||||
@@ -103,7 +104,7 @@ const Editor: FC<LexicalEditorProps> =({
|
||||
// Calculate line height based on size prop
|
||||
const lineHeight = useMemo(() => {
|
||||
return `${height ? height - 10 : size === 'small' && variant === 'borderless' ? 18 : size === 'small' ? 16 : 20}px`
|
||||
}, [size])
|
||||
}, [size, height, variant])
|
||||
|
||||
// Calculate placeholder minimum height
|
||||
const placeHolderMinheight = useMemo(() => {
|
||||
@@ -112,20 +113,24 @@ const Editor: FC<LexicalEditorProps> =({
|
||||
|
||||
return (
|
||||
<LexicalComposer initialConfig={initialConfig}>
|
||||
<div style={{ position: 'relative' }} className={className}>
|
||||
<div style={{ position: 'relative', borderRadius: '8px', background: variant === 'filled' ? '#F6F6F6': 'transparent' }} className={className}>
|
||||
<RichTextPlugin
|
||||
contentEditable={
|
||||
<ContentEditable
|
||||
style={{
|
||||
minHeight: minheight,
|
||||
padding: height ? '4px 6px' : variant === 'borderless' ? '0' : '6px 8px',
|
||||
border: variant === 'borderless' ? 'none' : '1px solid #EBEBEB',
|
||||
padding: height ? '4px 6px' : variant === 'outlined' ? '6px 8px': '0',
|
||||
border: type === 'input' && focused
|
||||
? '1px solid #171719'
|
||||
: variant === 'outlined' ? '1px solid #EBEBEB' : 'none',
|
||||
borderRadius: '8px',
|
||||
outline: 'none',
|
||||
resize: 'none',
|
||||
fontSize: fontSize,
|
||||
lineHeight: lineHeight,
|
||||
}}
|
||||
onFocus={() => type === 'input' && setFocused(true)}
|
||||
onBlur={() => type === 'input' && setFocused(false)}
|
||||
/>
|
||||
}
|
||||
placeholder={
|
||||
@@ -133,12 +138,13 @@ const Editor: FC<LexicalEditorProps> =({
|
||||
style={{
|
||||
minHeight: placeHolderMinheight,
|
||||
position: 'absolute',
|
||||
top: variant === 'borderless' ? '2px' : '6px',
|
||||
left: variant === 'borderless' ? '0' : '11px',
|
||||
color: '#A8A9AA',
|
||||
top: variant === 'outlined' ? '6px' : type === 'input' ? '6px' : '2px',
|
||||
left: variant === 'outlined' ? '11px' : type === 'input' ? '8px' : '0',
|
||||
color: 'rgba(23,23,25,0.25)',
|
||||
fontSize: fontSize,
|
||||
lineHeight: placeHolderMinheight,
|
||||
pointerEvents: 'none',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
>
|
||||
{placeholder}
|
||||
|
||||
@@ -48,17 +48,13 @@ const VariableComponent: React.FC<{ nodeKey: NodeKey; data: Suggestion }> = ({
|
||||
return (
|
||||
<span
|
||||
onClick={handleClick}
|
||||
className={clsx('rb:border rb:rounded-md rb:bg-white rb:text-[10px] rb:inline-flex rb:items-center rb:py-0 rb:px-1.5 rb:mx-0.5 rb:cursor-pointer', {
|
||||
'rb:border-[#171719]': isSelected,
|
||||
'rb:border-[#DFE4ED]': !isSelected
|
||||
})}
|
||||
className="rb-border rb:rounded-md rb:bg-white rb:text-[10px] rb:text-[#212332] rb:h-5! rb:inline-flex rb:items-center rb:p-1 rb:mx-px rb:cursor-pointer"
|
||||
contentEditable={false}
|
||||
>
|
||||
{data.isContext ? (
|
||||
<span style={{ fontSize: '12px', marginRight: '4px' }}>📄</span>
|
||||
) : data.group !== 'CONVERSATION' && !data.value.includes('conv') ? (
|
||||
<span className={`rb:size-4 rb:mr-1 rb:bg-cover rb:inline-block rb:flex-shrink-0 ${data.nodeData?.icon}`} />
|
||||
) : <span className="rb:inline-block rb:h-4"></span>}
|
||||
{!data.isContext && data.group !== 'CONVERSATION' && !data.value.includes('conv')
|
||||
? <div className={`rb:size-3 rb:mr-1 rb:bg-cover ${data.nodeData?.icon}`} />
|
||||
: null
|
||||
}
|
||||
{!data.isContext && data.group !== 'CONVERSATION' && (
|
||||
<>
|
||||
{!data.value.includes('conv') && <>
|
||||
@@ -73,7 +69,7 @@ const VariableComponent: React.FC<{ nodeKey: NodeKey; data: Suggestion }> = ({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<span className="rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap rb:flex-1 rb:text-[#171719]">{data.label}</span>
|
||||
<span className="rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap rb:flex-1">{data.label}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2025-12-23 16:22:51
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-07 16:51:04
|
||||
* @Last Modified time: 2026-04-13 14:00:07
|
||||
*/
|
||||
import { useEffect, useLayoutEffect, useState, useRef, type FC } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import { $getSelection, $isRangeSelection, COMMAND_PRIORITY_HIGH, KEY_ENTER_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND } from 'lexical';
|
||||
import { Space, Flex } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { INSERT_VARIABLE_COMMAND, CLOSE_AUTOCOMPLETE_COMMAND } from '../commands';
|
||||
import type { NodeProperties } from '../../../types'
|
||||
@@ -34,61 +36,62 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0, anchorBottom: 0 });
|
||||
const [expandedParent, setExpandedParent] = useState<Suggestion | null>(null);
|
||||
const [childPanelTop, setChildPanelTop] = useState(0);
|
||||
const [childPanelPos, setChildPanelPos] = useState({ top: 0, right: 0 });
|
||||
const [activePanel, setActivePanel] = useState<'main' | 'child'>('main');
|
||||
const [childActiveIndex, setChildActiveIndex] = useState(-1);
|
||||
const popupRef = useRef<HTMLDivElement>(null);
|
||||
const itemRefs = useRef<Map<string, HTMLElement>>(new Map());
|
||||
const childItemRefs = useRef<Map<string, HTMLElement>>(new Map());
|
||||
|
||||
// Adjust popup position after render based on actual height
|
||||
// Adjust popup position after render based on actual size
|
||||
useLayoutEffect(() => {
|
||||
if (!popupRef.current || !showSuggestions) return;
|
||||
const { top, anchorBottom } = popupPosition;
|
||||
const { top, left, anchorBottom } = popupPosition;
|
||||
const popupHeight = popupRef.current.offsetHeight;
|
||||
const popupWidth = popupRef.current.offsetWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const viewportWidth = window.innerWidth;
|
||||
const MARGIN = 10;
|
||||
|
||||
let finalTop: number;
|
||||
if (top - popupHeight - MARGIN >= 0) {
|
||||
// Enough space above: show above cursor
|
||||
finalTop = top - popupHeight - MARGIN;
|
||||
} else {
|
||||
// Not enough space above: show below cursor
|
||||
finalTop = anchorBottom + MARGIN;
|
||||
if (finalTop + popupHeight > viewportHeight - MARGIN) {
|
||||
finalTop = viewportHeight - popupHeight - MARGIN;
|
||||
}
|
||||
}
|
||||
|
||||
if (finalTop !== top) {
|
||||
setPopupPosition(prev => ({ ...prev, top: finalTop }));
|
||||
let finalLeft = left;
|
||||
if (finalLeft + popupWidth > viewportWidth - MARGIN) {
|
||||
finalLeft = viewportWidth - popupWidth - MARGIN;
|
||||
}
|
||||
if (finalLeft < MARGIN) finalLeft = MARGIN;
|
||||
|
||||
if (finalTop !== top || finalLeft !== left) {
|
||||
setPopupPosition(prev => ({ ...prev, top: finalTop, left: finalLeft }));
|
||||
}
|
||||
}, [showSuggestions, popupPosition.anchorBottom]);
|
||||
|
||||
const CHILD_PANEL_HEIGHT = 280; // max-h-60 (240) + header (~40)
|
||||
const CHILD_PANEL_HEIGHT = 280;
|
||||
|
||||
const calcChildPanelTop = (elRect: DOMRect, popupRect: DOMRect) => {
|
||||
const relativeTop = elRect.top - popupRect.top;
|
||||
const absoluteBottom = popupRect.top + relativeTop + CHILD_PANEL_HEIGHT;
|
||||
const overflow = absoluteBottom - (window.innerHeight - 10);
|
||||
return overflow > 0 ? relativeTop - overflow : relativeTop;
|
||||
const calcChildPanelPos = (key: string) => {
|
||||
const el = itemRefs.current.get(key);
|
||||
if (!el || !popupRef.current) return;
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const popupRect = popupRef.current.getBoundingClientRect();
|
||||
const actualChildHeight = Math.min(CHILD_PANEL_HEIGHT, popupRect.height);
|
||||
const top = Math.max(10, popupRect.bottom - actualChildHeight);
|
||||
setChildPanelPos({ top, right: window.innerWidth - elRect.left + 8 });
|
||||
};
|
||||
|
||||
const scrollSelectedIntoView = () => {
|
||||
if (!popupRef.current) return;
|
||||
|
||||
const selectedElement = popupRef.current.querySelector('[data-selected="true"]');
|
||||
if (!selectedElement) return;
|
||||
|
||||
const container = popupRef.current;
|
||||
const element = selectedElement as HTMLElement;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const elementRect = element.getBoundingClientRect();
|
||||
|
||||
if (elementRect.bottom > containerRect.bottom) {
|
||||
container.scrollTop += elementRect.bottom - containerRect.bottom;
|
||||
} else if (elementRect.top < containerRect.top) {
|
||||
container.scrollTop -= containerRect.top - elementRect.top;
|
||||
}
|
||||
const resetState = () => {
|
||||
setShowSuggestions(false);
|
||||
setExpandedParent(null);
|
||||
setChildPanelPos({ top: 0, right: 0 });
|
||||
setActivePanel('main');
|
||||
setChildActiveIndex(-1);
|
||||
};
|
||||
|
||||
// Listen to editor updates and show suggestions when '/' is typed
|
||||
@@ -104,11 +107,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
|
||||
|
||||
const anchorNode = selection.anchor.getNode();
|
||||
const anchorOffset = selection.anchor.offset;
|
||||
|
||||
// Get the text content of the current node
|
||||
const nodeText = anchorNode.getTextContent();
|
||||
|
||||
// Check if we have a '/' at the current position or after line break
|
||||
const textBeforeCursor = nodeText.substring(0, anchorOffset);
|
||||
const shouldShow = textBeforeCursor.endsWith('/') ||
|
||||
(textBeforeCursor === '/' && anchorOffset === 1);
|
||||
@@ -117,10 +116,11 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
|
||||
if (!shouldShow) {
|
||||
setSelectedIndex(0);
|
||||
setExpandedParent(null);
|
||||
setChildPanelTop(0);
|
||||
setChildPanelPos({ top: 0, right: 0 });
|
||||
setActivePanel('main');
|
||||
setChildActiveIndex(-1);
|
||||
}
|
||||
|
||||
// Calculate popup position to keep it within viewport bounds
|
||||
if (shouldShow) {
|
||||
const domSelection = window.getSelection();
|
||||
if (domSelection && domSelection.rangeCount > 0) {
|
||||
@@ -148,9 +148,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
|
||||
return editor.registerCommand(
|
||||
CLOSE_AUTOCOMPLETE_COMMAND,
|
||||
() => {
|
||||
setShowSuggestions(false);
|
||||
setExpandedParent(null);
|
||||
setChildPanelTop(0);
|
||||
resetState();
|
||||
return true;
|
||||
},
|
||||
COMMAND_PRIORITY_HIGH
|
||||
@@ -160,9 +158,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
|
||||
// Insert selected suggestion into editor
|
||||
const insertMention = (suggestion: Suggestion) => {
|
||||
editor.dispatchCommand(INSERT_VARIABLE_COMMAND, { data: suggestion });
|
||||
setShowSuggestions(false);
|
||||
setExpandedParent(null);
|
||||
setChildPanelTop(0);
|
||||
resetState();
|
||||
};
|
||||
|
||||
// Group suggestions by node ID
|
||||
@@ -176,13 +172,28 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
|
||||
return groups;
|
||||
}, {});
|
||||
|
||||
// Flat list for keyboard navigation
|
||||
const flatOptions = Object.values(groupedSuggestions).flat().flatMap(option => {
|
||||
if (option.key === expandedParent?.key && option.children?.length) {
|
||||
return [option, ...option.children];
|
||||
// Flat list of main-panel items for keyboard navigation
|
||||
const flatOptions = Object.values(groupedSuggestions).flat();
|
||||
|
||||
// Sync child panel position when keyboard navigates to a parent with children
|
||||
useEffect(() => {
|
||||
if (selectedIndex < 0 || selectedIndex >= flatOptions.length) return;
|
||||
const s = flatOptions[selectedIndex];
|
||||
if (s.children?.length) {
|
||||
calcChildPanelPos(s.key);
|
||||
setExpandedParent(s);
|
||||
} else {
|
||||
setExpandedParent(null);
|
||||
}
|
||||
return [option];
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedIndex]);
|
||||
|
||||
// Scroll child active item into view
|
||||
useEffect(() => {
|
||||
if (!expandedParent?.children?.length || childActiveIndex < 0) return;
|
||||
const child = expandedParent.children[childActiveIndex];
|
||||
if (child) childItemRefs.current.get(child.key)?.scrollIntoView({ block: 'nearest' });
|
||||
}, [childActiveIndex, expandedParent]);
|
||||
|
||||
// Handle Enter key to select suggestion
|
||||
useEffect(() => {
|
||||
@@ -191,7 +202,15 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
|
||||
return editor.registerCommand(
|
||||
KEY_ENTER_COMMAND,
|
||||
(event) => {
|
||||
if (showSuggestions && flatOptions.length > 0) {
|
||||
if (!showSuggestions) return false;
|
||||
if (activePanel === 'child' && expandedParent?.children?.length) {
|
||||
const child = expandedParent.children[childActiveIndex];
|
||||
if (child && !child.disabled) {
|
||||
event?.preventDefault();
|
||||
insertMention(child);
|
||||
return true;
|
||||
}
|
||||
} else if (flatOptions.length > 0) {
|
||||
const selectedOption = flatOptions[selectedIndex];
|
||||
if (selectedOption && !selectedOption.disabled) {
|
||||
event?.preventDefault();
|
||||
@@ -203,57 +222,56 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
|
||||
},
|
||||
COMMAND_PRIORITY_HIGH
|
||||
);
|
||||
}, [showSuggestions, selectedIndex, flatOptions, insertMention, editor]);
|
||||
}, [showSuggestions, selectedIndex, flatOptions, insertMention, editor, activePanel, childActiveIndex, expandedParent]);
|
||||
|
||||
// Handle keyboard navigation (Arrow Up/Down, Escape)
|
||||
// Handle keyboard navigation (Arrow Up/Down/Left/Right, Escape)
|
||||
useEffect(() => {
|
||||
if (!showSuggestions) return;
|
||||
|
||||
// Navigate down through suggestions, skip disabled items
|
||||
const unregisterArrowDown = editor.registerCommand(
|
||||
KEY_ARROW_DOWN_COMMAND,
|
||||
(event) => {
|
||||
if (showSuggestions && flatOptions.length > 0) {
|
||||
event?.preventDefault();
|
||||
if (!showSuggestions) return false;
|
||||
event?.preventDefault();
|
||||
if (activePanel === 'child' && expandedParent?.children) {
|
||||
setChildActiveIndex(i => Math.min(i + 1, expandedParent.children!.length - 1));
|
||||
} else {
|
||||
setSelectedIndex(prev => {
|
||||
let nextIndex = prev + 1;
|
||||
while (nextIndex < flatOptions.length && flatOptions[nextIndex].disabled) {
|
||||
nextIndex++;
|
||||
}
|
||||
const newIndex = nextIndex >= flatOptions.length ? prev : nextIndex;
|
||||
setTimeout(() => scrollSelectedIntoView(), 0);
|
||||
let next = prev + 1;
|
||||
// skip items that are disabled AND have no children
|
||||
while (next < flatOptions.length && flatOptions[next].disabled && !flatOptions[next].children?.length) next++;
|
||||
const newIndex = next >= flatOptions.length ? prev : next;
|
||||
setTimeout(() => itemRefs.current.get(flatOptions[newIndex]?.key)?.scrollIntoView({ block: 'nearest' }), 0);
|
||||
return newIndex;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
return true;
|
||||
},
|
||||
COMMAND_PRIORITY_HIGH
|
||||
);
|
||||
|
||||
// Navigate up through suggestions, skip disabled items
|
||||
const unregisterArrowUp = editor.registerCommand(
|
||||
KEY_ARROW_UP_COMMAND,
|
||||
(event) => {
|
||||
if (showSuggestions && flatOptions.length > 0) {
|
||||
event?.preventDefault();
|
||||
if (!showSuggestions) return false;
|
||||
event?.preventDefault();
|
||||
if (activePanel === 'child' && expandedParent?.children) {
|
||||
setChildActiveIndex(i => Math.max(i - 1, 0));
|
||||
} else {
|
||||
setSelectedIndex(prev => {
|
||||
let prevIndex = prev - 1;
|
||||
while (prevIndex >= 0 && flatOptions[prevIndex].disabled) {
|
||||
prevIndex--;
|
||||
}
|
||||
const newIndex = prevIndex < 0 ? prev : prevIndex;
|
||||
setTimeout(() => scrollSelectedIntoView(), 0);
|
||||
let prevIdx = prev - 1;
|
||||
// skip items that are disabled AND have no children
|
||||
while (prevIdx >= 0 && flatOptions[prevIdx].disabled && !flatOptions[prevIdx].children?.length) prevIdx--;
|
||||
const newIndex = prevIdx < 0 ? prev : prevIdx;
|
||||
setTimeout(() => itemRefs.current.get(flatOptions[newIndex]?.key)?.scrollIntoView({ block: 'nearest' }), 0);
|
||||
return newIndex;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
return true;
|
||||
},
|
||||
COMMAND_PRIORITY_HIGH
|
||||
);
|
||||
|
||||
// Close suggestions on Escape key
|
||||
const unregisterEscape = editor.registerCommand(
|
||||
KEY_ESCAPE_COMMAND,
|
||||
(event) => {
|
||||
@@ -272,130 +290,156 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
|
||||
unregisterArrowUp();
|
||||
unregisterEscape();
|
||||
};
|
||||
}, [showSuggestions, selectedIndex, flatOptions, editor]);
|
||||
}, [showSuggestions, selectedIndex, flatOptions, editor, activePanel, childActiveIndex, expandedParent]);
|
||||
|
||||
// Handle ArrowLeft/Right for panel switching via native keydown (lexical doesn't expose these commands)
|
||||
useEffect(() => {
|
||||
if (!showSuggestions) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
const current = flatOptions[selectedIndex];
|
||||
if (activePanel === 'main' && current?.children?.length) {
|
||||
e.preventDefault();
|
||||
setActivePanel('child');
|
||||
setChildActiveIndex(0);
|
||||
}
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
if (activePanel === 'child') {
|
||||
e.preventDefault();
|
||||
setActivePanel('main');
|
||||
setChildActiveIndex(-1);
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handler, true);
|
||||
return () => document.removeEventListener('keydown', handler, true);
|
||||
}, [showSuggestions, activePanel, selectedIndex, flatOptions]);
|
||||
|
||||
if (!showSuggestions) return null;
|
||||
if (Object.entries(groupedSuggestions).length === 0) return null;
|
||||
|
||||
if (Object.entries(groupedSuggestions).length === 0) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div
|
||||
ref={popupRef}
|
||||
data-autocomplete-popup="true"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
className="rb:fixed rb:z-1000 rb:bg-white rb:rounded-xl rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)]"
|
||||
style={{
|
||||
top: popupPosition.top,
|
||||
left: popupPosition.left,
|
||||
}}
|
||||
>
|
||||
<div className="rb:py-1 rb:min-w-70 rb:max-h-50 rb:overflow-y-auto">
|
||||
<Flex vertical gap={12}>
|
||||
{Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => {
|
||||
const nodeName = nodeOptions[0]?.nodeData?.name || nodeId;
|
||||
const nodeIcon = nodeOptions[0]?.nodeData?.icon;
|
||||
return (
|
||||
<div key={nodeId}>
|
||||
{nodeName !== 'undefined' && <Flex align="center" gap={4} className="rb:px-3! rb:text-[12px] rb:py-1.25! rb:font-medium rb:text-[#5B6167]">
|
||||
{nodeIcon && <div className={`rb:size-3 rb:bg-cover ${nodeIcon}`} />}
|
||||
{nodeName}
|
||||
</Flex>}
|
||||
{nodeOptions.map((option) => {
|
||||
const globalIndex = flatOptions.indexOf(option);
|
||||
const isExpanded = expandedParent?.key === option.key;
|
||||
const hasChildren = !!option.children?.length;
|
||||
return (
|
||||
<Flex
|
||||
key={option.key}
|
||||
ref={(el) => { if (el) itemRefs.current.set(option.key, el); }}
|
||||
data-selected={selectedIndex === globalIndex}
|
||||
className="rb:pl-6! rb:pr-3! rb:py-2!"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{
|
||||
cursor: option.disabled ? 'not-allowed' : 'pointer',
|
||||
background: (selectedIndex === globalIndex || isExpanded) ? '#f0f8ff' : 'white',
|
||||
opacity: option.disabled ? 0.5 : 1,
|
||||
}}
|
||||
onClick={() => {
|
||||
if (option.disabled) return;
|
||||
insertMention(option);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setSelectedIndex(globalIndex);
|
||||
if (hasChildren) {
|
||||
const el = itemRefs.current.get(option.key);
|
||||
if (el && popupRef.current) {
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const popupRect = popupRef.current.getBoundingClientRect();
|
||||
setChildPanelTop(calcChildPanelTop(elRect, popupRect));
|
||||
<>
|
||||
<div
|
||||
ref={popupRef}
|
||||
data-autocomplete-popup="true"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
className="rb:fixed rb:z-1000 rb:bg-white rb:rounded-lg rb:border-[0.5px] rb:border-[#EBEBEB] rb:shadow-[0px_2px_6px_0px_rgba(0,0,0,0.1)] rb:py-3 rb:px-2"
|
||||
style={{
|
||||
top: popupPosition.top,
|
||||
left: popupPosition.left,
|
||||
}}
|
||||
>
|
||||
<div className="rb:min-w-70 rb:max-h-57.5 rb:overflow-y-auto">
|
||||
<Flex vertical gap={12}>
|
||||
{Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => {
|
||||
const nodeName = nodeOptions[0]?.nodeData?.name || nodeId;
|
||||
return (
|
||||
<div key={nodeId} className="rb:text-[12px]">
|
||||
{nodeName !== 'undefined' &&
|
||||
<div className="rb:px-2 rb:leading-4.25 rb:mb-1.25 rb:font-medium rb:text-[#5B6167]">
|
||||
{nodeName}
|
||||
</div>
|
||||
}
|
||||
<Flex vertical gap={2}>
|
||||
{nodeOptions.map((option) => {
|
||||
const globalIndex = flatOptions.indexOf(option);
|
||||
const isExpanded = expandedParent?.key === option.key;
|
||||
const hasChildren = !!option.children?.length;
|
||||
const isActive = activePanel === 'main' && selectedIndex === globalIndex;
|
||||
return (
|
||||
<Flex
|
||||
key={option.key}
|
||||
ref={(el) => { if (el) itemRefs.current.set(option.key, el); }}
|
||||
className={clsx("rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]", {
|
||||
'rb:bg-[#F6F6F6]': isActive || isExpanded,
|
||||
'rb:cursor-not-allowed rb:opacity-65': option.disabled,
|
||||
'rb:cursor-pointer': !option.disabled,
|
||||
})}
|
||||
align="center"
|
||||
justify="space-between"
|
||||
onClick={() => {
|
||||
if (option.disabled && !hasChildren) return;
|
||||
if (!option.disabled) insertMention(option);
|
||||
if (hasChildren) {
|
||||
calcChildPanelPos(option.key);
|
||||
setExpandedParent(option);
|
||||
}
|
||||
setExpandedParent(option);
|
||||
} else {
|
||||
setExpandedParent(null);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setSelectedIndex(globalIndex);
|
||||
setActivePanel('main');
|
||||
setChildActiveIndex(-1);
|
||||
if (hasChildren) {
|
||||
calcChildPanelPos(option.key);
|
||||
setExpandedParent(option);
|
||||
} else {
|
||||
setExpandedParent(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{option.label &&
|
||||
<div className="rb:font-medium">
|
||||
<span className="rb:text-[#155EEF]">{`{x}`}</span> {option.label}
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
>
|
||||
{option.label && <Space size={4}>
|
||||
<span className="rb:text-[#155EEF]">{option.isContext ? '📄' : `{x}`}</span>
|
||||
<span>{option.label}</span>
|
||||
</Space>}
|
||||
<Space size={4}>
|
||||
{option.dataType && <span className="rb:text-[#5B6167]">{option.dataType}</span>}
|
||||
{hasChildren && <span className="rb:text-[#5B6167] rb:ml-1">›</span>}
|
||||
</Space>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
<Space size={2}>
|
||||
{option.dataType && <span>{option.dataType}</span>}
|
||||
{hasChildren && <div className="rb:size-3 rb:bg-cover rb:bg-[url('@/assets/images/common/arrow_up.svg')] rb:rotate-90"></div>}
|
||||
</Space>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</div>
|
||||
</div>
|
||||
{/* Child variables panel - floats to the left */}
|
||||
{expandedParent?.children?.length && (
|
||||
|
||||
{/* Child variables panel - fixed positioned via portal to avoid clipping */}
|
||||
{expandedParent?.children?.length && createPortal(
|
||||
<div
|
||||
className="rb:absolute rb:bg-white rb:rounded-xl rb:py-1 rb:min-w-60 rb:max-h-60 rb:overflow-y-auto rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)]"
|
||||
style={{
|
||||
top: childPanelTop,
|
||||
right: 'calc(100% + 8px)',
|
||||
transform: 'translateY(-8px)',
|
||||
}}
|
||||
onMouseEnter={() => setExpandedParent(expandedParent)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
className="rb:min-w-70 rb:max-h-57.5 rb:overflow-y-auto rb:text-[12px] rb:fixed rb:z-1000 rb:bg-white rb:rounded-lg rb:border-[0.5px] rb:border-[#EBEBEB] rb:shadow-[0px_2px_6px_0px_rgba(0,0,0,0.1)] rb:py-3 rb:px-2"
|
||||
style={{ top: childPanelPos.top, right: childPanelPos.right }}
|
||||
onMouseEnter={() => setActivePanel('child')}
|
||||
onMouseLeave={() => { setActivePanel('main'); setChildActiveIndex(-1); }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="rb:px-3 rb:py-2 rb:text-[12px] rb:font-medium rb:text-[#5B6167] rb:border-b rb:border-[#F0F0F0]">
|
||||
<Flex justify="space-between" align="center">
|
||||
<div className="rb:pb-2 rb:mb-1 rb:font-medium rb:text-[#5B6167] rb-border-b">
|
||||
<Flex justify="space-between" align="center" gap={8}>
|
||||
<span>{expandedParent.nodeData.name}.{expandedParent.label}</span>
|
||||
<span>{expandedParent.dataType}</span>
|
||||
</Flex>
|
||||
</div>
|
||||
{expandedParent.children.map((child) => {
|
||||
const childIndex = flatOptions.indexOf(child);
|
||||
{expandedParent.children.map((child, ci) => {
|
||||
const isChildActive = activePanel === 'child' && ci === childActiveIndex;
|
||||
return (
|
||||
<Flex
|
||||
key={child.key}
|
||||
data-selected={selectedIndex === childIndex}
|
||||
className="rb:px-3! rb:py-2!"
|
||||
ref={(el) => { if (el) childItemRefs.current.set(child.key, el); }}
|
||||
className={clsx("rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]", {
|
||||
'rb:bg-[#F6F6F6]': isChildActive,
|
||||
'rb:cursor-not-allowed rb:opacity-65': child.disabled,
|
||||
'rb:cursor-pointer': !child.disabled,
|
||||
})}
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{
|
||||
cursor: child.disabled ? 'not-allowed' : 'pointer',
|
||||
background: selectedIndex === childIndex ? '#f0f8ff' : 'white',
|
||||
opacity: child.disabled ? 0.5 : 1,
|
||||
}}
|
||||
onClick={() => !child.disabled && insertMention(child)}
|
||||
onMouseEnter={() => setSelectedIndex(childIndex)}
|
||||
onMouseEnter={() => { setActivePanel('child'); setChildActiveIndex(ci); }}
|
||||
>
|
||||
<span>{child.label}</span>
|
||||
{child.dataType && <span className="rb:text-[#5B6167]">{child.dataType}</span>}
|
||||
<span className="rb:font-medium">
|
||||
<span className="rb:text-[#155EEF]">{`{x}`}</span> {child.label}
|
||||
</span>
|
||||
{child.dataType && <span>{child.dataType}</span>}
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export default AutocompletePlugin
|
||||
export default AutocompletePlugin
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* @Last Modified time: 2026-04-07 14:50:14
|
||||
*/
|
||||
import { useEffect, useLayoutEffect, useState, useRef, type FC } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import {
|
||||
$getSelection, $isRangeSelection, $isTextNode,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND,
|
||||
} from 'lexical';
|
||||
import { Space, Flex } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { CLOSE_AUTOCOMPLETE_COMMAND } from '../commands';
|
||||
import type { Suggestion } from './AutocompletePlugin';
|
||||
@@ -22,17 +24,22 @@ const Jinja2AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) =>
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0, anchorBottom: 0 });
|
||||
const [expandedParent, setExpandedParent] = useState<Suggestion | null>(null);
|
||||
const [childPanelTop, setChildPanelTop] = useState(0);
|
||||
const [childPanelPos, setChildPanelPos] = useState({ top: 0, right: 0 });
|
||||
const [activePanel, setActivePanel] = useState<'main' | 'child'>('main');
|
||||
const [childActiveIndex, setChildActiveIndex] = useState(-1);
|
||||
const popupRef = useRef<HTMLDivElement>(null);
|
||||
const itemRefs = useRef<Map<string, HTMLElement>>(new Map());
|
||||
const childItemRefs = useRef<Map<string, HTMLElement>>(new Map());
|
||||
|
||||
const CHILD_PANEL_HEIGHT = 280;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!popupRef.current || !showSuggestions) return;
|
||||
const { top, anchorBottom } = popupPosition;
|
||||
const { top, left, anchorBottom } = popupPosition;
|
||||
const popupHeight = popupRef.current.offsetHeight;
|
||||
const popupWidth = popupRef.current.offsetWidth;
|
||||
const MARGIN = 10;
|
||||
|
||||
let finalTop: number;
|
||||
if (top - popupHeight - MARGIN >= 0) {
|
||||
finalTop = top - popupHeight - MARGIN;
|
||||
@@ -41,51 +48,57 @@ const Jinja2AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) =>
|
||||
if (finalTop + popupHeight > window.innerHeight - MARGIN)
|
||||
finalTop = window.innerHeight - popupHeight - MARGIN;
|
||||
}
|
||||
if (finalTop !== top) setPopupPosition(prev => ({ ...prev, top: finalTop }));
|
||||
|
||||
let finalLeft = left;
|
||||
if (finalLeft + popupWidth > window.innerWidth - MARGIN)
|
||||
finalLeft = window.innerWidth - popupWidth - MARGIN;
|
||||
if (finalLeft < MARGIN) finalLeft = MARGIN;
|
||||
|
||||
if (finalTop !== top || finalLeft !== left)
|
||||
setPopupPosition(prev => ({ ...prev, top: finalTop, left: finalLeft }));
|
||||
}, [showSuggestions, popupPosition.anchorBottom]);
|
||||
|
||||
const calcChildPanelTop = (elRect: DOMRect, popupRect: DOMRect) => {
|
||||
const relativeTop = elRect.top - popupRect.top;
|
||||
const overflow = popupRect.top + relativeTop + CHILD_PANEL_HEIGHT - (window.innerHeight - 10);
|
||||
return overflow > 0 ? relativeTop - overflow : relativeTop;
|
||||
const calcChildPanelPos = (key: string) => {
|
||||
const el = itemRefs.current.get(key);
|
||||
if (!el || !popupRef.current) return;
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const popupRect = popupRef.current.getBoundingClientRect();
|
||||
const actualChildHeight = Math.min(CHILD_PANEL_HEIGHT, popupRect.height);
|
||||
const top = Math.max(10, popupRect.bottom - actualChildHeight);
|
||||
setChildPanelPos({ top, right: window.innerWidth - elRect.left + 8 });
|
||||
};
|
||||
|
||||
const scrollSelectedIntoView = () => {
|
||||
if (!popupRef.current) return;
|
||||
const selectedElement = popupRef.current.querySelector('[data-selected="true"]');
|
||||
if (!selectedElement) return;
|
||||
const container = popupRef.current;
|
||||
const element = selectedElement as HTMLElement;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const elementRect = element.getBoundingClientRect();
|
||||
if (elementRect.bottom > containerRect.bottom) {
|
||||
container.scrollTop += elementRect.bottom - containerRect.bottom;
|
||||
} else if (elementRect.top < containerRect.top) {
|
||||
container.scrollTop -= containerRect.top - elementRect.top;
|
||||
}
|
||||
const resetState = () => {
|
||||
setShowSuggestions(false);
|
||||
setExpandedParent(null);
|
||||
setChildPanelPos({ top: 0, right: 0 });
|
||||
setActivePanel('main');
|
||||
setChildActiveIndex(-1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerUpdateListener(({ editorState }) => {
|
||||
editorState.read(() => {
|
||||
const selection = $getSelection();
|
||||
if (!selection || !$isRangeSelection(selection)) {
|
||||
setShowSuggestions(false);
|
||||
return;
|
||||
}
|
||||
if (!selection || !$isRangeSelection(selection)) { setShowSuggestions(false); return; }
|
||||
const anchorNode = selection.anchor.getNode();
|
||||
const anchorOffset = selection.anchor.offset;
|
||||
const textBeforeCursor = anchorNode.getTextContent().substring(0, anchorOffset);
|
||||
const shouldShow = textBeforeCursor.endsWith('/');
|
||||
setShowSuggestions(shouldShow);
|
||||
if (!shouldShow) { setSelectedIndex(0); setExpandedParent(null); setChildPanelTop(0); return; }
|
||||
|
||||
if (!shouldShow) {
|
||||
setSelectedIndex(0);
|
||||
setExpandedParent(null);
|
||||
setChildPanelPos({ top: 0, right: 0 });
|
||||
setActivePanel('main');
|
||||
setChildActiveIndex(-1);
|
||||
return;
|
||||
}
|
||||
const domSelection = window.getSelection();
|
||||
if (domSelection && domSelection.rangeCount > 0) {
|
||||
const rect = domSelection.getRangeAt(0).getBoundingClientRect();
|
||||
const popupWidth = 280;
|
||||
let left = rect.left;
|
||||
if (left + popupWidth > window.innerWidth) left = window.innerWidth - popupWidth - 10;
|
||||
if (left + 280 > window.innerWidth) left = window.innerWidth - 280 - 10;
|
||||
if (left < 10) left = 10;
|
||||
setPopupPosition({ top: rect.top, left, anchorBottom: rect.bottom });
|
||||
}
|
||||
@@ -96,7 +109,7 @@ const Jinja2AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) =>
|
||||
useEffect(() => {
|
||||
return editor.registerCommand(
|
||||
CLOSE_AUTOCOMPLETE_COMMAND,
|
||||
() => { setShowSuggestions(false); setExpandedParent(null); setChildPanelTop(0); return true; },
|
||||
() => { resetState(); return true; },
|
||||
COMMAND_PRIORITY_HIGH,
|
||||
);
|
||||
}, [editor]);
|
||||
@@ -119,9 +132,7 @@ const Jinja2AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) =>
|
||||
}
|
||||
});
|
||||
document.dispatchEvent(new CustomEvent('jinja2-variable-inserted', { detail: { value: suggestion.value } }));
|
||||
setShowSuggestions(false);
|
||||
setExpandedParent(null);
|
||||
setChildPanelTop(0);
|
||||
resetState();
|
||||
};
|
||||
|
||||
const groupedSuggestions = options.reduce((groups: Record<string, Suggestion[]>, s) => {
|
||||
@@ -131,152 +142,227 @@ const Jinja2AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) =>
|
||||
return groups;
|
||||
}, {});
|
||||
|
||||
const allOptions = Object.values(groupedSuggestions).flat().flatMap(o =>
|
||||
o.key === expandedParent?.key && o.children?.length ? [o, ...o.children] : [o]
|
||||
);
|
||||
// Flat list of main-panel items for keyboard navigation
|
||||
const flatOptions = Object.values(groupedSuggestions).flat();
|
||||
|
||||
// Sync child panel position when keyboard navigates to a parent with children
|
||||
useEffect(() => {
|
||||
if (selectedIndex < 0 || selectedIndex >= flatOptions.length) return;
|
||||
const s = flatOptions[selectedIndex];
|
||||
if (s.children?.length) {
|
||||
calcChildPanelPos(s.key);
|
||||
setExpandedParent(s);
|
||||
} else {
|
||||
setExpandedParent(null);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedIndex]);
|
||||
|
||||
// Scroll child active item into view
|
||||
useEffect(() => {
|
||||
if (!expandedParent?.children?.length || childActiveIndex < 0) return;
|
||||
const child = expandedParent.children[childActiveIndex];
|
||||
if (child) childItemRefs.current.get(child.key)?.scrollIntoView({ block: 'nearest' });
|
||||
}, [childActiveIndex, expandedParent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSuggestions) return;
|
||||
return editor.registerCommand(
|
||||
KEY_ENTER_COMMAND,
|
||||
(event) => {
|
||||
const opt = allOptions[selectedIndex];
|
||||
if (opt && !opt.disabled) { event?.preventDefault(); insertMention(opt); return true; }
|
||||
if (!showSuggestions) return false;
|
||||
if (activePanel === 'child' && expandedParent?.children?.length) {
|
||||
const child = expandedParent.children[childActiveIndex];
|
||||
if (child && !child.disabled) { event?.preventDefault(); insertMention(child); return true; }
|
||||
} else if (flatOptions.length > 0) {
|
||||
const opt = flatOptions[selectedIndex];
|
||||
if (opt && !opt.disabled) { event?.preventDefault(); insertMention(opt); return true; }
|
||||
}
|
||||
return false;
|
||||
},
|
||||
COMMAND_PRIORITY_HIGH,
|
||||
);
|
||||
}, [showSuggestions, selectedIndex, allOptions]);
|
||||
}, [showSuggestions, selectedIndex, flatOptions, activePanel, childActiveIndex, expandedParent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSuggestions) return;
|
||||
const down = editor.registerCommand(KEY_ARROW_DOWN_COMMAND, (e) => {
|
||||
if (!showSuggestions) return false;
|
||||
e?.preventDefault();
|
||||
setSelectedIndex(prev => {
|
||||
let next = prev + 1;
|
||||
while (next < allOptions.length && allOptions[next].disabled) next++;
|
||||
setTimeout(scrollSelectedIntoView, 0);
|
||||
return next >= allOptions.length ? prev : next;
|
||||
});
|
||||
if (activePanel === 'child' && expandedParent?.children) {
|
||||
setChildActiveIndex(i => Math.min(i + 1, expandedParent.children!.length - 1));
|
||||
} else {
|
||||
setSelectedIndex(prev => {
|
||||
let next = prev + 1;
|
||||
while (next < flatOptions.length && flatOptions[next].disabled && !flatOptions[next].children?.length) next++;
|
||||
const newIndex = next >= flatOptions.length ? prev : next;
|
||||
setTimeout(() => itemRefs.current.get(flatOptions[newIndex]?.key)?.scrollIntoView({ block: 'nearest' }), 0);
|
||||
return newIndex;
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}, COMMAND_PRIORITY_HIGH);
|
||||
|
||||
const up = editor.registerCommand(KEY_ARROW_UP_COMMAND, (e) => {
|
||||
if (!showSuggestions) return false;
|
||||
e?.preventDefault();
|
||||
setSelectedIndex(prev => {
|
||||
let p = prev - 1;
|
||||
while (p >= 0 && allOptions[p].disabled) p--;
|
||||
setTimeout(scrollSelectedIntoView, 0);
|
||||
return p < 0 ? prev : p;
|
||||
});
|
||||
if (activePanel === 'child' && expandedParent?.children) {
|
||||
setChildActiveIndex(i => Math.max(i - 1, 0));
|
||||
} else {
|
||||
setSelectedIndex(prev => {
|
||||
let p = prev - 1;
|
||||
while (p >= 0 && flatOptions[p].disabled && !flatOptions[p].children?.length) p--;
|
||||
const newIndex = p < 0 ? prev : p;
|
||||
setTimeout(() => itemRefs.current.get(flatOptions[newIndex]?.key)?.scrollIntoView({ block: 'nearest' }), 0);
|
||||
return newIndex;
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}, COMMAND_PRIORITY_HIGH);
|
||||
|
||||
const esc = editor.registerCommand(KEY_ESCAPE_COMMAND, (e) => {
|
||||
e?.preventDefault(); setShowSuggestions(false); return true;
|
||||
}, COMMAND_PRIORITY_HIGH);
|
||||
|
||||
return () => { down(); up(); esc(); };
|
||||
}, [showSuggestions, selectedIndex, allOptions, editor]);
|
||||
}, [showSuggestions, selectedIndex, flatOptions, editor, activePanel, childActiveIndex, expandedParent]);
|
||||
|
||||
// ArrowLeft/Right for panel switching
|
||||
useEffect(() => {
|
||||
if (!showSuggestions) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
const current = flatOptions[selectedIndex];
|
||||
if (activePanel === 'main' && current?.children?.length) {
|
||||
e.preventDefault();
|
||||
setActivePanel('child');
|
||||
setChildActiveIndex(0);
|
||||
}
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
if (activePanel === 'child') {
|
||||
e.preventDefault();
|
||||
setActivePanel('main');
|
||||
setChildActiveIndex(-1);
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handler, true);
|
||||
return () => document.removeEventListener('keydown', handler, true);
|
||||
}, [showSuggestions, activePanel, selectedIndex, flatOptions]);
|
||||
|
||||
if (!showSuggestions || Object.keys(groupedSuggestions).length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={popupRef}
|
||||
data-autocomplete-popup="true"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
className="rb:fixed rb:z-1000 rb:bg-white rb:rounded-xl rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)]"
|
||||
style={{ top: popupPosition.top, left: popupPosition.left }}
|
||||
>
|
||||
<div className="rb:py-1 rb:min-w-70 rb:max-h-50 rb:overflow-y-auto">
|
||||
<Flex vertical gap={12}>
|
||||
{Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => (
|
||||
<div key={nodeId}>
|
||||
<Flex align="center" gap={4} className="rb:px-3! rb:text-[12px] rb:py-1.25! rb:font-medium rb:text-[#5B6167]">
|
||||
{nodeOptions[0]?.nodeData?.icon && <div className={`rb:size-3 rb:bg-cover ${nodeOptions[0].nodeData.icon}`} />}
|
||||
{nodeOptions[0]?.nodeData?.name || nodeId}
|
||||
</Flex>
|
||||
{nodeOptions.map((option) => {
|
||||
const globalIndex = allOptions.indexOf(option);
|
||||
const hasChildren = !!option.children?.length;
|
||||
const isExpanded = expandedParent?.key === option.key;
|
||||
<>
|
||||
<div
|
||||
ref={popupRef}
|
||||
data-autocomplete-popup="true"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
className="rb:fixed rb:z-1000 rb:bg-white rb:rounded-lg rb:border-[0.5px] rb:border-[#EBEBEB] rb:shadow-[0px_2px_6px_0px_rgba(0,0,0,0.1)] rb:py-3 rb:px-2"
|
||||
style={{ top: popupPosition.top, left: popupPosition.left }}
|
||||
>
|
||||
<div className="rb:min-w-70 rb:max-h-57.5 rb:overflow-y-auto">
|
||||
<Flex vertical gap={12}>
|
||||
{Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => {
|
||||
const nodeName = nodeOptions[0]?.nodeData?.name || nodeId;
|
||||
return (
|
||||
<Flex
|
||||
key={option.key}
|
||||
ref={(el) => { if (el) itemRefs.current.set(option.key, el); }}
|
||||
data-selected={selectedIndex === globalIndex}
|
||||
className="rb:pl-6! rb:pr-3! rb:py-2!"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{
|
||||
cursor: option.disabled ? 'not-allowed' : 'pointer',
|
||||
background: (selectedIndex === globalIndex || isExpanded) ? '#f0f8ff' : 'white',
|
||||
opacity: option.disabled ? 0.5 : 1,
|
||||
}}
|
||||
onClick={() => { if (option.disabled || hasChildren) return; insertMention(option); }}
|
||||
onMouseEnter={() => {
|
||||
setSelectedIndex(globalIndex);
|
||||
if (hasChildren) {
|
||||
const el = itemRefs.current.get(option.key);
|
||||
if (el && popupRef.current) {
|
||||
setChildPanelTop(calcChildPanelTop(el.getBoundingClientRect(), popupRef.current.getBoundingClientRect()));
|
||||
}
|
||||
setExpandedParent(option);
|
||||
} else {
|
||||
setExpandedParent(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Space size={4}>
|
||||
<span className="rb:text-[#155EEF]">{option.isContext ? '📄' : '{x}'}</span>
|
||||
<span>{option.label}</span>
|
||||
</Space>
|
||||
<Space size={4}>
|
||||
{option.dataType && <span className="rb:text-[#5B6167]">{option.dataType}</span>}
|
||||
{hasChildren && <span className="rb:text-[#5B6167] rb:ml-1">›</span>}
|
||||
</Space>
|
||||
</Flex>
|
||||
<div key={nodeId} className="rb:text-[12px]">
|
||||
{nodeName !== 'undefined' &&
|
||||
<div className="rb:px-2 rb:leading-4.25 rb:mb-1.25 rb:font-medium rb:text-[#5B6167]">
|
||||
{nodeName}
|
||||
</div>
|
||||
}
|
||||
<Flex vertical gap={2}>
|
||||
{nodeOptions.map((option) => {
|
||||
const globalIndex = flatOptions.indexOf(option);
|
||||
const hasChildren = !!option.children?.length;
|
||||
const isExpanded = expandedParent?.key === option.key;
|
||||
const isActive = activePanel === 'main' && selectedIndex === globalIndex;
|
||||
return (
|
||||
<Flex
|
||||
key={option.key}
|
||||
ref={(el) => { if (el) itemRefs.current.set(option.key, el); }}
|
||||
className={clsx('rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]', {
|
||||
'rb:bg-[#F6F6F6]': isActive || isExpanded,
|
||||
'rb:cursor-not-allowed rb:opacity-65': option.disabled && !hasChildren,
|
||||
'rb:cursor-pointer': !option.disabled || hasChildren,
|
||||
})}
|
||||
align="center"
|
||||
justify="space-between"
|
||||
onClick={() => {
|
||||
if (option.disabled && !hasChildren) return;
|
||||
if (!option.disabled) insertMention(option);
|
||||
if (hasChildren) { calcChildPanelPos(option.key); setExpandedParent(option); }
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setSelectedIndex(globalIndex);
|
||||
setActivePanel('main');
|
||||
setChildActiveIndex(-1);
|
||||
if (hasChildren) { calcChildPanelPos(option.key); setExpandedParent(option); }
|
||||
else setExpandedParent(null);
|
||||
}}
|
||||
>
|
||||
{option.label &&
|
||||
<div className="rb:font-medium">
|
||||
<span className="rb:text-[#155EEF]">{`{x}`}</span> {option.label}
|
||||
</div>
|
||||
}
|
||||
<Space size={2}>
|
||||
{option.dataType && <span>{option.dataType}</span>}
|
||||
{hasChildren && <div className="rb:size-3 rb:bg-cover rb:bg-[url('@/assets/images/common/arrow_up.svg')] rb:rotate-90"></div>}
|
||||
</Space>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</div>
|
||||
</div>
|
||||
{expandedParent?.children?.length && (
|
||||
|
||||
{expandedParent?.children?.length && createPortal(
|
||||
<div
|
||||
className="rb:absolute rb:bg-white rb:rounded-xl rb:py-1 rb:min-w-60 rb:max-h-60 rb:overflow-y-auto rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)]"
|
||||
style={{ top: childPanelTop, right: 'calc(100% + 8px)', transform: 'translateY(-8px)' }}
|
||||
onMouseEnter={() => setExpandedParent(expandedParent)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
className="rb:min-w-70 rb:max-h-57.5 rb:overflow-y-auto rb:text-[12px] rb:fixed rb:z-1000 rb:bg-white rb:rounded-lg rb:border-[0.5px] rb:border-[#EBEBEB] rb:shadow-[0px_2px_6px_0px_rgba(0,0,0,0.1)] rb:py-3 rb:px-2"
|
||||
style={{ top: childPanelPos.top, right: childPanelPos.right }}
|
||||
onMouseEnter={() => setActivePanel('child')}
|
||||
onMouseLeave={() => { setActivePanel('main'); setChildActiveIndex(-1); }}
|
||||
>
|
||||
<div className="rb:px-3 rb:py-2 rb:text-[12px] rb:font-medium rb:text-[#5B6167] rb:border-b rb:border-[#F0F0F0]">
|
||||
<Flex justify="space-between" align="center">
|
||||
<div className="rb:pb-2 rb:mb-1 rb:font-medium rb:text-[#5B6167] rb-border-b">
|
||||
<Flex justify="space-between" align="center" gap={8}>
|
||||
<span>{expandedParent.nodeData.name}.{expandedParent.label}</span>
|
||||
<span>{expandedParent.dataType}</span>
|
||||
</Flex>
|
||||
</div>
|
||||
{expandedParent.children.map((child) => {
|
||||
const childIndex = allOptions.indexOf(child);
|
||||
{expandedParent.children.map((child, ci) => {
|
||||
const isChildActive = activePanel === 'child' && ci === childActiveIndex;
|
||||
return (
|
||||
<Flex
|
||||
key={child.key}
|
||||
data-selected={selectedIndex === childIndex}
|
||||
className="rb:px-3! rb:py-2!"
|
||||
ref={(el) => { if (el) childItemRefs.current.set(child.key, el); }}
|
||||
className={clsx('rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]', {
|
||||
'rb:bg-[#F6F6F6]': isChildActive,
|
||||
'rb:cursor-not-allowed rb:opacity-65': child.disabled,
|
||||
'rb:cursor-pointer': !child.disabled,
|
||||
})}
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{
|
||||
cursor: child.disabled ? 'not-allowed' : 'pointer',
|
||||
background: selectedIndex === childIndex ? '#f0f8ff' : 'white',
|
||||
opacity: child.disabled ? 0.5 : 1,
|
||||
}}
|
||||
onClick={() => !child.disabled && insertMention(child)}
|
||||
onMouseEnter={() => setSelectedIndex(childIndex)}
|
||||
onMouseEnter={() => { setActivePanel('child'); setChildActiveIndex(ci); }}
|
||||
>
|
||||
<span>{child.label}</span>
|
||||
{child.dataType && <span className="rb:text-[#5B6167]">{child.dataType}</span>}
|
||||
<span className="rb:font-medium">
|
||||
<span className="rb:text-[#155EEF]">{`{x}`}</span> {child.label}
|
||||
</span>
|
||||
{child.dataType && <span>{child.dataType}</span>}
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,12 +7,17 @@ import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined } from '@ant-desi
|
||||
|
||||
import NodeTools from './NodeTools'
|
||||
import { useVariableList } from '../Properties/hooks/useVariableList'
|
||||
import { isSubExprSet } from '../../utils'
|
||||
import { fileSubFieldOperators } from '../Properties/CaseList'
|
||||
|
||||
const caculateIsSet = (item: any, type: string) => {
|
||||
switch (type) {
|
||||
case 'categories':
|
||||
return typeof item?.class_name === 'string' && item?.class_name !== ''
|
||||
case 'cases': {
|
||||
if (item?.sub_variable_condition !== undefined) {
|
||||
return !!item.left && !!item.operator
|
||||
}
|
||||
if (!item.left) return false
|
||||
if (['not_empty', 'empty'].includes(item.operator)) return true
|
||||
return !!item.left && (!!item.right || typeof item.right === 'boolean' || typeof item.right === 'number')
|
||||
@@ -26,12 +31,24 @@ const ConditionNode: ReactShapeConfig['component'] = ({ node }) => {
|
||||
const variableList = useVariableList(node ?? null, graphRef, data.chatVariables ?? [])
|
||||
|
||||
const getLocaleField = (field: string, filedType: string) => {
|
||||
const key = filedType === 'boolean' ? `workflow.config.if-else..boolean.${field}` : filedType === 'number' ? `workflow.config.if-else.num.${field}` : `workflow.config.if-else.${field}`
|
||||
const key = filedType === 'boolean'
|
||||
? `workflow.config.if-else..boolean.${field}`
|
||||
: filedType === 'number'
|
||||
? `workflow.config.if-else.num.${field}`
|
||||
: `workflow.config.if-else.${field}`
|
||||
const value = t(key)
|
||||
return value !== key ? value : t(`workflow.config.if-else.num.${field}`)
|
||||
};
|
||||
const getSubLocaleField = (field: string, fieldKey: string) => {
|
||||
const operators = fileSubFieldOperators[fieldKey] ?? fileSubFieldOperators.default
|
||||
const match = operators?.find(op => op.value === field)
|
||||
return match?.label ? t(match.label as string) : field
|
||||
}
|
||||
|
||||
const labelRender = (value: string) => {
|
||||
const filterOption = variableList.find(vo => `{{${vo.value}}}` === value)
|
||||
?? variableList.flatMap(vo => vo.children ?? []).find(child => `{{${child.value}}}` === value)
|
||||
?? variableList.flatMap(vo => vo.children ?? []).flatMap((child: any) => child.children ?? []).find((grandchild: any) => `{{${grandchild.value}}}` === value)
|
||||
|
||||
if (filterOption) {
|
||||
return (
|
||||
@@ -91,7 +108,8 @@ const ConditionNode: ReactShapeConfig['component'] = ({ node }) => {
|
||||
{item.expressions.map((expression: any, eIndex: number) => (
|
||||
<div key={eIndex} className="rb:relative">
|
||||
{item.expressions.length > 1 && eIndex > 0 && <div className="rb:absolute rb:-top-2 rb:right-2 rb:text-[10px] rb:text-[#155EEF] rb:font-medium rb:leading-3.5 rb:text-right rb:pr-0.5">{item.logical_operator?.toLocaleUpperCase()}</div>}
|
||||
<Flex align="center" className="rb:bg-[#F0F3F8] rb:shadow-[0px_2px_4px_0px_rgba(23,23,25,0.03)] rb:rounded-md rb:py-1! rb:px-1.5! rb:text-[10px] rb:text-[#5B6167] rb:font-medium rb:leading-3.5">
|
||||
<Flex vertical gap={2} className="rb:bg-[#F0F3F8] rb:shadow-[0px_2px_4px_0px_rgba(23,23,25,0.03)] rb:rounded-md rb:py-1! rb:px-1.5! rb:text-[10px] rb:text-[#5B6167] rb:font-medium rb:leading-3.5">
|
||||
<Flex align="center">
|
||||
{caculateIsSet(expression, 'cases')
|
||||
? <>
|
||||
{labelRender(expression.left)}
|
||||
@@ -100,6 +118,33 @@ const ConditionNode: ReactShapeConfig['component'] = ({ node }) => {
|
||||
</>
|
||||
: t(`workflow.config.${data.type}.unset`)
|
||||
}
|
||||
</Flex>
|
||||
{expression.sub_variable_condition?.conditions?.length > 0 && expression.sub_variable_condition?.conditions.every(isSubExprSet)
|
||||
? <div className="rb-border-l rb:ml-2 rb:mt-1.5">
|
||||
{expression.sub_variable_condition?.conditions.map((sub: any, sIndex: number) => (
|
||||
<div key={sIndex} className="rb:relative">
|
||||
{expression.sub_variable_condition?.conditions.length > 1 && sIndex > 0 && <div className="rb:absolute rb:-top-2 rb:right-2 rb:text-[10px] rb:text-[#155EEF] rb:font-medium rb:leading-3.5 rb:text-right rb:pr-0.5">{expression.sub_variable_condition?.logical_operator?.toLocaleUpperCase()}</div>}
|
||||
<Flex align="center" className=" rb:py-1! rb:px-1.5! rb:text-[10px] rb:text-[#5B6167] rb:font-medium rb:leading-3.5">
|
||||
<span className="rb:text-[#155EEF]">{sub.key}</span>
|
||||
<span className="rb:mx-1">{getSubLocaleField(sub.operator, sub.key)}</span>
|
||||
<span className="rb:break-all rb:line-clamp-1">
|
||||
{sub.key === 'type'
|
||||
? t(`application.${sub.value}`)
|
||||
:!['not_empty', 'empty'].includes(sub.operator)
|
||||
? <span>{typeof sub.value === 'boolean' ? String(sub.value).charAt(0).toUpperCase() + String(sub.value).slice(1) : sub.value}</span>
|
||||
: null
|
||||
}
|
||||
</span>
|
||||
</Flex>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
: expression.sub_variable_condition?.conditions?.length > 0
|
||||
? <Flex align="center" className="rb:mt-1! rb:pl-2! rb:rounded-md rb:py-1! rb:px-1.5! rb:text-[10px] rb:text-[#5B6167] rb:font-medium rb:leading-3.5">
|
||||
{t(`workflow.config.${data.type}.unset`)}
|
||||
</Flex>
|
||||
: null
|
||||
}
|
||||
</Flex>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -30,6 +30,25 @@ const operationsObj = {
|
||||
],
|
||||
}
|
||||
|
||||
const filterByDataType = (options: Suggestion[], dataType: string): Suggestion[] =>
|
||||
options.reduce<Suggestion[]>((acc, vo) => {
|
||||
if (vo.children?.length) {
|
||||
const children = vo.children.reduce<Suggestion[]>((cacc, child) => {
|
||||
if (child.children?.length) {
|
||||
const grandchildren = child.children.filter(gc => gc.dataType === dataType);
|
||||
if (grandchildren.length) cacc.push({ ...child, children: grandchildren });
|
||||
} else if (child.dataType === dataType) {
|
||||
cacc.push(child);
|
||||
}
|
||||
return cacc;
|
||||
}, []);
|
||||
if (children.length) acc.push({ ...vo, children });
|
||||
} else if (vo.dataType === dataType) {
|
||||
acc.push(vo);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const AssignmentList: FC<AssignmentListProps> = ({
|
||||
parentName,
|
||||
options = [],
|
||||
@@ -59,7 +78,9 @@ const AssignmentList: FC<AssignmentListProps> = ({
|
||||
<Flex gap={10} vertical>
|
||||
{fields.map(({ key, name, ...restField }) => {
|
||||
const variableSelector = form.getFieldValue([parentName, name, 'variable_selector']);
|
||||
const selectedOption = options.find(option => `{{${option.value}}}` === variableSelector);
|
||||
const selectedOption = options.find(option => `{{${option.value}}}` === variableSelector)
|
||||
?? options.flatMap(o => o.children ?? []).find(child => `{{${child.value}}}` === variableSelector)
|
||||
?? options.flatMap(o => o.children ?? []).flatMap((c: any) => c.children ?? []).find((gc: any) => `{{${gc.value}}}` === variableSelector);
|
||||
const dataType = selectedOption?.dataType;
|
||||
const operationOptions = dataType === 'number' ? operationsObj.number : operationsObj.default;
|
||||
|
||||
@@ -119,7 +140,7 @@ const AssignmentList: FC<AssignmentListProps> = ({
|
||||
{dataType === 'number' && operation === 'cover'
|
||||
? <VariableSelect
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
options={dataType ? options.filter(vo => vo.dataType === dataType) : options}
|
||||
options={dataType ? filterByDataType(options, dataType) : options}
|
||||
size={size}
|
||||
className="rb:flex-1!"
|
||||
variant="filled"
|
||||
@@ -150,7 +171,7 @@ const AssignmentList: FC<AssignmentListProps> = ({
|
||||
</>
|
||||
: <VariableSelect
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
options={dataType ? options.filter(vo => vo.dataType === dataType) : options}
|
||||
options={dataType ? filterByDataType(options, dataType) : options}
|
||||
size={size}
|
||||
className="rb:flex-1!"
|
||||
variant="filled"
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-09 18:24:53
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-25 15:23:45
|
||||
* @Last Modified time: 2026-04-17 20:47:49
|
||||
*/
|
||||
import { useMemo, type FC } from 'react'
|
||||
import { useEffect, useMemo, type FC } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Button, Select, Space, Divider, InputNumber, type SelectProps, Flex, Row, Col } from 'antd'
|
||||
@@ -15,11 +15,37 @@ import Editor from '../../Editor'
|
||||
import { edgeAttrs, nodeWidth } from '../../../constant'
|
||||
import RbButton from '@/components/RbButton';
|
||||
import RadioGroupBtn from '../RadioGroupBtn'
|
||||
import { calcConditionNodeTotalHeight, getConditionNodeCasePortY } from '../../../utils';
|
||||
import { calcConditionNodeTotalHeight, getConditionNodeCasePortY, isSubExprSet } from '../../../utils';
|
||||
import { typeOptions } from '../ListOperator/FilterConditions'
|
||||
|
||||
|
||||
interface SubCondition {
|
||||
key: string;
|
||||
operator: string;
|
||||
value?: string | number;
|
||||
input_type?: string;
|
||||
}
|
||||
|
||||
interface SubVariableCondition {
|
||||
logical_operator: 'and' | 'or';
|
||||
conditions: SubCondition[];
|
||||
}
|
||||
|
||||
interface Expression {
|
||||
left?: string;
|
||||
operator?: string;
|
||||
right?: string | number;
|
||||
input_type?: string;
|
||||
sub_variable_condition?: SubVariableCondition;
|
||||
}
|
||||
|
||||
interface CaseItem {
|
||||
logical_operator: 'and' | 'or';
|
||||
expressions: Expression[];
|
||||
}
|
||||
interface CaseListProps {
|
||||
value?: Array<{ logical_operator: 'and' | 'or'; expressions: { left: string; operator: string; right: string; input_type?: string; }[] }>;
|
||||
onChange?: (value: Array<{ logical_operator: 'and' | 'or'; expressions: { left: string; operator: string; right: string; }[] }>) => void;
|
||||
value?: CaseItem[];
|
||||
onChange?: (value: CaseItem[]) => void;
|
||||
options: Suggestion[];
|
||||
name: string;
|
||||
selectedNode?: any;
|
||||
@@ -60,13 +86,12 @@ const operatorsObj: { [key: string]: SelectProps['options'] } = {
|
||||
{ value: 'empty', label: 'workflow.config.if-else.file.empty' },
|
||||
{ value: 'not_empty', label: 'workflow.config.if-else.file.not_empty' },
|
||||
],
|
||||
// TODO:包含、不包含、全都是
|
||||
'array[file]': [
|
||||
{ value: 'contains', label: 'workflow.config.if-else.contains' },
|
||||
{ value: 'not_contains', label: 'workflow.config.if-else.not_contains' },
|
||||
{ value: 'eq', label: 'workflow.config.if-else.file.eq' },
|
||||
{ value: 'empty', label: 'workflow.config.if-else.empty' },
|
||||
{ value: 'not_empty', label: 'workflow.config.if-else.not_empty' },
|
||||
// { value: 'eq', label: 'workflow.config.if-else.eq' },
|
||||
// { value: 'contains', label: 'workflow.config.if-else.contains' },
|
||||
// { value: 'not_contains', label: 'workflow.config.if-else.not_contains' },
|
||||
],
|
||||
'array': [
|
||||
{ value: 'contains', label: 'workflow.config.if-else.contains' },
|
||||
@@ -80,6 +105,218 @@ const operatorsObj: { [key: string]: SelectProps['options'] } = {
|
||||
]
|
||||
}
|
||||
|
||||
const fileSubFieldOptions = [
|
||||
{ value: 'type', label: 'type' },
|
||||
{ value: 'size', label: 'size' },
|
||||
{ value: 'name', label: 'name' },
|
||||
{ value: 'url', label: 'url' },
|
||||
{ value: 'extension', label: 'extension' },
|
||||
{ value: 'mime_type', label: 'mime_type' },
|
||||
]
|
||||
|
||||
export const fileSubFieldOperators: { [key: string]: SelectProps['options'] } = {
|
||||
type: [
|
||||
{ value: 'eq', label: 'workflow.config.list-operator.type.eq' },
|
||||
{ value: 'ne', label: 'workflow.config.list-operator.type.ne' },
|
||||
],
|
||||
size: [
|
||||
{ value: 'lt', label: 'workflow.config.if-else.num.lt' },
|
||||
{ value: 'le', label: 'workflow.config.if-else.num.le' },
|
||||
{ value: 'gt', label: 'workflow.config.if-else.num.gt' },
|
||||
{ value: 'ge', label: 'workflow.config.if-else.num.ge' },
|
||||
],
|
||||
default: [
|
||||
{ value: 'contains', label: 'workflow.config.if-else.contains' },
|
||||
{ value: 'not_contains', label: 'workflow.config.if-else.not_contains' },
|
||||
{ value: 'startwith', label: 'workflow.config.if-else.startwith' },
|
||||
{ value: 'endwith', label: 'workflow.config.if-else.endwith' },
|
||||
{ value: 'eq', label: 'workflow.config.if-else.eq' },
|
||||
{ value: 'ne', label: 'workflow.config.if-else.ne' },
|
||||
{ value: 'empty', label: 'workflow.config.if-else.empty' },
|
||||
{ value: 'not_empty', label: 'workflow.config.if-else.not_empty' },
|
||||
],
|
||||
}
|
||||
|
||||
interface ArrayFileSubConditionsProps {
|
||||
conditionFieldName: number;
|
||||
caseIndex: number;
|
||||
conditionIndex: number;
|
||||
name: string;
|
||||
filterNumberOptions: Suggestion[];
|
||||
options: Suggestion[];
|
||||
updateNodeLayout: (cases: any[]) => void;
|
||||
updateNodePorts: (caseCount: number, removedCaseIndex?: number) => void;
|
||||
}
|
||||
|
||||
const ArrayFileSubConditions: FC<ArrayFileSubConditionsProps> = ({ conditionFieldName, caseIndex, conditionIndex, name, filterNumberOptions, options, updateNodeLayout, updateNodePorts }) => {
|
||||
const { t } = useTranslation();
|
||||
const form = Form.useFormInstance();
|
||||
const subValues = Form.useWatch([name, caseIndex, 'expressions', conditionIndex, 'sub_variable_condition', 'conditions'], form)
|
||||
|
||||
const handleChangeSubLogicalOperator = () => {
|
||||
const current = form.getFieldValue([name, caseIndex, 'expressions', conditionIndex, 'sub_variable_condition', 'logical_operator']) || 'and';
|
||||
form.setFieldValue([name, caseIndex, 'expressions', conditionIndex, 'sub_variable_condition', 'logical_operator'], current === 'and' ? 'or' : 'and');
|
||||
};
|
||||
const handleInputTypeChange = (caseIndex: number, conditionIndex: number, subIndex: number) => {
|
||||
form.setFieldValue([name, caseIndex, 'expressions', conditionIndex, 'sub_variable_condition', 'conditions', subIndex, 'value'], undefined);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
console.log('subValues', subValues)
|
||||
if (!subValues) return
|
||||
const cases = form.getFieldValue(name) || [];
|
||||
setTimeout(() => {
|
||||
updateNodeLayout(cases);
|
||||
const allSet = (subValues ?? []).every(isSubExprSet);
|
||||
console.log('allSet', allSet)
|
||||
updateNodePorts(cases.length);
|
||||
}, 100);
|
||||
}, [subValues])
|
||||
|
||||
return (
|
||||
<div className="rb:bg-white rb:rounded-lg rb:p-1 rb:w-62">
|
||||
<Form.List name={[conditionFieldName, 'sub_variable_condition', 'conditions']}>
|
||||
{(subFields, { add: addSub, remove: removeSub }) => {
|
||||
const subLogicalOperator = form.getFieldValue([name, caseIndex, 'expressions', conditionIndex, 'sub_variable_condition', 'logical_operator']) || 'and';
|
||||
return (
|
||||
<>
|
||||
<div className={clsx("rb:relative", {
|
||||
'rb:ml-11': subFields.length > 1,
|
||||
})}>
|
||||
{subFields.length > 1 && (
|
||||
<div className="rb:absolute rb:-left-8 rb:top-4 rb:bottom-4 rb:w-6 rb:h-[calc(100%-32px)]">
|
||||
<div className="rb:absolute rb:w-2 rb:h-[calc(50%-20px)] rb:left-5 rb:top-0 rb:z-10 rb:border-l rb:border-t rb:border-[#EBEBEB] rb:rounded-tl-[10px] rb:border-r-0"></div>
|
||||
<div className="rb:absolute rb:z-10 rb:-right-1.25 rb:top-[calc(50%-10px)]">
|
||||
<Space size={2} className="rb:cursor-pointer rb:text-[#155EEF] rb:text-[10px] rb:leading-4.5 rb:font-medium rb-border rb:py-px! rb:px-1! rb:rounded-sm" onClick={handleChangeSubLogicalOperator}>
|
||||
{subLogicalOperator}
|
||||
<div className="rb:size-2.5 rb:bg-cover rb:bg-[url('@/assets/images/workflow/refresh_active.svg')]"></div>
|
||||
</Space>
|
||||
</div>
|
||||
<div className="rb:absolute rb:w-2 rb:h-[calc(50%-20px)] rb:left-5 rb:bottom-0 rb:z-10 rb:border-l rb:border-b rb:border-[#EBEBEB] rb:rounded-bl-[10px] rb:border-r-0"></div>
|
||||
</div>
|
||||
)}
|
||||
{subFields.map((subField, subIndex) => {
|
||||
const subExpr = form.getFieldValue([name, caseIndex, 'expressions', conditionIndex, 'sub_variable_condition', 'conditions', subIndex]) || {};
|
||||
const subOperator = subExpr.operator;
|
||||
const subLeft = subExpr.key;
|
||||
const subOperatorList = subLeft === 'type' ? fileSubFieldOperators.type : subLeft === 'size' ? fileSubFieldOperators.size : fileSubFieldOperators.default;
|
||||
const hideSubRight = subOperator === 'empty' || subOperator === 'not_empty';
|
||||
const subInputType = subExpr.input_type
|
||||
return (
|
||||
<Flex key={subField.key} gap={4} align="start" className="rb:mb-1.5!">
|
||||
<div className={clsx("rb:flex-1 rb:bg-[#F6F6F6] rb:rounded-lg rb:border rb:border-[#EBEBEB]", {
|
||||
'rb:w-43.5': subFields.length > 1,
|
||||
'rb:w-54.5': subFields.length === 1
|
||||
})}>
|
||||
<Row className={clsx('rb:p-1!', { 'rb-border-b': !hideSubRight })}>
|
||||
<Col flex="100px">
|
||||
<Form.Item name={[subField.name, 'key']} noStyle>
|
||||
<Select
|
||||
options={fileSubFieldOptions}
|
||||
size="small"
|
||||
popupMatchSelectWidth={false}
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
variant="borderless"
|
||||
className="rb:w-full!"
|
||||
onChange={(value: string) => {
|
||||
form.setFieldValue([name, caseIndex, 'expressions', conditionIndex, 'sub_variable_condition', 'conditions', subIndex], {
|
||||
key: value,
|
||||
input_type: value === 'size' ? 'constant' : undefined,
|
||||
value: undefined,
|
||||
operator: value === 'size' ? 'ge' : 'eq',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col flex="1">
|
||||
<Form.Item name={[subField.name, 'operator']} noStyle>
|
||||
<Select
|
||||
options={(subOperatorList ?? []).map(vo => ({ ...vo, label: t(String(vo?.label || '')) }))}
|
||||
size="small"
|
||||
popupMatchSelectWidth={false}
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
variant="borderless"
|
||||
className="rb:w-full!"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
{!hideSubRight && (
|
||||
<div>
|
||||
{subLeft === 'size'
|
||||
? <Flex align="center">
|
||||
<Form.Item name={[subField.name, 'input_type']} noStyle>
|
||||
<Select
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
options={[{ value: 'variable', label: 'Variable' }, { value: 'constant', label: 'Constant' }]}
|
||||
popupMatchSelectWidth={false}
|
||||
variant="borderless"
|
||||
size="small"
|
||||
onChange={() => { handleInputTypeChange(caseIndex, conditionIndex, subIndex); }}
|
||||
className="rb:w-20!"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Divider type="vertical" className="rb:mx-0!" />
|
||||
<Form.Item name={[subField.name, 'value']} noStyle>
|
||||
{subInputType === 'variable'
|
||||
? <VariableSelect
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
options={filterNumberOptions}
|
||||
allowClear={true}
|
||||
variant="borderless"
|
||||
size="small"
|
||||
className="rb:flex-1!"
|
||||
/>
|
||||
: <InputNumber
|
||||
placeholder={t('common.pleaseEnter')}
|
||||
variant="borderless"
|
||||
className="rb:w-full!"
|
||||
suffix="Byte"
|
||||
size="small"
|
||||
onChange={(value) => { form.setFieldValue([name, caseIndex, 'expressions', conditionIndex, 'right'], value); }}
|
||||
/>
|
||||
}
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
: <Form.Item name={[subField.name, 'value']} noStyle>
|
||||
{subLeft === 'type'
|
||||
? <Select
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
options={typeOptions.map(vo => ({ value: vo, label: t(`application.${vo}`) }))}
|
||||
variant="borderless"
|
||||
className="rb:w-full!"
|
||||
/>
|
||||
: <Editor options={options} size="small" type="input" variant='borderless' height={28} className="rb:w-full!" />
|
||||
}
|
||||
</Form.Item>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="rb:size-4 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/workflow/deleteBg.svg')] rb:hover:bg-[url('@/assets/images/workflow/deleteBg_hover.svg')]"
|
||||
onClick={() => { removeSub(subField.name); }}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => { addSub({ key: undefined, operator: undefined, value: undefined, input_type: undefined }); }}
|
||||
className="rb:py-0! rb:px-1! rb:h-4.5! rb:rounded-sm! rb:text-[12px]!"
|
||||
size="small"
|
||||
>
|
||||
+ {t('workflow.config.if-else.addSubVariable')}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form.List>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CaseList: FC<CaseListProps> = ({
|
||||
options,
|
||||
name,
|
||||
@@ -251,7 +488,7 @@ const CaseList: FC<CaseListProps> = ({
|
||||
left: newValue,
|
||||
operator: undefined,
|
||||
right: undefined,
|
||||
input_type: undefined
|
||||
input_type: 'constant'
|
||||
});
|
||||
};
|
||||
|
||||
@@ -303,7 +540,7 @@ const CaseList: FC<CaseListProps> = ({
|
||||
const logicalOperator = form.getFieldValue(name)?.[caseIndex]?.logical_operator || 'and'
|
||||
return (
|
||||
<Row className="rb:text-[12px] rb:mb-4!">
|
||||
<Col flex="48px">
|
||||
<Col flex="44px">
|
||||
<div className="rb:font-medium rb:leading-4.5">{caseIndex === 0 ? 'IF' : 'ELIF'}</div>
|
||||
{caseFields.length > 1 && <div className="rb:text-[10px] rb:text-[#5B6167] rb:leading-2.5"> {`CASE ${caseIndex + 1}`}</div>}
|
||||
</Col>
|
||||
@@ -314,9 +551,9 @@ const CaseList: FC<CaseListProps> = ({
|
||||
<div className="rb:absolute rb:w-3 rb:h-[calc(50%-20px)] rb:left-5 rb:top-0 rb:z-10 rb:border-l rb:border-t rb:border-[#EBEBEB] rb:rounded-tl-[10px] rb:border-r-0"></div>
|
||||
<div className="rb:absolute rb:z-10 rb:-right-1.25 rb:top-[calc(50%-10px)]">
|
||||
<Form.Item name={[caseField.name, 'logical_operator']} noStyle >
|
||||
<Space size={2} className="rb:cursor-pointer rb:text-[#155EEF] rb:leading-4.5 rb:font-medium rb-border rb:py-px! rb:px-1! rb:rounded-sm" onClick={() => handleChangeLogicalOperator(caseIndex)}>
|
||||
<Space size={2} className="rb:cursor-pointer rb:text-[#155EEF] rb:text-[10px] rb:leading-4.5 rb:font-medium rb-border rb:py-px! rb:px-1! rb:rounded-sm" onClick={() => handleChangeLogicalOperator(caseIndex)}>
|
||||
{logicalOperator}
|
||||
<div className="rb:size-3 rb:bg-cover rb:bg-[url('@/assets/images/workflow/refresh_active.svg')]"></div>
|
||||
<div className="rb:size-2.5 rb:bg-cover rb:bg-[url('@/assets/images/workflow/refresh_active.svg')]"></div>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</div>
|
||||
@@ -329,9 +566,11 @@ const CaseList: FC<CaseListProps> = ({
|
||||
const currentExpression = currentCase.expressions?.[conditionIndex] || {};
|
||||
const currentOperator = currentExpression.operator;
|
||||
const leftFieldValue = currentExpression.left;
|
||||
const leftFieldOption = options.find(option => `{{${option.value}}}` === leftFieldValue);
|
||||
const leftFieldOption = options.find(option => `{{${option.value}}}` === leftFieldValue)
|
||||
?? options.flatMap(o => o.children ?? []).find(child => `{{${child.value}}}` === leftFieldValue)
|
||||
?? options.flatMap(o => o.children ?? []).flatMap((c: any) => c.children ?? []).find((gc: any) => `{{${gc.value}}}` === leftFieldValue);
|
||||
const leftFieldType = leftFieldOption?.dataType;
|
||||
const hideRightField = currentOperator === 'empty' || currentOperator === 'not_empty' || leftFieldType === 'file' || leftFieldType === 'array[object]' || leftFieldType === 'array[file]';
|
||||
const hideRightField = currentOperator === 'empty' || currentOperator === 'not_empty' || leftFieldType === 'file' || leftFieldType === 'array[object]';
|
||||
const operatorList = leftFieldType && operatorsObj[leftFieldType]
|
||||
? operatorsObj[leftFieldType]
|
||||
: leftFieldType && leftFieldType?.includes('array')
|
||||
@@ -341,7 +580,7 @@ const CaseList: FC<CaseListProps> = ({
|
||||
return (
|
||||
<Flex key={conditionField.key} gap={4} align="start" className="rb:mb-2!">
|
||||
<div className="rb:flex-1 rb:bg-[#F6F6F6] rb:rounded-lg">
|
||||
<Row className={clsx("rb:p-1!", {
|
||||
<Row className={clsx("rb:px-1!", {
|
||||
'rb-border-b': !hideRightField
|
||||
})}>
|
||||
<Col flex="144px">
|
||||
@@ -375,22 +614,40 @@ const CaseList: FC<CaseListProps> = ({
|
||||
</Row>
|
||||
|
||||
{!hideRightField && (
|
||||
<div className="rb:py-1 rb:px-1.5">
|
||||
<div
|
||||
className={clsx({
|
||||
"rb:py-1 rb:px-1.5": ['boolean', 'array[boolean]', 'array[file]'].includes(leftFieldType as string)
|
||||
})}
|
||||
>
|
||||
{leftFieldType === 'array[file]'
|
||||
? <>TODO</>
|
||||
? <>
|
||||
<Form.Item name={[conditionField.name, 'sub_variable_condition', 'logical_operator']} initialValue="and" noStyle>
|
||||
<span />
|
||||
</Form.Item>
|
||||
<ArrayFileSubConditions
|
||||
conditionFieldName={conditionField.name}
|
||||
caseIndex={caseIndex}
|
||||
conditionIndex={conditionIndex}
|
||||
name={name}
|
||||
options={options}
|
||||
filterNumberOptions={filterNumberOptions}
|
||||
updateNodeLayout={updateNodeLayout}
|
||||
updateNodePorts={updateNodePorts}
|
||||
/>
|
||||
</>
|
||||
: leftFieldType === 'number'
|
||||
? <Flex align="center">
|
||||
<Form.Item name={[conditionField.name, 'input_type']} noStyle>
|
||||
<Select
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
options={[{ value: 'variable', label: 'Variable' }, { value: 'Constant', label: 'constant' }]}
|
||||
options={[{ value: 'variable', label: 'Variable' }, { value: 'constant', label: 'Constant' }]}
|
||||
popupMatchSelectWidth={false}
|
||||
variant="borderless"
|
||||
onChange={() => handleInputTypeChange(caseIndex, conditionIndex)}
|
||||
className="rb:w-20!"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Divider type="vertical" />
|
||||
<Divider type="vertical" className="rb:mx-0!" />
|
||||
<Form.Item name={[conditionField.name, 'right']} noStyle>
|
||||
{inputType === 'variable'
|
||||
? <VariableSelect
|
||||
@@ -413,7 +670,7 @@ const CaseList: FC<CaseListProps> = ({
|
||||
<Form.Item name={[conditionField.name, 'right']} noStyle>
|
||||
{['boolean', 'array[boolean]'].includes(leftFieldType as string)
|
||||
? <RadioGroupBtn options={[{ value: true, label: 'True' }, { value: false, label: 'False' }]} type="inner" />
|
||||
: <Editor options={options} size="small" type="input" />
|
||||
: <Editor options={options} size="small" type="input" variant='borderless' height={28} />
|
||||
}
|
||||
</Form.Item>
|
||||
)
|
||||
|
||||
@@ -94,7 +94,7 @@ const CodeExecution: FC<CodeExecutionProps> = ({ options }) => {
|
||||
{ label: 'JAVASCRIPT', value: 'javascript' }
|
||||
]}
|
||||
popupMatchSelectWidth={false}
|
||||
className={`rb:font-medium! rb:w-25! rb:h-4! rb:p-0! ${styles.editor}`}
|
||||
className={`rb:font-medium! rb:w-25! rb:h-4! rb:py-0! rb:px-2! ${styles.editor}`}
|
||||
onChange={handleChangeLanguage}
|
||||
variant="borderless"
|
||||
/>
|
||||
|
||||
@@ -155,7 +155,9 @@ const ConditionList: FC<CaseListProps> = ({
|
||||
const currentExpression = expressions[index] || {};
|
||||
const currentOperator = currentExpression.operator;
|
||||
const leftFieldValue = currentExpression.left;
|
||||
const leftFieldOption = options.find(option => `{{${option.value}}}` === leftFieldValue);
|
||||
const leftFieldOption = options.find(option => `{{${option.value}}}` === leftFieldValue)
|
||||
?? options.flatMap(o => o.children ?? []).find(child => `{{${child.value}}}` === leftFieldValue)
|
||||
?? options.flatMap(o => o.children ?? []).flatMap((c: any) => c.children ?? []).find((gc: any) => `{{${gc.value}}}` === leftFieldValue);
|
||||
const leftFieldType = leftFieldOption?.dataType;
|
||||
const hideRightField = currentOperator === 'empty' || currentOperator === 'not_empty' || ['array[object]', 'object'].includes(leftFieldType as string);
|
||||
const operatorList = leftFieldType && ['array[object]', 'object'].includes(leftFieldType)
|
||||
@@ -176,7 +178,7 @@ const ConditionList: FC<CaseListProps> = ({
|
||||
className="rb:mb-2!"
|
||||
>
|
||||
<div className="rb:flex-1 rb:bg-[#F6F6F6] rb:rounded-lg">
|
||||
<Row className={clsx("rb:p-1!", {
|
||||
<Row className={clsx("rb:px-1!", {
|
||||
'rb-border-b': !hideRightField
|
||||
})}>
|
||||
<Col flex="1">
|
||||
@@ -216,7 +218,7 @@ const ConditionList: FC<CaseListProps> = ({
|
||||
</Row>
|
||||
|
||||
{!hideRightField && (
|
||||
<div className="rb:py-1 rb:px-1.5">
|
||||
<div className={leftFieldType === 'boolean' ? "rb:py-1 rb:px-1.5" : ''}>
|
||||
{leftFieldType === 'number'
|
||||
? (
|
||||
<Flex align="center">
|
||||
|
||||
@@ -155,7 +155,7 @@ const CycleVarsList: FC<CycleVarsListProps> = ({
|
||||
? <CodeMirrorEditor
|
||||
language="json"
|
||||
placeholder={object_placeholder}
|
||||
variant="outlined"
|
||||
variant="filled"
|
||||
size="small"
|
||||
/>
|
||||
: (
|
||||
|
||||
@@ -62,14 +62,18 @@ const GroupVariableList: FC<GroupVariableListProps> = ({
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isCanAdd && value[0]) {
|
||||
const firstVariable = options.find(opt => `{{${opt.value}}}` === value[0]);
|
||||
const firstVariable = options.find(opt => `{{${opt.value}}}` === value[0])
|
||||
?? options.flatMap(o => o.children ?? []).find(c => `{{${c.value}}}` === value[0])
|
||||
?? options.flatMap(o => o.children ?? []).flatMap((c: any) => c.children ?? []).find((gc: any) => `{{${gc.value}}}` === value[0]);
|
||||
if (firstVariable) {
|
||||
form.setFieldValue(['group_type', 'output'], firstVariable.dataType);
|
||||
}
|
||||
} else if (isCanAdd) {
|
||||
value.forEach((item: any, index: number) => {
|
||||
if (item?.value?.[0]) {
|
||||
const firstVariable = options.find(opt => `{{${opt.value}}}` === item.value[0]);
|
||||
const firstVariable = options.find(opt => `{{${opt.value}}}` === item.value[0])
|
||||
?? options.flatMap(o => o.children ?? []).find(c => `{{${c.value}}}` === item.value[0])
|
||||
?? options.flatMap(o => o.children ?? []).flatMap((c: any) => c.children ?? []).find((gc: any) => `{{${gc.value}}}` === item.value[0]);
|
||||
if (firstVariable) {
|
||||
form.setFieldValue(['group_type', index], firstVariable.dataType);
|
||||
}
|
||||
|
||||
@@ -85,9 +85,9 @@ const EditableTable: FC<EditableTableProps> = ({
|
||||
return [
|
||||
{
|
||||
title: t('workflow.config.name'),
|
||||
dataIndex: 'name',
|
||||
dataIndex: 'key',
|
||||
render: (_: any, __: TableRow, index: number) => (
|
||||
<Form.Item name={[index, 'name']} className={formClassName}>
|
||||
<Form.Item name={[index, 'key']} className={formClassName}>
|
||||
<Editor
|
||||
options={namefilterOptions}
|
||||
type="input"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-09 18:35:43
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-02 17:17:06
|
||||
* @Last Modified time: 2026-04-14 17:36:53
|
||||
*/
|
||||
import { type FC, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -35,9 +35,8 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an
|
||||
form.setFieldsValue({ auth })
|
||||
}
|
||||
|
||||
const handleChangeBodyContentType = (e: any) => {
|
||||
const value = e.target.value || e.target.value
|
||||
form.setFieldValue(['body', 'data'], ['form-data', 'x-www-form-urlencoded'].includes(value) ? [{}] : undefined)
|
||||
const handleChangeBodyContentType = () => {
|
||||
form.setFieldValue(['body', 'data'], undefined)
|
||||
}
|
||||
|
||||
// Handle error handling method change and update node ports accordingly
|
||||
|
||||
@@ -56,7 +56,7 @@ const operatorsObj: { [key: string]: SelectProps['options'] } = {
|
||||
]
|
||||
}
|
||||
|
||||
const typeOptions = ['image', 'document', 'video', 'audio']
|
||||
export const typeOptions = ['image', 'document', 'video', 'audio']
|
||||
|
||||
const FilterConditions: FC<FilterConditionsProps> = ({
|
||||
options,
|
||||
@@ -101,24 +101,20 @@ const FilterConditions: FC<FilterConditionsProps> = ({
|
||||
align="start"
|
||||
className="rb:mb-2!"
|
||||
>
|
||||
<div className="rb:flex-1 rb:bg-[#F6F6F6] rb:rounded-lg">
|
||||
<div className="rb:flex-1">
|
||||
{variableType === 'array[file]' &&
|
||||
<Row className="rb:p-1! rb-border-b">
|
||||
<Col span={24}>
|
||||
<Form.Item name={[field.name, 'key']} noStyle>
|
||||
<Select
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
options={fileSubVariable}
|
||||
fieldNames={{ value: 'filed', label: 'label' }}
|
||||
onChange={(value) => handleKeyFieldChange(index, value)}
|
||||
variant="borderless"
|
||||
className="rb:w-full! rb:h-7!"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name={[field.name, 'key']} noStyle>
|
||||
<Select
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
options={fileSubVariable}
|
||||
fieldNames={{ value: 'filed', label: 'label' }}
|
||||
onChange={(value) => handleKeyFieldChange(index, value)}
|
||||
className="rb:w-full! select rb:mb-1!"
|
||||
variant="borderless"
|
||||
/>
|
||||
</Form.Item>
|
||||
}
|
||||
<Row>
|
||||
<Row gutter={8}>
|
||||
<Col flex={hideValueField ? '1' : "96px"}>
|
||||
<Form.Item name={[field.name, 'comparison_operator']} noStyle>
|
||||
<Select
|
||||
@@ -129,28 +125,27 @@ const FilterConditions: FC<FilterConditionsProps> = ({
|
||||
size="small"
|
||||
popupMatchSelectWidth={false}
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
className="rb:w-full! select"
|
||||
variant="borderless"
|
||||
className="rb:w-full! rb:h-7!"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
{!hideValueField && (
|
||||
<Col flex="1">
|
||||
<Form.Item name={[field.name, 'value']} className="rb:pt-0.5! rb:mb-0! rb:pl-2!">
|
||||
<Form.Item name={[field.name, 'value']} noStyle>
|
||||
{innerType === 'boolean'
|
||||
? <RadioGroupBtn options={[{ value: true, label: 'True' }, { value: false, label: 'False' }]} type="inner" />
|
||||
: keyFieldValue === 'type'
|
||||
? <Select
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
options={typeOptions.map(vo => ({ value: vo, label: t(`application.${vo}`) } ))}
|
||||
variant="borderless"
|
||||
className="rb:w-full!"
|
||||
variant="filled"
|
||||
/>
|
||||
: <Editor
|
||||
variant="borderless"
|
||||
variant="filled"
|
||||
type="input"
|
||||
size="small"
|
||||
height={24}
|
||||
height={28}
|
||||
options={keyFieldType ? options.flatMap(vo => {
|
||||
if (vo.dataType === keyFieldType) return [vo];
|
||||
const filteredChildren = vo.children?.filter(sub => sub.dataType === keyFieldType);
|
||||
@@ -167,7 +162,7 @@ const FilterConditions: FC<FilterConditionsProps> = ({
|
||||
</Row>
|
||||
</div>
|
||||
<div
|
||||
className="rb:size-4 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/workflow/deleteBg.svg')] rb:hover:bg-[url('@/assets/images/workflow/deleteBg_hover.svg')]"
|
||||
className="rb:size-4 rb:mt-1.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/workflow/deleteBg.svg')] rb:hover:bg-[url('@/assets/images/workflow/deleteBg_hover.svg')]"
|
||||
onClick={() => remove(field.name)}
|
||||
></div>
|
||||
</Flex>
|
||||
|
||||
@@ -58,7 +58,7 @@ const MappingList: FC<MappingListProps> = ({ label, name, options, extra, valueK
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
options={options}
|
||||
size="small"
|
||||
className="rb:w-51!"
|
||||
className="rb:flex-1!"
|
||||
/>
|
||||
</Form.Item>
|
||||
<div
|
||||
|
||||
@@ -1,16 +1,38 @@
|
||||
import { type FC } from "react";
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-03-07 14:55:04
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-17 10:05:32
|
||||
*/
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Form } from 'antd'
|
||||
import { Form, Switch } from 'antd'
|
||||
|
||||
import RbSlider from '@/components/RbSlider'
|
||||
import RbCard from '@/components/RbCard/Card'
|
||||
import ModelSelect from '@/components/ModelSelect'
|
||||
import type { Model } from '@/views/ModelManagement/types';
|
||||
|
||||
const ModelConfig: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const form = Form.useFormInstance()
|
||||
const model_id = Form.useWatch(['model_id'], form)
|
||||
console.log('ModelConfig', model_id)
|
||||
const [selectedModel, setSelectedModel] = useState<Model | null>(null)
|
||||
const [options, setOptions] = useState<Model[]>([])
|
||||
|
||||
const updateOptions = (options: Model[]) => {
|
||||
setOptions(options)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (model_id && options) {
|
||||
const model = options.find(item => item.id === model_id)
|
||||
setSelectedModel(model || null)
|
||||
form.setFieldValue('json_output', false)
|
||||
} else {
|
||||
setSelectedModel(null)
|
||||
}
|
||||
}, [model_id, options])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -25,6 +47,7 @@ const ModelConfig: FC = () => {
|
||||
params={{ type: 'llm,chat' }}
|
||||
className="rb:w-full!"
|
||||
size="small"
|
||||
updateOptions={updateOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
{model_id && (
|
||||
@@ -52,7 +75,7 @@ const ModelConfig: FC = () => {
|
||||
<Form.Item
|
||||
name="max_tokens"
|
||||
label={t('workflow.config.llm.max_tokens')}
|
||||
className="rb:mb-0!"
|
||||
className="rb:mb-1.5!"
|
||||
>
|
||||
<RbSlider
|
||||
min={256}
|
||||
@@ -63,6 +86,16 @@ const ModelConfig: FC = () => {
|
||||
className="rb:-mt-2!"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="json_output"
|
||||
valuePropName="checked"
|
||||
label={t('workflow.config.llm.json_output')}
|
||||
layout="horizontal"
|
||||
className="rb:mb-0!"
|
||||
hidden={!(selectedModel?.capability?.includes('json_output'))}
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</RbCard>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 15:40:13
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-08 10:48:21
|
||||
* @Last Modified time: 2026-04-16 13:57:30
|
||||
*/
|
||||
import { useState, useRef, useEffect, useLayoutEffect, type FC } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
@@ -40,15 +40,34 @@ const VariableSelect: FC<VariableSelectProps> = ({
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [expandedParent, setExpandedParent] = useState<Suggestion | null>(null);
|
||||
const [expandedParentKey, setExpandedParentKey] = useState<string | null>(null);
|
||||
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
||||
const [activePanel, setActivePanel] = useState<'main' | 'child'>('main');
|
||||
const [childActiveIndex, setChildActiveIndex] = useState<number>(-1);
|
||||
const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0, width: 0 });
|
||||
const [childPanelPos, setChildPanelPos] = useState({ top: 0, right: 0 });
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const itemRefs = useRef<Map<string, HTMLElement>>(new Map());
|
||||
const childItemRefs = useRef<Map<string, HTMLElement>>(new Map());
|
||||
const activeKeyRef = useRef<string | null>(null);
|
||||
|
||||
const CHILD_PANEL_HEIGHT = 280; // max-h-60 (240) + header (~40)
|
||||
|
||||
const calcChildPos = (key: string) => {
|
||||
const el = itemRefs.current.get(key);
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const dropdownEl = dropdownRef.current;
|
||||
if (!dropdownEl) return;
|
||||
const dropdownRect = dropdownEl.getBoundingClientRect();
|
||||
const dropdownBottom = dropdownRect.bottom;
|
||||
const actualChildHeight = Math.min(CHILD_PANEL_HEIGHT, dropdownRect.height);
|
||||
// Bottom-align child panel with main panel
|
||||
const top = Math.max(10, dropdownBottom - actualChildHeight);
|
||||
setChildPanelPos({ top, right: window.innerWidth - rect.left + 8 });
|
||||
};
|
||||
|
||||
// Calculate dropdown position (runs synchronously after DOM paint to avoid flicker)
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !containerRef.current) return;
|
||||
@@ -69,7 +88,9 @@ const VariableSelect: FC<VariableSelectProps> = ({
|
||||
? triggerRect.bottom + MARGIN
|
||||
: Math.max(MARGIN, triggerRect.top - dropdownHeight - MARGIN);
|
||||
setDropdownPos({ top, left, width });
|
||||
}, [open, search, Array.isArray(value) ? value.length : 0]);
|
||||
// Re-calculate child panel position if expanded
|
||||
if (expandedParentKey) calcChildPos(expandedParentKey);
|
||||
}, [open, search, Array.isArray(value) ? value.length : 0, options.length, expandedParentKey]);
|
||||
|
||||
const filteredOptions = filterBooleanType
|
||||
? options.filter(o => o.dataType !== 'boolean')
|
||||
@@ -84,6 +105,10 @@ const VariableSelect: FC<VariableSelectProps> = ({
|
||||
? filteredOptions.find(o => o.children?.some(c => `{{${c.value}}}` === value))
|
||||
: undefined;
|
||||
|
||||
const expandedParent = expandedParentKey
|
||||
? filteredOptions.find(o => o.key === expandedParentKey) ?? null
|
||||
: null;
|
||||
|
||||
const groupedSuggestions = filteredOptions.reduce((groups: Record<string, Suggestion[]>, s) => {
|
||||
const nodeId = s.nodeData.id as string;
|
||||
if (!groups[nodeId]) groups[nodeId] = [];
|
||||
@@ -103,6 +128,12 @@ const VariableSelect: FC<VariableSelectProps> = ({
|
||||
}, {})
|
||||
: groupedSuggestions;
|
||||
|
||||
useEffect(() => {
|
||||
if (!expandedParentKey) return;
|
||||
calcChildPos(expandedParentKey);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dropdownPos, expandedParentKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const updatePos = () => {
|
||||
@@ -139,7 +170,7 @@ const VariableSelect: FC<VariableSelectProps> = ({
|
||||
) {
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
setExpandedParent(null);
|
||||
setExpandedParentKey(null);
|
||||
setChildPanelPos({ top: 0, right: 0 });
|
||||
}
|
||||
};
|
||||
@@ -147,6 +178,87 @@ const VariableSelect: FC<VariableSelectProps> = ({
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
// Flat list of all visible selectable items (main panel only, no children expanded inline)
|
||||
const flatItems = Object.values(filteredGroups).flat();
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIndex(-1);
|
||||
setActivePanel('main');
|
||||
setChildActiveIndex(-1);
|
||||
}, [open, search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeIndex < 0 || activeIndex >= flatItems.length) {
|
||||
setExpandedParentKey(null);
|
||||
return;
|
||||
}
|
||||
const s = flatItems[activeIndex];
|
||||
activeKeyRef.current = s.key;
|
||||
itemRefs.current.get(s.key)?.scrollIntoView({ block: 'nearest' });
|
||||
if (s.children?.length) {
|
||||
calcChildPos(s.key);
|
||||
setExpandedParentKey(s.key);
|
||||
} else {
|
||||
setExpandedParentKey(null);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expandedParent?.children?.length || childActiveIndex < 0) return;
|
||||
const child = expandedParent.children[childActiveIndex];
|
||||
if (child) childItemRefs.current.get(child.key)?.scrollIntoView({ block: 'nearest' });
|
||||
}, [childActiveIndex, expandedParent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
const children = expandedParent?.children ?? [];
|
||||
if (activePanel === 'child') {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setChildActiveIndex(i => Math.min(i + 1, children.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setChildActiveIndex(i => Math.max(i - 1, 0));
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
setActivePanel('main');
|
||||
setChildActiveIndex(-1);
|
||||
} else if (e.key === 'Enter' && childActiveIndex >= 0 && childActiveIndex < children.length) {
|
||||
e.preventDefault();
|
||||
const child = children[childActiveIndex];
|
||||
if (!child.disabled) handleSelect(child);
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
}
|
||||
} else {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveIndex(i => Math.min(i + 1, flatItems.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveIndex(i => Math.max(i - 1, 0));
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
if (expandedParent?.children?.length) {
|
||||
setActivePanel('child');
|
||||
setChildActiveIndex(0);
|
||||
}
|
||||
} else if (e.key === 'Enter' && activeIndex >= 0 && activeIndex < flatItems.length) {
|
||||
e.preventDefault();
|
||||
const s = flatItems[activeIndex];
|
||||
if (!s.disabled) handleSelect(s);
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, activeIndex, activePanel, childActiveIndex, flatItems, expandedParent]);
|
||||
|
||||
const handleSelect = (suggestion: Suggestion) => {
|
||||
if (multiple) {
|
||||
const key = `{{${suggestion.value}}}`;
|
||||
@@ -159,7 +271,7 @@ const VariableSelect: FC<VariableSelectProps> = ({
|
||||
onChange?.(`{{${suggestion.value}}}`, suggestion);
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
setExpandedParent(null);
|
||||
setExpandedParentKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -167,19 +279,6 @@ const VariableSelect: FC<VariableSelectProps> = ({
|
||||
e.stopPropagation();
|
||||
onChange?.(multiple ? [] : '', multiple ? [] : undefined);
|
||||
};
|
||||
|
||||
const updateChildPos = (key: string) => {
|
||||
const el = itemRefs.current.get(key);
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const spaceBelow = window.innerHeight - rect.top - 10;
|
||||
const top = spaceBelow >= CHILD_PANEL_HEIGHT
|
||||
? rect.top
|
||||
: Math.max(10, window.innerHeight - CHILD_PANEL_HEIGHT - 10);
|
||||
setChildPanelPos({ top, right: window.innerWidth - rect.left + 8 });
|
||||
}
|
||||
};
|
||||
|
||||
const sep = <span className="rb:text-[#DFE4ED] rb:mx-0.5">/</span>;
|
||||
const isConversation = (parentOfSelected ?? selectedSuggestion)?.group === 'CONVERSATION' ||
|
||||
(selectedSuggestion ? filteredOptions.some(o => o.group === 'CONVERSATION' && o.children?.some(c => `{{${c.value}}}` === value)) : false);
|
||||
@@ -190,20 +289,30 @@ const VariableSelect: FC<VariableSelectProps> = ({
|
||||
{/* Trigger */}
|
||||
<div
|
||||
className={clsx(
|
||||
'rb:w-full rb:flex rb:items-center rb:justify-between rb:cursor-pointer rb:rounded-md rb:px-2 rb:transition-colors',
|
||||
variant === 'filled' && 'rb:bg-[#F6F6F6] rb:border-none rb:shadow-none',
|
||||
variant === 'outlined' && 'rb:border rb:border-[#d9d9d9] hover:rb:border-[#4096ff] rb:bg-white',
|
||||
variant === 'outlined' && open && 'rb:border-[#4096ff] rb:shadow-[0_0_0_2px_rgba(5,145,255,0.1)]',
|
||||
variant === 'borderless' && 'rb:border-none rb:shadow-none rb:bg-transparent',
|
||||
multiple && size === 'small' ? 'rb:min-h-7 rb:py-0.75' : multiple ? 'rb:min-h-8 rb:py-1' : size === 'small' ? 'rb:h-7 rb:text-[10px]' : size === 'large' ? 'rb:h-10' : 'rb:h-8 rb:text-[12px]',
|
||||
!multiple && (size === 'small' ? 'rb:text-[12px]' : 'rb:text-[12px]'),
|
||||
'rb:w-full rb:flex rb:items-center rb:justify-between rb:cursor-pointer rb:rounded-lg rb:px-2 rb:transition-colors', {
|
||||
'rb:bg-[#F6F6F6] rb:border-none rb:shadow-none': variant === 'filled',
|
||||
'rb:border rb:border-[#d9d9d9] hover:rb:border-[#4096ff] rb:bg-white': variant === 'outlined',
|
||||
'rb:border-[#171719]!': variant === 'outlined' && open,
|
||||
'rb:border-none rb:shadow-none rb:bg-transparent': variant === 'borderless',
|
||||
'rb:text-[12px]': size === 'small',
|
||||
'rb:text-[14px]': size !== 'small',
|
||||
},
|
||||
multiple && size === 'small'
|
||||
? 'rb:min-h-7 rb:py-0.75'
|
||||
: multiple
|
||||
? 'rb:min-h-8 rb:py-1'
|
||||
: size === 'small'
|
||||
? 'rb:h-7 rb:text-[10px]'
|
||||
: size === 'large'
|
||||
? 'rb:h-10'
|
||||
: 'rb:h-8 rb:text-[12px]',
|
||||
className
|
||||
)}
|
||||
onClick={() => setOpen(o => !o)}
|
||||
>
|
||||
{multiple ? (
|
||||
selectedValues.length > 0 ? (
|
||||
<span className="rb:flex rb:flex-wrap rb:gap-1 rb:flex-1 rb:min-w-0">
|
||||
<Flex wrap gap={4} className="rb:flex-1! rb:min-w-0">
|
||||
{selectedValues.map(v => {
|
||||
const s = suggestionMap.get(v);
|
||||
if (!s) return null;
|
||||
@@ -214,11 +323,11 @@ const VariableSelect: FC<VariableSelectProps> = ({
|
||||
return (
|
||||
<span
|
||||
key={v}
|
||||
className="rb:inline-flex rb:items-center rb:gap-0.5 rb:bg-[#f0f8ff] rb:rounded rb:px-1 rb:py-0.5 rb:text-[11px] rb:max-w-full"
|
||||
className="rb-border rb:rounded-md rb:bg-white rb:text-[10px] rb:text-[#212332] rb:h-5! rb:inline-flex rb:items-center rb:p-1 rb:cursor-pointer"
|
||||
>
|
||||
{!isConv && nd?.icon && <div className={`rb:size-3 rb:shrink-0 rb:bg-cover ${nd.icon}`} />}
|
||||
{!isConv && nd?.icon && <div className={`rb:size-3 rb:bg-cover ${nd.icon}`} />}
|
||||
{!isConv && nd?.name && <span className="rb:text-[#5B6167]">{nd.name}{sep}</span>}
|
||||
<span className="rb:text-[#171719]">
|
||||
<span>
|
||||
{parent ? <>{parent.label}{sep}{s.label}</> : s.label}
|
||||
</span>
|
||||
<span
|
||||
@@ -228,23 +337,25 @@ const VariableSelect: FC<VariableSelectProps> = ({
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</Flex>
|
||||
) : (
|
||||
<span className="rb:text-[#bfbfbf] rb:flex-1 rb:text-[12px]">{placeholder}</span>
|
||||
<span className="rb:text-[rgba(23,23,25,0.25)] rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap rb:flex-1">{placeholder}</span>
|
||||
)
|
||||
) : selectedSuggestion ? (
|
||||
<div className="rb:flex rb:flex-1 rb:min-w-0 rb:max-w-full">
|
||||
<span className="rb:inline-flex rb:items-center rb:gap-0.5 rb:bg-[#f0f8ff] rb:rounded rb:px-1 rb:py-0.5 rb:text-[11px] rb:max-w-full rb:overflow-hidden">
|
||||
{!isConversation && nodeData?.icon && <div className={`rb:size-3 rb:shrink-0 rb:bg-cover ${nodeData.icon}`} />}
|
||||
{!isConversation && nodeData?.name && <span className="rb:text-[#5B6167] rb:shrink rb:min-w-0 rb:truncate rb:max-w-[40%]">{nodeData.name}</span>}
|
||||
{!isConversation && nodeData?.name && <span className="rb:text-[#5B6167]">{sep}</span>}
|
||||
<span className="rb:text-[#171719] rb:shrink rb:min-w-0 rb:truncate">
|
||||
<span
|
||||
className="rb-border rb:rounded-md rb:bg-white rb:text-[10px] rb:text-[#212332] rb:h-5! rb:inline-flex rb:items-center rb:p-1 rb:cursor-pointer"
|
||||
>
|
||||
{!isConversation && nodeData?.icon && <div className={`rb:size-3 rb:bg-cover rb:mr-1 ${nodeData.icon}`} />}
|
||||
{!isConversation && nodeData?.name && <span className="rb:shrink rb:min-w-0 rb:truncate rb:max-w-[40%]">{nodeData.name}</span>}
|
||||
{!isConversation && nodeData?.name && <span>{sep}</span>}
|
||||
<span className="rb:shrink rb:min-w-0 rb:truncate">
|
||||
{parentOfSelected ? <>{parentOfSelected.label}{sep}{selectedSuggestion.label}</> : selectedSuggestion.label}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="rb:text-[#bfbfbf] rb:flex-1">{placeholder}</span>
|
||||
<span className="rb:text-[rgba(23,23,25,0.25)] rb:flex-1">{placeholder}</span>
|
||||
)}
|
||||
<Space size={4} className="rb:shrink-0 rb:ml-1">
|
||||
{allowClear && (
|
||||
@@ -266,18 +377,19 @@ const VariableSelect: FC<VariableSelectProps> = ({
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="rb:fixed rb:z-9999 rb:bg-white rb:text-[14px] rb:rounded-lg rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)] rb:p-1"
|
||||
className="rb:min-w-70 rb:max-h-57.5 rb:overflow-y-auto rb:fixed rb:z-1000 rb:bg-white rb:rounded-lg rb:border-[0.5px] rb:border-[#EBEBEB] rb:shadow-[0px_2px_6px_0px_rgba(0,0,0,0.1)] rb:py-3 rb:px-2"
|
||||
style={{ top: dropdownPos.top, left: dropdownPos.left, minWidth: dropdownPos.width }}
|
||||
>
|
||||
<div className="rb:min-w-70 rb:max-h-60 rb:overflow-y-auto rb:py-1">
|
||||
{Object.entries(filteredGroups).map(([nodeId, suggestions]) => {
|
||||
<div className="rb:min-w-70 rb:max-h-57.5 rb:overflow-y-auto">
|
||||
{Object.entries(filteredGroups).map(([nodeId, suggestions], index) => {
|
||||
const nd = suggestions[0].nodeData;
|
||||
return (
|
||||
<div key={nodeId}>
|
||||
<Flex align="center" gap={4} className="rb:px-3! rb:py-1.25! rb:text-[12px] rb:text-[#5B6167]">
|
||||
{nd.icon && <div className={`rb:size-4 rb:bg-cover ${nd.icon}`} />}
|
||||
<div key={nodeId} className={clsx("rb:text-[12px]", {
|
||||
'rb:mt-3': index !== 0
|
||||
})}>
|
||||
<div className="rb:px-2 rb:leading-4.25 rb:mb-1.25 rb:font-medium rb:text-[#5B6167]">
|
||||
{nd.name}
|
||||
</Flex>
|
||||
</div>
|
||||
{suggestions.map(s => {
|
||||
const isSelected = multiple
|
||||
? selectedValues.includes(`{{${s.value}}}`)
|
||||
@@ -288,11 +400,9 @@ const VariableSelect: FC<VariableSelectProps> = ({
|
||||
<Flex
|
||||
key={s.key}
|
||||
ref={(el) => { if (el) itemRefs.current.set(s.key, el); }}
|
||||
className={clsx("rb:pl-6! rb:pr-3! rb:py-1.25! rb:rounded-lg!", {
|
||||
'rb:bg-[#e6f4ff]': isSelected || isExpanded,
|
||||
'rb:bg-white rb:hover:bg-[#F6F6F6]!': !(isSelected || isExpanded),
|
||||
'rb:opacity-60': s.disabled,
|
||||
'rb:cursor-not-allowed': s.disabled,
|
||||
className={clsx("rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]", {
|
||||
'rb:bg-[#F6F6F6]': isSelected || isExpanded || flatItems.indexOf(s) === activeIndex,
|
||||
'rb:cursor-not-allowed rb:opacity-65': s.disabled,
|
||||
'rb:cursor-pointer': !s.disabled,
|
||||
})}
|
||||
align="center"
|
||||
@@ -300,30 +410,29 @@ const VariableSelect: FC<VariableSelectProps> = ({
|
||||
onClick={() => {
|
||||
if (s.disabled) return;
|
||||
if (hasChildren) {
|
||||
updateChildPos(s.key);
|
||||
setExpandedParent(prev => prev?.key === s.key ? null : s);
|
||||
calcChildPos(s.key);
|
||||
setExpandedParentKey(prev => prev === s.key ? null : s.key);
|
||||
}
|
||||
handleSelect(s);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (hasChildren) {
|
||||
updateChildPos(s.key);
|
||||
setExpandedParent(s);
|
||||
calcChildPos(s.key);
|
||||
setExpandedParentKey(s.key);
|
||||
} else {
|
||||
setExpandedParent(null);
|
||||
setExpandedParentKey(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Space size={4}>
|
||||
<div className="rb:font-medium">
|
||||
{multiple && (
|
||||
<Checkbox checked={isSelected} />
|
||||
<Checkbox checked={isSelected} className="rb:mr-2!" />
|
||||
)}
|
||||
<span className="rb:text-[#155EEF]">{`{x}`}</span>
|
||||
<span>{s.label}</span>
|
||||
</Space>
|
||||
<Space size={4} className="rb:text-[#5B6167] rb:text-[12px]">
|
||||
{s.dataType && <span>{s.dataType}</span>}
|
||||
<span className="rb:text-[#155EEF]">{`{x}`}</span> {s.label}
|
||||
</div>
|
||||
|
||||
<Space size={2}>
|
||||
{s.dataType && <span>{s.dataType}</span>}
|
||||
{hasChildren && <div className="rb:size-3 rb:bg-cover rb:bg-[url('@/assets/images/common/arrow_up.svg')] rb:rotate-90"></div>}
|
||||
</Space>
|
||||
</Flex>
|
||||
@@ -334,7 +443,7 @@ const VariableSelect: FC<VariableSelectProps> = ({
|
||||
})}
|
||||
{Object.keys(filteredGroups).length === 0 && (
|
||||
<div className="rb:px-3 rb:py-4 rb:text-center rb:text-[#bfbfbf] rb:text-[14px]">
|
||||
{t('workflow.variableSelect.empty', '暂无变量')}
|
||||
{t('workflow.variableSelect.empty')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -346,51 +455,43 @@ const VariableSelect: FC<VariableSelectProps> = ({
|
||||
{open && expandedParent?.children?.length && createPortal(
|
||||
<div
|
||||
id="variable-select-child-panel"
|
||||
className="rb:fixed rb:z-9999 rb:bg-white rb:rounded-xl rb:py-1 rb:min-w-60 rb:max-h-60 rb:overflow-y-auto rb:text-[14px] rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)]"
|
||||
className="rb:min-w-70 rb:max-h-57.5 rb:overflow-y-auto rb:text-[12px] rb:fixed rb:z-1000 rb:bg-white rb:rounded-lg rb:border-[0.5px] rb:border-[#EBEBEB] rb:shadow-[0px_2px_6px_0px_rgba(0,0,0,0.1)] rb:py-3 rb:px-2"
|
||||
style={{ top: childPanelPos.top, right: childPanelPos.right }}
|
||||
onMouseEnter={() => setExpandedParent(expandedParent)}
|
||||
onMouseEnter={() => setExpandedParentKey(expandedParentKey)}
|
||||
>
|
||||
<div
|
||||
className="rb:px-3 rb:py-2 rb:text-[14px] rb:font-medium rb:text-[#5B6167] rb:border-b rb:border-[#F0F0F0] rb:cursor-pointer rb:hover:bg-[#f0f8ff]"
|
||||
onClick={() => !expandedParent.disabled && handleSelect(expandedParent)}
|
||||
>
|
||||
<div className="rb:pb-2 rb:mb-1 rb:font-medium rb:text-[#5B6167] rb-border-b">
|
||||
<Flex justify="space-between" align="center" gap={8}>
|
||||
<Flex align="center" gap={6}>
|
||||
<span>{expandedParent.nodeData.name}.{expandedParent.label}</span>
|
||||
</Flex>
|
||||
<span>{expandedParent.nodeData.name}.{expandedParent.label}</span>
|
||||
<span>{expandedParent.dataType}</span>
|
||||
</Flex>
|
||||
</div>
|
||||
{expandedParent.children.map(child => {
|
||||
{expandedParent.children.map((child, ci) => {
|
||||
const isSelected = multiple
|
||||
? selectedValues.includes(`{{${child.value}}}`)
|
||||
: `{{${child.value}}}` === value;
|
||||
const hasGrandChildren = !!child.children?.length;
|
||||
const isChildActive = activePanel === 'child' && ci === childActiveIndex;
|
||||
return (
|
||||
<Flex
|
||||
key={child.key}
|
||||
className={clsx("rb:px-3! rb:py-2! rb:hover:bg-[#f0f8ff]!", {
|
||||
'rb:bg-[#f0f8ff]': isSelected,
|
||||
'rb:white': !isSelected
|
||||
ref={(el) => { if (el) childItemRefs.current.set(child.key, el); }}
|
||||
className={clsx("rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]", {
|
||||
'rb:bg-[#F6F6F6]': isSelected || isChildActive,
|
||||
'rb:cursor-not-allowed rb:opacity-65': child.disabled,
|
||||
'rb:cursor-pointer': !child.disabled,
|
||||
})}
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{
|
||||
cursor: child.disabled ? 'not-allowed' : 'pointer',
|
||||
opacity: child.disabled ? 0.5 : 1,
|
||||
}}
|
||||
onClick={() => !child.disabled && handleSelect(child)}
|
||||
>
|
||||
<Flex align="center" gap={6}>
|
||||
<Flex align="center" gap={8}>
|
||||
{multiple && (
|
||||
<Checkbox checked={isSelected} />
|
||||
)}
|
||||
<span>{child.label}</span>
|
||||
</Flex>
|
||||
<Flex align="center" gap={4}>
|
||||
{child.dataType && <span className="rb:text-[#5B6167]">{child.dataType}</span>}
|
||||
{hasGrandChildren && <span className="rb:text-[#5B6167] rb:ml-1">›</span>}
|
||||
<span className="rb:font-medium">{child.label}</span>
|
||||
</Flex>
|
||||
<Space size={2}>
|
||||
{child.dataType && <span>{child.dataType}</span>}
|
||||
</Space>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-01-19 17:00:26
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-08 10:12:27
|
||||
* @Last Modified time: 2026-04-13 10:44:17
|
||||
*/
|
||||
/**
|
||||
* useVariableList Hook
|
||||
@@ -414,7 +414,7 @@ export const useVariableList = (
|
||||
const pd = parentLoop.getData();
|
||||
const pid = pd.id;
|
||||
if (pd.type === 'loop') {
|
||||
(pd.cycle_vars || []).forEach((cv: any) => addVariable(list, keys, `${pid}_cycle_${cv.name}`, cv.name, cv.type || 'String', `${pid}.${cv.name}`, pd));
|
||||
(pd.cycle_vars || []).forEach((cv: any) => addVariable(list, keys, `${pid}_cycle_${cv.name}`, cv.name, cv.type || 'string', `${pid}.${cv.name}`, pd));
|
||||
} else if (pd.type === 'iteration' && pd.config.input.defaultValue) {
|
||||
let itemType = 'object';
|
||||
const iv = list.find(v => `{{${v.value}}}` === pd.config.input.defaultValue);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 15:39:59
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-10 17:24:19
|
||||
* @Last Modified time: 2026-04-13 10:44:19
|
||||
*/
|
||||
import { type FC, useEffect, useState, useMemo } from "react";
|
||||
import clsx from 'clsx'
|
||||
@@ -266,7 +266,7 @@ const Properties: FC<PropertiesProps> = ({
|
||||
key,
|
||||
label: cycleVar.name,
|
||||
type: 'variable',
|
||||
dataType: cycleVar.type || 'String',
|
||||
dataType: cycleVar.type || 'string',
|
||||
value: `${parentNodeId}.${cycleVar.name}`,
|
||||
nodeData: parentData,
|
||||
});
|
||||
@@ -643,7 +643,7 @@ const Properties: FC<PropertiesProps> = ({
|
||||
key: contextKey,
|
||||
label: 'context',
|
||||
type: 'variable',
|
||||
dataType: 'String',
|
||||
dataType: 'string',
|
||||
value: `context`,
|
||||
nodeData: selectedNode.getData(),
|
||||
isContext: true,
|
||||
@@ -791,7 +791,7 @@ const Properties: FC<PropertiesProps> = ({
|
||||
key: `${selectedNode.id}_cycle_${cycleVar.name}`,
|
||||
label: cycleVar.name,
|
||||
type: 'variable',
|
||||
dataType: cycleVar.type || 'String',
|
||||
dataType: cycleVar.type || 'string',
|
||||
value: `${selectedNode.getData().id}.${cycleVar.name}`,
|
||||
nodeData: selectedNode.getData(),
|
||||
}));
|
||||
|
||||
@@ -23,6 +23,11 @@
|
||||
}
|
||||
.properties :global(.select.ant-select-single.ant-select-sm.ant-select-borderless) {
|
||||
height: 28px;
|
||||
border: 1px solid #F6F6F6;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.properties :global(.select.ant-select-single.ant-select-sm.ant-select-borderless.ant-select-focused) {
|
||||
border: 1px solid #171719;
|
||||
}
|
||||
.properties :global(.ant-table-wrapper .ant-table-thead>tr>th),
|
||||
.properties :global(.ant-table-wrapper .ant-table-thead>tr>td),
|
||||
@@ -157,4 +162,7 @@
|
||||
padding-inline-start: 0px;
|
||||
border-radius: 4px;
|
||||
margin-block: 0px;
|
||||
}
|
||||
.properties :global(.ant-input-number-affix-wrapper) {
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 15:06:18
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-07 19:56:56
|
||||
* @Last Modified time: 2026-04-16 17:52:30
|
||||
*/
|
||||
import LoopNode from './components/Nodes/LoopNode';
|
||||
import NormalNode from './components/Nodes/NormalNode';
|
||||
@@ -101,6 +101,10 @@ export const nodeLibrary: NodeLibrary[] = [
|
||||
step: 1,
|
||||
defaultValue: 2000
|
||||
},
|
||||
json_output: {
|
||||
type: 'define',
|
||||
defaultValue: false
|
||||
},
|
||||
context: {
|
||||
type: 'variableList',
|
||||
placeholder: 'workflow.config.llm.contextPlaceholder'
|
||||
|
||||
@@ -114,6 +114,7 @@ export const useWorkflowGraph = ({
|
||||
graphRef.current.getNodes().forEach(node => {
|
||||
const data = node.getData()
|
||||
if (data?.type === 'if-else' || data?.type === 'question-classifier') {
|
||||
console.log('chatVariables', chatVariables)
|
||||
node.setData({ ...data, chatVariables }, { silent: true })
|
||||
}
|
||||
})
|
||||
@@ -206,7 +207,7 @@ export const useWorkflowGraph = ({
|
||||
? Object.entries(group_variables as Record<string, any>).map(([key, value]) => ({ key, value }))
|
||||
: group_variables
|
||||
} else if (type === 'http-request' && (key === 'headers' || key === 'params') && config[key] && typeof config[key] === 'object' && !Array.isArray(config[key]) && nodeLibraryConfig.config && nodeLibraryConfig.config[key]) {
|
||||
nodeLibraryConfig.config[key].defaultValue = Object.entries(config[key]).map(([name, value]) => ({ name, value }))
|
||||
nodeLibraryConfig.config[key].defaultValue = Object.entries(config[key]).map(([key, value]) => ({ key, value }))
|
||||
} else if (type === 'code' && key === 'code' && config[key] && nodeLibraryConfig.config && nodeLibraryConfig.config[key]) {
|
||||
try {
|
||||
nodeLibraryConfig.config[key].defaultValue = decodeURIComponent(atob(config[key] as string))
|
||||
@@ -1025,24 +1026,39 @@ export const useWorkflowGraph = ({
|
||||
|
||||
graphRef.current.on('node:removed', blankClick)
|
||||
// When edge connected, bring connected nodes' ports to front
|
||||
graphRef.current.on('edge:connected', ({ isNew }) => {
|
||||
// Bring edge to front first, then bring child nodes above edges
|
||||
// Parent (loop/iteration) nodes stay behind to avoid covering edges
|
||||
// Reset any port hover state left from dragging
|
||||
graphRef.current.on('edge:connected', ({ isNew, edge }) => {
|
||||
if (isNew) {
|
||||
graphRef.current?.getNodes().forEach(node => {
|
||||
if (!node.getData()?.cycle) node.toFront();
|
||||
});
|
||||
graphRef.current?.getEdges().forEach(edge => {
|
||||
const sourceCell = graphRef.current?.getCellById(edge.getSourceCellId());
|
||||
const targetCell = graphRef.current?.getCellById(edge.getTargetCellId());
|
||||
if (sourceCell?.getData()?.cycle || targetCell?.getData()?.cycle) {
|
||||
edge.toFront();
|
||||
}
|
||||
});
|
||||
graphRef.current?.getNodes().forEach(node => {
|
||||
if (node.getData()?.cycle) node.toFront();
|
||||
});
|
||||
const sourceCellId = edge.getSourceCellId()
|
||||
const targetCellId = edge.getTargetCellId()
|
||||
const sourceCell = graphRef.current?.getCellById(sourceCellId);
|
||||
const targetCell = graphRef.current?.getCellById(targetCellId);
|
||||
|
||||
sourceCell?.toFront();
|
||||
targetCell?.toFront()
|
||||
if (['loop', 'iteration'].includes(sourceCell?.getData()?.type)) {
|
||||
graphRef.current?.getEdges().forEach(edge => {
|
||||
const edgeSourceCell = graphRef.current?.getCellById(edge.getSourceCellId());
|
||||
const edgeTargetCell = graphRef.current?.getCellById(edge.getTargetCellId());
|
||||
if (edgeSourceCell?.getData()?.cycle === sourceCellId || edgeTargetCell?.getData()?.cycle === sourceCellId) {
|
||||
edge.toFront();
|
||||
}
|
||||
});
|
||||
graphRef.current?.getNodes().forEach(node => {
|
||||
if (node.getData()?.cycle === sourceCellId) node.toFront();
|
||||
});
|
||||
}
|
||||
if (['loop', 'iteration'].includes(targetCell?.getData()?.type)) {
|
||||
graphRef.current?.getEdges().forEach(edge => {
|
||||
const edgeSourceCell = graphRef.current?.getCellById(edge.getSourceCellId());
|
||||
const edgeTargetCell = graphRef.current?.getCellById(edge.getTargetCellId());
|
||||
if (edgeSourceCell?.getData()?.cycle === targetCellId || edgeTargetCell?.getData()?.cycle === targetCellId) {
|
||||
edge.toFront();
|
||||
}
|
||||
});
|
||||
graphRef.current?.getNodes().forEach(node => {
|
||||
if (node.getData()?.cycle === targetCellId) node.toFront();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1187,9 +1203,6 @@ export const useWorkflowGraph = ({
|
||||
}) || [];
|
||||
const edges = graphRef.current?.getEdges() || []
|
||||
|
||||
|
||||
console.log('config', config)
|
||||
|
||||
const params = {
|
||||
...config,
|
||||
features: featuresRef.current,
|
||||
@@ -1246,9 +1259,17 @@ export const useWorkflowGraph = ({
|
||||
itemConfig[key] = {}
|
||||
if (value.length > 0) {
|
||||
value.forEach((vo: any) => {
|
||||
itemConfig[key][vo.name] = vo.value
|
||||
itemConfig[key][vo.key] = vo.value
|
||||
})
|
||||
}
|
||||
} else if (data.type === 'http-request' && key === 'body' && data.config[key] && 'defaultValue' in data.config[key]) {
|
||||
const value = data.config[key].defaultValue
|
||||
itemConfig[key] = value
|
||||
if (value.content_type === 'json' && value.data && value.data !== '') {
|
||||
itemConfig[key].data = value.data.replace(/\u00a0/g, ' ')
|
||||
} else {
|
||||
itemConfig[key].data = value.data
|
||||
}
|
||||
} else if (data.config[key] && 'defaultValue' in data.config[key] && key !== 'knowledge_retrieval') {
|
||||
itemConfig[key] = data.config[key].defaultValue
|
||||
} else if (key === 'knowledge_retrieval' && data.config[key] && 'defaultValue' in data.config[key]) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-03-24 15:07:49
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-24 15:07:49
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-17 20:40:47
|
||||
*/
|
||||
|
||||
import { portItemArgsY, conditionNodePortItemArgsY, conditionNodeHeight } from './constant'
|
||||
@@ -22,11 +22,31 @@ import { portItemArgsY, conditionNodePortItemArgsY, conditionNodeHeight } from '
|
||||
* @param cases - Array of case objects, each containing an `expressions` array.
|
||||
* @returns The total pixel height for the condition node.
|
||||
*/
|
||||
export const isSubExprSet = (sub: any) => {
|
||||
if (!sub?.key) return false;
|
||||
if (['not_empty', 'empty'].includes(sub?.operator)) return true;
|
||||
return !!sub.value || typeof sub.value === 'boolean' || typeof sub.value === 'number';
|
||||
};
|
||||
|
||||
const getEffectiveExprCount = (expr: any): number => {
|
||||
const subs = expr?.sub_variable_condition?.conditions;
|
||||
if (subs?.length && subs.every(isSubExprSet)) return 1 + subs.length;
|
||||
if (subs?.length > 0) {
|
||||
return 2
|
||||
}
|
||||
return 1;
|
||||
};
|
||||
|
||||
export const calcConditionNodeTotalHeight = (cases: any[]) => {
|
||||
// Total number of expressions across all cases
|
||||
const exprCount = cases.reduce((acc: number, c: any) => acc + (c?.expressions?.length || 0), 0);
|
||||
// Sum of expression counts only for cases that have more than one expression
|
||||
const hasMultiExprCount = cases.reduce((acc: number, c: any) => acc + (c?.expressions?.length > 1 ? c?.expressions?.length : 0), 0);
|
||||
// Total number of effective expression rows (sub_variable_condition expand height when all set)
|
||||
const exprCount = cases.reduce((acc: number, c: any) =>
|
||||
acc + (c?.expressions?.reduce((s: number, e: any) => s + getEffectiveExprCount(e), 0) || 0), 0);
|
||||
// Sum of effective expression counts only for cases that have more than one expression
|
||||
const hasMultiExprCount = cases.reduce((acc: number, c: any) => {
|
||||
if (!c?.expressions?.length || c.expressions.length <= 1) return acc;
|
||||
const effectiveCount = c.expressions.reduce((s: number, e: any) => s + getEffectiveExprCount(e), 0);
|
||||
return acc + effectiveCount;
|
||||
}, 0);
|
||||
|
||||
return conditionNodeHeight + (cases.length - 1) * 26 + exprCount * 20 + hasMultiExprCount * 3;
|
||||
};
|
||||
@@ -68,17 +88,44 @@ export const getConditionNodeCasePortY = (cases: any[], caseIndex: number) => {
|
||||
let singleExprCount = 0;
|
||||
let multiExprCount = 0;
|
||||
let extraExprs = 0;
|
||||
let portItemArgsYNum = 0;
|
||||
|
||||
for (let i = 0; i < caseIndex; i++) {
|
||||
const notHasSub = cases[i]?.expressions?.filter((e: any) => !e?.sub_variable_condition?.conditions || e?.sub_variable_condition?.conditions.length <1).length
|
||||
const n = cases[i]?.expressions?.length || 0;
|
||||
y += portItemArgsY * (n + 1);
|
||||
if (n === 1) singleExprCount++;
|
||||
else if (n >= 2) {
|
||||
let casePortItemArgsYNum = n + 1;
|
||||
// Add extra y for expressions with all sub_variable_condition set
|
||||
cases[i]?.expressions?.forEach((expr: any) => {
|
||||
const subs = expr?.sub_variable_condition?.conditions;
|
||||
if (subs?.length && subs.every(isSubExprSet)) {
|
||||
casePortItemArgsYNum += subs.length;
|
||||
} else if (subs?.length) {
|
||||
casePortItemArgsYNum += 1
|
||||
}
|
||||
});
|
||||
portItemArgsYNum += casePortItemArgsYNum;
|
||||
if (n === 1 && !cases[i]?.expressions?.some((e: any) => e?.sub_variable_condition?.conditions?.length > 0)) {
|
||||
singleExprCount++
|
||||
} else if (n >= 2 || cases[i]?.expressions?.some((e: any) => e?.sub_variable_condition?.conditions?.length > 0)) {
|
||||
multiExprCount++;
|
||||
if (n > 2) extraExprs += n - 2;
|
||||
cases[i]?.expressions?.forEach((e: any) => {
|
||||
const subs = e?.sub_variable_condition?.conditions;
|
||||
if (subs?.length && subs.every(isSubExprSet) && subs.length > 1) {
|
||||
extraExprs += subs.length + 2;
|
||||
}
|
||||
});
|
||||
|
||||
console.log('extraExprs notHasSub', notHasSub)
|
||||
if (notHasSub > 3) {
|
||||
extraExprs += n - 2 + notHasSub/4;
|
||||
} else {
|
||||
extraExprs += n - 2 + notHasSub/4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('singleExprCount', singleExprCount, 'multiExprCount', multiExprCount, 'extraExprs', extraExprs)
|
||||
y += portItemArgsY * portItemArgsYNum
|
||||
// Correction for single-expression cases (slightly shorter rendered height)
|
||||
if (singleExprCount > 0) y -= singleExprCount * 7 + 2;
|
||||
// Correction for multi-expression cases (compact logical operator row)
|
||||
|
||||
Reference in New Issue
Block a user