Merge branch 'develop' into feature/Digital/SSR-123

# Conflicts:
#	src/common-components/button-question/button-question.vue
This commit is contained in:
Jason Wheeler 2022-11-17 11:23:48 -05:00
commit 207cd61380
35 changed files with 4134 additions and 2195 deletions

View file

@ -13,4 +13,5 @@
@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";
@import "@/styles/shared-input-button-styles.scss";
</style> </style>

View file

@ -0,0 +1,170 @@
<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 {
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.CHANGE:
this.handleClick(e);
break;
}
} else {
switch (eventType) {
case this.eventTypes.CLICK:
case this.eventTypes.ENTER:
case this.eventTypes.SPACE:
this.handleClick(e);
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,
});
},
},
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,101 +1,135 @@
<!-- Documented in confluence https://safelite.atlassian.net/wiki/spaces/DC/pages/76644418/Button+Question+Component -->
<template> <template>
<div :class="isOverflowScrollable ? 'button-question button-question-overflow' : 'button-question'"> <div
:class="
isOverflowScrollable ? 'button-question button-question-overflow' : 'button-question'
">
<div v-if="questionText && answers && answers.length > 0" class="question-text d-flex"> <div v-if="questionText && answers && answers.length > 0" class="question-text d-flex">
<span class="fw-bold w-100">{{ questionText }}</span> <span class="fw-bold w-100">{{ questionText }}</span>
</div> </div>
<div class="w-100 d-flex justify-content-center"> <div class="w-100 d-flex justify-content-center">
<fieldset class="w-100" :aria-required=isRequired :class="getFieldSetClasses" :role="isMultiSelect ? 'group' : 'radiogroup'" :aria-labelledby="formatString(groupName)"> <fieldset
<legend class="sr-only" :data-focus-target="formatString(groupName)" :id="formatString(groupName)" tabindex="-1"> class="w-100"
{{ questionText }} :aria-required="isRequired"
{{(isMultiSelect && answers && answers.length > 1) ? 'Select one or more options below.' : 'Select an option below.' }} :class="getFieldSetClasses"
</legend> :role="isMultiSelect ? 'group' : 'radiogroup'"
<div :class="getComponentLoopWrapperClasses"> :aria-labelledby="formatString(groupName)">
<div :class="getComponentWrapperClasses" v-for="answer in answers" :key="answer.Name ? answer.Name : answer"> <legend
<component class="sr-only"
:is="buttonType" :data-focus-target="formatString(groupName)"
@isCheckedChanged="handleCheckedChanged" tabindex="-1"
:buttonID="answer.Name ? formatString(groupName) + '-' + answer.Name : formatString(groupName) + '-' + getAnswerString(answer, 'Text')" :id="formatString(groupName)">
:value="getValue(answer)" {{ questionText }}
:buttonLabel="answer.Text ? answer.Text : getAnswerString(answer, 'Name')" {{
:buttonLabelSubCopy="answer.SubText" isMultiSelect && answers && answers.length > 1
:textPosition="textPosition" ? "Select one or more options below."
:isMultiSelect="isMultiSelect" : "Select an option below."
:groupName="formatString(groupName)" }}
:selectingInitiatesLoad="selectingInitiatesLoad" </legend>
:loaderColor="loaderColor" <div :class="getComponentLoopWrapperClasses">
:loaderPosition="loaderPosition" <div
:isWide=isWide :class="getComponentWrapperClasses"
:isCashOrInsurance=isCashOrInsurance v-for="answer in buttonsInfo"
:isRequired=isRequired :key="answer.value ? answer.value : answer">
:buttonImage="answer.AnswerImageUrl" <component
:buttonImageId="answer.ImageId" :is="buttonTypeString"
:altText="answer.Name ? answer.Name : answer" :buttonLabel="answer.buttonLabel"
screenReaderOnlyText="(opens new window)" :buttonLabelSubCopy="answer.buttonLabelSubCopy"
:colLength="getColLength" :buttonBodyCopy="answer.buttonBodyCopy"
:selectedValues="selectedValues" :buttonAuxillaryCopy="answer.buttonAuxillaryCopy"
data-test="button" :buttonFooterCopy="answer.buttonFooterCopy"
:validationRules="validationRules" :buttonImage="answer.buttonImage"
:class="[suppressError ? 'alertError' : '' , isCashOrInsurance ? 'radio-fancy' : '']" :buttonImageId="answer.buttonImageId"
:valueToLogType="valueToLogType" :groupName="answer.groupName"
/> :isMultiSelect="isMultiSelect"
<!-- For nested questions --> :value="answer.value"
<transition name="fade" mode="out-in"> :selectingInitiatesLoad="selectingInitiatesLoad"
<div v-if="typeof selectedValues == 'string' && selectedValues == answer.Name"> :isWide="isWide"
<slot></slot> :validationRules="validationRules"
</div> :textPosition="textPosition"
</transition> :additionalButtonStyling="additionalButtonStyling"
</div> :lastValuePushedToGa="lastValuePushedToGa"
</div> :setLastValuePushedToGa="setLastValuePushedToGa"
</fieldset> 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>
<div class="row form-test-error mt-1"> <div class="row form-test-error mt-1">
<error-message :name="formatString(groupName)" v-if="!suppressError"></error-message> <error-message :name="formatString(groupName)" v-if="!suppressError"></error-message>
</div> </div>
</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: { buttonTypeString: {
type: String, type: String,
default: "listButton", default: "listButton",
},
buttonTypeObject: {
type: Object,
default: null,
}, },
isMultiSelect: Boolean, isMultiSelect: Boolean,
groupName: String, groupName: String,
questionText: String, questionText: String,
answers: Array, answers: Array,
textPosition: { textPosition: {
type: String, type: String,
default: "text-center", default: "text-center",
}, },
selectingInitiatesLoad: Boolean, selectingInitiatesLoad: Boolean,
loaderColor: { loaderColor: {
type: String, type: String,
default: "blue", default: "blue",
}, },
loaderPosition: { loaderPosition: {
type: String, type: String,
default: "right", default: "right",
}, },
isRequired: Boolean, isRequired: Boolean,
isOverflowScrollable: Boolean, isOverflowScrollable: Boolean,
isWide: Boolean, isWide: Boolean,
isCashOrInsurance: Boolean, isCashOrInsurance: Boolean,
modelValue: [Array, String], modelValue: [Array, Number, String],
value: [Number, String],
validationRules: String, validationRules: String,
suppressError: Boolean, suppressError: Boolean,
useTextForValue: Boolean, useTextForValue: Boolean,
valueToLogType: String, valueToLogType: String,
}, additionalButtonStyling: String,
computed: { },
beforeMount() {
if (this.buttonTypeObject) {
this.$options.components[this.buttonTypeString] = this.buttonTypeObject;
}
},
data() {
return {
lastValuePushedToGa: null,
};
},
computed: {
getFieldSetClasses() { getFieldSetClasses() {
if (this.isOverflowScrollable) { if (this.isOverflowScrollable) {
return "container-fluid overflow-scroll position-absolute px-5 pb-2"; return "container-fluid overflow-scroll position-absolute px-5 pb-2";
@ -108,146 +142,132 @@
} }
}, },
getComponentLoopWrapperClasses() { getComponentLoopWrapperClasses() {
let classes; let classes;
switch (this.buttonType) { switch (this.buttonTypeString) {
case "listButton": case "listButton":
classes = "w-100"; classes = "w-100";
break; break;
case "listButtonHorizontal": case "listButtonHorizontal":
classes = "d-flex flex-row p-0"; classes = "d-flex flex-row p-0";
break; break;
case 'listCard': case "listCard":
classes = "row g-2 justify-content-center"; classes = "row g-2 justify-content-center";
break; if (this.isWide) {
case 'radio': classes += " flex-column";
classes = 'ui-radio d-flex' }
break; break;
} case "radio":
return classes; classes = "ui-radio d-flex";
break;
case "servicePackageRadio":
classes = "package-main";
break;
}
return classes;
}, },
getComponentWrapperClasses() { getComponentWrapperClasses() {
let classes = ""; let classes = "";
classes += this.isWide ? "col-12" : "col"; classes += this.isWide ? "col-12" : "col";
if (this.buttonType == "radio") { if (this.buttonTypeString == "radio") {
classes += " radio-button-container"; classes += " radio-button-container";
} } else if (this.buttonTypeString == "servicePackageRadio") {
classes = "package-wrapper";
return classes; }
return classes;
}, },
getColLength(){ buttonsInfo() {
if(this.isWide) { return (Array.isArray(this.answers) ? this.answers : [])?.map((answer) => ({
return "12" buttonLabel: answer.buttonLabel ?? answer.Text ?? answer,
} else { altText: answer.altText ?? (answer.Name ? answer.Name : answer),
return ""; buttonLabelSubCopy: answer.buttonLabelSubCopy ?? answer.SubText,
} buttonBodyCopy: answer.buttonBodyCopy ?? answer.buttonBodyCopy,
buttonAuxillaryCopy: answer.buttonAuxillaryCopy ?? answer.buttonAuxillaryCopy,
buttonFooterCopy: answer.buttonFooterCopy ?? answer.buttonFooterCopy,
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: { selectedValues: {
get: function() { get() {
return this.modelValue; return this.modelValue;
}, },
set: function(newValue) { set(selectedAnswers) {
this.$emit("update:modelValue", newValue); this.$emit("update:modelValue", selectedAnswers);
} },
}, },
}, },
methods: { methods: {
formatString(str) { formatString(str) {
return str.replace(" ", "-"); return str?.replaceAll(" ", "-");
}, },
getValue(answer){ setLastValuePushedToGa(lastValuePushedToGa) {
if (this.useTextForValue) { return answer.Text } this.lastValuePushedToGa = lastValuePushedToGa;
return answer.Name ? answer.Name : answer;
}, },
getAnswerString(answer, prop = "Name") { },
switch (typeof answer) { components: {
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, listButton,
listButtonHorizontal, listButtonHorizontal,
listCard, listCard,
ErrorMessage, ErrorMessage,
radio, 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 { }
color: $black; .button-question {
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 {
margin-top: 1.5rem;
margin-bottom: 1rem;
font-size: 1rem;
line-height: 1.625rem;
& > span {
text-align: center;
}
}
.vehicle-parts {
.question-text {
span {
font-size: 0.875rem;
text-align: left;
margin: 0 0 0.5rem 0;
} }
}
} }
.question-text { .question-text {
margin-top: 1.5rem;
margin-bottom: 1rem;
font-size: 1rem;
line-height: 1.5rem;
& > span {
text-align: center;
}
}
.vehicle-parts {
.question-text {
span {
font-size: .875rem;
text-align: left;
margin: 0 0 .5rem 0;
}
}
.question-text {
margin: 0; margin: 0;
}
fieldset {
.ui-radio {
margin: 0;
}
}
} }
</style> fieldset {
.ui-radio {
margin: 0;
}
}
}
</style>

View file

@ -33,7 +33,7 @@ import textLink from "@/ux-components/text-link/text-link";
import buttonMain from "@/ux-components/button-main/button-main"; import buttonMain from "@/ux-components/button-main/button-main";
export default { export default {
name: "funnelFooter", name: "siteFooter",
props: { props: {
isForwardActionDisabled: Boolean, isForwardActionDisabled: Boolean,
isBackButtonHidden: { type: Boolean, default: false }, isBackButtonHidden: { type: Boolean, default: false },

View file

@ -0,0 +1,9 @@
const damageLocationsCms = {
WINDSHIELD: "WINDSHIELD",
SIDEDOOR: "SIDEDOOR",
REARWINDOW: "REARWINDOW",
DRIVERSIDE: "DRIVERSIDE",
PASSENGERSIDE: "PASSENGERSIDE",
};
export { damageLocationsCms };

View file

@ -0,0 +1,21 @@
const damageLocationsSelected = {
WINDSHIELD: "Windshield",
SIDEDOOR: "SideDoor",
REARWINDOW: "RearWindow",
REPAIR: "Repair",
REPLACE: "Replace",
DRIVER: "Driver",
PASSENGER: "Passenger",
FRONT: "Front",
REAR: "Rear",
BACK: "Back",
QUARTER: "Quarter",
VENT: "Vent",
SINGLE: "Single",
DRIVERSIDE: "DriverSide",
PASSENGERSIDE: "PassengerSide",
STATIONARY: "Stationary",
SLIDER: "Slider",
};
export { damageLocationsSelected };

View file

@ -27,6 +27,10 @@ const endpoints = {
url: "/vehicle/api/v1/vehicle/styles", url: "/vehicle/api/v1/vehicle/styles",
method: "GET", method: "GET",
}, },
GetDamageOptions: {
url: "/parts/api/v1/parts/damage-options",
method: "GET",
},
GetVehicle: { GetVehicle: {
url: "/vehicle/api/v1/vehicle/lookup", url: "/vehicle/api/v1/vehicle/lookup",
method: "GET", method: "GET",

View file

@ -33,7 +33,6 @@ export default {
true true
); );
} }
return resolve(response); return resolve(response);
}, },
error => { error => {

View file

@ -0,0 +1,38 @@
/**
* 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,86 @@
<template>
<div class="damage-location-question">
<buttonQuestion
:questionText="questionText"
isMultiSelect
:answers="answersToDisplay"
:groupName="groupName"
buttonTypeString="listCard"
isRequired
v-model="selectedValues"
validationRules="damage-location-required" />
</div>
</template>
<script>
import buttonQuestion from "@/common-components/button-question/button-question";
import { defineRule } from "vee-validate";
import { required } from "@/helpers/validation-rules";
import { errorMessages } from "@/constants/error-messages";
// DEFINE VALIDATION RULES
defineRule("damage-location-required", required(errorMessages.DAMAGE_LOCATION_REQUIRED));
export default {
name: "damageLocationQuestion",
data() {
return {
damageOptions: Object,
};
},
props: {
modelValue: Array,
groupName: String,
cmsWidgetName: String,
},
methods: {
initializeComponent(damageOptions) {
this.damageOptions = damageOptions;
},
},
computed: {
questionText() {
return this.getCmsContent(this.cmsWidgetName, "QuestionText");
},
answersFromCms() {
return this.getCmsContent(this.cmsWidgetName, "Answers");
},
selectedValues: {
get: function () {
return this.modelValue;
},
set: function (newValue) {
this.$emit("update:modelValue", newValue);
},
},
damageOptionsMap() {
return {
Windshield: true,
SideDoor:
this.damageOptions.driverSideOptions.availableReplacementOptions.length ||
this.damageOptions.passengerSideOptions.availableReplacementOptions.length,
RearWindow: this.damageOptions.backGlassOptions.availableReplacementOptions.length,
};
},
answersToDisplay() {
const filteredAnswers = Array.isArray(this.answersFromCms)
? this.answersFromCms.filter((ans) => {
const name = ans.Name.split("-");
return (
name[0].toUpperCase() === this.mainStore.vehicle.category &&
this.damageOptionsMap[name[1]]
);
})
: [];
return filteredAnswers.map((ans) => {
const newName = ans.Name.includes("-") ? ans.Name.split("-")[1] : ans.Name;
ans.Name = newName;
return ans;
});
},
},
components: {
buttonQuestion,
},
};
</script>

View file

@ -0,0 +1,108 @@
<template>
<transition name="fade" mode="out-in">
<div
v-if="shouldDisplayReplaceOptionsQuestion"
class="replace-options-question"
:class="this.answersToDisplay.length < 2 ? 'd-none' : ''"
aria-live="polite">
<buttonQuestion
isWide
:questionText="questionText"
:isMultiSelect="isMultiSelect"
:answers="answersToDisplay"
:groupName="groupName"
buttonTypeString="listCard"
v-model="selectedValues"
:validationRules="validationRules"
:suppressError="suppressError"
:isRequired="isRequired" />
</div>
</transition>
</template>
<script>
import buttonQuestion from "@/common-components/button-question/button-question";
export default {
name: "replaceOptionsQuestion",
data() {
return {
replaceOptions: this.isMultiSelect ? [] : "",
};
},
props: {
isAvailable: Boolean,
filterByVehicleCategory: Boolean,
groupName: String,
modelValue: [Array, String, Number],
isMultiSelect: Boolean,
validationRules: String,
suppressError: Boolean,
cmsWidgetName: String,
isRequired: Boolean,
},
methods: {
initializeComponent(replaceOptions) {
this.replaceOptions = replaceOptions;
},
updateSelectedValues() {
// UPDATE SELECTEDVALUES IF ONLY ONE ANSWER
if (Array.isArray(this.answersToDisplay) && this.answersToDisplay.length === 1) {
this.selectedValues = this.isMultiSelect
? [this.answersToDisplay[0].Name]
: this.answersToDisplay[0].Name;
}
},
},
computed: {
questionText() {
return this.getCmsContent(this.cmsWidgetName, "QuestionText");
},
answersFromCms() {
return this.getCmsContent(this.cmsWidgetName, "Answers");
},
selectedValues: {
get: function () {
return this.modelValue;
},
set: function (newValue) {
this.$emit("update:modelValue", newValue);
},
},
answersToDisplay() {
const filteredAnswers = Array.isArray(this.answersFromCms)
? this.answersFromCms.filter((ans) => {
const name = ans.Name.split("-");
return this.filterByVehicleCategory
? name[0].toUpperCase() === this.mainStore.vehicle.category &&
this.replaceOptions.includes(name[1])
: this.replaceOptions.includes(ans.Name);
})
: [];
return filteredAnswers.map((ans) => {
const newName = ans.Name.includes("-") ? ans.Name.split("-")[1] : ans.Name;
ans.Name = newName;
return ans;
});
},
shouldDisplayReplaceOptionsQuestion() {
return this.isAvailable && this.answersToDisplay.length > 0;
},
},
watch: {
isAvailable(val) {
// CHECK TO UPDATE SELECTED VALUES WHEN ISAVAILABLE IS TRUE
val && this.updateSelectedValues();
},
shouldDisplayReplaceOptionsQuestion(shouldDisplayReplaceOptionsQuestion) {
if (!shouldDisplayReplaceOptionsQuestion) {
this.selectedValues = this.isMultiSelect ? [] : "";
}
},
},
components: {
buttonQuestion,
},
};
</script>

View file

@ -0,0 +1,167 @@
<template>
<div class="side-door-options">
<transition name="fade" mode="out-in">
<div
class="side-doors"
v-if="selectedDamageLocations.includes('SideDoor')"
aria-live="polite">
<buttonQuestion
:questionText="questionText"
isMultiSelect
:answers="answersToDisplay"
:groupName="groupName"
buttonTypeString="listCard"
v-model="selectedDoorSidesValues"
validationRules="damage-side-required"
isRequired />
</div>
</transition>
<replaceOptionsQuestion
ref="driverSideOptions"
cmsWidgetName="DriverSideReplaceOptionsQuestion"
:isAvailable="isDriverSideReplaceOptionsQuestionAvailable"
groupName="driverSideOptions"
isMultiSelect
filterByVehicleCategory
v-model="selectedDriverSideReplaceOptionsValues"
validationRules="driver-side-options-required"
isRequired />
<replaceOptionsQuestion
ref="passengerSideOptions"
cmsWidgetName="PassengerSideReplaceOptionsQuestion"
:isAvailable="isPassengerSideReplaceOptionsQuestionAvailable"
groupName="passengerSideOptions"
isMultiSelect
filterByVehicleCategory
v-model="selectedPassengerSideReplaceOptionsValues"
validationRules="passenger-side-options-required"
isRequired />
</div>
</template>
<script>
import buttonQuestion from "@/common-components/button-question/button-question";
import replaceOptionsQuestion from "@/layouts/vehicle-damage/replace-options-question/replace-options-question";
import { defineRule } from "vee-validate";
import { required } from "@/helpers/validation-rules";
import { errorMessages } from "@/constants/error-messages";
import { damageLocationsSelected } from "@/constants/damage-locations-selected";
// DEFINE VALIDATION RULES
defineRule("damage-side-required", required(errorMessages.DAMAGE_SIDE_REQUIRED));
defineRule("driver-side-options-required", required(errorMessages.DRIVER_SIDE_OPTIONS_REQUIRED));
defineRule("passenger-side-options-required",required(errorMessages.PASSENGER_SIDE_OPTIONS_REQUIRED));
export default {
name: "sideDoorOptions",
props: {
groupName: String,
modelValue: Object,
selectedDamageLocations: Array,
cmsWidgetName: String,
},
methods: {
initializeComponent(driverSideOptions, passengerSideOptions) {
this.$refs.driverSideOptions.initializeComponent(driverSideOptions);
this.$refs.passengerSideOptions.initializeComponent(passengerSideOptions);
},
getSideDoorReplacementOptions(
selectedDoorSides,
selectedDriverSideReplaceOptions,
selectedPassengerSideReplaceOptions
) {
return {
selectedDoorSides: selectedDoorSides,
selectedDriverSideReplaceOptions: selectedDriverSideReplaceOptions,
selectedPassengerSideReplaceOptions: selectedPassengerSideReplaceOptions,
};
},
},
computed: {
questionText() {
return this.getCmsContent(this.cmsWidgetName, "QuestionText");
},
answersFromCms() {
return this.getCmsContent(this.cmsWidgetName, "Answers");
},
selectedValues: {
get: function () {
return this.modelValue;
},
set: function (newValue) {
this.$emit("update:modelValue", newValue);
},
},
selectedDoorSidesValues: {
get: function () {
return this.selectedValues.selectedDoorSides;
},
set: function (newValue) {
this.selectedValues = this.getSideDoorReplacementOptions(
newValue,
this.selectedValues.selectedDriverSideReplaceOptions,
this.selectedValues.selectedPassengerSideReplaceOptions
);
},
},
selectedDriverSideReplaceOptionsValues: {
get: function () {
return this.selectedValues.selectedDriverSideReplaceOptions;
},
set: function (newValue) {
this.selectedValues = this.getSideDoorReplacementOptions(
this.selectedValues.selectedDoorSides,
newValue,
this.selectedValues.selectedPassengerSideReplaceOptions
);
},
},
selectedPassengerSideReplaceOptionsValues: {
get: function () {
return this.selectedValues.selectedPassengerSideReplaceOptions;
},
set: function (newValue) {
this.selectedValues = this.getSideDoorReplacementOptions(
this.selectedValues.selectedDoorSides,
this.selectedValues.selectedDriverSideReplaceOptions,
newValue
);
},
},
answersToDisplay() {
const filteredAnswers = Array.isArray(this.answersFromCms)
? this.answersFromCms.filter((ans) => {
const name = ans.Name.split("-");
return name[0].toUpperCase() === this.mainStore.vehicle.category;
})
: [];
return filteredAnswers.map((ans) => {
const newName = ans.Name.includes("-") ? ans.Name.split("-")[1] : ans.Name;
ans.Name = newName;
return ans;
});
},
isDriverSideReplaceOptionsQuestionAvailable() {
return (
Array.isArray(this.selectedDoorSidesValues) &&
this.selectedDoorSidesValues.includes(damageLocationsSelected.DRIVERSIDE) &&
Array.isArray(this.selectedDamageLocations) &&
this.selectedDamageLocations.includes(damageLocationsSelected.SIDEDOOR)
);
},
isPassengerSideReplaceOptionsQuestionAvailable() {
return (
Array.isArray(this.selectedDoorSidesValues) &&
this.selectedDoorSidesValues.includes(damageLocationsSelected.PASSENGERSIDE) &&
Array.isArray(this.selectedDamageLocations) &&
this.selectedDamageLocations.includes(damageLocationsSelected.SIDEDOOR)
);
},
},
components: {
buttonQuestion,
replaceOptionsQuestion,
},
};
</script>

View file

@ -0,0 +1,464 @@
<template>
<Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm" v-slot="{ meta }">
<div class="page-container-grouped-styles">
<siteHeader cmsWidgetName="SiteHeaderWidget" />
<vehicleBanner
cmsWidgetName="VehicleBannerWidget"
:displayGenericVehicleImage="false" />
<siteSubHeader cmsWidgetName="SiteSubHeaderWidget" />
<div class="fade-on-route-transition sub-container make-tall">
<alert
ref="vehicleChangeAlert"
v-if="shouldDisplayVehicleChangeAlert"
class="mt-5 mb-0"
cmsWidgetName="VehicleChangeAlert"
alertClass="alert-warning"
:isDismissible="false" />
<damageLocationQuestion
ref="damageLocation"
cmsWidgetName="DamageLocationQuestion"
v-model="selectedDamageLocations"
groupName="DamageLocationQuestion" />
<windshieldOptions
ref="windshieldOptions"
v-model="selectedWindshieldOptions"
:hasRepairReplaceConflict="hasRepairReplaceConflict"
:hasSplitSingleConflict="hasSplitSingleConflict"
:selectedDamageLocations="selectedDamageLocations" />
<alert
v-if="hasRepairReplaceConflict"
class="my-5"
cmsWidgetName="HasReplacementConflict"
alertClass="alert-danger"
:isDismissible="false" />
<sideDoorOptions
ref="sideDoorOptions"
cmsWidgetName="SideDoorSideQuestion"
groupName="SideDoorSideQuestion"
v-model="sideDoorOptionsData"
v-show="!hasRepairReplaceConflict"
:selectedDamageLocations="selectedDamageLocations" />
<replaceOptionsQuestion
ref="backGlassOptions"
cmsWidgetName="RearReplaceOptionsQuestion"
:isAvailable="isRearWindowDamageLocation && !hasRepairReplaceConflict"
v-model="selectedRearReplaceOptions"
groupName="BackGlassReplaceOptionsQuestion"
validationRules="replace-options-required" />
<site-footer
cmsWidgetName="SiteFooterWidget"
:isForwardActionDisabled="!meta.valid"
@back-clicked="backButtonAction"
@ForwardClicked="forwardButtonAction" />
</div>
</div>
</Form>
</template>
<script>
// Components
import siteHeader from "@/common-components/site-header/site-header";
import siteFooter from "@/common-components/site-footer/site-footer";
import vehicleBanner from "@/common-components/vehicle-banner/vehicle-banner";
import siteSubHeader from "@/common-components/site-sub-header/site-sub-header";
import sideDoorOptions from "@/layouts/vehicle-damage/side-door-options/side-door-options";
import damageLocationQuestion from "@/layouts/vehicle-damage/damage-location-question/damage-location-question";
import windshieldOptions from "@/layouts/vehicle-damage/windshield-options/windshield-options";
import replaceOptionsQuestion from "@/layouts/vehicle-damage/replace-options-question/replace-options-question";
import alert from "@/ux-components/alert/alert";
// Supporting files
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { settleAllPromises } from "@/helpers/layout-helper";
import { Form, defineRule } from "vee-validate";
import { required } from "@/helpers/validation-rules";
import { errorMessages } from "@/constants/error-messages";
import { damageLocationsCms } from "@/constants/damage-locations-cms.js";
import { damageLocationsSelected } from "@/constants/damage-locations-selected.js";
import { useMainStore } from '@/store';
// DEFINE VALIDATION RULES
defineRule("replace-options-required", required(errorMessages.REPLACE_OPTIONS_REQUIRED));
export default {
name: "vehicle-damage",
async beforeRouteEnter(to, from, next) {
// Call APIs
const cmsContentPromise = fetchCmsContentForPage(to.query.issPage);
const damageOptionsPromise = useMainStore().getDamageOptions(useMainStore().order.vehicle.carId);
// Settle promises and get results
const promiseResultMap = [
{
resultKey: "cmsContent",
promise: cmsContentPromise,
},
{
resultKey: "damageOptions",
promise: damageOptionsPromise,
},
];
const resultMap = await settleAllPromises(promiseResultMap);
// Call the "next" function to complete the transition to this page.
next((vm) => {
vm.setCmsContent(resultMap.cmsContent);
vm.$refs.damageLocation.initializeComponent(resultMap.damageOptions);
vm.$refs.sideDoorOptions.initializeComponent(
resultMap.damageOptions.driverSideOptions.availableReplacementOptions,
resultMap.damageOptions.passengerSideOptions.availableReplacementOptions
);
vm.$refs.windshieldOptions.initializeComponent(
resultMap.damageOptions.windshieldOptions.availableReplacementOptions
);
vm.$refs.backGlassOptions.initializeComponent(
resultMap.damageOptions.backGlassOptions.availableReplacementOptions
);
});
},
data() {
return {
selectedDamageLocations: this.getDamageLocationsFromStore(),
sideDoorOptionsData: {
selectedDoorSides: this.getDoorSidesFromStore(),
selectedDriverSideReplaceOptions: this.getDriverSideReplaceOptionsFromStore(),
selectedPassengerSideReplaceOptions: this.getPassengerSideReplaceOptionsFromStore(),
},
selectedWindshieldOptions: this.getWindshieldOptionsFromStore(),
selectedRearReplaceOptions: this.getRearReplaceOptionsFromStore(),
};
},
methods: {
arePagePrerequisitesValid() {
if (this.mainStore.order.vehicle.carId) {
return true;
}
return false;
},
backButtonAction() {
// route to move backwards
this.$router.navigate(this.navigationScenarios.CLICKED_BACK, this.$route);
},
getDamageLocationsFromStore() {
var glassSelections = [];
if (
useMainStore().order.damage.glassToReplace?.some((glass) => {
return glass.glassLocation === damageLocationsSelected.WINDSHIELD;
}) ||
useMainStore().order.damage.isRepair
) {
glassSelections.push(damageLocationsSelected.WINDSHIELD);
}
if (
useMainStore().order.damage.glassToReplace?.some((glass) => {
return (
glass.glassLocation === damageLocationsSelected.DRIVER ||
glass.glassLocation === damageLocationsSelected.PASSENGER
);
})
) {
glassSelections.push(damageLocationsSelected.SIDEDOOR);
}
if (
useMainStore().order.damage.glassToReplace?.some((glass) => {
return glass.glassLocation === damageLocationsSelected.REAR;
})
) {
glassSelections.push(damageLocationsSelected.REARWINDOW);
}
return glassSelections;
},
getWindshieldOptionsFromStore() {
var windShieldOptions = {
selectedWindshieldDamageType: "",
selectedWindshieldChipCount: null,
selectedWindshieldReplaceOptions: [],
};
if (useMainStore().order.damage.isRepair === undefined) return windshieldOptions;
if (useMainStore().order.damage.isRepair) {
windShieldOptions.selectedWindshieldDamageType = damageLocationsSelected.REPAIR;
windShieldOptions.selectedWindshieldChipCount = useMainStore().order.damage.numberOfChips;
} else {
if (
useMainStore().order.damage.glassToReplace?.some((glass) => {
return (
glass.glassLocation === damageLocationsSelected.WINDSHIELD &&
glass.glassName === damageLocationsSelected.SINGLE
);
})
) {
windShieldOptions.selectedWindshieldDamageType =
damageLocationsSelected.REPLACE;
windShieldOptions.selectedWindshieldReplaceOptions.push(
damageLocationsSelected.SINGLE
);
}
if (
useMainStore().order.damage.glassToReplace?.some((glass) => {
return (
glass.glassLocation === damageLocationsSelected.WINDSHIELD &&
glass.glassName === damageLocationsSelected.DRIVER
);
})
) {
windShieldOptions.selectedWindshieldDamageType =
damageLocationsSelected.REPLACE;
windShieldOptions.selectedWindshieldReplaceOptions.push(
damageLocationsSelected.DRIVER
);
}
if (
useMainStore().order.damage.glassToReplace?.some((glass) => {
return (
glass.glassLocation === damageLocationsSelected.WINDSHIELD &&
glass.glassName === damageLocationsSelected.PASSENGER
);
})
) {
windShieldOptions.selectedWindshieldDamageType =
damageLocationsSelected.REPLACE;
windShieldOptions.selectedWindshieldReplaceOptions.push(
damageLocationsSelected.PASSENGER
);
}
}
return windShieldOptions;
},
getDoorSidesFromStore() {
var doorSides = [];
if (
useMainStore().order.damage.glassToReplace?.some((glass) => {
return glass.glassLocation === damageLocationsSelected.DRIVER;
})
) {
doorSides.push(damageLocationsSelected.DRIVERSIDE);
}
if (
useMainStore().order.damage.glassToReplace?.some((glass) => {
return glass.glassLocation === damageLocationsSelected.PASSENGER;
})
) {
doorSides.push(damageLocationsSelected.PASSENGERSIDE);
}
return doorSides;
},
getDriverSideReplaceOptionsFromStore() {
var driverSideReplaceOptions = [];
useMainStore().order.damage.glassToReplace?.forEach((glass) => {
if (glass.glassLocation === damageLocationsSelected.DRIVER) {
driverSideReplaceOptions.push(glass.glassName);
}
});
return driverSideReplaceOptions;
},
getPassengerSideReplaceOptionsFromStore() {
var passengerSideReplaceOptions = [];
useMainStore().order.damage.glassToReplace?.forEach((glass) => {
if (glass.glassLocation === damageLocationsSelected.PASSENGER) {
passengerSideReplaceOptions.push(glass.glassName);
}
});
return passengerSideReplaceOptions;
},
getRearReplaceOptionsFromStore() {
var rearReplaceOptions = useMainStore().order.damage.glassToReplace?.filter(
(glass) => glass.glassLocation === damageLocationsSelected.REAR
)[0]?.glassName;
return rearReplaceOptions;
},
async forwardButtonAction() {
await this.dispatchStoreAction(
this.storeActions.SAVE_VEHICLE_DAMAGE,
{
isWindshieldRepair: this.isWindshieldRepair,
selectedGlassToReplace: this.selectedGlassToReplace(),
selectedWindshieldChipCount:
this.selectedWindshieldOptions.selectedWindshieldChipCount,
},
false
);
return this.navigateForward();
},
navigateForward() {
// If vin already exists, navigate directly to vin-lookup
if (useMainStore().order.vehicle.vin) {
this.$router.navigate(
this.navigationScenarios.CLICKED_FORWARD_WITH_VIN,
this.$route
);
} else {
this.$router.navigate(
this.navigationScenarios.CLICKED_FORWARD_WITHOUT_VIN,
this.$route
);
}
},
selectedGlassToReplace() {
const selectedGlassToReplace = [];
if (this.isWindshieldDamageLocation && !this.isWindshieldRepair) {
this.selectedWindshieldOptions.selectedWindshieldReplaceOptions.forEach(
(wsItem) => {
selectedGlassToReplace.push({
glassLocation: damageLocationsSelected.WINDSHIELD,
glassName: wsItem,
});
}
);
}
if (this.isDriverSideReplace) {
this.sideDoorOptionsData.selectedDriverSideReplaceOptions.forEach((driverItem) => {
selectedGlassToReplace.push({
glassLocation: damageLocationsSelected.DRIVER,
glassName: driverItem,
});
});
}
if (this.isPassengerSideReplace) {
this.sideDoorOptionsData.selectedPassengerSideReplaceOptions.forEach(
(passengerItem) => {
selectedGlassToReplace.push({
glassLocation: damageLocationsSelected.PASSENGER,
glassName: passengerItem,
});
}
);
}
if (this.isRearWindowDamageLocation) {
selectedGlassToReplace.push({
glassLocation: damageLocationsSelected.REAR,
glassName: this.selectedRearReplaceOptions,
});
}
return selectedGlassToReplace;
},
},
computed: {
isWindshieldDamageLocation() {
return this.selectedDamageLocations.some((selectedDamages) => {
return selectedDamages.toUpperCase() === damageLocationsCms.WINDSHIELD;
});
},
isSideDoorDamageLocation() {
return this.selectedDamageLocations.some((selectedDamages) => {
return selectedDamages.toUpperCase() === damageLocationsCms.SIDEDOOR;
});
},
isRearWindowDamageLocation() {
return this.selectedDamageLocations.some((selectedDamages) => {
return selectedDamages.toUpperCase() === damageLocationsCms.REARWINDOW;
});
},
isWindshieldRepair() {
return (
this.isWindshieldDamageLocation &&
this.selectedWindshieldOptions.selectedWindshieldDamageType ===
damageLocationsSelected.REPAIR
);
},
isDriverSideReplace() {
if (!this.isSideDoorDamageLocation) return false;
return this.sideDoorOptionsData.selectedDoorSides.some((selectedDriverSide) => {
return selectedDriverSide.toUpperCase() === damageLocationsCms.DRIVERSIDE;
});
},
isPassengerSideReplace() {
if (!this.isSideDoorDamageLocation) return false;
return this.sideDoorOptionsData.selectedDoorSides.some((selectedPassengerSide) => {
return selectedPassengerSide.toUpperCase() === damageLocationsCms.PASSENGERSIDE;
});
},
hasRepairReplaceConflict() {
return (
this.isWindshieldDamageLocation &&
this.selectedDamageLocations.length > 1 &&
this.isWindshieldRepair
);
},
hasSplitSingleConflict() {
if (
!this.selectedDamageLocations?.includes("Windshield") ||
this.selectedWindshieldOptions.selectedWindshieldDamageType ===
damageLocationsSelected.REPAIR ||
!this.selectedWindshieldOptions.selectedWindshieldReplaceOptions
)
return false;
return (
this.selectedWindshieldOptions.selectedWindshieldReplaceOptions?.some(
(selectedSingleWindshield) => {
return (
selectedSingleWindshield.toUpperCase() ===
damageLocationsSelected.SINGLE.toUpperCase()
);
}
) &&
(this.selectedWindshieldOptions.selectedWindshieldReplaceOptions?.some(
(selectedDriverWindshield) => {
return (
selectedDriverWindshield.toUpperCase() ===
damageLocationsSelected.DRIVER.toUpperCase()
);
}
) ||
this.selectedWindshieldOptions.selectedWindshieldReplaceOptions?.some(
(selectedPassengerWindshield) => {
return (
selectedPassengerWindshield.toUpperCase() ===
damageLocationsSelected.PASSENGER.toUpperCase()
);
}
))
);
},
shouldDisplayVehicleChangeAlert() {
return this.$route.params[this.routerParams.DISPLAY_VEHICLE_CHANGE_ALERT];
}
},
components: {
siteHeader,
siteFooter,
vehicleBanner,
siteSubHeader,
sideDoorOptions,
damageLocationQuestion,
windshieldOptions,
replaceOptionsQuestion,
Form,
alert,
},
};
</script>

View file

@ -0,0 +1,50 @@
<template>
<transition name="fade" mode="out-in">
<div class="windshield-chip-count-question" v-if="isAvailable" aria-live="polite">
<buttonQuestion
:questionText="questionText"
:answers="answersFromCms"
:groupName="groupName"
buttonTypeString="listButtonHorizontal"
useTextForValue
v-model="selectedValue"
:validationRules="validationRules"
isRequired />
</div>
</transition>
</template>
<script>
import buttonQuestion from "@/common-components/button-question/button-question";
export default {
name: "windshieldOptions",
props: {
modelValue: [String, Number],
groupName: String,
isAvailable: Boolean,
validationRules: String,
cmsWidgetName: String,
},
computed: {
questionText() {
return this.getCmsContent(this.cmsWidgetName, "QuestionText");
},
answersFromCms() {
return this.getCmsContent(this.cmsWidgetName, "Answers");
},
selectedValue: {
get: function () {
return this.modelValue;
},
set: function (newValue) {
const numberValue = Number(newValue);
this.$emit("update:modelValue", numberValue);
},
},
},
components: {
buttonQuestion,
},
};
</script>

View file

@ -0,0 +1,50 @@
<template>
<transition name="fade" mode="out-in">
<div class="windshield-damage-type-question" v-if="isAvailable" aria-live="polite">
<buttonQuestion
:questionText="questionText"
:answers="answersFromCms"
:groupName="groupName"
buttonTypeString="listCard"
v-model="selectedValues"
:suppressError="suppressError"
:validationRules="validationRules"
isRequired />
</div>
</transition>
</template>
<script>
import buttonQuestion from "@/common-components/button-question/button-question";
export default {
name: "windshieldDamageTypeQuestion",
props: {
modelValue: String,
groupName: String,
isAvailable: Boolean,
suppressError: Boolean,
validationRules: String,
cmsWidgetName: String,
},
computed: {
questionText() {
return this.getCmsContent(this.cmsWidgetName, "QuestionText");
},
answersFromCms() {
return this.getCmsContent(this.cmsWidgetName, "Answers");
},
selectedValues: {
get: function () {
return this.modelValue;
},
set: function (newValue) {
this.$emit("update:modelValue", newValue);
},
},
},
components: {
buttonQuestion,
},
};
</script>

View file

@ -0,0 +1,238 @@
<template>
<div class="windshield-options">
<windshieldDamageTypeQuestion
cmsWidgetName="WindshieldDamageTypeQuestion"
:isAvailable="isWindshieldDamageLocation"
:suppressError="hasRepairReplaceConflict || showNoReplacementAvailableError"
groupName="WindshieldDamageTypeQuestion"
v-model="selectedWindshieldDamageTypeValue"
:validationRules="windshieldDamageTypeQuestionValidationRules" />
<alert
v-if="showNoReplacementAvailableError"
class="my-3"
cmsWidgetName="NoReplacementAvailableError"
alertClass="alert-danger"
:isDismissible="false" />
<windshieldChipCountQuestion
cmsWidgetName="WindshieldChipCountQuestion"
:isAvailable="isRepairOptionSelected && !hasRepairReplaceConflict"
groupName="WindshieldChipCountQuestion"
v-model="selectedWindshieldChipCountValues"
validationRules="windshield-chip-count-required" />
<replaceOptionsQuestion
ref="replaceOptionsQuestion"
cmsWidgetName="WindshieldReplaceOptionsQuestion"
:isAvailable="isReplaceOptionSelected"
isMultiSelect
groupName="WindshieldReplaceOptions"
v-model="selectedWindshieldReplaceOptionsValues"
validationRules="windshield-replace-options-required|prevent-split-and-single-together"
:suppressError="hasSplitSingleConflict"
isRequired />
<alert
v-if="hasSplitSingleConflict"
class="mt-5"
cmsWidgetName="SplitSingleConflict"
alertClass="alert-danger"
:isDismissible="false" />
</div>
</template>
<script>
import windshieldDamageTypeQuestion from "@/layouts/vehicle-damage/windshield-options/windshield-damage-type-question/windshield-damage-type-question";
import windshieldChipCountQuestion from "@/layouts/vehicle-damage/windshield-options/windshield-chip-count-question/windshield-chip-count-question";
import replaceOptionsQuestion from "@/layouts/vehicle-damage/replace-options-question/replace-options-question";
import alert from "@/ux-components/alert/alert";
import { defineRule } from "vee-validate";
import { required } from "@/helpers/validation-rules";
import { errorMessages } from "@/constants/error-messages";
import { damageLocationsSelected } from "@/constants/damage-locations-selected.js";
// DEFINE VALIDATION RULES
defineRule(
"windshield-damage-type-required",
required(errorMessages.WINDSHIELD_DAMAGE_TYPE_REQUIRED)
);
defineRule(
"windshield-chip-count-required",
required(errorMessages.WINDSHIELD_CHIP_COUNT_REQUIRED)
);
defineRule(
"windshield-replace-options-required",
required(errorMessages.WINSHIELD_REPLACE_OPTIONS_REQUIRED)
);
defineRule(
"check-for-repair-and-replace",
(selectedWindshieldDamageType, selectedDamageLocations) => {
return (
selectedWindshieldDamageType.toString() != damageLocationsSelected.REPAIR ||
(!selectedDamageLocations.includes(damageLocationsSelected.WINDSHIELD) &&
!selectedDamageLocations[0]?.includes(damageLocationsSelected.WINDSHIELD)) ||
selectedDamageLocations[0].length === 1
);
}
);
defineRule("repair-only", (value) => {
return value.toString() === damageLocationsSelected.REPAIR;
});
defineRule("prevent-split-and-single-together", (value) => {
if (
value.toString().toUpperCase().includes(damageLocationsSelected.SINGLE.toUpperCase()) &&
(value.toString().toUpperCase().includes(damageLocationsSelected.DRIVER.toUpperCase()) ||
value
.toString()
.toUpperCase()
.includes(damageLocationsSelected.PASSENGER.toUpperCase()))
) {
return false;
}
return true;
});
export default {
name: "windshieldOptions",
data() {
return {
windshieldAvailableReplacementOptions: Object,
};
},
props: {
modelValue: Object,
selectedDamageLocations: Array,
hasRepairReplaceConflict: Boolean,
hasSplitSingleConflict: Boolean,
},
methods: {
initializeComponent(windshieldAvailableReplacementOptions) {
this.windshieldAvailableReplacementOptions = windshieldAvailableReplacementOptions;
this.$refs.replaceOptionsQuestion.initializeComponent(
windshieldAvailableReplacementOptions
);
},
getWindshieldOptions(
selectedWindshieldDamageType,
selectedWindshieldChipCount,
selectedWindshieldReplaceOptions
) {
// ONLY UPDATE THE NEW VALUE IF IT IS TRUTHY (NOT NULL)
return {
selectedWindshieldDamageType: selectedWindshieldDamageType
? selectedWindshieldDamageType
: this.selectedValues.selectedWindshieldDamageType,
selectedWindshieldChipCount: selectedWindshieldChipCount
? selectedWindshieldChipCount
: this.selectedValues.selectedWindshieldChipCount,
selectedWindshieldReplaceOptions: selectedWindshieldReplaceOptions
? selectedWindshieldReplaceOptions
: this.selectedValues.selectedWindshieldReplaceOptions,
};
},
},
computed: {
selectedValues: {
get: function () {
return this.modelValue;
},
set: function (newValue) {
this.$emit("update:modelValue", newValue);
},
},
selectedWindshieldDamageTypeValue: {
get: function () {
return this.selectedDamageLocations.includes(damageLocationsSelected.WINDSHIELD)
? this.selectedValues.selectedWindshieldDamageType
: null;
},
set: function (newValue) {
this.selectedValues = this.getWindshieldOptions(newValue, null, null);
},
},
selectedWindshieldChipCountValues: {
get: function () {
return this.selectedValues.selectedWindshieldChipCount;
},
set: function (newValue) {
this.selectedValues = this.getWindshieldOptions(
this.selectedWindshieldDamageTypeValue,
newValue,
null
);
},
},
selectedWindshieldReplaceOptionsValues: {
get: function () {
return this.selectedValues.selectedWindshieldReplaceOptions;
},
set: function (newValue) {
this.selectedValues = this.getWindshieldOptions(
this.selectedWindshieldDamageTypeValue,
null,
newValue
);
},
},
isWindshieldDamageLocation() {
return this.selectedDamageLocations.some(
(selectedDamageLocation) =>
selectedDamageLocation === damageLocationsSelected.WINDSHIELD
);
},
isRepairOptionSelected() {
if (!this.selectedWindshieldDamageTypeValue) return false;
return (
this.selectedWindshieldDamageTypeValue === damageLocationsSelected.REPAIR &&
this.isWindshieldDamageLocation
);
},
isReplaceOptionSelected() {
if (!this.selectedWindshieldDamageTypeValue) return false;
return (
this.selectedWindshieldDamageTypeValue === damageLocationsSelected.REPLACE &&
this.isWindshieldDamageLocation
);
},
isWindshieldReplaceAvailable() {
return !(
Array.isArray(this.windshieldAvailableReplacementOptions) &&
this.windshieldAvailableReplacementOptions.length < 1
);
},
isSplitWindshieldOption() {
const options = this.windshieldAvailableReplacementOptions.toString().toUpperCase();
if (options.includes("DRIVER") && options.includes("PASSENGER")) {
return true;
}
return false;
},
showNoReplacementAvailableError() {
if (this.isReplaceOptionSelected && !this.isWindshieldReplaceAvailable) {
return true;
}
return false;
},
windshieldDamageTypeQuestionValidationRules() {
// Note: the validation rules string is not dynamic (it cannot be changed once component has been created)
let validationRules =
"windshield-damage-type-required|check-for-repair-and-replace:@DamageLocationQuestion";
// if vehicle has no windshield replacement option
if (!this.isWindshieldReplaceAvailable) {
validationRules = validationRules.concat("|repair-only");
}
return validationRules;
},
},
components: {
windshieldDamageTypeQuestion,
windshieldChipCountQuestion,
replaceOptionsQuestion,
alert,
},
};
</script>

View file

@ -7,6 +7,7 @@
<siteSubHeader <siteSubHeader
cmsWidgetName="SiteSubHeaderWidget" cmsWidgetName="SiteSubHeaderWidget"
:hasBackButton="true" :hasBackButton="true"
:backButtonAccessibleText="backButtonAccessibleText"
@click-event="backButtonAction" /> @click-event="backButtonAction" />
<div class="fade-on-route-transition"> <div class="fade-on-route-transition">
<styleQuestion <styleQuestion
@ -92,5 +93,12 @@ export default {
siteSubHeader, siteSubHeader,
vehicleBanner, vehicleBanner,
}, },
computed: {
backButtonAccessibleText()
{
return this.getCmsContent(this.cmsWidgetName, "BackButtonAccessibleText")
}
}
}; };
</script> </script>

View file

@ -5,6 +5,7 @@ import { navigationScenarios } from "@/router/router-constants/navigation-scenar
import { vehicleCategories } from "@/constants/vehicle-categories.js"; import { vehicleCategories } from "@/constants/vehicle-categories.js";
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 { routerParams } from "@/router/router-constants/router-params";
export default { export default {
data() { data() {
@ -43,5 +44,8 @@ export default {
cssClassNameForCmsWidget(){ cssClassNameForCmsWidget(){
return "widget-name-" + this.cmsWidgetName; return "widget-name-" + this.cmsWidgetName;
}, },
routerParams() {
return routerParams;
},
}, },
}; };

View file

@ -0,0 +1,40 @@
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,
buttonBodyCopy: String,
buttonAuxillaryCopy: String,
buttonFooterCopy: String,
buttonImage: String,
buttonImageId: String,
altText: {
type: String,
default: "",
},
textPosition: String,
screenReaderOnlyText: String,
isWide: Boolean,
additionalButtonStyling: String,
},
computed: {
selectedValue: {
get() {
return this.modelValue;
},
set(e) {
if (this.preHandleAnswerChange) {
this.preHandleAnswerChange(e);
}
this.$emit("update:modelValue", e);
},
},
},
};

View file

@ -52,10 +52,27 @@ const routingTable = function(store) {
}, },
{ {
scenario: navigationScenarios.SELECTED_STYLE, scenario: navigationScenarios.SELECTED_STYLE,
destinationIssPageValue: issPageValues.WELCOME_PAGE, destinationIssPageValue: issPageValues.VEHICLE_DAMAGE,
} }
] ]
}, },
{
issPageValue: issPageValues.VEHICLE_DAMAGE,
maps: [
{
scenario: navigationScenarios.CLICKED_BACK,
destinationIssPageValue: issPageValues.VEHICLE_STYLE,
},
{
scenario: navigationScenarios.CLICKED_FORWARD_WITH_VIN,
destinationIssPageValue: issPageValues.WELCOME_PAGE,
},
{
scenario: navigationScenarios.CLICKED_FORWARD_WITHOUT_VIN,
destinationIssPageValue: issPageValues.WELCOME_PAGE,
},
],
},
{ {
issPageValue: issPageValues.WELCOME_PAGE, issPageValue: issPageValues.WELCOME_PAGE,
maps: [ maps: [

View file

@ -0,0 +1,5 @@
const routerParams = {
DISPLAY_VEHICLE_CHANGE_ALERT: "displayVehicleChangeAlert",
};
export { routerParams };

View file

@ -30,6 +30,14 @@ const getDefaultState = () => {
lastName: null, lastName: null,
}, },
}, },
damage: {
isRepair: null,
numberOfChips: null,
glassToReplace: null,
partQuestionAnswers: null,
moldingQuestionAnswers: null,
capabilityQuestionAnswers: null,
},
referralNumber: null, referralNumber: null,
referralDate: null, referralDate: null,
accountNumber: 0, accountNumber: 0,
@ -56,6 +64,8 @@ export const useMainStore = defineStore({
id: storeId, id: storeId,
state: () => state, state: () => state,
getters: { getters: {
vehicle: (state) => state.order.vehicle,
damage: (state) => state.order.damage,
eventBusItem: (state) => ( eventCategory, eventSubCategory) => { eventBusItem: (state) => ( eventCategory, eventSubCategory) => {
const matchedEvent = state.applicationUser.eventBus.find( const matchedEvent = state.applicationUser.eventBus.find(
@ -190,6 +200,13 @@ export const useMainStore = defineStore({
}); });
}, },
getDamageOptions(carId) {
return globalMethods.callHttpClient({
methods: endpoints.GetDamageOptions.method,
endpoint: `${endpoints.GetDamageOptions.url}/${carId}`,
payload: {},
});
},
setVehicle() { setVehicle() {
return globalMethods return globalMethods
.callHttpClient({ .callHttpClient({
@ -204,7 +221,11 @@ export const useMainStore = defineStore({
}, },
updateVehicle(vehicle) { updateVehicle(vehicle) {
this.order.vehicle = { ...this.order.vehicle, ...vehicle }; this.order.vehicle.carId = vehicle.carId;
this.order.vehicle.category = vehicle.category;
this.order.vehicle.imageUrl = vehicle.imageUrl;
this.order.vehicle.imageVifNumber = vehicle.imageVifNumber;
this.order.vehicle.imageColor = vehicle.imageColor;
}, },
resetVehicleState() resetVehicleState()
@ -342,6 +363,9 @@ export const useMainStore = defineStore({
}, },
logCustomEvent({ userId, sessionKey, pageName, sessionId, category, action, label, value, shouldUseSessionId, experimentsForUser}) logCustomEvent({ userId, sessionKey, pageName, sessionId, category, action, label, value, shouldUseSessionId, experimentsForUser})
{ {
if ( pageName == null || pageName.length == 0 )
pageName = "none";
var payload = { var payload = {
userId: userId, userId: userId,
sessionKey: sessionKey, sessionKey: sessionKey,
@ -355,7 +379,7 @@ export const useMainStore = defineStore({
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,

View file

@ -17,7 +17,7 @@ describe("Store", () => {
store = useMainStore(); store = useMainStore();
store.applicationUser.eventBus = []; store.applicationUser.eventBus = [];
jest.resetAllMocks(); jest.resetAllMocks();
}) });
it("Should Store Vehicle Year", () => { it("Should Store Vehicle Year", () => {
@ -114,7 +114,7 @@ describe("Store", () => {
expect(store.order.vehicle.imageUrl).toEqual(response.data.imageUrl); expect(store.order.vehicle.imageUrl).toEqual(response.data.imageUrl);
expect(store.order.vehicle.imageVifNumber).toEqual(response.data.imageVifNumber); expect(store.order.vehicle.imageVifNumber).toEqual(response.data.imageVifNumber);
expect(store.order.vehicle.imageVifColor).toEqual(response.data.imageVifColor); expect(store.order.vehicle.style).toEqual(response.data.style);
}); });
it("setVehicle should call globalMethods.callHttpClient", () => { it("setVehicle should call globalMethods.callHttpClient", () => {

View file

@ -4,3 +4,7 @@
@mixin blue-gradient { @mixin blue-gradient {
background: linear-gradient(270deg, $blue 0%, $blue-800 100%); background: linear-gradient(270deg, $blue 0%, $blue-800 100%);
} }
@mixin box-shadow-hover($color) {
box-shadow: 0 0 0 4px $color;
}

View file

@ -0,0 +1,15 @@
.base-input-button {
&:not(.has-error):hover {
cursor: pointer;
&.list-button,
&.list-button-horizontal,
&.list-card {
&:not(.selected) {
position: relative;
z-index: 4;
@include box-shadow-hover($blue-300);
}
}
}
}

View file

@ -1,186 +1,189 @@
//Colors //Colors
// White/black // White/black
$white: #FFFFFF; $white: #ffffff;
$black: #000000; $black: #000000;
// Blues // Blues
$blue-100: #E4F1F7;// Used in theme $blue-100: #e4f1f7; // Used in theme
$blue-200: #C1DFEE; $blue-200: #c1dfee;
$blue-300: #9FCEE6; $blue-300: #9fcee6;
$blue-400: #69ADCF;// Used in theme $blue-400: #69adcf; // Used in theme
$blue-500: #3B8FB8; $blue-500: #3b8fb8;
$blue: #1574A1;// Default Blue $blue: #1574a1; // Default Blue
$blue-700: #06577C; $blue-700: #06577c;
$blue-800: #003D58; $blue-800: #003d58;
$blue-900: #002433;// Used in theme $blue-900: #002433; // Used in theme
// Reds // Reds
$red-100: #FFE6E4; $red-100: #ffe6e4;
$red-200: #FCBFBB; $red-200: #fcbfbb;
$red-300: #F89892; $red-300: #f89892;
$red-400: #E65C53; $red-400: #e65c53;
$red: #D4281C;// Default Red $red: #d4281c; // Default Red
$red-600: #AC160B; $red-600: #ac160b;
$red-700: #840900; $red-700: #840900;
$red-800: #5B0600; $red-800: #5b0600;
$red-900: #330300; $red-900: #330300;
// Greens // Greens
$green-100: #E3F2EA; $green-100: #e3f2ea;
$green-200: #BCEDD4; $green-200: #bcedd4;
$green-300: #94D7B6; $green-300: #94d7b6;
$green-400: #5ABC8C;// Used in theme $green-400: #5abc8c; // Used in theme
$green-500: #2CA168; $green-500: #2ca168;
$green: #0C7E47;// Default Green $green: #0c7e47; // Default Green
$green-700: #006A36; $green-700: #006a36;
$green-800: #004F28; $green-800: #004f28;
$green-900: #00331A; $green-900: #00331a;
// Yellows // Yellows
$yellow-100: #FFF5EB; $yellow-100: #fff5eb;
$yellow-200: #FFE1C6; $yellow-200: #ffe1c6;
$yellow-300: #FFCAA0; $yellow-300: #ffcaa0;
$yellow-400: #F5975D; $yellow-400: #f5975d;
$yellow: #E86421;// Default Yellow $yellow: #e86421; // Default Yellow
$yellow-600: #BB4B06; $yellow-600: #bb4b06;
$yellow-700: #8E3C00; $yellow-700: #8e3c00;
$yellow-800: #602D00; $yellow-800: #602d00;
$yellow-900: #331A00; $yellow-900: #331a00;
// Grays // Grays
$gray-100: #F5F5F5;// Used in theme $gray-100: #f5f5f5; // Used in theme
$gray-200: #E3E4E4;// Used in theme $gray-200: #e3e4e4; // Used in theme
$gray-300: #D2D4D4; $gray-300: #d2d4d4;
$gray: #B0B3B3;// Default Gray $gray: #b0b3b3; // Default Gray
$gray-500: #8E9292;// Used in theme $gray-500: #8e9292; // Used in theme
$gray-550: #727676;// Used in theme $gray-550: #727676; // Used in theme
$gray-600: #4D5151;// Used in theme $gray-600: #4d5151; // Used in theme
$gray-700: #303333;// Used in theme $gray-700: #303333; // Used in theme
$gray-800: #222424;// Used in theme $gray-800: #222424; // Used in theme
$gray-900: #181A1A;// Used in theme $gray-900: #181a1a; // Used in theme
// Miscellaneous Colors // Miscellaneous Colors
$indigo: #6610f2; $indigo: #6610f2;
$purple: #6f42c1; $purple: #6f42c1;
$pink: #d63384; $pink: #d63384;
$orange: #fd7e14; $orange: #fd7e14;
$teal: #20c997; $teal: #20c997;
$cyan: #0dcaf0; $cyan: #0dcaf0;
// scss-docs-start colors-map // scss-docs-start colors-map
$colors: ( $colors: (
"blue": $blue, "blue": $blue,
"indigo": $indigo, "indigo": $indigo,
"purple": $purple, "purple": $purple,
"pink": $pink, "pink": $pink,
"red": $red, "red": $red,
"orange": $orange, "orange": $orange,
"yellow": $yellow, "yellow": $yellow,
"green": $green, "green": $green,
"teal": $teal, "teal": $teal,
"cyan": $cyan, "cyan": $cyan,
"white": $white, "white": $white,
"gray": $gray, "gray": $gray,
"gray-dark": $gray-500 "gray-dark": $gray-500,
); );
// scss-docs-start theme-color-variables // scss-docs-start theme-color-variables
$primary: $blue; $primary: $blue;
$secondary: $red; $secondary: $red;
$success: $green; $success: $green;
$info: $cyan; $info: $cyan;
$warning: $yellow; $warning: $yellow;
$danger: $red; $danger: $red;
$light: $gray-100; $light: $gray-100;
$dark: $gray-500; $dark: $gray-500;
// scss-docs-start theme-colors-map // scss-docs-start theme-colors-map
$theme-colors: ( $theme-colors: (
"primary": $primary, "primary": $primary,
"secondary": $secondary, "secondary": $secondary,
"success": $success, "success": $success,
"info": $info, "info": $info,
"warning": $warning, "warning": $warning,
"danger": $danger, "danger": $danger,
"light": $light, "light": $light,
"dark": $dark "dark": $dark,
); );
//Default font color //Default font color
$body-color: $gray-600; $body-color: $gray-600;
//Fonts //Fonts
$font-family-sans-serif: Roboto, Arial, Helvetica, sans-serif; $font-family-sans-serif: Roboto, Arial, Helvetica, sans-serif;
$font-family-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; $font-family-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
monospace;
// stylelint-enable value-keyword-case // stylelint-enable value-keyword-case
$font-family-base: $font-family-sans-serif; $font-family-base: $font-family-sans-serif;
$font-family-code: $font-family-monospace; $font-family-code: $font-family-monospace;
$font-size-base: 1rem; // Assumes the browser default, typically `16px` $font-size-base: 1rem; // Assumes the browser default, typically `16px`
//Custom Font size (extra small) //Custom Font size (extra small)
$font-size-xsm: $font-size-base * .75; $font-size-xsm: $font-size-base * 0.75;
$font-sizes: ( $font-sizes: (
7: $font-size-xsm 7: $font-size-xsm,
); );
//Font weight //Font weight
$font-weight-lighter: lighter; $font-weight-lighter: lighter;
$font-weight-light: 300; $font-weight-light: 300;
$font-weight-normal: 400; $font-weight-normal: 400;
$font-weight-bold: 500; $font-weight-bold: 500;
$font-weight-bolder: bolder; $font-weight-bolder: bolder;
//Headings //Headings
$h1-font-size: $font-size-base * 3; $h1-font-size: $font-size-base * 3;
$h2-font-size: $font-size-base * 2.625; $h2-font-size: $font-size-base * 2.625;
$h3-font-size: $font-size-base * 2; $h3-font-size: $font-size-base * 2;
$h4-font-size: $font-size-base * 1.625; $h4-font-size: $font-size-base * 1.625;
$h5-font-size: $font-size-base * 1.25; $h5-font-size: $font-size-base * 1.25;
$h6-font-size: $font-size-base * .875; $h6-font-size: $font-size-base * 0.875;
//Border Radius //Border Radius
// Helper classes are rounded, rounded-1, rounded-2, rounded-3 // Helper classes are rounded, rounded-1, rounded-2, rounded-3
$border-radius: .25rem; $border-radius: 0.25rem;
$border-radius-sm: .2rem; $border-radius-sm: 0.2rem;
$border-radius-lg: .5rem;//Used for buttons. Can be used for other things, of course. $border-radius-lg: 0.5rem; //Used for buttons. Can be used for other things, of course.
$border-radius-pill: 50rem; $border-radius-pill: 50rem;
//Spacing //Spacing
// 8 spacers available instead of the usual 5 // 8 spacers available instead of the usual 5
$spacer: 1rem; $spacer: 1rem;
$spacers: ( $spacers: (
0: 0, 0: 0,
1: $spacer * .25, /* 4px */ 1: $spacer * 0.25,
2: $spacer * .5, /* 8px */ /* 4px */ 2: $spacer * 0.5,
3: $spacer * .75, /* 12px */ /* 8px */ 3: $spacer * 0.75,
4: $spacer * 1, /* 16px */ /* 12px */ 4: $spacer * 1,
5: $spacer * 1.5, /* 24px */ /* 16px */ 5: $spacer * 1.5,
6: $spacer * 2, /* 32px */ /* 24px */ 6: $spacer * 2,
7: $spacer * 2.5, /* 40px */ /* 32px */ 7: $spacer * 2.5,
8: $spacer * 3, /* 48px */ /* 40px */ 8: $spacer * 3,
/* 48px */
); );
//Grid breakpoints //Grid breakpoints
$grid-breakpoints: ( $grid-breakpoints: (
xs: 0, xs: 0,
sm: 576px, sm: 576px,
md: 838px, md: 838px,
lg: 1074px, lg: 1074px,
xl: 1416px xl: 1416px,
); );
//Shadow //Shadow
$box-shadow: 0 .5rem 1rem rgba($black, .15); $box-shadow: 0 0.5rem 1rem rgba($black, 0.15);
$box-shadow-sm: 0 .125rem .25rem rgba($black, .075); $box-shadow-sm: 0 0.125rem 0.25rem rgba($black, 0.075);
$box-shadow-lg: 0 1rem 3rem rgba($black, .25);//Safelite default $box-shadow-lg: 0 1rem 3rem rgba($black, 0.25); //Safelite default
$box-shadow-inset: inset 0 1px 2px rgba($black, .075); $box-shadow-inset: inset 0 1px 2px rgba($black, 0.075);
//Alerts //Alerts
$alert-bg-scale: -90%; $alert-bg-scale: -90%;
$alert-border-scale: -100%; $alert-border-scale: -100%;
$alert-color-scale: 40%; $alert-color-scale: 40%;
//Modal animation //Modal animation
$modal-fade-transform: translate(0, 0); // This affects all [Bootstrap] modals
$modal-backdrop-opacity: 0; $modal-fade-transform: translate(0, 100%);
$modal-backdrop-opacity: 0;

View file

@ -1,264 +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";
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 'strong' class", () => {
// Arrange/Act
const { wrapper } = setupMocks({
mockData: {
propsData: {
additionalButtonStyling: "listButtonHorizontalStrong",
},
},
});
// Assert
const label = wrapper.find("label");
expect(label.classes()).toContain("strong");
});
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: { issPage: 'page-name' } },
}
},
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: { issPage: 'page-name' } },
}
},
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: { issPage: 'page-name' } },
}
},
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,206 +1,84 @@
<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 base-input-button',
@keyup.up="handleKeyupArrow()" { strong: isStrongStyling },
@keyup.down="handleKeyupArrow()" ]"
@keyup.left="handleKeyupArrow()" v-model="selectedValue">
@keyup.right="handleKeyupArrow()" <div
> class="button-content 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 v-if="buttonLabelSubCopy" class="m-0 small" :class="textPosition">
:aria-required="isRequired" {{ buttonLabelSubCopy }}
v-model="checkValue" </span>
:checked="checkValue" <span v-if="screenReaderOnlyText" class="sr-only">
@change="handleInputChange()" {{ screenReaderOnlyText }}
/> </span>
<label </div>
tabindex="-1" </baseInputButton>
:for="buttonID" </template>
:aria-label="buttonLabel"
class="d-flex flex-column justify-content-center py-3 px-4" <script>
@mouseup="triggerButton()" import baseInputButton from "@/common-components/base-input-button/base-input-button";
> import inputButtonWrapperMixin from "@/mixins/input-button-wrapper-mixin";
<span
class="m-0" export default {
:class="textPosition" name: "listButtonHorizontal",
> mixins: [inputButtonWrapperMixin],
{{buttonLabel}} computed: {
</span> isStrongStyling() {
<span v-if="buttonLabelSubCopy" class="m-0 small" :class="textPosition"> return this.additionalButtonStyling === "listButtonHorizontalStrong";
{{ buttonLabelSubCopy }}
</span>
<span v-if="screenReaderOnlyText" class="sr-only">
{{ screenReaderOnlyText }}
</span>
<loader v-if="isLoaderDisplayed && selectingInitiatesLoad" :class="[loaderColor, loaderPosition]" />
</label>
</div>
</template>
<script>
import { useField } from "vee-validate";
import loader from "@/ux-components/loader/loader";
import { toRef } from "vue";
export default {
name: "listButtonHorizontal",
props: {
isMultiSelect: Boolean,
groupName: String,
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, },
selectedValues: [Array, String], components: {
hasError: Boolean, baseInputButton,
valueToLogType: String, },
}, };
data() { </script>
return {
isLoaderDisplayed: false, <style lang="scss">
checkValue: Boolean, .list-button-horizontal {
}; input[type="radio"],
}, input[type="checkbox"] {
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();
}
},
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>
<style lang="scss">
.list-button-horizontal {
input[type="radio"],
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 {
box-shadow: 0 0 0 2.5px $blue; cursor: pointer;
z-index: 2;
} }
&:focus+label { &:focus-visible + .list-button-horizontal-content {
box-shadow: 0 0 0 2.5px $blue; box-shadow: 0 0 0 2.5px $blue;
z-index: 3; z-index: 2;
} }
&:checked+label { &:focus + .list-button-horizontal-content {
background: $blue-100; box-shadow: 0 0 0 2.5px $blue;
box-shadow: 0 0 0 1px $blue; z-index: 3;
outline: none;
z-index: 2;
} }
&:checked:focus+label { &:checked + .list-button-horizontal-content {
box-shadow: 0 0 0 2.5px $blue; background: $blue-100;
box-shadow: 0 0 0 1px $blue;
outline: none;
z-index: 2;
} }
&:checked+label p:first-child { &:checked:focus + .list-button-horizontal-content {
font-weight: 500; box-shadow: 0 0 0 2.5px $blue;
} }
}
&:checked + .list-button-horizontal-content p:first-child {
label { font-weight: 500;
}
}
.list-button-horizontal-content {
outline: none; outline: none;
position: relative; position: relative;
background: $white; background: $white;
@ -209,136 +87,132 @@
border-radius: 0; border-radius: 0;
width: 100%; width: 100%;
color: $gray-600; 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 { span {
font-size: .875rem; font-size: 0.875rem;
} }
} }
// Cash/Insurance option radio button styling // Cash/Insurance option radio button styling
&.radio-fancy { &.strong {
label { .list-button-horizontal-content {
border: 1px solid $blue-700; border: 1px solid $blue-700;
z-index: 2; z-index: 2;
color: $blue; color: $blue;
span { span {
font-size: 1rem; font-size: 1rem;
font-weight: 500; font-weight: 500;
} }
} }
label:hover { .list-button-horizontal-content:hover {
background-color: $blue-700; background-color: $blue-700;
color: $white; color: $white;
box-shadow: none; 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 { &:focus-visible + .list-button-horizontal-content {
border-radius: 0.5rem; border-radius: 0.5rem;
z-index: 2; z-index: 2;
} }
&:focus+label { &:focus + .list-button-horizontal-content {
z-index: 3; z-index: 3;
} }
&:checked+label { &:checked + .list-button-horizontal-content {
outline: none; outline: none;
box-shadow: none; box-shadow: none;
color: $white; color: $white;
background: linear-gradient(84.45deg, #125B7E 0%, #3B8FB8 100%); background: linear-gradient(84.45deg, #125b7e 0%, #3b8fb8 100%);
border-radius: 0.5rem; border-radius: 0.5rem;
z-index: 5; z-index: 5;
} }
&:checked:focus+label { &:checked:focus + .list-button-horizontal-content {
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;
} }
&:checked+label p:first-child { &:checked + .list-button-horizontal-content p:first-child {
font-weight: 500; font-weight: 500;
} }
} }
} }
&.list-button-horizontal { &.list-button-horizontal {
height: 100%; height: 100%;
label { label {
height: 100%; height: 100%;
} }
}
} }
}
.col {
&:first-of-type { .col,
.list-group {
border-radius: 0;
&:first-of-type {
.list-button-horizontal { .list-button-horizontal {
label {
border-bottom-left-radius: 0.5rem; border-bottom-left-radius: 0.5rem;
border-top-left-radius: 0.5rem; border-top-left-radius: 0.5rem;
}
.list-button-horizontal-content {
border-bottom-left-radius: 0.5rem;
border-top-left-radius: 0.5rem;
}
} }
} }
&:last-of-type { &:last-of-type {
.list-button-horizontal { .list-button-horizontal {
label {
border-bottom-right-radius: 0.5rem; border-bottom-right-radius: 0.5rem;
border-top-right-radius: 0.5rem; border-top-right-radius: 0.5rem;
}
.list-button-horizontal-content {
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 {
.list-button-horizontal.radio-fancy {
input[type="radio"] {
&:checked+label {
border-bottom-left-radius: 0;
border-top-left-radius: 0;
}
&:checked:focus+label {
border-bottom-left-radius: 0.5rem;
border-top-left-radius: 0.5rem;
}
}
}
}
} }
</style>
//Cash/insurance styling
&:first-of-type {
.list-button-horizontal.strong {
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.strong {
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;
}
}
}
}
}
</style>

View file

@ -1,256 +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 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: { issPage: 'page-name' } },
}
},
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: { issPage: 'page-name' } },
}
},
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: { issPage: 'page-name' } },
}
},
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,241 +1,114 @@
<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 base-input-button 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="button-content 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 v-if="buttonLabelSubCopy" class="m-0 small" :class="textPosition">
:id="buttonID" {{ buttonLabelSubCopy }}
:name="groupName" </span>
:value="value" <span v-if="screenReaderOnlyText" class="sr-only">
:aria-required="isRequired" {{ screenReaderOnlyText }}
v-model="checkValue" </span>
:checked="checkValue" <loader
@change="handleInputChange" v-if="isLoaderDisplayed && selectingInitiatesLoad"
> :class="[this.loaderColor, this.loaderPosition]" />
<label </div>
tabindex="-1" </baseInputButton>
:for="buttonID" </template>
:aria-label="buttonLabel"
class="d-flex flex-column justify-content-center py-3 px-4" <script>
@mouseup="triggerButton" import loader from "@/ux-components/loader/loader";
> import baseInputButton from "@/common-components/base-input-button/base-input-button";
<span import inputButtonWrapperMixin from "@/mixins/input-button-wrapper-mixin";
class="m-0"
:class="textPosition" export default {
> name: "listButton",
{{ buttonLabel }} mixins: [inputButtonWrapperMixin],
</span> props: {
<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>
<script>
import { useField } from "vee-validate";
import { toRef } from "vue";
import loader from "@/ux-components/loader/loader";
export default {
name: "listButton",
props: {
isMultiSelect: Boolean,
groupName: String,
buttonLabel: [Number, String],
buttonID: [Number, String],
isRequired: Boolean,
textPosition: String,
buttonLabelSubCopy: String,
screenReaderOnlyText: String,
selectingInitiatesLoad: Boolean,
loaderColor: String, loaderColor: String,
loaderPosition: String, loaderPosition: {
value: { type: String,
// Field initial value default: "right",
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];
}, },
},
data() {
return {
isLoaderDisplayed: false,
};
},
methods: {
displayLoader() { displayLoader() {
this.isLoaderDisplayed = true; this.isLoaderDisplayed = true;
}, },
handleInputChange() { preHandleAnswerChange() {
if(!this.selectingInitiatesLoad) { if (this.selectingInitiatesLoad) {
this.handleCheckChange(); this.displayLoader();
} }
}, },
handleKeyupArrow() { },
if (this.isMultiSelect) { components: {
return; // Prevent arrow keys from doing anything if element is a checkbox
}
},
triggerButton() {
if(this.selectingInitiatesLoad) {
this.displayLoader();
this.handleCheckChange();
}
},
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, loader,
}, baseInputButton,
setup(props) { },
const inputType = props.isMultiSelect ? "checkbox" : "radio"; };
</script>
const fieldOptions = {
type: inputType, <style lang="scss" scoped>
checkedValue: props.value, .loader {
potentialInitialValue: props.selectedValues, position: absolute;
}; }
.list-button {
// Set initialValue for validation setup if pre-selected outline: none;
// NOTE: props.selectedValues could be an array of strings, or an array of integers... input[type="radio"],
if (props.selectedValues && (props.selectedValues.includes(props.value) || props.selectedValues.includes(parseInt(props.value)))) { input[type="checkbox"] {
fieldOptions['initialValue'] = fieldOptions.potentialInitialValue; position: static; //override bootstrap
&:focus-visible + .list-button-content {
box-shadow: 0 0 0 2.5px $blue;
} }
&:focus + .list-button-content {
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>
<style lang="scss" scoped>
.list-group {
&.list-button {
outline: none;
input[type="radio"],
input[type="checkbox"] {
position: static; //override bootstrap
height: 0;
opacity: 0;
&:focus-visible + label {
box-shadow: 0 0 0 2.5px $blue; box-shadow: 0 0 0 2.5px $blue;
} }
&:focus + label { &:checked + .list-button-content {
box-shadow: 0 0 0 2.5px $blue;
}
&:checked + label {
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; color: $gray-600;
}
} }
}
label {
color: $gray-600;
position: relative;
background: $white;
transition: all 150ms linear;
border-radius: $border-radius-lg;
border: 1px solid $gray-500;
width: 100%;
outline: none;
span {
&.small {
font-size: .75rem;
color: $gray-550;
}
}
&:hover {
@include media-breakpoint-up(sm) {
box-shadow: 0 0 0 4px $blue-300;
}
cursor: pointer;
}
+ p {
display: none;
}
}
} }
</style> }
.list-button-content {
color: $gray-600;
position: relative;
background: $white;
transition: all 150ms linear;
border-radius: $border-radius-lg;
border: 1px solid $gray-500;
width: 100%;
outline: none;
span {
&.small {
font-size: 0.75rem;
color: $gray-550;
}
}
}
</style>

View file

@ -1,329 +1,179 @@
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";
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",
buttonID: "List Card Checkbox", buttonID: "List Card Checkbox",
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",
}); },
});
// Assert // Assert
const input = wrapper.find("input"); const input = wrapper.find("input");
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",
buttonID: "List Card Checkbox", buttonID: "List Card Checkbox",
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",
}); },
});
// Assert // Assert
const paragraph = wrapper.find("p"); const paragraph = wrapper.find("p");
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",
buttonID: "List Card Checkbox", buttonID: "List Card Checkbox",
groupID: "radio-demo-1", groupID: "radio-demo-1",
groupName: "radio 1", groupName: "radio 1",
buttonImage: "windshield-damage.svg", buttonImage: "windshield-damage.svg",
buttonLabelSubCopy: "Test", buttonLabelSubCopy: "Test",
}, value: "test value",
}); },
});
// Assert // Assert
const paragraph = wrapper.find("p:nth-of-type(2)"); const paragraph = wrapper.find("p:nth-of-type(2)");
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: { propsData: {
isRadioHorizontal: true, isRadioHorizontal: true,
buttonLabel: "Windshield", buttonLabel: "Windshield",
buttonID: "List Card Checkbox", buttonID: "List Card Checkbox",
groupID: "radio-demo-1", groupID: "radio-demo-1",
groupName: "radio 1", groupName: "radio 1",
buttonImage: "windshield-damage.svg", buttonImage: "windshield-damage.svg",
buttonLabelSubCopy: "Test", buttonLabelSubCopy: "Test",
}, value: "test value",
}); },
});
// Assert // Assert
const label = wrapper.find("label"); const input = wrapper.find("input");
expect(label.attributes().for).toEqual("List Card Checkbox"); expect(input.attributes().name).toEqual("radio 1");
}); });
it("Should return input group name used for radio or checkbox", 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",
buttonID: "List Card Checkbox", buttonID: "List Card Checkbox",
groupID: "radio-demo-1", groupID: "radio-demo-1",
groupName: "radio 1", groupName: "radio 1",
buttonImage: "windshield-damage.svg", buttonImage: "windshield-damage.svg",
buttonLabelSubCopy: "Test", isRequired: true,
}, value: "test value",
}); },
});
// Assert // Assert
const input = wrapper.find("input"); const input = wrapper.find("input");
expect(input.attributes().name).toEqual("radio 1"); expect(input.attributes()["aria-required"]).toEqual("true");
}); });
it("Should return aria-required state", 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",
buttonID: "List Card Checkbox", buttonID: "List Card Checkbox",
groupID: "radio-demo-1", groupID: "radio-demo-1",
groupName: "radio 1", groupName: "radio 1",
buttonImage: "windshield-damage.svg", buttonImage: "windshield-damage.svg",
isRequired: true, isRequired: true,
}, isWide: true,
}); buttonLabelSubCopy: "",
value: "test value",
},
});
// Assert // Assert
const input = wrapper.find("input"); const label = wrapper.find(".list-card-content");
expect(input.attributes()["aria-required"]).toEqual("true"); 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", 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",
buttonID: "List Card Checkbox", buttonID: "List Card Checkbox",
groupID: "radio-demo-1", groupID: "radio-demo-1",
groupName: "radio 1", groupName: "radio 1",
buttonImage: "windshield-damage.svg", buttonImage: "windshield-damage.svg",
isRequired: true, isRequired: true,
isWide: true, isWide: true,
buttonLabelSubCopy: "", 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"]); 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 row classes if isWide is true and checkboxTop if buttonLabelSubCopy is true", 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",
buttonID: "List Card Checkbox", buttonID: "List Card Checkbox",
groupID: "radio-demo-1", groupID: "radio-demo-1",
groupName: "radio 1", groupName: "radio 1",
buttonImage: "windshield-damage.svg", buttonImage: "windshield-damage.svg",
isRequired: true, isRequired: true,
isWide: true, isWide: false,
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-column");
expect(label.classes()).toContain("flex-column");
}); });
it("Should return flex column classes if isWide is false", async () => {
// Act
const wrapper = shallowMount(listCard, {
propsData: {
isRadioHorizontal: true,
buttonLabel: "Windshield",
buttonID: "List Card Checkbox",
groupID: "radio-demo-1",
groupName: "radio 1",
buttonImage: "windshield-damage.svg",
isRequired: true,
isWide: false,
},
});
// Assert
const label = wrapper.find("label");
expect(label.classes()).toEqual(["d-flex", "w-100", "align-items-center", "px-2", "h-100", "flex-column", "pt-4", "pb-3"]);
});
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: { issPage: 'page-name' } },
}
},
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: { issPage: 'page-name' } },
}
},
propsData: {
selectingInitiatesLoad: true,
},
});
// Assert
wrapper.vm.triggerButton();
await nextTick();
expect(wrapper.vm.handleCheckChange).toBeCalled;
expect(wrapper.vm.displayLoader).toBeCalled;
});
}); });

