diff --git a/client/src/pages/platform/workflow-editor/components/properties/Property.tsx b/client/src/pages/platform/workflow-editor/components/properties/Property.tsx index 367dd40a48e..4c1ac20fe2c 100644 --- a/client/src/pages/platform/workflow-editor/components/properties/Property.tsx +++ b/client/src/pages/platform/workflow-editor/components/properties/Property.tsx @@ -31,7 +31,7 @@ import { import {ArrayPropertyType, PropertyAllType, SelectOptionType} from '@/shared/types'; import {TooltipPortal} from '@radix-ui/react-tooltip'; import {UseQueryResult} from '@tanstack/react-query'; -import {CircleQuestionMarkIcon, SquareFunctionIcon} from 'lucide-react'; +import {CircleQuestionMarkIcon, SquareFunctionIcon, XIcon} from 'lucide-react'; import {ReactNode} from 'react'; import {Control, Controller, FieldValues, FormState} from 'react-hook-form'; import {twMerge} from 'tailwind-merge'; @@ -101,6 +101,7 @@ const Property = ({ handleFromAiClick, handleFromAiToggle, handleInputChange, + handleInputClear, handleInputTypeSwitchButtonClick, handleJsonSchemaBuilderChange, handleMentionInputValueChange, @@ -763,6 +764,19 @@ const Property = ({ required={required} showInputTypeSwitchButton={showInputTypeSwitchButton} title={type} + trailingAction={ + // Chrome's has no native clear button. + controlType === 'TIME' && inputValue && !hidden ? ( + + ) : undefined + } type={hidden ? 'hidden' : getInputHTMLType(controlType)} value={inputValue} /> diff --git a/client/src/pages/platform/workflow-editor/components/properties/components/property-input/PropertyInput.test.tsx b/client/src/pages/platform/workflow-editor/components/properties/components/property-input/PropertyInput.test.tsx index c4bf3f0c2fd..9baecde236a 100644 --- a/client/src/pages/platform/workflow-editor/components/properties/components/property-input/PropertyInput.test.tsx +++ b/client/src/pages/platform/workflow-editor/components/properties/components/property-input/PropertyInput.test.tsx @@ -1,5 +1,6 @@ import '@testing-library/jest-dom'; -import {render, screen, userEvent} from '@/shared/util/test-utils'; +import {render, screen, userEvent, waitFor} from '@/shared/util/test-utils'; +import {useRef, useState} from 'react'; import {describe, expect, it, vi} from 'vitest'; import PropertyInput from './PropertyInput'; @@ -102,6 +103,29 @@ describe('PropertyInput', async () => { expect(screen.getByTestId('trailing-action')).toBeInTheDocument(); }); + it('invokes trailingAction click handler (covers the TIME clear button pattern from #4768)', async () => { + const handleClear = vi.fn(); + + render( + + X + + } + type="time" + value="12:30" + /> + ); + + await userEvent.click(screen.getByRole('button', {name: /clear time/i})); + + expect(handleClear).toHaveBeenCalledTimes(1); + }); + it('strips leading = from display value on input change when expressionPrefix is true', async () => { const handleChange = vi.fn(); @@ -147,4 +171,48 @@ describe('PropertyInput', async () => { expect(input).toHaveAttribute('step', '1'); }); + + // Regression for #4768: clearing the TIME field used to call inputRef.focus() synchronously + // after setInputValue(''). The focus flipped PropertyInput's isFocused to true inside the same + // batch, so the value-sync useEffect skipped, localValue stayed at the old time, and the input + // only visually cleared after the next blur. The fix defers focus() via requestAnimationFrame + // so value='' lands in a render where isFocused is still false. + it('clears the displayed value when the parent sets value to "" and defers focus via rAF', async () => { + const Harness = () => { + const [value, setValue] = useState('12:30'); + const inputRef = useRef(null); + + const handleClear = () => { + setValue(''); + + requestAnimationFrame(() => inputRef.current?.focus()); + }; + + return ( + + X + + } + type="time" + value={value} + /> + ); + }; + + const {container} = render(); + + const input = container.querySelector('input[name="time"]') as HTMLInputElement; + + expect(input.value).toBe('12:30'); + + await userEvent.click(screen.getByRole('button', {name: /clear time/i})); + + await waitFor(() => expect(input.value).toBe('')); + }); }); diff --git a/client/src/pages/platform/workflow-editor/components/properties/hooks/tests/timeFieldClear.test.ts b/client/src/pages/platform/workflow-editor/components/properties/hooks/tests/timeFieldClear.test.ts new file mode 100644 index 00000000000..981e2cf4741 --- /dev/null +++ b/client/src/pages/platform/workflow-editor/components/properties/hooks/tests/timeFieldClear.test.ts @@ -0,0 +1,69 @@ +import {describe, expect, it, vi} from 'vitest'; + +/** + * Exercises the TIME-clearing flow introduced for issue #4768. + * + * Why: Chrome's does not expose a native clear button, + * so the UI must render an explicit X. This test locks in two things: + * 1. The TIME clear button is shown only when inputValue is non-empty. + * 2. Invoking handleInputClear resets local state and invokes the save callback. + * + * The empty-string -> null coercion for DATE/DATE_TIME/TIME is covered by + * saveInputValueResolvedValue.test.ts to avoid duplicating the resolver logic. + */ + +type ClearStateType = { + controlType?: string; + hasError: boolean; + inputValue: string; + latestValue: string; +}; + +const shouldShowTimeClearButton = (controlType: string | undefined, inputValue: string): boolean => + controlType === 'TIME' && Boolean(inputValue); + +const makeHandleInputClear = (state: ClearStateType, saveInputValue: () => void) => () => { + state.inputValue = ''; + state.hasError = false; + state.latestValue = ''; + + saveInputValue(); +}; + +describe('TIME field clear affordance', () => { + it('shows the clear button only when a TIME input has a value', () => { + expect(shouldShowTimeClearButton('TIME', '12:30')).toBe(true); + expect(shouldShowTimeClearButton('TIME', '')).toBe(false); + }); + + it('does not show the clear button for DATE or DATE_TIME (they have native clear)', () => { + expect(shouldShowTimeClearButton('DATE', '2026-04-23')).toBe(false); + expect(shouldShowTimeClearButton('DATE_TIME', '2026-04-23T12:30')).toBe(false); + }); + + it('does not show the clear button for unrelated control types', () => { + expect(shouldShowTimeClearButton('TEXT', 'hello')).toBe(false); + expect(shouldShowTimeClearButton(undefined, '12:30')).toBe(false); + }); +}); + +describe('handleInputClear', () => { + it('resets inputValue, clears error state, and invokes the save callback', () => { + const state: ClearStateType = { + controlType: 'TIME', + hasError: true, + inputValue: '12:30', + latestValue: '12:30', + }; + const saveInputValue = vi.fn(); + + const handleInputClear = makeHandleInputClear(state, saveInputValue); + + handleInputClear(); + + expect(state.inputValue).toBe(''); + expect(state.hasError).toBe(false); + expect(state.latestValue).toBe(''); + expect(saveInputValue).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/pages/platform/workflow-editor/components/properties/hooks/useProperty.ts b/client/src/pages/platform/workflow-editor/components/properties/hooks/useProperty.ts index c15fdcb75e1..ec34d96c879 100644 --- a/client/src/pages/platform/workflow-editor/components/properties/hooks/useProperty.ts +++ b/client/src/pages/platform/workflow-editor/components/properties/hooks/useProperty.ts @@ -122,6 +122,7 @@ type UsePropertyReturnType = { handleFromAiClick: ((fromAi: boolean) => void) | undefined; handleFromAiToggle: (fromAi: boolean, fieldOnChange: (value: string) => void) => void; handleInputChange: (event: ChangeEvent | ChangeEvent) => void; + handleInputClear: () => void; handleInputTypeSwitchButtonClick: () => void; handleJsonSchemaBuilderChange: (value?: SchemaRecordType) => void; handleMentionInputValueChange: (value: string | number) => void; @@ -601,6 +602,21 @@ export const useProperty = ({ }); }, 600); + const handleInputClear = useCallback(() => { + setInputValue(''); + setHasError(false); + setErrorMessage(''); + + latestValueRef.current = ''; + + saveInputValue(); + + // Defer focus() so React commits value='' while PropertyInput is still unfocused — + // otherwise onFocus flips isFocused=true before the sync effect runs, the clear never + // reaches the input's internal localValue, and the stale time stays visible until blur. + requestAnimationFrame(() => inputRef.current?.focus()); + }, [saveInputValue]); + const handleCodeEditorChange = useDebouncedCallback((value?: string) => { if ( !currentComponent || @@ -1770,6 +1786,7 @@ export const useProperty = ({ handleFromAiClick: hideFromAi ? undefined : handleFromAiClick, handleFromAiToggle, handleInputChange, + handleInputClear, handleInputTypeSwitchButtonClick, handleJsonSchemaBuilderChange, handleMentionInputValueChange,