From 4bbf758107ed4b2fdbde5a241f22c0f363c22104 Mon Sep 17 00:00:00 2001 From: Nikita Barsukov Date: Thu, 14 Sep 2023 15:18:55 +0300 Subject: [PATCH] feat(react): `elementPredicate` can accept asynchronous predicate (#502) --- .../core/src/lib/types/element-predicate.ts | 3 +- .../cypress/support/command.ts | 32 +---- .../cypress/support/commands/smart-tick.ts | 26 ++++ .../demo-integrations/cypress/support/e2e.ts | 4 + .../tests/react/element-predicate.cy.ts | 80 ++++++++++++ .../demo/src/pages/cypress/cypress.module.ts | 2 + .../src/pages/cypress/cypress.template.html | 2 + .../angular-wrapper.tsx | 18 +++ .../5-react-async-predicate/react-app.tsx | 77 ++++++++++++ .../src/lib/tests/elementPredicate.spec.tsx | 114 ++++++++++++++++++ .../src/lib/{ => tests}/useMaskito.spec.tsx | 2 +- projects/react/src/lib/useMaskito.ts | 43 ++++++- 12 files changed, 369 insertions(+), 34 deletions(-) create mode 100644 projects/demo-integrations/cypress/support/commands/smart-tick.ts create mode 100644 projects/demo/src/pages/cypress/examples/5-react-async-predicate/angular-wrapper.tsx create mode 100644 projects/demo/src/pages/cypress/examples/5-react-async-predicate/react-app.tsx create mode 100644 projects/react/src/lib/tests/elementPredicate.spec.tsx rename projects/react/src/lib/{ => tests}/useMaskito.spec.tsx (96%) diff --git a/projects/core/src/lib/types/element-predicate.ts b/projects/core/src/lib/types/element-predicate.ts index d75f6185d..5f53789b0 100644 --- a/projects/core/src/lib/types/element-predicate.ts +++ b/projects/core/src/lib/types/element-predicate.ts @@ -1,7 +1,8 @@ export type MaskitoElementPredicate = ( element: HTMLElement, -) => HTMLInputElement | HTMLTextAreaElement; +) => HTMLInputElement | HTMLTextAreaElement; // TODO: add `Promise` +// TODO: delete in v2.0 export type MaskitoElementPredicateAsync = ( element: HTMLElement, ) => Promise; diff --git a/projects/demo-integrations/cypress/support/command.ts b/projects/demo-integrations/cypress/support/command.ts index 2eb1ef8d5..d7495f05a 100644 --- a/projects/demo-integrations/cypress/support/command.ts +++ b/projects/demo-integrations/cypress/support/command.ts @@ -1,27 +1,7 @@ -// *********************************************** -// This example commands.js shows you how to -// create various custom commands and overwrite -// existing commands. -// -// For more comprehensive examples of custom -// commands please read more here: -// https://on.cypress.io/custom-commands -// *********************************************** -// -// -// -- This is a parent command -- -// Cypress.Commands.add('login', (email, password) => { ... }) -// -// -// -- This is a child command -- -// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) -// -// -// -- This is a dual command -- -// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) -// -// -// -- This will overwrite an existing command -- -// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) +import {smartTick} from './commands/smart-tick'; -/* eslint-disable unicorn/no-empty-file */ +Cypress.Commands.add( + 'smartTick', + {prevSubject: ['optional', 'element', 'window', 'document']}, + smartTick, +); diff --git a/projects/demo-integrations/cypress/support/commands/smart-tick.ts b/projects/demo-integrations/cypress/support/commands/smart-tick.ts new file mode 100644 index 000000000..b71184675 --- /dev/null +++ b/projects/demo-integrations/cypress/support/commands/smart-tick.ts @@ -0,0 +1,26 @@ +export function smartTick( + $subject: Cypress.PrevSubjectMap[Cypress.PrevSubject], + durationMs: number, // ms + frequencyMs = 100, // ms +): Cypress.Chainable { + const iterations = Math.ceil(durationMs / frequencyMs); + const lastIterationMs = durationMs % frequencyMs || frequencyMs; + + for (let i = 1; i <= iterations; i++) { + cy.tick(i === iterations ? lastIterationMs : frequencyMs, {log: false}); + cy.wait(0, {log: false}); // allow React hooks to process + } + + Cypress.log({ + displayName: 'smartTick', + message: `${durationMs}ms`, + consoleProps() { + return { + durationMs, + frequencyMs, + }; + }, + }); + + return cy.wrap($subject, {log: false}); +} diff --git a/projects/demo-integrations/cypress/support/e2e.ts b/projects/demo-integrations/cypress/support/e2e.ts index 787374886..81886db5c 100644 --- a/projects/demo-integrations/cypress/support/e2e.ts +++ b/projects/demo-integrations/cypress/support/e2e.ts @@ -15,6 +15,10 @@ declare global { * */ (chainer: 'have.ngControlValue'): Chainable; } + + interface Chainable { + smartTick(durationMs: number, frequencyMs?: number): Chainable; + } } } diff --git a/projects/demo-integrations/cypress/tests/react/element-predicate.cy.ts b/projects/demo-integrations/cypress/tests/react/element-predicate.cy.ts index 8b5395fc1..42553f0d7 100644 --- a/projects/demo-integrations/cypress/tests/react/element-predicate.cy.ts +++ b/projects/demo-integrations/cypress/tests/react/element-predicate.cy.ts @@ -26,4 +26,84 @@ describe('@maskito/react | Element Predicate', () => { cy.get('@input').type('992023').should('have.value', '09.09.2023'); }); }); + + describe('Async predicate works', () => { + describe('Basic async predicate (it returns promise which resolves in 2s)', () => { + beforeEach(() => { + cy.clock(); + cy.visit(DemoPath.Cypress); + cy.get('#react-async-predicate #async-predicate-2s-resolves') + .scrollIntoView() + .should('be.visible') + .as('input'); + }); + + it('does not apply mask until `elementPredicate` resolves', () => { + const typedText = 'Element predicate will resolves only in 2000 ms'; + + cy.get('@input').type(typedText); + + cy.smartTick(300); + cy.get('@input').should('have.value', typedText); + cy.smartTick(700); + cy.get('@input').should('have.value', typedText); + cy.smartTick(2000); + cy.get('@input').should('have.value', '20:00'); + }); + + it('rejects invalid character (after `elementPredicate` resolves)', () => { + cy.smartTick(2_000); + + cy.get('@input').type('0taiga_family').should('have.value', '0'); + }); + + it('automatically adds fixed characters (after `elementPredicate` resolves)', () => { + cy.smartTick(2_000); + + cy.get('@input').type('1234').should('have.value', '12:34'); + }); + + it('automatically pads time segments with zeroes for large digits (after `elementPredicate` resolves)', () => { + cy.smartTick(2_000); + + cy.get('@input').type('99').should('have.value', '09:09'); + }); + }); + + describe('race condition check', () => { + beforeEach(() => { + cy.clock(); + cy.visit(DemoPath.Cypress); + cy.get('#react-async-predicate #race-condition-check') + .scrollIntoView() + .should('be.visible') + .as('input'); + }); + + it('does not apply mask until the first (fast valid) `elementPredicate` resolves', () => { + const typedText = + 'UseEffect will be triggered in 2s and predicate will resolve only in 0.5 seconds'; + + cy.get('@input').type(typedText); + + cy.smartTick(500); // Selected predicate is longInvalidPredicate (pending state) + cy.get('@input').should('have.value', typedText); + + cy.smartTick(1000); // Selected predicate is longInvalidPredicate (still pending state) + cy.get('@input').should('have.value', typedText); + + cy.smartTick(600); // Selected predicate is fastValidPredicate (pending state) + cy.get('@input').should('have.value', typedText); + + cy.smartTick(1000); // Selected predicate is fastValidPredicate (promise is resolved) + cy.get('@input').should('have.value', '20:5'); + }); + + it('ignores the previous predicate if it resolves after the switching to new one', () => { + cy.smartTick(10_000); + + cy.get('@input').type('taiga1134 family').should('have.value', '11:34'); + }); + }); + }); }); diff --git a/projects/demo/src/pages/cypress/cypress.module.ts b/projects/demo/src/pages/cypress/cypress.module.ts index 8ece9432e..b2a686724 100644 --- a/projects/demo/src/pages/cypress/cypress.module.ts +++ b/projects/demo/src/pages/cypress/cypress.module.ts @@ -12,6 +12,7 @@ import {TestDocExample1} from './examples/1-predicate/component'; import {TestDocExample2} from './examples/2-native-max-length/component'; import {TestDocExample3} from './examples/3-mirrored-prefix-postfix/component'; import {TestDocExample4, TestPipe4} from './examples/4-runtime-postfix-changes/component'; +import {TestDocExample5} from './examples/5-react-async-predicate/angular-wrapper'; @NgModule({ imports: [ @@ -30,6 +31,7 @@ import {TestDocExample4, TestPipe4} from './examples/4-runtime-postfix-changes/c TestDocExample3, TestDocExample4, TestPipe4, + TestDocExample5, ], exports: [CypressDocPageComponent], }) diff --git a/projects/demo/src/pages/cypress/cypress.template.html b/projects/demo/src/pages/cypress/cypress.template.html index caf9af59f..fee1e3751 100644 --- a/projects/demo/src/pages/cypress/cypress.template.html +++ b/projects/demo/src/pages/cypress/cypress.template.html @@ -8,6 +8,8 @@ + + diff --git a/projects/demo/src/pages/cypress/examples/5-react-async-predicate/angular-wrapper.tsx b/projects/demo/src/pages/cypress/examples/5-react-async-predicate/angular-wrapper.tsx new file mode 100644 index 000000000..9b8a174d1 --- /dev/null +++ b/projects/demo/src/pages/cypress/examples/5-react-async-predicate/angular-wrapper.tsx @@ -0,0 +1,18 @@ +import {isPlatformBrowser} from '@angular/common'; +import {ChangeDetectionStrategy, Component, ElementRef, Inject, PLATFORM_ID} from '@angular/core'; +import {createRoot} from 'react-dom/client'; + +import {App} from './react-app'; + +@Component({ + selector: 'test-doc-example-5', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TestDocExample5 { + constructor(elementRef: ElementRef, @Inject(PLATFORM_ID) platformId: Record) { + if (isPlatformBrowser(platformId)) { + createRoot(elementRef.nativeElement).render(); + } + } +} diff --git a/projects/demo/src/pages/cypress/examples/5-react-async-predicate/react-app.tsx b/projects/demo/src/pages/cypress/examples/5-react-async-predicate/react-app.tsx new file mode 100644 index 000000000..795a018e3 --- /dev/null +++ b/projects/demo/src/pages/cypress/examples/5-react-async-predicate/react-app.tsx @@ -0,0 +1,77 @@ +// @ts-nocheck React & Vue Global JSX Types Conflicts +// TODO: Check if it still required after upgrade Vue to 3.4 (https://github.com/vuejs/core/pull/7958) +import type {MaskitoElementPredicateAsync} from '@maskito/core'; +import {MaskitoElementPredicate} from '@maskito/core'; +import {maskitoTimeOptionsGenerator} from '@maskito/kit'; +import {useMaskito} from '@maskito/react'; +import {forwardRef, useEffect, useState} from 'react'; + +const options = maskitoTimeOptionsGenerator({ + mode: 'HH:MM', +}); + +const correctPredicate: MaskitoElementPredicate = host => host.querySelector('.real-input')!; +const wrongPredicate: MaskitoElementPredicate = host => host.querySelector('input')!; + +const longCorrectPredicate: MaskitoElementPredicateAsync = host => + new Promise(resolve => { + setTimeout(() => { + resolve(correctPredicate(host)); + }, 2_000); + }); + +const longInvalidPredicate: MaskitoElementPredicateAsync = host => + new Promise(resolve => { + setTimeout(() => resolve(wrongPredicate(host)), 7_000); + }); + +const fastValidPredicate: MaskitoElementPredicateAsync = host => + new Promise(resolve => { + setTimeout(() => resolve(correctPredicate(host)), 500); + }); + +const hiddenInputStyles = { + display: 'none', +}; + +export const AwesomeInput = forwardRef((props, ref) => ( +
+ + + +
+)); + +export const App = () => { + const [useCorrectPredicate, setUseCorrectPredicate] = useState(false); + const inputRef2sec = useMaskito({options, elementPredicate: longCorrectPredicate}); + const inputRefRaceCondition = useMaskito({ + options, + elementPredicate: useCorrectPredicate ? fastValidPredicate : longInvalidPredicate, + }); + + useEffect(() => { + setTimeout(() => { + setUseCorrectPredicate(true); + }, 2_000); + }, []); + + return ( + <> + + + + + ); +}; diff --git a/projects/react/src/lib/tests/elementPredicate.spec.tsx b/projects/react/src/lib/tests/elementPredicate.spec.tsx new file mode 100644 index 000000000..a49b3ec09 --- /dev/null +++ b/projects/react/src/lib/tests/elementPredicate.spec.tsx @@ -0,0 +1,114 @@ +import { + MASKITO_DEFAULT_ELEMENT_PREDICATE, + MaskitoElementPredicate, + MaskitoElementPredicateAsync, + MaskitoOptions, +} from '@maskito/core'; +import {render, RenderResult, waitFor} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import {useMaskito} from '../useMaskito'; + +describe('@maskito/react | `elementPredicate` property', () => { + const options: MaskitoOptions = { + mask: /^\d+$/, + }; + let predicate: MaskitoElementPredicate | MaskitoElementPredicateAsync = MASKITO_DEFAULT_ELEMENT_PREDICATE; + + const correctPredicate: MaskitoElementPredicate = host => host.querySelector('.real-input')!; + const wrongPredicate: MaskitoElementPredicate = host => host.querySelector('input')!; + + const TestComponent = ({elementPredicate = predicate}) => { + const inputRef = useMaskito({options, elementPredicate}); + + return ( +
+ + + +
+ ); + }; + + let testElement: RenderResult; + + const setValue = (user: ReturnType, v: string) => + user.type(testElement.getByPlaceholderText('Enter number'), v); + const getValue = () => (testElement.getByPlaceholderText('Enter number') as HTMLInputElement).value; + + afterEach(() => { + testElement.unmount(); + }); + + describe('Sync predicate', () => { + it('applies mask to the textfield if predicate is correct', async () => { + predicate = correctPredicate; + testElement = render(); + + const user = userEvent.setup(); + + await setValue(user, '123blah45'); + expect(getValue()).toBe('12345'); + }); + + it('does not applies mask to the textfield if predicate is incorrect', async () => { + predicate = wrongPredicate; + testElement = render(); + + const user = userEvent.setup(); + + await setValue(user, '123blah45'); + expect(getValue()).toBe('123blah45'); + }); + }); + + describe('Async predicate', () => { + it('predicate resolves in next micro task', async () => { + const user = userEvent.setup(); + + predicate = host => Promise.resolve(correctPredicate(host)); + testElement = render(); + + await setValue(user, '123blah45'); + + await waitFor(() => { + expect(getValue()).toBe('12345'); + }); + }); + + it('predicate resolves in next macro task', async () => { + const user = userEvent.setup(); + + predicate = host => + new Promise(resolve => { + setTimeout(() => resolve(correctPredicate(host))); + }); + testElement = render(); + + await setValue(user, '123blah45'); + + await waitFor(() => { + expect(getValue()).toBe('12345'); + }); + }); + + it('predicate resolves in 100ms', async () => { + const user = userEvent.setup(); + + predicate = host => + new Promise(resolve => { + setTimeout(() => resolve(correctPredicate(host)), 100); + }); + testElement = render(); + + await setValue(user, '123blah45'); + + await waitFor(() => { + expect(getValue()).toBe('12345'); + }); + }); + }); +}); diff --git a/projects/react/src/lib/useMaskito.spec.tsx b/projects/react/src/lib/tests/useMaskito.spec.tsx similarity index 96% rename from projects/react/src/lib/useMaskito.spec.tsx rename to projects/react/src/lib/tests/useMaskito.spec.tsx index 2413fcfb5..e17e751fa 100644 --- a/projects/react/src/lib/useMaskito.spec.tsx +++ b/projects/react/src/lib/tests/useMaskito.spec.tsx @@ -2,7 +2,7 @@ import {MaskitoOptions} from '@maskito/core'; import {render, RenderResult} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import {useMaskito} from './useMaskito'; +import {useMaskito} from '../useMaskito'; const options: MaskitoOptions = { mask: /^\d+(,\d{0,2})?$/, diff --git a/projects/react/src/lib/useMaskito.ts b/projects/react/src/lib/useMaskito.ts index e67c6525e..6f6a40e45 100644 --- a/projects/react/src/lib/useMaskito.ts +++ b/projects/react/src/lib/useMaskito.ts @@ -3,12 +3,17 @@ import { MASKITO_DEFAULT_ELEMENT_PREDICATE, MASKITO_DEFAULT_OPTIONS, MaskitoElementPredicate, + MaskitoElementPredicateAsync, MaskitoOptions, } from '@maskito/core'; -import {RefCallback, useCallback, useState} from 'react'; +import {RefCallback, useCallback, useRef, useState} from 'react'; import {useIsomorphicLayoutEffect} from './useIsomorphicLayoutEffect'; +function isThenable(x: PromiseLike | T): x is PromiseLike { + return x && typeof x === 'object' && 'then' in x; +} + /** * Hook for convenient use of Maskito in React * @description For controlled inputs use `onInput` event @@ -28,28 +33,54 @@ export const useMaskito = ({ elementPredicate = MASKITO_DEFAULT_ELEMENT_PREDICATE, }: { options?: MaskitoOptions; - elementPredicate?: MaskitoElementPredicate; + elementPredicate?: MaskitoElementPredicate | MaskitoElementPredicateAsync; } = {}): RefCallback => { - const [element, setElement] = useState(null); + const [hostElement, setHostElement] = useState(null); + const [element, setElement] = useState( + null, + ); const onRefChange: RefCallback = useCallback( (node: HTMLElement | null) => { - setElement(node); + setHostElement(node); }, [], ); + const latestPredicateRef = useRef(elementPredicate); + + latestPredicateRef.current = elementPredicate; + + useIsomorphicLayoutEffect(() => { + if (!hostElement) { + return; + } + + const predicate = elementPredicate; + const elementOrPromise = predicate(hostElement); + + if (isThenable(elementOrPromise)) { + void elementOrPromise.then(el => { + if (latestPredicateRef.current === predicate) { + setElement(el); + } + }); + } else { + setElement(elementOrPromise); + } + }, [hostElement, elementPredicate, latestPredicateRef]); + useIsomorphicLayoutEffect(() => { if (!element) { return; } - const maskedElement = new Maskito(elementPredicate(element), options); + const maskedElement = new Maskito(element, options); return () => { maskedElement.destroy(); }; - }, [options, element, elementPredicate]); + }, [options, element]); return onRefChange; };