Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -101,6 +101,7 @@ const Property = ({
handleFromAiClick,
handleFromAiToggle,
handleInputChange,
handleInputClear,
handleInputTypeSwitchButtonClick,
handleJsonSchemaBuilderChange,
handleMentionInputValueChange,
Expand Down Expand Up @@ -763,6 +764,19 @@ const Property = ({
required={required}
showInputTypeSwitchButton={showInputTypeSwitchButton}
title={type}
trailingAction={
// Chrome's <input type="time"> has no native clear button.
controlType === 'TIME' && inputValue && !hidden ? (
<button
aria-label="Clear time"
className="flex items-center px-2 text-muted-foreground hover:text-foreground"
onClick={handleInputClear}
type="button"
>
<XIcon className="size-4" />
</button>
) : undefined
}
Comment thread
ivicac marked this conversation as resolved.
type={hidden ? 'hidden' : getInputHTMLType(controlType)}
value={inputValue}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(
<PropertyInput
aria-label="Time"
label="Time"
name="time"
trailingAction={
<button aria-label="Clear time" onClick={handleClear} type="button">
X
</button>
}
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();

Expand Down Expand Up @@ -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<HTMLInputElement>(null);

const handleClear = () => {
setValue('');

requestAnimationFrame(() => inputRef.current?.focus());
};

return (
<PropertyInput
aria-label="Time"
label="Time"
name="time"
ref={inputRef}
trailingAction={
<button aria-label="Clear time" onClick={handleClear} type="button">
X
</button>
}
type="time"
value={value}
/>
);
};

const {container} = render(<Harness />);

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(''));
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import {describe, expect, it, vi} from 'vitest';

/**
* Exercises the TIME-clearing flow introduced for issue #4768.
*
* Why: Chrome's <input type="time"> 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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ type UsePropertyReturnType = {
handleFromAiClick: ((fromAi: boolean) => void) | undefined;
handleFromAiToggle: (fromAi: boolean, fieldOnChange: (value: string) => void) => void;
handleInputChange: (event: ChangeEvent<HTMLInputElement> | ChangeEvent<HTMLTextAreaElement>) => void;
handleInputClear: () => void;
handleInputTypeSwitchButtonClick: () => void;
handleJsonSchemaBuilderChange: (value?: SchemaRecordType) => void;
handleMentionInputValueChange: (value: string | number) => void;
Expand Down Expand Up @@ -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 ||
Expand Down Expand Up @@ -1770,6 +1786,7 @@ export const useProperty = ({
handleFromAiClick: hideFromAi ? undefined : handleFromAiClick,
handleFromAiToggle,
handleInputChange,
handleInputClear,
handleInputTypeSwitchButtonClick,
handleJsonSchemaBuilderChange,
handleMentionInputValueChange,
Expand Down
Loading