Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/controllers/idv/phone_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
78 changes: 0 additions & 78 deletions app/javascript/packs/form-steps-wait.js

This file was deleted.

180 changes: 180 additions & 0 deletions app/javascript/packs/form-steps-wait.jsx
Original file line number Diff line number Diff line change
@@ -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(
<Alert type="error" className="margin-bottom-4">
{message}
</Alert>,
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());
});
31 changes: 25 additions & 6 deletions app/javascript/packs/spinner-button.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
2 changes: 1 addition & 1 deletion app/services/idv/steps/cac/verify_wait_step_show.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
2 changes: 1 addition & 1 deletion app/services/idv/steps/recover_verify_wait_step_show.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
3 changes: 1 addition & 2 deletions app/services/idv/steps/verify_wait_step_show.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions app/views/idv/cac/verify.html.erb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
<div id="form-steps-wait-alert"></div>

<% title t('cac_proofing.titles.cac_proofing') %>

<h5 class="my1 caps bold accent-blue">
Expand Down Expand Up @@ -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,
},
Expand Down
4 changes: 4 additions & 0 deletions app/views/idv/doc_auth/verify.html.erb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
<div id="form-steps-wait-alert"></div>

<% title t('titles.doc_auth.verify') %>

<h1 class='h3 my0'>
Expand Down Expand Up @@ -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,
},
Expand Down
Loading