Merge branch 'develop' into feature/CSR-659

This commit is contained in:
Leah Schumann 2022-06-20 07:55:21 -04:00
commit 0c84428b7e
10 changed files with 451 additions and 183 deletions

View file

@ -20,10 +20,8 @@ module.exports = {
"!src/layouts/reveal/**/*.vue",
"!src/layouts/estimate/**/*.vue",
// TODO REMOVE THESE AFTER WRITING UNIT TESTS
"!src/layouts/address-vehicles/address-vehicles-question/address-vehicles-question.vue",
"!src/ux-components/alert/alert.vue",
"!src/helpers/validation-rules.js",
"!src/common-components/question-chain/question-chain",
// END
], // ! means exclude from coverage.
testMatch: ["**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)"],

View file

@ -29,20 +29,22 @@ export default {
name: "FunnelSubHeader",
props: {
hasBackButton: Boolean,
backButtonAccessibleText: String,
cmsWidgetName: String,
},
components: {
buttonBack,
},
computed: {
text(){
text() {
return this.getCmsContent(this.cmsWidgetName, 'HeaderText');
},
subText(){
subText() {
return this.getCmsContent(this.cmsWidgetName, 'HeaderSubText');
},
headerColor(){
backButtonAccessibleText() {
return this.getCmsContent(this.cmsWidgetName, 'BackButtonAccessibleText');
},
headerColor() {
return this.subText ? 'dark-header' : 'light-header';
}
},

View file

@ -0,0 +1,241 @@
import { shallowMount } from "@vue/test-utils";
import questionChain from "@/common-components/question-chain/question-chain.vue";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import store from "@/store";
jest.mock("@/store",()=>{return{};},{virtual:true});
describe("Question Chain component", () => {
it("Should not emit a modelValue change when setting selectedValue if isNewModelValueComplete is false", () => {
//Arrange
const { wrapper } = setupMocks({ modelValueProp: [] });
wrapper.vm.currentQuestion = 6;
const answerReturned = [wrapper.vm.questions[wrapper.vm.currentQuestion].answers[0].Name.toString()];
const localThis = {
$emit: jest.fn(),
getNewModelValue: jest.fn(() => { return false })
}
//Act
questionChain.computed.selectedValue.set.call(localThis, answerReturned);
//Assert
expect(localThis.$emit).not.toBeCalled();
});
it("Should emit a modelValue change when setting selectedValue if isNewModelValueComplete is true", () => {
//Arrange
const { wrapper } = setupMocks({ modelValueProp: [] });
wrapper.vm.currentQuestion = 6;
const answerReturned = [wrapper.vm.questions[wrapper.vm.currentQuestion].answers[0].Name.toString()];
const localThis = {
$emit: jest.fn(),
getNewModelValue: jest.fn(() => { return true })
}
//Act
questionChain.computed.selectedValue.set.call(localThis, answerReturned);
//Assert
expect(localThis.$emit).toBeCalledWith("update:modelValue", true);
});
it("should return false if no returned answer is given", () => {
//Arrange
const { wrapper } = setupMocks({ modelValueProp: [] });
wrapper.vm.currentQuestion = 1;
const answerReturned = null;
//Act
const result = wrapper.vm.getNewModelValue(answerReturned);
//Assert
expect(result).toEqual(false);
});
it("should return false if the user's answer on the current question leads to another question", () => {
//Arrange
const { wrapper } = setupMocks({ modelValueProp: [] });
wrapper.vm.currentQuestion = 1;
const answerReturned = [wrapper.vm.questions[wrapper.vm.currentQuestion].answers[0].Name.toString()];
//Act
const result = wrapper.vm.getNewModelValue(answerReturned);
//Assert
expect(result).toEqual(false);
});
it("should return an object with the final answer if the user's answer on the current question is a part number", () => {
//Arrange
const { wrapper } = setupMocks({ modelValueProp: [] });
wrapper.vm.currentQuestion = 6;
const answerReturned = [wrapper.vm.questions[wrapper.vm.currentQuestion].answers[0].Name.toString()];
//Act
const result = wrapper.vm.getNewModelValue(answerReturned);
//Assert
expect(result).toEqual(
{
answerResult: 'DW02102',
answeredQuestions: [
{
questionText: 'Is your vehicle equipped with heated seats?',
selectedAnswerText: 'Yes'
}
]
}
);
});
});
function setupMocks({
modelValueProp = "",
questionDataProp = {
"glassName": "Single",
"glassLocation": "Windshield",
"parts": null,
"partQuestions": [
{
"questionSequence": 1,
"questionText": "Is your Cherokee the Overland edition which can be identified by having a wood and leather wrapped steering wheel?",
"answers": [
{
"answerText": "Yes",
"nextQuestionSequence": 2,
"answerResult": ""
},
{
"answerText": "No",
"nextQuestionSequence": 3,
"answerResult": ""
}
]
},
{
"questionSequence": 2,
"questionText": "Is your vehicle equipped with rain sensing wipers that adjust their speed automatically when it rains?",
"answers": [
{
"answerText": "Yes",
"nextQuestionSequence": null,
"answerResult": "DW02270"
},
{
"answerText": "No",
"nextQuestionSequence": null,
"answerResult": "DW02264"
}
]
},
{
"questionSequence": 3,
"questionText": "Is your vehicle equipped with rain sensing wipers that adjust their speed automatically when it rains?",
"answers": [
{
"answerText": "Yes",
"nextQuestionSequence": null,
"answerResult": "DW02268"
},
{
"answerText": "No",
"nextQuestionSequence": 4,
"answerResult": ""
}
]
},
{
"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": [
{
"answerText": "Yes",
"nextQuestionSequence": 5,
"answerResult": ""
},
{
"answerText": "No",
"nextQuestionSequence": 6,
"answerResult": ""
}
]
},
{
"questionSequence": 5,
"questionText": "Is your vehicle equipped with heated seats?",
"answers": [
{
"answerText": "Yes",
"nextQuestionSequence": null,
"answerResult": "DW02104"
},
{
"answerText": "No",
"nextQuestionSequence": null,
"answerResult": "DW02103"
}
]
},
{
"questionSequence": 6,
"questionText": "Is your vehicle equipped with heated seats?",
"answers": [
{
"answerText": "Yes",
"nextQuestionSequence": null,
"answerResult": "DW02102"
},
{
"answerText": "No",
"nextQuestionSequence": null,
"answerResult": "DW02101"
}
]
}
],
},
methodsToMock = [],
}) {
//Mock store
store.dispatch = jest.fn(() => dataFromStoreApi);
store.getters = { vehicle: {year: 2019, make: 'honda', model: 'civc', style: '2 Door', category: 'CAR'} };
const mountOptions = getMountOptions({
store: {
dispatch: store.dispatch,
getters: store.getters,
},
});
//Mock props
const mockMixin = {
methods: {
getCmsContent: jest.fn()
}
}
mountOptions.propsData = {
modelValue: modelValueProp,
questionData: questionDataProp,
};
mountOptions.mixins = [mockMixin];
//Mock methods
methodsToMock.forEach((methodName) => {
questionChain.methods[methodName] = jest.fn();
});
const wrapper = shallowMount(questionChain, mountOptions);
//Mock CMS content
const cmsContent = {
};
return { wrapper, cmsContent };
}

View file

@ -9,7 +9,7 @@
:groupName="`${questionData.glassName}-${questionData.glassLocation}-${i}`"
textPosition="text-start"
v-model="selectedValue"
isRequired=true
:isRequired=true
:validationRules="validationRules"
:clearOnUnmount=false
/>
@ -30,12 +30,14 @@ export default {
};
},
props: {
questionData: Array,
questionData: Object,
validationRules: String,
modelValue: String,
modelValue: Array,
},
computed: {
questions() {
console.log("answeredQuestions: ", this.answeredQuestions)
const questions = this.questionData.partQuestions.map((q, i) => {
return {
questionText: q.questionText,
@ -52,7 +54,7 @@ export default {
}
});
// add an empty item to be array[0] since we start with 1
questions.unshift({});
questions.unshift({ "DeliberatelyBlankObject": "This object has been added as a placeholder only for question #0"});
return questions;
},
selectedValue: {

View file

@ -1,7 +1,6 @@
import { shallowMount } from "@vue/test-utils";
import addressVehiclesQuestion from "@/layouts/address-vehicles/address-vehicles-question/address-vehicles-question";
describe("addressVehiclesQuestion.vue", () => {
it("Should return content for differentVehicleAlertHeader", () => {

View file

@ -7,7 +7,6 @@
<funnelSubHeader
cmsWidgetName="FunnelSubHeaderWidget"
:hasBackButton="true"
backButtonAccessibleText="Change Vehicle Year"
@click-event="backButtonAction"
/>
<div class="fade-on-route-transition">

View file

@ -7,7 +7,6 @@
<funnelSubHeader
cmsWidgetName="FunnelSubHeaderWidget"
:hasBackButton="true"
backButtonAccessibleText="Change Vehicle Make"
@click-event="backButtonAction"
/>
<div class="fade-on-route-transition">

View file

@ -29,7 +29,11 @@
:buttonID="`${glassLocation}-${glassName}-${name}`"
:groupName="`${glassLocation}-${glassName}`"
@isCheckedChanged="ResetTintAndPartSelections()"
:validationRules="validationRules"
/>
<div class="row form-test-error mt-1">
<error-message :name="`${glassLocation}-${glassName}`" v-if="!suppressError"></error-message>
</div>
</div>
</div>
<transition name="fade" mode="out-in">
@ -53,6 +57,7 @@
:loaderEnabled="false"
isRequired
:groupName="`${glassLocation}-${glassName}-${name}`"
:validationRules="validationRules"
/>
</div>
</div>
@ -68,6 +73,7 @@ import buttonQuestion from "@/common-components/button-question/button-question"
// Supporting files
import { getTintImage } from "@/constants/tint-mapper";
import { getCustomTransformValue } from "@/constants/dynamictext-mapper";
import { ErrorMessage } from 'vee-validate';
export default {
name: "glass-part-question",
@ -84,6 +90,7 @@ export default {
glassLocation: String,
colorAnswers: Array,
modelValue: Object,
validationRules: String,
},
mounted() {
this.LoadPreselectedValues();
@ -91,6 +98,7 @@ export default {
components: {
listCard,
buttonQuestion,
ErrorMessage,
},
computed: {
colorQuestionText() {

View file

@ -1,5 +1,11 @@
<template>
<div class="page-container-grouped-styles vehicle-parts">
<Form
@submit="onSubmit"
@invalid-submit="onInvalidSubmit"
ref="theForm"
v-slot="{ meta }"
>
<div class="page-container-grouped-styles vehicle-parts">
<funnelHeader ref="funnelHeader" cmsWidgetName="FunnelHeaderWidget" />
<vehicleBanner ref="vehicleBanner" cmsWidgetName="VehicleBannerWidget" :displayGenericVehicleImage="false" />
<funnelSubHeader ref="funnelSubHeader" cmsWidgetName="FunnelSubHeaderWidget" />
@ -8,10 +14,10 @@
<div class="row">
<div class="col">
<alert
class="rounded border-0 shadow-sm"
alertClass="alert-warning"
cmsWidgetName="AlertWidget"
:isDismissible="false"
class="rounded border-0 shadow-sm"
alertClass="alert-warning"
cmsWidgetName="AlertWidget"
:isDismissible="false"
/>
</div>
</div>
@ -24,16 +30,24 @@
</div>
<glassPartQuestion
:ref="`${RefPrefix}-${item.glassLocation}-${item.glassName}`"
v-model="glassParts[item.glassLocation + '-' + item.glassName]"
:glassLocation="item.glassLocation"
:glassName="item.glassName"
:colorAnswers="item.colorAnswers"
:ref="`${RefPrefix}-${item.glassLocation}-${item.glassName}`"
v-model="glassParts[item.glassLocation + '-' + item.glassName]"
:glassLocation="item.glassLocation"
:glassName="item.glassName"
:colorAnswers="item.colorAnswers"
validationRules="replace-options-required"
/>
</div>
<funnelFooter cmsWidgetName="FunnelFooterWidget" ref="funnelFooter" @back-clicked="backButtonAction" @ForwardClicked="forwardButtonAction" />
<funnelFooter
cmsWidgetName="FunnelFooterWidget"
ref="funnelFooter"
:isForwardActionDisabled="!meta.valid"
@back-clicked="backButtonAction"
@ForwardClicked="forwardButtonAction"
/>
</div>
</div>
</div>
</Form>
</template>
<script>
@ -50,170 +64,177 @@ import { settleAllPromises } from "@/helpers/layout-helper";
import { fmgPageValues } from "@/router/router-constants/fmgPage-values";
import { storeMutations } from "@/constants/store-mutations";
import store from "@/store";
import { Form, defineRule } from "vee-validate";
import { required } from "@/helpers/validation-rules";
import { errorMessages } from "@/constants/error-messages";
// DEFINE VALIDATION RULES
defineRule("replace-options-required", required(errorMessages.OPTION_REQUIRED));
export default {
name: "vehicle-parts",
async beforeRouteEnter(to, from, next) {
// Call APIs
const cmsContentPromise = fetchCmsContentForPage(to.query.fmgPage);
// Settle promises and get results
const promiseResultMap = [{
resultKey: "cmsContent",
promise: cmsContentPromise,
}, ];
const resultMap = await settleAllPromises(promiseResultMap);
// Call the "next" function to complete the transition to this page.
next((vm) => {
vm.setCmsContent(resultMap.cmsContent);
name: "vehicle-parts",
async beforeRouteEnter(to, from, next) {
// Call APIs
const cmsContentPromise = fetchCmsContentForPage(to.query.fmgPage);
// Settle promises and get results
const promiseResultMap = [{
resultKey: "cmsContent",
promise: cmsContentPromise,
}, ];
const resultMap = await settleAllPromises(promiseResultMap);
// Call the "next" function to complete the transition to this page.
next((vm) => {
vm.setCmsContent(resultMap.cmsContent);
// Glass Part Question dynamic component
Object.keys(vm.$refs)
.filter((r) => r.includes(vm.RefPrefix) && vm.$refs[r][0] !== undefined)
.forEach((c) =>
vm.$refs[c][0].initializeComponent({
ColorQuestionWidget: resultMap.cmsContent.ColorQuestionWidget,
FeatureQuestionWidget: resultMap.cmsContent.FeatureQuestionWidget,
})
);
});
},
data() {
return {
glassParts: {},
alertWidgetData: Object,
};
},
components: {
glassPartQuestion,
funnelHeader,
vehicleBanner,
funnelSubHeader,
funnelFooter,
alert,
},
computed: {
PartsForQuestions() {
const partsData = this.PartsFromApi;
// Glass Part Question dynamic component
Object.keys(vm.$refs)
.filter((r) => r.includes(vm.RefPrefix) && vm.$refs[r][0] !== undefined)
.forEach((c) =>
vm.$refs[c][0].initializeComponent({
ColorQuestionWidget: resultMap.cmsContent.ColorQuestionWidget,
FeatureQuestionWidget: resultMap.cmsContent.FeatureQuestionWidget,
})
);
});
},
data() {
return {
glassParts: {},
alertWidgetData: Object,
};
},
components: {
Form,
glassPartQuestion,
funnelHeader,
vehicleBanner,
funnelSubHeader,
funnelFooter,
alert,
},
computed: {
PartsForQuestions() {
const partsData = this.PartsFromApi;
// Map API result data, to vehicle-parts data structure
const mappedData = partsData.partsOrQuestions.map((g) => {
return {
glassName: g.glassName,
glassLocation: g.glassLocation,
colorAnswers: g.parts.reduce((arr, p) => {
arr.push({
ColorAnswerText: p.color,
FeatureAnswers: [
{
FeatureAnswerText:
p.description === "" ? p.color : p.description,
PartNumber: p.partNumber,
},
],
});
return arr;
}, []),
};
});
return mappedData;
},
PartsFromApi() {
return store.getters.pageData(fmgPageValues.VEHICLE_PARTS);
},
RefPrefix() {
return "partQuestion";
},
},
methods: {
arePagePrerequisitesValid() {
// Check if isRepair is populated and if the pageData we need is here (Parts data)
if (
(store.getters.damage.isRepair != null) &&
Object.keys(store.getters.pageData(fmgPageValues.VEHICLE_PARTS))
.length !== 0
) {
return true;
}
return false;
},
backButtonAction() {
this.$router.navigate(this.navigationScenarios.CLICKED_BACK, this.$route);
},
forwardButtonAction() {
const selectedGlassPartNumbers = [];
const matchedParts = [];
// Compile all selected parts from the page.
for (let [key, value] of Object.entries(this.glassParts)) {
for (let [glassKey, glassValue] of Object.entries(value)) {
selectedGlassPartNumbers.push(glassValue[0]);
}
}
// Match them to the parts from the API.
for (let [key, value] of Object.entries(
this.PartsFromApi.partsOrQuestions
)) {
for (let [partKey, partValue] of Object.entries(value.parts)) {
const currentPart =
this.PartsFromApi.partsOrQuestions[key].parts[partKey];
const isMatched = selectedGlassPartNumbers.some(
(p) => p === currentPart.partNumber
);
if (isMatched) {
matchedParts.push(currentPart);
}
}
}
// If no parts could be matched, throw an error.
if (matchedParts.length === 0) {
throw new Error("Could not match any parts to the selected parts");
}
// Save parts to the store.
store.commit(storeMutations.UPDATE_GLASS_PARTS, matchedParts);
// Navigate to the next page.
this.$router.navigateAfterSave(
this.navigationScenarios.SELECTED_PARTS,
this.$route
);
},
resetDependentState() {
// Nothing additional to reset here: The page save is already fully resetting all the line-items on the order
},
LoadInitialPartsData() {
const partsData = this.PartsFromApi;
const alreadyPopulatedPartsData =
this.$store.getters.lineItems.glassParts === null
? {}
: this.$store.getters.lineItems.glassParts;
partsData.partsOrQuestions.map((g) => {
// If the part is already populated, use the value from the store and populate the v-model.
Object.keys(alreadyPopulatedPartsData).forEach((key) => {
const partNumber = alreadyPopulatedPartsData[key].partNumber;
g.parts.forEach((p) => {
if (p.partNumber === partNumber) {
this.glassParts[g.glassLocation + "-" + g.glassName] = {
[g.glassLocation]: [partNumber],
};
}
// Map API result data, to vehicle-parts data structure
const mappedData = partsData.partsOrQuestions.map((g) => {
return {
glassName: g.glassName,
glassLocation: g.glassLocation,
colorAnswers: g.parts.reduce((arr, p) => {
arr.push({
ColorAnswerText: p.color,
FeatureAnswers: [
{
FeatureAnswerText:
p.description === "" ? p.color : p.description,
PartNumber: p.partNumber,
},
],
});
return arr;
}, []),
};
});
return mappedData;
},
PartsFromApi() {
return store.getters.pageData(fmgPageValues.VEHICLE_PARTS);
},
RefPrefix() {
return "partQuestion";
},
},
methods: {
arePagePrerequisitesValid() {
// Check if isRepair is populated and if the pageData we need is here (Parts data)
if (
(store.getters.damage.isRepair != null) &&
Object.keys(store.getters.pageData(fmgPageValues.VEHICLE_PARTS))
.length !== 0
) {
return true;
}
return false;
},
backButtonAction() {
this.$router.navigate(this.navigationScenarios.CLICKED_BACK, this.$route);
},
forwardButtonAction() {
const selectedGlassPartNumbers = [];
const matchedParts = [];
// Compile all selected parts from the page.
for (let [key, value] of Object.entries(this.glassParts)) {
for (let [glassKey, glassValue] of Object.entries(value)) {
selectedGlassPartNumbers.push(glassValue[0]);
}
}
// Match them to the parts from the API.
for (let [key, value] of Object.entries(
this.PartsFromApi.partsOrQuestions
)) {
for (let [partKey, partValue] of Object.entries(value.parts)) {
const currentPart =
this.PartsFromApi.partsOrQuestions[key].parts[partKey];
const isMatched = selectedGlassPartNumbers.some(
(p) => p === currentPart.partNumber
);
if (isMatched) {
matchedParts.push(currentPart);
}
}
}
// If no parts could be matched, throw an error.
if (matchedParts.length === 0) {
throw new Error("Could not match any parts to the selected parts");
}
// Save parts to the store.
store.commit(storeMutations.UPDATE_GLASS_PARTS, matchedParts);
// Navigate to the next page.
this.$router.navigateAfterSave(
this.navigationScenarios.SELECTED_PARTS,
this.$route
);
},
resetDependentState() {
// Nothing additional to reset here: The page save is already fully resetting all the line-items on the order
},
LoadInitialPartsData() {
const partsData = this.PartsFromApi;
const alreadyPopulatedPartsData =
this.$store.getters.lineItems.glassParts === null
? {}
: this.$store.getters.lineItems.glassParts;
partsData.partsOrQuestions.map((g) => {
// If the part is already populated, use the value from the store and populate the v-model.
Object.keys(alreadyPopulatedPartsData).forEach((key) => {
const partNumber = alreadyPopulatedPartsData[key].partNumber;
g.parts.forEach((p) => {
if (p.partNumber === partNumber) {
this.glassParts[g.glassLocation + "-" + g.glassName] = {
[g.glassLocation]: [partNumber],
};
}
});
});
},
},
mounted() {
this.LoadInitialPartsData();
});
},
},
mounted() {
this.LoadInitialPartsData();
},
};
</script>

View file

@ -7,7 +7,6 @@
<funnelSubHeader
cmsWidgetName="FunnelSubHeaderWidget"
:hasBackButton="true"
backButtonAccessibleText="Change Vehicle Model"
@click-event="backButtonAction"
/>
<div class="fade-on-route-transition">