diff --git a/src/mixins/ValidationRules.js b/src/mixins/ValidationRules.js index 336d3772..2de191e8 100644 --- a/src/mixins/ValidationRules.js +++ b/src/mixins/ValidationRules.js @@ -160,8 +160,18 @@ export const regex = (expression) => helpers.withParams({expression}, (value) => return !!String(value).match(regexp); }); +const hasRequiredValue = (value) => { + if (value instanceof Array) { + return value.length > 0; + } + if (typeof value === 'string') { + return value.trim().length > 0; + } + return !isNil(value) && value !== false; +}; + export const required = (value) => { - return value instanceof Array ? value.length > 0 : !isNil(value) && value !== '' && value !== false; + return hasRequiredValue(value); }; export const requiredIf = (variable, expected, fieldName) => helpers.withParams({variable, expected}, function(value, data) { @@ -174,7 +184,7 @@ export const requiredIf = (variable, expected, fieldName) => helpers.withParams( expectedValue = expected === 'true' || expected === '1'; } if (variableValue != expectedValue) return true; - return value instanceof Array ? value.length > 0 : !!value; + return hasRequiredValue(value); }); export const requiredUnless = (variable, expected, fieldName) => helpers.withParams({variable, expected}, function(value, data) { @@ -187,7 +197,7 @@ export const requiredUnless = (variable, expected, fieldName) => helpers.withPar expectedValue = expected === 'true' || expected === '1'; } if (variableValue == expectedValue) return true; - return value instanceof Array ? value.length > 0 : !!value; + return hasRequiredValue(value); }); export const sameAs = (field, fieldName) => helpers.withParams({field}, function(value, data) { diff --git a/tests/e2e/fixtures/required_whitespace.json b/tests/e2e/fixtures/required_whitespace.json new file mode 100644 index 00000000..608c55b1 --- /dev/null +++ b/tests/e2e/fixtures/required_whitespace.json @@ -0,0 +1,78 @@ +{ + "type": "screen_package", + "version": "2", + "screens": [ + { + "id": 32296, + "screen_category_id": "1", + "title": "Required Whitespace", + "description": "Required controls must reject whitespace-only values", + "type": "FORM", + "config": [ + { + "name": "Required Whitespace", + "items": [ + { + "label": "Line Input", + "component": "FormInput", + "config": { + "name": "required_input", + "type": "text", + "label": "Required Input", + "readonly": false, + "dataFormat": "string", + "validation": [ + { + "value": "required", + "content": "Required" + } + ] + }, + "inspector": [], + "editor-control": "FormInput", + "editor-component": "FormInput" + }, + { + "label": "Text Area", + "component": "FormTextArea", + "config": { + "name": "required_textarea", + "label": "Required Textarea", + "readonly": false, + "richtext": false, + "rows": 3, + "validation": [ + { + "value": "required", + "content": "Required" + } + ] + }, + "inspector": [], + "editor-control": "FormTextArea", + "editor-component": "FormTextArea" + }, + { + "label": "Submit Button", + "component": "FormButton", + "config": { + "name": "submit_button", + "event": "submit", + "label": "Submit", + "variant": "primary", + "fieldValue": null, + "defaultSubmit": true + }, + "inspector": [], + "editor-control": "FormButton", + "editor-component": "FormButton" + } + ] + } + ], + "computed": [], + "watchers": [], + "custom_css": null + } + ] +} diff --git a/tests/e2e/specs/RequiredWhitespace.spec.js b/tests/e2e/specs/RequiredWhitespace.spec.js new file mode 100644 index 00000000..c231b70d --- /dev/null +++ b/tests/e2e/specs/RequiredWhitespace.spec.js @@ -0,0 +1,40 @@ +describe("Required whitespace validation", () => { + beforeEach(() => { + cy.visit("/"); + cy.loadFromJson("required_whitespace.json", 0); + cy.get("[data-cy=mode-preview]").click(); + }); + + it("blocks whitespace-only values without modifying valid input", () => { + let alert = false; + cy.on("window:alert", (message) => { + alert = message; + }); + + cy.get("[data-cy=preview-content] [name=required_input]").type(" ", { + parseSpecialCharSequences: false + }); + cy.get("[data-cy=preview-content] [name=required_textarea]").type(" ", { + parseSpecialCharSequences: false + }); + cy.get("[data-cy=preview-content] [name=submit_button]").click(); + + cy.shouldHaveValidationErrors("screen-field-required_input"); + cy.shouldHaveValidationErrors("screen-field-required_textarea"); + cy.then(() => expect(alert).to.equal(false)); + + cy.get("[data-cy=preview-content] [name=required_input]") + .clear() + .type(" value ", { parseSpecialCharSequences: false }) + .should("have.value", " value "); + cy.get("[data-cy=preview-content] [name=required_textarea]") + .clear() + .type(" value ", { parseSpecialCharSequences: false }) + .should("have.value", " value "); + + cy.shouldNotHaveValidationErrors("screen-field-required_input"); + cy.shouldNotHaveValidationErrors("screen-field-required_textarea"); + cy.get("[data-cy=preview-content] [name=submit_button]").click(); + cy.then(() => expect(alert).to.equal("Preview Form was Submitted")); + }); +}); diff --git a/tests/e2e/specs/ValidationRules.spec.js b/tests/e2e/specs/ValidationRules.spec.js index 57b5cbb1..07e6237f 100644 --- a/tests/e2e/specs/ValidationRules.spec.js +++ b/tests/e2e/specs/ValidationRules.spec.js @@ -487,6 +487,12 @@ describe("Validation Rules", () => { ).click(); cy.shouldHaveValidationErrors("screen-field-form_input_2"); + // Whitespace-only values should not satisfy the active rule + cy.get( + '[data-cy=preview-content] [data-cy="screen-field-form_input_2"]' + ).type(" ", { parseSpecialCharSequences: false }); + cy.shouldHaveValidationErrors("screen-field-form_input_2"); + // Uncheck box 1 cy.get( '[data-cy=preview-content] [data-cy="screen-field-form_checkbox_1"]' @@ -515,6 +521,12 @@ describe("Validation Rules", () => { ).click(); cy.shouldHaveValidationErrors("screen-field-form_input_2"); + // Whitespace-only values should not satisfy the active rule + cy.get( + '[data-cy=preview-content] [data-cy="screen-field-form_input_2"]' + ).type(" ", { parseSpecialCharSequences: false }); + cy.shouldHaveValidationErrors("screen-field-form_input_2"); + // Uncheck box 1 cy.get( '[data-cy=preview-content] [data-cy="screen-field-form_checkbox_1"]' diff --git a/tests/unit/ValidationRules.spec.js b/tests/unit/ValidationRules.spec.js new file mode 100644 index 00000000..43c26438 --- /dev/null +++ b/tests/unit/ValidationRules.spec.js @@ -0,0 +1,78 @@ +import { + required, + requiredIf, + requiredUnless +} from "../../src/mixins/ValidationRules"; + +const validationContext = { + getDataAccordingToFieldLevel(data) { + return data; + }, + getRootScreen() { + return { + addReferenceToParents(data) { + return data; + } + }; + } +}; + +const activeValidators = [ + ["required", (value) => required(value)], + [ + "requiredIf", + (value) => + requiredIf("trigger", "true", "field").call(validationContext, value, { + trigger: true + }) + ], + [ + "requiredUnless", + (value) => + requiredUnless("trigger", "true", "field").call( + validationContext, + value, + { trigger: false } + ) + ] +]; + +describe.each(activeValidators)("%s", (name, validate) => { + it.each([ + ["an empty string", ""], + ["spaces", " "], + ["tabs and newlines", "\t\n"], + ["null", null], + ["undefined", undefined], + ["false", false], + ["an empty array", []] + ])("rejects %s", (description, value) => { + expect(validate(value)).toBe(false); + }); + + it.each([ + ["visible text with surrounding spaces", " value "], + ["zero", 0], + ["a non-empty array", ["value"]] + ])("accepts %s", (description, value) => { + expect(validate(value)).toBe(true); + }); +}); + +describe("inactive conditional required rules", () => { + it("does not require a value when requiredIf does not match", () => { + const validate = requiredIf("trigger", "true", "field"); + + expect(validate.call(validationContext, " ", { trigger: false })).toBe( + true + ); + }); + + it("does not require a value when requiredUnless matches", () => { + const validate = requiredUnless("trigger", "true", "field"); + + expect(validate.call(validationContext, " ", { trigger: true })).toBe( + true + ); + }); +});