diff --git a/api/pyproject.toml b/api/pyproject.toml index ac51d10513..31b778ab8c 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "dify-api" -version = "1.13.0" +version = "1.13.1" requires-python = ">=3.11,<3.13" dependencies = [ diff --git a/api/uv.lock b/api/uv.lock index 4bb86aa762..877c6141ab 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1533,7 +1533,7 @@ wheels = [ [[package]] name = "dify-api" -version = "1.13.0" +version = "1.13.1" source = { virtual = "." } dependencies = [ { name = "aliyun-log-python-sdk" }, diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index 1804592c0e..939f23136a 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -21,7 +21,7 @@ services: # API service api: - image: langgenius/dify-api:1.13.0 + image: langgenius/dify-api:1.13.1 restart: always environment: # Use the shared environment variables. @@ -63,7 +63,7 @@ services: # worker service # The Celery worker for processing all queues (dataset, workflow, mail, etc.) worker: - image: langgenius/dify-api:1.13.0 + image: langgenius/dify-api:1.13.1 restart: always environment: # Use the shared environment variables. @@ -102,7 +102,7 @@ services: # worker_beat service # Celery beat for scheduling periodic tasks. worker_beat: - image: langgenius/dify-api:1.13.0 + image: langgenius/dify-api:1.13.1 restart: always environment: # Use the shared environment variables. @@ -132,7 +132,7 @@ services: # Frontend web application. web: - image: langgenius/dify-web:1.13.0 + image: langgenius/dify-web:1.13.1 restart: always environment: CONSOLE_API_URL: ${CONSOLE_API_URL:-} diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index d14f0503e7..b6b6f299cf 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -728,7 +728,7 @@ services: # API service api: - image: langgenius/dify-api:1.13.0 + image: langgenius/dify-api:1.13.1 restart: always environment: # Use the shared environment variables. @@ -770,7 +770,7 @@ services: # worker service # The Celery worker for processing all queues (dataset, workflow, mail, etc.) worker: - image: langgenius/dify-api:1.13.0 + image: langgenius/dify-api:1.13.1 restart: always environment: # Use the shared environment variables. @@ -809,7 +809,7 @@ services: # worker_beat service # Celery beat for scheduling periodic tasks. worker_beat: - image: langgenius/dify-api:1.13.0 + image: langgenius/dify-api:1.13.1 restart: always environment: # Use the shared environment variables. @@ -839,7 +839,7 @@ services: # Frontend web application. web: - image: langgenius/dify-web:1.13.0 + image: langgenius/dify-web:1.13.1 restart: always environment: CONSOLE_API_URL: ${CONSOLE_API_URL:-} 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)} + > + + + + + + + +
{ ) const successToast = getToastElementByMessage('Success message') + expect(successToast).toHaveClass('z-[1101]') const successIcon = within(successToast).getByTestId('toast-icon-success') expect(successIcon).toHaveClass('text-text-success') diff --git a/web/app/components/base/toast/index.tsx b/web/app/components/base/toast/index.tsx index c66be8da15..897b6039ba 100644 --- a/web/app/components/base/toast/index.tsx +++ b/web/app/components/base/toast/index.tsx @@ -28,7 +28,8 @@ const Toast = ({ return (
+ 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/base/ui/toast/__tests__/index.spec.tsx b/web/app/components/base/ui/toast/__tests__/index.spec.tsx new file mode 100644 index 0000000000..212a11bea8 --- /dev/null +++ b/web/app/components/base/ui/toast/__tests__/index.spec.tsx @@ -0,0 +1,313 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import * as React from 'react' +import { toast, ToastHost } from '../index' + +describe('base/ui/toast', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers({ shouldAdvanceTime: true }) + act(() => { + toast.close() + }) + }) + + afterEach(() => { + act(() => { + toast.close() + vi.runOnlyPendingTimers() + }) + vi.useRealTimers() + }) + + // Core host and manager integration. + it('should render a toast when add is called', async () => { + render() + + act(() => { + toast.add({ + title: 'Saved', + description: 'Your changes are available now.', + type: 'success', + }) + }) + + expect(await screen.findByText('Saved')).toBeInTheDocument() + expect(screen.getByText('Your changes are available now.')).toBeInTheDocument() + const viewport = screen.getByRole('region', { name: 'common.toast.notifications' }) + expect(viewport).toHaveAttribute('aria-live', 'polite') + expect(viewport).toHaveClass('z-[1101]') + expect(viewport.firstElementChild).toHaveClass('top-4') + expect(document.body.querySelector('[aria-hidden="true"].i-ri-checkbox-circle-fill')).toBeInTheDocument() + expect(document.body.querySelector('button[aria-label="common.toast.close"][aria-hidden="true"]')).toBeInTheDocument() + }) + + // Collapsed stacks should keep multiple toast roots mounted for smooth stack animation. + it('should keep multiple toast roots mounted in a collapsed stack', async () => { + render() + + act(() => { + toast.add({ + title: 'First toast', + }) + }) + + expect(await screen.findByText('First toast')).toBeInTheDocument() + + act(() => { + toast.add({ + title: 'Second toast', + }) + toast.add({ + title: 'Third toast', + }) + }) + + expect(await screen.findByText('Third toast')).toBeInTheDocument() + expect(screen.getAllByRole('dialog')).toHaveLength(3) + expect(document.body.querySelectorAll('button[aria-label="common.toast.close"][aria-hidden="true"]')).toHaveLength(3) + + fireEvent.mouseEnter(screen.getByRole('region', { name: 'common.toast.notifications' })) + + await waitFor(() => { + expect(document.body.querySelector('button[aria-label="common.toast.close"][aria-hidden="true"]')).not.toBeInTheDocument() + }) + }) + + // Base UI limit should cap the visible stack and mark overflow toasts as limited. + it('should mark overflow toasts as limited when the stack exceeds the configured limit', async () => { + render() + + act(() => { + toast.add({ title: 'First toast' }) + toast.add({ title: 'Second toast' }) + }) + + expect(await screen.findByText('Second toast')).toBeInTheDocument() + expect(document.body.querySelector('[data-limited]')).toBeInTheDocument() + }) + + // Closing should work through the public manager API. + it('should close a toast when close(id) is called', async () => { + render() + + let toastId = '' + act(() => { + toastId = toast.add({ + title: 'Closable', + description: 'This toast can be removed.', + }) + }) + + expect(await screen.findByText('Closable')).toBeInTheDocument() + + act(() => { + toast.close(toastId) + }) + + await waitFor(() => { + expect(screen.queryByText('Closable')).not.toBeInTheDocument() + }) + }) + + // User dismissal needs to remain accessible. + it('should close a toast when the dismiss button is clicked', async () => { + const onClose = vi.fn() + + render() + + act(() => { + toast.add({ + title: 'Dismiss me', + description: 'Manual dismissal path.', + onClose, + }) + }) + + fireEvent.mouseEnter(screen.getByRole('region', { name: 'common.toast.notifications' })) + + const dismissButton = await screen.findByRole('button', { name: 'common.toast.close' }) + + act(() => { + dismissButton.click() + }) + + await waitFor(() => { + expect(screen.queryByText('Dismiss me')).not.toBeInTheDocument() + }) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + // Base UI default timeout should apply when no timeout is provided. + it('should auto dismiss toasts with the Base UI default timeout', async () => { + render() + + act(() => { + toast.add({ + title: 'Default timeout', + }) + }) + + expect(await screen.findByText('Default timeout')).toBeInTheDocument() + + act(() => { + vi.advanceTimersByTime(4999) + }) + + expect(screen.getByText('Default timeout')).toBeInTheDocument() + + act(() => { + vi.advanceTimersByTime(1) + }) + + await waitFor(() => { + expect(screen.queryByText('Default timeout')).not.toBeInTheDocument() + }) + }) + + // Provider timeout should apply to all toasts when configured. + it('should respect the host timeout configuration', async () => { + render() + + act(() => { + toast.add({ + title: 'Configured timeout', + }) + }) + + expect(await screen.findByText('Configured timeout')).toBeInTheDocument() + + act(() => { + vi.advanceTimersByTime(2999) + }) + + expect(screen.getByText('Configured timeout')).toBeInTheDocument() + + act(() => { + vi.advanceTimersByTime(1) + }) + + await waitFor(() => { + expect(screen.queryByText('Configured timeout')).not.toBeInTheDocument() + }) + }) + + // Callers must be able to override or disable timeout per toast. + it('should respect custom timeout values including zero', async () => { + render() + + act(() => { + toast.add({ + title: 'Custom timeout', + timeout: 1000, + }) + }) + + expect(await screen.findByText('Custom timeout')).toBeInTheDocument() + + act(() => { + vi.advanceTimersByTime(1000) + }) + + await waitFor(() => { + expect(screen.queryByText('Custom timeout')).not.toBeInTheDocument() + }) + + act(() => { + toast.add({ + title: 'Persistent', + timeout: 0, + }) + }) + + expect(await screen.findByText('Persistent')).toBeInTheDocument() + + act(() => { + vi.advanceTimersByTime(10000) + }) + + expect(screen.getByText('Persistent')).toBeInTheDocument() + }) + + // Updates should flow through the same manager state. + it('should update an existing toast', async () => { + render() + + let toastId = '' + act(() => { + toastId = toast.add({ + title: 'Loading', + description: 'Preparing your data…', + type: 'info', + }) + }) + + expect(await screen.findByText('Loading')).toBeInTheDocument() + + act(() => { + toast.update(toastId, { + title: 'Done', + description: 'Your data is ready.', + type: 'success', + }) + }) + + expect(screen.getByText('Done')).toBeInTheDocument() + expect(screen.getByText('Your data is ready.')).toBeInTheDocument() + expect(screen.queryByText('Loading')).not.toBeInTheDocument() + }) + + // Action props should pass through to the Base UI action button. + it('should render and invoke toast action props', async () => { + const onAction = vi.fn() + + render() + + act(() => { + toast.add({ + title: 'Action toast', + actionProps: { + children: 'Undo', + onClick: onAction, + }, + }) + }) + + const actionButton = await screen.findByRole('button', { name: 'Undo' }) + + act(() => { + actionButton.click() + }) + + expect(onAction).toHaveBeenCalledTimes(1) + }) + + // Promise helpers are part of the public API and need a regression test. + it('should transition a promise toast from loading to success', async () => { + render() + + let resolvePromise: ((value: string) => void) | undefined + const promise = new Promise((resolve) => { + resolvePromise = resolve + }) + + void act(() => toast.promise(promise, { + loading: 'Saving…', + success: result => ({ + title: 'Saved', + description: result, + type: 'success', + }), + error: 'Failed', + })) + + expect(await screen.findByText('Saving…')).toBeInTheDocument() + + await act(async () => { + resolvePromise?.('Your changes are available now.') + await promise + }) + + expect(await screen.findByText('Saved')).toBeInTheDocument() + expect(screen.getByText('Your changes are available now.')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/ui/toast/index.stories.tsx b/web/app/components/base/ui/toast/index.stories.tsx new file mode 100644 index 0000000000..045ca96823 --- /dev/null +++ b/web/app/components/base/ui/toast/index.stories.tsx @@ -0,0 +1,332 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import type { ReactNode } from 'react' +import { toast, ToastHost } from '.' + +const buttonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-2 text-sm text-text-secondary shadow-xs transition-colors hover:bg-state-base-hover' +const cardClassName = 'flex min-h-[220px] flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6 shadow-sm shadow-shadow-shadow-3' + +const ExampleCard = ({ + eyebrow, + title, + description, + children, +}: { + eyebrow: string + title: string + description: string + children: ReactNode +}) => { + return ( +
+
+
+ {eyebrow} +
+

+ {title} +

+

+ {description} +

+
+
+ {children} +
+
+ ) +} + +const VariantExamples = () => { + const createVariantToast = (type: 'success' | 'error' | 'warning' | 'info') => { + const copy = { + success: { + title: 'Changes saved', + description: 'Your draft is available to collaborators.', + }, + error: { + title: 'Sync failed', + description: 'Check your network connection and try again.', + }, + warning: { + title: 'Storage almost full', + description: 'You have less than 10% of workspace quota remaining.', + }, + info: { + title: 'Invitation sent', + description: 'An email has been sent to the new teammate.', + }, + } as const + + toast.add({ + type, + ...copy[type], + }) + } + + return ( + + + + + + + ) +} + +const StackExamples = () => { + const createStack = () => { + ;[ + { + type: 'info' as const, + title: 'Generating preview', + description: 'The first toast compresses behind the newest notification.', + }, + { + type: 'warning' as const, + title: 'Review required', + description: 'A second toast should deepen the stack without breaking spacing.', + }, + { + type: 'success' as const, + title: 'Ready to publish', + description: 'The newest toast stays frontmost while older items tuck behind it.', + }, + ].forEach(item => toast.add(item)) + } + + const createBurst = () => { + Array.from({ length: 5 }).forEach((_, index) => { + toast.add({ + type: index % 2 === 0 ? 'info' : 'success', + title: `Background task ${index + 1}`, + description: 'Use this to inspect how the stack behaves near the host limit.', + }) + }) + } + + return ( + + + + + ) +} + +const PromiseExamples = () => { + const createPromiseToast = () => { + const request = new Promise((resolve) => { + window.setTimeout(() => resolve('The deployment is now available in production.'), 1400) + }) + + void toast.promise(request, { + loading: { + type: 'info', + title: 'Deploying workflow', + description: 'Provisioning runtime and publishing the latest version.', + }, + success: result => ({ + type: 'success', + title: 'Deployment complete', + description: result, + }), + error: () => ({ + type: 'error', + title: 'Deployment failed', + description: 'The release could not be completed.', + }), + }) + } + + const createRejectingPromiseToast = () => { + const request = new Promise((_, reject) => { + window.setTimeout(() => reject(new Error('intentional story failure')), 1200) + }) + + void toast.promise(request, { + loading: 'Validating model credentials…', + success: 'Credentials verified', + error: () => ({ + type: 'error', + title: 'Credentials rejected', + description: 'The model provider returned an authentication error.', + }), + }) + } + + return ( + + + + + ) +} + +const ActionExamples = () => { + const createActionToast = () => { + toast.add({ + type: 'warning', + title: 'Project archived', + description: 'You can restore it from workspace settings for the next 30 days.', + actionProps: { + children: 'Undo', + onClick: () => { + toast.add({ + type: 'success', + title: 'Project restored', + description: 'The workspace is active again.', + }) + }, + }, + }) + } + + const createLongCopyToast = () => { + toast.add({ + type: 'info', + title: 'Knowledge ingestion in progress', + description: 'This longer example helps validate line wrapping, close button alignment, and action button placement when the content spans multiple rows.', + actionProps: { + children: 'View details', + onClick: () => { + toast.add({ + type: 'info', + title: 'Job details opened', + }) + }, + }, + }) + } + + return ( + + + + + ) +} + +const UpdateExamples = () => { + const createUpdatableToast = () => { + const toastId = toast.add({ + type: 'info', + title: 'Import started', + description: 'Preparing assets and metadata for processing.', + timeout: 0, + }) + + window.setTimeout(() => { + toast.update(toastId, { + type: 'success', + title: 'Import finished', + description: '128 records were imported successfully.', + timeout: 5000, + }) + }, 1400) + } + + const clearAll = () => { + toast.close() + } + + return ( + + + + + ) +} + +const ToastDocsDemo = () => { + return ( + <> + +
+
+
+
+ Base UI toast docs +
+

+ Shared stacked toast examples +

+

+ Each example card below triggers the same shared toast viewport in the top-right corner, so you can review stacking, state transitions, actions, and tone variants the same way the official Base UI documentation demonstrates toast behavior. +

+
+
+ + + + + +
+
+
+ + ) +} + +const meta = { + title: 'Base/Feedback/UI Toast', + component: ToastHost, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: 'Dify toast host built on Base UI Toast. The story is organized as multiple example panels that all feed the same shared toast viewport, matching the way the Base UI documentation showcases toast behavior.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const DocsPattern: Story = { + render: () => , +} diff --git a/web/app/components/base/ui/toast/index.tsx b/web/app/components/base/ui/toast/index.tsx new file mode 100644 index 0000000000..aed0c59b16 --- /dev/null +++ b/web/app/components/base/ui/toast/index.tsx @@ -0,0 +1,202 @@ +'use client' + +import type { + ToastManagerAddOptions, + ToastManagerPromiseOptions, + ToastManagerUpdateOptions, + ToastObject, +} from '@base-ui/react/toast' +import { Toast as BaseToast } from '@base-ui/react/toast' +import { useTranslation } from 'react-i18next' +import { cn } from '@/utils/classnames' + +type ToastData = Record +type ToastType = 'success' | 'error' | 'warning' | 'info' + +type ToastAddOptions = Omit, 'data' | 'positionerProps' | 'type'> & { + type?: ToastType +} + +type ToastUpdateOptions = Omit, 'data' | 'positionerProps' | 'type'> & { + type?: ToastType +} + +type ToastPromiseOptions = { + loading: string | ToastUpdateOptions + success: string | ToastUpdateOptions | ((result: Value) => string | ToastUpdateOptions) + error: string | ToastUpdateOptions | ((error: unknown) => string | ToastUpdateOptions) +} + +export type ToastHostProps = { + timeout?: number + limit?: number +} + +const toastManager = BaseToast.createToastManager() + +export const toast = { + add(options: ToastAddOptions) { + return toastManager.add(options) + }, + close(toastId?: string) { + toastManager.close(toastId) + }, + update(toastId: string, options: ToastUpdateOptions) { + toastManager.update(toastId, options) + }, + promise(promiseValue: Promise, options: ToastPromiseOptions) { + return toastManager.promise(promiseValue, options as ToastManagerPromiseOptions) + }, +} + +function ToastIcon({ type }: { type?: string }) { + if (type === 'success') { + return
- + 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> = ({
-
-