From 990917ade40ab870df7a571f42c072c7a8ef8feb Mon Sep 17 00:00:00 2001 From: Nick McCurdy Date: Fri, 12 Jun 2020 12:11:06 -0400 Subject: [PATCH 1/4] feat(paste): Extract paste event from type event --- src/user-event/__tests__/paste.js | 157 ++++++++++++++++++++++++++++++ src/user-event/index.js | 1 + src/user-event/paste.js | 122 +++++++++++++++++++++++ 3 files changed, 280 insertions(+) create mode 100644 src/user-event/__tests__/paste.js create mode 100644 src/user-event/paste.js diff --git a/src/user-event/__tests__/paste.js b/src/user-event/__tests__/paste.js new file mode 100644 index 00000000..83a20bb8 --- /dev/null +++ b/src/user-event/__tests__/paste.js @@ -0,0 +1,157 @@ +import React from 'react' +import {render, screen} from '@testing-library/react' +import userEvent from '..' +import {setup} from './helpers/utils' +import './helpers/customElement' + +test('should paste text', async () => { + const {element, getEventCalls} = setup() + await userEvent.paste(element, 'Sup') + expect(getEventCalls()).toMatchInlineSnapshot(` + focus + input: "{CURSOR}" -> "Sup" + `) +}) + +test('does not paste when readOnly', () => { + const handleChange = jest.fn() + render() + userEvent.paste(screen.getByTestId('input'), 'hi') + expect(handleChange).not.toHaveBeenCalled() +}) + +test('does not paste when disabled', () => { + const handleChange = jest.fn() + render() + userEvent.paste(screen.getByTestId('input'), 'hi') + expect(handleChange).not.toHaveBeenCalled() +}) + +test.each(['input', 'textarea'])('should paste text in <%s>', type => { + const onChange = jest.fn() + render( + React.createElement(type, { + 'data-testid': 'input', + onChange, + }), + ) + const text = 'Hello, world!' + userEvent.paste(screen.getByTestId('input'), text) + + expect(onChange).toHaveBeenCalledTimes(1) + expect(screen.getByTestId('input')).toHaveProperty('value', text) +}) + +test.each(['input', 'textarea'])( + 'should paste text in <%s> up to maxLength if provided', + async type => { + const onChange = jest.fn() + const onKeyDown = jest.fn() + const onKeyPress = jest.fn() + const onKeyUp = jest.fn() + const maxLength = 10 + + render( + React.createElement(type, { + 'data-testid': 'input', + onChange, + onKeyDown, + onKeyPress, + onKeyUp, + maxLength, + }), + ) + + const text = 'superlongtext' + const slicedText = text.slice(0, maxLength) + + const inputEl = screen.getByTestId('input') + + await userEvent.type(inputEl, text) + + expect(inputEl).toHaveProperty('value', slicedText) + expect(onChange).toHaveBeenCalledTimes(slicedText.length) + expect(onKeyPress).toHaveBeenCalledTimes(text.length) + expect(onKeyDown).toHaveBeenCalledTimes(text.length) + expect(onKeyUp).toHaveBeenCalledTimes(text.length) + + inputEl.value = '' + onChange.mockClear() + onKeyPress.mockClear() + onKeyDown.mockClear() + onKeyUp.mockClear() + + userEvent.paste(inputEl, text) + + expect(inputEl).toHaveProperty('value', slicedText) + expect(onChange).toHaveBeenCalledTimes(1) + expect(onKeyPress).not.toHaveBeenCalled() + expect(onKeyDown).not.toHaveBeenCalled() + expect(onKeyUp).not.toHaveBeenCalled() + }, +) + +test.each(['input', 'textarea'])( + 'should append text in <%s> up to maxLength if provided', + async type => { + const onChange = jest.fn() + const onKeyDown = jest.fn() + const onKeyPress = jest.fn() + const onKeyUp = jest.fn() + const maxLength = 10 + + render( + React.createElement(type, { + 'data-testid': 'input', + onChange, + onKeyDown, + onKeyPress, + onKeyUp, + maxLength, + }), + ) + + const text1 = 'superlong' + const text2 = 'text' + const text = text1 + text2 + const slicedText = text.slice(0, maxLength) + + const inputEl = screen.getByTestId('input') + + await userEvent.type(inputEl, text1) + await userEvent.type(inputEl, text2) + + expect(inputEl).toHaveProperty('value', slicedText) + expect(onChange).toHaveBeenCalledTimes(slicedText.length) + expect(onKeyPress).toHaveBeenCalledTimes(text.length) + expect(onKeyDown).toHaveBeenCalledTimes(text.length) + expect(onKeyUp).toHaveBeenCalledTimes(text.length) + + inputEl.value = '' + onChange.mockClear() + onKeyPress.mockClear() + onKeyDown.mockClear() + onKeyUp.mockClear() + + userEvent.paste(inputEl, text) + + expect(inputEl).toHaveProperty('value', slicedText) + expect(onChange).toHaveBeenCalledTimes(1) + expect(onKeyPress).not.toHaveBeenCalled() + expect(onKeyDown).not.toHaveBeenCalled() + expect(onKeyUp).not.toHaveBeenCalled() + }, +) + +test('should replace selected text all at once', async () => { + const onChange = jest.fn() + const { + container: {firstChild: input}, + } = render() + const selectionStart = 'hello world'.search('world') + const selectionEnd = selectionStart + 'world'.length + input.setSelectionRange(selectionStart, selectionEnd) + await userEvent.paste(input, 'friend') + expect(onChange).toHaveBeenCalledTimes(1) + expect(input).toHaveValue('hello friend') +}) diff --git a/src/user-event/index.js b/src/user-event/index.js index 77e86dae..f4e1926f 100644 --- a/src/user-event/index.js +++ b/src/user-event/index.js @@ -1,5 +1,6 @@ export {click, dblClick} from './click' export {type} from './type' +export {paste} from './paste' export {clear} from './clear' export {tab} from './tab' export {hover, unhover} from './hover' diff --git a/src/user-event/paste.js b/src/user-event/paste.js new file mode 100644 index 00000000..6cfe0134 --- /dev/null +++ b/src/user-event/paste.js @@ -0,0 +1,122 @@ +import { + getConfig as getDOMTestingLibraryConfig, + fireEvent, +} from '@testing-library/dom' + +// this needs to be wrapped in the asyncWrapper for React's act and angular's change detection +async function paste(...args) { + let result + await getDOMTestingLibraryConfig().asyncWrapper(async () => { + result = await pasteImpl(...args) + }) + return result +} + +const getActiveElement = document => { + const activeElement = document.activeElement + if (activeElement.shadowRoot) { + return getActiveElement(activeElement.shadowRoot) || activeElement + } else { + return activeElement + } +} + +// eslint-disable-next-line complexity +function pasteImpl( + element, + text, + {initialSelectionStart, initialSelectionEnd} = {}, +) { + if (element.disabled) return + + element.focus() + + // The focused element could change between each event, so get the currently active element each time + const currentElement = () => getActiveElement(element.ownerDocument) + const currentValue = () => currentElement().value + const setSelectionRange = ({newValue, newSelectionStart}) => { + // if we *can* change the selection start, then we will if the new value + // is the same as the current value (so it wasn't programatically changed + // when the fireEvent.input was triggered). + // The reason we have to do this at all is because it actually *is* + // programmatically changed by fireEvent.input, so we have to simulate the + // browser's default behavior + if ( + currentElement().selectionStart !== null && + currentValue() === newValue + ) { + currentElement().setSelectionRange?.(newSelectionStart, newSelectionStart) + } + } + + // by default, a new element has it's selection start and end at 0 + // but most of the time when people call "paste", they expect it to paste + // at the end of the current input value. So, if the selection start + // and end are both the default of 0, then we'll go ahead and change + // them to the length of the current value. + // the only time it would make sense to pass the initialSelectionStart or + // initialSelectionEnd is if you have an input with a value and want to + // explicitely start typing with the cursor at 0. Not super common. + if ( + currentElement().selectionStart === 0 && + currentElement().selectionEnd === 0 + ) { + currentElement().setSelectionRange( + initialSelectionStart ?? currentValue()?.length ?? 0, + initialSelectionEnd ?? currentValue()?.length ?? 0, + ) + } + + if (!element.readOnly) { + const {newValue, newSelectionStart} = calculateNewValue(text) + fireEvent.input(element, { + target: {value: newValue}, + }) + setSelectionRange({newValue, newSelectionStart}) + } + + function calculateNewValue(newEntry) { + const {selectionStart, selectionEnd} = currentElement() + // can't use .maxLength property because of a jsdom bug: + // https://github.com/jsdom/jsdom/issues/2927 + const maxLength = Number(currentElement().getAttribute('maxlength') ?? -1) + const value = currentValue() + let newValue, newSelectionStart + + if (selectionStart === null) { + // at the end of an input type that does not support selection ranges + // https://github.com/testing-library/user-event/issues/316#issuecomment-639744793 + newValue = value + newEntry + } else if (selectionStart === selectionEnd) { + if (selectionStart === 0) { + // at the beginning of the input + newValue = newEntry + value + } else if (selectionStart === value.length) { + // at the end of the input + newValue = value + newEntry + } else { + // in the middle of the input + newValue = + value.slice(0, selectionStart) + newEntry + value.slice(selectionEnd) + } + newSelectionStart = selectionStart + newEntry.length + } else { + // we have something selected + const firstPart = value.slice(0, selectionStart) + newEntry + newValue = firstPart + value.slice(selectionEnd) + newSelectionStart = firstPart.length + } + + if (maxLength < 0) { + return {newValue, newSelectionStart} + } else { + return { + newValue: newValue.slice(0, maxLength), + newSelectionStart: + newSelectionStart > maxLength ? maxLength : newSelectionStart, + } + } + } +} + +export {paste} From 195cf53c7acce532b4f9b2eeb80e67f2496b47e4 Mon Sep 17 00:00:00 2001 From: Nick McCurdy Date: Sun, 14 Jun 2020 09:40:27 -0400 Subject: [PATCH 2/4] fix(userEvent): make test more consistent --- src/user-event/__tests__/paste.js | 197 ++++++++++-------------------- src/user-event/paste.js | 6 +- 2 files changed, 69 insertions(+), 134 deletions(-) diff --git a/src/user-event/__tests__/paste.js b/src/user-event/__tests__/paste.js index 83a20bb8..9b2d869a 100644 --- a/src/user-event/__tests__/paste.js +++ b/src/user-event/__tests__/paste.js @@ -1,157 +1,94 @@ -import React from 'react' -import {render, screen} from '@testing-library/react' -import userEvent from '..' +import {userEvent} from '../../' import {setup} from './helpers/utils' -import './helpers/customElement' - -test('should paste text', async () => { - const {element, getEventCalls} = setup() - await userEvent.paste(element, 'Sup') - expect(getEventCalls()).toMatchInlineSnapshot(` - focus - input: "{CURSOR}" -> "Sup" + +test('should paste text in input', async () => { + const {element, getEventSnapshot} = setup('') + + const text = 'Hello, world!' + await userEvent.paste(element, text) + expect(element).toHaveValue(text) + expect(getEventSnapshot()).toMatchInlineSnapshot(` + Events fired on: input[value="Hello, world!"] + + input[value=""] - focus + input[value=""] - select + input[value="Hello, world!"] - input + input[value="Hello, world!"] - select `) }) -test('does not paste when readOnly', () => { - const handleChange = jest.fn() - render() - userEvent.paste(screen.getByTestId('input'), 'hi') - expect(handleChange).not.toHaveBeenCalled() +test('should paste text in textarea', async () => { + const {element, getEventSnapshot} = setup('