Merge branch 'feature/CSR-1088' into feature/CSR-1012

This commit is contained in:
Leah Schumann 2023-03-06 08:10:52 -05:00
commit 165249af2f
17 changed files with 713 additions and 175 deletions

View file

@ -54,6 +54,10 @@ const endpoints = {
url: "/vehicle/api/v1/vehicle/lookup-vin-by-address", url: "/vehicle/api/v1/vehicle/lookup-vin-by-address",
method: "POST", method: "POST",
}, },
IsVinByAddressPermissible: {
url: "/vehicle/api/v1/vehicle/is-vin-by-address-permissible",
method: "GET",
},
GetPartsOrQuestions: { GetPartsOrQuestions: {
url: "/parts/api/v1/parts/parts-or-questions", url: "/parts/api/v1/parts/parts-or-questions",
method: "POST", method: "POST",

View file

@ -1,7 +1,7 @@
const queryStrings = { const queryStrings = {
FMG_PAGE: "fmgPage", FMG_PAGE: "fmgPage",
START_TYPE: "start_type", START_TYPE: "start_type",
ZIP_CODE: "zipCode", ZIP_CODE: "zipcode",
}; };
export { queryStrings }; export { queryStrings };

View file

@ -13,6 +13,7 @@ const storeActions = {
GET_DAMAGE_OPTIONS: "getDamageOptions", GET_DAMAGE_OPTIONS: "getDamageOptions",
GET_EVOX_IMAGE: "getEvoxImage", GET_EVOX_IMAGE: "getEvoxImage",
IS_VIN_OPTIONAL_VEHICLE: "isVinOptionalVehicle", IS_VIN_OPTIONAL_VEHICLE: "isVinOptionalVehicle",
IS_VIN_BY_ADDRESS_PERMISSIBLE: "isVinByAddressPermissible",
// Lookup Actions // Lookup Actions
LOOKUP_VEHICLE_BY_YMMS: "lookupVehicleByYmms", LOOKUP_VEHICLE_BY_YMMS: "lookupVehicleByYmms",

View file

@ -0,0 +1,27 @@
// For nested objects, spread operator only creates new references to the top level fields,
// the remaining nested fields actually reference the original object which can introduce problems.
// The purpose of this method is to deep clone the data in an object recursively, this is useful
// for cloning modelValues to internal models when regular two-way binding is not an option.
// See: mobile-location-modal-questions.vue
// Creates a deep clone of an object. Clones primitives, arrays and objects, excluding class instances.
// https://www.30secondsofcode.org/js/s/deep-clone
export function deepClone(object) {
if (object === null) {
return null;
}
let clone = Object.assign({}, object);
Object.keys(clone).forEach(
(key) =>
(clone[key] = typeof object[key] === "object" ? deepClone(object[key]) : object[key])
);
if (Array.isArray(object)) {
clone.length = object.length;
return Array.from(clone);
}
return clone;
}

View file

@ -0,0 +1,48 @@
import { deepClone } from "./object-cloning-helper";
describe("object-cloning-helper.js", () => {
it("Should return null if no object is passed in", async () => {
// Arrange
const expected = null;
// Act
const result = deepClone(null);
// Assert
expect(result).toEqual(expected);
});
it("Should return a deep copy of the object", async () => {
// Arrange
const object = {
addressQuestions: {
streetAddress: "555 Some St",
apartmentNumberOrBusinessName: "Apt 1",
city: "Funkytown",
state: "OH",
zipCode: "55555",
},
isVehicleProtected: true,
serviceZipCode: "55555",
};
const expected = {
addressQuestions: {
streetAddress: "555 Some St",
apartmentNumberOrBusinessName: "Apt 1",
city: "Funkytown",
state: "OH",
zipCode: "55555",
},
isVehicleProtected: true,
serviceZipCode: "55555",
};
// Act
const result = deepClone(object);
// Assert
expect(result).toStrictEqual(expected);
});
});

View file

@ -1,9 +1,9 @@
import { storeActions } from "@/constants/store-actions"; import { storeActions } from "@/constants/store-actions";
import baseMixin from "@/mixins/base-mixin.js"; import baseMixin from "@/mixins/base-mixin.js";
export async function getMobileFee(serviceZipCode) { export async function getPricedMobileFeePart(serviceZipCode) {
if (!serviceZipCode) { if (!serviceZipCode) {
return 0; return Promise.resolve(null);
} }
const zipCodeData = await baseMixin.methods.getZipCodeData(serviceZipCode); const zipCodeData = await baseMixin.methods.getZipCodeData(serviceZipCode);
@ -26,5 +26,5 @@ export async function getMobileFee(serviceZipCode) {
false false
); );
return Promise.resolve(baseMixin.methods.getTotalLineItemPrice(pricingResults[0])); return Promise.resolve(pricingResults[0]);
} }

View file

@ -1 +1,78 @@
test.todo("some test to be written in the future"); import { getPricedMobileFeePart } from "./service-location-helper";
import { storeActions } from "@/constants/store-actions";
const mockStoreActionGetMobileFeePart = storeActions.GET_MOBILE_FEE_PART;
const mockStoreActionPriceOrderItemsAndSaveServerData =
storeActions.PRICE_ORDER_ITEMS_AND_SAVE_SERVER_DATA;
jest.mock("@/mixins/base-mixin.js", () => ({
methods: {
getZipCodeData: jest.fn().mockImplementation(() => {
return Promise.resolve({
containsMilitaryBase: false,
isServiceable: true,
isValid: true,
state: "OH",
zipCodeCtu: "01820",
});
}),
dispatchStoreAction: jest.fn().mockImplementation((actionName) => {
if (actionName === mockStoreActionGetMobileFeePart) {
return Promise.resolve({
data: {
partNumber: "MOBILE FEE",
description: "MOBILE FEE",
partType: "FEE",
},
});
}
if (actionName === mockStoreActionPriceOrderItemsAndSaveServerData) {
return Promise.resolve([
{
partNumber: "MOBILE FEE",
description: "MOBILE FEE",
partType: "FEE",
laborAmount: 49.99,
sellingPrice: 0,
kitPrice: 0,
},
]);
}
}),
},
}));
describe("service-location-helper.js", () => {
it("Should return null if no service zip code is passed in", async () => {
// Arrange
const serviceZipCode = null;
const expected = null;
// Act
const result = await getPricedMobileFeePart(serviceZipCode);
// Assert
expect(result).toEqual(expected);
});
it("Should return the priced mobile fee part", async () => {
// Arrange
const serviceZipCode = "43235";
const expected = {
partNumber: "MOBILE FEE",
description: "MOBILE FEE",
partType: "FEE",
laborAmount: 49.99,
sellingPrice: 0,
kitPrice: 0,
};
// Act
const result = await getPricedMobileFeePart(serviceZipCode);
// Assert
expect(result).toEqual(expected);
});
});

View file

@ -110,6 +110,8 @@ import { experimentSettings } from "@/constants/experiments";
import vinPagesMixin from "@/mixins/vin-pages-mixin"; import vinPagesMixin from "@/mixins/vin-pages-mixin";
import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper"; import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper";
import { saveSession } from "@/helpers/heritage-integration/order-helper.js"; import { saveSession } from "@/helpers/heritage-integration/order-helper.js";
import baseMixin from "@/mixins/base-mixin.js";
import { queryStrings } from "@/constants/query-strings";
// Define Validation Rules // Define Validation Rules
defineRule("zip-required", required(errorMessages.SERVICE_ZIP_REQUIRED)); defineRule("zip-required", required(errorMessages.SERVICE_ZIP_REQUIRED));
@ -130,7 +132,7 @@ export default {
data() { data() {
return { return {
selectedVinLookupMethod: null, selectedVinLookupMethod: null,
serviceZipCode: this.getZipFromStore() ?? this.$route.query.zipCode, serviceZipCode: this.getZipFromStore() ?? this.$route.query.zipcode,
emailAddress: this.getEmailFromStore(), emailAddress: this.getEmailFromStore(),
displayInvalidZipAlert: false, displayInvalidZipAlert: false,
displayNonServiceableZipAlert: false, displayNonServiceableZipAlert: false,
@ -143,12 +145,31 @@ export default {
//Call APIs //Call APIs
const cmsContentPromise = fetchCmsContentForPage(to.query.fmgPage); const cmsContentPromise = fetchCmsContentForPage(to.query.fmgPage);
const queryString = window.location.search;
const urlParams = new URLSearchParams(queryString);
const hasZip = urlParams.has(queryStrings.ZIP_CODE);
const zip = urlParams.get(queryStrings.ZIP_CODE);
var vinByAddressPromise;
if (hasZip) {
vinByAddressPromise = baseMixin.methods.dispatchStoreAction(
storeActions.IS_VIN_BY_ADDRESS_PERMISSIBLE,
zip,
false
);
}
//Settle promises and get results //Settle promises and get results
const promiseResultMap = [ const promiseResultMap = [
{ {
resultKey: "cmsContent", resultKey: "cmsContent",
promise: cmsContentPromise, promise: cmsContentPromise,
}, },
zip != null &&
zip != undefined && {
resultKey: "vinByAddress",
promise: vinByAddressPromise,
},
]; ];
const resultMap = await settleAllPromises(promiseResultMap); const resultMap = await settleAllPromises(promiseResultMap);
@ -170,6 +191,14 @@ export default {
} }
} }
if (zip && resultMap.vinByAddress === false) {
var indexToRemove = resultMap.cmsContent.VinLookupMethod.Answers.findIndex(
(answer) => answer.Name === "HomeAddress"
);
if (indexToRemove) {
resultMap.cmsContent.VinLookupMethod.Answers.splice(indexToRemove, 1);
}
}
vm.setCmsContent(resultMap.cmsContent); vm.setCmsContent(resultMap.cmsContent);
}); });
}, },

View file

@ -152,7 +152,7 @@ export default {
data() { data() {
return { return {
licensePlate: this.getLicensePlateFromStore(), licensePlate: this.getLicensePlateFromStore(),
registrationZipCode: this.getRegistrationZipFromStore() ?? this.$route.query.zipCode, registrationZipCode: this.getRegistrationZipFromStore() ?? this.$route.query.zipcode,
email: this.getEmailFromStore(), email: this.getEmailFromStore(),
serviceZipCode: this.getServiceZipFromStore(), serviceZipCode: this.getServiceZipFromStore(),
displayNonServiceableZipAlert: false, displayNonServiceableZipAlert: false,

View file

@ -2,6 +2,7 @@ import mobileLocationModalQuestions from "./mobile-location-modal-questions";
import { mount, shallowMount } from "@vue/test-utils"; import { mount, shallowMount } from "@vue/test-utils";
import { storeActions } from "@/constants/store-actions"; import { storeActions } from "@/constants/store-actions";
import { getMountOptions } from "@/helpers/unit-test-helper.js"; import { getMountOptions } from "@/helpers/unit-test-helper.js";
import modal from "@/digital-components/modal/modal";
import crypto from "crypto"; import crypto from "crypto";
@ -42,7 +43,7 @@ const mockMixin = {
}), }),
getZipCodeData: jest.fn((zip) => { getZipCodeData: jest.fn((zip) => {
if (zip === "43235") { if (zip === "43235" || zip === "55555") {
return Promise.resolve({ return Promise.resolve({
containsMilitaryBase: false, containsMilitaryBase: false,
isServiceable: true, isServiceable: true,
@ -128,6 +129,213 @@ describe("mobile-location-modal-questions.vue", () => {
expect(wrapper.vm.internalModel.isVehicleProtected).toEqual(true); expect(wrapper.vm.internalModel.isVehicleProtected).toEqual(true);
expect(wrapper.vm.internalModel.serviceZipCode).toEqual("55555"); expect(wrapper.vm.internalModel.serviceZipCode).toEqual("55555");
}); });
it("Should open the modal when the 'Enter your service address' link is clicked", async () => {
// Arrange
const mobileLocationQuestions = {
addressQuestions: {
streetAddress: "555 Some St",
apartmentNumberOrBusinessName: "Apt 1",
city: "Funkytown",
state: "OH",
zipCode: "55555",
},
isVehicleProtected: true,
serviceZipCode: "55555",
};
const { wrapper } = setupMocks({
mixins: [mockMixin],
props: {
modelValue: mobileLocationQuestions,
},
mountOptions: {
attachTo: document.body,
},
});
wrapper.vm.$refs.MobileLocationModalWidget.openModal = jest.fn();
const mobileLocationLink = wrapper.findComponent({ ref: "mobileLocationLink" });
// // Act
await mobileLocationLink.trigger("click-event");
// // Assert
expect(wrapper.vm.$refs.MobileLocationModalWidget.openModal).toHaveBeenCalled();
});
it("Should emit update:modelValue on setMobileLocation for a valid address and vehicle protection answer", async () => {
// Arrange
const mobileLocationQuestions = {
addressQuestions: {
streetAddress: "",
apartmentNumberOrBusinessName: "",
city: "",
state: "",
zipCode: "",
},
isVehicleProtected: true,
serviceZipCode: "",
};
const newMobileLocationQuestions = {
addressQuestions: {
streetAddress: "555 Some St",
apartmentNumberOrBusinessName: "Apt 1",
city: "Funkytown",
state: "OH",
zipCode: "55555",
},
isVehicleProtected: true,
serviceZipCode: "55555",
};
const wrapper = shallowMount(mobileLocationModalQuestions, {
mixins: [mockMixin],
props: {
modelValue: mobileLocationQuestions,
linkWidgetName: linkWidgetName,
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
wrapper.vm.$refs.MobileLocationModalWidget.closeModal = jest.fn();
// Act
wrapper.vm.internalModel = newMobileLocationQuestions;
await wrapper.vm.setMobileLocation();
let expectedEmit = [[newMobileLocationQuestions]];
// Assert
expect(wrapper.emitted("update:modelValue")).toEqual(expectedEmit);
});
it("Should not emit update:modelValue on setMobileLocation for an invalid address zip code", async () => {
// Arrange
const mobileLocationQuestions = {
addressQuestions: {
streetAddress: "",
apartmentNumberOrBusinessName: "",
city: "",
state: "",
zipCode: "",
},
isVehicleProtected: true,
serviceZipCode: "",
};
const newMobileLocationQuestions = {
addressQuestions: {
streetAddress: "555 Some St",
apartmentNumberOrBusinessName: "Apt 1",
city: "Funkytown",
state: "OH",
zipCode: "61000",
},
isVehicleProtected: true,
serviceZipCode: "61000",
};
const wrapper = shallowMount(mobileLocationModalQuestions, {
mixins: [mockMixin],
props: {
modelValue: mobileLocationQuestions,
linkWidgetName: linkWidgetName,
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
wrapper.vm.$refs.MobileLocationModalWidget.closeModal = jest.fn();
// Act
wrapper.vm.internalModel = newMobileLocationQuestions;
await wrapper.vm.setMobileLocation();
// Assert
expect(wrapper.emitted("update:modelValue")).not.toBeTruthy();
});
it("Should display invalid zip alert for invalid address zip inputs", async () => {
// Arrange
const mobileLocationQuestions = {
addressQuestions: {
streetAddress: "",
apartmentNumberOrBusinessName: "",
city: "",
state: "",
zipCode: "",
},
isVehicleProtected: true,
serviceZipCode: "",
};
const newMobileLocationQuestions = {
addressQuestions: {
streetAddress: "555 Some St",
apartmentNumberOrBusinessName: "Apt 1",
city: "Funkytown",
state: "OH",
zipCode: "11111",
},
isVehicleProtected: true,
serviceZipCode: "11111",
};
const wrapper = shallowMount(mobileLocationModalQuestions, {
mixins: [mockMixin],
props: {
modelValue: mobileLocationQuestions,
linkWidgetName: linkWidgetName,
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
wrapper.vm.internalModel = newMobileLocationQuestions;
// Act
await wrapper.vm.setMobileLocation();
// Assert
expect(wrapper.vm.displayInvalidZipAlert).toBe(true);
});
it("Should clear the internal model when resetModel is called", async () => {
// Arrange
const mobileLocationQuestions = {
addressQuestions: {
streetAddress: "555 Some St",
apartmentNumberOrBusinessName: "Apt 1",
city: "Funkytown",
state: "OH",
zipCode: "55555",
},
isVehicleProtected: true,
serviceZipCode: "55555",
};
const { wrapper } = setupMocks({
mixins: [mockMixin],
props: {
modelValue: mobileLocationQuestions,
},
mountOptions: {
attachTo: document.body,
},
});
// Act
wrapper.vm.resetModel();
// Assert
expect(wrapper.vm.internalModel.addressQuestions.streetAddress).toEqual("");
expect(wrapper.vm.internalModel.addressQuestions.apartmentNumberOrBusinessName).toEqual("");
expect(wrapper.vm.internalModel.addressQuestions.city).toEqual("");
expect(wrapper.vm.internalModel.addressQuestions.state).toEqual("");
expect(wrapper.vm.internalModel.addressQuestions.zipCode).toEqual("");
expect(wrapper.vm.internalModel.isVehicleProtected).toEqual(null);
});
}); });
function setupMocks({ mountOptions, mixins, props, isShallowMount = true }) { function setupMocks({ mountOptions, mixins, props, isShallowMount = true }) {

View file

@ -1,23 +1,15 @@
<template> <template>
<div class="text-center"> <div class="text-center">
<label
for="mobileLocationLinkPromptId"
:aria-label="mobileLocationLinkPromptText"
class="form-label fw-bold w-100 ps-4 pe-4 pt-4"
v-html="mobileLocationLinkPromptText"></label>
<div class="update-mobile-location-text-link"> <div class="update-mobile-location-text-link">
<textLink <textLink
ref="mobileLocationLink"
id="mobileLocationLinkPromptId" id="mobileLocationLinkPromptId"
linkType="text" linkType="text"
:text="this.mobileLocationLinkText" :text="mobileLocationLinkText"
href="#!" href="#!"
@click-event="openModal" @click-event="openModal"
aria-label="Modal window" /> aria-label="Modal window" />
</div> </div>
<textBlock
:customText="mobileFeeText"
cmsWidgetName="MobileFeeDisclaimerWidget"
typeStyle="caption" />
</div> </div>
<modal <modal
:ref="modalName" :ref="modalName"
@ -46,18 +38,21 @@
<script> <script>
// Components // Components
import textLink from "@/ux-components/text-link/text-link"; import textLink from "@/ux-components/text-link/text-link";
import textBlock from "@/digital-components/text-block/text-block";
import modal from "@/digital-components/modal/modal"; import modal from "@/digital-components/modal/modal";
import alert from "@/ux-components/alert/alert"; import alert from "@/ux-components/alert/alert";
import addressQuestions from "@/layouts/address-lookup/customer-questions/address-questions/address-questions"; import addressQuestions from "@/layouts/address-lookup/customer-questions/address-questions/address-questions";
import vehicleProtectedQuestion from "@/layouts/service-location/mobile-location-modal-questions/vehicle-protected-question/vehicle-protected-question"; import vehicleProtectedQuestion from "@/layouts/service-location/mobile-location-modal-questions/vehicle-protected-question/vehicle-protected-question";
import textBlock from "@/digital-components/text-block/text-block";
// Helpers
import { deepClone } from "@/helpers/object-cloning-helper";
export default { export default {
name: "mobile-location-modal-questions", name: "mobile-location-modal-questions",
emits: ["update:modelValue"], // The component emits an event emits: ["update:modelValue", "updated-zip-code"], // The component emits an event
data() { data() {
return { return {
internalModel: this.copyModel(this.modelValue), internalModel: deepClone(this.modelValue),
displayInvalidZipAlert: false, displayInvalidZipAlert: false,
modalName: "MobileLocationModalWidget", modalName: "MobileLocationModalWidget",
}; };
@ -75,7 +70,7 @@ export default {
}, },
isVehicleProtected: null, isVehicleProtected: null,
serviceZipCode: "", serviceZipCode: "",
mobileFee: 0, mobileFeePart: null,
}), }),
}, },
isZipServiceableMobile: Boolean, isZipServiceableMobile: Boolean,
@ -86,9 +81,6 @@ export default {
alertInvalidZipWidgetName: String, alertInvalidZipWidgetName: String,
}, },
computed: { computed: {
mobileLocationLinkPromptText() {
return this.getCmsContent(this.linkWidgetName, "HeaderText");
},
mobileLocationLinkText() { mobileLocationLinkText() {
if ( if (
this.addressModel.streetAddress !== null && this.addressModel.streetAddress !== null &&
@ -107,13 +99,6 @@ export default {
modalHeaderText() { modalHeaderText() {
return this.getCmsContent(this.modalWidgetName, "HeaderText"); return this.getCmsContent(this.modalWidgetName, "HeaderText");
}, },
mobileFeeText() {
const cmsContentText = this.getCmsContent("MobileFeeDisclaimerWidget", "Text");
if (this.modelValue.mobileFee === undefined) {
return cmsContentText.replaceAll("{custom:mobileFee}", 0);
}
return cmsContentText.replaceAll("{custom:mobileFee}", this.modelValue.mobileFee);
},
modalFooterText() { modalFooterText() {
return this.getCmsContent(this.modalWidgetName, "FooterText"); return this.getCmsContent(this.modalWidgetName, "FooterText");
}, },
@ -124,22 +109,12 @@ export default {
}, },
}, },
methods: { methods: {
copyModel(modelToCopy) {
return { ...modelToCopy };
},
setServiceZipCode(serviceZipCode) {
this.internalModel.serviceZipCode = serviceZipCode;
},
openModal() { openModal() {
this.$refs[this.modalName].openModal(); this.$refs[this.modalName].openModal();
}, },
closeModal() { closeModal() {
this.$refs[this.modalName].closeModal(); this.$refs[this.modalName].closeModal();
}, },
resetComponent() { resetComponent() {
this.resetModel(); this.resetModel();
@ -149,19 +124,15 @@ export default {
// Reinitialize the Address Auto Complete // Reinitialize the Address Auto Complete
this.$refs.addressQuestions.setupAddressLookup(); this.$refs.addressQuestions.setupAddressLookup();
}, },
resetModel() { resetModel() {
// Address // Address
this.internalModel.addressQuestions.streetAddress = ""; this.internalModel.addressQuestions.streetAddress = "";
this.internalModel.addressQuestions.apartmentNumberOrBusinessName = ""; this.internalModel.addressQuestions.apartmentNumberOrBusinessName = "";
this.internalModel.addressQuestions.city = ""; this.internalModel.addressQuestions.city = "";
this.internalModel.addressQuestions.state = "";
this.internalModel.addressQuestions.zipCode = "";
// Is Vehicle Protected // Is Vehicle Protected
this.internalModel.isVehicleProtected = null; this.internalModel.isVehicleProtected = null;
}, },
async setMobileLocation() { async setMobileLocation() {
// Validate the Zip Code // Validate the Zip Code
const zipCodeData = await this.getZipCodeData( const zipCodeData = await this.getZipCodeData(
@ -172,23 +143,17 @@ export default {
this.displayInvalidZipAlert = true; this.displayInvalidZipAlert = true;
} else { } else {
// Update the page level model // Update the page level model
this.internalModel.serviceZipCode = this.internalModel.addressQuestions.zipCode;
this.$emit("update:modelValue", this.internalModel); this.$emit("update:modelValue", this.internalModel);
// Emit the update service zip code information to the parent to update the service zipcode details
this.$emit("updated-zip-code", {
zipCode: this.internalModel.addressQuestions.zipCode,
state: zipCodeData.state,
isServiceable: zipCodeData.isServiceable,
});
this.closeModal(); this.closeModal();
} }
}, },
}, },
watch: { watch: {
modelValue(newValue) { modelValue: {
this.internalModel = this.copyModel(newValue); handler(newValue) {
this.internalModel = deepClone(newValue);
},
deep: true,
}, },
}, },
components: { components: {

View file

@ -4,24 +4,38 @@ import serviceLocation from "@/layouts/service-location/service-location.vue";
// Supporting files // Supporting files
import { shallowMount } from "@vue/test-utils"; import { shallowMount } from "@vue/test-utils";
import { getMountOptions } from "@/helpers/unit-test-helper"; import { getMountOptions } from "@/helpers/unit-test-helper";
import baseMixin from "@/mixins/base-mixin";
import { nextTick } from "vue";
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { settleAllPromises } from "@/helpers/layout-helper.js";
import { getMobileFee } from "@/helpers/service-location-helper";
import store from "@/store"; import store from "@/store";
import baseMixin from "@/mixins/base-mixin";
// Define Mocks // Define Mocks
jest.mock("@/helpers/layout-helper.js", () => ({ jest.mock("@/helpers/cms-content-helper", () => ({
settleAllPromises: jest.fn(), fetchCmsContentForPage: jest.fn(() => {
return Promise.resolve("content");
}),
})); }));
jest.mock("@/helpers/cms-content-helper", () => ({ const mockGetPricedMobileFeePart = (mockServiceZipCode) => {
fetchCmsContentForPage: jest.fn(), let mobileFeePart = {};
}));
if (mockServiceZipCode === "43235") {
mobileFeePart = {
partNumber: "MOBILE FEE",
description: "MOBILE FEE",
partType: "FEE",
laborAmount: 49.99,
sellingPrice: 0,
kitPrice: 0,
};
}
return Promise.resolve(mobileFeePart);
};
jest.mock("@/helpers/service-location-helper", () => ({ jest.mock("@/helpers/service-location-helper", () => ({
getMobileFee: jest.fn(), getPricedMobileFeePart: jest.fn((mockServiceZipCode) => {
return mockGetPricedMobileFeePart(mockServiceZipCode);
}),
})); }));
jest.mock("@/store", () => ({ jest.mock("@/store", () => ({
@ -55,16 +69,43 @@ beforeEach(() => {
}, },
vehicle: { vehicle: {
registration: { registration: {
address: undefined, address: "5555 Sulgrave Dr",
city: undefined, city: "New Albany",
state: undefined, state: "OH",
zipCode: undefined, zipCode: "43054",
}, },
}, },
}; };
}); });
describe("service-location.vue", () => { describe("service-location.vue", () => {
describe("beforeRouteEnter", () => {
test("on load sets the mobile fee part when an service zip code has already been provided", async () => {
// Arrange
const { wrapper } = setupMocks({});
const mobileFeePart = {
partNumber: "MOBILE FEE",
description: "MOBILE FEE",
partType: "FEE",
laborAmount: 49.99,
sellingPrice: 0,
kitPrice: 0,
};
// Act
await serviceLocation.beforeRouteEnter.call(
wrapper.vm,
{ query: { fmgPage: "serviceLocation" } },
undefined,
(c) => c(wrapper.vm)
);
// Assert
expect(wrapper.vm.mobileLocationQuestions.mobileFeePart).toStrictEqual(mobileFeePart);
});
});
describe("arePagePrerequisitesValid", () => { describe("arePagePrerequisitesValid", () => {
test("No prerequisites set: Should return false.", () => { test("No prerequisites set: Should return false.", () => {
const { wrapper } = setupMocks({}); const { wrapper } = setupMocks({});
@ -108,55 +149,147 @@ describe("service-location.vue", () => {
}); });
describe("updating service zip", () => { describe("updating service zip", () => {
test("resets mobile location when service zip code is updated", () => { test("updates the page model after providing the service zip code", () => {
// Arrange // Arrange
const { wrapper } = setupMocks({}); const { wrapper } = setupMocks({});
const newZip = "61606";
// Act
wrapper.vm.$refs.mobileLocationModalQuestions.resetComponent = jest.fn(); wrapper.vm.$refs.mobileLocationModalQuestions.resetComponent = jest.fn();
wrapper.vm.resetMobileLocation(newZip);
// Assert
expect(wrapper.vm.mobileLocationQuestions.serviceZipCode).toBe(newZip);
});
test("updates service zip code serviceZipCodeQuestion when resetServiceZipCode is called", () => {
// Arrange
const { wrapper } = setupMocks({});
const newServiceZipCodeQuestion = { const newServiceZipCodeQuestion = {
zipCode: "61606", zipCode: "61606",
state: "IL", state: "IL",
isServiceable: true, isServiceable: true,
}; };
const serviceZipCodeComponent = wrapper.findComponent({
ref: "serviceZipCodeQuestion",
});
// Act // Act
wrapper.vm.$refs.mobileLocationModalQuestions.resetComponent = jest.fn(); serviceZipCodeComponent.vm.$emit("update:modelValue", newServiceZipCodeQuestion);
wrapper.vm.resetServiceZipCode(newServiceZipCodeQuestion);
// Assert // Assert
expect(wrapper.vm.serviceZipCodeQuestion).toStrictEqual(newServiceZipCodeQuestion); expect(wrapper.vm.serviceZipCodeQuestion).toStrictEqual(newServiceZipCodeQuestion);
}); });
test("resets mobile location in watch for serviceZipCodeQuestion", async () => { test("resets mobile location when service zip code is updated", () => {
// Arrange // Arrange
const { wrapper } = setupMocks({}); const { wrapper } = setupMocks({});
const newServiceZipCodeQuestion = { wrapper.vm.$refs.mobileLocationModalQuestions.resetComponent = jest.fn();
zipCode: "61606", wrapper.vm.$refs.serviceZipCodeQuestion.resetMobileFeePart = jest.fn();
state: "IL",
const mobileLocationQuestions = {
addressQuestions: {
streetAddress: "5555 Sulgrave Dr",
apartmentNumberOrBusinessName: "Apt 1",
city: "New Albany",
state: "OH",
zipCode: "43054",
},
isVehicleProtected: true,
serviceZipCode: "43054",
mobileFee: 0,
};
wrapper.vm.mobileLocationQuestions = mobileLocationQuestions;
const newMobileLocationQuestions = {
addressQuestions: {
streetAddress: "",
apartmentNumberOrBusinessName: "",
city: "",
state: "",
zipCode: "",
},
isVehicleProtected: null,
serviceZipCode: "43081",
};
const serviceZipCodeComponent = wrapper.findComponent({
ref: "serviceZipCodeQuestion",
});
// Act
serviceZipCodeComponent.vm.$emit("updated-service-zip-code", "43081");
// Assert
expect(wrapper.vm.mobileLocationQuestions).toStrictEqual(newMobileLocationQuestions);
});
});
describe("updating mobile location", () => {
test("updates the page model after providing the mobile location", () => {
// Arrange
const { wrapper } = setupMocks({});
wrapper.vm.$refs.mobileLocationModalQuestions.resetComponent = jest.fn();
const mobileLocationQuestions = {
addressQuestions: {
streetAddress: "",
apartmentNumberOrBusinessName: "",
city: "",
state: "",
zipCode: "",
},
isVehicleProtected: null,
serviceZipCode: "43081",
};
wrapper.vm.mobileLocationQuestions = mobileLocationQuestions;
const newMobileLocationQuestions = {
addressQuestions: {
streetAddress: "5555 Sulgrave Dr",
apartmentNumberOrBusinessName: "Apt 1",
city: "New Albany",
state: "OH",
zipCode: "43054",
},
isVehicleProtected: true,
serviceZipCode: "43054",
mobileFee: 0,
};
const mobileLocationComponent = wrapper.findComponent({
ref: "mobileLocationModalQuestions",
});
// Act
mobileLocationComponent.vm.$emit("update:modelValue", newMobileLocationQuestions);
// Assert
expect(wrapper.vm.mobileLocationQuestions).toStrictEqual(newMobileLocationQuestions);
});
test("resets service zip code when mobile location is updated", async () => {
// Arrange
const { wrapper } = setupMocks({});
wrapper.vm.$refs.mobileLocationModalQuestions.resetComponent = jest.fn();
wrapper.vm.$refs.serviceZipCodeQuestion.resetMobileFeePart = jest.fn();
const serviceZipCodeInfo = {
state: "OH",
zipCode: "43054",
isServiceable: true, isServiceable: true,
}; };
// Act const newServiceZipCodeInfo = {
wrapper.vm.$refs.mobileLocationModalQuestions.resetComponent = jest.fn(); state: "OH",
wrapper.vm.serviceZipCodeQuestion = newServiceZipCodeQuestion; zipCode: "43081",
isServiceable: true,
};
wrapper.vm.serviceZipCodeQuestion = serviceZipCodeInfo;
await wrapper.vm.$nextTick(); const mobileLocationComponent = wrapper.findComponent({
ref: "mobileLocationModalQuestions",
});
// Act
mobileLocationComponent.vm.$emit("updated-zip-code", {
state: "OH",
zipCode: "43081",
isServiceable: true,
});
// Assert // Assert
expect(wrapper.vm.mobileLocationQuestions.serviceZipCode).toStrictEqual( expect(wrapper.vm.serviceZipCodeQuestion).toStrictEqual(newServiceZipCodeInfo);
newServiceZipCodeQuestion.zipCode
);
}); });
}); });
}); });
@ -166,10 +299,6 @@ function setupMocks({ mountOptionsMockData = {} }) {
const apiPromise = Promise.resolve(apiResponses); const apiPromise = Promise.resolve(apiResponses);
settleAllPromises.mockImplementation(() => apiPromise);
fetchCmsContentForPage.mockImplementation(() => Promise.resolve());
getMobileFee.mockImplementation(() => Promise.resolve());
const mountOptions = getMountOptions(mountOptionsMockData); const mountOptions = getMountOptions(mountOptionsMockData);
const wrapper = shallowMount(serviceLocation, mountOptions); const wrapper = shallowMount(serviceLocation, mountOptions);
wrapper.vm.setCmsContent = baseMixin.methods.setCmsContent; wrapper.vm.setCmsContent = baseMixin.methods.setCmsContent;

View file

@ -6,16 +6,25 @@
<funnelSubHeader cmsWidgetName="FunnelSubHeaderWidget" /> <funnelSubHeader cmsWidgetName="FunnelSubHeaderWidget" />
<serviceZipModalQuestion <serviceZipModalQuestion
v-model="serviceZipCodeQuestion" v-model="serviceZipCodeQuestion"
@updated-service-zip-code="recalculateMobileFee" @updated-service-zip-code="resetMobileLocation"
ref="serviceZipCodeQuestion" ref="serviceZipCodeQuestion"
linkWidgetName="ServiceZipLinkWidget" linkWidgetName="ServiceZipLinkWidget"
modalWidgetName="ServiceZipModalWidget" /> modalWidgetName="ServiceZipModalWidget" />
<div class="text-center">
<label
for="mobileLocationLinkPromptId"
:aria-label="mobileLocationLinkPromptText"
class="form-label fw-bold w-100 ps-4 pe-4 pt-4"
v-html="mobileLocationLinkPromptText"></label>
</div>
<mobileLocationModalQuestions <mobileLocationModalQuestions
v-model="mobileLocationQuestions" v-model="mobileLocationQuestions"
@updated-zip-code="resetServiceZipCode"
ref="mobileLocationModalQuestions" ref="mobileLocationModalQuestions"
linkWidgetName="MobileLocationLinkWidget" linkWidgetName="MobileLocationLinkWidget"
modalWidgetName="MobileLocationModalWidget" /> modalWidgetName="MobileLocationModalWidget" />
<div class="text-center">
<textBlock :customText="mobileFeeDisclaimerText" typeStyle="caption" />
</div>
<funnel-footer <funnel-footer
cmsWidgetName="FunnelFooterWidget" cmsWidgetName="FunnelFooterWidget"
ref="funnelFooter" ref="funnelFooter"
@ -38,32 +47,23 @@ import { Form } from "vee-validate";
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper"; import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { settleAllPromises } from "@/helpers/layout-helper"; import { settleAllPromises } from "@/helpers/layout-helper";
import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper"; import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper";
import { getMobileFee } from "@/helpers/service-location-helper"; import { getPricedMobileFeePart } from "@/helpers/service-location-helper";
import store from "@/store"; import store from "@/store";
import textBlock from "@/digital-components/text-block/text-block";
export default { export default {
name: "service-location", name: "service-location",
data() { data() {
return { return {
streetAddress: this.getServiceAddressFromStore(),
apartmentNumberOrBusinessName: "",
city: this.getServiceCityFromStore(),
state: this.getServiceStateFromStore(),
zipCode: this.getServiceZipCodeFromStore(),
isServiceable: false,
isZipServiceableMobile: null, isZipServiceableMobile: null,
isZipServiceableInShop: null, isZipServiceableInShop: null,
serviceZipCodeQuestion: { mobileFeePart: null,
state: this.getServiceStateFromStore(),
zipCode: this.getServiceZipCodeFromStore(),
isServiceable: this.getIsServiceableFromStore(),
},
mobileLocationQuestions: {
addressQuestions: {
streetAddress: this.getRegistrationAddressFromStore(),
apartmentNumberOrBusinessName: "",
city: this.getRegistrationCityFromStore(),
state: this.getRegistrationStateFromStore(),
zipCode: this.getRegistrationZipFromStore(),
},
isVehicleProtected: null,
serviceZipCode: this.getServiceZipCodeFromStore(),
mobileFee: 0,
},
}; };
}, },
async beforeRouteEnter(to, from, next) { async beforeRouteEnter(to, from, next) {
@ -71,7 +71,7 @@ export default {
const cmsContentPromise = fetchCmsContentForPage(to.query.fmgPage); const cmsContentPromise = fetchCmsContentForPage(to.query.fmgPage);
const serviceZipCode = store.getters.order.serviceLocation.zipCode; const serviceZipCode = store.getters.order.serviceLocation.zipCode;
const mobileFeePromise = getMobileFee(serviceZipCode); const mobileFeePartPromise = getPricedMobileFeePart(serviceZipCode);
// Settle promises and get results // Settle promises and get results
const promiseResultMap = [ const promiseResultMap = [
@ -80,8 +80,8 @@ export default {
promise: cmsContentPromise, promise: cmsContentPromise,
}, },
{ {
resultKey: "mobileFee", resultKey: "mobileFeePart",
promise: mobileFeePromise, promise: mobileFeePartPromise,
}, },
]; ];
@ -90,9 +90,71 @@ export default {
// Call the "next" function to complete the transition to this page. // Call the "next" function to complete the transition to this page.
next((vm) => { next((vm) => {
vm.setCmsContent(resultMap.cmsContent); vm.setCmsContent(resultMap.cmsContent);
vm.setData(resultMap.mobileFee); vm.setData(resultMap.mobileFeePart);
}); });
}, },
computed: {
serviceZipCodeQuestion: {
get: function () {
return {
state: this.state,
zipCode: this.zipCode,
isServiceable: this.isServiceable,
};
},
set: function (newValue) {
if (newValue.zipCode !== this.zipCode) {
this.resetMobileLocation(this.zipCode);
}
this.state = newValue.state;
this.zipCode = newValue.zipCode;
this.isServiceable = newValue.isServiceable;
},
},
mobileLocationQuestions: {
get: function () {
return {
addressQuestions: {
streetAddress: this.streetAddress,
apartmentNumberOrBusinessName: this.apartmentNumberOrBusinessName,
city: this.city,
state: this.state,
zipCode: this.zipCode,
},
isVehicleProtected: this.isVehicleProtected,
mobileFeePart: this.mobileFeePart,
};
},
set: function (newValue) {
this.streetAddress = newValue.addressQuestions.streetAddress;
this.apartmentNumberOrBusinessName =
newValue.addressQuestions.apartmentNumberOrBusinessName;
this.city = newValue.addressQuestions.city;
this.state = newValue.addressQuestions.state;
this.zipCode = newValue.addressQuestions.zipCode;
this.isVehicleProtected = newValue.isVehicleProtected;
this.mobileFeePart = newValue.mobileFeePart;
},
},
mobileLocationLinkPromptText() {
return this.getCmsContent("MobileLocationLinkWidget", "HeaderText");
},
mobileFeeDisclaimerText() {
const cmsContentText = this.getCmsContent("MobileFeeDisclaimerWidget", "Text");
return cmsContentText.replaceAll("{custom:mobileFee}", this.mobileFee);
},
mobileFee() {
if (!this.mobileFeePart) {
return 0;
}
return (
this.mobileFeePart.laborAmount +
this.mobileFeePart.sellingPrice +
this.mobileFeePart.kitPrice
);
},
},
methods: { methods: {
arePagePrerequisitesValid() { arePagePrerequisitesValid() {
return ( return (
@ -101,58 +163,40 @@ export default {
store.getters.payment.isInsurance !== null store.getters.payment.isInsurance !== null
); );
}, },
setData(mobileFee) { setData(mobileFeePart) {
if (mobileFee) { if (mobileFeePart) {
this.mobileLocationQuestions.mobileFee = mobileFee; this.mobileFeePart = mobileFeePart;
} }
}, },
getRegistrationAddressFromStore() { getServiceAddressFromStore() {
return store.getters.vehicle.registration.address; return store.getters.order.serviceLocation.address;
}, },
getRegistrationCityFromStore() { getServiceCityFromStore() {
return store.getters.vehicle.registration.city; return store.getters.order.serviceLocation.city;
},
getRegistrationStateFromStore() {
return store.getters.vehicle.registration.state;
},
getRegistrationZipFromStore() {
return store.getters.vehicle.registration.zipCode;
},
getServiceZipCodeFromStore() {
return store.getters.order.serviceLocation.zipCode;
}, },
getServiceStateFromStore() { getServiceStateFromStore() {
return store.getters.order.serviceLocation.state; return store.getters.order.serviceLocation.state;
}, },
getServiceZipCodeFromStore() {
return store.getters.order.serviceLocation.zipCode;
},
getIsServiceableFromStore() { getIsServiceableFromStore() {
return store.getters.order.serviceLocation.isServiceable; return store.getters.order.serviceLocation.isServiceable;
}, },
recalculateMobileFee(serviceZipCode) { resetMobileFeePart(serviceZipCode) {
getMobileFee(serviceZipCode).then((mobileFee) => { getPricedMobileFeePart(serviceZipCode).then((pricedMobileFeePart) => {
this.mobileLocationQuestions.mobileFee = mobileFee; this.mobileFeePart = pricedMobileFeePart;
}); });
}, },
resetMobileLocation(updatedServiceZipCode) { resetMobileLocation(updatedServiceZipCode) {
this.mobileLocationQuestions = { this.streetAddress = "";
addressQuestions: { this.apartmentNumberOrBusinessName = "";
streetAddress: "", this.city = "";
apartmentNumberOrBusinessName: "", this.isVehicleProtected = null;
city: "",
state: "",
zipCode: "",
},
isVehicleProtected: null,
serviceZipCode: updatedServiceZipCode,
};
this.$refs.mobileLocationModalQuestions.resetComponent(); this.$refs.mobileLocationModalQuestions.resetComponent();
},
resetServiceZipCode(newValue) {
this.serviceZipCodeQuestion.state = newValue.state;
this.serviceZipCodeQuestion.zipCode = newValue.zipCode;
this.serviceZipCodeQuestion.isServiceable = newValue.isServiceable;
this.recalculateMobileFee(this.serviceZipCodeQuestion.zipCode); this.resetMobileFeePart(updatedServiceZipCode);
}, },
backButtonAction() { backButtonAction() {
this.$router.navigateWithoutSaving(this.navigationScenarios.CLICKED_BACK, this.$route); this.$router.navigateWithoutSaving(this.navigationScenarios.CLICKED_BACK, this.$route);
@ -161,15 +205,6 @@ export default {
navigateToHeritageFunnel({ loadingModal: this.$refs.loadingModal }); navigateToHeritageFunnel({ loadingModal: this.$refs.loadingModal });
}, },
}, },
watch: {
serviceZipCodeQuestion: {
handler(newValue, oldValue) {
if (newValue !== oldValue) {
this.resetMobileLocation(newValue.zipCode);
}
},
},
},
components: { components: {
serviceZipModalQuestion, serviceZipModalQuestion,
mobileLocationModalQuestions, mobileLocationModalQuestions,
@ -178,6 +213,7 @@ export default {
funnelSubHeader, funnelSubHeader,
Form, Form,
loadingModal, loadingModal,
textBlock,
}, },
}; };
</script> </script>

View file

@ -142,8 +142,6 @@ export default {
this.internalModel.state = zipCodeData.state; this.internalModel.state = zipCodeData.state;
this.internalModel.isServiceable = zipCodeData.isServiceable; this.internalModel.isServiceable = zipCodeData.isServiceable;
this.$emit("updated-service-zip-code", this.internalModel.zipCode);
// Update the page level model // Update the page level model
this.$emit("update:modelValue", this.internalModel); this.$emit("update:modelValue", this.internalModel);

View file

@ -173,7 +173,7 @@ export default {
data() { data() {
return { return {
vin: this.getVinFromStore(), vin: this.getVinFromStore(),
serviceZipCode: this.getZipFromStore() ?? this.$route.query.zipCode, serviceZipCode: this.getZipFromStore() ?? this.$route.query.zipcode,
emailAddress: this.getEmailFromStore(), emailAddress: this.getEmailFromStore(),
isCarIdDifferent: false, isCarIdDifferent: false,
customAlertData: {}, customAlertData: {},

View file

@ -265,14 +265,23 @@ async function navigate(
fmgPage: destinationFmgPageValue, fmgPage: destinationFmgPageValue,
}; };
const queryString = window.location.search;
const urlParams = new URLSearchParams(queryString);
const lowerCaseParams = new URLSearchParams();
for (const [name, value] of urlParams) {
lowerCaseParams.append(name.toLowerCase(), value);
}
const hasZip = lowerCaseParams.has(queryStrings.ZIP_CODE);
const zip = lowerCaseParams.get(queryStrings.ZIP_CODE);
if ( if (
Object.hasOwn(currentRoute.query, queryStrings.ZIP_CODE) && hasZip &&
(store.getters.order.serviceLocation.zipCode === undefined || (store.getters.order.serviceLocation.zipCode === undefined ||
store.getters.order.serviceLocation.zipCode == null) && store.getters.order.serviceLocation.zipCode == null) &&
(store.getters.order.vehicle.registration.zipCode === undefined || (store.getters.order.vehicle.registration.zipCode === undefined ||
store.getters.order.vehicle.registration.zipCode == null) store.getters.order.vehicle.registration.zipCode == null)
) { ) {
queryStringsObject[queryStrings.ZIP_CODE] = currentRoute.query.zipCode; queryStringsObject[queryStrings.ZIP_CODE] = zip;
} }
router.push({ router.push({

View file

@ -530,6 +530,13 @@ export const actions = {
}, },
}); });
}, },
isVinByAddressPermissible(context, zip) {
return globalMethods.callHttpClient({
method: endpoints.IsVinByAddressPermissible.method,
endpoint: `${endpoints.IsVinByAddressPermissible.url}?zipcode=${zip}`,
payload: {},
});
},
getVehicleMakes(context, { year }) { getVehicleMakes(context, { year }) {
return globalMethods.callHttpClient({ return globalMethods.callHttpClient({
method: endpoints.GetVehicleMakes.method, method: endpoints.GetVehicleMakes.method,