Merge pull request #762 from Safelite/feature/CSR-762

Feature/csr 762
This commit is contained in:
katieoh-safelite 2022-10-27 13:31:36 -04:00 committed by GitHub
commit 61ecd7cf9f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
55 changed files with 5427 additions and 2783 deletions

5
.prettierrc Normal file
View file

@ -0,0 +1,5 @@
{
"tabWidth": 4,
"bracketSameLine": true,
"printWidth": 100
}

View file

@ -1,16 +1,29 @@
<template> <template>
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">
<transition :duration="{ enter: 200, leave: 200 }" name="route-fade" mode="out-in"> <transition
<!-- The above durations should be kept in sync with the global css class "fade-on-route-transition" --> :duration="{ enter: 200, leave: 200 }"
<component :is="Component" /> name="route-fade"
</transition> mode="out-in">
</router-view> <!-- The above durations should be kept in sync with the global css class "fade-on-route-transition" -->
<component :is="Component" @focusin="handleAnyComponentFocus" />
</transition>
</router-view>
</template> </template>
<script>
import { handleAnyComponentFocus } from "@/helpers/button-question-focus-helper"
export default {
name: "app",
methods: {
handleAnyComponentFocus: handleAnyComponentFocus
}
};
</script>
<style lang="scss"> <style lang="scss">
@import "./node_modules/bootstrap/scss/bootstrap"; @import "./node_modules/bootstrap/scss/bootstrap";
@import "@/styles/common-styles.scss"; @import "@/styles/common-styles.scss";
@import "@/styles/common-typography-styles.scss"; @import "@/styles/common-typography-styles.scss";
@import "@/styles/common-error-styles.scss"; @import "@/styles/common-error-styles.scss";
@import "@/styles/common-animations.scss"; @import "@/styles/common-animations.scss";
</style> </style>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,201 @@
<template>
<label
:class="[buttonWrapperClasses, { 'has-error': errors.length > 0 }]"
:for="buttonId"
@focusin="handleFocus"
@focusout="handleBlur"
@mousedown.left="handleEventAction(eventTypes.CLICK, $event)">
<input
:type="inputType"
:id="buttonId"
:key="buttonId"
:name="groupName"
:class="inputClasses"
:aria-required="isRequired"
:value="value"
:checked="isChecked"
@keypress.space="handleEventAction(eventTypes.SPACE, $event)"
@keypress.enter="handleEventAction(eventTypes.ENTER, $event)"
@change="handleEventAction(eventTypes.CHANGE, $event)" />
<slot></slot>
</label>
</template>
<script>
import { useField } from "vee-validate";
import { toRef } from "vue";
import { queryStrings } from "@/constants/query-strings";
import { handleButtonComponentFocus, handleInputComponentBlur } from "@/helpers/button-question-focus-helper";
import { inputButtonProps } from "@/common-components/base-input-button/button-functionality-props";
export default {
name: "base-input-button",
props: {
...inputButtonProps,
buttonWrapperClasses: [String, Array, Object],
inputClasses: [String, Array, Object],
},
data() {
return {
valueToEmit: null,
};
},
mounted() {
if (this.isChecked) {
this.handleChange(this.modelValue);
}
},
methods: {
handleEventAction(eventType, e) {
if (this.isMultiSelect) {
switch (eventType) {
case this.eventTypes.ENTER:
case this.eventTypes.CHANGE:
this.handleClick(e);
this.handlePushClickEventToGACheck(
this.eventTypes.CLICK
);
break;
}
} else {
switch (eventType) {
case this.eventTypes.CLICK:
case this.eventTypes.ENTER:
case this.eventTypes.SPACE:
this.handleClick(e);
this.handlePushClickEventToGACheck(
this.eventTypes.CLICK
);
break;
case this.eventTypes.CHANGE:
this.selectingInitiatesLoad
? this.handleSelectionChange(e)
: this.handleClick(e);
break;
}
}
},
handleSelectionChange(e) {
if (
this.isMultiSelect &&
(this.modelValue instanceof Array || this.modelValue == null)
) {
let newValue = this.modelValue ? [...this.modelValue] : [];
if (!newValue.includes(this.value)) {
newValue.push(this.value);
} else {
newValue.splice(newValue.indexOf(this.value), 1);
}
this.valueToEmit = newValue;
} else if (!this.isMultiSelect) {
this.valueToEmit = this.value;
}
this.handleChange(this.valueToEmit);
},
handleClick(e) {
this.handleSelectionChange(e);
this.$emit("update:modelValue", this.valueToEmit);
},
handleFocus() {
handleButtonComponentFocus({
groupName: this.groupName,
});
},
handleBlur() {
handleInputComponentBlur({
groupName: this.groupName,
onButtonQuestionLostFocusCallback: this.handlePushClickEventToGACheck,
});
},
handlePushClickEventToGACheck(source) {
// if from a click or click-like event
if (source === this.eventTypes.CLICK) {
this.pushClickEventToGA();
} else { // if from tabbing around
if (
this.valueToEmit !== null &&
!this.isValueSelectedOnClick &&
this.isChecked &&
this.lastValuePushedToGa != this.value
) {
this.pushClickEventToGA();
}
}
},
pushClickEventToGA(value) {
this.pushEventToGA(
this.$route.query[queryStrings.FMG_PAGE],
this.GaActions.CLICKED,
value?.toString() ?? this.value?.toString(),
true,
this.valueToLogType
);
this.setLastValuePushedToGa(value ?? this.value);
},
},
computed: {
isChecked() {
if (this.isMultiSelect && this.modelValue instanceof Array) {
return this.modelValue.includes(this.value);
} else if (!this.isMultiSelect) {
return this.modelValue == this.value;
} else {
return false;
}
},
inputType() {
return this.isMultiSelect ? "checkbox" : "radio";
},
buttonId() {
return `${this.groupName?.replace(" ", "-")}-${this.value
?.toString()
?.replace(" ", "-")}`;
},
isValueSelectedOnClick() {
return this.isMultiSelect || this.selectingInitiatesLoad;
},
eventTypes() {
return {
CHANGE: "change",
ENTER: "enter",
SPACE: "space",
CLICK: "click",
};
},
},
setup(props) {
const inputType = props.isMultiSelect ? "checkbox" : "radio";
const fieldOptions = {
type: inputType,
validateOnValueUpdate: false,
validateOnMount: false,
};
const { handleChange, meta, errors } = useField(
toRef(props, "groupName"),
toRef(props, "validationRules"),
fieldOptions
);
return {
handleChange,
errors,
meta,
fieldOptions, // only need to expose this for unit test purposes
};
},
};
</script>
<style lang="scss" scoped>
input {
opacity: 0;
height: 0.1px; // NOTE: cannot be zero or Safari can't put focus on it
width: 0;
}
</style>

View file

@ -0,0 +1,30 @@
export const inputButtonProps = {
value: {
type: [String, Number],
required: true,
},
modelValue: {
type: [Array, String, Number],
required: true,
},
isMultiSelect: Boolean,
groupName: {
type: String,
required: true,
},
validationRules: {
type: String,
default: "",
},
valueToLogType: String,
isRequired: {
type: Boolean,
default: true,
},
lastValuePushedToGa: [String, Number],
setLastValuePushedToGa: Function,
selectingInitiatesLoad: {
type: Boolean,
default: false,
},
}

File diff suppressed because it is too large Load diff

View file

