Merge branch 'develop' into feature/CSR-7

This commit is contained in:
FrankRua 2022-05-02 11:53:02 -04:00
commit e50860d7b0
83 changed files with 3284 additions and 921 deletions

View file

@ -79,6 +79,9 @@ stages:
__VUE_APP_CONSUMER_API_GATEWAY__: $(__VUE_APP_CONSUMER_API_GATEWAY__)
__VUE_APP_GOOGLE_PLACES_API_KEY__: $(__VUE_APP_GOOGLE_PLACES_API_KEY__)
__VUE_APP_HERITAGE_FUNNEL__: $(__VUE_APP_HERITAGE_FUNNEL__)
indexDeployVariables:
__VUE_APP_GOOGLE_TAG_MANAGER_SCRIPT_BODY__: $(__VUE_APP_GOOGLE_TAG_MANAGER_SCRIPT_BODY__)
__VUE_APP_GOOGLE_TAG_MANAGER_NOSCRIPT_FRAME_SRC__: $(__VUE_APP_GOOGLE_TAG_MANAGER_NOSCRIPT_FRAME_SRC__)
cfDistributionId: $(cfDistributionId)

View file

@ -11,30 +11,28 @@ module.exports = {
"!src/constants/*.js",
"!src/router/**/*.js",
"!src/helpers/unit-test-helper.js",
"!src/helpers/damage-helper.js",
"!src/layouts/component-test/component-test.vue",
"!src/layouts/form-test/form-test.vue",
"!src/layouts/license-plate-lookup/license-plate-lookup.vue",
"!src/layouts/vin-lookup/vin-lookup.vue",
"!src/layouts/license-plate-lookup/license-plate-lookup.vue",
"!src/layouts/vehicle-damage/windshield-damage-type-question/windshield-damage-type-question.vue",
"!src/layouts/vehicle-damage/windshield-options/windshield-options.vue",
"!src/layouts/address-poc/address-poc.vue",
"!src/layouts/nested-radio-poc/nested-radio.vue",
"!src/layouts/button-question-examples/**/*.vue",
"!src/layouts/part-questions/**/*.vue",
"!src/layouts/reveal/**/*.vue",
"!src/layouts/estimate/**/*.vue",
// REMOVE THESE AFTER WRITING UNIT TESTS
"!src/layouts/address-lookup/address-lookup.vue",
"!src/layouts/address-lookup/customer-questions/customer-questions.vue",
"!src/layouts/address-lookup/customer-questions/address-questions/address-questions.vue",
"!src/common-components/dropdown-question/dropdown-question.vue",
"!src/common-components/textbox-question/textbox-question.vue",
"!src/helpers/validation-rules.js",
"!src/common-components/textbox-question/textbox-question.vue",
// END
], //! means exclude from coverage.
testMatch: ["**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)"],
coverageThreshold: {
global: {
statements: 87,
statements: 85,
// Got the go ahead from Mark to temporarily lower this. Taking out initialize component made the year,make,model and style coverage drop a bit. Once unit tests for license plate lookup, vin lookup and address lookup are in the coverage should go back up to 90
},
},

View file

@ -1,6 +1,10 @@
<!DOCTYPE html>
<html lang="en-US">
<head>
<script>
window.dataLayer = [{}];
</script>
<script><%= process.env.VUE_APP_GOOGLE_TAG_MANAGER_SCRIPT_BODY %></script>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
@ -11,9 +15,17 @@
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<!-- Google Tag Manager -->
<noscript>
<iframe src="<%= process.env.VUE_APP_GOOGLE_TAG_MANAGER_NOSCRIPT_FRAME_SRC %>"
height="0" width="0" style="display:none;visibility:hidden"></iframe>
</noscript>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>

View file

@ -1,6 +1,8 @@
import { shallowMount } from "@vue/test-utils";
import buttonQuestion from "@/common-components/button-question/button-question";
import { nextTick } from "vue";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
jest.mock("@/store", () => { return {}; }, { virtual: true });
describe("buttonQuestion.vue", () => {
it("Should show overflow classes on fieldset if isOverflowScrollable is true", () => {
@ -45,15 +47,74 @@ describe("buttonQuestion.vue", () => {
});
});
describe("buttonQuestion.vue", () => {
it("Fieldset classes should contain ui-radio if button type is radio", () => {
// Act
const wrapper = shallowMount(buttonQuestion, {
propsData: {
buttonType: "radio",
}
});
// Assert
const Div = wrapper.find('fieldset div');
expect(Div.classes()).toContain("ui-radio");
});
});
// testing a computed property
describe("buttonQuestion.vue", () => {
it("getColLength should return '12' if prop isWide is set to true", () => {
// Act
const localThis = { isWide: true }
expect(buttonQuestion.computed.getColLength.call(localThis)).toBe("12");
});
});
describe("buttonQuestion.vue", () => {
it("getColLength should return '' if prop isWide is set to false", () => {
// Act
const localThis = {
isWide: false,
answers: ['a', 'b']
}
expect(buttonQuestion.computed.getColLength.call(localThis)).toBe("");
});
});
describe("buttonQuestion.vue", () => {
it("Should return answer.Text if prop useTextForValue is true", async () => {
// Act
const localThis = { useTextForValue: true };
const answer = { 'Name': 'testName', 'Text': 'testText' };
// Assert
expect(buttonQuestion.methods.getValues.call(localThis, answer)).toBe('testText');
});
});
describe("buttonQuestion.vue", () => {
it("Should return answer.Name if prop useTextForValue is false and answer.Name exists", async () => {
// Act
const localThis = { useTextForValue: false };
const answer = { 'Name': 'testName', 'Text': 'testText' };
// Assert
expect(buttonQuestion.methods.getValues.call(localThis, answer)).toBe('testName');
});
});
describe("buttonQuestion.vue", () => {
it("Should trigger event modelValue change to new value on when radio button selected", async () => {
// Act
const wrapper = shallowMount(buttonQuestion);
const wrapper = shallowMount(buttonQuestion, setupMocks({}));
await wrapper.setProps({
answers: ["2022", "2021", "2020"],
isMultiSelect: false
});
const val = {checkValue: true, value: "2021", }
const val = { checkValue: true, value: "2021", }
wrapper.vm.handleCheckedChanged(val);
// Assert
expect(wrapper.emitted()["update:modelValue"][0]).toEqual([["2021"]]);
@ -63,16 +124,76 @@ describe("buttonQuestion.vue", () => {
describe("buttonQuestion.vue", () => {
it("Should add values to array on checkbox click", () => {
// Act
const wrapper = shallowMount(buttonQuestion, {
const wrapper = shallowMount(buttonQuestion, setupMocks({
propsData: {
modelValue: ["2022", "2021", "2020"],
isMultiSelect: true,
}
});
const val = {checkValue: true, value: "2019", }
}));
const val = { checkValue: true, value: "2019", }
wrapper.vm.handleCheckedChanged(val);
// Assert
expect(wrapper.emitted()["update:modelValue"][0]).toEqual([["2022", "2021", "2020", "2019"]]);
});
});
describe("buttonQuestion.vue", () => {
it("Should add a value to this.selectedValues if prop isMultiSelect is true, checkValue is true and this.selectedValues already exists", () => {
// Act
const wrapper = shallowMount(buttonQuestion, setupMocks({
propsData: {
isMultiSelect: true,
modelValue: ['a', 'b']
}
}));
const val = { checkValue: true, value: "2021", }
wrapper.vm.handleCheckedChanged(val);
// Assert
expect(wrapper.vm.selectedValues).toEqual(["a", "b", "2021"]);
});
});
describe("buttonQuestion.vue", () => {
it("Should remove a value to this.selectedValues if prop isMultiSelect is true, checkValue is false and this.selectedValues already exists", () => {
// Act
const wrapper = shallowMount(buttonQuestion, setupMocks({
propsData: {
isMultiSelect: true,
modelValue: ['a', 'b']
}
}));
const val = { checkValue: false, value: "a", }
wrapper.vm.handleCheckedChanged(val);
// Assert
expect(wrapper.vm.selectedValues).toEqual(["b"]);
});
});
describe("buttonQuestion.vue", () => {
it("Should do nothing to this.selectedValues if this.selectedValues is not an array", () => {
// Act
const wrapper = shallowMount(buttonQuestion, setupMocks({
propsData: {
isMultiSelect: true,
modelValue: 'a',
}
}));
const val = { checkValue: true, value: "c", }
wrapper.vm.handleCheckedChanged(val);
// Assert
expect(wrapper.vm.selectedValues).toEqual("a");
});
});
function setupMocks(mountOptionsMockData = {}) {
const defaultMountOptions = { route: { query: { fmgPage: 'page-name' } } };
const baseMountOptions = getMountOptions(Object.assign(defaultMountOptions, mountOptionsMockData));
const allMountOptions = Object.assign(defaultMountOptions, baseMountOptions);
return allMountOptions;
}

View file

@ -5,7 +5,7 @@
<span class="fs-5 fw-bold w-100" :class="this.buttonType === 'radio' ? 'text-start' : 'text-center'">{{ questionText }}</span>
</div>
<div class="w-100 d-flex justify-content-center">
<fieldset class="w-100" :class="getFieldSetClasses" :role="isMultiSelect ? 'group' : 'radiogroup'" :aria-labelledby="groupName ? groupName + '-radio-group' : ''">
<fieldset class="w-100" :aria-required=isRequired :class="getFieldSetClasses" :role="isMultiSelect ? 'group' : 'radiogroup'" :aria-labelledby="groupName ? groupName + '-radio-group' : ''">
<legend class="sr-only" :data-focus-target="groupName" tabindex="-1">
{{(isMultiSelect && answers && answers.length > 1) ? 'Select one or more options below.' : 'Select an option below.' }}
</legend>
@ -35,6 +35,7 @@
:selectedValues="selectedValues"
data-test="button"
:validationRules="validationRules"
:class="[suppressError ? 'alertError' : '']"
/>
</div>
</fieldset>
@ -51,6 +52,7 @@ import listButtonHorizontal from "@/ux-components/list-button-horizontal/list-bu
import listCard from "@/ux-components/list-card/list-card";
import { ErrorMessage } from 'vee-validate';
import radio from "@/ux-components/radio/radio";
import { queryStrings } from "@/constants/query-strings";
export default {
name: "buttonQuestion",
@ -132,6 +134,9 @@ export default {
return answer.Name ? answer.Name : answer;
},
handleCheckedChanged(val) {
this.pushEventToGA(this.$route.query[queryStrings.FMG_PAGE], this.GaActions.CLICKED, val.value, true);
if(this.isMultiSelect && this.selectedValues) {
// Add or remove item to array of data to emit
const newSelectedValues = this.selectedValues;
@ -157,11 +162,11 @@ export default {
<style lang="scss" scoped>
.button-question-overflow {
height: calc(100vh - 252px);
height: calc(100vh - 266px);
.overflow-scroll {
// Height will be determined by overall height of content above list
height: calc(100% - 300px);
height: calc(100% - 314px);
overflow-x: hidden !important;
-webkit-overflow-scrolling: touch;
}

View file

@ -1,6 +1,7 @@
import { shallowMount } from "@vue/test-utils";
import dropdownQuestion from "./dropdown-question";
import { nextTick } from "vue";
import { maska } from 'maska';
describe("dropdownQuestion.vue", () => {
@ -19,7 +20,7 @@ describe("dropdownQuestion.vue", () => {
expect(input.attributes()["aria-disabled"]).toEqual("true");
});
it("Should render a text input", async () => {
it("Should render a select input", async () => {
// Act
const wrapper = shallowMount(dropdownQuestion, {
propsData: {
@ -49,33 +50,82 @@ describe("dropdownQuestion.vue", () => {
expect(input.attributes().id).toEqual("input ID");
});
/* it("Should return label text", async () => {
it("Should render the 'questionText' data value as the label text when disableAutoFill is false.", async () => {
// Arrange
//Mock CMS content
const questionText = "Question Text";
const cmsContent = {
QuestionText: questionText,
};
// Act
const wrapper = shallowMount(dropdownQuestion, {
propsData: {
labelText: "label text",
global: {
directives: {
maska: maska,
}
},
});
await wrapper.setData({
questionText: questionText,
});
wrapper.vm.initializeComponent(cmsContent);
// Assert
const label = wrapper.find("label");
expect(wrapper.find("label").text()).toContain(questionText);
wrapper.unmount();
});
it("Should render the 'questionText' data value with '&NoBreak;' after the first character as the label text when disableAutoFill is true.", async () => {
// Arrange
//Mock CMS content
const originalQuestionText = "Question Text";
// Trust me, the below instance of the string "Question Text" actually has the &NoBreak; in it. You just can't see it
// Don't believe me? Copy it and paste it into Google. Then inspect the search field element in Dev Tools,
// you will see "Q&uestion Text"
const expectedQuestionText = "Question Text";
const cmsContent = {
QuestionText: originalQuestionText,
};
expect(label.text()).toEqual("label text");
}); */
it("Should return input id", async () => {
// Act
const wrapper = shallowMount(dropdownQuestion, {
global: {
directives: {
maska: maska,
}
},
propsData: {
inputId: "input ID",
disableAutoFill: true,
},
});
await wrapper.setData({
questionText: originalQuestionText,
});
wrapper.vm.initializeComponent(cmsContent);
// Assert
const input = wrapper.find("select");
// Expect
expect(input.attributes().id).toEqual("input ID");
expect(wrapper.vm.$el.children[0].innerHTML).toBe(expectedQuestionText);
wrapper.unmount();
});
it("Should emit new value when modelValue is changed", async () => {
// Act
const wrapper = shallowMount(dropdownQuestion, {
global: {
directives: {
maska: maska,
}
},
propsData: {
modelValue: "val",
},
});
await wrapper.find("select").setValue("val2");
// Assert
expect(wrapper.emitted()).toHaveProperty('change')
});
});

View file

@ -37,6 +37,7 @@ export default {
isRequired: Boolean,
disableAutoFill: Boolean,
validationRules: String,
cmsWidgetName: String,
},
setup(props) {
const fieldOptions = {
@ -57,18 +58,11 @@ export default {
handleChange,
meta,
};
},
data() {
return {
questionText: "",
}
},
methods: {
initializeComponent(cmsContent) {
this.questionText = cmsContent;
}
},
},
computed: {
questionText(){
return this.getCmsContent(this.cmsWidgetName, 'QuestionText');
},
selectedOption: {
get: function() {
return this.modelValue;
@ -79,14 +73,22 @@ export default {
},
labelText: {
get: function () {
let labelText = this.questionText;
const noBreakChar = "&NoBreak;";
var questionText = "";
if (this.disableAutoFill) {
const noBreakChar = "&NoBreak;";
const position = 1;
labelText = [labelText.slice(0, position), noBreakChar, labelText.slice(position)].join('');
var words = this.questionText.toString().split(/[ ]+/);
words.forEach(function (word) {
const position = 1;
word = [word.toString().slice(0, position), noBreakChar, word.toString().slice(position)].join('');
questionText += `${word} `;
});
questionText = questionText.trimEnd();
} else {
questionText = this.questionText.toString();
}
return labelText;
return questionText;
}
}
},

View file

@ -22,13 +22,13 @@
isPrimary
:buttonText="buttonText"
loaderColor="white"
:class="isDisabled && 'form-test-invalid'"
:aria-disabled="isDisabled"
:isDisabled="isDisabled"
:class="isForwardActionDisabled && 'form-test-invalid'"
:aria-disabled="isForwardActionDisabled"
:isDisabled="isForwardActionDisabled"
@click-event="buttonClick"
/>
</div>
<div class="col-auto link-col py-1 text-break">
<div v-if="!isBackButtonHidden" class="col-auto link-col py-1 text-break">
<textLink
linkType="navigation"
:text="backLink"
@ -48,8 +48,10 @@ import buttonMain from "@/ux-components/button-main/button-main";
export default {
name: "funnelFooter",
props: {
isDisabled: Boolean,
isForwardActionDisabled: Boolean,
isBackButtonHidden: {type: Boolean, default: false},
cmsWidgetName: String,
},
components: {
textLink,
@ -90,6 +92,9 @@ export default {
},
removeLoader(){
this.$refs.buttonMain.removeLoader();
document.onkeydown = function (e) {
return true;
};
},
buttonClick() {
//prevent keyboard input after button click

View file

@ -1,12 +1,17 @@
import { shallowMount } from "@vue/test-utils";
import textboxQuestion from "./textbox-question";
import { nextTick } from "vue";
import { maska } from 'maska';
describe("textboxQuestion.vue", () => {
it("Should return aria-disabled state", async () => {
// Act
const wrapper = shallowMount(textboxQuestion, {
global: {
directives: {
maska: maska,
}
},
propsData: {
isDisabled: true,
},
@ -17,11 +22,17 @@ describe("textboxQuestion.vue", () => {
// Expect
expect(input.attributes()["aria-disabled"]).toEqual("true");
});
it("Should render a text input", async () => {
// Act
const wrapper = shallowMount(textboxQuestion, {
global: {
directives: {
maska: maska,
}
},
propsData: {
name: "test",
label: "unit test label",
@ -32,11 +43,17 @@ describe("textboxQuestion.vue", () => {
const input = wrapper.find("input");
expect(input.exists()).toBe(true);
});
it("Should return input id", async () => {
// Act
const wrapper = shallowMount(textboxQuestion, {
global: {
directives: {
maska: maska,
}
},
propsData: {
inputId: "input ID",
},
@ -47,35 +64,115 @@ describe("textboxQuestion.vue", () => {
// Expect
expect(input.attributes().id).toEqual("input ID");
});
it("Should return label text", async () => {
it("Should render the 'questionText' data value as the aria-label attribute.", async () => {
// Arrange
//Mock CMS content
const questionText = "Question Text";
const cmsContent = {
QuestionText: questionText,
};
// Act
const wrapper = shallowMount(textboxQuestion, {
global: {
directives: {
maska: maska,
}
},
});
await wrapper.setData({
questionText: questionText,
});
wrapper.vm.initializeComponent(cmsContent);
// Assert
expect(wrapper.find("label").attributes('aria-label')).toBe(questionText);
wrapper.unmount();
});
it("Should render the 'questionText' data value as the label text when disableAutoFill is false.", async () => {
// Arrange
//Mock CMS content
const questionText = "Question Text";
const cmsContent = {
QuestionText: questionText,
};
// Act
const wrapper = shallowMount(textboxQuestion, {
global: {
directives: {
maska: maska,
}
},
});
await wrapper.setData({
questionText: questionText,
});
wrapper.vm.initializeComponent(cmsContent);
// Assert
expect(wrapper.find("label").text()).toContain(questionText);
wrapper.unmount();
});
it("Should render the 'questionText' data value with '&NoBreak;' after the first character as the label text when disableAutoFill is true.", async () => {
// Arrange
//Mock CMS content
const originalQuestionText = "Question Text";
// Trust me, the below instance of the string "Question Text" actually has the &NoBreak; in it. You just can't see it
// Don't believe me? Copy it and paste it into Google. Then inspect the search field element in Dev Tools,
// you will see "Q&uestion Text"
const expectedQuestionText = "Question Text";
const cmsContent = {
QuestionText: originalQuestionText,
};
// Act
const wrapper = shallowMount(textboxQuestion, {
global: {
directives: {
maska: maska,
}
},
propsData: {
labelText: "label text",
disableAutoFill: true,
},
});
await wrapper.setData({
questionText: originalQuestionText,
});
wrapper.vm.initializeComponent(cmsContent);
// Assert
expect(wrapper.vm.$el.children[0].innerHTML).toBe(expectedQuestionText);
wrapper.unmount();
});
it("Should emit new value when modelValue is changed", async () => {
// Act
const wrapper = shallowMount(textboxQuestion, {
global: {
directives: {
maska: maska,
}
},
propsData: {
modelValue: "val",
},
});
// Assert
const label = wrapper.find("label");
expect(label.text()).toEqual("label text");
});
it("Should return input id", async () => {
// Act
const wrapper = shallowMount(textboxQuestion, {
propsData: {
inputId: "input ID",
},
});
await wrapper.find("input").setValue("val2");
// Assert
const input = wrapper.find("input");
expect(wrapper.emitted()).toHaveProperty('change')
// Expect
expect(input.attributes().id).toEqual("input ID");
});
});

View file

@ -1,25 +1,25 @@
<template>
<div class="textbox-question" :class="(errors.length > 0 || hasError) ? 'has-error' : ''">
<div class="textbox-question" :class="(errors && errors.length) || hasError ? 'has-error' : ''">
<label :for="inputId" :aria-label="questionText" class="form-label" v-html="labelText"></label>
<input v-model="value"
<input v-model="value"
v-maska="mask"
:type="type"
class="form-control"
:ref="inputId"
:id="inputId"
:type="type"
class="form-control"
:ref="inputId"
:id="inputId"
:name="inputId"
:placeholder="placeholderText"
:aria-disabled="isDisabled"
:disabled="isDisabled"
:aria-required="isRequired"
autocomplete="off"
:placeholder="placeholderText"
:aria-disabled="isDisabled"
:disabled="isDisabled"
:aria-required="isRequired"
autocomplete="off"
:class="[hasIcon ? 'has-icon' : '', iconRight ? 'icon-right' : '']"
:validationRules="validationRules"
@input="handleChange"
@blur="handleBlur" />
<div v-show="errorMessage" class="row mt-2 form-test-error">
<span role="alert">{{ errorMessage }}</span>
</div>
</div>
</div>
</template>
@ -56,7 +56,7 @@ export default {
setup(props) {
const fieldOptions = {
type: "text",
value: props.modelValue,
value: props.modelValue,
};
const {
@ -64,15 +64,17 @@ export default {
handleBlur,
handleChange,
meta,
validate
validate,
errors,
} = useField(props.inputId, props.validationRules, fieldOptions);
return {
return {
errorMessage,
handleBlur,
handleChange,
validate,
meta,
meta,
errors,
};
},
computed: {
@ -86,19 +88,27 @@ export default {
set: function(newValue) {
this.$emit("update:modelValue", newValue);
}
},
},
labelText: {
get: function () {
let labelText = this.questionText;
const noBreakChar = "&NoBreak;";
var questionText = "";
if (this.disableAutoFill) {
var noBreakChar = "&NoBreak;";
var position = 1;
labelText = [labelText.slice(0, position), noBreakChar, labelText.slice(position)].join('');
var words = this.questionText.toString().split(/[ ]+/);
words.forEach(function (word) {
const position = 1;
word = [word.toString().slice(0, position), noBreakChar, word.toString().slice(position)].join('');
questionText += `${word} `;
});
questionText = questionText.trimEnd();
} else {
questionText = this.questionText.toString();
}
return labelText;
return questionText;
}
}
}
},
watch: {
value(newValue) {

View file

@ -75,6 +75,6 @@ export default {
max-width: 290px;
object-fit: cover;
width: 100%;
height: 118px;
height: 132px;
}
</style>

View file

@ -0,0 +1,32 @@
const analyticsPageEvents = {
ENTRY: "ENTRY",
EVENT: "EVENT"
};
// GA Constants
const GaEvents = {
GENERIC_EVENT: 'ga_Event',
PAGE_VIEW_EVENT : 'logPageview'
};
const GaCategories = {
API_RESPONSE: 'Api_Response',
EVOX: 'Evox'
};
const GaActions = {
RESULT: 'Result',
CLICKED: 'Clicked',
VIF: 'vif',
SUBMITTED: 'Submitted',
};
const GaLabels = {
SUCCESS: 'Success',
ERROR: 'Error',
LICENSE_PLATE_LOOKUP: 'License_Plate_Look_Up',
VIN_LOOKUP: 'Vin_Look_Up',
};
export { analyticsPageEvents, GaCategories, GaActions, GaLabels, GaEvents};

View file

@ -0,0 +1,7 @@
const dynamicStrings = {
GLOBAL_STATE: "globalState",
CUSTOM: "custom",
ROUTER_LINK: "routerLink"
};
export { dynamicStrings };

View file

@ -47,6 +47,10 @@ const endpoints = {
url: "/vehicle/api/v1/vehicle/lookup-vin-by-plate",
method: "POST",
},
LookupVinByAddress: {
url: "/vehicle/api/v1/vehicle/lookup-vin-by-address",
method: "POST",
},
GetPartsOrQuestions: {
url: "/parts/api/v1/parts/parts-or-questions",
method: "POST",
@ -66,6 +70,14 @@ const endpoints = {
LogExperimentExposureIfAssigned:{
url: "/analytics/api/v1/analytics/log-experiment-exposure",
method: "POST",
},
LogActivity:{
url: "/analytics/api/v1/analytics/activity",
method: "POST",
},
GetExperimentsByUserForGa: {
url: "/analytics/api/v1/analytics/get-experiments-for-GA",
method: "GET",
}
};

View file

@ -11,11 +11,16 @@ const errorMessages = {
CITY_REQUIRED: "Please enter your city",
STATE_REQUIRED: "Please enter your state",
ZIP_REQUIRED: "Please enter your ZIP",
ZIP_FORMAT: "Please enter a valid ZIP",
LICENSE_PLATE_REQUIRED: "Please enter your license plate number",
FIRST_NAME_REQUIRED: "Please enter your first name",
LAST_NAME_REQUIRED: "Please enter your last name",
EMAIL_ADDRESS_REQUIRED: "Please enter your email address",
EMAIL_ADDRESS_FORMAT: "Please enter a valid email address",
SERVICE_ZIP_REQUIRED: "Please enter your Service ZIP",
SERVICE_ZIP_FORMAT: "Please enter a valid Service ZIP",
VIN_REQUIRED: "Please enter your VIN",
VIN_FORMAT: "Please enter a valid VIN",
};
export { errorMessages };
export { errorMessages };

View file

@ -1,6 +1,10 @@
const experimentUniverses = {
CONCEPT_FUNNEL: 'ConceptFunnel'
};
const experimentSettings = {
GOOGLE_CUSTOM_DIMENSION_INDEX: 'Google Custom Dimension Index'
}
export { experimentUniverses };
export { experimentUniverses, experimentSettings};

View file

@ -12,12 +12,15 @@ const storeActions = {
LOOKUP_VEHICLE_BY_YMMS: "lookupVehicleByYmms",
LOOKUP_VEHICLE_BY_VIN: "lookupVehicleByVin",
LOOKUP_VIN_BY_PLATE: "lookupVinByPlate",
LOOKUP_VIN_BY_ADDRESS: "lookupVinByAddress",
GET_PARTS_OR_QUESTIONS: "getPartsOrQuestions",
SAVE_ORDER: "saveOrder",
LOAD_ORDER: "loadOrder",
SET_REFERRAL_INFORMATION: "setReferralInformation",
VALIDATE_ZIP: "validateZip",
LOG_EXPERIMENT_EXPOSURE: "logExperimentExposure",
LOG_ACTIVITY: "logActivity",
GET_EXPERIMENTS_BY_USER_FOR_GA: "getExperimentsByUserForGa",
// DEPENDENCY MUTATIONS
RESET_VEHICLE_STATE_AND_DEPENDENCIES: "resetVehicleAndDependencies",

View file

@ -1,39 +1,34 @@
import axios from "axios";
import analyticsMixIn from "@/mixins/analytics-mixin.js";
import { applicationConfig } from "@/constants/application-config.js";
import httpStatusCodes from "http-status-codes";
import { GaCategories, GaActions, GaLabels } from "@/constants/analytics";
export default {
callHttpClient({ method, endpoint, payload }) {
callHttpClient({ method, endpoint, payload, logApiCall = true }) {
return new Promise((resolve, reject) => {
const apiGatewayUrl = applicationConfig.CONSUMER_APIGATEWAY_URL;
const payloadAndAnalyticsData = Object.assign({}, payload, { AppName: "FixMyGlass" });
const payloadAndAnalyticsData = Object.assign({}, payload, {
AppName: "FixMyGlass",
});
axios({ method: method, url: apiGatewayUrl + endpoint, data: payloadAndAnalyticsData, crossDomain: true, responseType: {} })
.then((response) => {
axios({
method: method,
url: apiGatewayUrl + endpoint,
data: payloadAndAnalyticsData,
crossDomain: true,
responseType: {},
}).then(
(response) => {
if (response.status == httpStatusCodes.OK) {
if(response.data == undefined) {
reject(response);
}else{
resolve(response);
}
} else {
reject(response);
if (logApiCall) {
analyticsMixIn.methods.pushEventToGA(GaCategories.API_RESPONSE, GaActions.RESULT, `${GaLabels.SUCCESS}_${endpoint}`, true);
}
return resolve(response);
},
(error) => {
console.error(error);
return reject(error.response);
}
);
error => {
console.error(error);
if (logApiCall) {
analyticsMixIn.methods.pushEventToGA(GaCategories.API_RESPONSE, GaActions.RESULT, `${GaLabels.ERROR}_${endpoint}`, true);
}
return reject(error.response);
}
);
});
},
@ -48,11 +43,7 @@ export default {
responseType: {},
}).then(
(response) => {
if (response.status == httpStatusCodes.OK) {
resolve(response);
} else {
reject(response);
}
resolve(response);
},
(error) => {
return reject(error.response);

View file

@ -1,13 +1,16 @@
import globalMethods from "@/global-methods";
import axios from "axios";
import analyticsMixIn from "@/mixins/analytics-mixin";
//Mock external dependencies
jest.mock("axios");
jest.mock("@/mixins/analytics-mixin");
it("Global Methods - Call Http Client - Should Resolve Promise", () => {
//Arrange
const endpoint = "https://mock.safelite.com";
const httpArgs = setupMocksForHttpClient({ endpoint: endpoint });
analyticsMixIn.methods.pushEventToGA = jest.fn();
//Act
globalMethods.callHttpClient(httpArgs).then((response) => {
@ -25,6 +28,7 @@ it("Global Methods - Call Http Client - Should Reject Promise", () => {
endpoint: endpoint,
isError: true,
});
analyticsMixIn.methods.pushEventToGA = jest.fn();
//Act
globalMethods.callHttpClient(httpArgs).catch((err) => {
@ -72,5 +76,6 @@ function setupMocksForHttpClient({
return {
endpoint: endpoint,
logApiCall: true
};
}

View file

@ -1,5 +1,6 @@
import { storeActions } from "@/constants/store-actions.js";
import store from "@/store";
import { dynamicStrings } from "../constants/dynamic-strings";
export function fetchCmsContentForPage(fmgPage) {
return store
@ -40,12 +41,15 @@ export function fetchCmsContentForPage(fmgPage) {
function mapStringToState(str) {
// Pull all matches out of the string.
const regexExp = new RegExp("{(.*?):(.*?)}", "g");
const matches = [...str.matchAll(regexExp)];
const regexMatches = [...str.matchAll(regexExp)];
const globalStateMatches = regexMatches.filter(match => {
return match[1] === dynamicStrings.GLOBAL_STATE;
})
// Our final string value that will be built from the matches.
let stringBuilder = "";
for (const match of matches) {
for (const match of globalStateMatches) {
// Reset store state for each match.
let storeState = store.state;
@ -60,7 +64,7 @@ function mapStringToState(str) {
const stringWithReplacement = str.replace(match[0], storeState);
// If we still have values we need to substitute, call this function again.
if (stringWithReplacement.includes("{globalState:")) {
if (stringWithReplacement.includes(dynamicStrings.GLOBAL_STATE)) {
return mapStringToState(stringWithReplacement);
}
@ -96,7 +100,7 @@ function findAndReplaceGlobalStateValues(widgetModel, widgetName) {
function processWidgetItemForReplacement(widgetModel, key) {
// If we have a string, and it needs to be replaced.
if (typeof widgetModel[key] === "string") {
if (widgetModel[key].includes("{globalState:")) {
if (widgetModel[key].includes(dynamicStrings.GLOBAL_STATE)) {
widgetModel[key] = mapStringToState(widgetModel[key]);
}
return widgetModel[key];
@ -116,4 +120,4 @@ function processWidgetItemForReplacement(widgetModel, key) {
// If we have something else like a number, boolean, etc. just return it
return widgetModel[key];
}
}

View file

@ -0,0 +1,31 @@
import store from "@/store";
import baseMixin from "@/mixins/base-mixin.js";
import { storeActions } from "@/constants/store-actions";
export function getDamageString() {
return store.getters.damage.glassToReplace.length > 1 ? "match" : store.getters.damage.glassToReplace[0].location;
}
export async function isGlassAvailableForCarId(carId){
const newGlassOptions = await baseMixin.methods.dispatchStoreAction(
storeActions.GET_DAMAGE_OPTIONS,
{ carId: carId }
);
const currentGlassOptions = store.getters.damage.glassToReplace;
const optionsMap = {
Windshield: "windshieldOptions",
Driver: "driverSideOptions",
Passenger: "passengerSideOptions",
Rear: "backGlassOptions"
}
for(const option of currentGlassOptions){
if(!newGlassOptions.data[optionsMap[option.location]].availableReplacementOptions.includes(option.name)){
return false;
}
}
return true;
}

View file

@ -0,0 +1,42 @@
import {getDamageString, isGlassAvailableForCarId} from "./damage-helper";
//import baseMixin from "@/mixins/base-mixin.js";
jest.mock("@/store", () => ({
getters: {damage: {
glassToReplace: [{location: "Windshield", name: "windshield"}]
}
}
}));
describe("damage-helper.js", () => {
it("Should return damage getter info", () => {
const damage = getDamageString();
expect(damage).toEqual("Windshield")
});
});
// describe("damage-helper.js", () => {
// it("Should return false if no mismatches between each array", async () => {
// const updatedOptions = {
// data: {
// windshieldOptions: {availableReplacementOptions: ["windshield"]}
// }
// }
// baseMixin.methods.dispatchStoreAction = jest.fn().mockImplementation(()=> {
// return updatedOptions;
// });
// const misMatch = await isGlassAvailableForCarId();
// expect(misMatch).toEqual(false);
// });
// });
// describe("damage-helper.js", () => {
// it("Should return true if there are any mismatches between arrays", () => {
// const newOptions = {
// windshieldOptions: {availableReplacementOptions: ["window"]}
// }
// const currentOptions = [{location: "Windshield", name: "windshield"}];
// const misMatch = compareGlassOptions(newOptions, currentOptions);
// expect(misMatch).toEqual(true);
// });
// });

View file

@ -18,6 +18,7 @@ export function updateOrCreateFunnelCookie() {
ReferralNumber: store.getters.order.referralNumber,
ReferralDate: store.getters.order.referralDate,
ReferralCorrelationId: store.getters.order.referralCorrelationId,
ReferralParentAccountNumber: store.getters.order.accountNumber,
});
}
@ -66,25 +67,12 @@ export function getDeviceIdValue(){
return cookieValuesSplit[1];
}
const cookieValueMatch = cookieValue.match("(?<=did=).*?(?=&)");
const cookieValueMatch = cookieValue.match("^did=[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}");
if(cookieValueMatch){
return cookieValueMatch[0];
return cookieValueMatch[0].split('=')[1];
}
return '';
}
/*
Gets value of sid cookie, returns empty string if not found.
*/
export function getSessionIdValue(){
const cookieValue = getCookieValueByName(cookieNames.SESSION_ID);
if(cookieValue){
return cookieValue;
}
return '';
return '00000000-0000-0000-0000-000000000000';
}
/*
@ -100,6 +88,19 @@ export function getSessionKeyValue(){
return 0;
}
/*
Gets value of skey cookie, returns 0 if not found.
*/
export function getSessionIdValue(){
const cookieValue = getCookieValueByName(cookieNames.SESSION_ID);
if(cookieValue){
return cookieValue;
}
return '00000000-0000-0000-0000-000000000000';
}
/*
===========================
= PRIVATE FUNCTIONS =

View file

@ -1,4 +1,4 @@
import {getFunnelCookie} from "@/helpers/heritage-integration/cookie-helper.js";
import {getFunnelCookie, getDeviceIdValue, getSessionKeyValue, getSessionIdValue} from "@/helpers/heritage-integration/cookie-helper.js";
import { removeAllTestCookies, setupCookies } from "@/helpers/unit-test-helper";
describe("cookies", () => {
@ -100,5 +100,45 @@ describe("cookies", () => {
expect(actualCookieValue).toBeNull();
});
})
describe("getDeviceIdValue", () => {
test("getDeviceIdValue, should return GUID", () => {
// Arrange
setupCookies({});
// Act
const result = getDeviceIdValue();
//Assert
expect(result).toBe('21b9b94a-ec23-42c1-aaac-e2ae4e4dbffe');
});
test("getSessionKeyValue, should return session key int", () => {
// Arrange
setupCookies({});
// Act
const result = getSessionKeyValue();
//Assert
expect(result).toBe('12345');
});
});
describe("getSessionIdValue", () => {
test("getSessionIdValue, should return GUID", () => {
// Arrange
setupCookies({});
// Act
const result = getSessionIdValue();
//Assert
expect(result).toBe('cba0c3d1-3c1b-4305-bb56-31aa50f58e27');
});
});
})

View file

@ -2,55 +2,38 @@ import { queryStrings } from "@/constants/query-strings";
import { externalUrls } from "@/router/router-constants/externalUrl-values";
import { lazyLoadComponent } from "@/router/dynamic-routing/component-loader.js";
import { saveOrder } from "@/helpers/heritage-integration/order-helper.js";
import { fmgPageValues } from "@/router/router-constants/fmgPage-values";
import store from "@/store";
import router from "@/router";
/*
If the user has visited the funnel before this method will determine the bets place to
drop them so they don't start at the beginning again. This method will return 'heritage' if
the user has an existing order and they come back in from the Safelite.com CTA.
the user has an existing order and they come back in from the Safelite.com CTA.
*/
export async function getPageToRouteExistingOrderTo(toRoute = {}, existingHeritageOrder = false) {
// If the user is coming in via the Safelite.Com CTA
if (toRoute.query[queryStrings.START_TYPE] === 'fmg') {
// If they have an existing order, return 'heritage' for the page name.
if (existingHeritageOrder) {
return 'heritage';
}
return await getLatestPageForRedirection();
}
// If this is a non-CTA navigation, determine where to send the user based on page prerequisites.
// This also works if a user has a 'fmg' start_type query string but no current order.
// That shouldn't happen, but it's possible.
const vehicleMakeComponent = (await lazyLoadComponent('vehicle-make')()).default;
const vehicleModelComponent = (await lazyLoadComponent('vehicle-model')()).default;
const vehicleStyleComponent = (await lazyLoadComponent('vehicle-style')()).default;
const vehicleDamageComponent = (await lazyLoadComponent('vehicle-damage')()).default;
if (!vehicleMakeComponent.methods.arePagePrerequisitesValid()) {
return "vehicle-year";
} else if (!vehicleModelComponent.methods.arePagePrerequisitesValid()) {
return "vehicle-make";
} else if (!vehicleStyleComponent.methods.arePagePrerequisitesValid()) {
return "vehicle-model";
} else if (!vehicleDamageComponent.methods.arePagePrerequisitesValid()) {
return "vehicle-style";
} else if (store.getters.damage.isRepair == null || !store.getters.vehicle.carId) {
return 'vehicle-damage'
} else {
if (store.getters.vehicle.vin) {
return 'vehicle-damage';
//return "vin-lookup"; (uncomment)
} else {
return 'vehicle-damage';
//return "estimate" (uncomment)
}
// If navigating to a specific page, and that page is not part of the vin pages.
// Return that page, so that it can navigate like normal.
if (toRoute.query[queryStrings.FMG_PAGE] !== undefined && !isVinRelatedPage(toRoute)) {
return overrideYmmsDirectionIfNeeded(toRoute);
}
// If this is not a direct link to a page using fmgPage, not from Safelite.com CTA or this is a vin related page.
// Get the latest page for redirection.
const latestPageRoute = await getLatestPageForRedirection();
return latestPageRoute;
}
/*
@ -68,4 +51,93 @@ export async function navigateToHeritageFunnel() {
src: "concept-funnel",
}
);
}
}
export async function navigateAfterSaveToHeritageFunnel(currentRoute) {
const currentComponent = currentRoute.matched[0].components;
currentComponent.default.methods.resetDependentState();
// Create the order (or save existing order) when navigating to Heritage Funnel.
await saveOrder();
router.navigateToExternalUrl(
externalUrls.HERITAGE_FUNNEL,
{
corid: store.getters.order.referralCorrelationId,
src: "concept-funnel"
}
);
}
/*
Logic for getting the last "valid" page a user visited.
*/
async function getLatestPageForRedirection() {
// If this is a non-CTA navigation, determine where to send the user based on page prerequisites.
// This also works if a user has a 'fmg' start_type query string but no current order.
// That shouldn't happen, but it's possible.
const vehicleMakeComponent = (await lazyLoadComponent('vehicle-make')()).default;
const vehicleModelComponent = (await lazyLoadComponent('vehicle-model')()).default;
const vehicleStyleComponent = (await lazyLoadComponent('vehicle-style')()).default;
const vehicleDamageComponent = (await lazyLoadComponent('vehicle-damage')()).default;
if (!vehicleMakeComponent.methods.arePagePrerequisitesValid()) {
return fmgPageValues.VEHICLE_YEAR;
} else if (!vehicleModelComponent.methods.arePagePrerequisitesValid()) {
return fmgPageValues.VEHICLE_MAKE;
} else if (!vehicleStyleComponent.methods.arePagePrerequisitesValid()) {
return fmgPageValues.VEHICLE_MODEL;
} else if (!vehicleDamageComponent.methods.arePagePrerequisitesValid()) {
return fmgPageValues.VEHICLE_STYLE;
} else if (store.getters.damage.isRepair == null || !store.getters.vehicle.carId) {
return fmgPageValues.VEHICLE_DAMAGE;
} else {
if (store.getters.vehicle.vin) {
return fmgPageValues.LICENSE_PLATE_LOOKUP;
} else {
return fmgPageValues.ESTIMATE;
}
}
}
/*
Overrides functionality to go to the YMMS pages in certain cases.
If this is not one of the cases, it returns the 'to' fmgPage value.
*/
/* istanbul ignore next */
function overrideYmmsDirectionIfNeeded(toRoute) {
const fmgPageValue = toRoute.query[queryStrings.FMG_PAGE];
if (store.getters.payment.insuranceCoverage.isVerified) {
switch (fmgPageValue) {
case fmgPageValues.VEHICLE_YEAR:
case fmgPageValues.VEHICLE_MAKE:
case fmgPageValues.VEHICLE_MODEL:
case fmgPageValues.VEHICLE_STYLE:
{
return fmgPageValues.VEHICLE_DAMAGE;
}
default: {
return fmgPageValue;
}
}
} else {
return fmgPageValue;
}
}
/*
Determine if the page is a vin related page.
*/
/* istanbul ignore next */
function isVinRelatedPage(toRoute) {
const fmgPageValue = toRoute.query[queryStrings.FMG_PAGE];
return fmgPageValue === fmgPageValues.VIN_LOOKUP ||
fmgPageValue === fmgPageValues.LICENSE_PLATE_LOOKUP ||
fmgPageValue === fmgPageValues.ADDRESS_LOOKUP ||
fmgPageValue === fmgPageValues.ADDRESS_VEHICLES ||
fmgPageValue === fmgPageValues.ESTIMATE;
}

View file

@ -175,7 +175,7 @@ describe("getPageToRouteExistingOrderTo", () => {
expect(result).toBe('vehicle-damage');
});
test("getPageToRouteExistingOrderTo, should return vin-lookup", async () => {
test("getPageToRouteExistingOrderTo, should return license-plate-lookup", async () => {
// Arrange
const toRoute = {
query: {}
@ -228,8 +228,7 @@ describe("getPageToRouteExistingOrderTo", () => {
const result = await getPageToRouteExistingOrderTo(toRoute, false);
//Assert
//expect(result).toBe('vin-lookup');
expect(result).toBe('vehicle-damage');
expect(result).toBe('license-plate-lookup');
});
test("getPageToRouteExistingOrderTo, should return estimate", async () => {
@ -284,8 +283,7 @@ describe("getPageToRouteExistingOrderTo", () => {
const result = await getPageToRouteExistingOrderTo(toRoute, false);
//Assert
//expect(result).toBe('estimate');
expect(result).toBe('vehicle-damage');
expect(result).toBe('estimate');
});
test("getPageToRouteExistingOrderTo, existing order, should return heritage", async () => {

View file

@ -18,14 +18,14 @@ export async function loadOrderIfPresent() {
// Reset state if cookie says to.
if (funnelCookie.ShouldResetState) {
baseMixin.methods.dispatchNonBlockingStoreAction(storeActions.RESET_STATE);
baseMixin.methods.dispatchStoreAction(storeActions.RESET_STATE);
deleteFunnelCookie();
return null;
}
// Load referral if there is a cookie, and it doesn't indicate it needs a state reset.
return (await loadOrder(funnelCookie.ReferralNumber, funnelCookie.ReferralDate, funnelCookie.ReferralCorrelationId)).data;
return (await loadOrder(funnelCookie.ReferralNumber, funnelCookie.ReferralDate, funnelCookie.ReferralCorrelationId, funnelCookie.ReferralParentAccountNumber)).data;
}
/*
@ -34,13 +34,14 @@ export async function loadOrderIfPresent() {
update the cookie.
*/
export async function saveOrder() {
const savedOrderInfo = await baseMixin.methods.dispatchNonBlockingStoreAction(storeActions.SAVE_ORDER);
const savedOrderInfo = await baseMixin.methods.dispatchStoreAction(storeActions.SAVE_ORDER);
// Save the referral information back from the store.
await baseMixin.methods.dispatchNonBlockingStoreAction(storeActions.SET_REFERRAL_INFORMATION, {
await baseMixin.methods.dispatchStoreAction(storeActions.SET_REFERRAL_INFORMATION, {
referralNumber: savedOrderInfo.data.referralNumber,
referralCorrelationId: savedOrderInfo.data.referralCorrelationId,
referralDate: savedOrderInfo.data.referralDate,
accountNumber: savedOrderInfo.data.accountNumber
}, false);
// Update the cookie with the referral information when saved.
@ -54,12 +55,13 @@ export async function saveOrder() {
Calls API to load order given the referral number, referralDate, and referralCorrelationId
and returns the response.
*/
async function loadOrder(referralNumber, referralDate, referralCorrelationId) {
const response = await baseMixin.methods.dispatchNonBlockingStoreAction(storeActions.LOAD_ORDER,
async function loadOrder(referralNumber, referralDate, referralCorrelationId, accountNumber) {
const response = await baseMixin.methods.dispatchStoreAction(storeActions.LOAD_ORDER,
{
referralNumber: referralNumber.toString(),
referralDate: referralDate,
referralCorrelationId: referralCorrelationId
referralCorrelationId: referralCorrelationId,
accountNumber: accountNumber?.toString()
}, false);
return response;

View file

@ -48,7 +48,7 @@ describe("loadOrderIfPresent", () => {
// Assert
expect(cookieHelper.getFunnelCookie).toHaveBeenCalled();
expect(mocks.baseMixin.methods.dispatchNonBlockingStoreAction).toHaveBeenCalledWith(storeActions.RESET_STATE);
expect(mocks.baseMixin.methods.dispatchStoreAction).toHaveBeenCalledWith(storeActions.RESET_STATE);
});
test("Funnel cookie is null => store is unchanged", () => {
@ -68,7 +68,7 @@ describe("loadOrderIfPresent", () => {
// Assert
expect(cookieHelper.getFunnelCookie).toHaveBeenCalled();
expect(mocks.baseMixin.methods.dispatchNonBlockingStoreAction).not.toHaveBeenCalledWith(storeActions.RESET_STATE);
expect(mocks.baseMixin.methods.dispatchStoreAction).not.toHaveBeenCalledWith(storeActions.RESET_STATE);
});
test("Funnel cookie valid, should call loadOrder", async () => {
@ -90,7 +90,7 @@ describe("loadOrderIfPresent", () => {
// Assert
expect(cookieHelper.getFunnelCookie).toHaveBeenCalled();
expect(mocks.baseMixin.methods.dispatchNonBlockingStoreAction).not.toHaveBeenCalledWith(storeActions.LOAD_ORDER);
expect(mocks.baseMixin.methods.dispatchStoreAction).not.toHaveBeenCalledWith(storeActions.LOAD_ORDER);
expect(result.ReferralNumber).toBe(123456);
expect(result.vehicle.year).toBe(2010);
});
@ -127,8 +127,8 @@ describe("saveOrder", () => {
await saveOrder();
// Assert
expect(mocks.baseMixin.methods.dispatchNonBlockingStoreAction).toHaveBeenCalledWith(storeActions.SAVE_ORDER);
expect(mocks.baseMixin.methods.dispatchNonBlockingStoreAction).toHaveBeenCalledWith(storeActions.SET_REFERRAL_INFORMATION, {
expect(mocks.baseMixin.methods.dispatchStoreAction).toHaveBeenCalledWith(storeActions.SAVE_ORDER);
expect(mocks.baseMixin.methods.dispatchStoreAction).toHaveBeenCalledWith(storeActions.SET_REFERRAL_INFORMATION, {
referralNumber: mockReferralNumber,
referralDate: mockReferralDate,
referralCorrelationId: mockCorrelationId

View file

@ -7,17 +7,25 @@ import { cookieNames } from "@/constants/cookie-names";
import { Form } from "vee-validate";
import baseMixin from "@/mixins/base-mixin";
import { getCookieDomainValue } from "@/helpers/heritage-integration/cookie-helper";
import { analyticsPageEvents, GaCategories, GaActions, GaLabels, GaEvents } from "@/constants/analytics";
import { queryStrings } from "@/constants/query-strings";
import { routerParams } from "@/router/router-constants/router-params";
// Common methods
export function getMountOptions(mockData) {
// Define our mocks to attached to the 'global' object for Vue/Jest.
const mocks = {};
//this is mocking if you use the mixin directly(baseMixin.methods.dispatchNonBlockingStoreAction) vs this.dispatchNonBlockingStoreAction
setupBaseMixinDispatchNonBlockingStoreAction(mockData);
//this is mocking if you use the mixin directly(baseMixin.methods.dispatchStoreAction) vs this.dispatchStoreAction
setupBaseMixinDispatchStoreAction(mockData);
mocks.dispatchNonBlockingStoreAction = jest.fn();
mocks.dispatchNonBlockingStoreAction.mockImplementation((actionName) => {
mocks.pushEventToGA = jest.fn();
mocks.pushPageViewToGA = jest.fn();
mocks.logEvent = jest.fn();
mocks.pushExperimentsToDataLayer = jest.fn();
mocks.dispatchStoreAction = jest.fn();
mocks.dispatchStoreAction.mockImplementation((actionName) => {
let actionFilterResult = mockData.actionList.filter(
(x) => x.actionName == actionName
);
@ -35,6 +43,13 @@ export function getMountOptions(mockData) {
mocks.navigationScenarios = navigationScenarios;
mocks.vehicleCategories = vehicleCategories;
mocks.fmgPageValues = fmgPageValues;
mocks.analyticsPageEvents = analyticsPageEvents;
mocks.GaCategories = GaCategories;
mocks.GaActions = GaActions;
mocks.GaLabels = GaLabels;
mocks.GaEvents = GaEvents;
mocks.queryStrings = queryStrings;
mocks.routerParams = routerParams;
// Mock $store and $router when accessing this.$store/$router
mocks.$store = mockData.store;
@ -50,7 +65,7 @@ export function getMountOptions(mockData) {
}
export function setupMocksForJsFiles(mockData = {}) {
setupBaseMixinDispatchNonBlockingStoreAction(mockData);
setupBaseMixinDispatchStoreAction(mockData);
return { baseMixin };
}
@ -60,7 +75,10 @@ export const cookies = {
[cookieNames.FUNNEL_SESSION_INFO]: `{"ReferralNumber":"1566818","ReferralDate":"2022-03-15T10:56:24.597","ReferralCorrelationId":"404d2b04-f86e-45c3-b373-127b6217b060","ShouldResetState":false,"DidHeritageFunnelUpdateLast":true}`,
"UNIQUE_SESSION_ID": "33756020-b58e-4ec7-b8b8-3f1576719c40",
"anotherCookie": "{}",
"someOtherCookie": "{}"
"someOtherCookie": "{}",
"dxdev": "did=21b9b94a-ec23-42c1-aaac-e2ae4e4dbffe",
"sid": "cba0c3d1-3c1b-4305-bb56-31aa50f58e27",
"skey": "12345"
};
export function removeAllTestCookies() {
@ -87,10 +105,10 @@ export function setupCookies({ funnelCookieValue = "", includeHeritageCookie = t
}
// Private methods
function setupBaseMixinDispatchNonBlockingStoreAction(mockData) {
function setupBaseMixinDispatchStoreAction(mockData) {
if (mockData.actionList !== undefined) {
baseMixin.methods.dispatchNonBlockingStoreAction = jest.fn();
baseMixin.methods.dispatchNonBlockingStoreAction.mockImplementation((actionName) => {
baseMixin.methods.dispatchStoreAction = jest.fn();
baseMixin.methods.dispatchStoreAction.mockImplementation((actionName) => {
let actionFilterResult = mockData.actionList.filter(
(x) => x.actionName == actionName
);

View file

@ -1,4 +1,5 @@
import { required } from "@/helpers/validation-rules";
import { regex } from "@/helpers/validation-rules";
describe("validation-rules.vue", () => {
test("required rules should return error if value missing", () => {
@ -15,15 +16,57 @@ describe("validation-rules.vue", () => {
});
describe("validation-rules.vue", () => {
test("required rules should return true if value present", () => {
//Arrange
const testFn = required("an error");
//Act
const testResponse = testFn('some value');
//Assert
expect(testResponse).toBe(true);
});
});
test("required rules should return true if value present", () => {
//Arrange
const testFn = required("an error");
//Act
const testResponse = testFn('some value');
//Assert
expect(testResponse).toBe(true);
});
});
describe("validation-rules.vue", () => {
test("regex rules should return true if value is not present", () => {
//Arrange
const testFn = regex(/(^\d{5}$)|(^\d{5}-\d{4}$)/, "an error"); // Using Zip regex
//Act
const testResponse = testFn();
//Assert
expect(testResponse).toBe(true);
});
});
describe("validation-rules.vue", () => {
test("regex rules should return false if value is present but does not match regular expression", () => {
//Arrange
const testFn = regex(/(^\d{5}$)|(^\d{5}-\d{4}$)/, "an error"); // Using Zip regex
//Act
const testResponse = testFn('4321'); // needs to be 5 numbers
//Assert
expect(testResponse).toBe("an error");
});
});
describe("validation-rules.vue", () => {
test("regex rules should return true if value is present and does match regular expression", () => {
//Arrange
const testFn = regex(/(^\d{5}$)|(^\d{5}-\d{4}$)/, "an error"); // Using Zip regex
//Act
const testResponse = testFn('43213'); // needs to be 5 numbers
//Assert
expect(testResponse).toBe(true);
});
});

View file

@ -0,0 +1,244 @@
// Components
import addressLookup from "@/layouts/address-lookup/address-lookup.vue";
import funnelHeader from "@/common-components/funnel-header/funnel-header";
import funnelFooter from "@/common-components/funnel-footer/funnel-footer";
import vehicleBanner from "@/common-components/vehicle-banner/vehicle-banner";
import funnelSubHeader from "@/common-components/funnel-sub-header/funnel-sub-header";
import customerQuestions from "@/layouts/address-lookup/customer-questions/customer-questions";
import addressQuestions from "@/layouts/address-lookup/customer-questions/address-questions/address-questions";
// Supporting Files
import { settleAllPromises } from "@/helpers/layout-helper.js";
import baseMixin from "@/mixins/base-mixin";
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { mount, flushPromises } from "@vue/test-utils";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import { maska } from 'maska';
import { nextTick } from "vue";
import { storeActions } from "@/constants/store-actions";
import { storeMutations } from "@/constants/store-mutations";
import store from "@/store";
import { validate } from "vee-validate";
// Mock our module for promises.
jest.mock("@/helpers/layout-helper.js", () => ({
settleAllPromises: jest.fn(),
}));
// Mock fetchCmsContentForPage
jest.mock("@/helpers/cms-content-helper", () => ({
fetchCmsContentForPage: jest.fn(),
}));
describe("address-lookup.vue", () => {
test("Page header is initialized with api data", async (done) => {
//Arrange
const pageHeaderWidgetHeaderText = "Select Damage";
const { wrapper, apiPromise } = setupMocks({
pageHeaderWidgetHeaderText: pageHeaderWidgetHeaderText,
});
//Act
addressLookup.beforeRouteEnter.call(
wrapper.vm,
{ query: { fmgPage: "address-lookup" } },
undefined,
(c) => c(wrapper.vm)
);
//Assert
apiPromise.finally(() => {
expect(funnelSubHeader.methods.initializeComponent).toHaveBeenCalledWith(
pageHeaderWidgetHeaderText
);
done();
});
});
test("Customer Questions component is initialized with api data", async (done) => {
//Arrange
const StreetAddressQuestionWidget = { QuestionText: "test" };
const CityQuestionWidget = { QuestionText: "test" };
const StateQuestionWidget = { QuestionText: "test" };
const ZipQuestionWidget = { QuestionText: "test" };
const FirstNameQuestionWidget = { QuestionText: "test" };
const LastNameQuestionWidget = { QuestionText: "test" };
const EmailAddressQuestionWidget = { QuestionText: "test" };
const AlertVerificationWarningWidget = { HeaderText: "test", BodyText: "test" };
const AlertNoMatchWarningWidget = { HeaderText: "test", BodyText: "test" };
const widgets = [
StreetAddressQuestionWidget,
CityQuestionWidget,
StateQuestionWidget,
ZipQuestionWidget,
AlertVerificationWarningWidget,
AlertNoMatchWarningWidget,
FirstNameQuestionWidget,
LastNameQuestionWidget,
EmailAddressQuestionWidget,
];
const { wrapper, apiPromise } = setupMocks({
cmsContent: widgets,
});
//Act
addressLookup.beforeRouteEnter.call(
wrapper.vm,
{ query: { fmgPage: "address-lookup" } },
undefined,
(c) => c(wrapper.vm)
);
//Assert
apiPromise.finally(() => {
expect(customerQuestions.methods.initializeComponent).toHaveBeenCalledWith(
widgets
);
done();
});
});
});
function setupMocks({
pageHeaderWidgetHeaderText = {},
mountOptionsMockData = {
router: {
navigate: jest.fn(),
},
store: {
getters: {
vehicle: {},
},
},
},
}) {
//Mock api responses
baseMixin.methods.dispatchNonBlockingStoreAction = jest.fn();
const apiResponses = {
cmsContent: {
FunnelSubHeaderWidget: pageHeaderWidgetHeaderText,
VehicleBannerWidget: {
GenericVehicleImage:
"https://digitalconsumercms-dev.safelite.com/images/default-source/default-album/blurred-image.jpg?sfvrsn=a6ce3034_3",
},
FunnelHeaderWidget: {
LogoImage:
"https://digitalconsumercms-dev.safelite.com/images/default-source/default-album/safelite-logo.svg?sfvrsn=45e7ed06_3",
},
StreetAddressQuestionWidget: {
QuestionText:
"test"
},
CityQuestionWidget: {
QuestionText:
"test"
},
StateQuestionWidget: {
QuestionText:
"test"
},
ZipQuestionWidget: {
QuestionText:
"test"
},
FirstNameQuestionWidget: {
QuestionText:
"test"
},
LastNameQuestionWidget: {
QuestionText:
"test"
},
EmailAddressQuestionWidget: {
QuestionText:
"test"
},
AlertVerificationWarningWidget: {
HeaderText:
"test",
BodyText:
"test",
},
AlertNoMatchWarningWidget: {
HeaderText:
"test",
BodyText:
"test",
},
},
};
const apiPromise = Promise.resolve(apiResponses);
settleAllPromises.mockImplementation(() => apiPromise);
fetchCmsContentForPage.mockImplementation(() => Promise.resolve());
//Mock damage initialize methods
funnelHeader.methods = {
initializeComponent: jest.fn(),
};
vehicleBanner.methods = {
initializeComponent: jest.fn(),
};
funnelSubHeader.methods = {
initializeComponent: jest.fn(),
};
funnelFooter.methods = {
initializeComponent: jest.fn(),
};
customerQuestions.methods = {
initializeComponent: jest.fn(),
};
addressQuestions.methods = {
initializeComponent: jest.fn(),
setupAddressLookup: jest.fn(),
};
const mountOptions = getMountOptions(mountOptionsMockData);
mountOptions['attachTo'] = document.body; // append wrapper to document.body to test DOM methods
mountOptions.global.directives = {
maska: maska
};
const wrapper = mount(addressLookup, mountOptions);
const funnelHeaderWrapper = wrapper.findComponent({ name: "funnelHeader" });
funnelHeaderWrapper.vm.initializeComponent =
funnelHeader.methods.initializeComponent;
const vehicleBannerWrapper = wrapper.findComponent({ name: "vehicleBanner" });
vehicleBannerWrapper.vm.initializeComponent =
vehicleBanner.methods.initializeComponent;
const funnelSubHeaderWrapper = wrapper.findComponent({ name: "funnelSubHeader" });
funnelSubHeaderWrapper.vm.initializeComponent =
funnelSubHeader.methods.initializeComponent;
const funnelFooterWrapper = wrapper.findComponent({ name: "funnelFooter" });
funnelFooterWrapper.vm.initializeComponent =
funnelFooter.methods.initializeComponent;
const customerQuestionsWrapper = wrapper.findComponent({ name: "customerQuestions" });
customerQuestionsWrapper.vm.initializeComponent =
customerQuestions.methods.initializeComponent;
const addressQuestionsWrapper = wrapper.findComponent({ name: "addressQuestions" });
addressQuestionsWrapper.vm.initializeComponent =
addressQuestions.methods.initializeComponent;
addressQuestionsWrapper.vm.setupAddressLookup =
addressQuestions.methods.setupAddressLookup;
return { wrapper, apiPromise };
}

View file

@ -5,15 +5,54 @@
ref="theForm"
v-slot="{ meta }"
autocomplete="off" >
<div class="container-fluid shadow rounded-3 p-2 position-relative make-tall">
<funnelHeader ref="funnelHeader" />
<div class="container-fluid shadow rounded-3 p-2 position-relative make-tall px-5">
<funnelHeader cmsWidgetName="FunnelHeaderWidget" ref="funnelHeader" />
<vehicleBanner ref="vehicleBanner" :displayGenericVehicleImage=false />
<funnelSubHeader ref="funnelSubHeader" />
<funnelSubHeader cmsWidgetName="FunnelSubHeaderWidget" ref="funnelSubHeader" />
<customerQuestions ref="customerQuestions" v-model="customerQuestions" />
<alert ref="alertVinNotFound" v-show="displayVinNotFoundAlert"
class="my-3"
cmsWidgetName="AlertVinNotFoundWidget"
alertClass="alert-danger"
v-bind:isDismissible="false"
/>
<alert ref="alertMatchedDifferentVehicle" v-show="displayMatchedDifferentVehicleAlert"
class="my-3"
:manualHeadline="AlertMatchedDifferentVehicleHeader"
:manualCopy="AlertMatchedDifferentVehicleBody"
alertClass="alert-warning"
v-bind:isDismissible="false"
/>
<alert ref="alertNonServiceableZip" v-show="displayNonServiceableZipAlert"
class="my-3"
alertClass="alert-danger"
:manualHeadline="AlertNonServiceableZipHeader"
:manualCopy="AlertNonServiceableZipBody"
v-bind:isDismissible="false"
/>
<alert ref="alertVinLookupsByHomeAddressNotAllowed" v-show="displayVinLookupByHomeAddressNotAllowedAlert"
class="my-3"
cmsWidgetName="AlertVinLookupsByHomeAddressNotAllowedWidget"
alertClass="alert-danger"
v-bind:isDismissible="false"
/>
<transition name="fade" mode="out-in">
<div class="service-zip-field" v-if="showServiceZipField" aria-live="polite">
<div class="row my-4">
<div class="col">
<textboxQuestion cmsWidgetName="ServiceZipQuestionWidget" v-model="serviceZip" ref="serviceZip" inputId="7add1b26df344f2caf1678de5797803f" aria-haspopup="" mask="#####" disableAutoFill validationRules="service-zip-required|service-zip-format" />
</div>
</div>
</div>
</transition>
<funnel-footer
cmsWidgetName="FunnelFooterWidget"
ref="funnelFooter"
:isDisabled="!meta.valid"
@ForwardClicked="forwardButtonAction"
:isForwardActionDisabled="!meta.valid"
/>
</div>
</Form>
</template>
@ -26,14 +65,27 @@ import funnelFooter from "@/common-components/funnel-footer/funnel-footer";
import vehicleBanner from "@/common-components/vehicle-banner/vehicle-banner";
import funnelSubHeader from "@/common-components/funnel-sub-header/funnel-sub-header";
import customerQuestions from "@/layouts/address-lookup/customer-questions/customer-questions";
import alert from "@/ux-components/alert/alert";
import textboxQuestion from "@/common-components/textbox-question/textbox-question";
import { Form } from "vee-validate";
import { defineRule } from "vee-validate";
import { required } from "@/helpers/validation-rules";
import { regex } from "@/helpers/validation-rules";
import { errorMessages } from "@/constants/error-messages";
// Supporting files
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { settleAllPromises } from "@/helpers/layout-helper";
import { storeActions } from "@/constants/store-actions";
import store from "@/store";
import { storeActions } from "@/constants/store-actions";
import { storeMutations } from "@/constants/store-mutations";
import baseMixin from "@/mixins/base-mixin";
import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper";
import { getDamageString, isGlassAvailableForCarId } from "@/helpers/damage-helper";
defineRule("service-zip-required", required(errorMessages.SERVICE_ZIP_REQUIRED));
defineRule("service-zip-format", regex(/(^\d{5}$)|(^\d{5}-\d{4}$)/, errorMessages.SERVICE_ZIP_FORMAT));
export default {
name: "address-lookup",
@ -53,46 +105,29 @@ export default {
// Call the "next" function to complete the transition to this page.
next((vm) => {
vm.$refs.funnelHeader.initializeComponent(
resultMap.cmsContent.FunnelHeaderWidget
);
vm.$refs.vehicleBanner.initializeComponent(
resultMap.cmsContent.VehicleBannerWidget
);
vm.$refs.funnelSubHeader.initializeComponent(
resultMap.cmsContent.FunnelSubHeaderWidget
);
vm.$refs.funnelFooter.initializeComponent(
resultMap.cmsContent.FunnelFooterWidget
);
vm.$refs.customerQuestions.initializeComponent([
resultMap.cmsContent.StreetAddressQuestionWidget,
resultMap.cmsContent.CityQuestionWidget,
resultMap.cmsContent.StateQuestionWidget,
resultMap.cmsContent.ZipQuestionWidget,
resultMap.cmsContent.AlertVerificationWarningWidget,
resultMap.cmsContent.AlertNoMatchWarningWidget,
resultMap.cmsContent.FirstNameQuestionWidget,
resultMap.cmsContent.LastNameQuestionWidget,
resultMap.cmsContent.EmailAddressQuestionWidget,
]
);
vm.setCmsContent(resultMap.cmsContent);
});
},
data() {
return {
customerQuestions: {
addressQuestions: {
streetAddress: "",
city: "",
state: "",
zip: "",
streetAddress: this.getRegistrationAddressFromStore(),
city: this.getRegistrationCityFromStore(),
state: this.getRegistrationStateFromStore(),
zip: this.getRegistrationZipFromStore(),
},
firstName: "",
lastName: "",
emailAddress: "",
}
firstName: this.getRegistrationFirstNameFromStore(),
lastName: this.getRegistrationLastNameFromStore(),
emailAddress: this.getEmailFromStore(),
},
serviceZip: this.getServiceZipFromStore(),
displayNonServiceableZipAlert: false,
displayVinNotFoundAlert: false,
displayMatchedDifferentVehicleAlert: false,
displayVinLookupByHomeAddressNotAllowedAlert: false,
previousCarIdFound: "",
customAlertData: {},
}
},
methods: {
@ -100,9 +135,220 @@ export default {
return store.getters.vehicle.carId !== null;
},
resetDependentState() {
// Invokes
store.commit(storeMutations.UPDATE_REGISTRATION_LICENSE_PLATE, null);
store.dispatch(storeActions.RESET_PARTS_STATE_AND_DEPENDENCIES);
},
backButtonAction() {
// route to move backwards
this.$router.navigate(
this.navigationScenarios.CLICKED_BACK,
this.$route
);
},
getRegistrationAddressFromStore() {
return store.getters.vehicle.registration.address;
},
getRegistrationCityFromStore() {
return store.getters.vehicle.registration.city;
},
getRegistrationStateFromStore() {
return store.getters.vehicle.registration.state;
},
getRegistrationZipFromStore() {
return store.getters.vehicle.registration.zipCode;
},
getRegistrationFirstNameFromStore() {
return store.getters.vehicle.registration.firstName;
},
getRegistrationLastNameFromStore() {
return store.getters.vehicle.registration.lastName;
},
getEmailFromStore() {
return store.getters.order.customer.emailAddress;
},
getServiceZipFromStore() {
return store.getters.order.serviceLocation.zip;
},
async forwardButtonAction() {
this.resetWarningsAndErrors();
// Lookup VIN(s) with the provided address
const vinLookup = await this.lookupVin(
this.customerQuestions.lastName,
this.customerQuestions.addressQuestions.streetAddress,
this.customerQuestions.addressQuestions.zip,
this.customerQuestions.addressQuestions.state
);
if (!vinLookup.data.isStatePermissible) {
// State Restrictions forbid lookup by address
this.displayVinLookupByHomeAddressNotAllowedAlert = true;
this.$refs.funnelFooter.removeLoader();
return;
}
// Validate if the original or service zip provided is serviceable
const zipValidation = this.serviceZip ? await this.validateZip(this.serviceZip) : await this.validateZip(this.customerQuestions.addressQuestions.zip);
if (!zipValidation.data.isServiceable) {
this.displayNonServiceableZipAlert = true;
this.showServiceZipField = true;
this.$refs.funnelFooter.removeLoader();
}
const carEntered = store.getters.vehicle;
const carsFound = vinLookup.data.vinVehicles;
if (carsFound.length == 0) {
// No VINs found
this.displayVinNotFoundAlert = true;
this.$refs.funnelFooter.removeLoader();
return;
} else if (carsFound.length == 1) {
var carFound = carsFound[0].vehicle;
if (carEntered.carId == carFound.carId || carFound.carId == this.previousCarIdFound) {
// update data
this.updateVehicleInfo(carFound.vin, carFound);
this.updateCustomerInfo();
// navigate forward
this.navigateForward(carEntered, carsFound);
} else {
// Display Alert
this.customAlertData.vehicleInfo = carFound;
this.displayMatchedDifferentVehicleAlert = true;
// Update button "Continue with..."
this.$refs.funnelFooter.updateButtonText(`Continue with ${carFound.year} ${carFound.make} ${carFound.model}`);
this.$refs.funnelFooter.removeLoader();
}
this.previousCarIdFound = carFound.carId;
} else if (vinLookup.data.vinVehicles.length > 1) {
this.updateCustomerInfo();
// navigate forward
this.navigateForward(carEntered, carsFound);
}
},
resetWarningsAndErrors() {
this.displayVinNotFoundAlert = false;
this.displayNonServiceableZipAlert = false;
this.displayMatchedDifferentVehicleAlert = false;
this.displayVinLookupByHomeAddressNotAllowedAlert = false;
},
async navigateForward(carEntered, carsFound) {
if (carsFound.length == 1) {
// get the damage options for the car that was found
const carFound = carsFound[0].vehicle;
const glassOptions = await baseMixin.methods.dispatchNonBlockingStoreAction(
storeActions.GET_DAMAGE_OPTIONS,
{ carId: carFound.carId }
);
// if the car entered is the same as the car found OR the glass options for the found car match the users damage selections
if (carEntered.carId == carFound.carId || isGlassAvailableForCarId(carFound.carId)) {
navigateAfterSaveToHeritageFunnel(this.$route);
} else {
// if not then navigate to the "vehicle-damage" page
this.$router.navigateAfterSave(this.navigationScenarios.CLICKED_FORWARD, this.$route, {}, { displayVehicleChangeAlert: true }, {});
}
} else if (carsFound.length > 1) {
// if multiple cars were found
if (carsFound.find(car => car.carId === carEntered.carId)) {
// and one of them matches the car id entered
navigateAfterSaveToHeritageFunnel(this.$route);
} else {
// and there is no match, navigate to "address-vehicle" page
this.$router.navigateAfterSave(this.navigationScenarios.CONTINUING_WITH_MULTIPLE_VEHICLES, this.$route, {}, {}, carsFound);
}
}
},
validateZip(zip) {
return baseMixin.methods.dispatchNonBlockingStoreAction(
storeActions.VALIDATE_ZIP,
{ zip });
},
lookupVin(lastName, streetAddress, zip, state) {
return baseMixin.methods.dispatchNonBlockingStoreAction(
storeActions.LOOKUP_VIN_BY_ADDRESS,
{
licenseLastName: lastName,
licenseStreetAddress: streetAddress,
licenseZip: zip,
licenseState: state
}, false
);
},
updateVehicleInfo(vin, vehicleInfo) {
store.commit(storeMutations.UPDATE_VEHICLE_VIN, vin);
store.commit(storeMutations.UPDATE_YEAR, vehicleInfo.year);
store.commit(storeMutations.UPDATE_MAKE, vehicleInfo.make);
store.commit(storeMutations.UPDATE_MODEL, vehicleInfo.model);
store.commit(storeMutations.UPDATE_STYLE, vehicleInfo.style);
store.commit(storeMutations.UPDATE_CAR_ID, vehicleInfo.carId);
store.commit(storeMutations.UPDATE_VEHICLE_CATEGORY, vehicleInfo.category);
store.commit(storeMutations.UPDATE_VEHICLE_IMAGE_URL, vehicleInfo.imageUrl);
store.commit(storeMutations.UPDATE_VEHICLE_IMAGE_VIF_NUMBER, vehicleInfo.imageVifNumber);
store.commit(storeMutations.UPDATE_VEHICLE_IMAGE_COLOR, vehicleInfo.imageColor);
},
updateCustomerInfo() {
store.commit(storeMutations.UPDATE_REGISTRATION_ADDRESS, this.customerQuestions.addressQuestions.streetAddress);
store.commit(storeMutations.UPDATE_REGISTRATION_CITY, this.customerQuestions.addressQuestions.city);
store.commit(storeMutations.UPDATE_REGISTRATION_STATE, this.customerQuestions.addressQuestions.state);
store.commit(storeMutations.UPDATE_REGISTRATION_ZIP_CODE, this.customerQuestions.addressQuestions.zipCode);
store.commit(storeMutations.UPDATE_REGISTRATION_FIRST_NAME, this.customerQuestions.firstName);
store.commit(storeMutations.UPDATE_REGISTRATION_LAST_NAME, this.customerQuestions.lastName);
store.commit(storeMutations.UPDATE_SERVICE_LOCATION_ZIP, this.serviceZip);
store.commit(storeMutations.UPDATE_CUSTOMER_EMAIL_ADDRESS, this.customerQuestions.email);
},
},
computed: {
AlertNonServiceableZipHeader(){
let zip = this.serviceZip ? this.serviceZip : this.customerQuestions.addressQuestions.zip;
let text = this.getCmsContent("AlertNonServiceableZipWidget", "HeadlineText").replaceAll("{custom:serviceZip}", zip);
return text;
},
AlertNonServiceableZipBody(){
return this.getCmsContent("AlertNonServiceableZipWidget", "BodyText");
},
AlertMatchedDifferentVehicleHeader(){
let text = this.getCmsContent("AlertMatchedDifferentVehicleWidget", "HeadlineText").replaceAll("{custom:glassText}", getDamageString());
return text;
},
AlertMatchedDifferentVehicleBody(){
let content = this.getCmsContent("AlertMatchedDifferentVehicleWidget", "BodyText");
content = content.replaceAll("{custom:glassText}", getDamageString());
let vinYmmFound = `${this.customAlertData?.vehicleInfo?.year} ${this.customAlertData?.vehicleInfo?.make} ${this.customAlertData?.vehicleInfo?.model}`;
let vinYmmExpected = `${store.getters.vehicle.year} ${store.getters.vehicle.make} ${store.getters.vehicle.model}`;
content = content.replaceAll("{custom:vinYmmFound}", vinYmmFound);
content = content.replaceAll("{custom:vinYmmExpected}", vinYmmExpected);
return content;
},
},
watch: {
customerQuestions: {
handler(newValue) {
// if they modify one of the lookup fields (address, city, state, zip, or lastName), then modify the button text back to Get my personalized quote
this.$refs.funnelFooter.updateButtonText(this.getCmsContent("FunnelFooterWidget", "ForwardButtonText"));
this.showServiceZipField = false;
this.resetWarningsAndErrors();
},
deep: true
},
serviceZip: {
handler(newValue) {
// if they modify the service zip, then hide the error message
this.displayNonServiceableZipAlert = false;
},
}
},
components: {
funnelHeader,
@ -110,6 +356,8 @@ export default {
vehicleBanner,
funnelSubHeader,
customerQuestions,
textboxQuestion,
alert,
Form
},
};

View file

@ -1,36 +1,34 @@
<template>
<div class="row my-4">
<div class="col">
<textboxQuestion v-model="addressModel.streetAddress" ref="autocomplete" inputId="autocomplete" placeholderText="Search" aria-haspopup="" hasIcon disableAutoFill validationRules="street-address-required" />
<textboxQuestion cmsWidgetName="StreetAddressQuestionWidget" v-model="addressModel.streetAddress" ref="autocomplete" inputId="autocomplete" placeholderText="Search" aria-haspopup="" hasIcon disableAutoFill validationRules="street-address-required" />
</div>
</div>
<transition name="fade" mode="out-in">
<div class="address-fields" v-show="showAddressFields" aria-live="polite">
<div class="row my-4">
<div class="col">
<textboxQuestion v-model="addressModel.city" ref="city" inputId="cbf28188fdf2436688fd735915f7ee56" disableAutoFill validationRules="city-required"/>
</div>
<div class="row my-4" v-show="showAddressFields" aria-live="polite">
<div class="col">
<textboxQuestion cmsWidgetName="CityQuestionWidget" v-model="addressModel.city" ref="city" inputId="cbf28188fdf2436688fd735915f7ee56" disableAutoFill validationRules="city-required"/>
</div>
<div class="row my-4">
<div class="col-6">
<dropdownQuestion v-model="addressModel.state" ref="state" inputId="8fdf9dc2e13e430eb57529499dceb3eb" :options="stateOptions" disableAutoFill validationRules="state-required" />
</div>
<div class="col-6">
<textboxQuestion v-model="addressModel.zip" ref="zip" inputId="01a9a1c2de0b4c9da8e023c9ae3be498" mask="#####" disableAutoFill validationRules="zip-required"/>
</div>
</div>
</div>
</transition>
<transition name="fade" mode="out-in">
<div class="row my-4" v-show="showAddressFields" aria-live="polite">
<div class="col">
<dropdownQuestion cmsWidgetName="StateQuestionWidget" v-model="addressModel.state" ref="state" inputId="8fdf9dc2e13e430eb57529499dceb3eb" :options="stateOptions" disableAutoFill validationRules="state-required" />
</div>
<div class="col">
<textboxQuestion cmsWidgetName="ZipQuestionWidget" v-model="addressModel.zip" ref="zip" inputId="01a9a1c2de0b4c9da8e023c9ae3be498" mask="#####" disableAutoFill validationRules="zip-required|zip-format"/>
</div>
</div>
</transition>
<alert ref="alertVerificationWarning" v-show="displayVerificationWarning"
cmsWidgetName="AlertVerificationWarningWidget"
alertClass="alert-warning"
:alertHeadline="alertHeadlineVerificationWarning"
:alertCopy="alertCopyVerificationWarning"
v-bind:isDismissible="false"
/>
<alert ref="alertNoMatchWarning" v-show="displayNoMatchWarning"
cmsWidgetName="AlertNoMatchWarningWidget"
alertClass="alert-warning"
:alertHeadline="alertHeadlineNoMatchWarning"
:alertCopy="alertCopyNoMatchWarning"
v-bind:isDismissible="false"
/>
</template>
@ -41,17 +39,17 @@ import textboxQuestion from "@/common-components/textbox-question/textbox-questi
import dropdownQuestion from "@/common-components/dropdown-question/dropdown-question";
import alert from "@/ux-components/alert/alert";
import { applicationConfig } from "@/constants/application-config.js";
import { computed } from 'vue';
import { defineRule } from "vee-validate";
import { required } from "@/helpers/validation-rules";
import { regex } from "@/helpers/validation-rules";
import { errorMessages } from "@/constants/error-messages";
//import store from "@/store";
// DEFINE VALIDATION RULES
defineRule("street-address-required", required(errorMessages.STREET_ADDRESS_REQUIRED));
defineRule("city-required", required(errorMessages.CITY_REQUIRED));
defineRule("state-required", required(errorMessages.STATE_REQUIRED));
defineRule("zip-required", required(errorMessages.ZIP_REQUIRED));
defineRule("zip-format", regex(/(^\d{5}$)|(^\d{5}-\d{4}$)/, errorMessages.ZIP_FORMAT));
export default ({
name: "address-questions",
@ -67,19 +65,7 @@ export default ({
}),
},
validationRules: String,
},
setup(props, { emit }) {
// Please do not modify, this "computed" is used to track and report
// this object's property changes to the parent component
const addressModel = computed({ // Use computed to wrap the object
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
});
return {
addressModel,
};
},
},
data() {
return {
showAddressFields: false,
@ -148,128 +134,130 @@ export default ({
'WY': 'Wyoming',
}
}
}
},
addressModel: {
get: function() {
return this.modelValue;
},
set: function(newValue) {
this.$emit("update:modelValue", newValue);
}
},
},
methods: {
initializeComponent(cmsContent) {
this.$refs.autocomplete.initializeComponent(cmsContent[0].QuestionText);
this.$refs.city.initializeComponent(cmsContent[1].QuestionText);
this.$refs.state.initializeComponent(cmsContent[2].QuestionText);
this.$refs.zip.initializeComponent(cmsContent[3].QuestionText);
setupAddressLookup() {
const addressField1 = document.getElementById("autocomplete");
const self = this;
// assign alert texts to this component
this.alertHeadlineVerificationWarning = cmsContent[4].HeadlineText;
this.alertCopyVerificationWarning = cmsContent[4].BodyText;
const apiKey = applicationConfig.GOOGLE_PLACES_API_KEY;
this.alertHeadlineNoMatchWarning = cmsContent[5].HeadlineText;
this.alertCopyNoMatchWarning = cmsContent[5].BodyText;
this.$loadScript(`https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places`)
.then(() => {
// Script is loaded, initialize the autocomplete textbox
const autocomplete = new window.google.maps.places.Autocomplete(
addressField1,
{
componentRestrictions: { country: ["us"] },
fields: ["address_components"],
types: ["geocode"],
}
);
// Standard place_changed event handling
autocomplete.addListener('place_changed', fillInAddress);
addressField1.onblur = function() {
const hover = document.querySelector(".pac-container .pac-item:hover");
// if an item has been clicked, do nothing, otherwise get first solution and use Geocoder to get the place
if (hover === null) {
const item = document.querySelector(".pac-container .pac-item");
if (item != null) {
const firstResult = item.textContent;
const geocoder = new window.google.maps.Geocoder();
geocoder.geocode({
address: firstResult
}, function (results, status) {
if (status === window.google.maps.GeocoderStatus.OK) {
fillInAddress(results[0]);
self.displayVerificationWarning = true;
self.displayNoMatchWarning = false;
}
});
}
else {
self.addressModel.city = "";
self.addressModel.state = "";
self.addressModel.zip = "";
self.showAddressFields = true;
self.displayVerificationWarning = false;
self.displayNoMatchWarning = true;
}
}
};
function fillInAddress(place) {
if (!place) {
place = autocomplete.getPlace();
}
if (place && place.address_components) {
self.addressModel.streetAddress= "";
self.showAddressFields = true;
for (const component of place.address_components) {
const componentType = component.types[0];
switch (componentType) {
case "street_number": {
self.addressModel.streetAddress = component.long_name;
break;
}
case "route": {
self.addressModel.streetAddress += ' ' + component.short_name;
break;
}
case "locality": {
self.addressModel.city = component.long_name;
break;
}
case "administrative_area_level_1": {
self.addressModel.state = component.short_name;
break;
}
case "postal_code": {
self.addressModel.zip = component.long_name;
break;
}
}
}
self.displayVerificationWarning = false;
self.displayNoMatchWarning = false;
}
else {
self.displayVerificationWarning = true;
self.displayNoMatchWarning = false;
}
}
})
.catch(() => {
// Failed to fetch script
console.log("Unable to load Google Places API script");
});
}
},
mounted() {
const addressField1 = document.getElementById("autocomplete");
const self = this;
const apiKey = applicationConfig.GOOGLE_PLACES_API_KEY;
this.$loadScript(`https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places`)
.then(() => {
// Script is loaded, initialize the autocomplete textbox
const autocomplete = new window.google.maps.places.Autocomplete(
addressField1,
{
componentRestrictions: { country: ["us"] },
fields: ["address_components"],
types: ["address"],
}
);
// Standard place_changed event handling
autocomplete.addListener('place_changed', fillInAddress);
addressField1.onblur = function() {
const hover = document.querySelector(".pac-container .pac-item:hover");
// if an item has been clicked, do nothing, otherwise get first solution and use Geocoder to get the place
if (hover === null) {
const item = document.querySelector(".pac-container .pac-item");
if (item != null) {
const firstResult = item.textContent;
const geocoder = new window.google.maps.Geocoder();
geocoder.geocode({
address: firstResult
}, function (results, status) {
if (status === window.google.maps.GeocoderStatus.OK) {
fillInAddress(results[0]);
self.displayVerificationWarning = true;
self.displayNoMatchWarning = false;
}
});
}
else {
self.addressModel.city = "";
self.addressModel.state = "";
self.addressModel.zip = "";
self.showAddressFields = true;
self.displayVerificationWarning = false;
self.displayNoMatchWarning = true;
}
}
};
function fillInAddress(place) {
if (!place) {
place = autocomplete.getPlace();
}
if (place && place.address_components) {
self.addressModel.streetAddress= "";
self.showAddressFields = true;
for (const component of place.address_components) {
const componentType = component.types[0];
switch (componentType) {
case "street_number": {
self.addressModel.streetAddress = component.long_name;
break;
}
case "route": {
self.addressModel.streetAddress += ' ' + component.short_name;
break;
}
case "locality": {
self.addressModel.city = component.long_name;
break;
}
case "administrative_area_level_1": {
self.addressModel.state = component.short_name;
break;
}
case "postal_code": {
self.addressModel.zip = component.long_name;
break;
}
}
}
self.displayVerificationWarning = false;
self.displayNoMatchWarning = false;
}
else {
self.displayVerificationWarning = true;
self.displayNoMatchWarning = false;
}
}
})
.catch(() => {
// Failed to fetch script
console.log("Unable to load Google Places API script");
});
this.setupAddressLookup();
},
watch: {
addressModel: {
handler(newValue){
this.displayNoMatchWarning = false;
},
deep: true
}
},
components: {
textboxQuestion,
dropdownQuestion,

View file

@ -1,18 +1,18 @@
<template>
<addressQuestions ref="addressQuestions" v-model="customerModel.addressQuestions" />
<addressQuestions ref="addressQuestions" v-model="customerModel.addressQuestions" :alertNotifications="alertNotifications" />
<div class="row my-4">
<div class="col">
<textboxQuestion v-model="customerModel.firstName" ref="firstName" inputId="08497a2efd9a4a73a70360ab47b4838d" disableAutoFill validationRules="first-name-required" />
<textboxQuestion cmsWidgetName="FirstNameQuestionWidget" v-model="customerModel.firstName" ref="firstName" inputId="08497a2efd9a4a73a70360ab47b4838d" disableAutoFill validationRules="first-name-required" />
</div>
</div>
<div class="row my-4">
<div class="col">
<textboxQuestion v-model="customerModel.lastName" ref="lastName" inputId="0030e56a57e74a4ab92de7fb8e97fec5" disableAutoFill validationRules="last-name-required" />
<textboxQuestion cmsWidgetName="LastNameQuestionWidget" v-model="customerModel.lastName" ref="lastName" inputId="0030e56a57e74a4ab92de7fb8e97fec5" disableAutoFill validationRules="last-name-required" />
</div>
</div>
<div class="row my-4">
<div class="col">
<textboxQuestion v-model="customerModel.emailAddress" ref="emailAddress" inputId="00450a91b8964a768ce3992e6feb890f" disableAutoFill validationRules="email-address-required|email-address-format"/>
<textboxQuestion cmsWidgetName="EmailAddressQuestionWidget" v-model="customerModel.emailAddress" ref="emailAddress" inputId="00450a91b8964a768ce3992e6feb890f" disableAutoFill validationRules="email-address-required|email-address-format"/>
</div>
</div>
</template>
@ -20,12 +20,10 @@
<script>
import addressQuestions from "@/layouts/address-lookup/customer-questions/address-questions/address-questions";
import textboxQuestion from "@/common-components/textbox-question/textbox-question";
import { computed } from 'vue';
import { defineRule } from "vee-validate";
import { required } from "@/helpers/validation-rules";
import { regex } from "@/helpers/validation-rules";
import { errorMessages } from "@/constants/error-messages";
//import store from "@/store";
// DEFINE VALIDATION RULES
defineRule("first-name-required", required(errorMessages.FIRST_NAME_REQUIRED));
@ -33,8 +31,6 @@ defineRule("last-name-required", required(errorMessages.LAST_NAME_REQUIRED));
defineRule("email-address-required", required(errorMessages.EMAIL_ADDRESS_REQUIRED));
defineRule("email-address-format", regex(/^([a-zA-Z0-9_\-.+]+)@([a-zA-Z0-9_\-.]+).([a-zA-Z]{2,})$/, errorMessages.EMAIL_ADDRESS_FORMAT));
//EMAIL_ADDRESS_FORMAT
export default ({
name: "customer-questions",
emits: ['update:modelValue'], // The component emits an event
@ -55,30 +51,10 @@ export default ({
}
}),
},
validationRules: String,
validationRules: String,
},
setup(props, { emit }) {
// Please do not modify, this "computed" is used to track and report
// this object's property changes to the parent component
const customerModel = computed({ // Use computed to wrap the object
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
});
return { customerModel };
},
methods: {
initializeComponent(cmsContent){
// pass alert texts to addressQuestions component
this.$refs.addressQuestions.initializeComponent(cmsContent);
this.$refs.firstName.initializeComponent(cmsContent[6].QuestionText);
this.$refs.lastName.initializeComponent(cmsContent[7].QuestionText);
this.$refs.emailAddress.initializeComponent(cmsContent[8].QuestionText);
}
},
computed: {
value: {
customerModel: {
get: function() {
return this.modelValue;
},

View file

@ -0,0 +1,13 @@
<template>
<p> Estimate </p>
</template>
<script>
export default {
name: "estimate",
methods: {
arePagePrerequisitesValid() {
return true;
},
}
};
</script>

View file

@ -44,7 +44,7 @@
/>
<funnel-footer
ref="funnelFooter"
:isDisabled="!meta.valid"
:isForwardActionDisabled="!meta.valid"
@back-clicked="backButtonAction"
@ForwardClicked="forwardButtonAction"
/>
@ -95,7 +95,7 @@ export default {
// Call APIs
const cmsContentPromise = fetchCmsContentForPage('vehicle-damage');
const damageOptionsPromise =
baseMixin.methods.dispatchNonBlockingStoreAction(
baseMixin.methods.dispatchStoreAction(
storeActions.GET_DAMAGE_OPTIONS,
{ carId: store.getters.vehicle.carId }
);

View file

@ -0,0 +1,147 @@
// Components
import vehicleDamage from "@/layouts/license-plate-lookup/license-plate-lookup.vue";
// Supporting Files
import { settleAllPromises } from "@/helpers/layout-helper.js";
import baseMixin from "@/mixins/base-mixin";
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { shallowMount, flushPromises } from "@vue/test-utils";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import { nextTick } from "vue";
import { storeActions } from "@/constants/store-actions";
import { storeMutations } from "@/constants/store-mutations";
import store from "@/store";
import { validate } from "vee-validate";
// Mock our module for promises.
jest.mock("@/helpers/layout-helper.js", () => ({
settleAllPromises: jest.fn(),
}));
// Mock fetchCmsContentForPage
jest.mock("@/helpers/cms-content-helper", () => ({
fetchCmsContentForPage: jest.fn(),
}));
// Mock Store
jest.mock("@/store", () => ({
commit: jest.fn(),
dispatch: jest.fn(),
getters: {
order: {
customer: {
emailAddress: "test@test.com"
},
serviceLocation: {
zip: "43443"
}
},
vehicle: {
carId: "C00000000",
image: "test.jpg",
payment: {
insuranceCoverage: {
isVerified: false
}
},
registration: {
licensePlate: "HWV4445",
zipCode: "43224"
}
},
eventBusItem: jest.fn(),
damage: {
glassToReplace: []
},
},
}));
describe("license-plate-lookup.vue", () => {
test("CarId set, arePagePrerequisitesValid should be true ", async () => {
//Arrange
const { wrapper } = setupMocks({});
//Act
vehicleDamage.beforeRouteEnter.call(
wrapper.vm,
{ query: { fmgPage: "license-plate-lookup" } },
undefined,
(c) => c(wrapper.vm)
);
let arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid();
await nextTick();
//Assert
expect(arePagePrerequisitesValid).toBe(true);
});
});
describe("license-plate-lookup.vue", () => {
test("BackButtonAction triggers a router.navigate change", async () => {
//Arrange
const { wrapper } = setupMocks({});
//Act
vehicleDamage.beforeRouteEnter.call(
wrapper.vm,
{ query: { fmgPage: "license-plate-lookup" } },
undefined,
(c) => c(wrapper.vm)
);
wrapper.vm.backButtonAction();
//Assert
expect(wrapper.vm.$router.navigate).toHaveBeenCalled();
});
});
function setupMocks({
pageHeaderWidgetHeaderText = {},
mountOptionsMockData = {
router: {
navigate: jest.fn(),
},
store: {
getters: {
vehicle: {},
payment: { insuranceCoverage: { isVerified: false } },
},
},
},
}) {
//Mock api responses
baseMixin.methods.dispatchStoreAction = jest.fn();
const apiResponses = {
cmsContent: {
FunnelSubHeaderWidget: pageHeaderWidgetHeaderText,
VehicleBannerWidget: {
GenericVehicleImage:
"https://digitalconsumercms-dev.safelite.com/images/default-source/default-album/blurred-image.jpg?sfvrsn=a6ce3034_3",
},
FunnelHeaderWidget: {
LogoImage:
"https://digitalconsumercms-dev.safelite.com/images/default-source/default-album/safelite-logo.svg?sfvrsn=45e7ed06_3",
},
},
};
const apiPromise = Promise.resolve(apiResponses);
settleAllPromises.mockImplementation(() => apiPromise);
fetchCmsContentForPage.mockImplementation(() => Promise.resolve());
const mountOptions = getMountOptions(mountOptionsMockData);
mountOptions['attachTo'] = document.body; // append wrapper to document.body to test DOM methods
const wrapper = shallowMount(vehicleDamage, mountOptions);
wrapper.vm.setCmsContent = baseMixin.methods.setCmsContent;
return { wrapper, apiPromise };
}

View file

@ -11,46 +11,48 @@
<funnelSubHeader cmsWidgetName="FunnelSubHeaderWidget" />
<div class="row my-2">
<div class="col">
<textboxQuestion cmsWidgetName="LicensePlateNumber" v-model="licensePlate" inputId="license_plate" disableAutoFill validationRules="license-plate-required" />
<textboxQuestion cmsWidgetName="LicensePlateNumber" v-model="licensePlate" inputId="license_plate" validationRules="license-plate-required" />
</div>
</div>
<div class="row my-2">
<div class="col">
<textboxQuestion cmsWidgetName="RegistrationZip" v-model="zip" inputId="zip" mask="#####" disableAutoFill validationRules="zip-required" />
<textboxQuestion cmsWidgetName="RegistrationZip" v-model="registrationZip" inputId="zip" mask="#####" validationRules="zip-required" />
</div>
</div>
<div class="row my-2">
<div class="col">
<textboxQuestion cmsWidgetName="EmailAddress" v-model="email" inputId="email" disableAutoFill validationRules="email-address-required|email-address-format" />
<textboxQuestion cmsWidgetName="EmailAddress" v-model="email" inputId="email" validationRules="email-address-required|email-address-format" />
</div>
</div>
<alert
class="my-3"
cmsWidgetName="NoServiceZipWidget"
v-if="newServiceZipRequired"
:manualHeadline="NoServiceZipHeader"
:manualCopy="NoServiceZipBody"
v-if="!isRegistrationZipServicable && isVinValid && !isCarIdDifferent"
alertClass="alert-danger"
/>
<div class="row my-2">
<div class="col">
<textboxQuestion v-if="!isRegistrationZipServicable" cmsWidgetName="ServiceZip" v-model="serviceZip" inputId="serviceZip" validationRules="zip-required" />
</div>
</div>
<alert
class="my-3"
cmsWidgetName="NoMatchAlertWidget"
v-if="vinNotValid"
v-if="!isVinValid"
alertClass="alert-danger"
/>
<alert
class="my-3"
cmsWidgetName="MatchedDifferentVehicleAlertWidget"
v-if="vinDoesNotMatchCarId"
:manualHeadline="MatchedDifferentVehicleAlertHeader"
:manualCopy="MatchedDifferentVehicleAlertBody"
v-if="isCarIdDifferent"
alertClass="alert-warning"
/>
<div class="row my-2">
<div class="col">
<textboxQuestion v-if="newServiceZipRequired" cmsWidgetName="ServiceZip" v-model="serviceZip" inputId="serviceZip" disableAutoFill validationRules="zip-required" />
</div>
</div>
<funnelFooter
ref="funnelFooter"
cmsWidgetName="FunnelFooterWidget"
:isDisabled="!meta.valid"
:isForwardActionDisabled="!meta.valid"
@back-clicked="backButtonAction"
@ForwardClicked="forwardButtonAction"
/>
@ -75,8 +77,10 @@ import baseMixin from "@/mixins/base-mixin.js";
import { storeActions } from "@/constants/store-actions";
import { storeMutations } from "@/constants/store-mutations";
import { errorMessages } from "@/constants/error-messages";
import { getDamageString, isGlassAvailableForCarId } from "@/helpers/damage-helper";
import { required, regex } from "@/helpers/validation-rules";
import { Form, defineRule } from "vee-validate";
import { navigateAfterSaveToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper";
// DEFINE VALIDATION RULES
defineRule("license-plate-required", required(errorMessages.LICENSE_PLATE_REQUIRED));
@ -111,15 +115,38 @@ export default {
},
data() {
return {
newServiceZipRequired: false,
vinNotValid: false,
vinDoesNotMatchCarId: false,
licensePlate: '',
zip: '',
email: '',
serviceZip: '',
isRegistrationZipServicable: true,
isVinValid: true,
isCarIdDifferent: false,
licensePlate: this.getLicensePlateFromStore(),
registrationZip: this.getRegistrationZipFromStore(),
email: this.getEmailFromStore(),
serviceZip: this.getServiceZipFromStore(),
previouslyEnteredCarId: '',
customAlertData: {},
isSelectedGlassAvailableForVehicle: true,
};
},
computed: {
MatchedDifferentVehicleAlertHeader(){
let text = this.getCmsContent("MatchedDifferentVehicleAlertWidget", "HeadlineText").replaceAll("{custom:damage}", getDamageString());
return text;
},
MatchedDifferentVehicleAlertBody(){
let text = this.getCmsContent("MatchedDifferentVehicleAlertWidget", "BodyText").replaceAll("{custom:damage}", getDamageString()).replaceAll("{custom:plateLookupYear}", this.customAlertData?.vehicleInfo?.year).replaceAll("{custom:plateLookupMake}", this.customAlertData?.vehicleInfo?.make).replaceAll("{custom:plateLookupModel}", this.customAlertData?.vehicleInfo?.model);
return text;
},
NoServiceZipHeader(){
let text = this.getCmsContent("NoServiceZipWidget", "HeadlineText").replaceAll("{custom:zip}", this.registrationZip);
return text;
},
NoServiceZipBody(){
return this.getCmsContent("NoServiceZipWidget", "BodyText");
},
},
methods: {
arePagePrerequisitesValid() {
return store.getters.vehicle.carId !== null;
@ -131,84 +158,120 @@ export default {
store.commit(storeMutations.UPDATE_REGISTRATION_LAST_NAME, null);
store.dispatch(storeActions.RESET_PARTS_STATE_AND_DEPENDENCIES);
},
getLicensePlateFromStore(){
return store.getters.vehicle.registration.licensePlate
},
getRegistrationZipFromStore(){
return store.getters.vehicle.registration.zipCode
},
getEmailFromStore(){
return store.getters.order.customer.emailAddress
},
getServiceZipFromStore(){
return store.getters.order.serviceLocation.zip
},
backButtonAction() {
this.$router.navigate(this.navigationScenarios.CLICKED_BACK, this.$route);
},
async forwardButtonAction() {
const zipValidation = this.serviceZip ? await this.validateZip(this.serviceZip) : await this.validateZip(this.zip);
this.pushEventToGA(this.$route.query[this.queryStrings.FMG_PAGE], this.GaActions.SUBMITTED, this.GaLabels.LICENSE_PLATE_LOOKUP , true);
const zipValidation = this.serviceZip ? await this.validateZip(this.serviceZip) : await this.validateZip(this.registrationZip);
if (!zipValidation.data.isServiceable) {
this.$refs.funnelFooter.removeLoader();
this.newServiceZipRequired = true;
return;
}
const vinLookup = await this.lookupVin(this.licensePlate, zipValidation.data.state).catch(() => {
this.$refs.funnelFooter.removeLoader();
this.vinNotValid = true;
return;
});
if (vinLookup.data.vehicle.carId !== store.getters.vehicle.carId) {
this.$refs.funnelFooter.updateButtonText(`Continue with ${vinLookup.data.vin} ${vinLookup.data.vehicle.year} ${vinLookup.data.vehicle.make} ${vinLookup.data.vehicle.model}`);
this.$refs.funnelFooter.removeLoader();
this.vinDoesNotMatchCarId = true;
this.isVinValid = true;
this.isRegistrationZipServicable = false;
this.isCarIdDifferent = false;
return;
}
const partsData = await baseMixin.methods.dispatchNonBlockingStoreAction(
const vinLookup = await this.lookupVin(this.licensePlate, zipValidation.data.state).catch(() => {
this.$refs.funnelFooter.removeLoader();
this.isVinValid = false;
this.isCarIdDifferent = false;
return;
});
this.isCarIdDifferent = vinLookup.data.vehicle.carId !== store.getters.vehicle.carId;
if (this.isCarIdDifferent && (vinLookup.data.vehicle.carId !== this.previouslyEnteredCarId)) {
this.previouslyEnteredCarId = vinLookup.data.vehicle.carId;
this.customAlertData.vehicleInfo = vinLookup.data.vehicle;
this.$refs.funnelFooter.updateButtonText(`Continue with ${vinLookup.data.vehicle.year} ${vinLookup.data.vehicle.make} ${vinLookup.data.vehicle.model}`);
this.isVinValid = true;
this.isSelectedGlassAvailableForVehicle = await isGlassAvailableForCarId(vinLookup.data.vehicle.carId);
this.$refs.funnelFooter.removeLoader();
return;
}
this.updateCustomerInfo(vinLookup.data.vin, vinLookup.data.vehicle, zipValidation.data.state);
const partsData = await baseMixin.methods.dispatchStoreAction(
this.storeActions.GET_PARTS_OR_QUESTIONS,
{
carId: store.getters.vehicle.carId,
glassArray: store.getters.damage.glassToReplace,
zipCode: this.zip,
vin: vinLookup.vin
carId: vinLookup.data.vehicle.carId,
glassArray: this.isCarIdDifferent && !this.isSelectedGlassAvailableForVehicle ? [] : store.getters.damage.glassToReplace,
zipCode: this.serviceZip ? this.serviceZip : this.registrationZip,
vin: vinLookup.data.vin
},
false
);
this.navigateForward(partsData);
},
navigateForward(partsData){
if(partsData.data.partsOrQuestions[0].partQuestions && partsData.data.partsOrQuestions[0].partQuestions.length > 0){
this.$router.navigateAfterSave(this.navigationScenarios.CONTINUING_WITH_PARTS_QUESTION, this.$route, {}, {}, partsData.data);
if(this.isCarIdDifferent && !this.isSelectedGlassAvailableForVehicle){
this.$router.navigateAfterSave(this.navigationScenarios.CLICKED_FORWARD, this.$route, {}, { displayVehicleChangeAlert: true }, partsData.data);
return;
} else if((!partsData.data.partsOrQuestions[0].partQuestions || partsData.data.partsOrQuestions[0].partQuestions.length < 1) && partsData.data.partsOrQuestions[0].parts.length > 1) {
this.$router.navigateAfterSave(this.navigationScenarios.CONTINUING_WITH_MULTIPLE_PARTS, this.$route, {}, {}, partsData.data);
return;
} else {
this.$router.navigate(this.navigationScenarios.CONTINUING_WITH_SINGLE_PART, this.$route);
navigateAfterSaveToHeritageFunnel(this.$route);
return;
}
},
validateZip(zip) {
return baseMixin.methods.dispatchNonBlockingStoreAction(
return baseMixin.methods.dispatchStoreAction(
storeActions.VALIDATE_ZIP,
{ zip }
);
},
lookupVin(plate, state) {
return baseMixin.methods.dispatchNonBlockingStoreAction(
return baseMixin.methods.dispatchStoreAction(
storeActions.LOOKUP_VIN_BY_PLATE,
{ licensePlate: plate, licenseState: state }
);
},
updateStore() {
// if(vehicleDamage){
// store.dispatch(storeActions.RESET_DAMAGE_STATE_AND_DEPENDENCIES);
// }
store.commit(storeMutations.UPDATE_VEHICLE_VIN, null);
store.commit(storeMutations.UPDATE_YEAR, null);
store.commit(storeMutations.UPDATE_MAKE, null);
store.commit(storeMutations.UPDATE_MODEL, null);
store.commit(storeMutations.UPDATE_STYLE, null);
store.commit(storeMutations.UPDATE_CAR_ID, null);
store.commit(storeMutations.UPDATE_VEHICLE_CATEGORY, null);
store.commit(storeMutations.UPDATE_VEHICLE_IMAGE_URL, null);
store.commit(storeMutations.UPDATE_VEHICLE_IMAGE_VIF_NUMBER, null);
store.commit(storeMutations.UPDATE_VEHICLE_IMAGE_COLOR, null);
store.commit(storeMutations.UPDATE_REGISTRATION_LICENSE_PLATE, null);
store.commit(storeMutations.UPDATE_REGISTRATION_STATE, null);
store.commit(storeMutations.UPDATE_REGISTRATION_ZIP_CODE, null);
store.commit(storeMutations.UPDATE_SERVICE_LOCATION_ZIP, null);
store.commit(storeMutations.UPDATE_CUSTOMER_EMAIL_ADDRESS, null);
updateCustomerInfo(vin, vehicleInfo, registrationState) {
if(this.isCarIdDifferent && !this.isSelectedGlassAvailableForVehicle){
store.dispatch(storeActions.RESET_DAMAGE_STATE_AND_DEPENDENCIES);
}
store.commit(storeMutations.UPDATE_VEHICLE_VIN, vin);
store.commit(storeMutations.UPDATE_YEAR, vehicleInfo.year);
store.commit(storeMutations.UPDATE_MAKE, vehicleInfo.make);
store.commit(storeMutations.UPDATE_MODEL, vehicleInfo.model);
store.commit(storeMutations.UPDATE_STYLE, vehicleInfo.style);
store.commit(storeMutations.UPDATE_CAR_ID, vehicleInfo.carId);
store.commit(storeMutations.UPDATE_VEHICLE_CATEGORY, vehicleInfo.category);
store.commit(storeMutations.UPDATE_VEHICLE_IMAGE_URL, vehicleInfo.imageUrl);
store.commit(storeMutations.UPDATE_VEHICLE_IMAGE_VIF_NUMBER, vehicleInfo.imageVifNumber);
store.commit(storeMutations.UPDATE_VEHICLE_IMAGE_COLOR, vehicleInfo.imageColor);
store.commit(storeMutations.UPDATE_REGISTRATION_LICENSE_PLATE, this.licensePlate);
store.commit(storeMutations.UPDATE_REGISTRATION_STATE, registrationState);
store.commit(storeMutations.UPDATE_REGISTRATION_ZIP_CODE, this.registrationZip);
store.commit(storeMutations.UPDATE_SERVICE_LOCATION_ZIP, this.serviceZip);
store.commit(storeMutations.UPDATE_CUSTOMER_EMAIL_ADDRESS, this.email);
},
},
watch: {
licensePlate() {
this.$refs.funnelFooter.updateButtonText(this.getCmsContent("FunnelFooterWidget", "ForwardButtonText"));
},
registrationZip(){
this.$refs.funnelFooter.updateButtonText(this.getCmsContent("FunnelFooterWidget", "ForwardButtonText"));
},
serviceZip(){
this.$refs.funnelFooter.updateButtonText(this.getCmsContent("FunnelFooterWidget", "ForwardButtonText"));
}
},
components: {
Form,
funnelHeader,

View file

@ -1,10 +1,12 @@
<template>
<div class="container-fluid shadow rounded-3 p-2 position-relative make-tall">
<funnelHeader ref="funnelHeader" />
<vehicleBanner ref="vehicleBanner" :displayGenericVehicleImage="false" />
<funnelSubHeader ref="funnelSubHeader" />
<funnelHeader cmsWidgetName="FunnelHeaderWidget" />
<vehicleBanner cmsWidgetName="VehicleBannerWidget" :displayGenericVehicleImage=false />
<funnelSubHeader cmsWidgetName="FunnelSubHeaderWidget" />
<h1>Part Questions Page Placeholder</h1>
<funnelFooter ref="funnelFooter" @back-clicked="backButtonAction" />
<funnel-footer
cmsWidgetName="FunnelFooterWidget" @back-clicked="backButtonAction"
/>
</div>
</template>
@ -49,18 +51,7 @@ export default {
// Call the "next" function to complete the transition to this page.
next((vm) => {
vm.$refs.funnelHeader.initializeComponent(
resultMap.cmsContent.FunnelHeaderWidget
);
vm.$refs.vehicleBanner.initializeComponent(
resultMap.cmsContent.VehicleBannerWidget
);
vm.$refs.funnelSubHeader.initializeComponent(
resultMap.cmsContent.FunnelSubHeaderWidget
);
vm.$refs.funnelFooter.initializeComponent(
resultMap.cmsContent.FunnelFooterWidget
);
vm.setCmsContent(resultMap.cmsContent);
});
},
methods: {

View file

@ -6,6 +6,7 @@
:answers="answersToDisplay"
:groupName="groupName"
buttonType="listCard"
isRequired
v-model="selectedValues"
validationRules="damage-location-required"
/>
@ -30,11 +31,7 @@ export default ({
}
},
props: {
isMultiSelect: Boolean,
modelValue: Array,
isAvailable: Boolean,
filterByVehicleCategory: Boolean,
name: String,
groupName: String,
cmsWidgetName: String,
},

View file

@ -11,6 +11,7 @@
v-model="selectedValues"
:validationRules="validationRules"
:suppressError="suppressError"
:isRequired=isRequired
/>
</div>
</transition>
@ -36,6 +37,7 @@ export default ({
validationRules: String,
suppressError: Boolean,
cmsWidgetName: String,
isRequired: Boolean,
},
methods: {
initializeComponent(replaceOptions){

View file

@ -10,6 +10,7 @@
buttonType="listCard"
v-model="selectedDoorSidesValues"
validationRules="damage-side-required"
isRequired
/>
</div>
</transition>
@ -22,6 +23,7 @@
filterByVehicleCategory
v-model="selectedDriverSideReplaceOptionsValues"
validationRules="driver-side-options-required"
isRequired
/>
<replaceOptionsQuestion
ref="passengerSideOptions"
@ -32,6 +34,7 @@
filterByVehicleCategory
v-model="selectedPassengerSideReplaceOptionsValues"
validationRules="passenger-side-options-required"
isRequired
/>
</div>
</template>

View file

@ -17,6 +17,7 @@ import { storeMutations } from "@/constants/store-mutations";
import store from "@/store";
import { validate } from "vee-validate";
import { damageLocationsSelected } from "@/constants/damage-locations-selected.js";
import { routerParams } from "@/router/router-constants/router-params";
// Mock our module for promises.
jest.mock("@/helpers/layout-helper.js", () => ({
@ -36,6 +37,11 @@ jest.mock("@/store", () => ({
vehicle: {
carId: "C00000000",
image: "test.jpg",
payment: {
insuranceCoverage: {
isVerified: false
}
}
},
eventBusItem: jest.fn(),
damage: {
@ -134,6 +140,7 @@ describe("vehicle-damage.vue", () => {
store: {
getters: {
vehicle: {},
payment: { insuranceCoverage: { isVerified: false } },
},
},
},
@ -201,6 +208,7 @@ describe("vehicle-damage.vue", () => {
store: {
getters: {
vehicle: {},
payment: { insuranceCoverage: { isVerified: false } },
},
},
},
@ -291,6 +299,7 @@ describe("vehicle-damage.vue", () => {
store: {
getters: {
vehicle: {},
payment: { insuranceCoverage: { isVerified: false } },
},
},
},
@ -336,6 +345,7 @@ describe("vehicle-damage.vue", () => {
store: {
getters: {
vehicle: {},
payment: { insuranceCoverage: { isVerified: false } },
},
},
},
@ -694,6 +704,57 @@ describe("vehicle-damage.vue", () => {
});
});
describe("vehicle-damage.vue", () => {
test("when displayVehicleChangeAlert router params is true, the alert: 'vehicleChangeAlert' should be visible", () => {
// Arrange & Act
const { wrapper } = setupMocks({
mountOptionsMockData: {
route: {
params: {
[routerParams.DISPLAY_VEHICLE_CHANGE_ALERT]: true
}
}
}
});
// Assert
expect(wrapper.findComponent({ref: 'vehicleChangeAlert'}).isVisible()).toBe(true);
})
});
describe("vehicle-damage.vue", () => {
test("when displayVehicleChangeAlert router params is false, the alert: 'vehicleChangeAlert' should not be visible", () => {
// Arrange & Act
const { wrapper } = setupMocks({
mountOptionsMockData: {
route: {
params: {
[routerParams.DISPLAY_VEHICLE_CHANGE_ALERT]: false
}
}
}
});
// Assert
expect(wrapper.findComponent({ref: 'vehicleChangeAlert'}).isVisible()).toBe(false);
})
});
describe("vehicle-damage.vue", () => {
test("when displayVehicleChangeAlert router params is undefined, the alert: 'vehicleChangeAlert' should not be visible", () => {
// Arrange & Act
const { wrapper } = setupMocks({
mountOptionsMockData: {
route: {
params: {
[routerParams.DISPLAY_VEHICLE_CHANGE_ALERT]: undefined
}
}
}
});
// Assert
expect(wrapper.findComponent({ref: 'vehicleChangeAlert'}).isVisible()).toBe(false);
})
});
// THE FOLLOWING TEST IS NOT NECESSARILY REQUIRED FOR COVERAGE
// BUT KEEP FOR AN EXAMPLE OF A VALIDATION TEST
//
@ -725,31 +786,41 @@ describe("vehicle-damage.vue", () => {
});
function setupMocks({
pageHeaderWidgetHeaderText = {},
mountOptionsMockData = {
function setupMocks({pageHeaderWidgetHeaderText, mountOptionsMockData}) {
var pageHeaderWidgetHeaderTextDefault = {};
var mountOptionsMockDataDefault = {
router: {
navigate: jest.fn(),
},
route: {
params: {
[routerParams.DISPLAY_VEHICLE_CHANGE_ALERT]: false
}
},
store: {
getters: {
vehicle: {},
payment: {
insuranceCoverage: {
isVerified: false
}
},
},
},
},
}) {
};
// Combine parameters with default values
pageHeaderWidgetHeaderText = Object.assign(pageHeaderWidgetHeaderTextDefault, pageHeaderWidgetHeaderText);
mountOptionsMockData = Object.assign(mountOptionsMockDataDefault, mountOptionsMockData);
//Mock api responses
baseMixin.methods.dispatchNonBlockingStoreAction = jest.fn();
baseMixin.methods.dispatchStoreAction = jest.fn();
const apiResponses = {
cmsContent: {
FunnelSubHeaderWidget: pageHeaderWidgetHeaderText,
VehicleBannerWidget: {
GenericVehicleImage:
"https://digitalconsumercms-dev.safelite.com/images/default-source/default-album/blurred-image.jpg?sfvrsn=a6ce3034_3",
GenericVehicleImage: "https://digitalconsumercms-dev.safelite.com/images/default-source/default-album/blurred-image.jpg?sfvrsn=a6ce3034_3",
},
FunnelHeaderWidget: {
LogoImage:
"https://digitalconsumercms-dev.safelite.com/images/default-source/default-album/safelite-logo.svg?sfvrsn=45e7ed06_3",
LogoImage: "https://digitalconsumercms-dev.safelite.com/images/default-source/default-album/safelite-logo.svg?sfvrsn=45e7ed06_3",
},
},
damageOptions: {

View file

@ -9,48 +9,57 @@
<funnelHeader cmsWidgetName="FunnelHeaderWidget" />
<vehicleBanner cmsWidgetName="VehicleBannerWidget" :displayGenericVehicleImage=false />
<funnelSubHeader cmsWidgetName="FunnelSubHeaderWidget" />
<damageLocationQuestion
ref="damageLocation"
cmsWidgetName="DamageLocationQuestion"
v-model="selectedDamageLocations"
groupName="DamageLocationQuestion"
/>
<windshieldOptions
ref="windshieldOptions"
v-model="selectedWindshieldOptions"
:hasRepairReplaceConflict="hasRepairReplaceConflict"
:hasSplitSingleConflict="hasSplitSingleConflict"
:selectedDamageLocations="selectedDamageLocations"
/>
<alert
class="my-3"
cmsWidgetName="HasReplacementConflict"
v-show="hasRepairReplaceConflict"
alertClass="alert-danger"
:isDismissible="false"
/>
<sideDoorOptions
ref="sideDoorOptions"
cmsWidgetName="SideDoorSideQuestion"
groupName="SideDoorSideQuestion"
v-model="sideDoorOptionsData"
v-show="!hasRepairReplaceConflict"
:selectedDamageLocations="selectedDamageLocations"
/>
<replaceOptionsQuestion
ref="backGlassOptions"
cmsWidgetName="RearReplaceOptionsQuestion"
:isAvailable="isRearWindowDamageLocation && !hasRepairReplaceConflict"
v-model="selectedRearReplaceOptions"
groupName="BackGlassReplaceOptionsQuestion"
validationRules="replace-options-required"
/>
<funnel-footer
cmsWidgetName="FunnelFooterWidget"
:isDisabled="!meta.valid"
@back-clicked="backButtonAction"
@ForwardClicked="forwardButtonAction"
/>
<alert
ref="vehicleChangeAlert"
class="mt-5 mb-0"
cmsWidgetName="VehicleChangeAlert"
v-show="shouldDisplayVehicleChangeAlert"
alertClass="alert-warning"
:isDismissible="false"
/>
<damageLocationQuestion
ref="damageLocation"
cmsWidgetName="DamageLocationQuestion"
v-model="selectedDamageLocations"
groupName="DamageLocationQuestion"
/>
<windshieldOptions
ref="windshieldOptions"
v-model="selectedWindshieldOptions"
:hasRepairReplaceConflict="hasRepairReplaceConflict"
:hasSplitSingleConflict="hasSplitSingleConflict"
:selectedDamageLocations="selectedDamageLocations"
/>
<alert
class="my-3"
cmsWidgetName="HasReplacementConflict"
v-show="hasRepairReplaceConflict"
alertClass="alert-danger"
:isDismissible="false"
/>
<sideDoorOptions
ref="sideDoorOptions"
cmsWidgetName="SideDoorSideQuestion"
groupName="SideDoorSideQuestion"
v-model="sideDoorOptionsData"
v-show="!hasRepairReplaceConflict"
:selectedDamageLocations="selectedDamageLocations"
/>
<replaceOptionsQuestion
ref="backGlassOptions"
cmsWidgetName="RearReplaceOptionsQuestion"
:isAvailable="isRearWindowDamageLocation && !hasRepairReplaceConflict"
v-model="selectedRearReplaceOptions"
groupName="BackGlassReplaceOptionsQuestion"
validationRules="replace-options-required"
/>
<funnel-footer
cmsWidgetName="FunnelFooterWidget"
:isForwardActionDisabled="!meta.valid"
:isBackButtonHidden=shouldHideBackButton
@back-clicked="backButtonAction"
@ForwardClicked="forwardButtonAction"
/>
</div>
</Form>
</template>
@ -77,6 +86,7 @@ import { errorMessages } from "@/constants/error-messages";
import { damageLocationsCms } from "@/constants/damage-locations-cms.js";
import { damageLocationsSelected } from "@/constants/damage-locations-selected.js";
import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper";
import { queryStrings } from "@/constants/query-strings";
import store from "@/store";
import baseMixin from "@/mixins/base-mixin";
@ -90,7 +100,7 @@ export default {
// Call APIs
const cmsContentPromise = fetchCmsContentForPage(to.query.fmgPage);
const damageOptionsPromise =
baseMixin.methods.dispatchNonBlockingStoreAction(
baseMixin.methods.dispatchStoreAction(
storeActions.GET_DAMAGE_OPTIONS,
{ carId: store.getters.vehicle.carId }
);
@ -138,6 +148,12 @@ export default {
selectedRearReplaceOptions: this.getRearReplaceOptionsFromStore(),
}
},
mounted(){
if(this.$store.getters.vehicle.imageVifNumber){
this.pushEventToGA(this.GaCategories.EVOX, `${this.GaActions.VIF}_${this.$store.getters.vehicle.imageVifNumber}`,
this.$store.getters.vehicle.carId, true);
}
},
methods: {
arePagePrerequisitesValid() {
if(store.getters.vehicle.carId){
@ -145,9 +161,11 @@ export default {
}
return false;
},
resetDependentState() {
store.dispatch(storeActions.RESET_PARTS_STATE_AND_DEPENDENCIES);
},
backButtonAction() {
// route to move backwards
this.$router.navigate(
@ -155,19 +173,20 @@ export default {
this.$route
);
},
getDamageLocationsFromStore() {
var glassSelections = [];
if (store.getters.damage.glassToReplace.some(glass => { return glass.location === damageLocationsSelected.WINDSHIELD }) ||
if (store.getters.damage.glassToReplace?.some(glass => { return glass.location === damageLocationsSelected.WINDSHIELD }) ||
store.getters.damage.isRepair) {
glassSelections.push(damageLocationsSelected.WINDSHIELD);
}
if (store.getters.damage.glassToReplace.some(glass => { return glass.location === damageLocationsSelected.DRIVER ||
if (store.getters.damage.glassToReplace?.some(glass => { return glass.location === damageLocationsSelected.DRIVER ||
glass.location === damageLocationsSelected.PASSENGER })) {
glassSelections.push(damageLocationsSelected.SIDEDOOR);
}
if (store.getters.damage.glassToReplace.some(glass => { return glass.location === damageLocationsSelected.REAR })) {
if (store.getters.damage.glassToReplace?.some(glass => { return glass.location === damageLocationsSelected.REAR })) {
glassSelections.push(damageLocationsSelected.REARWINDOW);
}
@ -180,19 +199,19 @@ export default {
if (store.getters.damage.isRepair === undefined) return windshieldOptions;
if (!store.getters.damage.isRepair) {
if (store.getters.damage.glassToReplace.some(glass => { return glass.location === damageLocationsSelected.WINDSHIELD &&
if (store.getters.damage.glassToReplace?.some(glass => { return glass.location === damageLocationsSelected.WINDSHIELD &&
glass.name === damageLocationsSelected.SINGLE })) {
windShieldOptions.selectedWindshieldDamageType.push(damageLocationsSelected.REPLACE);
windShieldOptions.selectedWindshieldReplaceOptions.push(damageLocationsSelected.SINGLE);
}
if (store.getters.damage.glassToReplace.some(glass => { return glass.location === damageLocationsSelected.WINDSHIELD &&
if (store.getters.damage.glassToReplace?.some(glass => { return glass.location === damageLocationsSelected.WINDSHIELD &&
glass.name === damageLocationsSelected.DRIVER })) {
windShieldOptions.selectedWindshieldDamageType.push(damageLocationsSelected.REPLACE);
windShieldOptions.selectedWindshieldReplaceOptions.push(damageLocationsSelected.DRIVER);
}
if (store.getters.damage.glassToReplace.some(glass => { return glass.location === damageLocationsSelected.WINDSHIELD &&
if (store.getters.damage.glassToReplace?.some(glass => { return glass.location === damageLocationsSelected.WINDSHIELD &&
glass.name === damageLocationsSelected.PASSENGER })) {
windShieldOptions.selectedWindshieldDamageType.push(damageLocationsSelected.REPLACE);
windShieldOptions.selectedWindshieldReplaceOptions.push(damageLocationsSelected.PASSENGER);
@ -210,11 +229,11 @@ export default {
getDoorSidesFromStore() {
var doorSides = [];
if (store.getters.damage.glassToReplace.some(glass => { return glass.location === damageLocationsSelected.DRIVER })){
if (store.getters.damage.glassToReplace?.some(glass => { return glass.location === damageLocationsSelected.DRIVER })){
doorSides.push(damageLocationsSelected.DRIVERSIDE);
}
if (store.getters.damage.glassToReplace.some(glass => { return glass.location === damageLocationsSelected.PASSENGER })){
if (store.getters.damage.glassToReplace?.some(glass => { return glass.location === damageLocationsSelected.PASSENGER })){
doorSides.push(damageLocationsSelected.PASSENGERSIDE);
}
@ -224,7 +243,7 @@ export default {
getDriverSideReplaceOptionsFromStore() {
var driverSideReplaceOptions = [];
store.getters.damage.glassToReplace.forEach(glass => {
store.getters.damage.glassToReplace?.forEach(glass => {
if (glass.location === damageLocationsSelected.DRIVER){
driverSideReplaceOptions.push(glass.name);
}
@ -236,7 +255,7 @@ export default {
getPassengerSideReplaceOptionsFromStore() {
var passengerSideReplaceOptions = [];
store.getters.damage.glassToReplace.forEach(glass => {
store.getters.damage.glassToReplace?.forEach(glass => {
if (glass.location === damageLocationsSelected.PASSENGER){
passengerSideReplaceOptions.push(glass.name);
}
@ -248,7 +267,7 @@ export default {
getRearReplaceOptionsFromStore(){
var rearReplaceOptions = [];
store.getters.damage.glassToReplace.forEach(glass => {
store.getters.damage.glassToReplace?.forEach(glass => {
if (glass.location === damageLocationsSelected.REAR){
rearReplaceOptions.push(glass.name);
}
@ -268,20 +287,33 @@ export default {
store.commit(this.storeMutations.UPDATE_GLASS_TO_REPLACE, this.selectedGlassToReplace());
const partsData = await baseMixin.methods.dispatchNonBlockingStoreAction(this.storeActions.GET_PARTS_OR_QUESTIONS,
const partsData = await baseMixin.methods.dispatchStoreAction(this.storeActions.GET_PARTS_OR_QUESTIONS,
{ carId: store.getters.vehicle.carId, glassArray: this.selectedGlassToReplace()}, false);
this.navigateForward(partsData);
},
navigateForward(partsData){
// CSR-98 TEMP
const vehicleYearsToShowHeritageFunnel = [ 2001, 2002, 2010 ];
// Temporary easter egg to navigate to heritage funnel.
const vehicleYearsToShowHeritageFunnel = [ 2001, 2002, 2010, 2016 ];
if (vehicleYearsToShowHeritageFunnel.includes(store.getters.vehicle.year)) {
navigateToHeritageFunnel();
return;
}
// Temporary easter egg to navigate to address-lookup.
if (store.getters.vehicle.year === 2014) {
this.$router.navigateAfterSave(this.navigationScenarios.TEMPORARY_TO_ADDRESS_LOOKUP, this.$route, {}, {}, partsData.data);
return;
}
// If vin already exists, navigate directly to vin-lookup
if(this.$store.getters.vehicle.vin){
this.$router.navigate(this.navigationScenarios.CLICKED_FORWARD_WITH_VIN, this.$route);
return;
}
//found problem questions
if (partsData.data.partsOrQuestions.some(pq => pq.partQuestions != null && pq.partQuestions.length > 0)){
this.$router.navigateAfterSave(this.navigationScenarios.SELECTED_DAMAGE_WITH_PART_QUESTIONS, this.$route, {}, {}, partsData.data);
@ -382,7 +414,7 @@ export default {
return this.isWindshieldDamageLocation && this.selectedDamageLocations.length > 1 && this.isWindshieldRepair;
},
hasSplitSingleConflict() {
if (!this.selectedWindshieldOptions.selectedWindshieldReplaceOptions) return false;
if (!this.selectedDamageLocations || !this.selectedDamageLocations.includes("Windshield") || !this.selectedWindshieldOptions.selectedWindshieldDamageType || !this.selectedWindshieldOptions.selectedWindshieldDamageType.includes("Replace") || !this.selectedWindshieldOptions.selectedWindshieldReplaceOptions) return false;
return this.selectedWindshieldOptions.selectedWindshieldReplaceOptions.some(selectedSingleWindshield =>
{
@ -398,6 +430,12 @@ export default {
})
);
},
shouldDisplayVehicleChangeAlert() {
return this.$route.params[this.routerParams.DISPLAY_VEHICLE_CHANGE_ALERT];
},
shouldHideBackButton(){
return this.$store.getters.payment.insuranceCoverage.isVerified;
}
},
components: {

View file

@ -9,6 +9,7 @@
useTextForValue
v-model="selectedChipCountValues"
:validationRules="validationRules"
isRequired
/>
</div>
</transition>

View file

@ -9,6 +9,7 @@
v-model="selectedValues"
:suppressError="suppressError"
:validationRules="validationRules"
isRequired
/>
</div>
</transition>

View file

@ -27,6 +27,7 @@
v-model="selectedWindshieldReplaceOptionsValues"
validationRules="windshield-replace-options-required|prevent-split-and-single-together"
:suppressError="hasSplitSingleConflict"
isRequired
/>
<alert
class="my-3"

View file

@ -5,7 +5,7 @@
selectingInitiatesLoad
:questionText="questionText"
:answers="makes"
groupName="Choose Vehicle Make"
groupName="ChooseVehicleMake"
textPosition="text-start"
v-model="selectedValueAsArray"
isRequired=true
@ -50,7 +50,7 @@ export default {
},
methods: {
loadInitialData() {
return baseMixin.methods.dispatchNonBlockingStoreAction(
return baseMixin.methods.dispatchStoreAction(
storeActions.GET_VEHICLE_MAKES,
{ year: store.getters.vehicle.year }
);

View file

@ -5,7 +5,7 @@
selectingInitiatesLoad
:questionText="questionText"
:answers="models"
groupName="Choose Vehicle Model"
groupName="ChooseVehicleModel"
textPosition="text-start"
v-model="selectedValueAsArray"
isRequired=true
@ -50,7 +50,7 @@ export default {
},
methods: {
loadInitialData() {
return baseMixin.methods.dispatchNonBlockingStoreAction(
return baseMixin.methods.dispatchStoreAction(
storeActions.GET_VEHICLE_MODELS,
{ year: store.getters.vehicle.year, make: store.getters.vehicle.make }
);

View file

@ -25,6 +25,7 @@
"
:buttonLabel="name"
altText=""
isRequired
:buttonID="`${glassLocation}-${glassName}-${name}`"
:groupName="`${glassLocation}-${glassName}`"
@isCheckedChanged="ResetTintAndPartSelections()"
@ -50,7 +51,7 @@
:answers="value"
textPosition="text-start"
:loaderEnabled="false"
:isRequired="true"
isRequired
:groupName="`${glassLocation}-${glassName}-${name}`"
/>
</div>

View file

@ -5,7 +5,7 @@
selectingInitiatesLoad
:questionText="questionText"
:answers="styles"
groupName="Choose Vehicle Style"
groupName="ChooseVehicleStyle"
textPosition="text-start"
v-model="selectedValueAsArray"
isRequired=true
@ -50,7 +50,7 @@ export default {
},
methods: {
loadInitialData() {
return baseMixin.methods.dispatchNonBlockingStoreAction(
return baseMixin.methods.dispatchStoreAction(
storeActions.GET_VEHICLE_STYLES,
{
year: store.getters.vehicle.year,

View file

@ -87,7 +87,7 @@ describe("vehicle-style.vue", () => {
});
describe("vehicle-style.vue", () => {
test("selectVehicle triggers a dispatchNonBlockingStoreAction commit", async (done) => {
test("selectVehicle triggers a dispatchStoreAction commit", async (done) => {
//Arrange
const { wrapper, apiPromise } = setupMocks({
pageHeaderWidgetHeaderText: "Select a style to get started",
@ -119,7 +119,7 @@ describe("vehicle-style.vue", () => {
//Assert
apiPromise.finally(() => {
expect(wrapper.vm.dispatchNonBlockingStoreAction).toHaveBeenCalled();
expect(wrapper.vm.dispatchStoreAction).toHaveBeenCalled();
done();
});
});

View file

@ -76,7 +76,7 @@ export default {
);
},
setVehicle() {
return this.dispatchNonBlockingStoreAction(
return this.dispatchStoreAction(
this.storeActions.SET_VEHICLE,
{
year: this.$store.getters.vehicle.year,

View file

@ -44,14 +44,11 @@ export default {
const yearQuestionInitialDataPromise = yearQuestion.methods.loadInitialData();
// Log experiment exposure
const logExperimentExposurePromise = baseMixin.methods.dispatchNonBlockingStoreAction(storeActions.LOG_EXPERIMENT_EXPOSURE,
const logExperimentExposurePromise = baseMixin.methods.dispatchStoreAction(storeActions.LOG_EXPERIMENT_EXPOSURE,
{
userId: getDeviceIdValue(),
sessionKey: getSessionKeyValue(),
sessionId: getSessionIdValue(),
pageName: to.query.fmgPage,
serverName: 'ConceptFunnel',
pageEvent: { action: '', event: 'ENTRY' },
universeName: experimentUniverses.CONCEPT_FUNNEL
}, false);

View file

@ -5,7 +5,7 @@
selectingInitiatesLoad
:questionText="questionText"
:answers="years"
groupName="Choose Vehicle Year"
groupName="ChooseVehicleYear"
textPosition="text-start"
v-model="selectedValueAsArray"
isRequired=true
@ -48,7 +48,7 @@ export default {
},
methods: {
loadInitialData() {
return baseMixin.methods.dispatchNonBlockingStoreAction(
return baseMixin.methods.dispatchStoreAction(
storeActions.GET_VEHICLE_YEARS,
{}
);

View file

@ -1,41 +1,274 @@
<template>
<div class="container-fluid shadow rounded-3 px-5 position-relative make-tall">
<funnelHeader ref="funnelHeader" />
<vehicleBanner ref="vehicleBanner" />
<funnelSubHeader ref="funnelSubHeader" />
<vinInformation
class="mb-5"
/>
<funnel-footer
ref="funnelFooter"
:isDisabled="!meta.valid"
@back-clicked="backButtonAction"
@ForwardClicked="forwardButtonAction"
/>
</div>
<Form
@submit="onSubmit"
@invalid-submit="onInvalidSubmit"
ref="theForm"
v-slot="{ meta }"
>
<div class="container-fluid shadow rounded-3 p-2 position-relative make-tall px-5">
<funnelHeader cmsWidgetName="FunnelHeaderWidget" />
<vehicleBanner cmsWidgetName="VehicleBannerWidget" :displayGenericVehicleImage="false" />
<funnelSubHeader cmsWidgetName="FunnelSubHeaderWidget" />
<div class="row my-2">
<div class="col">
<textboxQuestion cmsWidgetName="VinNumber" v-model="vin" inputId="vin" isRequired disableAutoFill validationRules="vin-required|vin-format" :isDisabled=isVinFieldReadOnly />
</div>
</div>
<div class="row my-2">
<div class="col">
<vinInformation />
</div>
</div>
<div class="row my-2">
<div class="col">
<textboxQuestion cmsWidgetName="ServiceZIP" v-model="zip" inputId="zip" mask="#####" isRequired disableAutoFill validationRules="zip-required" />
</div>
</div>
<div class="row my-2">
<div class="col">
<textboxQuestion cmsWidgetName="EmailAddress" v-model="email" inputId="email" isRequired disableAutoFill validationRules="email-address-required|email-address-format" />
</div>
</div>
<alert
class="my-3"
v-model="customAlertData"
v-if="matchedDifferentVehicle"
alertClass="alert-danger"
cmsWidgetName="MatchedDifferentVehicle"
/>
<alert
class="my-3"
v-model="customAlertData"
v-if="noMatchAlert"
alertClass="alert-warning"
cmsWidgetName="NoMatchAlertWidget"
/>
<alert
class="my-3"
v-model="customAlertData"
v-if="noServiceZip"
alertClass="alert-warning"
cmsWidgetName="NoServiceZipWidget"
/>
<alert
class="my-3"
v-model="customAlertData"
v-if="vinFound"
alertClass="alert-warning"
cmsWidgetName="VinFoundWidget"
/>
<alert
class="my-3"
v-model="customAlertData"
v-if="vinFoundReadOnly"
alertClass="alert-warning"
cmsWidgetName="VinFoundReadOnlyWidget"
/>
<alert
class="my-3"
v-model="customAlertData"
v-if="foundWindshieldAlert"
alertClass="alert-warning"
cmsWidgetName="FoundWindshieldAlert"
/>
<alert
class="my-3"
v-model="customAlertData"
v-if="vinNotFound"
alertClass="alert-warning"
cmsWidgetName="VinNotFound"
/>
<alert
class="my-3"
v-model="customAlertData"
v-if="perfectMatchNewVinAlert"
alertClass="alert-warning"
cmsWidgetName="PerfectMatchNewVinAlert"
/>
<funnelFooter
cmsWidgetName="FunnelFooterWidget"
ref="funnelFooter"
:isDisabled="!meta.valid"
@back-clicked="backButtonAction"
@ForwardClicked="forwardButtonAction"
/>
</div>
</Form>
</template>
<script>
// Components
import funnelHeader from "@/common-components/funnel-header/funnel-header";
import funnelFooter from "@/common-components/funnel-footer/funnel-footer";
import vehicleBanner from "@/common-components/vehicle-banner/vehicle-banner";
import funnelSubHeader from "@/common-components/funnel-sub-header/funnel-sub-header";
import funnelFooter from "@/common-components/funnel-footer/funnel-footer";
import alert from "@/ux-components/alert/alert";
import textboxQuestion from "@/common-components/textbox-question/textbox-question";
import vinInformation from "@/layouts/vin-lookup/vin-information/vin-information";
// Supporting files
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { settleAllPromises } from "@/helpers/layout-helper";
import store from "@/store";
import baseMixin from "@/mixins/base-mixin.js";
import { storeActions } from "@/constants/store-actions";
import { storeMutations } from "@/constants/store-mutations";
import { errorMessages } from "@/constants/error-messages";
import { required, regex } from "@/helpers/validation-rules";
import { Form, defineRule } from "vee-validate";
// DEFINE VALIDATION RULES
defineRule("zip-required", required(errorMessages.ZIP_REQUIRED));
defineRule("email-address-required", required(errorMessages.EMAIL_ADDRESS_REQUIRED));
defineRule("email-address-format", regex(/^([a-zA-Z0-9_\-.+]+)@([a-zA-Z0-9_\-.]+).([a-zA-Z]{2,})$/, errorMessages.EMAIL_ADDRESS_FORMAT));
defineRule("vin-required", required(errorMessages.VIN_REQUIRED));
defineRule("vin-format", regex(/^[A-HJ-NPR-Z0-9]{17}$/, errorMessages.VIN_FORMAT));
export default {
name: "vin-lookup",
async beforeRouteEnter(to, from, next) {
// Call APIs
const cmsContentPromise = fetchCmsContentForPage(to.query.fmgPage);
// Settle promises and get results
const promiseResultMap = [
{
resultKey: "cmsContent",
promise: cmsContentPromise,
},
];
const resultMap = await settleAllPromises(promiseResultMap);
// Call the "next" function to complete the transition to this page.
next((vm) => {
vm.setCmsContent(resultMap.cmsContent);
});
},
props: {
validationRules: String,
},
data() {
return {
matchedDifferentVehicle: false,
noMatchAlert: false,
noServiceZip: false,
vinFound: false,
vinFoundReadOnly: false,
foundWindshieldAlert: false,
vinNotFound: false,
perfectMatchNewVinAlert: false,
vin: '',
zip: '',
email: '',
customAlertData: {},
};
},
methods: {
arePagePrerequisitesValid() {
return store.getters.vehicle.carId !== null;
},
resetDependentState() {
store.commit(storeMutations.UPDATE_REGISTRATION_ADDRESS, null);
store.commit(storeMutations.UPDATE_REGISTRATION_CITY, null);
store.commit(storeMutations.UPDATE_REGISTRATION_FIRST_NAME, null);
store.commit(storeMutations.UPDATE_REGISTRATION_LAST_NAME, null);
store.dispatch(storeActions.RESET_PARTS_STATE_AND_DEPENDENCIES);
},
backButtonAction() {
this.$router.navigate(this.navigationScenarios.CLICKED_BACK, this.$route);
},
async forwardButtonAction() {
this.pushEventToGA(this.$route.query[this.queryStrings.FMG_PAGE], this.GaActions.SUBMITTED, this.GaLabels.VINLOOKUP , true);
const zipValidation = await this.validateZip(this.zip);
if (!zipValidation.data.isServiceable) {
this.customAlertData.zip = this.zip;
this.$refs.funnelFooter.removeLoader();
this.noServiceZip = true;
return;
}
const vinLookup = await this.lookupVin(this.vin).catch(() => {
this.$refs.funnelFooter.removeLoader();
this.noMatchAlert = true;
return;
});
if (vinLookup.data.carId !== store.getters.vehicle.carId) {
this.customAlertData.vehicleInfo = vinLookup.data.vehicle;
this.$refs.funnelFooter.removeLoader();
this.foundWindshieldAlert = true;
return;
}
const carInfo = this.vinDoesNotMatchCarId ? vinLookup.data : store.getters.vehicle;
this.updateStore(carInfo)
const partsData = await baseMixin.methods.dispatchStoreAction(
this.storeActions.GET_PARTS_OR_QUESTIONS,
{
carId: store.getters.vehicle.carId,
glassArray: store.getters.damage.glassToReplace,
zipCode: this.zip,
vin: vinLookup.vin
},
false
);
this.navigateForward(partsData);
},
navigateForward(partsData){
if(partsData.data.partsOrQuestions[0].partQuestions && partsData.data.partsOrQuestions[0].partQuestions.length > 0){
this.$router.navigateAfterSave(this.navigationScenarios.CONTINUING_WITH_PARTS_QUESTION, this.$route, {}, {}, partsData.data);
return;
} else if((!partsData.data.partsOrQuestions[0].partQuestions || partsData.data.partsOrQuestions[0].partQuestions.length < 1) && partsData.data.partsOrQuestions[0].parts.length > 1) {
this.$router.navigateAfterSave(this.navigationScenarios.CONTINUING_WITH_MULTIPLE_PARTS, this.$route, {}, {}, partsData.data);
return;
} else {
this.$router.navigate(this.navigationScenarios.CONTINUING_WITH_SINGLE_PART, this.$route);
}
},
validateZip(zip) {
return baseMixin.methods.dispatchStoreAction(
storeActions.VALIDATE_ZIP,
{ zip }
);
},
lookupVin(vin) {
return baseMixin.methods.dispatchStoreAction(
storeActions.LOOKUP_VEHICLE_BY_VIN,
{ vin }
);
},
updateStore(carInfo) {
// if(vehicleDamage){
// store.dispatch(storeActions.RESET_DAMAGE_STATE_AND_DEPENDENCIES);
// }
store.commit(storeMutations.UPDATE_VEHICLE_VIN, this.vin);
store.commit(storeMutations.UPDATE_YEAR, carInfo.year);
store.commit(storeMutations.UPDATE_MAKE, carInfo.make);
store.commit(storeMutations.UPDATE_MODEL, carInfo.model);
store.commit(storeMutations.UPDATE_STYLE, carInfo.style);
store.commit(storeMutations.UPDATE_CAR_ID, carInfo.carId);
store.commit(storeMutations.UPDATE_VEHICLE_CATEGORY, carInfo.category);
store.commit(storeMutations.UPDATE_VEHICLE_IMAGE_URL, carInfo.imageUrl);
store.commit(storeMutations.UPDATE_VEHICLE_IMAGE_VIF_NUMBER, carInfo.imageVifNumber);
store.commit(storeMutations.UPDATE_VEHICLE_IMAGE_COLOR, carInfo.imageColor);
store.commit(storeMutations.UPDATE_SERVICE_LOCATION_ZIP, this.zip);
store.commit(storeMutations.UPDATE_CUSTOMER_EMAIL_ADDRESS, this.email);
},
},
computed: {
isVinFieldReadOnly(){
return this.$store.getters.payment.insuranceCoverage.isVerified;
}
},
components: {
Form,
funnelHeader,
vehicleBanner,
funnelSubHeader,
vinInformation,
textboxQuestion,
alert,
funnelFooter,
vinInformation,
},
};
</script>
<style lang="scss">
</style>

View file

@ -5,6 +5,7 @@ import App from "./App.vue";
import router from "./router";
import store from "@/store";
import baseMixin from "@/mixins/base-mixin.js";
import analyticsMixin from "@/mixins/analytics-mixin.js";
import "../node_modules/bootstrap/dist/js/bootstrap.js";
// Vue App Setup
@ -15,5 +16,6 @@ vueApp.use(store);
vueApp.use(LoadScript);
vueApp.use(Maska);
vueApp.mixin(baseMixin);
vueApp.mixin(analyticsMixin);
vueApp.mount("#app");

View file

@ -0,0 +1,116 @@
import { storeActions } from "@/constants/store-actions";
import { getDeviceIdValue, getSessionIdValue, getSessionKeyValue } from "@/helpers/heritage-integration/cookie-helper";
import { queryStrings } from "@/constants/query-strings";
import { experimentSettings } from "@/constants/experiments";
import { analyticsPageEvents, GaCategories, GaActions, GaLabels, GaEvents } from "@/constants/analytics";
import baseMixin from "@/mixins/base-mixin";
// We will need current page name for multiple methods, define it once to re-use.
const currentPageName = getPageNameByQueryString();
export default {
methods: {
logEvent(pageEvent, category, action, label, value) {
var payload = {
userId: getDeviceIdValue(),
sessionKey: getSessionKeyValue(),
pageName: currentPageName,
sessionId: getSessionIdValue(),
shouldUseSessionId: true,
};
if (pageEvent) {
payload.pageEvent = { action: '', event: pageEvent };
}
if (category) {
payload.customEvent = { category: category, action: action, label: label, value: value };
}
baseMixin.methods.dispatchStoreAction(storeActions.LOG_ACTIVITY, payload, false);
},
pushEventToGA(category, action, label, pushToLogApp = false) {
const eventToBePushed = {
'event': GaEvents.GENERIC_EVENT,
'category': category,
'action': action,
'label': label,
'value': undefined,
'path': `/fmg/?${queryStrings.FMG_PAGE}=${currentPageName}`
}
pushToDataLayerIfDefined(eventToBePushed);
if (pushToLogApp) {
this.logEvent(undefined, category, action, label, undefined);
}
},
pushPageViewToGA() {
const pageViewEvent = {
'event': GaEvents.PAGE_VIEW_EVENT,
'pagePath': `/fmg/?${queryStrings.FMG_PAGE}=${currentPageName}`,
'pageTitle': currentPageName
};
pushToDataLayerIfDefined(pageViewEvent);
this.logEvent(currentPageName, analyticsPageEvents.ENTRY);
},
pushExperimentsToDataLayer(experiments) {
experiments?.data?.forEach(exp => {
// Set Google Dimension Index based on experiment settings.
let googleDimensionIndex = 99;
if (exp.settings[experimentSettings.GOOGLE_CUSTOM_DIMENSION_INDEX] !== undefined) {
googleDimensionIndex = exp.settings[experimentSettings.GOOGLE_CUSTOM_DIMENSION_INDEX];
}
// Create object with dimension index and value.
const experimentWithDimension = {};
Object.keys(exp).forEach(key => {
experimentWithDimension[`${key}_${googleDimensionIndex}`] = exp[key];
});
// Push to the data layer with the Google Custom Dimension Index.
pushToDataLayerIfDefined(experimentWithDimension);
});
}
},
computed: {
analyticsPageEvents() {
return analyticsPageEvents;
},
GaCategories() {
return GaCategories;
},
GaActions() {
return GaActions;
},
GaLabels() {
return GaLabels;
}
}
};
function pushToDataLayerIfDefined(data) {
if (window.dataLayer !== undefined) {
window.dataLayer.push(data);
}
}
function getPageNameByQueryString() {
const params = new URLSearchParams(location.search);
if (params.has(queryStrings.FMG_PAGE)) {
return params.get(queryStrings.FMG_PAGE);
} else {
return '';
}
}

View file

@ -0,0 +1,83 @@
import analyticsMixin from "@/mixins/analytics-mixin";
import { setupMocksForJsFiles } from "@/helpers/unit-test-helper.js";
import { storeActions } from "@/constants/store-actions";
describe("analyticsMixin.js", () => {
test("logEvent: calls dispatch with type and payload", () => {
const type = "";
const payload = {};
const mockData = {
actionList: [{
actionName: storeActions.LOG_ACTIVITY
}],
}
const mocks = setupMocksForJsFiles(mockData);
analyticsMixin.methods.logEvent(type, payload);
expect(mocks.baseMixin.methods.dispatchStoreAction).toBeCalled();
});
test("pushEventToGA, should call logEvent too", () => {
// Arrange
const mockData = {
actionList: [{
actionName: storeActions.LOG_ACTIVITY
}],
}
const mocks = setupMocksForJsFiles(mockData);
// Act
analyticsMixin.methods.pushEventToGA('category', 'action', 'label', true);
// Assert
expect(mocks.baseMixin.methods.dispatchStoreAction).toBeCalled();
});
test("Experiments, should push to dataLayer with default Google Custom Dimension Index", () => {
// Arrange
window.dataLayer = [];
const mockExperimentData = {
data: [
{
settings: {},
variationName: 'test',
universeName: 'testUniverse'
}
]
}
// Act
analyticsMixin.methods.pushExperimentsToDataLayer(mockExperimentData);
// Assert
expect(window.dataLayer).toEqual([ { settings_99: {}, variationName_99: 'test', universeName_99: 'testUniverse' } ]);
});
test("Experiments, should push to dataLayer with custom Google Custom Dimension Index", () => {
// Arrange
window.dataLayer = [];
const mockExperimentData = {
data: [
{
settings: { "Google Custom Dimension Index": "5"},
variationName: 'test',
universeName: 'testUniverse'
}
]
}
// Act
analyticsMixin.methods.pushExperimentsToDataLayer(mockExperimentData);
// Assert
expect(window.dataLayer).toEqual([ { settings_5: {"Google Custom Dimension Index": "5"}, variationName_5: 'test', universeName_5: 'testUniverse' } ]);
});
});

View file

@ -3,6 +3,8 @@ import { storeActions } from "@/constants/store-actions.js";
import { storeMutations } from "@/constants/store-mutations.js";
import { navigationScenarios } from "@/router/router-constants/navigation-scenarios";
import { vehicleCategories } from "@/constants/vehicle-categories.js";
import { routerParams } from "@/router/router-constants/router-params";
import { queryStrings } from "@/constants/query-strings";
export default {
data() {
@ -11,13 +13,13 @@ export default {
};
},
methods: {
setCmsContent(cmsContent){
setCmsContent(cmsContent) {
this.$root.cmsContentByWidget = cmsContent;
},
getCmsContent(widgetName, fieldName){
getCmsContent(widgetName, fieldName) {
return this.$root.cmsContentByWidget?.[widgetName]?.[fieldName] ? this.$root.cmsContentByWidget[widgetName][fieldName] : '';
},
dispatchNonBlockingStoreAction(type, payload, encodePayload = true) {
dispatchStoreAction(type, payload, encodePayload = true) {
// Encode the payload if required
if (encodePayload) {
encodeUriData(payload);
@ -25,10 +27,10 @@ export default {
return store.dispatch(type, payload);
},
savePageDataToStore(page, data){
savePageDataToStore(page, data) {
store.commit(storeMutations.UPDATE_PAGE_DATA, { page: page, data: data });
},
onSubmit() {}, // DO NOT REMOVE; needed to prevent default form submit behavior
onSubmit() { }, // DO NOT REMOVE; needed to prevent default form submit behavior
onInvalidSubmit({ values, errors, results }) {
// identify the first error field and put focus on it
// get error names array
@ -54,6 +56,12 @@ export default {
vehicleCategories() {
return vehicleCategories;
},
routerParams() {
return routerParams;
},
queryStrings(){
return queryStrings;
}
},
};

View file

@ -11,7 +11,7 @@ describe("baseMixin.js", () => {
const type = "";
const payload = {};
mixIn.methods.dispatchNonBlockingStoreAction(type, payload);
mixIn.methods.dispatchStoreAction(type, payload);
expect(store.dispatch).toBeCalledWith(type, payload);
});
@ -21,7 +21,7 @@ describe("baseMixin.js", () => {
const type = "";
const payload = { make: "Alfa Romeo/Chrysler" };
mixIn.methods.dispatchNonBlockingStoreAction(type, payload, true);
mixIn.methods.dispatchStoreAction(type, payload, true);
expect(store.dispatch).toBeCalledWith(type, payload);
});

View file

@ -4,21 +4,25 @@ import { storeActions } from "@/constants/store-actions";
import { lazyLoadComponent } from "@/router/dynamic-routing/component-loader.js";
import { routingTable } from "@/router/router-constants/routing-table.js";
import { globalEvents, globalEventTypes } from "@/constants/events";
import { queryStrings } from "@/constants/query-strings";
import { getDeviceIdValue } from "@/helpers/heritage-integration/cookie-helper";
// Heritage integration
import { isSavedSessionStillActive } from "@/helpers/heritage-integration/session-helper";
import { updateOrCreateFunnelCookie,getFunnelCookie } from "@/helpers/heritage-integration/cookie-helper";
import { updateOrCreateFunnelCookie, getFunnelCookie } from "@/helpers/heritage-integration/cookie-helper";
import { loadOrderIfPresent, saveOrder } from "@/helpers/heritage-integration/order-helper";
import { getPageToRouteExistingOrderTo, navigateToHeritageFunnel} from "@/helpers/heritage-integration/navigation-helper";
import { getPageToRouteExistingOrderTo, navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper";
import baseMixin from "@/mixins/base-mixin";
import eventBus from "@/helpers/event-bus/event-bus";
import store from "@/store";
import analyticsMixin from "@/mixins/analytics-mixin";
// Components
import ComponentTest from "@/layouts/component-test/component-test.vue";
import FormTest from "@/layouts/form-test/form-test.vue";
const routes = [
{
path: "/component-test", // This is a temporary route for testing.
@ -35,84 +39,78 @@ const routes = [
name: "root",
async beforeEnter(to, from, next) {
// If we have no query string, or we don't have the FmgPage query string.
if (to.query.fmgPage === undefined) {
await GoToFunnelStartOn404(next);
} else {
try {
// If the saved session has timed out, clear the session, execute 404 logic.
if (getFunnelCookie() !== null && !isSavedSessionStillActive()) {
await GoToFunnelStartOn404(next);
}
// On entering the funnel "fresh", read cookie information, decide what to do next.
if (from.redirectedFrom === undefined) {
const loadOrderResponse = await loadOrderIfPresent();
const pageToRedirectTo = await getPageToRouteExistingOrderTo(to, loadOrderResponse);
// If getPageToRouteExistingOrderTo determines that the return user needs to
// go back to heritage funnel, send them there and stop our current navigation.
if (pageToRedirectTo === 'heritage') {
await navigateToHeritageFunnel();
return next(false);
}
// Assign our fmgPage so it will load normally like the other pages.
to.query.fmgPage = pageToRedirectTo;
}
// Process funnel cookie.
updateOrCreateFunnelCookie();
// If we already have our route, go to it.
if (router.hasRoute(to.query.fmgPage)) {
// Since our route is already in scope, we can grab the component from it and call the arePagePrerequisitesValid function.
let component = router.getRoutes().filter((x) => x.name === to.query.fmgPage)[0].components;
// If the component hasn't been loaded fully, load it before we check prerequisites.
if (component.default.methods === undefined) {
component = await component.default();
}
if (!arePagePrerequisitesValid(component)) {
await GoToFunnelStartOn404(next);
}
return next({ name: to.query.fmgPage, query: to.query, params: to.params });
}
// Get route info for the given url. Names will have a 1:1 relationship with names in the Cms.
const routeData = await GetRouteInfoFromPageName(to.query.fmgPage);
// Add our dynamic route.
router.addRoute({
path: routeData[0].path, // Always the same path, because we control it with query strings.
name: routeData[0].name,
component: routeData[0].component,
});
// Call the next components arePagePrerequisitesValid method before load.
// If it returns false, use the 404 logic.
const nextComponent = await router.getRoutes().filter((x) => x.name === routeData[0].name)[0].components.default();
if (!arePagePrerequisitesValid(nextComponent)) {
await GoToFunnelStartOn404(next);
}
// Assign current query string parameters, as well as our fmgPage one.
next({
name: routeData[0].name,
query: Object.assign(to.query, { fmgPage: routeData[0].name }),
params: to.params
});
} catch (error) {
console.log(error);
// If we don't have a route, go to our 404 page.
try {
// If the saved session has timed out, clear the session, execute 404 logic.
if (getFunnelCookie() !== null && !isSavedSessionStillActive()) {
await GoToFunnelStartOn404(next);
}
// On entering the funnel "fresh", read cookie information, decide what to do next.
if (from.redirectedFrom === undefined) {
const loadOrderResponse = await loadOrderIfPresent();
const pageToRedirectTo = await getPageToRouteExistingOrderTo(to, loadOrderResponse);
// If getPageToRouteExistingOrderTo determines that the return user needs to
// go back to heritage funnel, send them there and stop our current navigation.
if (pageToRedirectTo === 'heritage') {
await navigateToHeritageFunnel();
return next(false);
}
// Assign our fmgPage so it will load normally like the other pages.
to.query.fmgPage = pageToRedirectTo;
}
// Process funnel cookie.
updateOrCreateFunnelCookie();
// If we already have our route, go to it.
if (router.hasRoute(to.query.fmgPage)) {
// Since our route is already in scope, we can grab the component from it and call the arePagePrerequisitesValid function.
let component = router.getRoutes().filter((x) => x.name === to.query.fmgPage)[0].components;
// If the component hasn't been loaded fully, load it before we check prerequisites.
if (component.default.methods === undefined) {
component = await component.default();
}
if (!arePagePrerequisitesValid(component)) {
await GoToFunnelStartOn404(next);
}
return next({ name: to.query.fmgPage, query: to.query, params: to.params });
}
// Get route info for the given url. Names will have a 1:1 relationship with names in the Cms.
const routeData = await GetRouteInfoFromPageName(to.query.fmgPage);
// Add our dynamic route.
router.addRoute({
path: routeData[0].path, // Always the same path, because we control it with query strings.
name: routeData[0].name,
component: routeData[0].component,
});
// Call the next components arePagePrerequisitesValid method before load.
// If it returns false, use the 404 logic.
const nextComponent = await router.getRoutes().filter((x) => x.name === routeData[0].name)[0].components.default();
if (!arePagePrerequisitesValid(nextComponent)) {
await GoToFunnelStartOn404(next);
}
// Assign current query string parameters, as well as our fmgPage one.
next({
name: routeData[0].name,
query: Object.assign(to.query, { fmgPage: routeData[0].name }),
params: to.params
});
} catch (error) {
console.log(error);
// If we don't have a route, go to our 404 page.
await GoToFunnelStartOn404(next);
}
},
},
@ -125,6 +123,13 @@ const router = createRouter({
//---------------------------------------------------------- Router Functions ----------------------------------------------------------
router.afterEach(async (to, from) => {
// Push page view to GA
analyticsMixin.methods.pushPageViewToGA(to.query[queryStrings.FMG_PAGE]);
const assignedExperiments = await baseMixin.methods.dispatchStoreAction(storeActions.GET_EXPERIMENTS_BY_USER_FOR_GA, { userId: getDeviceIdValue() });
analyticsMixin.methods.pushExperimentsToDataLayer(assignedExperiments);
});
router.navigate = (scenario, currentRoute, optionalQuery = {}, optionalParams = {}, optionalPageData = {}) => {
navigate(scenario, currentRoute, false, optionalQuery, optionalParams, optionalPageData);
}
@ -205,6 +210,13 @@ function navigateToUrl(url, optionalQuery = {}) {
externalUrl.searchParams.append(queryKey, optionalQuery[queryKey]);
}
/////////////////////////////////////////////////////
// TEMP CODE FOR TESTING WITH SPECIFIC EXPERIMENTS //
/////////////////////////////////////////////////////
if (externalUrl.search.indexOf("corid=") != -1)
externalUrl.search = externalUrl.search + '&experiments=CollectEmailOnQuote=CollectEmailOnQuote_V1=YesCollectEmail_TEST1=true,RemoveServiceAreaPage=ServAreaRemoval_V7=ServAreaNoRemove_V7_TEST=true,VINeducationV2=VINeducation_V2=NoShowVINmodalV2_CONTROL=true,ServicePackages=ServicePackages_V1=NoShowPackages_CONTROL=true,PhotoUploadRedesign=PhotoUploadRedesign_V1=CurrentPhotoUpload_CONTROL=true,ScheduleDetailsServiceType=ScheduleBeforeServiceType_V1=ServTypeThenSched_CONTROL=true';
///////////// END TEMP CODE /////////////////////////
window.location.assign(externalUrl);
}

View file

@ -11,6 +11,7 @@ const fmgPageValues = {
LICENSE_PLATE_LOOKUP: "license-plate-lookup",
REVEAL: "reveal",
ESTIMATE: "estimate",
ADDRESS_VEHICLES: "address-vehicles",
};
export { fmgPageValues };

View file

@ -4,14 +4,18 @@ const navigationScenarios = {
SELECTED_MAKE: "SELECTED_MAKE",
SELECTED_STYLE: "SELECTED_STYLE",
CLICKED_BACK: "CLICKED_BACK",
CLICKED_BACK_WITH_VIN: "CLICKED_BACK_WITH_VIN",
CLICKED_FORWARD: "CLICKED_FORWARD",
CLICKED_FORWARD_WITH_VIN: "CLICKED_FORWARD_WITH_VIN",
SELECTED_PARTS: "SELECTED_PARTS",
SELECTED_DAMAGE_WITH_SINGLE_PART: "SELECTED_DAMAGE_WITH_SINGLE_PART",
SELECTED_DAMAGE_WITH_MULTIPLE_PARTS: "SELECTED_DAMAGE_WITH_MULTIPLE_PARTS",
SELECTED_DAMAGE_WITH_PART_QUESTIONS: "SELECTED_DAMAGE_WITH_PART_QUESTIONS",
CONTINUING_WITH_PARTS_QUESTION: "CONTINUING_WITH_PARTS_QUESTION",
CONTINUING_WITH_MULTIPLE_PARTS: "CONTINUING_WITH_MULTIPLE_PARTS",
CONTINUING_WITH_SINGLE_PART: "CONTINUING_WITH_SINGLE_PART"
CONTINUING_WITH_SINGLE_PART: "CONTINUING_WITH_SINGLE_PART",
CONTINUING_WITH_MULTIPLE_VEHICLES: "CONTINUING_WITH_MULTIPLE_VEHICLES",
TEMPORARY_TO_ADDRESS_LOOKUP: "TEMPORARY_TO_ADDRESS_LOOKUP",
};
export { navigationScenarios };

View file

@ -0,0 +1,5 @@
const routerParams = {
DISPLAY_VEHICLE_CHANGE_ALERT: "displayVehicleChangeAlert"
};
export { routerParams };

View file

@ -1,6 +1,5 @@
import { fmgPageValues } from "@/router/router-constants/fmgPage-values";
import { navigationScenarios } from "@/router/router-constants/navigation-scenarios";
import { applicationConfig } from "@/constants/application-config";
const routingTable = [
{
@ -58,9 +57,13 @@ const routingTable = [
scenario: navigationScenarios.CLICKED_BACK,
destinationFmgPageValue: fmgPageValues.VEHICLE_STYLE,
},
{
scenario: navigationScenarios.CLICKED_FORWARD_WITH_VIN,
destinationFmgPageValue: fmgPageValues.LICENSE_PLATE_LOOKUP,
},
{
scenario: navigationScenarios.SELECTED_DAMAGE_WITH_SINGLE_PART,
destinationFmgPageValue: fmgPageValues.REVEAL
destinationFmgPageValue: fmgPageValues.LICENSE_PLATE_LOOKUP,
},
{
scenario: navigationScenarios.SELECTED_DAMAGE_WITH_MULTIPLE_PARTS,
@ -68,7 +71,11 @@ const routingTable = [
},
{
scenario: navigationScenarios.SELECTED_DAMAGE_WITH_PART_QUESTIONS,
destinationFmgPageValue: fmgPageValues.PART_QUESTIONS,
destinationFmgPageValue: fmgPageValues.LICENSE_PLATE_LOOKUP,//This might be temporary
},
{
scenario: navigationScenarios.TEMPORARY_TO_ADDRESS_LOOKUP,
destinationFmgPageValue: fmgPageValues.ADDRESS_LOOKUP, //This will be temporary
},
],
},
@ -107,6 +114,10 @@ const routingTable = [
maps: [
{
scenario: navigationScenarios.CLICKED_BACK,
destinationFmgPageValue: fmgPageValues.REVEAL,
},
{
scenario: navigationScenarios.CLICKED_BACK_WITH_VIN,
destinationFmgPageValue: fmgPageValues.VEHICLE_DAMAGE,
},
{
@ -134,8 +145,29 @@ const routingTable = [
scenario: navigationScenarios.CONTINUING_WITH_SINGLE_PART,
destinationFmgPageValue: fmgPageValues.REVEAL,
},
{
scenario: navigationScenarios.CLICKED_FORWARD,
destinationFmgPageValue: fmgPageValues.VEHICLE_DAMAGE,
},
],
},
{
fmgPageValue: fmgPageValues.ADDRESS_LOOKUP,
maps: [
{
scenario: navigationScenarios.CLICKED_BACK,
destinationFmgPageValue: fmgPageValues.ESTIMATE,
},
{
scenario: navigationScenarios.CONTINUING_WITH_MULTIPLE_VEHICLES,
destinationFmgPageValue: fmgPageValues.ADDRESS_VEHICLES,
},
{
scenario: navigationScenarios.CLICKED_FORWARD,
destinationFmgPageValue: fmgPageValues.VEHICLE_DAMAGE,
},
],
},
];
export { routingTable };

View file

@ -45,10 +45,16 @@ const getDefaultState = () => {
glassParts: null,
otherParts: null
},
payment:{
isInsurance: null,
insuranceCoverage: {
isVerified: null
}
},
referralNumber: null,
referralDate: null,
referralCorrelationId: null,
parentAccountNumber: null,
accountNumber: 0,
},
applicationUser: {
eventBus: [],
@ -118,7 +124,40 @@ export const mutations = {
state.order.referralDate = referralDate;
},
updateParentAcctNumber(state, parentAcctNumber) {
state.order.parentAccountNumber = parentAcctNumber;
state.order.accountNumber = parentAcctNumber;
},
updateIsInsurance(state, isInsurance) {
state.order.payment.isInsurance = isInsurance;
},
updateInsuranceVerifiedStatus(state, isVerified) {
state.order.payment.insuranceCoverage.isVerified = isVerified;
},
updateRegistrationLicensePlate(state, licensePlate){
state.order.vehicle.registration.licensePlate = licensePlate;
},
updateRegistrationState(state, registrationState){
state.order.vehicle.registration.state = registrationState;
},
updateRegistrationZipCode(state, registrationZipCode){
state.order.vehicle.registration.zipCode = registrationZipCode;
},
updateRegistrationAddress(state, registrationAddress){
state.order.vehicle.registration.address = registrationAddress;
},
updateServiceLocationZip(state, serviceLocationZip){
state.order.serviceLocation.zip = serviceLocationZip;
},
updateRegistrationCity(state, registrationCity){
state.order.vehicle.registration.city = registrationCity;
},
updateRegistrationFirstName(state, firstName){
state.order.vehicle.registration.firstName = firstName;
},
updateRegistrationLastName(state, lastName){
state.order.vehicle.registration.lastName = lastName;
},
updateCustomerEmailAddress(state, customerEmailAddress){
state.order.customer.emailAddress = customerEmailAddress;
},
@ -176,23 +215,30 @@ export const mutations = {
state.order.referralNumber = orderInformation.referralNumber;
state.order.referralDate = orderInformation.referralDate;
state.order.referralCorrelationId = orderInformation.referralCorrelationId;
state.order.vehicle = Object.assign(state.order.vehicle, {
year: orderInformation.vehicle?.year,
make: orderInformation.vehicle?.make,
model: orderInformation.vehicle?.model,
style: orderInformation.vehicle?.style,
vin: orderInformation.vehicle?.vin,
carId: orderInformation.vehicle?.carId,
category: orderInformation.vehicle?.category,
imageUrl: orderInformation.vehicle?.imageUrl,
imageVifNumber: orderInformation.vehicle?.imageVifNumber,
imageColor: orderInformation.vehicle?.imageVifColor
imageColor: orderInformation.vehicle?.imageVifColor,
});
state.order.damage.glassToReplace = orderInformation.glassToReplace;
state.order.damage.isRepair = orderInformation.isRepair;
state.order.damage.numberOfChips = orderInformation.numberOfChips;
state.order.lineItems.glassParts = orderInformation.parts;
state.order.parentAccountNumber = orderInformation.parentAccountNumber;
state.order.serviceLocation.zipCode = orderInformation.zipCode; // TODO CSR-416 Make sure this is correct
state.order.accountNumber = orderInformation.accountNumber;
state.order.serviceLocation.zipCode = orderInformation.zipCode;
state.order.payment.isInsurance = orderInformation.IsInsuranceOrder;
state.order.payment.insuranceCoverage.isVerified = orderInformation?.insuranceInfo.coverageVerified;
}
}
@ -210,11 +256,10 @@ export const getters = {
eventBus: (state) => state.applicationUser.eventBus,
damage: (state) => state.order.damage,
lineItems: (state) => state.order.lineItems,
pageData: (state) => (page) => {
return state.applicationUser.pageData[page];
},
pageData: (state) => (page) => { return state.applicationUser.pageData[page]; },
applicationUser: (state) => state.applicationUser,
order: (state) => state.order,
payment: (state) => state.order.payment,
}
// Export Actions
@ -253,6 +298,18 @@ export const actions = {
},
});
},
lookupVinByAddress(context, { licenseLastName, licenseStreetAddress, licenseZip, licenseState }) {
return globalMethods.callHttpClient({
method: endpoints.LookupVinByAddress.method,
endpoint: endpoints.LookupVinByAddress.url,
payload: {
licenseLastName: licenseLastName,
licenseStreetAddress: licenseStreetAddress,
licenseZip: licenseZip,
licenseState: licenseState
},
});
},
getVehicleMakes(context, { year }) {
return globalMethods.callHttpClient({
method: endpoints.GetVehicleMakes.method,
@ -363,22 +420,54 @@ export const actions = {
context.commit(storeMutations.UPDATE_REFERRAL_CORRELATION_ID, referralCorrelationId);
},
logExperimentExposure(context, { userId, sessionKey, sessionId, pageName, serverName, pageEvent, universeName }) {
logExperimentExposure(context, { userId, sessionKey, pageName, universeName }) {
return globalMethods.callHttpClient({
method: endpoints.LogExperimentExposureIfAssigned.method,
endpoint: endpoints.LogExperimentExposureIfAssigned.url,
payload: {
userId: userId,
sessionKey: sessionKey,
sessionId: sessionId,
pageName: pageName,
serverName: serverName,
pageEvent: pageEvent,
universeName: universeName
}
});
},
logActivity(context, { userId, sessionKey, pageName, sessionId, pageEvent, customEvent, shouldUseSessionId }) {
var payload = {
userId: userId,
sessionKey: sessionKey,
sessionId: sessionId,
pageName: pageName,
applicationName: 'SafeliteDotCom',
shouldUseSessionId: shouldUseSessionId
};
if (typeof pageEvent !== 'undefined') {
payload.pageEvent = { action: pageEvent.action, event: pageEvent.event};
}
if (typeof customEvent !== 'undefined') {
payload.customEvents = [{category: customEvent.category, action: customEvent.action, label: customEvent.label, value: customEvent.value}];
}
return globalMethods.callHttpClient({
method: endpoints.LogActivity.method,
endpoint: endpoints.LogActivity.url,
payload: payload,
logApiCall: false
});
},
getExperimentsByUserForGa(context, { userId }){
return globalMethods.callHttpClient({
method: endpoints.GetExperimentsByUserForGa.method,
endpoint: `${endpoints.GetExperimentsByUserForGa.url}/${userId}`,
payload: {}
});
},
// Parts API Actions
getPartsOrQuestions(context, { carId, glassArray, zipCode, vin = '' }) {
return globalMethods.callHttpClient({
@ -408,24 +497,27 @@ export const actions = {
make: vehicle.make,
model: vehicle.model,
style: vehicle.style,
vin: vehicle.vin
},
numberOfChips: damage.numberOfChips,
zipCode: 43215, // TODO CSR-416, should not be hardcoded (state.order.serviceLocation.zipCode)
glassToReplace: damage.glassToReplace,
referralNumber: context.state.order.referralNumber,
referralDate: context.state.order.referralDate
referralDate: context.state.order.referralDate,
accountNumber: context.state.order.accountNumber
},
});
},
loadOrder(context, { referralNumber, referralDate, referralCorrelationId }) {
loadOrder(context, { referralNumber, referralDate, referralCorrelationId, accountNumber}) {
return globalMethods.callHttpClient({
method: endpoints.LoadOrder.method,
endpoint: endpoints.LoadOrder.url,
payload: {
referralNumber: referralNumber,
referralDate: referralDate,
referralCorrelationId: referralCorrelationId
referralCorrelationId: referralCorrelationId,
accountNumber: accountNumber
},
}).then((response) => {
context.commit(storeMutations.RESET_STATE);

View file

@ -221,7 +221,8 @@ describe("Mutations", () => {
isRepair: false,
numberOfChips: 0,
parts: [],
parentAccountNumber: "123456789",
accountNumber: "123456789",
insuranceInfo: {}
});
// Assert
@ -232,6 +233,17 @@ describe("Mutations", () => {
expect(storeState.order.vehicle.model).toEqual("ILX");
});
it("updateInsuranceVerifiedStatus, should set isVerified flag", () => {
// Arrange
const storeState = state;
// Act
mutations.updateInsuranceVerifiedStatus(storeState, true);
// Assert
expect(storeState.order.payment.insuranceCoverage.isVerified).toEqual(true);
});
});
describe("Actions", () => {
@ -605,6 +617,32 @@ describe("Actions", () => {
expect(commit).toBeCalledWith(storeMutations.UPDATE_REFERRAL_CORRELATION_ID, "xxx-xxx-xxx");
});
it("logActivity action, should return nothing", async () => {
// Arrange
const context = state;
var pageEvent = {
action: "",
event: "ENTRY",
}
var customEvent = [{
category: "tstCat",
action: "click",
label: "damage",
value: "psych"
}];
// Act
globalMethods.callHttpClient.mockImplementation(() => {
return Promise.resolve({ });
});
// Assert
const response = await actions.logActivity(context, { userId: "userId", sessionKey: "sessionKey", pageName: "pageName", sessionId: "sessionId", pageEvent: pageEvent, customEvent: customEvent, shouldUseSessionId: true });
expect(response).toEqual({});
});
});
describe("Getters", () => {
@ -690,4 +728,15 @@ describe("Getters", () => {
});
it("Payment getter, should return payment data", () => {
// Arrange
const storeState = state;
//Act
mutations.updateInsuranceVerifiedStatus(storeState, true );
//Assert
expect(getters.payment(storeState).insuranceCoverage.isVerified).toEqual(true);
});
});

View file

@ -1,82 +1,126 @@
.has-error {
&.list-button,
&.list-card {
border: 1px solid $red;
color: $red;
input[type=checkbox]:focus + label,
input[type=radio]:focus + label {
box-shadow: 0 0 0 2.5px $red;
}
&:hover {
box-shadow: 0px 0px 0px 4px $red-200;
border-radius: 10px !important;
}
}
&.list-button-horizontal {
color: $red;
label {
html {
.has-error {
&.list-button,
&.list-card {
border: 1px solid $red;
color: $red;
input[type=checkbox]:focus + label,
input[type=radio]:focus + label {
box-shadow: 0 0 0 2.5px $red;
}
input[type=checkbox]:checked + label {
box-shadow: 0 0 0 1px $red;
}
&:hover {
box-shadow: 0px 0px 0px 4px $red-200;
border-radius: 10px;
}
}
input[type=checkbox]:focus + label,
input[type=radio]:focus + label {
box-shadow: 0 0 1px $red !important;
}
}
&.ui-radio,
&.ui-checkbox {
input[type=checkbox],
input[type=radio],
input[type=radio]+label:before,
input[type=checkbox]+label:before {
border: 1px solid $red;
}
input[type=checkbox]:checked + label:before {
border: 1px solid $blue;
}
}
&.textbox-question,
&.dropdown-question {
p {
&.list-button-horizontal {
color: $red;
}
input,
select {
border: 1px solid $red;
&:focus {
border: 1px solid transparent;
label {
border: 1px solid $red;
&:hover {
box-shadow: 0px 0px 0px 4px $red-200;
}
}
input[type=checkbox]:focus + label,
input[type=radio]:focus + label {
box-shadow: 0 0 1px $red;
}
}
select {
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 8.89' xml:space='preserve'%3e%3cpath d='M8 8.89c-.24 0-.46-.09-.63-.26L.26 1.53a.901.901 0 0 1 0-1.27C.43.1.66 0 .9 0s.47.1.64.26L8 6.74 14.47.27c.17-.17.4-.27.64-.27s.47.1.63.27c.17.17.26.4.26.64s-.1.47-.27.63l-7.1 7.09a.86.86 0 0 1-.63.26z' fill='%23d4281c'/%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 0.75rem center;
background-size: 16px 12px;
&.ui-radio,
&.ui-checkbox {
input[type=checkbox],
input[type=radio],
input[type=radio]+label:before,
input[type=checkbox]+label:before {
border: 1px solid $red;
}
input[type=checkbox]:checked + label:before {
border: 1px solid $blue;
}
}
&.textbox-question,
&.dropdown-question {
p {
color: $red;
}
input,
select {
border: 1px solid $red;
&:focus {
border: 1px solid transparent;
}
}
select {
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 8.89' xml:space='preserve'%3e%3cpath d='M8 8.89c-.24 0-.46-.09-.63-.26L.26 1.53a.901.901 0 0 1 0-1.27C.43.1.66 0 .9 0s.47.1.64.26L8 6.74 14.47.27c.17-.17.4-.27.64-.27s.47.1.63.27c.17.17.26.4.26.64s-.1.47-.27.63l-7.1 7.09a.86.86 0 0 1-.63.26z' fill='%23d4281c'/%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 0.75rem center;
background-size: 16px 12px;
}
}
}
//Restore to default style if alert box is present
.alertError {
.has-error {
&.list-button,
&.list-card {
border: 1px solid $gray-500;
input:not(:focus) {
+ label {
box-shadow: 0 0 0 1px $gray-500;
border-radius: .5rem;
}
}
input:checked:focus {
+ label {
box-shadow: 0 0 0 2.5px $blue;
border-radius: .5rem;
}
}
input:checked:not(:focus) {
+ label {
box-shadow: 0 0 0 1px $blue;
border-radius: .5rem;
}
}
input:focus {
border: 1px solid $gray-500;
+ label {
box-shadow: 0 0 0 2.5px transparent;
}
}
&:hover {
box-shadow: 0 0 0 4px $blue-300;
+ label {
box-shadow: 0 0 0 2.5px transparent;
border: 1px solid $blue;
}
}
}
}
}
.form-test-error {
color: $red;
font-size: .875rem;
font-weight: 500;
}
.form-test-invalid {
&.btn.btn-primary {
color: $gray;
background: $gray-200;
cursor: pointer;
pointer-events: all;
}
&.btn.btn-primary:hover,
&.btn.btn-primary:focus,
&.btn.btn-primary:focus-visible {
color: $gray !important;
background: $gray-200;
box-shadow: none;
}
}
}
.form-test-error {
color: $red;
font-size: .875rem;
font-weight: 500;
}
.form-test-invalid {
&.btn.btn-primary {
color: $gray;
background: $gray-200;
cursor: pointer;
pointer-events: all;
}
&.btn.btn-primary:hover,
&.btn.btn-primary:focus,
&.btn.btn-primary:focus-visible {
color: $gray !important;
background: $gray-200 !important;
box-shadow: none !important;
}
}

View file

@ -1,4 +1,4 @@
import { shallowMount } from "@vue/test-utils";
import { shallowMount } from "@vue/test-utils";
import alert from "./alert";
describe("alert.vue", () => {
@ -9,6 +9,13 @@ describe("alert.vue", () => {
propsData: {
isDismissible: true
},
computed: {
splitAlertCopyForLink: {
get() {
return "TEST";
},
}
},
mixins: [mockMixin]
});
@ -24,6 +31,13 @@ describe("alert.vue", () => {
propsData: {
alertClass: 'warning'
},
computed: {
splitAlertCopyForLink: {
get() {
return "TEST";
},
}
},
mixins: [mockMixin]
});

View file

@ -5,7 +5,15 @@
:class="[isDismissible ? 'alert-dismissible' : '', this.alertClass]"
>
<p class="m-0 fw-bold small alert-heading">{{ alertHeadline }}</p>
<p class="m-0 text-body small" v-html="alertCopy"></p>
<p v-if="splitAlertCopyForLink.length">
<template v-for="copy in splitAlertCopyForLink" :key="copy">
<span v-if="copy.includes('routerLink:')" class="m-0 text-body small">
<router-link :to="{query: {fmgPage: `${copy.split(':')[1].split(',')[0]}`}, name: 'root'}">{{ copy.split(':')[1].split(',')[1] }}</router-link>
</span>
<span v-else class="m-0 text-body small" v-html="copy"></span>
</template>
</p>
<p v-else class="m-0 text-body small" v-html="alertCopy"></p>
<button
type="button"
class="btn-close p-2"
@ -26,6 +34,7 @@
</template>
<script>
export default {
name: "alert",
props: {
@ -49,6 +58,10 @@ export default {
alertCopy(){
return this.cmsWidgetName ? this.getCmsContent(this.cmsWidgetName, 'BodyText') : this.manualCopy;
},
splitAlertCopyForLink(){
// Splits content when brackets are found in text so that text can be looped through and router-link can be injected when needed
return this.alertCopy.split(/{(.*?)}/g);
}
},
};
</script>
@ -115,5 +128,9 @@ export default {
height: 1rem;
}
}
& p {
font-size: 14px;
margin-bottom: 0px;
}
}
</style>

View file

@ -1,15 +1,16 @@
import { shallowMount } from "@vue/test-utils";
import buttonMain from "./button-main";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import { nextTick } from "vue";
describe("buttonMain.vue", () => {
it("Should return btn-primary class", async () => {
// Act
const wrapper = shallowMount(buttonMain, {
const wrapper = shallowMount(buttonMain, setupMocks({
propsData: {
isPrimary: true,
},
});
}));
// Assert
const button = wrapper.find("button");
@ -20,11 +21,11 @@ describe("buttonMain.vue", () => {
it("Should return aria-disabled state", async () => {
// Act
const wrapper = shallowMount(buttonMain, {
const wrapper = shallowMount(buttonMain, setupMocks({
propsData: {
isDisabled: true,
},
});
}));
// Assert
const button = wrapper.find("button");
@ -35,12 +36,12 @@ describe("buttonMain.vue", () => {
it("Should return loader color", async () => {
// Act
const wrapper = shallowMount(buttonMain, {
const wrapper = shallowMount(buttonMain, setupMocks({
propsData: {
loaderColor: "blue",
loaderEnabled: true,
},
});
}));
// Assert
@ -57,12 +58,12 @@ describe("buttonMain.vue", () => {
it("Should return loader position", async () => {
// Act
const wrapper = shallowMount(buttonMain, {
const wrapper = shallowMount(buttonMain, setupMocks({
propsData: {
loaderPosition: "right",
loaderEnabled: true,
},
});
}));
// Assert
@ -77,3 +78,11 @@ describe("buttonMain.vue", () => {
expect(loader.attributes("class")).toContain("right");
});
});
function setupMocks(mountOptionsMockData = {}) {
const defaultMountOptions = { route: { query: { fmgPage: 'page-name' } } };
const baseMountOptions = getMountOptions(Object.assign(defaultMountOptions, mountOptionsMockData));
const allMountOptions = Object.assign(defaultMountOptions, baseMountOptions);
return allMountOptions;
}

View file

@ -16,6 +16,7 @@
<script>
import loader from "@/ux-components/loader/loader";
export default {
name: "buttonMain",
props: {
@ -36,6 +37,7 @@ export default {
this.isLoaderDisplayed = false;
},
clicked() {
this.pushEventToGA(this.$route.query[this.queryStrings.FMG_PAGE], this.GaActions.CLICKED, this.buttonText, true);
if (!this.isDisabled) {
this.isLoaderDisplayed = true;
this.$emit("click-event");

View file

@ -100,7 +100,7 @@ describe("list-button-horizontal.vue", () => {
const label = wrapper.find("label");
wrapper.vm.handleCheckChange = jest.fn();
wrapper.vm.handleClick();
wrapper.vm.triggerButton();
await nextTick();
@ -123,7 +123,7 @@ describe("list-button-horizontal.vue", () => {
const label = wrapper.find("label");
wrapper.vm.handleCheckChange = jest.fn();
wrapper.vm.handleClick();
wrapper.vm.triggerButton();
await nextTick();
@ -146,7 +146,7 @@ describe("list-button-horizontal.vue", () => {
const label = wrapper.find("label");
wrapper.vm.handleCheckChange = jest.fn();
wrapper.vm.handleClick();
wrapper.vm.triggerButton();
await nextTick();
@ -195,4 +195,54 @@ describe("list-button-horizontal.vue", () => {
// Assert
expect(wrapper.componentVM.checkValue).toEqual("Car-Front");
});
it("Should run handleCheckChange if selectingInitiatesLoad is false and handleInputChange is triggered", async () => {
// Act
const wrapper = shallowMount(listButtonHorizontal, {
propsData: {
selectingInitiatesLoad: false,
},
});
// Assert
wrapper.vm.handleInputChange();
await nextTick();
expect(wrapper.vm.handleCheckChange).toBeCalled;
});
it("Should do nothing if isMultiSelect is true and handleKeyupArrow is triggered", async () => {
// Act
const wrapper = shallowMount(listButtonHorizontal, {
propsData: {
isMultiSelect: true,
},
});
// Assert
wrapper.vm.handleKeyupArrow();
await nextTick();
expect(wrapper.vm.handleKeyupArrow).toHaveReturned;
});
it("Should run handleCheckChange if selectingInitiatesLoad is false and handleKeyupArrow is triggered", async () => {
// Act
const wrapper = shallowMount(listButtonHorizontal, {
propsData: {
selectingInitiatesLoad: false,
isMultiSelect: false,
},
});
// Assert
wrapper.vm.handleKeyupArrow();
await nextTick();
expect(wrapper.vm.handleCheckChange).toBeCalled;
});
});

View file

@ -2,8 +2,11 @@
<div
class="list-group list-button-horizontal d-flex flex-column w-100 mb-2"
:class="[(errors.length > 0 || hasError) ? 'has-error' : '']"
@mouseup="handleClick(value)"
@keyup.space="handleClick(value)"
@keyup.space="triggerButton()"
@keyup.up="handleKeyupArrow()"
@keyup.down="handleKeyupArrow()"
@keyup.left="handleKeyupArrow()"
@keyup.right="handleKeyupArrow()"
>
<input
:type="isMultiSelect ? 'checkbox' : 'radio'"
@ -12,13 +15,14 @@
:value="value"
:aria-required="isRequired"
v-model="checkValue"
@change="!selectingInitiatesLoad ? handleCheckChange() : ''"
@change="handleInputChange()"
/>
<label
tabindex="-1"
:for="buttonID"
:aria-labelledby="buttonID"
class="d-flex flex-column justify-content-center py-3 px-4"
@mouseup="triggerButton()"
>
<span
class="m-0"
@ -31,13 +35,16 @@
class="m-0 small"
:class="textPosition"
>
{{buttonLabelSubCopy}}
{{ buttonLabelSubCopy }}
</span>
<span v-if="screenReaderOnlyText" class="sr-only">
{{screenReaderOnlyText}}
<span
v-if="screenReaderOnlyText"
class="sr-only"
>
{{ screenReaderOnlyText }}
</span>
<loader
v-if="isLoaderDisplayed && !isMultiSelect"
v-if="isLoaderDisplayed && selectingInitiatesLoad"
:class="[loaderColor, loaderPosition]"
/>
</label>
@ -77,27 +84,47 @@ export default {
checkValue: Boolean,
};
},
created(){
if(Array.isArray(this.selectedValues)){
this.checkValue = this.isMultiSelect ? this.selectedValues.includes(this.value) : this.selectedValues[0];
created() {
if (Array.isArray(this.selectedValues)) {
this.checkValue = this.isMultiSelect
? this.selectedValues.includes(this.value)
: this.selectedValues[0];
}
},
methods: {
displayLoader() {
this.isLoaderDisplayed = true;
},
handleClick(value) {
if(this.selectingInitiatesLoad) {
this.displayLoader();
handleInputChange() {
if(!this.selectingInitiatesLoad) {
this.handleCheckChange();
}
},
handleKeyupArrow() {
if (this.isMultiSelect) {
return; // Prevent arrow keys from doing anything if element is a checkbox
}
if(!this.selectingInitiatesLoad) {
this.handleCheckChange();
}
this.handleChange(value);
this.handleChange(this.value);
},
handleCheckChange(newValue, oldValue){
const isInitialization = typeof(oldValue) === 'function';
if (!isInitialization) {
this.$emit('isCheckedChanged', { checkValue: this.checkValue, value: this.value.toString() });
triggerButton() {
if(this.selectingInitiatesLoad) {
this.displayLoader();
this.handleCheckChange();
}
this.handleChange(this.value);
},
handleCheckChange() {
const emitEvent = {
checkValue: this.checkValue, // only read on checkboxes, on handleCheckedChanged on button-question
value: this.value.toString(),
buttonId: this.buttonID && this.buttonID.toString(),
};
this.$emit("isCheckedChanged", emitEvent);
this.$emit("update:modelValue", emitEvent);
}
},
components: {
@ -105,6 +132,7 @@ export default {
},
setup(props) {
const inputType = props.isMultiSelect ? "checkbox" : "radio";
const fieldOptions = {
type: inputType,
checkedValue: props.value,
@ -118,13 +146,11 @@ export default {
}
const {
checked,
handleChange,
errors,
} = useField(props.groupName, props.validationRules, fieldOptions);
return {
checked,
handleChange,
errors,
fieldOptions, // only need to expose this for unit test purposes
@ -137,10 +163,11 @@ export default {
.list-button-horizontal {
input[type="radio"],
input[type="checkbox"] {
position: absolute;
height: 0;
opacity: 0;
width: 0;
height: 0;
position: absolute;
&:focus-visible + label {
box-shadow: 0 0 0 2.5px $blue;
z-index: 2;

View file

@ -96,11 +96,8 @@ describe("list-button.vue", () => {
});
// Assert
const label = wrapper.find("label");
wrapper.vm.handleCheckChange = jest.fn();
wrapper.vm.handleClick();
wrapper.vm.triggerButton();
await nextTick();
@ -119,10 +116,8 @@ describe("list-button.vue", () => {
});
// Assert
const label = wrapper.find("label");
wrapper.vm.handleCheckChange = jest.fn();
wrapper.vm.handleClick();
wrapper.vm.triggerButton();
await nextTick();
const loader = wrapper.find("loader-stub");
@ -140,11 +135,8 @@ describe("list-button.vue", () => {
});
// Assert
const label = wrapper.find("label");
wrapper.vm.handleCheckChange = jest.fn();
wrapper.vm.handleClick();
wrapper.vm.triggerButton();
await nextTick();
@ -197,4 +189,53 @@ describe("list-button.vue", () => {
expect(wrapper.componentVM.checkValue).toEqual("Car-Front");
});
it("Should run handleCheckChange if selectingInitiatesLoad is false and handleInputChange is triggered", async () => {
// Act
const wrapper = shallowMount(listButton, {
propsData: {
selectingInitiatesLoad: false,
},
});
// Assert
wrapper.vm.handleInputChange();
await nextTick();
expect(wrapper.vm.handleCheckChange).toBeCalled;
});
it("Should do nothing if isMultiSelect is true and handleKeyupArrow is triggered", async () => {
// Act
const wrapper = shallowMount(listButton, {
propsData: {
isMultiSelect: true,
},
});
// Assert
wrapper.vm.handleKeyupArrow();
await nextTick();
expect(wrapper.vm.handleKeyupArrow).toHaveReturned;
});
it("Should run handleCheckChange if selectingInitiatesLoad is false and handleKeyupArrow is triggered", async () => {
// Act
const wrapper = shallowMount(listButton, {
propsData: {
selectingInitiatesLoad: false,
isMultiSelect: false,
},
});
// Assert
wrapper.vm.handleKeyupArrow();
await nextTick();
expect(wrapper.vm.handleCheckChange).toBeCalled;
});
});

View file

@ -2,8 +2,11 @@
<div
class="list-group list-button d-flex flex-column w-100 mb-2"
:class="[(errors.length > 0 || hasError) ? 'has-error' : '']"
@mouseup="handleClick(value)"
@keyup.space="handleClick(value)"
@keyup.space="triggerButton()"
@keyup.up="handleKeyupArrow()"
@keyup.down="handleKeyupArrow()"
@keyup.left="handleKeyupArrow()"
@keyup.right="handleKeyupArrow()"
>
<input
:type="isMultiSelect ? 'checkbox' : 'radio'"
@ -12,13 +15,14 @@
:value="value"
:aria-required="isRequired"
v-model="checkValue"
@change="!selectingInitiatesLoad ? handleCheckChange() : ''"
@change="handleInputChange()"
>
<label
tabindex="-1"
:for="buttonID"
:aria-labelledby="buttonID"
class="d-flex flex-column justify-content-center py-3 px-4"
@mouseup="triggerButton()"
>
<span
class="m-0"
@ -40,7 +44,7 @@
{{ screenReaderOnlyText }}
</span>
<loader
v-if="isLoaderDisplayed && !isMultiSelect"
v-if="isLoaderDisplayed && selectingInitiatesLoad"
:class="[this.loaderColor, this.loaderPosition]"
/>
</label>
@ -80,33 +84,47 @@ export default {
checkValue: Boolean,
};
},
created(){
if(Array.isArray(this.selectedValues)){
this.checkValue = this.isMultiSelect ? this.selectedValues.includes(this.value) : this.selectedValues[0];
created() {
if (Array.isArray(this.selectedValues)) {
this.checkValue = this.isMultiSelect
? this.selectedValues.includes(this.value)
: this.selectedValues[0];
}
},
methods: {
displayLoader() {
this.isLoaderDisplayed = true;
},
handleClick(value) {
handleInputChange() {
if(!this.selectingInitiatesLoad) {
this.handleCheckChange();
}
},
handleKeyupArrow() {
if (this.isMultiSelect) {
return; // Prevent arrow keys from doing anything if element is a checkbox
}
if(!this.selectingInitiatesLoad) {
this.handleCheckChange();
}
this.handleChange(this.value);
},
triggerButton() {
if(this.selectingInitiatesLoad) {
this.displayLoader();
this.handleCheckChange();
}
this.handleChange(value);
this.handleChange(this.value);
},
handleCheckChange(value, oldValue){
const isInitialization = typeof(oldValue) === 'function';
if (!isInitialization) {
const emitEvent = {
checkValue: this.checkValue,
value: this.value.toString(),
buttonId: this.buttonID.toString(),
};
this.$emit('isCheckedChanged', emitEvent);
this.$emit("update:modelValue", emitEvent);
}
handleCheckChange() {
const emitEvent = {
checkValue: this.checkValue, // only read on checkboxes, on handleCheckedChanged on button-question
value: this.value.toString(),
buttonId: this.buttonID && this.buttonID.toString(),
};
this.$emit("isCheckedChanged", emitEvent);
this.$emit("update:modelValue", emitEvent);
},
},
components: {
@ -128,13 +146,11 @@ export default {
}
const {
checked,
handleChange,
errors,
} = useField(props.groupName, props.validationRules, fieldOptions);
return {
checked,
handleChange,
errors,
fieldOptions, // only need to expose this for unit test purposes
@ -154,10 +170,10 @@ export default {
opacity: 0;
&:focus-visible + label {
box-shadow: 0 0 0 2.5px $blue inset;
box-shadow: 0 0 0 2.5px $blue;
}
&:focus + label {
box-shadow: 0 0 0 2.5px $blue inset;
box-shadow: 0 0 0 2.5px $blue;
}
&:checked + label {
color: $black;
@ -165,6 +181,9 @@ export default {
background: $blue-100;
box-shadow: 0 0 0 1px $blue;
}
&:checked:focus + label {
box-shadow: 0 0 0 2.5px $blue;
}
&:checked + label p,
&:checked + label span {
font-weight: 500;

View file

@ -1,5 +1,6 @@
import { shallowMount } from "@vue/test-utils";
import listCard from "./list-card";
import { nextTick } from "vue";
describe("list-card.vue", () => {
it("Should return input type checkbox if isMultiSelect is true", async () => {
@ -18,7 +19,6 @@ describe("list-card.vue", () => {
// Assert
const input = wrapper.find("input");
expect(input.attributes().type).toEqual("checkbox");
});
@ -38,7 +38,6 @@ describe("list-card.vue", () => {
// Assert
const paragraph = wrapper.find("p");
expect(paragraph.text()).toEqual("Windshield");
});
@ -59,7 +58,6 @@ describe("list-card.vue", () => {
// Assert
const paragraph = wrapper.find("p:nth-of-type(2)");
expect(paragraph.text()).toEqual("Test");
});
@ -80,7 +78,6 @@ describe("list-card.vue", () => {
// Assert
const label = wrapper.find("label");
expect(label.attributes().for).toEqual("List Card Checkbox");
});
@ -101,7 +98,6 @@ describe("list-card.vue", () => {
// Assert
const input = wrapper.find("input");
expect(input.attributes().name).toEqual("radio 1");
});
@ -122,7 +118,6 @@ describe("list-card.vue", () => {
// Assert
const input = wrapper.find("input");
expect(input.attributes()["aria-required"]).toEqual("true");
});
@ -247,5 +242,88 @@ describe("list-card.vue", () => {
expect(wrapper.vm.fieldOptions.initialValue).toEqual([ 'Windshield' ]);
});
it("Should run handleCheckChange if selectingInitiatesLoad is false and handleInputChange is triggered", async () => {
// Act
const wrapper = shallowMount(listCard, {
propsData: {
selectingInitiatesLoad: false,
},
});
// Assert
wrapper.vm.handleInputChange();
await nextTick();
expect(wrapper.vm.handleCheckChange).toBeCalled;
});
it("Should do nothing if isMultiSelect is true and handleKeyupArrow is triggered", async () => {
// Act
const wrapper = shallowMount(listCard, {
propsData: {
isMultiSelect: true,
},
});
// Assert
wrapper.vm.handleKeyupArrow();
await nextTick();
expect(wrapper.vm.handleKeyupArrow).toHaveReturned;
});
it("Should run handleCheckChange if selectingInitiatesLoad is false and handleKeyupArrow is triggered", async () => {
// Act
const wrapper = shallowMount(listCard, {
propsData: {
selectingInitiatesLoad: false,
isMultiSelect: false,
},
});
// Assert
wrapper.vm.handleKeyupArrow();
await nextTick();
expect(wrapper.vm.handleCheckChange).toBeCalled;
});
it("Should run handleChange if triggerButton is triggered", async () => {
// Act
const wrapper = shallowMount(listCard, {
propsData: {
selectingInitiatesLoad: false,
},
});
// Assert
wrapper.vm.triggerButton();
await nextTick();
expect(wrapper.vm.handleChange).toBeCalled;
expect(wrapper.vm.handleCheckChange).not.toBeCalled;
expect(wrapper.vm.displayLoader).not.toBeCalled;
});
it("Should run handleCheckChange and displayLoader if triggerButton is triggered and seletingInitiatesLoad is true", async () => {
// Act
const wrapper = shallowMount(listCard, {
propsData: {
selectingInitiatesLoad: true,
},
});
// Assert
wrapper.vm.triggerButton();
await nextTick();
expect(wrapper.vm.handleCheckChange).toBeCalled;
expect(wrapper.vm.displayLoader).toBeCalled;
});
});

View file

@ -6,8 +6,11 @@
isWide ? 'horizontal' : '',
(errors.length > 0 || hasError) ? 'has-error' : '',
]"
@mouseup="handleChange(value)"
@keyup.space="handleChange(value)"
@keyup.space="triggerButton()"
@keyup.up="handleKeyupArrow()"
@keyup.down="handleKeyupArrow()"
@keyup.left="handleKeyupArrow()"
@keyup.right="handleKeyupArrow()"
>
<input
:type="isMultiSelect ? 'checkbox' : 'radio'"
@ -16,13 +19,15 @@
:value="value"
:aria-required="isRequired"
v-model="checkValue"
@change="handleCheckChange(value)"
@change="handleInputChange()"
/>
<label
tabindex="-1"
:for="buttonID"
:aria-labelledby="buttonID"
class="d-flex w-100 align-items-center px-2 h-100"
:class="getLabelClasses"
tabindex="-1"
@mouseup="triggerButton()"
>
<img
:id="buttonImageId"
@ -93,15 +98,6 @@ export default {
: this.selectedValues[0];
}
},
watch: {
// Changing this will impact pre-selection data loads on vehicle-parts.
// If changed, please regression test that vehicle-parts data still loads correctly with previous selections.
modelValue(newVal) {
if (newVal !== undefined) {
this.checkValue = newVal.value;
}
},
},
computed: {
getLabelClasses() {
if (this.isWide) {
@ -116,16 +112,44 @@ export default {
},
},
methods: {
handleCheckChange(newValue, oldValue) {
const isInitialization = typeof oldValue === "function";
if (!isInitialization) {
const emitEvent = {
checkValue: this.checkValue,
value: this.value.toString(),
buttonId: this.buttonID.toString(),
};
this.$emit("isCheckedChanged", emitEvent);
this.$emit("update:modelValue", emitEvent);
handleInputChange() {
if(!this.selectingInitiatesLoad) {
this.handleCheckChange();
}
},
handleKeyupArrow() {
if (this.isMultiSelect) {
return; // Prevent arrow keys from doing anything if element is a checkbox
}
if(!this.selectingInitiatesLoad) {
this.handleCheckChange();
}
this.handleChange(this.value);
},
triggerButton() {
if(this.selectingInitiatesLoad) {
this.displayLoader();
this.handleCheckChange();
}
this.handleChange(this.value);
},
handleCheckChange() {
const emitEvent = {
checkValue: this.checkValue, // only read on checkboxes, on handleCheckedChanged on button-question
value: this.value.toString(),
buttonId: this.buttonID && this.buttonID.toString(),
};
this.$emit("isCheckedChanged", emitEvent);
this.$emit("update:modelValue", emitEvent);
},
},
watch: {
// Changing this will impact pre-selection data loads on vehicle-parts.
// If changed, please regression test that vehicle-parts data still loads correctly with previous selections.
modelValue(newVal) {
if (newVal !== undefined) {
this.checkValue = newVal.value;
}
},
},
@ -213,18 +237,17 @@ export default {
box-shadow: 0 0 0 1px $blue;
border-radius: 0.5rem;
}
&:focus-visible + label {
box-shadow: 0 0 0 2.5px $blue;
border-radius: 0.5rem;
}
&:focus + label {
box-shadow: 0 0 0 2.5px $blue;
border-radius: 0.5rem;
}
&:checked {
&:focus + label {
box-shadow: 0 0 0 2.5px $blue;
}
&:checked:focus + label {
box-shadow: 0 0 0 2.5px $blue;
}
&:checked + label {
p {
color: $black;

View file

@ -56,19 +56,15 @@ export default {
handleClick(value) {
this.handleChange(value);
},
handleCheckChange(newValue, oldValue) {
const isInitialization = typeof oldValue === "function";
if (!isInitialization) {
handleCheckChange() {
const emitEvent = {
checkValue: this.checkValue,
value: this.value.toString(),
buttonID: this.buttonID && this.buttonID.toString(),
};
const emitEvent = {
checkValue: this.checkValue,
value: this.value.toString(),
buttonID: this.buttonID.toString(),
};
this.$emit("isCheckedChanged", emitEvent);
this.$emit("update:modelValue", emitEvent);
}
this.$emit("isCheckedChanged", emitEvent);
this.$emit("update:modelValue", emitEvent);
},
},
setup(props) {

View file

@ -17,6 +17,7 @@ export default {
},
methods: {
handleClick(event) {
this.pushEventToGA(this.$route.query[this.queryStrings.FMG_PAGE], this.GaActions.CLICKED, this.text, true);
this.$emit("click-event");
},
},

View file

@ -3,7 +3,13 @@ process.env.VUE_APP_CONSUMER_API_GATEWAY =
process.env.VUE_APP_HERITAGE_FUNNEL =
"http://localhost:38000/default.aspx";
process.env.VUE_APP_GOOGLE_PLACES_API_KEY =
"AIzaSyDptGCkOPgN2uWJOy4ou4M33phRD4MAoJo"
"AIzaSyDptGCkOPgN2uWJOy4ou4M33phRD4MAoJo";
// GA & GTM
process.env.VUE_APP_GOOGLE_TAG_MANAGER_SCRIPT_BODY = "(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl+ '&gtm_auth=amlAYNhxUxuskQo7jmjadg&gtm_preview=env-38&gtm_cookies_win=x';f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','GTM-M6XCRH');";
process.env.VUE_APP_GOOGLE_TAG_MANAGER_NOSCRIPT_FRAME_SRC = "https://www.googletagmanager.com/ns.html?id=GTM-M6XCRH&gtm_auth=amlAYNhxUxuskQo7jmjadg&gtm_preview=env-38&gtm_cookies_win=x";
module.exports = {
outputDir: "dist/fmg",

View file

@ -2,6 +2,10 @@ process.env.VUE_APP_CONSUMER_API_GATEWAY = "__VUE_APP_CONSUMER_API_GATEWAY__";
process.env.VUE_APP_GOOGLE_PLACES_API_KEY = "__VUE_APP_GOOGLE_PLACES_API_KEY__";
process.env.VUE_APP_HERITAGE_FUNNEL = "__VUE_APP_HERITAGE_FUNNEL__";
// GA & GTM
process.env.VUE_APP_GOOGLE_TAG_MANAGER_SCRIPT_BODY = "__VUE_APP_GOOGLE_TAG_MANAGER_SCRIPT_BODY__";
process.env.VUE_APP_GOOGLE_TAG_MANAGER_NOSCRIPT_FRAME_SRC = "__VUE_APP_GOOGLE_TAG_MANAGER_NOSCRIPT_FRAME_SRC__"
module.exports = {
outputDir: "dist/fmg",
publicPath: "/fmg",