Feat: Initialize the data pipeline canvas. #9869 (#9870)

### What problem does this PR solve?
Feat: Initialize the data pipeline canvas. #9869

### Type of change


- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
balibabu
2025-09-02 15:47:33 +08:00
committed by GitHub
parent c2567844ea
commit cb14dafaca
196 changed files with 21201 additions and 0 deletions

View File

@ -0,0 +1,32 @@
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { t } from 'i18next';
import { useFormContext } from 'react-hook-form';
interface IApiKeyFieldProps {
placeholder?: string;
}
export function ApiKeyField({ placeholder }: IApiKeyFieldProps) {
const form = useFormContext();
return (
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel>{t('flow.apiKey')}</FormLabel>
<FormControl>
<Input type="password" {...field} placeholder={placeholder}></Input>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}

View File

@ -0,0 +1,27 @@
import {
FormControl,
FormField,
FormItem,
FormLabel,
} from '@/components/ui/form';
import { Textarea } from '@/components/ui/textarea';
import { t } from 'i18next';
import { useFormContext } from 'react-hook-form';
export function DescriptionField() {
const form = useFormContext();
return (
<FormField
control={form.control}
name={`description`}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>{t('flow.description')}</FormLabel>
<FormControl>
<Textarea {...field}></Textarea>
</FormControl>
</FormItem>
)}
/>
);
}

View File

@ -0,0 +1,127 @@
import { RAGFlowNodeType } from '@/interfaces/database/flow';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import { Button, Collapse, Flex, Form, Input, Select } from 'antd';
import { PropsWithChildren, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useBuildVariableOptions } from '../../hooks/use-get-begin-query';
import styles from './index.less';
interface IProps {
node?: RAGFlowNodeType;
}
enum VariableType {
Reference = 'reference',
Input = 'input',
}
const getVariableName = (type: string) =>
type === VariableType.Reference ? 'component_id' : 'value';
const DynamicVariableForm = ({ node }: IProps) => {
const { t } = useTranslation();
const valueOptions = useBuildVariableOptions(node?.id, node?.parentId);
const form = Form.useFormInstance();
const options = [
{ value: VariableType.Reference, label: t('flow.reference') },
{ value: VariableType.Input, label: t('flow.text') },
];
const handleTypeChange = useCallback(
(name: number) => () => {
setTimeout(() => {
form.setFieldValue(['query', name, 'component_id'], undefined);
form.setFieldValue(['query', name, 'value'], undefined);
}, 0);
},
[form],
);
return (
<Form.List name="query">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...restField }) => (
<Flex key={key} gap={10} align={'baseline'}>
<Form.Item
{...restField}
name={[name, 'type']}
className={styles.variableType}
>
<Select
options={options}
onChange={handleTypeChange(name)}
></Select>
</Form.Item>
<Form.Item noStyle dependencies={[name, 'type']}>
{({ getFieldValue }) => {
const type = getFieldValue(['query', name, 'type']);
return (
<Form.Item
{...restField}
name={[name, getVariableName(type)]}
className={styles.variableValue}
>
{type === VariableType.Reference ? (
<Select
placeholder={t('common.pleaseSelect')}
options={valueOptions}
></Select>
) : (
<Input placeholder={t('common.pleaseInput')} />
)}
</Form.Item>
);
}}
</Form.Item>
<MinusCircleOutlined onClick={() => remove(name)} />
</Flex>
))}
<Form.Item>
<Button
type="dashed"
onClick={() => add({ type: VariableType.Reference })}
block
icon={<PlusOutlined />}
className={styles.addButton}
>
{t('flow.addVariable')}
</Button>
</Form.Item>
</>
)}
</Form.List>
);
};
export function FormCollapse({
children,
title,
}: PropsWithChildren<{ title: string }>) {
return (
<Collapse
className={styles.dynamicInputVariable}
defaultActiveKey={['1']}
items={[
{
key: '1',
label: <span className={styles.title}>{title}</span>,
children,
},
]}
/>
);
}
const DynamicInputVariable = ({ node }: IProps) => {
const { t } = useTranslation();
return (
<FormCollapse title={t('flow.input')}>
<DynamicVariableForm node={node}></DynamicVariableForm>
</FormCollapse>
);
};
export default DynamicInputVariable;

