diff --git a/web/app/components/app/configuration/dataset-config/params-config/index.spec.tsx b/web/app/components/app/configuration/dataset-config/params-config/index.spec.tsx index 7904159109..9366039414 100644 --- a/web/app/components/app/configuration/dataset-config/params-config/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/params-config/index.spec.tsx @@ -180,7 +180,7 @@ describe('dataset-config/params-config', () => { const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 }) const dialogScope = within(dialog) - const incrementButtons = dialogScope.getAllByRole('button', { name: 'increment' }) + const incrementButtons = dialogScope.getAllByRole('button', { name: /increment/i }) await user.click(incrementButtons[0]) await waitFor(() => { @@ -213,7 +213,7 @@ describe('dataset-config/params-config', () => { const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 }) const dialogScope = within(dialog) - const incrementButtons = dialogScope.getAllByRole('button', { name: 'increment' }) + const incrementButtons = dialogScope.getAllByRole('button', { name: /increment/i }) await user.click(incrementButtons[0]) await waitFor(() => { diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/retrieval-section.spec.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/retrieval-section.spec.tsx index 2140afe1dd..e95414c061 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/retrieval-section.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/retrieval-section.spec.tsx @@ -212,7 +212,7 @@ describe('RetrievalSection', () => { currentDataset={dataset} />, ) - const [topKIncrement] = screen.getAllByLabelText('increment') + const [topKIncrement] = screen.getAllByRole('button', { name: /increment/i }) await userEvent.click(topKIncrement) // Assert @@ -267,7 +267,7 @@ describe('RetrievalSection', () => { docLink={path => path || ''} />, ) - const [topKIncrement] = screen.getAllByLabelText('increment') + const [topKIncrement] = screen.getAllByRole('button', { name: /increment/i }) await userEvent.click(topKIncrement) // Assert diff --git a/web/app/components/base/form/components/field/__tests__/number-input.spec.tsx b/web/app/components/base/form/components/field/__tests__/number-input.spec.tsx index 84c02f3327..eb5b419d78 100644 --- a/web/app/components/base/form/components/field/__tests__/number-input.spec.tsx +++ b/web/app/components/base/form/components/field/__tests__/number-input.spec.tsx @@ -27,7 +27,21 @@ describe('NumberInputField', () => { it('should update value when users click increment', () => { render() - fireEvent.click(screen.getByRole('button', { name: 'increment' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.increment' })) expect(mockField.handleChange).toHaveBeenCalledWith(3) }) + + it('should reset field value when users clear the input', () => { + render() + fireEvent.change(screen.getByRole('textbox'), { target: { value: '' } }) + expect(mockField.handleChange).toHaveBeenCalledWith(0) + }) + + it('should clamp out-of-range edits before updating field state', () => { + render() + + fireEvent.change(screen.getByRole('textbox'), { target: { value: '12' } }) + + expect(mockField.handleChange).toHaveBeenLastCalledWith(10) + }) }) diff --git a/web/app/components/base/form/components/field/number-input.tsx b/web/app/components/base/form/components/field/number-input.tsx index a7844983ae..fc874a0c2b 100644 --- a/web/app/components/base/form/components/field/number-input.tsx +++ b/web/app/components/base/form/components/field/number-input.tsx @@ -1,24 +1,52 @@ -import type { InputNumberProps } from '../../../input-number' +import type { ReactNode } from 'react' +import type { NumberFieldInputProps, NumberFieldRootProps, NumberFieldSize } from '../../../ui/number-field' import type { LabelProps } from '../label' import * as React from 'react' import { cn } from '@/utils/classnames' import { useFieldContext } from '../..' -import { InputNumber } from '../../../input-number' +import { + NumberField, + NumberFieldControls, + NumberFieldDecrement, + NumberFieldGroup, + NumberFieldIncrement, + NumberFieldInput, + NumberFieldUnit, +} from '../../../ui/number-field' import Label from '../label' -type TextFieldProps = { +type NumberInputFieldProps = { label: string labelOptions?: Omit className?: string -} & Omit + inputClassName?: string + unit?: ReactNode + size?: NumberFieldSize +} & Omit & Omit const NumberInputField = ({ label, labelOptions, className, - ...inputProps -}: TextFieldProps) => { + inputClassName, + unit, + size = 'regular', + ...props +}: NumberInputFieldProps) => { const field = useFieldContext() + const { + value: _value, + min, + max, + step, + disabled, + readOnly, + required, + name: _name, + id: _id, + ...inputProps + } = props + const emptyValue = min ?? 0 return (
@@ -27,13 +55,36 @@ const NumberInputField = ({ label={label} {...(labelOptions ?? {})} /> - field.handleChange(value)} - onBlur={field.handleBlur} - {...inputProps} - /> + min={min} + max={max} + step={step} + disabled={disabled} + readOnly={readOnly} + required={required} + onValueChange={value => field.handleChange(value ?? emptyValue)} + > + + + {Boolean(unit) && ( + + {unit} + + )} + + + + + +
) } diff --git a/web/app/components/base/input-number/__tests__/index.spec.tsx b/web/app/components/base/input-number/__tests__/index.spec.tsx deleted file mode 100644 index 6056bbf5c0..0000000000 --- a/web/app/components/base/input-number/__tests__/index.spec.tsx +++ /dev/null @@ -1,369 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { InputNumber } from '../index' - -describe('InputNumber Component', () => { - const defaultProps = { - onChange: vi.fn(), - } - - beforeEach(() => { - vi.clearAllMocks() - }) - - it('renders input with default values', () => { - render() - const input = screen.getByRole('textbox') - expect(input).toBeInTheDocument() - }) - - it('handles increment button click', async () => { - const user = userEvent.setup() - const onChange = vi.fn() - render() - const incrementBtn = screen.getByRole('button', { name: /increment/i }) - - await user.click(incrementBtn) - expect(onChange).toHaveBeenCalledWith(6) - }) - - it('handles decrement button click', async () => { - const user = userEvent.setup() - const onChange = vi.fn() - render() - const decrementBtn = screen.getByRole('button', { name: /decrement/i }) - - await user.click(decrementBtn) - expect(onChange).toHaveBeenCalledWith(4) - }) - - it('respects max value constraint', async () => { - const user = userEvent.setup() - const onChange = vi.fn() - render() - const incrementBtn = screen.getByRole('button', { name: /increment/i }) - - await user.click(incrementBtn) - expect(onChange).not.toHaveBeenCalled() - }) - - it('respects min value constraint', async () => { - const user = userEvent.setup() - const onChange = vi.fn() - render() - const decrementBtn = screen.getByRole('button', { name: /decrement/i }) - - await user.click(decrementBtn) - expect(onChange).not.toHaveBeenCalled() - }) - - it('handles direct input changes', () => { - const onChange = vi.fn() - render() - const input = screen.getByRole('textbox') - - fireEvent.change(input, { target: { value: '42' } }) - expect(onChange).toHaveBeenCalledWith(42) - }) - - it('handles empty input', () => { - const onChange = vi.fn() - render() - const input = screen.getByRole('textbox') - - fireEvent.change(input, { target: { value: '' } }) - expect(onChange).toHaveBeenCalledWith(0) - }) - - it('does not call onChange when input is not parseable', () => { - const onChange = vi.fn() - render() - const input = screen.getByRole('textbox') - - fireEvent.change(input, { target: { value: 'abc' } }) - expect(onChange).not.toHaveBeenCalled() - }) - - it('does not call onChange when direct input exceeds range', () => { - const onChange = vi.fn() - render() - const input = screen.getByRole('textbox') - - fireEvent.change(input, { target: { value: '11' } }) - - expect(onChange).not.toHaveBeenCalled() - }) - - it('uses default value when increment and decrement are clicked without value prop', async () => { - const user = userEvent.setup() - const onChange = vi.fn() - render() - - await user.click(screen.getByRole('button', { name: /increment/i })) - await user.click(screen.getByRole('button', { name: /decrement/i })) - - expect(onChange).toHaveBeenNthCalledWith(1, 7) - expect(onChange).toHaveBeenNthCalledWith(2, 7) - }) - - it('falls back to zero when controls are used without value and defaultValue', async () => { - const user = userEvent.setup() - const onChange = vi.fn() - render() - - await user.click(screen.getByRole('button', { name: /increment/i })) - await user.click(screen.getByRole('button', { name: /decrement/i })) - - expect(onChange).toHaveBeenNthCalledWith(1, 0) - expect(onChange).toHaveBeenNthCalledWith(2, 0) - }) - - it('displays unit when provided', () => { - const onChange = vi.fn() - const unit = 'px' - render() - expect(screen.getByText(unit)).toBeInTheDocument() - }) - - it('disables controls when disabled prop is true', () => { - const onChange = vi.fn() - render() - const input = screen.getByRole('textbox') - const incrementBtn = screen.getByRole('button', { name: /increment/i }) - const decrementBtn = screen.getByRole('button', { name: /decrement/i }) - - expect(input).toBeDisabled() - expect(incrementBtn).toBeDisabled() - expect(decrementBtn).toBeDisabled() - }) - - it('does not change value when disabled controls are clicked', async () => { - const user = userEvent.setup() - const onChange = vi.fn() - const { getByRole } = render() - - const incrementBtn = getByRole('button', { name: /increment/i }) - const decrementBtn = getByRole('button', { name: /decrement/i }) - - expect(incrementBtn).toBeDisabled() - expect(decrementBtn).toBeDisabled() - - await user.click(incrementBtn) - await user.click(decrementBtn) - - expect(onChange).not.toHaveBeenCalled() - }) - - it('keeps increment guard when disabled even if button is force-clickable', () => { - const onChange = vi.fn() - render() - const incrementBtn = screen.getByRole('button', { name: /increment/i }) - - // Remove native disabled to force event dispatch and hit component-level guard. - incrementBtn.removeAttribute('disabled') - fireEvent.click(incrementBtn) - - expect(onChange).not.toHaveBeenCalled() - }) - - it('keeps decrement guard when disabled even if button is force-clickable', () => { - const onChange = vi.fn() - render() - const decrementBtn = screen.getByRole('button', { name: /decrement/i }) - - // Remove native disabled to force event dispatch and hit component-level guard. - decrementBtn.removeAttribute('disabled') - fireEvent.click(decrementBtn) - - expect(onChange).not.toHaveBeenCalled() - }) - - it('applies large-size classes for control buttons', () => { - const onChange = vi.fn() - render() - const incrementBtn = screen.getByRole('button', { name: /increment/i }) - const decrementBtn = screen.getByRole('button', { name: /decrement/i }) - - expect(incrementBtn).toHaveClass('pt-1.5') - expect(decrementBtn).toHaveClass('pb-1.5') - }) - - it('prevents increment beyond max with custom amount', async () => { - const user = userEvent.setup() - const onChange = vi.fn() - render() - const incrementBtn = screen.getByRole('button', { name: /increment/i }) - - await user.click(incrementBtn) - expect(onChange).not.toHaveBeenCalled() - }) - - it('uses fallback step guard when step is any', async () => { - const user = userEvent.setup() - const onChange = vi.fn() - render() - const incrementBtn = screen.getByRole('button', { name: /increment/i }) - - await user.click(incrementBtn) - expect(onChange).not.toHaveBeenCalled() - }) - - it('prevents decrement below min with custom amount', async () => { - const user = userEvent.setup() - const onChange = vi.fn() - render() - const decrementBtn = screen.getByRole('button', { name: /decrement/i }) - - await user.click(decrementBtn) - expect(onChange).not.toHaveBeenCalled() - }) - - it('increments when value with custom amount stays within bounds', async () => { - const user = userEvent.setup() - const onChange = vi.fn() - render() - const incrementBtn = screen.getByRole('button', { name: /increment/i }) - - await user.click(incrementBtn) - expect(onChange).toHaveBeenCalledWith(8) - }) - - it('decrements when value with custom amount stays within bounds', async () => { - const user = userEvent.setup() - const onChange = vi.fn() - render() - const decrementBtn = screen.getByRole('button', { name: /decrement/i }) - - await user.click(decrementBtn) - expect(onChange).toHaveBeenCalledWith(2) - }) - - it('validates input against max constraint', () => { - const onChange = vi.fn() - render() - const input = screen.getByRole('textbox') - - fireEvent.change(input, { target: { value: '15' } }) - expect(onChange).not.toHaveBeenCalled() - }) - - it('validates input against min constraint', () => { - const onChange = vi.fn() - render() - const input = screen.getByRole('textbox') - - fireEvent.change(input, { target: { value: '2' } }) - expect(onChange).not.toHaveBeenCalled() - }) - - it('accepts input within min and max constraints', () => { - const onChange = vi.fn() - render() - const input = screen.getByRole('textbox') - - fireEvent.change(input, { target: { value: '50' } }) - expect(onChange).toHaveBeenCalledWith(50) - }) - - it('handles negative min and max values', async () => { - const user = userEvent.setup() - const onChange = vi.fn() - render() - const decrementBtn = screen.getByRole('button', { name: /decrement/i }) - - await user.click(decrementBtn) - expect(onChange).toHaveBeenCalledWith(-1) - }) - - it('prevents decrement below negative min', async () => { - const user = userEvent.setup() - const onChange = vi.fn() - render() - const decrementBtn = screen.getByRole('button', { name: /decrement/i }) - - await user.click(decrementBtn) - expect(onChange).not.toHaveBeenCalled() - }) - - it('applies wrapClassName to outer div', () => { - const onChange = vi.fn() - const wrapClassName = 'custom-wrap-class' - render() - const wrapper = screen.getByTestId('input-number-wrapper') - expect(wrapper).toHaveClass(wrapClassName) - }) - - it('applies wrapperClassName to outer div for Input compatibility', () => { - const onChange = vi.fn() - const wrapperClassName = 'custom-input-wrapper' - render() - - const input = screen.getByRole('textbox') - const wrapper = screen.getByTestId('input-number-wrapper') - - expect(input).not.toHaveAttribute('wrapperClassName') - expect(wrapper).toHaveClass(wrapperClassName) - }) - - it('applies styleCss to the input element', () => { - const onChange = vi.fn() - render() - - expect(screen.getByRole('textbox')).toHaveStyle({ color: 'rgb(255, 0, 0)' }) - }) - - it('applies controlWrapClassName to control buttons container', () => { - const onChange = vi.fn() - const controlWrapClassName = 'custom-control-wrap' - render() - const controlDiv = screen.getByTestId('input-number-controls') - expect(controlDiv).toHaveClass(controlWrapClassName) - }) - - it('applies controlClassName to individual control buttons', () => { - const onChange = vi.fn() - const controlClassName = 'custom-control' - render() - const incrementBtn = screen.getByRole('button', { name: /increment/i }) - const decrementBtn = screen.getByRole('button', { name: /decrement/i }) - expect(incrementBtn).toHaveClass(controlClassName) - expect(decrementBtn).toHaveClass(controlClassName) - }) - - it('applies regular-size classes for control buttons when size is regular', () => { - const onChange = vi.fn() - render() - const incrementBtn = screen.getByRole('button', { name: /increment/i }) - const decrementBtn = screen.getByRole('button', { name: /decrement/i }) - - expect(incrementBtn).toHaveClass('pt-1') - expect(decrementBtn).toHaveClass('pb-1') - }) - - it('handles zero as a valid input', () => { - const onChange = vi.fn() - render() - const input = screen.getByRole('textbox') - - fireEvent.change(input, { target: { value: '0' } }) - expect(onChange).toHaveBeenCalledWith(0) - }) - - it('prevents exact max boundary increment', async () => { - const user = userEvent.setup() - const onChange = vi.fn() - render() - - await user.click(screen.getByRole('button', { name: /increment/i })) - expect(onChange).not.toHaveBeenCalled() - }) - - it('prevents exact min boundary decrement', async () => { - const user = userEvent.setup() - const onChange = vi.fn() - render() - - await user.click(screen.getByRole('button', { name: /decrement/i })) - expect(onChange).not.toHaveBeenCalled() - }) -}) diff --git a/web/app/components/base/input-number/index.stories.tsx b/web/app/components/base/input-number/index.stories.tsx deleted file mode 100644 index 4b7bebf216..0000000000 --- a/web/app/components/base/input-number/index.stories.tsx +++ /dev/null @@ -1,479 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import { useState } from 'react' -import { InputNumber } from '.' - -const meta = { - title: 'Base/Data Entry/InputNumber', - component: InputNumber, - parameters: { - layout: 'centered', - docs: { - description: { - component: 'Number input component with increment/decrement buttons. Supports min/max constraints, custom step amounts, and units display.', - }, - }, - }, - tags: ['autodocs'], - argTypes: { - value: { - control: 'number', - description: 'Current value', - }, - size: { - control: 'select', - options: ['regular', 'large'], - description: 'Input size', - }, - min: { - control: 'number', - description: 'Minimum value', - }, - max: { - control: 'number', - description: 'Maximum value', - }, - amount: { - control: 'number', - description: 'Step amount for increment/decrement', - }, - unit: { - control: 'text', - description: 'Unit text displayed (e.g., "px", "ms")', - }, - disabled: { - control: 'boolean', - description: 'Disabled state', - }, - defaultValue: { - control: 'number', - description: 'Default value when undefined', - }, - }, - args: { - onChange: (value) => { - console.log('Value changed:', value) - }, - }, -} satisfies Meta - -export default meta -type Story = StoryObj - -// Interactive demo wrapper -const InputNumberDemo = (args: any) => { - const [value, setValue] = useState(args.value ?? 0) - - return ( -
- { - setValue(newValue) - console.log('Value changed:', newValue) - }} - /> -
- Current value: - {' '} - {value} -
-
- ) -} - -// Default state -export const Default: Story = { - render: args => , - args: { - value: 0, - size: 'regular', - }, -} - -// Large size -export const LargeSize: Story = { - render: args => , - args: { - value: 10, - size: 'large', - }, -} - -// With min/max constraints -export const WithMinMax: Story = { - render: args => , - args: { - value: 5, - min: 0, - max: 10, - size: 'regular', - }, -} - -// With custom step amount -export const CustomStepAmount: Story = { - render: args => , - args: { - value: 50, - amount: 5, - min: 0, - max: 100, - size: 'regular', - }, -} - -// With unit -export const WithUnit: Story = { - render: args => , - args: { - value: 100, - unit: 'px', - min: 0, - max: 1000, - amount: 10, - size: 'regular', - }, -} - -// Disabled state -export const Disabled: Story = { - render: args => , - args: { - value: 42, - disabled: true, - size: 'regular', - }, -} - -// Decimal values -export const DecimalValues: Story = { - render: args => , - args: { - value: 2.5, - amount: 0.5, - min: 0, - max: 10, - size: 'regular', - }, -} - -// Negative values allowed -export const NegativeValues: Story = { - render: args => , - args: { - value: 0, - min: -100, - max: 100, - amount: 10, - size: 'regular', - }, -} - -// Size comparison -const SizeComparisonDemo = () => { - const [regularValue, setRegularValue] = useState(10) - const [largeValue, setLargeValue] = useState(20) - - return ( -
-
- - -
-
- - -
-
- ) -} - -export const SizeComparison: Story = { - render: () => , - parameters: { controls: { disable: true } }, -} as unknown as Story - -// Real-world example - Font size picker -const FontSizePickerDemo = () => { - const [fontSize, setFontSize] = useState(16) - - return ( -
-
-
- - -
-
-

- Preview Text -

-
-
-
- ) -} - -export const FontSizePicker: Story = { - render: () => , - parameters: { controls: { disable: true } }, -} as unknown as Story - -// Real-world example - Quantity selector -const QuantitySelectorDemo = () => { - const [quantity, setQuantity] = useState(1) - const pricePerItem = 29.99 - const total = (quantity * pricePerItem).toFixed(2) - - return ( -
-
-
-
-

Product Name

-

- $ - {pricePerItem} - {' '} - each -

-
-
-
- - -
-
-
- Total - - $ - {total} - -
-
-
-
- ) -} - -export const QuantitySelector: Story = { - render: () => , - parameters: { controls: { disable: true } }, -} as unknown as Story - -// Real-world example - Timer settings -const TimerSettingsDemo = () => { - const [hours, setHours] = useState(0) - const [minutes, setMinutes] = useState(15) - const [seconds, setSeconds] = useState(30) - - const totalSeconds = hours * 3600 + minutes * 60 + seconds - - return ( -
-