View file

@ -1,399 +1,243 @@
<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 base-input-button',
'h-100', { horizontal: isWide },
isWide ? 'horizontal' : '',
(errors.length > 0 || hasError) ? 'has-error' : '',
]" ]"
@keyup.space="triggerButton" v-model="selectedValue">
@keyup.up="handleKeyupArrow" <div
@keyup.down="handleKeyupArrow" class="d-flex w-100 align-items-center px-2 h-100 list-card-content button-content rounded-3"
@keyup.left="handleKeyupArrow" :class="labelClasses">
@keyup.right="handleKeyupArrow" <img
> :id="buttonImageId"
<input :class="!isWide ? 'order-1' : 'ms-auto order-3'"
:type="isMultiSelect ? 'checkbox' : 'radio'" :src="buttonImage"
:id="buttonID" :alt="altText" />
:name="groupName" <p v-if="!isWide" class="small order-3" :class="isMultiSelect ? 'm-0' : 'mt-2 mb-0'">
:value="value" {{ buttonLabel }}
:aria-required="isRequired"
v-model="checkValue"
:checked="checkValue"
@change="handleInputChange"
/>
<label
tabindex="-1"
:for="buttonID"
:aria-label="buttonLabel"
class="d-flex w-100 align-items-center px-2 h-100"
:class="getLabelClasses"
@mouseup="triggerButton"
>
<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> </p>
</div> <p v-if="buttonLabelSubCopy && !isWide" class="fs-7 m-0 order-4 sub-copy">
</label> {{ buttonLabelSubCopy }}
</div> </p>
</div> <div v-if="isWide" class="order-2">
</template> <p class="m-0 small">{{ buttonLabel }}</p>
<p v-if="buttonLabelSubCopy" class="m-0 fs-7 sub-copy">
<script> {{ buttonLabelSubCopy }}
import { useField } from "vee-validate"; </p>
import { toRef } from "vue"; </div>
</div>
export default { </baseInputButton>
</template>
<script>
import baseInputButton from "@/common-components/base-input-button/base-input-button";
import inputButtonWrapperMixin from "@/mixins/input-button-wrapper-mixin";
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,
validationRules: String,
selectedValues: [Array, String],
hasError: Boolean,
valueToLogType: String,
},
data() {
return {
checkValue: null,
}
},
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: { computed: {
getLabelClasses() { labelClasses() {
if (this.isWide) { if (this.isWide) {
let classes = "flex-row py-2 ps-4 pe-4"; let classes = "flex-row py-2 ps-4 pe-4";
if (this.buttonLabelSubCopy) { if (this.buttonLabelSubCopy) {
classes += " checkboxTop"; classes += " checkboxTop";
} }
return classes; return classes;
} else { } else {
return "flex-column pt-4 pb-3"; return "flex-column pt-4 pb-3";
} }
}, },
}, },
methods: { };
isValueSelectedByArray(arr) { </script>
return this.isMultiSelect
? arr.includes(this.value) <style lang="scss">
: arr[0]; @mixin list-card-focus($box-shadow-color) {
}, &:focus-visible + .list-card-content {
handleInputChange() { box-shadow: 0 0 0 2.5px $box-shadow-color;
if(!this.selectingInitiatesLoad) { border-radius: 0.5rem;
this.handleCheckChange(); }
} &:focus + .list-card-content {
}, box-shadow: 0 0 0 2.5px $box-shadow-color;
handleKeyupArrow() { border-radius: 0.5rem;
if (this.isMultiSelect) { }
return; // Prevent arrow keys from doing anything if element is a checkbox &:checked:focus + .list-card-content {
} box-shadow: 0 0 0 2.5px $box-shadow-color;
}
if(!this.selectingInitiatesLoad) { }
this.handleCheckChange(); .list-card {
}
},
triggerButton() {
if(this.selectingInitiatesLoad) {
this.displayLoader();
this.handleCheckChange();
}
},
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>
<style lang="scss">
.list-card {
border: 1px solid $gray-500; border: 1px solid $gray-500;
&.invalid { &.has-error {
//Red border if invalid input[type="checkbox"],
border: 1px solid $red; input[type="radio"] {
@include list-card-focus($red);
}
} }
img { 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. // 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; height: auto;
width: 6.5rem; width: 6.5rem;
margin-bottom: 2.2rem; margin-bottom: 2.2rem;
max-width: 100%; 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"] {
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; position: absolute;
display: flex;
margin: 0 auto; + .list-card-content {
width: 1rem; outline: none;
height: 1rem; display: block;
margin: 3rem 0 0 0; position: relative;
background: white;
border: 1px solid $gray-500; p {
border-radius: 2px; color: $gray-600;
order: 2; text-align: center;
flex-shrink: 0;
color: $gray-600; &.sub-copy {
} color: $gray-550;
}
+ 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 {
margin-bottom: 0;
width: 5.5rem;
}
input[type="checkbox"],
input[type="radio"] {
+ label::before {
content: "";
position: relative;
margin: 0 0.5rem 0 0;
order: 1;
}
&:checked + label::after {
content: "";
margin: -0.15rem 0 0 0;
left: 1.175rem;
}
&:checked + label {
p {
color: $black;
font-weight: 500;
}
.sub-copy {
color: $gray-600;
font-weight: 400;
}
}
+ label {
outline: none;
min-height: 48px;
color: $gray-600;
img {
margin-bottom: 0;
}
p {
color: $gray-600;
text-align: left;
&.sub-copy {
color: $gray-550;
} }
}
} }
}
&:checked + .list-card-content {
background: $blue-100;
box-shadow: 0 0 0 1px $blue;
border-radius: 0.5rem;
}
@include list-card-focus($blue);
&:checked + .list-card-content {
p {
color: $black;
font-weight: 500;
}
.sub-copy {
color: $gray-600;
font-weight: 400;
}
}
+ .list-card-content::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;
}
+ .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;
}
} }
}
</style> 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>

