From 75cd2ea40943651ae7cd4960d03dce7b263ef19f Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf Date: Tue, 7 Jan 2020 12:24:29 +0100 Subject: [PATCH] feat: synchronous validators (#164) * refactor(required): use different definition of a valid value * refactor: update compose-validators test step descriptions * refactor(email): update to case-insensitive regex and allow empty string * feat(validators): introduce alphaNumeric validator and test * refactor: extract is-empty helper from required * chore(validators): fix test step descriptions for alpha-numeric * feat(validators): introduce boolean validator and tests * chore: stumped out various validators * refactor: keep all validation helpers in a single file * chore: introduce test helper to run a validator for an array of values * feat(validators): introduce alphaNumeric and test * feat(validators): introduce boolean and test * feat(validators): introduce createCharacterLengthRange and test * feat(validators): introduce createNumberRange and test * chore: fix import to restore build * refactor(validators): improve email validation logic and tests * fix(cypress): adjust error message in e2e tests so they pass again * feat(validators): introduce create-equal-to and tests * chore(validators): fix typos in the test descriptions * chore(validators): amend text in createEqualTo * feat(validators): introduce integer and tests * feat(validators): introduce number and test * feat(validators): introduce password and test * refactor(validators): extract repeated test into helper function * feat(validators): introduce createPattern and test * feat(validators): introduce internationalPhoneNumber and test * feat(validators): introduce string and test * feat(validators): introduce url and test * feat(validators): introduce username and test * refactor(validators): validate range-fn arguments and delegate to helper * feat(validators): introduce createMinNumber and test * feat(validators): introduce createMinNumber and test * feat(validators): introduce createMaxCharacterLength and test * feat(validators): introduce createMinCharacterLength and test * test(create-character-length-range): throws an error with invalid args * fix(create-equal-to): throw error for invalid argument incl test * fix(create-pattern): throw error for invalid pattern argument incl test * chore(validators): add regex attribution to url * chore(validators): removed redundant helpers * chore(validators): export validators from `./src/validators/index.js` * chore(validators): fix typo * chore(validators): fix import paths and generate translations * fix(validators): alphaNumeric should allow spaces * fix(validators): remove check on other field being empty * fix(validators): use zero as lower bound in createMaxCharacterLength * fix(validators): rename password to dhis2Password to signify intent * fix(validators): rename username to dhis2Username to signify intent * fix(validators): replace broken imports * fix(validators): only strip leading plus signs from intl phone number * refactor(validators): rename `required` to `hasValue` * fix(validators): correct broken import Co-authored-by: Jan-Gerke Salomon --- .eslintignore | 1 + .../CheckboxGroup/Displays_error/index.js | 3 +- .../integration/Input/Displays_error/index.js | 5 +- .../MultiSelect/Displays_error/index.js | 3 +- .../RadioGroup/Displays_error/index.js | 3 +- .../SingleSelect/Displays_error/index.js | 3 +- .../TextArea/Displays_error/index.js | 3 +- i18n/en.pot | 81 +++++++++++++- src/locales/en/translations.json | 27 ++++- src/validators/__test__/alphaNumeric.test.js | 26 +++++ src/validators/__test__/boolean.test.js | 20 ++++ .../__test__/composeValidators.test.js | 16 +-- .../createCharacterLengthRange.test.js | 56 ++++++++++ src/validators/__test__/createEqualTo.test.js | 43 ++++++++ .../__test__/createMaxCharacterLength.test.js | 24 ++++ .../__test__/createMaxNumber.test.js | 21 ++++ .../__test__/createMinCharacterLength.test.js | 24 ++++ .../__test__/createMinNumber.test.js | 21 ++++ .../__test__/createNumberRange.test.js | 69 ++++++++++++ src/validators/__test__/createPattern.test.js | 40 +++++++ src/validators/__test__/dhis2Password.test.js | 48 ++++++++ src/validators/__test__/dhis2Username.test.js | 32 ++++++ src/validators/__test__/email.test.js | 77 ++++++++++++- src/validators/__test__/hasValue.test.js | 21 ++++ src/validators/__test__/helpers/index.js | 21 ++++ src/validators/__test__/integer.test.js | 41 +++++++ .../__test__/internationalPhoneNumber.test.js | 46 ++++++++ src/validators/__test__/number.test.js | 36 ++++++ src/validators/__test__/required.test.js | 11 -- src/validators/__test__/string.test.js | 26 +++++ src/validators/__test__/url.test.js | 103 ++++++++++++++++++ src/validators/alphaNumeric.js | 15 +++ src/validators/boolean.js | 11 ++ src/validators/createCharacterLengthRange.js | 27 +++++ src/validators/createEqualTo.js | 16 +++ src/validators/createMaxCharacterLength.js | 13 +++ src/validators/createMaxNumber.js | 13 +++ src/validators/createMinCharacterLength.js | 13 +++ src/validators/createMinNumber.js | 13 +++ src/validators/createNumberRange.js | 28 +++++ src/validators/createPattern.js | 22 ++++ src/validators/dhis2Password.js | 81 ++++++++++++++ src/validators/dhis2Username.js | 14 +++ src/validators/email.js | 39 ++++++- src/validators/hasValue.js | 8 ++ src/validators/helpers/index.js | 25 +++++ src/validators/index.js | 19 +++- src/validators/integer.js | 13 +++ src/validators/internationalPhoneNumber.js | 56 ++++++++++ src/validators/number.js | 9 ++ src/validators/required.js | 4 - src/validators/string.js | 9 ++ src/validators/url.js | 14 +++ 53 files changed, 1368 insertions(+), 45 deletions(-) create mode 100644 .eslintignore create mode 100644 src/validators/__test__/alphaNumeric.test.js create mode 100644 src/validators/__test__/boolean.test.js create mode 100644 src/validators/__test__/createCharacterLengthRange.test.js create mode 100644 src/validators/__test__/createEqualTo.test.js create mode 100644 src/validators/__test__/createMaxCharacterLength.test.js create mode 100644 src/validators/__test__/createMaxNumber.test.js create mode 100644 src/validators/__test__/createMinCharacterLength.test.js create mode 100644 src/validators/__test__/createMinNumber.test.js create mode 100644 src/validators/__test__/createNumberRange.test.js create mode 100644 src/validators/__test__/createPattern.test.js create mode 100644 src/validators/__test__/dhis2Password.test.js create mode 100644 src/validators/__test__/dhis2Username.test.js create mode 100644 src/validators/__test__/hasValue.test.js create mode 100644 src/validators/__test__/helpers/index.js create mode 100644 src/validators/__test__/integer.test.js create mode 100644 src/validators/__test__/internationalPhoneNumber.test.js create mode 100644 src/validators/__test__/number.test.js delete mode 100644 src/validators/__test__/required.test.js create mode 100644 src/validators/__test__/string.test.js create mode 100644 src/validators/__test__/url.test.js create mode 100644 src/validators/alphaNumeric.js create mode 100644 src/validators/boolean.js create mode 100644 src/validators/createCharacterLengthRange.js create mode 100644 src/validators/createEqualTo.js create mode 100644 src/validators/createMaxCharacterLength.js create mode 100644 src/validators/createMaxNumber.js create mode 100644 src/validators/createMinCharacterLength.js create mode 100644 src/validators/createMinNumber.js create mode 100644 src/validators/createNumberRange.js create mode 100644 src/validators/createPattern.js create mode 100644 src/validators/dhis2Password.js create mode 100644 src/validators/dhis2Username.js create mode 100644 src/validators/hasValue.js create mode 100644 src/validators/helpers/index.js create mode 100644 src/validators/integer.js create mode 100644 src/validators/internationalPhoneNumber.js create mode 100644 src/validators/number.js delete mode 100644 src/validators/required.js create mode 100644 src/validators/string.js create mode 100644 src/validators/url.js diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..4b4d863 --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +coverage/ \ No newline at end of file diff --git a/cypress/integration/CheckboxGroup/Displays_error/index.js b/cypress/integration/CheckboxGroup/Displays_error/index.js index 3c6c822..77a61d1 100644 --- a/cypress/integration/CheckboxGroup/Displays_error/index.js +++ b/cypress/integration/CheckboxGroup/Displays_error/index.js @@ -1,6 +1,7 @@ import '../common' import { Then } from 'cypress-cucumber-preprocessor/steps' +import { requiredMessage } from '../../../../src/validators/required.js' Then('an error message is shown', () => { - cy.get('.error').should('contain', 'Required') + cy.get('.error').should('contain', requiredMessage) }) diff --git a/cypress/integration/Input/Displays_error/index.js b/cypress/integration/Input/Displays_error/index.js index 9589992..9f20a15 100644 --- a/cypress/integration/Input/Displays_error/index.js +++ b/cypress/integration/Input/Displays_error/index.js @@ -1,10 +1,11 @@ import { Given, Then } from 'cypress-cucumber-preprocessor/steps' +import { requiredMessage } from '../../../../src/validators/required.js' Given('an empty, required Input is rendered', () => { - cy.visitStory('Input', 'Required') + cy.visitStory('Testing:Input', 'Required') cy.verifyFormValue('agree', undefined) }) Then('an error message is shown', () => { - cy.get('.error').should('contain', 'Required') + cy.get('.error').should('contain', requiredMessage) }) diff --git a/cypress/integration/MultiSelect/Displays_error/index.js b/cypress/integration/MultiSelect/Displays_error/index.js index 3c6c822..f09d0d5 100644 --- a/cypress/integration/MultiSelect/Displays_error/index.js +++ b/cypress/integration/MultiSelect/Displays_error/index.js @@ -1,6 +1,7 @@ import '../common' import { Then } from 'cypress-cucumber-preprocessor/steps' +import { requiredMessage } from '../../../../src/validators/required' Then('an error message is shown', () => { - cy.get('.error').should('contain', 'Required') + cy.get('.error').should('contain', requiredMessage) }) diff --git a/cypress/integration/RadioGroup/Displays_error/index.js b/cypress/integration/RadioGroup/Displays_error/index.js index 3c6c822..f09d0d5 100644 --- a/cypress/integration/RadioGroup/Displays_error/index.js +++ b/cypress/integration/RadioGroup/Displays_error/index.js @@ -1,6 +1,7 @@ import '../common' import { Then } from 'cypress-cucumber-preprocessor/steps' +import { requiredMessage } from '../../../../src/validators/required' Then('an error message is shown', () => { - cy.get('.error').should('contain', 'Required') + cy.get('.error').should('contain', requiredMessage) }) diff --git a/cypress/integration/SingleSelect/Displays_error/index.js b/cypress/integration/SingleSelect/Displays_error/index.js index 3c6c822..f09d0d5 100644 --- a/cypress/integration/SingleSelect/Displays_error/index.js +++ b/cypress/integration/SingleSelect/Displays_error/index.js @@ -1,6 +1,7 @@ import '../common' import { Then } from 'cypress-cucumber-preprocessor/steps' +import { requiredMessage } from '../../../../src/validators/required' Then('an error message is shown', () => { - cy.get('.error').should('contain', 'Required') + cy.get('.error').should('contain', requiredMessage) }) diff --git a/cypress/integration/TextArea/Displays_error/index.js b/cypress/integration/TextArea/Displays_error/index.js index 7738612..92ea435 100644 --- a/cypress/integration/TextArea/Displays_error/index.js +++ b/cypress/integration/TextArea/Displays_error/index.js @@ -1,4 +1,5 @@ import { Given, Then } from 'cypress-cucumber-preprocessor/steps' +import { requiredMessage } from '../../../../src/validators/required' Given('an empty, required TextArea is rendered', () => { cy.visitStory('TextArea', 'Required') @@ -6,5 +7,5 @@ Given('an empty, required TextArea is rendered', () => { }) Then('an error message is shown', () => { - cy.get('.error').should('contain', 'Required') + cy.get('.error').should('contain', requiredMessage) }) diff --git a/i18n/en.pot b/i18n/en.pot index bc17a6d..9f80523 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2019-11-06T15:56:26.431Z\n" -"PO-Revision-Date: 2019-11-06T15:56:26.431Z\n" +"POT-Creation-Date: 2019-12-17T14:35:58.462Z\n" +"PO-Revision-Date: 2019-12-17T14:35:58.462Z\n" msgid "Upload file" msgstr "" @@ -20,8 +20,81 @@ msgstr "" msgid "No file(s) selected yet" msgstr "" -msgid "Not a valid e-mail" +msgid "Please provide an alpha-numeric value" msgstr "" -msgid "Required" +msgid "Please provide a boolean value" +msgstr "" + +msgid "Please enter between {{lowerBound}} and {{upperBound}} characters" +msgstr "" + +msgid "" +"Please make sure the value of this input matches the value in " +"\"{{otherField}}\"." +msgstr "" + +msgid "Please enter a maximum of {{upperBound}} characters" +msgstr "" + +msgid "Please enter a number with a maximum of {{upperBound}}" +msgstr "" + +msgid "Please enter at least {{lowerBound}} characters" +msgstr "" + +msgid "Please enter a number of at least {{lowerBound}}" +msgstr "" + +msgid "Please enter a number between {{lowerBound}} and {{upperBound}}" +msgstr "" + +msgid "" +"Please make sure the value of this input matches the pattern " +"{{patternString}}." +msgstr "" + +msgid "Please provide a valid email address" +msgstr "" + +msgid "Please provide a round number without decimals" +msgstr "" + +msgid "Please provide a valid international phone number." +msgstr "" + +msgid "Please provide a number" +msgstr "" + +msgid "Password should be a string" +msgstr "" + +msgid "Password should be at least 8 characters long" +msgstr "" + +msgid "Password should be no longer than 34 characters" +msgstr "" + +msgid "Password should contain at least one lowercase letter" +msgstr "" + +msgid "Password should contain at least one UPPERCASE letter" +msgstr "" + +msgid "Password should contain at least one number" +msgstr "" + +msgid "Password should have at least one special character" +msgstr "" + +msgid "This is a required field" +msgstr "" + +msgid "Please provide a string" +msgstr "" + +msgid "Please provide a valid url" +msgstr "" + +msgid "Please provide a username between 1 and 255 characters" msgstr "" diff --git a/src/locales/en/translations.json b/src/locales/en/translations.json index 2d79d5c..e93104e 100644 --- a/src/locales/en/translations.json +++ b/src/locales/en/translations.json @@ -3,6 +3,29 @@ "Upload files": "", "Remove": "", "No file(s) selected yet": "", - "Not a valid e-mail": "", - "Required": "" + "Please provide an alpha-numeric value": "", + "Please provide a boolean value": "", + "Please enter between {{lowerBound}} and {{upperBound}} characters": "", + "Please make sure the value of this input matches the value in \"{{otherField}}\".": "", + "Please enter a maximum of {{upperBound}} characters": "", + "Please enter a number with a maximum of {{upperBound}}": "", + "Please enter at least {{lowerBound}} characters": "", + "Please enter a number of at least {{lowerBound}}": "", + "Please enter a number between {{lowerBound}} and {{upperBound}}": "", + "Please make sure the value of this input matches the pattern {{patternString}}.": "", + "Please provide a valid email address": "", + "Please provide a round number without decimals": "", + "Please provide a valid international phone number.": "", + "Please provide a number": "", + "Password should be a string": "", + "Password should be at least 8 characters long": "", + "Password should be no longer than 34 characters": "", + "Password should contain at least one lowercase letter": "", + "Password should contain at least one UPPERCASE letter": "", + "Password should contain at least one number": "", + "Password should have at least one special character": "", + "This is a required field": "", + "Please provide a string": "", + "Please provide a valid url": "", + "Please provide a username between 1 and 255 characters": "" } \ No newline at end of file diff --git a/src/validators/__test__/alphaNumeric.test.js b/src/validators/__test__/alphaNumeric.test.js new file mode 100644 index 0000000..fac9caf --- /dev/null +++ b/src/validators/__test__/alphaNumeric.test.js @@ -0,0 +1,26 @@ +import { alphaNumeric, invalidAlphaNumericMessage } from '../alphaNumeric.js' +import { testValidatorValues, allowsEmptyValues } from './helpers/index.js' + +describe('validator: alphaNumeric', () => { + allowsEmptyValues(alphaNumeric) + + describe('allows alpha-numeric values', () => { + testValidatorValues(alphaNumeric, undefined, [ + '123456', + 'abcdef', + 'a1b2c3', + 'A1B2C3d4e5', + 'I have spaces', + ]) + }) + + describe('rejects non-alpha-numeric values', () => { + testValidatorValues(alphaNumeric, invalidAlphaNumericMessage, [ + '.,/|~', + true, + false, + 0, + 1, + ]) + }) +}) diff --git a/src/validators/__test__/boolean.test.js b/src/validators/__test__/boolean.test.js new file mode 100644 index 0000000..a0dad9e --- /dev/null +++ b/src/validators/__test__/boolean.test.js @@ -0,0 +1,20 @@ +import { boolean, invalidBooleanMessage } from '../boolean' +import { testValidatorValues, allowsEmptyValues } from './helpers/index.js' + +describe('validator: boolean', () => { + allowsEmptyValues(boolean) + + describe('allows boolean values', () => { + testValidatorValues(boolean, undefined, [true, false]) + }) + + describe('rejects non-boolean values', () => { + testValidatorValues(boolean, invalidBooleanMessage, [ + 'text', + 3, + {}, + [], + () => {}, + ]) + }) +}) diff --git a/src/validators/__test__/composeValidators.test.js b/src/validators/__test__/composeValidators.test.js index 5678823..4e48910 100644 --- a/src/validators/__test__/composeValidators.test.js +++ b/src/validators/__test__/composeValidators.test.js @@ -1,22 +1,22 @@ import { composeValidators } from '../composeValidators' -import { required, requiredMessage } from '../required' +import { hasValue, hasValueMessage } from '../hasValue' import { email, invalidEmailMessage } from '../email' describe('composeValidators', () => { - it('should return undefined', () => { - const validator = composeValidators(required, email) + const validator = composeValidators(hasValue, email) + it('should return undefined for valid values', () => { expect(validator('test@dhis2.org')).toBe(undefined) }) - it('should return required', () => { - const validator = composeValidators(required, email) + it('should return the required message for empty values', () => { + const validator = composeValidators(hasValue, email) - expect(validator('')).toBe(requiredMessage) + expect(validator('')).toBe(hasValueMessage) }) - it('should return invalid e-mail', () => { - const validator = composeValidators(required, email) + it('should return invalid e-mail message for malformed strings', () => { + const validator = composeValidators(hasValue, email) expect(validator('test@dhis2.')).toBe(invalidEmailMessage) }) diff --git a/src/validators/__test__/createCharacterLengthRange.test.js b/src/validators/__test__/createCharacterLengthRange.test.js new file mode 100644 index 0000000..f8e28a5 --- /dev/null +++ b/src/validators/__test__/createCharacterLengthRange.test.js @@ -0,0 +1,56 @@ +import { createCharacterLengthRange } from '../createCharacterLengthRange.js' +import { testValidatorValues, allowsEmptyValues } from './helpers/index.js' +import { requiredArgumentErrorMessage } from '../helpers/index.js' + +describe('validator: createCharacterLengthRange', () => { + const betweenSixAndTenChars = createCharacterLengthRange(6, 10) + const inValidMsg = 'Please enter between 6 and 10 characters' + + it('should throw an error when lower or upper bound are not a number', () => { + expect(() => { + createCharacterLengthRange(undefined, undefined) + }).toThrowError(requiredArgumentErrorMessage) + expect(() => { + createCharacterLengthRange('test', 'test') + }).toThrowError(requiredArgumentErrorMessage) + expect(() => { + createCharacterLengthRange(1, undefined) + }).toThrowError(requiredArgumentErrorMessage) + expect(() => { + createCharacterLengthRange(undefined, 0) + }).toThrowError(requiredArgumentErrorMessage) + }) + + it('should create a function', () => { + expect(typeof betweenSixAndTenChars).toEqual('function') + }) + + allowsEmptyValues(betweenSixAndTenChars) + + describe('allows within-range strings', () => { + testValidatorValues(betweenSixAndTenChars, undefined, [ + 'abcdef', // 6 + 'abcdefgh', + 'abcdefghij', // 10 + ]) + }) + + describe('rejects non-string values', () => { + testValidatorValues(betweenSixAndTenChars, inValidMsg, [ + true, + 3, + {}, + [], + () => {}, + ]) + }) + + describe('rejects out-of-range strings', () => { + testValidatorValues(betweenSixAndTenChars, inValidMsg, [ + 'a', + 'abcde', // 5 + 'abcdefghijk', // 11 + 'abcdefghijklmnopqrstuvw', + ]) + }) +}) diff --git a/src/validators/__test__/createEqualTo.test.js b/src/validators/__test__/createEqualTo.test.js new file mode 100644 index 0000000..6e4d125 --- /dev/null +++ b/src/validators/__test__/createEqualTo.test.js @@ -0,0 +1,43 @@ +import { createEqualTo } from '../createEqualTo.js' +import { allowsEmptyValues } from './helpers/index.js' +import { requiredArgumentErrorMessage } from '../helpers/index.js' + +describe('validator: createEqualTo', () => { + const equalToFoo = createEqualTo('foo') + + it('should throw an error when key is not a string', () => { + expect(() => { + createEqualTo(undefined) + }).toThrowError(requiredArgumentErrorMessage) + expect(() => { + createEqualTo({}) + }).toThrowError(requiredArgumentErrorMessage) + }) + + it('should create a function', () => { + expect(typeof equalToFoo).toEqual('function') + }) + + allowsEmptyValues(equalToFoo) + + it('should return undefined when the fields have equal values', () => { + const sameValue = 'abcde' + + expect(equalToFoo(sameValue, { foo: sameValue })).toEqual(undefined) + }) + + it('should return an error string when the fields have inequal values', () => { + const inValidFooMsg = + 'Please make sure the value of this input matches the value in "foo".' + + expect(equalToFoo('this', { foo: 'that' })).toEqual(inValidFooMsg) + }) + + it('should use the property description in the error string if provided', () => { + const equalToBar = createEqualTo('bar', 'Barista') + const inValidBarMsg = + 'Please make sure the value of this input matches the value in "Barista".' + + expect(equalToBar('this', { bar: 'that' })).toEqual(inValidBarMsg) + }) +}) diff --git a/src/validators/__test__/createMaxCharacterLength.test.js b/src/validators/__test__/createMaxCharacterLength.test.js new file mode 100644 index 0000000..28666fa --- /dev/null +++ b/src/validators/__test__/createMaxCharacterLength.test.js @@ -0,0 +1,24 @@ +import { createMaxCharacterLength } from '../createMaxCharacterLength.js' +import { testValidatorValues } from './helpers/index.js' + +describe('validator: createMaxCharacterLength', () => { + const maxSixChars = createMaxCharacterLength(6) + const errorMessage = 'Please enter a maximum of 6 characters' + + /* + * Since createMaxCharacterLength calls createNumberRange internally + * a lot of things have been tested there and here we focus + * purely on the bounderies + */ + + describe('allows strings with a lower or equal length than the upper bound', () => { + testValidatorValues(maxSixChars, undefined, ['a', '123456']) + }) + + describe('rejects strings a length above the upper bound', () => { + testValidatorValues(maxSixChars, errorMessage, [ + '1234567', + 'some even longer text here....', + ]) + }) +}) diff --git a/src/validators/__test__/createMaxNumber.test.js b/src/validators/__test__/createMaxNumber.test.js new file mode 100644 index 0000000..c277139 --- /dev/null +++ b/src/validators/__test__/createMaxNumber.test.js @@ -0,0 +1,21 @@ +import { createMaxNumber } from '../createMaxNumber.js' +import { testValidatorValues } from './helpers/index.js' + +describe('validator: createMaxNumber', () => { + const maxSix = createMaxNumber(6) + const errorMessage = 'Please enter a number with a maximum of 6' + + /* + * Since createMaxNumber calls createNumberRange internally + * a lot of things have been tested there and here we focus + * purely on the bounderies + */ + + describe('allows numbers up to and including the upper bound', () => { + testValidatorValues(maxSix, undefined, [-1000, 0, 1, 6]) + }) + + describe('rejects numbers above the upper bound', () => { + testValidatorValues(maxSix, errorMessage, [6.000001, 7, 100000]) + }) +}) diff --git a/src/validators/__test__/createMinCharacterLength.test.js b/src/validators/__test__/createMinCharacterLength.test.js new file mode 100644 index 0000000..e75398b --- /dev/null +++ b/src/validators/__test__/createMinCharacterLength.test.js @@ -0,0 +1,24 @@ +import { createMinCharacterLength } from '../createMinCharacterLength.js' +import { testValidatorValues } from './helpers/index.js' + +describe('validator: createMinCharacterLength', () => { + const atLeastSixChars = createMinCharacterLength(6) + const errorMessage = 'Please enter at least 6 characters' + + /* + * Since createMinCharacterLength calls createCharacterLengthRange internally + * a lot of things have been tested there and here we focus + * purely on the bounderies + */ + + describe('allows strings with an equal or greater length than the lower bound', () => { + testValidatorValues(atLeastSixChars, undefined, [ + '123456', + 'an even longer string', + ]) + }) + + describe('rejects strings a length below the lower bound', () => { + testValidatorValues(atLeastSixChars, errorMessage, ['a', '12345']) + }) +}) diff --git a/src/validators/__test__/createMinNumber.test.js b/src/validators/__test__/createMinNumber.test.js new file mode 100644 index 0000000..e156545 --- /dev/null +++ b/src/validators/__test__/createMinNumber.test.js @@ -0,0 +1,21 @@ +import { createMinNumber } from '../createMinNumber.js' +import { testValidatorValues } from './helpers/index.js' + +describe('validator: createMinNumber', () => { + const atLeastSix = createMinNumber(6) + const errorMessage = 'Please enter a number of at least 6' + + /* + * Since createMinNumber calls createNumberRange internally + * a lot of things have been tested there and here we focus + * purely on the bounderies + */ + + describe('allows numbers equal to or greater than the lower bound', () => { + testValidatorValues(atLeastSix, undefined, [6, 6.00001, 1000000]) + }) + + describe('rejects numbers below the lower bound', () => { + testValidatorValues(atLeastSix, errorMessage, [-10000, 0, 1, 5.999999]) + }) +}) diff --git a/src/validators/__test__/createNumberRange.test.js b/src/validators/__test__/createNumberRange.test.js new file mode 100644 index 0000000..cc08b70 --- /dev/null +++ b/src/validators/__test__/createNumberRange.test.js @@ -0,0 +1,69 @@ +import { createNumberRange } from '../createNumberRange.js' +import { testValidatorValues, allowsEmptyValues } from './helpers/index.js' +import { requiredArgumentErrorMessage } from '../helpers/index.js' + +describe('validator: createNumberRange', () => { + const betweenSixAndTen = createNumberRange(6, 10) + const errorMessage = 'Please enter a number between 6 and 10' + + it('should throw an error when lower or upper bound are not a number', () => { + expect(() => { + createNumberRange(undefined, undefined) + }).toThrowError(requiredArgumentErrorMessage) + expect(() => { + createNumberRange('test', 'test') + }).toThrowError(requiredArgumentErrorMessage) + expect(() => { + createNumberRange(1, undefined) + }).toThrowError(requiredArgumentErrorMessage) + expect(() => { + createNumberRange(undefined, 0) + }).toThrowError(requiredArgumentErrorMessage) + }) + + it('should create a function', () => { + expect(typeof betweenSixAndTen).toEqual('function') + }) + + allowsEmptyValues(betweenSixAndTen) + + describe('allows floats, integers and string representations of numbers', () => { + testValidatorValues(betweenSixAndTen, undefined, [ + 7, + 7.1, + 0.71e1, + '7', + '7.1', + ]) + }) + + describe('allows within-range numbers', () => { + testValidatorValues(betweenSixAndTen, undefined, [ + 6, + 8, + 10, + 9.999999, + 6.000001, + ]) + }) + + describe('rejects non-numerical values', () => { + testValidatorValues(betweenSixAndTen, errorMessage, [ + 'test', + true, + {}, + [], + () => {}, + ]) + }) + + describe('rejects out-of-range numbers', () => { + testValidatorValues(betweenSixAndTen, errorMessage, [ + 3, + 5, + 5.999999, + 10.000001, + 1000000, + ]) + }) +}) diff --git a/src/validators/__test__/createPattern.test.js b/src/validators/__test__/createPattern.test.js new file mode 100644 index 0000000..3ef66d1 --- /dev/null +++ b/src/validators/__test__/createPattern.test.js @@ -0,0 +1,40 @@ +import { createPattern, invalidPatternMessage } from '../createPattern.js' +import { allowsEmptyValues } from './helpers/index.js' + +describe('validator: createPattern', () => { + const pattern = /^test$/ + const equalToTestPattern = createPattern(pattern) + + it('should throw an error when pattern is not a regex object', () => { + expect(() => { + createPattern(undefined) + }).toThrowError(invalidPatternMessage) + expect(() => { + createPattern('test') + }).toThrowError(invalidPatternMessage) + }) + + it('should create a function', () => { + expect(typeof equalToTestPattern).toEqual('function') + }) + + allowsEmptyValues(equalToTestPattern) + + it('should return undefined when the input matches the pattern', () => { + expect(equalToTestPattern('test')).toEqual(undefined) + }) + + it('should return an error string when input does not match the pattern', () => { + const escapedRegexString = '/^test$/' + const invalidMsg = `Please make sure the value of this input matches the pattern ${escapedRegexString}.` + + expect(equalToTestPattern('bad input')).toEqual(invalidMsg) + }) + + it('should return an custon error string when one was provided and input does not match the pattern', () => { + const invalidMsg = 'You should not have done this' + const withCustomMessage = createPattern(pattern, invalidMsg) + + expect(withCustomMessage('bad input')).toEqual(invalidMsg) + }) +}) diff --git a/src/validators/__test__/dhis2Password.test.js b/src/validators/__test__/dhis2Password.test.js new file mode 100644 index 0000000..9e3d64c --- /dev/null +++ b/src/validators/__test__/dhis2Password.test.js @@ -0,0 +1,48 @@ +import { dhis2Password, errorMessages } from '../dhis2Password.js' +import { testValidatorValues, allowsEmptyValues } from './helpers/index.js' + +describe('validator: dhis2Password', () => { + allowsEmptyValues(dhis2Password) + + it('should return undefined for a valid password', () => { + expect(dhis2Password('Testing123!')).toEqual(undefined) + }) + + describe('rejects value types other than string', () => { + testValidatorValues(dhis2Password, errorMessages.notString, [ + true, + 3, + {}, + [], + () => {}, + ]) + }) + + it('should return the "password too short" message if password is less than 8 characters', () => { + expect(dhis2Password('123')).toEqual(errorMessages.tooShort) + }) + + it('should return the "password too long" message if password is more than 34 characters', () => { + expect( + dhis2Password('abcdefghijklmnopqrstuvwxyz12345678910111213') + ).toEqual(errorMessages.tooLong) + }) + + it('should return the "no lowercase" message if password does not contain lower case characters', () => { + expect(dhis2Password('TESTING123!')).toEqual(errorMessages.noLowerCase) + }) + + it('should return the "no uppercase" message if password has no uppercase characters', () => { + expect(dhis2Password('testing123!')).toEqual(errorMessages.noUpperCase) + }) + + it('should return the "no number" message if password has no digits', () => { + expect(dhis2Password('Testing!')).toEqual(errorMessages.noNumber) + }) + + it('should return the "no special character" message if password has no special characters', () => { + expect(dhis2Password('Testing123')).toEqual( + errorMessages.noSpecialCharacter + ) + }) +}) diff --git a/src/validators/__test__/dhis2Username.test.js b/src/validators/__test__/dhis2Username.test.js new file mode 100644 index 0000000..8d47b2f --- /dev/null +++ b/src/validators/__test__/dhis2Username.test.js @@ -0,0 +1,32 @@ +import { dhis2Username, invalidUsernameMessage } from '../dhis2Username.js' +import { testValidatorValues, allowsEmptyValues } from './helpers/index.js' + +describe('validator: dhis2Username', () => { + allowsEmptyValues(dhis2Username) + + describe('allows all sorts of strings between 1 and 255 characters long', () => { + testValidatorValues(dhis2Username, undefined, [ + 'electricchicken', + '1', //1 + 'sssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss', //255 + 'some_username^%&*(', + 'あいうえお', + ]) + }) + + describe('rejects other data types', () => { + testValidatorValues(dhis2Username, invalidUsernameMessage, [ + 1, + true, + {}, + [], + () => {}, + ]) + }) + + describe('values that are too long', () => { + testValidatorValues(dhis2Username, invalidUsernameMessage, [ + 'ssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss', //256 + ]) + }) +}) diff --git a/src/validators/__test__/email.test.js b/src/validators/__test__/email.test.js index 90f1e97..63e740a 100644 --- a/src/validators/__test__/email.test.js +++ b/src/validators/__test__/email.test.js @@ -1,11 +1,80 @@ import { email, invalidEmailMessage } from '../email' +import { testValidatorValues, allowsEmptyValues } from './helpers/index.js' + +/* + * A comprehensive list technically valid and invalid email addresses was + * taken from: + * https://codefool.tumblr.com/post/15288874550/list-of-valid-and-invalid-email-addresses + * + * Our chosen regex does not work correctly in each case, but on balance + * it performs pretty well: all plausible email addresses are accepted + * and most malformed email addresses are rejected. + * + * I have kept the original list of values and have simply commented out + * the ones that are not evaluated correctly by our regex. + */ describe('validator: email', () => { - it('should return undefined', () => { - expect(email('test@dhis2.org')).toBe(undefined) + allowsEmptyValues(email) + + describe('allows valid email addresses', () => { + /* + * Items that have been commented out below are FALSELY REJECTED by the chosen regex + */ + testValidatorValues(email, undefined, [ + 'email@example.com', + 'firstname.lastname@example.com', + 'email@subdomain.example.com', + 'firstname+lastname@example.com', + // 'email@123.123.123.123', + 'email@[123.123.123.123]', + '"email"@example.com', + '1234567890@example.com', + 'email@example-one.com', + '_______@example.com', + 'email@example.name', + 'email@example.museum', + 'email@example.co.jp', + 'firstname-lastname@example.com', + + /* very strange but technically valid addresses */ + // 'much."more unusual"@example.com', + // 'very.unusual."@".unusual.com@example.com', + // 'very."(),:;<>[]".VERY."very@\\\\\\ "very".unusual@strange.example.com', + ]) }) - it('should return invalid message', () => { - expect(email('test@dhis2.')).toBe(invalidEmailMessage) + describe('rejects invalid email addresses', () => { + /* + * Items that have been commented out below are FALSELY ACCEPTED by the chosen regex + */ + testValidatorValues(email, invalidEmailMessage, [ + 'plainaddress', + '#@%^%#$@#$@#.com', + '@example.com', + 'Joe Smith ', + 'email.example.com', + 'email@example@example.com', + '.email@example.com', + 'email.@example.com', + 'email..email@example.com', + /* + * I think this false positive below may actualy be correct behaviour, + * see https://en.wikipedia.org/wiki/International_email) + */ + // 'あいうえお@example.com' + 'email@example.com (Joe Smith)', + 'email@example', + // 'email@-example.com', + // 'email@example.web', + 'email@111.222.333.44444', + 'email@example..com', + 'Abc..123@example.com', + + /* very strange invalid addresses */ + '"(),:;<>[]@example.com', + 'just"not"right@example.com', + 'this is"really"not\\\\allowed@example.com', + ]) }) }) diff --git a/src/validators/__test__/hasValue.test.js b/src/validators/__test__/hasValue.test.js new file mode 100644 index 0000000..d77b54e --- /dev/null +++ b/src/validators/__test__/hasValue.test.js @@ -0,0 +1,21 @@ +import { hasValue, hasValueMessage } from '../hasValue.js' +import { testValidatorValues } from './helpers/index.js' + +describe('validator: hasValue', () => { + describe('should return undefined for allowed values', () => { + testValidatorValues(hasValue, undefined, [ + 'test', + false, + true, + 0, + 1, + {}, + [], + new Date(), + ]) + }) + + describe('should return the error message for disallowed values', () => { + testValidatorValues(hasValue, hasValueMessage, ['', undefined, null]) + }) +}) diff --git a/src/validators/__test__/helpers/index.js b/src/validators/__test__/helpers/index.js new file mode 100644 index 0000000..7725a4a --- /dev/null +++ b/src/validators/__test__/helpers/index.js @@ -0,0 +1,21 @@ +const testValidatorValues = (validator, returnValue, values) => { + const returnValueStr = + returnValue === undefined ? 'undefined' : 'an error string' + + for (const value of values) { + const type = typeof value + const valueStr = type === 'object' ? JSON.stringify(value) : value + + it(`should return ${returnValueStr} for value \`${valueStr}\` of type ${type}`, () => { + expect(validator(value)).toBe(returnValue) + }) + } +} + +const allowsEmptyValues = validator => { + describe('allows empty values', () => { + testValidatorValues(validator, undefined, ['', null, undefined]) + }) +} + +export { testValidatorValues, allowsEmptyValues } diff --git a/src/validators/__test__/integer.test.js b/src/validators/__test__/integer.test.js new file mode 100644 index 0000000..c61f5e7 --- /dev/null +++ b/src/validators/__test__/integer.test.js @@ -0,0 +1,41 @@ +import { integer, invalidIntegerMessage } from '../integer.js' +import { testValidatorValues, allowsEmptyValues } from './helpers/index.js' + +describe('validator: integer', () => { + allowsEmptyValues(integer) + + describe('allows integers and string representations of integers', () => { + testValidatorValues(integer, undefined, [ + -2, + 0, + 2, + 10000, + '-2', + '0', + '2', + '10000', + 12e4, + ]) + }) + + describe('rejects other data types', () => { + testValidatorValues(integer, invalidIntegerMessage, [ + 'text', + true, + {}, + [], + () => {}, + ]) + }) + + describe('rejects floats and string representations of floats', () => { + testValidatorValues(integer, invalidIntegerMessage, [ + 0.23456, + 5.987, + 1e-12, + '0.23456', + '5.987', + '1e-12', + ]) + }) +}) diff --git a/src/validators/__test__/internationalPhoneNumber.test.js b/src/validators/__test__/internationalPhoneNumber.test.js new file mode 100644 index 0000000..00a1bb0 --- /dev/null +++ b/src/validators/__test__/internationalPhoneNumber.test.js @@ -0,0 +1,46 @@ +import { + internationalPhoneNumber, + invalidInternationalPhoneNumberMessage, +} from '../internationalPhoneNumber' +import { testValidatorValues, allowsEmptyValues } from './helpers' + +describe('validator: internationalPhoneNumber', () => { + allowsEmptyValues(internationalPhoneNumber) + + describe('allows valid phone numbers', () => { + testValidatorValues(internationalPhoneNumber, undefined, [ + '+31(0)725111889', + '068.468.0076', + '1-724-187-8238', + '(967) 211-7114', + '(978) 242-4017', + '105-535-1160', + '147-357-7565', + '(974) 309-3992', + '+1-228-630-0639', + '917.258.5059', + '888-814-8989', + ]) + }) + + describe('rejects non-string values', () => { + testValidatorValues( + internationalPhoneNumber, + invalidInternationalPhoneNumberMessage, + [true, 3, {}, [], () => {}] + ) + }) + + describe('rejects invalid phone numbers', () => { + testValidatorValues( + internationalPhoneNumber, + invalidInternationalPhoneNumberMessage, + [ + 'sometext123', // text + '+3172%^$#%*182838485868', // special characters + '+31725111889101112131415', // too long + '0031_72_5111889', // bad separators + ] + ) + }) +}) diff --git a/src/validators/__test__/number.test.js b/src/validators/__test__/number.test.js new file mode 100644 index 0000000..7b89c84 --- /dev/null +++ b/src/validators/__test__/number.test.js @@ -0,0 +1,36 @@ +import { number, invalidNumberMessage } from '../number.js' +import { testValidatorValues, allowsEmptyValues } from './helpers/index.js' + +describe('validator: number', () => { + allowsEmptyValues(number) + + describe('allows numbers and string representations of numbers', () => { + testValidatorValues(number, undefined, [ + -2, + 0, + 2, + 10000, + '-2', + '0', + '2', + '10000', + 12e4, + 0.23456, + 5.987, + 1e-12, + '0.23456', + '5.987', + '1e-12', + ]) + }) + + describe('rejects other data types', () => { + testValidatorValues(number, invalidNumberMessage, [ + 'text', + true, + {}, + [], + () => {}, + ]) + }) +}) diff --git a/src/validators/__test__/required.test.js b/src/validators/__test__/required.test.js deleted file mode 100644 index 678edc1..0000000 --- a/src/validators/__test__/required.test.js +++ /dev/null @@ -1,11 +0,0 @@ -import { required, requiredMessage } from '../required' - -describe('validator: required', () => { - it('should return undefined', () => { - expect(required('not empty')).toBe(undefined) - }) - - it('should return Required', () => { - expect(required('')).toBe(requiredMessage) - }) -}) diff --git a/src/validators/__test__/string.test.js b/src/validators/__test__/string.test.js new file mode 100644 index 0000000..696ebe6 --- /dev/null +++ b/src/validators/__test__/string.test.js @@ -0,0 +1,26 @@ +import { string, invalidStringMessage } from '../string.js' +import { testValidatorValues, allowsEmptyValues } from './helpers/index.js' + +describe('validator: string', () => { + allowsEmptyValues(string) + + describe('allows strings', () => { + testValidatorValues(string, undefined, [ + 'text', + '1', + '0.15', + 'true', + 'false', + ]) + }) + + describe('rejects other data types', () => { + testValidatorValues(string, invalidStringMessage, [ + 1, + true, + {}, + [], + () => {}, + ]) + }) +}) diff --git a/src/validators/__test__/url.test.js b/src/validators/__test__/url.test.js new file mode 100644 index 0000000..c6ea619 --- /dev/null +++ b/src/validators/__test__/url.test.js @@ -0,0 +1,103 @@ +import { url, invalidUrlMessage } from '../url.js' +import { testValidatorValues, allowsEmptyValues } from './helpers/index.js' + +/** + * List of valid and invalid URIs was sourced from + * https://formvalidation.io/guide/validators/uri + */ + +describe('validator: url', () => { + allowsEmptyValues(url) + + describe('allows valid URLs', () => { + testValidatorValues(url, undefined, [ + 'http://foo.com/blah_blah', + 'http://foo.com/blah_blah/', + 'http://foo.com/blah_blah_(wikipedia)', + 'http://foo.com/blah_blah_(wikipedia)_(again)', + 'http://www.example.com/wpstyle/?p=364', + 'https://www.example.com/foo/?bar=baz&inga=42&quux', + 'http://✪df.ws/123', + 'http://userid:password@example.com:8080', + 'http://userid:password@example.com:8080/', + 'http://userid@example.com', + 'http://userid@example.com/', + 'http://userid@example.com:8080', + 'http://userid@example.com:8080/', + 'http://userid:password@example.com', + 'http://userid:password@example.com/', + 'http://142.42.1.1/', + 'http://142.42.1.1:8080/', + 'http://➡.ws/䨹', + 'http://⌘.ws', + 'http://⌘.ws/', + 'http://foo.com/blah_(wikipedia)#cite-1', + 'http://foo.com/blah_(wikipedia)_blah#cite-1', + 'http://foo.com/unicode_(✪)_in_parens', + 'http://foo.com/(something)?after=parens', + 'http://☺.damowmow.com/', + 'http://code.google.com/events/#&product=browser', + 'http://j.mp', + 'ftp://foo.bar/baz', + 'http://foo.bar/?q=Test%20URL-encoded%20stuff', + 'http://مثال.إختبار', + 'http://例子.测试', + 'http://उदाहरण.परीक्षा', + 'http://-.~_!$&()*+,;=:%40:80%2f::::::@example.com', + 'http://1337.net', + 'http://a.b-c.de', + 'http://223.255.255.254', + /* + * The one below is classified as valid by our regex + * but it was originally in the list of invalid urls + * I think it was misclassified, so moved it into the valid list. See: + * https://www.domainit.com/support/faq.mhtml?category=Domain_FAQ&question=9 + * "A little known fact is that you CAN have multiple dashes right next to each other." + */ + 'http://a.b--c.de/', + ]) + }) + + describe('rejects invalid URLs', () => { + testValidatorValues(url, invalidUrlMessage, [ + 'http://', + 'http://.', + 'http://..', + 'http://../', + 'http://?', + 'http://??', + 'http://??/', + 'http://#', + 'http://##', + 'http://##/', + 'http://foo.bar?q=Spaces should be encoded', + '//', + '//a', + '///a', + '///', + 'http:///a', + 'foo.com', + 'rdar://1234', + 'h://test', + 'http:// shouldfail.com', + ':// should fail', + 'http://foo.bar/foo(bar)baz quux', + 'ftps://foo.bar/', + 'http://-error-.invalid/', + 'http://-a.b.co', + 'http://a.b-.co', + 'http://0.0.0.0', + 'http://10.1.1.0', + 'http://10.1.1.255', + 'http://224.1.1.1', + 'http://1.1.1.1.1', + 'http://123.123.123', + 'http://3628126748', + 'http://.www.foo.bar/', + 'http://www.foo.bar./', + 'http://.www.foo.bar./', + 'http://10.1.1.1', + 'http://10.1.1.', + ]) + }) +}) diff --git a/src/validators/alphaNumeric.js b/src/validators/alphaNumeric.js new file mode 100644 index 0000000..e7893f2 --- /dev/null +++ b/src/validators/alphaNumeric.js @@ -0,0 +1,15 @@ +import i18n from '@dhis2/d2-i18n' +import { isEmpty, isString } from './helpers/index.js' + +const ALPHA_NUMERIC_PATTERN = /^[a-z0-9 ]*$/i + +const invalidAlphaNumericMessage = i18n.t( + 'Please provide an alpha-numeric value' +) + +const alphaNumeric = value => + isEmpty(value) || (isString(value) && ALPHA_NUMERIC_PATTERN.test(value)) + ? undefined + : invalidAlphaNumericMessage + +export { alphaNumeric, invalidAlphaNumericMessage } diff --git a/src/validators/boolean.js b/src/validators/boolean.js new file mode 100644 index 0000000..2979a60 --- /dev/null +++ b/src/validators/boolean.js @@ -0,0 +1,11 @@ +import i18n from '@dhis2/d2-i18n' +import { isEmpty } from './helpers/index.js' + +const invalidBooleanMessage = i18n.t('Please provide a boolean value') + +const boolean = value => + isEmpty(value) || typeof value === 'boolean' + ? undefined + : invalidBooleanMessage + +export { boolean, invalidBooleanMessage } diff --git a/src/validators/createCharacterLengthRange.js b/src/validators/createCharacterLengthRange.js new file mode 100644 index 0000000..9e6cc2a --- /dev/null +++ b/src/validators/createCharacterLengthRange.js @@ -0,0 +1,27 @@ +import i18n from '@dhis2/d2-i18n' +import { + isEmpty, + isString, + isInRange, + requireArgument, +} from './helpers/index.js' + +const createCharacterLengthRange = (lowerBound, upperBound, customMessage) => { + requireArgument(lowerBound, 'number') + requireArgument(upperBound, 'number') + + const errorMessage = + customMessage || + i18n.t( + 'Please enter between {{lowerBound}} and {{upperBound}} characters', + { lowerBound, upperBound } + ) + + return value => + isEmpty(value) || + (isString(value) && isInRange(lowerBound, upperBound, value.length)) + ? undefined + : errorMessage +} + +export { createCharacterLengthRange } diff --git a/src/validators/createEqualTo.js b/src/validators/createEqualTo.js new file mode 100644 index 0000000..47eb677 --- /dev/null +++ b/src/validators/createEqualTo.js @@ -0,0 +1,16 @@ +import i18n from '@dhis2/d2-i18n' +import { isEmpty, requireArgument } from './helpers/index.js' + +const createEqualTo = (key, description) => { + requireArgument(key, 'string') + + const errorMessage = i18n.t( + 'Please make sure the value of this input matches the value in "{{otherField}}".', + { otherField: description || key } + ) + + return (value, allValues) => + isEmpty(value) || value === allValues[key] ? undefined : errorMessage +} + +export { createEqualTo } diff --git a/src/validators/createMaxCharacterLength.js b/src/validators/createMaxCharacterLength.js new file mode 100644 index 0000000..22974c6 --- /dev/null +++ b/src/validators/createMaxCharacterLength.js @@ -0,0 +1,13 @@ +import i18n from '@dhis2/d2-i18n' +import { createCharacterLengthRange } from './createCharacterLengthRange' + +const createMaxCharacterLength = upperBound => + createCharacterLengthRange( + 0, + upperBound, + i18n.t('Please enter a maximum of {{upperBound}} characters', { + upperBound, + }) + ) + +export { createMaxCharacterLength } diff --git a/src/validators/createMaxNumber.js b/src/validators/createMaxNumber.js new file mode 100644 index 0000000..bbe2984 --- /dev/null +++ b/src/validators/createMaxNumber.js @@ -0,0 +1,13 @@ +import i18n from '@dhis2/d2-i18n' +import { createNumberRange } from './createNumberRange' + +const createMaxNumber = upperBound => + createNumberRange( + -Infinity, + upperBound, + i18n.t('Please enter a number with a maximum of {{upperBound}}', { + upperBound, + }) + ) + +export { createMaxNumber } diff --git a/src/validators/createMinCharacterLength.js b/src/validators/createMinCharacterLength.js new file mode 100644 index 0000000..66f25f5 --- /dev/null +++ b/src/validators/createMinCharacterLength.js @@ -0,0 +1,13 @@ +import i18n from '@dhis2/d2-i18n' +import { createCharacterLengthRange } from './createCharacterLengthRange' + +const createMinCharacterLength = lowerBound => + createCharacterLengthRange( + lowerBound, + Infinity, + i18n.t('Please enter at least {{lowerBound}} characters', { + lowerBound, + }) + ) + +export { createMinCharacterLength } diff --git a/src/validators/createMinNumber.js b/src/validators/createMinNumber.js new file mode 100644 index 0000000..eab5b75 --- /dev/null +++ b/src/validators/createMinNumber.js @@ -0,0 +1,13 @@ +import i18n from '@dhis2/d2-i18n' +import { createNumberRange } from './createNumberRange' + +const createMinNumber = lowerBound => + createNumberRange( + lowerBound, + Infinity, + i18n.t('Please enter a number of at least {{lowerBound}}', { + lowerBound, + }) + ) + +export { createMinNumber } diff --git a/src/validators/createNumberRange.js b/src/validators/createNumberRange.js new file mode 100644 index 0000000..dc41b45 --- /dev/null +++ b/src/validators/createNumberRange.js @@ -0,0 +1,28 @@ +import i18n from '@dhis2/d2-i18n' +import { + isEmpty, + isNumeric, + toNumber, + isInRange, + requireArgument, +} from './helpers/index.js' + +const createNumberRange = (lowerBound, upperBound, customMessage) => { + requireArgument(lowerBound, 'number') + requireArgument(upperBound, 'number') + + const errorMessage = + customMessage || + i18n.t( + 'Please enter a number between {{lowerBound}} and {{upperBound}}', + { lowerBound, upperBound } + ) + + return value => + isEmpty(value) || + (isNumeric(value) && isInRange(lowerBound, upperBound, toNumber(value))) + ? undefined + : errorMessage +} + +export { createNumberRange } diff --git a/src/validators/createPattern.js b/src/validators/createPattern.js new file mode 100644 index 0000000..3d95e03 --- /dev/null +++ b/src/validators/createPattern.js @@ -0,0 +1,22 @@ +import i18n from '@dhis2/d2-i18n' +import { isEmpty, isString } from './helpers/index.js' + +const invalidPatternMessage = + 'The first argument passed to createPattern was not a valid regex' + +const createPattern = (pattern, message) => { + if (!(pattern instanceof RegExp)) { + throw new Error(invalidPatternMessage) + } + + return value => + isEmpty(value) || (isString(value) && pattern.test(value)) + ? undefined + : message || + i18n.t( + 'Please make sure the value of this input matches the pattern {{patternString}}.', + { patternString: pattern.toString() } + ) +} + +export { createPattern, invalidPatternMessage } diff --git a/src/validators/dhis2Password.js b/src/validators/dhis2Password.js new file mode 100644 index 0000000..ef681c1 --- /dev/null +++ b/src/validators/dhis2Password.js @@ -0,0 +1,81 @@ +import i18n from '@dhis2/d2-i18n' +import { isEmpty, isString } from './helpers/index.js' + +const LOWER_CASE_PATTERN = /^(?=.*[a-z]).+$/ +const UPPER_CASE_PATTERN = /^(?=.*[A-Z]).+$/ +const DIGIT_PATTERN = /^(?=.*[0-9]).+$/ +// Using this regex to match all non-alphanumeric characters to match server-side implementation +// https://github.com/dhis2/dhis2-core/blob/master/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/user/SpecialCharacterValidationRule.java#L39 +const SPECIAL_CHARACTER_PATTERN = /[^a-zA-Z0-9]/ + +const notString = i18n.t('Password should be a string') +const tooShort = i18n.t('Password should be at least 8 characters long') +const tooLong = i18n.t('Password should be no longer than 34 characters') +const noLowerCase = i18n.t( + 'Password should contain at least one lowercase letter' +) +const noUpperCase = i18n.t( + 'Password should contain at least one UPPERCASE letter' +) +const noNumber = i18n.t('Password should contain at least one number') +const noSpecialCharacter = i18n.t( + 'Password should have at least one special character' +) + +/** + * Tests if a given password is compliant with the password restrictions. + * This function checks all restrictions below, but returns when the first violation was found: + * - At least 8 characters + * - No more than 34 characters + * - Contains at least 1 lowercase character + * - Contains at least 1 UPPERCASE character + * - Contains at least 1 number + * - Contains at least 1 special character + */ +const dhis2Password = value => { + if (isEmpty(value)) { + return undefined + } + + if (!isString(value)) { + return notString + } + + if (value.length < 8) { + return tooShort + } + + if (value.length > 35) { + return tooLong + } + + if (!LOWER_CASE_PATTERN.test(value)) { + return noLowerCase + } + + if (!UPPER_CASE_PATTERN.test(value)) { + return noUpperCase + } + + if (!DIGIT_PATTERN.test(value)) { + return noNumber + } + + if (!SPECIAL_CHARACTER_PATTERN.test(value)) { + return noSpecialCharacter + } + + return undefined +} + +const errorMessages = { + notString, + tooShort, + tooLong, + noLowerCase, + noUpperCase, + noNumber, + noSpecialCharacter, +} + +export { dhis2Password, errorMessages } diff --git a/src/validators/dhis2Username.js b/src/validators/dhis2Username.js new file mode 100644 index 0000000..d9f6494 --- /dev/null +++ b/src/validators/dhis2Username.js @@ -0,0 +1,14 @@ +import i18n from '@dhis2/d2-i18n' +import { isEmpty, isString } from './helpers/index.js' + +const invalidUsernameMessage = i18n.t( + 'Please provide a username between 1 and 255 characters' +) + +const dhis2Username = value => + isEmpty(value) || + (isString(value) && value.length >= 1 && value.length <= 255) + ? undefined + : invalidUsernameMessage + +export { dhis2Username, invalidUsernameMessage } diff --git a/src/validators/email.js b/src/validators/email.js index fd5b5af..4277464 100644 --- a/src/validators/email.js +++ b/src/validators/email.js @@ -1,6 +1,37 @@ import i18n from '@dhis2/d2-i18n' +import { isEmpty, isString } from './helpers/index.js' -export const emailPattern = /^.+@.+\.[a-zA-Z]+$/ -export const invalidEmailMessage = i18n.t('Not a valid e-mail') -export const email = value => - value && !emailPattern.test(value) ? invalidEmailMessage : undefined +/* + * Email validation is complicated business. There is no perfect regex, + * instead we have to make a trade-off between complexity, correctness, + * and the risk of producing false negatives. This article + * https://www.regular-expressions.info/email.html offers a good overview. + * It recommends to use a very simple regex when having to validate many + * records, but for validating an individual email address a more complex + * regex may be used. + * + * The pattern below is taken from the "The Official Standard: RFC 5322" + * section of the article and is described as: + * "[..] a more practical implementation of RFC 5322 [..] that will still + * match 99.99% of all email addresses in actual use today" + * + * const EMAIL_ADDRESS_PATTERN = /[a-z0-9!#$%&'*+/=?^_‘{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_‘{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/i + * + * However, this regex produces a few false negatives and quite a lot + * of false positives. + * + * Another regex, found in this stackoverflow answer below resulted in a better + * overall picture in terms of false negatives and positives, so I settled on that one: + * https://stackoverflow.com/questions/46155/how-to-validate-an-email-address-in-javascript/46181#46181 + */ + +const EMAIL_ADDRESS_PATTERN = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/i + +const invalidEmailMessage = i18n.t('Please provide a valid email address') + +const email = value => + isEmpty(value) || (isString(value) && EMAIL_ADDRESS_PATTERN.test(value)) + ? undefined + : invalidEmailMessage + +export { email, invalidEmailMessage } diff --git a/src/validators/hasValue.js b/src/validators/hasValue.js new file mode 100644 index 0000000..915d41e --- /dev/null +++ b/src/validators/hasValue.js @@ -0,0 +1,8 @@ +import i18n from '@dhis2/d2-i18n' +import { isEmpty } from './helpers/index.js' + +const hasValueMessage = i18n.t('Please provide a value') + +const hasValue = value => (isEmpty(value) ? hasValueMessage : undefined) + +export { hasValue, hasValueMessage } diff --git a/src/validators/helpers/index.js b/src/validators/helpers/index.js new file mode 100644 index 0000000..049ca02 --- /dev/null +++ b/src/validators/helpers/index.js @@ -0,0 +1,25 @@ +export const isEmpty = value => + typeof value === 'undefined' || value === null || value === '' + +export const isString = value => typeof value === 'string' + +export const isInteger = value => Number.isSafeInteger(value) + +export const isNumber = value => typeof value === 'number' + +export const isNumeric = value => + (isString(value) || isNumber(value)) && !isNaN(value) + +export const isInRange = (lowerBound, upperBound, value) => + value >= lowerBound && value <= upperBound + +export const toNumber = value => Number(value) + +export const requiredArgumentErrorMessage = + 'Incorrect arguments provided when creating validator' + +export const requireArgument = (value, type) => { + if (isEmpty(value) || typeof value !== type) { + throw new Error(requiredArgumentErrorMessage) + } +} diff --git a/src/validators/index.js b/src/validators/index.js index 9208f48..645fa7a 100644 --- a/src/validators/index.js +++ b/src/validators/index.js @@ -1,3 +1,20 @@ +export { alphaNumeric } from './alphaNumeric.js' +export { boolean } from './boolean.js' export { composeValidators } from './composeValidators.js' -export { required } from './required.js' +export { createCharacterLengthRange } from './createCharacterLengthRange.js' +export { createEqualTo } from './createEqualTo.js' +export { createMaxCharacterLength } from './createMaxCharacterLength.js' +export { createMaxNumber } from './createMaxNumber.js' +export { createMinCharacterLength } from './createMinCharacterLength.js' +export { createMinNumber } from './createMinNumber.js' +export { createNumberRange } from './createNumberRange.js' +export { createPattern } from './createPattern.js' +export { dhis2Password } from './dhis2Password.js' +export { dhis2Username } from './dhis2Username.js' export { email } from './email.js' +export { integer } from './integer.js' +export { internationalPhoneNumber } from './internationalPhoneNumber.js' +export { number } from './number.js' +export { hasValue } from './hasValue.js' +export { string } from './string.js' +export { url } from './url.js' diff --git a/src/validators/integer.js b/src/validators/integer.js new file mode 100644 index 0000000..1ed8643 --- /dev/null +++ b/src/validators/integer.js @@ -0,0 +1,13 @@ +import i18n from '@dhis2/d2-i18n' +import { isEmpty, isInteger, isNumeric, toNumber } from './helpers/index.js' + +const invalidIntegerMessage = i18n.t( + 'Please provide a round number without decimals' +) + +const integer = value => + isEmpty(value) || (isNumeric(value) && isInteger(toNumber(value))) + ? undefined + : invalidIntegerMessage + +export { integer, invalidIntegerMessage } diff --git a/src/validators/internationalPhoneNumber.js b/src/validators/internationalPhoneNumber.js new file mode 100644 index 0000000..d478fb9 --- /dev/null +++ b/src/validators/internationalPhoneNumber.js @@ -0,0 +1,56 @@ +import i18n from '@dhis2/d2-i18n' +import { isEmpty, isString, isNumeric } from './helpers/index.js' + +/* + * There were some problems with the server side implementation + * of how international phone numbers are validated, and the + * server side implementation will likely be removed, see: + * https://jira.dhis2.org/browse/DHIS2-8040 + * + * So, rather than aligning with the server-side implementation + * this validator implements the E.164 numbering plan, see: + * https://www.cm.com/blog/how-to-format-international-telephone-numbers/ + * + * SPECS + * Here's how the E.164 numbering plan works: + * - A telephone number can have a maximum of 15 digits + * - The first part of the telephone number is the country code (one to three digits) + * - The second part is the national destination code (NDC) + * - The last part is the subscriber number (SN) + * - The NDC and SN together are collectively called the national (significant) number + * + * IMPLEMENTATION ADVICE + * Two important things to note: First of all, in the international E.164 notation a + * leading ‘0’ is removed. The UK mobile phone number ‘07911 123456’ in international + * format is ‘+44 7911 123456’, so without the first zero. Secondly in the E.164 notation + * all spaces, dashes [‘-‘] and parentheses [ ‘(‘ and ‘)’] are removed, besides the + * leading ‘+’ all characters should be numeric. + */ + +const invalidInternationalPhoneNumberMessage = i18n.t( + 'Please provide a valid international phone number.' +) + +const internationalPhoneNumber = value => { + // allow empty values + if (isEmpty(value)) { + return undefined + } + + // value must be a string + if (!isString(value)) { + return invalidInternationalPhoneNumberMessage + } + + const cleanedValue = value + // strip all hyphens, dots, spaces + .replace(/[-. )(]/g, '') + // trim leading zeroes and plus signs + .replace(/^[0+]+/, '') + + return isNumeric(cleanedValue) && cleanedValue.length <= 15 + ? undefined + : invalidInternationalPhoneNumberMessage +} + +export { internationalPhoneNumber, invalidInternationalPhoneNumberMessage } diff --git a/src/validators/number.js b/src/validators/number.js new file mode 100644 index 0000000..47df76b --- /dev/null +++ b/src/validators/number.js @@ -0,0 +1,9 @@ +import i18n from '@dhis2/d2-i18n' +import { isEmpty, isNumeric } from './helpers/index.js' + +const invalidNumberMessage = i18n.t('Please provide a number') + +const number = value => + isEmpty(value) || isNumeric(value) ? undefined : invalidNumberMessage + +export { number, invalidNumberMessage } diff --git a/src/validators/required.js b/src/validators/required.js deleted file mode 100644 index f3e2dc3..0000000 --- a/src/validators/required.js +++ /dev/null @@ -1,4 +0,0 @@ -import i18n from '@dhis2/d2-i18n' - -export const requiredMessage = i18n.t('Required') -export const required = value => (value ? undefined : requiredMessage) diff --git a/src/validators/string.js b/src/validators/string.js new file mode 100644 index 0000000..c16c3ae --- /dev/null +++ b/src/validators/string.js @@ -0,0 +1,9 @@ +import i18n from '@dhis2/d2-i18n' +import { isEmpty, isString } from './helpers/index.js' + +const invalidStringMessage = i18n.t('Please provide a string') + +const string = value => + isEmpty(value) || isString(value) ? undefined : invalidStringMessage + +export { string, invalidStringMessage } diff --git a/src/validators/url.js b/src/validators/url.js new file mode 100644 index 0000000..d38a4ed --- /dev/null +++ b/src/validators/url.js @@ -0,0 +1,14 @@ +import i18n from '@dhis2/d2-i18n' +import { isEmpty, isString } from './helpers/index.js' + +// Source: https://gist.github.com/dperini/729294 +const URL_PATTERN = /^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))(?::\d{2,5})?(?:[/?#]\S*)?$/i + +const invalidUrlMessage = i18n.t('Please provide a valid url') + +const url = value => + isEmpty(value) || (isString(value) && URL_PATTERN.test(value)) + ? undefined + : invalidUrlMessage + +export { url, invalidUrlMessage }