Skip to content
Merged
139 changes: 138 additions & 1 deletion app/javascript/packages/memorable-date/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import userEvent from '@testing-library/user-event';
import '@18f/identity-validated-field/validated-field-element';
import '.';
import { findByDisplayValue } from '@testing-library/dom';

const EXAMPLE_ERROR_MAPPINGS = {
error_messages: {
Expand Down Expand Up @@ -47,6 +48,7 @@ const EXAMPLE_ERROR_MAPPINGS_WITH_RANGE_ERRORS = {
describe('MemorableDateElement', () => {
let container;
let formElement;
let otherClickableElement;
let memorableDateElement;
let errorMessageMappingsElement;
let errorMessageElement;
Expand All @@ -67,6 +69,7 @@ describe('MemorableDateElement', () => {
container = document.createElement('div');
container.innerHTML = `
<form id="test-md-form">
<div id="test-md-extra-text">This is an arbitrary element to click</div>
<lg-memorable-date id="test-memorable-date">
<script id="test-md-error-mappings" type="application/json" class="memorable-date__error-strings"></script>
<lg-validated-field>
Expand Down Expand Up @@ -103,7 +106,7 @@ describe('MemorableDateElement', () => {
maxlength="4" />
</lg-validated-field>
</lg-memorable-date>
<div id="test-md-error-message" class="usa-error-message"></div>
<div id="test-md-error-message" class="usa-error-message" style="display:none;"></div>
<button id="test-md-submit">Submit</button>
</form>
`;
Expand All @@ -112,6 +115,9 @@ describe('MemorableDateElement', () => {
formElement = document.getElementById('test-md-form');
expect(formElement?.tagName).to.equal('FORM');

otherClickableElement = document.getElementById('test-md-extra-text');
expect(otherClickableElement?.tagName).to.equal('DIV');

memorableDateElement = document.getElementById('test-memorable-date');
expect(memorableDateElement?.tagName).to.equal('LG-MEMORABLE-DATE');

Expand Down Expand Up @@ -160,6 +166,75 @@ describe('MemorableDateElement', () => {
expect(formElement.reportValidity()).to.be.true();
});
}

// This is for a New Relic bug that overrides
// the addEventListener and removeEventListener functions.
// See here: https://discuss.newrelic.com/t/javascrypt-snippet-breaks-site/52188
function itIsUnaffectedByNewRelicEventBug() {
context(
'another script overrides the addEventListener in a way that loses function identity',
() => {
let originalAddEventListenerFunction;
beforeEach(() => {
originalAddEventListenerFunction = Element.prototype.addEventListener;
Element.prototype.addEventListener = function addEventListener(type, listener, ...args) {
if (listener instanceof Function) {
listener = function overrideListener(...eventArgs) {
return listener.apply(this, eventArgs);
};
}

if (arguments.length > 1) {
args.unshift(listener);
}

if (arguments.length > 0) {
args.unshift(type);
}
return originalAddEventListenerFunction.apply(this, args);
};
});

afterEach(() => {
Element.prototype.addEventListener = originalAddEventListenerFunction;
originalAddEventListenerFunction = null;
});

context(
'user has entered a day and year, then clicks an element outside the memorable date fields',
() => {
beforeEach(async function () {
this.timeout(8000);

await userEvent.click(dayInput);
await userEvent.type(dayInput, '1');
await userEvent.click(yearInput);
await userEvent.type(yearInput, '19');
await userEvent.click(otherClickableElement);

// Issue seems to happen after a 5 or more second delay in this state
await new Promise((resolve) => setTimeout(resolve, 6000));
});

it('does not hang when the user modifies the day', async () => {
await userEvent.click(dayInput);
await userEvent.type(dayInput, '5');
const dayInputWithText = await findByDisplayValue(memorableDateElement, '15');
expect(dayInputWithText.id).to.equal(dayInput.id);
});

it('does not hang when the user modifies the year', async () => {
await userEvent.click(yearInput);
await userEvent.type(yearInput, '4');
const yearInputWithText = await findByDisplayValue(memorableDateElement, '194');
expect(yearInputWithText.id).to.equal(yearInput.id);
});
},
);
},
);
}

function itHidesValidationErrorsOnTyping() {
it('hides validation errors on typing', async () => {
const expectNoVisibleError = () => {
Expand Down Expand Up @@ -212,6 +287,7 @@ describe('MemorableDateElement', () => {
describe('error message mappings are empty', () => {
itAcceptsAValidDate();
itHidesValidationErrorsOnTyping();
itIsUnaffectedByNewRelicEventBug();
it('uses default required validation', async () => {
expectErrorToEqual('');
submitButton.click();
Expand Down Expand Up @@ -283,6 +359,7 @@ describe('MemorableDateElement', () => {
});
itAcceptsAValidDate();
itHidesValidationErrorsOnTyping();
itIsUnaffectedByNewRelicEventBug();
it('uses customized messages for required validation', async () => {
expectErrorToEqual('');
submitButton.click();
Expand Down Expand Up @@ -352,6 +429,66 @@ describe('MemorableDateElement', () => {
expect(formElement.reportValidity()).to.be.false();
});

it('does not show error styles on fields unrelated to the validation message', async () => {
await userEvent.type(monthInput, '2');
await userEvent.type(yearInput, '1972');
expectErrorToEqual('');
await userEvent.click(submitButton);
expectErrorToEqual('Enter a day');
expect(formElement.reportValidity()).to.be.false();
expect(Array.from(monthInput.classList)).to.not.include('usa-input--error');
expect(Array.from(dayInput.classList)).to.include('usa-input--error');
expect(Array.from(yearInput.classList)).to.not.include('usa-input--error');

await userEvent.type(dayInput, 'bc');
expectErrorToEqual('');
await userEvent.click(submitButton);
expectErrorToEqual('Enter a day between 1 and 31');
expect(formElement.reportValidity()).to.be.false();
expect(Array.from(monthInput.classList)).to.not.include('usa-input--error');
expect(Array.from(dayInput.classList)).to.include('usa-input--error');
expect(Array.from(yearInput.classList)).to.not.include('usa-input--error');

await userEvent.type(monthInput, 'z');
await userEvent.clear(dayInput);
await userEvent.type(dayInput, '18');
expectErrorToEqual('');
await userEvent.click(submitButton);
expectErrorToEqual('Enter a month between 1 and 12');
expect(formElement.reportValidity()).to.be.false();
expect(Array.from(monthInput.classList)).to.include('usa-input--error');
expect(Array.from(dayInput.classList)).to.not.include('usa-input--error');
expect(Array.from(yearInput.classList)).to.not.include('usa-input--error');

await userEvent.clear(monthInput);
expectErrorToEqual('');
await userEvent.click(submitButton);
expectErrorToEqual('Enter a month');
expect(formElement.reportValidity()).to.be.false();
expect(Array.from(monthInput.classList)).to.include('usa-input--error');
expect(Array.from(dayInput.classList)).to.not.include('usa-input--error');
expect(Array.from(yearInput.classList)).to.not.include('usa-input--error');

await userEvent.type(monthInput, '4');
await userEvent.clear(yearInput);
expectErrorToEqual('');
await userEvent.click(submitButton);
expectErrorToEqual('Enter a year');
expect(formElement.reportValidity()).to.be.false();
expect(Array.from(monthInput.classList)).to.not.include('usa-input--error');
expect(Array.from(dayInput.classList)).to.not.include('usa-input--error');
expect(Array.from(yearInput.classList)).to.include('usa-input--error');

await userEvent.type(yearInput, '1');
expectErrorToEqual('');
await userEvent.click(submitButton);
expectErrorToEqual('Enter a year with 4 numbers');
expect(formElement.reportValidity()).to.be.false();
expect(Array.from(monthInput.classList)).to.not.include('usa-input--error');
expect(Array.from(dayInput.classList)).to.not.include('usa-input--error');
expect(Array.from(yearInput.classList)).to.include('usa-input--error');
});

describe('min and max are set on lg-memorable-date', () => {
beforeEach(() => {
memorableDateElement.setAttribute('min', '1800-01-01');
Expand Down
33 changes: 21 additions & 12 deletions app/javascript/packages/memorable-date/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ export const enum MemorableDateErrorMessage {
OUTSIDE_DATE_RANGE = 'outside_date_range',
}

/**
* Custom input event detail flag used to prevent recursion
*/
const CUSTOM_INPUT_EVENT_DETAIL_FLAG = 'CustomMemorableDateInputEventDetailFlag';

/**
* Type for a range check with a corresponding error message
*/
Expand Down Expand Up @@ -103,6 +108,11 @@ class MemorableDateElement extends HTMLElement {
this.validate();

const inputListener = (event: Event) => {
// Don't process the event if this function generated it
if (event instanceof CustomEvent && event.detail?.flag === CUSTOM_INPUT_EVENT_DETAIL_FLAG) {
return;
}

this.validate();

// Artificially trigger input events on all inputs
Expand All @@ -111,15 +121,17 @@ class MemorableDateElement extends HTMLElement {
// memorable-date fields at the same time as it hides the error
// message (instead of only the selected field).
const otherInputs = allInputs.filter((input) => input !== event.target);
try {
this.removeEventListener('input', inputListener);
otherInputs.forEach((input) => {
// Prevent recursion by removing listener temporarily
input.dispatchEvent(new CustomEvent('input', { bubbles: true }));
});
} finally {
this.addEventListener('input', inputListener);
}

otherInputs.forEach((input) => {
input.dispatchEvent(
new CustomEvent('input', {
bubbles: true,
detail: {
flag: CUSTOM_INPUT_EVENT_DETAIL_FLAG,
},
}),
);
});
};

this.addEventListener('input', inputListener);
Expand Down Expand Up @@ -255,9 +267,6 @@ class MemorableDateElement extends HTMLElement {
allInputs.forEach((field) => {
if (fields.includes(field)) {
field.setCustomValidity(message);
} else if (!field.validity.valid) {
// Prevent the built-in errors from overriding custom errors
field.setCustomValidity(message);
} else {
field.setCustomValidity('');
}
Expand Down