diff --git a/src/user-event/__tests__/__snapshots__/clear.test.tsx.snap b/src/user-event/__tests__/__snapshots__/clear.test.tsx.snap new file mode 100644 index 000000000..9d0c7d1a3 --- /dev/null +++ b/src/user-event/__tests__/__snapshots__/clear.test.tsx.snap @@ -0,0 +1,269 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`clear() supports basic case: value: "Hello! 1`] = ` +[ + { + "name": "focus", + "payload": { + "nativeEvent": { + "target": 0, + }, + }, + }, + { + "name": "selectionChange", + "payload": { + "nativeEvent": { + "selection": { + "end": 6, + "start": 0, + }, + }, + }, + }, + { + "name": "keyPress", + "payload": { + "nativeEvent": { + "key": "Backspace", + }, + }, + }, + { + "name": "change", + "payload": { + "nativeEvent": { + "eventCount": 0, + "target": 0, + "text": "", + }, + }, + }, + { + "name": "changeText", + "payload": "", + }, + { + "name": "selectionChange", + "payload": { + "nativeEvent": { + "selection": { + "end": 0, + "start": 0, + }, + }, + }, + }, + { + "name": "endEditing", + "payload": { + "nativeEvent": { + "target": 0, + "text": "", + }, + }, + }, + { + "name": "blur", + "payload": { + "nativeEvent": { + "target": 0, + }, + }, + }, +] +`; + +exports[`clear() supports defaultValue prop: defaultValue: "Hello Default!" 1`] = ` +[ + { + "name": "focus", + "payload": { + "nativeEvent": { + "target": 0, + }, + }, + }, + { + "name": "selectionChange", + "payload": { + "nativeEvent": { + "selection": { + "end": 14, + "start": 0, + }, + }, + }, + }, + { + "name": "keyPress", + "payload": { + "nativeEvent": { + "key": "Backspace", + }, + }, + }, + { + "name": "change", + "payload": { + "nativeEvent": { + "eventCount": 0, + "target": 0, + "text": "", + }, + }, + }, + { + "name": "changeText", + "payload": "", + }, + { + "name": "selectionChange", + "payload": { + "nativeEvent": { + "selection": { + "end": 0, + "start": 0, + }, + }, + }, + }, + { + "name": "endEditing", + "payload": { + "nativeEvent": { + "target": 0, + "text": "", + }, + }, + }, + { + "name": "blur", + "payload": { + "nativeEvent": { + "target": 0, + }, + }, + }, +] +`; + +exports[`clear() supports multiline: value: "Hello World! +How are you?" multiline: true, 1`] = ` +[ + { + "name": "focus", + "payload": { + "nativeEvent": { + "target": 0, + }, + }, + }, + { + "name": "selectionChange", + "payload": { + "nativeEvent": { + "selection": { + "end": 25, + "start": 0, + }, + }, + }, + }, + { + "name": "keyPress", + "payload": { + "nativeEvent": { + "key": "Backspace", + }, + }, + }, + { + "name": "textInput", + "payload": { + "nativeEvent": { + "previousText": "Hello World! +How are you?", + "range": { + "end": 0, + "start": 0, + }, + "target": 0, + "text": "", + }, + }, + }, + { + "name": "change", + "payload": { + "nativeEvent": { + "eventCount": 0, + "target": 0, + "text": "", + }, + }, + }, + { + "name": "changeText", + "payload": "", + }, + { + "name": "selectionChange", + "payload": { + "nativeEvent": { + "selection": { + "end": 0, + "start": 0, + }, + }, + }, + }, + { + "name": "contentSizeChange", + "payload": { + "nativeEvent": { + "contentSize": { + "height": 16, + "width": 0, + }, + "target": 0, + }, + }, + }, + { + "name": "endEditing", + "payload": { + "nativeEvent": { + "target": 0, + "text": "", + }, + }, + }, + { + "name": "blur", + "payload": { + "nativeEvent": { + "target": 0, + }, + }, + }, +] +`; + +exports[`clear() works when not all events have handlers 1`] = ` +[ + { + "name": "changeText", + "payload": "", + }, + { + "name": "endEditing", + "payload": { + "nativeEvent": { + "target": 0, + "text": "", + }, + }, + }, +] +`; diff --git a/src/user-event/__tests__/clear.test.tsx b/src/user-event/__tests__/clear.test.tsx new file mode 100644 index 000000000..f508df52c --- /dev/null +++ b/src/user-event/__tests__/clear.test.tsx @@ -0,0 +1,217 @@ +import * as React from 'react'; +import { View, TextInput, TextInputProps } from 'react-native'; +import { createEventLogger } from '../../test-utils/events'; +import { render, userEvent } from '../..'; + +beforeEach(() => { + jest.useRealTimers(); +}); + +function renderTextInputWithToolkit(props: TextInputProps = {}) { + const { events, logEvent } = createEventLogger(); + + const screen = render( + + ); + + const textInput = screen.getByTestId('input'); + + return { + events, + textInput, + }; +} + +describe('clear()', () => { + it('supports basic case', async () => { + jest.spyOn(Date, 'now').mockImplementation(() => 100100100100); + const { textInput, events } = renderTextInputWithToolkit({ + value: 'Hello!', + }); + + const user = userEvent.setup(); + await user.clear(textInput); + + const eventNames = events.map((e) => e.name); + expect(eventNames).toEqual([ + 'focus', + 'selectionChange', + 'keyPress', + 'change', + 'changeText', + 'selectionChange', + 'endEditing', + 'blur', + ]); + + expect(events).toMatchSnapshot('value: "Hello!'); + }); + + 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.clear(textInput); + + const eventNames = events.map((e) => e.name); + expect(eventNames).toEqual([ + 'focus', + 'selectionChange', + 'keyPress', + 'change', + 'changeText', + 'selectionChange', + 'endEditing', + 'blur', + ]); + }); + + it('supports defaultValue prop', async () => { + const { textInput, events } = renderTextInputWithToolkit({ + defaultValue: 'Hello Default!', + }); + + const user = userEvent.setup(); + await user.clear(textInput); + + const eventNames = events.map((e) => e.name); + expect(eventNames).toEqual([ + 'focus', + 'selectionChange', + 'keyPress', + '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(); + user.clear(textInput); + + expect(textInput.props.value).toBe('Hello!'); + }); + + it('does respect pointer-events prop', async () => { + const { textInput } = renderTextInputWithToolkit({ + value: 'Hello!', + pointerEvents: 'none', + }); + + const user = userEvent.setup(); + user.clear(textInput); + + 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.clear(textInput); + + const eventNames = events.map((e) => e.name); + expect(eventNames).toEqual([ + 'focus', + 'selectionChange', + 'keyPress', + 'textInput', + '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(); + const screen = render( + + ); + + const user = userEvent.setup(); + await user.clear(screen.getByTestId('input')); + + const eventNames = events.map((e) => e.name); + expect(eventNames).toEqual(['changeText', 'endEditing']); + + expect(events).toMatchSnapshot(); + }); + + it('does NOT work on View', async () => { + const screen = render(); + + const user = userEvent.setup(); + await expect( + user.clear(screen.getByTestId('input')) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"clear() 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(); + const screen = render( + + + + ); + + const user = userEvent.setup(); + await user.clear(screen.getByTestId('input')); + expect(parentHandler).not.toHaveBeenCalled(); + }); +}); diff --git a/src/user-event/clear.ts b/src/user-event/clear.ts new file mode 100644 index 000000000..46df2a22d --- /dev/null +++ b/src/user-event/clear.ts @@ -0,0 +1,59 @@ +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 { EventBuilder } from './event-builder'; +import { UserEventInstance } from './setup'; +import { dispatchEvent, wait, isEditableTextInput } from './utils'; +import { emitTypingEvents } from './type/type'; + +export async function clear( + this: UserEventInstance, + element: ReactTestInstance +): Promise { + if (!isHostTextInput(element)) { + throw new ErrorWithStack( + `clear() only supports host "TextInput" elements. Passed element has type: "${element.type}".`, + clear + ); + } + + if (!isEditableTextInput(element) || !isPointerEventEnabled(element)) { + return; + } + + // 1. Enter element + dispatchEvent(element, 'focus', EventBuilder.Common.focus()); + + // 2. Select all + const previousText = element.props.value ?? element.props.defaultValue ?? ''; + const selectionRange = { + start: 0, + end: previousText.length, + }; + dispatchEvent( + element, + 'selectionChange', + EventBuilder.TextInput.selectionChange(selectionRange) + ); + + // 3. Press backspace + const finalText = ''; + await emitTypingEvents( + this.config, + element, + 'Backspace', + finalText, + previousText + ); + + // 4. Exit element + await wait(this.config); + dispatchEvent( + element, + 'endEditing', + EventBuilder.TextInput.endEditing(finalText) + ); + + dispatchEvent(element, 'blur', EventBuilder.Common.blur()); +} diff --git a/src/user-event/index.ts b/src/user-event/index.ts index dca1719e9..ee4511ad3 100644 --- a/src/user-event/index.ts +++ b/src/user-event/index.ts @@ -14,4 +14,5 @@ export const userEvent = { setup().longPress(element, options), type: (element: ReactTestInstance, text: string, options?: TypeOptions) => setup().type(element, text, options), + clear: (element: ReactTestInstance) => setup().clear(element), }; diff --git a/src/user-event/press/press.ts b/src/user-event/press/press.ts index 663d614ad..7c4835cf7 100644 --- a/src/user-event/press/press.ts +++ b/src/user-event/press/press.ts @@ -2,13 +2,15 @@ import { ReactTestInstance } from 'react-test-renderer'; import act from '../../act'; import { getHostParent } from '../../helpers/component-tree'; import { isPointerEventEnabled } from '../../helpers/pointer-events'; -import { - isHostText, - isHostTextInput, -} from '../../helpers/host-component-names'; +import { isHostText } from '../../helpers/host-component-names'; import { EventBuilder } from '../event-builder'; import { UserEventConfig, UserEventInstance } from '../setup'; -import { dispatchEvent, wait, warnAboutRealTimersIfNeeded } from '../utils'; +import { + dispatchEvent, + isEditableTextInput, + wait, + warnAboutRealTimersIfNeeded, +} from '../utils'; import { DEFAULT_MIN_PRESS_DURATION } from './constants'; export interface PressOptions { @@ -51,7 +53,7 @@ const basePress = async ( return; } - if (isEnabledTextInput(element)) { + if (isEditableTextInput(element) && isPointerEventEnabled(element)) { await emitTextInputPressEvents(config, element, options); return; } @@ -125,14 +127,6 @@ const isPressableText = (element: ReactTestInstance) => { ); }; -const isEnabledTextInput = (element: ReactTestInstance) => { - return ( - isHostTextInput(element) && - isPointerEventEnabled(element) && - element.props.editable !== false - ); -}; - /** * Dispatches a press event sequence for Text. */ diff --git a/src/user-event/setup/setup.ts b/src/user-event/setup/setup.ts index cc6716c35..db7cbc2dc 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 { PressOptions, press, longPress } from '../press'; import { TypeOptions, type } from '../type'; +import { clear } from '../clear'; export interface UserEventSetupOptions { /** @@ -84,7 +85,7 @@ export interface UserEventInstance { ) => Promise; /** - * Simulate user pressing on given `TextInput` element and typing given text. + * Simulate user pressing on a given `TextInput` element and typing given text. * * This method will trigger the events for each character of the text: * `keyPress`, `change`, `changeText`, `endEditing`, etc. @@ -92,7 +93,7 @@ export interface UserEventInstance { * It will also trigger events connected with entering and leaving the text * input. * - * The exact events sent depend on the props of TextInput (`editable`, + * The exact events sent depend on the props of the TextInput (`editable`, * `multiline`, value, defaultValue, etc) and passed options. * * @param element TextInput element to type on @@ -108,6 +109,19 @@ export interface UserEventInstance { text: string, options?: TypeOptions ) => Promise; + + /** + * Simulate user clearing the text of a given `TextInput` element. + * + * This method will simulate: + * 1. entering TextInput + * 2. selecting all text + * 3. pressing backspace to delete all text + * 4. leaving TextInput + * + * @param element TextInput element to clear + */ + clear: (element: ReactTestInstance) => Promise; } function createInstance(config: UserEventConfig): UserEventInstance { @@ -120,6 +134,7 @@ function createInstance(config: UserEventConfig): UserEventInstance { press: press.bind(instance), longPress: longPress.bind(instance), type: type.bind(instance), + clear: clear.bind(instance), }; Object.assign(instance, api); diff --git a/src/user-event/type/__tests__/type-managed.test.tsx b/src/user-event/type/__tests__/type-managed.test.tsx index c0914b976..178fb6056 100644 --- a/src/user-event/type/__tests__/type-managed.test.tsx +++ b/src/user-event/type/__tests__/type-managed.test.tsx @@ -6,7 +6,6 @@ import { userEvent } from '../..'; beforeEach(() => { jest.useRealTimers(); - jest.clearAllMocks(); }); interface ManagedTextInputProps { diff --git a/src/user-event/type/__tests__/type.test.tsx b/src/user-event/type/__tests__/type.test.tsx index 67fb610b1..6b13b1727 100644 --- a/src/user-event/type/__tests__/type.test.tsx +++ b/src/user-event/type/__tests__/type.test.tsx @@ -6,7 +6,6 @@ import { userEvent } from '../..'; beforeEach(() => { jest.useRealTimers(); - jest.clearAllMocks(); }); function renderTextInputWithToolkit(props: TextInputProps = {}) { diff --git a/src/user-event/type/type.ts b/src/user-event/type/type.ts index 42534ce86..6ea309376 100644 --- a/src/user-event/type/type.ts +++ b/src/user-event/type/type.ts @@ -3,13 +3,8 @@ import { isHostTextInput } from '../../helpers/host-component-names'; import { EventBuilder } from '../event-builder'; import { ErrorWithStack } from '../../helpers/errors'; import { isPointerEventEnabled } from '../../helpers/pointer-events'; -import { UserEventInstance } from '../setup'; -import { - dispatchEvent, - wait, - getTextRange, - getTextContentSize, -} from '../utils'; +import { UserEventConfig, UserEventInstance } from '../setup'; +import { dispatchEvent, wait, getTextContentSize } from '../utils'; import { parseKeys } from './parseKeys'; @@ -54,12 +49,16 @@ export async function type( const previousText = element.props.value ?? currentText; currentText = applyKey(previousText, key); - await wait(this.config); - emitTypingEvents(element, key, currentText, previousText); + await emitTypingEvents( + this.config, + element, + key, + currentText, + previousText + ); } const finalText = element.props.value ?? currentText; - await wait(this.config); if (options?.submitEditing) { @@ -79,7 +78,8 @@ export async function type( dispatchEvent(element, 'blur', EventBuilder.Common.blur()); } -async function emitTypingEvents( +export async function emitTypingEvents( + config: UserEventConfig, element: ReactTestInstance, key: string, currentText: string, @@ -87,6 +87,7 @@ async function emitTypingEvents( ) { const isMultiline = element.props.multiline === true; + await wait(config); dispatchEvent(element, 'keyPress', EventBuilder.TextInput.keyPress(key)); // According to the docs only multiline TextInput emits textInput event @@ -100,10 +101,12 @@ async function emitTypingEvents( } dispatchEvent(element, 'change', EventBuilder.TextInput.change(currentText)); - dispatchEvent(element, 'changeText', currentText); - const selectionRange = getTextRange(currentText); + const selectionRange = { + start: currentText.length, + end: currentText.length, + }; dispatchEvent( element, 'selectionChange', diff --git a/src/user-event/utils/__tests__/wait.test.ts b/src/user-event/utils/__tests__/wait.test.ts index 2606bcd4f..ac89070fc 100644 --- a/src/user-event/utils/__tests__/wait.test.ts +++ b/src/user-event/utils/__tests__/wait.test.ts @@ -2,7 +2,6 @@ import { wait } from '../wait'; beforeEach(() => { jest.useRealTimers(); - jest.clearAllMocks(); }); describe('wait()', () => { diff --git a/src/user-event/utils/host-components.ts b/src/user-event/utils/host-components.ts new file mode 100644 index 000000000..1a43785be --- /dev/null +++ b/src/user-event/utils/host-components.ts @@ -0,0 +1,6 @@ +import { ReactTestInstance } from 'react-test-renderer'; +import { isHostTextInput } from '../../helpers/host-component-names'; + +export function isEditableTextInput(element: ReactTestInstance) { + return isHostTextInput(element) && element.props.editable !== false; +} diff --git a/src/user-event/utils/index.ts b/src/user-event/utils/index.ts index 56e00613b..d97431dae 100644 --- a/src/user-event/utils/index.ts +++ b/src/user-event/utils/index.ts @@ -1,5 +1,6 @@ export * from './content-size'; export * from './dispatch-event'; +export * from './host-components'; export * from './text-range'; export * from './wait'; export * from './warn-about-real-timers'; diff --git a/src/user-event/utils/text-range.ts b/src/user-event/utils/text-range.ts index 05740ecee..31a2cf593 100644 --- a/src/user-event/utils/text-range.ts +++ b/src/user-event/utils/text-range.ts @@ -2,10 +2,3 @@ export interface TextRange { start: number; end: number; } - -export function getTextRange(text: string): TextRange { - return { - start: text.length, - end: text.length, - }; -} diff --git a/website/docs/UserEvent.md b/website/docs/UserEvent.md index 55178e351..6c799d937 100644 --- a/website/docs/UserEvent.md +++ b/website/docs/UserEvent.md @@ -14,6 +14,8 @@ title: User Event - [`type()`](#type) - [Options](#options-2) - [Sequence of events](#sequence-of-events) +- [`clear()`](#clear) + - [Sequence of events](#sequence-of-events-1) :::caution User Event API is in beta stage. @@ -108,6 +110,10 @@ This helper simulates user focusing on `TextInput` element, typing `text` one ch This function supports only host `TextInput` elements. Passing other element type will result in throwing error. +:::note +This function will add text to the text already present in the text input (as specified by `value` or `defaultValue` props). In order to replace existing text, use [`clear()`](#clear) helper first. +::: + ### Options - `skipPress` - if true, `pressIn` and `pressOut` events will not be triggered. - `submitEditing` - if true, `submitEditing` event will be triggered after typing the text. @@ -141,3 +147,45 @@ The `textInput` event is sent only for mutliline text inputs. The `submitEditing` event is skipped by default. It can sent by setting `submitEditing: true` option. +## `clear()` + +```ts +clear( + element: ReactTestInstance, +} +``` + +Example +```ts +const user = userEvent.setup(); +await user.clear(textInput); +``` + +This helper simulates user clearing content of `TextInput` element. + +This function supports only host `TextInput` elements. Passing other element type will result in throwing error. + +### Sequence of events + +The sequence of events depends on `multiline` prop, as well as passed options. + +Events will not be emitted if `editable` prop is set to `false`. + +**Entering the element**: +- `focus` + +**Selecting all content**: +- `selectionChange` + +**Pressing backspace**: +- `keyPress` +- `textInput` (optional) +- `change` +- `changeText` +- `selectionChange` + +The `textInput` event is sent only for mutliline text inputs. + +**Leaving the element**: +- `endEditing` +- `blur`