View File

@ -0,0 +1,16 @@
type FormProps = React.ComponentProps<'form'>;
export function FormWrapper({ children, ...props }: FormProps) {
return (
<form
className="space-y-6 p-4"
autoComplete="off"
onSubmit={(e) => {
e.preventDefault();
}}
{...props}
>
{children}
</form>
);
}

View File

@ -0,0 +1,22 @@
.dynamicInputVariable {
background-color: #ebe9e950;
:global(.ant-collapse-content) {
background-color: #f6f6f657;
}
margin-bottom: 20px;
.title {
font-weight: 600;
font-size: 16px;
}
.variableType {
width: 30%;
}
.variableValue {
flex: 1;
}
.addButton {
color: rgb(22, 119, 255);
font-weight: 600;
}
}

View File

@ -0,0 +1,135 @@
'use client';
import { SideDown } from '@/assets/icon/next-icon';
import { Button } from '@/components/ui/button';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { RAGFlowSelect } from '@/components/ui/select';
import { RAGFlowNodeType } from '@/interfaces/database/flow';
import { Plus, Trash2 } from 'lucide-react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useBuildVariableOptions } from '../../hooks/use-get-begin-query';
interface IProps {
node?: RAGFlowNodeType;
}
enum VariableType {
Reference = 'reference',
Input = 'input',
}
const getVariableName = (type: string) =>
type === VariableType.Reference ? 'component_id' : 'value';
export function DynamicVariableForm({ node }: IProps) {
const { t } = useTranslation();
const form = useFormContext();
const { fields, remove, append } = useFieldArray({
name: 'query',
control: form.control,
});
const valueOptions = useBuildVariableOptions(node?.id, node?.parentId);
const options = [
{ value: VariableType.Reference, label: t('flow.reference') },
{ value: VariableType.Input, label: t('flow.text') },
];
return (
<div>
{fields.map((field, index) => {
const typeField = `query.${index}.type`;
const typeValue = form.watch(typeField);
return (
<div key={field.id} className="flex items-center gap-1">
<FormField
control={form.control}
name={typeField}
render={({ field }) => (
<FormItem className="w-2/5">
<FormDescription />
<FormControl>
<RAGFlowSelect
{...field}
placeholder={t('common.pleaseSelect')}
options={options}
onChange={(val) => {
field.onChange(val);
form.resetField(`query.${index}.value`);
form.resetField(`query.${index}.component_id`);
}}
></RAGFlowSelect>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`query.${index}.${getVariableName(typeValue)}`}
render={({ field }) => (
<FormItem className="flex-1">
<FormDescription />
<FormControl>
{typeValue === VariableType.Reference ? (
<RAGFlowSelect
placeholder={t('common.pleaseSelect')}
{...field}
options={valueOptions}
></RAGFlowSelect>
) : (
<Input placeholder={t('common.pleaseInput')} {...field} />
)}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Trash2
className="cursor-pointer mx-3 size-4 text-colors-text-functional-danger"
onClick={() => remove(index)}
/>
</div>
);
})}
<Button onClick={append} className="mt-4" variant={'outline'} size={'sm'}>
<Plus />
{t('flow.addVariable')}
</Button>
</div>
);
}
export function DynamicInputVariable({ node }: IProps) {
const { t } = useTranslation();
return (
<Collapsible defaultOpen className="group/collapsible">
<CollapsibleTrigger className="flex justify-between w-full pb-2">
<span className="font-bold text-2xl text-colors-text-neutral-strong">
{t('flow.input')}
</span>
<Button variant={'icon'} size={'icon'}>
<SideDown />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<DynamicVariableForm node={node}></DynamicVariableForm>
</CollapsibleContent>
</Collapsible>
);
}

View File

@ -0,0 +1,35 @@
import { t } from 'i18next';
export type OutputType = {
title: string;
type?: string;
};
type OutputProps = {
list: Array<OutputType>;
};
export function transferOutputs(outputs: Record<string, any>) {
return Object.entries(outputs).map(([key, value]) => ({
title: key,
type: value?.type,
}));
}
export function Output({ list }: OutputProps) {
return (
<section className="space-y-2">
<div>{t('flow.output')}</div>
<ul>
{list.map((x, idx) => (
<li
key={idx}
className="bg-background-highlight text-accent-primary rounded-sm px-2 py-1"
>
{x.title}: <span className="text-text-secondary">{x.type}</span>
</li>
))}
</ul>
</section>
);
}

View File

@ -0,0 +1 @@
export const ProgrammaticTag = 'programmatic';

View File

@ -0,0 +1,76 @@
.typeahead-popover {
background: #fff;
box-shadow: 0px 5px 10px rgba(0, 0, 0, 0.3);
border-radius: 8px;
position: fixed;
z-index: 1000;
}
.typeahead-popover ul {
list-style: none;
margin: 0;
max-height: 200px;
overflow-y: scroll;
}
.typeahead-popover ul::-webkit-scrollbar {
display: none;
}
.typeahead-popover ul {
-ms-overflow-style: none;
scrollbar-width: none;
}
.typeahead-popover ul li {
margin: 0;
min-width: 180px;
font-size: 14px;
outline: none;
cursor: pointer;
border-radius: 8px;
}
.typeahead-popover ul li.selected {
background: #eee;
}
.typeahead-popover li {
margin: 0 8px 0 8px;
color: #050505;
cursor: pointer;
line-height: 16px;
font-size: 15px;
display: flex;
align-content: center;
flex-direction: row;
flex-shrink: 0;
background-color: #fff;
border: 0;
}
.typeahead-popover li.active {
display: flex;
width: 20px;
height: 20px;
background-size: contain;
}
.typeahead-popover li .text {
display: flex;
line-height: 20px;
flex-grow: 1;
min-width: 150px;
}
.typeahead-popover li .icon {
display: flex;
width: 20px;
height: 20px;
user-select: none;
margin-right: 8px;
line-height: 16px;
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}

View File

@ -0,0 +1,181 @@
import { CodeHighlightNode, CodeNode } from '@lexical/code';
import {
InitialConfigType,
LexicalComposer,
} from '@lexical/react/LexicalComposer';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { HeadingNode, QuoteNode } from '@lexical/rich-text';
import {
$getRoot,
$getSelection,
EditorState,
Klass,
LexicalNode,
} from 'lexical';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { Variable } from 'lucide-react';
import { ReactNode, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PasteHandlerPlugin } from './paste-handler-plugin';
import theme from './theme';
import { VariableNode } from './variable-node';
import { VariableOnChangePlugin } from './variable-on-change-plugin';
import VariablePickerMenuPlugin from './variable-picker-plugin';
// Catch any errors that occur during Lexical updates and log them
// or throw them as needed. If you don't throw them, Lexical will
// try to recover gracefully without losing user data.
function onError(error: Error) {
console.error(error);
}
const Nodes: Array<Klass<LexicalNode>> = [
HeadingNode,
QuoteNode,
CodeHighlightNode,
CodeNode,
VariableNode,
];
type PromptContentProps = { showToolbar?: boolean; multiLine?: boolean };
type IProps = {
value?: string;
onChange?: (value?: string) => void;
placeholder?: ReactNode;
} & PromptContentProps;
function PromptContent({
showToolbar = true,
multiLine = true,
}: PromptContentProps) {
const [editor] = useLexicalComposerContext();
const [isBlur, setIsBlur] = useState(false);
const { t } = useTranslation();
const insertTextAtCursor = useCallback(() => {
editor.update(() => {
const selection = $getSelection();
if (selection !== null) {
selection.insertText(' /');
}
});
}, [editor]);
const handleVariableIconClick = useCallback(() => {
insertTextAtCursor();
}, [insertTextAtCursor]);
const handleBlur = useCallback(() => {
setIsBlur(true);
}, []);
const handleFocus = useCallback(() => {
setIsBlur(false);
}, []);
return (
<section
className={cn('border rounded-sm ', { 'border-blue-400': !isBlur })}
>
{showToolbar && (
<div className="border-b px-2 py-2 justify-end flex">
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block cursor-pointer cursor p-0.5 hover:bg-gray-100 dark:hover:bg-slate-800 rounded-sm">
<Variable size={16} onClick={handleVariableIconClick} />
</span>
</TooltipTrigger>
<TooltipContent>
<p>{t('flow.insertVariableTip')}</p>
</TooltipContent>
</Tooltip>
</div>
)}
<ContentEditable
className={cn(
'relative px-2 py-1 focus-visible:outline-none max-h-[50vh] overflow-auto',
{
'min-h-40': multiLine,
},
)}
onBlur={handleBlur}
onFocus={handleFocus}
/>
</section>
);
}
export function PromptEditor({
value,
onChange,
placeholder,
showToolbar,
multiLine = true,
}: IProps) {
const { t } = useTranslation();
const initialConfig: InitialConfigType = {
namespace: 'PromptEditor',
theme,
onError,
nodes: Nodes,
};
const onValueChange = useCallback(
(editorState: EditorState) => {
editorState?.read(() => {
// const listNodes = $nodesOfType(VariableNode); // to be removed
// const allNodes = $dfs();
const text = $getRoot().getTextContent();
onChange?.(text);
});
},
[onChange],
);
return (
<div className="relative">
<LexicalComposer initialConfig={initialConfig}>
<RichTextPlugin
contentEditable={
<PromptContent
showToolbar={showToolbar}
multiLine={multiLine}
></PromptContent>
}
placeholder={
<div
className={cn(
'absolute top-1 left-2 text-text-secondary pointer-events-none',
{
'truncate w-[90%]': !multiLine,
'translate-y-10': multiLine,
},
)}
>
{placeholder || t('common.promptPlaceholder')}
</div>
}
ErrorBoundary={LexicalErrorBoundary}
/>
<VariablePickerMenuPlugin value={value}></VariablePickerMenuPlugin>
<PasteHandlerPlugin />
<VariableOnChangePlugin
onChange={onValueChange}
></VariableOnChangePlugin>
</LexicalComposer>
</div>
);
}

View File

@ -0,0 +1,83 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import {
$createParagraphNode,
$createTextNode,
$getSelection,
$isRangeSelection,
PASTE_COMMAND,
} from 'lexical';
import { useEffect } from 'react';
function PasteHandlerPlugin() {
const [editor] = useLexicalComposerContext();
useEffect(() => {
const removeListener = editor.registerCommand(
PASTE_COMMAND,
(clipboardEvent: ClipboardEvent) => {
const clipboardData = clipboardEvent.clipboardData;
if (!clipboardData) {
return false;
}
const text = clipboardData.getData('text/plain');
if (!text) {
return false;
}
// Check if text contains line breaks
if (text.includes('\n')) {
editor.update(() => {
const selection = $getSelection();
if (selection && $isRangeSelection(selection)) {
// Normalize line breaks, merge multiple consecutive line breaks into a single line break
const normalizedText = text.replace(/\n{2,}/g, '\n');
// Clear current selection
selection.removeText();
// Create a paragraph node to contain all content
const paragraph = $createParagraphNode();
// Split text by line breaks
const lines = normalizedText.split('\n');
// Process each line
lines.forEach((lineText, index) => {
// Add line text (if any)
if (lineText) {
const textNode = $createTextNode(lineText);
paragraph.append(textNode);
}
// If not the last line, add a line break
if (index < lines.length - 1) {
const lineBreak = $createTextNode('\n');
paragraph.append(lineBreak);
}
});
// Insert paragraph
selection.insertNodes([paragraph]);
}
});
// Prevent default paste behavior
clipboardEvent.preventDefault();
return true;
}
// If no line breaks, use default behavior
return false;
},
4,
);
return () => {
removeListener();
};
}, [editor]);
return null;
}
export { PasteHandlerPlugin };

View File

@ -0,0 +1,43 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
export default {
code: 'editor-code',
heading: {
h1: 'editor-heading-h1',
h2: 'editor-heading-h2',
h3: 'editor-heading-h3',
h4: 'editor-heading-h4',
h5: 'editor-heading-h5',
},
image: 'editor-image',
link: 'editor-link',
list: {
listitem: 'editor-listitem',
nested: {
listitem: 'editor-nested-listitem',
},
ol: 'editor-list-ol',
ul: 'editor-list-ul',
},
ltr: 'ltr',
paragraph: 'editor-paragraph',
placeholder: 'editor-placeholder',
quote: 'editor-quote',
rtl: 'rtl',
text: {
bold: 'editor-text-bold',
code: 'editor-text-code',
hashtag: 'editor-text-hashtag',
italic: 'editor-text-italic',
overflowed: 'editor-text-overflowed',
strikethrough: 'editor-text-strikethrough',
underline: 'editor-text-underline',
underlineStrikethrough: 'editor-text-underlineStrikethrough',
},
};

View File

@ -0,0 +1,91 @@
import { BeginId } from '@/pages/flow/constant';
import { DecoratorNode, LexicalNode, NodeKey } from 'lexical';
import { ReactNode } from 'react';
const prefix = BeginId + '@';
export class VariableNode extends DecoratorNode<ReactNode> {
__value: string;
__label: string;
key?: NodeKey;
__parentLabel?: string | ReactNode;
__icon?: ReactNode;
static getType(): string {
return 'variable';
}
static clone(node: VariableNode): VariableNode {
return new VariableNode(
node.__value,
node.__label,
node.__key,
node.__parentLabel,
node.__icon,
);
}
constructor(
value: string,
label: string,
key?: NodeKey,
parent?: string | ReactNode,
icon?: ReactNode,
) {
super(key);
this.__value = value;
this.__label = label;
this.__parentLabel = parent;
this.__icon = icon;
}
createDOM(): HTMLElement {
const dom = document.createElement('span');
dom.className = 'mr-1';
return dom;
}
updateDOM(): false {
return false;
}
decorate(): ReactNode {
let content: ReactNode = (
<div className="text-blue-600">{this.__label}</div>
);
if (this.__parentLabel) {
content = (
<div className="flex items-center gap-1 text-text-primary ">
<div>{this.__icon}</div>
<div>{this.__parentLabel}</div>
<div className="text-text-disabled mr-1">/</div>
{content}
</div>
);
}
return (
<div className="bg-gray-200 dark:bg-gray-400 text-sm inline-flex items-center rounded-md px-2 py-1">
{content}
</div>
);
}
getTextContent(): string {
return `{${this.__value}}`;
}
}
export function $createVariableNode(
value: string,
label: string,
parentLabel: string | ReactNode,
icon?: ReactNode,
): VariableNode {
return new VariableNode(value, label, undefined, parentLabel, icon);
}
export function $isVariableNode(
node: LexicalNode | null | undefined,
): node is VariableNode {
return node instanceof VariableNode;
}

View File

@ -0,0 +1,35 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { EditorState, LexicalEditor } from 'lexical';
import { useEffect } from 'react';
import { ProgrammaticTag } from './constant';
interface IProps {
onChange: (
editorState: EditorState,
editor?: LexicalEditor,
tags?: Set<string>,
) => void;
}
export function VariableOnChangePlugin({ onChange }: IProps) {
// Access the editor through the LexicalComposerContext
const [editor] = useLexicalComposerContext();
// Wrap our listener in useEffect to handle the teardown and avoid stale references.
useEffect(() => {
// most listeners return a teardown function that can be called to clean them up.
return editor.registerUpdateListener(
({ editorState, tags, dirtyElements }) => {
// Check if there is a "programmatic" tag
const isProgrammaticUpdate = tags.has(ProgrammaticTag);
// The onchange event is only triggered when the data is manually updated
// Otherwise, the content will be displayed incorrectly.
if (dirtyElements.size > 0 && !isProgrammaticUpdate) {
onChange(editorState);
}
},
);
}, [editor, onChange]);
return null;
}

View File

@ -0,0 +1,297 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import {
LexicalTypeaheadMenuPlugin,
MenuOption,
useBasicTypeaheadTriggerMatch,
} from '@lexical/react/LexicalTypeaheadMenuPlugin';
import {
$createParagraphNode,
$createTextNode,
$getRoot,
$getSelection,
$isRangeSelection,
TextNode,
} from 'lexical';
import React, {
ReactElement,
ReactNode,
useCallback,
useEffect,
useRef,
} from 'react';
import * as ReactDOM from 'react-dom';
import { $createVariableNode } from './variable-node';
import { useBuildQueryVariableOptions } from '@/pages/agent/hooks/use-get-begin-query';
import { ProgrammaticTag } from './constant';
import './index.css';
class VariableInnerOption extends MenuOption {
label: string;
value: string;
parentLabel: string | JSX.Element;
icon?: ReactNode;
constructor(
label: string,
value: string,
parentLabel: string | JSX.Element,
icon?: ReactNode,
) {
super(value);
this.label = label;
this.value = value;
this.parentLabel = parentLabel;
this.icon = icon;
}
}
class VariableOption extends MenuOption {
label: ReactElement | string;
title: string;
options: VariableInnerOption[];
constructor(
label: ReactElement | string,
title: string,
options: VariableInnerOption[],
) {
super(title);
this.label = label;
this.title = title;
this.options = options;
}
}
function VariablePickerMenuItem({
index,
option,
selectOptionAndCleanUp,
}: {
index: number;
option: VariableOption;
selectOptionAndCleanUp: (
option: VariableOption | VariableInnerOption,
) => void;
}) {
return (
<li
key={option.key}
tabIndex={-1}
ref={option.setRefElement}
role="option"
id={'typeahead-item-' + index}
>
<div>
<span className="text text-slate-500">{option.title}</span>
<ul className="pl-2 py-1">
{option.options.map((x) => (
<li
key={x.value}
onClick={() => selectOptionAndCleanUp(x)}
className="hover:bg-slate-300 p-1"
>
{x.label}
</li>
))}
</ul>
</div>
</li>
);
}
export default function VariablePickerMenuPlugin({
value,
}: {
value?: string;
}): JSX.Element {
const [editor] = useLexicalComposerContext();
const isFirstRender = useRef(true);
const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('/', {
minLength: 0,
});
const [queryString, setQueryString] = React.useState<string | null>('');
const options = useBuildQueryVariableOptions();
const buildNextOptions = useCallback(() => {
let filteredOptions = options;
if (queryString) {
const lowerQuery = queryString.toLowerCase();
filteredOptions = options
.map((x) => ({
...x,
options: x.options.filter(
(y) =>
y.label.toLowerCase().includes(lowerQuery) ||
y.value.toLowerCase().includes(lowerQuery),
),
}))
.filter((x) => x.options.length > 0);
}
const nextOptions: VariableOption[] = filteredOptions.map(
(x) =>
new VariableOption(
x.label,
x.title,
x.options.map((y) => {
return new VariableInnerOption(y.label, y.value, x.label, y.icon);
}),
),
);
return nextOptions;
}, [options, queryString]);
const findItemByValue = useCallback(
(value: string) => {
const children = options.reduce<
Array<{
label: string;
value: string;
parentLabel?: string | ReactNode;
icon?: ReactNode;
}>
>((pre, cur) => {
return pre.concat(cur.options);
}, []);
return children.find((x) => x.value === value);
},
[options],
);
const onSelectOption = useCallback(
(
selectedOption: VariableOption | VariableInnerOption,
nodeToRemove: TextNode | null,
closeMenu: () => void,
) => {
editor.update(() => {
const selection = $getSelection();
if (!$isRangeSelection(selection) || selectedOption === null) {
return;
}
if (nodeToRemove) {
nodeToRemove.remove();
}
const variableNode = $createVariableNode(
(selectedOption as VariableInnerOption).value,
selectedOption.label as string,
selectedOption.parentLabel as string | ReactNode,
selectedOption.icon as ReactNode,
);
selection.insertNodes([variableNode]);
closeMenu();
});
},
[editor],
);
const parseTextToVariableNodes = useCallback(
(text: string) => {
const paragraph = $createParagraphNode();
// Regular expression to match content within {}
const regex = /{([^}]*)}/g;
let match;
let lastIndex = 0;
while ((match = regex.exec(text)) !== null) {
const { 1: content, index, 0: template } = match;
// Add the previous text part (if any)
if (index > lastIndex) {
const textNode = $createTextNode(text.slice(lastIndex, index));
paragraph.append(textNode);
}
// Add variable node or text node
const nodeItem = findItemByValue(content);
if (nodeItem) {
paragraph.append(
$createVariableNode(
content,
nodeItem.label,
nodeItem.parentLabel,
nodeItem.icon,
),
);
} else {
paragraph.append($createTextNode(template));
}
// Update index
lastIndex = regex.lastIndex;
}
// Add the last part of text (if any)
if (lastIndex < text.length) {
const textNode = $createTextNode(text.slice(lastIndex));
paragraph.append(textNode);
}
$getRoot().clear().append(paragraph);
if ($isRangeSelection($getSelection())) {
$getRoot().selectEnd();
}
},
[findItemByValue],
);
useEffect(() => {
if (editor && value && isFirstRender.current) {
isFirstRender.current = false;
editor.update(
() => {
parseTextToVariableNodes(value);
},
{ tag: ProgrammaticTag },
);
}
}, [parseTextToVariableNodes, editor, value]);
return (
<LexicalTypeaheadMenuPlugin<VariableOption | VariableInnerOption>
onQueryChange={setQueryString}
onSelectOption={onSelectOption}
triggerFn={checkForTriggerMatch}
options={buildNextOptions()}
menuRenderFn={(anchorElementRef, { selectOptionAndCleanUp }) => {
const nextOptions = buildNextOptions();
return anchorElementRef.current && nextOptions.length
? ReactDOM.createPortal(
<div className="typeahead-popover w-[200px] p-2">
<ul className="overflow-y-auto !scrollbar-thin overflow-x-hidden">
{nextOptions.map((option, i: number) => (
<VariablePickerMenuItem
index={i}
key={option.key}
option={option}
selectOptionAndCleanUp={selectOptionAndCleanUp}
/>
))}
</ul>
</div>,
anchorElementRef.current,
)
: null;
}}
/>
);
}

View File

@ -0,0 +1,66 @@
import { SelectWithSearch } from '@/components/originui/select-with-search';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { toLower } from 'lodash';
import { ReactNode, useMemo } from 'react';
import { useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { VariableType } from '../../constant';
import { useBuildQueryVariableOptions } from '../../hooks/use-get-begin-query';
type QueryVariableProps = {
name?: string;
type?: VariableType;
label?: ReactNode;
};
export function QueryVariable({
name = 'query',
type,
label,
}: QueryVariableProps) {
const { t } = useTranslation();
const form = useFormContext();
const nextOptions = useBuildQueryVariableOptions();
const finalOptions = useMemo(() => {
return type
? nextOptions.map((x) => {
return {
...x,
options: x.options.filter((y) => toLower(y.type).includes(type)),
};
})
: nextOptions;
}, [nextOptions, type]);
return (
<FormField
control={form.control}
name={name}
render={({ field }) => (
<FormItem>
{label || (
<FormLabel tooltip={t('flow.queryTip')}>
{t('flow.query')}
</FormLabel>
)}
<FormControl>
<SelectWithSearch
options={finalOptions}
{...field}
allowClear
></SelectWithSearch>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}