### What problem does this PR solve? Feat: Add InnerBlurInput component to avoid frequent updates of zustand causing the input box to lose focus #3221 ### Type of change - [x] New Feature (non-breaking change which adds functionality)tags/v0.19.1
| @@ -1,4 +1,5 @@ | |||
| import { Form, InputNumber } from 'antd'; | |||
| import { useMemo } from 'react'; | |||
| import { useFormContext } from 'react-hook-form'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| import { | |||
| @@ -8,7 +9,7 @@ import { | |||
| FormLabel, | |||
| FormMessage, | |||
| } from './ui/form'; | |||
| import { Input } from './ui/input'; | |||
| import { BlurInput, Input } from './ui/input'; | |||
| const MessageHistoryWindowSizeItem = ({ | |||
| initialValue, | |||
| @@ -31,10 +32,20 @@ const MessageHistoryWindowSizeItem = ({ | |||
| export default MessageHistoryWindowSizeItem; | |||
| export function MessageHistoryWindowSizeFormField() { | |||
| type MessageHistoryWindowSizeFormFieldProps = { | |||
| useBlurInput?: boolean; | |||
| }; | |||
| export function MessageHistoryWindowSizeFormField({ | |||
| useBlurInput = false, | |||
| }: MessageHistoryWindowSizeFormFieldProps) { | |||
| const form = useFormContext(); | |||
| const { t } = useTranslation(); | |||
| const NextInput = useMemo(() => { | |||
| return useBlurInput ? BlurInput : Input; | |||
| }, [useBlurInput]); | |||
| return ( | |||
| <FormField | |||
| control={form.control} | |||
| @@ -45,7 +56,7 @@ export function MessageHistoryWindowSizeFormField() { | |||
| {t('flow.messageHistoryWindowSize')} | |||
| </FormLabel> | |||
| <FormControl> | |||
| <Input {...field} type={'number'}></Input> | |||
| <NextInput {...field} type={'number'}></NextInput> | |||
| </FormControl> | |||
| <FormMessage /> | |||
| </FormItem> | |||
| @@ -1,7 +1,14 @@ | |||
| 'use client'; | |||
| import { CheckIcon, ChevronDownIcon } from 'lucide-react'; | |||
| import { Fragment, useCallback, useEffect, useId, useState } from 'react'; | |||
| import { | |||
| Fragment, | |||
| forwardRef, | |||
| useCallback, | |||
| useEffect, | |||
| useId, | |||
| useState, | |||
| } from 'react'; | |||
| import { Button } from '@/components/ui/button'; | |||
| import { | |||
| @@ -72,11 +79,10 @@ export type SelectWithSearchFlagProps = { | |||
| onChange?(value: string): void; | |||
| }; | |||
| export function SelectWithSearch({ | |||
| value: val = '', | |||
| onChange, | |||
| options = countries, | |||
| }: SelectWithSearchFlagProps) { | |||
| export const SelectWithSearch = forwardRef< | |||
| React.ElementRef<typeof Button>, | |||
| SelectWithSearchFlagProps | |||
| >(({ value: val = '', onChange, options = countries }, ref) => { | |||
| const id = useId(); | |||
| const [open, setOpen] = useState<boolean>(false); | |||
| const [value, setValue] = useState<string>(''); | |||
| @@ -102,6 +108,7 @@ export function SelectWithSearch({ | |||
| variant="outline" | |||
| role="combobox" | |||
| aria-expanded={open} | |||
| ref={ref} | |||
| className="bg-background hover:bg-background border-input w-full justify-between px-3 font-normal outline-offset-0 outline-none focus-visible:outline-[3px]" | |||
| > | |||
| {value ? ( | |||
| @@ -160,4 +167,4 @@ export function SelectWithSearch({ | |||
| </PopoverContent> | |||
| </Popover> | |||
| ); | |||
| } | |||
| }); | |||
| @@ -67,4 +67,42 @@ const SearchInput = (props: InputProps) => { | |||
| ); | |||
| }; | |||
| type Value = string | readonly string[] | number | undefined; | |||
| export const InnerBlurInput = React.forwardRef< | |||
| HTMLInputElement, | |||
| InputProps & { value: Value; onChange(value: Value): void } | |||
| >(({ value, onChange, ...props }, ref) => { | |||
| const [val, setVal] = React.useState<Value>(); | |||
| const handleChange: React.ChangeEventHandler<HTMLInputElement> = | |||
| React.useCallback((e) => { | |||
| setVal(e.target.value); | |||
| }, []); | |||
| const handleBlur: React.FocusEventHandler<HTMLInputElement> = | |||
| React.useCallback( | |||
| (e) => { | |||
| onChange?.(e.target.value); | |||
| }, | |||
| [onChange], | |||
| ); | |||
| React.useEffect(() => { | |||
| setVal(value); | |||
| }, [value]); | |||
| return ( | |||
| <Input | |||
| {...props} | |||
| value={val} | |||
| onBlur={handleBlur} | |||
| onChange={handleChange} | |||
| ref={ref} | |||
| ></Input> | |||
| ); | |||
| }); | |||
| export const BlurInput = React.memo(InnerBlurInput); | |||
| export { ExpandedInput, Input, SearchInput }; | |||
| @@ -20,3 +20,42 @@ const Textarea = React.forwardRef< | |||
| Textarea.displayName = 'Textarea'; | |||
| export { Textarea }; | |||
| type Value = string | readonly string[] | number | undefined; | |||
| export const BlurTextarea = React.forwardRef< | |||
| HTMLTextAreaElement, | |||
| React.ComponentProps<'textarea'> & { | |||
| value: Value; | |||
| onChange(value: Value): void; | |||
| } | |||
| >(({ value, onChange, ...props }, ref) => { | |||
| const [val, setVal] = React.useState<Value>(); | |||
| const handleChange: React.ChangeEventHandler<HTMLTextAreaElement> = | |||
| React.useCallback((e) => { | |||
| setVal(e.target.value); | |||
| }, []); | |||
| const handleBlur: React.FocusEventHandler<HTMLTextAreaElement> = | |||
| React.useCallback( | |||
| (e) => { | |||
| onChange?.(e.target.value); | |||
| }, | |||
| [onChange], | |||
| ); | |||
| React.useEffect(() => { | |||
| setVal(value); | |||
| }, [value]); | |||
| return ( | |||
| <Textarea | |||
| {...props} | |||
| value={val} | |||
| onBlur={handleBlur} | |||
| onChange={handleChange} | |||
| ref={ref} | |||
| ></Textarea> | |||
| ); | |||
| }); | |||
| @@ -11,6 +11,7 @@ import { RAGFlowNodeType } from '@/interfaces/database/flow'; | |||
| import { cn } from '@/lib/utils'; | |||
| import { zodResolver } from '@hookform/resolvers/zod'; | |||
| import { get, isPlainObject, lowerFirst } from 'lodash'; | |||
| import omit from 'lodash/omit'; | |||
| import { Play, X } from 'lucide-react'; | |||
| import { useEffect, useRef } from 'react'; | |||
| import { useForm } from 'react-hook-form'; | |||
| @@ -54,7 +55,7 @@ const FormSheet = ({ | |||
| const OperatorForm = currentFormMap.component ?? EmptyContent; | |||
| const form = useForm({ | |||
| defaultValues: currentFormMap.defaultValues, | |||
| values: currentFormMap.defaultValues, | |||
| resolver: zodResolver(currentFormMap.schema), | |||
| }); | |||
| @@ -89,10 +90,16 @@ const FormSheet = ({ | |||
| if (isPlainObject(formData)) { | |||
| // form.setFieldsValue({ ...formData, items }); | |||
| console.info('xxx'); | |||
| form.reset({ ...formData, items }); | |||
| const nextValues = { | |||
| ...omit(formData, 'category_description'), | |||
| items, | |||
| }; | |||
| // Object.entries(nextValues).forEach(([key, value]) => { | |||
| // form.setValue(key, value, { shouldDirty: false }); | |||
| // }); | |||
| form.reset(nextValues); | |||
| } | |||
| } | |||
| if (operatorName === Operator.Message) { | |||
| } else if (operatorName === Operator.Message) { | |||
| form.reset({ | |||
| ...formData, | |||
| content: convertToObjectArray(formData.content), | |||
| @@ -124,15 +124,26 @@ export function useFormConfigMap() { | |||
| presencePenaltyEnabled: true, | |||
| frequencyPenaltyEnabled: true, | |||
| maxTokensEnabled: true, | |||
| items: [], | |||
| }, | |||
| schema: z.object({ | |||
| parameter: z.string().optional(), | |||
| ...LlmSettingSchema, | |||
| message_history_window_size: z.number(), | |||
| message_history_window_size: z.coerce.number(), | |||
| items: z.array( | |||
| z.object({ | |||
| name: z.string().min(1, t('flow.nameMessage')).trim(), | |||
| }), | |||
| z | |||
| .object({ | |||
| name: z.string().min(1, t('flow.nameMessage')).trim(), | |||
| description: z.string().optional(), | |||
| // examples: z | |||
| // .array( | |||
| // z.object({ | |||
| // value: z.string(), | |||
| // }), | |||
| // ) | |||
| // .optional(), | |||
| }) | |||
| .optional(), | |||
| ), | |||
| }), | |||
| }, | |||
| @@ -180,6 +191,12 @@ export function useFormConfigMap() { | |||
| arguments: z.array( | |||
| z.object({ name: z.string(), component_id: z.string() }), | |||
| ), | |||
| return: z.union([ | |||
| z | |||
| .array(z.object({ name: z.string(), component_id: z.string() })) | |||
| .optional(), | |||
| z.object({ name: z.string(), component_id: z.string() }), | |||
| ]), | |||
| }), | |||
| }, | |||
| [Operator.WaitingDialogue]: { | |||
| @@ -12,8 +12,7 @@ import { | |||
| FormMessage, | |||
| } from '@/components/ui/form'; | |||
| import { Input } from '@/components/ui/input'; | |||
| import { RAGFlowSelect } from '@/components/ui/select'; | |||
| import { Textarea } from '@/components/ui/textarea'; | |||
| import { BlurTextarea } from '@/components/ui/textarea'; | |||
| import { useTranslate } from '@/hooks/common-hooks'; | |||
| import { PlusOutlined } from '@ant-design/icons'; | |||
| import { useUpdateNodeInternals } from '@xyflow/react'; | |||
| @@ -23,6 +22,7 @@ import { ChevronsUpDown, X } from 'lucide-react'; | |||
| import { | |||
| ChangeEventHandler, | |||
| FocusEventHandler, | |||
| memo, | |||
| useCallback, | |||
| useEffect, | |||
| useState, | |||
| @@ -104,7 +104,7 @@ const NameInput = ({ | |||
| ); | |||
| }; | |||
| const FormSet = ({ nodeId, index }: IProps & { index: number }) => { | |||
| const InnerFormSet = ({ nodeId, index }: IProps & { index: number }) => { | |||
| const form = useFormContext(); | |||
| const { t } = useTranslate('flow'); | |||
| const buildCategorizeToOptions = useBuildFormSelectOptions( | |||
| @@ -152,13 +152,13 @@ const FormSet = ({ nodeId, index }: IProps & { index: number }) => { | |||
| <FormItem> | |||
| <FormLabel>{t('description')}</FormLabel> | |||
| <FormControl> | |||
| <Textarea {...field} rows={3} /> | |||
| <BlurTextarea {...field} rows={3} /> | |||
| </FormControl> | |||
| <FormMessage /> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| <FormField | |||
| {/* <FormField | |||
| control={form.control} | |||
| name={buildFieldName('examples')} | |||
| render={({ field }) => ( | |||
| @@ -170,8 +170,8 @@ const FormSet = ({ nodeId, index }: IProps & { index: number }) => { | |||
| <FormMessage /> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| <FormField | |||
| /> */} | |||
| {/* <FormField | |||
| control={form.control} | |||
| name={buildFieldName('to')} | |||
| render={({ field }) => ( | |||
| @@ -202,11 +202,13 @@ const FormSet = ({ nodeId, index }: IProps & { index: number }) => { | |||
| <FormMessage /> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| /> */} | |||
| </section> | |||
| ); | |||
| }; | |||
| const FormSet = memo(InnerFormSet); | |||
| const DynamicCategorize = ({ nodeId }: IProps) => { | |||
| const updateNodeInternals = useUpdateNodeInternals(); | |||
| const form = useFormContext(); | |||
| @@ -219,6 +221,8 @@ const DynamicCategorize = ({ nodeId }: IProps) => { | |||
| const handleAdd = () => { | |||
| append({ | |||
| name: humanId(), | |||
| description: '', | |||
| // examples: [], | |||
| }); | |||
| if (nodeId) updateNodeInternals(nodeId); | |||
| }; | |||
| @@ -226,7 +230,7 @@ const DynamicCategorize = ({ nodeId }: IProps) => { | |||
| return ( | |||
| <div className="flex flex-col gap-4 "> | |||
| {fields.map((field, index) => ( | |||
| <Collapsible key={field.id}> | |||
| <Collapsible key={field.id} defaultOpen> | |||
| <div className="flex items-center justify-between space-x-4"> | |||
| <h4 className="font-bold"> | |||
| {form.getValues(`items.${index}.name`)} | |||
| @@ -262,4 +266,4 @@ const DynamicCategorize = ({ nodeId }: IProps) => { | |||
| ); | |||
| }; | |||
| export default DynamicCategorize; | |||
| export default memo(DynamicCategorize); | |||
| @@ -41,7 +41,9 @@ const CategorizeForm = ({ form, node }: INextOperatorForm) => { | |||
| /> | |||
| <LargeModelFormField></LargeModelFormField> | |||
| <MessageHistoryWindowSizeFormField></MessageHistoryWindowSizeFormField> | |||
| <MessageHistoryWindowSizeFormField | |||
| useBlurInput | |||
| ></MessageHistoryWindowSizeFormField> | |||
| <DynamicCategorize nodeId={node?.id}></DynamicCategorize> | |||
| </form> | |||
| </Form> | |||
| @@ -8,7 +8,7 @@ import { | |||
| FormItem, | |||
| FormMessage, | |||
| } from '@/components/ui/form'; | |||
| import { Input } from '@/components/ui/input'; | |||
| import { BlurInput } from '@/components/ui/input'; | |||
| import { RAGFlowSelect } from '@/components/ui/select'; | |||
| import { Separator } from '@/components/ui/separator'; | |||
| import { RAGFlowNodeType } from '@/interfaces/database/flow'; | |||
| @@ -58,10 +58,10 @@ export function DynamicVariableForm({ node, name = 'arguments' }: IProps) { | |||
| render={({ field }) => ( | |||
| <FormItem className="w-2/5"> | |||
| <FormControl> | |||
| <Input | |||
| <BlurInput | |||
| {...field} | |||
| placeholder={t('common.pleaseInput')} | |||
| ></Input> | |||
| ></BlurInput> | |||
| </FormControl> | |||
| <FormMessage /> | |||
| </FormItem> | |||
| @@ -37,18 +37,49 @@ export const useHandleFormValuesChange = ( | |||
| [updateNodeForm, id], | |||
| ); | |||
| const value = useWatch({ control: form?.control }); | |||
| let values = useWatch({ control: form?.control }); | |||
| console.log('🚀 ~ x:', value); | |||
| // console.log('🚀 ~ x:', values); | |||
| useEffect(() => { | |||
| // Manually triggered form updates are synchronized to the canvas | |||
| if (id && form?.formState.isDirty) { | |||
| console.log('🚀 ~ useEffect ~ value:', value, operatorName); | |||
| values = form?.getValues(); | |||
| let nextValues: any = values; | |||
| // run(id, nextValues); | |||
| updateNodeForm(id, value); | |||
| const categoryDescriptionRegex = /items\.\d+\.name/g; | |||
| if (operatorName === Operator.Categorize) { | |||
| console.log('🚀 ~ useEffect ~ values:', values); | |||
| const categoryDescription = Array.isArray(values.items) | |||
| ? buildCategorizeObjectFromList(values.items) | |||
| : {}; | |||
| if (categoryDescription) { | |||
| nextValues = { | |||
| ...omit(values, 'items'), | |||
| category_description: categoryDescription, | |||
| }; | |||
| } | |||
| } else if (operatorName === Operator.Message) { | |||
| nextValues = { | |||
| ...values, | |||
| content: convertToStringArray(values.content), | |||
| }; | |||
| } | |||
| updateNodeForm(id, nextValues); | |||
| } | |||
| }, [form?.formState.isDirty, id, operatorName, updateNodeForm, value]); | |||
| }, [form?.formState.isDirty, id, operatorName, updateNodeForm, values]); | |||
| // useEffect(() => { | |||
| // form?.subscribe({ | |||
| // formState: { values: true }, | |||
| // callback: ({ values }) => { | |||
| // // console.info('subscribe', values); | |||
| // }, | |||
| // }); | |||
| // }, [form]); | |||
| return { handleValuesChange }; | |||
| @@ -0,0 +1,56 @@ | |||
| import { zodResolver } from '@hookform/resolvers/zod'; | |||
| import { Form, useForm } from 'react-hook-form'; | |||
| import { z } from 'zod'; | |||
| import { Button } from '@/components/ui/button'; | |||
| import { useCallback, useState } from 'react'; | |||
| import DynamicCategorize from './agent/form/categorize-form/dynamic-categorize'; | |||
| const formSchema = z.object({ | |||
| items: z | |||
| .array( | |||
| z | |||
| .object({ | |||
| name: z.string().min(1, 'xxx').trim(), | |||
| description: z.string().optional(), | |||
| // examples: z | |||
| // .array( | |||
| // z.object({ | |||
| // value: z.string(), | |||
| // }), | |||
| // ) | |||
| // .optional(), | |||
| }) | |||
| .optional(), | |||
| ) | |||
| .optional(), | |||
| }); | |||
| export function Demo() { | |||
| const [flag, setFlag] = useState(false); | |||
| const form = useForm<z.infer<typeof formSchema>>({ | |||
| resolver: zodResolver(formSchema), | |||
| defaultValues: { | |||
| items: [], | |||
| }, | |||
| }); | |||
| const handleReset = useCallback(() => { | |||
| form?.reset(); | |||
| }, [form]); | |||
| const handleSwitch = useCallback(() => { | |||
| setFlag(true); | |||
| }, []); | |||
| return ( | |||
| <div> | |||
| <Form {...form}> | |||
| <DynamicCategorize></DynamicCategorize> | |||
| </Form> | |||
| <Button onClick={handleReset}>reset</Button> | |||
| <Button onClick={handleSwitch}>switch</Button> | |||
| </div> | |||
| ); | |||
| } | |||