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} +
+
+

${errorMessage}

+
+
`, + ); + 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} +
+
+

Good news, everyone!

+
+
`, + ); + 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} +
+
+

${errorMessage}

+
+
`, + ), + }); + 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"