Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(react): elementPredicate can accept asynchronous predicate #502

Merged
merged 6 commits into from
Sep 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
);
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