diff --git a/src/user-event/__tests__/__snapshots__/paste.test.tsx.snap b/src/user-event/__tests__/__snapshots__/paste.test.tsx.snap new file mode 100644 index 000000000..fe5a510ca --- /dev/null +++ b/src/user-event/__tests__/__snapshots__/paste.test.tsx.snap @@ -0,0 +1,530 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`paste() paste on empty text input 1`] = ` +[ + { + "name": "focus", + "payload": { + "currentTarget": {}, + "isDefaultPrevented": [Function], + "isPersistent": [Function], + "isPropagationStopped": [Function], + "nativeEvent": { + "target": 0, + }, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + }, + }, + { + "name": "selectionChange", + "payload": { + "currentTarget": {}, + "isDefaultPrevented": [Function], + "isPersistent": [Function], + "isPropagationStopped": [Function], + "nativeEvent": { + "selection": { + "end": 0, + "start": 0, + }, + }, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + }, + }, + { + "name": "change", + "payload": { + "currentTarget": {}, + "isDefaultPrevented": [Function], + "isPersistent": [Function], + "isPropagationStopped": [Function], + "nativeEvent": { + "eventCount": 0, + "target": 0, + "text": "Hi!", + }, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + }, + }, + { + "name": "changeText", + "payload": "Hi!", + }, + { + "name": "selectionChange", + "payload": { + "currentTarget": {}, + "isDefaultPrevented": [Function], + "isPersistent": [Function], + "isPropagationStopped": [Function], + "nativeEvent": { + "selection": { + "end": 3, + "start": 3, + }, + }, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + }, + }, + { + "name": "endEditing", + "payload": { + "currentTarget": {}, + "isDefaultPrevented": [Function], + "isPersistent": [Function], + "isPropagationStopped": [Function], + "nativeEvent": { + "target": 0, + "text": "Hi!", + }, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + }, + }, + { + "name": "blur", + "payload": { + "currentTarget": {}, + "isDefaultPrevented": [Function], + "isPersistent": [Function], + "isPropagationStopped": [Function], + "nativeEvent": { + "target": 0, + }, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + }, + }, +] +`; + +exports[`paste() paste on filled text input 1`] = ` +[ + { + "name": "focus", + "payload": { + "currentTarget": {}, + "isDefaultPrevented": [Function], + "isPersistent": [Function], + "isPropagationStopped": [Function], + "nativeEvent": { + "target": 0, + }, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + }, + }, + { + "name": "selectionChange", + "payload": { + "currentTarget": {}, + "isDefaultPrevented": [Function], + "isPersistent": [Function], + "isPropagationStopped": [Function], + "nativeEvent": { + "selection": { + "end": 6, + "start": 0, + }, + }, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + }, + }, + { + "name": "change", + "payload": { + "currentTarget": {}, + "isDefaultPrevented": [Function], + "isPersistent": [Function], + "isPropagationStopped": [Function], + "nativeEvent": { + "eventCount": 0, + "target": 0, + "text": "Hi!", + }, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + }, + }, + { + "name": "changeText", + "payload": "Hi!", + }, + { + "name": "selectionChange", + "payload": { + "currentTarget": {}, + "isDefaultPrevented": [Function], + "isPersistent": [Function], + "isPropagationStopped": [Function], + "nativeEvent": { + "selection": { + "end": 3, + "start": 3, + }, + }, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + }, + }, + { + "name": "endEditing", + "payload": { + "currentTarget": {}, + "isDefaultPrevented": [Function], + "isPersistent": [Function], + "isPropagationStopped": [Function], + "nativeEvent": { + "target": 0, + "text": "Hi!", + }, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + }, + }, + { + "name": "blur", + "payload": { + "currentTarget": {}, + "isDefaultPrevented": [Function], + "isPersistent": [Function], + "isPropagationStopped": [Function], + "nativeEvent": { + "target": 0, + }, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + }, + }, +] +`; + +exports[`paste() supports defaultValue prop: defaultValue: "Hello Default!" 1`] = ` +[ + { + "name": "focus", + "payload": { + "currentTarget": {}, + "isDefaultPrevented": [Function], + "isPersistent": [Function], + "isPropagationStopped": [Function], + "nativeEvent": { + "target": 0, + }, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + }, + }, + { + "name": "selectionChange", + "payload": { + "currentTarget": {}, + "isDefaultPrevented": [Function], + "isPersistent": [Function], + "isPropagationStopped": [Function], + "nativeEvent": { + "selection": { + "end": 14, + "start": 0, + }, + }, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + }, + }, + { + "name": "change", + "payload": { + "currentTarget": {}, + "isDefaultPrevented": [Function], + "isPersistent": [Function], + "isPropagationStopped": [Function], + "nativeEvent": { + "eventCount": 0, + "target": 0, + "text": "Hi!", + }, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + }, + }, + { + "name": "changeText", + "payload": "Hi!", + }, + { + "name": "selectionChange", + "payload": { + "currentTarget": {}, + "isDefaultPrevented": [Function], + "isPersistent": [Function], + "isPropagationStopped": [Function], + "nativeEvent": { + "selection": { + "end": 3, + "start": 3, + }, + }, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + }, + }, + { + "name": "endEditing", + "payload": { + "currentTarget": {}, + "isDefaultPrevented": [Function], + "isPersistent": [Function], + "isPropagationStopped": [Function], + "nativeEvent": { + "target": 0, + "text": "Hi!", + }, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + }, + }, + { + "name": "blur", + "payload": { + "currentTarget": {}, + "isDefaultPrevented": [Function], + "isPersistent": [Function], + "isPropagationStopped": [Function], + "nativeEvent": { + "target": 0, + }, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + }, + }, +] +`; + +exports[`paste() supports multiline: value: "Hello World! +How are you?" multiline: true, 1`] = ` +[ + { + "name": "focus", + "payload": { + "currentTarget": {}, + "isDefaultPrevented": [Function], + "isPersistent": [Function], + "isPropagationStopped": [Function], + "nativeEvent": { + "target": 0, + }, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + }, + }, + { + "name": "selectionChange", + "payload": { + "currentTarget": {}, + "isDefaultPrevented": [Function], + "isPersistent": [Function], + "isPropagationStopped": [Function], + "nativeEvent": { + "selection": { + "end": 25, + "start": 0, + }, + }, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + }, + }, + { + "name": "change", + "payload": { + "currentTarget": {}, + "isDefaultPrevented": [Function], + "isPersistent": [Function], + "isPropagationStopped": [Function], + "nativeEvent": { + "eventCount": 0, + "target": 0, + "text": "Hi!", + }, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + }, + }, + { + "name": "changeText", + "payload": "Hi!", + }, + { + "name": "selectionChange", + "payload": { + "currentTarget": {}, + "isDefaultPrevented": [Function], + "isPersistent": [Function], + "isPropagationStopped": [Function], + "nativeEvent": { + "selection": { + "end": 3, + "start": 3, + }, + }, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + }, + }, + { + "name": "contentSizeChange", + "payload": { + "currentTarget": {}, + "isDefaultPrevented": [Function], + "isPersistent": [Function], + "isPropagationStopped": [Function], + "nativeEvent": { + "contentSize": { + "height": 16, + "width": 15, + }, + "target": 0, + }, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + }, + }, + { + "name": "endEditing", + "payload": { + "currentTarget": {}, + "isDefaultPrevented": [Function], + "isPersistent": [Function], + "isPropagationStopped": [Function], + "nativeEvent": { + "target": 0, + "text": "Hi!", + }, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + }, + }, + { + "name": "blur", + "payload": { + "currentTarget": {}, + "isDefaultPrevented": [Function], + "isPersistent": [Function], + "isPropagationStopped": [Function], + "nativeEvent": { + "target": 0, + }, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + }, + }, +] +`; + +exports[`paste() works when not all events have handlers 1`] = ` +[ + { + "name": "changeText", + "payload": "Hi!", + }, + { + "name": "endEditing", + "payload": { + "currentTarget": {}, + "isDefaultPrevented": [Function], + "isPersistent": [Function], + "isPropagationStopped": [Function], + "nativeEvent": { + "target": 0, + "text": "Hi!", + }, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + }, + }, +] +`; diff --git a/src/user-event/__tests__/paste.test.tsx b/src/user-event/__tests__/paste.test.tsx new file mode 100644 index 000000000..1c01c8b0b --- /dev/null +++ b/src/user-event/__tests__/paste.test.tsx @@ -0,0 +1,224 @@ +import * as React from 'react'; +import { TextInput, TextInputProps, View } from 'react-native'; +import { createEventLogger, getEventsNames } from '../../test-utils'; +import { render, userEvent, screen } from '../..'; + +beforeEach(() => { + jest.useRealTimers(); +}); + +function renderTextInputWithToolkit(props: TextInputProps = {}) { + const { events, logEvent } = createEventLogger(); + + render( + , + ); + + const textInput = screen.getByTestId('input'); + + return { + events, + textInput, + }; +} + +describe('paste()', () => { + it('paste on empty text input', async () => { + jest.spyOn(Date, 'now').mockImplementation(() => 100100100100); + const { textInput, events } = renderTextInputWithToolkit(); + + const user = userEvent.setup(); + await user.paste(textInput, 'Hi!'); + + expect(getEventsNames(events)).toEqual([ + 'focus', + 'selectionChange', + 'change', + 'changeText', + 'selectionChange', + 'endEditing', + 'blur', + ]); + + expect(events).toMatchSnapshot(); + }); + + it('paste on filled text input', async () => { + jest.spyOn(Date, 'now').mockImplementation(() => 100100100100); + const { textInput, events } = renderTextInputWithToolkit({ + value: 'Hello!', + }); + + const user = userEvent.setup(); + await user.paste(textInput, 'Hi!'); + + expect(getEventsNames(events)).toEqual([ + 'focus', + 'selectionChange', + 'change', + 'changeText', + 'selectionChange', + 'endEditing', + 'blur', + ]); + + expect(events).toMatchSnapshot(); + }); + + it.each(['modern', 'legacy'])('works with %s fake timers', async (type) => { + jest.useFakeTimers({ legacyFakeTimers: type === 'legacy' }); + const { textInput, events } = renderTextInputWithToolkit({ + value: 'Hello!', + }); + + const user = userEvent.setup(); + await user.paste(textInput, 'Hi!'); + + expect(getEventsNames(events)).toEqual([ + 'focus', + 'selectionChange', + 'change', + 'changeText', + 'selectionChange', + 'endEditing', + 'blur', + ]); + }); + + it('supports defaultValue prop', async () => { + const { textInput, events } = renderTextInputWithToolkit({ + defaultValue: 'Hello Default!', + }); + + const user = userEvent.setup(); + await user.paste(textInput, 'Hi!'); + + expect(getEventsNames(events)).toEqual([ + 'focus', + 'selectionChange', + 'change', + 'changeText', + 'selectionChange', + 'endEditing', + 'blur', + ]); + + expect(events).toMatchSnapshot('defaultValue: "Hello Default!"'); + }); + + it('does respect editable prop', async () => { + const { textInput } = renderTextInputWithToolkit({ + value: 'Hello!', + editable: false, + }); + + const user = userEvent.setup(); + await user.paste(textInput, 'Hi!'); + + expect(textInput.props.value).toBe('Hello!'); + }); + + it('does respect pointer-events prop', async () => { + const { textInput } = renderTextInputWithToolkit({ + value: 'Hello!', + pointerEvents: 'none', + }); + + const user = userEvent.setup(); + await user.paste(textInput, 'Hi!'); + + expect(textInput.props.value).toBe('Hello!'); + }); + + it('supports multiline', async () => { + const { textInput, events } = renderTextInputWithToolkit({ + value: 'Hello World!\nHow are you?', + multiline: true, + }); + + const user = userEvent.setup(); + await user.paste(textInput, 'Hi!'); + + expect(getEventsNames(events)).toEqual([ + 'focus', + 'selectionChange', + 'change', + 'changeText', + 'selectionChange', + 'contentSizeChange', + 'endEditing', + 'blur', + ]); + + expect(events).toMatchSnapshot('value: "Hello World!\nHow are you?" multiline: true,'); + }); + + it('works when not all events have handlers', async () => { + const { events, logEvent } = createEventLogger(); + render( + , + ); + + const user = userEvent.setup(); + await user.paste(screen.getByTestId('input'), 'Hi!'); + + expect(getEventsNames(events)).toEqual(['changeText', 'endEditing']); + + expect(events).toMatchSnapshot(); + }); + + it('does NOT work on View', async () => { + render(); + + const user = userEvent.setup(); + await expect( + user.paste(screen.getByTestId('input'), 'Hi!'), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"paste() only supports host "TextInput" elements. Passed element has type: "View"."`, + ); + }); + + // View that ignores props type checking + const AnyView = View as React.ComponentType; + + it('does NOT bubble up', async () => { + const parentHandler = jest.fn(); + render( + + + , + ); + + const user = userEvent.setup(); + await user.paste(screen.getByTestId('input'), 'Hi!'); + expect(parentHandler).not.toHaveBeenCalled(); + }); +}); diff --git a/src/user-event/index.ts b/src/user-event/index.ts index 6d8e50b63..17e0c946f 100644 --- a/src/user-event/index.ts +++ b/src/user-event/index.ts @@ -16,6 +16,7 @@ export const userEvent = { type: (element: ReactTestInstance, text: string, options?: TypeOptions) => setup().type(element, text, options), clear: (element: ReactTestInstance) => setup().clear(element), + paste: (element: ReactTestInstance, text: string) => setup().paste(element, text), scrollTo: (element: ReactTestInstance, options: ScrollToOptions) => setup().scrollTo(element, options), }; diff --git a/src/user-event/paste.ts b/src/user-event/paste.ts new file mode 100644 index 000000000..80ed6b2bd --- /dev/null +++ b/src/user-event/paste.ts @@ -0,0 +1,57 @@ +import { ReactTestInstance } from 'react-test-renderer'; +import { ErrorWithStack } from '../helpers/errors'; +import { isHostTextInput } from '../helpers/host-component-names'; +import { isPointerEventEnabled } from '../helpers/pointer-events'; +import { isTextInputEditable } from '../helpers/text-input'; +import { EventBuilder } from './event-builder'; +import { UserEventInstance } from './setup'; +import { dispatchEvent, getTextContentSize, wait } from './utils'; + +export async function paste( + this: UserEventInstance, + element: ReactTestInstance, + text: string, +): Promise { + if (!isHostTextInput(element)) { + throw new ErrorWithStack( + `paste() only supports host "TextInput" elements. Passed element has type: "${element.type}".`, + paste, + ); + } + + if (!isTextInputEditable(element) || !isPointerEventEnabled(element)) { + return; + } + + // 1. Enter element + dispatchEvent(element, 'focus', EventBuilder.Common.focus()); + + // 2. Select all + const textToClear = element.props.value ?? element.props.defaultValue ?? ''; + const rangeToClear = { start: 0, end: textToClear.length }; + dispatchEvent(element, 'selectionChange', EventBuilder.TextInput.selectionChange(rangeToClear)); + + // 3. Paste the text + dispatchEvent(element, 'change', EventBuilder.TextInput.change(text)); + dispatchEvent(element, 'changeText', text); + + const rangeAfter = { start: text.length, end: text.length }; + dispatchEvent(element, 'selectionChange', EventBuilder.TextInput.selectionChange(rangeAfter)); + + // According to the docs only multiline TextInput emits contentSizeChange event + // @see: https://reactnative.dev/docs/textinput#oncontentsizechange + const isMultiline = element.props.multiline === true; + if (isMultiline) { + const contentSize = getTextContentSize(text); + dispatchEvent( + element, + 'contentSizeChange', + EventBuilder.TextInput.contentSizeChange(contentSize), + ); + } + + // 4. Exit element + await wait(this.config); + dispatchEvent(element, 'endEditing', EventBuilder.TextInput.endEditing(text)); + dispatchEvent(element, 'blur', EventBuilder.Common.blur()); +} diff --git a/src/user-event/setup/setup.ts b/src/user-event/setup/setup.ts index 5091793f1..53a4befa8 100644 --- a/src/user-event/setup/setup.ts +++ b/src/user-event/setup/setup.ts @@ -2,6 +2,7 @@ import { ReactTestInstance } from 'react-test-renderer'; import { jestFakeTimersAreEnabled } from '../../helpers/timers'; import { wrapAsync } from '../../helpers/wrap-async'; import { clear } from '../clear'; +import { paste } from '../paste'; import { PressOptions, press, longPress } from '../press'; import { ScrollToOptions, scrollTo } from '../scroll'; import { TypeOptions, type } from '../type'; @@ -119,6 +120,19 @@ export interface UserEventInstance { */ clear: (element: ReactTestInstance) => Promise; + /** + * Simulate user pasting the text to a given `TextInput` element. + * + * This method will simulate: + * 1. entering TextInput + * 2. selecting all text + * 3. paste the text + * 4. leaving TextInput + * + * @param element TextInput element to paste to + */ + paste: (element: ReactTestInstance, text: string) => Promise; + /** * Simlate user scorlling a ScrollView element. * @@ -139,6 +153,7 @@ function createInstance(config: UserEventConfig): UserEventInstance { longPress: wrapAndBindImpl(instance, longPress), type: wrapAndBindImpl(instance, type), clear: wrapAndBindImpl(instance, clear), + paste: wrapAndBindImpl(instance, paste), scrollTo: wrapAndBindImpl(instance, scrollTo), }; diff --git a/website/docs/12.x/docs/api/events/user-event.mdx b/website/docs/12.x/docs/api/events/user-event.mdx index 50d657ff9..fbf46af74 100644 --- a/website/docs/12.x/docs/api/events/user-event.mdx +++ b/website/docs/12.x/docs/api/events/user-event.mdx @@ -124,12 +124,10 @@ The `pressIn` and `pressOut` events are sent by default but can be skipped by pa **Typing (for each character)**: - `keyPress` -- `textInput` (optional) - `change` - `changeText` - `selectionChange` - -The `textInput` event is sent only for multiline text inputs. +- `contentSizeChange` (only multiline) **Leaving the element**: @@ -144,7 +142,7 @@ The `submitEditing` event is skipped by default. It can sent by setting the `sub ```ts clear( element: ReactTestInstance, -} +) ``` Example @@ -160,8 +158,6 @@ This function supports only host `TextInput` elements. Passing other element typ ### Sequence of events -The sequence of events depends on the `multiline` prop and passed options. - Events will not be emitted if the `editable` prop is set to `false`. **Entering the element**: @@ -175,12 +171,52 @@ Events will not be emitted if the `editable` prop is set to `false`. **Pressing backspace**: - `keyPress` -- `textInput` (optional) - `change` - `changeText` - `selectionChange` -The `textInput` event is sent only for multiline text inputs. +**Leaving the element**: + +- `endEditing` +- `blur` + +## `paste()` + +```ts +paste( + element: ReactTestInstance, + text: string, +) +``` + +Example + +```ts +const user = userEvent.setup(); +await user.paste(textInput, 'Text to paste'); +``` + +This helper simulates the user pasting given text to a `TextInput` element. + +This function supports only host `TextInput` elements. Passing other element types will result in throwing an error. + +### Sequence of events + +Events will not be emitted if the `editable` prop is set to `false`. + +**Entering the element**: + +- `focus` + +**Selecting all content**: + +- `selectionChange` + +**Pasting the text**: + +- `change` +- `changeText` +- `selectionChange` **Leaving the element**: