merge develop into my branch

This commit is contained in:
FrankRua 2022-07-01 12:50:51 -04:00
parent d0709047cc
commit 1afdec7138
24 changed files with 770 additions and 572 deletions

View file

@ -15,6 +15,7 @@ module.exports = {
"!src/layouts/part-questions/**/*.vue", "!src/layouts/part-questions/**/*.vue",
"!src/layouts/reveal/**/*.vue", "!src/layouts/reveal/**/*.vue",
"!src/ux-components/text-link/**/*.vue", "!src/ux-components/text-link/**/*.vue",
"!src/common-components/question-chain/**/*.vue",
// END // END
], // ! means exclude from coverage. ], // ! means exclude from coverage.
testMatch: ["**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)"], testMatch: ["**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)"],

View file

@ -10,6 +10,7 @@ describe("buttonQuestion.vue", () => {
const wrapper = shallowMount(buttonQuestion, { const wrapper = shallowMount(buttonQuestion, {
propsData: { propsData: {
isOverflowScrollable: true, isOverflowScrollable: true,
groupName: "group-name"
} }
}); });
@ -25,6 +26,7 @@ describe("buttonQuestion.vue", () => {
const wrapper = shallowMount(buttonQuestion, { const wrapper = shallowMount(buttonQuestion, {
propsData: { propsData: {
buttonType: "listCard", buttonType: "listCard",
groupName: "group-name"
} }
}); });
// Assert // Assert
@ -39,6 +41,7 @@ describe("buttonQuestion.vue", () => {
const wrapper = shallowMount(buttonQuestion, { const wrapper = shallowMount(buttonQuestion, {
propsData: { propsData: {
buttonType: "listButtonHorizontal", buttonType: "listButtonHorizontal",
groupName: "group-name"
} }
}); });
// Assert // Assert
@ -53,6 +56,7 @@ describe("buttonQuestion.vue", () => {
const wrapper = shallowMount(buttonQuestion, { const wrapper = shallowMount(buttonQuestion, {
propsData: { propsData: {
buttonType: "radio", buttonType: "radio",
groupName: "group-name"
} }
}); });
// Assert // Assert
@ -77,7 +81,8 @@ describe("buttonQuestion.vue", () => {
// Act // Act
const localThis = { const localThis = {
isWide: false, isWide: false,
answers: ['a', 'b'] answers: ['a', 'b'],
groupName: "group-name"
} }
expect(buttonQuestion.computed.getColLength.call(localThis)).toBe(""); expect(buttonQuestion.computed.getColLength.call(localThis)).toBe("");
@ -109,7 +114,7 @@ describe("buttonQuestion.vue", () => {
describe("buttonQuestion.vue", () => { describe("buttonQuestion.vue", () => {
it("Should trigger event modelValue change to new value on when radio button selected", async () => { it("Should trigger event modelValue change to new value on when radio button selected", async () => {
// Act // Act
const wrapper = shallowMount(buttonQuestion, setupMocks({})); const wrapper = shallowMount(buttonQuestion, setupMocks({propsData: {groupName: "group-name"}}));
await wrapper.setProps({ await wrapper.setProps({
answers: ["2022", "2021", "2020"], answers: ["2022", "2021", "2020"],
isMultiSelect: false, isMultiSelect: false,
@ -129,6 +134,7 @@ describe("buttonQuestion.vue", () => {
propsData: { propsData: {
modelValue: ["2022", "2021", "2020"], modelValue: ["2022", "2021", "2020"],
isMultiSelect: true, isMultiSelect: true,
groupName: "group-name"
} }
})); }));
const val = { checkValue: true, value: "2019", } const val = { checkValue: true, value: "2019", }
@ -144,7 +150,8 @@ describe("buttonQuestion.vue", () => {
const wrapper = shallowMount(buttonQuestion, setupMocks({ const wrapper = shallowMount(buttonQuestion, setupMocks({
propsData: { propsData: {
isMultiSelect: true, isMultiSelect: true,
modelValue: ['a', 'b'] modelValue: ['a', 'b'],
groupName: "group-name"
} }
})); }));
const val = { checkValue: true, value: "2021", } const val = { checkValue: true, value: "2021", }
@ -161,7 +168,8 @@ describe("buttonQuestion.vue", () => {
const wrapper = shallowMount(buttonQuestion, setupMocks({ const wrapper = shallowMount(buttonQuestion, setupMocks({
propsData: { propsData: {
isMultiSelect: true, isMultiSelect: true,
modelValue: ['a', 'b'] modelValue: ['a', 'b'],
groupName: "group-name"
} }
})); }));
@ -173,24 +181,6 @@ describe("buttonQuestion.vue", () => {
}); });
}); });
describe("buttonQuestion.vue", () => {
it("Should do nothing to this.selectedValues if this.selectedValues is not an array", () => {
// Act
const wrapper = shallowMount(buttonQuestion, setupMocks({
propsData: {
isMultiSelect: true,
modelValue: 'a',
}
}));
const val = { checkValue: true, value: "c", }
wrapper.vm.handleCheckedChanged(val);
// Assert
expect(wrapper.vm.selectedValues).toEqual("a");
});
});
function setupMocks(mountOptionsMockData = {}) { function setupMocks(mountOptionsMockData = {}) {
const defaultMountOptions = { route: { query: { fmgPage: 'page-name' } } }; const defaultMountOptions = { route: { query: { fmgPage: 'page-name' } } };
const baseMountOptions = getMountOptions(Object.assign(defaultMountOptions, mountOptionsMockData)); const baseMountOptions = getMountOptions(Object.assign(defaultMountOptions, mountOptionsMockData));

View file

@ -1,27 +1,26 @@
<!-- Documented in confluence https://safelite.atlassian.net/wiki/spaces/DC/pages/76644418/Button+Question+Component --> <!-- Documented in confluence https://safelite.atlassian.net/wiki/spaces/DC/pages/76644418/Button+Question+Component -->
<template> <template>
<div :class="isOverflowScrollable ? 'button-question button-question-overflow' : 'button-question'"> <div :class="isOverflowScrollable ? 'button-question button-question-overflow' : 'button-question'">
<div v-if="questionText" class="question-text d-flex"> <div v-if="questionText && answers && answers.length > 0" class="question-text d-flex">
<span class="fs-5 fw-bold w-100">{{ questionText }}</span> <span class="fs-5 fw-bold w-100">{{ questionText }}</span>
</div> </div>
<div class="w-100 d-flex justify-content-center"> <div class="w-100 d-flex justify-content-center">
<fieldset class="w-100" :aria-required=isRequired :class="getFieldSetClasses" :role="isMultiSelect ? 'group' : 'radiogroup'" :aria-labelledby="groupName"> <fieldset class="w-100" :aria-required=isRequired :class="getFieldSetClasses" :role="isMultiSelect ? 'group' : 'radiogroup'" :aria-labelledby="formattedGroupName">
<legend class="sr-only" :data-focus-target="groupName" :id="groupName" tabindex="-1"> <legend class="sr-only" :data-focus-target="formattedGroupName" :id="formattedGroupName" tabindex="-1">
{{(isMultiSelect && answers && answers.length > 1) ? 'Select one or more options below.' : 'Select an option below.' }} {{(isMultiSelect && answers && answers.length > 1) ? 'Select one or more options below.' : 'Select an option below.' }}
</legend> </legend>
<div :class="getComponentWrapperClasses"> <div :class="getComponentLoopWrapperClasses">
<div :class="getComponentWrapperClasses" v-for="answer in answers" :key="answer.Name ? answer.Name : answer">
<component <component
:is="buttonType" :is="buttonType"
v-for="answer in answers"
:key="answer.Name ? answer.Name : answer"
@isCheckedChanged="handleCheckedChanged" @isCheckedChanged="handleCheckedChanged"
:buttonID="answer.Name ? groupName + '-' + answer.Name : groupName + '-' + answer" :buttonID="answer.Name ? formattedGroupName + '-' + answer.Name : formattedGroupName + '-' + answer"
:value="getValues(answer)" :value="getValues(answer)"
:buttonLabel="answer.Text ? answer.Text : answer" :buttonLabel="answer.Text ? answer.Text : answer"
:buttonLabelSubCopy="answer.SubText" :buttonLabelSubCopy="answer.SubText"
:textPosition="textPosition" :textPosition="textPosition"
:isMultiSelect="isMultiSelect" :isMultiSelect="isMultiSelect"
:groupName="groupName" :groupName="formattedGroupName"
:selectingInitiatesLoad="selectingInitiatesLoad" :selectingInitiatesLoad="selectingInitiatesLoad"
:loaderColor="loaderColor" :loaderColor="loaderColor"
:loaderPosition="loaderPosition" :loaderPosition="loaderPosition"
@ -38,11 +37,18 @@
:class="[suppressError ? 'alertError' : '']" :class="[suppressError ? 'alertError' : '']"
:clearOnUnmount="clearOnUnmount" :clearOnUnmount="clearOnUnmount"
/> />
<!-- For nested questions -->
<transition name="fade" mode="out-in">
<div v-if="typeof selectedValues == 'string' && selectedValues == answer.Name">
<slot></slot>
</div>
</transition>
</div>
</div> </div>
</fieldset> </fieldset>
</div> </div>
<div class="row form-test-error mt-1"> <div class="row form-test-error mt-1">
<error-message :name="groupName" v-if="!suppressError"></error-message> <error-message :name="formattedGroupName" v-if="!suppressError"></error-message>
</div> </div>
</div> </div>
</template> </template>
@ -81,22 +87,31 @@ export default {
isRequired: Boolean, isRequired: Boolean,
isOverflowScrollable: Boolean, isOverflowScrollable: Boolean,
isWide: Boolean, isWide: Boolean,
modelValue: Array, modelValue: [Array, String],
validationRules: String, validationRules: String,
suppressError: Boolean, suppressError: Boolean,
useTextForValue: Boolean, useTextForValue: Boolean,
clearOnUnmount: { clearOnUnmount: {
type: Boolean, type: Boolean,
default: true default: true
} },
}, },
computed: { computed: {
getFieldSetClasses() { formattedGroupName() {
return this.isOverflowScrollable return this.groupName.replace(" ", "-");
? "container-fluid overflow-scroll position-absolute px-5 pt-1 py-0"
: "";
}, },
getComponentWrapperClasses() { getFieldSetClasses() {
if (this.isOverflowScrollable) {
return "container-fluid overflow-scroll position-absolute px-5 pt-1 py-0";
}
else if (this.buttonType == "listCard") {
return "w-100";
}
else {
return "";
}
},
getComponentLoopWrapperClasses() {
let classes; let classes;
switch (this.buttonType) { switch (this.buttonType) {
case "listButton": case "listButton":
@ -106,7 +121,7 @@ export default {
classes = "d-flex flex-row p-0"; classes = "d-flex flex-row p-0";
break; break;
case 'listCard': case 'listCard':
classes = 'row justify-content-center g-2' classes = "row g-2 justify-content-center";
break; break;
case 'radio': case 'radio':
classes = 'ui-radio d-flex' classes = 'ui-radio d-flex'
@ -114,11 +129,22 @@ export default {
} }
return classes; return classes;
}, },
getComponentWrapperClasses() {
let classes = "";
classes += this.isWide ? "col-12" : "col";
if (this.buttonType == "radio") {
classes += " radio-button-container";
}
return classes;
},
getColLength(){ getColLength(){
if(this.isWide) { if(this.isWide) {
return "12" return "12"
} else { } else {
return this.answers.length < 3 ? '' : '-4'; return "";
} }
}, },
selectedValues: { selectedValues: {
@ -135,6 +161,7 @@ export default {
if (this.useTextForValue){ if (this.useTextForValue){
return answer.Text return answer.Text
} }
return answer.Name ? answer.Name : answer; return answer.Name ? answer.Name : answer;
}, },
handleCheckedChanged(val) { handleCheckedChanged(val) {
@ -146,7 +173,12 @@ export default {
val.checkValue ? newSelectedValues.push(val.value) : newSelectedValues.splice(newSelectedValues.indexOf(val.value), 1); val.checkValue ? newSelectedValues.push(val.value) : newSelectedValues.splice(newSelectedValues.indexOf(val.value), 1);
this.selectedValues = newSelectedValues; this.selectedValues = newSelectedValues;
} }
else {
this.selectedValues = val.value;
} }
}
this.$emit("isCheckedChanged", val);
}, },
}, },
components: { components: {
@ -172,6 +204,12 @@ export default {
} }
.button-question { .button-question {
color: $black; color: $black;
.radio-button-container {
&:not(:last-child) {
padding-bottom: map-get($spacers, 2);
}
}
} }
.question-text { .question-text {
margin-top: 1.5rem; margin-top: 1.5rem;

View file

@ -2,13 +2,13 @@
<div v-for="(q, i) in questions" :key="i"> <div v-for="(q, i) in questions" :key="i">
<transition appear name="fade" mode="out-in"> <transition appear name="fade" mode="out-in">
<buttonQuestion <buttonQuestion
v-if="q.questionSequence === currentQuestion" v-if="q.answerSelected || (q.questionSequence === currentQuestionNum)"
class="radioQuestion" class="radioQuestion"
:class="(q.questionSequence === currentQuestionNum) && 'current-question'"
:questionText="q.questionText" :questionText="q.questionText"
:answers="q.answers" :answers="q.answers"
:groupName="`${questionData.glassName}-${questionData.glassLocation}-${i}`" :groupName="`${questionData.glassName}-${questionData.glassLocation}-${i}`"
textPosition="text-start" v-model="selectedValuesArray"
v-model="selectedValue"
:isRequired=true :isRequired=true
:validationRules="validationRules" :validationRules="validationRules"
:clearOnUnmount=false :clearOnUnmount=false
@ -24,8 +24,8 @@ export default {
name: "questionChain", name: "questionChain",
data() { data() {
return { return {
currentQuestion: 1, currentQuestionNum: 1,
answeredQuestions: [], questions: [{ "BlankObject": "NOT USED... placeholder for question #0"}],
}; };
}, },
props: { props: {
@ -33,84 +33,109 @@ export default {
validationRules: String, validationRules: String,
modelValue: Array, modelValue: Array,
}, },
computed: { created() {
this.questionData.partQuestions.map((q, i) => {
questions() { let answerPair = [];
console.log("answeredQuestions: ", this.answeredQuestions) const eachQuestion = {
const questions = this.questionData.partQuestions.map((q, i) => {
return {
questionText: q.questionText, questionText: q.questionText,
questionSequence: q.questionSequence, questionSequence: q.questionSequence,
answers: q.answers.map((a) => { answers: q.answers.map((a) => {
answerPair.push(a.nextQuestionSequence ? a.nextQuestionSequence : a.answerResult);
return { return {
Text: a.answerText, Text: a.answerText,
// Name will either be nextQuestionSequence or answerResult // Name will either be nextQuestionSequence or answerResult
Name: a.nextQuestionSequence ? a.nextQuestionSequence : "answer-" + a.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
Name: a.nextQuestionSequence ?
q.questionSequence + "|nextQuestion|" + a.nextQuestionSequence + "|" + a.answerText :
q.questionSequence + "|answer|" + a.answerResult + "|" + a.answerText,
nextQuestionSequence: a.nextQuestionSequence, nextQuestionSequence: a.nextQuestionSequence,
answerResult: a.answerResult,
}
})
} }
}),
answerSelected: "",
};
eachQuestion.answerPair = answerPair;
this.questions.push(eachQuestion);
}); });
// add an empty item to be array[0] since we start with 1
questions.unshift({ "DeliberatelyBlankObject": "This object has been added as a placeholder only for question #0"});
return questions;
}, },
selectedValue: { computed: {
selectedValuesArray: {
get: function() { get: function() {
return this.modelValue; return [];
}, },
set: function(returnedAnswer) { set: function(returnedAnswerArray) {
const isNewModelValueComplete = this.getNewModelValue(returnedAnswer); const returnedAnswer = returnedAnswerArray[returnedAnswerArray.length-1];
const isQuestionChainComplete = this.handleReturnedAnswer(returnedAnswer);
if (isNewModelValueComplete) { if (isQuestionChainComplete) {
this.$emit("update:modelValue", isNewModelValueComplete); this.$emit("update:modelValue", isQuestionChainComplete);
}
} }
} }
}, },
currentQuestion() {
return this.questions[this.currentQuestionNum];
},
},
methods: { methods: {
getNewModelValue(returnedAnswer) { handleReturnedAnswer(returnedAnswer) { // returns either a final answer or Boolean false
if (!returnedAnswer || !Array.isArray(returnedAnswer)) { return false } if (!returnedAnswer) { return false }
const lastAnswer = returnedAnswer[returnedAnswer.length - 1];
const currentQuestion = this.questions[this.currentQuestion];
if (lastAnswer.indexOf("answer-") === 0) { // Example returnedAnswers:
// if it is an answerResult // "1|nextQuestion|3|No"
const finalAnswer = lastAnswer.slice(7); // "5|answer|DW02104|Yes"
const currentQuestionSelectedAnswer = currentQuestion.answers.find( const returnedAnswerArray = returnedAnswer.split("|");
({ answerResult }) => answerResult === finalAnswer const questionNum = returnedAnswerArray[0];
); const questionType = returnedAnswerArray[1];
const questionAnswer = returnedAnswerArray[2];
const questionAnswerText = returnedAnswerArray[3];
// add current item to list of answered questions // remove all previous answers after the index of this one in questions
this.answeredQuestions.push( this.questions.map((q) => {
{ if ((q.questionSequence > questionNum) || (q.answerPair?.includes(questionAnswer))) {
questionText: currentQuestion.questionText, q.answerSelected = "";
selectedAnswerText: currentQuestionSelectedAnswer.Text,
} }
); return q;
});
return { // set this question as "answered"
answerResult: finalAnswer, this.questions[questionNum].answerSelected = questionAnswerText;
answeredQuestions: this.answeredQuestions,
};
} else {
const currentQuestionSelectedAnswer = currentQuestion.answers.find(
({ nextQuestionSequence }) => nextQuestionSequence === parseInt(lastAnswer)
);
// add current item to list of answered questions // update to next question index
this.answeredQuestions.push( this.currentQuestionNum = questionType === "nextQuestion" ? parseInt(questionAnswer) : parseInt(questionNum); // update count to display next question
{
questionText: currentQuestion.questionText,
selectedAnswerText: currentQuestionSelectedAnswer.Text,
}
);
this.currentQuestion = parseInt(lastAnswer); // update count to display next question // return false if there's a nextQuestion... or return an object with "final" answers
if (questionType === "nextQuestion") {
return false; return false;
} else {
const answeredQuestions = [];
this.questions.forEach(( q ) => {
if (q.answerSelected) {
answeredQuestions.push({
questionText: q.questionText,
selectedAnswerText: q.answerSelected,
questionNum: questionNum,
});
} }
});
return {
answerResult: questionAnswer,
answeredQuestions: answeredQuestions,
};
}
}
},
watch: {
currentQuestion: {
handler() {
// scrolls page to next active question
this.$nextTick(() => {
document.querySelector('.current-question').scrollIntoView({behavior: "smooth"});
})
},
deep: true
} }
}, },
components: { components: {

View file

@ -197,7 +197,11 @@ export default {
handler() { handler() {
// does this vehicle match the previously selected carId? // does this vehicle match the previously selected carId?
this.isCarIdDifferent = this.selectedVehicle.vehicle.carId !== store.getters.vehicle.carId; this.isCarIdDifferent = this.selectedVehicle.vehicle.carId !== store.getters.vehicle.carId;
if (this.isCarIdDifferent) {
this.$refs.funnelFooter.updateButtonText(`Continue with ${this.selectedVehicle.vehicle.year} ${this.selectedVehicle.vehicle.make} ${this.selectedVehicle.vehicle.model}`); this.$refs.funnelFooter.updateButtonText(`Continue with ${this.selectedVehicle.vehicle.year} ${this.selectedVehicle.vehicle.make} ${this.selectedVehicle.vehicle.model}`);
} else {
this.$refs.funnelFooter.updateButtonText(this.getCmsContent("FunnelFooterWidget", "ForwardButtonText"));
}
}, },
deep: true deep: true
}, },

View file

@ -0,0 +1,10 @@
<script>
export default {
name: "quote",
methods: {
arePagePrerequisitesValid() {
return true;
}
}
}
</script>

View file

@ -162,10 +162,6 @@ export default {
return false; return false;
}, },
resetDependentState() {
store.dispatch(storeActions.RESET_PARTS_STATE_AND_DEPENDENCIES);
},
attachCustomEvents(){ attachCustomEvents(){
if(this.$store.getters.vehicle.imageVifNumber){ if(this.$store.getters.vehicle.imageVifNumber){
this.pushEventToGA(this.GaCategories.EVOX, `${this.GaActions.VIF}_${this.$store.getters.vehicle.imageVifNumber}`, this.pushEventToGA(this.GaCategories.EVOX, `${this.GaActions.VIF}_${this.$store.getters.vehicle.imageVifNumber}`,

View file

@ -7,7 +7,7 @@
:answers="makes" :answers="makes"
groupName="ChooseVehicleMake" groupName="ChooseVehicleMake"
textPosition="text-start" textPosition="text-start"
v-model="selectedValueAsArray" v-model="selectedValue"
isRequired=true isRequired=true
/> />
</template> </template>
@ -34,14 +34,13 @@ export default {
questionText(){ questionText(){
return this.getCmsContent(this.cmsWidgetName, 'QuestionText'); return this.getCmsContent(this.cmsWidgetName, 'QuestionText');
}, },
selectedValueAsArray: { selectedValue: {
get: function() { get: function() {
const modelValueAsArray = this.modelValue ? [this.modelValue] : []; const modelValueAsArray = this.modelValue ? [this.modelValue] : [];
return modelValueAsArray; return modelValueAsArray;
}, },
set: function(newValue) { set: function(newValue) {
const newValueAsScalar = newValue && newValue.length > 0 ? newValue[0] : null; this.$emit("update:modelValue", newValue);
this.$emit("update:modelValue", newValueAsScalar);
} }
} }
}, },

View file

@ -7,7 +7,7 @@
:answers="models" :answers="models"
groupName="ChooseVehicleModel" groupName="ChooseVehicleModel"
textPosition="text-start" textPosition="text-start"
v-model="selectedValueAsArray" v-model="selectedValue"
isRequired=true isRequired=true
/> />
</template> </template>
@ -34,14 +34,13 @@ export default {
questionText(){ questionText(){
return this.getCmsContent(this.cmsWidgetName, 'QuestionText'); return this.getCmsContent(this.cmsWidgetName, 'QuestionText');
}, },
selectedValueAsArray: { selectedValue: {
get: function() { get: function() {
const modelValueAsArray = this.modelValue ? [this.modelValue] : []; const modelValueAsArray = this.modelValue ? [this.modelValue] : [];
return modelValueAsArray; return modelValueAsArray;
}, },
set: function(newValue) { set: function(newValue) {
const newValueAsScalar = newValue && newValue.length > 0 ? newValue[0] : null; this.$emit("update:modelValue", newValue);
this.$emit("update:modelValue", newValueAsScalar);
} }
} }
}, },

View file

@ -69,24 +69,25 @@ describe("glass-part-question.vue", () => {
}); });
test("Should emit updateModelValue, and have correct attributes", async () => { test("Should emit updateModelValue, and have correct attributes", async () => {
//Arrange //Arrange
const { wrapper } = setupMocks(featureListData); const { wrapper } = setupMocks(featureListData);
store.getters.pageData.mockReset();
store.getters.pageData.mockReturnValueOnce({ partsOrQuestions: [{glassName: "Stationary", glassLocation: "Rear", parts: [{ partNumber: "DB12209GTYN", color: "Green Tint"}]}] });
//Act //Act
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
const listCard = await wrapper.findComponent({ const listCard = await wrapper.findComponent({
name: "listCard", name: "buttonQuestion",
}); });
wrapper.setValue({ selectedTint: 'Green Tint' }); await wrapper.setData({ selectedTint: 'Green Tint' });
// to trigger the computed setter
wrapper.vm.selectedPartNumber = "DB12209GTYN";
//Assert //Assert
expect(wrapper.emitted()["update:modelValue"][0]).toEqual([{ selectedTint: 'Green Tint' }]); expect(wrapper.emitted()["update:modelValue"][0]).toEqual([{ partNumber: "DB12209GTYN", color: "Green Tint"}]);
expect(listCard.attributes("buttonid")).toBe("Rear-Stationary-Green Tint");
expect(listCard.attributes("groupname")).toBe("Rear-Stationary"); expect(listCard.attributes("groupname")).toBe("Rear-Stationary");
expect(listCard.attributes("isradio")).toBe("true"); expect(listCard.attributes("validationrules")).toBe("Rear-Stationary-tint-required");
}); });
test("ResetTintAndPartSelections, should reset data elements ", async () => { test("ResetTintAndPartSelections, should reset data elements ", async () => {
@ -96,21 +97,101 @@ describe("glass-part-question.vue", () => {
//Act //Act
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
wrapper.setData({ selectedTint: { "Rear-Stationary": 'Green Tint' } }); await wrapper.setData({ selectedTint: 'Green Tint', selectedPartNumber: "test" });
expect(wrapper.vm.selectedTint).toStrictEqual({ "Rear-Stationary": 'Green Tint' }); expect(wrapper.vm.selectedTint).toEqual('Green Tint');
expect(wrapper.vm.selectedPartNumber).toEqual('test');
await wrapper.vm.ResetTintAndPartSelections(); await wrapper.vm.ResetTintAndPartSelections();
expect(wrapper.vm.selectedTint).toStrictEqual({});
expect(wrapper.vm.selectedTint).toEqual("Green Tint");
expect(wrapper.vm.selectedPartNumber).toEqual(null);
}); });
test("default is selected if only one option", async () => {
// Arrange
store.getters.pageData.mockReset();
store.getters.pageData.mockReturnValueOnce({ partsOrQuestions: [{glassName: "Stationary", glassLocation: "Rear", parts: [{ partNumber: "DB12209GTYN", color: "Green Tint"}]}] });
const { wrapper } = setupMocks(featureListData);
// Act
await wrapper.vm.$nextTick();
await wrapper.setData({ selectedTint: "Green Tint" });
// take emitted value, pass down as modelValue
// yes, yes, it's not ideal
await wrapper.setProps({ modelValue: wrapper.emitted()["update:modelValue"][0][0] })
await wrapper.vm.$nextTick();
// Assert
expect(wrapper.vm.selectedPartNumber).toBe("DB12209GTYN");
});
test("default is not selected if more than one option", async () => {
// Arrange
store.getters.pageData.mockReset();
store.getters.pageData.mockReturnValueOnce({ partsOrQuestions: [{glassName: "Stationary", glassLocation: "Rear", parts: [{ partNumber: "DB12209GTYN", color: "Green Tint"}, { partNumber: "DB12209GTYNXXX", color: "Green Tint"}]}] });
const { wrapper } = setupMocks(featureListData);
// Act
await wrapper.vm.$nextTick();
await wrapper.setData({ selectedTint: "Green Tint" });
await wrapper.vm.$nextTick();
// Assert
expect(wrapper.emitted()["update:modelValue"]).toBeFalsy();
expect(wrapper.vm.selectedPartNumber).toBeFalsy();
})
const partsForSelectedTintTestCases = [
["Rear", "Stationary", "Green Tint", [{ partNumber: "Glass1", color: "Green Tint"}, { partNumber: "Glass3", color: "Green Tint"}, { partNumber: "Glass4", color: "Green Tint"}, { partNumber: "Glass6", color: "Green Tint"} ]],
["Rear", "Stationary", "Blue Tint", [{ partNumber: "Glass2", color: "Blue Tint"}, { partNumber: "Glass5", color: "Blue Tint"}]],
["Rear", "Stationary", "Red Tint", [{ partNumber: "Glass7", color: "Red Tint"}]],
["Windshield", "Single", "Green Tint", [{ partNumber: "Windshield1", color: "Green Tint"}, { partNumber: "Windshield2", color: "Green Tint"}]],
["Windshield", "Single", "Blue Tint", []],
["Driver", "Quarter", "Green Tint", []]
];
test.each(partsForSelectedTintTestCases)("partsForSelectedTint returns correct parts", async (glassLocation, glassName, selectedTint, expectedResults) => {
// Arrange
store.getters.pageData.mockReset();
store.getters.pageData.mockReturnValueOnce({ partsOrQuestions: [
{
glassName: "Stationary",
glassLocation: "Rear",
parts: [
{ partNumber: "Glass1", color: "Green Tint"},
{ partNumber: "Glass2", color: "Blue Tint"},
{ partNumber: "Glass3", color: "Green Tint"},
{ partNumber: "Glass4", color: "Green Tint"},
{ partNumber: "Glass5", color: "Blue Tint"},
{ partNumber: "Glass6", color: "Green Tint"},
{ partNumber: "Glass7", color: "Red Tint"}
]
},
{
glassName: "Single",
glassLocation: "Windshield",
parts: [{ partNumber: "Windshield1", color: "Green Tint"}, { partNumber: "Windshield2", color: "Green Tint"}]
}
]});
const { wrapper } = setupMocks({
glassLocationProp: glassLocation,
glassNameProp: glassName,
colorAnswersProp: [],
});
// Act
await wrapper.setData({ selectedTint: selectedTint });
// Assert
expect(expectedResults).toEqual(wrapper.vm.partsForSelectedTint);
});
}); });
function setupMocks({ glassNameProp, glassLocationProp, colorAnswersProp, modelValueProp }) {
function setupMocks({ glassNameProp, glassLocationProp, colorAnswersProp, modelValueProp }) {
//Mock store //Mock store
store.getters.pageData.mockReturnValueOnce({ partsOrQuestions: {}}); store.getters.pageData.mockReturnValueOnce({ partsOrQuestions: [{glassName: "Stationary", glassLocation: "Rear", parts: []}] });
store.getters.lineItems = { glassParts: {} } store.getters.lineItems = { glassParts: {} }
const mountOptions = getMountOptions({ const mountOptions = getMountOptions({

View file

@ -1,79 +1,53 @@
<template> <template>
<div class="container">
<div class="row"> <div class="row">
<p class="mb-0 color-question-text">{{ colorQuestionText }}</p> <p class="mb-0 color-question-text">{{ colorQuestionText }}</p>
</div> </div>
</div> <div class="nested-radio">
<div
class="container nested-radio"
v-for="(value, name, index) in featureListData"
:key="index"
>
<div class="row my-2"> <div class="row my-2">
<div class="col"> <div class="col">
<listCard <buttonQuestion
v-model="selectedTint[name]" v-model="selectedTint"
:value="`${glassLocation}-${glassName}-${name}`" :answers="tintSelectionOptions"
:isRadio="true" buttonType="listCard"
:isWide="true" :isWide="true"
:buttonImage="
require(`@/assets/img/tints/${getTintSourceImage(
glassLocation,
name
)}`)
"
:buttonLabel="name"
altText="" altText=""
isRequired isRequired
:buttonID="`${glassLocation}-${glassName}-${name}`"
:groupName="`${glassLocation}-${glassName}`" :groupName="`${glassLocation}-${glassName}`"
@isCheckedChanged="ResetTintAndPartSelections()" @isCheckedChanged="ResetTintAndPartSelections"
:validationRules="validationRules" :validationRules="tintValidationRules"
/>
<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">
<div
v-if="
selectedTint[name] != undefined &&
selectedTint[name].buttonId ===
`${glassLocation}-${glassName}-${name}`
"
class="row my-2"
aria-live="polite"
> >
<div class="row my-2" aria-live="polite">
<div class="col"> <div class="col">
<buttonQuestion <buttonQuestion
v-model="selectedPart[glassLocation]" v-model="selectedPartNumber"
buttonType="radio" buttonType="radio"
class="radioQuestion" class="radioQuestion"
:questionText="glassFeatureQuestion" :questionText="glassFeatureQuestion"
:answers="value" :answers="featureListData[selectedTint]"
textPosition="text-start" textPosition="text-start"
:loaderEnabled="false" :loaderEnabled="false"
isRequired isRequired
:groupName="`${glassLocation}-${glassName}-${name}`" :groupName="`${glassLocation}-${glassName}-${selectedTint}`"
:validationRules="validationRules" :validationRules="partValidationRules"
/> />
</div> </div>
</div> </div>
</transition> </buttonQuestion>
</div>
</div>
</div> </div>
</template> </template>
<script> <script>
// Components // Components
import listCard from "@/ux-components/list-card/list-card";
import buttonQuestion from "@/common-components/button-question/button-question"; import buttonQuestion from "@/common-components/button-question/button-question";
// Supporting files // Supporting files
import { getTintImage } from "@/constants/tint-mapper"; import { getTintImage } from "@/constants/tint-mapper";
import { getCustomTransformValue } from "@/constants/dynamictext-mapper"; import { getCustomTransformValue } from "@/constants/dynamictext-mapper";
import { ErrorMessage } from 'vee-validate'; import { defineRule } from "vee-validate";
import { required } from "@/helpers/validation-rules";
import { errorMessages } from "@/constants/error-messages";
export default { export default {
name: "glass-part-question", name: "glass-part-question",
@ -82,7 +56,7 @@ export default {
return { return {
glassColorQuestion: "", glassColorQuestion: "",
glassFeatureQuestion: "", glassFeatureQuestion: "",
selectedTint: {}, // v-model for the list card selections selectedTint: "",
}; };
}, },
props: { props: {
@ -90,17 +64,25 @@ export default {
glassLocation: String, glassLocation: String,
colorAnswers: Array, colorAnswers: Array,
modelValue: Object, modelValue: Object,
validationRules: String, alreadyPopulatedPartsData: Array
}, },
mounted() { mounted() {
this.LoadPreselectedValues(); this.LoadPreselectedValues();
}, },
components: { components: {
listCard,
buttonQuestion, buttonQuestion,
ErrorMessage,
}, },
computed: { computed: {
tintValidationRules() {
const validationRuleName = `${this.glassLocation}-${this.glassName}-tint-required`;
defineRule(validationRuleName, required(errorMessages.OPTION_REQUIRED));
return validationRuleName;
},
partValidationRules() {
const validationRuleName = `${this.glassLocation}-${this.glassName}-part-required`;
defineRule(validationRuleName, required(errorMessages.OPTION_REQUIRED));
return validationRuleName;
},
colorQuestionText() { colorQuestionText() {
return getCustomTransformValue( return getCustomTransformValue(
this.glassColorQuestion, this.glassColorQuestion,
@ -108,13 +90,37 @@ export default {
); );
}, },
selectedPart: { tintSelectionOptions() {
get: function () { let tintOptions = [];
return this.modelValue; Object.keys(this.featureListData).forEach((tintOption) => {
tintOptions.push({
Name: tintOption,
Text: tintOption,
AnswerImageUrl: require(`@/assets/img/tints/${this.getTintSourceImage(
this.glassLocation,
tintOption
)}`),
});
});
return tintOptions;
}, },
set: function (newValue) {
this.$emit("update:modelValue", newValue); selectedPartNumber: {
get() {
return this.modelValue?.partNumber;
}, },
set(newValue) {
this.$emit("update:modelValue", this.partsForSelectedTint.filter(part => part.partNumber == newValue)[0]);
},
},
partsForSelectedTint() {
const matchingGlass = this.PartDataFromApi.partsOrQuestions?.filter(dataForGlassLocationAndName =>
dataForGlassLocationAndName.glassName == this.glassName &&
dataForGlassLocationAndName.glassLocation == this.glassLocation);
const matchingGlassParts = matchingGlass?.length == 1 ? matchingGlass[0].parts : [];
return matchingGlassParts.filter(part => part.color == this.selectedTint) ?? [];
}, },
// Creates a map of the feature list data in the correct Name/Value // Creates a map of the feature list data in the correct Name/Value
@ -126,12 +132,15 @@ export default {
arr[itemColor] = arr[itemColor] || []; arr[itemColor] = arr[itemColor] || [];
// Map the data to Name/Text object for ButtonQuestion // Map the data to Name/Text object for ButtonQuestion
const mappedItem = item.FeatureAnswers.reduce((featureArr, item) => { const mappedItem = item.FeatureAnswers.reduce(
(featureArr, item) => {
featureArr["Text"] = item.FeatureAnswerText; // Display to User featureArr["Text"] = item.FeatureAnswerText; // Display to User
featureArr["Name"] = item.PartNumber; // Backing Value featureArr["Name"] = item.PartNumber; // Backing Value
return featureArr; return featureArr;
}, {}); },
{}
);
// Add onto the final object // Add onto the final object
arr[itemColor].push(mappedItem); arr[itemColor].push(mappedItem);
@ -143,14 +152,16 @@ export default {
}, },
PartDataFromApi() { PartDataFromApi() {
return this.$store.getters.pageData(this.$route.query.fmgPage); return this.$store.getters.pageData(this.$route.query.fmgPage) ?? {};
}, },
}, },
methods: { methods: {
// Initialize the component data // Initialize the component data
initializeComponent(cmsContent) { initializeComponent(cmsContent) {
this.glassColorQuestion = cmsContent.ColorQuestionWidget.QuestionText; this.glassColorQuestion =
this.glassFeatureQuestion = cmsContent.FeatureQuestionWidget.QuestionText; cmsContent.ColorQuestionWidget.QuestionText;
this.glassFeatureQuestion =
cmsContent.FeatureQuestionWidget.QuestionText;
}, },
// Gets tint images based on the glass type, and tint name. // Gets tint images based on the glass type, and tint name.
@ -158,7 +169,10 @@ export default {
getTintSourceImage(glassLocation, tintColor) { getTintSourceImage(glassLocation, tintColor) {
const tintSourceObject = getTintImage(glassLocation, tintColor); const tintSourceObject = getTintImage(glassLocation, tintColor);
if (tintSourceObject === undefined || tintSourceObject.src == undefined) { if (
tintSourceObject === undefined ||
tintSourceObject.src == undefined
) {
return ""; return "";
} }
@ -168,33 +182,15 @@ export default {
// Reset selections when tint changes for the same glass to ensure proper selection. // Reset selections when tint changes for the same glass to ensure proper selection.
// Also checks if only a single part is present for the tint. // Also checks if only a single part is present for the tint.
ResetTintAndPartSelections() { ResetTintAndPartSelections() {
this.selectedTint = {}; this.selectedPartNumber = null;
this.selectedPart = {};
this.AutoSelectIfSinglePart(); this.AutoSelectIfSinglePart();
}, },
// Check if only a single part is present for the tint and set the v-model if it is. // Check if only a single part is present for the tint and set the v-model if it is.
AutoSelectIfSinglePart() { AutoSelectIfSinglePart() {
// Check if the selected tint only has a single feature if (this.partsForSelectedTint?.length == 1) {
Object.keys(this.PartDataFromApi.partsOrQuestions ?? {}).forEach( this.selectedPartNumber = this.partsForSelectedTint[0].partNumber;
(key) => {
const currentGlassSelection =
this.PartDataFromApi.partsOrQuestions[key];
if (
currentGlassSelection.glassName === this.glassName &&
currentGlassSelection.glassLocation === this.glassLocation
) {
if (currentGlassSelection.parts.length === 1) {
this.selectedPart = {
[currentGlassSelection.glassLocation]: [
currentGlassSelection.parts[0].partNumber,
],
};
} }
}
}
);
}, },
// Loads the preselected values from the store. // Loads the preselected values from the store.
@ -202,27 +198,16 @@ export default {
this.$nextTick(() => { this.$nextTick(() => {
if (this.modelValue !== undefined) { if (this.modelValue !== undefined) {
// Populate button-question model-value if parts data already exists in VueX // Populate button-question model-value if parts data already exists in VueX
const alreadyPopulatedPartsData = this.selectedTint = this.alreadyPopulatedPartsData?.filter(part => part.partNumber === this.selectedPartNumber)[0].color;
this.$store.getters.lineItems.glassParts;
Object.keys(alreadyPopulatedPartsData).forEach((key) => {
const partNumber = alreadyPopulatedPartsData[key].partNumber;
const tintColor = alreadyPopulatedPartsData[key].color;
Object.keys(this.modelValue).forEach((key) => {
if (this.modelValue[key][0] === partNumber) {
this.selectedTint[tintColor] = {
buttonId: `${this.glassLocation}-${this.glassName}-${tintColor}`,
checkValue: "",
value: `${this.glassLocation}-${this.glassName}-${tintColor}`,
};
}
});
});
} }
}); });
}, },
}, },
watch: {
selectedTint() {
this.AutoSelectIfSinglePart();
}
}
}; };
</script> </script>

View file

@ -65,7 +65,7 @@ describe("vehicle-parts.vue", () => {
test("Set cms content called on load", async (done) => { test("Set cms content called on load", async (done) => {
//Arrange //Arrange
store.getters.pageData.mockReturnValue(basePartResponse); store.getters.pageData.mockReturnValue(basePartResponse);
store.getters.lineItems = { glassParts: {} } store.getters.lineItems = { glassParts: null }
const { wrapper, apiPromise } = setupMocks( const { wrapper, apiPromise } = setupMocks(
{ {
@ -104,7 +104,7 @@ describe("vehicle-parts.vue", () => {
//Arrange //Arrange
store.getters.pageData.mockReturnValue(basePartResponse); store.getters.pageData.mockReturnValue(basePartResponse);
store.getters.lineItems = { glassParts: {} } store.getters.lineItems = { glassParts: null }
const { wrapper } = setupMocks({ const { wrapper } = setupMocks({
mountOptionsMockData: { mountOptionsMockData: {
@ -141,7 +141,7 @@ describe("vehicle-parts.vue", () => {
//Arrange //Arrange
store.getters.pageData.mockReturnValueOnce(basePartResponse); store.getters.pageData.mockReturnValueOnce(basePartResponse);
store.getters.lineItems = { glassParts: { 0: { partNumber: 'DB12209YPYNOEM'} } } store.getters.lineItems = { glassParts: [{ partNumber: 'DB12209YPYNOEM'} ] }
const { wrapper } = setupMocks({ const { wrapper } = setupMocks({
mountOptionsMockData: { mountOptionsMockData: {
@ -177,7 +177,7 @@ describe("vehicle-parts.vue", () => {
//Arrange //Arrange
store.getters.pageData.mockReturnValueOnce(basePartResponse); store.getters.pageData.mockReturnValueOnce(basePartResponse);
store.getters.lineItems = { glassParts: {} } store.getters.lineItems = { glassParts: null }
const { wrapper } = setupMocks({ const { wrapper } = setupMocks({
mountOptionsMockData: { mountOptionsMockData: {
@ -236,7 +236,7 @@ describe("vehicle-parts.vue", () => {
} }
}); });
wrapper.setData({ glassParts: { "Rear-Stationary": { "Rear": ['DB12209YPYNOEM'] } } }); wrapper.setData({ glassParts: {"Rear-Stationary": { partNumber: 'DB12209GTYN'}}});
//Act //Act
vehicleParts.beforeRouteEnter.call( vehicleParts.beforeRouteEnter.call(
@ -280,13 +280,14 @@ function setupMocks({ pageHeaderWidgetHeaderText = {}, mountOptionsMockData = {}
settleAllPromises.mockImplementation(() => apiPromise); settleAllPromises.mockImplementation(() => apiPromise);
fetchCmsContentForPage.mockImplementation(() => Promise.resolve()); fetchCmsContentForPage.mockImplementation(() => Promise.resolve());
const mountOptions = getMountOptions(mountOptionsMockData,); const mountOptions = getMountOptions(mountOptionsMockData);
const wrapper = shallowMount(vehicleParts, mountOptions); const wrapper = shallowMount(vehicleParts, mountOptions);
const partQuestionRearWrapper = wrapper.findComponent({ name: "glassPartQuestion", }); const partQuestionRearWrapper = wrapper.findComponent({ name: "glassPartQuestion", });
partQuestionRearWrapper.vm.initializeComponent = glassPartQuestion.methods.initializeComponent; partQuestionRearWrapper.vm.initializeComponent = glassPartQuestion.methods.initializeComponent;
wrapper.vm.setCmsContent = baseMixin.methods.setCmsContent; wrapper.vm.setCmsContent = baseMixin.methods.setCmsContent;
wrapper.vm.$refs.funnelFooter.removeLoader = jest.fn();
return { wrapper, apiPromise }; return { wrapper, apiPromise };
} }

View file

@ -1,16 +1,18 @@
<template> <template>
<Form <Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm">
@submit="onSubmit"
@invalid-submit="onInvalidSubmit"
ref="theForm"
v-slot="{ meta }"
>
<div class="page-container-grouped-styles vehicle-parts"> <div class="page-container-grouped-styles vehicle-parts">
<funnelHeader ref="funnelHeader" cmsWidgetName="FunnelHeaderWidget" /> <funnelHeader ref="funnelHeader" cmsWidgetName="FunnelHeaderWidget" />
<vehicleBanner ref="vehicleBanner" cmsWidgetName="VehicleBannerWidget" :displayGenericVehicleImage="false" /> <vehicleBanner
<funnelSubHeader ref="funnelSubHeader" cmsWidgetName="FunnelSubHeaderWidget" /> ref="vehicleBanner"
cmsWidgetName="VehicleBannerWidget"
:displayGenericVehicleImage="false"
/>
<funnelSubHeader
ref="funnelSubHeader"
cmsWidgetName="FunnelSubHeaderWidget"
/>
<div class="fade-on-route-transition sub-container make-tall"> <div class="fade-on-route-transition sub-container make-tall">
<div class="container-fluid prevent-squish my-5"> <div class="prevent-squish my-5">
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<alert <alert
@ -22,32 +24,28 @@
</div> </div>
</div> </div>
</div> </div>
<div v-for="(item, i) in PartsOrQuestions" :key="i">
<div v-for="(item, i) in PartsForQuestions" :key="i">
<!-- Render horizontal lines if there is multi-glass (aka if i > 0) --> <!-- Render horizontal lines if there is multi-glass (aka if i > 0) -->
<div class="container-fluid">
<hr v-if="i > 0" /> <hr v-if="i > 0" />
</div>
<glassPartQuestion <glassPartQuestion
:ref="`${RefPrefix}-${item.glassLocation}-${item.glassName}`" :ref="`${RefPrefix}-${item.glassLocation}-${item.glassName}`"
v-model="glassParts[item.glassLocation + '-' + item.glassName]" v-model="glassParts[item.glassLocation + '-' + item.glassName]"
:glassLocation="item.glassLocation" :glassLocation="item.glassLocation"
:glassName="item.glassName" :glassName="item.glassName"
:colorAnswers="item.colorAnswers" :colorAnswers="item.colorAnswers"
validationRules="replace-options-required" :alreadyPopulatedPartsData="alreadyPopulatedPartsData"
/> />
</div> </div>
<funnelFooter <funnelFooter
cmsWidgetName="FunnelFooterWidget" cmsWidgetName="FunnelFooterWidget"
ref="funnelFooter" ref="funnelFooter"
:isForwardActionDisabled="!meta.valid" :isForwardActionDisabled="isForwardActionDisabled"
@back-clicked="backButtonAction" @back-clicked="backButtonAction"
@ForwardClicked="forwardButtonAction" @ForwardClicked="forwardButtonAction"
/> />
</div> </div>
</div> </div>
</Form> </Form>
</template> </template>
<script> <script>
@ -69,19 +67,18 @@ import { required } from "@/helpers/validation-rules";
import { errorMessages } from "@/constants/error-messages"; import { errorMessages } from "@/constants/error-messages";
import { storeActions } from "@/constants/store-actions"; import { storeActions } from "@/constants/store-actions";
// DEFINE VALIDATION RULES
defineRule("replace-options-required", required(errorMessages.OPTION_REQUIRED));
export default { export default {
name: "vehicle-parts", name: "vehicle-parts",
async beforeRouteEnter(to, from, next) { async beforeRouteEnter(to, from, next) {
// Call APIs // Call APIs
const cmsContentPromise = fetchCmsContentForPage(to.query.fmgPage); const cmsContentPromise = fetchCmsContentForPage(to.query.fmgPage);
// Settle promises and get results // Settle promises and get results
const promiseResultMap = [{ const promiseResultMap = [
{
resultKey: "cmsContent", resultKey: "cmsContent",
promise: cmsContentPromise, promise: cmsContentPromise,
}, ]; },
];
const resultMap = await settleAllPromises(promiseResultMap); const resultMap = await settleAllPromises(promiseResultMap);
// Call the "next" function to complete the transition to this page. // Call the "next" function to complete the transition to this page.
next((vm) => { next((vm) => {
@ -97,14 +94,15 @@ export default {
}) })
); );
}); });
}, },
data() { data() {
return { return {
glassParts: {}, glassParts: {},
alertWidgetData: Object, alertWidgetData: Object,
alreadyPopulatedPartsData: {},
}; };
}, },
components: { components: {
Form, Form,
glassPartQuestion, glassPartQuestion,
funnelHeader, funnelHeader,
@ -112,9 +110,33 @@ components: {
funnelSubHeader, funnelSubHeader,
funnelFooter, funnelFooter,
alert, alert,
}, },
computed: { computed: {
PartsForQuestions() { isForwardActionDisabled() {
return (
Object.keys(this.matchedParts).length !==
this.PartsFromApi.partsOrQuestions.length
);
},
matchedParts() {
const matchedParts = [];
// Match them to the parts from the API.
this.PartsFromApi.partsOrQuestions.forEach((part) => {
const selectedPartForGlassLocationAndName =
this.glassParts[`${part.glassLocation}-${part.glassName}`];
const selectedPartData = part.parts.filter(
(part) =>
selectedPartForGlassLocationAndName &&
part.partNumber == selectedPartForGlassLocationAndName?.partNumber
)[0];
if (selectedPartData) matchedParts.push(selectedPartData);
});
return matchedParts;
},
PartsOrQuestions() {
const partsData = this.PartsFromApi; const partsData = this.PartsFromApi;
// Map API result data, to vehicle-parts data structure // Map API result data, to vehicle-parts data structure
@ -148,12 +170,12 @@ computed: {
RefPrefix() { RefPrefix() {
return "partQuestion"; return "partQuestion";
}, },
}, },
methods: { methods: {
arePagePrerequisitesValid() { arePagePrerequisitesValid() {
// Check if isRepair is populated and if the pageData we need is here (Parts data) // Check if isRepair is populated and if the pageData we need is here (Parts data)
if ( if (
(store.getters.damage.isRepair != null) && store.getters.damage.isRepair != null &&
Object.keys(store.getters.pageData(fmgPageValues.VEHICLE_PARTS)) Object.keys(store.getters.pageData(fmgPageValues.VEHICLE_PARTS))
.length !== 0 .length !== 0
) { ) {
@ -177,9 +199,7 @@ methods: {
} }
// Match them to the parts from the API. // Match them to the parts from the API.
for (let [key, value] of Object.entries( for (let [key, value] of Object.entries(this.PartsFromApi.partsOrQuestions)) {
this.PartsFromApi.partsOrQuestions
)) {
for (let [partKey, partValue] of Object.entries(value.parts)) { for (let [partKey, partValue] of Object.entries(value.parts)) {
const currentPart = const currentPart =
this.PartsFromApi.partsOrQuestions[key].parts[partKey]; this.PartsFromApi.partsOrQuestions[key].parts[partKey];
@ -194,16 +214,22 @@ methods: {
} }
// If no parts could be matched, throw an error. // If no parts could be matched, throw an error.
if (matchedParts.length === 0) { if (this.isForwardActionDisabled) {
this.$refs.funnelFooter.removeLoader();
throw new Error("Could not match any parts to the selected parts"); throw new Error("Could not match any parts to the selected parts");
} }
// Save parts to the store. // Save parts to the store.
await this.dispatchStoreAction(storeActions.SAVE_GLASS_PARTS, matchedParts); await this.dispatchStoreAction(
storeActions.SAVE_GLASS_PARTS,
matchedParts
);
// Navigate to the next page. // Navigate to the next page.
this.$router.navigate(this.navigationScenarios.SELECTED_PARTS,this.$route); this.$router.navigate(
this.navigationScenarios.SELECTED_PARTS,
this.$route
);
}, },
LoadInitialPartsData() { LoadInitialPartsData() {
@ -227,9 +253,9 @@ methods: {
}); });
}); });
}, },
}, },
mounted() { mounted() {
this.LoadInitialPartsData(); this.LoadInitialPartsData();
}, },
}; };
</script> </script>

View file

@ -7,7 +7,7 @@
:answers="styles" :answers="styles"
groupName="ChooseVehicleStyle" groupName="ChooseVehicleStyle"
textPosition="text-start" textPosition="text-start"
v-model="selectedValueAsArray" v-model="selectedValue"
isRequired=true isRequired=true
/> />
</template> </template>
@ -34,14 +34,13 @@ export default {
questionText(){ questionText(){
return this.getCmsContent(this.cmsWidgetName, 'QuestionText'); return this.getCmsContent(this.cmsWidgetName, 'QuestionText');
}, },
selectedValueAsArray: { selectedValue: {
get: function() { get: function() {
const modelValueAsArray = this.modelValue ? [this.modelValue] : []; const modelValueAsArray = this.modelValue ? [this.modelValue] : [];
return modelValueAsArray; return modelValueAsArray;
}, },
set: function(newValue) { set: function(newValue) {
const newValueAsScalar = newValue && newValue.length > 0 ? newValue[0] : null; this.$emit("update:modelValue", newValue);
this.$emit("update:modelValue", newValueAsScalar);
} }
} }
}, },

View file

@ -7,7 +7,7 @@
:answers="years" :answers="years"
groupName="ChooseVehicleYear" groupName="ChooseVehicleYear"
textPosition="text-start" textPosition="text-start"
v-model="selectedValueAsArray" v-model="selectedValue"
isRequired=true isRequired=true
/> />
</template> </template>
@ -35,14 +35,13 @@ export default {
questionText(){ questionText(){
return this.getCmsContent(this.cmsWidgetName, 'QuestionText'); return this.getCmsContent(this.cmsWidgetName, 'QuestionText');
}, },
selectedValueAsArray: { selectedValue: {
get: function() { get: function() {
const modelValueAsArray = this.modelValue ? [this.modelValue] : []; const modelValueAsArray = this.modelValue ? [this.modelValue] : [];
return modelValueAsArray; return modelValueAsArray;
}, },
set: function(newValue) { set: function(newValue) {
const newValueAsScalar = newValue && newValue.length > 0 ? newValue[0] : null; this.$emit("update:modelValue", newValue);
this.$emit("update:modelValue", newValueAsScalar);
} }
} }
}, },

View file

@ -177,15 +177,10 @@ export default {
previouslyEnteredCarId: '', previouslyEnteredCarId: '',
invalidZip: '', invalidZip: '',
vinPopulatedOnPageLoad: this.getVinFromStore()?.length > 0, vinPopulatedOnPageLoad: this.getVinFromStore()?.length > 0,
isInsuranceVerified: false,
}; };
}, },
mounted() { mounted() {
this.attachCustomEvents(); this.attachCustomEvents();
if (this.vinPopulatedOnPageLoad) {
this.setupVinMask();
this.isInsuranceVerified = store.getters.payment.insuranceCoverage.isVerified || getFunnelCookie().HasDelayedClaimRegistration;
}
}, },
watch: { watch: {
vin() { vin() {
@ -231,12 +226,20 @@ export default {
return this.getCmsContent("PerfectMatchInsuranceVerifiedAlert", "BodyText").replaceAll("{custom:damage}", return this.getCmsContent("PerfectMatchInsuranceVerifiedAlert", "BodyText").replaceAll("{custom:damage}",
getIsWindshieldOnly()) getIsWindshieldOnly())
}, },
isInsuranceVerified() {
return store.getters.payment.insuranceCoverage.isVerified || getFunnelCookie().HasDelayedClaimRegistration;
},
vinMask() {
if (this.vinPopulatedOnPageLoad) {
const lastSixChars = this.vin.substring(11, this.vin.length);
return `!X!X!X!X!X!X!X!X!X!X!X${lastSixChars}`;
}
else {
return 'XXXXXXXXXXXXXXXXX';
}
},
}, },
methods: { methods: {
setupVinMask() {
const lastSixChars = this.vin.substring(11, this.vin.length);
this.vinMask = `!X!X!X!X!X!X!X!X!X!X!X${lastSixChars}`;
},
arePagePrerequisitesValid() { arePagePrerequisitesValid() {
return store.getters.vehicle.carId !== null; return store.getters.vehicle.carId !== null;
}, },

View file

@ -12,6 +12,7 @@ const fmgPageValues = {
REVEAL: "reveal", REVEAL: "reveal",
ESTIMATE: "estimate", ESTIMATE: "estimate",
ADDRESS_VEHICLES: "address-vehicles", ADDRESS_VEHICLES: "address-vehicles",
QUOTE: "quote"
}; };
export { fmgPageValues }; export { fmgPageValues };

View file

@ -46,6 +46,7 @@ html {
input[type=radio]+label:before, input[type=radio]+label:before,
input[type=checkbox]+label:before { input[type=checkbox]+label:before {
border: 1px solid $red; border: 1px solid $red;
background-color: initial;
} }
input[type=checkbox]:checked + label:before { input[type=checkbox]:checked + label:before {
border: 1px solid $blue; border: 1px solid $blue;

View file

@ -224,18 +224,26 @@ export default {
font-size: .875rem; font-size: .875rem;
} }
} }
}
.col {
&:first-of-type { &:first-of-type {
.list-button-horizontal {
label { label {
border-bottom-left-radius: 0.5rem; border-bottom-left-radius: 0.5rem;
border-top-left-radius: 0.5rem; border-top-left-radius: 0.5rem;
z-index: 2; z-index: 2;
} }
} }
}
&:last-of-type { &:last-of-type {
.list-button-horizontal {
label { label {
border-bottom-right-radius: 0.5rem; border-bottom-right-radius: 0.5rem;
border-top-right-radius: 0.5rem; border-top-right-radius: 0.5rem;
} }
} }
}
} }
</style> </style>

View file

@ -205,7 +205,7 @@ describe("list-card.vue", () => {
}); });
wrapper.vm.handleCheckChange(); wrapper.vm.handleCheckChange();
// Assert // Assert
expect(wrapper.emitted()["isCheckedChanged"][0]).toEqual([{value: "List Card Checkbox", checkValue: [Boolean, String], buttonId: 'list-card-id'}]); expect(wrapper.emitted()["isCheckedChanged"][0]).toEqual([{value: "List Card Checkbox", checkValue: "List Card Checkbox", buttonId: 'list-card-id'}]);
}); });
it("Should set checkValue data if selectedButtonIDs has value(s)", async () => { it("Should set checkValue data if selectedButtonIDs has value(s)", async () => {

View file

@ -1,8 +1,9 @@
<template> <template>
<div :class="'col' + colLength"> <div :class="{'h-100': !isWide}">
<div <div
class="list-card w-100 rounded-3 d-flex align-items-center h-100" class="list-card w-100 rounded-3 d-flex align-items-center"
:class="[ :class="[
'h-100',
isWide ? 'horizontal' : '', isWide ? 'horizontal' : '',
(errors.length > 0 || hasError) ? 'has-error' : '', (errors.length > 0 || hasError) ? 'has-error' : '',
]" ]"
@ -19,7 +20,8 @@
:value="value" :value="value"
:aria-required="isRequired" :aria-required="isRequired"
v-model="checkValue" v-model="checkValue"
@change="handleInputChange()" :checked="checkValue"
@change="handleInputChange"
/> />
<label <label
tabindex="-1" tabindex="-1"
@ -27,7 +29,7 @@
:aria-labelledby="buttonID" :aria-labelledby="buttonID"
class="d-flex w-100 align-items-center px-2 h-100" class="d-flex w-100 align-items-center px-2 h-100"
:class="getLabelClasses" :class="getLabelClasses"
@mouseup="triggerButton()" @mouseup="triggerButton"
> >
<img <img
:id="buttonImageId" :id="buttonImageId"
@ -93,7 +95,7 @@ export default {
}, },
data() { data() {
return { return {
checkValue: [Boolean, String], checkValue: null,
} }
}, },
created() { created() {
@ -102,6 +104,14 @@ export default {
? this.selectedValues.includes(this.value) ? this.selectedValues.includes(this.value)
: this.selectedValues[0]; : this.selectedValues[0];
} }
else if (Array.isArray(this.modelValue)) {
this.checkValue = this.isMultiSelect
? this.modelValue.includes(this.value)
: this.modelValue[0];
}
else {
this.checkValue = this.selectedValues == this.value || this.modelValue == this.value;
}
}, },
unmounted() { // needed to clear this button's selectedValues if it is removed to keep validation in sync unmounted() { // needed to clear this button's selectedValues if it is removed to keep validation in sync
if (this.clearOnUnmount) { if (this.clearOnUnmount) {
@ -164,6 +174,15 @@ export default {
this.checkValue = newVal.value; this.checkValue = newVal.value;
} }
}, },
selectedValues(newVal) {
if (typeof newVal === "string") {
this.handleChange(this.value);
this.checkValue = newVal == this.value;
}
else if (newVal !== undefined) {
this.checkValue = newVal.value;
}
},
}, },
setup(props) { setup(props) {
const inputType = props.isMultiSelect ? "checkbox" : "radio"; const inputType = props.isMultiSelect ? "checkbox" : "radio";

View file

@ -92,7 +92,7 @@ describe("radio.vue", () => {
isRequired: true, isRequired: true,
modelValue: ["List Card Checkbox"], modelValue: ["List Card Checkbox"],
value: "Car-Front", value: "Car-Front",
selectedValues: ["Car-Front"] selectedValues: "Car-Front"
}, },
}); });
// Assert // Assert

View file

@ -1,6 +1,6 @@
<template> <template>
<!-- Checkbox groups MUST be wrapped in a <fieldset> and <legend> tag and must contain tabindex --> <!-- Checkbox groups MUST be wrapped in a <fieldset> and <legend> tag and must contain tabindex -->
<div class="ui-radio form-check" :class="[hasError ? 'has-error' : '']"> <div class="ui-radio form-check" :class="[(errors.length > 0 || hasError) ? 'has-error' : '']">
<input <input
type="radio" type="radio"
class="form-check-input" class="form-check-input"
@ -10,8 +10,9 @@
:aria-required="isRequired" :aria-required="isRequired"
:value="value" :value="value"
:v-model="checkValue" :v-model="checkValue"
@change="handleCheckChange()" @change="handleCheckChange"
:checked="checkValue" :checked="checkValue"
:validationRules="validationRules"
/> />
<label class="d-flex align-items-start form-check-label" :for="buttonID"> <label class="d-flex align-items-start form-check-label" :for="buttonID">
<p v-if="buttonLabel" class="m-0">{{ buttonLabel }}</p> <p v-if="buttonLabel" class="m-0">{{ buttonLabel }}</p>
@ -37,8 +38,9 @@ export default {
default: "", default: "",
}, },
screenReaderOnlyText: String, screenReaderOnlyText: String,
selectedValues: [Array, String], selectedValues: String,
hasError: Boolean, hasError: Boolean,
validationRules: String
}, },
data() { data() {
return { return {
@ -47,16 +49,15 @@ export default {
}, },
created() { created() {
if (this.selectedValues) { if (this.selectedValues) {
this.checkValue = this.selectedValues[0] === this.value; this.checkValue = this.selectedValues === this.value;
}else{ this.handleCheckChange();
} else{
this.checkValue = false; this.checkValue = false;
} }
}, },
methods: { methods: {
handleClick(value) {
this.handleChange(value);
},
handleCheckChange() { handleCheckChange() {
this.handleChange(this.value);
const emitEvent = { const emitEvent = {
checkValue: this.checkValue, checkValue: this.checkValue,
value: this.value.toString(), value: this.value.toString(),
@ -88,13 +89,17 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
.form-check { .form-check {
position: relative;
.form-check-input { .form-check-input {
border: 1px solid $gray-500; border: 1px solid $gray-500;
border-radius: 100%; border-radius: 50%;
margin-right: 0.5rem; margin-right: 0.5rem;
&:checked { &:checked {
background-color: $white; background-color: $white;
background-size: 71%; background-size: 71%;
background-position: center;
border: 1px solid $blue; border: 1px solid $blue;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 50 50' xml:space='preserve'%3e%3ccircle cx='25' cy='25' r='25' fill='%231574A1'/%3e%3c/svg%3e"); background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 50 50' xml:space='preserve'%3e%3ccircle cx='25' cy='25' r='25' fill='%231574A1'/%3e%3c/svg%3e");
+ label { + label {
@ -108,7 +113,15 @@ export default {
&:focus { &:focus {
box-shadow: 0 0 0 2.5px $blue; box-shadow: 0 0 0 2.5px $blue;
} }
&, & + label {
margin-top: 0;
position: absolute;
top: 50%;
transform: translateY(-50%);
} }
}
&:hover { &:hover {
.form-check-input { .form-check-input {
box-shadow: 0 0 0 4px $blue-300; box-shadow: 0 0 0 4px $blue-300;