Timer Configuration

-
-
- - -
-
- - -
-
- - -
-
-
- Total duration: - {' '} - - {totalSeconds} - {' '} - seconds - -
-
-
-
- ) -} - -export const TimerSettings: Story = { - render: () => , - parameters: { controls: { disable: true } }, -} as unknown as Story - -// Real-world example - Animation settings -const AnimationSettingsDemo = () => { - const [duration, setDuration] = useState(300) - const [delay, setDelay] = useState(0) - const [iterations, setIterations] = useState(1) - - return ( -
-

Animation Properties

-
-
- - -
-
- - -
-
- - -
-
-
- animation: - {' '} - {duration} - ms - {' '} - {delay} - ms - {' '} - {iterations} -
-
-
-
- ) -} - -export const AnimationSettings: Story = { - render: () => , - parameters: { controls: { disable: true } }, -} as unknown as Story - -// Real-world example - Temperature control -const TemperatureControlDemo = () => { - const [temperature, setTemperature] = useState(20) - const fahrenheit = ((temperature * 9) / 5 + 32).toFixed(1) - - return ( -
-

Temperature Control

-
-
- - -
-
-
-
Celsius
-
- {temperature} - °C -
-
-
-
Fahrenheit
-
- {fahrenheit} - °F -
-
-
-
-
- ) -} - -export const TemperatureControl: Story = { - render: () => , - parameters: { controls: { disable: true } }, -} as unknown as Story - -// Interactive playground -export const Playground: Story = { - render: args => , - args: { - value: 10, - size: 'regular', - min: 0, - max: 100, - amount: 1, - unit: '', - disabled: false, - defaultValue: 0, - }, -} diff --git a/web/app/components/base/input-number/index.tsx b/web/app/components/base/input-number/index.tsx deleted file mode 100644 index 42aec3f742..0000000000 --- a/web/app/components/base/input-number/index.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import type { NumberFieldRoot as BaseNumberFieldRoot } from '@base-ui/react/number-field' -import type { CSSProperties, FC, InputHTMLAttributes } from 'react' -import { useCallback } from 'react' -import { - NumberField, - NumberFieldControls, - NumberFieldDecrement, - NumberFieldGroup, - NumberFieldIncrement, - NumberFieldInput, - NumberFieldUnit, -} from '@/app/components/base/ui/number-field' -import { cn } from '@/utils/classnames' - -type InputNumberInputProps = Omit< - InputHTMLAttributes, - 'defaultValue' | 'max' | 'min' | 'onChange' | 'size' | 'type' | 'value' -> - -export type InputNumberProps = InputNumberInputProps & { - unit?: string - value?: number - onChange: (value: number) => void - amount?: number - size?: 'regular' | 'large' - max?: number - min?: number - step?: number | 'any' - defaultValue?: number - disabled?: boolean - wrapClassName?: string - wrapperClassName?: string - styleCss?: CSSProperties - controlWrapClassName?: string - controlClassName?: string - type?: 'number' -} - -const STEPPER_REASONS = new Set([ - 'increment-press', - 'decrement-press', -]) - -const isValueWithinBounds = (value: number, min?: number, max?: number) => { - if (typeof min === 'number' && value < min) - return false - - if (typeof max === 'number' && value > max) - return false - - return true -} - -const resolveStep = (amount?: number, step?: InputNumberProps['step']) => ( - amount ?? (step === 'any' || typeof step === 'number' ? step : undefined) ?? 1 -) - -const exceedsStepBounds = ({ - value, - reason, - stepAmount, - min, - max, -}: { - value?: number - reason: BaseNumberFieldRoot.ChangeEventDetails['reason'] - stepAmount: number - min?: number - max?: number -}) => { - if (typeof value !== 'number') - return false - - if (reason === 'increment-press' && typeof max === 'number') - return value + stepAmount > max - - if (reason === 'decrement-press' && typeof min === 'number') - return value - stepAmount < min - - return false -} - -export const InputNumber: FC = (props) => { - const { - unit, - className, - wrapperClassName, - styleCss, - onChange, - amount, - value, - size = 'regular', - max, - min, - defaultValue, - wrapClassName, - controlWrapClassName, - controlClassName, - disabled, - step, - id, - name, - readOnly, - required, - type: _type, - ...rest - } = props - - const resolvedStep = resolveStep(amount, step) - const stepAmount = typeof resolvedStep === 'number' ? resolvedStep : 1 - - const handleValueChange = useCallback(( - nextValue: number | null, - eventDetails: BaseNumberFieldRoot.ChangeEventDetails, - ) => { - if (value === undefined && STEPPER_REASONS.has(eventDetails.reason)) { - onChange(defaultValue ?? 0) - return - } - - if (nextValue === null) { - onChange(0) - return - } - - if (exceedsStepBounds({ - value, - reason: eventDetails.reason, - stepAmount, - min, - max, - })) { - return - } - - if (!isValueWithinBounds(nextValue, min, max)) - return - - onChange(nextValue) - }, [defaultValue, max, min, onChange, stepAmount, value]) - - return ( -
- - - - {unit && ( - - {unit} - - )} - - - - - - - - -
- ) -} diff --git a/web/app/components/base/param-item/__tests__/index.spec.tsx b/web/app/components/base/param-item/__tests__/index.spec.tsx index b18c10216d..96591446c8 100644 --- a/web/app/components/base/param-item/__tests__/index.spec.tsx +++ b/web/app/components/base/param-item/__tests__/index.spec.tsx @@ -112,6 +112,63 @@ describe('ParamItem', () => { expect(defaultProps.onChange).toHaveBeenLastCalledWith('test_param', 0.8) }) + it('should reset the textbox and slider when users clear the input', async () => { + const user = userEvent.setup() + const StatefulParamItem = () => { + const [value, setValue] = useState(defaultProps.value) + + return ( + { + defaultProps.onChange(key, nextValue) + setValue(nextValue) + }} + /> + ) + } + + render() + + const input = screen.getByRole('textbox') + await user.clear(input) + + expect(defaultProps.onChange).toHaveBeenLastCalledWith('test_param', 0) + expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '0') + + await user.tab() + + expect(input).toHaveValue('0') + }) + + it('should clamp out-of-range text edits before updating state', async () => { + const user = userEvent.setup() + const StatefulParamItem = () => { + const [value, setValue] = useState(defaultProps.value) + + return ( + { + defaultProps.onChange(key, nextValue) + setValue(nextValue) + }} + /> + ) + } + + render() + + const input = screen.getByRole('textbox') + await user.clear(input) + await user.type(input, '1.5') + + expect(defaultProps.onChange).toHaveBeenLastCalledWith('test_param', 1) + expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '100') + }) + it('should pass scaled value to slider when max < 5', () => { render() const slider = screen.getByRole('slider') diff --git a/web/app/components/base/param-item/index.tsx b/web/app/components/base/param-item/index.tsx index 1652290fda..63af4bca84 100644 --- a/web/app/components/base/param-item/index.tsx +++ b/web/app/components/base/param-item/index.tsx @@ -3,7 +3,14 @@ import type { FC } from 'react' import Slider from '@/app/components/base/slider' import Switch from '@/app/components/base/switch' import Tooltip from '@/app/components/base/tooltip' -import { InputNumber } from '../input-number' +import { + NumberField, + NumberFieldControls, + NumberFieldDecrement, + NumberFieldGroup, + NumberFieldIncrement, + NumberFieldInput, +} from '../ui/number-field' type Props = { className?: string @@ -36,7 +43,7 @@ const ParamItem: FC = ({ className, id, name, noTooltip, tip, step = 0.1, }} /> )} - {name} + {name} {!noTooltip && ( = ({ className, id, name, noTooltip, tip, step = 0.1,
- { - onChange(id, value) - }} - className="w-[72px]" - /> + onValueChange={nextValue => onChange(id, nextValue ?? min)} + > + + + + + + + +
+ inputProps?: Partial + unitProps?: Partial & { children?: ReactNode } + controlsProps?: Partial + incrementProps?: Partial + decrementProps?: Partial +} + +const renderNumberField = ({ + defaultValue = 8, + groupProps, + inputProps, + unitProps, + controlsProps, + incrementProps, + decrementProps, +}: RenderNumberFieldOptions = {}) => { + const { + children: unitChildren = 'ms', + ...restUnitProps + } = unitProps ?? {} + + return render( + + + + {unitProps && ( + + {unitChildren} + + )} + {(controlsProps || incrementProps || decrementProps) && ( + + + + + )} + + , + ) +} + describe('NumberField wrapper', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Export mapping should stay aligned with the Base UI primitive. describe('Exports', () => { it('should map NumberField to the matching base primitive root', () => { expect(NumberField).toBe(BaseNumberField.Root) }) }) - describe('Variants', () => { - it('should apply regular variant classes and forward className to group and input', () => { - render( - - - - - , - ) + // Group and input wrappers should preserve the design-system variants and DOM defaults. + describe('Group and input', () => { + it('should apply regular group classes by default and merge custom className', () => { + renderNumberField({ + groupProps: { + className: 'custom-group', + }, + }) const group = screen.getByTestId('group') - const input = screen.getByRole('textbox', { name: 'Regular amount' }) expect(group).toHaveClass('radius-md') expect(group).toHaveClass('custom-group') - expect(input).toHaveAttribute('placeholder', 'Regular placeholder') - expect(input).toHaveClass('px-3') - expect(input).toHaveClass('py-[7px]') - expect(input).toHaveClass('custom-input') }) - it('should apply large variant classes to grouped parts when large size is provided', () => { - render( - - - - ms - - - - - - , - ) + it('should apply large group and input classes when large size is provided', () => { + renderNumberField({ + groupProps: { + size: 'large', + }, + inputProps: { + size: 'large', + }, + }) const group = screen.getByTestId('group') - const input = screen.getByRole('textbox', { name: 'Large amount' }) - const unit = screen.getByText('ms') - const increment = screen.getByRole('button', { name: 'Increment amount' }) - const decrement = screen.getByRole('button', { name: 'Decrement amount' }) + const input = screen.getByTestId('input') expect(group).toHaveClass('radius-lg') expect(input).toHaveClass('px-4') expect(input).toHaveClass('py-2') - expect(unit).toHaveClass('flex') - expect(unit).toHaveClass('items-center') - expect(unit).toHaveClass('pr-2.5') - expect(increment).toHaveClass('pt-1.5') - expect(decrement).toHaveClass('pb-1.5') + }) + + it('should set input defaults and forward passthrough props', () => { + renderNumberField({ + inputProps: { + className: 'custom-input', + placeholder: 'Regular placeholder', + required: true, + }, + }) + + const input = screen.getByRole('textbox', { name: 'Amount' }) + + expect(input).toHaveAttribute('autoComplete', 'off') + expect(input).toHaveAttribute('autoCorrect', 'off') + expect(input).toHaveAttribute('placeholder', 'Regular placeholder') + expect(input).toBeRequired() + expect(input).toHaveClass('px-3') + expect(input).toHaveClass('py-[7px]') + expect(input).toHaveClass('system-sm-regular') + expect(input).toHaveClass('custom-input') }) }) - describe('Passthrough props', () => { - it('should forward passthrough props and custom classes to controls and buttons', () => { - render( - - - - - - - - - , - ) + // Unit and controls wrappers should preserve layout tokens and HTML passthrough props. + describe('Unit and controls', () => { + it.each([ + ['regular', 'pr-2'], + ['large', 'pr-2.5'], + ] as const)('should apply the %s unit spacing variant', (size, spacingClass) => { + renderNumberField({ + unitProps: { + size, + className: 'custom-unit', + title: `unit-${size}`, + }, + }) + + const unit = screen.getByTestId('unit') + + expect(unit).toHaveTextContent('ms') + expect(unit).toHaveAttribute('title', `unit-${size}`) + expect(unit).toHaveClass('custom-unit') + expect(unit).toHaveClass(spacingClass) + }) + + it('should forward passthrough props to controls', () => { + renderNumberField({ + controlsProps: { + className: 'custom-controls', + title: 'controls-title', + }, + }) const controls = screen.getByTestId('controls') - const increment = screen.getByRole('button', { name: 'Increment' }) - const decrement = screen.getByRole('button', { name: 'Decrement' }) - expect(controls).toHaveClass('border-l') + expect(controls).toHaveAttribute('title', 'controls-title') expect(controls).toHaveClass('custom-controls') + }) + }) + + // Increment and decrement buttons should preserve accessible naming, icon fallbacks, and spacing variants. + describe('Control buttons', () => { + it('should provide localized aria labels and default icons when labels are not provided', () => { + renderNumberField({ + controlsProps: {}, + }) + + const increment = screen.getByRole('button', { name: 'common.operation.increment' }) + const decrement = screen.getByRole('button', { name: 'common.operation.decrement' }) + + expect(increment.querySelector('.i-ri-arrow-up-s-line')).toBeInTheDocument() + expect(decrement.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument() + }) + + it('should preserve explicit aria labels and custom children', () => { + renderNumberField({ + controlsProps: {}, + incrementProps: { + 'aria-label': 'Increase amount', + 'children': +, + }, + decrementProps: { + 'aria-label': 'Decrease amount', + 'children': -, + }, + }) + + const increment = screen.getByRole('button', { name: 'Increase amount' }) + const decrement = screen.getByRole('button', { name: 'Decrease amount' }) + + expect(increment).toContainElement(screen.getByTestId('custom-increment-icon')) + expect(decrement).toContainElement(screen.getByTestId('custom-decrement-icon')) + expect(increment.querySelector('.i-ri-arrow-up-s-line')).not.toBeInTheDocument() + expect(decrement.querySelector('.i-ri-arrow-down-s-line')).not.toBeInTheDocument() + }) + + it('should keep the fallback aria labels when aria-label is omitted in props', () => { + renderNumberField({ + controlsProps: {}, + incrementProps: { + 'aria-label': undefined, + }, + decrementProps: { + 'aria-label': undefined, + }, + }) + + expect(screen.getByRole('button', { name: 'common.operation.increment' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.decrement' })).toBeInTheDocument() + }) + + it('should rely on aria-labelledby when provided instead of injecting a translated aria-label', () => { + render( + <> + Increment from label + Decrement from label + + + + + + + + + + , + ) + + const increment = screen.getByRole('button', { name: 'Increment from label' }) + const decrement = screen.getByRole('button', { name: 'Decrement from label' }) + + expect(increment).not.toHaveAttribute('aria-label') + expect(decrement).not.toHaveAttribute('aria-label') + }) + + it.each([ + ['regular', 'pt-1', 'pb-1'], + ['large', 'pt-1.5', 'pb-1.5'], + ] as const)('should apply the %s control button compound spacing classes', (size, incrementClass, decrementClass) => { + renderNumberField({ + controlsProps: {}, + incrementProps: { + size, + className: 'custom-increment', + }, + decrementProps: { + size, + className: 'custom-decrement', + title: `decrement-${size}`, + }, + }) + + const increment = screen.getByTestId('increment') + const decrement = screen.getByTestId('decrement') + + expect(increment).toHaveClass(incrementClass) expect(increment).toHaveClass('custom-increment') - expect(increment).toHaveAttribute('data-track-id', 'increment-track') + expect(decrement).toHaveClass(decrementClass) expect(decrement).toHaveClass('custom-decrement') - expect(decrement).toHaveAttribute('data-track-id', 'decrement-track') + expect(decrement).toHaveAttribute('title', `decrement-${size}`) }) }) }) diff --git a/web/app/components/base/ui/number-field/index.stories.tsx b/web/app/components/base/ui/number-field/index.stories.tsx new file mode 100644 index 0000000000..c8a8ed4d07 --- /dev/null +++ b/web/app/components/base/ui/number-field/index.stories.tsx @@ -0,0 +1,285 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import { useId, useState } from 'react' +import { cn } from '@/utils/classnames' +import { + NumberField, + NumberFieldControls, + NumberFieldDecrement, + NumberFieldGroup, + NumberFieldIncrement, + NumberFieldInput, + NumberFieldUnit, +} from '.' + +type DemoFieldProps = { + label: string + helperText: string + placeholder: string + size: 'regular' | 'large' + unit?: string + defaultValue?: number | null + min?: number + max?: number + step?: number + disabled?: boolean + readOnly?: boolean + showCurrentValue?: boolean + widthClassName?: string + formatValue?: (value: number | null) => string +} + +const formatNumericValue = (value: number | null, unit?: string) => { + if (value === null) + return 'Empty' + + if (!unit) + return String(value) + + return `${value} ${unit}` +} + +const FieldLabel = ({ + inputId, + label, + helperText, +}: Pick & { inputId: string }) => ( +
+ +

{helperText}

+
+) + +const DemoField = ({ + label, + helperText, + placeholder, + size, + unit, + defaultValue, + min, + max, + step, + disabled, + readOnly, + showCurrentValue, + widthClassName, + formatValue, +}: DemoFieldProps) => { + const inputId = useId() + const [value, setValue] = useState(defaultValue ?? null) + + return ( +
+ + + + + {unit && {unit}} + + + + + + + {showCurrentValue && ( +

+ Current value: + {' '} + {formatValue ? formatValue(value) : formatNumericValue(value, unit)} +

+ )} +
+ ) +} + +const meta = { + title: 'Base/Form/NumberField', + component: NumberField, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Compound numeric input built on Base UI NumberField. Stories explicitly enumerate the shipped CVA variants, then cover realistic numeric-entry cases such as decimals, empty values, range limits, read-only, and disabled states.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const VariantMatrix: Story = { + render: () => ( +
+ + + + +
+ ), +} + +export const DecimalInputs: Story = { + render: () => ( +
+ value === null ? 'Empty' : value.toFixed(2)} + /> + value === null ? 'Empty' : value.toFixed(1)} + /> + value === null ? 'Empty' : value.toFixed(2)} + /> + value === null ? 'Empty' : `${value.toFixed(1)} s`} + /> +
+ ), +} + +export const BoundariesAndStates: Story = { + render: () => ( +
+ + + + +
+ ), +} diff --git a/web/app/components/base/ui/number-field/index.tsx b/web/app/components/base/ui/number-field/index.tsx index 9d58fc9982..3b0a186586 100644 --- a/web/app/components/base/ui/number-field/index.tsx +++ b/web/app/components/base/ui/number-field/index.tsx @@ -4,9 +4,11 @@ import type { VariantProps } from 'class-variance-authority' import { NumberField as BaseNumberField } from '@base-ui/react/number-field' import { cva } from 'class-variance-authority' import * as React from 'react' +import { useTranslation } from 'react-i18next' import { cn } from '@/utils/classnames' export const NumberField = BaseNumberField.Root +export type NumberFieldRootProps = React.ComponentPropsWithoutRef export const numberFieldGroupVariants = cva( [ @@ -15,7 +17,7 @@ export const numberFieldGroupVariants = cva( 'data-[focused]:border-components-input-border-active data-[focused]:bg-components-input-bg-active data-[focused]:shadow-xs', 'data-[disabled]:cursor-not-allowed data-[disabled]:border-transparent data-[disabled]:bg-components-input-bg-disabled data-[disabled]:text-components-input-text-filled-disabled', 'data-[disabled]:hover:border-transparent data-[disabled]:hover:bg-components-input-bg-disabled', - 'data-[readonly]:shadow-none motion-reduce:transition-none', + 'data-[readonly]:shadow-none data-[readonly]:hover:border-transparent data-[readonly]:hover:bg-components-input-bg-normal motion-reduce:transition-none', ], { variants: { @@ -29,8 +31,9 @@ export const numberFieldGroupVariants = cva( }, }, ) +export type NumberFieldSize = NonNullable['size']> -type NumberFieldGroupProps = React.ComponentPropsWithoutRef & VariantProps +export type NumberFieldGroupProps = React.ComponentPropsWithoutRef & VariantProps export function NumberFieldGroup({ className, @@ -65,7 +68,7 @@ export const numberFieldInputVariants = cva( }, ) -type NumberFieldInputProps = Omit, 'size'> & VariantProps +export type NumberFieldInputProps = Omit, 'size'> & VariantProps export function NumberFieldInput({ className, @@ -95,7 +98,7 @@ export const numberFieldUnitVariants = cva( }, ) -type NumberFieldUnitProps = React.HTMLAttributes & VariantProps +export type NumberFieldUnitProps = React.HTMLAttributes & VariantProps export function NumberFieldUnit({ className, @@ -114,7 +117,7 @@ export const numberFieldControlsVariants = cva( 'flex shrink-0 flex-col items-stretch border-l border-divider-subtle bg-transparent text-text-tertiary', ) -type NumberFieldControlsProps = React.HTMLAttributes +export type NumberFieldControlsProps = React.HTMLAttributes export function NumberFieldControls({ className, @@ -130,11 +133,12 @@ export function NumberFieldControls({ export const numberFieldControlButtonVariants = cva( [ - 'flex items-center justify-center px-1.5 text-text-tertiary outline-none transition-colors', + 'flex touch-manipulation select-none items-center justify-center px-1.5 text-text-tertiary outline-none transition-colors', 'hover:bg-components-input-bg-hover focus-visible:bg-components-input-bg-hover', - 'disabled:cursor-not-allowed disabled:hover:bg-transparent', - 'group-data-[disabled]/number-field:cursor-not-allowed group-data-[disabled]/number-field:hover:bg-transparent', - 'group-data-[readonly]/number-field:cursor-default group-data-[readonly]/number-field:hover:bg-transparent', + 'focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-components-input-border-active', + 'disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:focus-visible:bg-transparent disabled:focus-visible:ring-0', + 'group-data-[disabled]/number-field:cursor-not-allowed group-data-[disabled]/number-field:hover:bg-transparent group-data-[disabled]/number-field:focus-visible:bg-transparent group-data-[disabled]/number-field:focus-visible:ring-0', + 'group-data-[readonly]/number-field:cursor-default group-data-[readonly]/number-field:hover:bg-transparent group-data-[readonly]/number-field:focus-visible:bg-transparent group-data-[readonly]/number-field:focus-visible:ring-0', 'motion-reduce:transition-none', ], { @@ -182,30 +186,42 @@ type NumberFieldButtonVariantProps = Omit< 'direction' > -type NumberFieldButtonProps = React.ComponentPropsWithoutRef & NumberFieldButtonVariantProps +export type NumberFieldButtonProps = React.ComponentPropsWithoutRef & NumberFieldButtonVariantProps export function NumberFieldIncrement({ className, + children, size = 'regular', ...props }: NumberFieldButtonProps) { + const { t } = useTranslation() + return ( + aria-label={props['aria-label'] ?? (props['aria-labelledby'] ? undefined : t('operation.increment', { ns: 'common' }))} + className={cn(numberFieldControlButtonVariants({ size, direction: 'increment' }), className)} + > + {children ?? ) } export function NumberFieldDecrement({ className, + children, size = 'regular', ...props }: NumberFieldButtonProps) { + const { t } = useTranslation() + return ( + aria-label={props['aria-label'] ?? (props['aria-labelledby'] ? undefined : t('operation.decrement', { ns: 'common' }))} + className={cn(numberFieldControlButtonVariants({ size, direction: 'decrement' }), className)} + > + {children ?? ) } diff --git a/web/app/components/datasets/create/step-two/components/__tests__/inputs.spec.tsx b/web/app/components/datasets/create/step-two/components/__tests__/inputs.spec.tsx index aeeef838f4..28c640cdbe 100644 --- a/web/app/components/datasets/create/step-two/components/__tests__/inputs.spec.tsx +++ b/web/app/components/datasets/create/step-two/components/__tests__/inputs.spec.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { DelimiterInput, MaxLengthInput, OverlapInput } from '../inputs' @@ -61,6 +61,21 @@ describe('MaxLengthInput', () => { const input = screen.getByRole('textbox') expect(input).toBeInTheDocument() }) + + it('should reset to the minimum when users clear the value', () => { + const onChange = vi.fn() + render() + fireEvent.change(screen.getByRole('textbox'), { target: { value: '' } }) + expect(onChange).toHaveBeenCalledWith(1) + }) + + it('should clamp out-of-range text edits before updating state', () => { + const onChange = vi.fn() + render() + + fireEvent.change(screen.getByRole('textbox'), { target: { value: '1200' } }) + expect(onChange).toHaveBeenLastCalledWith(1000) + }) }) describe('OverlapInput', () => { @@ -89,4 +104,19 @@ describe('OverlapInput', () => { const input = screen.getByRole('textbox') expect(input).toBeInTheDocument() }) + + it('should reset to the minimum when users clear the value', () => { + const onChange = vi.fn() + render() + fireEvent.change(screen.getByRole('textbox'), { target: { value: '' } }) + expect(onChange).toHaveBeenCalledWith(1) + }) + + it('should clamp out-of-range text edits before updating state', () => { + const onChange = vi.fn() + render() + + fireEvent.change(screen.getByRole('textbox'), { target: { value: '150' } }) + expect(onChange).toHaveBeenLastCalledWith(100) + }) }) diff --git a/web/app/components/datasets/create/step-two/components/inputs.tsx b/web/app/components/datasets/create/step-two/components/inputs.tsx index 349796858e..9d40f511f9 100644 --- a/web/app/components/datasets/create/step-two/components/inputs.tsx +++ b/web/app/components/datasets/create/step-two/components/inputs.tsx @@ -1,10 +1,18 @@ import type { FC, PropsWithChildren, ReactNode } from 'react' import type { InputProps } from '@/app/components/base/input' -import type { InputNumberProps } from '@/app/components/base/input-number' +import type { NumberFieldInputProps, NumberFieldRootProps, NumberFieldSize } from '@/app/components/base/ui/number-field' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' -import { InputNumber } from '@/app/components/base/input-number' import Tooltip from '@/app/components/base/tooltip' +import { + NumberField, + NumberFieldControls, + NumberFieldDecrement, + NumberFieldGroup, + NumberFieldIncrement, + NumberFieldInput, + NumberFieldUnit, +} from '@/app/components/base/ui/number-field' import { env } from '@/env' const TextLabel: FC = (props) => { @@ -25,7 +33,7 @@ export const DelimiterInput: FC = (props) => return ( - {t('stepTwo.separator', { ns: 'datasetCreation' })} + {t('stepTwo.separator', { ns: 'datasetCreation' })} @@ -46,19 +54,69 @@ export const DelimiterInput: FC = (props) => ) } -export const MaxLengthInput: FC = (props) => { +type CompoundNumberInputProps = Omit & Omit & { + unit?: ReactNode + size?: NumberFieldSize + onChange: (value: number) => void +} + +function CompoundNumberInput({ + onChange, + unit, + size = 'large', + className, + ...props +}: CompoundNumberInputProps) { + const { value, defaultValue, min, max, step, disabled, readOnly, required, id, name, onBlur, ...inputProps } = props + const emptyValue = defaultValue ?? min ?? 0 + + return ( + onChange(value ?? emptyValue)} + > + + + {Boolean(unit) && ( + + {unit} + + )} + + + + + + + ) +} + +export const MaxLengthInput: FC = (props) => { const maxValue = env.NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH const { t } = useTranslation() return ( +
{t('stepTwo.maxLength', { ns: 'datasetCreation' })}
)} > - = (props) => { ) } -export const OverlapInput: FC = (props) => { +export const OverlapInput: FC = (props) => { const { t } = useTranslation() return ( = (props) => {
)} > - { expect(handleChange).toHaveBeenCalled() }) + it('should reset cleared number input to 0', () => { + const handleChange = vi.fn() + render( + , + ) + + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: '' } }) + + expect(handleChange).toHaveBeenCalledWith(0) + }) + it('should display current value for number type', () => { const handleChange = vi.fn() render( diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/input-combined.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/input-combined.tsx index aec74bcfef..87db148202 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/input-combined.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/input-combined.tsx @@ -2,7 +2,14 @@ import type { FC } from 'react' import * as React from 'react' import Input from '@/app/components/base/input' -import { InputNumber } from '@/app/components/base/input-number' +import { + NumberField, + NumberFieldControls, + NumberFieldDecrement, + NumberFieldGroup, + NumberFieldIncrement, + NumberFieldInput, +} from '@/app/components/base/ui/number-field' import { cn } from '@/utils/classnames' import Datepicker from '../base/date-picker' import { DataType } from '../types' @@ -36,15 +43,23 @@ const InputCombined: FC = ({ if (type === DataType.number) { return (
- + onValueChange={value => onChange(value ?? 0)} + > + + + + + + + +
) } diff --git a/web/app/components/datasets/settings/index-method/__tests__/index.spec.tsx b/web/app/components/datasets/settings/index-method/__tests__/index.spec.tsx index d0c75e4199..ae2c17d880 100644 --- a/web/app/components/datasets/settings/index-method/__tests__/index.spec.tsx +++ b/web/app/components/datasets/settings/index-method/__tests__/index.spec.tsx @@ -190,7 +190,7 @@ describe('IndexMethod', () => { expect(screen.getByText(/stepTwo\.qualified/)).toBeInTheDocument() }) - it('should handle keywordNumber of 0', () => { + it('should handle minimum keywordNumber', () => { render() const input = screen.getByRole('textbox') expect(input).toHaveValue('0') diff --git a/web/app/components/datasets/settings/index-method/__tests__/keyword-number.spec.tsx b/web/app/components/datasets/settings/index-method/__tests__/keyword-number.spec.tsx index eb853014b3..e7ba8af6f1 100644 --- a/web/app/components/datasets/settings/index-method/__tests__/keyword-number.spec.tsx +++ b/web/app/components/datasets/settings/index-method/__tests__/keyword-number.spec.tsx @@ -24,9 +24,8 @@ describe('KeyWordNumber', () => { it('should render tooltip with question icon', () => { render() - // RiQuestionLine renders as an svg const container = screen.getByText(/form\.numberOfKeywords/).closest('div')?.parentElement - const questionIcon = container?.querySelector('svg') + const questionIcon = container?.querySelector('.i-ri-question-line') expect(questionIcon).toBeInTheDocument() }) @@ -88,15 +87,22 @@ describe('KeyWordNumber', () => { expect(handleChange).toHaveBeenCalled() }) - it('should not call onKeywordNumberChange with undefined value', () => { + it('should reset to 0 when users clear the input', () => { const handleChange = vi.fn() render() const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: '' } }) - // When value is empty/undefined, handleInputChange should not call onKeywordNumberChange - expect(handleChange).not.toHaveBeenCalled() + expect(handleChange).toHaveBeenCalledWith(0) + }) + + it('should clamp out-of-range edits before updating state', () => { + const handleChange = vi.fn() + render() + + fireEvent.change(screen.getByRole('textbox'), { target: { value: '60' } }) + expect(handleChange).toHaveBeenLastCalledWith(50) }) }) diff --git a/web/app/components/datasets/settings/index-method/keyword-number.tsx b/web/app/components/datasets/settings/index-method/keyword-number.tsx index 66b29a39c5..95810d7d49 100644 --- a/web/app/components/datasets/settings/index-method/keyword-number.tsx +++ b/web/app/components/datasets/settings/index-method/keyword-number.tsx @@ -1,10 +1,19 @@ -import { RiQuestionLine } from '@remixicon/react' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import { InputNumber } from '@/app/components/base/input-number' import Slider from '@/app/components/base/slider' import Tooltip from '@/app/components/base/tooltip' +import { + NumberField, + NumberFieldControls, + NumberFieldDecrement, + NumberFieldGroup, + NumberFieldIncrement, + NumberFieldInput, +} from '@/app/components/base/ui/number-field' + +const MIN_KEYWORD_NUMBER = 0 +const MAX_KEYWORD_NUMBER = 50 type KeyWordNumberProps = { keywordNumber: number @@ -17,35 +26,44 @@ const KeyWordNumber = ({ }: KeyWordNumberProps) => { const { t } = useTranslation() - const handleInputChange = useCallback((value: number | undefined) => { - if (value) - onKeywordNumberChange(value) + const handleInputChange = useCallback((value: number | null) => { + onKeywordNumberChange(value ?? MIN_KEYWORD_NUMBER) }, [onKeywordNumberChange]) return (
-
+
{t('form.numberOfKeywords', { ns: 'datasetSettings' })}
- +
- + onValueChange={handleInputChange} + > + + + + + + + +
) } diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/agent-strategy.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/agent-strategy.spec.tsx new file mode 100644 index 0000000000..72e2032d75 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/__tests__/agent-strategy.spec.tsx @@ -0,0 +1,186 @@ +import type { ReactNode } from 'react' +import type { + CredentialFormSchema, + CredentialFormSchemaNumberInput, + CredentialFormSchemaTextInput, +} from '@/app/components/header/account-setting/model-provider-page/declarations' +import { render, screen } from '@testing-library/react' +import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { AgentStrategy } from '../agent-strategy' + +const createI18nLabel = (text: string) => ({ en_US: text, zh_Hans: text }) + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useDefaultModel: () => ({ data: null }), +})) + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => () => '/docs', +})) + +vi.mock('@/hooks/use-i18n', () => ({ + useRenderI18nObject: () => (value: unknown) => { + if (typeof value === 'string') + return value + if (value && typeof value === 'object' && 'en_US' in value) + return value.en_US + return 'label' + }, +})) + +vi.mock('../../../../store', () => ({ + useWorkflowStore: () => ({ + getState: () => ({ + setControlPromptEditorRerenderKey: vi.fn(), + }), + }), +})) + +vi.mock('../agent-strategy-selector', () => ({ + AgentStrategySelector: () =>
, +})) + +vi.mock('../field', () => ({ + default: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +vi.mock('../prompt/editor', () => ({ + default: ({ value }: { value: string }) =>
{value}
, +})) + +type MockFormRenderProps = { + value: Record + onChange: (value: Record) => void + nodeId?: string + nodeOutputVars?: unknown[] + availableNodes?: unknown[] +} + +type MockFormProps = { + formSchemas: Array<{ variable: string }> + value: Record + onChange: (value: Record) => void + override?: [unknown, (schema: unknown, props: MockFormRenderProps) => ReactNode] + nodeId?: string + nodeOutputVars?: unknown[] + availableNodes?: unknown[] +} + +vi.mock('@/app/components/header/account-setting/model-provider-page/model-modal/Form', () => ({ + default: ({ formSchemas, value, onChange, override, nodeId, nodeOutputVars, availableNodes }: MockFormProps) => { + const renderOverride = override?.[1] + + return ( +
+ {formSchemas.map(schema => ( +
+ {renderOverride?.(schema, { + value, + onChange, + nodeId, + nodeOutputVars, + availableNodes, + })} +
+ ))} +
+ ) + }, +})) + +describe('AgentStrategy', () => { + const defaultProps = { + strategy: { + agent_strategy_provider_name: 'provider', + agent_strategy_name: 'strategy', + agent_strategy_label: 'Strategy', + agent_output_schema: {}, + plugin_unique_identifier: 'plugin', + }, + onStrategyChange: vi.fn(), + formValue: {}, + onFormValueChange: vi.fn(), + nodeOutputVars: [], + availableNodes: [], + nodeId: 'node-1', + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + const createTextNumberSchema = (overrides: Partial = {}): CredentialFormSchema => ({ + name: 'count', + variable: 'count', + label: createI18nLabel('Count'), + type: FormTypeEnum.textNumber, + required: false, + show_on: [], + default: '1', + ...overrides, + } as unknown as CredentialFormSchema) + + const createTextInputSchema = (overrides: Partial = {}): CredentialFormSchema => ({ + name: 'prompt', + variable: 'prompt', + label: createI18nLabel('Prompt'), + type: FormTypeEnum.textInput, + required: false, + show_on: [], + default: 'hello', + ...overrides, + }) + + it('should render text-number schemas when min and max are zero', () => { + render( + , + ) + + expect(screen.getByRole('slider')).toBeInTheDocument() + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + + it('should skip text-number schemas when min is missing', () => { + render( + , + ) + + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + }) + + it('should skip text-number schemas when max is missing', () => { + render( + , + ) + + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + }) + + it('should render text-input schemas through the editor override', () => { + render( + , + ) + + expect(screen.getByTestId('agent-strategy-editor')).toHaveTextContent('hello') + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx b/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx index 42be3d46e4..ba30053b77 100644 --- a/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx +++ b/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx @@ -9,9 +9,16 @@ import Link from 'next/link' import { memo } from 'react' import { useTranslation } from 'react-i18next' import { Agent } from '@/app/components/base/icons/src/vender/workflow' -import { InputNumber } from '@/app/components/base/input-number' import ListEmpty from '@/app/components/base/list-empty' import Slider from '@/app/components/base/slider' +import { + NumberField, + NumberFieldControls, + NumberFieldDecrement, + NumberFieldGroup, + NumberFieldIncrement, + NumberFieldInput, +} from '@/app/components/base/ui/number-field' import { FormTypeEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import Form from '@/app/components/header/account-setting/model-provider-page/model-modal/Form' @@ -116,11 +123,11 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => { } case FormTypeEnum.textNumber: { const def = schema as CredentialFormSchemaNumberInput - if (!def.max || !def.min) + if (def.max == null || def.min == null) return false const defaultValue = schema.default ? Number.parseInt(schema.default) : 1 - const value = props.value[schema.variable] || defaultValue + const value = props.value[schema.variable] ?? defaultValue const onChange = (value: number) => { props.onChange({ ...props.value, [schema.variable]: value }) } @@ -145,16 +152,20 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => { min={def.min} max={def.max} /> - + onValueChange={nextValue => onChange(nextValue ?? defaultValue)} + > + + + + + + + +
) diff --git a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/top-k-and-score-threshold.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/top-k-and-score-threshold.spec.tsx new file mode 100644 index 0000000000..762c4c4c05 --- /dev/null +++ b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/top-k-and-score-threshold.spec.tsx @@ -0,0 +1,35 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import TopKAndScoreThreshold from '../top-k-and-score-threshold' + +describe('TopKAndScoreThreshold', () => { + const defaultProps = { + topK: 3, + onTopKChange: vi.fn(), + scoreThreshold: 0.4, + onScoreThresholdChange: vi.fn(), + isScoreThresholdEnabled: true, + onScoreThresholdEnabledChange: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should round top-k input values before notifying the parent', () => { + render() + + const [topKInput] = screen.getAllByRole('textbox') + fireEvent.change(topKInput, { target: { value: '3.7' } }) + + expect(defaultProps.onTopKChange).toHaveBeenCalledWith(4) + }) + + it('should round score-threshold input values to two decimals', () => { + render() + + const [, scoreThresholdInput] = screen.getAllByRole('textbox') + fireEvent.change(scoreThresholdInput, { target: { value: '0.456' } }) + + expect(defaultProps.onScoreThresholdChange).toHaveBeenCalledWith(0.46) + }) +}) diff --git a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/top-k-and-score-threshold.tsx b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/top-k-and-score-threshold.tsx index 62b4e68093..a94d95024d 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/top-k-and-score-threshold.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/top-k-and-score-threshold.tsx @@ -1,8 +1,15 @@ import { memo, useCallback } from 'react' import { useTranslation } from 'react-i18next' -import { InputNumber } from '@/app/components/base/input-number' import Switch from '@/app/components/base/switch' import Tooltip from '@/app/components/base/tooltip' +import { + NumberField, + NumberFieldControls, + NumberFieldDecrement, + NumberFieldGroup, + NumberFieldIncrement, + NumberFieldInput, +} from '@/app/components/base/ui/number-field' import { env } from '@/env' export type TopKAndScoreThresholdProps = { @@ -40,17 +47,11 @@ const TopKAndScoreThreshold = ({ }: TopKAndScoreThresholdProps) => { const { t } = useTranslation() const handleTopKChange = useCallback((value: number) => { - let notOutRangeValue = Number.parseInt(value.toFixed(0)) - notOutRangeValue = Math.max(TOP_K_VALUE_LIMIT.min, notOutRangeValue) - notOutRangeValue = Math.min(TOP_K_VALUE_LIMIT.max, notOutRangeValue) - onTopKChange?.(notOutRangeValue) + onTopKChange?.(Number.parseInt(value.toFixed(0))) }, [onTopKChange]) const handleScoreThresholdChange = (value: number) => { - let notOutRangeValue = Number.parseFloat(value.toFixed(2)) - notOutRangeValue = Math.max(SCORE_THRESHOLD_VALUE_LIMIT.min, notOutRangeValue) - notOutRangeValue = Math.min(SCORE_THRESHOLD_VALUE_LIMIT.max, notOutRangeValue) - onScoreThresholdChange?.(notOutRangeValue) + onScoreThresholdChange?.(Number.parseFloat(value.toFixed(2))) } return ( @@ -63,14 +64,22 @@ const TopKAndScoreThreshold = ({ popupContent={t('datasetConfig.top_kTip', { ns: 'appDebug' })} />
- + onValueChange={value => handleTopKChange(value ?? 0)} + > + + + + + + + +
{ !hiddenScoreThreshold && ( @@ -90,14 +99,22 @@ const TopKAndScoreThreshold = ({ popupContent={t('datasetConfig.score_thresholdTip', { ns: 'appDebug' })} /> - + step={SCORE_THRESHOLD_VALUE_LIMIT.step} + min={SCORE_THRESHOLD_VALUE_LIMIT.min} + max={SCORE_THRESHOLD_VALUE_LIMIT.max} + value={scoreThreshold ?? null} + onValueChange={value => handleScoreThresholdChange(value ?? 0)} + > + + + + + + + + ) } diff --git a/web/app/components/workflow/nodes/trigger-webhook/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/trigger-webhook/__tests__/panel.spec.tsx new file mode 100644 index 0000000000..efeeb77c9e --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/__tests__/panel.spec.tsx @@ -0,0 +1,133 @@ +import type { WebhookTriggerNodeType } from '../types' +import type { NodePanelProps } from '@/app/components/workflow/types' +import type { PanelProps } from '@/types/workflow' +import { fireEvent, render, screen } from '@testing-library/react' +import { BlockEnum } from '@/app/components/workflow/types' +import Panel from '../panel' + +const { + mockHandleStatusCodeChange, + mockGenerateWebhookUrl, +} = vi.hoisted(() => ({ + mockHandleStatusCodeChange: vi.fn(), + mockGenerateWebhookUrl: vi.fn(), +})) + +vi.mock('../use-config', () => ({ + DEFAULT_STATUS_CODE: 200, + MAX_STATUS_CODE: 399, + normalizeStatusCode: (statusCode: number) => Math.min(Math.max(statusCode, 200), 399), + useConfig: () => ({ + readOnly: false, + inputs: { + method: 'POST', + webhook_url: 'https://example.com/webhook', + webhook_debug_url: '', + content_type: 'application/json', + headers: [], + params: [], + body: [], + status_code: 200, + response_body: '', + }, + handleMethodChange: vi.fn(), + handleContentTypeChange: vi.fn(), + handleHeadersChange: vi.fn(), + handleParamsChange: vi.fn(), + handleBodyChange: vi.fn(), + handleStatusCodeChange: mockHandleStatusCodeChange, + handleResponseBodyChange: vi.fn(), + generateWebhookUrl: mockGenerateWebhookUrl, + }), +})) + +vi.mock('@/app/components/base/input-with-copy', () => ({ + default: () =>
, +})) + +vi.mock('@/app/components/base/select', () => ({ + SimpleSelect: () =>
, +})) + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({ + default: ({ title, children }: { title: React.ReactNode, children: React.ReactNode }) => ( +
+
{title}
+ {children} +
+ ), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({ + default: () =>
, +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({ + default: () =>
, +})) + +vi.mock('../components/header-table', () => ({ + default: () =>
, +})) + +vi.mock('../components/parameter-table', () => ({ + default: () =>
, +})) + +vi.mock('../components/paragraph-input', () => ({ + default: () =>
, +})) + +vi.mock('../utils/render-output-vars', () => ({ + OutputVariablesContent: () =>
, +})) + +describe('WebhookTriggerPanel', () => { + const panelProps: NodePanelProps = { + id: 'node-1', + data: { + title: 'Webhook', + desc: 'Webhook', + type: BlockEnum.TriggerWebhook, + method: 'POST', + content_type: 'application/json', + headers: [], + params: [], + body: [], + async_mode: false, + status_code: 200, + response_body: '', + variables: [], + }, + panelProps: {} as PanelProps, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should update the status code when users enter a parseable value', () => { + render() + + fireEvent.change(screen.getByRole('textbox'), { target: { value: '201' } }) + + expect(mockHandleStatusCodeChange).toHaveBeenCalledWith(201) + }) + + it('should ignore clear changes until the value is committed', () => { + render() + + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: '' } }) + + expect(mockHandleStatusCodeChange).not.toHaveBeenCalled() + + fireEvent.blur(input) + + expect(mockHandleStatusCodeChange).toHaveBeenCalledWith(200) + }) +}) diff --git a/web/app/components/workflow/nodes/trigger-webhook/panel.tsx b/web/app/components/workflow/nodes/trigger-webhook/panel.tsx index 5a5082f5b8..839ca6875f 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/panel.tsx +++ b/web/app/components/workflow/nodes/trigger-webhook/panel.tsx @@ -6,11 +6,18 @@ import copy from 'copy-to-clipboard' import * as React from 'react' import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { InputNumber } from '@/app/components/base/input-number' import InputWithCopy from '@/app/components/base/input-with-copy' import { SimpleSelect } from '@/app/components/base/select' import Toast from '@/app/components/base/toast' import Tooltip from '@/app/components/base/tooltip' +import { + NumberField, + NumberFieldControls, + NumberFieldDecrement, + NumberFieldGroup, + NumberFieldIncrement, + NumberFieldInput, +} from '@/app/components/base/ui/number-field' import Field from '@/app/components/workflow/nodes/_base/components/field' import OutputVars from '@/app/components/workflow/nodes/_base/components/output-vars' import Split from '@/app/components/workflow/nodes/_base/components/split' @@ -18,7 +25,7 @@ import { isPrivateOrLocalAddress } from '@/utils/urlValidation' import HeaderTable from './components/header-table' import ParagraphInput from './components/paragraph-input' import ParameterTable from './components/parameter-table' -import useConfig from './use-config' +import { DEFAULT_STATUS_CODE, MAX_STATUS_CODE, normalizeStatusCode, useConfig } from './use-config' import { OutputVariablesContent } from './utils/render-output-vars' const i18nPrefix = 'nodes.triggerWebhook' @@ -56,7 +63,6 @@ const Panel: FC> = ({ handleParamsChange, handleBodyChange, handleStatusCodeChange, - handleStatusCodeBlur, handleResponseBodyChange, generateWebhookUrl, } = useConfig(id, data) @@ -134,7 +140,7 @@ const Panel: FC> = ({
{isPrivateOrLocalAddress(inputs.webhook_debug_url) && ( -
+
{t(`${i18nPrefix}.debugUrlPrivateAddressWarning`, { ns: 'workflow' })}
)} @@ -192,25 +198,35 @@ const Panel: FC> = ({
-
-