Skip to content

Commit 15e7656

Browse files
committed
ui(web): update node widgets
1 parent 05d753e commit 15e7656

6 files changed

Lines changed: 214 additions & 26 deletions

File tree

apps/web/src/components/workflow/input-edit-popover.tsx

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Position } from "@xyflow/react";
22
import File from "lucide-react/icons/file";
33
import Upload from "lucide-react/icons/upload";
44
import XCircleIcon from "lucide-react/icons/x-circle";
5-
import { useEffect, useState } from "react";
5+
import React, { useEffect, useState } from "react";
66

77
import { Button } from "@/components/ui/button";
88
import { Input } from "@/components/ui/input";
@@ -57,6 +57,9 @@ export function InputEditPopover({
5757
const [isUploading, setIsUploading] = useState(false);
5858
const [uploadError, setUploadError] = useState<string | null>(null);
5959

60+
// Debounce ref for text inputs
61+
const debounceTimeoutRef = React.useRef<number | null>(null);
62+
6063
// Helper function to create object URL for previews and downloads
6164
const getObjectUrl = (objectRef: any): string | null => {
6265
if (!isObjectReference(objectRef)) return null;
@@ -87,12 +90,32 @@ export function InputEditPopover({
8790
setUploadError(null);
8891
}, [input]);
8992

93+
// Cleanup debounce timeout on unmount
94+
useEffect(() => {
95+
return () => {
96+
if (debounceTimeoutRef.current !== null) {
97+
clearTimeout(debounceTimeoutRef.current);
98+
}
99+
};
100+
}, []);
101+
90102
const handleInputChange = (value: string) => {
91103
if (!input || readonly || !updateNodeData) return;
92104

105+
// Update local state immediately for responsive UI
93106
setInputValue(value);
94-
const typedValue = convertValueByType(value, input.type);
95-
updateNodeInput(nodeId, input.id, typedValue, nodeInputs, updateNodeData);
107+
108+
// Clear any pending debounce
109+
if (debounceTimeoutRef.current !== null) {
110+
clearTimeout(debounceTimeoutRef.current);
111+
}
112+
113+
// Debounce the actual node data update
114+
debounceTimeoutRef.current = window.setTimeout(() => {
115+
const typedValue = convertValueByType(value, input.type);
116+
updateNodeInput(nodeId, input.id, typedValue, nodeInputs, updateNodeData);
117+
debounceTimeoutRef.current = null;
118+
}, 300);
96119
};
97120

98121
const handleClearValue = () => {

apps/web/src/components/workflow/widgets/input-text.tsx

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import { useEffect, useRef, useState } from "react";
2+
13
import { Input } from "@/components/ui/input";
24
import { cn } from "@/utils/utils";
35

46
import type { BaseWidgetProps } from "./widget";
5-
import { createWidget, getInputValue } from "./widget";
7+
import { createWidget, getInputValue, useDebouncedChange } from "./widget";
68

79
interface InputTextWidgetProps extends BaseWidgetProps {
810
value: string;
@@ -17,13 +19,42 @@ function InputTextWidget({
1719
compact = false,
1820
readonly = false,
1921
}: InputTextWidgetProps) {
22+
// Use local state for immediate UI updates
23+
const [localValue, setLocalValue] = useState(value || "");
24+
const isUserTypingRef = useRef(false);
25+
26+
// Debounce the actual node update
27+
const { debouncedOnChange } = useDebouncedChange(onChange, 300);
28+
29+
// Sync local state when prop value changes (e.g., from external updates)
30+
// but only if user is not currently typing
31+
useEffect(() => {
32+
if (!isUserTypingRef.current) {
33+
setLocalValue(value || "");
34+
}
35+
}, [value]);
36+
37+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
38+
if (!readonly) {
39+
const newValue = e.target.value;
40+
isUserTypingRef.current = true;
41+
setLocalValue(newValue);
42+
debouncedOnChange(newValue);
43+
44+
// Reset typing flag after debounce completes
45+
setTimeout(() => {
46+
isUserTypingRef.current = false;
47+
}, 350);
48+
}
49+
};
50+
2051
return (
21-
<div className={cn("p-2", className)}>
52+
<div className={cn(compact ? "p-1" : "p-2", className)}>
2253
<Input
23-
value={value || ""}
24-
onChange={(e) => !readonly && onChange(e.target.value)}
54+
value={localValue}
55+
onChange={handleChange}
2556
placeholder={placeholder || "Enter text..."}
26-
className={cn(compact && "text-sm h-8")}
57+
className={cn(compact && "text-[0.6rem] leading-tight h-6 px-1.5")}
2758
disabled={readonly}
2859
/>
2960
</div>

apps/web/src/components/workflow/widgets/number-input.tsx

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import { useEffect, useRef, useState } from "react";
2+
13
import { Input } from "@/components/ui/input";
24
import { cn } from "@/utils/utils";
35

46
import type { BaseWidgetProps } from "./widget";
5-
import { createWidget, getInputValue } from "./widget";
7+
import { createWidget, getInputValue, useDebouncedChange } from "./widget";
68

79
interface NumberInputWidgetProps extends BaseWidgetProps {
810
value: number;
@@ -23,26 +25,52 @@ function NumberInputWidget({
2325
compact = false,
2426
readonly = false,
2527
}: NumberInputWidgetProps) {
28+
// Use local state for immediate UI updates
29+
const [localValue, setLocalValue] = useState(value?.toString() || "");
30+
const isUserTypingRef = useRef(false);
31+
32+
// Debounce the actual node update
33+
const { debouncedOnChange } = useDebouncedChange(onChange, 300);
34+
35+
// Sync local state when prop value changes (e.g., from external updates)
36+
// but only if user is not currently typing
37+
useEffect(() => {
38+
if (!isUserTypingRef.current) {
39+
setLocalValue(value?.toString() || "");
40+
}
41+
}, [value]);
42+
2643
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
2744
if (readonly) return;
2845

29-
const val = parseFloat(e.target.value);
46+
const newValue = e.target.value;
47+
isUserTypingRef.current = true;
48+
setLocalValue(newValue);
49+
50+
const val = parseFloat(newValue);
3051
if (!isNaN(val)) {
31-
onChange(val);
52+
debouncedOnChange(val);
3253
}
54+
55+
// Reset typing flag after debounce completes
56+
setTimeout(() => {
57+
isUserTypingRef.current = false;
58+
}, 350);
3359
};
3460

3561
return (
36-
<div className={cn("p-2", className)}>
62+
<div className={cn(compact ? "p-1" : "p-2", className)}>
3763
<Input
3864
type="number"
39-
value={value || ""}
65+
value={localValue}
4066
onChange={handleChange}
4167
min={min}
4268
max={max}
4369
step={step}
4470
placeholder={placeholder || "Enter number..."}
45-
className={cn(compact && "h-8 text-sm")}
71+
className={cn(
72+
compact && "h-6 text-[0.6rem] leading-tight px-1.5 py-0.5"
73+
)}
4674
disabled={readonly}
4775
/>
4876
</div>

apps/web/src/components/workflow/widgets/slider.tsx

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import { useEffect, useRef, useState } from "react";
2+
13
import { Slider } from "@/components/ui/slider";
24
import { cn } from "@/utils/utils";
35

46
import type { BaseWidgetProps } from "./widget";
5-
import { createWidget, getInputValue } from "./widget";
7+
import { createWidget, getInputValue, useDebouncedChange } from "./widget";
68

79
interface SliderWidgetProps extends BaseWidgetProps {
810
value: number;
@@ -21,26 +23,54 @@ function SliderWidget({
2123
compact = false,
2224
readonly = false,
2325
}: SliderWidgetProps) {
26+
// Use local state for immediate UI updates
27+
const [localValue, setLocalValue] = useState(value);
28+
const isUserDraggingRef = useRef(false);
29+
30+
// Debounce the actual node update
31+
const { debouncedOnChange } = useDebouncedChange(onChange, 100);
32+
33+
// Sync local state when prop value changes (e.g., from external updates)
34+
// but only if user is not currently dragging
35+
useEffect(() => {
36+
if (!isUserDraggingRef.current) {
37+
setLocalValue(value);
38+
}
39+
}, [value]);
40+
2441
const handleValueChange = (values: number[]) => {
2542
if (values.length > 0 && !readonly) {
26-
onChange(values[0]);
43+
const newValue = values[0];
44+
isUserDraggingRef.current = true;
45+
setLocalValue(newValue);
46+
debouncedOnChange(newValue);
47+
48+
// Reset dragging flag after debounce completes
49+
setTimeout(() => {
50+
isUserDraggingRef.current = false;
51+
}, 150);
2752
}
2853
};
2954

3055
return (
31-
<div className={cn("space-y-2 p-2", className)}>
56+
<div className={cn(compact ? "space-y-1 p-1" : "space-y-2 p-2", className)}>
3257
<Slider
3358
min={min}
3459
max={max}
3560
step={step}
36-
value={[value]}
61+
value={[localValue]}
3762
onValueChange={handleValueChange}
38-
className={cn("py-4", compact && "py-2")}
63+
className={cn(compact ? "py-2" : "py-4")}
3964
disabled={readonly}
4065
/>
41-
<div className="flex justify-between text-xs text-neutral-500">
66+
<div
67+
className={cn(
68+
"flex justify-between text-neutral-500",
69+
compact ? "text-[0.6rem] leading-tight" : "text-xs"
70+
)}
71+
>
4272
<span>{min}</span>
43-
<span>Value: {value}</span>
73+
<span>Value: {localValue}</span>
4474
<span>{max}</span>
4575
</div>
4676
</div>

apps/web/src/components/workflow/widgets/text-area.tsx

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import { useEffect, useRef, useState } from "react";
2+
13
import { Textarea } from "@/components/ui/textarea";
24
import { cn } from "@/utils/utils";
35

46
import type { BaseWidgetProps } from "./widget";
5-
import { createWidget, getInputValue } from "./widget";
7+
import { createWidget, getInputValue, useDebouncedChange } from "./widget";
68

79
interface TextAreaWidgetProps extends BaseWidgetProps {
810
value: string;
@@ -19,19 +21,44 @@ function TextAreaWidget({
1921
compact = false,
2022
readonly = false,
2123
}: TextAreaWidgetProps) {
24+
// Use local state for immediate UI updates
25+
const [localValue, setLocalValue] = useState(value || "");
26+
const isUserTypingRef = useRef(false);
27+
28+
// Debounce the actual node update
29+
const { debouncedOnChange } = useDebouncedChange(onChange, 300);
30+
31+
// Sync local state when prop value changes (e.g., from external updates)
32+
// but only if user is not currently typing
33+
useEffect(() => {
34+
if (!isUserTypingRef.current) {
35+
setLocalValue(value || "");
36+
}
37+
}, [value]);
38+
2239
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
2340
if (!readonly) {
24-
onChange(e.target.value);
41+
const newValue = e.target.value;
42+
isUserTypingRef.current = true;
43+
setLocalValue(newValue);
44+
debouncedOnChange(newValue);
45+
46+
// Reset typing flag after debounce completes
47+
setTimeout(() => {
48+
isUserTypingRef.current = false;
49+
}, 350);
2550
}
2651
};
2752

2853
return (
29-
<div className={cn("p-2", className)}>
54+
<div className={cn(compact ? "p-1" : "p-2", className)}>
3055
<Textarea
31-
value={value || ""}
56+
value={localValue}
3257
onChange={handleChange}
3358
placeholder={placeholder || "Enter text..."}
34-
className={cn(compact && "min-h-[100px]")}
59+
className={cn(
60+
compact && "min-h-[100px] text-[0.6rem] leading-tight p-1.5"
61+
)}
3562
disabled={readonly}
3663
rows={compact ? undefined : rows}
3764
/>

apps/web/src/components/workflow/widgets/widget.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { useCallback, useEffect, useRef } from "react";
2+
13
import type { WorkflowParameter } from "../workflow-types";
24

35
/**
@@ -45,3 +47,50 @@ export function getInputValue<T = any>(
4547
const input = inputs.find((i) => i.id === id);
4648
return input?.value !== undefined ? (input.value as T) : defaultValue;
4749
}
50+
51+
/**
52+
* Hook to debounce onChange calls to prevent excessive re-renders.
53+
* Updates immediately in the UI but debounces the actual node data update.
54+
*/
55+
export function useDebouncedChange(
56+
onChange: (value: any) => void,
57+
delay = 300
58+
) {
59+
const timeoutRef = useRef<number | null>(null);
60+
const previousValueRef = useRef<any>(undefined);
61+
62+
// Clear timeout on unmount
63+
useEffect(() => {
64+
return () => {
65+
if (timeoutRef.current !== null) {
66+
clearTimeout(timeoutRef.current);
67+
}
68+
};
69+
}, []);
70+
71+
const debouncedOnChange = useCallback(
72+
(value: any) => {
73+
// Clear any pending timeout
74+
if (timeoutRef.current !== null) {
75+
clearTimeout(timeoutRef.current);
76+
}
77+
78+
// Schedule the update
79+
timeoutRef.current = window.setTimeout(() => {
80+
// Only call onChange if the value actually changed
81+
if (value !== previousValueRef.current) {
82+
previousValueRef.current = value;
83+
onChange(value);
84+
}
85+
timeoutRef.current = null;
86+
}, delay);
87+
},
88+
[onChange, delay]
89+
);
90+
91+
// Return both debounced and immediate versions
92+
return {
93+
debouncedOnChange,
94+
immediateOnChange: onChange,
95+
};
96+
}

0 commit comments

Comments
 (0)