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 { diff --git a/src/Input/InputContext.ts b/src/Input/InputContext.ts index 7c4bd34..993fb9b 100644 --- a/src/Input/InputContext.ts +++ b/src/Input/InputContext.ts @@ -6,10 +6,10 @@ export interface InputContext { name: string; value: any; - onChange: (value: any) => 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/MultiplePatternInputContext.ts b/src/MultiplePatternInput/MultiplePatternInputContext.ts new file mode 100644 index 0000000..ae9ce27 --- /dev/null +++ b/src/MultiplePatternInput/MultiplePatternInputContext.ts @@ -0,0 +1,15 @@ +import * as PropTypes from "prop-types"; + +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 new file mode 100644 index 0000000..60ba38c --- /dev/null +++ b/src/MultiplePatternInput/MultiplePatternInputProps.ts @@ -0,0 +1,19 @@ +import * as React from "react"; +import * as PropTypes from "prop-types"; +import {Pattern} from "./Pattern"; + +export interface MultiplePatternInputProps extends React.HTMLProps { + patterns: Array; +} + +export const MultiplePatternInputPropTypes = { + patterns: PropTypes.oneOfType([ + PropTypes.instanceOf(RegExp), + PropTypes.arrayOf( + PropTypes.oneOfType([ + 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/src/PatternInput/PatternInput.tsx b/src/PatternInput/PatternInput.tsx new file mode 100644 index 0000000..46931d3 --- /dev/null +++ b/src/PatternInput/PatternInput.tsx @@ -0,0 +1,46 @@ +import * as React from "react"; +import * as PropTypes from "prop-types"; +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 { + const {regex, ...childProps} = this.props; + return ; + } + + 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/PatternInputProps.ts b/src/PatternInput/PatternInputProps.ts new file mode 100644 index 0000000..12fc23e --- /dev/null +++ b/src/PatternInput/PatternInputProps.ts @@ -0,0 +1,10 @@ +import * as React from "react"; +import * as PropTypes from "prop-types"; + +export interface PatternInputProps extends React.HTMLProps { + regex: RegExp; +} + +export const PatternInputPropTypes = { + regex: PropTypes.instanceOf(RegExp), +}; 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/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/multiple-pattern-input-specs.tsx b/tests/multiple-pattern-input-specs.tsx new file mode 100644 index 0000000..397c3ea --- /dev/null +++ b/tests/multiple-pattern-input-specs.tsx @@ -0,0 +1,159 @@ +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"; +import {simulateInputChange} from "./helpers/simulateInputChange"; + +describe("", () => { + const placeholder = () => undefined; + const name = "fieldName"; + const id = "prefix-" + (new Date()); + const defaultValue = ""; + const defaultContext = { + id, + name, + onChange: (changedValue: string) => { + spy(); + value = changedValue; + }, + onBlur: placeholder, + onFocus: placeholder, + onMount: placeholder, + value: "", + }; + let wrapper: ReactWrapper, any>; + let spy; + let value; + let pattern; + let context: FormGroupContext; + + const input = () => wrapper.find("input"); + const mountWrapper = () => { + const patterns = Array.isArray(pattern) ? pattern : [pattern]; + wrapper = mount( + , + {context} + ); + }; + + placeholder(); + + beforeEach(() => { + pattern = "123"; + value = ""; + spy = sinon.spy(); + context = defaultContext; + }); + + 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(); + wrapper.setContext({ + ...defaultContext, + 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(); + wrapper.setContext({ + ...defaultContext, + 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 = value = pattern + "1"; + + wrapper.setContext({ + ...defaultContext, + value: newValue, + }); + + input().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); + }); +}); diff --git a/tests/pattern-input-specs.tsx b/tests/pattern-input-specs.tsx new file mode 100644 index 0000000..7c6cd9f --- /dev/null +++ b/tests/pattern-input-specs.tsx @@ -0,0 +1,72 @@ +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, + onMount: 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; + }); +});