diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 2760d6a1a..e6fa6d0ff 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -76,7 +76,7 @@ stages: deployFolder: '' region: us-east-1 appDeployVariables: - __VUE_APP_CONSUMER_API_GATEWAY__: $(__VUE_APP_CONSUMER_API_GATEWAY__) + __VUE_APP_CONSUMER_CF_DISTRO__: $(__VUE_APP_CONSUMER_CF_DISTRO__) __VUE_APP_GOOGLE_PLACES_API_KEY__: $(__VUE_APP_GOOGLE_PLACES_API_KEY__) __VUE_APP_HERITAGE_FUNNEL__: $(__VUE_APP_HERITAGE_FUNNEL__) indexDeployVariables: @@ -92,7 +92,7 @@ stages: jobs: - deployment: qaBuildDeployment displayName: Build and Deploy FMG - QA - environment: digitalCloud-qa + environment: NoApproval-All container: node workspace: clean: all @@ -116,7 +116,7 @@ stages: deployFolder: '' region: us-east-1 appDeployVariables: - __VUE_APP_CONSUMER_API_GATEWAY__: $(__VUE_APP_CONSUMER_API_GATEWAY__) + __VUE_APP_CONSUMER_CF_DISTRO__: $(__VUE_APP_CONSUMER_CF_DISTRO__) __VUE_APP_GOOGLE_PLACES_API_KEY__: $(__VUE_APP_GOOGLE_PLACES_API_KEY__) __VUE_APP_HERITAGE_FUNNEL__: $(__VUE_APP_HERITAGE_FUNNEL__) cfDistributionId: $(cfDistributionId) \ No newline at end of file diff --git a/jest.config.js b/jest.config.js index ea17e6222..e75fc82ab 100644 --- a/jest.config.js +++ b/jest.config.js @@ -11,6 +11,7 @@ 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/vin-lookup/vin-lookup.vue", diff --git a/src/common-components/button-question/button-question.spec.js b/src/common-components/button-question/button-question.spec.js index d36b73012..b70304035 100644 --- a/src/common-components/button-question/button-question.spec.js +++ b/src/common-components/button-question/button-question.spec.js @@ -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; +} diff --git a/src/common-components/button-question/button-question.vue b/src/common-components/button-question/button-question.vue index e29f1fd93..7fba59c51 100644 --- a/src/common-components/button-question/button-question.vue +++ b/src/common-components/button-question/button-question.vue @@ -5,7 +5,7 @@ {{ questionText }}
-
+
{{(isMultiSelect && answers && answers.length > 1) ? 'Select one or more options below.' : 'Select an option below.' }} @@ -52,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", @@ -133,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; @@ -158,11 +162,11 @@ export default { diff --git a/src/constants/analytics.js b/src/constants/analytics.js new file mode 100644 index 000000000..f6f6ff82e --- /dev/null +++ b/src/constants/analytics.js @@ -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}; diff --git a/src/constants/application-config.js b/src/constants/application-config.js index a7841e5a9..b1ab342a7 100644 --- a/src/constants/application-config.js +++ b/src/constants/application-config.js @@ -1,6 +1,6 @@ const applicationConfig = { - CONSUMER_APIGATEWAY_URL: process.env.VUE_APP_CONSUMER_API_GATEWAY, + CONSUMER_CF_DISTRO: process.env.VUE_APP_CONSUMER_CF_DISTRO, GOOGLE_PLACES_API_KEY: process.env.VUE_APP_GOOGLE_PLACES_API_KEY, ANALYTICS_SESSION_TIMEOUT_MINUTES: 30, SAVED_SESSION_TIMEOUT_DAYS: 45, diff --git a/src/constants/endpoints.js b/src/constants/endpoints.js index 2325c29cd..ca1555e7b 100644 --- a/src/constants/endpoints.js +++ b/src/constants/endpoints.js @@ -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", } }; diff --git a/src/constants/error-messages.js b/src/constants/error-messages.js index c3976d0f9..17012ab68 100644 --- a/src/constants/error-messages.js +++ b/src/constants/error-messages.js @@ -11,11 +11,14 @@ 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", }; diff --git a/src/constants/experiments.js b/src/constants/experiments.js index 13a51aa7f..cd2325a24 100644 --- a/src/constants/experiments.js +++ b/src/constants/experiments.js @@ -1,6 +1,10 @@ const experimentUniverses = { CONCEPT_FUNNEL: 'ConceptFunnel' }; + +const experimentSettings = { + GOOGLE_CUSTOM_DIMENSION_INDEX: 'Google Custom Dimension Index' +} -export { experimentUniverses }; +export { experimentUniverses, experimentSettings}; \ No newline at end of file diff --git a/src/constants/store-actions.js b/src/constants/store-actions.js index 9e87416d4..74dac5043 100644 --- a/src/constants/store-actions.js +++ b/src/constants/store-actions.js @@ -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", diff --git a/src/global-methods.js b/src/global-methods.js index c0823f29a..5e4d04ea3 100644 --- a/src/global-methods.js +++ b/src/global-methods.js @@ -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 cfDistroUrl = applicationConfig.CONSUMER_CF_DISTRO; + const payloadAndAnalyticsData = Object.assign({}, payload, { AppName: "FixMyGlass" }); - const payloadAndAnalyticsData = Object.assign({}, payload, { - AppName: "FixMyGlass", - }); + axios({ method: method, url: cfDistroUrl + 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); diff --git a/src/global-methods.spec.js b/src/global-methods.spec.js index 59fa15b77..5dcba0ccd 100644 --- a/src/global-methods.spec.js +++ b/src/global-methods.spec.js @@ -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 }; } diff --git a/src/helpers/damage-helper.spec.js b/src/helpers/damage-helper.spec.js index 3d1fb8d0c..ad5f8f878 100644 --- a/src/helpers/damage-helper.spec.js +++ b/src/helpers/damage-helper.spec.js @@ -1,37 +1,16 @@ -import {getDamageString, compareGlassOptions} from "./damage-helper"; +import {getDamageString, isGlassAvailableForCarId} from "./damage-helper"; +//import baseMixin from "@/mixins/base-mixin.js"; jest.mock("@/store", () => ({ getters: {damage: { - glassToReplace: [{location: "TEST"}] - } + glassToReplace: [{location: "Windshield", name: "windshield"}] + } } })); describe("damage-helper.js", () => { it("Should return damage getter info", () => { const damage = getDamageString(); - expect(damage).toEqual("TEST") + expect(damage).toEqual("Windshield") }); - }); - - // describe("damage-helper.js", () => { - // it("Should return false if no mismatches between each array", () => { - // const newOptions = { - // windshieldOptions: {availableReplacementOptions: ["windshield"]} - // } - // const currentOptions = [{location: "Windshield", name: "windshield"}]; - // const misMatch = compareGlassOptions(newOptions, currentOptions); - // 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); - // }); - // }); + }); \ No newline at end of file diff --git a/src/helpers/heritage-integration/cookie-helper.js b/src/helpers/heritage-integration/cookie-helper.js index a0be40075..e69c21419 100644 --- a/src/helpers/heritage-integration/cookie-helper.js +++ b/src/helpers/heritage-integration/cookie-helper.js @@ -72,7 +72,7 @@ export function getDeviceIdValue(){ return cookieValueMatch[0].split('=')[1]; } - return ''; + return '00000000-0000-0000-0000-000000000000'; } /* @@ -88,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 = diff --git a/src/helpers/heritage-integration/cookie-helper.spec.js b/src/helpers/heritage-integration/cookie-helper.spec.js index b4498f61b..03fca51b5 100644 --- a/src/helpers/heritage-integration/cookie-helper.spec.js +++ b/src/helpers/heritage-integration/cookie-helper.spec.js @@ -1,4 +1,4 @@ -import {getFunnelCookie, getDeviceIdValue, getSessionKeyValue} 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", () => { @@ -126,5 +126,19 @@ describe("cookies", () => { }); }); + + describe("getSessionIdValue", () => { + test("getSessionIdValue, should return GUID", () => { + // Arrange + setupCookies({}); + + // Act + const result = getSessionIdValue(); + + //Assert + expect(result).toBe('cba0c3d1-3c1b-4305-bb56-31aa50f58e27'); + + }); + }); }) \ No newline at end of file diff --git a/src/helpers/heritage-integration/navigation-helper.spec.js b/src/helpers/heritage-integration/navigation-helper.spec.js index 22f5a0922..de5df0239 100644 --- a/src/helpers/heritage-integration/navigation-helper.spec.js +++ b/src/helpers/heritage-integration/navigation-helper.spec.js @@ -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: {} diff --git a/src/helpers/heritage-integration/order-helper.js b/src/helpers/heritage-integration/order-helper.js index 35c3b8570..b18b64a85 100644 --- a/src/helpers/heritage-integration/order-helper.js +++ b/src/helpers/heritage-integration/order-helper.js @@ -18,7 +18,7 @@ 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; } @@ -34,10 +34,10 @@ 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, @@ -56,7 +56,7 @@ export async function saveOrder() { and returns the response. */ async function loadOrder(referralNumber, referralDate, referralCorrelationId, accountNumber) { - const response = await baseMixin.methods.dispatchNonBlockingStoreAction(storeActions.LOAD_ORDER, + const response = await baseMixin.methods.dispatchStoreAction(storeActions.LOAD_ORDER, { referralNumber: referralNumber.toString(), referralDate: referralDate, diff --git a/src/helpers/heritage-integration/order-helper.spec.js b/src/helpers/heritage-integration/order-helper.spec.js index cbe54ba25..803c94b6b 100644 --- a/src/helpers/heritage-integration/order-helper.spec.js +++ b/src/helpers/heritage-integration/order-helper.spec.js @@ -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 diff --git a/src/helpers/unit-test-helper.js b/src/helpers/unit-test-helper.js index 20fad7ba2..770dd893e 100644 --- a/src/helpers/unit-test-helper.js +++ b/src/helpers/unit-test-helper.js @@ -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 }; } @@ -62,6 +77,7 @@ export const cookies = { "anotherCookie": "{}", "someOtherCookie": "{}", "dxdev": "did=21b9b94a-ec23-42c1-aaac-e2ae4e4dbffe", + "sid": "cba0c3d1-3c1b-4305-bb56-31aa50f58e27", "skey": "12345" }; @@ -89,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 ); diff --git a/src/helpers/validation-rules.spec.js b/src/helpers/validation-rules.spec.js index 323e05ff1..7e0c6304e 100644 --- a/src/helpers/validation-rules.spec.js +++ b/src/helpers/validation-rules.spec.js @@ -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); - }); - }); \ No newline at end of file + 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); + }); +}); \ No newline at end of file diff --git a/src/layouts/address-lookup/address-lookup.spec.js1 b/src/layouts/address-lookup/address-lookup.spec.js1 index e69de29bb..4443100c3 100644 --- a/src/layouts/address-lookup/address-lookup.spec.js1 +++ b/src/layouts/address-lookup/address-lookup.spec.js1 @@ -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.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", + }, + 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 }; + } \ No newline at end of file diff --git a/src/layouts/address-lookup/address-lookup.vue b/src/layouts/address-lookup/address-lookup.vue index 7a90ee123..895148575 100644 --- a/src/layouts/address-lookup/address-lookup.vue +++ b/src/layouts/address-lookup/address-lookup.vue @@ -5,15 +5,54 @@ ref="theForm" v-slot="{ meta }" autocomplete="off" > -
- +
+ - + + + + + + +
+
+
+ +
+
+
+
+
@@ -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.dispatchStoreAction( + 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.dispatchStoreAction( + storeActions.VALIDATE_ZIP, + { zip }); + }, + lookupVin(lastName, streetAddress, zip, state) { + return baseMixin.methods.dispatchStoreAction( + 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 }, }; diff --git a/src/layouts/address-lookup/customer-questions/address-questions/address-questions.vue b/src/layouts/address-lookup/customer-questions/address-questions/address-questions.vue index 0954caf34..4a772e468 100644 --- a/src/layouts/address-lookup/customer-questions/address-questions/address-questions.vue +++ b/src/layouts/address-lookup/customer-questions/address-questions/address-questions.vue @@ -1,36 +1,34 @@ @@ -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, diff --git a/src/layouts/address-lookup/customer-questions/customer-questions.vue b/src/layouts/address-lookup/customer-questions/customer-questions.vue index 7fa324f91..e1c505c9e 100644 --- a/src/layouts/address-lookup/customer-questions/customer-questions.vue +++ b/src/layouts/address-lookup/customer-questions/customer-questions.vue @@ -1,18 +1,18 @@ @@ -20,12 +20,10 @@ + \ No newline at end of file diff --git a/src/layouts/vehicle-damage/damage-location-question/damage-location-question.vue b/src/layouts/vehicle-damage/damage-location-question/damage-location-question.vue index 690e7df78..16d67b3ee 100644 --- a/src/layouts/vehicle-damage/damage-location-question/damage-location-question.vue +++ b/src/layouts/vehicle-damage/damage-location-question/damage-location-question.vue @@ -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, }, diff --git a/src/layouts/vehicle-damage/replace-options-question/replace-options-question.vue b/src/layouts/vehicle-damage/replace-options-question/replace-options-question.vue index e2a6bb426..53ef1e2fa 100644 --- a/src/layouts/vehicle-damage/replace-options-question/replace-options-question.vue +++ b/src/layouts/vehicle-damage/replace-options-question/replace-options-question.vue @@ -11,6 +11,7 @@ v-model="selectedValues" :validationRules="validationRules" :suppressError="suppressError" + :isRequired=isRequired />
@@ -36,6 +37,7 @@ export default ({ validationRules: String, suppressError: Boolean, cmsWidgetName: String, + isRequired: Boolean, }, methods: { initializeComponent(replaceOptions){ diff --git a/src/layouts/vehicle-damage/side-door-options/side-door-options.vue b/src/layouts/vehicle-damage/side-door-options/side-door-options.vue index a79cdf49f..b4df1121e 100644 --- a/src/layouts/vehicle-damage/side-door-options/side-door-options.vue +++ b/src/layouts/vehicle-damage/side-door-options/side-door-options.vue @@ -10,6 +10,7 @@ buttonType="listCard" v-model="selectedDoorSidesValues" validationRules="damage-side-required" + isRequired />
@@ -22,6 +23,7 @@ filterByVehicleCategory v-model="selectedDriverSideReplaceOptionsValues" validationRules="driver-side-options-required" + isRequired /> diff --git a/src/layouts/vehicle-damage/vehicle-damage.spec.js b/src/layouts/vehicle-damage/vehicle-damage.spec.js index 7b28e4bb5..9a9f7e6d7 100644 --- a/src/layouts/vehicle-damage/vehicle-damage.spec.js +++ b/src/layouts/vehicle-damage/vehicle-damage.spec.js @@ -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", () => ({ @@ -703,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 // @@ -734,32 +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 } }, + 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: { diff --git a/src/layouts/vehicle-damage/vehicle-damage.vue b/src/layouts/vehicle-damage/vehicle-damage.vue index 543a02bef..862abaced 100644 --- a/src/layouts/vehicle-damage/vehicle-damage.vue +++ b/src/layouts/vehicle-damage/vehicle-damage.vue @@ -9,49 +9,57 @@ - - - - - - + + + + + + + @@ -78,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"; @@ -91,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 } ); @@ -139,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){ @@ -272,7 +287,7 @@ 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); @@ -287,6 +302,12 @@ export default { 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); @@ -409,6 +430,9 @@ export default { }) ); }, + shouldDisplayVehicleChangeAlert() { + return this.$route.params[this.routerParams.DISPLAY_VEHICLE_CHANGE_ALERT]; + }, shouldHideBackButton(){ return this.$store.getters.payment.insuranceCoverage.isVerified; } diff --git a/src/layouts/vehicle-damage/windshield-options/windshield-chip-count-question/windshield-chip-count-question.vue b/src/layouts/vehicle-damage/windshield-options/windshield-chip-count-question/windshield-chip-count-question.vue index fc29d1c34..25d273eca 100644 --- a/src/layouts/vehicle-damage/windshield-options/windshield-chip-count-question/windshield-chip-count-question.vue +++ b/src/layouts/vehicle-damage/windshield-options/windshield-chip-count-question/windshield-chip-count-question.vue @@ -9,6 +9,7 @@ useTextForValue v-model="selectedChipCountValues" :validationRules="validationRules" + isRequired /> diff --git a/src/layouts/vehicle-damage/windshield-options/windshield-damage-type-question/windshield-damage-type-question.vue b/src/layouts/vehicle-damage/windshield-options/windshield-damage-type-question/windshield-damage-type-question.vue index 3c377ec88..455c27361 100644 --- a/src/layouts/vehicle-damage/windshield-options/windshield-damage-type-question/windshield-damage-type-question.vue +++ b/src/layouts/vehicle-damage/windshield-options/windshield-damage-type-question/windshield-damage-type-question.vue @@ -9,6 +9,7 @@ v-model="selectedValues" :suppressError="suppressError" :validationRules="validationRules" + isRequired /> diff --git a/src/layouts/vehicle-damage/windshield-options/windshield-options.vue b/src/layouts/vehicle-damage/windshield-options/windshield-options.vue index df2560e17..1e9cfbc0a 100644 --- a/src/layouts/vehicle-damage/windshield-options/windshield-options.vue +++ b/src/layouts/vehicle-damage/windshield-options/windshield-options.vue @@ -27,6 +27,7 @@ v-model="selectedWindshieldReplaceOptionsValues" validationRules="windshield-replace-options-required|prevent-split-and-single-together" :suppressError="hasSplitSingleConflict" + isRequired /> diff --git a/src/layouts/vehicle-style/style-question/style-question.vue b/src/layouts/vehicle-style/style-question/style-question.vue index 0d3dd54ac..45c0f7dea 100644 --- a/src/layouts/vehicle-style/style-question/style-question.vue +++ b/src/layouts/vehicle-style/style-question/style-question.vue @@ -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, diff --git a/src/layouts/vehicle-style/vehicle-style.spec.js b/src/layouts/vehicle-style/vehicle-style.spec.js index 852bf248d..3d62e8f15 100644 --- a/src/layouts/vehicle-style/vehicle-style.spec.js +++ b/src/layouts/vehicle-style/vehicle-style.spec.js @@ -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(); }); }); diff --git a/src/layouts/vehicle-style/vehicle-style.vue b/src/layouts/vehicle-style/vehicle-style.vue index e98cc400d..8bb542154 100644 --- a/src/layouts/vehicle-style/vehicle-style.vue +++ b/src/layouts/vehicle-style/vehicle-style.vue @@ -76,7 +76,7 @@ export default { ); }, setVehicle() { - return this.dispatchNonBlockingStoreAction( + return this.dispatchStoreAction( this.storeActions.SET_VEHICLE, { year: this.$store.getters.vehicle.year, diff --git a/src/layouts/vehicle-year/vehicle-year.vue b/src/layouts/vehicle-year/vehicle-year.vue index 96f62409c..fec51eafd 100644 --- a/src/layouts/vehicle-year/vehicle-year.vue +++ b/src/layouts/vehicle-year/vehicle-year.vue @@ -44,7 +44,7 @@ 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(), diff --git a/src/layouts/vehicle-year/year-question/year-question.vue b/src/layouts/vehicle-year/year-question/year-question.vue index 8a53e83db..a5b263fe8 100644 --- a/src/layouts/vehicle-year/year-question/year-question.vue +++ b/src/layouts/vehicle-year/year-question/year-question.vue @@ -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, {} ); diff --git a/src/layouts/vin-lookup/vin-lookup.vue b/src/layouts/vin-lookup/vin-lookup.vue index 2d92eb69a..c6a497e6e 100644 --- a/src/layouts/vin-lookup/vin-lookup.vue +++ b/src/layouts/vin-lookup/vin-lookup.vue @@ -11,7 +11,7 @@
- +
@@ -21,12 +21,12 @@
- +
- +
{ + + // 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 ''; + } +} \ No newline at end of file diff --git a/src/mixins/analytics-mixin.spec.js b/src/mixins/analytics-mixin.spec.js new file mode 100644 index 000000000..b4ec2a5b5 --- /dev/null +++ b/src/mixins/analytics-mixin.spec.js @@ -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' } ]); + + }); + +}); \ No newline at end of file diff --git a/src/mixins/base-mixin.js b/src/mixins/base-mixin.js index 11947f26a..d8df53281 100644 --- a/src/mixins/base-mixin.js +++ b/src/mixins/base-mixin.js @@ -3,6 +3,7 @@ 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 { @@ -18,7 +19,7 @@ export default { 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); @@ -41,28 +42,6 @@ export default { el && el.focus(); } }, - - pushEventToGA(category, action, label, value, pageName) { - const eventToBePushed = { - 'event': 'ga_event', - 'category': category, - 'action': action, - 'label': label, - 'value': value, - 'path': `/fmg/?${queryStrings.FMG_PAGE}=${pageName}` - } - pushToDataLayerIfDefined(eventToBePushed); - }, - - pushPageViewToGA(pageName) { - const pageViewEvent = { - 'event': 'logPageview', - 'pagePath': `/fmg/?${queryStrings.FMG_PAGE}=${pageName}`, - 'pageTitle': pageName - }; - pushToDataLayerIfDefined(pageViewEvent); - } - }, computed: { storeActions() { @@ -77,6 +56,12 @@ export default { vehicleCategories() { return vehicleCategories; }, + routerParams() { + return routerParams; + }, + queryStrings(){ + return queryStrings; + } }, }; @@ -88,9 +73,3 @@ function encodeUriData(payload) { }); } } - -function pushToDataLayerIfDefined(data) { - if (window.dataLayer !== undefined) { - window.dataLayer.push(data); - } -} diff --git a/src/mixins/base-mixin.spec.js b/src/mixins/base-mixin.spec.js index 48af9a7c0..b0c8b26f3 100644 --- a/src/mixins/base-mixin.spec.js +++ b/src/mixins/base-mixin.spec.js @@ -6,22 +6,22 @@ import { vehicleCategories } from "@/constants/vehicle-categories.js"; import store from "@/store"; describe("baseMixin.js", () => { - test("dispatchNonblockingStoreAction: calls dispatch with type and payload", () => { + test("dispatchStoreAction: calls dispatch with type and payload", () => { const mixIn = getMixInInstance({}); const type = ""; const payload = {}; - mixIn.methods.dispatchNonBlockingStoreAction(type, payload); + mixIn.methods.dispatchStoreAction(type, payload); expect(store.dispatch).toBeCalledWith(type, payload); }); - test("dispatchNonblockingStoreAction: calls dispatch with type and payload, handles Uri encode", () => { + test("dispatchStoreAction: calls dispatch with type and payload, handles Uri encode", () => { const mixIn = getMixInInstance({}); 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); }); diff --git a/src/router/index.js b/src/router/index.js index 54c0bfac2..9ffd28522 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -5,6 +5,7 @@ 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"; @@ -15,11 +16,13 @@ import { getPageToRouteExistingOrderTo, navigateToHeritageFunnel } from "@/helpe 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. @@ -37,7 +40,6 @@ const routes = [ async beforeEnter(to, from, next) { // If we have no query string, or we don't have the FmgPage query string. try { - // If the saved session has timed out, clear the session, execute 404 logic. if (getFunnelCookie() !== null && !isSavedSessionStillActive()) { await GoToFunnelStartOn404(next); @@ -121,8 +123,11 @@ const router = createRouter({ //---------------------------------------------------------- Router Functions ---------------------------------------------------------- -router.afterEach((to, from) => { - baseMixin.methods.pushPageViewToGA(to.query[queryStrings.FMG_PAGE]); +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 = {}) => { diff --git a/src/router/router-constants/navigation-scenarios.js b/src/router/router-constants/navigation-scenarios.js index 57f07a041..5380a3fa7 100644 --- a/src/router/router-constants/navigation-scenarios.js +++ b/src/router/router-constants/navigation-scenarios.js @@ -13,7 +13,9 @@ const navigationScenarios = { 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 }; diff --git a/src/router/router-constants/router-params.js b/src/router/router-constants/router-params.js new file mode 100644 index 000000000..80ddb779d --- /dev/null +++ b/src/router/router-constants/router-params.js @@ -0,0 +1,5 @@ +const routerParams = { + DISPLAY_VEHICLE_CHANGE_ALERT: "displayVehicleChangeAlert" + }; + + export { routerParams }; \ No newline at end of file diff --git a/src/router/router-constants/routing-table.js b/src/router/router-constants/routing-table.js index f52f2d24f..3290c43eb 100644 --- a/src/router/router-constants/routing-table.js +++ b/src/router/router-constants/routing-table.js @@ -59,11 +59,11 @@ const routingTable = [ }, { scenario: navigationScenarios.CLICKED_FORWARD_WITH_VIN, - destinationFmgPageValue: fmgPageValues.VIN_LOOKUP, + destinationFmgPageValue: fmgPageValues.LICENSE_PLATE_LOOKUP, }, { scenario: navigationScenarios.SELECTED_DAMAGE_WITH_SINGLE_PART, - destinationFmgPageValue: fmgPageValues.VIN_LOOKUP, + destinationFmgPageValue: fmgPageValues.LICENSE_PLATE_LOOKUP, }, { scenario: navigationScenarios.SELECTED_DAMAGE_WITH_MULTIPLE_PARTS, @@ -71,7 +71,11 @@ const routingTable = [ }, { scenario: navigationScenarios.SELECTED_DAMAGE_WITH_PART_QUESTIONS, - destinationFmgPageValue: fmgPageValues.VIN_LOOKUP,//This might be temporary + destinationFmgPageValue: fmgPageValues.LICENSE_PLATE_LOOKUP,//This might be temporary + }, + { + scenario: navigationScenarios.TEMPORARY_TO_ADDRESS_LOOKUP, + destinationFmgPageValue: fmgPageValues.ADDRESS_LOOKUP, //This will be temporary }, ], }, @@ -151,6 +155,23 @@ const routingTable = [ }, ], }, + { + 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 }; diff --git a/src/store/index.js b/src/store/index.js index a9779f446..cf4ed40b6 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -138,8 +138,11 @@ export const mutations = { updateRegistrationState(state, registrationState){ state.order.vehicle.registration.state = registrationState; }, - updateRegistrationZipCode(state, reistrationZipCode){ - state.order.vehicle.registration.zipCode = reistrationZipCode; + updateRegistrationZipCode(state, registrationZipCode){ + state.order.vehicle.registration.zipCode = registrationZipCode; + }, + updateRegistrationAddress(state, registrationAddress){ + state.order.vehicle.registration.address = registrationAddress; }, updateRegistrationAddress(state, reistrationAddress){ state.order.vehicle.registration.address = reistrationAddress; @@ -298,6 +301,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, @@ -421,6 +436,41 @@ export const actions = { }); }, + 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({ diff --git a/src/store/store.spec.js b/src/store/store.spec.js index 0f8aab73a..e27569a6f 100644 --- a/src/store/store.spec.js +++ b/src/store/store.spec.js @@ -617,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", () => { diff --git a/src/styles/common-error-styles.scss b/src/styles/common-error-styles.scss index 165101177..67bd9473b 100644 --- a/src/styles/common-error-styles.scss +++ b/src/styles/common-error-styles.scss @@ -75,7 +75,7 @@ html { } input:checked:focus { + label { - box-shadow: 0 0 0 1px $blue; + box-shadow: 0 0 0 2.5px $blue; border-radius: .5rem; } } diff --git a/src/ux-components/button-main/button-main.spec.js b/src/ux-components/button-main/button-main.spec.js index ee5589a1d..2f0b4c72e 100644 --- a/src/ux-components/button-main/button-main.spec.js +++ b/src/ux-components/button-main/button-main.spec.js @@ -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; +} \ No newline at end of file diff --git a/src/ux-components/button-main/button-main.vue b/src/ux-components/button-main/button-main.vue index df422234d..00a17a7b6 100644 --- a/src/ux-components/button-main/button-main.vue +++ b/src/ux-components/button-main/button-main.vue @@ -16,6 +16,7 @@