Skip to content

Commit

Permalink
feat(react): elementPredicate can accept asynchronous predicate (#502)
Browse files Browse the repository at this point in the history
  • Loading branch information
nsbarsukov authored Sep 14, 2023
1 parent 58531ae commit 4bbf758
Show file tree
Hide file tree
Showing 12 changed files with 369 additions and 34 deletions.
3 changes: 2 additions & 1 deletion projects/core/src/lib/types/element-predicate.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
export type MaskitoElementPredicate = (
element: HTMLElement,
) => HTMLInputElement | HTMLTextAreaElement;
) => HTMLInputElement | HTMLTextAreaElement; // TODO: add `Promise<HTMLInputElement | HTMLTextAreaElement>`

// TODO: delete in v2.0
export type MaskitoElementPredicateAsync = (
element: HTMLElement,
) => Promise<HTMLInputElement | HTMLTextAreaElement>;
32 changes: 6 additions & 26 deletions projects/demo-integrations/cypress/support/command.ts
Original file line number Diff line number Diff line change
@@ -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,
);
26 changes: 26 additions & 0 deletions projects/demo-integrations/cypress/support/commands/smart-tick.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export function smartTick(
$subject: Cypress.PrevSubjectMap<void>[Cypress.PrevSubject],
durationMs: number, // ms
frequencyMs = 100, // ms
): Cypress.Chainable<unknown> {
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});
}
4 changes: 4 additions & 0 deletions projects/demo-integrations/cypress/support/e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ declare global {
* */
(chainer: 'have.ngControlValue'): Chainable<Subject>;
}

interface Chainable {
smartTick(durationMs: number, frequencyMs?: number): Chainable;
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
});
2 changes: 2 additions & 0 deletions projects/demo/src/pages/cypress/cypress.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -30,6 +31,7 @@ import {TestDocExample4, TestPipe4} from './examples/4-runtime-postfix-changes/c
TestDocExample3,
TestDocExample4,
TestPipe4,
TestDocExample5,
],
exports: [CypressDocPageComponent],
})
Expand Down
2 changes: 2 additions & 0 deletions projects/demo/src/pages/cypress/cypress.template.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
<test-doc-example-3 id="mirrored-prefix-postfix"></test-doc-example-3>

<test-doc-example-4 id="runtime-postfix-changes"></test-doc-example-4>

<test-doc-example-5 id="react-async-predicate"></test-doc-example-5>
</div>
</ng-template>
</tui-doc-page>
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
if (isPlatformBrowser(platformId)) {
createRoot(elementRef.nativeElement).render(<App />);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>((props, ref) => (
<div ref={ref}>
<input style={hiddenInputStyles} />
<input
className="real-input"
{...props}
/>
<input style={hiddenInputStyles} />
</div>
));

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 (
<>
<AwesomeInput
ref={inputRef2sec}
id="async-predicate-2s-resolves"
placeholder="Async predicate (2s)"
/>

<AwesomeInput
ref={inputRefRaceCondition}
id="race-condition-check"
placeholder="Race condition check"
/>
</>
);
};
Loading

0 comments on commit 4bbf758

Please sign in to comment.