diff --git a/src/digital-components/question-chain/question-chain.spec.js b/src/digital-components/question-chain/question-chain.spec.js index 7435feefd..1dad2d5de 100644 --- a/src/digital-components/question-chain/question-chain.spec.js +++ b/src/digital-components/question-chain/question-chain.spec.js @@ -109,11 +109,11 @@ describe("Question Chain component", () => { expect(wrapper.emitted()).not.toHaveProperty("update:modelValue"); }); - test("should NOT emit update:modelValue if returnedAnswer contains 'nextQuestion'", () => { + test("should not emit update:modelValue for nextQuestion during normal forward progress", () => { //Arrange const { wrapper } = setupMocks({}); const testQuestion = {}; - const testReturnedAnswer = "1|nextQuestion|DB10840|No"; + const testReturnedAnswer = "1|nextQuestion|3|No"; //Act wrapper.vm.handleAnswer(testQuestion, testReturnedAnswer); @@ -121,6 +121,24 @@ describe("Question Chain component", () => { //Assert expect(wrapper.emitted()).not.toHaveProperty("update:modelValue"); }); + + test("should emit update:modelValue for nextQuestion when revising a saved answer", async () => { + //Arrange + const { wrapper } = setupMocks({}); + await wrapper.setProps({ hasSavedAnswer: true }); + const testQuestion = {}; + const testReturnedAnswer = "1|nextQuestion|3|No"; + + //Act + wrapper.vm.handleAnswer(testQuestion, testReturnedAnswer); + + //Assert + expect(wrapper.emitted()).toHaveProperty("update:modelValue"); + expect(wrapper.emitted()["update:modelValue"][0][0]).toMatchObject({ + incomplete: true, + index: 0, + }); + }); }); describe("method getQuestionChainAnswerIfComplete...", () => { @@ -213,25 +231,103 @@ describe("Question Chain component", () => { wrapper.vm.getQuestionChainAnswerIfComplete(testReturnedAnswer); //Assert - expect(wrapper.vm.questions[1].answerSelected).toBeUndefined; + expect(wrapper.vm.questions[1].answerSelected).toBeUndefined(); }); - test("should return false if returnedAnswer is a nextQuestion (not a final answer)", async () => { + test("should follow the No path and show the next question when changing Q1 from Yes to No", async () => { + const { wrapper } = setupMocks({ + questionDataProp: [ + { + questionSequence: 1, + questionText: "Is this the Overland edition?", + answers: [ + { + answerResult: null, + answerText: "Yes", + nextQuestionSequence: 2, + }, + { + answerResult: null, + answerText: "No", + nextQuestionSequence: 5, + }, + ], + }, + { + questionSequence: 2, + questionText: "Rain sensing wipers?", + answers: [ + { answerResult: "PART-A", answerText: "Yes", nextQuestionSequence: 3 }, + { answerResult: "PART-B", answerText: "No", nextQuestionSequence: 3 }, + ], + answerSelected: "2|answer|PART-A|Yes", + }, + { + questionSequence: 3, + questionText: "Laminated door glass?", + answers: [ + { + answerResult: "PART-C", + answerText: "Yes", + nextQuestionSequence: null, + }, + { + answerResult: "PART-D", + answerText: "No", + nextQuestionSequence: null, + }, + ], + answerSelected: "3|answer|PART-C|Yes", + }, + { + questionSequence: 5, + questionText: "Factory installed antenna?", + answers: [ + { + answerResult: "PART-E", + answerText: "Yes", + nextQuestionSequence: null, + }, + { + answerResult: "PART-F", + answerText: "No", + nextQuestionSequence: null, + }, + ], + }, + ], + }); + await wrapper.setProps({ index: 0 }); + + const result = wrapper.vm.getQuestionChainAnswerIfComplete("1|nextQuestion|5|No"); + + expect(result.incomplete).toBe(true); + expect(wrapper.vm.questions[1].answerSelected).toBeUndefined(); + expect(wrapper.vm.questions[2].answerSelected).toBeUndefined(); + expect(wrapper.vm.currentQuestionNum).toBe(5); + expect(result.answeredQuestions).toHaveLength(1); + expect(result.answeredQuestions[0].selectedAnswerText).toBe("No"); + }); + + test("should return incomplete answer if returnedAnswer is a nextQuestion (not a final answer)", async () => { //Arrange const { wrapper } = setupMocks({}); - const testReturnedAnswer = "1|nextQuestion|DB10840|No"; + const testReturnedAnswer = "1|nextQuestion|3|No"; //Act const result = wrapper.vm.getQuestionChainAnswerIfComplete(testReturnedAnswer); //Assert - expect(result).toBeFalsy(); + expect(result).toMatchObject({ + incomplete: true, + index: 0, + }); }); test("should trigger scroll to .current-question if returnedAnswer is a nextQuestion (not a final answer)", async () => { //Arrange const { wrapper } = setupMocks({}); - const testReturnedAnswer = "1|nextQuestion|DB10840|No"; + const testReturnedAnswer = "1|nextQuestion|3|No"; //Act wrapper.vm.getQuestionChainAnswerIfComplete(testReturnedAnswer); @@ -323,9 +419,129 @@ describe("Question Chain component", () => { expect(wrapper.vm.questions[0].answers[0].problemQuestionId).toBe(9531); expect(wrapper.vm.questions[0].answers[1].problemQuestionId).toBe(9529); }); + + test("should normalize uppercase answerSelected to match button value on created", async () => { + const { wrapper } = setupMocks({ + questionDataProp: [ + { + questionSequence: 1, + questionText: "Is your vehicle equipped with rain sensing wipers?", + answerSelected: "1|ANSWER|DW01705|YES", + answers: [ + { + answerResult: "DW01705", + answerText: "Yes", + nextQuestionSequence: null, + }, + { + answerResult: "DW01591", + answerText: "No", + nextQuestionSequence: null, + }, + ], + }, + ], + }); + + await nextTick(); + + expect(wrapper.vm.questions[0].answerSelected).toBe("1|answer|DW01705|Yes"); + }); + test("should show question 3 after answering No on Cherokee Overland question", async () => { + const { wrapper } = setupMocks({ + questionDataProp: getCherokeeWindshieldQuestions(), + }); + await nextTick(); + + const q1 = wrapper.vm.questions[0]; + wrapper.vm.handleAnswer(q1, "1|nextQuestion|3|No"); + + expect(wrapper.vm.currentQuestionNum).toBe(3); + expect( + wrapper.vm.questions.filter((question) => wrapper.vm.isQuestionVisible(question)) + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ questionSequence: 1 }), + expect.objectContaining({ questionSequence: 3 }), + ]) + ); + expect( + wrapper.vm.questions.filter((question) => wrapper.vm.isQuestionVisible(question)) + ).not.toEqual( + expect.arrayContaining([expect.objectContaining({ questionSequence: 2 })]) + ); + }); + + test("should resume at question 3 after remount when Q1 No was already saved", async () => { + const questionDataProp = getCherokeeWindshieldQuestions(); + questionDataProp[0].answerSelected = "1|nextQuestion|3|No"; + + const { wrapper } = setupMocks({ questionDataProp }); + await nextTick(); + + expect(wrapper.vm.currentQuestionNum).toBe(3); + expect(wrapper.vm.isQuestionVisible(wrapper.vm.questions[2])).toBe(true); + }); }); }); +function getCherokeeWindshieldQuestions() { + return [ + { + questionSequence: 1, + questionText: + "Is your Cherokee the Overland edition which can be identified by having a wood and leather wrapped steering wheel?", + answers: [ + { answerResult: "", answerText: "Yes", nextQuestionSequence: 2 }, + { answerResult: "", answerText: "No", nextQuestionSequence: 3 }, + ], + }, + { + questionSequence: 2, + questionText: + "Is your vehicle equipped with rain sensing wipers that adjust their speed automatically when it rains?", + answers: [ + { answerResult: "DW02270", answerText: "Yes", nextQuestionSequence: null }, + { answerResult: "DW02264", answerText: "No", nextQuestionSequence: null }, + ], + }, + { + questionSequence: 3, + questionText: + "Is your vehicle equipped with rain sensing wipers that adjust their speed automatically when it rains?", + answers: [ + { answerResult: "DW02268", answerText: "Yes", nextQuestionSequence: null }, + { answerResult: "", answerText: "No", nextQuestionSequence: 4 }, + ], + }, + { + questionSequence: 4, + questionText: + "Is your vehicle equipped with automatic climate control which will change the fan speed automatically in order to maintain a set temperature?", + answers: [ + { answerResult: "", answerText: "Yes", nextQuestionSequence: 5 }, + { answerResult: "", answerText: "No", nextQuestionSequence: 6 }, + ], + }, + { + questionSequence: 5, + questionText: "Is your vehicle equipped with heated seats?", + answers: [ + { answerResult: "DW02104", answerText: "Yes", nextQuestionSequence: null }, + { answerResult: "DW02103", answerText: "No", nextQuestionSequence: null }, + ], + }, + { + questionSequence: 6, + questionText: "Is your vehicle equipped with heated seats?", + answers: [ + { answerResult: "DW02102", answerText: "Yes", nextQuestionSequence: null }, + { answerResult: "DW02101", answerText: "No", nextQuestionSequence: null }, + ], + }, + ]; +} + function setupMocks({ questionDataProp = [ { @@ -351,6 +567,7 @@ function setupMocks({ const mountOptions = getMountOptions({}); mountOptions.propsData = { questionData: questionDataProp, + index: 0, }; mountOptions["attachTo"] = document.body; diff --git a/src/digital-components/question-chain/question-chain.vue b/src/digital-components/question-chain/question-chain.vue index b1960eb51..b37755ee7 100644 --- a/src/digital-components/question-chain/question-chain.vue +++ b/src/digital-components/question-chain/question-chain.vue @@ -2,9 +2,9 @@
import buttonQuestion from "@/digital-components/button-question/button-question"; import { useValidateForm } from "vee-validate"; +import { + mapQuestionAnswersForChain, + normalizeAnswerSelectedValue, +} from "@/helpers/question-chain-helper"; export default { name: "questionChain", @@ -34,54 +38,81 @@ export default { modelValue: Object, index: Number, answerKey: String, + hasSavedAnswer: Boolean, + hasDownstreamSavedAnswers: Boolean, }, async created() { // do a test validation check upon create to prevent out of sync / incorrect valid states await useValidateForm(); // NOTE: needs to have async/await here; tested and won't work without it this.questionData.map((q, i) => { + const answers = mapQuestionAnswersForChain(q); const question = { questionText: q.questionText, questionSequence: q.questionSequence, - answers: q.answers.map((a) => { - return { - buttonLabel: a.answerText, - // Name will either be nextQuestionSequence or answerResult - // Name will be used by list-button as the input value. - // It must be a single string or number, so concatenating together a string with - // 4 pieces of data separated by pipe characters: - // question number|type of answer|answer value|answer text - value: a.nextQuestionSequence - ? q.questionSequence + - "|nextQuestion|" + - a.nextQuestionSequence + - "|" + - a.answerText - : q.questionSequence + "|answer|" + a.answerResult + "|" + a.answerText, - nextQuestionSequence: a.nextQuestionSequence, - answerResult: a.answerResult, - problemQuestionId: a.problemQuestionId, - questionSequence: q.questionSequence, - questionType: a.nextQuestionSequence ? "nextQuestion" : "answer", - }; - }), - answerSelected: q.answerSelected || "", + answers, + answerSelected: normalizeAnswerSelectedValue(q.answerSelected, answers), }; if (!q.suppressThisQuestion) { this.questions.push(question); } }); - if (!this.modelValue?.length > 0 && this.questions.length > 0) { - // set this.currentQuestionNum to first valid question - this.currentQuestionNum = this.questions[0].questionSequence; - // scroll the next question into view - this.$nextTick(() => { - document.querySelector(".current-question")?.scrollIntoView({ behavior: "smooth" }); - }); + if (this.questions.length > 0) { + this.initializeCurrentQuestionNum(); + this.scrollCurrentQuestionIntoView(); } }, methods: { + getQuestionSequence(question) { + return Number(question?.questionSequence); + }, + isCurrentQuestion(question) { + return this.getQuestionSequence(question) === Number(this.currentQuestionNum); + }, + isQuestionVisible(question) { + return !!question.answerSelected || this.isCurrentQuestion(question); + }, + initializeCurrentQuestionNum() { + const pendingNextQuestion = [...this.questions] + .reverse() + .find((question) => question.answerSelected?.includes("|nextQuestion|")); + + if (pendingNextQuestion) { + const nextQuestionSequence = Number( + pendingNextQuestion.answerSelected.split("|")[2] + ); + + if ( + this.questions.some( + (question) => this.getQuestionSequence(question) === nextQuestionSequence + ) + ) { + this.currentQuestionNum = nextQuestionSequence; + return; + } + } + + const firstUnanswered = this.questions.find((question) => !question.answerSelected); + + if (firstUnanswered) { + this.currentQuestionNum = this.getQuestionSequence(firstUnanswered); + } + }, + scrollCurrentQuestionIntoView() { + this.$nextTick(() => { + document.querySelector(".current-question")?.scrollIntoView({ behavior: "smooth" }); + }); + }, + syncAnswerSelectedToQuestionData(questionSequence, answerSelected) { + const sourceQuestion = this.questionData?.find( + (q) => this.getQuestionSequence(q) === Number(questionSequence) + ); + + if (sourceQuestion) { + sourceQuestion.answerSelected = answerSelected; + } + }, getProblemQuestionIdFromSelectedAnswer(question, selectedAnswer) { if (!question?.answers || !selectedAnswer) { return null; @@ -100,10 +131,20 @@ export default { */ question.answerSelected = returnedAnswer; - const isQuestionChainComplete = this.getQuestionChainAnswerIfComplete(returnedAnswer); + this.syncAnswerSelectedToQuestionData(question.questionSequence, returnedAnswer); + const questionChainAnswer = this.getQuestionChainAnswerIfComplete(returnedAnswer); - if (isQuestionChainComplete) { - this.$emit("update:modelValue", isQuestionChainComplete); + if (!questionChainAnswer) { + return; + } + + const shouldNotifyParent = + !questionChainAnswer.incomplete || + this.hasSavedAnswer || + this.hasDownstreamSavedAnswers; + + if (shouldNotifyParent) { + this.$emit("update:modelValue", questionChainAnswer); } }, getQuestionChainAnswerIfComplete(returnedAnswer) { @@ -117,20 +158,24 @@ export default { // "5|answer|DW02104|Yes" const returnedAnswerArray = returnedAnswer.split("|"); - const questionNum = parseInt(returnedAnswerArray[0]); + const questionNum = Number(returnedAnswerArray[0]); const questionType = returnedAnswerArray[1]; const questionAnswer = returnedAnswerArray[2]; const answeredQuestions = []; this.questions.forEach((q) => { + const questionSequence = this.getQuestionSequence(q); + // find this question and mark it as "answered" by populating answerSelected - if (q.questionSequence === questionNum) { + if (questionSequence === questionNum) { q.answerSelected = returnedAnswer; + this.syncAnswerSelectedToQuestionData(questionSequence, returnedAnswer); } // remove all answers AFTER this question... // (needed in case user is changing previously answered questions) - if (q.questionSequence > questionNum) { + if (questionSequence > questionNum) { delete q.answerSelected; + this.syncAnswerSelectedToQuestionData(questionSequence, ""); } if (q.answerSelected) { answeredQuestions.push({ @@ -146,22 +191,21 @@ export default { } }); - // return false if there's a nextQuestion... or return an object with final answers (truthy) + // return incomplete answer if there's a nextQuestion... or return final answers (truthy) if (questionType === "nextQuestion") { // update to next question index - this.currentQuestionNum = parseInt(questionAnswer); // update count to display next question - // scroll the next question into view - this.$nextTick(() => { - document - .querySelector(".current-question") - .scrollIntoView({ behavior: "smooth" }); - }); - return false; + this.currentQuestionNum = Number(questionAnswer); + this.scrollCurrentQuestionIntoView(); + return { + incomplete: true, + answeredQuestions, + index: this.index, + }; } else { // reset current question index (removes .current-question class) this.currentQuestionNum = 0; // reset count const answeredQuestion = this.questions.find( - (q) => q.questionSequence === questionNum + (q) => this.getQuestionSequence(q) === questionNum ); // return an object with the part answer, all the answered questions, and the part index diff --git a/src/fmg-components/layouts/questions-page-layout/questions-page-layout.spec.js b/src/fmg-components/layouts/questions-page-layout/questions-page-layout.spec.js index 96e5434cd..db34bd262 100644 --- a/src/fmg-components/layouts/questions-page-layout/questions-page-layout.spec.js +++ b/src/fmg-components/layouts/questions-page-layout/questions-page-layout.spec.js @@ -150,6 +150,43 @@ describe("questionsPageLayout.vue", () => { wrapper.unmount(); }); + + test("Should return false when answerData was cleared even if answerSelected remains", async () => { + const { wrapper } = setupMocks({}); + await wrapper.setProps({ index: 1 }); + + const testGlassPiece = { + questions: [ + { + questionSequence: 1, + questionText: "Is your Grand Cherokee the Laredo model?", + answers: [], + answerSelected: "1|answer|DD11132|Yes", + }, + ], + answerData: {}, + }; + + expect(wrapper.vm.showThisQuestionChain(testGlassPiece, 0)).toBe(false); + + wrapper.unmount(); + }); + + test("Should return false for reset chains ahead of the current index", async () => { + const { wrapper } = setupMocks({}); + await wrapper.setProps({ index: 1 }); + + const testGlassPiece = { + questions: [ + { questionSequence: 1, questionText: "Rain sensing wipers?", answers: [] }, + ], + answerData: null, + }; + + expect(wrapper.vm.showThisQuestionChain(testGlassPiece, 2)).toBe(false); + + wrapper.unmount(); + }); }); describe("method handleForwardButtonAction...", () => { diff --git a/src/fmg-components/layouts/questions-page-layout/questions-page-layout.vue b/src/fmg-components/layouts/questions-page-layout/questions-page-layout.vue index c9f9989e2..3152f5b14 100644 --- a/src/fmg-components/layouts/questions-page-layout/questions-page-layout.vue +++ b/src/fmg-components/layouts/questions-page-layout/questions-page-layout.vue @@ -10,7 +10,9 @@
-
+
- -
+ + glass.answerData?.answerResult); + }, showThisQuestionChain(glass, i) { - // return false if no questions or if suppressed if (!glass.questions || glass.questions?.length < 1 || glass.isSuppressedPart) { return false; } - return this.index === i || glass.answerData?.answerResult?.length > 0; + + if (this.index === i) { + return true; + } + + // Only show completed chains that still have a saved answer + return !!glass.answerData?.answerResult; }, handleForwardButtonAction() { this.$emit("forwardButtonAction"); diff --git a/src/helpers/question-chain-helper.js b/src/helpers/question-chain-helper.js new file mode 100644 index 000000000..b5ba8c168 --- /dev/null +++ b/src/helpers/question-chain-helper.js @@ -0,0 +1,49 @@ +/** + * Builds the pipe-delimited value used by question-chain button options. + * Format: questionNumber|type|answer value|answer text + */ +export function buildQuestionChainAnswerValue({ + questionSequence, + nextQuestionSequence, + answerResult, + answerText, +}) { + if (nextQuestionSequence) { + return `${questionSequence}|nextQuestion|${nextQuestionSequence}|${answerText}`; + } + + return `${questionSequence}|answer|${answerResult}|${answerText}`; +} + +export function mapQuestionAnswersForChain(question) { + return question.answers.map((answer) => ({ + buttonLabel: answer.answerText, + value: buildQuestionChainAnswerValue({ + questionSequence: question.questionSequence, + nextQuestionSequence: answer.nextQuestionSequence, + answerResult: answer.answerResult, + answerText: answer.answerText, + }), + nextQuestionSequence: answer.nextQuestionSequence, + answerResult: answer.answerResult, + problemQuestionId: answer.problemQuestionId, + questionSequence: question.questionSequence, + questionType: answer.nextQuestionSequence ? "nextQuestion" : "answer", + })); +} + +export function normalizeAnswerSelectedValue(answerSelected, answers) { + if (!answerSelected) { + return ""; + } + + if (answers.some((answer) => answer.value === answerSelected)) { + return answerSelected; + } + + const matchedAnswer = answers.find( + (answer) => answer.value.toUpperCase() === String(answerSelected).toUpperCase() + ); + + return matchedAnswer?.value ?? answerSelected; +} diff --git a/src/helpers/question-chain-helper.spec.js b/src/helpers/question-chain-helper.spec.js new file mode 100644 index 000000000..fa8db3714 --- /dev/null +++ b/src/helpers/question-chain-helper.spec.js @@ -0,0 +1,74 @@ +import { + buildQuestionChainAnswerValue, + mapQuestionAnswersForChain, + normalizeAnswerSelectedValue, +} from "./question-chain-helper"; + +describe("question-chain-helper", () => { + describe("buildQuestionChainAnswerValue", () => { + test("returns nextQuestion format when nextQuestionSequence is set", () => { + expect( + buildQuestionChainAnswerValue({ + questionSequence: 1, + nextQuestionSequence: 3, + answerResult: "ignored", + answerText: "Yes", + }) + ).toBe("1|nextQuestion|3|Yes"); + }); + + test("returns answer format for final answers", () => { + expect( + buildQuestionChainAnswerValue({ + questionSequence: 1, + nextQuestionSequence: null, + answerResult: "DW01705", + answerText: "Yes", + }) + ).toBe("1|answer|DW01705|Yes"); + }); + }); + + describe("normalizeAnswerSelectedValue", () => { + const answers = [{ value: "1|answer|DW01705|Yes" }, { value: "1|answer|DW01591|No" }]; + + test("returns empty string when answerSelected is empty", () => { + expect(normalizeAnswerSelectedValue("", answers)).toBe(""); + }); + + test("returns exact match when casing already matches", () => { + expect(normalizeAnswerSelectedValue("1|answer|DW01705|Yes", answers)).toBe( + "1|answer|DW01705|Yes" + ); + }); + + test("normalizes uppercase legacy values to button value casing", () => { + expect(normalizeAnswerSelectedValue("1|ANSWER|DW01705|YES", answers)).toBe( + "1|answer|DW01705|Yes" + ); + }); + }); + + describe("mapQuestionAnswersForChain", () => { + test("maps API answers to question-chain button answers", () => { + const result = mapQuestionAnswersForChain({ + questionSequence: 1, + answers: [ + { + answerResult: "DW01705", + answerText: "Yes", + nextQuestionSequence: null, + problemQuestionId: 123, + }, + ], + }); + + expect(result[0]).toMatchObject({ + buttonLabel: "Yes", + value: "1|answer|DW01705|Yes", + problemQuestionId: 123, + questionType: "answer", + }); + }); + }); +}); diff --git a/src/layouts/capability-questions/capability-questions.spec.js b/src/layouts/capability-questions/capability-questions.spec.js index fb4515f50..271a2e34c 100644 --- a/src/layouts/capability-questions/capability-questions.spec.js +++ b/src/layouts/capability-questions/capability-questions.spec.js @@ -213,7 +213,7 @@ describe("capabilityQuestions.vue", () => { }); describe("forwardButtonAction", () => { - test("Should clear out answerData", () => { + test("Should clear out answerData", async () => { // Arrange const { wrapper } = setupMocks({}); @@ -245,7 +245,7 @@ describe("capabilityQuestions.vue", () => { }); // Act - wrapper.vm.forwardButtonAction(); + await wrapper.vm.forwardButtonAction(); //Assert expect(wrapper.vm.questionsData[0].answerData).toEqual({}); diff --git a/src/layouts/capability-questions/capability-questions.vue b/src/layouts/capability-questions/capability-questions.vue index c4f5df92c..6b9f5aaa1 100644 --- a/src/layouts/capability-questions/capability-questions.vue +++ b/src/layouts/capability-questions/capability-questions.vue @@ -180,11 +180,6 @@ export default { }; }); - // clear out answerData for future page loads; must occur prior to store save - this.questionsData.forEach((glass) => { - glass.answerData = {}; - }); - // save to vuex store as order.damage.capabilityQuestionAnswers (array) // used in GET_PART_FROM_CAPABILITY_QUESTION_ANSWER call following this one await this.dispatchStoreAction( @@ -211,6 +206,8 @@ export default { } this.navigateForward(partsOrQuestions); + + this.clearQuestionsAnswerData(); }, }, components: { diff --git a/src/layouts/molding-questions/molding-questions.spec.js b/src/layouts/molding-questions/molding-questions.spec.js index 8daa6e780..bb668b62c 100644 --- a/src/layouts/molding-questions/molding-questions.spec.js +++ b/src/layouts/molding-questions/molding-questions.spec.js @@ -217,7 +217,7 @@ describe("moldingQuestions.vue", () => { }); describe("forwardButtonAction", () => { - test("Should clear out answerData", () => { + test("Should clear out answerData", async () => { // Arrange const { wrapper } = setupMocks({}); @@ -240,7 +240,7 @@ describe("moldingQuestions.vue", () => { }); // Act - wrapper.vm.forwardButtonAction(); + await wrapper.vm.forwardButtonAction(); //Assert expect(wrapper.vm.questionsData[0].answerData).toEqual({}); diff --git a/src/layouts/molding-questions/molding-questions.vue b/src/layouts/molding-questions/molding-questions.vue index a7f4900de..1e1cf1cb4 100644 --- a/src/layouts/molding-questions/molding-questions.vue +++ b/src/layouts/molding-questions/molding-questions.vue @@ -181,11 +181,6 @@ export default { }; }); - // clear out answerData for future page loads; must occur prior to store save - this.questionsData.forEach((glass) => { - glass.answerData = {}; - }); - // save to vuex store as order.damage.moldingQuestionArrays (array) await this.dispatchStoreAction( this.storeActions.SAVE_MOLDING_QUESTION_ANSWERS, @@ -209,6 +204,8 @@ export default { } this.navigateForward(partsOrQuestions); + + this.clearQuestionsAnswerData(); }, }, components: { diff --git a/src/layouts/part-questions/part-questions.spec.js b/src/layouts/part-questions/part-questions.spec.js index 4e7cb53dd..527f5dd74 100644 --- a/src/layouts/part-questions/part-questions.spec.js +++ b/src/layouts/part-questions/part-questions.spec.js @@ -258,9 +258,7 @@ describe("partQuestions.vue...", () => { }); // Act - wrapper.vm.forwardButtonAction(); - - await nextTick(); + await wrapper.vm.forwardButtonAction(); //Assert expect(wrapper.vm.questionsData[0].answerData).toEqual({}); diff --git a/src/layouts/part-questions/part-questions.vue b/src/layouts/part-questions/part-questions.vue index 1c342543f..f6c3c7667 100644 --- a/src/layouts/part-questions/part-questions.vue +++ b/src/layouts/part-questions/part-questions.vue @@ -182,13 +182,6 @@ export default { }; }); - // clear out answerData for future page loads; must occur prior to store save - this.questionsData.forEach((glass) => { - if (glass.answerData) { - glass.answerData = {}; - } - }); - this.dispatchStoreAction(this.storeActions.SAVE_IS_OEM_GLASS_SELECTED, false, false); // save to vuex store as order.damage.partQuestionAnswers (array) @@ -209,6 +202,8 @@ export default { const glassPartsForStore = partsLookup.data.glassPieceParts; this.navigateForward(glassPartsForStore); + + this.clearQuestionsAnswerData(); }, }, components: { diff --git a/src/mixins/vehicle-questions-mixin.js b/src/mixins/vehicle-questions-mixin.js index 1636f0c47..265d54fa2 100644 --- a/src/mixins/vehicle-questions-mixin.js +++ b/src/mixins/vehicle-questions-mixin.js @@ -7,9 +7,17 @@ import baseMixin from "@/mixins/base-mixin.js"; import store from "@/store"; import { getIsWindshieldOnly } from "@/helpers/damage-helper"; import { partTypeStrings } from "@/constants/part-type-strings"; +import { buildQuestionChainAnswerValue } from "@/helpers/question-chain-helper"; export default { methods: { + clearQuestionsAnswerData() { + this.questionsData?.forEach((glass) => { + if (glass.answerData) { + glass.answerData = {}; + } + }); + }, hasPartQuestions(partsOrQuestions) { return partsOrQuestions?.some((pq) => pq.partQuestions?.length > 0); }, @@ -114,11 +122,12 @@ export default { ); }); // 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}`; - } + answerString = buildQuestionChainAnswerValue({ + questionSequence: answeredQuestion.questionNum, + nextQuestionSequence: chosenAns.nextQuestionSequence, + answerResult: chosenAns.answerResult, + answerText: chosenAns.answerText, + }); // mark this question as answered (question-chain will read this) glass.questions[answeredQuestion.questionNum - 1].answerSelected = @@ -177,6 +186,8 @@ export default { */ const self = vm ?? this; + const isIncomplete = !!answer.incomplete; + const hadSavedAnswer = !!self.questionsData[answer.index]?.answerData?.answerResult; // clear out any preloaded answers self.selectedAnswers = {}; @@ -193,7 +204,6 @@ export default { */ const answeredQuestionText = answeredQuestion.questionText.toUpperCase(); - const answeredQuestionAnswer = answeredQuestion.selectedAnswer.toUpperCase(); const answeredQuestionAnswerText = answeredQuestion.selectedAnswerText.toUpperCase(); const answeredQuestionNum = answeredQuestion.questionNum; @@ -208,8 +218,7 @@ export default { if (answeredQuestionIndex === 0) question.answerSelected = null; if (answeredQuestionNum - 1 === questionIndex) { - // on the right question - question.answerSelected = answeredQuestionAnswer; + question.answerSelected = answeredQuestion.selectedAnswer; } }); } @@ -346,6 +355,27 @@ export default { }); }); + if (isIncomplete) { + // User changed an earlier answer; downstream questions were cleared in the chain + self.questionsData[answer.index].answerData = null; + self.currentGlassIndex = answer.index; + + if (hadSavedAnswer) { + self.questionsData[answer.index].key = + (self.questionsData[answer.index].key ?? + self.questionsData[answer.index].answerKey ?? + answer.index) + Date.now().toString(); + } + + return; + } + + // Force re-render so the updated selection displays when changing a prior answer + self.questionsData[answer.index].key = + (self.questionsData[answer.index].key ?? + self.questionsData[answer.index].answerKey ?? + answer.index) + Date.now().toString(); + // set final answer data for the current answered glass part self.questionsData[answer.index].answerData = { answerResult: answer.answerResult, diff --git a/src/mixins/vehicle-questions-mixin.spec.js b/src/mixins/vehicle-questions-mixin.spec.js index 0fd027258..a557adefa 100644 --- a/src/mixins/vehicle-questions-mixin.spec.js +++ b/src/mixins/vehicle-questions-mixin.spec.js @@ -499,6 +499,88 @@ describe("vehicle-questions-mixin", () => { // Assert expect(wrapper.vm.selectedAnswers).toMatchObject({}); }); + + test("should clear answerData and keep currentGlassIndex when answer is incomplete", () => { + const answer = { + incomplete: true, + answeredQuestions: [ + { + questionText: "Is this the Overland edition?", + selectedAnswer: "1|nextQuestion|5|No", + selectedAnswerText: "No", + questionNum: 1, + }, + ], + index: 0, + }; + const { wrapper } = setupMocks({}); + + wrapper.vm.currentGlassIndex = 1; + wrapper.vm.questionsData = [ + { + glassLocation: "Windshield", + glassName: "Single", + questions: [ + { + questionSequence: 1, + questionText: "Is this the Overland edition?", + answers: [], + }, + ], + answerData: { answerResult: "OLD-PART" }, + answerKey: "Windshield-Single", + }, + { + glassLocation: "Driver", + glassName: "Front", + questions: [], + answerData: { answerResult: "OTHER-PART" }, + }, + ]; + + wrapper.vm.handleCompletedQuestionChainAnswers(answer, "", wrapper.vm); + + expect(wrapper.vm.questionsData[0].answerData).toBeNull(); + expect(wrapper.vm.questionsData[1].answerData).toBeNull(); + expect(wrapper.vm.currentGlassIndex).toBe(0); + }); + + test("should not bump key when incomplete answer had no saved answer", () => { + const answer = { + incomplete: true, + answeredQuestions: [ + { + questionText: "Is this the Overland edition?", + selectedAnswer: "1|nextQuestion|2|Yes", + selectedAnswerText: "Yes", + questionNum: 1, + }, + ], + index: 0, + }; + const { wrapper } = setupMocks({}); + + wrapper.vm.questionsData = [ + { + glassLocation: "Windshield", + glassName: "Single", + questions: [ + { + questionSequence: 1, + questionText: "Is this the Overland edition?", + answers: [], + }, + ], + answerData: null, + answerKey: "Windshield-Single", + key: "stable-key", + }, + ]; + + wrapper.vm.handleCompletedQuestionChainAnswers(answer, "", wrapper.vm); + + expect(wrapper.vm.questionsData[0].key).toBe("stable-key"); + }); }); describe("questions in glass parts that are after the answered glass", () => {