From 5fbbbad63554587bb4d1044779ae2fa1d79287a9 Mon Sep 17 00:00:00 2001 From: joshuayoes <37849890+joshuayoes@users.noreply.github.com> Date: Wed, 7 Aug 2024 09:57:01 -0700 Subject: [PATCH 1/6] fix: Respect maxLength prop in type() function This commit modifies the `type()` function to respect the `maxLength` prop of the input element. If the current text length exceeds the `maxLength` value, typing events will not be emitted. This ensures that the input value does not exceed the specified maximum length. 1. Create an input element with a `maxLength` prop. 2. Use the `type()` function to input text that exceeds the `maxLength` value. 3. Verify that the input value does not exceed the specified maximum length. 4. Check that no typing events are emitted once the `maxLength` is reached. Additionally, see that `yarn test` passes with an additional test added to `src/user-event/type/__tests__/type.test.tsx` --- src/user-event/type/__tests__/type.test.tsx | 32 +++++++++++++++++++++ src/user-event/type/type.ts | 4 ++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/user-event/type/__tests__/type.test.tsx b/src/user-event/type/__tests__/type.test.tsx index 4918e3f2a..0ede1964f 100644 --- a/src/user-event/type/__tests__/type.test.tsx +++ b/src/user-event/type/__tests__/type.test.tsx @@ -338,4 +338,36 @@ describe('type()', () => { await userEvent.type(screen.getByTestId('input'), 'abc'); expect(handleKeyPress).toHaveBeenCalledTimes(3); }); + + it('does respect maxLength prop', async () => { + const { events, ...queries } = renderTextInputWithToolkit({ + maxLength: 2, + }); + + const user = userEvent.setup(); + await user.type(queries.getByTestId('input'), 'abc'); + + const eventNames = events.map((e) => e.name); + expect(eventNames).toEqual([ + 'pressIn', + 'focus', + 'pressOut', + 'keyPress', + 'change', + 'changeText', + 'selectionChange', + 'keyPress', + 'change', + 'changeText', + 'selectionChange', + 'endEditing', + 'blur', + ]); + + const lastChangeTestEvent = events.filter((e) => e.name === 'changeText').pop(); + expect(lastChangeTestEvent).toMatchObject({ + name: 'changeText', + payload: 'ab', + }); + }); }); diff --git a/src/user-event/type/type.ts b/src/user-event/type/type.ts index f1117b094..5ce7d1bc3 100644 --- a/src/user-event/type/type.ts +++ b/src/user-event/type/type.ts @@ -49,7 +49,9 @@ export async function type( const previousText = element.props.value ?? currentText; currentText = applyKey(previousText, key); - await emitTypingEvents(this.config, element, key, currentText, previousText); + if (element.props.maxLength === undefined || currentText.length <= element.props.maxLength) { + await emitTypingEvents(this.config, element, key, currentText, previousText); + } } const finalText = element.props.value ?? currentText; From 86e866e1ca95e4ea39fe9abc5501b2f8074b8a2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Fri, 9 Aug 2024 15:17:39 +0200 Subject: [PATCH 2/6] refactor: code review changes --- src/user-event/clear.ts | 10 ++- src/user-event/type/__tests__/type.test.tsx | 68 +++++++++++---------- src/user-event/type/type.ts | 58 ++++++++++++------ 3 files changed, 85 insertions(+), 51 deletions(-) diff --git a/src/user-event/clear.ts b/src/user-event/clear.ts index b230fb782..ffff6973c 100644 --- a/src/user-event/clear.ts +++ b/src/user-event/clear.ts @@ -31,9 +31,15 @@ export async function clear(this: UserEventInstance, element: ReactTestInstance) }; dispatchEvent(element, 'selectionChange', EventBuilder.TextInput.selectionChange(selectionRange)); - // 3. Press backspace + // 3. Press backspace with selected text const finalText = ''; - await emitTypingEvents(this.config, element, 'Backspace', finalText, previousText); + await emitTypingEvents(element, { + config: this.config, + key: 'Backspace', + text: finalText, + previousText, + isAccepted: true, + }); // 4. Exit element await wait(this.config); diff --git a/src/user-event/type/__tests__/type.test.tsx b/src/user-event/type/__tests__/type.test.tsx index 0ede1964f..19a993f73 100644 --- a/src/user-event/type/__tests__/type.test.tsx +++ b/src/user-event/type/__tests__/type.test.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { TextInput, TextInputProps, View } from 'react-native'; -import { createEventLogger } from '../../../test-utils'; +import { createEventLogger, EventEntry } from '../../../test-utils'; import { render, screen } from '../../..'; import { userEvent } from '../..'; @@ -31,7 +31,6 @@ function renderTextInputWithToolkit(props: TextInputProps = {}) { ); return { - ...screen, events, }; } @@ -39,10 +38,10 @@ function renderTextInputWithToolkit(props: TextInputProps = {}) { describe('type()', () => { it('supports basic case', async () => { jest.spyOn(Date, 'now').mockImplementation(() => 100100100100); - const { events, ...queries } = renderTextInputWithToolkit(); + const { events } = renderTextInputWithToolkit(); const user = userEvent.setup(); - await user.type(queries.getByTestId('input'), 'abc'); + await user.type(screen.getByTestId('input'), 'abc'); const eventNames = events.map((e) => e.name); expect(eventNames).toEqual([ @@ -70,10 +69,10 @@ describe('type()', () => { it.each(['modern', 'legacy'])('works with %s fake timers', async (type) => { jest.useFakeTimers({ legacyFakeTimers: type === 'legacy' }); - const { events, ...queries } = renderTextInputWithToolkit(); + const { events } = renderTextInputWithToolkit(); const user = userEvent.setup(); - await user.type(queries.getByTestId('input'), 'abc'); + await user.type(screen.getByTestId('input'), 'abc'); const eventNames = events.map((e) => e.name); expect(eventNames).toEqual([ @@ -98,12 +97,12 @@ describe('type()', () => { }); it('supports defaultValue prop', async () => { - const { events, ...queries } = renderTextInputWithToolkit({ + const { events } = renderTextInputWithToolkit({ defaultValue: 'xxx', }); const user = userEvent.setup(); - await user.type(queries.getByTestId('input'), 'ab'); + await user.type(screen.getByTestId('input'), 'ab'); const eventNames = events.map((e) => e.name); expect(eventNames).toEqual([ @@ -126,24 +125,24 @@ describe('type()', () => { }); it('does respect editable prop', async () => { - const { events, ...queries } = renderTextInputWithToolkit({ + const { events } = renderTextInputWithToolkit({ editable: false, }); const user = userEvent.setup(); - await user.type(queries.getByTestId('input'), 'ab'); + await user.type(screen.getByTestId('input'), 'ab'); const eventNames = events.map((e) => e.name); expect(eventNames).toEqual([]); }); it('supports backspace', async () => { - const { events, ...queries } = renderTextInputWithToolkit({ + const { events } = renderTextInputWithToolkit({ defaultValue: 'xxx', }); const user = userEvent.setup(); - await user.type(queries.getByTestId('input'), '{Backspace}a'); + await user.type(screen.getByTestId('input'), '{Backspace}a'); const eventNames = events.map((e) => e.name); expect(eventNames).toEqual([ @@ -166,12 +165,12 @@ describe('type()', () => { }); it('supports multiline', async () => { - const { events, ...queries } = renderTextInputWithToolkit({ + const { events } = renderTextInputWithToolkit({ multiline: true, }); const user = userEvent.setup(); - await user.type(queries.getByTestId('input'), '{Enter}\n'); + await user.type(screen.getByTestId('input'), '{Enter}\n'); const eventNames = events.map((e) => e.name); expect(eventNames).toEqual([ @@ -198,10 +197,10 @@ describe('type()', () => { }); test('skips press events when `skipPress: true`', async () => { - const { events, ...queries } = renderTextInputWithToolkit(); + const { events } = renderTextInputWithToolkit(); const user = userEvent.setup(); - await user.type(queries.getByTestId('input'), 'a', { + await user.type(screen.getByTestId('input'), 'a', { skipPress: true, }); @@ -217,13 +216,17 @@ describe('type()', () => { 'endEditing', 'blur', ]); + + expect(lastEvent(events, 'endEditing')?.payload).toMatchObject({ + nativeEvent: { text: 'a', target: 0 }, + }); }); it('triggers submit event with `submitEditing: true`', async () => { - const { events, ...queries } = renderTextInputWithToolkit(); + const { events } = renderTextInputWithToolkit(); const user = userEvent.setup(); - await user.type(queries.getByTestId('input'), 'a', { + await user.type(screen.getByTestId('input'), 'a', { submitEditing: true, }); @@ -241,8 +244,7 @@ describe('type()', () => { 'blur', ]); - expect(events[7].name).toBe('submitEditing'); - expect(events[7].payload).toMatchObject({ + expect(lastEvent(events, 'submitEditing')?.payload).toMatchObject({ nativeEvent: { text: 'a', target: 0 }, currentTarget: {}, target: {}, @@ -339,35 +341,39 @@ describe('type()', () => { expect(handleKeyPress).toHaveBeenCalledTimes(3); }); - it('does respect maxLength prop', async () => { - const { events, ...queries } = renderTextInputWithToolkit({ - maxLength: 2, - }); + it('respects the "maxLength" prop', async () => { + const { events } = renderTextInputWithToolkit({ maxLength: 2 }); const user = userEvent.setup(); - await user.type(queries.getByTestId('input'), 'abc'); + await user.type(screen.getByTestId('input'), 'abcd'); const eventNames = events.map((e) => e.name); expect(eventNames).toEqual([ 'pressIn', 'focus', 'pressOut', - 'keyPress', + 'keyPress', // a 'change', 'changeText', 'selectionChange', - 'keyPress', + 'keyPress', // b 'change', 'changeText', 'selectionChange', + 'keyPress', // c + 'keyPress', // d 'endEditing', 'blur', ]); - const lastChangeTestEvent = events.filter((e) => e.name === 'changeText').pop(); - expect(lastChangeTestEvent).toMatchObject({ - name: 'changeText', - payload: 'ab', + expect(lastEvent(events, 'changeText')?.payload).toBe('ab'); + expect(lastEvent(events, 'endEditing')?.payload.nativeEvent).toMatchObject({ + target: 0, + text: 'ab', }); }); }); + +function lastEvent(events: EventEntry[], name: string) { + return events.filter((e) => e.name === name).pop(); +} diff --git a/src/user-event/type/type.ts b/src/user-event/type/type.ts index 5ce7d1bc3..3140c1961 100644 --- a/src/user-event/type/type.ts +++ b/src/user-event/type/type.ts @@ -47,11 +47,20 @@ export async function type( let currentText = element.props.value ?? element.props.defaultValue ?? ''; for (const key of keys) { const previousText = element.props.value ?? currentText; - currentText = applyKey(previousText, key); - - if (element.props.maxLength === undefined || currentText.length <= element.props.maxLength) { - await emitTypingEvents(this.config, element, key, currentText, previousText); + const proposedText = applyKey(previousText, key); + let isAccepted = false; + if (isTextChangeAllowed(element, proposedText)) { + currentText = proposedText; + isAccepted = true; } + + await emitTypingEvents(element, { + config: this.config, + key, + text: currentText, + previousText, + isAccepted, + }); } const finalText = element.props.value ?? currentText; @@ -66,41 +75,49 @@ export async function type( dispatchEvent(element, 'blur', EventBuilder.Common.blur()); } +type EmitTypingEventsContext = { + config: UserEventConfig; + key: string; + text: string; + previousText: string; + isAccepted?: boolean; +}; + export async function emitTypingEvents( - config: UserEventConfig, element: ReactTestInstance, - key: string, - currentText: string, - previousText: string, + { config, key, text, previousText, isAccepted }: EmitTypingEventsContext, ) { const isMultiline = element.props.multiline === true; await wait(config); dispatchEvent(element, 'keyPress', EventBuilder.TextInput.keyPress(key)); + // Platform difference (based on experiments): + // - iOS and RN Web: TextInput emits only `keyPress` event when max length has been reached + // - Android: TextInputs does not emit any events + if (isAccepted === false) { + return; + } + // According to the docs only multiline TextInput emits textInput event // @see: https://github.com/facebook/react-native/blob/42a2898617da1d7a98ef574a5b9e500681c8f738/packages/react-native/Libraries/Components/TextInput/TextInput.d.ts#L754 if (isMultiline) { - dispatchEvent( - element, - 'textInput', - EventBuilder.TextInput.textInput(currentText, previousText), - ); + dispatchEvent(element, 'textInput', EventBuilder.TextInput.textInput(text, previousText)); } - dispatchEvent(element, 'change', EventBuilder.TextInput.change(currentText)); - dispatchEvent(element, 'changeText', currentText); + dispatchEvent(element, 'change', EventBuilder.TextInput.change(text)); + dispatchEvent(element, 'changeText', text); const selectionRange = { - start: currentText.length, - end: currentText.length, + start: text.length, + end: text.length, }; dispatchEvent(element, 'selectionChange', EventBuilder.TextInput.selectionChange(selectionRange)); // According to the docs only multiline TextInput emits contentSizeChange event // @see: https://reactnative.dev/docs/textinput#oncontentsizechange if (isMultiline) { - const contentSize = getTextContentSize(currentText); + const contentSize = getTextContentSize(text); dispatchEvent( element, 'contentSizeChange', @@ -120,3 +137,8 @@ function applyKey(text: string, key: string) { return text + key; } + +function isTextChangeAllowed(element: ReactTestInstance, text: string) { + const maxLength = element.props.maxLength; + return maxLength === undefined || text.length <= maxLength; +} From 31f6b90560aacd15dc6f053b25ff87e412b15583 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Fri, 9 Aug 2024 15:24:12 +0200 Subject: [PATCH 3/6] refactor: tweaks --- src/test-utils/events.ts | 6 +- src/user-event/__tests__/clear.test.tsx | 17 ++--- .../__tests__/press.real-timers.test.tsx | 10 +-- src/user-event/press/__tests__/press.test.tsx | 14 ++-- .../type/__tests__/type-managed.test.tsx | 8 +-- src/user-event/type/__tests__/type.test.tsx | 68 ++++++++++--------- 6 files changed, 62 insertions(+), 61 deletions(-) diff --git a/src/test-utils/events.ts b/src/test-utils/events.ts index 914ccf2e7..3f7c6d5d2 100644 --- a/src/test-utils/events.ts +++ b/src/test-utils/events.ts @@ -19,6 +19,10 @@ export function createEventLogger() { return { events, logEvent }; } -export function getEventsName(events: EventEntry[]) { +export function getEventsNames(events: EventEntry[]) { return events.map((event) => event.name); } + +export function lastEventPayload(events: EventEntry[], name: string) { + return events.filter((e) => e.name === name).pop()?.payload; +} diff --git a/src/user-event/__tests__/clear.test.tsx b/src/user-event/__tests__/clear.test.tsx index 9b14c0ea2..ff6920b74 100644 --- a/src/user-event/__tests__/clear.test.tsx +++ b/src/user-event/__tests__/clear.test.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { TextInput, TextInputProps, View } from 'react-native'; -import { createEventLogger } from '../../test-utils'; +import { createEventLogger, getEventsNames } from '../../test-utils'; import { render, userEvent, screen } from '../..'; beforeEach(() => { @@ -47,8 +47,7 @@ describe('clear()', () => { const user = userEvent.setup(); await user.clear(textInput); - const eventNames = events.map((e) => e.name); - expect(eventNames).toEqual([ + expect(getEventsNames(events)).toEqual([ 'focus', 'selectionChange', 'keyPress', @@ -71,8 +70,7 @@ describe('clear()', () => { const user = userEvent.setup(); await user.clear(textInput); - const eventNames = events.map((e) => e.name); - expect(eventNames).toEqual([ + expect(getEventsNames(events)).toEqual([ 'focus', 'selectionChange', 'keyPress', @@ -92,8 +90,7 @@ describe('clear()', () => { const user = userEvent.setup(); await user.clear(textInput); - const eventNames = events.map((e) => e.name); - expect(eventNames).toEqual([ + expect(getEventsNames(events)).toEqual([ 'focus', 'selectionChange', 'keyPress', @@ -140,8 +137,7 @@ describe('clear()', () => { const user = userEvent.setup(); await user.clear(textInput); - const eventNames = events.map((e) => e.name); - expect(eventNames).toEqual([ + expect(getEventsNames(events)).toEqual([ 'focus', 'selectionChange', 'keyPress', @@ -170,8 +166,7 @@ describe('clear()', () => { const user = userEvent.setup(); await user.clear(screen.getByTestId('input')); - const eventNames = events.map((e) => e.name); - expect(eventNames).toEqual(['changeText', 'endEditing']); + expect(getEventsNames(events)).toEqual(['changeText', 'endEditing']); expect(events).toMatchSnapshot(); }); diff --git a/src/user-event/press/__tests__/press.real-timers.test.tsx b/src/user-event/press/__tests__/press.real-timers.test.tsx index e0cca3346..18f3476b4 100644 --- a/src/user-event/press/__tests__/press.real-timers.test.tsx +++ b/src/user-event/press/__tests__/press.real-timers.test.tsx @@ -7,7 +7,7 @@ import { TouchableOpacity, View, } from 'react-native'; -import { createEventLogger, getEventsName } from '../../../test-utils'; +import { createEventLogger, getEventsNames } from '../../../test-utils'; import { render, screen } from '../../..'; import { userEvent } from '../..'; import * as WarnAboutRealTimers from '../../utils/warn-about-real-timers'; @@ -34,7 +34,7 @@ describe('userEvent.press with real timers', () => { ); await user.press(screen.getByTestId('pressable')); - expect(getEventsName(events)).toEqual(['pressIn', 'press', 'pressOut']); + expect(getEventsNames(events)).toEqual(['pressIn', 'press', 'pressOut']); }); test('does not trigger event when pressable is disabled', async () => { @@ -130,7 +130,7 @@ describe('userEvent.press with real timers', () => { ); await user.press(screen.getByTestId('pressable')); - expect(getEventsName(events)).toEqual(['pressIn', 'press', 'pressOut']); + expect(getEventsNames(events)).toEqual(['pressIn', 'press', 'pressOut']); }); test('crawls up in the tree to find an element that responds to touch events', async () => { @@ -200,7 +200,7 @@ describe('userEvent.press with real timers', () => { ); await userEvent.press(screen.getByText('press me')); - expect(getEventsName(events)).toEqual(['pressIn', 'press', 'pressOut']); + expect(getEventsNames(events)).toEqual(['pressIn', 'press', 'pressOut']); }); test('does not trigger on disabled Text', async () => { @@ -254,7 +254,7 @@ describe('userEvent.press with real timers', () => { ); await userEvent.press(screen.getByPlaceholderText('email')); - expect(getEventsName(events)).toEqual(['pressIn', 'pressOut']); + expect(getEventsNames(events)).toEqual(['pressIn', 'pressOut']); }); test('does not call onPressIn and onPressOut on non editable TetInput', async () => { diff --git a/src/user-event/press/__tests__/press.test.tsx b/src/user-event/press/__tests__/press.test.tsx index 5b2c95e53..c4ff8be74 100644 --- a/src/user-event/press/__tests__/press.test.tsx +++ b/src/user-event/press/__tests__/press.test.tsx @@ -8,7 +8,7 @@ import { View, Button, } from 'react-native'; -import { createEventLogger, getEventsName } from '../../../test-utils'; +import { createEventLogger, getEventsNames } from '../../../test-utils'; import { render, screen } from '../../..'; import { userEvent } from '../..'; @@ -129,7 +129,7 @@ describe('userEvent.press with fake timers', () => { ); await user.press(screen.getByTestId('pressable')); - expect(getEventsName(events)).toEqual(['pressIn', 'press', 'pressOut']); + expect(getEventsNames(events)).toEqual(['pressIn', 'press', 'pressOut']); }); test('crawls up in the tree to find an element that responds to touch events', async () => { @@ -199,7 +199,7 @@ describe('userEvent.press with fake timers', () => { ); await userEvent.press(screen.getByText('press me')); - expect(getEventsName(events)).toEqual(['pressIn', 'press', 'pressOut']); + expect(getEventsNames(events)).toEqual(['pressIn', 'press', 'pressOut']); }); test('press works on Button', async () => { @@ -208,7 +208,7 @@ describe('userEvent.press with fake timers', () => { render(