diff --git a/app/controllers/idv/phone_controller.rb b/app/controllers/idv/phone_controller.rb
index 8cacb66e13e..b7e72cd0f71 100644
--- a/app/controllers/idv/phone_controller.rb
+++ b/app/controllers/idv/phone_controller.rb
@@ -17,7 +17,7 @@ def new
elsif async_state.in_progress?
render :wait
elsif async_state.timed_out?
- flash[:info] = I18n.t('idv.failure.timeout')
+ flash.now[:error] = I18n.t('idv.failure.timeout')
render :new
elsif async_state.done?
async_state_done(async_state)
diff --git a/app/javascript/packs/form-steps-wait.js b/app/javascript/packs/form-steps-wait.js
deleted file mode 100644
index 299138a624b..00000000000
--- a/app/javascript/packs/form-steps-wait.js
+++ /dev/null
@@ -1,78 +0,0 @@
-import { loadPolyfills } from '@18f/identity-polyfill';
-
-/**
- * @typedef FormStepsWaitElements
- *
- * @prop {HTMLFormElement} form
- */
-
-/**
- * @typedef FormStepsWaitOptions
- *
- * @prop {number} pollIntervalMs Poll interval.
- * @prop {string} waitStepPath URL path to wait step, used in polling.
- */
-
-/** @type {FormStepsWaitOptions} */
-const DEFAULT_OPTIONS = {
- pollIntervalMs: 3000,
- waitStepPath: `${window.location.pathname}_wait`,
-};
-
-export class FormStepsWait {
- constructor(form) {
- /** @type {FormStepsWaitElements} */
- this.elements = { form };
-
- this.options = {
- ...DEFAULT_OPTIONS,
- ...this.elements.form.dataset,
- };
-
- this.options.pollIntervalMs = Number(this.options.pollIntervalMs);
- }
-
- bind() {
- this.elements.form.addEventListener('submit', (event) => this.handleSubmit(event));
- }
-
- /**
- * @param {Event} event Form submit event.
- */
- async handleSubmit(event) {
- event.preventDefault();
-
- const { form } = this.elements;
- const { action, method } = form;
-
- const response = await window.fetch(action, {
- method,
- body: new window.FormData(form),
- });
-
- this.handleResponse(response);
- }
-
- handleResponse(response) {
- const { waitStepPath, pollIntervalMs } = this.options;
- if (!response.ok) {
- // If form submission fails, assume there's a server-side flash error to be shown to the user.
- window.location.reload();
- } else if (response.redirected && new URL(response.url).pathname !== waitStepPath) {
- window.location.href = response.url;
- } else {
- setTimeout(() => this.poll(), pollIntervalMs);
- }
- }
-
- async poll() {
- const { waitStepPath } = this.options;
- const response = await window.fetch(waitStepPath, { method: 'HEAD' });
- this.handleResponse(response);
- }
-}
-
-loadPolyfills(['fetch']).then(() => {
- const forms = [...document.querySelectorAll('[data-form-steps-wait]')];
- forms.forEach((form) => new FormStepsWait(form).bind());
-});
diff --git a/app/javascript/packs/form-steps-wait.jsx b/app/javascript/packs/form-steps-wait.jsx
new file mode 100644
index 00000000000..5fcaa60c2d2
--- /dev/null
+++ b/app/javascript/packs/form-steps-wait.jsx
@@ -0,0 +1,180 @@
+import { render, unmountComponentAtNode } from 'react-dom';
+import { Alert } from '@18f/identity-components';
+import { loadPolyfills } from '@18f/identity-polyfill';
+
+/**
+ * @typedef FormStepsWaitElements
+ *
+ * @prop {HTMLFormElement} form
+ */
+
+/**
+ * @typedef FormStepsWaitOptions
+ *
+ * @prop {number} pollIntervalMs Poll interval.
+ * @prop {string} waitStepPath URL path to wait step, used in polling.
+ * @prop {string=} errorMessage Message to show on unhandled server error.
+ * @prop {string=} alertTarget DOM selector of HTML element to which alert should render.
+ */
+
+/** @type {FormStepsWaitOptions} */
+const DEFAULT_OPTIONS = {
+ pollIntervalMs: 3000,
+ waitStepPath: `${window.location.pathname}_wait`,
+};
+
+/**
+ * Returns a DOM document object for given markup string.
+ *
+ * @param {string} html HTML markup.
+ *
+ * @return {Document} DOM document.
+ */
+export function getDOMFromHTML(html) {
+ const dom = document.implementation.createHTMLDocument();
+ dom.body.innerHTML = html;
+ return dom;
+}
+
+/**
+ * @param {Document} dom
+ *
+ * @return {boolean} Whether page polls.
+ */
+export function isPollingPage(dom) {
+ return Boolean(dom.querySelector('meta[http-equiv="refresh"]'));
+}
+
+/**
+ * Returns trimmed page alert contents, if exists.
+ *
+ * @param {Document} dom
+ *
+ * @return {string?=} Page alert, if exists.
+ */
+export function getPageErrorMessage(dom) {
+ return dom.querySelector('.usa-alert.usa-alert--error')?.textContent?.trim();
+}
+
+export class FormStepsWait {
+ constructor(form) {
+ /** @type {FormStepsWaitElements} */
+ this.elements = { form };
+
+ this.options = {
+ ...DEFAULT_OPTIONS,
+ ...this.elements.form.dataset,
+ };
+
+ this.options.pollIntervalMs = Number(this.options.pollIntervalMs);
+ }
+
+ bind() {
+ this.elements.form.addEventListener('submit', (event) => this.handleSubmit(event));
+ }
+
+ /**
+ * @param {Event} event Form submit event.
+ */
+ async handleSubmit(event) {
+ event.preventDefault();
+
+ const { form } = this.elements;
+ const { action, method } = form;
+
+ // Clear error, if present.
+ this.renderError('');
+
+ const response = await window.fetch(action, {
+ method,
+ body: new window.FormData(form),
+ });
+
+ this.handleResponse(response);
+ }
+
+ /**
+ * @param {Response} response
+ */
+ async handleResponse(response) {
+ if (response.status >= 500) {
+ this.handleFailedResponse();
+ } else {
+ const body = await response.text();
+ const dom = getDOMFromHTML(body);
+ if (isPollingPage(dom)) {
+ this.scheduleNextPollFetch();
+ } else {
+ const message = getPageErrorMessage(dom);
+ if (message) {
+ this.renderError(message);
+ this.stopSpinner();
+ } else {
+ window.location.href = response.url;
+ }
+ }
+ }
+ }
+
+ handleFailedResponse() {
+ this.stopSpinner();
+
+ const { errorMessage } = this.options;
+ if (errorMessage) {
+ this.renderError(errorMessage);
+ }
+ }
+
+ scheduleNextPollFetch() {
+ setTimeout(() => this.poll(), this.options.pollIntervalMs);
+ }
+
+ /**
+ * @param {string} message Error message text.
+ */
+ renderError(message) {
+ const { alertTarget } = this.options;
+ if (!alertTarget) {
+ return;
+ }
+
+ const errorRoot = document.querySelector(alertTarget);
+ if (!errorRoot) {
+ return;
+ }
+
+ if (message) {
+ render(
+
+ {message}
+ ,
+ errorRoot,
+ );
+ } else {
+ unmountComponentAtNode(errorRoot);
+ }
+ }
+
+ /**
+ * Stops any active spinner buttons associated with this form.
+ */
+ stopSpinner() {
+ const { form } = this.elements;
+ const event = new window.CustomEvent('spinner.stop', { bubbles: true });
+ // Spinner button may be within the form, or an ancestor. To handle both cases, dispatch a
+ // bubbling event on the innermost element that could be associated with a spinner button.
+ const target = form.querySelector('.spinner-button--spinner-active') || form;
+ target.dispatchEvent(event);
+ }
+
+ async poll() {
+ const { waitStepPath } = this.options;
+ const response = await window.fetch(waitStepPath);
+ this.handleResponse(response);
+ }
+}
+
+loadPolyfills(['fetch', 'custom-event']).then(() => {
+ const forms = [...document.querySelectorAll('[data-form-steps-wait]')];
+ forms.forEach((form) => new FormStepsWait(form).bind());
+});
diff --git a/app/javascript/packs/spinner-button.js b/app/javascript/packs/spinner-button.js
index 78eb173327b..ace7a7cdce1 100644
--- a/app/javascript/packs/spinner-button.js
+++ b/app/javascript/packs/spinner-button.js
@@ -35,21 +35,40 @@ export class SpinnerButton {
}
bind() {
- this.elements.button.addEventListener('click', () => this.showSpinner());
+ this.elements.button.addEventListener('click', () => this.toggleSpinner(true));
+ this.elements.wrapper.addEventListener('spinner.start', () => this.toggleSpinner(true));
+ this.elements.wrapper.addEventListener('spinner.stop', () => this.toggleSpinner(false));
}
- showSpinner() {
+ /**
+ * @param {boolean} isVisible
+ */
+ toggleSpinner(isVisible) {
const { wrapper, button, actionMessage } = this.elements;
- wrapper.classList.add('spinner-button--spinner-active');
+ wrapper.classList.toggle('spinner-button--spinner-active', isVisible);
// Avoid setting disabled immediately to allow click event to propagate for form submission.
- setTimeout(() => button.setAttribute('disabled', ''), 0);
+ setTimeout(() => {
+ if (isVisible) {
+ button.setAttribute('disabled', '');
+ } else {
+ button.removeAttribute('disabled');
+ }
+ }, 0);
if (actionMessage) {
- actionMessage.textContent = /** @type {string} */ (actionMessage.dataset.message);
+ actionMessage.textContent = isVisible
+ ? /** @type {string} */ (actionMessage.dataset.message)
+ : '';
}
- setTimeout(() => this.handleLongWait(), this.options.longWaitDurationMs);
+ window.clearTimeout(this.longWaitTimeout);
+ if (isVisible) {
+ this.longWaitTimeout = window.setTimeout(
+ () => this.handleLongWait(),
+ this.options.longWaitDurationMs,
+ );
+ }
}
handleLongWait() {
diff --git a/app/services/idv/steps/cac/verify_wait_step_show.rb b/app/services/idv/steps/cac/verify_wait_step_show.rb
index 00bdc8928ed..e328b5c5bfd 100644
--- a/app/services/idv/steps/cac/verify_wait_step_show.rb
+++ b/app/services/idv/steps/cac/verify_wait_step_show.rb
@@ -16,7 +16,7 @@ def process_async_state(current_async_state)
elsif current_async_state.in_progress?
nil
elsif current_async_state.timed_out?
- flash[:info] = I18n.t('idv.failure.timeout')
+ flash[:error] = I18n.t('idv.failure.timeout')
delete_async
mark_step_incomplete(:verify)
elsif current_async_state.done?
diff --git a/app/services/idv/steps/recover_verify_wait_step_show.rb b/app/services/idv/steps/recover_verify_wait_step_show.rb
index 747dad88a99..f6120867f8c 100644
--- a/app/services/idv/steps/recover_verify_wait_step_show.rb
+++ b/app/services/idv/steps/recover_verify_wait_step_show.rb
@@ -15,7 +15,7 @@ def process_async_state(current_async_state)
elsif current_async_state.in_progress?
nil
elsif current_async_state.timed_out?
- flash[:info] = I18n.t('idv.failure.timeout')
+ flash[:error] = I18n.t('idv.failure.timeout')
delete_async
mark_step_incomplete(:verify)
elsif current_async_state.done?
diff --git a/app/services/idv/steps/verify_wait_step_show.rb b/app/services/idv/steps/verify_wait_step_show.rb
index 0340891d81c..1aacd189e09 100644
--- a/app/services/idv/steps/verify_wait_step_show.rb
+++ b/app/services/idv/steps/verify_wait_step_show.rb
@@ -15,7 +15,7 @@ def process_async_state(current_async_state)
elsif current_async_state.in_progress?
nil
elsif current_async_state.timed_out?
- flash[:info] = I18n.t('idv.failure.timeout')
+ flash[:error] = I18n.t('idv.failure.timeout')
delete_async
mark_step_incomplete(:verify)
elsif current_async_state.done?
@@ -31,7 +31,6 @@ def async_state_done(current_async_state)
delete_async
if response.success?
- flash[:success] = I18n.t('doc_auth.forms.doc_success')
mark_step_complete(:verify_wait)
else
mark_step_incomplete(:verify)
diff --git a/app/views/idv/cac/verify.html.erb b/app/views/idv/cac/verify.html.erb
index 5a398ca5139..49ef462e9fa 100644
--- a/app/views/idv/cac/verify.html.erb
+++ b/app/views/idv/cac/verify.html.erb
@@ -1,3 +1,5 @@
+
+
<% title t('cac_proofing.titles.cac_proofing') %>
@@ -56,6 +58,8 @@
class: 'button_to read-after-submit',
data: {
form_steps_wait: '',
+ error_message: t("idv.failure.sessions.exception"),
+ alert_target: '#form-steps-wait-alert',
wait_step_path: idv_cac_step_path(step: :verify_wait),
poll_interval_ms: AppConfig.env.poll_rate_for_verify_in_seconds.to_i * 1000,
},
diff --git a/app/views/idv/doc_auth/verify.html.erb b/app/views/idv/doc_auth/verify.html.erb
index fd97f19ee9c..f6bd23e087c 100644
--- a/app/views/idv/doc_auth/verify.html.erb
+++ b/app/views/idv/doc_auth/verify.html.erb
@@ -1,3 +1,5 @@
+
+
<% title t('titles.doc_auth.verify') %>
@@ -51,6 +53,8 @@
class: 'button_to read-after-submit',
data: {
form_steps_wait: '',
+ error_message: t("idv.failure.sessions.exception"),
+ alert_target: '#form-steps-wait-alert',
wait_step_path: idv_doc_auth_step_path(step: :verify_wait),
poll_interval_ms: AppConfig.env.poll_rate_for_verify_in_seconds.to_i * 1000,
},
diff --git a/app/views/idv/phone/new.html.erb b/app/views/idv/phone/new.html.erb
index 589170bc19b..48c23bf30c4 100644
--- a/app/views/idv/phone/new.html.erb
+++ b/app/views/idv/phone/new.html.erb
@@ -1,3 +1,11 @@
+
+ <%= render 'shared/alert', {
+ type: 'success',
+ class: 'margin-bottom-4',
+ message: I18n.t('doc_auth.forms.doc_success'),
+ } %>
+
+
<% title t('idv.titles.phone') %>
@@ -30,6 +38,8 @@
url: idv_phone_path,
data: {
form_steps_wait: '',
+ error_message: t("idv.failure.sessions.exception"),
+ alert_target: '#form-steps-wait-alert',
wait_step_path: idv_phone_path,
poll_interval_ms: AppConfig.env.poll_rate_for_verify_in_seconds.to_i * 1000,
},
diff --git a/package.json b/package.json
index 0bebd791b11..c67d40d88a8 100644
--- a/package.json
+++ b/package.json
@@ -17,7 +17,6 @@
"build": "true"
},
"dependencies": {
- "basscss-sass": "^3.0.0",
"@babel/core": "^7.12.10",
"@babel/eslint-parser": "^7.12.1",
"@babel/eslint-plugin": "^7.12.1",
@@ -25,6 +24,8 @@
"@babel/preset-env": "^7.12.11",
"@babel/preset-react": "^7.12.10",
"@babel/register": "^7.12.10",
+ "@rails/webpacker": "^5.2.1",
+ "basscss-sass": "^3.0.0",
"classlist-polyfill": "^1.2.0",
"cleave.js": "^1.6.0",
"clipboard": "^2.0.6",
@@ -33,10 +34,9 @@
"identity-style-guide": "^3.0.0",
"intl-tel-input": "^17.0.8",
"libphonenumber-js": "^1.9.6",
+ "postcss-clean": "^1.1.0",
"react": "^17.0.1",
"react-dom": "^17.0.1",
- "@rails/webpacker": "^5.2.1",
- "postcss-clean": "^1.1.0",
"source-map-loader": "^1.1.3",
"zxcvbn": "^4.4.2"
},
@@ -47,6 +47,7 @@
"@testing-library/react-hooks": "^3.7.0",
"@testing-library/user-event": "^12.6.0",
"@types/react": "^17.0.0",
+ "@types/react-dom": "^17.0.0",
"chai": "^4.2.0",
"dirty-chai": "^2.0.1",
"eslint": "^7.16.0",
diff --git a/spec/controllers/idv/phone_controller_spec.rb b/spec/controllers/idv/phone_controller_spec.rb
index 4f3b348a232..c24efa05a4d 100644
--- a/spec/controllers/idv/phone_controller_spec.rb
+++ b/spec/controllers/idv/phone_controller_spec.rb
@@ -71,7 +71,7 @@
subject.idv_session.idv_phone_step_document_capture_session_uuid = 'abc123'
get :new
- expect(flash[:info]).to include t('idv.failure.timeout')
+ expect(flash[:error]).to include t('idv.failure.timeout')
expect(response).to render_template :new
put :create, params: { idv_phone_form: { phone: good_phone } }
get :new
diff --git a/spec/features/idv/cac/verify_step_spec.rb b/spec/features/idv/cac/verify_step_spec.rb
index 8ecad0338fe..7af08661bfd 100644
--- a/spec/features/idv/cac/verify_step_spec.rb
+++ b/spec/features/idv/cac/verify_step_spec.rb
@@ -56,4 +56,37 @@
expect(page).to have_current_path(idv_cac_proofing_success_step)
end
end
+
+ context 'javascript enabled', js: true do
+ before do
+ sign_in_and_2fa_user
+ complete_cac_proofing_steps_before_verify_step
+ end
+
+ around do |example|
+ # Adjust the wait time to give the frontend time to poll for results.
+ Capybara.using_wait_time(5) do
+ example.run
+ end
+ end
+
+ it 'proceeds to the next page upon confirmation' do
+ click_continue
+
+ expect(page).to have_current_path(idv_cac_proofing_success_step)
+ end
+
+ context 'async timed out' do
+ it 'allows resubmitting form' do
+ allow(DocumentCaptureSession).to receive(:find_by).and_return(nil)
+
+ click_continue
+ expect(page).to have_content(t('idv.failure.timeout'))
+ expect(page).to have_current_path(idv_cac_proofing_verify_step)
+ allow(DocumentCaptureSession).to receive(:find_by).and_call_original
+ click_continue
+ expect(page).to have_current_path(idv_cac_proofing_success_step)
+ end
+ end
+ end
end
diff --git a/spec/features/idv/doc_auth/verify_step_spec.rb b/spec/features/idv/doc_auth/verify_step_spec.rb
index 8bedaf901ae..a83fdba4b53 100644
--- a/spec/features/idv/doc_auth/verify_step_spec.rb
+++ b/spec/features/idv/doc_auth/verify_step_spec.rb
@@ -5,10 +5,13 @@
include DocAuthHelper
include InPersonHelper
+ let(:skip_step_completion) { false }
let(:max_attempts) { idv_max_attempts }
before do
- sign_in_and_2fa_user
- complete_doc_auth_steps_before_verify_step
+ unless skip_step_completion
+ sign_in_and_2fa_user
+ complete_doc_auth_steps_before_verify_step
+ end
end
it 'is on the correct page' do
@@ -213,10 +216,59 @@
and_return(nil)
click_continue
+ expect(page).to have_content(t('idv.failure.timeout'))
expect(page).to have_current_path(idv_doc_auth_verify_step)
allow(DocumentCaptureSession).to receive(:find_by).and_call_original
click_continue
expect(page).to have_current_path(idv_phone_path)
end
end
+
+ context 'javascript enabled', js: true do
+ around do |example|
+ # Adjust the wait time to give the frontend time to poll for results.
+ Capybara.using_wait_time(5) do
+ example.run
+ end
+ end
+
+ it 'proceeds to the next page upon confirmation' do
+ click_idv_continue
+
+ expect(page).to have_current_path(idv_phone_path)
+ expect(page).to have_content(t('doc_auth.forms.doc_success'))
+ end
+
+ context 'resolution failure' do
+ let(:skip_step_completion) { true }
+
+ it 'does not proceed to the next page' do
+ sign_in_and_2fa_user
+ complete_doc_auth_steps_before_ssn_step
+ fill_out_ssn_form_with_ssn_that_fails_resolution
+ click_idv_continue
+ click_idv_continue
+
+ expect(page).to have_current_path(idv_session_errors_warning_path)
+
+ click_on t('idv.failure.button.warning')
+
+ expect(page).to have_current_path(idv_doc_auth_verify_step)
+ end
+ end
+
+ context 'async timed out' do
+ it 'allows resubmitting form' do
+ allow(DocumentCaptureSession).to receive(:find_by).
+ and_return(nil)
+
+ click_continue
+ expect(page).to have_content(t('idv.failure.timeout'))
+ expect(page).to have_current_path(idv_doc_auth_verify_step)
+ allow(DocumentCaptureSession).to receive(:find_by).and_call_original
+ click_continue
+ expect(page).to have_current_path(idv_phone_path)
+ end
+ end
+ end
end
diff --git a/spec/features/idv/steps/phone_step_spec.rb b/spec/features/idv/steps/phone_step_spec.rb
index 4798f223d09..131093d7c2b 100644
--- a/spec/features/idv/steps/phone_step_spec.rb
+++ b/spec/features/idv/steps/phone_step_spec.rb
@@ -119,6 +119,37 @@
expect(page).to have_current_path(idv_doc_auth_step_path(step: :welcome))
end
+ shared_examples 'async timed out' do
+ it 'allows resubmitting form' do
+ user = user_with_2fa
+ start_idv_from_sp
+ complete_idv_steps_before_phone_step(user)
+
+ allow(DocumentCaptureSession).to receive(:find_by).and_return(nil)
+
+ fill_out_phone_form_ok(MfaContext.new(user).phone_configurations.first.phone)
+ click_idv_continue
+ expect(page).to have_content(t('idv.failure.timeout'))
+ expect(page).to have_current_path(idv_phone_path)
+ allow(DocumentCaptureSession).to receive(:find_by).and_call_original
+ click_idv_continue
+ expect(page).to have_current_path(idv_review_path)
+ end
+ end
+
+ it_behaves_like 'async timed out'
+
+ context 'javascript enabled', js: true do
+ around do |example|
+ # Adjust the wait time to give the frontend time to poll for results.
+ Capybara.using_wait_time(5) do
+ example.run
+ end
+ end
+
+ it_behaves_like 'async timed out'
+ end
+
context 'cancelling IdV' do
it_behaves_like 'cancel at idv step', :phone
it_behaves_like 'cancel at idv step', :phone, :oidc
diff --git a/spec/javascripts/packs/form-steps-wait-spec.js b/spec/javascripts/packs/form-steps-wait-spec.js
index a543d6f6736..cb7968cd908 100644
--- a/spec/javascripts/packs/form-steps-wait-spec.js
+++ b/spec/javascripts/packs/form-steps-wait-spec.js
@@ -1,7 +1,77 @@
-import { fireEvent } from '@testing-library/dom';
+import { fireEvent, findByRole } from '@testing-library/dom';
import { useSandbox } from '../support/sinon';
import useDefineProperty from '../support/define-property';
-import { FormStepsWait } from '../../../app/javascript/packs/form-steps-wait';
+import {
+ FormStepsWait,
+ getDOMFromHTML,
+ isPollingPage,
+ getPageErrorMessage,
+} from '../../../app/javascript/packs/form-steps-wait';
+
+const POLL_PAGE_MARKUP = 'x';
+const NON_POLL_PAGE_MARKUP = 'x';
+
+describe('getDOMFromHTML', () => {
+ it('returns document of given markup', () => {
+ const dom = getDOMFromHTML(NON_POLL_PAGE_MARKUP);
+
+ expect(dom.querySelector('title').textContent).to.equal('x');
+ });
+});
+
+describe('isPollingPage', () => {
+ it('returns true if polling markup exists in page', () => {
+ const dom = getDOMFromHTML(POLL_PAGE_MARKUP);
+ const result = isPollingPage(dom);
+
+ expect(result).to.equal(true);
+ });
+
+ it('returns false if polling markup does not exist in page', () => {
+ const dom = getDOMFromHTML(NON_POLL_PAGE_MARKUP);
+ const result = isPollingPage(dom);
+
+ expect(result).to.equal(false);
+ });
+});
+
+describe('getPageErrorMessage', () => {
+ it('returns error message if polling markup exists in page', () => {
+ const errorMessage = 'An error occurred!';
+ const dom = getDOMFromHTML(
+ `${NON_POLL_PAGE_MARKUP}
+ `,
+ );
+ const result = getPageErrorMessage(dom);
+
+ expect(result).to.equal(errorMessage);
+ });
+
+ it('returns falsey if markup does not include alert', () => {
+ const dom = getDOMFromHTML(NON_POLL_PAGE_MARKUP);
+ const result = getPageErrorMessage(dom);
+
+ expect(result).to.not.be.ok();
+ });
+
+ it('returns falsey if markup contains non-error alert', () => {
+ const dom = getDOMFromHTML(
+ `${NON_POLL_PAGE_MARKUP}
+ `,
+ );
+ const result = getPageErrorMessage(dom);
+
+ expect(result).to.not.be.ok();
+ });
+});
describe('FormStepsWait', () => {
const sandbox = useSandbox({ useFakeTimers: true });
@@ -9,7 +79,13 @@ describe('FormStepsWait', () => {
function createForm({ action, method, options }) {
document.body.innerHTML = `
-
`;
@@ -35,7 +111,7 @@ describe('FormStepsWait', () => {
body: sandbox.match((formData) => /** @type {FormData} */ (formData).has('foo')),
}),
)
- .resolves({ ok: true });
+ .resolves({ status: 200 });
const didNativeSubmit = fireEvent.submit(form);
@@ -43,18 +119,183 @@ describe('FormStepsWait', () => {
mock.verify();
});
- it('reloads on failed submit', (done) => {
+ describe('failure', () => {
const action = new URL('/', window.location).toString();
const method = 'post';
- const form = createForm({ action, method });
- new FormStepsWait(form).bind();
- sandbox
- .stub(window, 'fetch')
- .withArgs(action, sandbox.match({ method }))
- .resolves({ ok: false });
- defineProperty(window, 'location', { value: { reload: done } });
- fireEvent.submit(form);
+ context('server error', () => {
+ beforeEach(() => {
+ sandbox
+ .stub(window, 'fetch')
+ .withArgs(action, sandbox.match({ method }))
+ .resolves({ ok: false, status: 500, url: 'http://example.com' });
+ });
+
+ it('stops spinner', (done) => {
+ const form = createForm({ action, method });
+ new FormStepsWait(form).bind();
+ fireEvent.submit(form);
+ form.addEventListener('spinner.stop', () => done());
+ });
+
+ context('error message configured', () => {
+ const errorMessage = 'An error occurred!';
+
+ /** @type {HTMLFormElement} */
+ let form;
+ beforeEach(() => {
+ form = createForm({ action, method });
+ form.setAttribute('data-error-message', errorMessage);
+ new FormStepsWait(form).bind();
+ });
+
+ it('shows message', async () => {
+ fireEvent.submit(form);
+ const alert = await findByRole(form, 'alert');
+ expect(alert.textContent).to.equal(errorMessage);
+ });
+ });
+ });
+
+ context('handled error', () => {
+ context('alert not in response', () => {
+ const redirect = window.location.href;
+ beforeEach(() => {
+ sandbox
+ .stub(window, 'fetch')
+ .withArgs(action, sandbox.match({ method }))
+ .resolves({
+ status: 200,
+ url: redirect,
+ redirected: true,
+ text: () => Promise.resolve(NON_POLL_PAGE_MARKUP),
+ });
+ });
+
+ it('redirects', (done) => {
+ const form = createForm({ action, method });
+ new FormStepsWait(form).bind();
+
+ fireEvent.submit(form);
+
+ const { pathname } = window.location;
+
+ defineProperty(window, 'location', {
+ value: {
+ get pathname() {
+ return pathname;
+ },
+ set href(url) {
+ expect(url).to.equal(redirect);
+ done();
+ },
+ },
+ });
+ });
+ });
+
+ context('alert in response', () => {
+ const errorMessage = 'An error occurred!';
+
+ context('synchronous resolution', () => {
+ const createResponse = (suffix = '') => ({
+ status: 200,
+ url: window.location.href,
+ redirected: true,
+ text: () =>
+ Promise.resolve(
+ `${NON_POLL_PAGE_MARKUP}
+
+
+
${errorMessage}${suffix}
+
+
`,
+ ),
+ });
+
+ beforeEach(() => {
+ sandbox
+ .stub(window, 'fetch')
+ .withArgs(action, sandbox.match({ method }))
+ .onFirstCall()
+ .resolves(createResponse())
+ .onSecondCall()
+ .resolves(createResponse(' Again!'));
+ });
+
+ it('shows message', async () => {
+ const form = createForm({ action, method });
+ new FormStepsWait(form).bind();
+
+ fireEvent.submit(form);
+
+ const alert = await findByRole(form, 'alert');
+ expect(alert.textContent).to.equal(errorMessage);
+ });
+
+ it('replaces previous message', async () => {
+ const form = createForm({ action, method });
+ new FormStepsWait(form).bind();
+
+ fireEvent.submit(form);
+
+ let alert = await findByRole(form, 'alert');
+ expect(alert.textContent).to.equal(errorMessage);
+
+ fireEvent.submit(form);
+
+ alert = await findByRole(form, 'alert');
+ expect(alert.textContent).to.equal(`${errorMessage} Again!`);
+ });
+ });
+
+ context('asynchronous resolution', () => {
+ const waitStepPath = '/wait';
+
+ beforeEach(() => {
+ sandbox
+ .stub(window, 'fetch')
+ .withArgs(action, sandbox.match({ method }))
+ .resolves({
+ status: 200,
+ redirected: true,
+ url: new URL(waitStepPath, window.location).toString(),
+ text: () => Promise.resolve(POLL_PAGE_MARKUP),
+ })
+ .withArgs(waitStepPath)
+ .resolves({
+ status: 200,
+ redirected: true,
+ url: window.location.href,
+ text: () =>
+ Promise.resolve(
+ `${NON_POLL_PAGE_MARKUP}
+ `,
+ ),
+ });
+ sandbox.stub(global, 'setTimeout').callsArg(0);
+ });
+
+ it('shows message', async () => {
+ const form = createForm({
+ action,
+ method,
+ options: { waitStepPath, pollIntervalMs: 0 },
+ });
+ new FormStepsWait(form).bind();
+
+ fireEvent.submit(form);
+
+ const alert = await findByRole(form, 'alert');
+ expect(alert.textContent).to.equal(errorMessage);
+ });
+ });
+ });
+ });
});
it('navigates on redirected response', (done) => {
@@ -66,7 +307,12 @@ describe('FormStepsWait', () => {
sandbox
.stub(window, 'fetch')
.withArgs(action, sandbox.match({ method }))
- .resolves({ ok: true, redirected: true, url: redirect });
+ .resolves({
+ status: 200,
+ redirected: true,
+ url: redirect,
+ text: () => Promise.resolve(NON_POLL_PAGE_MARKUP),
+ });
defineProperty(window, 'location', {
value: {
set href(url) {
@@ -91,12 +337,18 @@ describe('FormStepsWait', () => {
.stub(window, 'fetch')
.withArgs(action, sandbox.match({ method }))
.resolves({
- ok: true,
+ status: 200,
redirected: true,
url: new URL(waitStepPath, window.location).toString(),
+ text: () => Promise.resolve(POLL_PAGE_MARKUP),
})
- .withArgs(waitStepPath, sandbox.match({ method: 'HEAD' }))
- .resolves({ ok: true, redirected: true, url: redirect });
+ .withArgs(waitStepPath)
+ .resolves({
+ status: 200,
+ redirected: true,
+ url: redirect,
+ text: () => Promise.resolve(NON_POLL_PAGE_MARKUP),
+ });
defineProperty(window, 'location', {
value: {
diff --git a/spec/javascripts/packs/spinner-button-spec.js b/spec/javascripts/packs/spinner-button-spec.js
index 6044b037799..794a1c26505 100644
--- a/spec/javascripts/packs/spinner-button-spec.js
+++ b/spec/javascripts/packs/spinner-button-spec.js
@@ -1,6 +1,6 @@
import sinon from 'sinon';
import userEvent from '@testing-library/user-event';
-import { getByRole } from '@testing-library/dom';
+import { getByRole, fireEvent } from '@testing-library/dom';
import { SpinnerButton } from '../../../app/javascript/packs/spinner-button';
describe('SpinnerButton', () => {
@@ -102,4 +102,15 @@ describe('SpinnerButton', () => {
clock.tick(1);
expect(status.classList.contains('usa-sr-only')).to.be.false();
});
+
+ it('supports external dispatched events to control spinner', () => {
+ const wrapper = createWrapper();
+ const spinnerButton = new SpinnerButton(wrapper);
+ spinnerButton.bind();
+
+ fireEvent(wrapper, new window.CustomEvent('spinner.start'));
+ expect(wrapper.classList.contains('spinner-button--spinner-active')).to.be.true();
+ fireEvent(wrapper, new window.CustomEvent('spinner.stop'));
+ expect(wrapper.classList.contains('spinner-button--spinner-active')).to.be.false();
+ });
});
diff --git a/tsconfig.json b/tsconfig.json
index 4eef56be3f8..2ad5010b8cf 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -14,7 +14,7 @@
"include": [
"app/javascript/packages",
"app/javascript/packs/document-capture.jsx",
- "app/javascript/packs/form-steps-wait.js",
+ "app/javascript/packs/form-steps-wait.jsx",
"app/javascript/packs/form-validation.js",
"app/javascript/packs/intl-tel-input.js",
"app/javascript/packs/spinner-button.js",
diff --git a/yarn.lock b/yarn.lock
index 46624bebc35..479b214bf06 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1219,6 +1219,13 @@
resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.4.tgz#15925414e0ad2cd765bfef58842f7e26a7accb24"
integrity sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug==
+"@types/react-dom@^17.0.0":
+ version "17.0.0"
+ resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.0.tgz#b3b691eb956c4b3401777ee67b900cb28415d95a"
+ integrity sha512-lUqY7OlkF/RbNtD5nIq7ot8NquXrdFrjSOR6+w9a9RFQevGi1oZO1dcJbXMeONAPKtZ2UrZOEJ5UOCVsxbLk/g==
+ dependencies:
+ "@types/react" "*"
+
"@types/react-test-renderer@*":
version "16.9.3"
resolved "https://registry.yarnpkg.com/@types/react-test-renderer/-/react-test-renderer-16.9.3.tgz#96bab1860904366f4e848b739ba0e2f67bcae87e"