View file

@ -1,34 +1,34 @@
<template> <template>
<div <div
class="loader" class="loader"
role="alert" role="alert"
aria-label="Loading new page" aria-label="Loading new page"
v-bind:class="[this.loaderColor, this.loaderPosition]" v-bind:class="[this.loaderColor, this.loaderPosition]"></div>
></div> </template>
</template>
<script>
<script> export default {
export default { name: "loader",
name: "loader", /* Specify size in number value which translates to rem value. For example, 1.5 = 1.5rem = 24px */
/* Specify size in number value which translates to rem value. For example, 1.5 = 1.5rem = 24px */ props: {
props: {
/* Color options: red, green, blue, white, black */ /* Color options: red, green, blue, white, black */
loaderColor: { loaderColor: {
type: String, type: String,
}, },
/* Position options: center, right, left (OPTIONAL, do NOT use on btn-* classes) */ /* Position options: center, right, left (OPTIONAL, do NOT use on btn-* classes) */
loaderPosition: { loaderPosition: {
type: String, type: String,
}, },
}, },
}; };
</script> </script>
<style lang="scss"> <style lang="scss">
.loader { .loader {
display: flex; display: flex;
//Open an overlay to prevent page interaction
&:before { //Open an overlay to prevent page interaction
&:before {
content: ""; content: "";
position: fixed; position: fixed;
top: 0; top: 0;
@ -38,9 +38,9 @@
background-color: transparent; background-color: transparent;
z-index: 9999; z-index: 9999;
cursor: default; cursor: default;
} }
//Spinner basics //Spinner basics
&:after { &:after {
content: ""; content: "";
mask: url(../../assets/icons/spinner.svg); mask: url(../../assets/icons/spinner.svg);
mask-size: cover; mask-size: cover;
@ -49,42 +49,38 @@
height: 1rem; height: 1rem;
animation: rotation 1s infinite linear; animation: rotation 1s infinite linear;
@keyframes rotation { @keyframes rotation {
100% { 100% {
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
} }
//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
&:after { &:after {
//Default spinner color (blue) if no other color is specified from the options below //Default spinner color (blue) if no other color is specified from the options below
background-color: $blue; background-color: $blue;
}
&.red:after {
background-color: $red;
}
&.green:after {
background-color: $green;
}
&.white:after {
background-color: $white;
}
&.black:after {
background-color: $black;
}
} }
</style> &.red:after {
background-color: $red;
}
&.green:after {
background-color: $green;
}
&.white:after {
background-color: $white;
}
&.black:after {
background-color: $black;
}
}
</style>