src/applications/edu-benefits/feedback-tool/helpers.js
import appendQuery from 'append-query';
import * as Sentry from '@sentry/browser';
import React from 'react';
import fullSchema from 'vets-json-schema/dist/FEEDBACK-TOOL-schema.json';
import { transformForSubmit } from 'platform/forms-system/src/js/helpers';
import dataUtils from 'platform/utilities/data/index';
import { apiRequest } from 'platform/utilities/api';
import recordEvent from 'platform/monitoring/record-event';
import environment from '@department-of-veterans-affairs/platform-utilities/environment';
import UserInteractionRecorder from '../components/UserInteractionRecorder';
export const isProductionOfTestProdEnv = automatedTest => {
return (
environment.isProduction() ||
automatedTest ||
(global && global?.window && global?.window?.buildType)
);
};
export const trackingPrefix = 'edu-feedback-tool-';
const { get, unset } = dataUtils;
const domesticSchoolAddressFields = get(
'properties.educationDetails.properties.school.properties.address.anyOf[0].properties',
fullSchema,
);
const searchToolSchoolAddressFields = get(
'properties.educationDetails.properties.school.properties.address.anyOf[2].properties',
fullSchema,
);
// The flags to add to formData that will indicate if a page is prefilled or not
// These flags will be used for each form page's call to
// conditionalPrefillMessage()
export const PREFILL_FLAGS = {
// if formData.fullName is set:
APPLICANT_INFORMATION: 'view:applicantInformationWasPrefilled',
// if formData.serviceBranch or formData.serviceDateRange is set:
SERVICE_INFORMATION: 'view:serviceInformationWasPrefilled',
// if formData.address or formData.phone or formData.applicantEmail is set:
CONTACT_INFORMATION: 'view:contactInformationWasPrefilled',
};
// For a given PREFILL_FLAG, what data needs to exist in the prefilled data for
// that flag to be set to `true`? ie, what prefill data needs to exist for a
// given page to be considered prefilled?
const prefillFlagsToFieldsMap = {
[PREFILL_FLAGS.APPLICANT_INFORMATION]: ['fullName'],
[PREFILL_FLAGS.SERVICE_INFORMATION]: ['serviceBranch', 'serviceDateRange'],
[PREFILL_FLAGS.CONTACT_INFORMATION]: ['address', 'phone', 'applicantEmail'],
};
export function fetchInstitutions({ institutionQuery, page, onDone, onError }) {
const fetchUrl = appendQuery('/gi/institutions/search', {
name: institutionQuery,
include_address: true, // eslint-disable-line camelcase
page,
});
return apiRequest(fetchUrl)
.then(payload => onDone(payload))
.catch(error => onError(error));
}
// Helper to remove the facility code. Needed if the code was set via the
// search tool and then manual address entry was selected. if this is not
// cleared out the (incorrect) facility code will be sent along with the
// manually entered school address.
export function removeFacilityCodeIfManualEntry(form) {
if (
get(
'data.educationDetails.school.view:searchSchoolSelect.view:manualSchoolEntryChecked',
form,
)
) {
return unset(
'data.educationDetails.school.view:searchSchoolSelect.facilityCode',
form,
);
}
return form;
}
export function transform(
formConfig,
form,
formTransformer = removeFacilityCodeIfManualEntry,
) {
const formData = transformForSubmit(formConfig, formTransformer(form), null);
return JSON.stringify({
giBillFeedback: {
form: formData,
},
});
}
function checkStatus(guid) {
const headers = { 'Content-Type': 'application/json' };
return apiRequest(`/gi_bill_feedbacks/${guid}`, { headers }).catch(res => {
if (res instanceof Error) {
Sentry.captureException(res);
Sentry.captureMessage('vets_gi_bill_feedbacks_poll_client_error');
recordEvent({ event: `${trackingPrefix}submission-failed` });
// keep polling because we know they submitted earlier
// and this is likely a network error
return Promise.resolve();
}
// if we get here, it's likely that we hit a server error
return Promise.reject(res);
});
}
const POLLING_INTERVAL = 1000;
function pollStatus(guid, onDone, onError) {
setTimeout(() => {
checkStatus(guid)
.then(res => {
if (!res || res.data.attributes.state === 'pending') {
pollStatus(guid, onDone, onError);
} else if (res.data.attributes.state === 'success') {
onDone(res.data.attributes.parsedResponse);
} else {
recordEvent({ event: `${trackingPrefix}submission-failed` });
// needs to start with this string to get the right message on the form
throw new Error(
`vets_server_error_gi_bill_feedbacks: status ${
res.data.attributes.state
}`,
);
}
})
.catch(onError);
}, window.VetsGov.pollTimeout || POLLING_INTERVAL);
}
export function submit(form, formConfig) {
const headers = { 'Content-Type': 'application/json' };
const body = transform(formConfig, form);
const apiRequestOptions = {
method: 'POST',
headers,
body,
};
const onSuccess = json => {
const { guid } = json.data.attributes;
return new Promise((resolve, reject) => {
pollStatus(
guid,
response => {
recordEvent({
event: `${formConfig.trackingPrefix}submission-successful`,
});
return resolve(response);
},
error => reject(error),
);
});
};
const onFailure = respOrError => {
if (respOrError instanceof Response && respOrError.status === 429) {
const error = new Error('vets_throttled_error_gi_bill_feedbacks');
error.extra = parseInt(respOrError.headers.get('x-ratelimit-reset'), 10);
recordEvent({ event: `${formConfig.trackingPrefix}submission-failed` });
return Promise.reject(error);
}
return Promise.reject(respOrError);
};
return apiRequest('/gi_bill_feedbacks', apiRequestOptions)
.then(onSuccess)
.catch(onFailure);
}
/**
* The base object all the onBehalfOf tracking event objects extend
*/
const baseOnBehalfOfEventObject = {
event: `${trackingPrefix}applicant-selection`,
};
/**
* Map of possible onBehalfOf values and the tracking event object to send to
* Google Analytics
*/
const applicantRelationshipEventMap = {
Myself: {
...baseOnBehalfOfEventObject,
completingForm: 'myself',
},
'Someone else': {
...baseOnBehalfOfEventObject,
completingForm: 'someone-else',
},
Anonymous: {
...baseOnBehalfOfEventObject,
completingForm: 'anonymous',
},
};
/**
*
* @param {*} _ - Object with a formData.onBehalfOf property (from GIBFT
* form)
*/
export function recordApplicantRelationship({ formData: { onBehalfOf } }) {
return (
<UserInteractionRecorder
eventRecorder={recordEvent}
selectedValue={onBehalfOf}
trackingEventMap={applicantRelationshipEventMap}
/>
);
}
export function issueUIDescription({ formContext }) {
if (!formContext) {
// HACK: due to https://github.com/usds/us-forms-system/issues/260
return (
<div>
Please note, below the topics are examples of what the feedback could
include.
</div>
);
}
return null;
}
/**
* Checks the values of an object's properties and completely removes the
* property if its value is an empty string. Immutable and shallow.
*
* @param {Object} obj - the object to strip properties off of
* @returns {Object} - the object without top-level properties that were empty
* strings
*/
export function removeEmptyStringProperties(obj) {
const cleanObject = { ...obj };
Object.keys(cleanObject).forEach(key => {
const value = cleanObject[key];
if (typeof value === 'string' && value.trim() === '') {
delete cleanObject[key];
}
});
return cleanObject;
}
// Formats address on one line
// Used in school select field radio options
export function displaySingleLineAddress(obj) {
const { address1, address2, address3, city, state, zip, country } = obj;
return `${address1}${address2 && `, ${address2}`}${address3 &&
`, ${address3}`}, ${city || ''}${city && state ? ', ' : ''}${state || ''}${
!state ? ` ${country}` : ` ${zip || ''}`
}`;
}
/*
* A helper that takes data from the SchoolSelectField back end and transforms
* it to a valid format as specified by the FEEDBACK-TOOL's
* educationDetails.school.address schema.
*
* @param {*} _ - Object with address fields
* @returns {Object} An Object that passes the FEEDBACK-TOOL's educationDetails.
* school.address schema.
*/
export function transformSearchToolAddress({
address1,
address2,
address3,
city,
country,
state,
zip,
}) {
const isDomesticAddress = country === 'USA';
let address = {};
if (isDomesticAddress) {
address = {
country: 'United States',
street:
address1 &&
address1.slice(0, domesticSchoolAddressFields.street.maxLength),
street2:
address2 &&
address2.slice(0, domesticSchoolAddressFields.street2.maxLength),
street3:
address3 &&
address3.slice(0, domesticSchoolAddressFields.street3.maxLength),
city: city && city.slice(0, domesticSchoolAddressFields.city.maxLength),
state:
state && state.slice(0, domesticSchoolAddressFields.state.maxLength),
postalCode:
zip && zip.slice(0, domesticSchoolAddressFields.postalCode.maxLength),
};
} else {
address = {
country,
street:
address1 &&
address1.slice(0, searchToolSchoolAddressFields.street.maxLength),
street2:
address2 &&
address2.slice(0, searchToolSchoolAddressFields.street2.maxLength),
street3:
address3 &&
address3.slice(0, searchToolSchoolAddressFields.street3.maxLength),
city: city && city.slice(0, searchToolSchoolAddressFields.city.maxLength),
state:
state && state.slice(0, searchToolSchoolAddressFields.state.maxLength),
postalCode:
zip && zip.slice(0, searchToolSchoolAddressFields.postalCode.maxLength),
};
}
return removeEmptyStringProperties(address);
}
function addPrefilledFlagsToFormData(formData) {
const newFormData = { ...formData };
Object.keys(prefillFlagsToFieldsMap).forEach(flag => {
if (prefillFlagsToFieldsMap[flag].some(field => get(field, formData))) {
newFormData[flag] = true;
}
});
return newFormData;
}
export function prefillTransformer(pages, formData, metadata) {
const newFormData = addPrefilledFlagsToFormData(formData);
return { metadata, formData: newFormData, pages };
}
/**
* Checks if the `prefillFlag` is set on the `data.formData`. If it is, returns
* the React Component created by the `messageComponentCreator`
*
* @param {string} prefillFlag - The key to look for on the form data
* @param {Object} data - Data object from form config
* @param {React Component} messageComponent - The React component to render
* @returns {React Component|null}
*/
export function conditionallyShowPrefillMessage(
prefillFlag,
data,
messageComponent,
) {
if (data.formData[prefillFlag]) {
return messageComponent(data);
}
return null;
}
export function validateMatch(field1, field2, fieldType) {
return function matchValidator(errors, formData) {
if (formData[field1] !== formData[field2]) {
errors[field2].addError(`Please ensure your ${fieldType} entries match`);
}
};
}