From 47bfc5dd9151b0679d5ee326503e3e70a5eb64f1 Mon Sep 17 00:00:00 2001 From: alexander Date: Thu, 24 Aug 2017 20:02:03 +0300 Subject: [PATCH 1/6] Create pattern input with handling change --- src/PatternInput/PatternInput.tsx | 82 +++++++++++++++++++++++++++ src/PatternInput/PatternInputProps.ts | 18 ++++++ 2 files changed, 100 insertions(+) create mode 100644 src/PatternInput/PatternInput.tsx create mode 100644 src/PatternInput/PatternInputProps.ts diff --git a/src/PatternInput/PatternInput.tsx b/src/PatternInput/PatternInput.tsx new file mode 100644 index 0000000..582cd24 --- /dev/null +++ b/src/PatternInput/PatternInput.tsx @@ -0,0 +1,82 @@ +import * as React from "react"; +import {Input} from "../Input/Input"; +import {PatternInputProps, PatternInputPropTypes} from "./PatternInputProps"; + +export class PatternInput extends React.Component { + public static propTypes = PatternInputPropTypes; + + public render(): JSX.Element { + return ; + } + + protected handleChange = (event: any) => { + Array.isArray(this.props.patterns) + ? this.handleArrayPattern(event) + : this.handleSinglePattern(event); + }; + + protected handleSinglePattern = (event: any) => { + if (!event.target.value.match(this.props.patterns)) { + return; + } + + }; + + protected handleArrayPattern = (event: any) => { + let endOfStringReached = false; + /* Variable for storing target pattern. */ + /* After each loop iteration this pattern will be concatenated with used pattern. */ + let targetPattern = /^/; + const patterns: Array = this.props.patterns; + const {value} = event.target; + + for (const pattern of patterns) { + + if (!(pattern instanceof RegExp)) { + /* Save current pattern, because it will be updated. */ + const prevPattern = targetPattern; + /* Update target pattern using passed string. */ + targetPattern = new RegExp(targetPattern.source + pattern); + + /* If passed string was ignored, add it to the target value. */ + if (!value.match(targetPattern) && !(`${value}${pattern}`).match(targetPattern)) { + /* Get string that satisfies the prev pattern. */ + const match = value.match(prevPattern); + const {length} = match[0]; + /* Insert passed string on position that equals to length of matched string. */ + event.target.value = `${value.slice(0, length)}${pattern}${value.slice(length)}`; + break; + } + + /* If value can be concatenated, then there is no more characters in target value. */ + /* Add passed string and call onChange. */ + if (endOfStringReached) { + event.target.value += pattern; + break; + } + + /* If user decided to erase text, check, if this string is last in target value.*/ + /* If so, remove it from target value and call onChange. */ + if (value.match(new RegExp(targetPattern.source + "$"))) { + event.target.value = value.match(prevPattern)[0]; + break; + } + continue; + } + + /* Update target pattern with current pattern. */ + targetPattern = new RegExp(targetPattern.source + pattern.source); + + /* Ignore invalid value and end of patterns. */ + if (!value.match(targetPattern) || endOfStringReached) { + return; + } + + /* Check if target value matches pattern totally. */ + if (value.match(new RegExp(targetPattern.source + "$"))) { + /* If so, passed strings can be added to*/ + endOfStringReached = true; + } + } + }; +} diff --git a/src/PatternInput/PatternInputProps.ts b/src/PatternInput/PatternInputProps.ts new file mode 100644 index 0000000..e095058 --- /dev/null +++ b/src/PatternInput/PatternInputProps.ts @@ -0,0 +1,18 @@ +import * as React from "react"; +import * as PropTypes from "prop-types"; + +export interface PatternInputProps extends React.HTMLProps { + patterns: Array; +} + +export const PatternInputPropTypes = { + patterns: PropTypes.oneOfType([ + PropTypes.instanceOf(RegExp), + PropTypes.arrayOf( + PropTypes.oneOfType([ + PropTypes.instanceOf(RegExp), + PropTypes.string, + ]) + ), + ]), +}; From 3d2e9e02e8faf6ec1c19abf661ef4f4c2f709f5d Mon Sep 17 00:00:00 2001 From: alexander Date: Tue, 29 Aug 2017 20:40:16 +0300 Subject: [PATCH 2/6] Split pattern input to certain components (with single pattern and with several patterns); create focus/blur handlers for multiple patterns input --- .../MuitlplePatternInput.tsx | 102 ++++++++++++++++++ .../MultiplePatternInputProps.ts | 18 ++++ src/PatternInput/PatternInput.tsx | 66 +----------- src/PatternInput/PatternInputProps.ts | 12 +-- 4 files changed, 123 insertions(+), 75 deletions(-) create mode 100644 src/MultiplePatternInput/MuitlplePatternInput.tsx create mode 100644 src/MultiplePatternInput/MultiplePatternInputProps.ts diff --git a/src/MultiplePatternInput/MuitlplePatternInput.tsx b/src/MultiplePatternInput/MuitlplePatternInput.tsx new file mode 100644 index 0000000..ba51d11 --- /dev/null +++ b/src/MultiplePatternInput/MuitlplePatternInput.tsx @@ -0,0 +1,102 @@ +import * as React from "react"; +import {Input} from "../Input/Input"; +import {MultiplePatternInputProps, MultiplePatternInputPropTypes} from "./MultiplePatternInputProps"; + +export class PatternInput extends React.Component { + public static propTypes = MultiplePatternInputPropTypes; + + public render(): JSX.Element { + return ; + } + + protected handleChange = (event: any) => { + let endOfStringReached = false; + /* Variable for storing target pattern. */ + /* After each loop iteration this pattern will be concatenated with used pattern. */ + let targetPattern = /^/; + const patterns: Array = this.props.patterns; + const {value} = event.target; + + for (const pattern of patterns) { + + if (!(pattern instanceof RegExp)) { + /* Save current pattern, because it will be updated. */ + const prevPattern = targetPattern; + /* Update target pattern using passed string. */ + targetPattern = new RegExp(targetPattern.source + pattern); + + /* If passed string was ignored, add it to the target value. */ + if (!value.match(targetPattern) && !(`${value}${pattern}`).match(targetPattern)) { + /* Get string that satisfies the prev pattern. */ + const match = value.match(prevPattern); + const {length} = match[0]; + /* Insert passed string on position that equals to length of matched string. */ + event.target.value = value.slice(0, length) + pattern + value.slice(length); + break; + } + + /* If value can be concatenated, then there is no more characters in target value. */ + /* Add passed string and call onChange. */ + if (endOfStringReached) { + event.target.value += pattern; + break; + } + + /* If user decided to erase text, check, if this string is last in target value.*/ + /* If so, remove it from target value and call onChange. */ + if (value.match(new RegExp(targetPattern.source + "$"))) { + event.target.value = value.match(prevPattern)[0]; + break; + } + continue; + } + + /* Update target pattern with current pattern. */ + targetPattern = new RegExp(targetPattern.source + pattern.source); + + /* Ignore invalid value and end of patterns. */ + if (!value.match(targetPattern) || endOfStringReached) { + return; + } + + /* Check if target value matches pattern totally. */ + if (value.match(new RegExp(targetPattern.source + "$"))) { + /* If so, passed strings can be added to*/ + endOfStringReached = true; + } + } + + this.callOnChange(event); + }; + + protected handleFocus = (event: any) => { + if ( + this.props.patterns[0] instanceof RegExp + || event.target.value !== "" + ) { + return; + } + + event.target.value = this.props.patterns[0]; + this.callOnChange(event); + }; + + protected handleBlur = (event: any) => { + if ( + this.props.patterns[0] instanceof RegExp + || event.target.value !== this.props.patterns[0] + ) { + return; + } + + event.target.value = ""; + this.callOnChange(event); + }; + + protected callOnChange = (event: any) => { + this.props.onChange && this.props.onChange(event); + if (!event.defaultPrevented) { + this.context.onChange(event.currentTarget.value); + } + }; +} diff --git a/src/MultiplePatternInput/MultiplePatternInputProps.ts b/src/MultiplePatternInput/MultiplePatternInputProps.ts new file mode 100644 index 0000000..3f1459c --- /dev/null +++ b/src/MultiplePatternInput/MultiplePatternInputProps.ts @@ -0,0 +1,18 @@ +import * as React from "react"; +import * as PropTypes from "prop-types"; + +export interface MultiplePatternInputProps extends React.HTMLProps { + patterns: Array; +} + +export const MultiplePatternInputPropTypes = { + patterns: PropTypes.oneOfType([ + PropTypes.instanceOf(RegExp), + PropTypes.arrayOf( + PropTypes.oneOfType([ + PropTypes.instanceOf(RegExp), + PropTypes.string, + ]) + ), + ]), +}; diff --git a/src/PatternInput/PatternInput.tsx b/src/PatternInput/PatternInput.tsx index 582cd24..16faabf 100644 --- a/src/PatternInput/PatternInput.tsx +++ b/src/PatternInput/PatternInput.tsx @@ -10,73 +10,9 @@ export class PatternInput extends React.Component { } protected handleChange = (event: any) => { - Array.isArray(this.props.patterns) - ? this.handleArrayPattern(event) - : this.handleSinglePattern(event); - }; - - protected handleSinglePattern = (event: any) => { - if (!event.target.value.match(this.props.patterns)) { + if (!event.target.value.match(this.props.regex)) { return; } }; - - protected handleArrayPattern = (event: any) => { - let endOfStringReached = false; - /* Variable for storing target pattern. */ - /* After each loop iteration this pattern will be concatenated with used pattern. */ - let targetPattern = /^/; - const patterns: Array = this.props.patterns; - const {value} = event.target; - - for (const pattern of patterns) { - - if (!(pattern instanceof RegExp)) { - /* Save current pattern, because it will be updated. */ - const prevPattern = targetPattern; - /* Update target pattern using passed string. */ - targetPattern = new RegExp(targetPattern.source + pattern); - - /* If passed string was ignored, add it to the target value. */ - if (!value.match(targetPattern) && !(`${value}${pattern}`).match(targetPattern)) { - /* Get string that satisfies the prev pattern. */ - const match = value.match(prevPattern); - const {length} = match[0]; - /* Insert passed string on position that equals to length of matched string. */ - event.target.value = `${value.slice(0, length)}${pattern}${value.slice(length)}`; - break; - } - - /* If value can be concatenated, then there is no more characters in target value. */ - /* Add passed string and call onChange. */ - if (endOfStringReached) { - event.target.value += pattern; - break; - } - - /* If user decided to erase text, check, if this string is last in target value.*/ - /* If so, remove it from target value and call onChange. */ - if (value.match(new RegExp(targetPattern.source + "$"))) { - event.target.value = value.match(prevPattern)[0]; - break; - } - continue; - } - - /* Update target pattern with current pattern. */ - targetPattern = new RegExp(targetPattern.source + pattern.source); - - /* Ignore invalid value and end of patterns. */ - if (!value.match(targetPattern) || endOfStringReached) { - return; - } - - /* Check if target value matches pattern totally. */ - if (value.match(new RegExp(targetPattern.source + "$"))) { - /* If so, passed strings can be added to*/ - endOfStringReached = true; - } - } - }; } diff --git a/src/PatternInput/PatternInputProps.ts b/src/PatternInput/PatternInputProps.ts index e095058..12fc23e 100644 --- a/src/PatternInput/PatternInputProps.ts +++ b/src/PatternInput/PatternInputProps.ts @@ -2,17 +2,9 @@ import * as React from "react"; import * as PropTypes from "prop-types"; export interface PatternInputProps extends React.HTMLProps { - patterns: Array; + regex: RegExp; } export const PatternInputPropTypes = { - patterns: PropTypes.oneOfType([ - PropTypes.instanceOf(RegExp), - PropTypes.arrayOf( - PropTypes.oneOfType([ - PropTypes.instanceOf(RegExp), - PropTypes.string, - ]) - ), - ]), + regex: PropTypes.instanceOf(RegExp), }; From 6a5320b32670267d93f2e543dc5114711fb2fbd2 Mon Sep 17 00:00:00 2001 From: alexander Date: Fri, 1 Sep 2017 18:09:13 +0300 Subject: [PATCH 3/6] Improve pattern input and perform tests for it --- package-lock.json | 16 ++++++ src/Input/Input.tsx | 5 +- src/Input/InputContext.ts | 7 +-- src/PatternInput/PatternInput.tsx | 33 ++++++++++-- src/PatternInput/PatternInputContext.ts | 9 ++++ src/PatternInput/index.ts | 2 + tests/bootstrap.ts | 6 +-- tests/form-group-specs.tsx | 6 +-- tests/helpers/simulateInputChange.ts | 16 ++++++ tests/pattern-input-specs.tsx | 71 +++++++++++++++++++++++++ 10 files changed, 156 insertions(+), 15 deletions(-) create mode 100644 src/PatternInput/PatternInputContext.ts create mode 100644 src/PatternInput/index.ts create mode 100644 tests/helpers/simulateInputChange.ts create mode 100644 tests/pattern-input-specs.tsx diff --git a/package-lock.json b/package-lock.json index 58516ee..4485142 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1908,6 +1908,16 @@ "object-assign": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz" } }, + "cross-env": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-5.0.5.tgz", + "integrity": "sha1-Q4PTZNlmCHPdGFs5ivO/717//vM=", + "dev": true, + "requires": { + "cross-spawn": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", + "is-windows": "1.0.1" + } + }, "cross-spawn": { "version": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", "integrity": "sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=", @@ -3667,6 +3677,12 @@ "integrity": "sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=", "dev": true }, + "is-windows": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.1.tgz", + "integrity": "sha1-MQ23D3QtJZoWo2kgK1GvhCMzENk=", + "dev": true + }, "isarray": { "version": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", diff --git a/src/Input/Input.tsx b/src/Input/Input.tsx index b05825d..b1fca68 100644 --- a/src/Input/Input.tsx +++ b/src/Input/Input.tsx @@ -1,5 +1,4 @@ import * as React from "react"; -import {FormGroupContext, FormGroupContextTypes} from "../FormGroup/FormGroupContext"; import {InputContext, InputContextTypes} from "./InputContext"; export class Input extends React.Component> { @@ -30,14 +29,14 @@ export class Input extends React.Component> { protected handleBlur = (event: any) => { this.props.onBlur && this.props.onBlur(event); if (!event.defaultPrevented) { - this.context.onBlur(); + this.context.onBlur(event); } }; protected handleFocus = (event: any) => { this.props.onFocus && this.props.onFocus(event); if (!event.defaultPrevented) { - this.context.onFocus(); + this.context.onFocus(event); } }; } diff --git a/src/Input/InputContext.ts b/src/Input/InputContext.ts index 05308b6..1d7aeaf 100644 --- a/src/Input/InputContext.ts +++ b/src/Input/InputContext.ts @@ -1,3 +1,4 @@ +import * as React from "react"; import * as PropTypes from "prop-types"; export interface InputContext { @@ -6,9 +7,9 @@ export interface InputContext { name: string; value: any; - onChange: (value: any) => void; - onFocus: () => void; - onBlur: () => void; + onChange: (value: string) => void; + onFocus: (event: Event) => void; + onBlur: (event: Event) => void; } export const InputContextTypes = { diff --git a/src/PatternInput/PatternInput.tsx b/src/PatternInput/PatternInput.tsx index 16faabf..b4cceb1 100644 --- a/src/PatternInput/PatternInput.tsx +++ b/src/PatternInput/PatternInput.tsx @@ -1,18 +1,45 @@ import * as React from "react"; import {Input} from "../Input/Input"; import {PatternInputProps, PatternInputPropTypes} from "./PatternInputProps"; +import {PatternInputContext, PatternInputContextTypes} from "./PatternInputContext"; +import {InputContext, InputContextTypes} from "../Input/InputContext"; export class PatternInput extends React.Component { public static propTypes = PatternInputPropTypes; + public static childContextTypes = PatternInputContextTypes; + public static contextTypes = InputContextTypes; + public context: InputContext; + + public getChildContext(): PatternInputContext { + return { + onChange: this.handleChange, + }; + } + + protected get regex(): RegExp { + let {regex} = this.props; + + if (regex.source[0] !== "^") { + regex = new RegExp("^" + regex.source); + } + if (regex.source[regex.source.length] !== "$") { + regex = new RegExp(regex.source + "$"); + } + + return regex; + } + public render(): JSX.Element { - return ; + const {regex, ...childProps} = this.props; + return ; } - protected handleChange = (event: any) => { - if (!event.target.value.match(this.props.regex)) { + protected handleChange = (value: string) => { + if (value && !value.match(this.regex)) { return; } + this.context.onChange(value); }; } diff --git a/src/PatternInput/PatternInputContext.ts b/src/PatternInput/PatternInputContext.ts new file mode 100644 index 0000000..832e354 --- /dev/null +++ b/src/PatternInput/PatternInputContext.ts @@ -0,0 +1,9 @@ +import * as PropTypes from "prop-types"; + +export interface PatternInputContext { + onChange: (event: string) => void +} + +export const PatternInputContextTypes = { + onChange: PropTypes.func.isRequired, +}; diff --git a/src/PatternInput/index.ts b/src/PatternInput/index.ts new file mode 100644 index 0000000..4dbf1f2 --- /dev/null +++ b/src/PatternInput/index.ts @@ -0,0 +1,2 @@ +export * from "./PatternInput"; +export * from "./PatternInputProps"; diff --git a/tests/bootstrap.ts b/tests/bootstrap.ts index e03ca1d..406fff7 100644 --- a/tests/bootstrap.ts +++ b/tests/bootstrap.ts @@ -1,4 +1,4 @@ -import * as chai from 'chai'; -import * as chaiEnzyme from 'chai-enzyme'; +import * as chai from "chai"; +import * as chaiEnzyme from "chai-enzyme"; -chai.use(chaiEnzyme()); \ No newline at end of file +chai.use(chaiEnzyme()); diff --git a/tests/form-group-specs.tsx b/tests/form-group-specs.tsx index e192c7f..f8570ed 100644 --- a/tests/form-group-specs.tsx +++ b/tests/form-group-specs.tsx @@ -44,14 +44,14 @@ describe("", () => { it("Should add class `has-focus` when `context.onFocus` triggered", () => { expect(wrapper).not.to.have.className("has-focus"); - node.getChildContext().onFocus(); + node.getChildContext().onFocus(new Event("focus")); expect(wrapper).to.have.className("has-focus"); }); it("Should remove class `has-focus` when `context.onBlur` triggered", () => { - node.getChildContext().onFocus(); + node.getChildContext().onFocus(new Event("focus")); expect(wrapper).to.have.className("has-focus"); - node.getChildContext().onBlur(); + node.getChildContext().onBlur(new Event("focus")); expect(wrapper).not.to.have.className("has-focus"); }); diff --git a/tests/helpers/simulateInputChange.ts b/tests/helpers/simulateInputChange.ts new file mode 100644 index 0000000..490cef3 --- /dev/null +++ b/tests/helpers/simulateInputChange.ts @@ -0,0 +1,16 @@ +import * as React from "react"; +import {ReactWrapper} from "enzyme"; + +export function simulateInputChange(wrapper: ReactWrapper, any>, + value: string = "") { + + const inputWrapper = wrapper.find("input"); + const input = inputWrapper.getDOMNode() as HTMLInputElement; + input.value = ""; + inputWrapper.simulate("change"); + + for (const char of value) { + input.value += char; + inputWrapper.simulate("change"); + } +} diff --git a/tests/pattern-input-specs.tsx b/tests/pattern-input-specs.tsx new file mode 100644 index 0000000..c267634 --- /dev/null +++ b/tests/pattern-input-specs.tsx @@ -0,0 +1,71 @@ +import * as React from "react"; +import {expect} from "chai"; +import {PatternInput} from "../src/PatternInput/PatternInput" +import {mount, ReactWrapper} from "enzyme"; +import {FormGroupContext} from "../src/FormGroup/FormGroupContext"; +import {simulateInputChange} from "./helpers/simulateInputChange" +import * as sinon from "sinon"; + +describe("", () => { + const name = "fieldName"; + const id = "prefix-" + (new Date()); + const maxPatternLength = 2; + const placeholder = () => undefined; + let wrapper: ReactWrapper, any>; + let onChange; + let spy; + let value; + placeholder(); + + beforeEach(() => { + value = ""; + spy = sinon.spy(); + onChange = (changedValue: string) => { + spy(); + value = changedValue; + }; + const context: FormGroupContext = { + id, + name, + onChange, + onBlur: placeholder, + onFocus: placeholder, + value: "", + }; + wrapper = mount( + , + {context} + ); + }); + + afterEach(() => { + wrapper.unmount(); + }); + + it("Should not call change value that doesn't match pattern", () => { + simulateInputChange(wrapper, "a"); + expect(spy.calledTwice).to.be.false; + }); + + it("Should call change for value that matches pattern", () => { + simulateInputChange(wrapper, "12"); + expect(spy.called).to.be.true; + }); + + it("Shouldn't call change for too big value", () => { + const newValue = "123"; + simulateInputChange(wrapper, newValue); + expect(value).to.be.equal(newValue.substr(0, maxPatternLength)); + }); + + it("Should trigger change only for digits", () => { + const newValue = "1d"; + simulateInputChange(wrapper, newValue); + expect(value).to.be.equal(newValue.replace(/\D/, "")); + }); + + it("Should trigger change on empty value", () => { + simulateInputChange(wrapper); + expect(spy.calledOnce).to.be.true; + }); +}); From ef19f27b387d4e359743c7d933b38e99cef1ceac Mon Sep 17 00:00:00 2001 From: alexander Date: Fri, 1 Sep 2017 19:38:37 +0300 Subject: [PATCH 4/6] Refactor multiple pattern input and create tests for it --- .../MuitlplePatternInput.tsx | 59 ++++--- .../MultiplePatternInputContext.ts | 13 ++ tests/multiple-pattern-input-specs.tsx | 148 ++++++++++++++++++ 3 files changed, 196 insertions(+), 24 deletions(-) create mode 100644 src/MultiplePatternInput/MultiplePatternInputContext.ts create mode 100644 tests/multiple-pattern-input-specs.tsx diff --git a/src/MultiplePatternInput/MuitlplePatternInput.tsx b/src/MultiplePatternInput/MuitlplePatternInput.tsx index ba51d11..7952f9b 100644 --- a/src/MultiplePatternInput/MuitlplePatternInput.tsx +++ b/src/MultiplePatternInput/MuitlplePatternInput.tsx @@ -1,21 +1,37 @@ import * as React from "react"; import {Input} from "../Input/Input"; import {MultiplePatternInputProps, MultiplePatternInputPropTypes} from "./MultiplePatternInputProps"; +import {InputContext, InputContextTypes} from "../Input/InputContext"; +import {MultiplePatternInputContext, MultiplePatternInputContextTypes} from "./MultiplePatternInputContext"; -export class PatternInput extends React.Component { +export class MultiplePatternInput extends React.Component { public static propTypes = MultiplePatternInputPropTypes; + public static childContextTypes = MultiplePatternInputContextTypes; + public static contextTypes = InputContextTypes; + public context: InputContext; + + public escapeRegExp(text) { + return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"); + } + + public getChildContext(): MultiplePatternInputContext { + return { + onChange: this.handleChange, + onFocus: this.handleFocus, + onBlur: this.handleBlur, + }; + } public render(): JSX.Element { - return ; + return ; } - protected handleChange = (event: any) => { + protected handleChange = (value: string) => { let endOfStringReached = false; /* Variable for storing target pattern. */ /* After each loop iteration this pattern will be concatenated with used pattern. */ let targetPattern = /^/; - const patterns: Array = this.props.patterns; - const {value} = event.target; + const patterns: Array = this.props.patterns; for (const pattern of patterns) { @@ -23,7 +39,7 @@ export class PatternInput extends React.Component { /* Save current pattern, because it will be updated. */ const prevPattern = targetPattern; /* Update target pattern using passed string. */ - targetPattern = new RegExp(targetPattern.source + pattern); + targetPattern = new RegExp(targetPattern.source + this.escapeRegExp(pattern)); /* If passed string was ignored, add it to the target value. */ if (!value.match(targetPattern) && !(`${value}${pattern}`).match(targetPattern)) { @@ -31,21 +47,21 @@ export class PatternInput extends React.Component { const match = value.match(prevPattern); const {length} = match[0]; /* Insert passed string on position that equals to length of matched string. */ - event.target.value = value.slice(0, length) + pattern + value.slice(length); - break; + value = value.slice(0, length) + pattern + value.slice(length); + continue; } /* If value can be concatenated, then there is no more characters in target value. */ /* Add passed string and call onChange. */ if (endOfStringReached) { - event.target.value += pattern; + value += pattern; break; } /* If user decided to erase text, check, if this string is last in target value.*/ /* If so, remove it from target value and call onChange. */ if (value.match(new RegExp(targetPattern.source + "$"))) { - event.target.value = value.match(prevPattern)[0]; + value = value.match(prevPattern)[0]; break; } continue; @@ -66,37 +82,32 @@ export class PatternInput extends React.Component { } } - this.callOnChange(event); + this.context.onChange(value); }; protected handleFocus = (event: any) => { if ( this.props.patterns[0] instanceof RegExp - || event.target.value !== "" + || event.currentTarget.value !== "" ) { return; } - event.target.value = this.props.patterns[0]; - this.callOnChange(event); + event.currentTarget.value = this.props.patterns[0].toString(); + this.context.onChange(event.currentTarget.value); + this.context.onFocus(event); }; protected handleBlur = (event: any) => { if ( this.props.patterns[0] instanceof RegExp - || event.target.value !== this.props.patterns[0] + || event.currentTarget.value !== this.props.patterns[0] ) { return; } - event.target.value = ""; - this.callOnChange(event); - }; - - protected callOnChange = (event: any) => { - this.props.onChange && this.props.onChange(event); - if (!event.defaultPrevented) { - this.context.onChange(event.currentTarget.value); - } + event.currentTarget.value = ""; + this.context.onChange(event.currentTarget.value); + this.context.onBlur(event); }; } diff --git a/src/MultiplePatternInput/MultiplePatternInputContext.ts b/src/MultiplePatternInput/MultiplePatternInputContext.ts new file mode 100644 index 0000000..546ba42 --- /dev/null +++ b/src/MultiplePatternInput/MultiplePatternInputContext.ts @@ -0,0 +1,13 @@ +import * as PropTypes from "prop-types"; + +export interface MultiplePatternInputContext { + onChange: (event: string) => void + onFocus: (event: Event) => void + onBlur: (event: Event) => void +} + +export const MultiplePatternInputContextTypes = { + onChange: PropTypes.func.isRequired, + onFocus: PropTypes.func.isRequired, + onBlur: PropTypes.func.isRequired, +}; diff --git a/tests/multiple-pattern-input-specs.tsx b/tests/multiple-pattern-input-specs.tsx new file mode 100644 index 0000000..11aca9d --- /dev/null +++ b/tests/multiple-pattern-input-specs.tsx @@ -0,0 +1,148 @@ +import * as React from "react"; +import {expect} from "chai"; +import {mount, ReactWrapper} from "enzyme"; +import {FormGroupContext} from "../src/FormGroup/FormGroupContext"; +import * as sinon from "sinon"; +import {MultiplePatternInput} from "../src/MultiplePatternInput/MuitlplePatternInput"; +import {simulateInputChange} from "./helpers/simulateInputChange"; + +describe("", () => { + const name = "fieldName"; + const id = "prefix-" + (new Date()); + const defaultValue = ""; + let wrapper: ReactWrapper, any>; + let spy; + let value; + let pattern; + let context: FormGroupContext; + + const changeValue = (event: any) => value = event.target.value; + const input = () => wrapper.find("input"); + const mountWrapper = () => { + const patterns = Array.isArray(pattern) ? pattern : [pattern]; + wrapper = mount( + , + {context} + ); + }; + + beforeEach(() => { + pattern = "123"; + value = ""; + spy = sinon.spy(); + context = { + id, + name, + onChange: (changedValue: string) => { + spy(); + value = changedValue; + }, + onBlur: changeValue, + onFocus: changeValue, + value: "", + }; + }); + + afterEach(() => { + wrapper.unmount(); + }); + + it("Should change value on focus", () => { + mountWrapper(); + + expect(value).to.be.equal(defaultValue); + input().simulate("focus"); + expect(value).to.be.equal(pattern); + }); + + it("Should not change on focus when value is not empty", () => { + mountWrapper(); + + expect(value).to.be.equal(defaultValue); + (input().getDOMNode() as HTMLInputElement).value = "1"; + input().simulate("focus"); + expect(value).to.be.not.equal(pattern); + expect(value).to.be.equal(defaultValue); + }); + + it("Should remove first string pattern on blur event and match", () => { + mountWrapper(); + + (input().getDOMNode() as HTMLInputElement).value = pattern; + input().simulate("blur"); + expect(value).to.be.equal(defaultValue); + }); + + it("Should not remove first string pattern on blur event when value doesn't match", () => { + mountWrapper(); + + const newValue = pattern + "1"; + (input().getDOMNode() as HTMLInputElement).value = newValue; + input() + .simulate("change") + .simulate("blur"); + expect(value).to.be.not.equal(defaultValue); + expect(value).to.be.equal(newValue); + }); + + it("Should add first string pattern after change", () => { + const initial = "1"; + mountWrapper(); + + expect(value).to.be.equal(defaultValue); + simulateInputChange(wrapper, initial); + expect(value).to.be.not.equal(pattern); + expect(value).to.be.equal(`${pattern}${initial}`); + }); + + it("Should append string pattern after filling regex pattern", () => { + pattern = [ + /\d{1,2}/, + ":", + /\d{1,2}/, + ]; + mountWrapper(); + + const newValue = "123"; + simulateInputChange(wrapper, newValue); + + const firstPatternsLength = 2; + const expectedValue = newValue.slice(0, firstPatternsLength) + + pattern[1] + + newValue.slice(firstPatternsLength); + expect(value).to.be.equal(expectedValue); + }); + + it("Should remove string pattern when target value ends on it", () => { + pattern = [ + /\d{1,2}/, + ":", + /\d{1,2}/, + ]; + mountWrapper(); + + const newValue = `12${pattern[1]}`; + (input().getDOMNode() as HTMLInputElement).value = newValue; + input().simulate("change"); + + const expectedValue = newValue.match(pattern[0])[0]; + expect(value).to.be.equal(expectedValue); + }); + + it("Should correctly work with several patterns and with escaping special chars ('.')", () => { + const newValue = "123456"; + pattern = [ + /\d/, + " ", + /\d{1,3}/, + ".", + /\d{1,2}/, + ]; + mountWrapper(); + + simulateInputChange(wrapper, newValue); + + const expectedValue = "1 234.56"; + expect(value).to.be.equal(expectedValue); + }); +}); From 3a6a3d5afa4dfdee589ec5a3a8e0d7e4886942d8 Mon Sep 17 00:00:00 2001 From: alexander Date: Mon, 4 Sep 2017 12:47:01 +0300 Subject: [PATCH 5/6] Return correct value type in InputContext --- src/Input/InputContext.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Input/InputContext.ts b/src/Input/InputContext.ts index 71b9953..7c4bd34 100644 --- a/src/Input/InputContext.ts +++ b/src/Input/InputContext.ts @@ -6,7 +6,7 @@ export interface InputContext { name: string; value: any; - onChange: (value: string) => void; + onChange: (value: any) => void; onFocus: () => void; onBlur: () => void; onMount: (ref: HTMLElement) => void; From 84c8afc9f4dcc549b04eee1ffe1f19bd64eed042 Mon Sep 17 00:00:00 2001 From: alexander Date: Mon, 11 Sep 2017 14:33:25 +0300 Subject: [PATCH 6/6] Improve pattern input; perform setting pointer position Stage commit --- src/FormGroup/FormGroup.tsx | 4 +- src/Input/BaseInput.tsx | 1 + src/Input/InputContext.ts | 4 +- .../MuitlplePatternInput.tsx | 112 ---------- .../MultiplePatternInputContext.ts | 2 + .../MultiplePatternInputProps.ts | 5 +- .../MultlplePatternInput.tsx | 209 ++++++++++++++++++ src/MultiplePatternInput/Pattern.ts | 14 ++ src/MultiplePatternInput/index.ts | 4 + tests/multiple-pattern-input-specs.tsx | 2 +- 10 files changed, 238 insertions(+), 119 deletions(-) delete mode 100644 src/MultiplePatternInput/MuitlplePatternInput.tsx create mode 100644 src/MultiplePatternInput/MultlplePatternInput.tsx create mode 100644 src/MultiplePatternInput/Pattern.ts create mode 100644 src/MultiplePatternInput/index.ts diff --git a/src/FormGroup/FormGroup.tsx b/src/FormGroup/FormGroup.tsx index 30b0abc..a3bd10d 100644 --- a/src/FormGroup/FormGroup.tsx +++ b/src/FormGroup/FormGroup.tsx @@ -51,12 +51,12 @@ export class FormGroup extends React.Component { this.context.onUnmount(this.props.name); } - public handleChange = (value: any) => this.context.onChange(this.props.name, value); + public handleChange = async (value: any) => this.context.onChange(this.props.name, value); public handleBlur = () => this.setState({isFocused: false}); public handleFocus = () => this.setState({isFocused: true}); - public handleMount = (ref: HTMLElement) => this.context.onMount(this.props.name, ref); + public handleMount = async (ref: HTMLElement) => this.context.onMount(this.props.name, ref); public get value(): ModelValue | undefined { return this.context.values.find( diff --git a/src/Input/BaseInput.tsx b/src/Input/BaseInput.tsx index e03d09d..151192c 100644 --- a/src/Input/BaseInput.tsx +++ b/src/Input/BaseInput.tsx @@ -13,6 +13,7 @@ export class BaseInput extends React.Component void; + onChange: (value: any) => Promise; onFocus: () => void; onBlur: () => void; - onMount: (ref: HTMLElement) => void; + onMount: (ref: HTMLElement) => Promise; } export const InputContextTypes = { diff --git a/src/MultiplePatternInput/MuitlplePatternInput.tsx b/src/MultiplePatternInput/MuitlplePatternInput.tsx deleted file mode 100644 index 0b6b7a7..0000000 --- a/src/MultiplePatternInput/MuitlplePatternInput.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import * as React from "react"; -import * as PropTypes from "prop-types"; -import {Input} from "../Input/Input"; -import {MultiplePatternInputProps, MultiplePatternInputPropTypes} from "./MultiplePatternInputProps"; -import {InputContext, InputContextTypes} from "../Input/InputContext"; -import {MultiplePatternInputContext, MultiplePatternInputContextTypes} from "./MultiplePatternInputContext"; - -export class MultiplePatternInput extends React.Component { - public static propTypes = MultiplePatternInputPropTypes; - public static childContextTypes = MultiplePatternInputContextTypes; - public static contextTypes = InputContextTypes; - public context: InputContext; - - public escapeRegExp(text) { - return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"); - } - - public getChildContext(): MultiplePatternInputContext { - return { - onChange: this.handleChange, - onFocus: this.handleFocus, - onBlur: this.handleBlur, - }; - } - - public render(): JSX.Element { - return ; - } - - protected handleChange = (value: string) => { - let endOfStringReached = false; - /* Variable for storing target pattern. */ - /* After each loop iteration this pattern will be concatenated with used pattern. */ - let targetPattern = /^/; - const patterns: Array = this.props.patterns; - - for (const pattern of patterns) { - - if (!(pattern instanceof RegExp)) { - /* Save current pattern, because it will be updated. */ - const prevPattern = targetPattern; - /* Update target pattern using passed string. */ - targetPattern = new RegExp(targetPattern.source + this.escapeRegExp(pattern)); - - /* If passed string was ignored, add it to the target value. */ - if (!value.match(targetPattern) && !(`${value}${pattern}`).match(targetPattern)) { - /* Get string that satisfies the prev pattern. */ - const match = value.match(prevPattern); - const {length} = match[0]; - /* Insert passed string on position that equals to length of matched string. */ - value = value.slice(0, length) + pattern + value.slice(length); - continue; - } - - /* If value can be concatenated, then there is no more characters in target value. */ - /* Add passed string and call onChange. */ - if (endOfStringReached) { - value += pattern; - break; - } - - /* If user decided to erase text, check, if this string is last in target value.*/ - /* If so, remove it from target value and call onChange. */ - if (value.match(new RegExp(targetPattern.source + "$"))) { - value = value.match(prevPattern)[0]; - break; - } - continue; - } - - /* Update target pattern with current pattern. */ - targetPattern = new RegExp(targetPattern.source + pattern.source); - - /* Ignore invalid value and end of patterns. */ - if (!value.match(targetPattern) || endOfStringReached) { - return; - } - - /* Check if target value matches pattern totally. */ - if (value.match(new RegExp(targetPattern.source + "$"))) { - /* If so, passed strings can be added to*/ - endOfStringReached = true; - } - } - - this.context.onChange(value); - }; - - protected handleFocus = () => { - if ( - this.props.patterns[0] instanceof RegExp - || this.context.value !== "" - ) { - return; - } - - this.context.onChange(this.props.patterns[0].toString()); - this.context.onFocus(); - }; - - protected handleBlur = () => { - if ( - this.props.patterns[0] instanceof RegExp - || this.context.value !== this.props.patterns[0] - ) { - return; - } - - this.context.onChange(""); - this.context.onBlur(); - }; -} diff --git a/src/MultiplePatternInput/MultiplePatternInputContext.ts b/src/MultiplePatternInput/MultiplePatternInputContext.ts index 1220060..ae9ce27 100644 --- a/src/MultiplePatternInput/MultiplePatternInputContext.ts +++ b/src/MultiplePatternInput/MultiplePatternInputContext.ts @@ -4,10 +4,12 @@ export interface MultiplePatternInputContext { onChange: (event: string) => void onFocus: () => void onBlur: () => void + onMount: (ref: HTMLInputElement) => void } export const MultiplePatternInputContextTypes = { onChange: PropTypes.func.isRequired, onFocus: PropTypes.func.isRequired, onBlur: PropTypes.func.isRequired, + onMount: PropTypes.func.isRequired, }; diff --git a/src/MultiplePatternInput/MultiplePatternInputProps.ts b/src/MultiplePatternInput/MultiplePatternInputProps.ts index 3f1459c..60ba38c 100644 --- a/src/MultiplePatternInput/MultiplePatternInputProps.ts +++ b/src/MultiplePatternInput/MultiplePatternInputProps.ts @@ -1,8 +1,9 @@ import * as React from "react"; import * as PropTypes from "prop-types"; +import {Pattern} from "./Pattern"; export interface MultiplePatternInputProps extends React.HTMLProps { - patterns: Array; + patterns: Array; } export const MultiplePatternInputPropTypes = { @@ -10,7 +11,7 @@ export const MultiplePatternInputPropTypes = { PropTypes.instanceOf(RegExp), PropTypes.arrayOf( PropTypes.oneOfType([ - PropTypes.instanceOf(RegExp), + PropTypes.instanceOf(Pattern), PropTypes.string, ]) ), diff --git a/src/MultiplePatternInput/MultlplePatternInput.tsx b/src/MultiplePatternInput/MultlplePatternInput.tsx new file mode 100644 index 0000000..4591898 --- /dev/null +++ b/src/MultiplePatternInput/MultlplePatternInput.tsx @@ -0,0 +1,209 @@ +import * as React from "react"; +import * as PropTypes from "prop-types"; +import {Input} from "../Input/Input"; +import {MultiplePatternInputProps, MultiplePatternInputPropTypes} from "./MultiplePatternInputProps"; +import {InputContext, InputContextTypes} from "../Input/InputContext"; +import {MultiplePatternInputContext, MultiplePatternInputContextTypes} from "./MultiplePatternInputContext"; +import {Pattern} from "./Pattern"; + +export class MultiplePatternInput extends React.Component { + public static propTypes = MultiplePatternInputPropTypes; + public static childContextTypes = MultiplePatternInputContextTypes; + public static contextTypes = InputContextTypes; + public context: InputContext; + + protected input: HTMLInputElement; + + public escapeRegExp(text) { + return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"); + } + + public get patternsLength() { + return this.props.patterns + .reduce((carry, current) => { + return carry + current.length; + }, 0); + } + + public get patternsGroup() { + return new RegExp( + "[" + + this.props.patterns + .filter((pattern) => pattern instanceof Pattern) + .reduce((carry: string, current: Pattern) => { + return carry + current.regex.source; + }, "") + + "]" + ); + } + + public getChildContext(): MultiplePatternInputContext { + return { + onChange: this.handleChange, + onFocus: this.handleFocus, + onBlur: this.handleBlur, + onMount: this.handleMount, + }; + } + + public render(): JSX.Element { + return ; + } + + protected handleMount = async (ref: HTMLElement) => { + this.input = ref as HTMLInputElement; + await this.context.onMount(ref); + }; + + protected handleChange = async (sourceValue: string) => { + if (sourceValue === "") { + return; + } + + const value = this.clearValue(sourceValue); + let targetValue = ""; + + for (const char of value || [""]) { + const result = this.process(targetValue + char, sourceValue); + if (result === false) { + continue; + } + targetValue = result; + } + + const cursorPosition = this.getCursorPosition(targetValue); + + await this.context.onChange(targetValue.substr(0, this.patternsLength)); + + if (this.input) { + this.input.setSelectionRange(cursorPosition, cursorPosition); + } + }; + + protected process = (value: string, sourceValue: string) => { + let targetPattern = /^/; + let endOfStringReached = false; + + const patterns: Array = this.props.patterns; + + for (const pattern of patterns) { + if (!(pattern instanceof Pattern)) { + if (value === "") { + return pattern; + } + + const prevPattern = targetPattern; + /* Update target pattern using passed string. */ + targetPattern = new RegExp(targetPattern.source + this.escapeRegExp(pattern)); + + /* If value can be concatenated, then there is no more characters in target value. */ + /* Add passed string and call onChange. */ + if (endOfStringReached) { + if ( + sourceValue.length < this.context.value.length + && this.context.value.endsWith(pattern) + ) { + return sourceValue.substr(0, this.context.value.length - pattern.length - 1); + } + return value + pattern; + } + /* If passed string was ignored, add it to the target value. */ + if (!value.match(targetPattern) && !(`${value}${pattern}`).match(targetPattern)) { + /* Get string that satisfies the prev pattern. */ + const match = value.match(prevPattern); + const {length} = match[0]; + /* Insert passed string on position that equals to length of matched string. */ + value = value.slice(0, length) + pattern + value.slice(length); + } + + continue; + } + + for (let i = 1; i <= pattern.length; i++) { + if (endOfStringReached) { + return value; + } + + targetPattern = new RegExp(targetPattern.source + pattern.regex.source); + if (!value.match(targetPattern)) { + return false; + } + + if (value.match(new RegExp(targetPattern.source + "$"))) { + endOfStringReached = true; + } + } + } + + return value; + }; + + protected getCursorPosition = (value: string): number => { + const currentLength = this.context.value.length; + + let start: any = false; + + for (let i = 0; i < currentLength; i++) { + if (value[i] !== this.context.value[i]) { + start = i; + break; + } + } + const maxLength = Math.max(currentLength, value.length); + if (start === false) { + return maxLength; + } + + let count = 1; + if (value.length < currentLength) { + return start; + } + + while ( + (value[start + count] !== this.context.value[start]) + && (start + count < maxLength) + ) { + count++; + } + return start + count; + }; + + protected clearValue = (value: string) => { + for (const pattern of this.props.patterns) { + if (pattern instanceof Pattern) { + continue; + } + + for (const char of pattern) { + const patternIndex = value.indexOf(char); + if (patternIndex === -1) { + break; + } + value = value.substr(0, patternIndex) + value.substr(patternIndex + 1); + } + } + return value; + }; + + protected handleFocus = async () => { + if ( + this.props.patterns[0] instanceof Pattern + || !!this.context.value + ) { + return; + } + + await this.context.onChange(this.props.patterns[0].toString()); + }; + + protected handleBlur = async () => { + if ( + this.props.patterns[0] instanceof Pattern + || this.context.value !== this.props.patterns[0] + ) { + return; + } + + await this.context.onChange(""); + }; +} diff --git a/src/MultiplePatternInput/Pattern.ts b/src/MultiplePatternInput/Pattern.ts new file mode 100644 index 0000000..7df7580 --- /dev/null +++ b/src/MultiplePatternInput/Pattern.ts @@ -0,0 +1,14 @@ +export interface PatternInterface { + regex: RegExp; + length: number; +} + +export class Pattern implements PatternInterface { + public regex: RegExp; + public length: number; + + constructor(regex, length) { + this.regex = regex; + this.length = length; + } +} diff --git a/src/MultiplePatternInput/index.ts b/src/MultiplePatternInput/index.ts new file mode 100644 index 0000000..dc43994 --- /dev/null +++ b/src/MultiplePatternInput/index.ts @@ -0,0 +1,4 @@ +export * from "./MultlplePatternInput"; +export * from "./MultiplePatternInputContext"; +export * from "./MultiplePatternInputProps"; +export * from "./Pattern"; diff --git a/tests/multiple-pattern-input-specs.tsx b/tests/multiple-pattern-input-specs.tsx index 45ff711..397c3ea 100644 --- a/tests/multiple-pattern-input-specs.tsx +++ b/tests/multiple-pattern-input-specs.tsx @@ -3,7 +3,7 @@ import {expect} from "chai"; import {mount, ReactWrapper} from "enzyme"; import {FormGroupContext} from "../src/FormGroup/FormGroupContext"; import * as sinon from "sinon"; -import {MultiplePatternInput} from "../src/MultiplePatternInput/MuitlplePatternInput"; +import {MultiplePatternInput} from "../src/MultiplePatternInput"; import {simulateInputChange} from "./helpers/simulateInputChange"; describe("", () => {