diff --git a/packages/grafana-ui/src/components/Input/AutoSizeInput.test.tsx b/packages/grafana-ui/src/components/Input/AutoSizeInput.test.tsx index fad2600de3e..148f35c6af3 100644 --- a/packages/grafana-ui/src/components/Input/AutoSizeInput.test.tsx +++ b/packages/grafana-ui/src/components/Input/AutoSizeInput.test.tsx @@ -1,6 +1,4 @@ import { screen, render, fireEvent, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { useState } from 'react'; import { measureText } from '../../utils/measureText'; @@ -15,214 +13,15 @@ jest.mock('../../utils/measureText', () => { return { measureText }; }); -// The length calculation should be (text.length * 14) + 24 -const FIXTURES = { - 'Initial value': '206px', - 'Initial value with more text': '416px', - 'A new value': '178px', - 'Placeholder text': '248px', - _emptyString: '80px', // min width - foo: '80px', // min width -} as const; - describe('AutoSizeInput', () => { - it('all the test fixture strings should be a different length', () => { - const lengths = Object.keys(FIXTURES).map((key) => key.length); + it('should support default Input API', () => { + const onChange = jest.fn(); + render(); - // The unique number of lengths should be the same as the number of items in the object - const uniqueLengths = new Set(lengths); - expect(uniqueLengths.size).toBe(lengths.length); - }); + const input: HTMLInputElement = screen.getByTestId('autosize-input'); + fireEvent.change(input, { target: { value: 'foo' } }); - describe('as an uncontrolled component', () => { - it('renders an initial value prop with correct width', async () => { - render(); - - const input: HTMLInputElement = screen.getByTestId('autosize-input'); - const inputWrapper: HTMLDivElement = screen.getByTestId('input-wrapper'); - - expect(input.value).toBe('Initial value'); - expect(getComputedStyle(inputWrapper).width).toBe(FIXTURES['Initial value']); - }); - - it('renders an updated value prop with correct width', () => { - const { rerender } = render(); - - const input: HTMLInputElement = screen.getByTestId('autosize-input'); - const inputWrapper: HTMLDivElement = screen.getByTestId('input-wrapper'); - - rerender(); - - expect(input.value).toBe('A new value'); - expect(getComputedStyle(inputWrapper).width).toBe(FIXTURES['A new value']); - }); - - it('renders the user typing in the input with correct width', async () => { - render(); - - const input: HTMLInputElement = screen.getByTestId('autosize-input'); - const inputWrapper: HTMLDivElement = screen.getByTestId('input-wrapper'); - - await userEvent.type(input, 'Initial value with more text'); - - expect(input.value).toBe('Initial value with more text'); - expect(getComputedStyle(inputWrapper).width).toBe(FIXTURES['Initial value with more text']); - }); - - it('renders correctly after the user clears the input', async () => { - render(); - - const input: HTMLInputElement = screen.getByTestId('autosize-input'); - const inputWrapper: HTMLDivElement = screen.getByTestId('input-wrapper'); - - await userEvent.clear(input); - - expect(input.value).toBe(''); - expect(getComputedStyle(inputWrapper).width).toBe(FIXTURES._emptyString); - }); - - it('renders correctly with a placeholder after the user clears the input', async () => { - render(); - - const input: HTMLInputElement = screen.getByTestId('autosize-input'); - const inputWrapper: HTMLDivElement = screen.getByTestId('input-wrapper'); - - await userEvent.clear(input); - - expect(input.value).toBe(''); - expect(getComputedStyle(inputWrapper).width).toBe(FIXTURES['Placeholder text']); - }); - - it('emits onCommitChange when you blur the input', () => { - const onCommitChange = jest.fn(); - render(); - - const input: HTMLInputElement = screen.getByTestId('autosize-input'); - - fireEvent.blur(input); - - expect(onCommitChange).toHaveBeenCalledTimes(1); - }); - - it('emits onBlur instead of onCommitChange when you blur the input', () => { - const onCommitChange = jest.fn(); - const onBlur = jest.fn(); - render(); - - const input: HTMLInputElement = screen.getByTestId('autosize-input'); - - fireEvent.blur(input); - - expect(onCommitChange).not.toHaveBeenCalled(); - expect(onBlur).toHaveBeenCalledTimes(1); - }); - - it('emits the value when you press enter', async () => { - const onCommitChange = jest.fn(); - render(); - - const input: HTMLInputElement = screen.getByTestId('autosize-input'); - - await userEvent.type(input, '{enter}'); - - expect(onCommitChange).toHaveBeenCalledTimes(1); - }); - }); - - describe('as a controlled component', () => { - function ControlledAutoSizeInputExample() { - const [value, setValue] = useState(''); - return setValue(event.currentTarget.value)} />; - } - - // AutoSizeInput is considered controlled when it has both value and onChange props - - it('renders a value prop with correct width', () => { - const onChange = jest.fn(); - render(); - - const input: HTMLInputElement = screen.getByTestId('autosize-input'); - const inputWrapper: HTMLDivElement = screen.getByTestId('input-wrapper'); - - expect(input.value).toBe('Initial value'); - expect(getComputedStyle(inputWrapper).width).toBe(FIXTURES['Initial value']); - }); - - it('renders an updated value prop with correct width', () => { - const onChange = jest.fn(); - const { rerender } = render(); - - const input: HTMLInputElement = screen.getByTestId('autosize-input'); - const inputWrapper: HTMLDivElement = screen.getByTestId('input-wrapper'); - - rerender(); - - expect(input.value).toBe('A new value'); - expect(getComputedStyle(inputWrapper).width).toBe(FIXTURES['A new value']); - }); - - it('as a user types, the value is not updated because it is controlled', async () => { - const onChange = jest.fn(); - render(); - - const input: HTMLInputElement = screen.getByTestId('autosize-input'); - const inputWrapper: HTMLDivElement = screen.getByTestId('input-wrapper'); - - await userEvent.type(input, ' and more text'); - - expect(input.value).toBe('Initial value'); - expect(getComputedStyle(inputWrapper).width).toBe(FIXTURES['Initial value']); - }); - - it('functions correctly as a controlled input', async () => { - render(); - const input: HTMLInputElement = screen.getByTestId('autosize-input'); - const inputWrapper: HTMLDivElement = screen.getByTestId('input-wrapper'); - - await userEvent.type(input, 'Initial value with more text'); - expect(input.value).toBe('Initial value with more text'); - expect(getComputedStyle(inputWrapper).width).toBe(FIXTURES['Initial value with more text']); - }); - - it('emits onCommitChange when you blur the input', () => { - const onCommitChange = jest.fn(); - const onChange = jest.fn(); - render(); - - const input: HTMLInputElement = screen.getByTestId('autosize-input'); - - fireEvent.blur(input); - - expect(onCommitChange).toHaveBeenCalledTimes(1); - }); - - it('emits onBlur instead of onCommitChange when you blur the input', () => { - const onCommitChange = jest.fn(); - const onBlur = jest.fn(); - const onChange = jest.fn(); - render( - - ); - - const input: HTMLInputElement = screen.getByTestId('autosize-input'); - - fireEvent.blur(input); - - expect(onCommitChange).not.toHaveBeenCalled(); - expect(onBlur).toHaveBeenCalledTimes(1); - }); - - it('emits the value when you press enter', async () => { - const onCommitChange = jest.fn(); - const onChange = jest.fn(); - render(); - - const input: HTMLInputElement = screen.getByTestId('autosize-input'); - - await userEvent.type(input, '{enter}'); - - expect(onCommitChange).toHaveBeenCalledTimes(1); - }); + expect(onChange).toHaveBeenCalled(); }); it('should have default minWidth when empty', () => { @@ -231,38 +30,104 @@ describe('AutoSizeInput', () => { const input: HTMLInputElement = screen.getByTestId('autosize-input'); const inputWrapper: HTMLDivElement = screen.getByTestId('input-wrapper'); + fireEvent.change(input, { target: { value: '' } }); + expect(input.value).toBe(''); - expect(getComputedStyle(inputWrapper).width).toBe(FIXTURES._emptyString); + expect(getComputedStyle(inputWrapper).width).toBe('80px'); }); it('should have default minWidth for short content', () => { - render(); + render(); const input: HTMLInputElement = screen.getByTestId('autosize-input'); const inputWrapper: HTMLDivElement = screen.getByTestId('input-wrapper'); + fireEvent.change(input, { target: { value: 'foo' } }); + expect(input.value).toBe('foo'); - expect(getComputedStyle(inputWrapper).width).toBe(FIXTURES['foo']); + expect(getComputedStyle(inputWrapper).width).toBe('80px'); + }); + + it('should change width for long content', () => { + render(); + + const input: HTMLInputElement = screen.getByTestId('autosize-input'); + const inputWrapper: HTMLDivElement = screen.getByTestId('input-wrapper'); + + fireEvent.change(input, { target: { value: 'very very long value' } }); + expect(getComputedStyle(inputWrapper).width).toBe('304px'); }); it('should use placeholder for width if input is empty', () => { - render(); + render(); const inputWrapper: HTMLDivElement = screen.getByTestId('input-wrapper'); - expect(getComputedStyle(inputWrapper).width).toBe(FIXTURES['Placeholder text']); + expect(getComputedStyle(inputWrapper).width).toBe('304px'); }); it('should use value for width even with a placeholder', () => { - render(); + render(); + const input: HTMLInputElement = screen.getByTestId('autosize-input'); const inputWrapper: HTMLDivElement = screen.getByTestId('input-wrapper'); - expect(getComputedStyle(inputWrapper).width).toBe(FIXTURES['Initial value']); + fireEvent.change(input, { target: { value: 'very very long value' } }); + expect(getComputedStyle(inputWrapper).width).toBe('304px'); + }); + + it('should call onBlur if set when blurring', () => { + const onBlur = jest.fn(); + const onCommitChange = jest.fn(); + render(); + + const input: HTMLInputElement = screen.getByTestId('autosize-input'); + + fireEvent.blur(input); + + expect(onBlur).toHaveBeenCalled(); + expect(onCommitChange).not.toHaveBeenCalled(); + }); + + it('should call onCommitChange if not set when blurring', () => { + const onCommitChange = jest.fn(); + render(); + + const input: HTMLInputElement = screen.getByTestId('autosize-input'); + + fireEvent.blur(input); + + expect(onCommitChange).toHaveBeenCalled(); + }); + + it('should call onKeyDown if set when keydown', () => { + const onKeyDown = jest.fn(); + const onCommitChange = jest.fn(); + render(); + + const input: HTMLInputElement = screen.getByTestId('autosize-input'); + + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(onKeyDown).toHaveBeenCalled(); + expect(onCommitChange).not.toHaveBeenCalled(); + }); + + it('should call onCommitChange if not set when keydown', () => { + const onCommitChange = jest.fn(); + render(); + + const input: HTMLInputElement = screen.getByTestId('autosize-input'); + + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(onCommitChange).toHaveBeenCalled(); }); it('should respect min width', async () => { render(); + await waitFor(() => expect(measureText).toHaveBeenCalled()); + expect(getComputedStyle(screen.getByTestId('input-wrapper')).width).toBe('32px'); }); @@ -280,6 +145,32 @@ describe('AutoSizeInput', () => { expect(getComputedStyle(screen.getByTestId('input-wrapper')).width).toBe('32px'); }); + it('should update the input value if the value prop changes', () => { + const { rerender } = render(); + + // Get a handle on the original input element (to catch if it's unmounted) + // And check the initial value + const input: HTMLInputElement = screen.getByTestId('autosize-input'); + expect(input.value).toBe('Initial'); + + // Rerender and make sure it clears the input + rerender(); + expect(input.value).toBe('Updated'); + }); + + it('should clear the input when the value is changed to an empty string', () => { + const { rerender } = render(); + + // Get a handle on the original input element (to catch if it's unmounted) + // And check the initial value + const input: HTMLInputElement = screen.getByTestId('autosize-input'); + expect(input.value).toBe('Initial'); + + // Rerender and make sure it clears the input + rerender(); + expect(input.value).toBe(''); + }); + it('should render string values as expected', () => { render(); diff --git a/packages/grafana-ui/src/components/Input/AutoSizeInput.tsx b/packages/grafana-ui/src/components/Input/AutoSizeInput.tsx index e7a89ab4fc8..9e7b275c120 100644 --- a/packages/grafana-ui/src/components/Input/AutoSizeInput.tsx +++ b/packages/grafana-ui/src/components/Input/AutoSizeInput.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useEffect, useMemo } from 'react'; import * as React from 'react'; import { measureText } from '../../utils/measureText'; @@ -28,32 +28,37 @@ export const AutoSizeInput = React.forwardRef((props, r placeholder, ...restProps } = props; - const [inputState, setInputValue] = useControlledState(controlledValue, onChange); - const inputValue = inputState || defaultValue; + // Initialize internal state + const [value, setValue] = React.useState(controlledValue ?? defaultValue); + + // Update internal state when controlled `value` prop changes + useEffect(() => { + setValue(controlledValue ?? defaultValue); + }, [controlledValue, defaultValue]); // Update input width when `value`, `minWidth`, or `maxWidth` change const inputWidth = useMemo(() => { - const displayValue = inputValue || placeholder || ''; + const displayValue = value || placeholder || ''; const valueString = typeof displayValue === 'string' ? displayValue : displayValue.toString(); return getWidthFor(valueString, minWidth, maxWidth); - }, [placeholder, inputValue, minWidth, maxWidth]); + }, [placeholder, value, minWidth, maxWidth]); return ( + // Used to tell Input to increase the width properly of the input to fit the text. + // See comment in Input.tsx for more details { if (onChange) { onChange(event); } - - setInputValue(event.currentTarget.value); + setValue(event.currentTarget.value); }} - width={inputWidth} onBlur={(event) => { if (onBlur) { onBlur(event); @@ -68,7 +73,8 @@ export const AutoSizeInput = React.forwardRef((props, r onCommitChange(event); } }} - data-testid={'autosize-input'} + width={inputWidth} + data-testid="autosize-input" /> ); @@ -94,39 +100,3 @@ function getWidthFor(value: string, minWidth: number, maxWidth: number | undefin } AutoSizeInput.displayName = 'AutoSizeInput'; - -/** - * Hook to abstract away state management for controlled and uncontrolled inputs. - * If the initial value is not undefined, then the value will be controlled by the parent - * for the lifetime of the component and calls to setState will be ignored. - */ -function useControlledState(controlledValue: T, onChange: Function | undefined): [T, (newValue: T) => void] { - const isControlledNow = controlledValue !== undefined && onChange !== undefined; - const isControlledRef = useRef(isControlledNow); // set the initial value - we never change this - - const hasLoggedControlledWarning = useRef(false); - if (isControlledNow !== isControlledRef.current && !hasLoggedControlledWarning.current) { - console.warn( - 'An AutoSizeInput is changing from an uncontrolled to a controlled input. If you want to control the input, the empty value should be an empty string.' - ); - hasLoggedControlledWarning.current = true; - } - - const [internalValue, setInternalValue] = React.useState(controlledValue); - - useEffect(() => { - if (!isControlledRef.current) { - setInternalValue(controlledValue); - } - }, [controlledValue]); - - const handleChange = useCallback((newValue: T) => { - if (!isControlledRef.current) { - setInternalValue(newValue); - } - }, []); - - const value = isControlledRef.current ? controlledValue : internalValue; - - return [value, handleChange]; -}