@ -1,253 +1,249 @@
<!-- 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
<div v-if="questionText && answers && answers.length > 0" class="question-text d-flex"> :class="
<span class="fw-bold w-100">{{ questionText }}</span> isOverflowScrollable ? 'button-question button-question-overflow' : 'button-question'
</div> ">
<div class="w-100 d-flex justify-content-center"> <div v-if="questionText && answers && answers.length > 0" class="question-text d-flex">
<fieldset class="w-100" :aria-required=isRequired :class="getFieldSetClasses" :role="isMultiSelect ? 'group' : 'radiogroup'" :aria-labelledby="formatString(groupName)"> <span class="fw-bold w-100">{{ questionText }}</span>
<legend class="sr-only" :data-focus-target="formatString(groupName)" :id="formatString(groupName)" tabindex="-1"> </div>
{{ questionText }}
{{(isMultiSelect && answers && answers.length > 1) ? 'Select one or more options below.' : 'Select an option below.' }} <div class="w-100 d-flex justify-content-center">
</legend> <fieldset
<div :class="getComponentLoopWrapperClasses"> class="w-100"
<div :class="getComponentWrapperClasses" v-for="answer in answers" :key="answer.Name ? answer.Name : answer"> :aria-required="isRequired"
<component :class="getFieldSetClasses"
:is="buttonType" :role="isMultiSelect ? 'group' : 'radiogroup'"
@isCheckedChanged="handleCheckedChanged" :aria-labelledby="formatString(groupName)">
:buttonID="answer.Name ? formatString(groupName) + '-' + answer.Name : formatString(groupName) + '-' + getAnswerString(answer, 'Text')" <legend
:value="getValue(answer)" class="sr-only"
:buttonLabel="answer.Text ? answer.Text : getAnswerString(answer, 'Name')" :data-focus-target="formatString(groupName)"
:buttonLabelSubCopy="answer.SubText" tabindex="-1"
:textPosition="textPosition" :id="formatString(groupName)">
:isMultiSelect="isMultiSelect" {{ questionText }}
:groupName="formatString(groupName)" {{
:selectingInitiatesLoad="selectingInitiatesLoad" isMultiSelect && answers && answers.length > 1
:loaderColor="loaderColor" ? "Select one or more options below."
:loaderPosition="loaderPosition" : "Select an option below."
:isWide=isWide }}
:isCashOrInsurance=isCashOrInsurance </legend>
:isRequired=isRequired <div :class="getComponentLoopWrapperClasses">
:buttonImage="answer.AnswerImageUrl" <div
:buttonImageId="answer.ImageId" :class="getComponentWrapperClasses"
:altText="answer.Name ? answer.Name : answer" v-for="answer in buttonsInfo"
screenReaderOnlyText="(opens new window)" :key="answer.value ? answer.value : answer">
:colLength="getColLength" <component
:selectedValues="selectedValues" :is="buttonType"
data-test="button" :buttonLabel="answer.buttonLabel"
:validationRules="validationRules" :buttonLabelSubCopy="answer.buttonLabelSubCopy"
:class="[suppressError ? 'alertError' : '' , isCashOrInsurance ? 'radio-fancy' : '']" :buttonImage="answer.buttonImage"
:valueToLogType="valueToLogType" :buttonImageId="answer.buttonImageId"
/> :groupName="answer.groupName"
<!-- For nested questions --> :isMultiSelect="isMultiSelect"
<transition name="fade" mode="out-in"> :value="answer.value"
<div v-if="typeof selectedValues == 'string' && selectedValues == answer.Name"> :selectingInitiatesLoad="selectingInitiatesLoad"
<slot></slot> :isWide="isWide"
</div> :validationRules="validationRules"
</transition> :textPosition="textPosition"
</div> :lastValuePushedToGa="lastValuePushedToGa"
:setLastValuePushedToGa="setLastValuePushedToGa"
v-model="selectedValues" />
<!-- For nested questions -->
<transition name="fade" mode="out-in">
<div
v-if="
typeof selectedValues == 'string' &&
selectedValues == answer.value
">
<slot></slot>
</div>
</transition>
</div>
</div>
</fieldset>
</div>
<div class="row form-test-error mt-1">
<error-message :name="formatString(groupName)" v-if="!suppressError"></error-message>
</div> </div>
</fieldset>
</div> </div>
<div class="row form-test-error mt-1">
<error-message :name="formatString(groupName)" v-if="!suppressError"></error-message>
</div>
</div>
</template> </template>
<script> <script>
import listButton from "@/ux-components/list-button/list-button"; import listButton from "@/ux-components/list-button/list-button";
import listButtonHorizontal from "@/ux-components/list-button-horizontal/list-button-horizontal"; import listButtonHorizontal from "@/ux-components/list-button-horizontal/list-button-horizontal";
import listCard from "@/ux-components/list-card/list-card"; import listCard from "@/ux-components/list-card/list-card";
import { ErrorMessage } from 'vee-validate';
import radio from "@/ux-components/radio/radio"; import radio from "@/ux-components/radio/radio";
import { ErrorMessage } from "vee-validate";
export default { export default {
name: "buttonQuestion", name: "buttonQuestion",
props: { props: {
buttonType: { buttonType: {
type: String, type: String,
default: "listButton", default: "listButton",
},
isMultiSelect: Boolean,
groupName: String,
questionText: String,
answers: Array,
textPosition: {
type: String,
default: "text-center",
},
selectingInitiatesLoad: Boolean,
loaderColor: {
type: String,
default: "blue",
},
loaderPosition: {
type: String,
default: "right",
},
isRequired: Boolean,
isOverflowScrollable: Boolean,
isWide: Boolean,
isCashOrInsurance: Boolean,
modelValue: [Array, Number, String],
value: [Number, String],
validationRules: String,
suppressError: Boolean,
useTextForValue: Boolean,
valueToLogType: String,
}, },
isMultiSelect: Boolean, data() {
groupName: String, return {
questionText: String, lastValuePushedToGa: null,
answers: Array, };
textPosition: {
type: String,
default: "text-center",
}, },
selectingInitiatesLoad: Boolean, computed: {
loaderColor: { getFieldSetClasses() {
type: String, if (this.isOverflowScrollable) {
default: "blue", return "container-fluid overflow-scroll position-absolute px-5 pt-1 py-0";
}, } else if (this.buttonType == "listCard") {
loaderPosition: { return "w-100";
type: String, } else {
default: "right", return "";
}, }
isRequired: Boolean, },
isOverflowScrollable: Boolean, getComponentLoopWrapperClasses() {
isWide: Boolean, let classes;
isCashOrInsurance: Boolean, switch (this.buttonType) {
modelValue: [Array, String], case "listButton":
validationRules: String, classes = "w-100";
suppressError: Boolean, break;
useTextForValue: Boolean, case "listButtonHorizontal":
valueToLogType: String, classes = "d-flex flex-row p-0";
}, break;
computed: { case "listCard":
getFieldSetClasses() { classes = "row g-2 justify-content-center";
if (this.isOverflowScrollable) { if (this.isWide) {
return "container-fluid overflow-scroll position-absolute px-5 pt-1 py-0"; classes += " flex-column";
} }
else if (this.buttonType == "listCard") { break;
return "w-100"; case "radio":
} classes = "ui-radio d-flex";
else { break;
return ""; }
} return classes;
}, },
getComponentLoopWrapperClasses() { getComponentWrapperClasses() {
let classes; let classes = "";
switch (this.buttonType) {
case "listButton":
classes = "w-100";
break;
case "listButtonHorizontal":
classes = "d-flex flex-row p-0";
break;
case 'listCard':
classes = "row g-2 justify-content-center";
break;
case 'radio':
classes = 'ui-radio d-flex'
break;
}
return classes;
},
getComponentWrapperClasses() {
let classes = "";
classes += this.isWide ? "col-12" : "col"; classes += this.isWide ? "col-12" : "col";
if (this.buttonType == "radio") { if (this.buttonType == "radio") {
classes += " radio-button-container"; classes += " radio-button-container";
} }
return classes; return classes;
},
buttonsInfo() {
return (Array.isArray(this.answers) ? this.answers : [])?.map((answer) => ({
buttonLabel: answer.buttonLabel ?? answer.Text ?? answer,
altText: answer.altText ?? (answer.Name ? answer.Name : answer),
buttonLabelSubCopy: answer.buttonLabelSubCopy ?? answer.SubText,
buttonImage: answer.buttonImage ?? answer.AnswerImageUrl,
buttonImageId: answer.buttonImageId ?? answer.ImageId,
groupName: this.formatString(this.groupName),
value:
answer.value ??
(this.useTextForValue && answer.Text ? answer.Text : answer.Name) ??
(typeof answer !== "object" ? answer : null),
}));
},
selectedValues: {
get() {
return this.modelValue;
},
set(selectedAnswers) {
this.$emit("update:modelValue", selectedAnswers);
},
},
}, },
getColLength(){ methods: {
if(this.isWide) { formatString(str) {
return "12" return str?.replaceAll(" ", "-");
} else { },
return ""; setLastValuePushedToGa(lastValuePushedToGa) {
} this.lastValuePushedToGa = lastValuePushedToGa;
},
}, },
selectedValues: { components: {
get: function() { listButton,
return this.modelValue; listButtonHorizontal,
}, listCard,
set: function(newValue) { ErrorMessage,
this.$emit("update:modelValue", newValue); radio,
}
}, },
},
methods: {
formatString(str) {
return str.replace(" ", "-");
},
getValue(answer){
if (this.useTextForValue) { return answer.Text }
return answer.Name ? answer.Name : answer;
},
getAnswerString(answer, prop = "Name") {
switch (typeof answer) {
case "string":
case "number":
case "boolean":
return this.formatString(answer.toString());
default:
return answer[prop] ? this.formatString(answer[prop]) : this.formatString(answer.toString());
}
},
handleCheckedChanged(val) {
if(this.selectingInitiatesLoad) {
this.selectedValues = val.value;
} else {
if(this.isMultiSelect) {
const newSelectedValues = this.selectedValues;
val.checkValue ? newSelectedValues.push(val.value) : newSelectedValues.splice(newSelectedValues.indexOf(val.value), 1);
this.selectedValues = newSelectedValues;
}
else if (Array.isArray(this.selectedValues)) {
this.selectedValues[0] = val.value;
const temp = this.selectedValues;
this.selectedValues = temp;
}
else {
this.selectedValues = val.value;
}
}
this.$emit("isCheckedChanged", val);
},
},
components: {
listButton,
listButtonHorizontal,
listCard,
ErrorMessage,
radio,
},
}; };
</script> </script>
<style lang="scss"> <style lang="scss">
.button-question-overflow { .button-question-overflow {
height: calc(100vh - 274px); height: calc(100vh - 274px);
.overflow-scroll { .overflow-scroll {
// Height will be determined by overall height of content above list // Height will be determined by overall height of content above list
height: calc(100% - 314px); height: calc(100% - 314px);
overflow-x: hidden !important; overflow-x: hidden !important;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
} }
.button-question { .button-question {
color: $black; color: $black;
.radio-button-container { .radio-button-container {
&:not(:last-child) { &:not(:last-child) {
padding-bottom: map-get($spacers, 2); padding-bottom: map-get($spacers, 2);
}
} }
}
} }
.question-text { .question-text {
margin-top: 1.5rem; margin-top: 1.5rem;
margin-bottom: 1rem; margin-bottom: 1rem;
font-size: 1rem; font-size: 1rem;
line-height: 1.625rem; line-height: 1.625rem;
& > span { & > span {
text-align: center; text-align: center;
} }
} }
.vehicle-parts { .vehicle-parts {
.question-text { .question-text {
span { span {
font-size: .875rem; font-size: 0.875rem;
text-align: left; text-align: left;
margin: 0 0 .5rem 0; margin: 0 0 0.5rem 0;
}
} }
} .question-text {
.question-text { margin: 0;
margin: 0; }
} fieldset {
fieldset { .ui-radio {
.ui-radio { margin: 0;
margin: 0; }
} }
}
} }
</style> </style>

View file

@ -8,8 +8,8 @@
:questionText="q.questionText" :questionText="q.questionText"
:answers="q.answers" :answers="q.answers"
:groupName="`question-${glassIndex}-${q.questionSequence}`" :groupName="`question-${glassIndex}-${q.questionSequence}`"
v-model="q.answerSelected" :modelValue="q.answerSelected"
@isCheckedChanged="handleAnswer" @update:modelValue="handleAnswer(q, $event)"
isRequired isRequired
:validationRules="validationRules" :validationRules="validationRules"
/> />
@ -45,13 +45,13 @@ export default {
questionSequence: q.questionSequence, questionSequence: q.questionSequence,
answers: q.answers.map((a) => { answers: q.answers.map((a) => {
return { return {
Text: a.answerText, buttonLabel: a.answerText,
// Name will either be nextQuestionSequence or answerResult // Name will either be nextQuestionSequence or answerResult
// Name will be used by list-button as the input value. // 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 // It must be a single string or number, so concatenating together a string with
// 4 pieces of data separated by pipe characters: // 4 pieces of data separated by pipe characters:
// question number|type of answer|answer value|answer text // question number|type of answer|answer value|answer text
Name: a.nextQuestionSequence ? value: a.nextQuestionSequence ?
q.questionSequence + "|nextQuestion|" + a.nextQuestionSequence + "|" + a.answerText : q.questionSequence + "|nextQuestion|" + a.nextQuestionSequence + "|" + a.answerText :
q.questionSequence + "|answer|" + a.answerResult + "|" + a.answerText, q.questionSequence + "|answer|" + a.answerResult + "|" + a.answerText,
nextQuestionSequence: a.nextQuestionSequence, nextQuestionSequence: a.nextQuestionSequence,
@ -77,7 +77,8 @@ export default {
} }
}, },
methods: { methods: {
handleAnswer(returnedAnswer) { handleAnswer(question, returnedAnswer) {
question.answerSelected = returnedAnswer;
/* /*
returnedAnswer example format: returnedAnswer example format:
{ {
@ -86,7 +87,7 @@ export default {
"buttonId": "Driver-Front-1-1|answer|DD11132|Yes" "buttonId": "Driver-Front-1-1|answer|DD11132|Yes"
} }
*/ */
const isQuestionChainComplete = this.getQuestionChainAnswerIfComplete(returnedAnswer.value); const isQuestionChainComplete = this.getQuestionChainAnswerIfComplete(returnedAnswer);
if (isQuestionChainComplete) { if (isQuestionChainComplete) {
this.$emit("update:modelValue", isQuestionChainComplete); this.$emit("update:modelValue", isQuestionChainComplete);

View file

@ -18,11 +18,11 @@ const storeActions = {
LOOKUP_VEHICLE_BY_VIN: "lookupVehicleByVin", LOOKUP_VEHICLE_BY_VIN: "lookupVehicleByVin",
LOOKUP_VIN_BY_PLATE: "lookupVinByPlate", LOOKUP_VIN_BY_PLATE: "lookupVinByPlate",
LOOKUP_VIN_BY_ADDRESS: "lookupVinByAddress", LOOKUP_VIN_BY_ADDRESS: "lookupVinByAddress",
GET_PARTS_OR_QUESTIONS: "getPartsOrQuestions", GET_PARTS_OR_QUESTIONS: "getPartsOrQuestions",
GET_PARTS: "getParts", GET_PARTS: "getParts",
GET_CAPABILITY_QUESTIONS: "getCapabilityQuestions", GET_CAPABILITY_QUESTIONS: "getCapabilityQuestions",
GET_PART_FROM_CAPABILITY_QUESTION_ANSWER: "getPartFromCapabilityQuestionAnswer", GET_PART_FROM_CAPABILITY_QUESTION_ANSWER:
"getPartFromCapabilityQuestionAnswer",
GET_MOLDING_QUESTIONS: "getMoldingQuestions", GET_MOLDING_QUESTIONS: "getMoldingQuestions",
SAVE_SESSION: "saveSession", SAVE_SESSION: "saveSession",
LOAD_SESSION: "loadSession", LOAD_SESSION: "loadSession",
@ -45,22 +45,23 @@ const storeActions = {
RESET_STATE: "resetState", RESET_STATE: "resetState",
// SAVE COMPONENT STATE // SAVE COMPONENT STATE
SAVE_VEHICLE_YEAR: "saveVehicleYear", SAVE_VEHICLE_YEAR: "saveVehicleYear",
SAVE_VEHICLE_MAKE:"saveVehicleMake", SAVE_VEHICLE_MAKE: "saveVehicleMake",
SAVE_VEHICLE_MODEL:"saveVehicleModel", SAVE_VEHICLE_MODEL: "saveVehicleModel",
SAVE_VEHICLE_STYLE: "saveVehicleStyle", SAVE_VEHICLE_STYLE: "saveVehicleStyle",
SAVE_VEHICLE_DAMAGE: "saveVehicleDamage", SAVE_VEHICLE_DAMAGE: "saveVehicleDamage",
SAVE_VIN_LOOKUP: "saveVinLookup", SAVE_VIN_LOOKUP: "saveVinLookup",
SAVE_SERVICE_LOCATION: "saveServiceLocation", SAVE_SERVICE_LOCATION: "saveServiceLocation",
SAVE_EMAIL: "saveEmail", SAVE_EMAIL: "saveEmail",
SAVE_REGISTRATION_LICENSE_PLATE_LOOKUP: "saveRegistrationLicensePlateLookup", SAVE_REGISTRATION_LICENSE_PLATE_LOOKUP: "saveRegistrationLicensePlateLookup",
SAVE_VIN: "saveVin", SAVE_VIN: "saveVin",
SAVE_REGISTRATION_ADDRESS_LOOKUP: "saveRegistrationAddressLookup", SAVE_REGISTRATION_ADDRESS_LOOKUP: "saveRegistrationAddressLookup",
SAVE_GLASS_PARTS: "saveGlassParts", SAVE_GLASS_PARTS: "saveGlassParts",
SAVE_PART_QUESTION_ANSWERS: "savePartQuestionAnswers", SAVE_PART_QUESTION_ANSWERS: "savePartQuestionAnswers",
RESET_MOLDING_AND_CAPABILITY_QUESTIONS_IF_NEEDED: "resetMoldingAndCapabilityQuestionAnswersIfNeeded", RESET_MOLDING_AND_CAPABILITY_QUESTIONS_IF_NEEDED:
"resetMoldingAndCapabilityQuestionAnswersIfNeeded",
SAVE_MOLDING_QUESTION_ANSWERS: "saveMoldingQuestionAnswers", SAVE_MOLDING_QUESTION_ANSWERS: "saveMoldingQuestionAnswers",
SAVE_CAPABILITY_QUESTION_ANSWERS: "saveCapabilityQuestionAnswers" SAVE_CAPABILITY_QUESTION_ANSWERS: "saveCapabilityQuestionAnswers",
}; };
export { storeActions }; export { storeActions };

View file

@ -1,5 +1,4 @@
const storeMutations = { const storeMutations = {
// VEHICLE MUTATIONS // VEHICLE MUTATIONS
UPDATE_YEAR: "updateYear", UPDATE_YEAR: "updateYear",
UPDATE_MAKE: "updateMake", UPDATE_MAKE: "updateMake",
@ -22,7 +21,7 @@ const storeMutations = {
UPDATE_GLASS_PARTS: "updateGlassParts", UPDATE_GLASS_PARTS: "updateGlassParts",
UPDATE_OTHER_PARTS: "updateOtherParts", UPDATE_OTHER_PARTS: "updateOtherParts",
UPDATE_REGISTRATION_LICENSE_PLATE : "updateRegistrationLicensePlate", UPDATE_REGISTRATION_LICENSE_PLATE: "updateRegistrationLicensePlate",
UPDATE_REGISTRATION_ADDRESS: "updateRegistrationAddress", UPDATE_REGISTRATION_ADDRESS: "updateRegistrationAddress",
UPDATE_REGISTRATION_CITY: "updateRegistrationCity", UPDATE_REGISTRATION_CITY: "updateRegistrationCity",
UPDATE_REGISTRATION_STATE: "updateRegistrationState", UPDATE_REGISTRATION_STATE: "updateRegistrationState",
@ -69,4 +68,4 @@ const storeMutations = {
UPDATE_TRIGGERED_SITE_ENTRY: "updateTriggeredSiteEntry", UPDATE_TRIGGERED_SITE_ENTRY: "updateTriggeredSiteEntry",
}; };
export { storeMutations }; export { storeMutations };

View file

@ -0,0 +1,41 @@
/**
* Helper for GA click event. When the user mouse clicks on a `base-input-button`, we
* push the click event. When the user tabs through a list of radio buttons via a
* keyboard, we only want to push the GA click event if the selection was deliberate
* (space/enter key) or if there is a selection and the user tabs off of the radio group.
*/
let lastFocusedInputGroupName = "";
let onButtonQuestionLostFocusCallback = null;
const handleAnyComponentFocus = (e) => {
const targetType = e.target.type;
if (targetType !== "radio" && targetType !== "checkbox") {
invokeButtonQuestionLostFocusCallback();
}
};
const handleButtonComponentFocus = (e) => {
if (e && lastFocusedInputGroupName !== e.groupName) {
invokeButtonQuestionLostFocusCallback();
}
};
const handleInputComponentBlur = (e) => {
if (e) {
lastFocusedInputGroupName = e.groupName;
onButtonQuestionLostFocusCallback = e.onButtonQuestionLostFocusCallback;
}
};
const invokeButtonQuestionLostFocusCallback = () => {
if (onButtonQuestionLostFocusCallback) {
onButtonQuestionLostFocusCallback();
}
};
export {
handleAnyComponentFocus,
handleButtonComponentFocus,
handleInputComponentBlur,
};

View file

@ -0,0 +1,251 @@
import {
handleAnyComponentFocus,
handleButtonComponentFocus,
handleInputComponentBlur,
} from "@/helpers/button-question-focus-helper";
describe("buttonQuestionFocusHelper", () => {
let onButtonQuestionLostFocusCallbackOne = jest.fn();
let onButtonQuestionLostFocusCallbackTwo = jest.fn();
let focusOnInputInGroupOne;
let blurFromInputInGroupOne;
let focusOnInputInGroupTwo;
let blurFromInputInGroupTwo;
let focusOnNonRadioCheckboxElement;
beforeEach(() => {
onButtonQuestionLostFocusCallbackOne = jest.fn();
onButtonQuestionLostFocusCallbackTwo = jest.fn();
// Sanity check
expect(onButtonQuestionLostFocusCallbackOne).not.toHaveBeenCalled();
expect(onButtonQuestionLostFocusCallbackTwo).not.toHaveBeenCalled();
focusOnInputInGroupOne = () => {
handleButtonComponentFocus({
groupName: "group1",
});
};
blurFromInputInGroupOne = () => {
handleInputComponentBlur({
groupName: "group1",
onButtonQuestionLostFocusCallback: onButtonQuestionLostFocusCallbackOne,
});
};
focusOnInputInGroupTwo = () => {
handleButtonComponentFocus({
groupName: "group2",
});
};
blurFromInputInGroupTwo = () => {
handleInputComponentBlur({
groupName: "group2",
onButtonQuestionLostFocusCallback: onButtonQuestionLostFocusCallbackTwo,
});
};
focusOnNonRadioCheckboxElement = () => {
handleAnyComponentFocus({
target: {
type: "nonRadioCheckbox",
},
});
};
});
test("focus on input => no callbacks were called", () => {
// focus on input in group 1
focusOnInputInGroupOne();
// Assert
expect(onButtonQuestionLostFocusCallbackOne).not.toHaveBeenCalled();
expect(onButtonQuestionLostFocusCallbackTwo).not.toHaveBeenCalled();
});
test("focus on input, then focus on input in same group => no callbacks are called", () => {
// focus on input in group 1
focusOnInputInGroupOne();
// Assert
expect(onButtonQuestionLostFocusCallbackOne).not.toHaveBeenCalled();
expect(onButtonQuestionLostFocusCallbackTwo).not.toHaveBeenCalled();
// focus on input in group 1
blurFromInputInGroupOne();
focusOnInputInGroupOne();
// Assert
expect(onButtonQuestionLostFocusCallbackOne).not.toHaveBeenCalled();
expect(onButtonQuestionLostFocusCallbackTwo).not.toHaveBeenCalled();
});
test("focus on non-radio/checkbox, focus on input => no callbacks are called", () => {
// focus on non-radio/checkbox
focusOnNonRadioCheckboxElement();
// Assert
expect(onButtonQuestionLostFocusCallbackOne).not.toHaveBeenCalled();
expect(onButtonQuestionLostFocusCallbackTwo).not.toHaveBeenCalled();
// focus on input
// focus on input in group 1
focusOnInputInGroupOne();
// Assert
expect(onButtonQuestionLostFocusCallbackOne).not.toHaveBeenCalled();
expect(onButtonQuestionLostFocusCallbackTwo).not.toHaveBeenCalled();
});
test("focus on input in group 1, then focus on input in different group => callback for group 1 is called", () => {
// focus on input in group 1
focusOnInputInGroupOne();
// Assert
expect(onButtonQuestionLostFocusCallbackOne).not.toHaveBeenCalled();
expect(onButtonQuestionLostFocusCallbackTwo).not.toHaveBeenCalled();
// focus on input in different group
blurFromInputInGroupOne();
focusOnInputInGroupTwo();
// Assert
expect(onButtonQuestionLostFocusCallbackOne).toHaveBeenCalledTimes(1);
expect(onButtonQuestionLostFocusCallbackTwo).not.toHaveBeenCalled();
});
test("focus on input in group 1, focus on input in same group, then focus on input in different group => callback for group 1 is called", () => {
// focus on input in group 1
focusOnInputInGroupOne();
// Assert
expect(onButtonQuestionLostFocusCallbackOne).not.toHaveBeenCalled();
expect(onButtonQuestionLostFocusCallbackTwo).not.toHaveBeenCalled();
// focus on input in same group
blurFromInputInGroupOne();
focusOnInputInGroupOne();
// Assert
expect(onButtonQuestionLostFocusCallbackOne).not.toHaveBeenCalled();
expect(onButtonQuestionLostFocusCallbackTwo).not.toHaveBeenCalled();
// focus on input in different group
blurFromInputInGroupOne();
focusOnInputInGroupTwo();
// Assert
expect(onButtonQuestionLostFocusCallbackOne).toHaveBeenCalledTimes(1);
expect(onButtonQuestionLostFocusCallbackTwo).not.toHaveBeenCalled();
});
test("focus on input in group 1, focus on non-radio/checkbox element => callback from group 1 is called", () => {
// focus on input in group 1
focusOnInputInGroupOne();
// Assert
expect(onButtonQuestionLostFocusCallbackOne).not.toHaveBeenCalled();
expect(onButtonQuestionLostFocusCallbackTwo).not.toHaveBeenCalled();
// focus on input in group 2
blurFromInputInGroupOne();
focusOnNonRadioCheckboxElement();
// Assert
expect(onButtonQuestionLostFocusCallbackOne).toHaveBeenCalledTimes(1);
expect(onButtonQuestionLostFocusCallbackTwo).not.toHaveBeenCalled();
});
test("focus on input in group 1, focus on input in group 2, focus on non-radio/checkbox element => both callbacks are called", () => {
// focus on input in group 1
focusOnInputInGroupOne();
// Assert
expect(onButtonQuestionLostFocusCallbackOne).not.toHaveBeenCalled();
expect(onButtonQuestionLostFocusCallbackTwo).not.toHaveBeenCalled();
// focus on input in group 2
blurFromInputInGroupOne();
focusOnInputInGroupTwo();
// Assert
expect(onButtonQuestionLostFocusCallbackOne).toHaveBeenCalledTimes(1);
expect(onButtonQuestionLostFocusCallbackTwo).toHaveBeenCalledTimes(0);
// focus on non-radio/checkbox
blurFromInputInGroupTwo();
focusOnNonRadioCheckboxElement();
// Assert
expect(onButtonQuestionLostFocusCallbackOne).toHaveBeenCalledTimes(1);
expect(onButtonQuestionLostFocusCallbackTwo).toHaveBeenCalledTimes(1);
});
test("focus on input in group 1, focus on input in group 2, focus on input in group 1 => both callbacks are called", () => {
// focus on input in group 1
focusOnInputInGroupOne();
// Assert
expect(onButtonQuestionLostFocusCallbackOne).not.toHaveBeenCalled();
expect(onButtonQuestionLostFocusCallbackTwo).not.toHaveBeenCalled();
// focus on input in group 2
blurFromInputInGroupOne();
focusOnInputInGroupTwo();
// Assert
expect(onButtonQuestionLostFocusCallbackOne).toHaveBeenCalledTimes(1);
expect(onButtonQuestionLostFocusCallbackTwo).toHaveBeenCalledTimes(0);
// focus on input in group 1
blurFromInputInGroupTwo();
focusOnInputInGroupOne();
// Assert
expect(onButtonQuestionLostFocusCallbackOne).toHaveBeenCalledTimes(1);
expect(onButtonQuestionLostFocusCallbackTwo).toHaveBeenCalledTimes(1);
});
test("go back and forth a lot => correct callbacks are called at the right time", () => {
// Arrange/Act
// focus on input in group 1
focusOnInputInGroupOne();
// Assert
expect(onButtonQuestionLostFocusCallbackOne).not.toHaveBeenCalled();
expect(onButtonQuestionLostFocusCallbackTwo).not.toHaveBeenCalled();
// focus on input in group 2
blurFromInputInGroupOne();
focusOnInputInGroupTwo();
// Assert
expect(onButtonQuestionLostFocusCallbackOne).toHaveBeenCalledTimes(1);
expect(onButtonQuestionLostFocusCallbackTwo).not.toHaveBeenCalled();
// focus on input in group 1
blurFromInputInGroupTwo();
focusOnInputInGroupOne();
// Assert
expect(onButtonQuestionLostFocusCallbackOne).toHaveBeenCalledTimes(1);
expect(onButtonQuestionLostFocusCallbackTwo).toHaveBeenCalledTimes(1);
// focus on non-radio/checkbox element
blurFromInputInGroupOne();
focusOnNonRadioCheckboxElement();
// Assert
expect(onButtonQuestionLostFocusCallbackOne).toHaveBeenCalledTimes(2);
expect(onButtonQuestionLostFocusCallbackTwo).toHaveBeenCalledTimes(1);
// focus on input in group 1
focusOnInputInGroupOne();
expect(onButtonQuestionLostFocusCallbackOne).toHaveBeenCalledTimes(2);
expect(onButtonQuestionLostFocusCallbackTwo).toHaveBeenCalledTimes(1);
});
});

View file

@ -25,7 +25,6 @@ export async function loadSessionIfPresent() {
return null; return null;
} }
// Load referral if there is a cookie, and it doesn't indicate it needs a state reset. // Load referral if there is a cookie, and it doesn't indicate it needs a state reset.
return (await loadSession(funnelCookie.ReferralNumber, funnelCookie.ReferralDate, funnelCookie.ReferralCorrelationId, funnelCookie.ReferralParentAccountNumber)).data; return (await loadSession(funnelCookie.ReferralNumber, funnelCookie.ReferralDate, funnelCookie.ReferralCorrelationId, funnelCookie.ReferralParentAccountNumber)).data;
} }

View file

@ -34,14 +34,14 @@ describe("addressVehiclesQuestion.vue", () => {
const wrapper = shallowMount(addressVehiclesQuestion, { const wrapper = shallowMount(addressVehiclesQuestion, {
mixins: [mockMixin], mixins: [mockMixin],
propsData: { propsData: {
vehicles: ["1", "2"], vehicles: ["1", "2", "newValue"],
modelValue: ["1", "2"], modelValue: "2",
} }
}); });
// Act // Act
const localThis = { $emit: jest.fn() } const localThis = { $emit: jest.fn() }
addressVehiclesQuestion.computed.selectedVehicleVinAsArray.set.call(localThis, ['newValue']); addressVehiclesQuestion.computed.selectedVehicleVin.set.call(localThis, 'newValue');
// Assert // Assert
expect(localThis.$emit).toBeCalledWith("update:modelValue", "newValue"); expect(localThis.$emit).toBeCalledWith("update:modelValue", "newValue");

View file

@ -5,7 +5,7 @@
groupName="ChooseAddressVehicle" groupName="ChooseAddressVehicle"
:questionText="questionText" :questionText="questionText"
:answers="vehicles" :answers="vehicles"
v-model="selectedVehicleVinAsArray" v-model="selectedVehicleVin"
isRequired isRequired
:validation-rules="validationRules" :validation-rules="validationRules"
:valueToLogType="ValueToLogTypes.LAST_5" :valueToLogType="ValueToLogTypes.LAST_5"
@ -63,18 +63,16 @@ export default {
questionText() { questionText() {
return this.getCmsContent("VehicleConfirmationQuestion", "QuestionText"); return this.getCmsContent("VehicleConfirmationQuestion", "QuestionText");
}, },
selectedVehicleVinAsArray: { selectedVehicleVin: {
get: function() { get: function() {
const modelValueAsArray = this.modelValue ? [this.modelValue] : []; return this.modelValue;
return modelValueAsArray;
}, },
set: function(newValue) { set: function(newValue) {
const newValueAsScalar = newValue && newValue.length > 0 ? newValue[newValue.length-1] : null; this.$emit("update:modelValue", newValue);
this.$emit("update:modelValue", newValueAsScalar);
} }
}, },
selectedVehicle() { // this computed is only needed for the computed differentVehicleAlertBody text above selectedVehicle() { // this computed is only needed for the computed differentVehicleAlertBody text above
return this.vehicles.find( ({ vin }) => vin === this.selectedVehicleVinAsArray[this.selectedVehicleVinAsArray.length-1] ); return this.vehicles.find( ({ vin }) => vin === this.selectedVehicleVin[this.selectedVehicleVin.length-1] );
}, },
}, },
components: { components: {

View file

@ -195,7 +195,7 @@ export default {
selectedVehicleVin: { selectedVehicleVin: {
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) { 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 { } else {

View file

@ -168,7 +168,6 @@ describe("estimate.vue", () => {
store.commit(storeMutations.UPDATE_IS_REPAIR, null); store.commit(storeMutations.UPDATE_IS_REPAIR, null);
// Act // Act
console.log(store.getters.damage)
let arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid(); let arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid();
// Assert // Assert

View file

@ -27,7 +27,7 @@
validationRules="option-required" validationRules="option-required"
/> />
</div> </div>
<div v-if="isRepair"> <div v-else>
<alert <alert
class="my-4" class="my-4"
cmsWidgetName="AlertQuoteReady" cmsWidgetName="AlertQuoteReady"
@ -87,7 +87,6 @@
cmsWidgetName="FunnelFooterWidget" cmsWidgetName="FunnelFooterWidget"
ref="funnelFooter" ref="funnelFooter"
:isForwardActionDisabled="!meta.valid" :isForwardActionDisabled="!meta.valid"
@isDisabled="!meta.valid"
@back-clicked="backButtonAction" @back-clicked="backButtonAction"
@ForwardClicked="forwardButtonAction" @ForwardClicked="forwardButtonAction"
/> />
@ -131,7 +130,7 @@ export default {
name: "estimate", name: "estimate",
data() { data() {
return { return {
selectedVinLookupMethod: "", selectedVinLookupMethod: null,
serviceZipCode: this.getZipFromStore(), serviceZipCode: this.getZipFromStore(),
emailAddress: this.getEmailFromStore(), emailAddress: this.getEmailFromStore(),
displayInvalidZipAlert: false, displayInvalidZipAlert: false,

View file

@ -36,7 +36,7 @@ describe("replace-options-question.vue", () => {
}); });
describe("replace-options-question.vue", () => { describe("replace-options-question.vue", () => {
test("when updateSelectedValues method is called with a single answerToDisplay it will call to update this.selectedValues", async () => { test("when updateSelectedValues method is called with a single answerToDisplay it will call to update this.selectedReplaceOptions", async () => {
//Arrange //Arrange
const { wrapper, cmsContent, replaceOptions const { wrapper, cmsContent, replaceOptions

View file

@ -11,7 +11,7 @@
v-model="selectedValues" v-model="selectedValues"
:validationRules="validationRules" :validationRules="validationRules"
:suppressError="suppressError" :suppressError="suppressError"
:isRequired=isRequired :isRequired="isRequired"
/> />
</div> </div>
</transition> </transition>
@ -25,14 +25,14 @@ export default ({
name: "replaceOptionsQuestion", name: "replaceOptionsQuestion",
data(){ data(){
return { return {
replaceOptions: [], replaceOptions: this.isMultiSelect ? [] : "",
} }
}, },
props: { props: {
isAvailable: Boolean, isAvailable: Boolean,
filterByVehicleCategory: Boolean, filterByVehicleCategory: Boolean,
groupName: String, groupName: String,
modelValue: Array, modelValue: [Array, String, Number],
isMultiSelect: Boolean, isMultiSelect: Boolean,
validationRules: String, validationRules: String,
suppressError: Boolean, suppressError: Boolean,
@ -46,7 +46,7 @@ export default ({
updateSelectedValues() { updateSelectedValues() {
// UPDATE SELECTEDVALUES IF ONLY ONE ANSWER // UPDATE SELECTEDVALUES IF ONLY ONE ANSWER
if(Array.isArray(this.answersToDisplay) && this.answersToDisplay.length === 1) { if(Array.isArray(this.answersToDisplay) && this.answersToDisplay.length === 1) {
this.selectedValues = [this.answersToDisplay[0].Name]; this.selectedValues = this.isMultiSelect ? [this.answersToDisplay[0].Name] : this.answersToDisplay[0].Name;
} }
}, },
}, },
@ -91,7 +91,7 @@ export default ({
}, },
shouldDisplayReplaceOptionsQuestion(shouldDisplayReplaceOptionsQuestion) { shouldDisplayReplaceOptionsQuestion(shouldDisplayReplaceOptionsQuestion) {
if (!shouldDisplayReplaceOptionsQuestion) { if (!shouldDisplayReplaceOptionsQuestion) {
this.selectedValues = []; this.selectedValues = this.isMultiSelect ? [] : "";
} }
} }
}, },

View file

@ -57,7 +57,7 @@ export default ({
name: "sideDoorOptions", name: "sideDoorOptions",
props: { props: {
groupName: String, groupName: String,
modelValue: Array, modelValue: Object,
selectedDamageLocations: Array, selectedDamageLocations: Array,
cmsWidgetName: String, cmsWidgetName: String,
}, },

View file

@ -129,20 +129,20 @@ describe("vehicle-damage.vue", () => {
}, },
}); });
wrapper.vm.selectedDamageLocations = ["Windshield", "SideDoor", "RearWindow"]; wrapper.setData({
wrapper.vm.selectedWindshieldOptions = { selectedDamageLocations: ["Windshield", "SideDoor", "RearWindow"],
selectedWindshieldChipCount: null, selectedWindshieldOptions: {
selectedWindshieldReplaceOptions: ["Single"], selectedWindshieldChipCount: null,
selectedWindshieldDamageType: "Replace" selectedWindshieldReplaceOptions: ["Single"],
}; selectedWindshieldDamageType: "Replace"
},
wrapper.vm.sideDoorOptionsData = { sideDoorOptionsData: {
selectedDoorSides: ["DriverSide", "PassengerSide"], selectedDoorSides: ["DriverSide", "PassengerSide"],
selectedDriverSideReplaceOptions: ["Back"], selectedDriverSideReplaceOptions: ["Back"],
selectedPassengerSideReplaceOptions: ["Quarter"] selectedPassengerSideReplaceOptions: ["Quarter"]
}; },
selectedRearReplaceOptions: "Stationary",
wrapper.vm.selectedRearReplaceOptions = ["Stationary"]; })
const expectedGlassToReplace = [{ location: "Windshield", name: "Single" }, { location: "Driver", name: "Back" }, const expectedGlassToReplace = [{ location: "Windshield", name: "Single" }, { location: "Driver", name: "Back" },
{ location: "Passenger", name: "Quarter" }, { location: "Rear", name: "Stationary" }]; { location: "Passenger", name: "Quarter" }, { location: "Rear", name: "Stationary" }];
@ -519,19 +519,19 @@ describe("vehicle-damage.vue", () => {
const storeWindshieldOptions = [[1, false, "Windshield", "Single", { const storeWindshieldOptions = [[1, false, "Windshield", "Single", {
selectedWindshieldDamageType: damageLocationsSelected.REPLACE, selectedWindshieldDamageType: damageLocationsSelected.REPLACE,
selectedWindshieldChipCount: [], selectedWindshieldReplaceOptions: [damageLocationsSelected.SINGLE] selectedWindshieldChipCount: null, selectedWindshieldReplaceOptions: [damageLocationsSelected.SINGLE]
}], }],
[2, false, "Windshield", "Driver", { [2, false, "Windshield", "Driver", {
selectedWindshieldDamageType: damageLocationsSelected.REPLACE, selectedWindshieldDamageType: damageLocationsSelected.REPLACE,
selectedWindshieldChipCount: [], selectedWindshieldReplaceOptions: [damageLocationsSelected.DRIVER] selectedWindshieldChipCount: null, selectedWindshieldReplaceOptions: [damageLocationsSelected.DRIVER]
}], }],
[3, false, "Windshield", "Passenger", { [3, false, "Windshield", "Passenger", {
selectedWindshieldDamageType: damageLocationsSelected.REPLACE, selectedWindshieldDamageType: damageLocationsSelected.REPLACE,
selectedWindshieldChipCount: [], selectedWindshieldReplaceOptions: [damageLocationsSelected.PASSENGER] selectedWindshieldChipCount: null, selectedWindshieldReplaceOptions: [damageLocationsSelected.PASSENGER]
}], }],
[4, true, "", "", { [4, true, "", "", {
selectedWindshieldDamageType: damageLocationsSelected.REPAIR, selectedWindshieldDamageType: damageLocationsSelected.REPAIR,
selectedWindshieldChipCount: [2], selectedWindshieldReplaceOptions: [] selectedWindshieldChipCount: 2, selectedWindshieldReplaceOptions: []
}] }]
]; ];
test.each(storeWindshieldOptions)("getWindshieldOptionsFromStore test #%s", async (testNum, isRepair, damageLocation, damageName, expectedWindshieldOptions) => { test.each(storeWindshieldOptions)("getWindshieldOptionsFromStore test #%s", async (testNum, isRepair, damageLocation, damageName, expectedWindshieldOptions) => {
@ -618,8 +618,8 @@ describe("vehicle-damage.vue", () => {
expect(glassSelections).toEqual(expectedGlass); expect(glassSelections).toEqual(expectedGlass);
}); });
const rearReplaceOptions = [["Rear", "Stationary", [damageLocationsSelected.STATIONARY]], const rearReplaceOptions = [["Rear", "Stationary", damageLocationsSelected.STATIONARY],
["Rear", "Slider", [damageLocationsSelected.SLIDER]] ["Rear", "Slider", damageLocationsSelected.SLIDER]
]; ];
test.each(rearReplaceOptions)("getRearReplaceOptionsFromStore for %s-%s returns expected %s", async (damageLocation, damageName, expectedGlass) => { test.each(rearReplaceOptions)("getRearReplaceOptionsFromStore for %s-%s returns expected %s", async (damageLocation, damageName, expectedGlass) => {

View file

@ -196,11 +196,15 @@ export default {
}, },
getWindshieldOptionsFromStore() { getWindshieldOptionsFromStore() {
var windShieldOptions = { selectedWindshieldDamageType: "", selectedWindshieldChipCount: [], selectedWindshieldReplaceOptions: []}; var windShieldOptions = { selectedWindshieldDamageType: "", selectedWindshieldChipCount: null, selectedWindshieldReplaceOptions: []};
if (store.getters.damage.isRepair === undefined) return windshieldOptions; if (store.getters.damage.isRepair === undefined) return windshieldOptions;
if (!store.getters.damage.isRepair) { if (store.getters.damage.isRepair) {
windShieldOptions.selectedWindshieldDamageType = damageLocationsSelected.REPAIR;
windShieldOptions.selectedWindshieldChipCount = store.getters.damage.numberOfChips;
}
else {
if (store.getters.damage.glassToReplace?.some(glass => { return glass.location === damageLocationsSelected.WINDSHIELD && if (store.getters.damage.glassToReplace?.some(glass => { return glass.location === damageLocationsSelected.WINDSHIELD &&
glass.name === damageLocationsSelected.SINGLE })) { glass.name === damageLocationsSelected.SINGLE })) {
windShieldOptions.selectedWindshieldDamageType = damageLocationsSelected.REPLACE; windShieldOptions.selectedWindshieldDamageType = damageLocationsSelected.REPLACE;
@ -220,13 +224,7 @@ export default {
} }
} }
if (store.getters.damage.isRepair) {
windShieldOptions.selectedWindshieldDamageType = damageLocationsSelected.REPAIR;
windShieldOptions.selectedWindshieldChipCount.push(store.getters.damage.numberOfChips);
}
return windShieldOptions; return windShieldOptions;
}, },
getDoorSidesFromStore() { getDoorSidesFromStore() {
@ -265,15 +263,9 @@ export default {
return passengerSideReplaceOptions; return passengerSideReplaceOptions;
}, },
getRearReplaceOptionsFromStore(){ getRearReplaceOptionsFromStore() {
var rearReplaceOptions = []; var rearReplaceOptions = store.getters.damage.glassToReplace?.filter(glass => glass.location === damageLocationsSelected.REAR)[0]?.name;
store.getters.damage.glassToReplace?.forEach(glass => {
if (glass.location === damageLocationsSelected.REAR){
rearReplaceOptions.push(glass.name);
}
});
return rearReplaceOptions; return rearReplaceOptions;
}, },
@ -290,7 +282,6 @@ export default {
}, },
navigateForward(){ navigateForward(){
// If vin already exists, navigate directly to vin-lookup // If vin already exists, navigate directly to vin-lookup
if(store.getters.vehicle.vin) { if(store.getters.vehicle.vin) {
this.$router.navigateWithSaving(this.navigationScenarios.CLICKED_FORWARD_WITH_VIN, this.$route); this.$router.navigateWithSaving(this.navigationScenarios.CLICKED_FORWARD_WITH_VIN, this.$route);
@ -321,9 +312,7 @@ export default {
} }
if (this.isRearWindowDamageLocation) { if (this.isRearWindowDamageLocation) {
this.selectedRearReplaceOptions.forEach(rearItem => { selectedGlassToReplace.push({ location: damageLocationsSelected.REAR, name: this.selectedRearReplaceOptions});
selectedGlassToReplace.push({ location: damageLocationsSelected.REAR, name: rearItem});
})
} }
return selectedGlassToReplace; return selectedGlassToReplace;
@ -373,15 +362,15 @@ export default {
hasSplitSingleConflict() { hasSplitSingleConflict() {
if (!this.selectedDamageLocations?.includes("Windshield") || this.selectedWindshieldOptions.selectedWindshieldDamageType === damageLocationsSelected.REPAIR || !this.selectedWindshieldOptions.selectedWindshieldReplaceOptions) return false; if (!this.selectedDamageLocations?.includes("Windshield") || this.selectedWindshieldOptions.selectedWindshieldDamageType === damageLocationsSelected.REPAIR || !this.selectedWindshieldOptions.selectedWindshieldReplaceOptions) return false;
return this.selectedWindshieldOptions.selectedWindshieldReplaceOptions.some(selectedSingleWindshield => return this.selectedWindshieldOptions.selectedWindshieldReplaceOptions?.some(selectedSingleWindshield =>
{ {
return selectedSingleWindshield.toUpperCase() === damageLocationsSelected.SINGLE.toUpperCase(); return selectedSingleWindshield.toUpperCase() === damageLocationsSelected.SINGLE.toUpperCase();
}) && }) &&
(this.selectedWindshieldOptions.selectedWindshieldReplaceOptions.some(selectedDriverWindshield => (this.selectedWindshieldOptions.selectedWindshieldReplaceOptions?.some(selectedDriverWindshield =>
{ {
return selectedDriverWindshield.toUpperCase() === damageLocationsSelected.DRIVER.toUpperCase(); return selectedDriverWindshield.toUpperCase() === damageLocationsSelected.DRIVER.toUpperCase();
}) || }) ||
this.selectedWindshieldOptions.selectedWindshieldReplaceOptions.some(selectedPassengerWindshield => this.selectedWindshieldOptions.selectedWindshieldReplaceOptions?.some(selectedPassengerWindshield =>
{ {
return selectedPassengerWindshield.toUpperCase() === damageLocationsSelected.PASSENGER.toUpperCase(); return selectedPassengerWindshield.toUpperCase() === damageLocationsSelected.PASSENGER.toUpperCase();
}) })

View file

@ -1,78 +1,81 @@
import { shallowMount } from "@vue/test-utils"; import { shallowMount } from "@vue/test-utils";
import windshieldChipCountQuestion from "@/layouts/vehicle-damage/windshield-options/windshield-chip-count-question/windshield-chip-count-question"; import windshieldChipCountQuestion from "@/layouts/vehicle-damage/windshield-options/windshield-chip-count-question/windshield-chip-count-question";
import { getMountOptions } from "@/helpers/unit-test-helper.js"; import { getMountOptions } from "@/helpers/unit-test-helper.js";
import { nextTick } from "vue";
import store from "@/store"; import store from "@/store";
jest.mock("@/store", () => { return {}; }, {virtual: true}); jest.mock(
"@/store",
() => {
return {};
},
{ virtual: true }
);
describe("windshield-chip-count-question.vue", () => { describe("windshield-chip-count-question.vue", () => {
test("Selected chip count is emitted upon selection.", async () => { test("Selected chip count is emitted upon selection.", async () => {
//Arrange //Arrange
const { wrapper } = setupMocks({modelValueProp: ["One"]}); const { wrapper } = setupMocks({ modelValueProp: 1 });
//Act //Act
wrapper.setValue({ modelValue: ["Two"] }); wrapper.vm.selectedValue = "2";
await wrapper.vm.$nextTick();
//Assert
//Assert expect(wrapper.emitted()["update:modelValue"][0]).toEqual([2]);
expect(wrapper.vm.selectedChipCountValues).toEqual(["One"]);
expect(wrapper.emitted()["update:modelValue"][0]).toEqual([{ modelValue: ["Two"] }]);
}); });
});
describe("Windshield-chip-count-question.vue", () => { test("selectedValue matches modelValue", () => {
test("Should display question and answers from api.", async () => { // Arrange/Act
//Arrange const { wrapper } = setupMocks({ modelValueProp: 2 });
const { wrapper } = setupMocks({modelValueProp: ["One"]});
//Act // Assert
wrapper.setProps({isAvailable: true}); expect(wrapper.vm.selectedValue).toBe(2);
wrapper.vm.updateSelectedValues = jest.fn(); });
await wrapper.vm.$nextTick(); });
//Assert function setupMocks({
expect(wrapper.vm.updateSelectedValues).toBeCalled();
});
});
function setupMocks({
modelValueProp = ["Two"], modelValueProp = ["Two"],
groupName = "WindshieldChipCountQuestion", groupName = "WindshieldChipCountQuestion",
cmsQuestionText = "How many chips are we repairing?", cmsQuestionText = "How many chips are we repairing?",
cmsAnswers = [{Name: "One"}, {Name: "Two"}, {Name: "Three"}], cmsAnswers = [{ Name: "One" }, { Name: "Two" }, { Name: "Three" }],
dataFromStoreApi = [], dataFromStoreApi = [],
}) { }) {
//Mock store //Mock store
store.dispatch = jest.fn(() => dataFromStoreApi); store.dispatch = jest.fn(() => dataFromStoreApi);
store.getters = { vehicle: {year: 2019, make: 'honda', model: 'civc', style: '2 Door', category: 'CAR'} }; store.getters = {
vehicle: {
year: 2019,
make: "honda",
model: "civc",
style: "2 Door",
category: "CAR",
},
};
const mountOptions = getMountOptions({ const mountOptions = getMountOptions({
store: { store: {
dispatch: store.dispatch, dispatch: store.dispatch,
getters: store.getters, getters: store.getters,
}, },
}); });
//Mock props //Mock props
const mockMixin = { const mockMixin = {
methods: { methods: {
getCmsContent: jest.fn() getCmsContent: jest.fn(),
} },
} };
mountOptions.propsData = { mountOptions.propsData = {
modelValue: modelValueProp modelValue: modelValueProp,
}; };
mountOptions.mixins = [mockMixin]; mountOptions.mixins = [mockMixin];
const wrapper = shallowMount(windshieldChipCountQuestion, mountOptions); const wrapper = shallowMount(windshieldChipCountQuestion, mountOptions);
//Mock CMS content //Mock CMS content
const cmsContent = { const cmsContent = {
groupName: groupName, groupName: groupName,
QuestionText: cmsQuestionText, QuestionText: cmsQuestionText,
Answers: cmsAnswers, Answers: cmsAnswers,
}; };
const damageOptions = dataFromStoreApi; const damageOptions = dataFromStoreApi;
return { wrapper, cmsContent, damageOptions }; return { wrapper, cmsContent, damageOptions };
} }

View file

@ -7,7 +7,7 @@
:groupName="groupName" :groupName="groupName"
buttonType="listButtonHorizontal" buttonType="listButtonHorizontal"
useTextForValue useTextForValue
v-model="selectedChipCountValues" v-model="selectedValue"
:validationRules="validationRules" :validationRules="validationRules"
isRequired isRequired
/> />
@ -21,20 +21,12 @@ import buttonQuestion from "@/common-components/button-question/button-question"
export default ({ export default ({
name: "windshieldOptions", name: "windshieldOptions",
props: { props: {
modelValue: Array, modelValue: [String, Number],
groupName: String, groupName: String,
isAvailable: Boolean, isAvailable: Boolean,
validationRules: String, validationRules: String,
cmsWidgetName: String, cmsWidgetName: String,
}, },
methods: {
updateSelectedValues() {
// UPDATE SELECTEDVALUES IF ONLY ONE ANSWER
if(Array.isArray(this.answersToDisplay) && this.answersToDisplay.length === 1 && this.selectedValues) {
this.selectedValues = [this.answersToDisplay[0].Name];
}
},
},
computed: { computed: {
questionText(){ questionText(){
return this.getCmsContent(this.cmsWidgetName, 'QuestionText'); return this.getCmsContent(this.cmsWidgetName, 'QuestionText');
@ -42,7 +34,7 @@ export default ({
answersFromCms(){ answersFromCms(){
return this.getCmsContent(this.cmsWidgetName, 'Answers'); return this.getCmsContent(this.cmsWidgetName, 'Answers');
}, },
selectedChipCountValues: { selectedValue: {
get: function() { get: function() {
return this.modelValue; return this.modelValue;
}, },
@ -52,12 +44,6 @@ export default ({
} }
}, },
}, },
watch: {
isAvailable(val) {
// CHECK TO UPDATE SELECTED VALUES WHEN ISAVAILABLE IS TRUE
val && this.updateSelectedValues();
}
},
components: { components: {
buttonQuestion, buttonQuestion,
} }

View file

@ -6,22 +6,22 @@ import store from "@/store";
jest.mock("@/store", () => { return {}; }, {virtual: true}); jest.mock("@/store", () => { return {}; }, {virtual: true});
describe("windshield-damage-type-question.vue", () => { describe("windshield-damage-type-question.vue", () => {
test("Selected chip count is emitted upon selection.", async () => { test("Selected windshield damage is emitted upon selection.", async () => {
//Arrange //Arrange
const { wrapper } = setupMocks({modelValueProp: ["Repair"]}); const { wrapper } = setupMocks({modelValueProp: "Repair"});
//Act //Act
wrapper.setValue({ modelValue: ["Replace"] }); wrapper.setValue({ modelValue: "Replace" });
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
//Assert //Assert
expect(wrapper.vm.selectedValues).toEqual(["Repair"]); expect(wrapper.vm.selectedValues).toEqual("Repair");
expect(wrapper.emitted()["update:modelValue"][0]).toEqual([{ modelValue: ["Replace"] }]); expect(wrapper.emitted()["update:modelValue"][0]).toEqual([{ modelValue: "Replace" }]);
}); });
}); });
function setupMocks({ function setupMocks({
modelValueProp = ["Two"], modelValueProp = "",
groupName = "WindshieldDamageTypeQuestion", groupName = "WindshieldDamageTypeQuestion",
cmsQuestionText = "What's your windshield damage?", cmsQuestionText = "What's your windshield damage?",
cmsAnswers = [{Name: "Repair"}, {Name: "Replace"}], cmsAnswers = [{Name: "Repair"}, {Name: "Replace"}],

View file

@ -57,8 +57,8 @@ defineRule("windshield-replace-options-required", required(errorMessages.WINSHIE
defineRule("check-for-repair-and-replace", (selectedWindshieldDamageType, selectedDamageLocations) => { defineRule("check-for-repair-and-replace", (selectedWindshieldDamageType, selectedDamageLocations) => {
return selectedWindshieldDamageType.toString() != damageLocationsSelected.REPAIR || return selectedWindshieldDamageType.toString() != damageLocationsSelected.REPAIR ||
!selectedDamageLocations.includes(damageLocationsSelected.WINDSHIELD) || (!selectedDamageLocations.includes(damageLocationsSelected.WINDSHIELD) && !selectedDamageLocations[0]?.includes(damageLocationsSelected.WINDSHIELD)) ||
selectedDamageLocations.length === 1; (selectedDamageLocations[0].length === 1);
}); });
defineRule("repair-only", (value) => { defineRule("repair-only", (value) => {
return value.toString() === damageLocationsSelected.REPAIR; return value.toString() === damageLocationsSelected.REPAIR;
@ -83,7 +83,7 @@ export default ({
}, },
props: { props: {
modelValue: String, modelValue: Object,
selectedDamageLocations: Array, selectedDamageLocations: Array,
hasRepairReplaceConflict: Boolean, hasRepairReplaceConflict: Boolean,
hasSplitSingleConflict: Boolean, hasSplitSingleConflict: Boolean,
@ -114,7 +114,7 @@ export default ({
}, },
selectedWindshieldDamageTypeValue: { selectedWindshieldDamageTypeValue: {
get: function() { get: function() {
return this.selectedValues.selectedWindshieldDamageType; return this.selectedDamageLocations.includes(damageLocationsSelected.WINDSHIELD) ? this.selectedValues.selectedWindshieldDamageType : null;
}, },
set: function(newValue) { set: function(newValue) {
this.selectedValues = this.getWindshieldOptions(newValue, null, null); this.selectedValues = this.getWindshieldOptions(newValue, null, null);

View file

@ -23,7 +23,7 @@ export default {
name: "make-question", name: "make-question",
data() { data() {
return { return {
makes: Array, makes: [],
}; };
}, },
props: { props: {

View file

@ -5,150 +5,224 @@ import { settleAllPromises } from "@/helpers/layout-helper.js";
import { nextTick } from "vue"; import { nextTick } from "vue";
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper"; import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import baseMixin from "@/mixins/base-mixin.js"; import baseMixin from "@/mixins/base-mixin.js";
import store from "@/store";
// Components // Components
import vehicleMake from "@/layouts/vehicle-make/vehicle-make.vue"; import vehicleMake from "@/layouts/vehicle-make/vehicle-make.vue";
import makeQuestion from "@/layouts/vehicle-make/make-question/make-question"; import makeQuestion from "@/layouts/vehicle-make/make-question/make-question";
jest.mock("@/store", () => ({ jest.mock("@/store", () => ({
commit: jest.fn(), commit: jest.fn(),
dispatch: jest.fn(), dispatch: jest.fn(),
getters: { // getters: jest.fn().mockImplementation(() => ({
vehicle: { // vehicle: {
year: 2019, // year: 2019,
}, // },
}, // })),
})); }));
// Mock fetchCmsContentForPage // Mock fetchCmsContentForPage
jest.mock("@/helpers/cms-content-helper", () => ({ jest.mock("@/helpers/cms-content-helper", () => ({
fetchCmsContentForPage: jest.fn(), fetchCmsContentForPage: jest.fn(),
})); }));
// Mock our module for promises. // Mock our module for promises.
jest.mock("@/helpers/layout-helper.js", () => ({ jest.mock("@/helpers/layout-helper.js", () => ({
settleAllPromises: jest.fn(), settleAllPromises: jest.fn(),
})); }));
describe("vehicle-make.vue", () => { describe("vehicle-make.vue", () => {
test("Make question component is initized with api data", async (done) => { test("Make question component is initized with api data", async (done) => {
//Arrange //Arrange
const makeQuestionInitialData = ["honda", "ford", "dodge"]; const makeQuestionInitialData = ["honda", "ford", "dodge"];
const { wrapper, apiPromise } = setupMocks({ const { wrapper, apiPromise } = setupMocks({
makeQuestionInitialData: makeQuestionInitialData, makeQuestionInitialData: makeQuestionInitialData,
}); });
//Act //Act
vehicleMake.beforeRouteEnter.call( vehicleMake.beforeRouteEnter.call(
wrapper.vm, wrapper.vm,
{ query: { fmgPage: "vehicle-make" } }, { query: { fmgPage: "vehicle-make" } },
undefined, undefined,
(c) => c(wrapper.vm) (c) => c(wrapper.vm)
); );
//Assert //Assert
apiPromise.finally(() => { apiPromise.finally(() => {
expect(makeQuestion.methods.initializeComponent).toHaveBeenCalledWith( expect(makeQuestion.methods.initializeComponent).toHaveBeenCalledWith(
makeQuestionInitialData makeQuestionInitialData
); );
done(); done();
});
}); });
});
}); });
describe("vehicle-make.vue", () => { describe("vehicle-make.vue", () => {
test("BackButtonAction triggers a router.navigateWithoutSaving change", async (done) => { test("BackButtonAction triggers a router.navigateWithoutSaving change", async (done) => {
//Arrange //Arrange
const { wrapper, apiPromise } = setupMocks({ const { wrapper, apiPromise } = setupMocks({
pageHeaderWidgetHeaderText: "Select a make to get started", pageHeaderWidgetHeaderText: "Select a make to get started",
mountOptionsMockData: { mountOptionsMockData: {
router: { router: {
navigate: jest.fn(), navigate: jest.fn(),
navigateWithoutSaving: jest.fn(), navigateWithoutSaving: jest.fn(),
}, },
}, },
}); });
//Act //Act
vehicleMake.beforeRouteEnter.call( vehicleMake.beforeRouteEnter.call(
wrapper.vm, wrapper.vm,
{ query: { fmgPage: "vehicle-make" } }, { query: { fmgPage: "vehicle-make" } },
undefined, undefined,
(c) => c(wrapper.vm) (c) => c(wrapper.vm)
); );
wrapper.vm.backButtonAction(); wrapper.vm.backButtonAction();
await nextTick(); await nextTick();
//Assert //Assert
apiPromise.finally(() => { apiPromise.finally(() => {
expect(wrapper.vm.$router.navigateWithoutSaving).toHaveBeenCalled(); expect(wrapper.vm.$router.navigateWithoutSaving).toHaveBeenCalled();
done(); done();
});
}); });
});
}); });
describe("vehicle-make.vue", () => { describe("vehicle-make.vue", () => {
test("Year set, arePagePrerequisitesValid should be true ", async () => { describe("arePagePrerequisitesValue", () => {
//Arrange test("Year set, arePagePrerequisitesValid should be true", async () => {
const { wrapper } = setupMocks({}); //Arrange
const { wrapper } = setupMocks({
vehicleData: {
year: 2019
}
});
//Act //Act
vehicleMake.beforeRouteEnter.call( vehicleMake.beforeRouteEnter.call(
wrapper.vm, wrapper.vm,
{ query: { fmgPage: "vehicle-make" } }, { query: { fmgPage: "vehicle-make" } },
undefined, undefined,
(c) => c(wrapper.vm) (c) => c(wrapper.vm)
); );
let arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid(); let arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid();
//Assert //Assert
expect(arePagePrerequisitesValid).toBe(true); expect(arePagePrerequisitesValid).toBe(true);
}); });
test("Year not set, arePagePrerequisitesValid should be false", async () => {
//Arrange
store.getters.vehicle.year = jest.fn().mockReturnValueOnce(undefined);
const { wrapper } = setupMocks({});
//Act
let arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid();
//Assert
expect(arePagePrerequisitesValid).toBe(false);
});
});
test("selectedMake changes => save make in store", async () => {
//Arrange
const { wrapper } = setupMocks({
mountOptionsMockData: {
router: {
navigate: jest.fn(),
navigateWithSaving: jest.fn(),
},
}
});
// Act
await wrapper.setData({
selectedMake: "Make",
});
await wrapper.vm.$nextTick();
// Assert
expect(wrapper.vm.dispatchStoreAction).toHaveBeenCalledTimes(1);
expect(wrapper.vm.dispatchStoreAction).toHaveBeenCalledWith(
"saveVehicleMake",
"Make",
false
);
});
test("selectedMake changes => navigate with saving", async () => {
//Arrange
const { wrapper } = setupMocks({
mountOptionsMockData: {
router: {
navigate: jest.fn(),
navigateWithSaving: jest.fn(),
},
route: {
fmgPage: "test",
},
},
});
// Act
await wrapper.setData({
selectedMake: "Make",
});
// Assert
expect(wrapper.vm.$router.navigateWithSaving).toHaveBeenCalledTimes(1);
expect(wrapper.vm.$router.navigateWithSaving).toHaveBeenCalledWith(
"SELECTED_MAKE",
expect.anything()
);
});
}); });
function setupMocks({ function setupMocks({
vehicleMakeQuestionCmsContent = {}, vehicleMakeQuestionCmsContent = {},
makeQuestionInitialData = {}, makeQuestionInitialData = {},
pageHeaderWidgetHeaderText = {}, pageHeaderWidgetHeaderText = {},
mountOptionsMockData = {}, mountOptionsMockData = {},
vehicleData = {}
}) { }) {
//Mock api responses //Mock api responses
const apiResponses = { const apiResponses = {
cmsContent: { cmsContent: {
FunnelSubHeaderWidget: pageHeaderWidgetHeaderText, FunnelSubHeaderWidget: pageHeaderWidgetHeaderText,
VehicleMakeQuestion: vehicleMakeQuestionCmsContent, VehicleMakeQuestion: vehicleMakeQuestionCmsContent,
VehicleBannerWidget: { VehicleBannerWidget: {
GenericVehicleImage: GenericVehicleImage:
"https://digitalconsumercms-dev.safelite.com/images/default-source/default-album/blurred-image.jpg?sfvrsn=a6ce3034_3", "https://digitalconsumercms-dev.safelite.com/images/default-source/default-album/blurred-image.jpg?sfvrsn=a6ce3034_3",
}, },
FunnelHeaderWidget: { FunnelHeaderWidget: {
LogoImage: LogoImage:
"https://digitalconsumercms-dev.safelite.com/images/default-source/default-album/safelite-logo.svg?sfvrsn=45e7ed06_3", "https://digitalconsumercms-dev.safelite.com/images/default-source/default-album/safelite-logo.svg?sfvrsn=45e7ed06_3",
}, },
}, },
makeQuestionInitialData: makeQuestionInitialData, makeQuestionInitialData: makeQuestionInitialData,
}; };
const apiPromise = Promise.resolve(apiResponses); const apiPromise = Promise.resolve(apiResponses);
fetchCmsContentForPage.mockImplementation(() => Promise.resolve()); store.getters = {
settleAllPromises.mockImplementation(() => apiPromise); vehicle: vehicleData
}
//Mock make question methods fetchCmsContentForPage.mockImplementation(() => Promise.resolve());
makeQuestion.methods = { settleAllPromises.mockImplementation(() => apiPromise);
loadInitialData: jest.fn(),
initializeComponent: jest.fn(),
};
const mountOptions = getMountOptions(mountOptionsMockData); //Mock make question methods
const wrapper = shallowMount(vehicleMake, mountOptions); makeQuestion.methods = {
const makeQuestionWrapper = wrapper.findComponent({ name: "makeQuestion" }); loadInitialData: jest.fn(),
makeQuestionWrapper.vm.initializeComponent = initializeComponent: jest.fn(),
makeQuestion.methods.initializeComponent; };
const mountOptions = getMountOptions(mountOptionsMockData);
const wrapper = shallowMount(vehicleMake, mountOptions);
const makeQuestionWrapper = wrapper.findComponent({ name: "makeQuestion" });
makeQuestionWrapper.vm.initializeComponent = makeQuestion.methods.initializeComponent;
wrapper.vm.setCmsContent = baseMixin.methods.setCmsContent; wrapper.vm.setCmsContent = baseMixin.methods.setCmsContent;
return { wrapper, apiPromise }; return { wrapper, apiPromise };
} }

View file

@ -3,14 +3,21 @@
<funnelHeader cmsWidgetName="FunnelHeaderWidget" /> <funnelHeader cmsWidgetName="FunnelHeaderWidget" />
<div class="select-car"> <div class="select-car">
<div class="select-car-form rounded text-center"> <div class="select-car-form rounded text-center">
<vehicleBanner cmsWidgetName="VehicleBannerWidget" displayGenericVehicleImage /> <vehicleBanner
cmsWidgetName="VehicleBannerWidget"
displayGenericVehicleImage
/>
<funnelSubHeader <funnelSubHeader
cmsWidgetName="FunnelSubHeaderWidget" cmsWidgetName="FunnelSubHeaderWidget"
:hasBackButton="true" :hasBackButton="true"
@click-event="backButtonAction" @click-event="backButtonAction"
/> />
<div class="fade-on-route-transition"> <div class="fade-on-route-transition">
<makeQuestion v-model="selectedMake" ref="makeQuestion" cmsWidgetName="VehicleMakeQuestion" /> <makeQuestion
v-model="selectedMake"
ref="makeQuestion"
cmsWidgetName="VehicleMakeQuestion"
/>
</div> </div>
</div> </div>
</div> </div>
@ -26,7 +33,6 @@ import funnelSubHeader from "@/common-components/funnel-sub-header/funnel-sub-he
// Supporting files // Supporting files
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper"; import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { settleAllPromises } from "@/helpers/layout-helper"; import { settleAllPromises } from "@/helpers/layout-helper";
import { storeMutations } from "@/constants/store-mutations";
import { storeActions } from "@/constants/store-actions"; import { storeActions } from "@/constants/store-actions";
import store from "@/store"; import store from "@/store";
@ -75,7 +81,7 @@ export default {
); );
}, },
arePagePrerequisitesValid() { arePagePrerequisitesValid() {
if (store.getters.vehicle.year){ if (store.getters.vehicle.year) {
return true; return true;
} }
return false; return false;
@ -84,7 +90,7 @@ export default {
watch: { watch: {
selectedMake(make) { selectedMake(make) {
this.dispatchStoreAction(storeActions.SAVE_VEHICLE_MAKE, make, false); this.dispatchStoreAction(storeActions.SAVE_VEHICLE_MAKE, make, false);
this.$router.navigateWithSaving( this.$router.navigateWithSaving(
this.navigationScenarios.SELECTED_MAKE, this.navigationScenarios.SELECTED_MAKE,
this.$route this.$route

View file

@ -23,7 +23,7 @@ export default {
name: "model-question", name: "model-question",
data() { data() {
return { return {
models: Array, models: [],
}; };
}, },
props: { props: {

View file

@ -19,7 +19,6 @@ const featureListData = {
} }
describe("glass-part-question.vue", () => { describe("glass-part-question.vue", () => {
test("Part data passed in, should map data for ButtonQuestion (radio type)", async () => { test("Part data passed in, should map data for ButtonQuestion (radio type)", async () => {
//Arrange //Arrange

View file

@ -13,9 +13,7 @@
altText="" altText=""
isRequired isRequired
:groupName="`${location}-${name}`" :groupName="`${location}-${name}`"
@isCheckedChanged="ResetTintAndPartSelections" :validationRules="tintValidationRules">
:validationRules="tintValidationRules"
>
<div class="row my-2" aria-live="polite"> <div class="row my-2" aria-live="polite">
<div class="col"> <div class="col">
<buttonQuestion <buttonQuestion
@ -29,7 +27,7 @@
isRequired isRequired
:groupName="`${location}-${name}-${selectedTint}`" :groupName="`${location}-${name}-${selectedTint}`"
:validationRules="partValidationRules" :validationRules="partValidationRules"
/> />
</div> </div>
</div> </div>
</buttonQuestion> </buttonQuestion>
@ -64,7 +62,10 @@ export default {
location: String, location: String,
colorAnswers: Array, colorAnswers: Array,
modelValue: Object, modelValue: Object,
alreadyPopulatedPartsData: Array alreadyPopulatedPartsData: {
type: Array,
default: () => [],
},
}, },
mounted() { mounted() {
this.LoadPreselectedValues(); this.LoadPreselectedValues();
@ -75,12 +76,21 @@ export default {
computed: { computed: {
tintValidationRules() { tintValidationRules() {
const validationRuleName = `${this.location}-${this.name}-tint-required`; const validationRuleName = `${this.location}-${this.name}-tint-required`;
defineRule(validationRuleName, required(errorMessages.OPTION_REQUIRED));
defineRule(
validationRuleName,
required(errorMessages.OPTION_REQUIRED)
);
return validationRuleName; return validationRuleName;
}, },
partValidationRules() { partValidationRules() {
const validationRuleName = `${this.location}-${this.name}-part-required`; const validationRuleName = `${this.location}-${this.name}-part-required`;
defineRule(validationRuleName, required(errorMessages.OPTION_REQUIRED));
defineRule(
validationRuleName,
required(errorMessages.OPTION_REQUIRED)
);
return validationRuleName; return validationRuleName;
}, },
colorQuestionText() { colorQuestionText() {
@ -95,9 +105,9 @@ export default {
Object.keys(this.featureListData).forEach((tintOption) => { Object.keys(this.featureListData).forEach((tintOption) => {
tintOptions.push({ tintOptions.push({
Name: tintOption, value: tintOption,
Text: tintOption, buttonLabel: tintOption,
AnswerImageUrl: require(`@/assets/img/tints/${this.getTintSourceImage( buttonImage: require(`@/assets/img/tints/${this.getTintSourceImage(
this.location, this.location,
tintOption tintOption
)}`), )}`),
@ -112,16 +122,29 @@ export default {
return this.modelValue?.partNumber; return this.modelValue?.partNumber;
}, },
set(newValue) { set(newValue) {
this.$emit("update:modelValue", this.partsForSelectedTint.filter(part => part.partNumber == newValue)[0]); this.$emit(
"update:modelValue",
this.partsForSelectedTint.filter(
(part) => part.partNumber == newValue
)[0]
);
}, },
}, },
partsForSelectedTint() { partsForSelectedTint() {
const matchingGlass = this.PartDataFromApi.partsOrQuestions?.filter(dataForlocationAndName => const matchingGlass = this.PartDataFromApi.partsOrQuestions?.filter(
dataForlocationAndName.name == this.name && (dataForGlassLocationAndName) =>
dataForlocationAndName.location == this.location); dataForGlassLocationAndName.name == this.name &&
const matchingGlassParts = matchingGlass?.length == 1 ? matchingGlass[0].parts : []; dataForGlassLocationAndName.location ==
return matchingGlassParts.filter(part => part.color == this.selectedTint) ?? []; this.location
);
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
@ -153,7 +176,9 @@ export default {
}, },
PartDataFromApi() { PartDataFromApi() {
return this.$store.getters.pageData(this.$route.query.fmgPage) ?? {}; return (
this.$store.getters.pageData(this.$route.query.fmgPage) ?? {}
);
}, },
}, },
methods: { methods: {
@ -190,7 +215,8 @@ export default {
// 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() {
if (this.partsForSelectedTint?.length == 1) { if (this.partsForSelectedTint?.length == 1) {
this.selectedPartNumber = this.partsForSelectedTint[0].partNumber; this.selectedPartNumber =
this.partsForSelectedTint[0].partNumber;
} }
}, },
@ -199,7 +225,7 @@ 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
this.selectedTint = this.alreadyPopulatedPartsData?.filter(part => part.partNumber === this.selectedPartNumber)[0]?.color; this.selectedTint = this.modelValue?.color
} }
}); });
}, },
@ -207,8 +233,8 @@ export default {
watch: { watch: {
selectedTint() { selectedTint() {
this.AutoSelectIfSinglePart(); this.AutoSelectIfSinglePart();
} },
} },
}; };
</script> </script>

View file

@ -183,7 +183,16 @@ describe("vehicle-parts.vue", () => {
await nextTick(); await nextTick();
//Assert //Assert
expect(wrapper.vm.selectedGlassParts).toEqual({ "Rear-Stationary": { "Rear": ['DB12209YPYNOEM'] } }); expect(wrapper.vm.selectedGlassParts).toEqual({
"Rear-Stationary": {
partNumber: "DB12209YPYNOEM",
description: "heated glass, solar, 1 hole",
color: "Gray Tint Privacy",
requiresRecalibration: false,
requiresCapabilityQuestions: false,
childParts: null
}
});
}); });
test("User had part questions > BackButtonAction triggers a router.navigateWithoutSaving change with correct scenario", async () => { test("User had part questions > BackButtonAction triggers a router.navigateWithoutSaving change with correct scenario", async () => {
@ -509,6 +518,8 @@ function setupMocks({ pageHeaderWidgetHeaderText = {}, mountOptionsMockData = {}
wrapper.vm.setCmsContent = baseMixin.methods.setCmsContent; wrapper.vm.setCmsContent = baseMixin.methods.setCmsContent;
wrapper.vm.$refs.funnelFooter.removeLoader = jest.fn(); wrapper.vm.$refs.funnelFooter.removeLoader = jest.fn();
wrapper.vm.$refs.loadingModal.showModal = jest.fn(); wrapper.vm.$refs.loadingModal.showModal = jest.fn();
// wrapper.vm.$refs.onSubmit = jest.fn();
// wrapper.vm.$refs.onInvalidSubmit = jest.fn();
return { wrapper, apiPromise }; return { wrapper, apiPromise };
} }

View file

@ -1,52 +1,45 @@
<template> <template>
<Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm"> <Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm">
<div class="page-container-grouped-styles vehicle-parts"> <div class="page-container-grouped-styles vehicle-parts">
<loadingModal ref="loadingModal"/> <loadingModal ref="loadingModal" />
<funnelHeader ref="funnelHeader" cmsWidgetName="FunnelHeaderWidget" /> <funnelHeader ref="funnelHeader" cmsWidgetName="FunnelHeaderWidget" />
<vehicleBanner <vehicleBanner
ref="vehicleBanner" ref="vehicleBanner"
cmsWidgetName="VehicleBannerWidget" cmsWidgetName="VehicleBannerWidget"
:displayGenericVehicleImage="false" :displayGenericVehicleImage="false" />
/> <funnelSubHeader ref="funnelSubHeader" cmsWidgetName="FunnelSubHeaderWidget" />
<funnelSubHeader <div class="fade-on-route-transition sub-container make-tall">
ref="funnelSubHeader" <div class="prevent-squish my-5">
cmsWidgetName="FunnelSubHeaderWidget" <div class="row">
/> <div class="col">
<div class="fade-on-route-transition sub-container make-tall"> <alert
<div class="prevent-squish my-5"> class="rounded border-0 shadow-sm"
<div class="row"> alertClass="alert-warning"
<div class="col"> cmsWidgetName="AlertWidget"
<alert :isDismissible="false" />
class="rounded border-0 shadow-sm" </div>
alertClass="alert-warning" </div>
cmsWidgetName="AlertWidget" </div>
:isDismissible="false" <div v-for="(item, i) in PartsOrQuestions" :key="i">
/> <!-- Render horizontal lines if there is multi-glass (aka if i > 0) -->
<hr v-if="i > 0" />
<glassPartQuestion
:ref="`${RefPrefix}-${item.location}-${item.name}`"
v-model="selectedGlassParts[item.location + '-' + item.name]"
:location="item.location"
:name="item.name"
:colorAnswers="item.colorAnswers"
:alreadyPopulatedPartsData="alreadyPopulatedPartsData" />
</div>
<funnelFooter
cmsWidgetName="FunnelFooterWidget"
ref="funnelFooter"
:isForwardActionDisabled="isForwardActionDisabled"
@back-clicked="backButtonAction"
@ForwardClicked="forwardButtonAction" />
</div> </div>
</div>
</div> </div>
<div v-for="(item, i) in PartsOrQuestions" :key="i"> </Form>
<!-- Render horizontal lines if there is multi-glass (aka if i > 0) -->
<hr v-if="i > 0" />
<glassPartQuestion
:ref="`${RefPrefix}-${item.location}-${item.name}`"
v-model="selectedGlassParts[item.location + '-' + item.name]"
:location="item.location"
:name="item.name"
:colorAnswers="item.colorAnswers"
:alreadyPopulatedPartsData="alreadyPopulatedPartsData"
/>
</div>
<funnelFooter
cmsWidgetName="FunnelFooterWidget"
ref="funnelFooter"
:isForwardActionDisabled="isForwardActionDisabled"
@back-clicked="backButtonAction"
@ForwardClicked="forwardButtonAction"
/>
</div>
</div>
</Form>
</template> </template>
<script> <script>
@ -57,17 +50,14 @@ import vehicleBanner from "@/common-components/vehicle-banner/vehicle-banner";
import funnelSubHeader from "@/common-components/funnel-sub-header/funnel-sub-header"; import funnelSubHeader from "@/common-components/funnel-sub-header/funnel-sub-header";
import funnelFooter from "@/common-components/funnel-footer/funnel-footer"; import funnelFooter from "@/common-components/funnel-footer/funnel-footer";
import alert from "@/ux-components/alert/alert"; import alert from "@/ux-components/alert/alert";
import loadingModal from '@/common-components/loading-modal/loading-modal.vue'; import loadingModal from "@/common-components/loading-modal/loading-modal.vue";
// Supporting Files // Supporting Files
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper"; import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { settleAllPromises } from "@/helpers/layout-helper"; import { settleAllPromises } from "@/helpers/layout-helper";
import { fmgPageValues } from "@/router/router-constants/fmgPage-values"; import { fmgPageValues } from "@/router/router-constants/fmgPage-values";
import { Form } from "vee-validate"; import { Form } from "vee-validate";
import store from "@/store"; import store from "@/store";
import { storeMutations } from "@/constants/store-mutations.js";
import vehicleQuestionsMixin from "@/mixins/vehicle-questions-mixin"; import vehicleQuestionsMixin from "@/mixins/vehicle-questions-mixin";
import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper";
import { assertParenthesizedExpression } from "@babel/types";
export default { export default {
name: "vehicle-parts", name: "vehicle-parts",
@ -77,25 +67,25 @@ export default {
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) => {
vm.setCmsContent(resultMap.cmsContent); vm.setCmsContent(resultMap.cmsContent);
// Glass Part Question dynamic component // Glass Part Question dynamic component
Object.keys(vm.$refs) Object.keys(vm.$refs)
.filter((r) => r.includes(vm.RefPrefix) && vm.$refs[r][0] !== undefined) .filter((r) => r.includes(vm.RefPrefix) && vm.$refs[r][0] !== undefined)
.forEach((c) => .forEach((c) =>
vm.$refs[c][0].initializeComponent({ vm.$refs[c][0].initializeComponent({
ColorQuestionWidget: resultMap.cmsContent.ColorQuestionWidget, ColorQuestionWidget: resultMap.cmsContent.ColorQuestionWidget,
FeatureQuestionWidget: resultMap.cmsContent.FeatureQuestionWidget, FeatureQuestionWidget: resultMap.cmsContent.FeatureQuestionWidget,
}) })
); );
}); });
}, },
data() { data() {
@ -111,11 +101,13 @@ export default {
this.selectedGlassPartNumbers.length !== this.PartsFromApi.partsOrQuestions.length this.selectedGlassPartNumbers.length !== this.PartsFromApi.partsOrQuestions.length
); );
}, },
selectedGlassPartNumbers () { selectedGlassPartNumbers() {
// Compile all selected parts from the page. // Compile all selected parts from the page.
const numberArray = []; const numberArray = [];
for (let glassPart of Object.values(this.selectedGlassParts)) { for (let glassPart of Object.values(this.selectedGlassParts)) {
if (glassPart?.partNumber) { numberArray.push(glassPart.partNumber) } if (glassPart?.partNumber) {
numberArray.push(glassPart.partNumber);
}
} }
return numberArray; return numberArray;
}, },
@ -132,7 +124,8 @@ export default {
ColorAnswerText: p.color, ColorAnswerText: p.color,
FeatureAnswers: [ FeatureAnswers: [
{ {
FeatureAnswerText: p.description === "" ? p.color : p.description, FeatureAnswerText:
p.description === "" ? p.color : p.description,
PartNumber: p.partNumber, PartNumber: p.partNumber,
}, },
], ],
@ -156,16 +149,17 @@ export default {
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)
return store.getters.damage.isRepair != null && store.getters.pageData(fmgPageValues.VEHICLE_PARTS) && return (
Object.keys(store.getters.pageData(fmgPageValues.VEHICLE_PARTS)).length !== 0; store.getters.damage.isRepair != null &&
store.getters.pageData(fmgPageValues.VEHICLE_PARTS) &&
Object.keys(store.getters.pageData(fmgPageValues.VEHICLE_PARTS)).length !== 0
);
}, },
async forwardButtonAction() { async forwardButtonAction() {
const matchedParts = []; const matchedParts = [];
// 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 = this.PartsFromApi.partsOrQuestions[key].parts[partKey]; const currentPart = this.PartsFromApi.partsOrQuestions[key].parts[partKey];
@ -177,7 +171,7 @@ export default {
matchedParts.push({ matchedParts.push({
location: value.location, location: value.location,
name: value.name, name: value.name,
parts: [currentPart] parts: [currentPart],
}); });
} }
} }
@ -188,7 +182,11 @@ export default {
throw new Error("Could not match any parts to the selected parts"); throw new Error("Could not match any parts to the selected parts");
} }
await this.dispatchStoreAction(this.storeActions.RESET_MOLDING_AND_CAPABILITY_QUESTIONS_IF_NEEDED, matchedParts, false); await this.dispatchStoreAction(
this.storeActions.RESET_MOLDING_AND_CAPABILITY_QUESTIONS_IF_NEEDED,
matchedParts,
false
);
// Navigate to the next page // Navigate to the next page
this.navigateForward(matchedParts); this.navigateForward(matchedParts);
@ -196,20 +194,18 @@ export default {
LoadInitialPartsData() { LoadInitialPartsData() {
const partsData = this.PartsFromApi; const partsData = this.PartsFromApi;
const alreadyPopulatedPartsData = this.alreadyPopulatedPartsData =
this.$store.getters.lineItems.glassParts === null this.$store.getters.lineItems.glassParts === null
? [] ? []
: this.$store.getters.lineItems.glassParts; : this.$store.getters.lineItems.glassParts;
partsData.partsOrQuestions.map((g) => { partsData.partsOrQuestions.map((g) => {
// If the part is already populated, use the value from the store and populate the v-model. // If the part is already populated, use the value from the store and populate the v-model.
Object.keys(alreadyPopulatedPartsData).forEach((key) => { Object.keys(this.alreadyPopulatedPartsData).forEach((key) => {
const partNumber = alreadyPopulatedPartsData[key].partNumber; const partNumber = this.alreadyPopulatedPartsData[key].partNumber;
g.parts.forEach((p) => { g.parts.forEach((p) => {
if (p.partNumber === partNumber) { if (p.partNumber === partNumber) {
this.selectedGlassParts[g.location + "-" + g.name] = { this.selectedGlassParts[g.location + "-" + g.name] = p;
[g.location]: [partNumber],
};
} }
}); });
}); });

View file

@ -23,7 +23,7 @@ export default {
name: "style-question", name: "style-question",
data() { data() {
return { return {
styles: Array, styles: [],
}; };
}, },
props: { props: {

View file

@ -21,7 +21,7 @@ export default {
name: "year-question", name: "year-question",
data() { data() {
return { return {
years: Array, years: [],
}; };
}, },
props: { props: {

View file

@ -48,6 +48,7 @@ export default {
pushEventToGA(category, action, label, pushToLogApp = false, valueToLogType = null) { pushEventToGA(category, action, label, pushToLogApp = false, valueToLogType = null) {
const currentPageName = getPageNameByQueryString(); const currentPageName = getPageNameByQueryString();
const labelToLog = getValueToLog(label, valueToLogType); const labelToLog = getValueToLog(label, valueToLogType);
const eventToBePushed = { const eventToBePushed = {
'event': GaEvents.GENERIC_EVENT, 'event': GaEvents.GENERIC_EVENT,
'category': category, 'category': category,
@ -140,7 +141,7 @@ export default {
noSession() { noSession() {
return getSessionKeyValue() === 0 || getSessionIdValue() === '00000000-0000-0000-0000-000000000000'; return getSessionKeyValue() === 0 || getSessionIdValue() === '00000000-0000-0000-0000-000000000000';
} },
}, },
computed: { computed: {
analyticsPageEvents() { analyticsPageEvents() {

View file

@ -6,12 +6,11 @@ import { vehicleCategories } from "@/constants/vehicle-categories.js";
import { routerParams } from "@/router/router-constants/router-params"; import { routerParams } from "@/router/router-constants/router-params";
import { queryStrings } from "@/constants/query-strings"; import { queryStrings } from "@/constants/query-strings";
import { dynamicStrings } from "@/constants/dynamic-strings"; import { dynamicStrings } from "@/constants/dynamic-strings";
import { settleAllPromises } from "@/helpers/layout-helper";
export default { export default {
data() { data() {
return { return {
cmsContentByWidget: {} cmsContentByWidget: {},
}; };
}, },
methods: { methods: {
@ -19,7 +18,9 @@ export default {
this.$root.cmsContentByWidget = cmsContent; this.$root.cmsContentByWidget = cmsContent;
}, },
getCmsContent(widgetName, fieldName) { getCmsContent(widgetName, fieldName) {
return this.$root.cmsContentByWidget?.[widgetName]?.[fieldName] ? this.$root.cmsContentByWidget[widgetName][fieldName] : ''; return this.$root.cmsContentByWidget?.[widgetName]?.[fieldName]
? this.$root.cmsContentByWidget[widgetName][fieldName]
: "";
}, },
dispatchStoreAction(type, payload, encodePayload = true) { dispatchStoreAction(type, payload, encodePayload = true) {
// Encode the payload if required // Encode the payload if required
@ -32,7 +33,7 @@ export default {
savePageDataToStore(page, data) { savePageDataToStore(page, data) {
store.commit(storeMutations.UPDATE_PAGE_DATA, { page: page, data: data }); store.commit(storeMutations.UPDATE_PAGE_DATA, { page: page, data: data });
}, },
onSubmit() { }, // DO NOT REMOVE; needed to prevent default form submit behavior onSubmit() {}, // DO NOT REMOVE; needed to prevent default form submit behavior. Cannot use .prevent modifier for vee-validate Form
onInvalidSubmit({ values, errors, results }) { onInvalidSubmit({ values, errors, results }) {
// identify the first error field and put focus on it // identify the first error field and put focus on it
// get error names array // get error names array
@ -49,14 +50,17 @@ export default {
return footerInfoBox ? footerInfoBox.offsetHeight : 0; return footerInfoBox ? footerInfoBox.offsetHeight : 0;
}, },
async getZipCodeData(zipCode) { async getZipCodeData(zipCode) {
const serviceZipValidationResponse = await this.dispatchStoreAction(storeActions.VALIDATE_ZIP, { zip: zipCode }); const serviceZipValidationResponse = await this.dispatchStoreAction(
storeActions.VALIDATE_ZIP,
return { { zip: zipCode }
isValid: serviceZipValidationResponse.data.isValid, );
return {
isValid: serviceZipValidationResponse.data.isValid,
isServiceable: serviceZipValidationResponse.data.isServiceable, isServiceable: serviceZipValidationResponse.data.isServiceable,
state: serviceZipValidationResponse.data.state state: serviceZipValidationResponse.data.state,
}; };
} },
}, },
computed: { computed: {
storeActions() { storeActions() {
@ -74,13 +78,13 @@ export default {
routerParams() { routerParams() {
return routerParams; return routerParams;
}, },
queryStrings(){ queryStrings() {
return queryStrings; return queryStrings;
}, },
dynamicStrings(){ dynamicStrings() {
return dynamicStrings; return dynamicStrings;
}, },
cssClassNameForCmsWidget(){ cssClassNameForCmsWidget() {
return "widget-name-" + this.cmsWidgetName; return "widget-name-" + this.cmsWidgetName;
}, },
}, },

View file

@ -0,0 +1,36 @@
import { inputButtonProps } from "@/common-components/base-input-button/button-functionality-props";
export default {
model: {
prop: "modelValue",
event: "change",
},
props: {
...inputButtonProps,
buttonLabel: [Number, String],
buttonLabelSubCopy: String,
buttonImage: String,
buttonImageId: String,
altText: {
type: String,
default: "",
},
textPosition: String,
screenReaderOnlyText: String,
isWide: Boolean,
},
computed: {
selectedValue: {
get() {
return this.modelValue;
},
set(e) {
if (this.preHandleAnswerChange) {
this.preHandleAnswerChange(e);
}
this.$emit("update:modelValue", e);
},
},
},
};

View file

@ -0,0 +1,317 @@
import { mount } from "@vue/test-utils";
import baseInputButton from "@/common-components/base-input-button/base-input-button";
import inputButtonWrapperMixin from "@/mixins/input-button-wrapper-mixin";
import listButtonHorizontal from "@/ux-components/list-button-horizontal/list-button-horizontal";
import listButton from "@/ux-components/list-button/list-button";
import listCard from "@/ux-components/list-card/list-card";
import radio from "@/ux-components/radio/radio";
describe("input-button-wrapper-mixin", () => {
describe("mouse clicks", () => {
describe("checkbox", () => {
test("click on both => both are selected", async () => {
// Arrange
const { wrapper } = setupBaseInputButtonWrapper({
mockData: {
isMultiSelect: true,
},
});
// Act
const buttonWrappers = wrapper.findAllComponents({
name: "baseInputButtonWrapper",
});
const inputButtonOne = buttonWrappers.at(0);
const inputButtonTwo = buttonWrappers.at(1);
expect(inputButtonOne.vm.isMultiSelect).toBe(true);
expect(inputButtonTwo.vm.isMultiSelect).toBe(true);
expect(wrapper.vm.value).toEqual([]);
await inputButtonOne.find("input").trigger("click");
await inputButtonTwo.find("input").trigger("click");
// Assert
expect(wrapper.vm.value).toEqual(["value1", "value2"]);
});
test("click input 1, 2, 1 => only input 2 selected", async () => {
// Arrange
const { wrapper } = setupBaseInputButtonWrapper({
mockData: {
isMultiSelect: true,
},
});
// Act
const buttonWrappers = wrapper.findAllComponents({
name: "baseInputButtonWrapper",
});
const inputButtonOne = buttonWrappers.at(0);
const inputButtonTwo = buttonWrappers.at(1);
expect(inputButtonOne.vm.isMultiSelect).toBe(true);
expect(inputButtonTwo.vm.isMultiSelect).toBe(true);
expect(wrapper.vm.value).toEqual([]);
await inputButtonOne.find("input").trigger("click");
await inputButtonTwo.find("input").trigger("click");
await inputButtonOne.find("input").trigger("click");
// Assert
expect(wrapper.vm.value).toEqual(["value2"]);
});
});
describe("radio", () => {
test("click on both => last clicked is selected", async () => {
// Arrange
const { wrapper } = setupBaseInputButtonWrapper({
mockData: {
isMultiSelect: false,
},
});
// Act
const buttonWrappers = wrapper.findAllComponents({
name: "baseInputButtonWrapper",
});
const inputButtonOne = buttonWrappers.at(0);
const inputButtonTwo = buttonWrappers.at(1);
expect(inputButtonOne.vm.isMultiSelect).toBe(false);
expect(inputButtonTwo.vm.isMultiSelect).toBe(false);
expect(wrapper.vm.value).toEqual("");
await inputButtonOne.find("input").trigger("click");
await inputButtonTwo.find("input").trigger("click");
// Assert
expect(wrapper.vm.value).toEqual("value2");
});
test("click input 1, 2, 1 => input 1 is selected", async () => {
// Arrange
const { wrapper } = setupBaseInputButtonWrapper({
mockData: {
isMultiSelect: false,
},
});
// Act
const buttonWrappers = wrapper.findAllComponents({
name: "baseInputButtonWrapper",
});
const inputButtonOne = buttonWrappers.at(0);
const inputButtonTwo = buttonWrappers.at(1);
expect(inputButtonOne.vm.isMultiSelect).toBe(false);
expect(inputButtonTwo.vm.isMultiSelect).toBe(false);
expect(wrapper.vm.value).toEqual("");
await inputButtonOne.find("input").trigger("click");
await inputButtonTwo.find("input").trigger("click");
await inputButtonOne.find("input").trigger("click");
// Assert
expect(wrapper.vm.value).toEqual("value1");
});
});
});
describe("initial values", () => {
describe("checkbox", () => {
const defaultCheckedCases = [
[["value2"], false, true],
[["value1", "value2"], true, true],
[["value1"], true, false],
[[], false, false],
];
test.each(defaultCheckedCases)(
"initial value is %s => correct input buttons are selected",
async (modelValue, isInputButtonOneChecked, isInputButtonTwoChecked) => {
// Arrange
const { wrapper } = setupBaseInputButtonWrapper({
mockData: {
isMultiSelect: true,
initialValue: modelValue,
},
});
// Act
const buttonWrappers = wrapper.findAllComponents({
name: "baseInputButtonWrapper",
});
const inputs = wrapper.findAll("input");
const inputButtonOne = buttonWrappers.at(0);
const inputButtonTwo = buttonWrappers.at(1);
// Assert
expect(inputButtonOne.vm.isMultiSelect).toBe(true);
expect(inputButtonTwo.vm.isMultiSelect).toBe(true);
expect(inputs[0].element.checked).toBe(isInputButtonOneChecked);
expect(inputs[1].element.checked).toBe(isInputButtonTwoChecked);
expect(wrapper.vm.value).toEqual(modelValue);
}
);
});
describe("radio", () => {
const defaultCheckedCases = [
["", false, false],
["value1", true, false],
["value2", false, true],
[[], false, false],
];
test.each(defaultCheckedCases)(
"initial value is %s => correct input buttons are selected",
async (modelValue, isInputButtonOneChecked, isInputButtonTwoChecked) => {
// Arrange
const { wrapper } = setupBaseInputButtonWrapper({
mockData: {
isMultiSelect: false,
initialValue: modelValue,
},
});
// Act
const buttonWrappers = wrapper.findAllComponents({
name: "baseInputButtonWrapper",
});
const inputs = wrapper.findAll("input");
const inputButtonOne = buttonWrappers.at(0);
const inputButtonTwo = buttonWrappers.at(1);
// Assert
expect(inputButtonOne.vm.isMultiSelect).toBe(false);
expect(inputButtonTwo.vm.isMultiSelect).toBe(false);
expect(inputs[0].element.checked).toBe(isInputButtonOneChecked);
expect(inputs[1].element.checked).toBe(isInputButtonTwoChecked);
expect(wrapper.vm.value).toEqual(modelValue);
}
);
});
});
// shared checks between components that use input-button-wrapper-mixin
describe("shared checks", () => {
const inputButtonComponents = [listButtonHorizontal, listButton, listCard, radio];
test.each(inputButtonComponents.map((x) => [x.name, x]))(
"%s - should return input type checkbox if isMultiSelect is true",
async (name, inputButtonComponent) => {
// Act
const { wrapper } = setupMocksForComponentsUsingInputButtonWrapperMixin({
component: inputButtonComponent,
mockData: {
propsData: {
isMultiSelect: true,
},
},
});
// Assert
const input = wrapper.find("input");
expect(input.attributes().type).toEqual("checkbox");
}
);
test.each(inputButtonComponents.map((x) => [x.name, x]))(
"%s - return input type radio if isMultiSelect is false or not specified",
async (name, inputButtonComponent) => {
// Act
const { wrapper } = setupMocksForComponentsUsingInputButtonWrapperMixin({
component: inputButtonComponent,
mockData: {
propsData: {
isMultiSelect: false,
},
},
});
// Assert
const input = wrapper.find("input");
expect(input.attributes().type).toEqual("radio");
}
);
const selectedValues = ["something", ["test"]];
inputButtonComponents.forEach((inputButtonComponent) => {
test.each(selectedValues)(
`${inputButtonComponent.name} with selectedValue %s - should emit button value on click`,
async (selectedValue) => {
// Act
const { wrapper } = setupMocksForComponentsUsingInputButtonWrapperMixin({
component: inputButtonComponent,
mockData: {
propsData: {
isRadioHorizontal: true,
buttonLabel: "Windshield",
value: "List Card Checkbox",
groupID: "radio-demo-1",
groupName: "radio 1",
buttonImage: "windshield-damage.svg",
isRequired: true,
isWide: false,
modelValue: ["List Card Checkbox"],
buttonID: "list-card-id",
},
},
});
// Act
wrapper.vm.selectedValue = selectedValue;
await wrapper.vm.$nextTick();
// Assert
expect(wrapper.emitted()["update:modelValue"][0][0]).toEqual(selectedValue);
}
);
});
});
});
function setupMocksForComponentsUsingInputButtonWrapperMixin({ mockData, component }) {
const wrapper = mount(component, {
...mockData,
propsData: {
...mockData.propsData,
groupName: "my-group",
modelValue: mockData.propsData?.isMultiSelect ? ["5"] : "5",
value: "4",
},
mixins: [inputButtonWrapperMixin],
});
return { wrapper };
}
function setupBaseInputButtonWrapper({ mockData = {} }) {
baseInputButton.methods.pushClickEventToGA = jest.fn();
const baseInputButtonWrapper = {
name: "baseInputButtonWrapper",
components: { baseInputButton },
template: '<div><baseInputButton v-bind="$props" v-model="selectedValue" /></div>',
mixins: [inputButtonWrapperMixin],
};
let parentComponentTemplate = `<div>`;
parentComponentTemplate += `<baseInputButtonWrapper v-model="value" groupName="myGroupName" :isMultiSelect="${mockData.isMultiSelect}" value="value1" />`;
parentComponentTemplate += `<baseInputButtonWrapper v-model="value" groupName="myGroupName" :isMultiSelect="${mockData.isMultiSelect}" value="value2" />`;
parentComponentTemplate += `</div>`;
const wrapper = mount(
{
data() {
return {
value: mockData.initialValue ?? (mockData.isMultiSelect ? [] : ""),
$route: {
query: {
fmgPage: "myPage",
},
},
};
},
template: parentComponentTemplate,
components: { baseInputButtonWrapper },
},
{}
);
return { wrapper };
}

View file

@ -177,7 +177,6 @@ export default {
// if single parts only // if single parts only
const collectedGlassParts = this.reducedGlassPartsArray(partsOrQuestions); const collectedGlassParts = this.reducedGlassPartsArray(partsOrQuestions);
// save to store lineItems.glassParts // save to store lineItems.glassParts
// TODO KO
self.$store.commit(storeMutations.UPDATE_GLASS_PARTS, collectedGlassParts); self.$store.commit(storeMutations.UPDATE_GLASS_PARTS, collectedGlassParts);
self.$refs.loadingModal.showModal(); self.$refs.loadingModal.showModal();

View file

@ -238,7 +238,7 @@ function navigateToUrl(url, optionalQuery = {}) {
for (const queryKey in optionalQuery) { for (const queryKey in optionalQuery) {
externalUrl.searchParams.append(queryKey, optionalQuery[queryKey]); externalUrl.searchParams.append(queryKey, optionalQuery[queryKey]);
} }
window.location.assign(externalUrl); window.location.assign(externalUrl);
} }

View file

@ -1,4 +1,4 @@
import { createStore } from "vuex"; import { createStore, Store } from "vuex";
import { endpoints } from "@/constants/endpoints.js"; import { endpoints } from "@/constants/endpoints.js";
import { storeMutations } from "@/constants/store-mutations"; import { storeMutations } from "@/constants/store-mutations";
import { getDateForSavedSessionTimeout } from "@/helpers/heritage-integration/session-helper"; import { getDateForSavedSessionTimeout } from "@/helpers/heritage-integration/session-helper";
@ -50,22 +50,22 @@ const getDefaultState = () => {
glassToReplace: null, glassToReplace: null,
partQuestionAnswers: null, partQuestionAnswers: null,
moldingQuestionAnswers: null, moldingQuestionAnswers: null,
capabilityQuestionAnswers: null capabilityQuestionAnswers: null,
}, },
lineItems: { lineItems: {
glassParts: null glassParts: null,
}, },
payment: { payment: {
isInsurance: null, isInsurance: null,
insuranceCoverage: { insuranceCoverage: {
isVerified: null isVerified: null,
} },
}, },
referralNumber: null, referralNumber: null,
referralDate: null, referralDate: null,
referralCorrelationId: null, referralCorrelationId: null,
accountNumber: 0, accountNumber: 0,
eon: null eon: null,
}, },
applicationUser: { applicationUser: {
eventBus: [], eventBus: [],
@ -76,9 +76,15 @@ const getDefaultState = () => {
crmCustomerId: null, crmCustomerId: null,
lastPageVisited: null, lastPageVisited: null,
experiments: [], experiments: [],
triggeredSiteEntry: false triggeredSiteEntry: false,
}, },
} // gaClickInformation: {
// currentlySelectedValues: {},
// firedGaClickEventValues: {},
// lastFocusedInputGroup: "",
// wasLastFocusedInputMultiselect: undefined,
// },
};
}; };
export const state = getDefaultState(); export const state = getDefaultState();
@ -195,7 +201,6 @@ export const mutations = {
state.order.customer.emailAddress = customerEmailAddress; state.order.customer.emailAddress = customerEmailAddress;
}, },
updateVehicle(state, vehicleInfo) { updateVehicle(state, vehicleInfo) {
state.order.vehicle.year = vehicleInfo.year; state.order.vehicle.year = vehicleInfo.year;
state.order.vehicle.make = vehicleInfo.make; state.order.vehicle.make = vehicleInfo.make;
state.order.vehicle.model = vehicleInfo.model; state.order.vehicle.model = vehicleInfo.model;
@ -209,7 +214,8 @@ export const mutations = {
state.order.vehicle.imageColor = vehicleInfo.imageVifColor; state.order.vehicle.imageColor = vehicleInfo.imageVifColor;
}, },
updateRegistration(state, registrationInfo) { updateRegistration(state, registrationInfo) {
state.order.vehicle.registration.licensePlate = registrationInfo?.licensePlate; state.order.vehicle.registration.licensePlate =
registrationInfo?.licensePlate;
state.order.vehicle.registration.address = registrationInfo?.address; state.order.vehicle.registration.address = registrationInfo?.address;
state.order.vehicle.registration.city = registrationInfo?.city; state.order.vehicle.registration.city = registrationInfo?.city;
state.order.vehicle.registration.state = registrationInfo?.state; state.order.vehicle.registration.state = registrationInfo?.state;
@ -235,7 +241,7 @@ export const mutations = {
state.applicationUser.crmCustomerId = crmCustomerId; state.applicationUser.crmCustomerId = crmCustomerId;
}, },
updateLastPageVisited(state, lastPageVisited) { updateLastPageVisited(state, lastPageVisited) {
state.applicationUser.lastPageVisited = lastPageVisited state.applicationUser.lastPageVisited = lastPageVisited;
}, },
// EVENT BUS MUTATIONS // EVENT BUS MUTATIONS
addEventToBus(state, event) { addEventToBus(state, event) {
@ -244,8 +250,7 @@ export const mutations = {
removeEventFromBus(state, eventData) { removeEventFromBus(state, eventData) {
const matchedEvent = state.applicationUser.eventBus.find( const matchedEvent = state.applicationUser.eventBus.find(
({ category, subCategory }) => ({ category, subCategory }) =>
category === eventData.category && category === eventData.category && subCategory === eventData.subCategory
subCategory === eventData.subCategory
); );
const itemIndex = state.applicationUser.eventBus.indexOf(matchedEvent); const itemIndex = state.applicationUser.eventBus.indexOf(matchedEvent);
@ -331,7 +336,7 @@ export const mutations = {
state: orderInformation.vehicle.registration.state, state: orderInformation.vehicle.registration.state,
zipCode: orderInformation.vehicle.registration.zipCode, zipCode: orderInformation.vehicle.registration.zipCode,
licensePlate: orderInformation.vehicle.registration.licensePlateNumber, licensePlate: orderInformation.vehicle.registration.licensePlateNumber,
} },
}); });
state.order.damage.glassToReplace = orderInformation.damage.glassToReplace; state.order.damage.glassToReplace = orderInformation.damage.glassToReplace;
@ -340,13 +345,18 @@ export const mutations = {
state.order.lineItems.glassParts = orderInformation.parts; state.order.lineItems.glassParts = orderInformation.parts;
state.order.accountNumber = orderInformation.accountNumber; state.order.accountNumber = orderInformation.accountNumber;
state.order.serviceLocation.address = orderInformation.serviceLocation.streetAddress, (state.order.serviceLocation.address =
state.order.serviceLocation.city = orderInformation.serviceLocation.city, orderInformation.serviceLocation.streetAddress),
state.order.serviceLocation.state = orderInformation.serviceLocation.state, (state.order.serviceLocation.city =
state.order.serviceLocation.zipCode = orderInformation.serviceLocation.zipCode; orderInformation.serviceLocation.city),
(state.order.serviceLocation.state =
orderInformation.serviceLocation.state),
(state.order.serviceLocation.zipCode =
orderInformation.serviceLocation.zipCode);
state.order.payment.isInsurance = orderInformation.IsInsuranceOrder; state.order.payment.isInsurance = orderInformation.IsInsuranceOrder;
state.order.payment.insuranceCoverage.isVerified = orderInformation?.insuranceInfo.coverageVerified; state.order.payment.insuranceCoverage.isVerified =
orderInformation?.insuranceInfo.coverageVerified;
state.order.customer.emailAddress = orderInformation.customer.emailAddress; state.order.customer.emailAddress = orderInformation.customer.emailAddress;
state.applicationUser.experiments = orderInformation.experiments; state.applicationUser.experiments = orderInformation.experiments;
@ -356,8 +366,23 @@ export const mutations = {
}, },
updateTriggeredSiteEntry(state, wasSiteEntryTriggered) { updateTriggeredSiteEntry(state, wasSiteEntryTriggered) {
state.applicationUser.triggeredSiteEntry = wasSiteEntryTriggered; state.applicationUser.triggeredSiteEntry = wasSiteEntryTriggered;
} },
} // START GA click event mutations
// updateCurrentlySelectedValues(state, groupName, value) {
// state.gaClickInformation.currentlySelectedValues[groupName] = value;
// },
// updateFiredGaClickEventValues(state, groupName, value) {
// state.gaClickInformation.firedGaClickEventValues[groupName] = value;
// },
// updateLastFocusedInputGroup(state, groupName) {
// state.gaClickInformation.lastFocusedInputGroup = groupName;
// },
// updateWasLastFocusedInputMultiselect(state, wasLastFocusedInputMultiselect) {
// state.gaClickInformation.wasLastFocusedInputMultiselect =
// wasLastFocusedInputMultiselect;
// },
// END GA click event mutations
};
// Export Getters // Export Getters
export const getters = { export const getters = {
@ -373,7 +398,9 @@ export const getters = {
eventBus: (state) => state.applicationUser.eventBus, eventBus: (state) => state.applicationUser.eventBus,
damage: (state) => state.order.damage, damage: (state) => state.order.damage,
lineItems: (state) => state.order.lineItems, lineItems: (state) => state.order.lineItems,
pageData: (state) => (page) => { return state.applicationUser.pageData[page]; }, pageData: (state) => (page) => {
return state.applicationUser.pageData[page];
},
applicationUser: (state) => state.applicationUser, applicationUser: (state) => state.applicationUser,
order: (state) => state.order, order: (state) => state.order,
payment: (state) => state.order.payment, payment: (state) => state.order.payment,
@ -390,7 +417,8 @@ export const getters = {
funnelServiceState: state.order.serviceLocation.state, funnelServiceState: state.order.serviceLocation.state,
funnelServiceZipCode: state.order.serviceLocation.zipCode, funnelServiceZipCode: state.order.serviceLocation.zipCode,
funnelParentAccountNumber: state.order.accountNumber, funnelParentAccountNumber: state.order.accountNumber,
funnelIsCoverageVerified: state.order.payment.insuranceCoverage.isVerified, funnelIsCoverageVerified:
state.order.payment.insuranceCoverage.isVerified,
funnelHasRecalibrationPart: getHasRecalibrationPart(state), funnelHasRecalibrationPart: getHasRecalibrationPart(state),
funnelSelectedMultiGlass: state.order.damage.glassToReplace?.length > 1, funnelSelectedMultiGlass: state.order.damage.glassToReplace?.length > 1,
funnelSelectedWindshieldGlass: getNonFalseValuesOfPropertyInArrayOfObjects(state.order.damage.glassToReplace, "glassLocation").includes(damageLocationsSelected.WINDSHIELD), funnelSelectedWindshieldGlass: getNonFalseValuesOfPropertyInArrayOfObjects(state.order.damage.glassToReplace, "glassLocation").includes(damageLocationsSelected.WINDSHIELD),
@ -403,8 +431,12 @@ export const getters = {
funnelOrderPartTypes: [...getNonFalseValuesOfPropertyInArrayOfObjects(state.order.lineItems.glassParts, "recalibrationType"), ...getNonFalseValuesOfPropertyInArrayOfObjects(state.order.lineItems.otherParts, "recalibrationType")], funnelOrderPartTypes: [...getNonFalseValuesOfPropertyInArrayOfObjects(state.order.lineItems.glassParts, "recalibrationType"), ...getNonFalseValuesOfPropertyInArrayOfObjects(state.order.lineItems.otherParts, "recalibrationType")],
} }
}, },
experimentSettings: (state) => state.applicationUser.experiments.map(x => x.settings).reduce((r, c) => Object.assign(r, c), {}) ?? {} experimentSettings: (state) =>
} state.applicationUser.experiments
.map((x) => x.settings)
.reduce((r, c) => Object.assign(r, c), {}) ?? {},
// gaClickInformation: (state) => state.gaClickInformation,
};
function getNonFalseValuesOfPropertyInArrayOfObjects(array, propertyName) { function getNonFalseValuesOfPropertyInArrayOfObjects(array, propertyName) {
return (array ?? []).map(x => x[propertyName]).filter(x => x); return (array ?? []).map(x => x[propertyName]).filter(x => x);
@ -412,7 +444,6 @@ function getNonFalseValuesOfPropertyInArrayOfObjects(array, propertyName) {
// Export Actions // Export Actions
export const actions = { export const actions = {
// Vehicle API Actions // Vehicle API Actions
getVehicleYears(context) { getVehicleYears(context) {
return globalMethods.callHttpClient({ return globalMethods.callHttpClient({
@ -443,11 +474,14 @@ export const actions = {
endpoint: endpoints.LookupVinByPlate.url, endpoint: endpoints.LookupVinByPlate.url,
payload: { payload: {
licensePlate: licensePlate, licensePlate: licensePlate,
licenseState: licenseState licenseState: licenseState,
}, },
}); });
}, },
lookupVinByAddress(context, { licenseLastName, licenseStreetAddress, licenseZip, licenseState }) { lookupVinByAddress(
context,
{ licenseLastName, licenseStreetAddress, licenseZip, licenseState }
) {
return globalMethods.callHttpClient({ return globalMethods.callHttpClient({
method: endpoints.LookupVinByAddress.method, method: endpoints.LookupVinByAddress.method,
endpoint: endpoints.LookupVinByAddress.url, endpoint: endpoints.LookupVinByAddress.url,
@ -455,7 +489,7 @@ export const actions = {
licenseLastName: licenseLastName, licenseLastName: licenseLastName,
licenseStreetAddress: licenseStreetAddress, licenseStreetAddress: licenseStreetAddress,
licenseZip: licenseZip, licenseZip: licenseZip,
licenseState: licenseState licenseState: licenseState,
}, },
}); });
}, },
@ -489,10 +523,22 @@ export const actions = {
}) })
.then((response) => { .then((response) => {
context.commit(storeMutations.UPDATE_CAR_ID, response.data.carId); context.commit(storeMutations.UPDATE_CAR_ID, response.data.carId);
context.commit(storeMutations.UPDATE_VEHICLE_CATEGORY, response.data.category); context.commit(
context.commit(storeMutations.UPDATE_VEHICLE_IMAGE_URL, response.data.imageUrl); storeMutations.UPDATE_VEHICLE_CATEGORY,
context.commit(storeMutations.UPDATE_VEHICLE_IMAGE_VIF_NUMBER, response.data.imageVifNumber); response.data.category
context.commit(storeMutations.UPDATE_VEHICLE_IMAGE_COLOR, response.data.imageVifColor); );
context.commit(
storeMutations.UPDATE_VEHICLE_IMAGE_URL,
response.data.imageUrl
);
context.commit(
storeMutations.UPDATE_VEHICLE_IMAGE_VIF_NUMBER,
response.data.imageVifNumber
);
context.commit(
storeMutations.UPDATE_VEHICLE_IMAGE_COLOR,
response.data.imageVifColor
);
return response; return response;
}); });
}, },
@ -506,8 +552,8 @@ export const actions = {
validateZip(context, { zip }) { validateZip(context, { zip }) {
return globalMethods.callHttpClient({ return globalMethods.callHttpClient({
methods: endpoints.ValidateZip.method, methods: endpoints.ValidateZip.method,
endpoint: `${endpoints.ValidateZip.url}/${zip}` endpoint: `${endpoints.ValidateZip.url}/${zip}`,
}) });
}, },
// Dependency Actions // Dependency Actions
@ -522,7 +568,7 @@ export const actions = {
}, },
resetRegistrationAndDependencies(context) { resetRegistrationAndDependencies(context) {
context.commit(storeMutations.RESET_REGISTRATION_STATE); context.commit(storeMutations.RESET_REGISTRATION_STATE);
context.commit(storeMutations.RESET_GLASS_PARTS_STATE) context.commit(storeMutations.RESET_GLASS_PARTS_STATE);
}, },
resetPartsAndDependencies(context) { resetPartsAndDependencies(context) {
context.commit(storeMutations.RESET_GLASS_PARTS_STATE); context.commit(storeMutations.RESET_GLASS_PARTS_STATE);
@ -578,8 +624,8 @@ export const actions = {
assignmentId: experiment.assignmentId, assignmentId: experiment.assignmentId,
sessionKey: sessionKey, sessionKey: sessionKey,
pageName: pageName, pageName: pageName,
} },
} },
}); });
}, },
@ -587,13 +633,28 @@ export const actions = {
updateStoreWithSaveSessionResponse(context, { referralNumber, referralDate, referralCorrelationId, eon, accountNumber, savedSessionId, crmCustomerId }) { updateStoreWithSaveSessionResponse(context, { referralNumber, referralDate, referralCorrelationId, eon, accountNumber, savedSessionId, crmCustomerId }) {
context.commit(storeMutations.UPDATE_REFERRAL_NUMBER, referralNumber); context.commit(storeMutations.UPDATE_REFERRAL_NUMBER, referralNumber);
context.commit(storeMutations.UPDATE_REFERRAL_DATE, referralDate); context.commit(storeMutations.UPDATE_REFERRAL_DATE, referralDate);
context.commit(storeMutations.UPDATE_REFERRAL_CORRELATION_ID, referralCorrelationId); context.commit(
storeMutations.UPDATE_REFERRAL_CORRELATION_ID,
referralCorrelationId
);
context.commit(storeMutations.UPDATE_EON, eon); context.commit(storeMutations.UPDATE_EON, eon);
context.commit(storeMutations.UPDATE_PARENT_ACCT_NUMBER, accountNumber); context.commit(storeMutations.UPDATE_PARENT_ACCT_NUMBER, accountNumber);
context.commit(storeMutations.UPDATE_SAVED_SESSION_ID, savedSessionId); context.commit(storeMutations.UPDATE_SAVED_SESSION_ID, savedSessionId);
context.commit(storeMutations.UPDATE_CRM_CUSTOMER_ID, crmCustomerId); context.commit(storeMutations.UPDATE_CRM_CUSTOMER_ID, crmCustomerId);
}, },
logPageView(context, { userId, sessionKey, pageName, sessionId, action, event, shouldUseSessionId, experimentsForUser }) { logPageView(
context,
{
userId,
sessionKey,
pageName,
sessionId,
action,
event,
shouldUseSessionId,
experimentsForUser,
}
) {
var payload = { var payload = {
userId: userId, userId: userId,
sessionKey: sessionKey, sessionKey: sessionKey,
@ -603,17 +664,31 @@ export const actions = {
action: action, action: action,
event: event, event: event,
shouldUseSessionId: shouldUseSessionId, shouldUseSessionId: shouldUseSessionId,
experimentsForUser: experimentsForUser experimentsForUser: experimentsForUser,
}; };
return globalMethods.callHttpClient({ return globalMethods.callHttpClient({
method: endpoints.LogPageView.method, method: endpoints.LogPageView.method,
endpoint: endpoints.LogPageView.url, endpoint: endpoints.LogPageView.url,
payload: payload, payload: payload,
logApiCall: false logApiCall: false,
}); });
}, },
logCustomEvent(context, { userId, sessionKey, pageName, sessionId, category, action, label, value, shouldUseSessionId, experimentsForUser }) { logCustomEvent(
context,
{
userId,
sessionKey,
pageName,
sessionId,
category,
action,
label,
value,
shouldUseSessionId,
experimentsForUser,
}
) {
var payload = { var payload = {
userId: userId, userId: userId,
sessionKey: sessionKey, sessionKey: sessionKey,
@ -625,14 +700,14 @@ export const actions = {
label: label, label: label,
value: value, value: value,
shouldUseSessionId: shouldUseSessionId, shouldUseSessionId: shouldUseSessionId,
experimentsForUser: experimentsForUser experimentsForUser: experimentsForUser,
}; };
return globalMethods.callHttpClient({ return globalMethods.callHttpClient({
method: endpoints.LogCustomEvent.method, method: endpoints.LogCustomEvent.method,
endpoint: endpoints.LogCustomEvent.url, endpoint: endpoints.LogCustomEvent.url,
payload: payload, payload: payload,
logApiCall: false logApiCall: false,
}); });
}, },
initializeSession(context, { userId, sessionId, userAgent, referrer }) { initializeSession(context, { userId, sessionId, userAgent, referrer }) {
@ -644,22 +719,28 @@ export const actions = {
userAgent: userAgent, userAgent: userAgent,
operatorId: "WEB", operatorId: "WEB",
userName: "SafeliteConceptFunnel", userName: "SafeliteConceptFunnel",
referrer: referrer referrer: referrer,
}; };
return globalMethods.callHttpClient({ return globalMethods.callHttpClient({
method: endpoints.InitializeSession.method, method: endpoints.InitializeSession.method,
endpoint: endpoints.InitializeSession.url, endpoint: endpoints.InitializeSession.url,
payload: payload, payload: payload,
logApiCall: false logApiCall: false,
}); });
}, },
// Misc Actions // Misc Actions
setReferralInformation(context, { referralNumber, referralDate, referralCorrelationId, eon }) { setReferralInformation(
context,
{ referralNumber, referralDate, referralCorrelationId, eon }
) {
context.commit(storeMutations.UPDATE_REFERRAL_NUMBER, referralNumber); context.commit(storeMutations.UPDATE_REFERRAL_NUMBER, referralNumber);
context.commit(storeMutations.UPDATE_REFERRAL_DATE, referralDate); context.commit(storeMutations.UPDATE_REFERRAL_DATE, referralDate);
context.commit(storeMutations.UPDATE_REFERRAL_CORRELATION_ID, referralCorrelationId); context.commit(
storeMutations.UPDATE_REFERRAL_CORRELATION_ID,
referralCorrelationId
);
context.commit(storeMutations.UPDATE_EON, eon); context.commit(storeMutations.UPDATE_EON, eon);
}, },
@ -667,11 +748,14 @@ export const actions = {
return globalMethods.callHttpClient({ return globalMethods.callHttpClient({
method: endpoints.GetExperimentsByUser.method, method: endpoints.GetExperimentsByUser.method,
endpoint: `${endpoints.GetExperimentsByUser.url}/${userId}`, endpoint: `${endpoints.GetExperimentsByUser.url}/${userId}`,
payload: {} payload: {},
}); });
}, },
async runExperimentsForTrigger(context, { userId, triggerEvent, triggerValue }) { async runExperimentsForTrigger(
context,
{ userId, triggerEvent, triggerValue }
) {
if (triggerEvent == experimentTriggers.SITE_ENTRY) { if (triggerEvent == experimentTriggers.SITE_ENTRY) {
context.commit(storeMutations.UPDATE_TRIGGERED_SITE_ENTRY, true); context.commit(storeMutations.UPDATE_TRIGGERED_SITE_ENTRY, true);
} }
@ -681,7 +765,7 @@ export const actions = {
userId: userId, userId: userId,
triggerEvent: triggerEvent, triggerEvent: triggerEvent,
triggerValue: triggerValue, triggerValue: triggerValue,
experimentOrder: context.getters.experimentOrder experimentOrder: context.getters.experimentOrder,
}; };
const response = await globalMethods.callHttpClient({ const response = await globalMethods.callHttpClient({
@ -690,7 +774,10 @@ export const actions = {
payload: payload, payload: payload,
}); });
context.commit(storeMutations.UPDATE_EXPERIMENTS, response.data.experiments); context.commit(
storeMutations.UPDATE_EXPERIMENTS,
response.data.experiments
);
}, },
getEvoxImage(context, { relativeUrl }) { getEvoxImage(context, { relativeUrl }) {
@ -719,7 +806,7 @@ export const actions = {
carId: carId, carId: carId,
glassPieces: glassArray ?? [], glassPieces: glassArray ?? [],
zip: zipCode, zip: zipCode,
vin: vin vin: vin,
}, },
}); });
@ -755,7 +842,7 @@ export const actions = {
glassPieces: glassArray, glassPieces: glassArray,
answerResults: resultsArray, answerResults: resultsArray,
zip: zipCode, zip: zipCode,
vin: vin vin: vin,
}, },
}); });
@ -773,24 +860,31 @@ export const actions = {
return globalMethods.callHttpClient({ return globalMethods.callHttpClient({
method: endpoints.GetCapabilityQuestions.method, method: endpoints.GetCapabilityQuestions.method,
endpoint: `${endpoints.GetCapabilityQuestions.url}/${carId}/${partNumber}`, endpoint: `${endpoints.GetCapabilityQuestions.url}/${carId}/${partNumber}`,
}) });
}, },
getPartFromCapabilityQuestionAnswer(context, location) { getPartFromCapabilityQuestionAnswer(context, glassLocation) {
const pageData = context.getters.pageData(fmgPageValues.CAPABILITY_QUESTIONS); const pageData = context.getters.pageData(
fmgPageValues.CAPABILITY_QUESTIONS
);
const part = pageData.partsOrQuestions.find(x => x.location === location).parts[0]; const part = pageData.partsOrQuestions.find(
const capabilityQuestionAnswers = context.getters.damage.capabilityQuestionAnswers; (x) => x.location === glassLocation
const capabilityQuestionAnswersForPart = capabilityQuestionAnswers.find(x => x.location === location); ).parts[0];
const capabilityQuestionAnswers =
context.getters.damage.capabilityQuestionAnswers;
const capabilityQuestionAnswersForPart = capabilityQuestionAnswers.find(
(x) => x.location === glassLocation
);
return globalMethods.callHttpClient({ return globalMethods.callHttpClient({
method: endpoints.GetPartFromCapabilityAnswer.method, method: endpoints.GetPartFromCapabilityAnswer.method,
endpoint: endpoints.GetPartFromCapabilityAnswer.url, endpoint: endpoints.GetPartFromCapabilityAnswer.url,
payload: { payload: {
part, part,
capabilityAnswerResults: capabilityQuestionAnswersForPart capabilityAnswerResults: capabilityQuestionAnswersForPart,
} },
}) });
}, },
// Session API Actions // Session API Actions
@ -825,21 +919,21 @@ export const actions = {
damage: { damage: {
numberOfChips: damage.numberOfChips, numberOfChips: damage.numberOfChips,
glassToReplace: damage.glassToReplace, glassToReplace: damage.glassToReplace,
isRepair: damage.isRepair isRepair: damage.isRepair,
}, },
customer: { customer: {
emailAddress: order.customer.emailAddress, emailAddress: order.customer.emailAddress,
}, },
lineItems: { lineItems: {
glassParts: lineItems.glassParts glassParts: lineItems.glassParts,
}, },
serviceLocation: { serviceLocation: {
streetAddress: order.serviceLocation.address, streetAddress: order.serviceLocation.address,
city: order.serviceLocation.city, city: order.serviceLocation.city,
state: order.serviceLocation.state, state: order.serviceLocation.state,
zipCode: order.serviceLocation.zipCode zipCode: order.serviceLocation.zipCode,
}, },
referralNumber: order.referralNumber?.toString(), // TODO It'd be nice to save these as strings in the first place referralNumber: order.referralNumber?.toString(), // TODO It'd be nice to save these as strings in the first place
referralDate: order.referralDate, referralDate: order.referralDate,
accountNumber: order.accountNumber?.toString(), accountNumber: order.accountNumber?.toString(),
existingPromoCode: null, existingPromoCode: null,
@ -874,8 +968,7 @@ export const actions = {
// Vehicle // Vehicle
saveVehicleYear(context, year) { saveVehicleYear(context, year) {
//Reset dependent state when changing
//Reset dependent state when changing
if (context.state.order.vehicle.year !== year) { if (context.state.order.vehicle.year !== year) {
context.commit(storeMutations.UPDATE_MAKE, null); context.commit(storeMutations.UPDATE_MAKE, null);
context.commit(storeMutations.UPDATE_MODEL, null); context.commit(storeMutations.UPDATE_MODEL, null);
@ -896,7 +989,6 @@ export const actions = {
} }
}, },
saveVehicleMake(context, make) { saveVehicleMake(context, make) {
//Reset dependent state when changing //Reset dependent state when changing
if (context.state.order.vehicle.make !== make) { if (context.state.order.vehicle.make !== make) {
context.commit(storeMutations.UPDATE_MODEL, null); context.commit(storeMutations.UPDATE_MODEL, null);
@ -917,8 +1009,7 @@ export const actions = {
} }
}, },
saveVehicleModel(context, model) { saveVehicleModel(context, model) {
//Reset dependent state when changing
//Reset dependent state when changing
if (context.state.order.vehicle.model !== model) { if (context.state.order.vehicle.model !== model) {
context.commit(storeMutations.UPDATE_STYLE, null); context.commit(storeMutations.UPDATE_STYLE, null);
context.commit(storeMutations.UPDATE_CAR_ID, null); context.commit(storeMutations.UPDATE_CAR_ID, null);
@ -937,7 +1028,7 @@ export const actions = {
} }
}, },
saveVehicleStyle(context, style) { saveVehicleStyle(context, style) {
//Reset dependent state when changing //Reset dependent state when changing
if (context.state.order.vehicle.style !== style) { if (context.state.order.vehicle.style !== style) {
context.commit(storeMutations.UPDATE_CAR_ID, null); context.commit(storeMutations.UPDATE_CAR_ID, null);
context.commit(storeMutations.UPDATE_VEHICLE_CATEGORY, null); context.commit(storeMutations.UPDATE_VEHICLE_CATEGORY, null);
@ -954,20 +1045,32 @@ export const actions = {
context.commit(storeMutations.UPDATE_STYLE, style); context.commit(storeMutations.UPDATE_STYLE, style);
} }
}, },
saveVehicleDamage(context, { isWindshieldRepair, selectedGlassToReplace, selectedWindshieldChipCount }) { saveVehicleDamage(
context,
{ isWindshieldRepair, selectedGlassToReplace, selectedWindshieldChipCount }
) {
const selectedGlassPassedInSorted = selectedGlassToReplace.slice().sort(); const selectedGlassPassedInSorted = selectedGlassToReplace.slice().sort();
const isGlassToReplaceTheSame = (context.state.order.damage.glassToReplace?.length === selectedGlassToReplace.length) const isGlassToReplaceTheSame =
&& context.state.order.damage.glassToReplace context.state.order.damage.glassToReplace?.length ===
selectedGlassToReplace.length &&
context.state.order.damage.glassToReplace
.slice() .slice()
.sort() .sort()
.every((obj, index) => obj.glassLocation === selectedGlassPassedInSorted[index].glassLocation && obj.glassName === selectedGlassPassedInSorted[index].glassName); .every(
const isWindshieldRepairTheSame = isWindshieldRepair === context.state.order.damage.isRepair; (obj, index) =>
const isChipCountTheSame = Array.isArray(selectedWindshieldChipCount) //TODO: fix the underlying components so this is never an array obj.glassLocation ===
? selectedWindshieldChipCount[0] === context.state.order.damage.numberOfChips selectedGlassPassedInSorted[index].glassLocation &&
: selectedWindshieldChipCount === context.state.order.damage.numberOfChips; obj.glassName === selectedGlassPassedInSorted[index].glassName
);
const isWindshieldRepairTheSame =
isWindshieldRepair === context.state.order.damage.isRepair;
const isDamageChanging = !isGlassToReplaceTheSame || !isWindshieldRepairTheSame || (isWindshieldRepair && !isChipCountTheSame); const isChipCountTheSame = selectedWindshieldChipCount === context.state.order.damage.numberOfChips;
const isDamageChanging =
!isGlassToReplaceTheSame ||
!isWindshieldRepairTheSame ||
(isWindshieldRepair && !isChipCountTheSame);
if (isDamageChanging) { if (isDamageChanging) {
//Reset dependent state when changing //Reset dependent state when changing
@ -975,14 +1078,23 @@ export const actions = {
// Save new values // Save new values
context.commit(storeMutations.UPDATE_IS_REPAIR, isWindshieldRepair); context.commit(storeMutations.UPDATE_IS_REPAIR, isWindshieldRepair);
context.commit(storeMutations.UPDATE_NUMBER_OF_CHIPS, isWindshieldRepair ? parseInt(selectedWindshieldChipCount) : null); context.commit(
context.commit(storeMutations.UPDATE_GLASS_TO_REPLACE, selectedGlassToReplace); storeMutations.UPDATE_NUMBER_OF_CHIPS,
isWindshieldRepair ? parseInt(selectedWindshieldChipCount) : null
);
context.commit(
storeMutations.UPDATE_GLASS_TO_REPLACE,
selectedGlassToReplace
);
} }
}, },
// Vin lookup // Vin lookup
saveVinLookup(context, { isSelectedGlassAvailableForVehicle, vehicleInfo, registrationInfo }) { saveVinLookup(
//Reset dependent state when changing context,
{ isSelectedGlassAvailableForVehicle, vehicleInfo, registrationInfo }
) {
//Reset dependent state when changing
if (vehicleInfo.vin !== context.state.order.vehicle.vin) { if (vehicleInfo.vin !== context.state.order.vehicle.vin) {
context.dispatch(storeActions.RESET_REGISTRATION_STATE_AND_DEPENDENCIES); context.dispatch(storeActions.RESET_REGISTRATION_STATE_AND_DEPENDENCIES);
context.dispatch(storeActions.RESET_PARTS_STATE_AND_DEPENDENCIES); context.dispatch(storeActions.RESET_PARTS_STATE_AND_DEPENDENCIES);
@ -996,10 +1108,15 @@ export const actions = {
context.commit(storeMutations.UPDATE_REGISTRATION, registrationInfo); context.commit(storeMutations.UPDATE_REGISTRATION, registrationInfo);
} }
}, },
saveRegistrationLicensePlateLookup(context, { isSelectedGlassAvailableForVehicle, vehicleInfo, registrationInfo }) { saveRegistrationLicensePlateLookup(
//Reset dependent state when changing context,
if (registrationInfo?.licensePlate !== context.state.order.vehicle.registration?.licensePlate) { { isSelectedGlassAvailableForVehicle, vehicleInfo, registrationInfo }
) {
//Reset dependent state when changing
if (
registrationInfo?.licensePlate !==
context.state.order.vehicle.registration?.licensePlate
) {
context.dispatch(storeActions.RESET_REGISTRATION_STATE_AND_DEPENDENCIES); context.dispatch(storeActions.RESET_REGISTRATION_STATE_AND_DEPENDENCIES);
if (!isSelectedGlassAvailableForVehicle) { if (!isSelectedGlassAvailableForVehicle) {
@ -1012,10 +1129,25 @@ export const actions = {
context.commit(storeMutations.UPDATE_REGISTRATION, registrationInfo); context.commit(storeMutations.UPDATE_REGISTRATION, registrationInfo);
} }
}, },
saveRegistrationAddressLookup(context, { isSelectedGlassAvailableForVehicle, vehicleInfo, registrationInfo }) { saveRegistrationAddressLookup(
//Reset dependent state when changing context,
if (registrationInfo?.address !== context.state.order.vehicle.registration?.address || registrationInfo?.city !== context.state.order.vehicle.registration?.city || registrationInfo?.state !== context.state.order.vehicle.registration?.state || registrationInfo?.zipCode !== context.state.order.vehicle.registration?.zipCode || registrationInfo?.firstName !== context.state.order.vehicle.registration?.firstName || registrationInfo?.lastName !== context.state.order.vehicle.registration?.lastName) { { isSelectedGlassAvailableForVehicle, vehicleInfo, registrationInfo }
) {
//Reset dependent state when changing
if (
registrationInfo?.address !==
context.state.order.vehicle.registration?.address ||
registrationInfo?.city !==
context.state.order.vehicle.registration?.city ||
registrationInfo?.state !==
context.state.order.vehicle.registration?.state ||
registrationInfo?.zipCode !==
context.state.order.vehicle.registration?.zipCode ||
registrationInfo?.firstName !==
context.state.order.vehicle.registration?.firstName ||
registrationInfo?.lastName !==
context.state.order.vehicle.registration?.lastName
) {
context.dispatch(storeActions.RESET_REGISTRATION_STATE_AND_DEPENDENCIES); context.dispatch(storeActions.RESET_REGISTRATION_STATE_AND_DEPENDENCIES);
if (!isSelectedGlassAvailableForVehicle) { if (!isSelectedGlassAvailableForVehicle) {
@ -1030,72 +1162,141 @@ export const actions = {
}, },
savePartQuestionAnswers(context, partQuestionAnswersArray) { savePartQuestionAnswers(context, partQuestionAnswersArray) {
// if part question answers have changed, reset subsequent question answers // if part question answers have changed, reset subsequent question answers
const sortedPreviousResultsArray = sortArrayOfObjectsByPropertyValue(context.getters.damage.partQuestionAnswers, "result"); const sortedPreviousResultsArray = sortArrayOfObjectsByPropertyValue(
const sortedPartQuestionAnswersArray = sortArrayOfObjectsByPropertyValue(partQuestionAnswersArray, "result") context.getters.damage.partQuestionAnswers,
const havePartQuestionAnswersChanged = sortedPreviousResultsArray?.length !== sortedPartQuestionAnswersArray.length || "result"
!sortedPreviousResultsArray?.every((x, i) => x.result === sortedPartQuestionAnswersArray[i].result); );
const sortedPartQuestionAnswersArray = sortArrayOfObjectsByPropertyValue(
partQuestionAnswersArray,
"result"
);
const havePartQuestionAnswersChanged =
sortedPreviousResultsArray?.length !==
sortedPartQuestionAnswersArray.length ||
!sortedPreviousResultsArray?.every(
(x, i) => x.result === sortedPartQuestionAnswersArray[i].result
);
if (havePartQuestionAnswersChanged) { if (havePartQuestionAnswersChanged) {
context.commit(storeMutations.UPDATE_GLASS_PARTS, null); context.commit(storeMutations.UPDATE_GLASS_PARTS, null);
context.commit(storeMutations.UPDATE_MOLDING_QUESTION_ANSWERS, null); context.commit(storeMutations.UPDATE_MOLDING_QUESTION_ANSWERS, null);
context.commit(storeMutations.UPDATE_CAPABILITY_QUESTION_ANSWERS, null); context.commit(storeMutations.UPDATE_CAPABILITY_QUESTION_ANSWERS, null);
context.commit(storeMutations.UPDATE_PAGE_DATA, { page: fmgPageValues.VEHICLE_PARTS, data: null }); context.commit(storeMutations.UPDATE_PAGE_DATA, {
context.commit(storeMutations.UPDATE_PAGE_DATA, { page: fmgPageValues.MOLDING_QUESTIONS, data: null }); page: fmgPageValues.VEHICLE_PARTS,
context.commit(storeMutations.UPDATE_PAGE_DATA, { page: fmgPageValues.CAPABILITY_QUESTIONS, data: null }); data: null,
});
context.commit(storeMutations.UPDATE_PAGE_DATA, {
page: fmgPageValues.MOLDING_QUESTIONS,
data: null,
});
context.commit(storeMutations.UPDATE_PAGE_DATA, {
page: fmgPageValues.CAPABILITY_QUESTIONS,
data: null,
});
} }
//Save new values //Save new values
context.commit(storeMutations.UPDATE_PART_QUESTION_ANSWERS, partQuestionAnswersArray); context.commit(
storeMutations.UPDATE_PART_QUESTION_ANSWERS,
partQuestionAnswersArray
);
}, },
resetMoldingAndCapabilityQuestionAnswersIfNeeded(context, matchedParts) { resetMoldingAndCapabilityQuestionAnswersIfNeeded(context, matchedParts) {
const partsOrQuestionsDataToCompareWith = context.getters.pageData(fmgPageValues.MOLDING_QUESTIONS)?.partsOrQuestions ?? context.getters.pageData(fmgPageValues.CAPABILITY_QUESTIONS)?.partsOrQuestions ?? []; const partsOrQuestionsDataToCompareWith =
context.getters.pageData(fmgPageValues.MOLDING_QUESTIONS)
?.partsOrQuestions ??
context.getters.pageData(fmgPageValues.CAPABILITY_QUESTIONS)
?.partsOrQuestions ??
[];
function getAllPartNumbers(partsOrQuestions) { function getAllPartNumbers(partsOrQuestions) {
return partsOrQuestions[0]?.parts return partsOrQuestions[0]?.parts
? [...partsOrQuestions].map(glass => glass.parts).flat().map(part => part.partNumber).filter(partNumber => !partNumber.toUpperCase().includes("FEE")).sort().join(",") ? [...partsOrQuestions]
: [] .map((glass) => glass.parts)
.flat()
.map((part) => part.partNumber)
.filter((partNumber) => !partNumber.toUpperCase().includes("FEE"))
.sort()
.join(",")
: [];
} }
const previouslySelectedPartNumbers = getAllPartNumbers(partsOrQuestionsDataToCompareWith); const previouslySelectedPartNumbers = getAllPartNumbers(
partsOrQuestionsDataToCompareWith
);
const currentlySelectedPartNumbers = getAllPartNumbers(matchedParts); const currentlySelectedPartNumbers = getAllPartNumbers(matchedParts);
const haveSelectedVehiclePartsChanged = previouslySelectedPartNumbers !== currentlySelectedPartNumbers; const haveSelectedVehiclePartsChanged =
previouslySelectedPartNumbers !== currentlySelectedPartNumbers;
if (haveSelectedVehiclePartsChanged) { if (haveSelectedVehiclePartsChanged) {
context.commit(storeMutations.UPDATE_GLASS_PARTS, null); context.commit(storeMutations.UPDATE_GLASS_PARTS, null);
context.commit(storeMutations.UPDATE_MOLDING_QUESTION_ANSWERS, null); context.commit(storeMutations.UPDATE_MOLDING_QUESTION_ANSWERS, null);
context.commit(storeMutations.UPDATE_CAPABILITY_QUESTION_ANSWERS, null); context.commit(storeMutations.UPDATE_CAPABILITY_QUESTION_ANSWERS, null);
context.commit(storeMutations.UPDATE_PAGE_DATA, { page: fmgPageValues.MOLDING_QUESTIONS, data: null }); context.commit(storeMutations.UPDATE_PAGE_DATA, {
context.commit(storeMutations.UPDATE_PAGE_DATA, { page: fmgPageValues.CAPABILITY_QUESTIONS, data: null }); page: fmgPageValues.MOLDING_QUESTIONS,
data: null,
});
context.commit(storeMutations.UPDATE_PAGE_DATA, {
page: fmgPageValues.CAPABILITY_QUESTIONS,
data: null,
});
} }
}, },
saveMoldingQuestionAnswers(context, moldingQuestionAnswers) { saveMoldingQuestionAnswers(context, moldingQuestionAnswers) {
const sortedPreviousResultsArray = sortArrayOfObjectsByPropertyValue(context.getters.damage.moldingQuestionAnswers, "partNum"); const sortedPreviousResultsArray = sortArrayOfObjectsByPropertyValue(
const sortedMoldingQuestionAnswersArray = sortArrayOfObjectsByPropertyValue(moldingQuestionAnswers, "partNum"); context.getters.damage.moldingQuestionAnswers,
const haveMoldingQuestionAnswersChanged = sortedPreviousResultsArray?.length !== sortedMoldingQuestionAnswersArray.length || "partNum"
!sortedPreviousResultsArray?.every((x, i) => x.partNum === sortedMoldingQuestionAnswersArray[i].partNum); );
const sortedMoldingQuestionAnswersArray = sortArrayOfObjectsByPropertyValue(
moldingQuestionAnswers,
"partNum"
);
const haveMoldingQuestionAnswersChanged =
sortedPreviousResultsArray?.length !==
sortedMoldingQuestionAnswersArray.length ||
!sortedPreviousResultsArray?.every(
(x, i) => x.partNum === sortedMoldingQuestionAnswersArray[i].partNum
);
if (haveMoldingQuestionAnswersChanged) { if (haveMoldingQuestionAnswersChanged) {
context.commit(storeMutations.UPDATE_GLASS_PARTS, null); context.commit(storeMutations.UPDATE_GLASS_PARTS, null);
context.commit(storeMutations.UPDATE_CAPABILITY_QUESTION_ANSWERS, null); context.commit(storeMutations.UPDATE_CAPABILITY_QUESTION_ANSWERS, null);
context.commit(storeMutations.UPDATE_PAGE_DATA, { page: fmgPageValues.CAPABILITY_QUESTIONS, data: null }); context.commit(storeMutations.UPDATE_PAGE_DATA, {
page: fmgPageValues.CAPABILITY_QUESTIONS,
data: null,
});
} }
//Save new values //Save new values
context.commit(storeMutations.UPDATE_MOLDING_QUESTION_ANSWERS, moldingQuestionAnswers); context.commit(
storeMutations.UPDATE_MOLDING_QUESTION_ANSWERS,
moldingQuestionAnswers
);
}, },
saveCapabilityQuestionAnswers(context, capabilityQuestionAnswers) { saveCapabilityQuestionAnswers(context, capabilityQuestionAnswers) {
const sortedPreviousResultsArray = sortArrayOfObjectsByPropertyValue(context.getters.damage.capabilityQuestionAnswers, "result"); const sortedPreviousResultsArray = sortArrayOfObjectsByPropertyValue(
const sortedCapabilityQuestionAnswersArray = sortArrayOfObjectsByPropertyValue(capabilityQuestionAnswers, "result"); context.getters.damage.capabilityQuestionAnswers,
const haveCapabilityQuestionAnswersChanged = sortedPreviousResultsArray?.length !== sortedCapabilityQuestionAnswersArray.length || "result"
!sortedPreviousResultsArray?.every((x, i) => x.result === sortedCapabilityQuestionAnswersArray[i].result); );
const sortedCapabilityQuestionAnswersArray =
sortArrayOfObjectsByPropertyValue(capabilityQuestionAnswers, "result");
const haveCapabilityQuestionAnswersChanged =
sortedPreviousResultsArray?.length !==
sortedCapabilityQuestionAnswersArray.length ||
!sortedPreviousResultsArray?.every(
(x, i) => x.result === sortedCapabilityQuestionAnswersArray[i].result
);
if (haveCapabilityQuestionAnswersChanged) { if (haveCapabilityQuestionAnswersChanged) {
context.commit(storeMutations.UPDATE_GLASS_PARTS, null); context.commit(storeMutations.UPDATE_GLASS_PARTS, null);
} }
//Save new values //Save new values
context.commit(storeMutations.UPDATE_CAPABILITY_QUESTION_ANSWERS, capabilityQuestionAnswers); context.commit(
storeMutations.UPDATE_CAPABILITY_QUESTION_ANSWERS,
capabilityQuestionAnswers
);
}, },
// Misc order actions // Misc order actions
saveServiceLocation(context, serviceLocationInfo) { saveServiceLocation(context, serviceLocationInfo) {
@ -1107,7 +1308,6 @@ export const actions = {
saveVin(context, { isSelectedGlassAvailableForVehicle, vehicleInfo }) { saveVin(context, { isSelectedGlassAvailableForVehicle, vehicleInfo }) {
//Reset dependent state when changing //Reset dependent state when changing
if (vehicleInfo.vin !== context.state.order.vehicle.vin) { if (vehicleInfo.vin !== context.state.order.vehicle.vin) {
if (!isSelectedGlassAvailableForVehicle) { if (!isSelectedGlassAvailableForVehicle) {
context.dispatch(storeActions.RESET_DAMAGE_STATE_AND_DEPENDENCIES); context.dispatch(storeActions.RESET_DAMAGE_STATE_AND_DEPENDENCIES);
context.dispatch(storeActions.RESET_PARTS_STATE_AND_DEPENDENCIES); context.dispatch(storeActions.RESET_PARTS_STATE_AND_DEPENDENCIES);
@ -1122,12 +1322,11 @@ export const actions = {
}, },
clearVin(context) { clearVin(context) {
context.commit(storeMutations.UPDATE_VEHICLE_VIN, null); context.commit(storeMutations.UPDATE_VEHICLE_VIN, null);
} },
} };
export default createStore({ export default createStore({
plugins: [createPersistedState()], plugins: [createPersistedState()],
// IMPORTANT: Be VERY careful when modifying these fields for at least a few reasons: // IMPORTANT: Be VERY careful when modifying these fields for at least a few reasons:
// * The CMS can reference the fields by name // * The CMS can reference the fields by name
// * Return users may have a previous "version" of the model, and we don't want // * Return users may have a previous "version" of the model, and we don't want
@ -1150,21 +1349,18 @@ function getHasRecalibrationPart(state) {
} else { // Has 'requiresRecalibration' but no 'recalibrationType' at all } else { // Has 'requiresRecalibration' but no 'recalibrationType' at all
return true; return true;
} }
} else { // Does not have 'requiresRecalibration' } else {
// Does not have 'requiresRecalibration'
return false; return false;
} }
} }
function sortArrayOfObjectsByPropertyValue(arrayOfObjects, propertyName) { function sortArrayOfObjectsByPropertyValue(arrayOfObjects, propertyName) {
if (!arrayOfObjects) return null; if (!arrayOfObjects) return null;
return arrayOfObjects.sort((a, b) => { return arrayOfObjects.sort((a, b) => {
if (a[propertyName] < b[propertyName]) if (a[propertyName] < b[propertyName]) return -1;
return -1; else if (a[propertyName] > b[propertyName]) return 1;
else if (a[propertyName] > b[propertyName]) else return 0;
return 1; });
else }
return 0;
})
}

View file

@ -3,7 +3,6 @@ html {
&.list-button, &.list-button,
&.list-card, &.list-card,
&.list-card.list-button { &.list-card.list-button {
border: none;
color: $red; color: $red;
input[type=checkbox]:focus + label, input[type=checkbox]:focus + label,
input[type=radio]:focus + label { input[type=radio]:focus + label {

View file

@ -2,15 +2,17 @@
<button <button
:aria-disabled="isDisabled" :aria-disabled="isDisabled"
class="btn d-flex align-items-center py-3 px-4 delay" class="btn d-flex align-items-center py-3 px-4 delay"
:class="[isPrimary ? 'btn-primary' : 'btn-secondary',isFloat ? 'float-end' : '', isLoaderDisplayed ? 'has-loader' : '']" :class="[
@click="clicked()" isPrimary ? 'btn-primary' : 'btn-secondary',
> isFloat ? 'float-end' : '',
isLoaderDisplayed ? 'has-loader' : '',
]"
@click="clicked">
<span class="m-0">{{ this.buttonText }}</span> <span class="m-0">{{ this.buttonText }}</span>
<loader <loader
class="ms-2" class="ms-2"
v-if="isLoaderDisplayed" v-if="isLoaderDisplayed"
v-bind:class="[this.loaderColor, this.loaderPosition]" v-bind:class="[this.loaderColor, this.loaderPosition]" />
/>
</button> </button>
</template> </template>
@ -33,11 +35,16 @@ export default {
}; };
}, },
methods: { methods: {
removeLoader(){ removeLoader() {
this.isLoaderDisplayed = false; this.isLoaderDisplayed = false;
}, },
clicked() { clicked() {
this.pushEventToGA(this.$route.query[this.queryStrings.FMG_PAGE], this.GaActions.CLICKED, this.buttonText, true); this.pushEventToGA(
this.$route.query[this.queryStrings.FMG_PAGE],
this.GaActions.CLICKED,
this.buttonText,
true
);
if (!this.isDisabled) { if (!this.isDisabled) {
this.isLoaderDisplayed = true; this.isLoaderDisplayed = true;
this.$emit("click-event"); this.$emit("click-event");
@ -98,7 +105,8 @@ export default {
background: $blue-700; background: $blue-700;
box-shadow: 0 0 0 3px, 0 0 0 5.5px $blue-700; box-shadow: 0 0 0 3px, 0 0 0 5.5px $blue-700;
} }
&.delay {// fixes flicker while transitioning between states &.delay {
// fixes flicker while transitioning between states
transition: background 0s 0s ease-in-out; transition: background 0s 0s ease-in-out;
} }
} }
@ -137,7 +145,8 @@ export default {
color: $white; color: $white;
@include blue-gradient; @include blue-gradient;
} }
&.delay {// fixes flicker while transitioning between states &.delay {
// fixes flicker while transitioning between states
transition: background 0s 0s ease-in-out; transition: background 0s 0s ease-in-out;
} }
} }

View file

@ -1,271 +1,139 @@
import { shallowMount } from "@vue/test-utils"; import { mount } from "@vue/test-utils";
import listButtonHorizontal from "./list-button-horizontal"; import listButtonHorizontal from "./list-button-horizontal";
import { nextTick } from "vue"; import inputButtonWrapperMixin from "@/mixins/input-button-wrapper-mixin";
import { GaActions } from "@/constants/analytics";
describe("list-button-horizontal.vue", () => { describe("list-button-horizontal.vue", () => {
it("Should return input type checkbox if isMultiSelect is true", async () => { describe("styling/UI", () => {
// Act it("Should return screen reader text", async () => {
const wrapper = shallowMount(listButtonHorizontal, { // Act
propsData: { const { wrapper } = setupMocks({
isMultiSelect: true, mockData: {
}, propsData: {
screenReaderOnlyText: "Screen Reader Only Text",
},
},
});
// Assert
const paragraph = wrapper.find("span.sr-only");
expect(paragraph.text()).toEqual("Screen Reader Only Text");
});
it("Should return text alignment class", async () => {
// Act
const { wrapper } = setupMocks({
mockData: {
propsData: {
textPosition: "text-center",
},
},
});
// Assert
const paragraph = wrapper.find("span.m-0");
expect(paragraph.attributes("class")).toContain("text-center");
});
it("Should return aria-required state", async () => {
// Act
const { wrapper } = setupMocks({
mockData: {
propsData: {
isRequired: true,
},
},
});
// Assert
const input = wrapper.find("input");
expect(input.attributes()["aria-required"]).toEqual("true");
});
it("is cash or insurance button => has 'radio-fancy' class", () => {
// Arrange/Act
const { wrapper } = setupMocks({
mockData: {
propsData: {
isCashOrInsurance: true,
},
},
});
// Assert
const label = wrapper.find("label");
expect(label.classes()).toContain("radio-fancy");
});
test("has buttonLabel => displays buttonLabel", () => {
// Arrange/Act
const { wrapper } = setupMocks({
mockData: {
propsData: {
buttonLabel: "Surprise!",
},
},
});
// Assert
const content = wrapper.find(".list-button-horizontal-content");
expect(content.isVisible()).toBe(true);
expect(content.text()).toContain("Surprise!");
});
test("has buttonLabelSubCopy => displays buttonLabelSubCopy", () => {
// Arrange/Act
const { wrapper } = setupMocks({
mockData: {
propsData: {
buttonLabel: "Surprise!",
buttonLabelSubCopy: "Super duper surprise :)",
},
},
});
// Assert
const content = wrapper.find(".list-button-horizontal-content");
expect(content.isVisible()).toBe(true);
expect(content.text()).toContain("Super duper surprise :)");
});
test("has screenReaderOnlyText => displays screenReaderOnlyText", () => {
// Arrange/Act
const { wrapper } = setupMocks({
mockData: {
propsData: {
buttonLabel: "Surprise!",
buttonLabelSubCopy: "Super duper surprise :)",
screenReaderOnlyText: "Tests are fun!",
},
},
});
// Assert
const content = wrapper.find(".list-button-horizontal-content");
const screenReaderOnlyText = wrapper.find(".sr-only");
expect(content.isVisible()).toBe(true);
expect(screenReaderOnlyText.exists()).toBe(true);
expect(screenReaderOnlyText.text()).toContain("Tests are fun!");
});
}); });
// Assert
const input = wrapper.find("input");
expect(input.attributes().type).toEqual("checkbox");
});
it("Should return input type radio if isMultiSelect is false or not specified", async () => {
// Act
const wrapper = shallowMount(listButtonHorizontal, {
propsData: {
isMultiSelect: false,
},
});
// Assert
const input = wrapper.find("input");
expect(input.attributes().type).toEqual("radio");
});
it("Should return primary label text (buttonID)", async () => {
// Act
const wrapper = shallowMount(listButtonHorizontal, {
propsData: {
buttonID: "List Card Checkbox",
},
});
// Assert
const label = wrapper.find("label");
expect(label.attributes().for).toEqual("List Card Checkbox");
});
it("Should return screen reader text", async () => {
// Act
const wrapper = shallowMount(listButtonHorizontal, {
propsData: {
screenReaderOnlyText: "Screen Reader Only Text",
},
});
// Assert
const paragraph = wrapper.find("span.sr-only");
expect(paragraph.text()).toEqual("Screen Reader Only Text");
});
it("Should return text alignment class", async () => {
// Act
const wrapper = shallowMount(listButtonHorizontal, {
propsData: {
textPosition: "text-center",
},
});
// Assert
const paragraph = wrapper.find("span.m-0");
expect(paragraph.attributes("class")).toContain("text-center");
});
it("Should return aria-required state", async () => {
// Act
const wrapper = shallowMount(listButtonHorizontal, {
propsData: {
isRequired: true,
},
});
// Assert
const input = wrapper.find("input");
expect(input.attributes()["aria-required"]).toEqual("true");
});
it("Should return loader enabled true", async () => {
// Act
const wrapper = shallowMount(listButtonHorizontal, {
global: {
mocks: {
'$route': { query: { fmgPage: 'page-name' } },
GaActions: GaActions,
pushEventToGA: jest.fn(),
}
},
propsData: {
selectingInitiatesLoad: true,
},
});
// Assert
const label = wrapper.find("label");
wrapper.vm.handleCheckChange = jest.fn();
wrapper.vm.triggerButton();
await nextTick();
const loader = wrapper.find("loader-stub");
expect(loader.exists()).toBe(true);
});
it("Should return loader color", async () => {
// Act
const wrapper = shallowMount(listButtonHorizontal, {
global: {
mocks: {
'$route': { query: { fmgPage: 'page-name' } },
GaActions: GaActions,
pushEventToGA: jest.fn(),
}
},
propsData: {
loaderColor: "blue",
selectingInitiatesLoad: true,
},
});
// Assert
const label = wrapper.find("label");
wrapper.vm.handleCheckChange = jest.fn();
wrapper.vm.triggerButton();
await nextTick();
const loader = wrapper.find("loader-stub");
expect(loader.attributes("class")).toContain("blue");
});
it("Should return loader position", async () => {
// Act
const wrapper = shallowMount(listButtonHorizontal, {
global: {
mocks: {
'$route': { query: { fmgPage: 'page-name' } },
GaActions: GaActions,
pushEventToGA: jest.fn(),
}
},
propsData: {
loaderPosition: "right",
selectingInitiatesLoad: true,
},
});
// Assert
const label = wrapper.find("label");
wrapper.vm.handleCheckChange = jest.fn();
wrapper.vm.triggerButton();
await nextTick();
const loader = wrapper.find("loader-stub");
expect(loader.attributes("class")).toContain("right");
});
it("Should emit button value on click", async () => {
// Act
const wrapper = shallowMount(listButtonHorizontal, {
propsData: {
isRadioHorizontal: true,
buttonLabel: "Windshield",
value: "List Card Checkbox",
groupID: "radio-demo-1",
groupName: "radio 1",
buttonImage: "windshield-damage.svg",
isRequired: true,
isWide: false,
modelValue: ["List Card Checkbox"],
},
});
wrapper.vm.handleCheckChange();
// Assert
expect(wrapper.emitted()["isCheckedChanged"][0]).toEqual([{value: "List Card Checkbox", checkValue: Boolean, buttonId: undefined, checkValue: false}]);
});
it("Should set checkValue data if selectedButtonIDs has value(s)", async () => {
// Act
const wrapper = shallowMount(listButtonHorizontal, {
propsData: {
isRadioHorizontal: true,
buttonLabel: "Windshield",
buttonID: "List Card Checkbox",
groupID: "radio-demo-1",
groupName: "radio 1",
buttonImage: "windshield-damage.svg",
isRequired: true,
isWide: false,
modelValue: ["List Card Checkbox"],
isMultiSelect: false,
value: "Car-Front",
selectedValues: ["Car-Front"]
},
});
// Assert
expect(wrapper.vm.checkValue).toEqual(true);
});
it("Should run handleCheckChange if selectingInitiatesLoad is false and handleInputChange is triggered", async () => {
// Act
const wrapper = shallowMount(listButtonHorizontal, {
propsData: {
selectingInitiatesLoad: false,
},
});
// Assert
wrapper.vm.handleInputChange();
await nextTick();
expect(wrapper.vm.handleCheckChange).toBeCalled;
});
it("Should do nothing if isMultiSelect is true and handleKeyupArrow is triggered", async () => {
// Act
const wrapper = shallowMount(listButtonHorizontal, {
propsData: {
isMultiSelect: true,
},
});
// Assert
wrapper.vm.handleKeyupArrow();
await nextTick();
expect(wrapper.vm.handleKeyupArrow).toHaveReturned;
});
it("Should run handleCheckChange if selectingInitiatesLoad is false and handleKeyupArrow is triggered", async () => {
// Act
const wrapper = shallowMount(listButtonHorizontal, {
propsData: {
selectingInitiatesLoad: false,
isMultiSelect: false,
},
});
// Assert
wrapper.vm.handleKeyupArrow();
await nextTick();
expect(wrapper.vm.handleCheckChange).toBeCalled;
});
}); });
function setupMocks({ mockData }) {
const wrapper = mount(listButtonHorizontal, {
...mockData,
propsData: {
...mockData.propsData,
groupName: "my-group",
modelValue: mockData.propsData?.isMultiSelect ? ["5"] : "5",
value: mockData.propsData?.isMultiSelect ? ["4"] : "4",
},
mixins: [inputButtonWrapperMixin],
});
return { wrapper };
}

View file

@ -1,345 +1,222 @@
<template> <template>
<div <baseInputButton
class="list-group list-button-horizontal d-flex flex-column w-100" v-bind="$props"
:class="[(errors.length > 0 || hasError) ? 'has-error' : '']" :buttonWrapperClasses="[
@keyup.space="triggerButton()" 'list-group list-button-horizontal d-flex flex-column w-100',
@keyup.up="handleKeyupArrow()" { 'radio-fancy': isCashOrInsurance },
@keyup.down="handleKeyupArrow()" ]"
@keyup.left="handleKeyupArrow()" v-model="selectedValue">
@keyup.right="handleKeyupArrow()" <div
> class="list-button-horizontal-content d-flex flex-column justify-content-center p-3">
<input <span class="m-0" :class="textPosition">
:type="isMultiSelect ? 'checkbox' : 'radio'" {{ buttonLabel }}
:id="buttonID" </span>
:name="groupName" <span
:aria-required="isRequired" v-if="buttonLabelSubCopy"
v-model="checkValue" class="m-0 small"
:checked="checkValue" :class="textPosition">
@change="handleInputChange()" {{ buttonLabelSubCopy }}
/> </span>
<label <span v-if="screenReaderOnlyText" class="sr-only">
tabindex="-1" {{ screenReaderOnlyText }}
:for="buttonID" </span>
:aria-label="buttonLabel" </div>
class="d-flex flex-column justify-content-center py-3 px-4" </baseInputButton>
@mouseup="triggerButton()"
>
<span
class="m-0"
:class="textPosition"
>
{{buttonLabel}}
</span>
<span v-if="buttonLabelSubCopy" class="m-0 small" :class="textPosition">
{{ buttonLabelSubCopy }}
</span>
<span v-if="screenReaderOnlyText" class="sr-only">
{{ screenReaderOnlyText }}
</span>
<loader v-if="isLoaderDisplayed && selectingInitiatesLoad" :class="[loaderColor, loaderPosition]" />
</label>
</div>
</template> </template>
<script> <script>
import { useField } from "vee-validate"; import baseInputButton from "@/common-components/base-input-button/base-input-button";
import loader from "@/ux-components/loader/loader"; import inputButtonWrapperMixin from "@/mixins/input-button-wrapper-mixin";
import { toRef } from "vue";
import { queryStrings } from "@/constants/query-strings";
export default { export default {
name: "listButtonHorizontal", name: "listButtonHorizontal",
props: { mixins: [inputButtonWrapperMixin],
isMultiSelect: Boolean, props: {
groupName: String, isCashOrInsurance: Boolean,
buttonID: String,
buttonLabel: String,
buttonLabelSubCopy: String,
screenReaderOnlyText: String,
textPosition: String,
selectingInitiatesLoad: Boolean,
loaderColor: String,
loaderPosition: String,
isRequired: Boolean,
isCashOrInsurance: Boolean,
value: {
// Field initial value
type: String,
default: "",
}, },
validationRules: String, components: {
selectedValues: [Array, String], baseInputButton,
hasError: Boolean,
valueToLogType: String,
},
data() {
return {
isLoaderDisplayed: false,
checkValue: Boolean,
};
},
created() {
if (Array.isArray(this.selectedValues)) {
this.checkValue = this.isMultiSelect
? this.selectedValues.includes(this.value)
: this.selectedValues[0] == this.value;
}
else {
this.checkValue = this.selectedValues === this.value;
}
},
methods: {
isValueSelectedByArray(arr) {
return this.isMultiSelect
? arr.includes(this.value)
: arr[0];
}, },
displayLoader() {
this.isLoaderDisplayed = true;
},
handleInputChange() {
if (!this.selectingInitiatesLoad) {
this.handleCheckChange();
}
},
handleKeyupArrow() {
if (this.isMultiSelect) {
return; // Prevent arrow keys from doing anything if element is a checkbox
}
if (!this.selectingInitiatesLoad) {
this.handleCheckChange();
}
},
triggerButton() {
if (this.selectingInitiatesLoad) {
this.displayLoader();
this.handleCheckChange();
}
this.pushEventToGA(this.$route.query[queryStrings.FMG_PAGE], this.GaActions.CLICKED, this.value.toString(), true, this.valueToLogType);
},
handleCheckChange() {
const emitEvent = {
checkValue: this.checkValue, // only read on checkboxes, on handleCheckedChanged on button-question
value: this.value,
buttonId: this.buttonID && this.buttonID.toString(),
};
this.handleChange(this.value);
this.$emit("isCheckedChanged", emitEvent);
this.$emit("update:modelValue", emitEvent);
}
},
components: {
loader,
},
setup(props) {
const inputType = props.isMultiSelect ? "checkbox" : "radio";
const fieldOptions = {
type: inputType,
checkedValue: props.value,
potentialInitialValue: props.selectedValues,
};
// Set initialValue for validation setup if pre-selected
// NOTE: props.selectedValues could be an array of strings, or an array of integers...
if (props.selectedValues && (props.selectedValues.includes(props.value) || props.selectedValues.includes(parseInt(props.value)))) {
fieldOptions['initialValue'] = fieldOptions.potentialInitialValue;
}
const {
handleChange,
errors,
value
} = useField(toRef(props, "groupName"), toRef(props, "validationRules"), fieldOptions);
const validateValue = value;
return {
handleChange,
errors,
validateValue,
fieldOptions, // only need to expose this for unit test purposes
};
},
}; };
</script> </script>
<style lang="scss"> <style lang="scss">
.list-button-horizontal { .list-button-horizontal {
input[type="radio"],
input[type="checkbox"] {
position: absolute;
height: 0;
opacity: 0;
width: 0;
&:focus-visible+label {
box-shadow: 0 0 0 2.5px $blue;
z-index: 2;
}
&:focus+label {
box-shadow: 0 0 0 2.5px $blue;
z-index: 3;
}
&:checked+label {
background: $blue-100;
box-shadow: 0 0 0 1px $blue;
outline: none;
z-index: 2;
}
&:checked:focus+label {
box-shadow: 0 0 0 2.5px $blue;
}
&:checked+label p:first-child {
font-weight: 500;
}
}
label {
outline: none;
position: relative;
background: $white;
transition: all 150ms linear;
border: 1px solid $gray-500;
border-radius: 0;
width: 100%;
color: $gray-600;
&:hover {
@include media-breakpoint-up(sm) {
box-shadow: 0 0 0 4px $blue-300;
cursor: pointer;
z-index: 4 !important;
}
}
+p {
display: none;
}
span {
font-size: .875rem;
}
}
// Cash/Insurance option radio button styling
&.radio-fancy {
label {
border: 1px solid $blue-700;
z-index: 2;
color: $blue;
span {
font-size: 1rem;
font-weight: 500;
}
}
label:hover {
background-color: $blue-700;
color: $white;
box-shadow: none;
}
input[type="radio"], input[type="radio"],
input[type="checkbox"] { input[type="checkbox"] {
position: absolute; position: absolute;
height: 0; height: 0;
opacity: 0; opacity: 0;
width: 0; width: 0;
&:focus-visible+label { .list-button-horizontal-content {
border-radius: 0.5rem; cursor: pointer;
z-index: 2; }
}
&:focus+label { &:focus-visible + .list-button-horizontal-content {
z-index: 3; box-shadow: 0 0 0 2.5px $blue;
} z-index: 2;
}
&:checked+label { &:focus + .list-button-horizontal-content {
box-shadow: 0 0 0 2.5px $blue;
z-index: 3;
}
&:checked + .list-button-horizontal-content {
background: $blue-100;
box-shadow: 0 0 0 1px $blue;
outline: none;
z-index: 2;
}
&:checked:focus + .list-button-horizontal-content {
box-shadow: 0 0 0 2.5px $blue;
}
&:checked + .list-button-horizontal-content p:first-child {
font-weight: 500;
}
}
.list-button-horizontal-content {
outline: none; outline: none;
box-shadow: none; position: relative;
color: $white; background: $white;
background: linear-gradient(84.45deg, #125B7E 0%, #3B8FB8 100%); transition: all 150ms linear;
border-radius: 0.5rem; border: 1px solid $gray-500;
z-index: 5; border-radius: 0;
} width: 100%;
color: $gray-600;
&:checked:focus+label { &:hover {
box-shadow: 0 0 0 3px, 0 0 0 5.5px $blue-700; @include media-breakpoint-up(sm) {
} box-shadow: 0 0 0 4px $blue-300;
cursor: pointer;
z-index: 4 !important;
}
}
&:checked+label p:first-child { + p {
font-weight: 500; display: none;
} }
span {
font-size: 0.875rem;
}
} }
}
&.list-button-horizontal { // Cash/Insurance option radio button styling
height: 100%; &.radio-fancy {
label { .list-button-horizontal-content {
height: 100%; border: 1px solid $blue-700;
z-index: 2;
color: $blue;
span {
font-size: 1rem;
font-weight: 500;
}
}
.list-button-horizontal-content:hover {
background-color: $blue-700;
color: $white;
box-shadow: none;
}
input[type="radio"],
input[type="checkbox"] {
position: absolute;
height: 0;
opacity: 0;
width: 0;
&:focus-visible + .list-button-horizontal-content {
border-radius: 0.5rem;
z-index: 2;
}
&:focus + .list-button-horizontal-content {
z-index: 3;
}
&:checked + .list-button-horizontal-content {
outline: none;
box-shadow: none;
color: $white;
background: linear-gradient(84.45deg, #125b7e 0%, #3b8fb8 100%);
border-radius: 0.5rem;
z-index: 5;
}
&:checked:focus + .list-button-horizontal-content {
box-shadow: 0 0 0 3px, 0 0 0 5.5px $blue-700;
}
&:checked + .list-button-horizontal-content p:first-child {
font-weight: 500;
}
}
}
&.list-button-horizontal {
height: 100%;
label {
height: 100%;
}
} }
}
} }
.col { .col {
&:first-of-type { &:first-of-type {
.list-button-horizontal { .list-button-horizontal {
label { .list-button-horizontal-content {
border-bottom-left-radius: 0.5rem; border-bottom-left-radius: 0.5rem;
border-top-left-radius: 0.5rem; border-top-left-radius: 0.5rem;
} }
}
}
&:last-of-type {
.list-button-horizontal {
label {
border-bottom-right-radius: 0.5rem;
border-top-right-radius: 0.5rem;
}
}
}
//Cash/insurance styling
&:first-of-type {
.list-button-horizontal.radio-fancy {
input[type="radio"] {
&:checked+label {
border-bottom-right-radius: 0;
border-top-right-radius: 0;
} }
&:checked:focus+label {
border-bottom-right-radius: 0.5rem;
border-top-right-radius: 0.5rem;
}
}
} }
}
&:last-of-type { &:last-of-type {
.list-button-horizontal.radio-fancy { .list-button-horizontal {
input[type="radio"] { .list-button-horizontal-content {
&:checked+label { border-bottom-right-radius: 0.5rem;
border-bottom-left-radius: 0; border-top-right-radius: 0.5rem;
border-top-left-radius: 0; }
}
}
//Cash/insurance styling
&:first-of-type {
.list-button-horizontal.radio-fancy {
input[type="radio"] {
&:checked + .list-button-horizontal-content {
border-bottom-right-radius: 0;
border-top-right-radius: 0;
}
&:checked:focus + .list-button-horizontal-content {
border-bottom-right-radius: 0.5rem;
border-top-right-radius: 0.5rem;
}
}
}
}
&:last-of-type {
.list-button-horizontal.radio-fancy {
input[type="radio"] {
&:checked + .list-button-horizontal-content {
border-bottom-left-radius: 0;
border-top-left-radius: 0;
}
&:checked:focus + .list-button-horizontal-content {
border-bottom-left-radius: 0.5rem;
border-top-left-radius: 0.5rem;
}
}
} }
&:checked:focus+label {
border-bottom-left-radius: 0.5rem;
border-top-left-radius: 0.5rem;
}
}
} }
}
} }
</style> </style>

View file

@ -1,263 +1,304 @@
import { shallowMount } from "@vue/test-utils"; import { mount } from "@vue/test-utils";
import listButton from "./list-button"; import listButton from "./list-button";
import { nextTick } from "vue";
import { GaActions } from "@/constants/analytics"; import { GaActions } from "@/constants/analytics";
import inputButtonWrapperMixin from "@/mixins/input-button-wrapper-mixin";
describe("list-button.vue", () => { describe("list-button.vue", () => {
it("Should return input type checkbox if isMultiSelect is true", async () => { describe("loader", () => {
// Act it("selectingInitiatesLoad is true and answer is changed => show the loader", async () => {
const wrapper = shallowMount(listButton, { // Act
propsData: { const { wrapper } = setupMocks({
isMultiSelect: true, mockData: {
}, global: {
mocks: {
$route: { query: { fmgPage: "page-name" } },
GaActions: GaActions,
pushEventToGA: jest.fn(),
},
},
propsData: {
selectingInitiatesLoad: true,
},
},
});
// Act
wrapper.vm.selectedValue = "something";
await wrapper.vm.$nextTick();
// Assert
const loader = wrapper.findComponent({ name: "loader" });
expect(loader.exists()).toBe(true);
});
it("Should return loader color", async () => {
// Arrange
const { wrapper } = setupMocks({
mockData: {
global: {
mocks: {
$route: { query: { fmgPage: "page-name" } },
GaActions: GaActions,
pushEventToGA: jest.fn(),
},
},
propsData: {
loaderColor: "blue",
selectingInitiatesLoad: true,
},
},
});
// Act
wrapper.vm.selectedValue = "something";
await wrapper.vm.$nextTick();
// Assert
const loader = wrapper.findComponent({ name: "loader" });
expect(loader.attributes("class")).toContain("blue");
});
it("Should return loader position", async () => {
// Act
const { wrapper } = setupMocks({
mockData: {
global: {
mocks: {
$route: { query: { fmgPage: "page-name" } },
GaActions: GaActions,
pushEventToGA: jest.fn(),
},
},
propsData: {
loaderPosition: "right",
selectingInitiatesLoad: true,
},
},
});
// Act
wrapper.vm.selectedValue = "something";
await wrapper.vm.$nextTick();
// Assert
const loader = wrapper.findComponent({ name: "loader" });
expect(loader.attributes("class")).toContain("right");
});
}); });
// Assert describe("baseInputButton checks", () => {
const input = wrapper.find("input"); it("Should return input type checkbox if isMultiSelect is true", async () => {
// Act
const { wrapper } = setupMocks({
mockData: {
propsData: {
isMultiSelect: true,
},
},
});
expect(input.attributes().type).toEqual("checkbox"); // Assert
}); const input = wrapper.find("input");
it("Should return input type radio if isMultiSelect is false or not specified", async () => { expect(input.attributes().type).toEqual("checkbox");
// Act });
const wrapper = shallowMount(listButton, {
propsData: { it("Should return input type radio if isMultiSelect is false or not specified", async () => {
isMultiSelect: false, // Act
}, const { wrapper } = setupMocks({
mockData: {
propsData: {
isMultiSelect: false,
},
},
});
// Assert
const input = wrapper.find("input");
expect(input.attributes().type).toEqual("radio");
});
it("(Radio) Should emit button value on selectedValue change", async () => {
// Act
const { wrapper } = setupMocks({
mockData: {
propsData: {
isRadioHorizontal: true,
buttonLabel: "Windshield",
value: "List Card Checkbox",
groupID: "radio-demo-1",
groupName: "radio1",
buttonImage: "windshield-damage.svg",
isRequired: true,
isWide: false,
modelValue: "List Card Checkbox",
buttonID: "list-card-id",
},
},
});
// Act
wrapper.vm.selectedValue = "test";
// Assert
expect(wrapper.emitted()["update:modelValue"][0][0]).toEqual("test");
});
it("(Checkbox) Should emit button value on selectedValue change", async () => {
// Act
const { wrapper } = setupMocks({
mockData: {
propsData: {
isRadioHorizontal: true,
buttonLabel: "Windshield",
value: "List Card Checkbox",
groupID: "radio-demo-1",
groupName: "radio 1",
buttonImage: "windshield-damage.svg",
isRequired: true,
isWide: false,
modelValue: "List Card Checkbox",
buttonID: "list-card-id",
isMultiSelect: true,
},
},
});
// Act
wrapper.vm.selectedValue = ["test"];
// Assert
expect(wrapper.emitted()["update:modelValue"][0][0]).toEqual(["test"]);
});
}); });
// Assert describe("styling/UI", () => {
const input = wrapper.find("input"); test("has buttonLabel => displays buttonLabel", () => {
// Arrange/Act
const { wrapper } = setupMocks({
mockData: {
propsData: {
buttonLabel: "Surprise!",
modelValue: "",
groupName: "groupName",
value: "myValue"
},
},
});
expect(input.attributes().type).toEqual("radio"); // Assert
}); const content = wrapper.find(".list-button-content");
expect(content.isVisible()).toBe(true);
expect(content.text()).toContain("Surprise!");
});
it("Should return primary label text (buttonID)", async () => { test("has buttonLabelSubCopy => displays buttonLabelSubCopy", () => {
// Act // Arrange/Act
const wrapper = shallowMount(listButton, { const { wrapper } = setupMocks({
propsData: { mockData: {
buttonID: "List Card Checkbox", propsData: {
}, buttonLabel: "Surprise!",
buttonLabelSubCopy: "Super duper surprise :)",
modelValue: "",
groupName: "groupName",
value: "myValue"
},
},
});
// Assert
const content = wrapper.find(".list-button-content");
expect(content.isVisible()).toBe(true);
expect(content.text()).toContain("Super duper surprise :)");
});
test("has screenReaderOnlyText => displays screenReaderOnlyText", () => {
// Arrange/Act
const { wrapper } = setupMocks({
mockData: {
propsData: {
buttonLabel: "Surprise!",
buttonLabelSubCopy: "Super duper surprise :)",
screenReaderOnlyText: "Tests are fun!",
modelValue: "",
groupName: "groupName",
value: "myValue"
},
},
});
// Assert
const content = wrapper.find(".list-button-content");
const screenReaderOnlyText = wrapper.find(".sr-only");
expect(content.isVisible()).toBe(true);
expect(screenReaderOnlyText.exists()).toBe(true);
expect(screenReaderOnlyText.text()).toContain("Tests are fun!");
});
it("Should return screen reader text", async () => {
// Act
const { wrapper } = setupMocks({
mockData: {
propsData: {
screenReaderOnlyText: "Screen Reader Only Text",
modelValue: "",
groupName: "groupName",
value: "myValue"
},
},
});
// Assert
const paragraph = wrapper.find("span.sr-only");
expect(paragraph.text()).toEqual("Screen Reader Only Text");
});
it("Should return text alignment class", async () => {
// Act
const { wrapper } = setupMocks({
mockData: {
propsData: {
textPosition: "text-center",
modelValue: "",
groupName: "groupName",
value: "myValue"
},
},
});
// Assert
const paragraph = wrapper.find("span.m-0");
expect(paragraph.attributes("class")).toContain("text-center");
});
it("Should return aria-required state", async () => {
// Act
const { wrapper } = setupMocks({
mockData: {
propsData: {
isRequired: true,
groupName: "groupName",
modelValue: "",
value: "myValue",
},
},
});
// Assert
const input = wrapper.find("input");
expect(input.attributes()["aria-required"]).toEqual("true");
});
}); });
// Assert
const label = wrapper.find("label");
expect(label.attributes().for).toEqual("List Card Checkbox");
});
it("Should return screen reader text", async () => {
// Act
const wrapper = shallowMount(listButton, {
propsData: {
screenReaderOnlyText: "Screen Reader Only Text",
},
});
// Assert
const paragraph = wrapper.find("span.sr-only");
expect(paragraph.text()).toEqual("Screen Reader Only Text");
});
it("Should return text alignment class", async () => {
// Act
const wrapper = shallowMount(listButton, {
propsData: {
textPosition: "text-center",
},
});
// Assert
const paragraph = wrapper.find("span.m-0");
expect(paragraph.attributes("class")).toContain("text-center");
});
it("Should return aria-required state", async () => {
// Act
const wrapper = shallowMount(listButton, {
propsData: {
isRequired: true,
},
});
// Assert
const input = wrapper.find("input");
expect(input.attributes()["aria-required"]).toEqual("true");
});
it("Should return loader enabled true", async () => {
// Act
const wrapper = shallowMount(listButton, {
global: {
mocks: {
'$route': { query: { fmgPage: 'page-name' } },
GaActions: GaActions,
pushEventToGA: jest.fn(),
}
},
propsData: {
selectingInitiatesLoad: true,
},
});
// Assert
wrapper.vm.handleCheckChange = jest.fn();
wrapper.vm.triggerButton();
await nextTick();
const loader = wrapper.find("loader-stub");
expect(loader.exists()).toBe(true);
});
it("Should return loader color", async () => {
// Act
const wrapper = shallowMount(listButton, {
global: {
mocks: {
'$route': { query: { fmgPage: 'page-name' } },
GaActions: GaActions,
pushEventToGA: jest.fn(),
}
},
propsData: {
loaderColor: "blue",
selectingInitiatesLoad: true,
},
});
// Assert
wrapper.vm.handleCheckChange = jest.fn();
wrapper.vm.triggerButton();
await nextTick();
const loader = wrapper.find("loader-stub");
expect(loader.attributes("class")).toContain("blue");
});
it("Should return loader position", async () => {
// Act
const wrapper = shallowMount(listButton, {
global: {
mocks: {
'$route': { query: { fmgPage: 'page-name' } },
GaActions: GaActions,
pushEventToGA: jest.fn(),
}
},
propsData: {
loaderPosition: "right",
selectingInitiatesLoad: true,
},
});
// Assert
wrapper.vm.handleCheckChange = jest.fn();
wrapper.vm.triggerButton();
await nextTick();
const loader = wrapper.find("loader-stub");
expect(loader.attributes("class")).toContain("right");
});
it("Should emit button value on click", async () => {
// Act
const wrapper = shallowMount(listButton, {
propsData: {
isRadioHorizontal: true,
buttonLabel: "Windshield",
value: "List Card Checkbox",
groupID: "radio-demo-1",
groupName: "radio 1",
buttonImage: "windshield-damage.svg",
isRequired: true,
isWide: false,
modelValue: ["List Card Checkbox"],
buttonID: 'list-card-id'
},
});
wrapper.vm.handleCheckChange();
// Assert
expect(wrapper.emitted()["isCheckedChanged"][0]).toEqual([{value: "List Card Checkbox", checkValue: false, buttonId: 'list-card-id'}]);
});
it("Should set checkValue data if selectedButtonIDs has value(s)", async () => {
// Act
const wrapper = shallowMount(listButton, {
propsData: {
isRadioHorizontal: true,
buttonLabel: "Windshield",
buttonID: "List Card Checkbox",
groupID: "radio-demo-1",
groupName: "radio 1",
buttonImage: "windshield-damage.svg",
isRequired: true,
isWide: false,
modelValue: ["List Card Checkbox"],
selectedValues: "Car-Front"
},
});
// Assert
expect(wrapper.componentVM.checkValue).toEqual(false);
});
it("Should run handleCheckChange if selectingInitiatesLoad is false and handleInputChange is triggered", async () => {
// Act
const wrapper = shallowMount(listButton, {
propsData: {
selectingInitiatesLoad: false,
},
});
// Assert
wrapper.vm.handleInputChange();
await nextTick();
expect(wrapper.vm.handleCheckChange).toBeCalled;
});
it("Should do nothing if isMultiSelect is true and handleKeyupArrow is triggered", async () => {
// Act
const wrapper = shallowMount(listButton, {
propsData: {
isMultiSelect: true,
},
});
// Assert
wrapper.vm.handleKeyupArrow();
await nextTick();
expect(wrapper.vm.handleKeyupArrow).toHaveReturned;
});
it("Should run handleCheckChange if selectingInitiatesLoad is false and handleKeyupArrow is triggered", async () => {
// Act
const wrapper = shallowMount(listButton, {
propsData: {
selectingInitiatesLoad: false,
isMultiSelect: false,
},
});
// Assert
wrapper.vm.handleKeyupArrow();
await nextTick();
expect(wrapper.vm.handleCheckChange).toBeCalled;
});
}); });
function setupMocks({ mockData }) {
const wrapper = mount(listButton, {
...mockData,
mixins: [inputButtonWrapperMixin],
});
return { wrapper };
}

View file

@ -1,242 +1,130 @@
<template> <template>
<div <baseInputButton
class="list-group list-button rounded-3 d-flex flex-column w-100 mb-2" v-bind="$props"
:class="[(errors.length > 0 || hasError) ? 'has-error' : '']" buttonWrapperClasses="list-group list-button rounded-3 d-flex flex-column w-100 mb-2"
@keyup.space="triggerButton" v-model="selectedValue">
@keyup.enter="triggerButton" <div
@keyup.up="handleKeyupArrow" :aria-label="buttonLabel"
@keyup.down="handleKeyupArrow" class="list-button-content d-flex flex-column justify-content-center py-3 px-4">
@keyup.left="handleKeyupArrow" <span class="m-0" :class="textPosition">
@keyup.right="handleKeyupArrow"> {{ buttonLabel }}
<input </span>
:type="isMultiSelect ? 'checkbox' : 'radio'" <span
:id="buttonID" v-if="buttonLabelSubCopy"
:name="groupName" class="m-0 small"
:value="value" :class="textPosition">
:aria-required="isRequired" {{ buttonLabelSubCopy }}
v-model="checkValue" </span>
:checked="checkValue" <span v-if="screenReaderOnlyText" class="sr-only">
@change="handleInputChange" {{ screenReaderOnlyText }}
> </span>
<label <loader
tabindex="-1" v-if="isLoaderDisplayed && selectingInitiatesLoad"
:for="buttonID" :class="[this.loaderColor, this.loaderPosition]" />
:aria-label="buttonLabel" </div>
class="d-flex flex-column justify-content-center py-3 px-4" </baseInputButton>
@mouseup="triggerButton"
>
<span
class="m-0"
:class="textPosition"
>
{{ buttonLabel }}
</span>
<span
v-if="buttonLabelSubCopy"
class="m-0 small"
:class="textPosition"
>
{{ buttonLabelSubCopy }}
</span>
<span
v-if="screenReaderOnlyText"
class="sr-only"
>
{{ screenReaderOnlyText }}
</span>
<loader
v-if="isLoaderDisplayed && selectingInitiatesLoad"
:class="[this.loaderColor, this.loaderPosition]"
/>
</label>
</div>
</template> </template>
<script> <script>
import { useField } from "vee-validate";
import { toRef } from "vue";
import loader from "@/ux-components/loader/loader"; import loader from "@/ux-components/loader/loader";
import { queryStrings } from "@/constants/query-strings"; import baseInputButton from "@/common-components/base-input-button/base-input-button";
import inputButtonWrapperMixin from "@/mixins/input-button-wrapper-mixin";
export default { export default {
name: "listButton", name: "listButton",
props: { mixins: [inputButtonWrapperMixin],
isMultiSelect: Boolean, props: {
groupName: String, selectingInitiatesLoad: Boolean,
buttonLabel: [Number, String], loaderColor: String,
buttonID: [Number, String], loaderPosition: {
isRequired: Boolean, type: String,
textPosition: String, default: "right",
buttonLabelSubCopy: String, },
screenReaderOnlyText: String,
selectingInitiatesLoad: Boolean,
loaderColor: String,
loaderPosition: String,
value: {
// Field initial value
type: [String, Number],
default: "",
},
validationRules: String,
selectedValues: [Array, String],
hasError: Boolean,
valueToLogType: String,
},
data() {
return {
isLoaderDisplayed: false,
checkValue: false,
};
},
mounted() {
if (Array.isArray(this.validateValue)) {
this.checkValue = this.isValueSelectedByArray(this.selectedValues);
const isSelectedByValidator = this.isValueSelectedByArray(this.validateValue);
if (this.checkValue != isSelectedByValidator) {
this.handleChange(this.value);
}
}
else {
this.checkValue = this.selectedValues == this.value;
}
},
methods: {
isValueSelectedByArray(arr) {
return this.isMultiSelect
? arr.includes(this.value)
: arr[0];
}, },
displayLoader() { data() {
this.isLoaderDisplayed = true; return {
isLoaderDisplayed: false,
};
}, },
handleInputChange() { methods: {
if(!this.selectingInitiatesLoad) { displayLoader() {
this.handleCheckChange(); this.isLoaderDisplayed = true;
} },
preHandleAnswerChange() {
if (this.selectingInitiatesLoad) {
this.displayLoader();
}
},
}, },
handleKeyupArrow() { components: {
if (this.isMultiSelect) { loader,
return; // Prevent arrow keys from doing anything if element is a checkbox baseInputButton,
}
}, },
triggerButton() {
if(this.selectingInitiatesLoad) {
this.displayLoader();
this.handleCheckChange();
}
this.pushEventToGA(this.$route.query[queryStrings.FMG_PAGE], this.GaActions.CLICKED, this.value.toString(), true, this.valueToLogType);
},
handleCheckChange() {
const emitEvent = {
checkValue: this.checkValue, // only read on checkboxes, on handleCheckedChanged on button-question
value: this.value.toString(),
buttonId: this.buttonID && this.buttonID.toString(),
};
this.handleChange(this.value);
this.$emit("isCheckedChanged", emitEvent);
this.$emit("update:modelValue", emitEvent);
},
},
components: {
loader,
},
setup(props) {
const inputType = props.isMultiSelect ? "checkbox" : "radio";
const fieldOptions = {
type: inputType,
checkedValue: props.value,
potentialInitialValue: props.selectedValues,
};
// Set initialValue for validation setup if pre-selected
// NOTE: props.selectedValues could be an array of strings, or an array of integers...
if (props.selectedValues && (props.selectedValues.includes(props.value) || props.selectedValues.includes(parseInt(props.value)))) {
fieldOptions['initialValue'] = fieldOptions.potentialInitialValue;
}
const {
handleChange,
errors,
value
} = useField(toRef(props, "groupName"), toRef(props, "validationRules"), fieldOptions);
const validateValue = value;
return {
handleChange,
errors,
validateValue,
fieldOptions, // only need to expose this for unit test purposes
};
},
}; };
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.list-group { .list-group {
&.list-button { .loader {
outline: none; position: absolute;
input[type="radio"], }
input[type="checkbox"] { &.list-button {
position: static; //override bootstrap outline: none;
height: 0; input[type="radio"],
opacity: 0; input[type="checkbox"] {
position: static; //override bootstrap
&:focus-visible + label { &:focus-visible + .list-button-content {
box-shadow: 0 0 0 2.5px $blue; box-shadow: 0 0 0 2.5px $blue;
} }
&:focus + label { &:focus + .list-button-content {
box-shadow: 0 0 0 2.5px $blue; box-shadow: 0 0 0 2.5px $blue;
} }
&:checked + label { &:checked + .list-button-content {
color: $black; color: $black;
font-weight: 500; font-weight: 500;
background: $blue-100; background: $blue-100;
box-shadow: 0 0 0 1px $blue; box-shadow: 0 0 0 1px $blue;
} }
&:checked:focus + label { &:checked:focus + .list-button-content {
box-shadow: 0 0 0 2.5px $blue; box-shadow: 0 0 0 2.5px $blue;
} }
&:checked + label p, &:checked + .list-button-content p,
&:checked + label span { &:checked + .list-button-content span {
font-weight: 500; font-weight: 500;
} }
&:checked + label span:nth-child(2) { &:checked + .list-button-content span:nth-child(2) {
font-weight: 400; font-weight: 400;
color: $gray-600;
}
}
}
.list-button-content {
color: $gray-600; color: $gray-600;
} position: relative;
} background: $white;
} transition: all 150ms linear;
label { border-radius: $border-radius-lg;
color: $gray-600; border: 1px solid $gray-500;
position: relative; width: 100%;
background: $white; outline: none;
transition: all 150ms linear;
border-radius: $border-radius-lg;
border: 1px solid $gray-500;
width: 100%;
outline: none;
span { span {
&.small { &.small {
font-size: .75rem; font-size: 0.75rem;
color: $gray-550; color: $gray-550;
} }
} }
&:hover { &:hover {
@include media-breakpoint-up(sm) { @include media-breakpoint-up(sm) {
box-shadow: 0 0 0 4px $blue-300; box-shadow: 0 0 0 4px $blue-300;
} }
cursor: pointer; cursor: pointer;
}
+ p {
display: none;
}
} }
+ p {
display: none;
}
}
} }
</style> </style>

View file

@ -1,12 +1,12 @@
import { shallowMount } from "@vue/test-utils"; import { mount } from "@vue/test-utils";
import listCard from "./list-card"; import listCard from "./list-card";
import { nextTick } from "vue"; import { nextTick } from "vue";
import { GaActions } from "@/constants/analytics"; import { GaActions } from "@/constants/analytics";
describe("list-card.vue", () => { describe("list-card.vue", () => {
it("Should return input type checkbox if isMultiSelect is true", async () => { it("Should return input type checkbox if isMultiSelect is true", () => {
// Act // Act
const wrapper = shallowMount(listCard, { const wrapper = mount(listCard, {
propsData: { propsData: {
isMultiSelect: true, isMultiSelect: true,
buttonLabel: "Windshield", buttonLabel: "Windshield",
@ -14,6 +14,7 @@ describe("list-card.vue", () => {
groupID: "checkbox-demo-1", groupID: "checkbox-demo-1",
groupName: "Checkbox 1", groupName: "Checkbox 1",
buttonImage: "windshield-damage.svg", buttonImage: "windshield-damage.svg",
value: "test value",
}, },
}); });
@ -22,9 +23,9 @@ describe("list-card.vue", () => {
expect(input.attributes().type).toEqual("checkbox"); expect(input.attributes().type).toEqual("checkbox");
}); });
it("Should return primary label text", async () => { it("Should return primary label text", () => {
// Act // Act
const wrapper = shallowMount(listCard, { const wrapper = mount(listCard, {
propsData: { propsData: {
isRadioHorizontal: true, isRadioHorizontal: true,
buttonLabel: "Windshield", buttonLabel: "Windshield",
@ -32,6 +33,7 @@ describe("list-card.vue", () => {
groupID: "radio-demo-1", groupID: "radio-demo-1",
groupName: "radio 1", groupName: "radio 1",
buttonImage: "windshield-damage.svg", buttonImage: "windshield-damage.svg",
value: "test value",
}, },
}); });
@ -40,9 +42,9 @@ describe("list-card.vue", () => {
expect(paragraph.text()).toEqual("Windshield"); expect(paragraph.text()).toEqual("Windshield");
}); });
it("Should return secondary (sub) label text", async () => { it("Should return secondary (sub) label text", () => {
// Act // Act
const wrapper = shallowMount(listCard, { const wrapper = mount(listCard, {
propsData: { propsData: {
isRadioHorizontal: true, isRadioHorizontal: true,
buttonLabel: "Windshield", buttonLabel: "Windshield",
@ -51,6 +53,7 @@ describe("list-card.vue", () => {
groupName: "radio 1", groupName: "radio 1",
buttonImage: "windshield-damage.svg", buttonImage: "windshield-damage.svg",
buttonLabelSubCopy: "Test", buttonLabelSubCopy: "Test",
value: "test value",
}, },
}); });
@ -59,28 +62,9 @@ describe("list-card.vue", () => {
expect(paragraph.text()).toEqual("Test"); expect(paragraph.text()).toEqual("Test");
}); });
it("Should return value used for various text settings including the label 'for' and input id", async () => { it("Should return input group name used for radio or checkbox", () => {
// Act // Act
const wrapper = shallowMount(listCard, { const wrapper = mount(listCard, {
propsData: {
isRadioHorizontal: true,
buttonLabel: "Windshield",
buttonID: "List Card Checkbox",
groupID: "radio-demo-1",
groupName: "radio 1",
buttonImage: "windshield-damage.svg",
buttonLabelSubCopy: "Test",
},
});
// Assert
const label = wrapper.find("label");
expect(label.attributes().for).toEqual("List Card Checkbox");
});
it("Should return input group name used for radio or checkbox", async () => {
// Act
const wrapper = shallowMount(listCard, {
propsData: { propsData: {
isRadioHorizontal: true, isRadioHorizontal: true,
buttonLabel: "Windshield", buttonLabel: "Windshield",
@ -89,6 +73,7 @@ describe("list-card.vue", () => {
groupName: "radio 1", groupName: "radio 1",
buttonImage: "windshield-damage.svg", buttonImage: "windshield-damage.svg",
buttonLabelSubCopy: "Test", buttonLabelSubCopy: "Test",
value: "test value",
}, },
}); });
@ -97,9 +82,9 @@ describe("list-card.vue", () => {
expect(input.attributes().name).toEqual("radio 1"); expect(input.attributes().name).toEqual("radio 1");
}); });
it("Should return aria-required state", async () => { it("Should return aria-required state", () => {
// Act // Act
const wrapper = shallowMount(listCard, { const wrapper = mount(listCard, {
propsData: { propsData: {
isRadioHorizontal: true, isRadioHorizontal: true,
buttonLabel: "Windshield", buttonLabel: "Windshield",
@ -108,6 +93,7 @@ describe("list-card.vue", () => {
groupName: "radio 1", groupName: "radio 1",
buttonImage: "windshield-damage.svg", buttonImage: "windshield-damage.svg",
isRequired: true, isRequired: true,
value: "test value",
}, },
}); });
@ -116,9 +102,9 @@ describe("list-card.vue", () => {
expect(input.attributes()["aria-required"]).toEqual("true"); expect(input.attributes()["aria-required"]).toEqual("true");
}); });
it("Should return flex row classes if isWide is true", async () => { it("Should return flex row classes if isWide is true", () => {
// Act // Act
const wrapper = shallowMount(listCard, { const wrapper = mount(listCard, {
propsData: { propsData: {
isRadioHorizontal: true, isRadioHorizontal: true,
buttonLabel: "Windshield", buttonLabel: "Windshield",
@ -129,17 +115,21 @@ describe("list-card.vue", () => {
isRequired: true, isRequired: true,
isWide: true, isWide: true,
buttonLabelSubCopy: "", buttonLabelSubCopy: "",
value: "test value",
}, },
}); });
// Assert // Assert
const label = wrapper.find("label"); const label = wrapper.find(".list-card-content");
expect(label.classes()).toEqual(["d-flex", "w-100", "align-items-center", "px-2", "h-100", "flex-row", "py-2", "ps-4", "pe-4"]); expect(label.exists()).toBe(true);
const labelClasses = wrapper.vm.labelClasses;
expect(labelClasses).toContain("flex-row");
expect(label.classes()).toContain("flex-row");
}); });
it("Should return flex row classes if isWide is true and checkboxTop if buttonLabelSubCopy is true", async () => { it("Should return flex row classes if isWide is true and checkboxTop if buttonLabelSubCopy is provided", () => {
// Act // Act
const wrapper = shallowMount(listCard, { const wrapper = mount(listCard, {
propsData: { propsData: {
isRadioHorizontal: true, isRadioHorizontal: true,
buttonLabel: "Windshield", buttonLabel: "Windshield",
@ -150,17 +140,23 @@ describe("list-card.vue", () => {
isRequired: true, isRequired: true,
isWide: true, isWide: true,
buttonLabelSubCopy: "Button Subcopy", buttonLabelSubCopy: "Button Subcopy",
value: "test value",
}, },
}); });
// Assert // Assert
const label = wrapper.find("label"); const label = wrapper.find(".list-card-content");
expect(label.classes()).toEqual(["d-flex", "w-100", "align-items-center", "px-2", "h-100", "flex-row", "py-2", "ps-4", "pe-4", "checkboxTop"]); expect(label.exists()).toBe(true);
const labelClasses = wrapper.vm.labelClasses;
expect(labelClasses).toContain("flex-row");
expect(label.classes()).toContain("flex-row");
expect(labelClasses).toContain("checkboxTop");
expect(label.classes()).toContain("checkboxTop");
}); });
it("Should return flex column classes if isWide is false", async () => { it("Should return flex column classes if isWide is false", () => {
// Act // Act
const wrapper = shallowMount(listCard, { const wrapper = mount(listCard, {
propsData: { propsData: {
isRadioHorizontal: true, isRadioHorizontal: true,
buttonLabel: "Windshield", buttonLabel: "Windshield",
@ -170,165 +166,15 @@ describe("list-card.vue", () => {
buttonImage: "windshield-damage.svg", buttonImage: "windshield-damage.svg",
isRequired: true, isRequired: true,
isWide: false, isWide: false,
value: "test value",
}, },
}); });
// Assert // Assert
const label = wrapper.find("label"); const label = wrapper.find(".list-card-content");
expect(label.classes()).toEqual(["d-flex", "w-100", "align-items-center", "px-2", "h-100", "flex-column", "pt-4", "pb-3"]); expect(label.exists()).toBe(true);
const labelClasses = wrapper.vm.labelClasses;
expect(labelClasses).toContain("flex-column");
expect(label.classes()).toContain("flex-column");
}); });
it("Should emit button value on click", async () => {
// Act
const wrapper = shallowMount(listCard, {
propsData: {
isRadioHorizontal: true,
buttonLabel: "Windshield",
value: "List Card Checkbox",
groupID: "radio-demo-1",
groupName: "radio 1",
buttonImage: "windshield-damage.svg",
isRequired: true,
isWide: false,
buttonID: 'list-card-id',
selectedValues: "List Card Checkbox"
},
});
wrapper.vm.handleCheckChange();
// Assert
expect(wrapper.emitted()["isCheckedChanged"][0]).toEqual([{value: "List Card Checkbox", checkValue: true, buttonId: 'list-card-id'}]);
});
it("Should set checkValue data if selectedButtonIDs has value(s)", async () => {
// Act
const wrapper = shallowMount(listCard, {
propsData: {
isRadioHorizontal: true,
buttonLabel: "Windshield",
value: "List Card Checkbox",
groupID: "radio-demo-1",
groupName: "radio 1",
buttonImage: "windshield-damage.svg",
isRequired: true,
isWide: false,
selectedValues: "Car-Front"
},
});
// Assert
expect(wrapper.componentVM.checkValue).toEqual(false);
});
it("Should set an initial value for validation if selectedValues include the value", async () => {
// Arrange
const wrapper = shallowMount(listCard, {
propsData: {
value: "Windshield",
groupName: "radio 1",
selectedValues: ["Windshield"],
},
});
// Assert
expect(wrapper.vm.fieldOptions.initialValue).toEqual([ 'Windshield' ]);
});
it("Should run handleCheckChange if selectingInitiatesLoad is false and handleInputChange is triggered", async () => {
// Act
const wrapper = shallowMount(listCard, {
propsData: {
selectingInitiatesLoad: false,
},
});
// Assert
wrapper.vm.handleInputChange();
await nextTick();
expect(wrapper.vm.handleCheckChange).toBeCalled;
});
it("Should do nothing if isMultiSelect is true and handleKeyupArrow is triggered", async () => {
// Act
const wrapper = shallowMount(listCard, {
propsData: {
isMultiSelect: true,
},
});
// Assert
wrapper.vm.handleKeyupArrow();
await nextTick();
expect(wrapper.vm.handleKeyupArrow).toHaveReturned;
});
it("Should run handleCheckChange if selectingInitiatesLoad is false and handleKeyupArrow is triggered", async () => {
// Act
const wrapper = shallowMount(listCard, {
propsData: {
selectingInitiatesLoad: false,
isMultiSelect: false,
},
});
// Assert
wrapper.vm.handleKeyupArrow();
await nextTick();
expect(wrapper.vm.handleCheckChange).toBeCalled;
});
it("Should run handleChange if triggerButton is triggered", async () => {
// Act
const wrapper = shallowMount(listCard, {
global: {
mocks: {
'$route': { query: { fmgPage: 'page-name' } },
GaActions: GaActions,
pushEventToGA: jest.fn(),
}
},
propsData: {
selectingInitiatesLoad: false,
},
});
// Assert
wrapper.vm.triggerButton();
await nextTick();
expect(wrapper.vm.handleChange).toBeCalled;
expect(wrapper.vm.handleCheckChange).not.toBeCalled;
expect(wrapper.vm.displayLoader).not.toBeCalled;
});
it("Should run handleCheckChange and displayLoader if triggerButton is triggered and seletingInitiatesLoad is true", async () => {
// Act
const wrapper = shallowMount(listCard, {
global: {
mocks: {
'$route': { query: { fmgPage: 'page-name' } },
GaActions: GaActions,
pushEventToGA: jest.fn(),
}
},
propsData: {
selectingInitiatesLoad: true,
},
});
// Assert
wrapper.vm.triggerButton();
await nextTick();
expect(wrapper.vm.handleCheckChange).toBeCalled;
expect(wrapper.vm.displayLoader).toBeCalled;
});
}); });

View file

@ -1,400 +1,252 @@
<template> <template>
<div :class="{'h-100': !isWide}"> <baseInputButton
<div v-bind="$props"
class="list-card w-100 rounded-3 d-flex align-items-center" :buttonWrapperClasses="[
:class="[ 'list-card w-100 rounded-3 d-flex align-items-center h-100',
'h-100', { horizontal: isWide },
isWide ? 'horizontal' : '', ]"
(errors.length > 0 || hasError) ? 'has-error' : '', v-model="selectedValue">
]" <div
@keyup.space="triggerButton" class="d-flex w-100 align-items-center px-2 h-100 list-card-content"
@keyup.up="handleKeyupArrow" :class="labelClasses">
@keyup.down="handleKeyupArrow" <img
@keyup.left="handleKeyupArrow" :id="buttonImageId"
@keyup.right="handleKeyupArrow" :class="!isWide ? 'order-1' : 'ms-auto order-3'"
> :src="buttonImage"
<input :alt="altText" />
:type="isMultiSelect ? 'checkbox' : 'radio'" <p
:id="buttonID" v-if="!isWide"
:name="groupName" class="small order-3"
:value="value" :class="isMultiSelect ? 'm-0' : 'mt-2 mb-0'">
:aria-required="isRequired" {{ buttonLabel }}
v-model="checkValue" </p>
:checked="checkValue" <p
@change="handleInputChange" v-if="buttonLabelSubCopy && !isWide"
/> class="fs-7 m-0 order-4 sub-copy">
<label {{ buttonLabelSubCopy }}
tabindex="-1" </p>
:for="buttonID" <div v-if="isWide" class="order-2">
:aria-label="buttonLabel" <p class="m-0 small">{{ buttonLabel }}</p>
class="d-flex w-100 align-items-center px-2 h-100" <p v-if="buttonLabelSubCopy" class="m-0 fs-7 sub-copy">
:class="getLabelClasses" {{ buttonLabelSubCopy }}
@mouseup="triggerButton" </p>
> </div>
<img
:id="buttonImageId"
:class="!isWide ? 'order-1' : 'ms-auto order-3'"
:src="buttonImage"
:alt="altText"
/>
<p
v-if="!isWide"
class="small order-3"
:class="isMultiSelect ? 'm-0' : 'mt-2 mb-0'"
>
{{ buttonLabel }}
</p>
<p
v-if="buttonLabelSubCopy && !isWide"
class="fs-7 m-0 order-4 sub-copy"
>
{{ buttonLabelSubCopy }}
</p>
<div v-if="isWide" class="order-2">
<p class="m-0 small">{{ buttonLabel }}</p>
<p v-if="buttonLabelSubCopy" class="m-0 fs-7 sub-copy">
{{ buttonLabelSubCopy }}
</p>
</div> </div>
</label> </baseInputButton>
</div>
</div>
</template> </template>
<script> <script>
import { useField } from "vee-validate"; import baseInputButton from "@/common-components/base-input-button/base-input-button";
import { toRef } from "vue"; import inputButtonWrapperMixin from "@/mixins/input-button-wrapper-mixin";
import { queryStrings } from "@/constants/query-strings";
export default { export default {
name: "listCard", name: "listCard",
props: { mixins: [inputButtonWrapperMixin],
isMultiSelect: Boolean, //Defines use as checkbox components: {
isWide: Boolean, baseInputButton,
buttonImage: String, //Required: File name of image
buttonImageId: String,
buttonLabel: String, //Required: Label text
isRequired: Boolean, //Required: is aria-required required or not?
altText: String, //Leave empty. Screen readers read the buttonLabel text. If alt has content, it will repeat unnecessarily.
buttonID: String, //Required: Unique
groupName: String, //Rquired: Unique
buttonLabelSubCopy: String, //Optional: sub text
value: {
// Field initial value
type: String,
default: "",
}, },
colLength: String, computed: {
validationRules: String, labelClasses() {
selectedValues: [Array, String], if (this.isWide) {
hasError: Boolean, let classes = "flex-row py-2 ps-4 pe-4";
valueToLogType: String, if (this.buttonLabelSubCopy) {
}, classes += " checkboxTop";
data() { }
return { return classes;
checkValue: null, } else {
} return "flex-column pt-4 pb-3";
}, }
mounted() { },
if (Array.isArray(this.validateValue)) {
this.checkValue = this.isValueSelectedByArray(this.selectedValues);
const isSelectedByValidator = this.isValueSelectedByArray(this.validateValue);
if (this.checkValue != isSelectedByValidator) {
this.handleChange(this.value);
}
}
else {
this.checkValue = this.selectedValues == this.value;
}
},
computed: {
getLabelClasses() {
if (this.isWide) {
let classes = "flex-row py-2 ps-4 pe-4";
if (this.buttonLabelSubCopy) {
classes += " checkboxTop";
}
return classes;
} else {
return "flex-column pt-4 pb-3";
}
}, },
},
methods: {
isValueSelectedByArray(arr) {
return this.isMultiSelect
? arr.includes(this.value)
: arr[0];
},
handleInputChange() {
if(!this.selectingInitiatesLoad) {
this.handleCheckChange();
}
},
handleKeyupArrow() {
if (this.isMultiSelect) {
return; // Prevent arrow keys from doing anything if element is a checkbox
}
if(!this.selectingInitiatesLoad) {
this.handleCheckChange();
}
},
triggerButton() {
if(this.selectingInitiatesLoad) {
this.displayLoader();
this.handleCheckChange();
}
this.pushEventToGA(this.$route.query[queryStrings.FMG_PAGE], this.GaActions.CLICKED, this.value.toString(), true, this.valueToLogType);
},
handleCheckChange() {
const emitEvent = {
checkValue: this.checkValue, // only read on checkboxes, on handleCheckedChanged on button-question
value: this.value.toString(),
buttonId: this.buttonID && this.buttonID.toString(),
};
this.handleChange(this.value);
this.$emit("isCheckedChanged", emitEvent);
},
},
watch: {
// Changing this will impact pre-selection data loads on vehicle-parts.
// If changed, please regression test that vehicle-parts data still loads correctly with previous selections.
selectedValues(newVal) {
if (typeof newVal === "string") {
this.checkValue = newVal == this.value;
}
else if (newVal !== undefined) {
this.checkValue = newVal.value;
}
},
},
setup(props) {
const inputType = props.isMultiSelect ? "checkbox" : "radio";
const fieldOptions = {
type: inputType,
checkedValue: props.value, // EX: "Single" or "Passenger"
potentialInitialValue: props.selectedValues,
};
// Set initialValue for validation setup if pre-selected
// NOTE: props.selectedValues could be an array of strings, or an array of integers...
if (props.selectedValues && (props.selectedValues.includes(props.value) || props.selectedValues.includes(parseInt(props.value)))) {
fieldOptions['initialValue'] = fieldOptions.potentialInitialValue;
}
const {
handleChange,
errors,
value
} = useField(toRef(props, "groupName"), toRef(props, "validationRules"), fieldOptions);
// First land on the blank, unselected page, no handleChange
// Land on page with initial values, handleChange
const validateValue = value;
return {
handleChange,
errors,
validateValue,
fieldOptions, // only need to expose this for unit test purposes
};
},
}; };
</script> </script>
<style lang="scss"> <style lang="scss">
.list-card { .list-card {
border: 1px solid $gray-500; border: 1px solid $gray-500;
&.invalid { &.has-error {
//Red border if invalid //Red border if invalid
border: 1px solid $red; border: 1px solid $red;
}
img {
// svg's should be constructed on the same canvas size/viewbox to ensure they occupy the same space in the DOM. This will allow easy/proper alignment of elements. See exisitng svg's for examples.
height: auto;
width: 6.5rem;
margin-bottom: 2.2rem;
max-width: 100%;
}
&:hover {
@include media-breakpoint-up(sm) {
box-shadow: 0px 0px 0px 4px $blue-300;
border: 1px solid transparent;
}
}
input[type="checkbox"],
input[type="radio"] {
opacity: 0;
width: 0;
height: 0.1px; // NOTE: cannot be zero or safari can't put focus on it
position: absolute;
+ label {
outline: none;
display: block;
position: relative;
&:hover {
cursor: pointer;
}
p {
color: $gray-600;
text-align: center;
&.sub-copy {
color: $gray-550;
}
}
} }
&:checked + label {
background: $blue-100;
box-shadow: 0 0 0 1px $blue;
border-radius: 0.5rem;
}
&:focus-visible + label {
box-shadow: 0 0 0 2.5px $blue;
border-radius: 0.5rem;
}
&:focus + label {
box-shadow: 0 0 0 2.5px $blue;
border-radius: 0.5rem;
}
&:checked:focus + label {
box-shadow: 0 0 0 2.5px $blue;
}
&:checked + label {
p {
color: $black;
font-weight: 500;
}
.sub-copy {
color: $gray-600;
font-weight: 400;
}
}
+ label::before {
content: "";
position: absolute;
display: flex;
margin: 0 auto;
width: 1rem;
height: 1rem;
margin: 3rem 0 0 0;
background: white;
border: 1px solid $gray-500;
border-radius: 2px;
order: 2;
flex-shrink: 0;
color: $gray-600;
}
+ label.checkboxTop::before {
margin: -1.25rem 0.5rem 0 0 !important;
}
+ label.checkboxTop::after {
margin: -1.5rem 0.5rem 0 0 !important;
}
&:checked + label::before {
background: $blue;
}
&:checked + label::after {
content: "";
position: absolute;
margin: 3.2rem 0 0 0;
border-left: 2px solid $white;
border-bottom: 2px solid $white;
height: 6px;
width: 11px;
transform: rotate(-45deg);
z-index: 1;
}
}
input[type="radio"] {
+ label::before {
content: "";
display: none;
}
+ label::after {
content: "";
display: none;
}
+ label {
img {
margin-bottom: 0;
}
}
}
&.horizontal {
img { img {
margin-bottom: 0; // svg's should be constructed on the same canvas size/viewbox to ensure they occupy the same space in the DOM. This will allow easy/proper alignment of elements. See exisitng svg's for examples.
width: 5.5rem; height: auto;
width: 6.5rem;
margin-bottom: 2.2rem;
max-width: 100%;
}
&:hover {
@include media-breakpoint-up(sm) {
box-shadow: 0px 0px 0px 4px $blue-300;
border: 1px solid transparent;
}
} }
input[type="checkbox"], input[type="checkbox"],
input[type="radio"] { input[type="radio"] {
+ label::before { position: absolute;
content: "";
position: relative;
margin: 0 0.5rem 0 0;
order: 1;
}
&:checked + label::after { + .list-card-content {
content: ""; outline: none;
margin: -0.15rem 0 0 0; display: block;
left: 1.175rem; position: relative;
}
&:checked + label { &:hover {
p { cursor: pointer;
color: $black; }
font-weight: 500;
p {
color: $gray-600;
text-align: center;
&.sub-copy {
color: $gray-550;
}
}
} }
.sub-copy { &:checked + .list-card-content {
color: $gray-600; background: $blue-100;
font-weight: 400; box-shadow: 0 0 0 1px $blue;
border-radius: 0.5rem;
} }
} &:focus-visible + .list-card-content {
box-shadow: 0 0 0 2.5px $blue;
border-radius: 0.5rem;
}
&:focus + .list-card-content {
box-shadow: 0 0 0 2.5px $blue;
border-radius: 0.5rem;
}
&:checked:focus + .list-card-content {
box-shadow: 0 0 0 2.5px $blue;
}
&:checked + .list-card-content {
p {
color: $black;
font-weight: 500;
}
+ label { .sub-copy {
outline: none; color: $gray-600;
min-height: 48px; font-weight: 400;
color: $gray-600; }
img {
margin-bottom: 0;
} }
p { + .list-card-content::before {
color: $gray-600; content: "";
text-align: left; position: absolute;
display: flex;
&.sub-copy { margin: 0 auto;
color: $gray-550; width: 1rem;
} height: 1rem;
margin: 3rem 0 0 0;
background: white;
border: 1px solid $gray-500;
border-radius: 2px;
order: 2;
flex-shrink: 0;
color: $gray-600;
}
+ .list-card-content.checkboxTop::before {
margin: -1.25rem 0.5rem 0 0 !important;
}
+ .list-card-content.checkboxTop::after {
margin: -1.5rem 0.5rem 0 0 !important;
}
&:checked + .list-card-content::before {
background: $blue;
}
&:checked + .list-card-content::after {
content: "";
position: absolute;
margin: 3.2rem 0 0 0;
border-left: 2px solid $white;
border-bottom: 2px solid $white;
height: 6px;
width: 11px;
transform: rotate(-45deg);
z-index: 1;
}
}
input[type="radio"] {
+ .list-card-content::before {
content: "";
display: none;
}
+ .list-card-content::after {
content: "";
display: none;
}
+ .list-card-content {
img {
margin-bottom: 0;
}
}
}
&.horizontal {
img {
margin-bottom: 0;
width: 5.5rem;
}
input[type="checkbox"],
input[type="radio"] {
+ .list-card-content::before {
content: "";
position: relative;
margin: 0 0.5rem 0 0;
order: 1;
}
&:checked + .list-card-content::after {
content: "";
margin: -0.15rem 0 0 0;
left: 1.175rem;
}
&:checked + .list-card-content {
p {
color: $black;
font-weight: 500;
}
.sub-copy {
color: $gray-600;
font-weight: 400;
}
}
+ .list-card-content {
outline: none;
min-height: 48px;
color: $gray-600;
img {
margin-bottom: 0;
}
p {
color: $gray-600;
text-align: left;
&.sub-copy {
color: $gray-550;
}
}
}
} }
}
} }
}
} }
</style> </style>

View file

@ -27,6 +27,7 @@ export default {
<style lang="scss"> <style lang="scss">
.loader { .loader {
display: flex; display: flex;
//Open an overlay to prevent page interaction //Open an overlay to prevent page interaction
&:before { &:before {
content: ""; content: "";
@ -56,16 +57,13 @@ export default {
} }
//Spinner position //Spinner position
&.center { &.center {
position: absolute;
right: 50%; right: 50%;
transform: translateX(50%); transform: translateX(50%);
} }
&.right { &.right {
position: absolute;
right: 1rem; right: 1rem;
} }
&.left { &.left {
position: absolute;
left: 1rem; left: 1rem;
} }
//Spinner color //Spinner color

View file

@ -1,117 +1,66 @@
import { shallowMount } from "@vue/test-utils"; import { mount } from "@vue/test-utils";
import radio from "./radio"; import radio from "./radio";
import { nextTick } from "vue"; import { getMountOptions } from "@/helpers/unit-test-helper.js";
import { GaActions } from "@/constants/analytics"; import inputButtonWrapperMixin from "@/mixins/input-button-wrapper-mixin";
describe("radio.vue", () => { describe("radio.vue", () => {
it("Should return group name", async () => { it("Should have correct group name", async () => {
// Act // Arrange
const wrapper = shallowMount(radio, { let { wrapper } = setupMocks({
propsData: { mountOptionsMockData: {
groupName: "radio-button-test", propsData: {
}, groupName: "radio-button-test",
value: "test value",
},
},
});
// Act
const input = wrapper.find("input");
// Assert
expect(input.attributes().name).toEqual("radio-button-test");
}); });
// Assert it("Should have correct label text", async () => {
const input = wrapper.find("input"); // Act
let { wrapper } = setupMocks({
mountOptionsMockData: {
propsData: {
buttonLabel: "label text",
value: "test value",
},
},
});
// Expect // Arrange
expect(input.attributes().name).toEqual("radio-button-test"); const paragraph = wrapper.find("p");
});
it("Should return checkbox id", async () => { // Assert
// Act expect(paragraph.text()).toEqual("label text");
const wrapper = shallowMount(radio, {
propsData: {
buttonID: "Radio ID",
},
}); });
// Assert it("Should have correct screenreader-only text", async () => {
const input = wrapper.find("input"); // Act
let { wrapper } = setupMocks({});
// Expect // Arrange
expect(input.attributes().id).toEqual("Radio ID"); await wrapper.setProps({
}); screenReaderOnlyText: "screenreader text",
value: "test value",
});
const paragraph = wrapper.find(".sr-only");
it("Should return label text", async () => { // Assert
// Act expect(paragraph.text()).toEqual("screenreader text");
const wrapper = shallowMount(radio, {
propsData: {
buttonLabel: "label text",
},
}); });
// Assert
const paragraph = wrapper.find("p");
expect(paragraph.text()).toEqual("label text");
});
it("Should return label text", async () => {
// Act
const wrapper = shallowMount(radio, {
propsData: {
screenReaderOnlyText: "screenreader text",
},
});
// Assert
const paragraph = wrapper.find("span");
expect(paragraph.text()).toEqual("screenreader text");
});
it("Should emit button value on click", async () => {
// Act
const wrapper = shallowMount(radio, {
global: {
mocks: {
'$route': { query: { fmgPage: 'page-name' } },
GaActions: GaActions,
pushEventToGA: jest.fn(),
}
},
propsData: {
buttonLabel: "Windshield",
value: "List Card Checkbox",
buttonID: "List Card Checkbox",
groupID: "radio-demo-1",
groupName: "radio 1",
isRequired: true,
isWide: false,
modelValue: ["List Card Checkbox"],
},
});
wrapper.vm.handleCheckChange();
// Assert
expect(wrapper.emitted()["isCheckedChanged"][0]).toEqual([{"buttonID": "List Card Checkbox", value: "List Card Checkbox", checkValue: false}]);;
expect(wrapper.vm.pushEventToGA).toHaveBeenCalled();
});
it("Should set checkValue data if selectedButtonIDs has value(s)", async () => {
// Act
const wrapper = shallowMount(radio, {
global: {
mocks: {
'$route': { query: { fmgPage: 'page-name' } },
GaActions: GaActions,
pushEventToGA: jest.fn(),
}
},
propsData: {
buttonLabel: "Windshield",
buttonID: "List Card Checkbox",
groupID: "radio-demo-1",
groupName: "radio 1",
buttonImage: "windshield-damage.svg",
isRequired: true,
modelValue: ["List Card Checkbox"],
value: "Car-Front",
selectedValues: "Car-Front"
},
});
// Assert
expect(wrapper.componentVM.checkValue).toEqual(true);
});
}); });
function setupMocks({ mountOptionsMockData = {} }) {
const wrapper = mount(radio, {
...mountOptionsMockData,
mixins: [inputButtonWrapperMixin],
});
return { wrapper };
}

View file

@ -1,140 +1,79 @@
<template> <template>
<!-- Checkbox groups MUST be wrapped in a <fieldset> and <legend> tag and must contain tabindex --> <baseInputButton
<div class="ui-radio form-check" :class="[(errors.length > 0 || hasError) ? 'has-error' : '']"> v-bind="$props"
<input buttonWrapperClasses="ui-radio form-check"
type="radio" inputClasses="form-check-input"
class="form-check-input" v-model="selectedValue">
aria-checked="false" <div class="d-flex align-items-start form-check-label">
:name="groupName" <p v-if="buttonLabel" class="m-0">{{ buttonLabel }}</p>
:id="buttonID" <span v-if="screenReaderOnlyText" class="sr-only">{{
:aria-required="isRequired" screenReaderOnlyText
:value="value" }}</span>
:v-model="checkValue" </div>
@change="handleCheckChange" </baseInputButton>
:checked="checkValue"
:validationRules="validationRules"
/>
<label class="d-flex align-items-start form-check-label" :for="buttonID">
<p v-if="buttonLabel" class="m-0">{{ buttonLabel }}</p>
<span v-if="screenReaderOnlyText" class="sr-only">{{
screenReaderOnlyText
}}</span>
</label>
</div>
</template> </template>
<script> <script>
import { useField } from "vee-validate"; import baseInputButton from "@/common-components/base-input-button/base-input-button";
import { queryStrings } from "@/constants/query-strings"; import inputButtonWrapperMixin from "@/mixins/input-button-wrapper-mixin";
export default { export default {
name: "radio", name: "radio",
props: { mixins: [inputButtonWrapperMixin],
groupName: String, components: {
buttonLabel: String, baseInputButton,
buttonID: String,
isRequired: Boolean,
value: {
type: [String, Number],
default: "",
}, },
screenReaderOnlyText: String,
selectedValues: String,
hasError: Boolean,
validationRules: String,
valueToLogType: String,
},
data() {
return {
checkValue: Boolean,
};
},
created() {
if (this.selectedValues) {
this.checkValue = this.selectedValues === this.value;
this.handleCheckChange();
} else{
this.checkValue = false;
}
},
methods: {
handleCheckChange() {
this.handleChange(this.value);
const emitEvent = {
checkValue: this.checkValue,
value: this.value.toString(),
buttonID: this.buttonID && this.buttonID.toString(),
};
this.$emit("isCheckedChanged", emitEvent);
this.$emit("update:modelValue", emitEvent);
this.pushEventToGA(this.$route.query[queryStrings.FMG_PAGE], this.GaActions.CLICKED, this.value.toString(), true, this.valueToLogType);
},
},
setup(props) {
const inputType = "radio";
const {
value: inputValue,
handleChange,
errors,
} = useField(props.groupName, props.validationRules,
{
type: inputType,
checkedValue: props.value,
});
return {
handleChange,
errors,
};
},
}; };
</script> </script>
<style lang="scss" scoped> <style lang="scss">
.form-check { .form-check {
position: relative; position: relative;
.form-check-input {
border: 1px solid $gray-500;
border-radius: 50%;
margin-right: 0.5rem;
&:checked {
background-color: $white;
background-size: 71%;
background-position: center;
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");
+ label {
p {
font-weight: 500;
font-size: .875rem;
color: $black;
}
}
}
&:focus {
box-shadow: 0 0 0 2.5px $blue;
}
&, & + label {
margin-top: 0;
position: absolute;
top: 50%;
transform: translateY(-50%);
}
}
&:hover {
.form-check-input { .form-check-input {
box-shadow: 0 0 0 4px $blue-300; border: 1px solid $gray-500;
border-radius: 50%;
margin-right: 0.5rem;
opacity: 1;
height: 1em;
width: 1em;
&:checked {
background-color: $white;
background-size: 71%;
background-position: center;
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");
+ .form-check-label {
p {
font-weight: 500;
font-size: 0.875rem;
color: $black;
}
}
}
&:focus {
box-shadow: 0 0 0 2.5px $blue;
}
&,
& + .form-check-label {
margin-top: 0;
position: absolute;
top: 50%;
transform: translateY(-50%);
}
}
&:hover {
.form-check-input {
box-shadow: 0 0 0 4px $blue-300;
}
}
p {
font-weight: 400;
font-size: 0.875rem;
color: $gray-600;
} }
}
p {
font-weight: 400;
font-size: .875rem;
color: $gray-600;
}
} }
</style> </style>