feat: user event paste (#1646)

This commit is contained in:
Maciej Jastrzebski
2024-08-16 16:44:58 +02:00
committed by GitHub
parent 46350912d3
commit 38bceed25b
6 changed files with 871 additions and 8 deletions
@@ -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,
},
},
]
`;
+224
View File
@@ -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(
<TextInput
testID="input"
onFocus={logEvent('focus')}
onBlur={logEvent('blur')}
onPressIn={logEvent('pressIn')}
onPressOut={logEvent('pressOut')}
onChange={logEvent('change')}
onChangeText={logEvent('changeText')}
onKeyPress={logEvent('keyPress')}
onSelectionChange={logEvent('selectionChange')}
onSubmitEditing={logEvent('submitEditing')}
onEndEditing={logEvent('endEditing')}
onContentSizeChange={logEvent('contentSizeChange')}
{...props}
/>,
);
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(
<TextInput
testID="input"
onChangeText={logEvent('changeText')}
onEndEditing={logEvent('endEditing')}
/>,
);
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(<View testID="input" />);
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<any>;
it('does NOT bubble up', async () => {
const parentHandler = jest.fn();
render(
<AnyView
onChangeText={parentHandler}
onChange={parentHandler}
onKeyPress={parentHandler}
onTextInput={parentHandler}
onFocus={parentHandler}
onBlur={parentHandler}
onEndEditing={parentHandler}
onPressIn={parentHandler}
onPressOut={parentHandler}
>
<TextInput testID="input" />
</AnyView>,
);
const user = userEvent.setup();
await user.paste(screen.getByTestId('input'), 'Hi!');
expect(parentHandler).not.toHaveBeenCalled();
});
});
+1
View File
@@ -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),
};
+57
View File
@@ -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<void> {
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());
}
+15
View File
@@ -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<void>;
/**
* 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<void>;
/**
* 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),
};
@@ -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**: