diff --git a/src/assets/img/loader.gif b/src/assets/img/loader.gif new file mode 100644 index 00000000..c069b049 Binary files /dev/null and b/src/assets/img/loader.gif differ diff --git a/src/assets/img/windshield.png b/src/assets/img/windshield.png new file mode 100644 index 00000000..4f0c5e5d Binary files /dev/null and b/src/assets/img/windshield.png differ diff --git a/src/common-components/loading-modal/loading-modal.spec.js b/src/common-components/loading-modal/loading-modal.spec.js new file mode 100644 index 00000000..6367c22c --- /dev/null +++ b/src/common-components/loading-modal/loading-modal.spec.js @@ -0,0 +1,30 @@ +import { shallowMount } from "@vue/test-utils"; +import loadingModal from "./loading-modal"; +import { getMountOptions } from "@/helpers/unit-test-helper.js"; + +jest.mock("@/assets/img/loader.gif", () => "loader.gif"); +jest.mock("@/assets/img/windshield.png", () => "windshield.png"); + +describe("loadingModal", () => { + test("showModal sets modal visible", async () => { + // Arrange + const { wrapper } = setupMocks(); + wrapper.vm.isModalVisible = false; + + //Act + wrapper.vm.showModal(); + + // Assert + expect(wrapper.vm.isModalVisible).toEqual(true); + wrapper.unmount(); + }); +}); + +function setupMocks() { + + const mountOptions = getMountOptions({ + }); + + const wrapper = shallowMount(loadingModal, mountOptions); + return { wrapper }; +} diff --git a/src/common-components/loading-modal/loading-modal.vue b/src/common-components/loading-modal/loading-modal.vue new file mode 100644 index 00000000..9622435c --- /dev/null +++ b/src/common-components/loading-modal/loading-modal.vue @@ -0,0 +1,250 @@ + + + + + + + + Loading... + + + + + + + + Finding shops near you + . + . + . + + + Looking for dates + . + . + . + + + Searching for times + . + . + . + + + Nearly there + . + . + . + + + Finishing up + . + . + . + + + + + + + + + + + + diff --git a/src/common-components/questions-page-layout/questions-page-layout.spec.js b/src/common-components/questions-page-layout/questions-page-layout.spec.js new file mode 100644 index 00000000..48427452 --- /dev/null +++ b/src/common-components/questions-page-layout/questions-page-layout.spec.js @@ -0,0 +1,250 @@ +// Components +import questionsPageLayout from "@/common-components/questions-page-layout/questions-page-layout"; + +// Supporting Files +import { shallowMount } from "@vue/test-utils"; +import { getMountOptions } from "@/helpers/unit-test-helper.js"; +import baseMixin from "../../mixins/base-mixin"; +import vehicleQuestionsMixin from "@/mixins/vehicle-questions-mixin"; + +// 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("questionsPageLayout.vue", () => { + describe("method showThisQuestionChain...", () => { + test("Should return true if index prop and passed index match", async () => { + // Arrange + const { wrapper } = setupMocks({}); + await wrapper.setProps({ index: 0 }); + + const testGlassPiece = { + questions: [ + { + questionSequence: 1, + questionText: + "Is your vehicle equipped with the optional Lane-Keeping System which tugs on the steering wheel and/or beeps to alert you if you drift too close to the edge of the lane?", + answers: [], + }, + ], + }; + const testIndex = 0; + + // Act + const result = wrapper.vm.showThisQuestionChain(testGlassPiece, testIndex); + + //Assert + expect(result).toBe(true); + + wrapper.unmount(); + }); + + test("Should return true if answerResult exists", async () => { + // Arrange + const { wrapper } = setupMocks({}); + await wrapper.setProps({ index: 0 }); + + const testGlassPiece = { + questions: [ + { + questionSequence: 1, + questionText: + "Is your vehicle equipped with the optional Lane-Keeping System which tugs on the steering wheel and/or beeps to alert you if you drift too close to the edge of the lane?", + answers: [], + }, + ], + answerData: { + answerResult: "test", + }, + }; + const testIndex = 1; + + // Act + const result = wrapper.vm.showThisQuestionChain(testGlassPiece, testIndex); + + //Assert + expect(result).toBe(true); + + wrapper.unmount(); + }); + + test("Should return false if indexes don't match and answerResult is missing", async () => { + // Arrange + const { wrapper } = setupMocks({}); + await wrapper.setProps({ index: 0 }); + + const testGlassPiece = { + questions: [ + { + questionSequence: 1, + questionText: + "Is your vehicle equipped with the optional Lane-Keeping System which tugs on the steering wheel and/or beeps to alert you if you drift too close to the edge of the lane?", + answers: [], + }, + ], + }; + const testIndex = 1; + + // Act + const result = wrapper.vm.showThisQuestionChain(testGlassPiece, testIndex); + + //Assert + expect(result).toBe(false); + + wrapper.unmount(); + }); + + test("Should return false if no questions", async () => { + // Arrange + const { wrapper } = setupMocks({}); + await wrapper.setProps({ index: 0 }); + + const testGlassPiece = {}; + const testIndex = 0; + + // Act + const result = wrapper.vm.showThisQuestionChain(testGlassPiece, testIndex); + + //Assert + expect(result).toBe(false); + + wrapper.unmount(); + }); + + test("Should return false if suppressed", async () => { + // Arrange + const { wrapper } = setupMocks({}); + await wrapper.setProps({ index: 0 }); + + const testGlassPiece = { + isSuppressedPart: true, + questions: [ + { + questionSequence: 1, + questionText: + "Is your vehicle equipped with the optional Lane-Keeping System which tugs on the steering wheel and/or beeps to alert you if you drift too close to the edge of the lane?", + answers: [], + }, + ], + answerData: { + answerResult: "test", + }, + }; + const testIndex = 0; + + // Act + const result = wrapper.vm.showThisQuestionChain(testGlassPiece, testIndex); + + //Assert + expect(result).toBe(false); + + wrapper.unmount(); + }); + }); + + describe("method handleForwardButtonAction...", () => { + test("Should emit forwardButtonAction", async () => { + // Arrange + const { wrapper } = setupMocks({}); + await wrapper.setProps({}); + + // Act + wrapper.vm.handleForwardButtonAction(); + + //Assert + expect(wrapper.emitted()).toHaveProperty("forwardButtonAction"); + + wrapper.unmount(); + }); + }); + + describe("method handleBackButtonAction...", () => { + test("Should emit backButtonAction", async () => { + // Arrange + const { wrapper } = setupMocks({}); + await wrapper.setProps({}); + + // Act + wrapper.vm.handleBackButtonAction(); + + //Assert + expect(wrapper.emitted()).toHaveProperty("back-click"); + + wrapper.unmount(); + }); + }); +}); + +function setupMocks() { + const baseStoreGettersPageData = () => { + return { + partsOrQuestions: [ + { + parts: null, + partQuestions: [ + { + questionSequence: 1, + questionText: + "Is your vehicle equipped with the Panoramic Sunroof which can be identified by having a glass panel over the rear seats?", + answers: [ + { + answerResult: "", + answerText: "Yes", + nextQuestionSequence: 2, + }, + { + answerResult: "", + answerText: "No", + nextQuestionSequence: 3, + }, + ], + }, + ], + glassLocation: "Windshield", + glassName: "Single", + answerKey: "Windshield-Single", + answerData: null, + }, + ], + }; + }; + const baseStoreGettersDamage = () => { + return { + partsQuestionAnswers: [ + { + glassLocation: "Windshield", + glassName: "Single", + result: "FW04848", + answeredQuestions: [ + { + questionText: + "Is your vehicle equipped with the Panoramic Sunroof which can be identified by having a glass panel over the rear seats?", + selectedAnswerText: "Yes", + questionNum: 1, + }, + { + questionText: + "Is your vehicle equipped with a heated windshield that melts snow and ice from underneath the windshield wiper blades?", + selectedAnswerText: "Yes", + questionNum: 2, + }, + ], + }, + ], + }; + }; + const mountOptions = getMountOptions({ + mixins: [baseMixin, vehicleQuestionsMixin], + }); + mountOptions["attachTo"] = document.body; + const wrapper = shallowMount(questionsPageLayout, mountOptions); + + return { wrapper }; +} diff --git a/src/common-components/questions-page-layout/questions-page-layout.vue b/src/common-components/questions-page-layout/questions-page-layout.vue new file mode 100644 index 00000000..f2874093 --- /dev/null +++ b/src/common-components/questions-page-layout/questions-page-layout.vue @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/constants/endpoints.js b/src/constants/endpoints.js index 3aa3ce24..5778b79a 100644 --- a/src/constants/endpoints.js +++ b/src/constants/endpoints.js @@ -31,6 +31,14 @@ const endpoints = { url: "/parts/api/v1/parts/damage-options", method: "GET", }, + GetParts: { + url: "/parts/api/v1/parts/parts", + method: "POST", + }, + GetCapabilityQuestions: { + url: "/parts/api/v1/parts/capability-questions", + method: "GET", + }, GetVehicle: { url: "/vehicle/api/v1/vehicle/lookup", method: "GET", diff --git a/src/layouts/part-questions/part-questions.vue b/src/layouts/part-questions/part-questions.vue new file mode 100644 index 00000000..66c36158 --- /dev/null +++ b/src/layouts/part-questions/part-questions.vue @@ -0,0 +1,146 @@ + + + + + + + diff --git a/src/mixins/base-mixin.js b/src/mixins/base-mixin.js index 32134711..3b3abe33 100644 --- a/src/mixins/base-mixin.js +++ b/src/mixins/base-mixin.js @@ -24,6 +24,9 @@ export default { const footerInfoBox = document.querySelector(".footer#infoBox"); return footerInfoBox ? footerInfoBox.offsetHeight : 0; }, + savePageDataToStore(page, data) { + useMainStore().updatePageData({ page: page, data: data}); + }, }, computed: { // store will be accessible globally as its id + 'Store' diff --git a/src/mixins/vehicle-questions-mixin.js b/src/mixins/vehicle-questions-mixin.js new file mode 100644 index 00000000..d06c1010 --- /dev/null +++ b/src/mixins/vehicle-questions-mixin.js @@ -0,0 +1,487 @@ +import { issPageValues } from "@/router/router-constants/issPage-values"; +import { navigationScenarios } from "@/router/router-constants/navigation-scenarios"; +import { useMainStore } from "../store"; + +export default { + methods: { + hasPartQuestions(partsOrQuestions) { + return partsOrQuestions?.some((pq) => pq.partQuestions?.length > 0); + }, + hasGlassLocationWithMultipleParts(partsOrQuestions) { + return partsOrQuestions?.some((pq) => pq.parts?.length > 1); + }, + hasChildPartQuestions(partsOrQuestions) { + return partsOrQuestions?.some((pq) => { + return pq.parts?.some((part) => part.childPartQuestions?.length > 0); + }); + }, + hasCapabilityQuestions(partsOrQuestions) { + return partsOrQuestions?.some((pq) => { + return pq.parts?.some((part) => part.requiresCapabilityQuestions === true); + }); + }, + + // method to only include keys listed for lineItems.glassParts in + reducedGlassPartsArray(glassParts) { + const reducedGlassParts = []; + glassParts.forEach((glass) => { + if (Array.isArray(glass.parts) && glass.parts.length === 1) { + const singlePart = glass.parts[0]; + reducedGlassParts.push({ + partNumber: singlePart.partNumber, + description: singlePart.description, + color: singlePart.color, + partType: singlePart.partType, + canSafeliteRecalibrate: singlePart.canSafeliteRecalibrate, + requiresRecalibration: singlePart.requiresRecalibration, + requiresCapabilityQuestions: singlePart.requiresCapabilityQuestions, + recalibrationType: singlePart.recalibrationType, + childParts: singlePart.childParts, + price: singlePart.price, + }); + } + }); + return reducedGlassParts; + }, + comparePageIndices(currentPage, issPage) { + const orderedVehicleQuestionPages = [ + issPageValues.PART_QUESTIONS, + issPageValues.VEHICLE_PARTS, + issPageValues.MOLDING_QUESTIONS, + issPageValues.CAPABILITY_QUESTIONS, + issPageValues.QUOTE, + ]; + + return ( + orderedVehicleQuestionPages.indexOf(currentPage) - + orderedVehicleQuestionPages.indexOf(issPage) + ); + }, + currentPageComesBeforePage(currentPage = this.$route.query.issPage, issPage) { + return this.comparePageIndices(currentPage, issPage) < 0; + }, + currentPageComesAfterPage(currentPage = this.$route.query.issPage, issPage) { + return this.comparePageIndices(currentPage, issPage) > 0; + }, + setupInitialData(glass, index, alreadyAnsweredQuestions, vm) { + const self = vm ?? this; + + // clear answerData if no questions are already answered + if (!alreadyAnsweredQuestions) { + glass.answerData = null; + } + + alreadyAnsweredQuestions?.forEach((answeredGlass) => { + // if answeredGlass lacks any of these properties then exit + if ( + !answeredGlass.glassLocation || + !answeredGlass.glassName || + !answeredGlass.answeredQuestions || + (!answeredGlass.result && !answeredGlass.partNum) + ) { + return; + } + + // test if glass parts match + if ( + glass.glassLocation === answeredGlass.glassLocation && + glass.glassName === answeredGlass.glassName + ) { + let answerString = ""; + + // loop through answeredQuestions for matches + answeredGlass.answeredQuestions.forEach((answeredQuestion) => { + if (!answeredQuestion.questionNum || !answeredQuestion.selectedAnswerText) { + return; + } + // determine which answer was previously chosen + const chosenAns = glass.questions[ + answeredQuestion.questionNum - 1 + ].answers.find((a) => { + return ( + a.answerText.toUpperCase() === + answeredQuestion.selectedAnswerText.toUpperCase() + ); + }); + // set the answerString to use for answerSelected + if (chosenAns.nextQuestionSequence) { + answerString = `${answeredQuestion.questionNum}|nextQuestion|${chosenAns.nextQuestionSequence}|${chosenAns.answerText}`; + } else { + answerString = `${answeredQuestion.questionNum}|answer|${chosenAns.answerResult}|${chosenAns.answerText}`; + } + + // mark this question as answered (question-chain will read this) + glass.questions[answeredQuestion.questionNum - 1].answerSelected = + answerString; + // mark this question as suppressed if needed (question-chain uses this) + if (answeredQuestion.suppressThisQuestion) { + glass.questions[ + answeredQuestion.questionNum - 1 + ].suppressThisQuestion = true; + } + }); + + // advance the currentGlassIndex + self.currentGlassIndex = index; + + const answerResult = answeredGlass.partNum + ? answeredGlass.partNum + : answeredGlass.result; + // the above logic for answerResult covers all 3 ___-questions page scenarios + + // add answerData to current glass + glass.answerData = { + answerResult: answerResult, + answeredQuestions: answeredGlass.answeredQuestions, + }; + } + }); + + return glass; + }, + handleAnswerUpdates(answer, glassKey, vm) { + // only runs when all questions in a question-chain have been answered + // when selectedAnswers updates, user has completed this part's question chain and has a final answer + // (does not get run for each invididual question's answer, only when + // all relevant questions for the current part have been answered) + + /* answer example format: + { + "answerResult": "DD11132", + "answeredQuestions": [ + { + "questionText": "Is your Grand Cherokee the Laredo model?", + "selectedAnswerText": "Yes", + "questionNum": 1, + } + ], + "index": 0 + } + */ + + const self = vm ?? this; + + // clear out any preloaded answers + self.selectedAnswers = {}; + + // loop through every answered question on the currently answered glass part + answer.answeredQuestions?.forEach((answeredQuestion) => { + /* answeredQuestion example format: + { + "questionText": "Is your Grand Cherokee the Laredo model?", + "selectedAnswerText": "Yes", + "questionNum": 1, + } + */ + + const answeredQuestionText = answeredQuestion.questionText.toUpperCase(); + const answeredQuestionAnswer = answeredQuestion.selectedAnswerText.toUpperCase(); + + // HANDLE DUPLICATE QUESTIONS + + // loop through all glass data + self.questionsData.forEach((glass, glassIndex) => { + /* glass example format: + { + "glassName": "Single", + "glassLocation": "Windshield", + "parts": null, + "questions": [ + { + "questionSequence": 1, + "questionText": "Is your vehicle equipped with a black dotted pattern behind the rear view mirror, known as a third visor frit?", + "answers": [ + { + "answerResult": "DW01537", + "answerText": "Yes", + "nextQuestionSequence": null + }, + { + "answerResult": "DW01537b", + "answerText": "No", + "nextQuestionSequence": null + } + ] + } + ], + "answerKey": "Windshield-Single", + "answerData": null + } + */ + + // limit duplicate search to glass pieces that follow after the currently being answered glass piece + if (glassIndex > answer.index) { + let indexToSuppressTo; + // reset this glass piece, in case user is changing their previous answers + glass.answerData = null; + glass.isSuppressedPart = null; + + // loop through this glass piece's questions, looking for a questionText match + glass.questions.forEach((question, questionIndex) => { + // clear out any previously set answers + question.answerSelected = null; + + // clear or set suppressThisQuestion property for each question + if (indexToSuppressTo && questionIndex + 1 < indexToSuppressTo) { + question.suppressThisQuestion = true; + } else { + question.suppressThisQuestion = null; + } + + // handle duplicate questions + if (question.questionText.toUpperCase() === answeredQuestionText) { + let matchedAnswer; + + // handle matching answer in duplicated question + question.answers.forEach((ans) => { + if (ans.answerText.toUpperCase() === answeredQuestionAnswer) { + matchedAnswer = ans; + ans.selected = true; + } else { + ans.selected = null; + } + }); + + // handle duplicate's nextQuestion logic on other related questions + if (matchedAnswer.nextQuestionSequence) { + // clear any suppression on the nextQuestion + glass.questions[ + matchedAnswer.nextQuestionSequence - 1 + ].suppressThisQuestion = null; + // if the duplicate is 1ST question in array, set indexToSuppressTo + if (questionIndex === 0) { + if ( + !indexToSuppressTo || + matchedAnswer.nextQuestionSequence < indexToSuppressTo + ) { + indexToSuppressTo = matchedAnswer.nextQuestionSequence; + } + } + } + + // update answers in this glass piece's questions with duplication logic modifications + glass.questions.forEach((q) => { + q.answers.forEach((a) => { + // revert any previously set nextQuestion logic modifications + if ( + a.originalNextQuestionSequence === + question.questionSequence + ) { + // restore original nextQuestionSequence + a.nextQuestionSequence = a.originalNextQuestionSequence; + self.originalNextQuestionSequence = null; + // restore original answerResult + if (a.originalAnswerResult) { + a.answerResult = a.originalAnswerResult; + a.originalAnswerResult = null; + } + } + // modify logic on any related questions + if (a.nextQuestionSequence === question.questionSequence) { + // update either nextQuestionSequence or answerResult + + // update questions that lead to duplicated question + if (matchedAnswer.nextQuestionSequence) { + a.originalNextQuestionSequence = + a.nextQuestionSequence; + a.nextQuestionSequence = + matchedAnswer.nextQuestionSequence; + } else { + a.originalNextQuestionSequence = + a.nextQuestionSequence; + a.nextQuestionSequence = null; + a.originalAnswerResult = + a.originalAnswerResult || a.answerResult; + a.answerResult = matchedAnswer.answerResult; + } + } + }); + }); + + // suppress current question + question.suppressThisQuestion = true; + + // are there any questions left that are not suppressed? + const remainingQuestions = glass.questions.filter((q) => { + return !q.suppressThisQuestion; + }); + + if (remainingQuestions.length < 1) { + // this is the final answer for this glass piece + + // mark this glass piece as completely answered by adding answerData + const answeredQuestionObj = { + questionText: question.questionText, + selectedAnswerText: matchedAnswer.answerText, + questionNum: question.questionSequence, + suppressThisQuestion: question.suppressThisQuestion, + }; + // set the answerData (used as indicator that it has been already answered) + glass.answerData = { + answerResult: matchedAnswer.nextQuestionSequence + ? matchedAnswer.nextQuestionSequence + : matchedAnswer.answerResult, + answeredQuestions: [answeredQuestionObj], + }; + + // suppress this glass piece because it has an answer + glass.isSuppressedPart = true; + } + } + }); + + // Update key to force re-render of glass piece with duplicate question in case user changes previous related answer in the chain + self.questionsData[glassIndex].key = + self.questionsData[glassIndex].key + Date.now().toString(); + } + }); + }); + + // set final answer data for the current answered glass part + self.questionsData[answer.index].answerData = { + answerResult: answer.answerResult, + answeredQuestions: answer.answeredQuestions, + }; + + // this part has been fully answered, so advance to next part's question chain + for (let i = answer.index + 1; i < this.questionsData.length; i++) { + // if this part has not yet been fully answered, then make it the current part + if (!this.questionsData[i].answerData?.answerResult) { + this.currentGlassIndex = i; + break; + } + } + }, + + // Can't use `this` because navigateForward is also called from vin-pages-mixin + async navigateForward(partsOrQuestions, vm) { + const self = vm ?? this; + const currentPage = self.$route.query.issPage; + + const hasPartQuestions = this.hasPartQuestions(partsOrQuestions); + const hasGlassLocationWithMultipleParts = + this.hasGlassLocationWithMultipleParts(partsOrQuestions); + const hasChildPartQuestions = this.hasChildPartQuestions(partsOrQuestions); + const hasCapabilityQuestions = this.hasCapabilityQuestions(partsOrQuestions); + + if (hasPartQuestions && this.currentPageComesBeforePage(currentPage, issPageValues.PART_QUESTIONS)) + { + self.$router.navigate(self.navigationScenarios.CLICKED_FORWARD_WITH_PART_QUESTIONS, self.$route, + {}, + {}, + { partsOrQuestions: partsOrQuestions } + ); + } else if (hasGlassLocationWithMultipleParts && this.currentPageComesBeforePage(currentPage, issPageValues.VEHICLE_PARTS)) + { + // if multiple parts on any glass + // go to vehicle-parts page and pass the partsData + self.$router.navigate( + self.navigationScenarios.CLICKED_FORWARD_WITH_MULTIPLE_PARTS_TO_CHOOSE, + self.$route, + {}, + {}, + { partsOrQuestions: partsOrQuestions } + ); + } else if ( + hasChildPartQuestions && + this.currentPageComesBeforePage(currentPage, issPageValues.MOLDING_QUESTIONS) + ) { + // if any childpart questions + // go to molding-questions page and pass the partsData + self.$router.navigate( + self.navigationScenarios.CLICKED_FORWARD_WITH_MOLDING_QUESTIONS, + self.$route, + {}, + {}, + { partsOrQuestions: partsOrQuestions } + ); + } else if ( + hasCapabilityQuestions && + this.currentPageComesBeforePage(currentPage, issPageValues.CAPABILITY_QUESTIONS) + ) { + // if has capability questions + // go to capability-questions page and pass the partsData + + // mimic part-questions page data for consistency + for (let partOrQuestion of partsOrQuestions) { + if (this.hasCapabilityQuestions([partOrQuestion])) { + let capabilityQuestionsForGlassLocation = ( + await self.mainStore.getCapabilityQuestions(useMainStore().vehicle.carId, partOrQuestion.parts[0].partNumber) + ).data; + + capabilityQuestionsForGlassLocation.forEach((question) => { + question.answers = question.answers.map((answer) => { + return { + ...answer, + answerResult: answer.answerResult1, + }; + }); + }); + + partOrQuestion.capabilityQuestions = capabilityQuestionsForGlassLocation; + } + } + + self.$router.navigate( + self.navigationScenarios.CLICKED_FORWARD_WITH_CAPABILITY_QUESTIONS, + self.$route, + {}, + {}, + { partsOrQuestions } + ); + } else { + // if single parts only + const collectedGlassParts = this.reducedGlassPartsArray(partsOrQuestions); + + // save to store lineItems.glassParts + self.mainStore.updateGlassParts(collectedGlassParts); + + self.$router.navigate(self.navigationScenarios.CLICKED_FORWARD_WITH_NO_MORE_QUESTIONS,self.$route); + } + }, + + navigateBack(vm) { + const self = vm ?? this; + const partsOrQuestions = ( + self.mainStore.pageData(issPageValues.CAPABILITY_QUESTIONS) ?? + self.mainStore.pageData(issPageValues.MOLDING_QUESTIONS) ?? + self.mainStore.pageData(issPageValues.VEHICLE_PARTS) ?? + self.mainStore.pageData(issPageValues.PART_QUESTIONS) + )?.partsOrQuestions; + const hasPartQuestions = this.hasPartQuestions(partsOrQuestions); + const hasGlassLocationWithMultipleParts = + this.hasGlassLocationWithMultipleParts(partsOrQuestions); + const hasChildPartQuestions = this.hasChildPartQuestions(partsOrQuestions); + const hasCapabilityQuestions = this.hasCapabilityQuestions(partsOrQuestions); + let backNavigationScenario = self.mainStore.vehicle.vin + ? navigationScenarios.CLICKED_BACK_WITH_VIN_AND_NO_MORE_QUESTIONS + : navigationScenarios.CLICKED_BACK_WITH_NO_VIN_NOR_QUESTIONS; + + const currentPage = self.$route.query.issPage; + if ( + hasCapabilityQuestions && + this.currentPageComesAfterPage(currentPage, issPageValues.CAPABILITY_QUESTIONS) + ) { + backNavigationScenario = navigationScenarios.CLICKED_BACK_WITH_CAPABILITY_QUESTIONS; + } else if ( + hasChildPartQuestions && + this.currentPageComesAfterPage(currentPage, issPageValues.MOLDING_QUESTIONS) + ) { + backNavigationScenario = navigationScenarios.CLICKED_BACK_WITH_MOLDING_QUESTIONS; + } else if ( + hasGlassLocationWithMultipleParts && + this.currentPageComesAfterPage(currentPage, issPageValues.VEHICLE_PARTS) + ) { + backNavigationScenario = + navigationScenarios.CLICKED_BACK_WITH_MULTIPLE_PARTS_TO_CHOOSE; + } else if ( + hasPartQuestions && + this.currentPageComesAfterPage(currentPage, issPageValues.PART_QUESTIONS) + ) { + backNavigationScenario = navigationScenarios.CLICKED_BACK_WITH_PART_QUESTIONS; + } + + self.$router.navigate(backNavigationScenario, self.$route); + }, + }, +}; diff --git a/src/router/index.js b/src/router/index.js index 3f647a5f..92cc932a 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -5,6 +5,7 @@ import { routingTable } from "@/router/router-constants/routing-table"; import { useMainStore } from '@/store'; import eventBus from "@/helpers/event-bus/event-bus"; import { globalEvents, globalEventTypes } from "@/constants/events"; +import baseMixin from "@/mixins/base-mixin"; import { getDeviceIdValue, updateOrCreateISSCookie, @@ -105,14 +106,14 @@ async function GetRouteInfoFromPageName(pageName) { return routeData; }; -router.navigate = (scenario, currentRoute, optionalQuery = {}, optionalParams = {}) => { +router.navigate = (scenario, currentRoute, optionalQuery = {}, optionalParams = {}, optionalPageData = {}) => { - navigate(scenario, currentRoute, false, optionalQuery, optionalParams); + navigate(scenario, currentRoute, false, optionalQuery, optionalParams, optionalPageData); } // Navigate to the next route, depending on the scenario. -function navigate (scenario, currentRoute, optionalQuery = {}, optionalParams = {}) { +function navigate (scenario, currentRoute, optionalQuery = {}, optionalParams = {}, optionalPageData = {}) { if (!scenario) { console.error("No scenario provided. Please review the routing table."); return; @@ -127,6 +128,16 @@ function navigate (scenario, currentRoute, optionalQuery = {}, optionalParams = } if (matchingScenarioMap.destinationIssPageValue) { + + // Update page data to the store for next page if provided. Otherwise, keep existing page data or set to empty object + const existingPageDataForPage = useMainStore().pageData(matchingScenarioMap.destinationIssPageValue); + baseMixin.methods.savePageDataToStore( + matchingScenarioMap.destinationIssPageValue, + Object.keys(optionalPageData).length > 0 + ? optionalPageData + : existingPageDataForPage ?? {} + ); + // We're always pushing the same path, just changing query strings. Make sure our optional query strings get combined with our issPage one. router.push({ name: "root", diff --git a/src/router/router-constants/navigation-scenarios.js b/src/router/router-constants/navigation-scenarios.js index 816f0fd2..fe2040f3 100644 --- a/src/router/router-constants/navigation-scenarios.js +++ b/src/router/router-constants/navigation-scenarios.js @@ -9,8 +9,29 @@ const navigationScenarios = { SELECTED_MODEL: "SELECTED_MODEL", SELECTED_STYLE: "SELECTED_STYLE", - // TESTING - CLICKED_TEST: "CLICKED_TEST" + // Vin selection + CLICKED_BACK_WITH_VIN: "CLICKED_BACK_WITH_VIN", + CLICKED_FORWARD_WITH_VIN: "CLICKED_FORWARD_WITH_VIN", + CLICKED_FORWARD_WITHOUT_VIN: "CLICKED_FORWARD_WITHOUT_VIN", + CLICKED_FORWARD_WITH_MULTIPLE_VEHICLES: "CLICKED_FORWARD_WITH_MULTIPLE_VEHICLES", + SELECTED_VIN_WITH_MISMATCHED_GLASS: "SELECTED_VIN_WITH_MISMATCHED_GLASS", + SELECTED_MANUAL_VIN: "SELECTED_MANUAL_VIN", + SELECTED_LICENSE_PLATE: "SELECTED_LICENSE_PLATE", + SELECTED_HOME_ADDRESS: "SELECTED_HOME_ADDRESS", + CLICKED_FORWARD_WITH_NO_QUESTIONS: "CLICKED_FORWARD_WITH_NO_QUESTIONS", + + // Part selection + CLICKED_FORWARD_WITH_PART_QUESTIONS: "CLICKED_FORWARD_WITH_PART_QUESTIONS", + CLICKED_FORWARD_WITH_MULTIPLE_PARTS_TO_CHOOSE: "CLICKED_FORWARD_WITH_MULTIPLE_PARTS_TO_CHOOSE", + CLICKED_FORWARD_WITH_MOLDING_QUESTIONS: "CLICKED_FORWARD_WITH_MOLDING_QUESTIONS", + CLICKED_FORWARD_WITH_CAPABILITY_QUESTIONS: "CLICKED_FORWARD_WITH_CAPABILITY_QUESTIONS", + CLICKED_FORWARD_WITH_NO_MORE_QUESTIONS: "CLICKED_FORWARD_WITH_NO_MORE_QUESTIONS", + CLICKED_BACK_WITH_PART_QUESTIONS: "CLICKED_BACK_WITH_PART_QUESTIONS", + CLICKED_BACK_WITH_MULTIPLE_PARTS_TO_CHOOSE: "CLICKED_BACK_WITH_MULTIPLE_PARTS_TO_CHOOSE", + CLICKED_BACK_WITH_MOLDING_QUESTIONS: "CLICKED_BACK_WITH_MOLDING_QUESTIONS", + CLICKED_BACK_WITH_CAPABILITY_QUESTIONS: "CLICKED_BACK_WITH_CAPABILITY_QUESTIONS", + CLICKED_BACK_WITH_VIN_AND_NO_MORE_QUESTIONS: "CLICKED_BACK_WITH_VIN_AND_NO_MORE_QUESTIONS", + CLICKED_BACK_WITH_NO_VIN_NOR_QUESTIONS: "CLICKED_BACK_WITH_NO_VIN_NOR_QUESTIONS", }; export { navigationScenarios }; \ 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 717ef11e..cfa431f0 100644 --- a/src/router/router-constants/routing-table.js +++ b/src/router/router-constants/routing-table.js @@ -73,6 +73,126 @@ const routingTable = function(store) { }, ], }, + { + issPageValue: issPageValues.PART_QUESTIONS, + maps: [ + { + scenario: navigationScenarios.CLICKED_BACK_WITH_VIN_AND_NO_MORE_QUESTIONS, + destinationIssPageValue: issPageValues.VIN_LOOKUP, + }, + { + scenario: navigationScenarios.CLICKED_BACK_WITH_NO_VIN_NOR_QUESTIONS, + destinationIssPageValue: issPageValues.ESTIMATE, + }, + { + scenario: navigationScenarios.CLICKED_FORWARD_WITH_MULTIPLE_PARTS_TO_CHOOSE, + destinationIssPageValue: issPageValues.VEHICLE_PARTS, + }, + { + scenario: navigationScenarios.CLICKED_FORWARD_WITH_MOLDING_QUESTIONS, + destinationIssPageValue: issPageValues.MOLDING_QUESTIONS, + }, + { + scenario: navigationScenarios.CLICKED_FORWARD_WITH_CAPABILITY_QUESTIONS, + destinationIssPageValue: issPageValues.CAPABILITY_QUESTIONS, + }, + { + scenario: navigationScenarios.CLICKED_FORWARD_WITH_NO_MORE_QUESTIONS, + destinationIssPageValue: issPageValues.QUOTE, + }, + ], + }, + { + issPageValue: issPageValues.VEHICLE_PARTS, + maps: [ + { + scenario: navigationScenarios.CLICKED_FORWARD, + destinationIssPageValue: issPageValues.REVEAL, + }, + { + scenario: navigationScenarios.CLICKED_BACK_WITH_PART_QUESTIONS, + destinationIssPageValue: issPageValues.PART_QUESTIONS, + }, + { + scenario: navigationScenarios.CLICKED_BACK_WITH_VIN_AND_NO_MORE_QUESTIONS, + destinationIssPageValue: issPageValues.VIN_LOOKUP, + }, + { + scenario: navigationScenarios.CLICKED_BACK_WITH_NO_VIN_NOR_QUESTIONS, + destinationIssPageValue: issPageValues.ESTIMATE, + }, + { + scenario: navigationScenarios.CLICKED_FORWARD_WITH_MOLDING_QUESTIONS, + destinationIssPageValue: issPageValues.MOLDING_QUESTIONS, + }, + { + scenario: navigationScenarios.CLICKED_FORWARD_WITH_CAPABILITY_QUESTIONS, + destinationIssPageValue: issPageValues.CAPABILITY_QUESTIONS, + }, + { + scenario: navigationScenarios.CLICKED_FORWARD_WITH_NO_MORE_QUESTIONS, + destinationIssPageValue: issPageValues.QUOTE, + }, + ], + }, + { + issPageValue: issPageValues.MOLDING_QUESTIONS, + maps: [ + { + scenario: navigationScenarios.CLICKED_BACK_WITH_VIN_AND_NO_MORE_QUESTIONS, + destinationIssPageValue: issPageValues.VIN_LOOKUP, + }, + { + scenario: navigationScenarios.CLICKED_BACK_WITH_NO_VIN_NOR_QUESTIONS, + destinationIssPageValue: issPageValues.ESTIMATE, + }, + { + scenario: navigationScenarios.CLICKED_BACK_WITH_PART_QUESTIONS, + destinationIssPageValue: issPageValues.PART_QUESTIONS, + }, + { + scenario: navigationScenarios.CLICKED_BACK_WITH_MULTIPLE_PARTS_TO_CHOOSE, + destinationIssPageValue: issPageValues.VEHICLE_PARTS, + }, + { + scenario: navigationScenarios.CLICKED_FORWARD_WITH_CAPABILITY_QUESTIONS, + destinationIssPageValue: issPageValues.CAPABILITY_QUESTIONS, + }, + { + scenario: navigationScenarios.CLICKED_FORWARD_WITH_NO_MORE_QUESTIONS, + destinationIssPageValue: issPageValues.QUOTE, + }, + ], + }, + { + issPageValue: issPageValues.CAPABILITY_QUESTIONS, + maps: [ + { + scenario: navigationScenarios.CLICKED_BACK_WITH_VIN_AND_NO_MORE_QUESTIONS, + destinationIssPageValue: issPageValues.VIN_LOOKUP, + }, + { + scenario: navigationScenarios.CLICKED_BACK_WITH_NO_VIN_NOR_QUESTIONS, + destinationIssPageValue: issPageValues.ESTIMATE, + }, + { + scenario: navigationScenarios.CLICKED_BACK_WITH_PART_QUESTIONS, + destinationIssPageValue: issPageValues.PART_QUESTIONS, + }, + { + scenario: navigationScenarios.CLICKED_BACK_WITH_MULTIPLE_PARTS_TO_CHOOSE, + destinationIssPageValue: issPageValues.VEHICLE_PARTS, + }, + { + scenario: navigationScenarios.CLICKED_BACK_WITH_MOLDING_QUESTIONS, + destinationIssPageValue: issPageValues.MOLDING_QUESTIONS, + }, + { + scenario: navigationScenarios.CLICKED_FORWARD_WITH_NO_MORE_QUESTIONS, + destinationIssPageValue: issPageValues.QUOTE, + }, + ], + }, { issPageValue: issPageValues.WELCOME_PAGE, maps: [ diff --git a/src/router/router.spec.js b/src/router/router.spec.js index 521b382e..c21d2752 100644 --- a/src/router/router.spec.js +++ b/src/router/router.spec.js @@ -1,35 +1,19 @@ import router from "@/router/"; import { navigationScenarios } from "@/router/router-constants/navigation-scenarios"; import { issPageValues } from "@/router/router-constants/issPage-values"; - import { createApp } from 'vue'; import { createPinia } from "pinia"; import App from '@/App.vue'; -const originalLocation = global.location - describe("Router", () => { beforeAll(() => { const vueApp = createApp(App); const pinia = createPinia(); vueApp.use(pinia); - }) - - it("Should change the location when navigating to external site", () => { - delete global.location - global.location = { assign: jest.fn() } - - let scenario = navigationScenarios.CLICKED_TEST; - let currentRoute = { query: { issPage: issPageValues.WELCOME_PAGE }}; - - router.navigate(scenario, currentRoute); - - expect(global.location.assign).toHaveBeenCalledTimes(1) - - global.location = originalLocation; }); + it("Should push next view when navigating locally", () => { let scenario = navigationScenarios.CLICKED_FORWARD; let currentRoute = { query: { issPage: issPageValues.WELCOME_PAGE }}; @@ -37,7 +21,7 @@ describe("Router", () => { router.push = jest.fn(); router.navigate(scenario, currentRoute); - expect(router.push.mock.calls[0][0].query.issPage).toBe(issPageValues.VEHICLE_YEAR) + expect(router.push.mock.calls[0][0].query.issPage).toBe(issPageValues.VEHICLE_YEAR); }); diff --git a/src/store/index.js b/src/store/index.js index f29b2a4e..6a098a76 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -41,8 +41,15 @@ const getDefaultState = () => { moldingQuestionAnswers: null, capabilityQuestionAnswers: null, }, + serviceLocation: { + address: null, + city: null, + state: null, + zipCode: null, + }, lineItems: { glassParts: null, + otherParts: null }, payment: { isInsurance: true, @@ -224,6 +231,49 @@ export const useMainStore = defineStore({ payload: {}, }); }, + + async getParts() { + const vehicle = this.getters.vehicle; + const damage = this.getters.damage; + const order = this.order; + + const carId = vehicle.carId; + const glassArray = damage.glassToReplace; + const resultsArray = damage.partQuestionAnswers; + const zipCode = order.serviceLocation.zipCode; + const vin = vehicle.vin; + + // create a new array to avoid mutating state + const glassArrayForPayload = convertGlassPieceNamingForApi(glassArray); + const resultsArrayForPayload = convertResultsForApi(resultsArray); + + const response = await globalMethods.callHttpClient({ + method: endpoints.GetParts.method, + endpoint: endpoints.GetParts.url, + payload: { + carId: carId, + glassPieces: glassArrayForPayload, + answerResults: resultsArrayForPayload, + zip: zipCode, + vin: vin, + }, + }); + + // Flatten location and name properties + response.data.glassPieceParts = convertGlassPieceNamingFromApi( + response.data.glassPieceParts + ); + + return response; + }, + + getCapabilityQuestions(carId, partNumber) { + return globalMethods.callHttpClient({ + method: endpoints.GetCapabilityQuestions.method, + endpoint: `${endpoints.GetCapabilityQuestions.url}/${carId}/${partNumber}`, + }); + }, + setVehicle() { return globalMethods .callHttpClient({ @@ -376,6 +426,55 @@ export const useMainStore = defineStore({ updateGlassToReplace(glassToReplace) { this.order.damage.glassToReplace = glassToReplace; }, + + updateGlassParts(partsData) { + this.order.lineItems.glassParts = partsData; + }, + + updateMoldingQuestionAnswers(answersArray) { + this.order.damage.moldingQuestionAnswers = answersArray; + }, + + updateCapabilityQuestionAnswers(answersArray) { + this.order.damage.capabilityQuestionAnswers = answersArray; + }, + + updatePartQuestionAnswers(answersArray) { + this.order.damage.partQuestionAnswers = answersArray; + }, + + updatePageData(pageData) { + this.applicationUser.pageData[pageData.page] = pageData.data; + }, + + savePartQuestionAnswers(partQuestionAnswersArray) { + // if part question answers have changed, reset subsequent question answers + const sortedPreviousResultsArray = sortArrayOfObjectsByPropertyValue( + context.getters.damage.partQuestionAnswers, + "result" + ); + const sortedPartQuestionAnswersArray = sortArrayOfObjectsByPropertyValue( + partQuestionAnswersArray, + "result" + ); + const havePartQuestionAnswersChanged = + sortedPreviousResultsArray?.length !== sortedPartQuestionAnswersArray.length || + !sortedPreviousResultsArray?.every( + (x, i) => x.result === sortedPartQuestionAnswersArray[i].result + ); + + if (havePartQuestionAnswersChanged) { + this.updateGlassParts(null); + this.updateMoldingQuestionAnswers(null); + this.updateCapabilityQuestionAnswers(null); + this.updatePageData({ page: issPageValues.VEHICLE_PARTS, data: null}); + this.updatePageData({ page: issPageValues.MOLDING_QUESTIONS, data: null}); + this.updatePageData({ page: issPageValues.CAPABILITY_QUESTIONS, data: null}); + } + + //Save new values + this.updatePartQuestionAnswers(partQuestionAnswersArray); + }, addEventToBus (event) { this.applicationUser.eventBus.push(event); @@ -574,3 +673,56 @@ function getHasRecalibrationPart(state) { function getNonFalseValuesOfPropertyInArrayOfObjects(array, propertyName) { return (array ?? []).map((x) => x[propertyName]).filter((x) => x); } + + +function sortArrayOfObjectsByPropertyValue(arrayOfObjects, propertyName) { + if (!arrayOfObjects) return null; + + return arrayOfObjects.sort((a, b) => { + if (a[propertyName] < b[propertyName]) return -1; + else if (a[propertyName] > b[propertyName]) return 1; + else return 0; + }); +} + +function convertGlassPieceNamingForApi(glassArray) { + if (!glassArray || glassArray.length === 0) return []; + + // check if array already converted. (likely when a session has been saved previously and then reloaded) + if (glassArray[0].location !== undefined) { + return glassArray; + } + + const converted = []; + glassArray.forEach((glass) => { + converted.push({ + location: glass.glassLocation, + name: glass.glassName, + }); + }); + return converted; +} + +function convertResultsForApi(resultsArray) { + if (!resultsArray) return []; + const converted = []; + resultsArray.forEach((answer) => { + converted.push({ + location: answer.glassLocation, + name: answer.glassName, + result: answer.result, + }); + }); + return converted; +} + +function convertGlassPieceNamingFromApi(glassArray) { + glassArray.forEach((glass) => { + glass.glassLocation = glass.glassPiece.location; + glass.glassName = glass.glassPiece.name; + delete glass.glassPiece; + return glass; + }); + return glassArray; +} +
+ Finding shops near you + . + . + . +
+ Looking for dates + . + . + . +
+ Searching for times + . + . + . +
+ Nearly there + . + . + . +
+ Finishing up + . + . + . +